diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index afec611177..223f85518d 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -24,12 +24,12 @@ jobs: Write-Output ("##vso[task.setvariable variable=CI_BUILD_TAG;]$CI_BUILD_TAG") condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" displayName: Set the tag name as an environment variable - + - task: NodeTool@0 inputs: versionSpec: $(node_version) displayName: Install Node.js - + - task: Cache@2 inputs: key: 'yarn | "$(Agent.OS)"" | yarn.lock' @@ -37,7 +37,7 @@ jobs: yarn | "$(Agent.OS)" path: $(YARN_CACHE_FOLDER) displayName: Cache Yarn packages - + - bash: | set -e git clone "https://${GH_TOKEN}@github.com/lensapp/lens-ide.git" .lens-ide-overlay @@ -47,13 +47,13 @@ jobs: env: GH_TOKEN: $(LENS_IDE_GH_TOKEN) displayName: Customize config - + - script: make node_modules displayName: Install dependencies - + - script: make build-npm displayName: Generate npm package - + - script: make build condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" env: @@ -75,12 +75,12 @@ jobs: - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" displayName: Set the tag name as an environment variable - + - task: NodeTool@0 inputs: versionSpec: $(node_version) displayName: Install Node.js - + - task: Cache@2 inputs: key: 'yarn | "$(Agent.OS)" | yarn.lock' @@ -88,7 +88,7 @@ jobs: yarn | "$(Agent.OS)" path: $(YARN_CACHE_FOLDER) displayName: Cache Yarn packages - + - bash: | set -e git clone "https://${GH_TOKEN}@github.com/lensapp/lens-ide.git" .lens-ide-overlay @@ -98,13 +98,13 @@ jobs: env: GH_TOKEN: $(LENS_IDE_GH_TOKEN) displayName: Customize config - + - script: make node_modules displayName: Install dependencies - + - script: make build-npm displayName: Generate npm package - + - script: make build condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" env: @@ -128,12 +128,12 @@ jobs: - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" displayName: Set the tag name as an environment variable - + - task: NodeTool@0 inputs: versionSpec: $(node_version) displayName: Install Node.js - + - task: Cache@2 inputs: key: 'yarn | "$(Agent.OS)" | yarn.lock' @@ -141,7 +141,7 @@ jobs: yarn | "$(Agent.OS)" path: $(YARN_CACHE_FOLDER) displayName: Cache Yarn packages - + - bash: | set -e git clone "https://${GH_TOKEN}@github.com/lensapp/lens-ide.git" .lens-ide-overlay @@ -151,13 +151,13 @@ jobs: env: GH_TOKEN: $(LENS_IDE_GH_TOKEN) displayName: Customize config - + - script: make node_modules displayName: Install dependencies - + - script: make build-npm displayName: Generate npm package - + - bash: | sudo chown root:root / sudo apt-get update && sudo apt-get install -y snapd @@ -168,7 +168,7 @@ jobs: env: SNAP_LOGIN: $(SNAP_LOGIN) displayName: Setup snapcraft - + - script: make build condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" env: diff --git a/.github/actions/add-card-to-project/Dockerfile b/.github/actions/add-card-to-project/Dockerfile new file mode 100644 index 0000000000..95c2fe1628 --- /dev/null +++ b/.github/actions/add-card-to-project/Dockerfile @@ -0,0 +1,11 @@ +# Container image that runs your code +FROM alpine:3.10 + +RUN apk add --no-cache --no-progress curl jq + +# Copies your code file from your action repository to the filesystem path `/` of the container +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Code file to execute when the docker container starts up (`entrypoint.sh`) +ENTRYPOINT ["/entrypoint.sh"] diff --git a/.github/actions/add-card-to-project/action.yml b/.github/actions/add-card-to-project/action.yml new file mode 100644 index 0000000000..e7fcba44c6 --- /dev/null +++ b/.github/actions/add-card-to-project/action.yml @@ -0,0 +1,23 @@ +name: 'add_card_to_project' +description: 'A GitHub Action to add a card to a project and set the card position' +author: 'Steve Richards' +branding: + icon: 'command' + color: 'blue' +inputs: + project: + description: 'The url of the project to be assigned to.' + required: true + column_name: + description: 'The column name of the project, defaults to "To do" for issues and "In progress" for pull requests.' + required: false + card_position: + description: 'The card position of the card in the column, defaults to "bottom". Valid values are "top", "bottom" and "after:".' + required: false +runs: + using: 'docker' + image: 'Dockerfile' + args: + - ${{ inputs.project }} + - ${{ inputs.column_name }} + - ${{ inputs.card_position }} diff --git a/.github/actions/add-card-to-project/entrypoint.sh b/.github/actions/add-card-to-project/entrypoint.sh new file mode 100644 index 0000000000..a67bfb1d96 --- /dev/null +++ b/.github/actions/add-card-to-project/entrypoint.sh @@ -0,0 +1,157 @@ +#!/bin/sh -l + +PROJECT_URL="$INPUT_PROJECT" +if [ -z "$PROJECT_URL" ]; then + echo "PROJECT_URL is not defined." >&2 + exit 1 +fi + +get_project_type() { + _PROJECT_URL="$1" + + case "$_PROJECT_URL" in + https://github.com/orgs/*) + echo "org" + ;; + https://github.com/users/*) + echo "user" + ;; + https://github.com/*/projects/*) + echo "repo" + ;; + *) + echo "Invalid PROJECT_URL: $_PROJECT_URL" >&2 + exit 1 + ;; + esac + + unset _PROJECT_URL +} + +find_project_id() { + _PROJECT_TYPE="$1" + _PROJECT_URL="$2" + + case "$_PROJECT_TYPE" in + org) + _ORG_NAME=$(echo "$_PROJECT_URL" | sed -e 's@https://github.com/orgs/\([^/]\+\)/projects/[0-9]\+@\1@') + _ENDPOINT="https://api.github.com/orgs/$_ORG_NAME/projects" + ;; + user) + _USER_NAME=$(echo "$_PROJECT_URL" | sed -e 's@https://github.com/users/\([^/]\+\)/projects/[0-9]\+@\1@') + _ENDPOINT="https://api.github.com/users/$_USER_NAME/projects" + ;; + repo) + _ENDPOINT="https://api.github.com/repos/$GITHUB_REPOSITORY/projects" + ;; + esac + + _PROJECTS=$(curl -s -X GET -u "$GITHUB_ACTOR:$TOKEN" --retry 3 \ + -H 'Accept: application/vnd.github.inertia-preview+json' \ + "$_ENDPOINT") + + _PROJECTID=$(echo "$_PROJECTS" | jq -r ".[] | select(.html_url == \"$_PROJECT_URL\").id") + + if [ "$_PROJECTID" != "" ]; then + echo "$_PROJECTID" + else + echo "No project was found." >&2 + exit 1 + fi + + unset _PROJECT_TYPE _PROJECT_URL _ORG_NAME _USER_NAME _ENDPOINT _PROJECTS _PROJECTID +} + +find_column_id() { + _PROJECT_ID="$1" + _INITIAL_COLUMN_NAME="$2" + + _COLUMNS=$(curl -s -X GET -u "$GITHUB_ACTOR:$TOKEN" --retry 3 \ + -H 'Accept: application/vnd.github.inertia-preview+json' \ + "https://api.github.com/projects/$_PROJECT_ID/columns") + + + echo "$_COLUMNS" | jq -r ".[] | select(.name == \"$_INITIAL_COLUMN_NAME\").id" + unset _PROJECT_ID _INITIAL_COLUMN_NAME _COLUMNS +} + +PROJECT_TYPE=$(get_project_type "${PROJECT_URL:? required this environment variable}") + +if [ "$PROJECT_TYPE" = org ] || [ "$PROJECT_TYPE" = user ]; then + if [ -z "$MY_GITHUB_TOKEN" ]; then + echo "MY_GITHUB_TOKEN not defined" >&2 + exit 1 + fi + + TOKEN="$MY_GITHUB_TOKEN" # It's User's personal access token. It should be secret. +else + if [ -z "$GITHUB_TOKEN" ]; then + echo "GITHUB_TOKEN not defined" >&2 + exit 1 + fi + + TOKEN="$GITHUB_TOKEN" # GitHub sets. The scope in only the repository containing the workflow file. +fi + +INITIAL_COLUMN_NAME="$INPUT_COLUMN_NAME" +if [ -z "$INITIAL_COLUMN_NAME" ]; then + # assing the column name by default + INITIAL_COLUMN_NAME='To do' + if [ "$GITHUB_EVENT_NAME" == "pull_request" ] || [ "$GITHUB_EVENT_NAME" == "pull_request_target" ]; then + echo "changing col name for PR event" + INITIAL_COLUMN_NAME='In progress' + fi +fi + + +PROJECT_ID=$(find_project_id "$PROJECT_TYPE" "$PROJECT_URL") +INITIAL_COLUMN_ID=$(find_column_id "$PROJECT_ID" "${INITIAL_COLUMN_NAME:? required this environment variable}") + +if [ -z "$INITIAL_COLUMN_ID" ]; then + echo "INITIAL_COLUMN_ID is not found." >&2 + exit 1 +fi + +case "$GITHUB_EVENT_NAME" in + issues) + ISSUE_ID=$(jq -r '.issue.id' < "$GITHUB_EVENT_PATH") + + # Add this issue to the project column + _CARDS=$(curl -s -X POST -u "$GITHUB_ACTOR:$TOKEN" --retry 3 \ + -H 'Accept: application/vnd.github.inertia-preview+json' \ + -d "{\"content_type\": \"Issue\", \"content_id\": $ISSUE_ID}" \ + "https://api.github.com/projects/columns/$INITIAL_COLUMN_ID/cards") + ;; + pull_request|pull_request_target) + PULL_REQUEST_ID=$(jq -r '.pull_request.id' < "$GITHUB_EVENT_PATH") + + # Add this pull_request to the project column + _CARDS=$(curl -s -X POST -u "$GITHUB_ACTOR:$TOKEN" --retry 3 \ + -H 'Accept: application/vnd.github.inertia-preview+json' \ + -d "{\"content_type\": \"PullRequest\", \"content_id\": $PULL_REQUEST_ID}" \ + "https://api.github.com/projects/columns/$INITIAL_COLUMN_ID/cards") + ;; + *) + echo "Nothing to be done on this action: $GITHUB_EVENT_NAME" >&2 + exit 1 + ;; +esac + +CARDS_ID=$(echo "$_CARDS" | jq -r .id) +unset _CARDS + +if [ -z "$CARDS_ID" ]; then + echo "CARDS_ID is not found." >&2 + exit 1 +fi + +INITIAL_CARD_POSITION="$INPUT_CARD_POSITION" +if [ -z "$INITIAL_CARD_POSITION" ]; then + # assign the card position by default + INITIAL_CARD_POSITION='bottom' +fi + +_CARDS=$(curl -s -X POST -u "$GITHUB_ACTOR:$TOKEN" --retry 3 \ + -H 'Accept: application/vnd.github.inertia-preview+json' \ + -d "{\"position\": \"$INITIAL_CARD_POSITION\"}" \ + "https://api.github.com/projects/columns/cards/$CARDS_ID/moves") diff --git a/.github/workflows/add-to-project-board.yaml b/.github/workflows/add-to-project-board.yaml new file mode 100644 index 0000000000..8f23cd406c --- /dev/null +++ b/.github/workflows/add-to-project-board.yaml @@ -0,0 +1,33 @@ +name: Add Card to Project(s) +on: + issues: + types: [opened] + pull_request_target: + types: [opened] +env: + MY_GITHUB_TOKEN: ${{ secrets.PROJECT_GITHUB_TOKEN }} + EVENT_TYPE: $GITHUB_EVENT_NAME + +jobs: + add_card_to_project: + 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' diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000000..fc2692be70 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,71 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ master ] + schedule: + - cron: '41 3 * * 2' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + 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 + + # 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 + + # â„šī¸ 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 + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c4a43f6eb4..2ce53e59e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -77,15 +77,12 @@ jobs: sudo chown -R $USER $HOME/.kube $HOME/.minikube name: Install integration test dependencies if: runner.os == 'Linux' - - run: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration-linux + + - run: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration name: Run Linux integration tests if: runner.os == 'Linux' - - run: make integration-win - name: Run Windows integration tests + - run: make integration + name: Run integration tests shell: bash - if: runner.os == 'Windows' - - - run: make integration-mac - name: Run macOS integration tests - if: runner.os == 'macOS' + if: runner.os != 'Linux' diff --git a/Makefile b/Makefile index d9a5fe1a83..1a8baf0d37 100644 --- a/Makefile +++ b/Makefile @@ -50,21 +50,8 @@ tag-release: test: binaries/client yarn run jest $(or $(CMD_ARGS), "src") -.PHONY: integration-linux -integration-linux: binaries/client build-extension-types build-extensions - yarn build:linux - yarn integration - -.PHONY: integration-mac -integration-mac: binaries/client build-extension-types build-extensions - # rm ${HOME}/Library/Application\ Support/Lens - yarn build:mac - yarn integration - -.PHONY: integration-win -integration-win: binaries/client build-extension-types build-extensions - # rm %APPDATA%/Lens - yarn build:win +.PHONY: integration +integration: build yarn integration .PHONY: build @@ -73,6 +60,8 @@ build: node_modules binaries/client $(MAKE) build-extensions -B yarn run compile ifeq "$(DETECTED_OS)" "Windows" +# https://github.com/ukoloff/win-ca#clear-pem-folder-on-publish + rm -rf node_modules/win-ca/pem yarn run electron-builder --publish onTag --x64 --ia32 else yarn run electron-builder --publish onTag diff --git a/__mocks__/windowMock.ts b/__mocks__/windowMock.ts new file mode 100644 index 0000000000..baf8fa1a65 --- /dev/null +++ b/__mocks__/windowMock.ts @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +Object.defineProperty(window, "requestIdleCallback", { + writable: true, + value: jest.fn().mockImplementation(callback => callback()), +}); + +Object.defineProperty(window, "cancelIdleCallback", { + writable: true, + value: jest.fn(), +}); + +export default {}; diff --git a/build/set_build_version.ts b/build/set_build_version.ts index 3f503e93ba..ebf74d6fab 100644 --- a/build/set_build_version.ts +++ b/build/set_build_version.ts @@ -32,6 +32,8 @@ function getBuildChannel(): string { switch (versionInfo.prerelease?.[0]) { case "beta": return "beta"; + case undefined: + return "latest"; default: return "alpha"; } @@ -57,16 +59,16 @@ async function writeOutNewVersions() { } function main() { - if (versionInfo.prerelease && versionInfo.prerelease.length > 1) { - const prereleaseParts: string[] = [getBuildChannel()]; + 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(".")}`; - } else { - appInfo.version = `${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}+${buildNumber}`; } + prereleaseParts.push(buildNumber); + + appInfo.version = `${versionInfo.major}.${versionInfo.minor}.${versionInfo.patch}-${prereleaseParts.join(".")}`; + writeOutNewVersions() .catch((error) => { console.error(error); diff --git a/docs/extensions/get-started/anatomy.md b/docs/extensions/get-started/anatomy.md index 87b3042189..745bff5a89 100644 --- a/docs/extensions/get-started/anatomy.md +++ b/docs/extensions/get-started/anatomy.md @@ -6,7 +6,7 @@ In this section you will learn how this extension works under the hood. The Hello World sample extension does three things: - Implements `onActivate()` and outputs a message to the console. -- Implements `onDectivate()` and outputs a message to the console. +- Implements `onDeactivate()` and outputs a message to the console. - Registers `ClusterPage` so that the page is visible in the left-side menu of the cluster dashboard. Let's take a closer look at our Hello World sample's source code and see how these three things are achieved. diff --git a/docs/extensions/guides/generator.md b/docs/extensions/guides/generator.md index cbbdaa31bd..64838c1bc4 100644 --- a/docs/extensions/guides/generator.md +++ b/docs/extensions/guides/generator.md @@ -72,4 +72,4 @@ To dive deeper, consider looking at [Common Capabilities](../capabilities/common 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). -The Generator source code is hosted at [Github](https://github.com/lensapp/generator-lens-ext). +The Generator source code is hosted at [GitHub](https://github.com/lensapp/generator-lens-ext). diff --git a/docs/extensions/guides/kube-object-list-layout.md b/docs/extensions/guides/kube-object-list-layout.md index f5ee72e902..6d350c98cf 100644 --- a/docs/extensions/guides/kube-object-list-layout.md +++ b/docs/extensions/guides/kube-object-list-layout.md @@ -213,7 +213,7 @@ export class CertificatePage extends React.Component<{ extension: LensRendererEx return ( certificate.getName(), [sortBy.namespace]: (certificate: Certificate) => certificate.metadata.namespace, diff --git a/docs/extensions/guides/main-extension.md b/docs/extensions/guides/main-extension.md index 8a501ca533..fec937db17 100644 --- a/docs/extensions/guides/main-extension.md +++ b/docs/extensions/guides/main-extension.md @@ -94,7 +94,7 @@ export default class SamplePageMainExtension extends Main.LensExtension { ``` When the menu item is clicked the `navigate()` method looks for and displays a global page with id `"myGlobalPage"`. -This page would be defined in your extension's `Renderer.LensExtension` implmentation (See [`Renderer.LensExtension`](renderer-extension.md)). +This page would be defined in your extension's `Renderer.LensExtension` implementation (See [`Renderer.LensExtension`](renderer-extension.md)). ### `addCatalogSource()` and `removeCatalogSource()` Methods diff --git a/docs/extensions/guides/resource-stack.md b/docs/extensions/guides/resource-stack.md index 8d36bb6495..4d8c494339 100644 --- a/docs/extensions/guides/resource-stack.md +++ b/docs/extensions/guides/resource-stack.md @@ -90,7 +90,7 @@ This is the cluster that the resource stack will be applied to, and the construc Similarly, `ExampleClusterFeature` implements an `uninstall()` method which simply invokes the `kubectlDeleteFolder()` method of the `Renderer.K8sApi.ResourceStack` class. `kubectlDeleteFolder()` tries to delete from the cluster all kubernetes resources found in the folder passed to it, again in this case `../resources`. -`ExampleClusterFeature` also implements an `isInstalled()` method, which demonstrates how you can utiliize the kubernetes api to inspect the resource stack status. +`ExampleClusterFeature` also implements an `isInstalled()` method, which demonstrates how you can utilize the kubernetes api to inspect the resource stack status. `isInstalled()` simply tries to find a pod named `example-pod`, as a way to determine if the pod is already installed. This method can be useful in creating a context-sensitive UI for installing/uninstalling the feature, as demonstrated in the next sample code. diff --git a/docs/extensions/guides/stores.md b/docs/extensions/guides/stores.md index 3a990f58b7..8b31548a32 100644 --- a/docs/extensions/guides/stores.md +++ b/docs/extensions/guides/stores.md @@ -148,8 +148,8 @@ export class ExamplePreferenceInput extends React.Component { return ( { ExamplePreferencesStore.getInstace().enabled = v; }} + value={ExamplePreferencesStore.getInstance().enabled} + onChange={v => { ExamplePreferencesStore.getInstance().enabled = v; }} /> ); } diff --git a/docs/extensions/testing-and-publishing/publishing.md b/docs/extensions/testing-and-publishing/publishing.md index d8e7b8efad..9ea33c4e19 100644 --- a/docs/extensions/testing-and-publishing/publishing.md +++ b/docs/extensions/testing-and-publishing/publishing.md @@ -43,4 +43,4 @@ Say you have your project folder at `~/my-extension/` and you want to create an npm pack ``` -This will create a NPM tarball that can be hosted on Github Releases or any other publicly available file hosting service. +This will create a NPM tarball that can be hosted on GitHub Releases or any other publicly available file hosting service. diff --git a/extensions/kube-object-event-status/package/package.json b/extensions/kube-object-event-status/package/package.json deleted file mode 100644 index e6ab1bb688..0000000000 --- a/extensions/kube-object-event-status/package/package.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "name": "kube-object-event-status", - "version": "0.1.0", - "description": "Adds kube object status from events", - "renderer": "dist/renderer.js", - "lens": { - "metadata": {}, - "styles": [] - }, - "scripts": { - "build": "webpack && npm pack", - "dev": "webpack --watch", - "test": "echo NO TESTS" - }, - "files": [ - "dist/**/*" - ], - "dependencies": {}, - "devDependencies": { - "@k8slens/extensions": "file:../../src/extensions/npm/extensions", - "ts-loader": "^8.0.4", - "typescript": "^4.0.3", - "webpack": "^4.44.2" - } -} diff --git a/package.json b/package.json index 44ef6158dc..a3fd56e51f 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,13 @@ "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", "homepage": "https://github.com/lensapp/lens", - "version": "5.0.0", + "version": "5.1.4", "main": "static/build/main.js", "copyright": "Š 2021 OpenLens Authors", "license": "MIT", "author": { - "name": "OpenLens Authors" + "name": "OpenLens Authors", + "email": "info@k8slens.dev" }, "scripts": { "dev": "concurrently -i -k \"yarn run dev-run -C\" yarn:dev:*", @@ -48,7 +49,8 @@ }, "config": { "bundledKubectlVersion": "1.21.2", - "bundledHelmVersion": "3.5.4" + "bundledHelmVersion": "3.5.4", + "sentryDsn": "" }, "engines": { "node": ">=12 <13" @@ -62,7 +64,7 @@ "moduleNameMapper": { "\\.(css|scss)$": "/__mocks__/styleMock.ts", "\\.(svg)$": "/__mocks__/imageMock.ts", - "^@lingui/macro$": "/__mocks__/@linguiMacro.ts" + "src/(.*)": "/__mocks__/windowMock.ts" }, "modulePathIgnorePatterns": [ "/dist", @@ -182,6 +184,8 @@ "@hapi/call": "^8.0.1", "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.14.3", + "@sentry/electron": "^2.5.0", + "@sentry/integrations": "^6.8.0", "abort-controller": "^3.0.0", "array-move": "^3.0.1", "auto-bind": "^4.0.0", @@ -197,8 +201,8 @@ "electron-updater": "^4.3.1", "electron-window-state": "^5.0.3", "filehound": "^1.17.4", - "filenamify": "^4.1.0", "fs-extra": "^9.0.1", + "glob-to-regexp": "^0.4.1", "grapheme-splitter": "^1.0.4", "handlebars": "^4.7.7", "http-proxy": "^1.18.1", @@ -208,23 +212,23 @@ "jsdom": "^16.4.0", "jsonpath": "^1.0.2", "lodash": "^4.17.15", - "mac-ca": "^1.0.4", + "mac-ca": "^1.0.6", "marked": "^2.0.3", "md5-file": "^5.0.0", "mobx": "^6.3.0", "mobx-observable-history": "^2.0.1", "mobx-react": "^7.1.0", - "mock-fs": "^4.12.0", + "mock-fs": "^4.14.0", "moment": "^2.29.1", "moment-timezone": "^0.5.33", - "node-pty": "^0.9.0", + "node-pty": "^0.10.1", "npm": "^6.14.8", "openid-client": "^3.15.2", "p-limit": "^3.1.0", "path-to-regexp": "^6.1.0", "proper-lockfile": "^4.1.2", - "react": "^17.0.1", - "react-dom": "^17.0.1", + "react": "^17.0.2", + "react-dom": "^17.0.2", "react-router": "^5.2.0", "react-virtualized-auto-sizer": "^1.0.5", "readable-stream": "^3.6.0", @@ -240,7 +244,7 @@ "url-parse": "^1.5.1", "uuid": "^8.3.2", "win-ca": "^3.2.0", - "winston": "^3.2.1", + "winston": "^3.3.3", "winston-transport-browserconsole": "^1.0.5", "ws": "^7.4.6" }, @@ -250,6 +254,8 @@ "@material-ui/icons": "^4.11.2", "@material-ui/lab": "^4.0.0-alpha.57", "@pmmmwh/react-refresh-webpack-plugin": "^0.4.3", + "@sentry/react": "^6.8.0", + "@sentry/types": "^6.8.0", "@testing-library/jest-dom": "^5.13.0", "@testing-library/react": "^11.2.6", "@types/byline": "^4.2.32", @@ -261,7 +267,8 @@ "@types/electron-devtools-installer": "^2.2.0", "@types/electron-window-state": "^2.0.34", "@types/fs-extra": "^9.0.1", - "@types/hapi": "^18.0.5", + "@types/glob-to-regexp": "^0.4.1", + "@types/hapi": "^18.0.6", "@types/hoist-non-react-statics": "^3.3.1", "@types/html-webpack-plugin": "^3.2.3", "@types/http-proxy": "^1.17.5", @@ -273,11 +280,11 @@ "@types/marked": "^2.0.3", "@types/md5-file": "^4.0.2", "@types/mini-css-extract-plugin": "^0.9.1", - "@types/mock-fs": "^4.10.0", + "@types/mock-fs": "^4.13.1", "@types/module-alias": "^2.0.0", "@types/node": "12.20", "@types/npm": "^2.0.31", - "@types/progress-bar-webpack-plugin": "^2.1.0", + "@types/progress-bar-webpack-plugin": "^2.1.2", "@types/proper-lockfile": "^4.1.1", "@types/randomcolor": "^0.5.5", "@types/react": "^17.0.0", @@ -293,9 +300,8 @@ "@types/request-promise-native": "^1.0.17", "@types/semver": "^7.2.0", "@types/sharp": "^0.28.3", - "@types/shelljs": "^0.8.8", "@types/spdy": "^3.4.4", - "@types/tar": "^4.0.4", + "@types/tar": "^4.0.5", "@types/tcp-port-used": "^1.0.0", "@types/tempy": "^0.3.0", "@types/url-parse": "^1.4.3", @@ -315,7 +321,7 @@ "concurrently": "^5.2.0", "css-loader": "^5.2.6", "deepdash": "^5.3.5", - "dompurify": "^2.0.11", + "dompurify": "^2.0.17", "electron": "^9.4.4", "electron-builder": "^22.10.5", "electron-notarize": "^0.3.0", @@ -328,28 +334,27 @@ "eslint-plugin-unused-imports": "^1.0.1", "file-loader": "^6.2.0", "flex.box": "^3.4.4", - "fork-ts-checker-webpack-plugin": "^5.0.0", + "fork-ts-checker-webpack-plugin": "^5.2.1", "hoist-non-react-statics": "^3.3.2", - "html-webpack-plugin": "^4.3.0", + "html-webpack-plugin": "^4.5.2", "identity-obj-proxy": "^3.0.0", "include-media": "^1.4.9", - "jest": "^26.0.1", + "jest": "26.6.3", "jest-canvas-mock": "^2.3.0", "jest-fetch-mock": "^3.0.3", - "jest-mock-extended": "^1.0.10", + "jest-mock-extended": "^1.0.16", "make-plural": "^6.2.2", "mini-css-extract-plugin": "^1.6.0", "node-loader": "^1.0.3", "node-sass": "^4.14.1", - "nodemon": "^2.0.4", - "open": "^7.3.1", + "nodemon": "^2.0.12", "patch-package": "^6.2.2", "postcss": "^8.2.14", - "postcss-loader": "~3.0.0", + "postcss-loader": "4.0.3", "postinstall-postinstall": "^2.1.0", "progress-bar-webpack-plugin": "^2.1.0", "randomcolor": "^0.6.2", - "raw-loader": "^4.0.1", + "raw-loader": "^4.0.2", "react-beautiful-dnd": "^13.1.0", "react-refresh": "^0.9.0", "react-router-dom": "^5.2.0", @@ -361,8 +366,8 @@ "sharp": "^0.26.1", "spectron": "11.0.0", "style-loader": "^2.0.0", - "tailwindcss": "^2.1.2", - "ts-jest": "26.3.0", + "tailwindcss": "^2.2.4", + "ts-jest": "26.5.6", "ts-loader": "^7.0.5", "ts-node": "^8.10.2", "type-fest": "^1.0.2", @@ -371,14 +376,14 @@ "typedoc-plugin-markdown": "^3.9.0", "typeface-roboto": "^1.1.13", "typescript": "^4.3.2", - "typescript-plugin-css-modules": "^3.2.0", - "url-loader": "^4.1.0", - "webpack": "^4.44.2", + "typescript-plugin-css-modules": "^3.4.0", + "url-loader": "^4.1.1", + "webpack": "^4.46.0", "webpack-cli": "^3.3.11", "webpack-dev-server": "^3.11.0", "webpack-node-externals": "^1.7.2", "what-input": "^5.2.10", "xterm": "^4.12.0", - "xterm-addon-fit": "^0.4.0" + "xterm-addon-fit": "^0.5.0" } } diff --git a/postcss.config.js b/postcss.config.js index 2ae5d843b6..66746899cf 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -19,10 +19,9 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -const tailwindcss = require("tailwindcss"); - module.exports = { plugins: [ - tailwindcss("./tailwind.config.js") + require("tailwindcss/nesting"), + require("tailwindcss"), ], }; diff --git a/src/common/__tests__/catalog-category-registry.test.ts b/src/common/__tests__/catalog-category-registry.test.ts index 11f7a88afd..90615d552c 100644 --- a/src/common/__tests__/catalog-category-registry.test.ts +++ b/src/common/__tests__/catalog-category-registry.test.ts @@ -56,7 +56,7 @@ class TestCatalogCategory2 extends CatalogCategory { } describe("CatalogCategoryRegistry", () => { - it("should remove only the category registered when running the disopser", () => { + it("should remove only the category registered when running the disposer", () => { const registry = new TestCatalogCategoryRegistry(); expect(registry.items.length).toBe(0); diff --git a/src/common/__tests__/event-emitter.test.ts b/src/common/__tests__/event-emitter.test.ts new file mode 100644 index 0000000000..b54df2af98 --- /dev/null +++ b/src/common/__tests__/event-emitter.test.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { EventEmitter } from "../event-emitter"; + +describe("EventEmitter", () => { + it("should stop early if a listener returns false", () => { + let called = false; + const e = new EventEmitter<[]>(); + + e.addListener(() => false, {}); + e.addListener(() => { called = true; }, {}); + e.emit(); + + expect(called).toBe(false); + }); + + it("shouldn't stop early if a listener returns 0", () => { + let called = false; + const e = new EventEmitter<[]>(); + + e.addListener(() => 0 as any, {}); + e.addListener(() => { called = true; }, {}); + e.emit(); + + expect(called).toBe(true); + }); + + it("prepended listeners should be called before others", () => { + const callOrder: number[] = []; + const e = new EventEmitter<[]>(); + + e.addListener(() => { callOrder.push(1); }, {}); + e.addListener(() => { callOrder.push(2); }, {}); + e.addListener(() => { callOrder.push(3); }, { prepend: true }); + e.emit(); + + expect(callOrder).toStrictEqual([3, 1, 2]); + }); + + it("once listeners should be called only once", () => { + const callOrder: number[] = []; + const e = new EventEmitter<[]>(); + + e.addListener(() => { callOrder.push(1); }, {}); + e.addListener(() => { callOrder.push(2); }, {}); + e.addListener(() => { callOrder.push(3); }, { once: true }); + e.emit(); + e.emit(); + + expect(callOrder).toStrictEqual([1, 2, 3, 1, 2]); + }); + + it("removeListener should stop the listener from being called", () => { + const callOrder: number[] = []; + const e = new EventEmitter<[]>(); + const r = () => { callOrder.push(3); }; + + e.addListener(() => { callOrder.push(1); }, {}); + e.addListener(() => { callOrder.push(2); }, {}); + e.addListener(r); + + e.emit(); + e.removeListener(r); + e.emit(); + + expect(callOrder).toStrictEqual([1, 2, 3, 1, 2]); + }); + + it("removeAllListeners should stop the all listeners from being called", () => { + const callOrder: number[] = []; + const e = new EventEmitter<[]>(); + + e.addListener(() => { callOrder.push(1); }); + e.addListener(() => { callOrder.push(2); }); + e.addListener(() => { callOrder.push(3); }); + + e.emit(); + e.removeAllListeners(); + e.emit(); + + expect(callOrder).toStrictEqual([1, 2, 3]); + }); +}); diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index b1b3f60ce6..c5a15aec28 100644 --- a/src/common/__tests__/user-store.test.ts +++ b/src/common/__tests__/user-store.test.ts @@ -19,10 +19,6 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { Console } from "console"; - -console = new Console(process.stdout, process.stderr); - import mockFs from "mock-fs"; jest.mock("electron", () => { @@ -37,26 +33,27 @@ jest.mock("electron", () => { }); import { UserStore } from "../user-store"; +import { Console } from "console"; import { SemVer } from "semver"; import electron from "electron"; import { stdout, stderr } from "process"; import { beforeEachWrapped } from "../../../integration/helpers/utils"; +import { ThemeStore } from "../../renderer/theme.store"; +import type { ClusterStoreModel } from "../cluster-store"; console = new Console(stdout, stderr); describe("user store tests", () => { describe("for an empty config", () => { beforeEachWrapped(() => { - UserStore.resetInstance(); mockFs({ tmp: { "config.json": "{}", "kube_config": "{}" } }); (UserStore.createInstance() as any).refreshNewContexts = jest.fn(() => Promise.resolve()); - - UserStore.getInstance(); }); afterEach(() => { mockFs.restore(); + UserStore.resetInstance(); }); it("allows setting and retrieving lastSeenAppVersion", () => { @@ -72,7 +69,7 @@ describe("user store tests", () => { us.httpsProxy = "abcd://defg"; expect(us.httpsProxy).toBe("abcd://defg"); - expect(us.colorTheme).toBe(UserStore.defaultTheme); + expect(us.colorTheme).toBe(ThemeStore.defaultTheme); us.colorTheme = "light"; expect(us.colorTheme).toBe("light"); @@ -83,7 +80,7 @@ describe("user store tests", () => { us.colorTheme = "some other theme"; us.resetTheme(); - expect(us.colorTheme).toBe(UserStore.defaultTheme); + expect(us.colorTheme).toBe(ThemeStore.defaultTheme); }); it("correctly calculates if the last seen version is an old release", () => { @@ -98,14 +95,31 @@ describe("user store tests", () => { describe("migrations", () => { beforeEachWrapped(() => { - UserStore.resetInstance(); mockFs({ "tmp": { "config.json": JSON.stringify({ user: { username: "foobar" }, preferences: { colorTheme: "light" }, lastSeenAppVersion: "1.2.3" - }) + }), + "lens-cluster-store.json": JSON.stringify({ + clusters: [ + { + id: "foobar", + kubeConfigPath: "tmp/extension_data/foo/bar", + }, + { + id: "barfoo", + kubeConfigPath: "some/other/path", + }, + ] + } as ClusterStoreModel), + "extension_data": {}, + }, + "some": { + "other": { + "path": "is file", + } } }); @@ -113,6 +127,7 @@ describe("user store tests", () => { }); afterEach(() => { + UserStore.resetInstance(); mockFs.restore(); }); @@ -121,5 +136,12 @@ describe("user store tests", () => { expect(us.lastSeenAppVersion).toBe("0.0.0"); }); + + it.only("skips clusters for adding to kube-sync with files under extension_data/", () => { + const us = UserStore.getInstance(); + + expect(us.syncKubeconfigEntries.has("tmp/extension_data/foo/bar")).toBe(false); + expect(us.syncKubeconfigEntries.has("some/other/path")).toBe(true); + }); }); }); diff --git a/src/common/base-store.ts b/src/common/base-store.ts index d30725d6bb..095e0c1877 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -56,9 +56,15 @@ export abstract class BaseStore extends Singleton { cwd: this.cwd(), }); - logger.info(`[STORE]: LOADED from ${this.path}`); - this.fromStore(this.storeConfig.store); + const res: any = this.fromStore(this.storeConfig.store); + + if (res instanceof Promise || (typeof res === "object" && res && typeof res.then === "function")) { + console.error(`${this.name} extends BaseStore's fromStore method returns a Promise or promise-like object. This is an error and must be fixed.`); + } + this.enableSync(); + + logger.info(`[STORE]: LOADED from ${this.path}`); } get name() { @@ -157,6 +163,9 @@ export abstract class BaseStore extends Singleton { /** * fromStore is called internally when a child class syncs with the file * system. + * + * Note: This function **must** be synchronous. + * * @param data the parsed information read from the stored JSON file */ protected abstract fromStore(data: T): void; diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 3b79c3ab56..2e8fdccf04 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -43,6 +43,7 @@ export interface KubernetesClusterPrometheusMetrics { export interface KubernetesClusterSpec extends CatalogEntitySpec { kubeconfigPath: string; kubeconfigContext: string; + accessibleNamespaces?: string[]; metrics?: { source: string; prometheus?: KubernetesClusterPrometheusMetrics; @@ -55,15 +56,23 @@ export interface KubernetesClusterSpec extends CatalogEntitySpec { }; } +export interface KubernetesClusterMetadata extends CatalogEntityMetadata { + distro?: string; + kubeVersion?: string; +} + export type KubernetesClusterStatusPhase = "connected" | "connecting" | "disconnected" | "deleting"; export interface KubernetesClusterStatus extends CatalogEntityStatus { phase: KubernetesClusterStatusPhase; } -export class KubernetesCluster extends CatalogEntity { - public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; - public readonly kind = "KubernetesCluster"; +export class KubernetesCluster extends CatalogEntity { + public static readonly apiVersion = "entity.k8slens.dev/v1alpha1"; + public static readonly kind = "KubernetesCluster"; + + public readonly apiVersion = KubernetesCluster.apiVersion; + public readonly kind = KubernetesCluster.kind; async connect(): Promise { if (app) { diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index e18b3ac931..d54aa8533a 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -24,8 +24,10 @@ import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { productName } from "../vars"; import { WeblinkStore } from "../weblink-store"; +export type WebLinkStatusPhase = "available" | "unavailable"; + export interface WebLinkStatus extends CatalogEntityStatus { - phase: "available" | "unavailable"; + phase: WebLinkStatusPhase; } export type WebLinkSpec = { diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index fa5219ceb6..b6e198a213 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -101,6 +101,8 @@ export interface ClusterPreferences extends ClusterPrometheusPreferences { icon?: string; httpsProxy?: string; hiddenMetrics?: string[]; + nodeShellImage?: string; + imagePullSecret?: string; } export interface ClusterPrometheusPreferences { @@ -117,6 +119,8 @@ export interface ClusterPrometheusPreferences { const initialStates = "cluster:states"; +export const initialNodeShellImage = "docker.io/alpine:3.13"; + export class ClusterStore extends BaseStore { private static StateChannel = "cluster:state"; diff --git a/src/common/event-emitter.ts b/src/common/event-emitter.ts index 5ff785d431..4e1fb45151 100644 --- a/src/common/event-emitter.ts +++ b/src/common/event-emitter.ts @@ -29,35 +29,31 @@ interface Options { type Callback = (...data: D) => void | boolean; export class EventEmitter { - protected listeners = new Map, Options>(); + protected listeners: [Callback, Options][] = []; addListener(callback: Callback, options: Options = {}) { - if (options.prepend) { - const listeners = [...this.listeners]; + const fn = options.prepend ? "unshift" : "push"; - listeners.unshift([callback, options]); - this.listeners = new Map(listeners); - } - else { - this.listeners.set(callback, options); - } + this.listeners[fn]([callback, options]); } removeListener(callback: Callback) { - this.listeners.delete(callback); + this.listeners = this.listeners.filter(([cb]) => cb !== callback); } removeAllListeners() { - this.listeners.clear(); + this.listeners.length = 0; } emit(...data: D) { - [...this.listeners].every(([callback, options]) => { - if (options.once) { + for (const [callback, { once }] of this.listeners) { + if (once) { this.removeListener(callback); } - return callback(...data) !== false; - }); + if (callback(...data) === false) { + break; + } + } } } diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index bad8b95a31..0faa165bf7 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -52,7 +52,7 @@ export interface HotbarStoreModel { activeHotbarId: string; } -export const defaultHotbarCells = 12; // Number is choosen to easy hit any item with keyboard +export const defaultHotbarCells = 12; // Number is chosen to easy hit any item with keyboard export class HotbarStore extends BaseStore { @observable hotbars: Hotbar[] = []; @@ -94,7 +94,7 @@ export class HotbarStore extends BaseStore { } @action - protected async fromStore(data: Partial = {}) { + protected fromStore(data: Partial = {}) { if (!data.hotbars || !data.hotbars.length) { this.hotbars = [{ id: uuid.v4(), @@ -153,6 +153,19 @@ export class HotbarStore extends BaseStore { } } + @action + setHotbarName(id: string, name: string) { + const index = this.hotbars.findIndex((hotbar) => hotbar.id === id); + + if(index < 0) { + console.warn(`[HOTBAR-STORE]: cannot setHotbarName: unknown id`, { id }); + + return; + } + + this.hotbars[index].name = name; + } + @action remove(hotbar: Hotbar) { this.hotbars = this.hotbars.filter((h) => h !== hotbar); @@ -203,7 +216,7 @@ export class HotbarStore extends BaseStore { } /** - * Remvove all hotbar items that reference the `uid`. + * Remove all hotbar items that reference the `uid`. * @param uid The `EntityId` that each hotbar item refers to * @returns A function that will (in an action) undo the removing of the hotbar items. This function will not complete if the hotbar has changed. */ diff --git a/src/common/rbac.ts b/src/common/rbac.ts index 55b8ca414c..9d8242cd42 100644 --- a/src/common/rbac.ts +++ b/src/common/rbac.ts @@ -40,14 +40,14 @@ export interface KubeApiResourceData { export const apiResourceRecord: Record = { "clusterroles": { kind: "ClusterRole", group: "rbac.authorization.k8s.io" }, "clusterrolebindings": { kind: "ClusterRoleBinding", group: "rbac.authorization.k8s.io" }, - "configmaps": { kind: "ConfigMap" }, + "configmaps": { kind: "ConfigMap" }, //empty group means "core" "cronjobs": { kind: "CronJob", group: "batch" }, "customresourcedefinitions": { kind: "CustomResourceDefinition", group: "apiextensions.k8s.io" }, "daemonsets": { kind: "DaemonSet", group: "apps" }, "deployments": { kind: "Deployment", group: "apps" }, "endpoints": { kind: "Endpoint" }, "events": { kind: "Event" }, - "horizontalpodautoscalers": { kind: "HorizontalPodAutoscaler" }, + "horizontalpodautoscalers": { kind: "HorizontalPodAutoscaler", group: "autoscaling" }, "ingresses": { kind: "Ingress", group: "networking.k8s.io" }, "jobs": { kind: "Job", group: "batch" }, "namespaces": { kind: "Namespace" }, @@ -58,13 +58,13 @@ export const apiResourceRecord: Record = { "persistentvolumeclaims": { kind: "PersistentVolumeClaim" }, "pods": { kind: "Pod" }, "poddisruptionbudgets": { kind: "PodDisruptionBudget", group: "policy" }, - "podsecuritypolicies": { kind: "PodSecurityPolicy" }, + "podsecuritypolicies": { kind: "PodSecurityPolicy", group: "policy" }, "resourcequotas": { kind: "ResourceQuota" }, "replicasets": { kind: "ReplicaSet", group: "apps" }, "roles": { kind: "Role", group: "rbac.authorization.k8s.io" }, "rolebindings": { kind: "RoleBinding", group: "rbac.authorization.k8s.io" }, "secrets": { kind: "Secret" }, - "serviceaccounts": { kind: "ServiceAccount", group: "core" }, + "serviceaccounts": { kind: "ServiceAccount" }, "services": { kind: "Service" }, "statefulsets": { kind: "StatefulSet", group: "apps" }, "storageclasses": { kind: "StorageClass", group: "storage.k8s.io" }, diff --git a/src/common/sentry.ts b/src/common/sentry.ts new file mode 100644 index 0000000000..da0e749994 --- /dev/null +++ b/src/common/sentry.ts @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { CaptureConsole, Dedupe, Offline } from "@sentry/integrations"; +import * as Sentry from "@sentry/electron"; +import { sentryDsn, isProduction } from "./vars"; +import { UserStore } from "./user-store"; +import logger from "../main/logger"; + +/** + * "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 SentryInit() { + const processName = mapProcessName(process.type); + + Sentry.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; + } + + logger.info(`🔒 [SENTRY-BEFORE-SEND-HOOK]: allowErrorReporting: ${allowErrorReporting}. Sentry event is caught but not sent to server.`); + logger.info("🔒 [SENTRY-BEFORE-SEND-HOOK]: === START OF SENTRY EVENT ==="); + logger.info(event); + logger.info("🔒 [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 CaptureConsole({ levels: ["error"] }), + 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 index a12e55cd02..7ed0120569 100644 --- a/src/common/system-ca.ts +++ b/src/common/system-ca.ts @@ -33,5 +33,9 @@ if (isMac) { } if (isWindows) { - winca.inject("+"); // see: https://github.com/ukoloff/win-ca#caveats + try { + winca.inject("+"); // see: https://github.com/ukoloff/win-ca#caveats + } catch (error) { + logger.error(`[CA]: failed to force load: ${error}`); + } } diff --git a/src/common/user-store/index.ts b/src/common/user-store/index.ts new file mode 100644 index 0000000000..a8dc6eb028 --- /dev/null +++ b/src/common/user-store/index.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export * from "./user-store"; +export type { KubeconfigSyncEntry, KubeconfigSyncValue, UserPreferencesModel } from "./preferences-helpers"; diff --git a/src/common/user-store/preferences-helpers.ts b/src/common/user-store/preferences-helpers.ts new file mode 100644 index 0000000000..4363511120 --- /dev/null +++ b/src/common/user-store/preferences-helpers.ts @@ -0,0 +1,251 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import moment from "moment-timezone"; +import path from "path"; +import os from "os"; +import { ThemeStore } from "../../renderer/theme.store"; +import { ObservableToggleSet } from "../utils"; + +export interface KubeconfigSyncEntry extends KubeconfigSyncValue { + filePath: string; +} + +export interface KubeconfigSyncValue { } + +interface PreferenceDescription { + fromStore(val: T | undefined): R; + toStore(val: R): T | undefined; +} + +const httpsProxy: PreferenceDescription = { + fromStore(val) { + return val; + }, + toStore(val) { + return val || undefined; + }, +}; + +const shell: PreferenceDescription = { + fromStore(val) { + return val; + }, + toStore(val) { + return val || undefined; + }, +}; + +const colorTheme: PreferenceDescription = { + fromStore(val) { + return val || ThemeStore.defaultTheme; + }, + toStore(val) { + if (!val || val === ThemeStore.defaultTheme) { + return undefined; + } + + return val; + }, +}; + +const localeTimezone: PreferenceDescription = { + fromStore(val) { + return val || moment.tz.guess(true) || "UTC"; + }, + toStore(val) { + if (!val || val === moment.tz.guess(true) || val === "UTC") { + return undefined; + } + + return val; + }, +}; + +const allowUntrustedCAs: PreferenceDescription = { + fromStore(val) { + return val ?? false; + }, + toStore(val) { + if (!val) { + return undefined; + } + + return val; + }, +}; + +const allowTelemetry: PreferenceDescription = { + fromStore(val) { + return val ?? true; + }, + toStore(val) { + if (val === true) { + return undefined; + } + + return val; + }, +}; + +const allowErrorReporting: PreferenceDescription = { + fromStore(val) { + return val ?? true; + }, + toStore(val) { + if (val === true) { + return undefined; + } + + return val; + }, +}; + +const downloadMirror: PreferenceDescription = { + fromStore(val) { + return val ?? "default"; + }, + toStore(val) { + if (!val || val === "default") { + return undefined; + } + + return val; + }, +}; + +const downloadKubectlBinaries: PreferenceDescription = { + fromStore(val) { + return val ?? true; + }, + toStore(val) { + if (val === true) { + return undefined; + } + + return val; + }, +}; + +const downloadBinariesPath: PreferenceDescription = { + fromStore(val) { + return val; + }, + toStore(val) { + if (!val) { + return undefined; + } + + return val; + }, +}; + +const kubectlBinariesPath: PreferenceDescription = { + fromStore(val) { + return val; + }, + toStore(val) { + if (!val) { + return undefined; + } + + return val; + }, +}; + +const openAtLogin: PreferenceDescription = { + fromStore(val) { + return val ?? false; + }, + toStore(val) { + if (!val) { + return undefined; + } + + return val; + }, +}; + +const hiddenTableColumns: PreferenceDescription<[string, string[]][], Map>> = { + fromStore(val) { + return new Map( + (val ?? []).map(([tableId, columnIds]) => [tableId, new ObservableToggleSet(columnIds)]) + ); + }, + toStore(val) { + const res: [string, string[]][] = []; + + for (const [table, columns] of val) { + if (columns.size) { + res.push([table, Array.from(columns)]); + } + } + + return res.length ? res : undefined; + }, +}; + +const mainKubeFolder = path.join(os.homedir(), ".kube"); + +const syncKubeconfigEntries: PreferenceDescription> = { + fromStore(val) { + return new Map( + val + ?.map(({ filePath, ...rest }) => [filePath, rest]) + ?? [[mainKubeFolder, {}]] + ); + }, + toStore(val) { + if (val.size === 1 && val.has(mainKubeFolder)) { + return undefined; + } + + return Array.from(val, ([filePath, rest]) => ({ filePath, ...rest })); + }, +}; + +type PreferencesModelType = typeof DESCRIPTORS[field] extends PreferenceDescription ? T : never; +type UserStoreModelType = typeof DESCRIPTORS[field] extends PreferenceDescription ? T : never; + +export type UserStoreFlatModel = { + [field in keyof typeof DESCRIPTORS]: UserStoreModelType; +}; + +export type UserPreferencesModel = { + [field in keyof typeof DESCRIPTORS]: PreferencesModelType; +}; + +export const DESCRIPTORS = { + httpsProxy, + shell, + colorTheme, + localeTimezone, + allowUntrustedCAs, + allowTelemetry, + allowErrorReporting, + downloadMirror, + downloadKubectlBinaries, + downloadBinariesPath, + kubectlBinariesPath, + openAtLogin, + hiddenTableColumns, + syncKubeconfigEntries, +}; diff --git a/src/common/user-store.ts b/src/common/user-store/user-store.ts similarity index 50% rename from src/common/user-store.ts rename to src/common/user-store/user-store.ts index c1e956b331..7121357671 100644 --- a/src/common/user-store.ts +++ b/src/common/user-store/user-store.ts @@ -19,50 +19,26 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { ThemeId } from "../renderer/theme.store"; import { app, remote } from "electron"; import semver from "semver"; import { action, computed, observable, reaction, makeObservable } from "mobx"; -import moment from "moment-timezone"; -import { BaseStore } from "./base-store"; -import migrations from "../migrations/user-store"; -import { getAppVersion } from "./utils/app-version"; -import { appEventBus } from "./event-bus"; +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 "../event-bus"; import path from "path"; -import os from "os"; -import { fileNameMigration } from "../migrations/user-store"; -import { ObservableToggleSet, toJS } from "../renderer/utils"; +import { fileNameMigration } from "../../migrations/user-store"; +import { ObservableToggleSet, toJS } from "../../renderer/utils"; +import { DESCRIPTORS, KubeconfigSyncValue, UserPreferencesModel } from "./preferences-helpers"; +import logger from "../../main/logger"; export interface UserStoreModel { lastSeenAppVersion: string; preferences: UserPreferencesModel; } -export interface KubeconfigSyncEntry extends KubeconfigSyncValue { - filePath: string; -} - -export interface KubeconfigSyncValue { } - -export interface UserPreferencesModel { - httpsProxy?: string; - shell?: string; - colorTheme?: string; - localeTimezone?: string; - allowUntrustedCAs?: boolean; - allowTelemetry?: boolean; - downloadMirror?: string | "default"; - downloadKubectlBinaries?: boolean; - downloadBinariesPath?: string; - kubectlBinariesPath?: string; - openAtLogin?: boolean; - hiddenTableColumns?: [string, string[]][]; - syncKubeconfigEntries?: KubeconfigSyncEntry[]; -} - -export class UserStore extends BaseStore { - static readonly defaultTheme: ThemeId = "lens-dark"; - +export class UserStore extends BaseStore /* implements UserStoreFlatModel (when strict null is enabled) */ { constructor() { super({ configName: "lens-user-store", @@ -75,11 +51,19 @@ export class UserStore extends BaseStore { } @observable lastSeenAppVersion = "0.0.0"; - @observable allowTelemetry = true; - @observable allowUntrustedCAs = false; - @observable colorTheme = UserStore.defaultTheme; - @observable localeTimezone = moment.tz.guess(true) || "UTC"; - @observable downloadMirror = "default"; + + /** + * used in add-cluster page for providing context + */ + @observable kubeConfigPath = kubeConfigDefaultPath; + @observable seenContexts = observable.set(); + @observable newContexts = observable.set(); + @observable allowTelemetry: boolean; + @observable allowErrorReporting: boolean; + @observable allowUntrustedCAs: boolean; + @observable colorTheme: string; + @observable localeTimezone: string; + @observable downloadMirror: string; @observable httpsProxy?: string; @observable shell?: string; @observable downloadBinariesPath?: string; @@ -88,8 +72,8 @@ export class UserStore extends BaseStore { /** * Download kubectl binaries matching cluster version */ - @observable downloadKubectlBinaries = true; - @observable openAtLogin = false; + @observable downloadKubectlBinaries: boolean; + @observable openAtLogin: boolean; /** * The column IDs under each configurable table ID that have been configured @@ -100,9 +84,7 @@ export class UserStore extends BaseStore { /** * The set of file/folder paths to be synced */ - syncKubeconfigEntries = observable.map([ - [path.join(os.homedir(), ".kube"), {}] - ]); + syncKubeconfigEntries = observable.map(); @computed get isNewVersion() { return semver.gt(getAppVersion(), this.lastSeenAppVersion); @@ -138,13 +120,13 @@ export class UserStore extends BaseStore { */ isTableColumnHidden(tableId: string, ...columnIds: string[]): boolean { if (columnIds.length === 0) { - return true; + return false; } const config = this.hiddenTableColumns.get(tableId); if (!config) { - return true; + return false; } return columnIds.some(columnId => config.has(columnId)); @@ -155,12 +137,16 @@ export class UserStore extends BaseStore { * Toggles the hidden configuration of a table's column */ toggleTableColumnVisibility(tableId: string, columnId: string) { - this.hiddenTableColumns.get(tableId)?.toggle(columnId); + if (!this.hiddenTableColumns.get(tableId)) { + this.hiddenTableColumns.set(tableId, new ObservableToggleSet()); + } + + this.hiddenTableColumns.get(tableId).toggle(columnId); } @action resetTheme() { - this.colorTheme = UserStore.defaultTheme; + this.colorTheme = DESCRIPTORS.colorTheme.fromStore(undefined); } @action @@ -175,71 +161,47 @@ export class UserStore extends BaseStore { } @action - protected async fromStore(data: Partial = {}) { - const { lastSeenAppVersion, preferences } = data; + protected fromStore({ lastSeenAppVersion, preferences }: Partial = {}) { + logger.debug("UserStore.fromStore()", { lastSeenAppVersion, preferences }); if (lastSeenAppVersion) { this.lastSeenAppVersion = lastSeenAppVersion; } - if (!preferences) { - return; - } - - this.httpsProxy = preferences.httpsProxy; - this.shell = preferences.shell; - this.colorTheme = preferences.colorTheme; - this.localeTimezone = preferences.localeTimezone; - this.allowUntrustedCAs = preferences.allowUntrustedCAs; - this.allowTelemetry = preferences.allowTelemetry; - this.downloadMirror = preferences.downloadMirror; - this.downloadKubectlBinaries = preferences.downloadKubectlBinaries; - this.downloadBinariesPath = preferences.downloadBinariesPath; - this.kubectlBinariesPath = preferences.kubectlBinariesPath; - this.openAtLogin = preferences.openAtLogin; - - if (preferences.hiddenTableColumns) { - this.hiddenTableColumns.replace( - preferences.hiddenTableColumns - .map(([tableId, columnIds]) => [tableId, new ObservableToggleSet(columnIds)]) - ); - } - - if (preferences.syncKubeconfigEntries) { - this.syncKubeconfigEntries.replace( - preferences.syncKubeconfigEntries.map(({ filePath, ...rest }) => [filePath, rest]) - ); - } + this.httpsProxy = DESCRIPTORS.httpsProxy.fromStore(preferences?.httpsProxy); + this.shell = DESCRIPTORS.shell.fromStore(preferences?.shell); + this.colorTheme = DESCRIPTORS.colorTheme.fromStore(preferences?.colorTheme); + this.localeTimezone = DESCRIPTORS.localeTimezone.fromStore(preferences?.localeTimezone); + this.allowUntrustedCAs = DESCRIPTORS.allowUntrustedCAs.fromStore(preferences?.allowUntrustedCAs); + this.allowTelemetry = DESCRIPTORS.allowTelemetry.fromStore(preferences?.allowTelemetry); + this.allowErrorReporting = DESCRIPTORS.allowErrorReporting.fromStore(preferences?.allowErrorReporting); + this.downloadMirror = DESCRIPTORS.downloadMirror.fromStore(preferences?.downloadMirror); + this.downloadKubectlBinaries = DESCRIPTORS.downloadKubectlBinaries.fromStore(preferences?.downloadKubectlBinaries); + this.downloadBinariesPath = DESCRIPTORS.downloadBinariesPath.fromStore(preferences?.downloadBinariesPath); + this.kubectlBinariesPath = DESCRIPTORS.kubectlBinariesPath.fromStore(preferences?.kubectlBinariesPath); + this.openAtLogin = DESCRIPTORS.openAtLogin.fromStore(preferences?.openAtLogin); + this.hiddenTableColumns.replace(DESCRIPTORS.hiddenTableColumns.fromStore(preferences?.hiddenTableColumns)); + this.syncKubeconfigEntries.replace(DESCRIPTORS.syncKubeconfigEntries.fromStore(preferences?.syncKubeconfigEntries)); } toJSON(): UserStoreModel { - const hiddenTableColumns: [string, string[]][] = []; - const syncKubeconfigEntries: KubeconfigSyncEntry[] = []; - - for (const [key, values] of this.hiddenTableColumns.entries()) { - hiddenTableColumns.push([key, Array.from(values)]); - } - - for (const [filePath, rest] of this.syncKubeconfigEntries) { - syncKubeconfigEntries.push({ filePath, ...rest }); - } - const model: UserStoreModel = { lastSeenAppVersion: this.lastSeenAppVersion, preferences: { - httpsProxy: toJS(this.httpsProxy), - shell: toJS(this.shell), - colorTheme: toJS(this.colorTheme), - localeTimezone: toJS(this.localeTimezone), - allowUntrustedCAs: toJS(this.allowUntrustedCAs), - allowTelemetry: toJS(this.allowTelemetry), - downloadMirror: toJS(this.downloadMirror), - downloadKubectlBinaries: toJS(this.downloadKubectlBinaries), - downloadBinariesPath: toJS(this.downloadBinariesPath), - kubectlBinariesPath: toJS(this.kubectlBinariesPath), - openAtLogin: toJS(this.openAtLogin), - hiddenTableColumns, - syncKubeconfigEntries, + httpsProxy: DESCRIPTORS.httpsProxy.toStore(this.httpsProxy), + shell: DESCRIPTORS.shell.toStore(this.shell), + colorTheme: DESCRIPTORS.colorTheme.toStore(this.colorTheme), + localeTimezone: DESCRIPTORS.localeTimezone.toStore(this.localeTimezone), + allowUntrustedCAs: DESCRIPTORS.allowUntrustedCAs.toStore(this.allowUntrustedCAs), + allowTelemetry: DESCRIPTORS.allowTelemetry.toStore(this.allowTelemetry), + allowErrorReporting: DESCRIPTORS.allowErrorReporting.toStore(this.allowErrorReporting), + downloadMirror: DESCRIPTORS.downloadMirror.toStore(this.downloadMirror), + downloadKubectlBinaries: DESCRIPTORS.downloadKubectlBinaries.toStore(this.downloadKubectlBinaries), + downloadBinariesPath: DESCRIPTORS.downloadBinariesPath.toStore(this.downloadBinariesPath), + kubectlBinariesPath: DESCRIPTORS.kubectlBinariesPath.toStore(this.kubectlBinariesPath), + openAtLogin: DESCRIPTORS.openAtLogin.toStore(this.openAtLogin), + hiddenTableColumns: DESCRIPTORS.hiddenTableColumns.toStore(this.hiddenTableColumns), + syncKubeconfigEntries: DESCRIPTORS.syncKubeconfigEntries.toStore(this.syncKubeconfigEntries), }, }; diff --git a/src/common/utils/__tests__/paths.test.ts b/src/common/utils/__tests__/paths.test.ts new file mode 100644 index 0000000000..0eec289fb9 --- /dev/null +++ b/src/common/utils/__tests__/paths.test.ts @@ -0,0 +1,130 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { describeIf } from "../../../../integration/helpers/utils"; +import { isWindows } from "../../vars"; +import { isLogicalChildPath } from "../paths"; + +describe("isLogicalChildPath", () => { + describeIf(isWindows)("windows tests", () => { + it.each([ + { + parentPath: "C:\\Foo", + testPath: "C:\\Foo\\Bar", + expected: true, + }, + { + parentPath: "C:\\Foo", + testPath: "C:\\Bar", + expected: false, + }, + { + parentPath: "C:\\Foo", + testPath: "C:/Bar", + expected: false, + }, + { + parentPath: "C:\\Foo", + testPath: "C:/Foo/Bar", + expected: true, + }, + { + parentPath: "C:\\Foo", + testPath: "D:\\Foo\\Bar", + expected: false, + }, + ])("test %#", (testData) => { + expect(isLogicalChildPath(testData.parentPath, testData.testPath)).toBe(testData.expected); + }); + }); + + describeIf(!isWindows)("posix tests", () => { + it.each([ + { + parentPath: "/foo", + testPath: "/foo", + expected: false, + }, + { + parentPath: "/foo", + testPath: "/bar", + expected: false, + }, + { + parentPath: "/foo", + testPath: "/foobar", + expected: false, + }, + { + parentPath: "/foo", + testPath: "/foo/bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "/foo/../bar", + expected: false, + }, + { + parentPath: "/foo", + testPath: "/foo/./bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "/foo/.bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "/foo/..bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "/foo/...bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "/foo/..\\.bar", + expected: true, + }, + { + parentPath: "/bar/../foo", + testPath: "/foo/bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "/foo/\\bar", + expected: true, + }, + { + parentPath: "/foo", + testPath: "./bar", + expected: false, + }, + ])("test %#", (testData) => { + expect(isLogicalChildPath(testData.parentPath, testData.testPath)).toBe(testData.expected); + }); + }); +}); diff --git a/src/common/utils/extended-map.ts b/src/common/utils/extended-map.ts index c759fa3459..bd29cbbfe0 100644 --- a/src/common/utils/extended-map.ts +++ b/src/common/utils/extended-map.ts @@ -27,7 +27,7 @@ export class ExtendedMap extends Map { } /** - * Get the value behind `key`. If it was not pressent, first insert the value returned by `getVal` + * Get the value behind `key`. If it was not present, first insert the value returned by `getVal` * @param key The key to insert into the map with * @param getVal A function that returns a new instance of `V`. * @returns The value in the map diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index d652933341..1644c07f36 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -45,6 +45,7 @@ export * from "./openExternal"; export * from "./paths"; export * from "./reject-promise"; export * from "./singleton"; +export * from "./sort-compare"; export * from "./splitArray"; export * from "./tar"; export * from "./toggle-set"; diff --git a/src/common/utils/paths.ts b/src/common/utils/paths.ts index 391e1392f2..68d8f61ffa 100644 --- a/src/common/utils/paths.ts +++ b/src/common/utils/paths.ts @@ -33,3 +33,35 @@ function resolveTilde(filePath: string) { 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/singleton.ts b/src/common/utils/singleton.ts index 6b53a19537..ca8eb3eb61 100644 --- a/src/common/utils/singleton.ts +++ b/src/common/utils/singleton.ts @@ -46,12 +46,15 @@ export class Singleton { static createInstance(this: StaticThis, ...args: R): T { if (!Singleton.instances.has(this)) { if (Singleton.creating.length > 0) { - throw new TypeError("Cannot create a second singleton while creating a first"); + throw new TypeError(`Cannot create a second singleton (${this.name}) while creating a first (${Singleton.creating})`); } - Singleton.creating = this.name; - Singleton.instances.set(this, new this(...args)); - Singleton.creating = ""; + try { + Singleton.creating = this.name; + Singleton.instances.set(this, new this(...args)); + } finally { + Singleton.creating = ""; + } } return Singleton.instances.get(this) as T; diff --git a/src/common/utils/sort-compare.ts b/src/common/utils/sort-compare.ts new file mode 100644 index 0000000000..cd4e1b128a --- /dev/null +++ b/src/common/utils/sort-compare.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import semver, { SemVer } from "semver"; + +export function sortCompare(left: T, right: T): -1 | 0 | 1 { + if (left < right) { + return -1; + } + + if (left === right) { + return 0; + } + + return 1; +} + +interface ChartVersion { + version: string; + __version?: SemVer; +} + +export function sortCompareChartVersions(left: ChartVersion, right: ChartVersion): -1 | 0 | 1 { + if (left.__version && right.__version) { + return semver.compare(right.__version, left.__version); + } + + if (!left.__version && right.__version) { + return 1; + } + + if (left.__version && !right.__version) { + return -1; + } + + return sortCompare(left.version, right.version); +} diff --git a/src/common/vars.ts b/src/common/vars.ts index a49c8da910..0dea82cab6 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -69,4 +69,6 @@ export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5 export const supportUrl = "https://docs.k8slens.dev/latest/support/" as string; export const appSemVer = new SemVer(packageInfo.version); -export const docsUrl = `https://docs.k8slens.dev/main/` as string; +export const docsUrl = "https://docs.k8slens.dev/main/" as string; + +export const sentryDsn = packageInfo.config?.sentryDsn ?? ""; diff --git a/src/common/weblink-store.ts b/src/common/weblink-store.ts index 7b3a62f946..234e17537a 100644 --- a/src/common/weblink-store.ts +++ b/src/common/weblink-store.ts @@ -21,7 +21,7 @@ import { action, comparer, observable, makeObservable } from "mobx"; import { BaseStore } from "./base-store"; -import migrations from "../migrations/hotbar-store"; +import migrations from "../migrations/weblinks-store"; import * as uuid from "uuid"; import { toJS } from "./utils"; @@ -58,7 +58,8 @@ export class WeblinkStore extends BaseStore { this.load(); } - @action protected async fromStore(data: Partial = {}) { + @action + protected fromStore(data: Partial = {}) { this.weblinks = data.weblinks || []; } diff --git a/src/extensions/__tests__/extension-compatibility.test.ts b/src/extensions/__tests__/extension-compatibility.test.ts new file mode 100644 index 0000000000..d8e789a9d9 --- /dev/null +++ b/src/extensions/__tests__/extension-compatibility.test.ts @@ -0,0 +1,138 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { isCompatibleExtension } from "../extension-compatibility"; +import { Console } from "console"; +import { stdout, stderr } from "process"; +import type { LensExtensionManifest } from "../lens-extension"; +import { appSemVer } from "../../common/vars"; + +console = new Console(stdout, stderr); + +describe("extension compatibility", () => { + describe("appSemVer with no prerelease tag", () => { + beforeAll(() => { + appSemVer.major = 5; + appSemVer.minor = 0; + appSemVer.patch = 3; + appSemVer.prerelease = []; + }); + + it("has no extension comparator", () => { + const manifest = { name: "extensionName", version: "0.0.1"}; + + expect(isCompatibleExtension(manifest,)).toBe(false); + }); + + it.each([ + { + comparator: "", + expected: false, + }, + { + comparator: "bad comparator", + expected: false, + }, + { + comparator: "^4.0.0", + expected: false, + }, + { + comparator: "^5.0.0", + expected: true, + }, + { + comparator: "^6.0.0", + expected: false, + }, + { + comparator: "^4.0.0-alpha.1", + expected: false, + }, + { + comparator: "^5.0.0-alpha.1", + expected: true, + }, + { + comparator: "^6.0.0-alpha.1", + expected: false, + }, + ])("extension comparator test: %p", ({ comparator, expected }) => { + const manifest: LensExtensionManifest = { name: "extensionName", version: "0.0.1", engines: { lens: comparator}}; + + expect(isCompatibleExtension(manifest,)).toBe(expected); + }); + }); + + describe("appSemVer with prerelease tag", () => { + beforeAll(() => { + appSemVer.major = 5; + appSemVer.minor = 0; + appSemVer.patch = 3; + appSemVer.prerelease = ["beta", 3]; + }); + + it("has no extension comparator", () => { + const manifest = { name: "extensionName", version: "0.0.1"}; + + expect(isCompatibleExtension(manifest,)).toBe(false); + }); + + it.each([ + { + comparator: "", + expected: false, + }, + { + comparator: "bad comparator", + expected: false, + }, + { + comparator: "^4.0.0", + expected: false, + }, + { + comparator: "^5.0.0", + expected: true, + }, + { + comparator: "^6.0.0", + expected: false, + }, + { + comparator: "^4.0.0-alpha.1", + expected: false, + }, + { + comparator: "^5.0.0-alpha.1", + expected: true, + }, + { + comparator: "^6.0.0-alpha.1", + expected: false, + }, + ])("extension comparator test: %p", ({ comparator, expected }) => { + const manifest: LensExtensionManifest = { name: "extensionName", version: "0.0.1", engines: { lens: comparator}}; + + expect(isCompatibleExtension(manifest,)).toBe(expected); + }); + }); +}); diff --git a/src/extensions/__tests__/extension-loader.test.ts b/src/extensions/__tests__/extension-loader.test.ts index 5e0844d02d..e35afe5a1b 100644 --- a/src/extensions/__tests__/extension-loader.test.ts +++ b/src/extensions/__tests__/extension-loader.test.ts @@ -84,7 +84,7 @@ jest.mock( (channel: string, listener: (event: any, ...args: any[]) => void) => { if (channel === "extensions:main") { // First initialize with extensions 1 and 2 - // and then broadcast event to remove extensioin 2 and add extension number 3 + // and then broadcast event to remove extension 2 and add extension number 3 setTimeout(() => { listener({}, [ [ diff --git a/src/extensions/extension-compatibility.ts b/src/extensions/extension-compatibility.ts new file mode 100644 index 0000000000..3d958072dc --- /dev/null +++ b/src/extensions/extension-compatibility.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import semver from "semver"; +import { appSemVer, isProduction } from "../common/vars"; +import type { LensExtensionManifest } from "./lens-extension"; + +export function isCompatibleExtension(manifest: LensExtensionManifest): boolean { + if (manifest.engines?.lens) { + /* include Lens's prerelease tag in the matching so the extension's compatibility is not limited by it */ + return semver.satisfies(appSemVer, manifest.engines.lens, { includePrerelease: true }); + } + + return false; +} + +export function isCompatibleBundledExtension(manifest: LensExtensionManifest): boolean { + return !isProduction || manifest.version === appSemVer.raw; +} diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 9f2fe15067..077f29d694 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -34,9 +34,8 @@ import { extensionInstaller } from "./extension-installer"; import { ExtensionsStore } from "./extensions-store"; import { ExtensionLoader } from "./extension-loader"; import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; -import semver from "semver"; -import { appSemVer } from "../common/vars"; import { isProduction } from "../common/vars"; +import { isCompatibleBundledExtension, isCompatibleExtension } from "./extension-compatibility"; export interface InstalledExtension { id: LensExtensionId; @@ -362,11 +361,7 @@ export class ExtensionDiscovery extends Singleton { const extensionDir = path.dirname(manifestPath); const npmPackage = path.join(extensionDir, `${manifest.name}-${manifest.version}.tgz`); const absolutePath = (isProduction && await fse.pathExists(npmPackage)) ? npmPackage : extensionDir; - let isCompatible = isBundled; - - if (manifest.engines?.lens) { - isCompatible = semver.satisfies(appSemVer, manifest.engines.lens); - } + const isCompatible = (isBundled && isCompatibleBundledExtension(manifest)) || isCompatibleExtension(manifest); return { id, diff --git a/src/extensions/ipc/ipc-main.ts b/src/extensions/ipc/ipc-main.ts index eec89e9966..09d26b00fb 100644 --- a/src/extensions/ipc/ipc-main.ts +++ b/src/extensions/ipc/ipc-main.ts @@ -25,23 +25,31 @@ import type { LensMainExtension } from "../lens-main-extension"; import type { Disposer } from "../../common/utils"; import { once } from "lodash"; import { ipcMainHandle } from "../../common/ipc"; +import logger from "../../main/logger"; export abstract class IpcMain extends IpcRegistrar { constructor(extension: LensMainExtension) { super(extension); - extension[Disposers].push(() => IpcMain.resetInstance()); + + // Call the static method on the bottom child class. + extension[Disposers].push(() => (this.constructor as typeof IpcMain).resetInstance()); } /** * Listen for broadcasts within your extension * @param channel The channel to listen for broadcasts on * @param listener The function that will be called with the arguments of the broadcast - * @returns An optional disopser, Lens will cleanup when the extension is disabled or uninstalled even if this is not called + * @returns An optional disposer, Lens will cleanup when the extension is disabled or uninstalled even if this is not called */ listen(channel: string, listener: (event: Electron.IpcRendererEvent, ...args: any[]) => any): Disposer { const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`; - const cleanup = once(() => ipcMain.removeListener(prefixedChannel, listener)); + const cleanup = once(() => { + logger.info(`[IPC-RENDERER]: removing extension listener`, { channel, extension: { name: this.extension.name, version: this.extension.version } }); + return ipcMain.removeListener(prefixedChannel, listener); + }); + + logger.info(`[IPC-RENDERER]: adding extension listener`, { channel, extension: { name: this.extension.name, version: this.extension.version } }); ipcMain.addListener(prefixedChannel, listener); this.extension[Disposers].push(cleanup); @@ -56,7 +64,12 @@ export abstract class IpcMain extends IpcRegistrar { handle(channel: string, handler: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any): void { const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`; + logger.info(`[IPC-RENDERER]: adding extension handler`, { channel, extension: { name: this.extension.name, version: this.extension.version } }); ipcMainHandle(prefixedChannel, handler); - this.extension[Disposers].push(() => ipcMain.removeHandler(prefixedChannel)); + this.extension[Disposers].push(() => { + logger.info(`[IPC-RENDERER]: removing extension handler`, { channel, extension: { name: this.extension.name, version: this.extension.version } }); + + return ipcMain.removeHandler(prefixedChannel); + }); } } diff --git a/src/extensions/ipc/ipc-renderer.ts b/src/extensions/ipc/ipc-renderer.ts index 0facfb2085..364ebc5d73 100644 --- a/src/extensions/ipc/ipc-renderer.ts +++ b/src/extensions/ipc/ipc-renderer.ts @@ -28,7 +28,9 @@ import { once } from "lodash"; export abstract class IpcRenderer extends IpcRegistrar { constructor(extension: LensRendererExtension) { super(extension); - extension[Disposers].push(() => IpcRenderer.resetInstance()); + + // Call the static method on the bottom child class. + extension[Disposers].push(() => (this.constructor as typeof IpcRenderer).resetInstance()); } /** @@ -41,8 +43,13 @@ export abstract class IpcRenderer extends IpcRegistrar { */ listen(channel: string, listener: (event: Electron.IpcRendererEvent, ...args: any[]) => any): Disposer { const prefixedChannel = `extensions@${this[IpcPrefix]}:${channel}`; - const cleanup = once(() => ipcRenderer.removeListener(prefixedChannel, listener)); + const cleanup = once(() => { + console.info(`[IPC-RENDERER]: removing extension listener`, { channel, extension: { name: this.extension.name, version: this.extension.version } }); + return ipcRenderer.removeListener(prefixedChannel, listener); + }); + + console.info(`[IPC-RENDERER]: adding extension listener`, { channel, extension: { name: this.extension.name, version: this.extension.version } }); ipcRenderer.addListener(prefixedChannel, listener); this.extension[Disposers].push(cleanup); diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index ac77a3b229..5d562953ca 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -74,7 +74,7 @@ export class LensExtension { * getExtensionFileFolder returns the path to an already created folder. This * folder is for the sole use of this extension. * - * Note: there is no security done on this folder, only obfiscation of the + * Note: there is no security done on this folder, only obfuscation of the * folder name. */ async getExtensionFileFolder(): Promise { diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index a3026d99a5..dd5670fd67 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -23,6 +23,7 @@ import type * as registries from "./registries"; import type { Cluster } from "../main/cluster"; import { LensExtension } from "./lens-extension"; import { getExtensionPageUrl } from "./registries/page-registry"; +import type { CatalogEntity } from "../common/catalog"; export class LensRendererExtension extends LensExtension { globalPages: registries.PageRegistration[] = []; @@ -37,7 +38,7 @@ export class LensRendererExtension extends LensExtension { kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = []; commands: registries.CommandRegistration[] = []; welcomeMenus: registries.WelcomeMenuRegistration[] = []; - catalogEntityDetailItems: registries.CatalogEntityDetailRegistration[] = []; + catalogEntityDetailItems: registries.CatalogEntityDetailRegistration[] = []; topBarItems: registries.TopBarRegistration[] = []; async navigate

(pageId?: string, params?: P) { diff --git a/src/extensions/registries/__tests__/page-registry.test.ts b/src/extensions/registries/__tests__/page-registry.test.ts index ed9bf983cd..9a64d95b6b 100644 --- a/src/extensions/registries/__tests__/page-registry.test.ts +++ b/src/extensions/registries/__tests__/page-registry.test.ts @@ -22,14 +22,24 @@ import { ClusterPageRegistry, getExtensionPageUrl, GlobalPageRegistry, PageParams } from "../page-registry"; import { LensExtension } from "../../lens-extension"; import React from "react"; +import fse from "fs-extra"; import { Console } from "console"; import { stdout, stderr } from "process"; +import { ThemeStore } from "../../../renderer/theme.store"; +import { TerminalStore } from "../../renderer-api/components"; +import { UserStore } from "../../../common/user-store"; + +jest.mock("electron", () => ({ + app: { + getPath: () => "tmp", + }, +})); console = new Console(stdout, stderr); let ext: LensExtension = null; -describe("getPageUrl", () => { +describe("page registry tests", () => { beforeEach(async () => { ext = new LensExtension({ manifest: { @@ -43,6 +53,9 @@ describe("getPageUrl", () => { isEnabled: true, isCompatible: true }); + UserStore.createInstance(); + ThemeStore.createInstance(); + TerminalStore.createInstance(); ClusterPageRegistry.createInstance(); GlobalPageRegistry.createInstance().add({ id: "page-with-params", @@ -54,70 +67,6 @@ describe("getPageUrl", () => { test2: "" // no default value, just declaration }, }, ext); - }); - - afterEach(() => { - GlobalPageRegistry.resetInstance(); - ClusterPageRegistry.resetInstance(); - }); - - it("returns a page url for extension", () => { - expect(getExtensionPageUrl({ extensionId: ext.name })).toBe("/extension/foo-bar"); - }); - - it("allows to pass base url as parameter", () => { - expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "/test" })).toBe("/extension/foo-bar/test"); - }); - - it("removes @ and replace `/` to `--`", () => { - expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo--bar"); - }); - - it("adds / prefix", () => { - expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "test" })).toBe("/extension/foo-bar/test"); - }); - - it("normalize possible multi-slashes in page.id", () => { - expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "//test/" })).toBe("/extension/foo-bar/test"); - }); - - it("gets page url with custom params", () => { - const params: PageParams = { test1: "one", test2: "2" }; - const searchParams = new URLSearchParams(params); - const pageUrl = getExtensionPageUrl({ - extensionId: ext.name, - pageId: "page-with-params", - params, - }); - - expect(pageUrl).toBe(`/extension/foo-bar/page-with-params?${searchParams}`); - }); - - it("gets page url with default custom params", () => { - const defaultPageUrl = getExtensionPageUrl({ - extensionId: ext.name, - pageId: "page-with-params", - }); - - expect(defaultPageUrl).toBe(`/extension/foo-bar/page-with-params?test1=test1-default`); - }); -}); - -describe("globalPageRegistry", () => { - beforeEach(async () => { - ext = new LensExtension({ - manifest: { - name: "@acme/foo-bar", - version: "0.1.1" - }, - id: "/this/is/fake/package.json", - absolutePath: "/absolute/fake/", - manifestPath: "/this/is/fake/package.json", - isBundled: false, - isEnabled: true, - isCompatible: true - }); - ClusterPageRegistry.createInstance(); GlobalPageRegistry.createInstance().add([ { id: "test-page", @@ -142,33 +91,82 @@ describe("globalPageRegistry", () => { afterEach(() => { GlobalPageRegistry.resetInstance(); ClusterPageRegistry.resetInstance(); + TerminalStore.resetInstance(); + ThemeStore.resetInstance(); + UserStore.resetInstance(); + fse.remove("tmp"); }); - describe("getByPageTarget", () => { - it("matching to first registered page without id", () => { - const page = GlobalPageRegistry.getInstance().getByPageTarget({ extensionId: ext.name }); - - expect(page.id).toEqual(undefined); - expect(page.extensionId).toEqual(ext.name); - expect(page.url).toEqual(getExtensionPageUrl({ extensionId: ext.name })); + describe("getPageUrl", () => { + it("returns a page url for extension", () => { + expect(getExtensionPageUrl({ extensionId: ext.name })).toBe("/extension/foo-bar"); }); - it("returns matching page", () => { - const page = GlobalPageRegistry.getInstance().getByPageTarget({ - pageId: "test-page", - extensionId: ext.name - }); - - expect(page.id).toEqual("test-page"); + it("allows to pass base url as parameter", () => { + expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "/test" })).toBe("/extension/foo-bar/test"); }); - it("returns null if target not found", () => { - const page = GlobalPageRegistry.getInstance().getByPageTarget({ - pageId: "wrong-page", - extensionId: ext.name + it("removes @ and replace `/` to `--`", () => { + expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo--bar"); + }); + + it("adds / prefix", () => { + expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "test" })).toBe("/extension/foo-bar/test"); + }); + + it("normalize possible multi-slashes in page.id", () => { + expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "//test/" })).toBe("/extension/foo-bar/test"); + }); + + it("gets page url with custom params", () => { + const params: PageParams = { test1: "one", test2: "2" }; + const searchParams = new URLSearchParams(params); + const pageUrl = getExtensionPageUrl({ + extensionId: ext.name, + pageId: "page-with-params", + params, }); - expect(page).toBeNull(); + expect(pageUrl).toBe(`/extension/foo-bar/page-with-params?${searchParams}`); + }); + + it("gets page url with default custom params", () => { + const defaultPageUrl = getExtensionPageUrl({ + extensionId: ext.name, + pageId: "page-with-params", + }); + + expect(defaultPageUrl).toBe(`/extension/foo-bar/page-with-params?test1=test1-default`); + }); + }); + + describe("globalPageRegistry", () => { + describe("getByPageTarget", () => { + it("matching to first registered page without id", () => { + const page = GlobalPageRegistry.getInstance().getByPageTarget({ extensionId: ext.name }); + + expect(page.id).toEqual(undefined); + expect(page.extensionId).toEqual(ext.name); + expect(page.url).toEqual(getExtensionPageUrl({ extensionId: ext.name })); + }); + + it("returns matching page", () => { + const page = GlobalPageRegistry.getInstance().getByPageTarget({ + pageId: "test-page", + extensionId: ext.name + }); + + expect(page.id).toEqual("test-page"); + }); + + it("returns null if target not found", () => { + const page = GlobalPageRegistry.getInstance().getByPageTarget({ + pageId: "wrong-page", + extensionId: ext.name + }); + + expect(page).toBeNull(); + }); }); }); }); diff --git a/src/extensions/registries/catalog-entity-detail-registry.ts b/src/extensions/registries/catalog-entity-detail-registry.ts index b4b9fb0dd2..4ee9c4d667 100644 --- a/src/extensions/registries/catalog-entity-detail-registry.ts +++ b/src/extensions/registries/catalog-entity-detail-registry.ts @@ -20,20 +20,25 @@ */ import type React from "react"; +import type { CatalogEntity } from "../common-api/catalog"; import { BaseRegistry } from "./base-registry"; -export interface CatalogEntityDetailComponents { - Details: React.ComponentType; +export interface CatalogEntityDetailsProps { + entity: T; } -export interface CatalogEntityDetailRegistration { +export interface CatalogEntityDetailComponents { + Details: React.ComponentType>; +} + +export interface CatalogEntityDetailRegistration { kind: string; apiVersions: string[]; - components: CatalogEntityDetailComponents; + components: CatalogEntityDetailComponents; priority?: number; } -export class CatalogEntityDetailRegistry extends BaseRegistry { +export class CatalogEntityDetailRegistry extends BaseRegistry> { getItemsForKind(kind: string, apiVersion: string) { const items = this.getItems().filter((item) => { return item.kind === kind && item.apiVersions.includes(apiVersion); diff --git a/src/extensions/registries/kube-object-detail-registry.ts b/src/extensions/registries/kube-object-detail-registry.ts index 844f5fc0ee..58c807f57a 100644 --- a/src/extensions/registries/kube-object-detail-registry.ts +++ b/src/extensions/registries/kube-object-detail-registry.ts @@ -24,14 +24,14 @@ import type { KubeObjectDetailsProps } from "../renderer-api/components"; import type { KubeObject } from "../renderer-api/k8s-api"; import { BaseRegistry } from "./base-registry"; -export interface KubeObjectDetailComponents { - Details: React.ComponentType>; +export interface KubeObjectDetailComponents { + Details: React.ComponentType>; } export interface KubeObjectDetailRegistration { kind: string; apiVersions: string[]; - components: KubeObjectDetailComponents; + components: KubeObjectDetailComponents; priority?: number; } diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index f19ef18f7a..cb4cfd0377 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -67,5 +67,5 @@ export * from "../../renderer/components/+events/kube-event-details"; // specific exports export * from "../../renderer/components/status-brick"; -export { terminalStore, createTerminalTab } from "../../renderer/components/dock/terminal.store"; +export { terminalStore, createTerminalTab, TerminalStore } from "../../renderer/components/dock/terminal.store"; export { logTabStore } from "../../renderer/components/dock/log-tab.store"; diff --git a/src/main/__test__/context-handler.test.ts b/src/main/__test__/context-handler.test.ts index 0ba3a2d028..0652ed5733 100644 --- a/src/main/__test__/context-handler.test.ts +++ b/src/main/__test__/context-handler.test.ts @@ -207,7 +207,7 @@ describe("ContextHandler", () => { expect(service.id === "id_0"); }); - it("shouldn't pick the second provider of 2 succcess(es) after 1 failure(s)", async () => { + it("shouldn't pick the second provider of 2 success(es) after 1 failure(s)", async () => { const reg = PrometheusProviderRegistry.getInstance(); let count = 0; diff --git a/src/main/catalog-sources/kubeconfig-sync.ts b/src/main/catalog-sources/kubeconfig-sync.ts index 6a6f4120f9..8e3c3b1cf7 100644 --- a/src/main/catalog-sources/kubeconfig-sync.ts +++ b/src/main/catalog-sources/kubeconfig-sync.ts @@ -24,6 +24,7 @@ import type { CatalogEntity } from "../../common/catalog"; import { catalogEntityRegistry } from "../../main/catalog"; import { watch } from "chokidar"; import fs from "fs"; +import path from "path"; import fse from "fs-extra"; import type stream from "stream"; import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils"; @@ -36,9 +37,23 @@ import { UserStore } from "../../common/user-store"; import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store"; import { createHash } from "crypto"; import { homedir } from "os"; +import globToRegExp from "glob-to-regexp"; +import { inspect } from "util"; const logPrefix = "[KUBECONFIG-SYNC]:"; +/** + * This is the list of globs of which files are ignored when under a folder sync + */ +const ignoreGlobs = [ + "*.lock", // kubectl lock files + "*.swp", // vim swap files + ".DS_Store", // macOS specific +].map(rawGlob => ({ + rawGlob, + matcher: globToRegExp(rawGlob), +})); + export class KubeconfigSyncManager extends Singleton { protected sources = observable.map, Disposer]>(); protected syncing = false; @@ -260,26 +275,50 @@ function diffChangedConfig(filePath: string, source: RootSource): Disposer { async function watchFileChanges(filePath: string): Promise<[IComputedValue, Disposer]> { const stat = await fse.stat(filePath); // traverses symlinks, is a race condition + const isFolderSync = stat.isDirectory(); const watcher = watch(filePath, { followSymlinks: true, - depth: stat.isDirectory() ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095) + depth: isFolderSync ? 0 : 1, // DIRs works with 0 but files need 1 (bug: https://github.com/paulmillr/chokidar/issues/1095) disableGlobbing: true, + ignorePermissionErrors: true, + usePolling: false, + awaitWriteFinish: { + pollInterval: 100, + stabilityThreshold: 1000, + }, }); const rootSource = new ExtendedObservableMap>(); const derivedSource = computed(() => Array.from(iter.flatMap(rootSource.values(), from => iter.map(from.values(), child => child[1])))); - const stoppers = new Map(); + const cleanupFns = new Map(); watcher .on("change", (childFilePath) => { - stoppers.get(childFilePath)(); - stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrInsert(childFilePath, observable.map))); + const cleanup = cleanupFns.get(childFilePath); + + if (!cleanup) { + // file was previously ignored, do nothing + return void logger.debug(`${logPrefix} ${inspect(childFilePath)} that should have been previously ignored has changed. Doing nothing`); + } + + cleanup(); + cleanupFns.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrInsert(childFilePath, observable.map))); }) .on("add", (childFilePath) => { - stoppers.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrInsert(childFilePath, observable.map))); + if (isFolderSync) { + const fileName = path.basename(childFilePath); + + for (const ignoreGlob of ignoreGlobs) { + if (ignoreGlob.matcher.test(fileName)) { + return void logger.info(`${logPrefix} ignoring ${inspect(childFilePath)} due to ignore glob: ${ignoreGlob.rawGlob}`); + } + } + } + + cleanupFns.set(childFilePath, diffChangedConfig(childFilePath, rootSource.getOrInsert(childFilePath, observable.map))); }) .on("unlink", (childFilePath) => { - stoppers.get(childFilePath)(); - stoppers.delete(childFilePath); + cleanupFns.get(childFilePath)?.(); + cleanupFns.delete(childFilePath); rootSource.delete(childFilePath); }) .on("error", error => logger.error(`${logPrefix} watching file/folder failed: ${error}`, { filePath })); diff --git a/src/main/catalog-sources/weblinks.ts b/src/main/catalog-sources/weblinks.ts index 2593fedc7c..c8e983f3be 100644 --- a/src/main/catalog-sources/weblinks.ts +++ b/src/main/catalog-sources/weblinks.ts @@ -19,43 +19,19 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { observable, reaction } from "mobx"; +import { computed, observable, reaction } from "mobx"; import { WeblinkStore } from "../../common/weblink-store"; import { WebLink } from "../../common/catalog-entities"; import { catalogEntityRegistry } from "../catalog"; import got from "got"; -import logger from "../logger"; -import { docsUrl, slackUrl } from "../../common/vars"; - -const defaultLinks = [ - { title: "Lens Website", url: "https://k8slens.dev" }, - { title: "Lens Documentation", url: docsUrl }, - { title: "Lens Community Slack", url: slackUrl }, - { title: "Kubernetes Documentation", url: "https://kubernetes.io/docs/home/" }, - { title: "Lens on Twitter", url: "https://twitter.com/k8slens" }, - { title: "Lens Official Blog", url: "https://medium.com/k8slens" } -].map((link) => ( - new WebLink({ - metadata: { - uid: link.url, - name: link.title, - source: "app", - labels: {} - }, - spec: { - url: link.url - }, - status: { - phase: "available", - active: true - } - }) -)); +import type { Disposer } from "../../common/utils"; +import { random } from "lodash"; async function validateLink(link: WebLink) { try { const response = await got.get(link.spec.url, { - throwHttpErrors: false + throwHttpErrors: false, + timeout: 20_000, }); if (response.statusCode >= 200 && response.statusCode < 500) { @@ -63,7 +39,7 @@ async function validateLink(link: WebLink) { } else { link.status.phase = "unavailable"; } - } catch(error) { + } catch { link.status.phase = "unavailable"; } } @@ -71,32 +47,60 @@ async function validateLink(link: WebLink) { export function syncWeblinks() { const weblinkStore = WeblinkStore.getInstance(); - const weblinks = observable.array(defaultLinks); + const webLinkEntities = observable.map(); + + function periodicallyCheckLink(link: WebLink): Disposer { + validateLink(link); + + let interval: NodeJS.Timeout; + const timeout = setTimeout(() => { + interval = setInterval(() => validateLink(link), 60 * 60 * 1000); // every 60 minutes + }, random(0, 5 * 60 * 1000, false)); // spread out over 5 minutes + + return () => { + clearTimeout(timeout); + clearInterval(interval); + }; + } reaction(() => weblinkStore.weblinks, (links) => { - weblinks.replace(links.map((data) => new WebLink({ - metadata: { - uid: data.id, - name: data.name, - source: "local", - labels: {} - }, - spec: { - url: data.url - }, - status: { - phase: "available", - active: true - } - }))); - weblinks.push(...defaultLinks); + const seenWeblinks = new Set(); - for (const link of weblinks) { - validateLink(link).catch((error) => { - logger.error(`failed to validate link ${link.spec.url}: %s`, error); - }); + for (const weblinkData of links) { + seenWeblinks.add(weblinkData.id); + + if (!webLinkEntities.has(weblinkData.id)) { + const link = new WebLink({ + metadata: { + uid: weblinkData.id, + name: weblinkData.name, + source: "local", + labels: {} + }, + spec: { + url: weblinkData.url + }, + status: { + phase: "available", + active: true + } + }); + + webLinkEntities.set(weblinkData.id, [ + link, + periodicallyCheckLink(link), + ]); + } + } + + // Stop checking and remove weblinks that are no longer in the store + for (const [weblinkId, [, disposer]] of webLinkEntities) { + if (!seenWeblinks.has(weblinkId)) { + disposer(); + webLinkEntities.delete(weblinkId); + } } }, {fireImmediately: true}); - catalogEntityRegistry.addObservableSource("weblinks", weblinks); + catalogEntityRegistry.addComputedSource("weblinks", computed(() => Array.from(webLinkEntities.values(), ([link]) => link))); } diff --git a/src/main/catalog/catalog-entity-registry.ts b/src/main/catalog/catalog-entity-registry.ts index 4cecf4f49b..f2188132eb 100644 --- a/src/main/catalog/catalog-entity-registry.ts +++ b/src/main/catalog/catalog-entity-registry.ts @@ -20,7 +20,7 @@ */ import { action, computed, IComputedValue, IObservableArray, makeObservable, observable } from "mobx"; -import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity } from "../../common/catalog"; +import { CatalogCategoryRegistry, catalogCategoryRegistry, CatalogEntity, CatalogEntityKindData } from "../../common/catalog"; import { iter } from "../../common/utils"; export class CatalogEntityRegistry { @@ -62,6 +62,10 @@ export class CatalogEntityRegistry { return items as T[]; } + + getItemsByEntityClass({ apiVersion, kind }: CatalogEntityKindData): T[] { + return this.getItemsForApiKind(apiVersion, kind); + } } export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry); diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 42d2dd1d19..5338bbad14 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -23,7 +23,7 @@ import "../common/cluster-ipc"; import type http from "http"; import { action, autorun, makeObservable, observable, observe, reaction, toJS } from "mobx"; import { ClusterId, ClusterStore, getClusterIdFromHost } from "../common/cluster-store"; -import type { Cluster } from "./cluster"; +import { Cluster } from "./cluster"; import logger from "./logger"; import { apiKubePrefix } from "../common/vars"; import { Singleton } from "../common/utils"; @@ -32,6 +32,8 @@ import { KubernetesCluster, KubernetesClusterPrometheusMetrics, KubernetesCluste import { ipcMainOn } from "../common/ipc"; import { once } from "lodash"; +const logPrefix = "[CLUSTER-MANAGER]:"; + export class ClusterManager extends Singleton { private store = ClusterStore.getInstance(); deleting = observable.set(); @@ -73,7 +75,7 @@ export class ClusterManager extends Singleton { if (removedClusters.length > 0) { const meta = removedClusters.map(cluster => cluster.getMeta()); - logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta); + logger.info(`${logPrefix} removing clusters`, meta); removedClusters.forEach(cluster => cluster.disconnect()); this.store.removedClusters.clear(); } @@ -103,10 +105,18 @@ export class ClusterManager extends Singleton { this.updateEntityStatus(entity, cluster); - entity.metadata.labels = Object.assign({}, cluster.labels, entity.metadata.labels); - entity.metadata.labels.distro = cluster.distribution; + entity.metadata.labels = { + ...entity.metadata.labels, + ...cluster.labels, + }; + entity.metadata.distro = cluster.distribution; + entity.metadata.kubeVersion = cluster.version; if (cluster.preferences?.clusterName) { + /** + * Only set the name if the it is overriden in preferences. If it isn't + * set then the name of the entity has been explicitly set by its source + */ entity.metadata.name = cluster.preferences.clusterName; } @@ -161,17 +171,28 @@ export class ClusterManager extends Singleton { const cluster = this.store.getById(entity.metadata.uid); if (!cluster) { - this.store.addCluster({ - id: entity.metadata.uid, - preferences: { - clusterName: entity.metadata.name - }, - kubeConfigPath: entity.spec.kubeconfigPath, - contextName: entity.spec.kubeconfigContext - }); + try { + /** + * Add the bare minimum of data to ClusterStore. And especially no + * preferences, as those might be configured by the entity's source + */ + this.store.addCluster({ + id: entity.metadata.uid, + kubeConfigPath: entity.spec.kubeconfigPath, + contextName: entity.spec.kubeconfigContext, + accessibleNamespaces: entity.spec.accessibleNamespaces ?? [], + }); + } catch (error) { + if (error.code === "ENOENT" && error.path === entity.spec.kubeconfigPath) { + logger.warn(`${logPrefix} kubeconfig file disappeared`, { path: entity.spec.kubeconfigPath }); + } else { + logger.error(`${logPrefix} failed to add cluster: ${error}`); + } + } } else { cluster.kubeConfigPath = entity.spec.kubeconfigPath; cluster.contextName = entity.spec.kubeconfigContext; + cluster.accessibleNamespaces = entity.spec.accessibleNamespaces ?? []; this.updateEntityFromCluster(cluster); } @@ -179,7 +200,7 @@ export class ClusterManager extends Singleton { } protected onNetworkOffline = () => { - logger.info("[CLUSTER-MANAGER]: network is offline"); + logger.info(`${logPrefix} network is offline`); this.store.clustersList.forEach((cluster) => { if (!cluster.disconnected) { cluster.online = false; @@ -190,7 +211,7 @@ export class ClusterManager extends Singleton { }; protected onNetworkOnline = () => { - logger.info("[CLUSTER-MANAGER]: network is online"); + logger.info(`${logPrefix} network is online`); this.store.clustersList.forEach((cluster) => { if (!cluster.disconnected) { cluster.refreshConnectionStatus().catch((e) => e); @@ -236,8 +257,10 @@ export function catalogEntityFromCluster(cluster: Cluster) { name: cluster.name, source: "local", labels: { - distro: cluster.distribution, - } + ...cluster.labels, + }, + distro: cluster.distribution, + kubeVersion: cluster.version, }, spec: { kubeconfigPath: cluster.kubeConfigPath, diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 1266a9c00a..6d3331c447 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -34,6 +34,7 @@ import { VersionDetector } from "./cluster-detectors/version-detector"; import { detectorRegistry } from "./cluster-detectors/detector-registry"; import plimit from "p-limit"; import { toJS } from "../common/utils"; +import { initialNodeShellImage } from "../common/cluster-store"; export enum ClusterStatus { AccessGranted = 2, @@ -61,6 +62,8 @@ export enum ClusterMetricsResourceType { VolumeClaim = "VolumeClaim", ReplicaSet = "ReplicaSet", DaemonSet = "DaemonSet", + Job = "Job", + Namespace = "Namespace" } export type ClusterRefreshOptions = { @@ -234,8 +237,18 @@ export class Cluster implements ClusterModel, ClusterState { return this.preferences.clusterName || this.contextName; } + /** + * The detected kubernetes distribution + */ @computed get distribution(): string { - return this.metadata.distribution?.toString() || "unknown"; + return this.metadata[ClusterMetadataKey.DISTRIBUTION]?.toString() || "unknown"; + } + + /** + * The detected kubernetes version + */ + @computed get version(): string { + return this.metadata[ClusterMetadataKey.VERSION]?.toString() || "unknown"; } /** @@ -250,13 +263,6 @@ export class Cluster implements ClusterModel, ClusterState { return toJS({ prometheus, prometheusProvider }); } - /** - * Kubernetes version - */ - get version(): string { - return String(this.metadata?.version ?? ""); - } - constructor(model: ClusterModel) { makeObservable(this); this.id = model.id; @@ -745,4 +751,12 @@ export class Cluster implements ClusterModel, ClusterState { isMetricHidden(resource: ClusterMetricsResourceType): boolean { return Boolean(this.preferences.hiddenMetrics?.includes(resource)); } + + get nodeShellImage(): string { + return this.preferences.nodeShellImage || initialNodeShellImage; + } + + get imagePullSecret(): string { + return this.preferences.imagePullSecret || ""; + } } diff --git a/src/main/context-handler.ts b/src/main/context-handler.ts index 04c2842a37..7e09d29f5c 100644 --- a/src/main/context-handler.ts +++ b/src/main/context-handler.ts @@ -146,8 +146,10 @@ export class ContextHandler { proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy; } this.kubeAuthProxy = new KubeAuthProxy(this.cluster, proxyEnv); - await this.kubeAuthProxy.run(); + this.kubeAuthProxy.run(); } + + await this.kubeAuthProxy.whenReady; } stopServer() { diff --git a/src/main/helm/__mocks__/helm-chart-manager.ts b/src/main/helm/__mocks__/helm-chart-manager.ts index 7d4a5e2a65..6a511273da 100644 --- a/src/main/helm/__mocks__/helm-chart-manager.ts +++ b/src/main/helm/__mocks__/helm-chart-manager.ts @@ -34,6 +34,36 @@ export class HelmChartManager { switch (this.repo.name) { case "stable": return Promise.resolve({ + "invalid-semver": [ + { + apiVersion: "3.0.0", + name: "weird-versioning", + version: "I am not semver", + repo: "stable", + digest: "test" + }, + { + apiVersion: "3.0.0", + name: "weird-versioning", + version: "v4.3.0", + repo: "stable", + digest: "test" + }, + { + apiVersion: "3.0.0", + name: "weird-versioning", + version: "I am not semver but more", + repo: "stable", + digest: "test" + }, + { + apiVersion: "3.0.0", + name: "weird-versioning", + version: "v4.4.0", + repo: "stable", + digest: "test" + }, + ], "apm-server": [ { apiVersion: "3.0.0", diff --git a/src/main/helm/__tests__/helm-service.test.ts b/src/main/helm/__tests__/helm-service.test.ts index 6ac169d5d0..56127c1684 100644 --- a/src/main/helm/__tests__/helm-service.test.ts +++ b/src/main/helm/__tests__/helm-service.test.ts @@ -62,6 +62,36 @@ describe("Helm Service tests", () => { digest: "test" } ], + "invalid-semver": [ + { + apiVersion: "3.0.0", + name: "weird-versioning", + version: "v4.4.0", + repo: "stable", + digest: "test" + }, + { + apiVersion: "3.0.0", + name: "weird-versioning", + version: "v4.3.0", + repo: "stable", + digest: "test" + }, + { + apiVersion: "3.0.0", + name: "weird-versioning", + version: "I am not semver", + repo: "stable", + digest: "test" + }, + { + apiVersion: "3.0.0", + name: "weird-versioning", + version: "I am not semver but more", + repo: "stable", + digest: "test" + }, + ], "redis": [ { apiVersion: "3.0.0", diff --git a/src/main/helm/helm-repo-manager.ts b/src/main/helm/helm-repo-manager.ts index dc30d92edd..d1c904539d 100644 --- a/src/main/helm/helm-repo-manager.ts +++ b/src/main/helm/helm-repo-manager.ts @@ -146,7 +146,7 @@ export class HelmRepoManager extends Singleton { return stdout; } - public static async addСustomRepo(repoAttributes : HelmRepo) { + public static async addCustomRepo(repoAttributes : HelmRepo) { logger.info(`[HELM]: adding repo "${repoAttributes.name}" from ${repoAttributes.url}`); const helm = await helmCli.binaryPath(); diff --git a/src/main/helm/helm-service.ts b/src/main/helm/helm-service.ts index 01262b6ef8..3cf56ccb13 100644 --- a/src/main/helm/helm-service.ts +++ b/src/main/helm/helm-service.ts @@ -19,13 +19,14 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import semver from "semver"; +import semver, { SemVer } from "semver"; import type { Cluster } from "../cluster"; import logger from "../logger"; import { HelmRepoManager } from "./helm-repo-manager"; import { HelmChartManager } from "./helm-chart-manager"; -import type { HelmChartList, RepoHelmChartList } from "../../renderer/api/endpoints/helm-charts.api"; +import type { HelmChart, HelmChartList, RepoHelmChartList } from "../../renderer/api/endpoints/helm-charts.api"; import { deleteRelease, getHistory, getRelease, getValues, installChart, listReleases, rollback, upgradeRelease } from "./helm-release-manager"; +import { iter, sortCompareChartVersions } from "../../common/utils"; interface GetReleaseValuesArgs { cluster: Cluster; @@ -132,28 +133,55 @@ class HelmService { } private excludeDeprecatedChartGroups(chartGroups: RepoHelmChartList) { - const groups = new Map(Object.entries(chartGroups)); + return Object.fromEntries( + iter.filterMap( + Object.entries(chartGroups), + ([name, charts]) => { + for (const chart of charts) { + if (chart.deprecated) { + // ignore chart group if any chart is deprecated + return undefined; + } + } - for (const [chartName, charts] of groups) { - if (charts[0].deprecated) { - groups.delete(chartName); - } + return [name, charts]; + } + ) + ); + } + + private sortCharts(charts: HelmChart[]) { + interface ExtendedHelmChart extends HelmChart { + __version: SemVer; } - return Object.fromEntries(groups); + const chartsWithVersion = Array.from( + iter.map( + charts, + (chart => { + const __version = semver.coerce(chart.version, { includePrerelease: true, loose: true }); + + if (!__version) { + logger.error(`[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 HelmChart)); } private sortChartsByVersion(chartGroups: RepoHelmChartList) { - for (const key in chartGroups) { - chartGroups[key] = chartGroups[key].sort((first, second) => { - const firstVersion = semver.coerce(first.version || 0); - const secondVersion = semver.coerce(second.version || 0); - - return semver.compare(secondVersion, firstVersion); - }); - } - - return chartGroups; + return Object.fromEntries( + Object.entries(chartGroups) + .map(([name, charts]) => [name, this.sortCharts(charts)]) + ); } } diff --git a/src/main/index.ts b/src/main/index.ts index 0599f820cb..a5116cfdb0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -59,6 +59,11 @@ import { UserStore } from "../common/user-store"; import { WeblinkStore } from "../common/weblink-store"; import { ExtensionsStore } from "../extensions/extensions-store"; import { FilesystemProvisionerStore } from "./extension-filesystem"; +import { SentryInit } from "../common/sentry"; + +// This has to be called before start using winton-based logger +// For example, before any logger.log +SentryInit(); const workingDir = path.join(app.getPath("appData"), appName); const cleanup = disposer(); @@ -132,15 +137,20 @@ app.on("ready", async () => { /** * The following sync MUST be done before HotbarStore creation, because that * store has migrations that will remove items that previous migrations add - * if this is not presant + * if this is not present */ syncGeneralEntities(); logger.info("💾 Loading stores"); UserStore.createInstance().startMainReactions(); + + // ClusterStore depends on: UserStore ClusterStore.createInstance().provideInitialFromMain(); + + // HotbarStore depends on: ClusterStore HotbarStore.createInstance(); + ExtensionsStore.createInstance(); FilesystemProvisionerStore.createInstance(); WeblinkStore.createInstance(); diff --git a/src/main/initializers/ipc.ts b/src/main/initializers/ipc.ts index 95c4571a41..3614c602d7 100644 --- a/src/main/initializers/ipc.ts +++ b/src/main/initializers/ipc.ts @@ -20,7 +20,7 @@ */ import type { IpcMainInvokeEvent } from "electron"; -import type { KubernetesCluster } from "../../common/catalog-entities"; +import { KubernetesCluster } from "../../common/catalog-entities"; import { clusterFrameMap } from "../../common/cluster-frames"; import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler } from "../../common/cluster-ipc"; import { ClusterId, ClusterStore } from "../../common/cluster-store"; @@ -50,9 +50,9 @@ export function initIpcMainHandlers() { }); ipcMainHandle(clusterVisibilityHandler, (event: IpcMainInvokeEvent, clusterId: ClusterId, visible: boolean) => { - const entity = catalogEntityRegistry.getById(clusterId); + const entity = catalogEntityRegistry.getById(clusterId); - for (const kubeEntity of catalogEntityRegistry.getItemsForApiKind(entity.apiVersion, entity.kind)) { + for (const kubeEntity of catalogEntityRegistry.getItemsByEntityClass(KubernetesCluster)) { kubeEntity.status.active = false; } diff --git a/src/main/kube-auth-proxy.ts b/src/main/kube-auth-proxy.ts index 87d4f9f67c..ac3bdf8dd1 100644 --- a/src/main/kube-auth-proxy.ts +++ b/src/main/kube-auth-proxy.ts @@ -27,6 +27,7 @@ import { Kubectl } from "./kubectl"; import logger from "./logger"; import * as url from "url"; import { getPortFrom } from "./utils/get-port"; +import { makeObservable, observable, when } from "mobx"; export interface KubeAuthProxyLog { data: string; @@ -47,8 +48,11 @@ export class KubeAuthProxy { protected env: NodeJS.ProcessEnv = null; protected proxyProcess: ChildProcess; protected kubectl: Kubectl; + @observable protected ready: boolean; constructor(cluster: Cluster, env: NodeJS.ProcessEnv) { + makeObservable(this); + this.ready = false; this.env = env; this.cluster = cluster; this.kubectl = Kubectl.bundled(); @@ -58,9 +62,13 @@ export class KubeAuthProxy { return url.parse(this.cluster.apiUrl).hostname; } + get whenReady() { + return when(() => this.ready); + } + public async run(): Promise { if (this.proxyProcess) { - return; + return this.whenReady; } const proxyBin = await this.kubectl.getPath(); @@ -103,7 +111,9 @@ export class KubeAuthProxy { this.sendIpcLogMessage({ data: data.toString() }); }); - return waitUntilUsed(this.port, 500, 10000); + await waitUntilUsed(this.port, 500, 10000); + + this.ready = true; } protected parseError(data: string) { @@ -132,6 +142,7 @@ export class KubeAuthProxy { } public exit() { + this.ready = false; if (!this.proxyProcess) return; logger.debug("[KUBE-AUTH]: stopping local proxy", this.cluster.getMeta()); this.proxyProcess.kill(); diff --git a/src/main/prometheus/helm.ts b/src/main/prometheus/helm.ts index f583578ddb..4f98107946 100644 --- a/src/main/prometheus/helm.ts +++ b/src/main/prometheus/helm.ts @@ -27,7 +27,7 @@ export class PrometheusHelm extends PrometheusLens { readonly id: string = "helm"; readonly name: string = "Helm"; readonly rateAccuracy: string = "5m"; - readonly isConfigurable: boolean = false; + readonly isConfigurable: boolean = true; public async getPrometheusService(client: CoreV1Api): Promise { return this.getFirstNamespacedServer(client, "app=prometheus,component=server,heritage=Helm"); diff --git a/src/main/prometheus/lens.ts b/src/main/prometheus/lens.ts index 63341fe30e..c00bc31069 100644 --- a/src/main/prometheus/lens.ts +++ b/src/main/prometheus/lens.ts @@ -28,7 +28,7 @@ export class PrometheusLens extends PrometheusProvider { readonly id: string = "lens"; readonly name: string = "Lens"; readonly rateAccuracy: string = "1m"; - readonly isConfigurable: boolean = true; + readonly isConfigurable: boolean = false; public async getPrometheusService(client: CoreV1Api): Promise { try { diff --git a/src/main/prometheus/operator.ts b/src/main/prometheus/operator.ts index 58d41210b5..f037ef8a53 100644 --- a/src/main/prometheus/operator.ts +++ b/src/main/prometheus/operator.ts @@ -27,7 +27,7 @@ export class PrometheusOperator extends PrometheusProvider { readonly rateAccuracy: string = "1m"; readonly id: string = "operator"; readonly name: string = "Prometheus Operator"; - readonly isConfigurable: boolean = false; + readonly isConfigurable: boolean = true; public async getPrometheusService(client: CoreV1Api): Promise { return this.getFirstNamespacedServer(client, "operated-prometheus=true", "self-monitor=true"); diff --git a/src/main/prometheus/stacklight.ts b/src/main/prometheus/stacklight.ts index 7960e666a8..8148f3bff7 100644 --- a/src/main/prometheus/stacklight.ts +++ b/src/main/prometheus/stacklight.ts @@ -28,7 +28,7 @@ export class PrometheusStacklight extends PrometheusProvider { readonly id: string = "stacklight"; readonly name: string = "Stacklight"; readonly rateAccuracy: string = "1m"; - readonly isConfigurable: boolean = false; + readonly isConfigurable: boolean = true; public async getPrometheusService(client: CoreV1Api): Promise { try { diff --git a/src/main/proxy/lens-proxy.ts b/src/main/proxy/lens-proxy.ts index 00ef5fbb48..9ffff0fd6a 100644 --- a/src/main/proxy/lens-proxy.ts +++ b/src/main/proxy/lens-proxy.ts @@ -61,7 +61,8 @@ export class LensProxy extends Singleton { if (req.url.startsWith(`${apiPrefix}?`)) { handleWsUpgrade(req, socket, head); } else { - this.handleProxyUpgrade(proxy, req, socket, head); + this.handleProxyUpgrade(proxy, req, socket, head) + .catch(error => logger.error(`[LENS-PROXY]: failed to handle proxy upgrade: ${error}`)); } }); } @@ -168,7 +169,10 @@ export class LensProxy extends Singleton { if (!res.headersSent && req.url) { const url = new URL(req.url, "http://localhost"); - if (url.searchParams.has("watch")) res.flushHeaders(); + if (url.searchParams.has("watch")) { + res.statusCode = proxyRes.statusCode; + res.flushHeaders(); + } } }); @@ -177,6 +181,8 @@ export class LensProxy extends Singleton { return; } + logger.error(`[LENS-PROXY]: http proxy errored for cluster: ${error}`, { url: req.url }); + if (target) { logger.debug(`Failed proxy to target: ${JSON.stringify(target, null, 2)}`); @@ -189,14 +195,15 @@ export class LensProxy extends Singleton { logger.debug(`Retrying proxy request to url: ${reqId}`); setTimeout(() => { this.retryCounters.set(reqId, retryCount + 1); - this.handleRequest(proxy, req, res); + this.handleRequest(proxy, req, res) + .catch(error => logger.error(`[LENS-PROXY]: failed to handle request on proxy error: ${error}`)); }, timeoutMs); } } } try { - res.writeHead(500).end("Oops, something went wrong."); + res.writeHead(500).end(`Oops, something went wrong.\n${error}`); } catch (e) { logger.error(`[LENS-PROXY]: Failed to write headers: `, e); } diff --git a/src/main/routes/port-forward-route.ts b/src/main/routes/port-forward-route.ts index cfd88e771f..e97b4171cb 100644 --- a/src/main/routes/port-forward-route.ts +++ b/src/main/routes/port-forward-route.ts @@ -134,11 +134,22 @@ export class PortForwardRoute { name: resourceName, port, }); - const started = await portForward.start(); - if (!started) { + try { + const started = await portForward.start(); + + if (!started) { + logger.warn("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { namespace, port, resourceType, resourceName }); + + return respondJson(response, { + message: "Failed to open port-forward" + }, 400); + } + } catch (error) { + logger.warn(`[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, { namespace, port, resourceType, resourceName }); + return respondJson(response, { - message: "Failed to open port-forward" + message: error?.toString() || "Failed to open port-forward", }, 400); } } diff --git a/src/main/shell-session/local-shell-session.ts b/src/main/shell-session/local-shell-session.ts index 384e9317a8..ed1208fcbd 100644 --- a/src/main/shell-session/local-shell-session.ts +++ b/src/main/shell-session/local-shell-session.ts @@ -31,8 +31,11 @@ export class LocalShellSession extends ShellSession { return [helmCli.getBinaryDir()]; } - public async open() { + protected get cwd(): string | undefined { + return this.cluster.preferences?.terminalCWD; + } + public async open() { const env = await this.getCachedShellEnv(); const shell = env.PTYSHELL; const args = await this.getShellArgs(shell); @@ -51,7 +54,7 @@ export class LocalShellSession extends ShellSession { case "bash": return ["--init-file", path.join(await this.kubectlBinDirP, ".bash_set_path")]; case "fish": - return ["--login", "--init-command", `export PATH="${helmpath}:${kubectlPathDir}:$PATH"; export KUBECONFIG="${this.kubeconfigPathP}"`]; + return ["--login", "--init-command", `export PATH="${helmpath}:${kubectlPathDir}:$PATH"; export KUBECONFIG="${await this.kubeconfigPathP}"`]; case "zsh": return ["--login"]; default: diff --git a/src/main/shell-session/node-shell-session.ts b/src/main/shell-session/node-shell-session.ts index a3cc918371..aa8c14ba9b 100644 --- a/src/main/shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session.ts @@ -32,6 +32,10 @@ export class NodeShellSession extends ShellSession { protected podId = `node-shell-${uuid()}`; protected kc: KubeConfig; + protected get cwd(): string | undefined { + return undefined; + } + constructor(socket: WebSocket, cluster: Cluster, protected nodeName: string) { super(socket, cluster); } @@ -77,13 +81,16 @@ export class NodeShellSession extends ShellSession { }], containers: [{ name: "shell", - image: "docker.io/alpine:3.13", + image: this.cluster.nodeShellImage, securityContext: { privileged: true, }, command: ["nsenter"], args: ["-t", "1", "-m", "-u", "-i", "-n", "sleep", "14000"] }], + imagePullSecrets: [{ + name: this.cluster.imagePullSecret, + }] } }); } diff --git a/src/main/shell-session/shell-session.ts b/src/main/shell-session/shell-session.ts index 039caa44ce..4771ec8b77 100644 --- a/src/main/shell-session/shell-session.ts +++ b/src/main/shell-session/shell-session.ts @@ -19,6 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import fse from "fs-extra"; import type { Cluster } from "../cluster"; import { Kubectl } from "../kubectl"; import type * as WebSocket from "ws"; @@ -50,9 +51,7 @@ export abstract class ShellSession { protected kubectlBinDirP: Promise; protected kubeconfigPathP: Promise; - protected get cwd(): string | undefined { - return this.cluster.preferences?.terminalCWD; - } + protected abstract get cwd(): string | undefined; constructor(protected websocket: WebSocket, protected cluster: Cluster) { this.kubectl = new Kubectl(cluster.version); @@ -60,10 +59,14 @@ export abstract class ShellSession { this.kubectlBinDirP = this.kubectl.binDir(); } - open(shell: string, args: string[], env: Record): void { + protected async open(shell: string, args: string[], env: Record) { + const cwd = (this.cwd && await fse.pathExists(this.cwd)) + ? this.cwd + : env.HOME; + this.shellProcess = pty.spawn(shell, args, { cols: 80, - cwd: this.cwd || env.HOME, + cwd, env, name: "xterm-256color", rows: 30, diff --git a/src/main/tray.ts b/src/main/tray.ts index 24b00d122a..3a772503f8 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -93,7 +93,7 @@ function createTrayMenu(windowManager: WindowManager): Menu { click() { windowManager .navigate(preferencesURL()) - .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to nativate to Preferences`, { error })); + .catch(error => logger.error(`${TRAY_LOG_PREFIX}: Failed to navigate to Preferences`, { error })); }, } ]; diff --git a/src/main/utils/get-port.ts b/src/main/utils/get-port.ts index cdce0ce740..a9ad5655bc 100644 --- a/src/main/utils/get-port.ts +++ b/src/main/utils/get-port.ts @@ -21,6 +21,7 @@ import type { Readable } from "stream"; import URLParse from "url-parse"; +import logger from "../logger"; interface GetPortArgs { /** @@ -47,11 +48,15 @@ interface GetPortArgs { * @returns A Promise for port number */ export function getPortFrom(stream: Readable, args: GetPortArgs): Promise { + const logLines: string[] = []; + return new Promise((resolve, reject) => { const handler = (data: any) => { const logItem: string = data.toString(); const match = logItem.match(args.lineRegex); + logLines.push(logItem); + if (match) { // use unknown protocol so that there is no default port const addr = new URLParse(`s://${match.groups.address.trim()}`); @@ -64,8 +69,9 @@ export function getPortFrom(stream: Readable, args: GetPortArgs): Promise { stream.off("data", handler); + logger.warn(`[getPortFrom]: failed to retrieve port via ${args.lineRegex.toString()}: ${logLines}`); reject(new Error("failed to retrieve port from stream")); - }, args.timeout ?? 5000); + }, args.timeout ?? 15000); stream.on("data", handler); }); diff --git a/src/migrations/cluster-store/5.0.0-beta.10.ts b/src/migrations/cluster-store/5.0.0-beta.10.ts index 3a2b5bdfe3..4b7fa9f3bd 100644 --- a/src/migrations/cluster-store/5.0.0-beta.10.ts +++ b/src/migrations/cluster-store/5.0.0-beta.10.ts @@ -45,7 +45,7 @@ export default { workspaces.set(id, name); } - const clusters: ClusterModel[] = store.get("clusters"); + const clusters: ClusterModel[] = store.get("clusters") ?? []; for (const cluster of clusters) { if (cluster.workspace && workspaces.has(cluster.workspace)) { diff --git a/src/migrations/cluster-store/5.0.0-beta.13.ts b/src/migrations/cluster-store/5.0.0-beta.13.ts index 25e0cd1543..e6f260db1b 100644 --- a/src/migrations/cluster-store/5.0.0-beta.13.ts +++ b/src/migrations/cluster-store/5.0.0-beta.13.ts @@ -108,7 +108,7 @@ export default { run(store) { const folder = path.resolve(app.getPath("userData"), "lens-local-storage"); - const oldClusters: ClusterModel[] = store.get("clusters"); + const oldClusters: ClusterModel[] = store.get("clusters") ?? []; const clusters = new Map(); for (const { id: oldId, ...cluster } of oldClusters) { diff --git a/src/migrations/helpers.ts b/src/migrations/helpers.ts index 623d46a63e..8ed4242652 100644 --- a/src/migrations/helpers.ts +++ b/src/migrations/helpers.ts @@ -44,8 +44,10 @@ export function joinMigrations(...declarations: MigrationDeclaration[]): Migrati return Object.fromEntries( iter.map( - migrations, + migrations, ([v, fns]) => [v, (store: Conf) => { + migrationLog(`Running ${v} migration for ${store.path}`); + for (const fn of fns) { fn(store); } diff --git a/src/migrations/hotbar-store/5.0.0-beta.10.ts b/src/migrations/hotbar-store/5.0.0-beta.10.ts index 9a696ba36c..3dd635effc 100644 --- a/src/migrations/hotbar-store/5.0.0-beta.10.ts +++ b/src/migrations/hotbar-store/5.0.0-beta.10.ts @@ -40,7 +40,7 @@ interface Pre500WorkspaceStoreModel { export default { version: "5.0.0-beta.10", run(store) { - const hotbars: Hotbar[] = store.get("hotbars"); + const hotbars = (store.get("hotbars") as Hotbar[] ?? []).filter(Boolean); const userDataPath = app.getPath("userData"); try { @@ -75,6 +75,11 @@ export default { for (const workspaceId of cluster.workspaces ?? [cluster.workspace].filter(Boolean)) { const workspaceHotbar = workspaceHotbars.get(workspaceId); + if (!workspaceHotbar) { + migrationLog(`Cluster ${uid} has unknown workspace ID, skipping`); + continue; + } + migrationLog(`Adding cluster ${uid} to ${workspaceHotbar.name}`); if (workspaceHotbar?.items.length < defaultHotbarCells) { @@ -143,8 +148,8 @@ export default { store.set("hotbars", hotbars); } catch (error) { - if (!(error.code === "ENOENT" && error.path.endsWith("lens-workspace-store.json"))) { - // ignore lens-workspace-store.json being missing + // ignore files being missing + if (error.code !== "ENOENT") { throw error; } } diff --git a/src/migrations/user-store/5.0.3-beta.1.ts b/src/migrations/user-store/5.0.3-beta.1.ts new file mode 100644 index 0000000000..ef3448116d --- /dev/null +++ b/src/migrations/user-store/5.0.3-beta.1.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { app } from "electron"; +import { existsSync, readFileSync } from "fs"; +import path from "path"; +import os from "os"; +import { ClusterStore, ClusterStoreModel } from "../../common/cluster-store"; +import type { KubeconfigSyncEntry, UserPreferencesModel } from "../../common/user-store"; +import { MigrationDeclaration, migrationLog } from "../helpers"; +import { isLogicalChildPath } from "../../common/utils"; + +export default { + version: "5.0.3-beta.1", + run(store) { + try { + const { syncKubeconfigEntries = [], ...preferences }: UserPreferencesModel = store.get("preferences") ?? {}; + const { clusters = [] }: ClusterStoreModel = JSON.parse(readFileSync(path.resolve(app.getPath("userData"), "lens-cluster-store.json"), "utf-8")) ?? {}; + const extensionDataDir = path.resolve(app.getPath("userData"), "extension_data"); + const syncPaths = new Set(syncKubeconfigEntries.map(s => s.filePath)); + + syncPaths.add(path.join(os.homedir(), ".kube")); + + for (const cluster of clusters) { + const dirOfKubeconfig = path.dirname(cluster.kubeConfigPath); + + if (dirOfKubeconfig === ClusterStore.storedKubeConfigFolder) { + migrationLog(`Skipping ${cluster.id} because kubeConfigPath is under ClusterStore.storedKubeConfigFolder`); + continue; + } + + if (syncPaths.has(cluster.kubeConfigPath) || syncPaths.has(dirOfKubeconfig)) { + migrationLog(`Skipping ${cluster.id} because kubeConfigPath is already being synced`); + continue; + } + + if (isLogicalChildPath(extensionDataDir, cluster.kubeConfigPath)) { + migrationLog(`Skipping ${cluster.id} because kubeConfigPath is placed under an extension_data folder`); + continue; + } + + if (!existsSync(cluster.kubeConfigPath)) { + migrationLog(`Skipping ${cluster.id} because kubeConfigPath no longer exists`); + continue; + } + + migrationLog(`Adding ${cluster.kubeConfigPath} from ${cluster.id} to sync paths`); + syncPaths.add(cluster.kubeConfigPath); + } + + const updatedSyncEntries: KubeconfigSyncEntry[] = [...syncPaths].map(filePath => ({ filePath })); + + migrationLog("Final list of synced paths", updatedSyncEntries); + store.set("preferences", { ...preferences, syncKubeconfigEntries: updatedSyncEntries }); + } catch (error) { + if (error.code !== "ENOENT") { + // ignore files being missing + throw error; + } + } + }, +} as MigrationDeclaration; diff --git a/src/migrations/user-store/index.ts b/src/migrations/user-store/index.ts index c08ef2c5c5..87628daeef 100644 --- a/src/migrations/user-store/index.ts +++ b/src/migrations/user-store/index.ts @@ -25,6 +25,7 @@ import { joinMigrations } from "../helpers"; import version210Beta4 from "./2.1.0-beta.4"; import version500Alpha3 from "./5.0.0-alpha.3"; +import version503Beta1 from "./5.0.3-beta.1"; import { fileNameMigration } from "./file-name-migration"; export { @@ -34,4 +35,5 @@ export { export default joinMigrations( version210Beta4, version500Alpha3, + version503Beta1, ); diff --git a/src/migrations/weblinks-store/5.1.4.ts b/src/migrations/weblinks-store/5.1.4.ts new file mode 100644 index 0000000000..84aaad5e6d --- /dev/null +++ b/src/migrations/weblinks-store/5.1.4.ts @@ -0,0 +1,43 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { docsUrl, slackUrl } from "../../common/vars"; +import type { WeblinkData } from "../../common/weblink-store"; +import type { MigrationDeclaration } from "../helpers"; + +export default { + version: "5.1.4", + run(store) { + const weblinksRaw: any = store.get("weblinks"); + const weblinks = (Array.isArray(weblinksRaw) ? weblinksRaw : []) as WeblinkData[]; + + weblinks.push( + { id: "https://k8slens.dev", name: "Lens Website", url: "https://k8slens.dev" }, + { id: docsUrl, name: "Lens Documentation", url: docsUrl }, + { id: slackUrl, name: "Lens Community Slack", url: slackUrl }, + { id: "https://kubernetes.io/docs/home/", name: "Kubernetes Documentation", url: "https://kubernetes.io/docs/home/" }, + { id: "https://twitter.com/k8slens", name: "Lens on Twitter", url: "https://twitter.com/k8slens" }, + { id: "https://medium.com/k8slens", name: "Lens Official Blog", url: "https://medium.com/k8slens" } + ); + + store.set("weblinks", weblinks); + } +} as MigrationDeclaration; diff --git a/src/renderer/components/app-init/app-init.scss b/src/migrations/weblinks-store/index.ts similarity index 89% rename from src/renderer/components/app-init/app-init.scss rename to src/migrations/weblinks-store/index.ts index 7583111eba..1646b07810 100644 --- a/src/renderer/components/app-init/app-init.scss +++ b/src/migrations/weblinks-store/index.ts @@ -19,11 +19,10 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -.AppInit { - height: 100%; +import { joinMigrations } from "../helpers"; - .waiting-services { - font-size: small; - opacity: .75; - } -} \ No newline at end of file +import version514 from "./5.1.4"; + +export default joinMigrations( + version514, +); diff --git a/src/renderer/api/__tests__/api-manager.test.ts b/src/renderer/api/__tests__/api-manager.test.ts index 8c7beb12c9..83034d20b6 100644 --- a/src/renderer/api/__tests__/api-manager.test.ts +++ b/src/renderer/api/__tests__/api-manager.test.ts @@ -22,8 +22,9 @@ import { ingressStore } from "../../components/+network-ingresses/ingress.store"; import { apiManager } from "../api-manager"; import { KubeApi } from "../kube-api"; +import { KubeObject } from "../kube-object"; -class TestApi extends KubeApi { +class TestApi extends KubeApi { protected async checkPreferredVersion() { return; @@ -36,6 +37,7 @@ describe("ApiManager", () => { const apiBase = "apis/v1/foo"; const fallbackApiBase = "/apis/extensions/v1beta1/foo"; const kubeApi = new TestApi({ + objectConstructor: KubeObject, apiBase, fallbackApiBases: [fallbackApiBase], checkPreferredVersion: true, diff --git a/src/renderer/api/__tests__/crd.test.ts b/src/renderer/api/__tests__/crd.test.ts index af7db4280c..459a7f67d9 100644 --- a/src/renderer/api/__tests__/crd.test.ts +++ b/src/renderer/api/__tests__/crd.test.ts @@ -20,7 +20,7 @@ */ import { CustomResourceDefinition } from "../endpoints"; -import type { IKubeObjectMetadata } from "../kube-object"; +import type { KubeObjectMetadata } from "../kube-object"; describe("Crds", () => { describe("getVersion", () => { @@ -28,7 +28,7 @@ describe("Crds", () => { const crd = new CustomResourceDefinition({ apiVersion: "foo", kind: "CustomResourceDefinition", - metadata: {} as IKubeObjectMetadata, + metadata: {} as KubeObjectMetadata, }); crd.spec = { @@ -48,7 +48,7 @@ describe("Crds", () => { const crd = new CustomResourceDefinition({ apiVersion: "foo", kind: "CustomResourceDefinition", - metadata: {} as IKubeObjectMetadata, + metadata: {} as KubeObjectMetadata, }); crd.spec = { @@ -73,7 +73,7 @@ describe("Crds", () => { const crd = new CustomResourceDefinition({ apiVersion: "foo", kind: "CustomResourceDefinition", - metadata: {} as IKubeObjectMetadata, + metadata: {} as KubeObjectMetadata, }); crd.spec = { @@ -99,7 +99,7 @@ describe("Crds", () => { const crd = new CustomResourceDefinition({ apiVersion: "foo", kind: "CustomResourceDefinition", - metadata: {} as IKubeObjectMetadata, + metadata: {} as KubeObjectMetadata, }); crd.spec = { diff --git a/src/renderer/api/__tests__/endpoint.api.test.ts b/src/renderer/api/__tests__/endpoint.api.test.ts new file mode 100644 index 0000000000..c7a897544b --- /dev/null +++ b/src/renderer/api/__tests__/endpoint.api.test.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { EndpointSubset } from "../endpoints"; + +describe("endpoint tests", () => { + describe("EndpointSubset", () => { + it.each([ + 4, + false, + null, + {}, + [], + "ahe", + /a/, + ])("should always initialize fields when given %j", (data: any) => { + const sub = new EndpointSubset(data); + + expect(sub.addresses).toStrictEqual([]); + expect(sub.notReadyAddresses).toStrictEqual([]); + expect(sub.ports).toStrictEqual([]); + }); + + it("toString should be addresses X ports", () => { + const sub = new EndpointSubset({ + addresses: [{ + ip: "1.1.1.1", + }, { + ip: "1.1.1.2", + }] as any, + notReadyAddresses: [], + ports: [{ + port: "81", + }, { + port: "82", + }] as any, + }); + + expect(sub.toString()).toBe("1.1.1.1:81, 1.1.1.1:82, 1.1.1.2:81, 1.1.1.2:82"); + }); + }); +}); diff --git a/src/renderer/api/__tests__/kube-api-parse.test.ts b/src/renderer/api/__tests__/kube-api-parse.test.ts index beb8df8221..18b805212d 100644 --- a/src/renderer/api/__tests__/kube-api-parse.test.ts +++ b/src/renderer/api/__tests__/kube-api-parse.test.ts @@ -129,8 +129,18 @@ const tests: KubeApiParseTestData[] = [ }], ]; +const throwtests = [ + undefined, + "", + "ajklsmh" +]; + describe("parseApi unit tests", () => { - it.each(tests)("testing %s", (url, expected) => { + it.each(tests)("testing %j", (url, expected) => { expect(parseKubeApi(url)).toStrictEqual(expected); }); + + it.each(throwtests)("testing %j should throw", (url) => { + expect(() => parseKubeApi(url)).toThrowError("invalid apiPath"); + }); }); diff --git a/src/renderer/api/__tests__/kube-api.test.ts b/src/renderer/api/__tests__/kube-api.test.ts index c7415cba73..1eb0199f89 100644 --- a/src/renderer/api/__tests__/kube-api.test.ts +++ b/src/renderer/api/__tests__/kube-api.test.ts @@ -20,6 +20,7 @@ */ import { KubeApi } from "../kube-api"; +import { KubeObject } from "../kube-object"; describe("KubeApi", () => { it("uses url from apiBase if apiBase contains the resource", async () => { @@ -29,7 +30,7 @@ describe("KubeApi", () => { body: JSON.stringify({ resources: [{ name: "ingresses" - }] as any [] + }] as any[] }) }; } else if (request.url === "/api-kube/apis/extensions/v1beta1") { @@ -38,13 +39,13 @@ describe("KubeApi", () => { body: JSON.stringify({ resources: [{ name: "ingresses" - }] as any [] + }] as any[] }) }; } else { return { body: JSON.stringify({ - resources: [] as any [] + resources: [] as any[] }) }; } @@ -53,6 +54,7 @@ describe("KubeApi", () => { const apiBase = "/apis/networking.k8s.io/v1/ingresses"; const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; const kubeApi = new KubeApi({ + objectConstructor: KubeObject, apiBase, fallbackApiBases: [fallbackApiBase], checkPreferredVersion: true, @@ -68,7 +70,7 @@ describe("KubeApi", () => { if (request.url === "/api-kube/apis/networking.k8s.io/v1") { return { body: JSON.stringify({ - resources: [] as any [] + resources: [] as any[] }) }; } else if (request.url === "/api-kube/apis/extensions/v1beta1") { @@ -76,13 +78,13 @@ describe("KubeApi", () => { body: JSON.stringify({ resources: [{ name: "ingresses" - }] as any [] + }] as any[] }) }; } else { return { body: JSON.stringify({ - resources: [] as any [] + resources: [] as any[] }) }; } @@ -91,6 +93,7 @@ describe("KubeApi", () => { const apiBase = "apis/networking.k8s.io/v1/ingresses"; const fallbackApiBase = "/apis/extensions/v1beta1/ingresses"; const kubeApi = new KubeApi({ + objectConstructor: KubeObject, apiBase, fallbackApiBases: [fallbackApiBase], checkPreferredVersion: true, diff --git a/src/renderer/api/__tests__/pods.api.test.ts b/src/renderer/api/__tests__/pods.api.test.ts new file mode 100644 index 0000000000..e7a90f8e38 --- /dev/null +++ b/src/renderer/api/__tests__/pods.api.test.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { Pod } from "../endpoints"; + +describe("Pod tests", () => { + it("getAllContainers() should never throw", () => { + const pod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar", + resourceVersion: "foobar", + uid: "foobar", + }, + }); + + expect(pod.getAllContainers()).toStrictEqual([]); + }); +}); diff --git a/src/renderer/api/api-manager.ts b/src/renderer/api/api-manager.ts index eafcf0d8ee..1e01bd4cfb 100644 --- a/src/renderer/api/api-manager.ts +++ b/src/renderer/api/api-manager.ts @@ -22,34 +22,35 @@ import type { KubeObjectStore } from "../kube-object.store"; import { action, observable, makeObservable } from "mobx"; -import { autoBind } from "../utils"; +import { autoBind, iter } from "../utils"; import { KubeApi, parseKubeApi } from "./kube-api"; +import type { KubeObject } from "./kube-object"; export class ApiManager { - private apis = observable.map(); - private stores = observable.map(); + private apis = observable.map>(); + private stores = observable.map>(); constructor() { makeObservable(this); autoBind(this); } - getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { + getApi(pathOrCallback: string | ((api: KubeApi) => boolean)) { if (typeof pathOrCallback === "string") { return this.apis.get(pathOrCallback) || this.apis.get(parseKubeApi(pathOrCallback).apiBase); } - return Array.from(this.apis.values()).find(pathOrCallback ?? (() => true)); + return iter.find(this.apis.values(), pathOrCallback ?? (() => true)); } getApiByKind(kind: string, apiVersion: string) { - return Array.from(this.apis.values()).find((api) => api.kind === kind && api.apiVersionWithGroup === apiVersion); + return iter.find(this.apis.values(), api => api.kind === kind && api.apiVersionWithGroup === apiVersion); } - registerApi(apiBase: string, api: KubeApi) { + registerApi(apiBase: string, api: KubeApi) { if (!this.apis.has(apiBase)) { this.stores.forEach((store) => { - if(store.api === api) { + if (store.api === api) { this.stores.set(apiBase, store); } }); @@ -58,13 +59,19 @@ export class ApiManager { } } - protected resolveApi(api: string | KubeApi): KubeApi { - if (typeof api === "string") return this.getApi(api); + protected resolveApi(api?: string | KubeApi): KubeApi | undefined { + if (!api) { + return undefined; + } + + if (typeof api === "string") { + return this.getApi(api) as KubeApi; + } return api; } - unregisterApi(api: string | KubeApi) { + unregisterApi(api: string | KubeApi) { if (typeof api === "string") this.apis.delete(api); else { const apis = Array.from(this.apis.entries()); @@ -75,13 +82,13 @@ export class ApiManager { } @action - registerStore(store: KubeObjectStore, apis: KubeApi[] = [store.api]) { + registerStore(store: KubeObjectStore, apis: KubeApi[] = [store.api]) { apis.forEach(api => { this.stores.set(api.apiBase, store); }); } - getStore(api: string | KubeApi): S { + getStore>(api: string | KubeApi): S | undefined { return this.stores.get(this.resolveApi(api)?.apiBase) as S; } } diff --git a/src/renderer/api/endpoints/cluster.api.ts b/src/renderer/api/endpoints/cluster.api.ts index 75f15b5a5b..f0879fa501 100644 --- a/src/renderer/api/endpoints/cluster.api.ts +++ b/src/renderer/api/endpoints/cluster.api.ts @@ -26,30 +26,30 @@ import { KubeApi } from "../kube-api"; export class ClusterApi extends KubeApi { static kind = "Cluster"; static namespaced = true; +} - async getMetrics(nodeNames: string[], params?: IMetricsReqParams): Promise { - const nodes = nodeNames.join("|"); - const opts = { category: "cluster", nodes }; +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); - } + 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 { diff --git a/src/renderer/api/endpoints/daemon-set.api.ts b/src/renderer/api/endpoints/daemon-set.api.ts index 43419b5ac1..cb584806d4 100644 --- a/src/renderer/api/endpoints/daemon-set.api.ts +++ b/src/renderer/api/endpoints/daemon-set.api.ts @@ -20,11 +20,12 @@ */ import get from "lodash/get"; -import type { IPodContainer } from "./pods.api"; import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; import { autoBind } from "../../utils"; import { KubeApi } from "../kube-api"; +import { metricsApi } from "./metrics.api"; import type { KubeJsonApiData } from "../kube-json-api"; +import type { IPodContainer, IPodMetrics } from "./pods.api"; export class DaemonSet extends WorkloadKubeObject { static kind = "DaemonSet"; @@ -97,6 +98,24 @@ export class DaemonSet extends WorkloadKubeObject { } } -export const daemonSetApi = new KubeApi({ +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, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); +} + +export const daemonSetApi = new DaemonSetApi({ objectConstructor: DaemonSet, }); diff --git a/src/renderer/api/endpoints/deployment.api.ts b/src/renderer/api/endpoints/deployment.api.ts index ef5b1c9ca7..0d5bef48dd 100644 --- a/src/renderer/api/endpoints/deployment.api.ts +++ b/src/renderer/api/endpoints/deployment.api.ts @@ -24,6 +24,8 @@ import moment from "moment"; import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; import { autoBind } from "../../utils"; import { KubeApi } from "../kube-api"; +import { metricsApi } from "./metrics.api"; +import type { IPodMetrics } from "./pods.api"; import type { KubeJsonApiData } from "../kube-json-api"; export class DeploymentApi extends KubeApi { @@ -68,6 +70,21 @@ 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, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); +} + interface IContainerProbe { httpGet?: { path?: string; diff --git a/src/renderer/api/endpoints/endpoint.api.ts b/src/renderer/api/endpoints/endpoint.api.ts index 9aa1701c1d..14bc399062 100644 --- a/src/renderer/api/endpoints/endpoint.api.ts +++ b/src/renderer/api/endpoints/endpoint.api.ts @@ -23,6 +23,7 @@ import { autoBind } from "../../utils"; import { KubeObject } from "../kube-object"; import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; +import { get } from "lodash"; export interface IEndpointPort { name?: string; @@ -63,6 +64,10 @@ export class EndpointAddress implements IEndpointAddress { resourceVersion: string; }; + static create(data: IEndpointAddress): EndpointAddress { + return new EndpointAddress(data); + } + constructor(data: IEndpointAddress) { Object.assign(this, data); } @@ -90,35 +95,27 @@ export class EndpointSubset implements IEndpointSubset { ports: IEndpointPort[]; constructor(data: IEndpointSubset) { - Object.assign(this, data); + this.addresses = get(data, "addresses", []); + this.notReadyAddresses = get(data, "notReadyAddresses", []); + this.ports = get(data, "ports", []); } getAddresses(): EndpointAddress[] { - const addresses = this.addresses || []; - - return addresses.map(a => new EndpointAddress(a)); + return this.addresses.map(EndpointAddress.create); } getNotReadyAddresses(): EndpointAddress[] { - const notReadyAddresses = this.notReadyAddresses || []; - - return notReadyAddresses.map(a => new EndpointAddress(a)); + return this.notReadyAddresses.map(EndpointAddress.create); } toString(): string { - if(!this.addresses) { - return ""; - } - - return this.addresses.map(address => { - if (!this.ports) { - return address.ip; - } - - return this.ports.map(port => { - return `${address.ip}:${port.port}`; - }).join(", "); - }).join(", "); + return this.addresses + .map(address => ( + this.ports + .map(port => `${address.ip}:${port.port}`) + .join(", ") + )) + .join(", "); } } diff --git a/src/renderer/api/endpoints/ingress.api.ts b/src/renderer/api/endpoints/ingress.api.ts index a65af0e326..31745f30c4 100644 --- a/src/renderer/api/endpoints/ingress.api.ts +++ b/src/renderer/api/endpoints/ingress.api.ts @@ -26,18 +26,19 @@ import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; export class IngressApi extends KubeApi { - getMetrics(ingress: string, namespace: string): Promise { - const opts = { category: "ingress", ingress, namespace }; +} - return metricsApi.getMetrics({ - bytesSentSuccess: opts, - bytesSentFailure: opts, - requestDurationSeconds: opts, - responseDurationSeconds: opts - }, { - namespace, - }); - } +export function getMetricsForIngress(ingress: string, namespace: string): Promise { + const opts = { category: "ingress", ingress }; + + return metricsApi.getMetrics({ + bytesSentSuccess: opts, + bytesSentFailure: opts, + requestDurationSeconds: opts, + responseDurationSeconds: opts + }, { + namespace, + }); } export interface IIngressMetrics { diff --git a/src/renderer/api/endpoints/job.api.ts b/src/renderer/api/endpoints/job.api.ts index f9e3188021..823a04bef9 100644 --- a/src/renderer/api/endpoints/job.api.ts +++ b/src/renderer/api/endpoints/job.api.ts @@ -22,10 +22,11 @@ import get from "lodash/get"; import { autoBind } from "../../utils"; import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; -import type { IPodContainer } from "./pods.api"; import { KubeApi } from "../kube-api"; +import { metricsApi } from "./metrics.api"; import type { JsonApiParams } from "../json-api"; import type { KubeJsonApiData } from "../kube-json-api"; +import type { IPodContainer, IPodMetrics } from "./pods.api"; export class Job extends WorkloadKubeObject { static kind = "Job"; @@ -129,6 +130,24 @@ export class Job extends WorkloadKubeObject { } } -export const jobApi = new KubeApi({ +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, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); +} + +export const jobApi = new JobApi({ objectConstructor: Job, }); diff --git a/src/renderer/api/endpoints/metrics.api.ts b/src/renderer/api/endpoints/metrics.api.ts index 3f5b6e66e7..b0e3c0bf9d 100644 --- a/src/renderer/api/endpoints/metrics.api.ts +++ b/src/renderer/api/endpoints/metrics.api.ts @@ -60,6 +60,15 @@ export interface IMetricsReqParams { namespace?: string; // rbac-proxy validation param } +export interface IResourceMetrics { + [metric: string]: T; + cpuUsage: T; + memoryUsage: T; + fsUsage: T; + networkReceive: T; + networkTransmit: T; +} + export const metricsApi = { async getMetrics(query: T, reqParams: IMetricsReqParams = {}): Promise { const { range = 3600, step = 60, namespace } = reqParams; diff --git a/src/renderer/api/endpoints/namespaces.api.ts b/src/renderer/api/endpoints/namespaces.api.ts index 63dd6a2ab0..208edb023f 100644 --- a/src/renderer/api/endpoints/namespaces.api.ts +++ b/src/renderer/api/endpoints/namespaces.api.ts @@ -22,6 +22,8 @@ import { KubeApi } from "../kube-api"; import { KubeObject } from "../kube-object"; import { autoBind } from "../../utils"; +import { metricsApi } from "./metrics.api"; +import type { IPodMetrics } from "./pods.api"; import type { KubeJsonApiData } from "../kube-json-api"; export enum NamespaceStatus { @@ -50,6 +52,23 @@ export class Namespace extends KubeObject { } } -export const namespacesApi = new KubeApi({ +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, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); +} + +export const namespacesApi = new NamespaceApi({ objectConstructor: Namespace, }); diff --git a/src/renderer/api/endpoints/nodes.api.ts b/src/renderer/api/endpoints/nodes.api.ts index 17a7571209..21f7776c58 100644 --- a/src/renderer/api/endpoints/nodes.api.ts +++ b/src/renderer/api/endpoints/nodes.api.ts @@ -26,20 +26,21 @@ import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; export class NodesApi extends KubeApi { - getMetrics(): 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 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 INodeMetrics { @@ -156,9 +157,13 @@ export class Node extends KubeObject { } getRoleLabels() { - const roleLabels = Object.keys(this.metadata.labels).filter(key => - key.includes("node-role.kubernetes.io") - ).map(key => key.match(/([^/]+$)/)[0]); // all after last slash + if (!this.metadata?.labels || typeof this.metadata.labels !== "object") { + return ""; + } + + const roleLabels = Object.keys(this.metadata.labels) + .filter(key => key.includes("node-role.kubernetes.io")) + .map(key => key.match(/([^/]+$)/)[0]); // all after last slash if (this.metadata.labels["kubernetes.io/role"] != undefined) { roleLabels.push(this.metadata.labels["kubernetes.io/role"]); diff --git a/src/renderer/api/endpoints/persistent-volume-claims.api.ts b/src/renderer/api/endpoints/persistent-volume-claims.api.ts index e8b50683c9..356122569a 100644 --- a/src/renderer/api/endpoints/persistent-volume-claims.api.ts +++ b/src/renderer/api/endpoints/persistent-volume-claims.api.ts @@ -27,14 +27,15 @@ import { KubeApi } from "../kube-api"; import type { KubeJsonApiData } from "../kube-json-api"; export class PersistentVolumeClaimsApi extends KubeApi { - getMetrics(pvcName: string, namespace: string): Promise { - return metricsApi.getMetrics({ - diskUsage: { category: "pvc", pvc: pvcName, namespace }, - diskCapacity: { category: "pvc", pvc: pvcName, namespace } - }, { - namespace - }); - } +} + +export function getMetricsForPvc(pvc: PersistentVolumeClaim): Promise { + return metricsApi.getMetrics({ + diskUsage: { category: "pvc", pvc: pvc.getName() }, + diskCapacity: { category: "pvc", pvc: pvc.getName() } + }, { + namespace: pvc.getNs() + }); } export interface IPvcMetrics { diff --git a/src/renderer/api/endpoints/pods.api.ts b/src/renderer/api/endpoints/pods.api.ts index 8ac8024227..e4d33c75bc 100644 --- a/src/renderer/api/endpoints/pods.api.ts +++ b/src/renderer/api/endpoints/pods.api.ts @@ -31,38 +31,38 @@ export class PodsApi extends KubeApi { return this.request.get(path, { query }); } +} - getMetrics(pods: Pod[], namespace: string, selector = "pod, namespace"): Promise { - const podSelector = pods.map(pod => pod.getName()).join("|"); - const opts = { category: "pods", pods: podSelector, namespace, selector }; +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, - networkReceive: opts, - networkTransmit: opts, - }, { - namespace, - }); - } + return metricsApi.getMetrics({ + cpuUsage: opts, + cpuRequests: opts, + cpuLimits: opts, + memoryUsage: opts, + memoryRequests: opts, + memoryLimits: opts, + fsUsage: opts, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); } export interface IPodMetrics { [metric: string]: T; cpuUsage: T; - cpuRequests: T; - cpuLimits: T; memoryUsage: T; - memoryRequests: T; - memoryLimits: T; fsUsage: T; networkReceive: T; networkTransmit: T; + cpuRequests?: T; + cpuLimits?: T; + memoryRequests?: T; + memoryLimits?: T; } // Reference: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#read-log-pod-v1-core @@ -213,7 +213,7 @@ export class Pod extends WorkloadKubeObject { autoBind(this); } - declare spec: { + declare spec?: { volumes?: { name: string; persistentVolumeClaim: { @@ -291,11 +291,11 @@ export class Pod extends WorkloadKubeObject { }; getInitContainers() { - return this.spec.initContainers || []; + return this.spec?.initContainers || []; } getContainers() { - return this.spec.containers || []; + return this.spec?.containers || []; } getAllContainers() { diff --git a/src/renderer/api/endpoints/replica-set.api.ts b/src/renderer/api/endpoints/replica-set.api.ts index e2311c6b87..4632e1bfad 100644 --- a/src/renderer/api/endpoints/replica-set.api.ts +++ b/src/renderer/api/endpoints/replica-set.api.ts @@ -22,8 +22,9 @@ import get from "lodash/get"; import { autoBind } from "../../utils"; import { WorkloadKubeObject } from "../workload-kube-object"; -import type { IPodContainer, Pod } from "./pods.api"; import { KubeApi } from "../kube-api"; +import { metricsApi } from "./metrics.api"; +import type { IPodContainer, IPodMetrics, Pod } from "./pods.api"; import type { KubeJsonApiData } from "../kube-json-api"; export class ReplicaSetApi extends KubeApi { @@ -49,6 +50,21 @@ 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, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); +} + export class ReplicaSet extends WorkloadKubeObject { static kind = "ReplicaSet"; static namespaced = true; diff --git a/src/renderer/api/endpoints/resource-applier.api.ts b/src/renderer/api/endpoints/resource-applier.api.ts index 1756977c95..b10c10f7db 100644 --- a/src/renderer/api/endpoints/resource-applier.api.ts +++ b/src/renderer/api/endpoints/resource-applier.api.ts @@ -30,7 +30,7 @@ export const resourceApplierApi = { "kubectl.kubernetes.io/last-applied-configuration" ], - async update(resource: object | string): Promise { + async update(resource: object | string): Promise { if (typeof resource === "string") { resource = jsYaml.safeLoad(resource); } @@ -48,7 +48,7 @@ export const resourceApplierApi = { } }); - return items.length === 1 ? items[0] : items; + return items[0] as K ?? null; }); } }; diff --git a/src/renderer/api/endpoints/stateful-set.api.ts b/src/renderer/api/endpoints/stateful-set.api.ts index b91e06c25c..21f4c6e02e 100644 --- a/src/renderer/api/endpoints/stateful-set.api.ts +++ b/src/renderer/api/endpoints/stateful-set.api.ts @@ -20,10 +20,11 @@ */ import get from "lodash/get"; -import type { IPodContainer } from "./pods.api"; import { IAffinity, WorkloadKubeObject } from "../workload-kube-object"; import { autoBind } from "../../utils"; import { KubeApi } from "../kube-api"; +import { metricsApi } from "./metrics.api"; +import type { IPodContainer, IPodMetrics } from "./pods.api"; import type { KubeJsonApiData } from "../kube-json-api"; export class StatefulSetApi extends KubeApi { @@ -49,6 +50,21 @@ 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, + networkReceive: opts, + networkTransmit: opts, + }, { + namespace, + }); +} + export class StatefulSet extends WorkloadKubeObject { static kind = "StatefulSet"; static namespaced = true; diff --git a/src/renderer/api/kube-api-parse.ts b/src/renderer/api/kube-api-parse.ts index b02067fb63..689a6b9858 100644 --- a/src/renderer/api/kube-api-parse.ts +++ b/src/renderer/api/kube-api-parse.ts @@ -24,6 +24,9 @@ import type { KubeObject } from "./kube-object"; import { splitArray } from "../../common/utils"; import { apiManager } from "./api-manager"; +import { isDebugging } from "../../common/vars"; +import logger from "../../main/logger"; +import { inspect } from "util"; export interface IKubeObjectRef { kind: string; @@ -47,8 +50,26 @@ export interface IKubeApiParsed extends IKubeApiLinkRef { } export function parseKubeApi(path: string): IKubeApiParsed { - path = new URL(path, location.origin).pathname; - const [, prefix, ...parts] = path.split("/"); + if (!isDebugging) { + return _parseKubeApi(path); + } + + try { + const res = _parseKubeApi(path); + + logger.debug(`parseKubeApi(${inspect(path, false, null, false)}) -> ${inspect(res, false, null, false)}`); + + return res; + } catch (error) { + logger.debug(`parseKubeApi(${inspect(path, false, null, false)}) threw: ${error}`); + + throw error; + } +} + +function _parseKubeApi(path: string): IKubeApiParsed { + const apiPath = new URL(path, location.origin).pathname; + const [, prefix, ...parts] = apiPath.split("/"); const apiPrefix = `/${prefix}`; const [left, right, namespaced] = splitArray(parts, "namespaces"); let apiGroup, apiVersion, namespace, resource, name; @@ -70,12 +91,14 @@ export function parseKubeApi(path: string): IKubeApiParsed { apiGroup = left.join("/"); } else { switch (left.length) { + case 0: + throw new Error(`invalid apiPath: ${apiPath}`); case 4: [apiGroup, apiVersion, resource, name] = left; break; case 2: resource = left.pop(); - // fallthrough + // fallthrough case 1: apiVersion = left.pop(); apiGroup = ""; @@ -91,7 +114,7 @@ export function parseKubeApi(path: string): IKubeApiParsed { * There is no well defined selection from an array of items that were * separated by '/' * - * Solution is to create a huristic. Namely: + * Solution is to create a heuristic. Namely: * 1. if '.' in left[0] then apiGroup <- left[0] * 2. if left[1] matches /^v[0-9]/ then apiGroup, apiVersion <- left[0], left[1] * 3. otherwise assume apiVersion <- left[0] @@ -113,7 +136,7 @@ export function parseKubeApi(path: string): IKubeApiParsed { const apiBase = [apiPrefix, apiGroup, apiVersion, resource].filter(v => v).join("/"); if (!apiBase) { - throw new Error(`invalid apiPath: ${path}`); + throw new Error(`invalid apiPath: ${apiPath}`); } return { diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index 21e370a207..20c4672727 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -28,7 +28,7 @@ import logger from "../../main/logger"; import { apiManager } from "./api-manager"; import { apiKube } from "./index"; import { createKubeApiURL, parseKubeApi } from "./kube-api-parse"; -import { IKubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object"; +import { KubeObjectConstructor, KubeObject, KubeStatus } from "./kube-object"; import byline from "byline"; import type { IKubeWatchEvent } from "./kube-watch-api"; import { ReadableWebToNodeStream } from "../utils/readableStream"; @@ -49,7 +49,7 @@ export interface IKubeApiOptions { */ fallbackApiBases?: string[]; - objectConstructor?: IKubeObjectConstructor; + objectConstructor: KubeObjectConstructor; request?: KubeJsonApi; isNamespaced?: boolean; kind?: string; @@ -99,7 +99,7 @@ export interface IKubeApiCluster { } } -export function forCluster(cluster: IKubeApiCluster, kubeClass: IKubeObjectConstructor): KubeApi { +export function forCluster(cluster: IKubeApiCluster, kubeClass: KubeObjectConstructor): KubeApi { const request = new KubeJsonApi({ apiBase: apiKubePrefix, debug: isDevelopment, @@ -115,7 +115,7 @@ export function forCluster(cluster: IKubeApiCluster, kubeC }); } -export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) { +export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) { if (!object.metadata.selfLink) { object.metadata.selfLink = createKubeApiURL({ apiPrefix: api.apiPrefix, @@ -127,7 +127,7 @@ export function ensureObjectSelfLink(api: KubeApi, object: KubeJsonApiData) { } } -export type KubeApiWatchCallback = (data: IKubeWatchEvent, error: any) => void; +export type KubeApiWatchCallback = (data: IKubeWatchEvent, error: any) => void; export type KubeApiWatchOptions = { namespace: string; @@ -135,7 +135,7 @@ export type KubeApiWatchOptions = { abortController?: AbortController }; -export class KubeApi { +export class KubeApi { readonly kind: string; readonly apiBase: string; readonly apiPrefix: string; @@ -145,14 +145,14 @@ export class KubeApi { readonly apiResource: string; readonly isNamespaced: boolean; - public objectConstructor: IKubeObjectConstructor; + public objectConstructor: KubeObjectConstructor; protected request: KubeJsonApi; protected resourceVersions = new Map(); protected watchDisposer: () => void; constructor(protected options: IKubeApiOptions) { const { - objectConstructor = KubeObject as IKubeObjectConstructor, + objectConstructor, request = apiKube, kind = options.objectConstructor?.kind, isNamespaced = options.objectConstructor?.namespaced @@ -456,14 +456,14 @@ export class KubeApi { clearTimeout(timedRetry); timedRetry = setTimeout(() => { // we did not get any kubernetes errors so let's retry - this.watch({...opts, namespace, callback}); + this.watch({ ...opts, namespace, callback }); }, 1000); }); }); byline(nodeStream).on("data", (line) => { try { - const event: IKubeWatchEvent = JSON.parse(line); + const event: IKubeWatchEvent = JSON.parse(line); if (event.type === "ERROR" && event.object.kind === "Status") { errorReceived = true; @@ -474,7 +474,7 @@ export class KubeApi { this.modifyWatchEvent(event); callback(event, null); } catch (ignore) { - // ignore parse errors + // ignore parse errors } }); }) @@ -487,7 +487,7 @@ export class KubeApi { return abort; } - protected modifyWatchEvent(event: IKubeWatchEvent) { + protected modifyWatchEvent(event: IKubeWatchEvent) { switch (event.type) { case "ADDED": diff --git a/src/renderer/api/kube-json-api.ts b/src/renderer/api/kube-json-api.ts index 362494a659..ebb01b696c 100644 --- a/src/renderer/api/kube-json-api.ts +++ b/src/renderer/api/kube-json-api.ts @@ -48,6 +48,7 @@ export interface KubeJsonApiMetadata { annotations?: { [annotation: string]: string; }; + [key: string]: any; } export interface KubeJsonApiData extends JsonApiData { diff --git a/src/renderer/api/kube-object.ts b/src/renderer/api/kube-object.ts index df249f64e6..2548cac3aa 100644 --- a/src/renderer/api/kube-object.ts +++ b/src/renderer/api/kube-object.ts @@ -31,13 +31,13 @@ import { resourceApplierApi } from "./endpoints/resource-applier.api"; import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing"; import _ from "lodash"; -export type IKubeObjectConstructor = (new (data: KubeJsonApiData | any) => T) & { +export type KubeObjectConstructor = (new (data: KubeJsonApiData | any) => K) & { kind?: string; namespaced?: boolean; apiBase?: string; }; -export interface IKubeObjectMetadata { +export interface KubeObjectMetadata { uid: string; name: string; namespace?: string; @@ -63,7 +63,7 @@ export interface IKubeObjectMetadata { }[]; } -export interface IKubeStatus { +export interface KubeStatusData { kind: string; apiVersion: string; code: number; @@ -78,7 +78,7 @@ export class KubeStatus { public readonly message: string; public readonly reason: string; - constructor(data: IKubeStatus) { + constructor(data: KubeStatusData) { this.apiVersion = data.apiVersion; this.code = data.code; this.message = data.message || ""; @@ -86,9 +86,9 @@ export class KubeStatus { } } -export type IKubeMetaField = keyof IKubeObjectMetadata; +export type KubeMetaField = keyof KubeObjectMetadata; -export class KubeObject implements ItemObject { +export class KubeObject implements ItemObject { static readonly kind: string; static readonly namespaced: boolean; @@ -254,12 +254,10 @@ export class KubeObject ({ - ...ownerRef, - namespace: this.getNs(), - })); + return refs.map(ownerRef => ({ ...ownerRef, namespace })); } getSearchFields() { @@ -279,14 +277,14 @@ export class KubeObject(data: Partial): Promise { + async update(data: Partial): Promise { for (const field of KubeObject.nonEditableFields) { if (!_.isEqual(_.get(this, field), _.get(data, field))) { throw new Error(`Failed to update Kube Object: ${field} has been modified`); } } - return resourceApplierApi.update({ + return resourceApplierApi.update({ ...this.toPlainObject(), ...data, }); diff --git a/src/renderer/api/kube-watch-api.ts b/src/renderer/api/kube-watch-api.ts index 1a0f7e0b17..4d316b5caa 100644 --- a/src/renderer/api/kube-watch-api.ts +++ b/src/renderer/api/kube-watch-api.ts @@ -31,8 +31,9 @@ import { autoBind, Disposer, noop } from "../utils"; import type { KubeApi } from "./kube-api"; import type { KubeJsonApiData } from "./kube-json-api"; import { isDebugging, isProduction } from "../../common/vars"; +import type { KubeObject } from "./kube-object"; -export interface IKubeWatchEvent { +export interface IKubeWatchEvent { type: "ADDED" | "MODIFIED" | "DELETED" | "ERROR"; object?: T; } @@ -58,11 +59,11 @@ export class KubeWatchApi { autoBind(this); } - isAllowedApi(api: KubeApi): boolean { + isAllowedApi(api: KubeApi): boolean { return Boolean(this.context?.cluster.isAllowedResource(api.kind)); } - preloadStores(stores: KubeObjectStore[], opts: { namespaces?: string[], loadOnce?: boolean } = {}) { + preloadStores(stores: KubeObjectStore[], opts: { namespaces?: string[], loadOnce?: boolean } = {}) { const limitRequests = plimit(1); // load stores one by one to allow quick skipping when fast clicking btw pages const preloading: Promise[] = []; @@ -80,7 +81,7 @@ export class KubeWatchApi { }; } - subscribeStores(stores: KubeObjectStore[], opts: IKubeWatchSubscribeStoreOptions = {}): Disposer { + subscribeStores(stores: KubeObjectStore[], opts: IKubeWatchSubscribeStoreOptions = {}): Disposer { const { preload = true, waitUntilLoaded = true, loadOnce = false, } = opts; const subscribingNamespaces = opts.namespaces ?? this.context?.allNamespaces ?? []; const unsubscribeList: Function[] = []; diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 3b2c8a1cd9..347dfac224 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -19,11 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { stringify } from "querystring"; import { boundMethod, base64, EventEmitter } from "../utils"; import { WebSocketApi } from "./websocket-api"; import isEqual from "lodash/isEqual"; import { isDevelopment } from "../../common/vars"; +import url from "url"; +import { makeObservable, observable } from "mobx"; +import type { ParsedUrlQueryInput } from "querystring"; export enum TerminalChannels { STDIN = 0, @@ -55,7 +57,9 @@ export class TerminalApi extends WebSocketApi { protected size: { Width: number; Height: number }; public onReady = new EventEmitter<[]>(); - public isReady = false; + @observable public isReady = false; + @observable public shellRunCommandsFinished = false; + public readonly url: string; constructor(protected options: TerminalApiQuery) { super({ @@ -63,34 +67,33 @@ export class TerminalApi extends WebSocketApi { flushOnOpen: false, pingIntervalSeconds: 30, }); - } + makeObservable(this); - async getUrl() { - let { port } = location; - const { hostname, protocol } = location; - const { id, node } = this.options; - const wss = `ws${protocol === "https:" ? "s" : ""}://`; - const query: TerminalApiQuery = { id }; + const { hostname, protocol, port } = location; + const query: ParsedUrlQueryInput = { + id: options.id, + }; - if (port) { - port = `:${port}`; + if (options.node) { + query.node = options.node; + query.type = options.type || "node"; } - if (node) { - query.node = node; - query.type = "node"; - } - - return `${wss}${hostname}${port}/api?${stringify(query)}`; + this.url = url.format({ + protocol: protocol.includes("https") ? "wss" : "ws", + hostname, + port, + pathname: "/api", + query, + slashes: true, + }); } - async connect() { - const apiUrl = await this.getUrl(); - + connect() { this.emitStatus("Connecting ..."); this.onData.addListener(this._onReady, { prepend: true }); - - return super.connect(apiUrl); + this.onData.addListener(this._onShellRunCommandsFinished); + super.connect(this.url); } destroy() { @@ -106,6 +109,24 @@ export class TerminalApi extends WebSocketApi { this.onReady.removeAllListeners(); } + _onShellRunCommandsFinished = (data: string) => { + if (!data) { + return; + } + + /** + * This is a heuistic for ditermining when a shell has finished executing + * its own rc file (or RunCommands file) such as `.bashrc` or `.zshrc`. + * + * This heuistic assumes that the prompt line of a terminal is a single line + * and ends with a whitespace character. + */ + if (data.match(/\r?\n/) === null && data.match(/\s$/)) { + this.shellRunCommandsFinished = true; + this.onData.removeListener(this._onShellRunCommandsFinished); + } + }; + @boundMethod protected _onReady(data: string) { if (!data) return true; diff --git a/src/renderer/api/workload-kube-object.ts b/src/renderer/api/workload-kube-object.ts index 34fa5d5109..8d1f2a6a36 100644 --- a/src/renderer/api/workload-kube-object.ts +++ b/src/renderer/api/workload-kube-object.ts @@ -26,6 +26,7 @@ export interface IToleration { key?: string; operator?: string; effect?: string; + value?: string; tolerationSeconds?: number; } diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 7768a9e0eb..87c30dba59 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -47,11 +47,13 @@ import { WeblinkStore } from "../common/weblink-store"; import { ExtensionsStore } from "../extensions/extensions-store"; import { FilesystemProvisionerStore } from "../main/extension-filesystem"; import { ThemeStore } from "./theme.store"; +import { SentryInit } from "../common/sentry"; +import { TerminalStore } from "./components/dock/terminal.store"; configurePackages(); /** - * If this is a development buid, wait a second to attach + * If this is a development build, wait a second to attach * Chrome Debugger to renderer process * https://stackoverflow.com/questions/52844870/debugging-electron-renderer-process-with-vscode */ @@ -78,6 +80,7 @@ export async function bootstrap(App: AppComponent) { initializers.intiKubeObjectDetailRegistry(); initializers.initWelcomeMenuRegistry(); initializers.initWorkloadsOverviewDetailRegistry(); + initializers.initCatalogEntityDetailRegistry(); initializers.initCatalog(); initializers.initIpcRendererListeners(); @@ -85,18 +88,31 @@ export async function bootstrap(App: AppComponent) { ExtensionDiscovery.createInstance().init(); UserStore.createInstance(); - await ClusterStore.createInstance().loadInitialOnRenderer(); + + SentryInit(); + + // ClusterStore depends on: UserStore + const cs = ClusterStore.createInstance(); + + await cs.loadInitialOnRenderer(); + + // HotbarStore depends on: ClusterStore HotbarStore.createInstance(); ExtensionsStore.createInstance(); FilesystemProvisionerStore.createInstance(); + + // ThemeStore depends on: UserStore ThemeStore.createInstance(); + + // TerminalStore depends on: ThemeStore + TerminalStore.createInstance(); WeblinkStore.createInstance(); ExtensionInstallationStateStore.bindIpcListeners(); HelmRepoManager.createInstance(); // initialize the manager // Register additional store listeners - ClusterStore.getInstance().registerIpcListener(); + cs.registerIpcListener(); // init app's dependencies if any if (App.init) { diff --git a/src/renderer/components/+apps-helm-charts/helm-chart.store.ts b/src/renderer/components/+apps-helm-charts/helm-chart.store.ts index c07dfaa886..71a5072068 100644 --- a/src/renderer/components/+apps-helm-charts/helm-chart.store.ts +++ b/src/renderer/components/+apps-helm-charts/helm-chart.store.ts @@ -21,7 +21,7 @@ import semver from "semver"; import { observable, makeObservable } from "mobx"; -import { autoBind } from "../../utils"; +import { autoBind, sortCompareChartVersions } from "../../utils"; import { getChartDetails, HelmChart, listCharts } from "../../api/endpoints/helm-charts.api"; import { ItemStore } from "../../item.store"; import flatten from "lodash/flatten"; @@ -60,12 +60,10 @@ export class HelmChartStore extends ItemStore { } protected sortVersions = (versions: IChartVersion[]) => { - return versions.sort((first, second) => { - const firstVersion = semver.coerce(first.version || 0); - const secondVersion = semver.coerce(second.version || 0); - - return semver.compare(secondVersion, firstVersion); - }); + return versions + .map(chartVersion => ({ ...chartVersion, __version: semver.coerce(chartVersion.version, { includePrerelease: true, loose: true }), })) + .sort(sortCompareChartVersions) + .map(({ __version, ...chartVersion }) => chartVersion); }; async getVersions(chartName: string, force?: boolean): Promise { diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.tsx b/src/renderer/components/+apps-helm-charts/helm-charts.tsx index 455c95e7e8..1fc1dbf7b0 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-charts.tsx @@ -83,14 +83,14 @@ export class HelmCharts extends Component { store={helmChartStore} isSelectable={false} sortingCallbacks={{ - [columnId.name]: (chart: HelmChart) => chart.getName(), - [columnId.repo]: (chart: HelmChart) => chart.getRepository(), + [columnId.name]: chart => chart.getName(), + [columnId.repo]: chart => chart.getRepository(), }} searchFilters={[ - (chart: HelmChart) => chart.getName(), - (chart: HelmChart) => chart.getVersion(), - (chart: HelmChart) => chart.getAppVersion(), - (chart: HelmChart) => chart.getKeywords(), + chart => chart.getName(), + chart => chart.getVersion(), + chart => chart.getAppVersion(), + chart => chart.getKeywords(), ]} customizeHeader={({ searchProps }) => ({ searchProps: { @@ -106,7 +106,7 @@ export class HelmCharts extends Component { { title: "App Version", className: "app-version", id: columnId.appVersion }, { title: "Repository", className: "repository", sortBy: columnId.repo, id: columnId.repo }, ]} - renderTableContents={(chart: HelmChart) => [ + renderTableContents={chart => [

{ {this.renderValues()} diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index ae69d38bc1..9d704566e7 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -55,12 +55,16 @@ export class ReleaseStore extends ItemStore { this.loadFromContextNamespaces(); } this.releaseSecrets.replace(newSecrets); + }, { + fireImmediately: true, }); } - watchSelecteNamespaces(): (() => void) { + watchSelectedNamespaces(): (() => void) { return reaction(() => namespaceStore.context.contextNamespaces, namespaces => { this.loadAll(namespaces); + }, { + fireImmediately: true, }); } @@ -91,7 +95,7 @@ export class ReleaseStore extends ItemStore { this.failedLoading = false; } catch (error) { this.failedLoading = true; - console.error("Loading Helm Chart releases has failed", error); + console.warn("Loading Helm Chart releases has failed", error); if (error.error) { Notifications.error(error.error); diff --git a/src/renderer/components/+apps-releases/releases.tsx b/src/renderer/components/+apps-releases/releases.tsx index 9a746b8909..cf464710c5 100644 --- a/src/renderer/components/+apps-releases/releases.tsx +++ b/src/renderer/components/+apps-releases/releases.tsx @@ -36,6 +36,7 @@ import { secretsStore } from "../+config-secrets/secrets.store"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import type { ReleaseRouteParams } from "../../../common/routes"; import { releaseURL } from "../../../common/routes"; +import { namespaceStore } from "../+namespaces/namespace.store"; enum columnId { name = "name", @@ -54,9 +55,15 @@ interface Props extends RouteComponentProps { @observer export class HelmReleases extends Component { componentDidMount() { + const { match: { params: { namespace } } } = this.props; + + if (namespace) { + namespaceStore.selectNamespaces(namespace); + } + disposeOnUnmount(this, [ releaseStore.watchAssociatedSecrets(), - releaseStore.watchSelecteNamespaces(), + releaseStore.watchSelectedNamespaces(), ]); } @@ -104,19 +111,19 @@ export class HelmReleases extends Component { store={releaseStore} dependentStores={[secretsStore]} sortingCallbacks={{ - [columnId.name]: (release: HelmRelease) => release.getName(), - [columnId.namespace]: (release: HelmRelease) => release.getNs(), - [columnId.revision]: (release: HelmRelease) => release.getRevision(), - [columnId.chart]: (release: HelmRelease) => release.getChart(), - [columnId.status]: (release: HelmRelease) => release.getStatus(), - [columnId.updated]: (release: HelmRelease) => release.getUpdated(false, false), + [columnId.name]: release => release.getName(), + [columnId.namespace]: release => release.getNs(), + [columnId.revision]: release => release.getRevision(), + [columnId.chart]: release => release.getChart(), + [columnId.status]: release => release.getStatus(), + [columnId.updated]: release => release.getUpdated(false, false), }} searchFilters={[ - (release: HelmRelease) => release.getName(), - (release: HelmRelease) => release.getNs(), - (release: HelmRelease) => release.getChart(), - (release: HelmRelease) => release.getStatus(), - (release: HelmRelease) => release.getVersion(), + release => release.getName(), + release => release.getNs(), + release => release.getChart(), + release => release.getStatus(), + release => release.getVersion(), ]} customizeHeader={({ filters, searchProps, ...headerPlaceholders }) => ({ filters: ( @@ -142,7 +149,7 @@ export class HelmReleases extends Component { { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, { title: "Updated", className: "updated", sortBy: columnId.updated, id: columnId.updated }, ]} - renderTableContents={(release: HelmRelease) => [ + renderTableContents={release => [ release.getName(), release.getNs(), release.getChart(), @@ -152,13 +159,13 @@ export class HelmReleases extends Component { { title: release.getStatus(), className: kebabCase(release.getStatus()) }, release.getUpdated(), ]} - renderItemMenu={(release: HelmRelease) => ( + renderItemMenu={release => ( )} - customizeRemoveDialog={(selectedItems: HelmRelease[]) => ({ + customizeRemoveDialog={selectedItems => ({ message: this.renderRemoveDialogMessage(selectedItems) })} detailsItem={this.selectedRelease} diff --git a/src/renderer/components/+catalog/catalog-entity.store.tsx b/src/renderer/components/+catalog/catalog-entity.store.tsx index fea9e5e6ba..b606d54c1a 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.tsx +++ b/src/renderer/components/+catalog/catalog-entity.store.tsx @@ -88,6 +88,7 @@ export class CatalogEntityItem implements ItemObject { onClick?.(event); event.stopPropagation(); }} + expandable={false} /> )); } diff --git a/src/renderer/components/+catalog/catalog-tree.module.css b/src/renderer/components/+catalog/catalog-tree.module.css index 4787fd71b8..824cea3280 100644 --- a/src/renderer/components/+catalog/catalog-tree.module.css +++ b/src/renderer/components/+catalog/catalog-tree.module.css @@ -31,33 +31,40 @@ .label { font-size: var(--font-size); background-color: transparent!important; -} -.label:hover { - background-color: transparent; + &:hover { + background-color: transparent; + } } .content { min-height: 26px; -} -.content:hover { - background-color: var(--sidebarItemHoverBackground); - border-radius: 2px; + &:hover { + background-color: var(--sidebarItemHoverBackground); + border-radius: 2px; + } + + &:active { + color: white; + background-color: var(--blue); + } } .group { margin-left: 0px; + + .iconContainer { + margin-left: 28px; + } } -.group .iconContainer { - margin-left: 28px; -} - -.selected > *:first-child { - background-color: var(--blue); - color: white; - border-radius: 2px; +.selected { + & > *:first-child { + background-color: var(--blue); + color: white; + border-radius: 2px; + } } .iconContainer { diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 76339e79a3..4256170649 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -42,6 +42,8 @@ import { CatalogEntityDetails } from "./catalog-entity-details"; import { catalogURL, CatalogViewRouteParam } from "../../../common/routes"; import { CatalogMenu } from "./catalog-menu"; import { HotbarIcon } from "../hotbar/hotbar-icon"; +import { RenderDelay } from "../render-delay/render-delay"; +import { CatalogTopbar } from "../cluster-manager/catalog-topbar"; export const previousActiveTab = createAppStorage("catalog-previous-active-tab", ""); @@ -144,7 +146,9 @@ export class Catalog extends React.Component { }; renderNavigation() { - return ; + return ( + + ); } renderItemMenu = (item: CatalogEntityItem) => { @@ -163,7 +167,7 @@ export class Catalog extends React.Component { )) } - this.addToHotbar(item) }> + this.addToHotbar(item)}> Pin to Hotbar @@ -172,110 +176,68 @@ export class Catalog extends React.Component { renderIcon(item: CatalogEntityItem) { return ( - + + + ); } - renderSingleCategoryList() { - return ( - ) => item.name, - [sortBy.source]: (item: CatalogEntityItem) => item.source, - [sortBy.status]: (item: CatalogEntityItem) => item.phase, - }} - searchFilters={[ - (entity: CatalogEntityItem) => entity.searchFields, - ]} - renderTableHeader={[ - { title: "", className: css.iconCell, id: "icon" }, - { title: "Name", className: css.nameCell, sortBy: sortBy.name, id: "name" }, - { title: "Source", className: css.sourceCell, sortBy: sortBy.source, id: "source" }, - { title: "Labels", className: css.labelsCell, id: "labels" }, - { title: "Status", className: css.statusCell, sortBy: sortBy.status, id: "status" }, - ]} - customizeTableRowProps={(item: CatalogEntityItem) => ({ - disabled: !item.enabled, - })} - renderTableContents={(item: CatalogEntityItem) => [ - this.renderIcon(item), - item.name, - item.source, - item.getLabelBadges(), - { title: item.phase, className: cssNames(css[item.phase]) } - ]} - onDetails={this.onDetails} - renderItemMenu={this.renderItemMenu} - /> - ); - } + renderList() { + const { activeCategory } = this.catalogEntityStore; + const tableId = activeCategory ? `catalog-items-${activeCategory.metadata.name.replace(" ", "")}` : "catalog-items"; - renderAllCategoriesList() { - return ( - ) => item.name, - [sortBy.kind]: (item: CatalogEntityItem) => item.kind, - [sortBy.source]: (item: CatalogEntityItem) => item.source, - [sortBy.status]: (item: CatalogEntityItem) => item.phase, - }} - searchFilters={[ - (entity: CatalogEntityItem) => entity.searchFields, - ]} - renderTableHeader={[ - { title: "", className: css.iconCell, id: "icon" }, - { title: "Name", className: css.nameCell, sortBy: sortBy.name, id: "name" }, - { title: "Kind", className: css.kindCell, sortBy: sortBy.kind, id: "kind" }, - { title: "Source", className: css.sourceCell, sortBy: sortBy.source, id: "source" }, - { title: "Labels", className: css.labelsCell, id: "labels" }, - { title: "Status", className: css.statusCell, sortBy: sortBy.status, id: "status" }, - ]} - customizeTableRowProps={(item: CatalogEntityItem) => ({ - disabled: !item.enabled, - })} - renderTableContents={(item: CatalogEntityItem) => [ - this.renderIcon(item), - item.name, - item.kind, - item.source, - item.getLabelBadges(), - { title: item.phase, className: cssNames(css[item.phase]) } - ]} - detailsItem={this.catalogEntityStore.selectedItem} - onDetails={this.onDetails} - renderItemMenu={this.renderItemMenu} - /> - ); - } - - renderCategoryList() { if (this.activeTab === undefined) { return null; } - return this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList(); + return ( + item.name, + [sortBy.source]: item => item.source, + [sortBy.status]: item => item.phase, + [sortBy.kind]: item => item.kind, + }} + searchFilters={[ + entity => entity.searchFields, + ]} + renderTableHeader={[ + { title: "", className: css.iconCell, id: "icon" }, + { title: "Name", className: css.nameCell, sortBy: sortBy.name, id: "name" }, + !activeCategory && { title: "Kind", className: css.kindCell, sortBy: sortBy.kind, id: "kind" }, + { title: "Source", className: css.sourceCell, sortBy: sortBy.source, id: "source" }, + { title: "Labels", className: css.labelsCell, id: "labels" }, + { title: "Status", className: css.statusCell, sortBy: sortBy.status, id: "status" }, + ].filter(Boolean)} + customizeTableRowProps={item => ({ + disabled: !item.enabled, + })} + renderTableContents={item => [ + this.renderIcon(item), + item.name, + !activeCategory && item.kind, + item.source, + item.getLabelBadges(), + { title: item.phase, className: cssNames(css[item.phase]) } + ].filter(Boolean)} + onDetails={this.onDetails} + renderItemMenu={this.renderItemMenu} + /> + ); } render() { @@ -284,21 +246,28 @@ export class Catalog extends React.Component { } return ( - -
- { this.renderCategoryList() } -
- { - this.catalogEntityStore.selectedItem - ? this.catalogEntityStore.selectedItemId = null} - /> - : - } -
+ <> + + +
+ { this.renderList() } +
+ { + this.catalogEntityStore.selectedItem + ? this.catalogEntityStore.selectedItemId = null} + /> + : ( + + + + ) + } +
+ ); } } diff --git a/src/renderer/components/+cluster/cluster-issues.tsx b/src/renderer/components/+cluster/cluster-issues.tsx index 90a8a1a74b..f35f8bea61 100644 --- a/src/renderer/components/+cluster/cluster-issues.tsx +++ b/src/renderer/components/+cluster/cluster-issues.tsx @@ -22,7 +22,7 @@ import "./cluster-issues.scss"; import React from "react"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { computed, makeObservable } from "mobx"; import { Icon } from "../icon"; import { SubHeader } from "../layout/sub-header"; @@ -35,6 +35,7 @@ import { Spinner } from "../spinner"; import { ThemeStore } from "../../theme.store"; import { lookupApiLink } from "../../api/kube-api"; import { kubeSelectedUrlParam, showDetails } from "../kube-object"; +import { kubeWatchApi } from "../../api/kube-watch-api"; interface Props { className?: string; @@ -65,6 +66,10 @@ export class ClusterIssues extends React.Component { constructor(props: Props) { super(props); makeObservable(this); + + disposeOnUnmount(this, [ + kubeWatchApi.subscribeStores([eventStore, nodesStore]) + ]); } @computed get warnings() { diff --git a/src/renderer/components/+cluster/cluster-overview.store.ts b/src/renderer/components/+cluster/cluster-overview.store.ts index 846f2466b4..547eb6832f 100644 --- a/src/renderer/components/+cluster/cluster-overview.store.ts +++ b/src/renderer/components/+cluster/cluster-overview.store.ts @@ -21,7 +21,7 @@ import { action, observable, reaction, when, makeObservable } from "mobx"; import { KubeObjectStore } from "../../kube-object.store"; -import { Cluster, clusterApi, IClusterMetrics } from "../../api/endpoints"; +import { Cluster, clusterApi, getMetricsByNodeNames, IClusterMetrics } from "../../api/endpoints"; import { autoBind, createStorage } from "../../utils"; import { IMetricsReqParams, normalizeMetrics } from "../../api/endpoints/metrics.api"; import { nodesStore } from "../+nodes/nodes.store"; @@ -101,7 +101,7 @@ export class ClusterOverviewStore extends KubeObjectStore implements Cl const { masterNodes, workerNodes } = nodesStore; const nodes = this.metricNodeRole === MetricNodeRole.MASTER && masterNodes.length ? masterNodes : workerNodes; - this.metrics = await clusterApi.getMetrics(nodes.map(node => node.getName()), params); + this.metrics = await getMetricsByNodeNames(nodes.map(node => node.getName()), params); this.metricsLoaded = true; } diff --git a/src/renderer/components/+config-autoscalers/hpa-details.scss b/src/renderer/components/+config-autoscalers/hpa-details.scss index ef9506976f..c1c6e930fc 100644 --- a/src/renderer/components/+config-autoscalers/hpa-details.scss +++ b/src/renderer/components/+config-autoscalers/hpa-details.scss @@ -22,7 +22,7 @@ @import "autoscaler.mixins"; .HpaDetails { - .Badge { + .status { @include hpa-status-bgc; } diff --git a/src/renderer/components/+config-autoscalers/hpa-details.tsx b/src/renderer/components/+config-autoscalers/hpa-details.tsx index 1a6db54e78..cf56b2c35c 100644 --- a/src/renderer/components/+config-autoscalers/hpa-details.tsx +++ b/src/renderer/components/+config-autoscalers/hpa-details.tsx @@ -125,7 +125,7 @@ export class HpaDetails extends React.Component { {hpa.getReplicas()} - + {hpa.getConditions().map(({ type, tooltip, isReady }) => { if (!isReady) return null; diff --git a/src/renderer/components/+config-autoscalers/hpa.scss b/src/renderer/components/+config-autoscalers/hpa.scss index 41fa3f6d08..dc79016fcf 100644 --- a/src/renderer/components/+config-autoscalers/hpa.scss +++ b/src/renderer/components/+config-autoscalers/hpa.scss @@ -38,10 +38,7 @@ &.status { flex: 1.5; @include table-cell-labels-offsets; - - .Badge { - @include hpa-status-bgc; - } + @include hpa-status-bgc; } &.age { diff --git a/src/renderer/components/+config-autoscalers/hpa.tsx b/src/renderer/components/+config-autoscalers/hpa.tsx index eca60e8c63..35bf3d6375 100644 --- a/src/renderer/components/+config-autoscalers/hpa.tsx +++ b/src/renderer/components/+config-autoscalers/hpa.tsx @@ -64,15 +64,15 @@ export class HorizontalPodAutoscalers extends React.Component { tableId="configuration_hpa" className="HorizontalPodAutoscalers" store={hpaStore} sortingCallbacks={{ - [columnId.name]: (item: HorizontalPodAutoscaler) => item.getName(), - [columnId.namespace]: (item: HorizontalPodAutoscaler) => item.getNs(), - [columnId.minPods]: (item: HorizontalPodAutoscaler) => item.getMinPods(), - [columnId.maxPods]: (item: HorizontalPodAutoscaler) => item.getMaxPods(), - [columnId.replicas]: (item: HorizontalPodAutoscaler) => item.getReplicas(), - [columnId.age]: (item: HorizontalPodAutoscaler) => item.getTimeDiffFromNow(), + [columnId.name]: item => item.getName(), + [columnId.namespace]: item => item.getNs(), + [columnId.minPods]: item => item.getMinPods(), + [columnId.maxPods]: item => item.getMaxPods(), + [columnId.replicas]: item => item.getReplicas(), + [columnId.age]: item => item.getTimeDiffFromNow(), }} searchFilters={[ - (item: HorizontalPodAutoscaler) => item.getSearchFields() + item => item.getSearchFields() ]} renderHeaderTitle="Horizontal Pod Autoscalers" renderTableHeader={[ @@ -86,7 +86,7 @@ export class HorizontalPodAutoscalers extends React.Component { { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, { title: "Status", className: "status", id: columnId.status }, ]} - renderTableContents={(hpa: HorizontalPodAutoscaler) => [ + renderTableContents={hpa => [ hpa.getName(), , hpa.getNs(), @@ -104,6 +104,7 @@ export class HorizontalPodAutoscalers extends React.Component { label={type} tooltip={tooltip} className={cssNames(type.toLowerCase())} + expandable={false} /> ); }) diff --git a/src/renderer/components/+config-limit-ranges/limit-ranges.tsx b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx index 664654814c..e0bec106aa 100644 --- a/src/renderer/components/+config-limit-ranges/limit-ranges.tsx +++ b/src/renderer/components/+config-limit-ranges/limit-ranges.tsx @@ -27,7 +27,6 @@ import { KubeObjectListLayout } from "../kube-object/kube-object-list-layout"; import { limitRangeStore } from "./limit-ranges.store"; import React from "react"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; -import type { LimitRange } from "../../api/endpoints/limit-range.api"; import type { LimitRangeRouteParams } from "../../../common/routes"; enum columnId { @@ -49,22 +48,22 @@ export class LimitRanges extends React.Component { className="LimitRanges" store={limitRangeStore} sortingCallbacks={{ - [columnId.name]: (item: LimitRange) => item.getName(), - [columnId.namespace]: (item: LimitRange) => item.getNs(), - [columnId.age]: (item: LimitRange) => item.getTimeDiffFromNow(), + [columnId.name]: item => item.getName(), + [columnId.namespace]: item => item.getNs(), + [columnId.age]: item => item.getTimeDiffFromNow(), }} searchFilters={[ - (item: LimitRange) => item.getName(), - (item: LimitRange) => item.getNs(), + item => item.getName(), + item => item.getNs(), ]} - renderHeaderTitle={"Limit Ranges"} + renderHeaderTitle="Limit Ranges" renderTableHeader={[ { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, { className: "warning", showWithColumn: columnId.name }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(limitRange: LimitRange) => [ + renderTableContents={limitRange => [ limitRange.getName(), , limitRange.getNs(), diff --git a/src/renderer/components/+config-maps/config-maps.tsx b/src/renderer/components/+config-maps/config-maps.tsx index fb757a69bf..b9fecc9fc9 100644 --- a/src/renderer/components/+config-maps/config-maps.tsx +++ b/src/renderer/components/+config-maps/config-maps.tsx @@ -25,7 +25,6 @@ import React from "react"; import { observer } from "mobx-react"; import type { RouteComponentProps } from "react-router"; import { configMapsStore } from "./config-maps.store"; -import type { ConfigMap } from "../../api/endpoints/configmap.api"; import { KubeObjectListLayout } from "../kube-object"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import type { ConfigMapsRouteParams } from "../../../common/routes"; @@ -49,14 +48,14 @@ export class ConfigMaps extends React.Component { tableId="configuration_configmaps" className="ConfigMaps" store={configMapsStore} sortingCallbacks={{ - [columnId.name]: (item: ConfigMap) => item.getName(), - [columnId.namespace]: (item: ConfigMap) => item.getNs(), - [columnId.keys]: (item: ConfigMap) => item.getKeys(), - [columnId.age]: (item: ConfigMap) => item.getTimeDiffFromNow(), + [columnId.name]: item => item.getName(), + [columnId.namespace]: item => item.getNs(), + [columnId.keys]: item => item.getKeys(), + [columnId.age]: item => item.getTimeDiffFromNow(), }} searchFilters={[ - (item: ConfigMap) => item.getSearchFields(), - (item: ConfigMap) => item.getKeys() + item => item.getSearchFields(), + item => item.getKeys() ]} renderHeaderTitle="Config Maps" renderTableHeader={[ @@ -66,7 +65,7 @@ export class ConfigMaps extends React.Component { { title: "Keys", className: "keys", sortBy: columnId.keys, id: columnId.keys }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(configMap: ConfigMap) => [ + renderTableContents={configMap => [ configMap.getName(), , configMap.getNs(), diff --git a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx b/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx index abc87a6a4d..f662c96ab3 100644 --- a/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx +++ b/src/renderer/components/+config-pod-disruption-budgets/pod-disruption-budgets.tsx @@ -51,16 +51,16 @@ export class PodDisruptionBudgets extends React.Component { className="PodDisruptionBudgets" store={podDisruptionBudgetsStore} sortingCallbacks={{ - [columnId.name]: (pdb: PodDisruptionBudget) => pdb.getName(), - [columnId.namespace]: (pdb: PodDisruptionBudget) => pdb.getNs(), - [columnId.minAvailable]: (pdb: PodDisruptionBudget) => pdb.getMinAvailable(), - [columnId.maxUnavailable]: (pdb: PodDisruptionBudget) => pdb.getMaxUnavailable(), - [columnId.currentHealthy]: (pdb: PodDisruptionBudget) => pdb.getCurrentHealthy(), - [columnId.desiredHealthy]: (pdb: PodDisruptionBudget) => pdb.getDesiredHealthy(), - [columnId.age]: (pdb: PodDisruptionBudget) => pdb.getAge(), + [columnId.name]: pdb => pdb.getName(), + [columnId.namespace]: pdb => pdb.getNs(), + [columnId.minAvailable]: pdb => pdb.getMinAvailable(), + [columnId.maxUnavailable]: pdb => pdb.getMaxUnavailable(), + [columnId.currentHealthy]: pdb => pdb.getCurrentHealthy(), + [columnId.desiredHealthy]: pdb => pdb.getDesiredHealthy(), + [columnId.age]: pdb => pdb.getAge(), }} searchFilters={[ - (pdb: PodDisruptionBudget) => pdb.getSearchFields(), + pdb => pdb.getSearchFields(), ]} renderHeaderTitle="Pod Disruption Budgets" renderTableHeader={[ @@ -73,7 +73,7 @@ export class PodDisruptionBudgets extends React.Component { { title: "Desired Healthy", className: "desired-healthy", sortBy: columnId.desiredHealthy, id: columnId.desiredHealthy }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(pdb: PodDisruptionBudget) => { + renderTableContents={pdb => { return [ pdb.getName(), , diff --git a/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx b/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx index 1227e97049..b341d2c86a 100644 --- a/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx +++ b/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx @@ -175,6 +175,7 @@ export class AddQuotaDialog extends React.Component { this.quotaName = v.toLowerCase()} className="box grow" @@ -218,7 +219,7 @@ export class AddQuotaDialog extends React.Component {
{this.quotaEntries.map(([quota, value]) => { return ( -
+
{quota}
{value}
this.quotas[quota] = ""} /> diff --git a/src/renderer/components/+config-resource-quotas/resource-quotas.tsx b/src/renderer/components/+config-resource-quotas/resource-quotas.tsx index d1ce05c098..e1f54f2499 100644 --- a/src/renderer/components/+config-resource-quotas/resource-quotas.tsx +++ b/src/renderer/components/+config-resource-quotas/resource-quotas.tsx @@ -25,7 +25,6 @@ import React from "react"; import { observer } from "mobx-react"; import type { RouteComponentProps } from "react-router"; import { KubeObjectListLayout } from "../kube-object"; -import type { ResourceQuota } from "../../api/endpoints/resource-quota.api"; import { AddQuotaDialog } from "./add-quota-dialog"; import { resourceQuotaStore } from "./resource-quotas.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; @@ -50,13 +49,13 @@ export class ResourceQuotas extends React.Component { tableId="configuration_quotas" className="ResourceQuotas" store={resourceQuotaStore} sortingCallbacks={{ - [columnId.name]: (item: ResourceQuota) => item.getName(), - [columnId.namespace]: (item: ResourceQuota) => item.getNs(), - [columnId.age]: (item: ResourceQuota) => item.getTimeDiffFromNow(), + [columnId.name]: item => item.getName(), + [columnId.namespace]: item => item.getNs(), + [columnId.age]: item => item.getTimeDiffFromNow(), }} searchFilters={[ - (item: ResourceQuota) => item.getSearchFields(), - (item: ResourceQuota) => item.getName(), + item => item.getSearchFields(), + item => item.getName(), ]} renderHeaderTitle="Resource Quotas" renderTableHeader={[ @@ -65,7 +64,7 @@ export class ResourceQuotas extends React.Component { { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(resourceQuota: ResourceQuota) => [ + renderTableContents={resourceQuota => [ resourceQuota.getName(), , resourceQuota.getNs(), diff --git a/src/renderer/components/+config-secrets/add-secret-dialog.tsx b/src/renderer/components/+config-secrets/add-secret-dialog.tsx index bfd7a3262e..5c417023e2 100644 --- a/src/renderer/components/+config-secrets/add-secret-dialog.tsx +++ b/src/renderer/components/+config-secrets/add-secret-dialog.tsx @@ -33,7 +33,7 @@ import { SubTitle } from "../layout/sub-title"; import { NamespaceSelect } from "../+namespaces/namespace-select"; import { Select, SelectOption } from "../select"; import { Icon } from "../icon"; -import type { IKubeObjectMetadata } from "../../api/kube-object"; +import type { KubeObjectMetadata } from "../../api/kube-object"; import { base64 } from "../../utils"; import { Notifications } from "../notifications"; import upperFirst from "lodash/upperFirst"; @@ -127,7 +127,7 @@ export class AddSecretDialog extends React.Component { namespace, annotations: this.getDataFromFields(annotations), labels: this.getDataFromFields(labels), - } as IKubeObjectMetadata + } as KubeObjectMetadata }; try { @@ -223,6 +223,7 @@ export class AddSecretDialog extends React.Component { this.name = v} /> diff --git a/src/renderer/components/+config-secrets/secrets.tsx b/src/renderer/components/+config-secrets/secrets.tsx index d74305a499..f00c20d67e 100644 --- a/src/renderer/components/+config-secrets/secrets.tsx +++ b/src/renderer/components/+config-secrets/secrets.tsx @@ -24,7 +24,6 @@ import "./secrets.scss"; import React from "react"; import { observer } from "mobx-react"; import type { RouteComponentProps } from "react-router"; -import type { Secret } from "../../api/endpoints"; import { AddSecretDialog } from "./add-secret-dialog"; import { KubeObjectListLayout } from "../kube-object"; import { Badge } from "../badge"; @@ -54,16 +53,16 @@ export class Secrets extends React.Component { tableId="configuration_secrets" className="Secrets" store={secretsStore} sortingCallbacks={{ - [columnId.name]: (item: Secret) => item.getName(), - [columnId.namespace]: (item: Secret) => item.getNs(), - [columnId.labels]: (item: Secret) => item.getLabels(), - [columnId.keys]: (item: Secret) => item.getKeys(), - [columnId.type]: (item: Secret) => item.type, - [columnId.age]: (item: Secret) => item.getTimeDiffFromNow(), + [columnId.name]: item => item.getName(), + [columnId.namespace]: item => item.getNs(), + [columnId.labels]: item => item.getLabels(), + [columnId.keys]: item => item.getKeys(), + [columnId.type]: item => item.type, + [columnId.age]: item => item.getTimeDiffFromNow(), }} searchFilters={[ - (item: Secret) => item.getSearchFields(), - (item: Secret) => item.getKeys(), + item => item.getSearchFields(), + item => item.getKeys(), ]} renderHeaderTitle="Secrets" renderTableHeader={[ @@ -75,11 +74,11 @@ export class Secrets extends React.Component { { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(secret: Secret) => [ + renderTableContents={secret => [ secret.getName(), , secret.getNs(), - secret.getLabels().map(label => ), + secret.getLabels().map(label => ), secret.getKeys().join(", "), secret.type, secret.getAge(), diff --git a/src/renderer/components/+custom-resources/crd-details.scss b/src/renderer/components/+custom-resources/crd-details.scss index e5d016bb30..8e969a580b 100644 --- a/src/renderer/components/+custom-resources/crd-details.scss +++ b/src/renderer/components/+custom-resources/crd-details.scss @@ -23,11 +23,9 @@ .CRDDetails { .conditions { - .Badge { - @include crd-condition-bgc; - } + @include crd-condition-bgc; } - + .Table { margin-left: -$margin * 2; margin-right: -$margin * 2; @@ -41,21 +39,8 @@ flex: 0.5; } - .description { - flex: 1.5; - - .Badge { - background: transparent; - } - } - .json-path { flex: 3; - - .Badge { - white-space: pre-line; - text-overflow: initial; - } } } } diff --git a/src/renderer/components/+custom-resources/crd-details.tsx b/src/renderer/components/+custom-resources/crd-details.tsx index 146c299884..bef1dc6f67 100644 --- a/src/renderer/components/+custom-resources/crd-details.tsx +++ b/src/renderer/components/+custom-resources/crd-details.tsx @@ -25,7 +25,6 @@ import React from "react"; import { Link } from "react-router-dom"; import { observer } from "mobx-react"; import type { CustomResourceDefinition } from "../../api/endpoints/crd.api"; -import { cssNames } from "../../utils"; import { AceEditor } from "../ace-editor"; import { Badge } from "../badge"; import { DrawerItem, DrawerTitle } from "../drawer"; @@ -86,7 +85,8 @@ export class CRDDetails extends React.Component {

{message}

diff --git a/src/renderer/components/+custom-resources/crd-list.tsx b/src/renderer/components/+custom-resources/crd-list.tsx index 822eff575f..70ae2c96fb 100644 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ b/src/renderer/components/+custom-resources/crd-list.tsx @@ -32,6 +32,7 @@ import type { CustomResourceDefinition } from "../../api/endpoints/crd.api"; import { Select, SelectOption } from "../select"; import { createPageParam } from "../../navigation"; import { Icon } from "../icon"; +import type { TableSortCallbacks } from "../table"; export const crdGroupsUrlParam = createPageParam({ name: "groups", @@ -78,11 +79,11 @@ export class CrdList extends React.Component { render() { const { items, selectedGroups } = this; - const sortingCallbacks = { - [columnId.kind]: (crd: CustomResourceDefinition) => crd.getResourceKind(), - [columnId.group]: (crd: CustomResourceDefinition) => crd.getGroup(), - [columnId.version]: (crd: CustomResourceDefinition) => crd.getVersion(), - [columnId.scope]: (crd: CustomResourceDefinition) => crd.getScope(), + const sortingCallbacks: TableSortCallbacks = { + [columnId.kind]: crd => crd.getResourceKind(), + [columnId.group]: crd => crd.getGroup(), + [columnId.version]: crd => crd.getVersion(), + [columnId.scope]: crd => crd.getScope(), }; return ( @@ -137,9 +138,9 @@ export class CrdList extends React.Component { { title: "Scope", className: "scope", sortBy: columnId.scope, id: columnId.scope }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(crd: CustomResourceDefinition) => [ + renderTableContents={crd => [ - {crd.getResourceTitle()} + {crd.getResourceKind()} , crd.getGroup(), crd.getVersion(), diff --git a/src/renderer/components/+custom-resources/crd-resource-details.scss b/src/renderer/components/+custom-resources/crd-resource-details.scss index af2c36ef2c..b7e57a0295 100644 --- a/src/renderer/components/+custom-resources/crd-resource-details.scss +++ b/src/renderer/components/+custom-resources/crd-resource-details.scss @@ -21,13 +21,9 @@ .CrdResourceDetails { .status { - .value { - .Badge { - &.ready { - color: white; - background-color: $colorOk; - } - } + .ready { + color: white; + background-color: $colorOk; } } } \ No newline at end of file diff --git a/src/renderer/components/+custom-resources/crd-resource-details.tsx b/src/renderer/components/+custom-resources/crd-resource-details.tsx index f7305788eb..dc6181c63c 100644 --- a/src/renderer/components/+custom-resources/crd-resource-details.tsx +++ b/src/renderer/components/+custom-resources/crd-resource-details.tsx @@ -90,7 +90,8 @@ export class CrdResourceDetails extends React.Component { .map(({ kind, message, status }, index) => ( )); diff --git a/src/renderer/components/+custom-resources/crd-resource.store.ts b/src/renderer/components/+custom-resources/crd-resource.store.ts index 8717884785..335a015aa7 100644 --- a/src/renderer/components/+custom-resources/crd-resource.store.ts +++ b/src/renderer/components/+custom-resources/crd-resource.store.ts @@ -23,10 +23,10 @@ import type { KubeApi } from "../../api/kube-api"; import { KubeObjectStore } from "../../kube-object.store"; import type { KubeObject } from "../../api/kube-object"; -export class CRDResourceStore extends KubeObjectStore { - api: KubeApi; +export class CRDResourceStore extends KubeObjectStore { + api: KubeApi; - constructor(api: KubeApi) { + constructor(api: KubeApi) { super(); this.api = api; } diff --git a/src/renderer/components/+custom-resources/crd-resources.tsx b/src/renderer/components/+custom-resources/crd-resources.tsx index 0203bef3da..be12f910c1 100644 --- a/src/renderer/components/+custom-resources/crd-resources.tsx +++ b/src/renderer/components/+custom-resources/crd-resources.tsx @@ -23,13 +23,13 @@ import "./crd-resources.scss"; import React from "react"; import jsonPath from "jsonpath"; -import { disposeOnUnmount, observer } from "mobx-react"; +import { observer } from "mobx-react"; import type { RouteComponentProps } from "react-router"; import { KubeObjectListLayout } from "../kube-object"; import type { KubeObject } from "../../api/kube-object"; -import { autorun, computed, makeObservable } from "mobx"; +import { computed, makeObservable } from "mobx"; import { crdStore } from "./crd.store"; -import type { TableSortCallback } from "../table"; +import type { TableSortCallbacks } from "../table"; import { apiManager } from "../../api/api-manager"; import { parseJsonPath } from "../../utils/jsonPath"; import type { CRDRouteParams } from "../../../common/routes"; @@ -50,18 +50,6 @@ export class CrdResources extends React.Component { makeObservable(this); } - componentDidMount() { - disposeOnUnmount(this, [ - autorun(() => { - const { store } = this; - - if (store && !store.isLoading && !store.isLoaded) { - store.reloadAll(); - } - }) - ]); - } - @computed get crd() { const { group, name } = this.props.match.params; @@ -80,25 +68,26 @@ export class CrdResources extends React.Component { if (!crd) return null; const isNamespaced = crd.isNamespaced(); const extraColumns = crd.getPrinterColumns(false); // Cols with priority bigger than 0 are shown in details - const sortingCallbacks: { [sortBy: string]: TableSortCallback } = { - [columnId.name]: (item: KubeObject) => item.getName(), - [columnId.namespace]: (item: KubeObject) => item.getNs(), - [columnId.age]: (item: KubeObject) => item.getTimeDiffFromNow(), + const sortingCallbacks: TableSortCallbacks = { + [columnId.name]: item => item.getName(), + [columnId.namespace]: item => item.getNs(), + [columnId.age]: item => item.getTimeDiffFromNow(), }; extraColumns.forEach(column => { - sortingCallbacks[column.name] = (item: KubeObject) => jsonPath.value(item, parseJsonPath(column.jsonPath.slice(1))); + sortingCallbacks[column.name] = item => jsonPath.value(item, parseJsonPath(column.jsonPath.slice(1))); }); return ( item.getSearchFields(), + item => item.getSearchFields(), ]} renderHeaderTitle={crd.getResourceTitle()} customizeHeader={({ searchProps, ...headerPlaceholders }) => ({ @@ -123,7 +112,7 @@ export class CrdResources extends React.Component { }), { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(crdInstance: KubeObject) => [ + renderTableContents={crdInstance => [ crdInstance.getName(), isNamespaced && crdInstance.getNs(), ...extraColumns.map((column) => { diff --git a/src/renderer/components/+custom-resources/crd.mixins.scss b/src/renderer/components/+custom-resources/crd.mixins.scss index 62a160b1ff..89c7c86acc 100644 --- a/src/renderer/components/+custom-resources/crd.mixins.scss +++ b/src/renderer/components/+custom-resources/crd.mixins.scss @@ -30,7 +30,7 @@ $crd-condition-colors: ( @mixin crd-condition-bgc { @each $status, $color in $crd-condition-colors { - &.#{$status} { + .#{$status} { background: $color; color: white; } diff --git a/src/renderer/components/+custom-resources/crd.store.ts b/src/renderer/components/+custom-resources/crd.store.ts index 613438bca7..25415e5ab3 100644 --- a/src/renderer/components/+custom-resources/crd.store.ts +++ b/src/renderer/components/+custom-resources/crd.store.ts @@ -26,13 +26,18 @@ import { crdApi, CustomResourceDefinition } from "../../api/endpoints/crd.api"; import { apiManager } from "../../api/api-manager"; import { KubeApi } from "../../api/kube-api"; import { CRDResourceStore } from "./crd-resource.store"; -import type { KubeObject } from "../../api/kube-object"; +import { KubeObject } from "../../api/kube-object"; function initStore(crd: CustomResourceDefinition) { const apiBase = crd.getResourceApiBase(); const kind = crd.getResourceKind(); const isNamespaced = crd.isNamespaced(); - const api = apiManager.getApi(apiBase) || new KubeApi({ apiBase, kind, isNamespaced }); + const api = apiManager.getApi(apiBase) ?? new KubeApi({ + objectConstructor: KubeObject, + apiBase, + kind, + isNamespaced, + }); if (!apiManager.getStore(api)) { apiManager.registerStore(new CRDResourceStore(api)); diff --git a/src/renderer/components/+entity-settings/entity-settings.tsx b/src/renderer/components/+entity-settings/entity-settings.tsx index ad0713e342..f656b6fa9d 100644 --- a/src/renderer/components/+entity-settings/entity-settings.tsx +++ b/src/renderer/components/+entity-settings/entity-settings.tsx @@ -82,6 +82,13 @@ export class EntitySettings extends React.Component { renderNavigation() { const groups = Object.entries(groupBy(this.menuItems, (item) => item.group || "Extensions")); + groups.sort((a, b) => { + if (a[0] === "Settings") return -1; + if (a[0] === "Extensions") return 1; + + return a[0] <= b[0] ? -1 : 1; + }); + return ( <>
diff --git a/src/renderer/components/+events/events.tsx b/src/renderer/components/+events/events.tsx index f0d95bd41e..23aa985ab8 100644 --- a/src/renderer/components/+events/events.tsx +++ b/src/renderer/components/+events/events.tsx @@ -29,7 +29,7 @@ import { TabLayout } from "../layout/tab-layout"; import { EventStore, eventStore } from "./event.store"; import { getDetailsUrl, KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object"; import type { KubeEvent } from "../../api/endpoints/events.api"; -import type { TableSortCallbacks, TableSortParams, TableProps } from "../table"; +import type { TableSortCallbacks, TableSortParams } from "../table"; import type { HeaderCustomizer } from "../item-object-list"; import { Tooltip } from "../tooltip"; import { Link } from "react-router-dom"; @@ -49,7 +49,7 @@ enum columnId { lastSeen = "last-seen", } -interface Props extends Partial { +interface Props extends Partial> { className?: IClassName; compact?: boolean; compactLimit?: number; @@ -69,19 +69,13 @@ export class Events extends React.Component { orderBy: "asc", }; - private sortingCallbacks: TableSortCallbacks = { - [columnId.namespace]: (event: KubeEvent) => event.getNs(), - [columnId.type]: (event: KubeEvent) => event.type, - [columnId.object]: (event: KubeEvent) => event.involvedObject.name, - [columnId.count]: (event: KubeEvent) => event.count, - [columnId.age]: (event: KubeEvent) => event.getTimeDiffFromNow(), - [columnId.lastSeen]: (event: KubeEvent) => this.now - new Date(event.lastTimestamp).getTime(), - }; - - private tableConfiguration: TableProps = { - sortSyncWithUrl: false, - sortByDefault: this.sorting, - onSort: params => this.sorting = params, + private sortingCallbacks: TableSortCallbacks = { + [columnId.namespace]: event => event.getNs(), + [columnId.type]: event => event.type, + [columnId.object]: event => event.involvedObject.name, + [columnId.count]: event => event.count, + [columnId.age]: event => event.getTimeDiffFromNow(), + [columnId.lastSeen]: event => this.now - new Date(event.lastTimestamp).getTime(), }; constructor(props: Props) { @@ -160,13 +154,17 @@ export class Events extends React.Component { isSelectable={false} items={visibleItems} virtual={!compact} - tableProps={this.tableConfiguration} + tableProps={{ + sortSyncWithUrl: false, + sortByDefault: this.sorting, + onSort: params => this.sorting = params, + }} sortingCallbacks={this.sortingCallbacks} searchFilters={[ - (event: KubeEvent) => event.getSearchFields(), - (event: KubeEvent) => event.message, - (event: KubeEvent) => event.getSource(), - (event: KubeEvent) => event.involvedObject.name, + event => event.getSearchFields(), + event => event.message, + event => event.getSource(), + event => event.involvedObject.name, ]} renderTableHeader={[ { title: "Type", className: "type", sortBy: columnId.type, id: columnId.type }, @@ -178,7 +176,7 @@ export class Events extends React.Component { { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, { title: "Last Seen", className: "last-seen", sortBy: columnId.lastSeen, id: columnId.lastSeen }, ]} - renderTableContents={(event: KubeEvent) => { + renderTableContents={event => { const { involvedObject, type, message } = event; const tooltipId = `message-${event.getId()}`; const isWarning = event.isWarning(); diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 3c2484b92e..f51ba24575 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -59,14 +59,7 @@ describe("Extensions", () => { }); ExtensionInstallationStateStore.reset(); - UserStore.resetInstance(); - UserStore.createInstance(); - - ExtensionDiscovery.resetInstance(); - ExtensionDiscovery.createInstance().uninstallExtension = jest.fn(() => Promise.resolve()); - - ExtensionLoader.resetInstance(); ExtensionLoader.createInstance().addExtension({ id: "extensionId", manifest: { @@ -79,10 +72,15 @@ describe("Extensions", () => { isEnabled: true, isCompatible: true }); + ExtensionDiscovery.createInstance().uninstallExtension = jest.fn(() => Promise.resolve()); + UserStore.createInstance(); }); afterEach(() => { mockFs.restore(); + UserStore.resetInstance(); + ExtensionDiscovery.resetInstance(); + ExtensionLoader.resetInstance(); }); it("disables uninstall and disable buttons while uninstalling", async () => { diff --git a/src/renderer/components/+extensions/extension-install.store.ts b/src/renderer/components/+extensions/extension-install.store.ts index c02bb482fd..4ca79ec337 100644 --- a/src/renderer/components/+extensions/extension-install.store.ts +++ b/src/renderer/components/+extensions/extension-install.store.ts @@ -225,7 +225,7 @@ export class ExtensionInstallationStateStore { } /** - * If there is at least one extension currently ininstalling + * If there is at least one extension currently uninstalling */ static get anyUninstalling(): boolean { return ExtensionInstallationStateStore.uninstalling > 0; diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index b135487343..0265a6ddf3 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -49,7 +49,7 @@ import { docsUrl } from "../../../common/vars"; function getMessageFromError(error: any): string { if (!error || typeof error !== "object") { - return "an error has occured"; + return "an error has occurred"; } if (error.message) { @@ -63,7 +63,7 @@ function getMessageFromError(error: any): string { const rawMessage = String(error); if (rawMessage === String({})) { - return "an error has occured"; + return "an error has occurred"; } return rawMessage; diff --git a/src/renderer/components/+extensions/install.module.css b/src/renderer/components/+extensions/install.module.css index fbf236177a..6fe73b9169 100644 --- a/src/renderer/components/+extensions/install.module.css +++ b/src/renderer/components/+extensions/install.module.css @@ -2,7 +2,3 @@ @apply h-5 w-5 mr-3 cursor-pointer; background-color: transparent!important; } - -.icon:hover { - color: var(--textColorAccent); -} \ No newline at end of file diff --git a/src/renderer/components/+namespaces/add-namespace-dialog.tsx b/src/renderer/components/+namespaces/add-namespace-dialog.tsx index 213891c233..2427c9fd9b 100644 --- a/src/renderer/components/+namespaces/add-namespace-dialog.tsx +++ b/src/renderer/components/+namespaces/add-namespace-dialog.tsx @@ -100,6 +100,7 @@ export class AddNamespaceDialog extends React.Component { required autoFocus iconLeft="layers" placeholder="Namespace" + trim validators={systemName} value={namespace} onChange={v => this.namespace = v.toLowerCase()} /> diff --git a/src/renderer/components/+namespaces/namespace-details.tsx b/src/renderer/components/+namespaces/namespace-details.tsx index 95103f553b..ee051977e5 100644 --- a/src/renderer/components/+namespaces/namespace-details.tsx +++ b/src/renderer/components/+namespaces/namespace-details.tsx @@ -22,28 +22,44 @@ import "./namespace-details.scss"; import React from "react"; -import { computed, makeObservable } from "mobx"; -import { observer } from "mobx-react"; +import { computed, makeObservable, observable, reaction } from "mobx"; +import { disposeOnUnmount, observer } from "mobx-react"; import { DrawerItem } from "../drawer"; -import { cssNames } from "../../utils"; -import type { Namespace } from "../../api/endpoints"; +import { boundMethod, cssNames } from "../../utils"; +import { getMetricsForNamespace, IPodMetrics, Namespace } from "../../api/endpoints"; import { getDetailsUrl, KubeObjectDetailsProps } from "../kube-object"; import { Link } from "react-router-dom"; import { Spinner } from "../spinner"; import { resourceQuotaStore } from "../+config-resource-quotas/resource-quotas.store"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { limitRangeStore } from "../+config-limit-ranges/limit-ranges.store"; +import { ResourceMetrics } from "../resource-metrics"; +import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; +import { ClusterMetricsResourceType } from "../../../main/cluster"; +import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; interface Props extends KubeObjectDetailsProps { } @observer export class NamespaceDetails extends React.Component { + @observable metrics: IPodMetrics = null; + constructor(props: Props) { super(props); makeObservable(this); } + @disposeOnUnmount + clean = reaction(() => this.props.object, () => { + this.metrics = null; + }); + + componentDidMount() { + resourceQuotaStore.reloadAll(); + limitRangeStore.reloadAll(); + } + @computed get quotas() { const namespace = this.props.object.getName(); @@ -56,9 +72,9 @@ export class NamespaceDetails extends React.Component { return limitRangeStore.getAllByNs(namespace); } - componentDidMount() { - resourceQuotaStore.reloadAll(); - limitRangeStore.reloadAll(); + @boundMethod + async loadMetrics() { + this.metrics = await getMetricsForNamespace(this.props.object.getName(), ""); } render() { @@ -66,9 +82,18 @@ export class NamespaceDetails extends React.Component { if (!namespace) return null; const status = namespace.getStatus(); + const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Namespace); return (
+ {!isMetricHidden && ( + + + + )} diff --git a/src/renderer/components/+namespaces/namespace-select-filter.tsx b/src/renderer/components/+namespaces/namespace-select-filter.tsx index 8ae2e97958..07b492ebd8 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter.tsx +++ b/src/renderer/components/+namespaces/namespace-select-filter.tsx @@ -22,14 +22,16 @@ import "./namespace-select-filter.scss"; import React from "react"; -import { observer } from "mobx-react"; +import { disposeOnUnmount, observer } from "mobx-react"; import { components, PlaceholderProps } from "react-select"; +import { action, computed, makeObservable, observable, reaction } from "mobx"; import { Icon } from "../icon"; import { NamespaceSelect } from "./namespace-select"; import { namespaceStore } from "./namespace.store"; import type { SelectOption, SelectProps } from "../select"; +import { isMac } from "../../../common/vars"; const Placeholder = observer((props: PlaceholderProps) => { const getPlaceholder = (): React.ReactNode => { @@ -55,13 +57,51 @@ const Placeholder = observer((props: PlaceholderProps) => { @observer export class NamespaceSelectFilter extends React.Component { + static isMultiSelection = observable.box(false); + static isMenuOpen = observable.box(false); + + private selected = observable.set(); + private didToggle = false; + + constructor(props: SelectProps) { + super(props); + makeObservable(this); + } + + @computed get isMultiSelection() { + return NamespaceSelectFilter.isMultiSelection.get(); + } + + set isMultiSelection(val: boolean) { + NamespaceSelectFilter.isMultiSelection.set(val); + } + + @computed get isMenuOpen() { + return NamespaceSelectFilter.isMenuOpen.get(); + } + + set isMenuOpen(val: boolean) { + NamespaceSelectFilter.isMenuOpen.set(val); + } + + componentDidMount() { + disposeOnUnmount(this, [ + reaction(() => this.isMenuOpen, newVal => { + if (newVal) { // rising edge of selection + this.selected.replace(namespaceStore.selectedNamespaces); + this.didToggle = false; + } + }), + ]); + } + formatOptionLabel({ value: namespace, label }: SelectOption) { if (namespace) { const isSelected = namespaceStore.hasContext(namespace); return (
- + {namespace} {isSelected && }
@@ -71,27 +111,78 @@ export class NamespaceSelectFilter extends React.Component { return label; } - onChange([{ value: namespace }]: SelectOption[]) { + @action + onChange = ([{ value: namespace }]: SelectOption[]) => { if (namespace) { - namespaceStore.toggleContext(namespace); + if (this.isMultiSelection) { + this.didToggle = true; + namespaceStore.toggleContext(namespace); + } else { + namespaceStore.toggleSingle(namespace); + } } else { - namespaceStore.toggleAll(false); // "All namespaces" clicked + namespaceStore.toggleAll(true); // "All namespaces" clicked } + }; + + private isSelectionKey(e: React.KeyboardEvent): boolean { + if (isMac) { + return e.key === "Meta"; + } + + return e.key === "Control"; // windows or linux } + @action + onKeyDown = (e: React.KeyboardEvent) => { + if (this.isSelectionKey(e)) { + this.isMultiSelection = true; + } + }; + + @action + onKeyUp = (e: React.KeyboardEvent) => { + if (this.isSelectionKey(e)) { + this.isMultiSelection = false; + } + + if (!this.isMultiSelection && this.didToggle) { + this.isMenuOpen = false; + } + }; + + @action + onClick = () => { + if (!this.isMenuOpen) { + this.isMenuOpen = true; + } else if (!this.isMultiSelection) { + this.isMenuOpen = !this.isMenuOpen; + } + }; + + reset = () => { + this.isMultiSelection = false; + this.isMenuOpen = false; + }; + render() { return ( - +
+ +this.selected.has(right.value) - +this.selected.has(left.value)} + /> +
); } } diff --git a/src/renderer/components/+namespaces/namespace-select.tsx b/src/renderer/components/+namespaces/namespace-select.tsx index c1fe06c258..3a662ef66c 100644 --- a/src/renderer/components/+namespaces/namespace-select.tsx +++ b/src/renderer/components/+namespaces/namespace-select.tsx @@ -32,6 +32,7 @@ import { kubeWatchApi } from "../../api/kube-watch-api"; interface Props extends SelectProps { showIcons?: boolean; + sort?: (a: SelectOption, b: SelectOption) => number; showAllNamespacesOption?: boolean; // show "All namespaces" option on the top (default: false) customizeOptions?(options: SelectOption[]): SelectOption[]; } @@ -59,9 +60,13 @@ export class NamespaceSelect extends React.Component { } @computed.struct get options(): SelectOption[] { - const { customizeOptions, showAllNamespacesOption } = this.props; + const { customizeOptions, showAllNamespacesOption, sort } = this.props; let options: SelectOption[] = namespaceStore.items.map(ns => ({ value: ns.getName() })); + if (sort) { + options.sort(sort); + } + if (showAllNamespacesOption) { options.unshift({ label: "All Namespaces", value: "" }); } diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index c292f0f9fa..cc4a8b98ce 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -109,7 +109,7 @@ export class NamespaceStore extends KubeObjectStore { return super.subscribe(); } - protected async loadItems(params: KubeObjectStoreLoadingParams) { + protected async loadItems(params: KubeObjectStoreLoadingParams): Promise { const { allowedNamespaces } = this; let namespaces = await super.loadItems(params).catch(() => []); @@ -161,6 +161,12 @@ export class NamespaceStore extends KubeObjectStore { } } + @action + toggleSingle(namespace: string){ + this.clearSelected(); + this.selectNamespaces(namespace); + } + @action toggleAll(showAll?: boolean) { if (typeof showAll === "boolean") { diff --git a/src/renderer/components/+namespaces/namespaces.tsx b/src/renderer/components/+namespaces/namespaces.tsx index c80c839071..cc29fa05ea 100644 --- a/src/renderer/components/+namespaces/namespaces.tsx +++ b/src/renderer/components/+namespaces/namespaces.tsx @@ -22,7 +22,7 @@ import "./namespaces.scss"; import React from "react"; -import { Namespace, NamespaceStatus } from "../../api/endpoints"; +import { NamespaceStatus } from "../../api/endpoints"; import { AddNamespaceDialog } from "./add-namespace-dialog"; import { TabLayout } from "../layout/tab-layout"; import { Badge } from "../badge"; @@ -51,14 +51,14 @@ export class Namespaces extends React.Component { tableId="namespaces" className="Namespaces" store={namespaceStore} sortingCallbacks={{ - [columnId.name]: (ns: Namespace) => ns.getName(), - [columnId.labels]: (ns: Namespace) => ns.getLabels(), - [columnId.age]: (ns: Namespace) => ns.getTimeDiffFromNow(), - [columnId.status]: (ns: Namespace) => ns.getStatus(), + [columnId.name]: ns => ns.getName(), + [columnId.labels]: ns => ns.getLabels(), + [columnId.age]: ns => ns.getTimeDiffFromNow(), + [columnId.status]: ns => ns.getStatus(), }} searchFilters={[ - (item: Namespace) => item.getSearchFields(), - (item: Namespace) => item.getStatus() + item => item.getSearchFields(), + item => item.getStatus() ]} renderHeaderTitle="Namespaces" renderTableHeader={[ @@ -68,7 +68,7 @@ export class Namespaces extends React.Component { { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} - renderTableContents={(item: Namespace) => [ + renderTableContents={item => [ item.getName(), , item.getLabels().map(label => ), @@ -79,7 +79,7 @@ export class Namespaces extends React.Component { addTooltip: "Add Namespace", onAdd: () => AddNamespaceDialog.open(), }} - customizeTableRowProps={(item: Namespace) => ({ + customizeTableRowProps={item => ({ disabled: item.getStatus() === NamespaceStatus.TERMINATING, })} /> diff --git a/src/renderer/components/+network-endpoints/endpoints.tsx b/src/renderer/components/+network-endpoints/endpoints.tsx index 3ae66884fa..940ab89783 100644 --- a/src/renderer/components/+network-endpoints/endpoints.tsx +++ b/src/renderer/components/+network-endpoints/endpoints.tsx @@ -24,7 +24,6 @@ import "./endpoints.scss"; import React from "react"; import { observer } from "mobx-react"; import type { RouteComponentProps } from "react-router-dom"; -import type { Endpoint } from "../../api/endpoints/endpoint.api"; import { endpointStore } from "./endpoints.store"; import { KubeObjectListLayout } from "../kube-object"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; @@ -49,12 +48,12 @@ export class Endpoints extends React.Component { tableId="network_endpoints" className="Endpoints" store={endpointStore} sortingCallbacks={{ - [columnId.name]: (endpoint: Endpoint) => endpoint.getName(), - [columnId.namespace]: (endpoint: Endpoint) => endpoint.getNs(), - [columnId.age]: (endpoint: Endpoint) => endpoint.getTimeDiffFromNow(), + [columnId.name]: endpoint => endpoint.getName(), + [columnId.namespace]: endpoint => endpoint.getNs(), + [columnId.age]: endpoint => endpoint.getTimeDiffFromNow(), }} searchFilters={[ - (endpoint: Endpoint) => endpoint.getSearchFields() + endpoint => endpoint.getSearchFields() ]} renderHeaderTitle="Endpoints" renderTableHeader={[ @@ -64,7 +63,7 @@ export class Endpoints extends React.Component { { title: "Endpoints", className: "endpoints", id: columnId.endpoints }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(endpoint: Endpoint) => [ + renderTableContents={endpoint => [ endpoint.getName(), , endpoint.getNs(), @@ -72,7 +71,7 @@ export class Endpoints extends React.Component { endpoint.getAge(), ]} tableProps={{ - customRowHeights: (item: Endpoint, lineHeight, paddings) => { + customRowHeights: (item, lineHeight, paddings) => { const lines = item.getEndpointSubsets().length || 1; return lines * lineHeight + paddings; diff --git a/src/renderer/components/+network-ingresses/ingress-details.tsx b/src/renderer/components/+network-ingresses/ingress-details.tsx index 8e3c836b34..995e4d3b0f 100644 --- a/src/renderer/components/+network-ingresses/ingress-details.tsx +++ b/src/renderer/components/+network-ingresses/ingress-details.tsx @@ -23,16 +23,15 @@ import "./ingress-details.scss"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; -import { reaction } from "mobx"; +import { observable, reaction } from "mobx"; import { DrawerItem, DrawerTitle } from "../drawer"; import type { ILoadBalancerIngress, Ingress } from "../../api/endpoints"; import { Table, TableCell, TableHead, TableRow } from "../table"; -import { ingressStore } from "./ingress.store"; import { ResourceMetrics } from "../resource-metrics"; import type { KubeObjectDetailsProps } from "../kube-object"; import { IngressCharts } from "./ingress-charts"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; -import { getBackendServiceNamePort } from "../../api/endpoints/ingress.api"; +import { getBackendServiceNamePort, getMetricsForIngress, IIngressMetrics } from "../../api/endpoints/ingress.api"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { ClusterMetricsResourceType } from "../../../main/cluster"; @@ -41,13 +40,17 @@ interface Props extends KubeObjectDetailsProps { @observer export class IngressDetails extends React.Component { + @observable metrics: IIngressMetrics = null; + @disposeOnUnmount clean = reaction(() => this.props.object, () => { - ingressStore.reset(); + this.metrics = null; }); - componentWillUnmount() { - ingressStore.reset(); + async loadMetrics() { + const { object: ingress } = this.props; + + this.metrics = await getMetricsForIngress(ingress.getName(), ingress.getNs()); } renderPaths(ingress: Ingress) { @@ -124,7 +127,7 @@ export class IngressDetails extends React.Component { const { spec, status } = ingress; const ingressPoints = status?.loadBalancer?.ingress; - const { metrics } = ingressStore; + const { metrics } = this; const metricTabs = [ "Network", "Duration", @@ -136,7 +139,7 @@ export class IngressDetails extends React.Component {
{!isMetricHidden && ( ingressStore.loadMetrics(ingress)} + loader={this.loadMetrics} tabs={metricTabs} object={ingress} params={{ metrics }} > diff --git a/src/renderer/components/+network-ingresses/ingress.store.ts b/src/renderer/components/+network-ingresses/ingress.store.ts index 3d17aefa3c..72190a5103 100644 --- a/src/renderer/components/+network-ingresses/ingress.store.ts +++ b/src/renderer/components/+network-ingresses/ingress.store.ts @@ -18,31 +18,12 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import { observable, makeObservable } from "mobx"; -import { KubeObjectStore } from "../../kube-object.store"; -import { autoBind } from "../../utils"; -import { IIngressMetrics, Ingress, ingressApi } from "../../api/endpoints"; import { apiManager } from "../../api/api-manager"; +import { Ingress, ingressApi } from "../../api/endpoints"; +import { KubeObjectStore } from "../../kube-object.store"; export class IngressStore extends KubeObjectStore { api = ingressApi; - @observable metrics: IIngressMetrics = null; - - constructor() { - super(); - - makeObservable(this); - autoBind(this); - } - - async loadMetrics(ingress: Ingress) { - this.metrics = await this.api.getMetrics(ingress.getName(), ingress.getNs()); - } - - reset() { - this.metrics = null; - } } export const ingressStore = new IngressStore(); diff --git a/src/renderer/components/+network-ingresses/ingresses.tsx b/src/renderer/components/+network-ingresses/ingresses.tsx index 2a15bf5cb8..153a4eb62f 100644 --- a/src/renderer/components/+network-ingresses/ingresses.tsx +++ b/src/renderer/components/+network-ingresses/ingresses.tsx @@ -24,7 +24,6 @@ import "./ingresses.scss"; import React from "react"; import { observer } from "mobx-react"; import type { RouteComponentProps } from "react-router-dom"; -import type { Ingress } from "../../api/endpoints/ingress.api"; import { ingressStore } from "./ingress.store"; import { KubeObjectListLayout } from "../kube-object"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; @@ -50,13 +49,13 @@ export class Ingresses extends React.Component { tableId="network_ingresses" className="Ingresses" store={ingressStore} sortingCallbacks={{ - [columnId.name]: (ingress: Ingress) => ingress.getName(), - [columnId.namespace]: (ingress: Ingress) => ingress.getNs(), - [columnId.age]: (ingress: Ingress) => ingress.getTimeDiffFromNow(), + [columnId.name]: ingress => ingress.getName(), + [columnId.namespace]: ingress => ingress.getNs(), + [columnId.age]: ingress => ingress.getTimeDiffFromNow(), }} searchFilters={[ - (ingress: Ingress) => ingress.getSearchFields(), - (ingress: Ingress) => ingress.getPorts(), + ingress => ingress.getSearchFields(), + ingress => ingress.getPorts(), ]} renderHeaderTitle="Ingresses" renderTableHeader={[ @@ -67,7 +66,7 @@ export class Ingresses extends React.Component { { title: "Rules", className: "rules", id: columnId.rules }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(ingress: Ingress) => [ + renderTableContents={ingress => [ ingress.getName(), , ingress.getNs(), @@ -76,7 +75,7 @@ export class Ingresses extends React.Component { ingress.getAge(), ]} tableProps={{ - customRowHeights: (item: Ingress, lineHeight, paddings) => { + customRowHeights: (item, lineHeight, paddings) => { const lines = item.getRoutes().length || 1; return lines * lineHeight + paddings; diff --git a/src/renderer/components/+network-policies/network-policies.tsx b/src/renderer/components/+network-policies/network-policies.tsx index 6aa63eff23..65579eaa56 100644 --- a/src/renderer/components/+network-policies/network-policies.tsx +++ b/src/renderer/components/+network-policies/network-policies.tsx @@ -24,7 +24,6 @@ import "./network-policies.scss"; import React from "react"; import { observer } from "mobx-react"; import type { RouteComponentProps } from "react-router-dom"; -import type { NetworkPolicy } from "../../api/endpoints/network-policy.api"; import { KubeObjectListLayout } from "../kube-object"; import { networkPolicyStore } from "./network-policy.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; @@ -49,12 +48,12 @@ export class NetworkPolicies extends React.Component { tableId="network_policies" className="NetworkPolicies" store={networkPolicyStore} sortingCallbacks={{ - [columnId.name]: (item: NetworkPolicy) => item.getName(), - [columnId.namespace]: (item: NetworkPolicy) => item.getNs(), - [columnId.age]: (item: NetworkPolicy) => item.getTimeDiffFromNow(), + [columnId.name]: item => item.getName(), + [columnId.namespace]: item => item.getNs(), + [columnId.age]: item => item.getTimeDiffFromNow(), }} searchFilters={[ - (item: NetworkPolicy) => item.getSearchFields(), + item => item.getSearchFields(), ]} renderHeaderTitle="Network Policies" renderTableHeader={[ @@ -64,7 +63,7 @@ export class NetworkPolicies extends React.Component { { title: "Policy Types", className: "type", id: columnId.types }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(item: NetworkPolicy) => [ + renderTableContents={item => [ item.getName(), , item.getNs(), diff --git a/src/renderer/components/+network-services/service-details.tsx b/src/renderer/components/+network-services/service-details.tsx index 6450850076..ec10cd5f94 100644 --- a/src/renderer/components/+network-services/service-details.tsx +++ b/src/renderer/components/+network-services/service-details.tsx @@ -39,9 +39,12 @@ interface Props extends KubeObjectDetailsProps { @observer export class ServiceDetails extends React.Component { componentDidMount() { + const { object: service } = this.props; + disposeOnUnmount(this, [ kubeWatchApi.subscribeStores([endpointStore], { preload: true, + namespaces: [service.getNs()], }), ]); } diff --git a/src/renderer/components/+network-services/services.tsx b/src/renderer/components/+network-services/services.tsx index 502955b630..745236be61 100644 --- a/src/renderer/components/+network-services/services.tsx +++ b/src/renderer/components/+network-services/services.tsx @@ -24,7 +24,6 @@ import "./services.scss"; import React from "react"; import { observer } from "mobx-react"; import type { RouteComponentProps } from "react-router"; -import type { Service } from "../../api/endpoints/service.api"; import { KubeObjectListLayout } from "../kube-object"; import { Badge } from "../badge"; import { serviceStore } from "./services.store"; @@ -55,19 +54,19 @@ export class Services extends React.Component { tableId="network_services" className="Services" store={serviceStore} sortingCallbacks={{ - [columnId.name]: (service: Service) => service.getName(), - [columnId.namespace]: (service: Service) => service.getNs(), - [columnId.selector]: (service: Service) => service.getSelector(), - [columnId.ports]: (service: Service) => (service.spec.ports || []).map(({ port }) => port)[0], - [columnId.clusterIp]: (service: Service) => service.getClusterIp(), - [columnId.type]: (service: Service) => service.getType(), - [columnId.age]: (service: Service) => service.getTimeDiffFromNow(), - [columnId.status]: (service: Service) => service.getStatus(), + [columnId.name]: service => service.getName(), + [columnId.namespace]: service => service.getNs(), + [columnId.selector]: service => service.getSelector(), + [columnId.ports]: service => (service.spec.ports || []).map(({ port }) => port)[0], + [columnId.clusterIp]: service => service.getClusterIp(), + [columnId.type]: service => service.getType(), + [columnId.age]: service => service.getTimeDiffFromNow(), + [columnId.status]: service => service.getStatus(), }} searchFilters={[ - (service: Service) => service.getSearchFields(), - (service: Service) => service.getSelector().join(" "), - (service: Service) => service.getPorts().join(" "), + service => service.getSearchFields(), + service => service.getSelector().join(" "), + service => service.getPorts().join(" "), ]} renderHeaderTitle="Services" renderTableHeader={[ @@ -82,7 +81,7 @@ export class Services extends React.Component { { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} - renderTableContents={(service: Service) => [ + renderTableContents={service => [ service.getName(), , service.getNs(), diff --git a/src/renderer/components/+nodes/node-details-resources.tsx b/src/renderer/components/+nodes/node-details-resources.tsx index df613cf436..636a3f55f0 100644 --- a/src/renderer/components/+nodes/node-details-resources.tsx +++ b/src/renderer/components/+nodes/node-details-resources.tsx @@ -54,7 +54,7 @@ export class NodeDetailsResources extends React.Component { selectable scrollable={false} > - + CPU Memory Ephemeral Storage diff --git a/src/renderer/components/+nodes/node-details.scss b/src/renderer/components/+nodes/node-details.scss index 895fdadfee..8d7672fde5 100644 --- a/src/renderer/components/+nodes/node-details.scss +++ b/src/renderer/components/+nodes/node-details.scss @@ -24,9 +24,6 @@ --status-default-bg: #{$colorError}; .conditions { - .Badge { - cursor: default; - @include node-status-bgs; - } + @include node-status-bgs; } } \ No newline at end of file diff --git a/src/renderer/components/+nodes/node-details.tsx b/src/renderer/components/+nodes/node-details.tsx index a12109235b..56ce3e53e2 100644 --- a/src/renderer/components/+nodes/node-details.tsx +++ b/src/renderer/components/+nodes/node-details.tsx @@ -27,36 +27,46 @@ import kebabCase from "lodash/kebabCase"; import { disposeOnUnmount, observer } from "mobx-react"; import { DrawerItem, DrawerItemLabels } from "../drawer"; import { Badge } from "../badge"; -import { nodesStore } from "./nodes.store"; import { ResourceMetrics } from "../resource-metrics"; import { podsStore } from "../+workloads-pods/pods.store"; import type { KubeObjectDetailsProps } from "../kube-object"; -import type { Node } from "../../api/endpoints"; +import { getMetricsByNodeNames, IClusterMetrics, Node } from "../../api/endpoints"; import { NodeCharts } from "./node-charts"; -import { reaction } from "mobx"; +import { makeObservable, observable, reaction } from "mobx"; import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { ClusterMetricsResourceType } from "../../../main/cluster"; import { NodeDetailsResources } from "./node-details-resources"; import { DrawerTitle } from "../drawer/drawer-title"; +import { boundMethod } from "../../utils"; interface Props extends KubeObjectDetailsProps { } @observer export class NodeDetails extends React.Component { + @observable metrics: Partial; + + constructor(props: Props) { + super(props); + makeObservable(this); + } + @disposeOnUnmount clean = reaction(() => this.props.object.getName(), () => { - nodesStore.nodeMetrics = null; + this.metrics = null; }); async componentDidMount() { podsStore.reloadAll(); } - componentWillUnmount() { - nodesStore.nodeMetrics = null; + @boundMethod + async loadMetrics() { + const { object: node } = this.props; + + this.metrics = await getMetricsByNodeNames([node.getName()]); } render() { @@ -68,7 +78,7 @@ export class NodeDetails extends React.Component { const conditions = node.getActiveConditions(); const taints = node.getTaints(); const childPods = podsStore.getPodsByNode(node.getName()); - const metrics = nodesStore.nodeMetrics; + const { metrics } = this; const metricTabs = [ "CPU", "Memory", @@ -81,7 +91,7 @@ export class NodeDetails extends React.Component {
{!isMetricHidden && podsStore.isLoaded && ( nodesStore.loadMetrics(node.getName())} + loader={this.loadMetrics} tabs={metricTabs} object={node} params={{ metrics }} > diff --git a/src/renderer/components/+nodes/nodes-mixins.scss b/src/renderer/components/+nodes/nodes-mixins.scss index 5ac0e4400a..74979c7a9f 100644 --- a/src/renderer/components/+nodes/nodes-mixins.scss +++ b/src/renderer/components/+nodes/nodes-mixins.scss @@ -33,7 +33,7 @@ $node-status-color-list: ( @mixin node-status-bgs { @each $status, $color in $node-status-color-list { - &.#{$status} { + .#{$status} { background: $color; color: white; } diff --git a/src/renderer/components/+nodes/nodes.store.ts b/src/renderer/components/+nodes/nodes.store.ts index 676643db62..43b5e32cca 100644 --- a/src/renderer/components/+nodes/nodes.store.ts +++ b/src/renderer/components/+nodes/nodes.store.ts @@ -18,22 +18,17 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - import { sum } from "lodash"; -import { action, computed, observable, makeObservable } from "mobx"; -import { clusterApi, IClusterMetrics, INodeMetrics, Node, nodesApi } from "../../api/endpoints"; -import { autoBind } from "../../utils"; -import { KubeObjectStore } from "../../kube-object.store"; +import { computed, makeObservable } from "mobx"; + import { apiManager } from "../../api/api-manager"; +import { Node, nodesApi } from "../../api/endpoints"; +import { KubeObjectStore } from "../../kube-object.store"; +import { autoBind } from "../../utils"; export class NodesStore extends KubeObjectStore { api = nodesApi; - @observable metrics: Partial = {}; - @observable nodeMetrics: Partial = null; - @observable metricsLoading = false; - @observable metricsLoaded = false; - constructor() { super(); @@ -41,23 +36,6 @@ export class NodesStore extends KubeObjectStore { autoBind(this); } - @action - async loadUsageMetrics() { - this.metricsLoading = true; - - try { - this.metrics = await nodesApi.getMetrics(); - this.metricsLoaded = true; - } finally { - this.metricsLoading = false; - } - } - - @action - async loadMetrics(nodeName: string) { - this.nodeMetrics = await clusterApi.getMetrics([nodeName]); - } - @computed get masterNodes() { return this.items.filter(node => node.getRoleLabels().includes("master")); } @@ -66,41 +44,9 @@ export class NodesStore extends KubeObjectStore { return this.items.filter(node => !node.getRoleLabels().includes("master")); } - getLastMetricValues(node: Node, metricNames: string[]): number[] { - if (!this.metricsLoaded) { - return []; - } - const nodeName = node.getName(); - - return metricNames.map(metricName => { - try { - const metric = this.metrics[metricName]; - const result = metric.data.result.find(result => { - return [ - result.metric.node, - result.metric.instance, - result.metric.kubernetes_node, - ].includes(nodeName); - }); - - return result ? parseFloat(result.values.slice(-1)[0][1]) : 0; - } catch (e) { - return 0; - } - }); - } - getWarningsCount(): number { return sum(this.items.map((node: Node) => node.getWarningConditions().length)); } - - reset() { - super.reset(); - this.metrics = {}; - this.nodeMetrics = null; - this.metricsLoading = false; - this.metricsLoaded = false; - } } export const nodesStore = new NodesStore(); diff --git a/src/renderer/components/+nodes/nodes.tsx b/src/renderer/components/+nodes/nodes.tsx index abd389d6e7..1225ea8c15 100644 --- a/src/renderer/components/+nodes/nodes.tsx +++ b/src/renderer/components/+nodes/nodes.tsx @@ -28,7 +28,7 @@ import { TabLayout } from "../layout/tab-layout"; import { nodesStore } from "./nodes.store"; import { podsStore } from "../+workloads-pods/pods.store"; import { KubeObjectListLayout } from "../kube-object"; -import type { Node } from "../../api/endpoints/nodes.api"; +import { getMetricsForAllNodes, INodeMetrics, Node } from "../../api/endpoints/nodes.api"; import { LineProgress } from "../line-progress"; import { bytesToUnits } from "../../utils/convertMemory"; import { Tooltip, TooltipPosition } from "../tooltip"; @@ -39,6 +39,8 @@ import { Badge } from "../badge/badge"; import { kubeWatchApi } from "../../api/kube-watch-api"; import { eventStore } from "../+events/event.store"; import type { NodesRouteParams } from "../../../common/routes"; +import { observable } from "mobx"; +import isEmpty from "lodash/isEmpty"; enum columnId { name = "name", @@ -58,7 +60,8 @@ interface Props extends RouteComponentProps { @observer export class Nodes extends React.Component { - private metricsWatcher = interval(30, () => nodesStore.loadUsageMetrics()); + @observable metrics: Partial = {}; + private metricsWatcher = interval(30, async () => this.metrics = await getMetricsForAllNodes()); componentDidMount() { this.metricsWatcher.start(true); @@ -73,8 +76,32 @@ export class Nodes extends React.Component { this.metricsWatcher.stop(); } + getLastMetricValues(node: Node, metricNames: string[]): number[] { + if (isEmpty(this.metrics)) { + return []; + } + const nodeName = node.getName(); + + return metricNames.map(metricName => { + try { + const metric = this.metrics[metricName]; + const result = metric.data.result.find(result => { + return [ + result.metric.node, + result.metric.instance, + result.metric.kubernetes_node, + ].includes(nodeName); + }); + + return result ? parseFloat(result.values.slice(-1)[0][1]) : 0; + } catch (e) { + return 0; + } + }); + } + renderCpuUsage(node: Node) { - const metrics = nodesStore.getLastMetricValues(node, ["cpuUsage", "cpuCapacity"]); + const metrics = this.getLastMetricValues(node, ["cpuUsage", "cpuCapacity"]); if (!metrics || !metrics[1]) return ; const usage = metrics[0]; @@ -97,7 +124,7 @@ export class Nodes extends React.Component { } renderMemoryUsage(node: Node) { - const metrics = nodesStore.getLastMetricValues(node, ["workloadMemoryUsage", "memoryAllocatableCapacity"]); + const metrics = this.getLastMetricValues(node, ["workloadMemoryUsage", "memoryAllocatableCapacity"]); if (!metrics || !metrics[1]) return ; const usage = metrics[0]; @@ -116,7 +143,7 @@ export class Nodes extends React.Component { } renderDiskUsage(node: Node): any { - const metrics = nodesStore.getLastMetricValues(node, ["fsUsage", "fsSize"]); + const metrics = this.getLastMetricValues(node, ["fsUsage", "fsSize"]); if (!metrics || !metrics[1]) return ; const usage = metrics[0]; @@ -171,21 +198,21 @@ export class Nodes extends React.Component { dependentStores={[podsStore]} isSelectable={false} sortingCallbacks={{ - [columnId.name]: (node: Node) => node.getName(), - [columnId.cpu]: (node: Node) => nodesStore.getLastMetricValues(node, ["cpuUsage"]), - [columnId.memory]: (node: Node) => nodesStore.getLastMetricValues(node, ["memoryUsage"]), - [columnId.disk]: (node: Node) => nodesStore.getLastMetricValues(node, ["fsUsage"]), - [columnId.conditions]: (node: Node) => node.getNodeConditionText(), - [columnId.taints]: (node: Node) => node.getTaints().length, - [columnId.roles]: (node: Node) => node.getRoleLabels(), - [columnId.age]: (node: Node) => node.getTimeDiffFromNow(), - [columnId.version]: (node: Node) => node.getKubeletVersion(), + [columnId.name]: node => node.getName(), + [columnId.cpu]: node => this.getLastMetricValues(node, ["cpuUsage"]), + [columnId.memory]: node => this.getLastMetricValues(node, ["memoryUsage"]), + [columnId.disk]: node => this.getLastMetricValues(node, ["fsUsage"]), + [columnId.conditions]: node => node.getNodeConditionText(), + [columnId.taints]: node => node.getTaints().length, + [columnId.roles]: node => node.getRoleLabels(), + [columnId.age]: node => node.getTimeDiffFromNow(), + [columnId.version]: node => node.getKubeletVersion(), }} searchFilters={[ - (node: Node) => node.getSearchFields(), - (node: Node) => node.getRoleLabels(), - (node: Node) => node.getKubeletVersion(), - (node: Node) => node.getNodeConditionText(), + node => node.getSearchFields(), + node => node.getRoleLabels(), + node => node.getKubeletVersion(), + node => node.getNodeConditionText(), ]} renderHeaderTitle="Nodes" renderTableHeader={[ @@ -200,7 +227,7 @@ export class Nodes extends React.Component { { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, { title: "Conditions", className: "conditions", sortBy: columnId.conditions, id: columnId.conditions }, ]} - renderTableContents={(node: Node) => { + renderTableContents={node => { const tooltipId = `node-taints-${node.getId()}`; return [ diff --git a/src/renderer/components/+pod-security-policies/pod-security-policies.tsx b/src/renderer/components/+pod-security-policies/pod-security-policies.tsx index a74a5e8623..c578bf8e34 100644 --- a/src/renderer/components/+pod-security-policies/pod-security-policies.tsx +++ b/src/renderer/components/+pod-security-policies/pod-security-policies.tsx @@ -25,7 +25,6 @@ import React from "react"; import { observer } from "mobx-react"; import { KubeObjectListLayout } from "../kube-object"; import { podSecurityPoliciesStore } from "./pod-security-policies.store"; -import type { PodSecurityPolicy } from "../../api/endpoints"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; enum columnId { @@ -45,15 +44,15 @@ export class PodSecurityPolicies extends React.Component { className="PodSecurityPolicies" store={podSecurityPoliciesStore} sortingCallbacks={{ - [columnId.name]: (item: PodSecurityPolicy) => item.getName(), - [columnId.volumes]: (item: PodSecurityPolicy) => item.getVolumes(), - [columnId.privileged]: (item: PodSecurityPolicy) => +item.isPrivileged(), - [columnId.age]: (item: PodSecurityPolicy) => item.getTimeDiffFromNow(), + [columnId.name]: item => item.getName(), + [columnId.volumes]: item => item.getVolumes(), + [columnId.privileged]: item => +item.isPrivileged(), + [columnId.age]: item => item.getTimeDiffFromNow(), }} searchFilters={[ - (item: PodSecurityPolicy) => item.getSearchFields(), - (item: PodSecurityPolicy) => item.getVolumes(), - (item: PodSecurityPolicy) => Object.values(item.getRules()), + item => item.getSearchFields(), + item => item.getVolumes(), + item => Object.values(item.getRules()), ]} renderHeaderTitle="Pod Security Policies" renderTableHeader={[ @@ -63,7 +62,7 @@ export class PodSecurityPolicies extends React.Component { { title: "Volumes", className: "volumes", sortBy: columnId.volumes, id: columnId.volumes }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(item: PodSecurityPolicy) => { + renderTableContents={item => { return [ item.getName(), , diff --git a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx index 1bf978de68..36388088e1 100644 --- a/src/renderer/components/+preferences/add-helm-repo-dialog.tsx +++ b/src/renderer/components/+preferences/add-helm-repo-dialog.tsx @@ -107,7 +107,7 @@ export class AddHelmRepoDialog extends React.Component { async addCustomRepo() { try { - await HelmRepoManager.addĐĄustomRepo(this.helmRepo); + await HelmRepoManager.addCustomRepo(this.helmRepo); Notifications.ok(<>Helm repository {this.helmRepo.name} has added); this.props.onAddRepo(); this.close(); @@ -145,7 +145,7 @@ export class AddHelmRepoDialog extends React.Component { /> {this.renderFileInput(`Key file`, FileType.KeyFile, AddHelmRepoDialog.keyExtensions)} {this.renderFileInput(`Ca file`, FileType.CaFile, AddHelmRepoDialog.certExtensions)} - {this.renderFileInput(`Cerificate file`, FileType.CertFile, AddHelmRepoDialog.certExtensions)} + {this.renderFileInput(`Certificate file`, FileType.CertFile, AddHelmRepoDialog.certExtensions)} { this.helmRepo.name = v} /> diff --git a/src/renderer/components/+preferences/preferences.tsx b/src/renderer/components/+preferences/preferences.tsx index 9bf82f9e4f..f384d049db 100644 --- a/src/renderer/components/+preferences/preferences.tsx +++ b/src/renderer/components/+preferences/preferences.tsx @@ -40,6 +40,8 @@ import { Tab, Tabs } from "../tabs"; import { FormSwitch, Switcher } from "../switch"; import { KubeconfigSyncs } from "./kubeconfig-syncs"; import { SettingLayout } from "../layout/setting-layout"; +import { Checkbox } from "../checkbox"; +import { sentryDsn } from "../../../common/vars"; enum Pages { Application = "application", @@ -79,7 +81,7 @@ export class Preferences extends React.Component { const fragment = hash.slice(1); // hash is /^(#\w.)?$/ if (fragment) { - // ignore empty framents + // ignore empty fragments document.getElementById(fragment)?.scrollIntoView(); } }, { @@ -257,6 +259,29 @@ export class Preferences extends React.Component {

Telemetry

{telemetryExtensions.map(this.renderExtension)} + {sentryDsn ? ( + +
+ + { + UserStore.getInstance().allowErrorReporting = value; + }} + /> +
+ + Automatic error reports provide vital information about issues and application crashes. + It is highly recommended to keep this feature enabled to ensure fast turnaround for issues you might encounter. + +
+
+
+
) : + // we don't need to shows the checkbox at all if Sentry dsn is not a valid url + null + }
)} {this.activeTab == Pages.Extensions && ( diff --git a/src/renderer/components/+storage-classes/storage-classes.tsx b/src/renderer/components/+storage-classes/storage-classes.tsx index 9ccfca9448..d81d32041e 100644 --- a/src/renderer/components/+storage-classes/storage-classes.tsx +++ b/src/renderer/components/+storage-classes/storage-classes.tsx @@ -24,7 +24,6 @@ import "./storage-classes.scss"; import React from "react"; import type { RouteComponentProps } from "react-router-dom"; import { observer } from "mobx-react"; -import type { StorageClass } from "../../api/endpoints/storage-class.api"; import { KubeObjectListLayout } from "../kube-object"; import { storageClassStore } from "./storage-class.store"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; @@ -51,14 +50,14 @@ export class StorageClasses extends React.Component { className="StorageClasses" store={storageClassStore} sortingCallbacks={{ - [columnId.name]: (item: StorageClass) => item.getName(), - [columnId.age]: (item: StorageClass) => item.getTimeDiffFromNow(), - [columnId.provisioner]: (item: StorageClass) => item.provisioner, - [columnId.reclaimPolicy]: (item: StorageClass) => item.reclaimPolicy, + [columnId.name]: item => item.getName(), + [columnId.age]: item => item.getTimeDiffFromNow(), + [columnId.provisioner]: item => item.provisioner, + [columnId.reclaimPolicy]: item => item.reclaimPolicy, }} searchFilters={[ - (item: StorageClass) => item.getSearchFields(), - (item: StorageClass) => item.provisioner, + item => item.getSearchFields(), + item => item.provisioner, ]} renderHeaderTitle="Storage Classes" renderTableHeader={[ @@ -69,7 +68,7 @@ export class StorageClasses extends React.Component { { title: "Default", className: "is-default", id: columnId.default }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(storageClass: StorageClass) => [ + renderTableContents={storageClass => [ storageClass.getName(), , storageClass.provisioner, diff --git a/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx b/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx index 3066aafea8..372d6bfb6e 100644 --- a/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx +++ b/src/renderer/components/+storage-volume-claims/volume-claim-details.tsx @@ -22,17 +22,16 @@ import "./volume-claim-details.scss"; import React, { Fragment } from "react"; -import { reaction } from "mobx"; +import { action, observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { DrawerItem, DrawerTitle } from "../drawer"; import { Badge } from "../badge"; import { podsStore } from "../+workloads-pods/pods.store"; import { Link } from "react-router-dom"; -import { volumeClaimStore } from "./volume-claim.store"; import { ResourceMetrics } from "../resource-metrics"; import { VolumeClaimDiskChart } from "./volume-claim-disk-chart"; import { getDetailsUrl, KubeObjectDetailsProps, KubeObjectMeta } from "../kube-object"; -import type { PersistentVolumeClaim } from "../../api/endpoints"; +import { getMetricsForPvc, IPvcMetrics, PersistentVolumeClaim } from "../../api/endpoints"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { ClusterMetricsResourceType } from "../../../main/cluster"; @@ -41,13 +40,18 @@ interface Props extends KubeObjectDetailsProps { @observer export class PersistentVolumeClaimDetails extends React.Component { + @observable metrics: IPvcMetrics = null; + @disposeOnUnmount clean = reaction(() => this.props.object, () => { - volumeClaimStore.reset(); + this.metrics = null; }); - componentWillUnmount() { - volumeClaimStore.reset(); + @action + async loadMetrics() { + const { object: volumeClaim } = this.props; + + this.metrics = await getMetricsForPvc(volumeClaim); } render() { @@ -57,7 +61,7 @@ export class PersistentVolumeClaimDetails extends React.Component { return null; } const { storageClassName, accessModes } = volumeClaim.spec; - const { metrics } = volumeClaimStore; + const { metrics } = this; const pods = volumeClaim.getPods(podsStore.items); const metricTabs = [ "Disk" @@ -68,7 +72,7 @@ export class PersistentVolumeClaimDetails extends React.Component {
{!isMetricHidden && ( volumeClaimStore.loadMetrics(volumeClaim)} + loader={this.loadMetrics} tabs={metricTabs} object={volumeClaim} params={{ metrics }} > diff --git a/src/renderer/components/+storage-volume-claims/volume-claim.store.ts b/src/renderer/components/+storage-volume-claims/volume-claim.store.ts index d703dd8d46..4beca645a5 100644 --- a/src/renderer/components/+storage-volume-claims/volume-claim.store.ts +++ b/src/renderer/components/+storage-volume-claims/volume-claim.store.ts @@ -18,32 +18,12 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ - -import { action, observable, makeObservable } from "mobx"; -import { KubeObjectStore } from "../../kube-object.store"; -import { autoBind } from "../../utils"; -import { IPvcMetrics, PersistentVolumeClaim, pvcApi } from "../../api/endpoints"; import { apiManager } from "../../api/api-manager"; +import { PersistentVolumeClaim, pvcApi } from "../../api/endpoints"; +import { KubeObjectStore } from "../../kube-object.store"; export class VolumeClaimStore extends KubeObjectStore { api = pvcApi; - @observable metrics: IPvcMetrics = null; - - constructor() { - super(); - - makeObservable(this); - autoBind(this); - } - - @action - async loadMetrics(pvc: PersistentVolumeClaim) { - this.metrics = await pvcApi.getMetrics(pvc.getName(), pvc.getNs()); - } - - reset() { - this.metrics = null; - } } export const volumeClaimStore = new VolumeClaimStore(); diff --git a/src/renderer/components/+storage-volume-claims/volume-claims.tsx b/src/renderer/components/+storage-volume-claims/volume-claims.tsx index c222405756..60230033f9 100644 --- a/src/renderer/components/+storage-volume-claims/volume-claims.tsx +++ b/src/renderer/components/+storage-volume-claims/volume-claims.tsx @@ -25,7 +25,6 @@ import React from "react"; import { observer } from "mobx-react"; import { Link, RouteComponentProps } from "react-router-dom"; import { volumeClaimStore } from "./volume-claim.store"; -import type { PersistentVolumeClaim } from "../../api/endpoints/persistent-volume-claims.api"; import { podsStore } from "../+workloads-pods/pods.store"; import { getDetailsUrl, KubeObjectListLayout } from "../kube-object"; import { unitsToBytes } from "../../utils/convertMemory"; @@ -58,17 +57,17 @@ export class PersistentVolumeClaims extends React.Component { store={volumeClaimStore} dependentStores={[podsStore]} sortingCallbacks={{ - [columnId.name]: (pvc: PersistentVolumeClaim) => pvc.getName(), - [columnId.namespace]: (pvc: PersistentVolumeClaim) => pvc.getNs(), - [columnId.pods]: (pvc: PersistentVolumeClaim) => pvc.getPods(podsStore.items).map(pod => pod.getName()), - [columnId.status]: (pvc: PersistentVolumeClaim) => pvc.getStatus(), - [columnId.size]: (pvc: PersistentVolumeClaim) => unitsToBytes(pvc.getStorage()), - [columnId.storageClass]: (pvc: PersistentVolumeClaim) => pvc.spec.storageClassName, - [columnId.age]: (pvc: PersistentVolumeClaim) => pvc.getTimeDiffFromNow(), + [columnId.name]: pvc => pvc.getName(), + [columnId.namespace]: pvc => pvc.getNs(), + [columnId.pods]: pvc => pvc.getPods(podsStore.items).map(pod => pod.getName()), + [columnId.status]: pvc => pvc.getStatus(), + [columnId.size]: pvc => unitsToBytes(pvc.getStorage()), + [columnId.storageClass]: pvc => pvc.spec.storageClassName, + [columnId.age]: pvc => pvc.getTimeDiffFromNow(), }} searchFilters={[ - (item: PersistentVolumeClaim) => item.getSearchFields(), - (item: PersistentVolumeClaim) => item.getPods(podsStore.items).map(pod => pod.getName()), + item => item.getSearchFields(), + item => item.getPods(podsStore.items).map(pod => pod.getName()), ]} renderHeaderTitle="Persistent Volume Claims" renderTableHeader={[ @@ -81,7 +80,7 @@ export class PersistentVolumeClaims extends React.Component { { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} - renderTableContents={(pvc: PersistentVolumeClaim) => { + renderTableContents={pvc => { const pods = pvc.getPods(podsStore.items); const { storageClassName } = pvc.spec; const storageClassDetailsUrl = getDetailsUrl(storageClassApi.getUrl({ diff --git a/src/renderer/components/+storage-volumes/volumes.tsx b/src/renderer/components/+storage-volumes/volumes.tsx index 8b60fc9afc..ce1e313e9b 100644 --- a/src/renderer/components/+storage-volumes/volumes.tsx +++ b/src/renderer/components/+storage-volumes/volumes.tsx @@ -24,7 +24,6 @@ import "./volumes.scss"; import React from "react"; import { observer } from "mobx-react"; import { Link, RouteComponentProps } from "react-router-dom"; -import type { PersistentVolume } from "../../api/endpoints/persistent-volume.api"; import { getDetailsUrl, KubeObjectListLayout } from "../kube-object"; import { stopPropagation } from "../../utils"; import { volumesStore } from "./volumes.store"; @@ -54,15 +53,15 @@ export class PersistentVolumes extends React.Component { className="PersistentVolumes" store={volumesStore} sortingCallbacks={{ - [columnId.name]: (item: PersistentVolume) => item.getName(), - [columnId.storageClass]: (item: PersistentVolume) => item.getStorageClass(), - [columnId.capacity]: (item: PersistentVolume) => item.getCapacity(true), - [columnId.status]: (item: PersistentVolume) => item.getStatus(), - [columnId.age]: (item: PersistentVolume) => item.getTimeDiffFromNow(), + [columnId.name]: item => item.getName(), + [columnId.storageClass]: item => item.getStorageClass(), + [columnId.capacity]: item => item.getCapacity(true), + [columnId.status]: item => item.getStatus(), + [columnId.age]: item => item.getTimeDiffFromNow(), }} searchFilters={[ - (item: PersistentVolume) => item.getSearchFields(), - (item: PersistentVolume) => item.getClaimRefName(), + item => item.getSearchFields(), + item => item.getClaimRefName(), ]} renderHeaderTitle="Persistent Volumes" renderTableHeader={[ @@ -74,7 +73,7 @@ export class PersistentVolumes extends React.Component { { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} - renderTableContents={(volume: PersistentVolume) => { + renderTableContents={volume => { const { claimRef, storageClassName } = volume.spec; const storageClassDetailsUrl = getDetailsUrl(storageClassApi.getUrl({ name: storageClassName diff --git a/src/renderer/components/+user-management/+cluster-role-bindings/details.scss b/src/renderer/components/+user-management/+cluster-role-bindings/details.scss index 1efb4412a2..1afe80f236 100644 --- a/src/renderer/components/+user-management/+cluster-role-bindings/details.scss +++ b/src/renderer/components/+user-management/+cluster-role-bindings/details.scss @@ -19,5 +19,5 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -.RoleBindingDetails { -} \ No newline at end of file +.ClusterRoleBindingDetails { +} diff --git a/src/renderer/components/+user-management/+cluster-role-bindings/details.tsx b/src/renderer/components/+user-management/+cluster-role-bindings/details.tsx index 08087324cc..7dea8c083e 100644 --- a/src/renderer/components/+user-management/+cluster-role-bindings/details.tsx +++ b/src/renderer/components/+user-management/+cluster-role-bindings/details.tsx @@ -81,7 +81,7 @@ export class ClusterRoleBindingDetails extends React.Component { const subjects = clusterRoleBinding.getSubjects(); return ( -
+
diff --git a/src/renderer/components/+user-management/+cluster-role-bindings/view.tsx b/src/renderer/components/+user-management/+cluster-role-bindings/view.tsx index 2cf5b95037..cb1f542fd1 100644 --- a/src/renderer/components/+user-management/+cluster-role-bindings/view.tsx +++ b/src/renderer/components/+user-management/+cluster-role-bindings/view.tsx @@ -24,7 +24,6 @@ import "./view.scss"; import { observer } from "mobx-react"; import React from "react"; import type { RouteComponentProps } from "react-router"; -import type { ClusterRoleBinding } from "../../../api/endpoints"; import { KubeObjectListLayout } from "../../kube-object"; import { KubeObjectStatusIcon } from "../../kube-object-status-icon"; import { ClusterRoleBindingDialog } from "./dialog"; @@ -55,13 +54,13 @@ export class ClusterRoleBindings extends React.Component { store={clusterRoleBindingsStore} dependentStores={[clusterRolesStore, serviceAccountsStore]} sortingCallbacks={{ - [columnId.name]: (binding: ClusterRoleBinding) => binding.getName(), - [columnId.bindings]: (binding: ClusterRoleBinding) => binding.getSubjectNames(), - [columnId.age]: (binding: ClusterRoleBinding) => binding.getTimeDiffFromNow(), + [columnId.name]: binding => binding.getName(), + [columnId.bindings]: binding => binding.getSubjectNames(), + [columnId.age]: binding => binding.getTimeDiffFromNow(), }} searchFilters={[ - (binding: ClusterRoleBinding) => binding.getSearchFields(), - (binding: ClusterRoleBinding) => binding.getSubjectNames(), + binding => binding.getSearchFields(), + binding => binding.getSubjectNames(), ]} renderHeaderTitle="Cluster Role Bindings" renderTableHeader={[ @@ -70,7 +69,7 @@ export class ClusterRoleBindings extends React.Component { { title: "Bindings", className: "bindings", sortBy: columnId.bindings, id: columnId.bindings }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(binding: ClusterRoleBinding) => [ + renderTableContents={binding => [ binding.getName(), , binding.getSubjectNames(), diff --git a/src/renderer/components/+user-management/+cluster-roles/add-dialog.scss b/src/renderer/components/+user-management/+cluster-roles/add-dialog.scss index 4b94e11c4b..cc0e3fcdd6 100644 --- a/src/renderer/components/+user-management/+cluster-roles/add-dialog.scss +++ b/src/renderer/components/+user-management/+cluster-roles/add-dialog.scss @@ -19,8 +19,8 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -.AddRoleDialog { +.AddClusterRoleDialog { .AceEditor { min-height: 200px; } -} \ No newline at end of file +} diff --git a/src/renderer/components/+user-management/+cluster-roles/add-dialog.tsx b/src/renderer/components/+user-management/+cluster-roles/add-dialog.tsx index 4006334903..f6866355b3 100644 --- a/src/renderer/components/+user-management/+cluster-roles/add-dialog.tsx +++ b/src/renderer/components/+user-management/+cluster-roles/add-dialog.tsx @@ -76,12 +76,12 @@ export class AddClusterRoleDialog extends React.Component { return ( - Create ClusterRole} + Create ClusterRole} done={AddClusterRoleDialog.close} > { className="ClusterRoles" store={clusterRolesStore} sortingCallbacks={{ - [columnId.name]: (clusterRole: ClusterRole) => clusterRole.getName(), - [columnId.age]: (clusterRole: ClusterRole) => clusterRole.getTimeDiffFromNow(), + [columnId.name]: clusterRole => clusterRole.getName(), + [columnId.age]: clusterRole => clusterRole.getTimeDiffFromNow(), }} searchFilters={[ - (clusterRole: ClusterRole) => clusterRole.getSearchFields(), + clusterRole => clusterRole.getSearchFields(), ]} renderHeaderTitle="Cluster Roles" renderTableHeader={[ @@ -63,7 +62,7 @@ export class ClusterRoles extends React.Component { { className: "warning", showWithColumn: columnId.name }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(clusterRole: ClusterRole) => [ + renderTableContents={clusterRole => [ clusterRole.getName(), , clusterRole.getAge(), diff --git a/src/renderer/components/+user-management/+role-bindings/view.tsx b/src/renderer/components/+user-management/+role-bindings/view.tsx index fa32c599d6..247e72482c 100644 --- a/src/renderer/components/+user-management/+role-bindings/view.tsx +++ b/src/renderer/components/+user-management/+role-bindings/view.tsx @@ -23,7 +23,6 @@ import "./view.scss"; import { observer } from "mobx-react"; import React from "react"; import type { RouteComponentProps } from "react-router"; -import type { RoleBinding } from "../../../api/endpoints"; import { KubeObjectListLayout } from "../../kube-object"; import { KubeObjectStatusIcon } from "../../kube-object-status-icon"; import { RoleBindingDialog } from "./dialog"; @@ -55,14 +54,14 @@ export class RoleBindings extends React.Component { store={roleBindingsStore} dependentStores={[rolesStore, clusterRolesStore, serviceAccountsStore]} sortingCallbacks={{ - [columnId.name]: (binding: RoleBinding) => binding.getName(), - [columnId.namespace]: (binding: RoleBinding) => binding.getNs(), - [columnId.bindings]: (binding: RoleBinding) => binding.getSubjectNames(), - [columnId.age]: (binding: RoleBinding) => binding.getTimeDiffFromNow(), + [columnId.name]: binding => binding.getName(), + [columnId.namespace]: binding => binding.getNs(), + [columnId.bindings]: binding => binding.getSubjectNames(), + [columnId.age]: binding => binding.getTimeDiffFromNow(), }} searchFilters={[ - (binding: RoleBinding) => binding.getSearchFields(), - (binding: RoleBinding) => binding.getSubjectNames(), + binding => binding.getSearchFields(), + binding => binding.getSubjectNames(), ]} renderHeaderTitle="Role Bindings" renderTableHeader={[ @@ -72,7 +71,7 @@ export class RoleBindings extends React.Component { { title: "Bindings", className: "bindings", sortBy: columnId.bindings, id: columnId.bindings }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(binding: RoleBinding) => [ + renderTableContents={binding => [ binding.getName(), , binding.getNs(), diff --git a/src/renderer/components/+user-management/+roles/view.tsx b/src/renderer/components/+user-management/+roles/view.tsx index b6894b0571..dd71c83b93 100644 --- a/src/renderer/components/+user-management/+roles/view.tsx +++ b/src/renderer/components/+user-management/+roles/view.tsx @@ -24,7 +24,6 @@ import "./view.scss"; import { observer } from "mobx-react"; import React from "react"; import type { RouteComponentProps } from "react-router"; -import type { Role } from "../../../api/endpoints"; import { KubeObjectListLayout } from "../../kube-object"; import { KubeObjectStatusIcon } from "../../kube-object-status-icon"; import { AddRoleDialog } from "./add-dialog"; @@ -51,12 +50,12 @@ export class Roles extends React.Component { className="Roles" store={rolesStore} sortingCallbacks={{ - [columnId.name]: (role: Role) => role.getName(), - [columnId.namespace]: (role: Role) => role.getNs(), - [columnId.age]: (role: Role) => role.getTimeDiffFromNow(), + [columnId.name]: role => role.getName(), + [columnId.namespace]: role => role.getNs(), + [columnId.age]: role => role.getTimeDiffFromNow(), }} searchFilters={[ - (role: Role) => role.getSearchFields(), + role => role.getSearchFields(), ]} renderHeaderTitle="Roles" renderTableHeader={[ @@ -65,7 +64,7 @@ export class Roles extends React.Component { { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(role: Role) => [ + renderTableContents={role => [ role.getName(), , role.getNs(), diff --git a/src/renderer/components/+user-management/+service-accounts/create-dialog.tsx b/src/renderer/components/+user-management/+service-accounts/create-dialog.tsx index e812068506..1f50e717c6 100644 --- a/src/renderer/components/+user-management/+service-accounts/create-dialog.tsx +++ b/src/renderer/components/+user-management/+service-accounts/create-dialog.tsx @@ -88,8 +88,10 @@ export class CreateServiceAccountDialog extends React.Component { this.name = v.toLowerCase()} /> diff --git a/src/renderer/components/+user-management/+service-accounts/view.tsx b/src/renderer/components/+user-management/+service-accounts/view.tsx index 65798af192..c8bdaaa656 100644 --- a/src/renderer/components/+user-management/+service-accounts/view.tsx +++ b/src/renderer/components/+user-management/+service-accounts/view.tsx @@ -54,12 +54,12 @@ export class ServiceAccounts extends React.Component { tableId="access_service_accounts" className="ServiceAccounts" store={serviceAccountsStore} sortingCallbacks={{ - [columnId.name]: (account: ServiceAccount) => account.getName(), - [columnId.namespace]: (account: ServiceAccount) => account.getNs(), - [columnId.age]: (account: ServiceAccount) => account.getTimeDiffFromNow(), + [columnId.name]: account => account.getName(), + [columnId.namespace]: account => account.getNs(), + [columnId.age]: account => account.getTimeDiffFromNow(), }} searchFilters={[ - (account: ServiceAccount) => account.getSearchFields(), + account => account.getSearchFields(), ]} renderHeaderTitle="Service Accounts" renderTableHeader={[ @@ -68,7 +68,7 @@ export class ServiceAccounts extends React.Component { { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(account: ServiceAccount) => [ + renderTableContents={account => [ account.getName(), , account.getNs(), diff --git a/src/renderer/components/+workloads-cronjobs/cronjob-details.scss b/src/renderer/components/+workloads-cronjobs/cronjob-details.scss index 513afd4273..f5e3c76ec8 100644 --- a/src/renderer/components/+workloads-cronjobs/cronjob-details.scss +++ b/src/renderer/components/+workloads-cronjobs/cronjob-details.scss @@ -33,8 +33,6 @@ } .conditions { - .Badge { - @include job-condition-bgs; - } + @include job-condition-bgs; } } \ No newline at end of file diff --git a/src/renderer/components/+workloads-cronjobs/cronjob-trigger-dialog.tsx b/src/renderer/components/+workloads-cronjobs/cronjob-trigger-dialog.tsx index f4006bd89f..6938185479 100644 --- a/src/renderer/components/+workloads-cronjobs/cronjob-trigger-dialog.tsx +++ b/src/renderer/components/+workloads-cronjobs/cronjob-trigger-dialog.tsx @@ -31,7 +31,7 @@ import { Notifications } from "../notifications"; import { cssNames } from "../../utils"; import { Input } from "../input"; import { systemName, maxLength } from "../input/input_validators"; -import type { IKubeObjectMetadata } from "../../api/kube-object"; +import type { KubeObjectMetadata } from "../../api/kube-object"; interface Props extends Partial { } @@ -104,7 +104,7 @@ export class CronJobTriggerDialog extends Component { name: cronjob.metadata.name, uid: cronjob.metadata.uid, }], - } as IKubeObjectMetadata + } as KubeObjectMetadata }); close(); @@ -123,6 +123,7 @@ export class CronJobTriggerDialog extends Component { this.jobName = v.toLowerCase()} diff --git a/src/renderer/components/+workloads-cronjobs/cronjob.store.ts b/src/renderer/components/+workloads-cronjobs/cronjob.store.ts index 422cc7b69d..90ea0e0c2c 100644 --- a/src/renderer/components/+workloads-cronjobs/cronjob.store.ts +++ b/src/renderer/components/+workloads-cronjobs/cronjob.store.ts @@ -34,7 +34,7 @@ export class CronJobStore extends KubeObjectStore { } getStatuses(cronJobs?: CronJob[]) { - const status = { suspended: 0, scheduled: 0 }; + const status = { scheduled: 0, suspended: 0 }; cronJobs.forEach(cronJob => { if (cronJob.spec.suspend) { diff --git a/src/renderer/components/+workloads-cronjobs/cronjobs.tsx b/src/renderer/components/+workloads-cronjobs/cronjobs.tsx index b1a812cc4f..8dca609361 100644 --- a/src/renderer/components/+workloads-cronjobs/cronjobs.tsx +++ b/src/renderer/components/+workloads-cronjobs/cronjobs.tsx @@ -37,6 +37,7 @@ import { KubeObjectStatusIcon } from "../kube-object-status-icon"; import { ConfirmDialog } from "../confirm-dialog/confirm-dialog"; import { Notifications } from "../notifications/notifications"; import type { CronJobsRouteParams } from "../../../common/routes"; +import moment from "moment"; enum columnId { name = "name", @@ -61,16 +62,20 @@ export class CronJobs extends React.Component { className="CronJobs" store={cronJobStore} dependentStores={[jobStore, eventStore]} sortingCallbacks={{ - [columnId.name]: (cronJob: CronJob) => cronJob.getName(), - [columnId.namespace]: (cronJob: CronJob) => cronJob.getNs(), - [columnId.suspend]: (cronJob: CronJob) => cronJob.getSuspendFlag(), - [columnId.active]: (cronJob: CronJob) => cronJobStore.getActiveJobsNum(cronJob), - [columnId.lastSchedule]: (cronJob: CronJob) => cronJob.getLastScheduleTime(), - [columnId.age]: (cronJob: CronJob) => cronJob.getTimeDiffFromNow(), + [columnId.name]: cronJob => cronJob.getName(), + [columnId.namespace]: cronJob => cronJob.getNs(), + [columnId.suspend]: cronJob => cronJob.getSuspendFlag(), + [columnId.active]: cronJob => cronJobStore.getActiveJobsNum(cronJob), + [columnId.lastSchedule]: cronJob => ( + cronJob.status?.lastScheduleTime + ? moment().diff(cronJob.status.lastScheduleTime) + : 0 + ), + [columnId.age]: cronJob => cronJob.getTimeDiffFromNow(), }} searchFilters={[ - (cronJob: CronJob) => cronJob.getSearchFields(), - (cronJob: CronJob) => cronJob.getSchedule(), + cronJob => cronJob.getSearchFields(), + cronJob => cronJob.getSchedule(), ]} renderHeaderTitle="Cron Jobs" renderTableHeader={[ @@ -83,7 +88,7 @@ export class CronJobs extends React.Component { { title: "Last schedule", className: "last-schedule", sortBy: columnId.lastSchedule, id: columnId.lastSchedule }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(cronJob: CronJob) => [ + renderTableContents={cronJob => [ cronJob.getName(), , cronJob.getNs(), diff --git a/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx b/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx index 199385a1f4..4479995506 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx +++ b/src/renderer/components/+workloads-daemonsets/daemonset-details.tsx @@ -31,31 +31,42 @@ import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities" import { daemonSetStore } from "./daemonsets.store"; import { podsStore } from "../+workloads-pods/pods.store"; import type { KubeObjectDetailsProps } from "../kube-object"; -import type { DaemonSet } from "../../api/endpoints"; +import { DaemonSet, getMetricsForDaemonSets, IPodMetrics } from "../../api/endpoints"; import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; -import { reaction } from "mobx"; +import { makeObservable, observable, reaction } from "mobx"; import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { ClusterMetricsResourceType } from "../../../main/cluster"; +import { boundMethod } from "../../utils"; interface Props extends KubeObjectDetailsProps { } @observer export class DaemonSetDetails extends React.Component { + @observable metrics: IPodMetrics = null; + + constructor(props: Props) { + super(props); + makeObservable(this); + } + @disposeOnUnmount clean = reaction(() => this.props.object, () => { - daemonSetStore.reset(); + this.metrics = null; }); componentDidMount() { podsStore.reloadAll(); } - componentWillUnmount() { - daemonSetStore.reset(); + @boundMethod + async loadMetrics() { + const { object: daemonSet } = this.props; + + this.metrics = await getMetricsForDaemonSets([daemonSet], daemonSet.getNs(), ""); } render() { @@ -67,15 +78,14 @@ export class DaemonSetDetails extends React.Component { const images = daemonSet.getImages(); const nodeSelector = daemonSet.getNodeSelectors(); const childPods = daemonSetStore.getChildPods(daemonSet); - const metrics = daemonSetStore.metrics; const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.DaemonSet); return (
{!isMetricHidden && podsStore.isLoaded && ( daemonSetStore.loadMetrics(daemonSet)} - tabs={podMetricTabs} object={daemonSet} params={{ metrics }} + loader={this.loadMetrics} + tabs={podMetricTabs} object={daemonSet} params={{ metrics: this.metrics }} > @@ -110,7 +120,7 @@ export class DaemonSetDetails extends React.Component { - +
); diff --git a/src/renderer/components/+workloads-daemonsets/daemonsets.store.ts b/src/renderer/components/+workloads-daemonsets/daemonsets.store.ts index 210e6bdcdd..e8f729fde1 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonsets.store.ts +++ b/src/renderer/components/+workloads-daemonsets/daemonsets.store.ts @@ -18,19 +18,17 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { makeObservable } from "mobx"; -import { observable, makeObservable } from "mobx"; -import { KubeObjectStore } from "../../kube-object.store"; -import { autoBind } from "../../utils"; -import { DaemonSet, daemonSetApi, IPodMetrics, Pod, podsApi, PodStatus } from "../../api/endpoints"; import { podsStore } from "../+workloads-pods/pods.store"; import { apiManager } from "../../api/api-manager"; +import { DaemonSet, daemonSetApi, Pod, PodStatus } from "../../api/endpoints"; +import { KubeObjectStore } from "../../kube-object.store"; +import { autoBind } from "../../utils"; export class DaemonSetStore extends KubeObjectStore { api = daemonSetApi; - @observable metrics: IPodMetrics = null; - constructor() { super(); @@ -38,18 +36,12 @@ export class DaemonSetStore extends KubeObjectStore { autoBind(this); } - async loadMetrics(daemonSet: DaemonSet) { - const pods = this.getChildPods(daemonSet); - - this.metrics = await podsApi.getMetrics(pods, daemonSet.getNs(), ""); - } - getChildPods(daemonSet: DaemonSet): Pod[] { return podsStore.getPodsByOwnerId(daemonSet.getId()); } getStatuses(daemonSets?: DaemonSet[]) { - const status = { failed: 0, pending: 0, running: 0 }; + const status = { running: 0, failed: 0, pending: 0 }; daemonSets.forEach(daemonSet => { const pods = this.getChildPods(daemonSet); @@ -67,10 +59,6 @@ export class DaemonSetStore extends KubeObjectStore { return status; } - - reset() { - this.metrics = null; - } } export const daemonSetStore = new DaemonSetStore(); diff --git a/src/renderer/components/+workloads-daemonsets/daemonsets.tsx b/src/renderer/components/+workloads-daemonsets/daemonsets.tsx index e22c310e3d..63f04a8049 100644 --- a/src/renderer/components/+workloads-daemonsets/daemonsets.tsx +++ b/src/renderer/components/+workloads-daemonsets/daemonsets.tsx @@ -65,14 +65,14 @@ export class DaemonSets extends React.Component { className="DaemonSets" store={daemonSetStore} dependentStores={[podsStore, nodesStore, eventStore]} sortingCallbacks={{ - [columnId.name]: (daemonSet: DaemonSet) => daemonSet.getName(), - [columnId.namespace]: (daemonSet: DaemonSet) => daemonSet.getNs(), - [columnId.pods]: (daemonSet: DaemonSet) => this.getPodsLength(daemonSet), - [columnId.age]: (daemonSet: DaemonSet) => daemonSet.getTimeDiffFromNow(), + [columnId.name]: daemonSet => daemonSet.getName(), + [columnId.namespace]: daemonSet => daemonSet.getNs(), + [columnId.pods]: daemonSet => this.getPodsLength(daemonSet), + [columnId.age]: daemonSet => daemonSet.getTimeDiffFromNow(), }} searchFilters={[ - (daemonSet: DaemonSet) => daemonSet.getSearchFields(), - (daemonSet: DaemonSet) => daemonSet.getLabels(), + daemonSet => daemonSet.getSearchFields(), + daemonSet => daemonSet.getLabels(), ]} renderHeaderTitle="Daemon Sets" renderTableHeader={[ @@ -83,7 +83,7 @@ export class DaemonSets extends React.Component { { title: "Node Selector", className: "labels", id: columnId.labels }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(daemonSet: DaemonSet) => [ + renderTableContents={daemonSet => [ daemonSet.getName(), daemonSet.getNs(), this.getPodsLength(daemonSet), diff --git a/src/renderer/components/+workloads-deployments/deployment-details.scss b/src/renderer/components/+workloads-deployments/deployment-details.scss index 75ff19bbd4..490edf169f 100644 --- a/src/renderer/components/+workloads-deployments/deployment-details.scss +++ b/src/renderer/components/+workloads-deployments/deployment-details.scss @@ -21,21 +21,19 @@ .DeploymentDetails { .conditions { - .Badge { - &.available { - color: white; - background-color: $deployment-available; - } + .available { + color: white; + background-color: $deployment-available; + } - &.progressing { - color: white; - background-color: $deployment-progressing; - } + .progressing { + color: white; + background-color: $deployment-progressing; + } - &.replica-failure { - color: white; - background-color: $deployment-replicafailure; - } + .replica-failure { + color: white; + background-color: $deployment-replicafailure; } } } \ No newline at end of file diff --git a/src/renderer/components/+workloads-deployments/deployment-details.tsx b/src/renderer/components/+workloads-deployments/deployment-details.tsx index acd514749f..110c9a5679 100644 --- a/src/renderer/components/+workloads-deployments/deployment-details.tsx +++ b/src/renderer/components/+workloads-deployments/deployment-details.tsx @@ -26,8 +26,7 @@ import kebabCase from "lodash/kebabCase"; import { disposeOnUnmount, observer } from "mobx-react"; import { DrawerItem } from "../drawer"; import { Badge } from "../badge"; -import type { Deployment } from "../../api/endpoints"; -import { cssNames } from "../../utils"; +import { Deployment, getMetricsForDeployments, IPodMetrics } from "../../api/endpoints"; import { PodDetailsTolerations } from "../+workloads-pods/pod-details-tolerations"; import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities"; import { podsStore } from "../+workloads-pods/pods.store"; @@ -35,22 +34,30 @@ import type { KubeObjectDetailsProps } from "../kube-object"; import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; import { deploymentStore } from "./deployments.store"; import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; -import { reaction } from "mobx"; +import { makeObservable, observable, reaction } from "mobx"; import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; import { DeploymentReplicaSets } from "./deployment-replicasets"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { ClusterMetricsResourceType } from "../../../main/cluster"; +import { boundMethod } from "../../utils"; interface Props extends KubeObjectDetailsProps { } @observer export class DeploymentDetails extends React.Component { + @observable metrics: IPodMetrics = null; + + constructor(props: Props) { + super(props); + makeObservable(this); + } + @disposeOnUnmount clean = reaction(() => this.props.object, () => { - deploymentStore.reset(); + this.metrics = null; }); componentDidMount() { @@ -58,8 +65,11 @@ export class DeploymentDetails extends React.Component { replicaSetStore.reloadAll(); } - componentWillUnmount() { - deploymentStore.reset(); + @boundMethod + async loadMetrics() { + const { object: deployment } = this.props; + + this.metrics = await getMetricsForDeployments([deployment], deployment.getNs(), ""); } render() { @@ -71,15 +81,14 @@ export class DeploymentDetails extends React.Component { const selectors = deployment.getSelectors(); const childPods = deploymentStore.getChildPods(deployment); const replicaSets = replicaSetStore.getReplicaSetsByOwner(deployment); - const metrics = deploymentStore.metrics; const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Deployment); return (
{!isMetricHidden && podsStore.isLoaded && ( deploymentStore.loadMetrics(deployment)} - tabs={podMetricTabs} object={deployment} params={{ metrics }} + loader={this.loadMetrics} + tabs={podMetricTabs} object={deployment} params={{ metrics: this.metrics }} > @@ -118,7 +127,8 @@ export class DeploymentDetails extends React.Component {

{message}

@@ -132,7 +142,7 @@ export class DeploymentDetails extends React.Component { - +
diff --git a/src/renderer/components/+workloads-deployments/deployments.store.ts b/src/renderer/components/+workloads-deployments/deployments.store.ts index 8d99731d4f..34b6e295ad 100644 --- a/src/renderer/components/+workloads-deployments/deployments.store.ts +++ b/src/renderer/components/+workloads-deployments/deployments.store.ts @@ -18,17 +18,16 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { makeObservable } from "mobx"; -import { observable, makeObservable } from "mobx"; -import { Deployment, deploymentApi, IPodMetrics, podsApi, PodStatus } from "../../api/endpoints"; -import { KubeObjectStore } from "../../kube-object.store"; -import { autoBind } from "../../utils"; import { podsStore } from "../+workloads-pods/pods.store"; import { apiManager } from "../../api/api-manager"; +import { Deployment, deploymentApi, PodStatus } from "../../api/endpoints"; +import { KubeObjectStore } from "../../kube-object.store"; +import { autoBind } from "../../utils"; export class DeploymentStore extends KubeObjectStore { api = deploymentApi; - @observable metrics: IPodMetrics = null; constructor() { super(); @@ -43,14 +42,8 @@ export class DeploymentStore extends KubeObjectStore { ], "desc"); } - async loadMetrics(deployment: Deployment) { - const pods = this.getChildPods(deployment); - - this.metrics = await podsApi.getMetrics(pods, deployment.getNs(), ""); - } - getStatuses(deployments?: Deployment[]) { - const status = { failed: 0, pending: 0, running: 0 }; + const status = { running: 0, failed: 0, pending: 0 }; deployments.forEach(deployment => { const pods = this.getChildPods(deployment); @@ -74,10 +67,6 @@ export class DeploymentStore extends KubeObjectStore { .getByLabel(deployment.getTemplateLabels()) .filter(pod => pod.getNs() === deployment.getNs()); } - - reset() { - this.metrics = null; - } } export const deploymentStore = new DeploymentStore(); diff --git a/src/renderer/components/+workloads-deployments/deployments.tsx b/src/renderer/components/+workloads-deployments/deployments.tsx index c07dc397cc..d43670f6e7 100644 --- a/src/renderer/components/+workloads-deployments/deployments.tsx +++ b/src/renderer/components/+workloads-deployments/deployments.tsx @@ -81,15 +81,15 @@ export class Deployments extends React.Component { className="Deployments" store={deploymentStore} dependentStores={[replicaSetStore, podsStore, nodesStore, eventStore]} sortingCallbacks={{ - [columnId.name]: (deployment: Deployment) => deployment.getName(), - [columnId.namespace]: (deployment: Deployment) => deployment.getNs(), - [columnId.replicas]: (deployment: Deployment) => deployment.getReplicas(), - [columnId.age]: (deployment: Deployment) => deployment.getTimeDiffFromNow(), - [columnId.condition]: (deployment: Deployment) => deployment.getConditionsText(), + [columnId.name]: deployment => deployment.getName(), + [columnId.namespace]: deployment => deployment.getNs(), + [columnId.replicas]: deployment => deployment.getReplicas(), + [columnId.age]: deployment => deployment.getTimeDiffFromNow(), + [columnId.condition]: deployment => deployment.getConditionsText(), }} searchFilters={[ - (deployment: Deployment) => deployment.getSearchFields(), - (deployment: Deployment) => deployment.getConditionsText(), + deployment => deployment.getSearchFields(), + deployment => deployment.getConditionsText(), ]} renderHeaderTitle="Deployments" renderTableHeader={[ @@ -101,7 +101,7 @@ export class Deployments extends React.Component { { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, { title: "Conditions", className: "conditions", sortBy: columnId.condition, id: columnId.condition }, ]} - renderTableContents={(deployment: Deployment) => [ + renderTableContents={deployment => [ deployment.getName(), , deployment.getNs(), @@ -110,9 +110,7 @@ export class Deployments extends React.Component { deployment.getAge(), this.renderConditions(deployment), ]} - renderItemMenu={(item: Deployment) => { - return ; - }} + renderItemMenu={item => } /> ); } diff --git a/src/renderer/components/+workloads-jobs/job-details.scss b/src/renderer/components/+workloads-jobs/job-details.scss index ffd78bc791..8ac3a66a93 100644 --- a/src/renderer/components/+workloads-jobs/job-details.scss +++ b/src/renderer/components/+workloads-jobs/job-details.scss @@ -22,8 +22,6 @@ .JobDetails { .conditions { - .Badge { - @include job-condition-bgs; - } + @include job-condition-bgs; } } \ No newline at end of file diff --git a/src/renderer/components/+workloads-jobs/job-details.tsx b/src/renderer/components/+workloads-jobs/job-details.tsx index 02cd5b41d4..b0ba055122 100644 --- a/src/renderer/components/+workloads-jobs/job-details.tsx +++ b/src/renderer/components/+workloads-jobs/job-details.tsx @@ -33,20 +33,40 @@ import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities" import { podsStore } from "../+workloads-pods/pods.store"; import { jobStore } from "./job.store"; import { getDetailsUrl, KubeObjectDetailsProps } from "../kube-object"; -import type { Job } from "../../api/endpoints"; +import { getMetricsForJobs, IPodMetrics, Job } from "../../api/endpoints"; import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { lookupApiLink } from "../../api/kube-api"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; +import { makeObservable, observable } from "mobx"; +import { podMetricTabs, PodCharts } from "../+workloads-pods/pod-charts"; +import { ClusterMetricsResourceType } from "../../../main/cluster"; +import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; +import { ResourceMetrics } from "../resource-metrics"; +import { boundMethod } from "autobind-decorator"; interface Props extends KubeObjectDetailsProps { } @observer export class JobDetails extends React.Component { + @observable metrics: IPodMetrics = null; + + constructor(props: Props) { + super(props); + makeObservable(this); + } + async componentDidMount() { podsStore.reloadAll(); } + @boundMethod + async loadMetrics() { + const { object: job } = this.props; + + this.metrics = await getMetricsForJobs([job], job.getNs(), ""); + } + render() { const { object: job } = this.props; @@ -57,9 +77,18 @@ export class JobDetails extends React.Component { const childPods = jobStore.getChildPods(job); const ownerRefs = job.getOwnerRefs(); const condition = job.getCondition(); + const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Job); return (
+ {!isMetricHidden && ( + + + + )} { diff --git a/src/renderer/components/+workloads-jobs/job.store.ts b/src/renderer/components/+workloads-jobs/job.store.ts index 754c8562c8..643f29385a 100644 --- a/src/renderer/components/+workloads-jobs/job.store.ts +++ b/src/renderer/components/+workloads-jobs/job.store.ts @@ -46,7 +46,7 @@ export class JobStore extends KubeObjectStore { } getStatuses(jobs?: Job[]) { - const status = { failed: 0, pending: 0, running: 0, succeeded: 0 }; + const status = { succeeded: 0, running: 0, failed: 0, pending: 0 }; jobs.forEach(job => { const pods = this.getChildPods(job); diff --git a/src/renderer/components/+workloads-jobs/jobs.tsx b/src/renderer/components/+workloads-jobs/jobs.tsx index 9d0c46764f..e2640db6f6 100644 --- a/src/renderer/components/+workloads-jobs/jobs.tsx +++ b/src/renderer/components/+workloads-jobs/jobs.tsx @@ -27,7 +27,6 @@ import type { RouteComponentProps } from "react-router"; import { podsStore } from "../+workloads-pods/pods.store"; import { jobStore } from "./job.store"; import { eventStore } from "../+events/event.store"; -import type { Job } from "../../api/endpoints/job.api"; import { KubeObjectListLayout } from "../kube-object"; import kebabCase from "lodash/kebabCase"; import { KubeObjectStatusIcon } from "../kube-object-status-icon"; @@ -54,13 +53,13 @@ export class Jobs extends React.Component { className="Jobs" store={jobStore} dependentStores={[podsStore, eventStore]} sortingCallbacks={{ - [columnId.name]: (job: Job) => job.getName(), - [columnId.namespace]: (job: Job) => job.getNs(), - [columnId.conditions]: (job: Job) => job.getCondition() != null ? job.getCondition().type : "", - [columnId.age]: (job: Job) => job.getTimeDiffFromNow(), + [columnId.name]: job => job.getName(), + [columnId.namespace]: job => job.getNs(), + [columnId.conditions]: job => job.getCondition() != null ? job.getCondition().type : "", + [columnId.age]: job => job.getTimeDiffFromNow(), }} searchFilters={[ - (job: Job) => job.getSearchFields(), + job => job.getSearchFields(), ]} renderHeaderTitle="Jobs" renderTableHeader={[ @@ -71,7 +70,7 @@ export class Jobs extends React.Component { { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, { title: "Conditions", className: "conditions", sortBy: columnId.conditions, id: columnId.conditions }, ]} - renderTableContents={(job: Job) => { + renderTableContents={job => { const condition = job.getCondition(); return [ diff --git a/src/renderer/components/+workloads-overview/overview-statuses.tsx b/src/renderer/components/+workloads-overview/overview-statuses.tsx index ae67d96667..ebf62c1d98 100644 --- a/src/renderer/components/+workloads-overview/overview-statuses.tsx +++ b/src/renderer/components/+workloads-overview/overview-statuses.tsx @@ -47,7 +47,12 @@ const resources: KubeResource[] = [ export class OverviewStatuses extends React.Component { @boundMethod renderWorkload(resource: KubeResource): React.ReactElement { - const store = workloadStores[resource]; + const store = workloadStores.get(resource); + + if (!store) { + return null; + } + const items = store.getAllByNs(namespaceStore.contextNamespaces); return ( diff --git a/src/renderer/components/+workloads-pods/container-charts.tsx b/src/renderer/components/+workloads-pods/container-charts.tsx index df55fc9b59..0c810b47de 100644 --- a/src/renderer/components/+workloads-pods/container-charts.tsx +++ b/src/renderer/components/+workloads-pods/container-charts.tsx @@ -27,6 +27,7 @@ import { isMetricsEmpty, normalizeMetrics } from "../../api/endpoints/metrics.ap import { NoMetrics } from "../resource-metrics/no-metrics"; import { IResourceMetricsValue, ResourceMetricsContext } from "../resource-metrics"; import { ThemeStore } from "../../theme.store"; +import { mapValues } from "lodash"; type IContext = IResourceMetricsValue; @@ -37,10 +38,7 @@ export const ContainerCharts = observer(() => { if (!metrics) return null; if (isMetricsEmpty(metrics)) return ; - const values = Object.values(metrics) - .map(normalizeMetrics) - .map(({ data }) => data.result[0].values); - const [ + const { cpuUsage, cpuRequests, cpuLimits, @@ -48,7 +46,7 @@ export const ContainerCharts = observer(() => { memoryRequests, memoryLimits, fsUsage - ] = values; + } = mapValues(metrics, metric => normalizeMetrics(metric).data.result[0].values); const datasets = [ // CPU diff --git a/src/renderer/components/+workloads-pods/pod-charts.tsx b/src/renderer/components/+workloads-pods/pod-charts.tsx index d3aafa0214..cc05aeec55 100644 --- a/src/renderer/components/+workloads-pods/pod-charts.tsx +++ b/src/renderer/components/+workloads-pods/pod-charts.tsx @@ -19,15 +19,16 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import React, { useContext } from "react"; +import { mapValues } from "lodash"; import { observer } from "mobx-react"; -import type { IPodMetrics } from "../../api/endpoints"; -import { BarChart, cpuOptions, memoryOptions } from "../chart"; +import React, { useContext } from "react"; import { isMetricsEmpty, normalizeMetrics } from "../../api/endpoints/metrics.api"; -import { NoMetrics } from "../resource-metrics/no-metrics"; +import { BarChart, cpuOptions, memoryOptions } from "../chart"; import { IResourceMetricsValue, ResourceMetricsContext } from "../resource-metrics"; +import { NoMetrics } from "../resource-metrics/no-metrics"; + import type { WorkloadKubeObject } from "../../api/workload-kube-object"; -import { ThemeStore } from "../../theme.store"; +import type { IPodMetrics } from "../../api/endpoints"; export const podMetricTabs = [ "CPU", @@ -40,27 +41,19 @@ type IContext = IResourceMetricsValue { const { params: { metrics }, tabId, object } = useContext(ResourceMetricsContext); - const { chartCapacityColor } = ThemeStore.getInstance().activeTheme.colors; const id = object.getId(); if (!metrics) return null; if (isMetricsEmpty(metrics)) return ; const options = tabId == 0 ? cpuOptions : memoryOptions; - const values = Object.values(metrics) - .map(normalizeMetrics) - .map(({ data }) => data.result[0].values); - const [ + const { cpuUsage, - cpuRequests, - cpuLimits, memoryUsage, - memoryRequests, - memoryLimits, fsUsage, networkReceive, networkTransmit - ] = values; + } = mapValues(metrics, metric => normalizeMetrics(metric).data.result[0].values); const datasets = [ // CPU @@ -71,20 +64,6 @@ export const PodCharts = observer(() => { tooltip: `Container CPU cores usage`, borderColor: "#3D90CE", data: cpuUsage.map(([x, y]) => ({ x, y })) - }, - { - id: `${id}-cpuRequests`, - label: `Requests`, - tooltip: `Container CPU requests`, - borderColor: "#30b24d", - data: cpuRequests.map(([x, y]) => ({ x, y })) - }, - { - id: `${id}-cpuLimits`, - label: `Limits`, - tooltip: `CPU limits`, - borderColor: chartCapacityColor, - data: cpuLimits.map(([x, y]) => ({ x, y })) } ], // Memory @@ -96,20 +75,6 @@ export const PodCharts = observer(() => { borderColor: "#c93dce", data: memoryUsage.map(([x, y]) => ({ x, y })) }, - { - id: `${id}-memoryRequests`, - label: `Requests`, - tooltip: `Container memory requests`, - borderColor: "#30b24d", - data: memoryRequests.map(([x, y]) => ({ x, y })) - }, - { - id: `${id}-memoryLimits`, - label: `Limits`, - tooltip: `Container memory limits`, - borderColor: chartCapacityColor, - data: memoryLimits.map(([x, y]) => ({ x, y })) - } ], // Network [ diff --git a/src/renderer/components/+workloads-pods/pod-details-container.scss b/src/renderer/components/+workloads-pods/pod-details-container.scss index dc670c4c09..fca42c9433 100644 --- a/src/renderer/components/+workloads-pods/pod-details-container.scss +++ b/src/renderer/components/+workloads-pods/pod-details-container.scss @@ -58,8 +58,4 @@ @include pod-status-colors; } - - .Badge { - white-space: normal; - } } \ No newline at end of file diff --git a/src/renderer/components/+workloads-pods/pod-details-list.scss b/src/renderer/components/+workloads-pods/pod-details-list.scss index 05dd787a2c..04ccd2d05e 100644 --- a/src/renderer/components/+workloads-pods/pod-details-list.scss +++ b/src/renderer/components/+workloads-pods/pod-details-list.scss @@ -28,6 +28,10 @@ &.virtual { height: 500px; } + + .TableHead.sticky { + top: calc(var(--spacing) * -1); + } } .TableCell { diff --git a/src/renderer/components/+workloads-pods/pod-details.tsx b/src/renderer/components/+workloads-pods/pod-details.tsx index 2a7516b816..6249288b77 100644 --- a/src/renderer/components/+workloads-pods/pod-details.tsx +++ b/src/renderer/components/+workloads-pods/pod-details.tsx @@ -25,18 +25,17 @@ import React from "react"; import kebabCase from "lodash/kebabCase"; import { disposeOnUnmount, observer } from "mobx-react"; import { Link } from "react-router-dom"; -import { autorun, observable, reaction, makeObservable } from "mobx"; -import { IPodMetrics, nodesApi, Pod, pvcApi, configMapApi } from "../../api/endpoints"; +import { observable, reaction, makeObservable } from "mobx"; +import { IPodMetrics, nodesApi, Pod, pvcApi, configMapApi, getMetricsForPods } from "../../api/endpoints"; import { DrawerItem, DrawerTitle } from "../drawer"; import { Badge } from "../badge"; -import { boundMethod, cssNames, interval, toJS } from "../../utils"; +import { boundMethod, cssNames, toJS } from "../../utils"; import { PodDetailsContainer } from "./pod-details-container"; import { PodDetailsAffinities } from "./pod-details-affinities"; import { PodDetailsTolerations } from "./pod-details-tolerations"; import { Icon } from "../icon"; import { PodDetailsSecrets } from "./pod-details-secrets"; import { ResourceMetrics } from "../resource-metrics"; -import { podsStore } from "./pods.store"; import { getDetailsUrl, KubeObjectDetailsProps } from "../kube-object"; import { getItemMetrics } from "../../api/endpoints/metrics.api"; import { PodCharts, podMetricTabs } from "./pod-charts"; @@ -49,10 +48,9 @@ interface Props extends KubeObjectDetailsProps { @observer export class PodDetails extends React.Component { + @observable metrics: IPodMetrics; @observable containerMetrics: IPodMetrics; - private watcher = interval(60, () => this.loadMetrics()); - constructor(props: Props) { super(props); makeObservable(this); @@ -60,26 +58,19 @@ export class PodDetails extends React.Component { componentDidMount() { disposeOnUnmount(this, [ - autorun(() => { - this.containerMetrics = null; - this.loadMetrics(); - }), reaction(() => this.props.object, () => { - podsStore.reset(); + this.metrics = null; + this.containerMetrics = null; }) ]); - this.watcher.start(); - } - - componentWillUnmount() { - podsStore.reset(); } @boundMethod async loadMetrics() { const { object: pod } = this.props; - this.containerMetrics = await podsStore.loadContainerMetrics(pod); + this.metrics = await getMetricsForPods([pod], pod.getNs()); + this.containerMetrics = await getMetricsForPods([pod], pod.getNs(), "container, namespace"); } render() { @@ -92,15 +83,14 @@ export class PodDetails extends React.Component { const { nodeName } = spec; const nodeSelector = pod.getNodeSelectors(); const volumes = pod.getVolumes(); - const metrics = podsStore.metrics; const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.Pod); return (
{!isMetricHidden && ( podsStore.loadMetrics(pod)} - tabs={podMetricTabs} object={pod} params={{ metrics }} + loader={this.loadMetrics} + tabs={podMetricTabs} object={pod} params={{ metrics: this.metrics }} > @@ -142,7 +132,7 @@ export class PodDetails extends React.Component { ); diff --git a/src/renderer/components/+workloads-pods/pod-tolerations.tsx b/src/renderer/components/+workloads-pods/pod-tolerations.tsx index 5ca65cc2b3..28793e0ea5 100644 --- a/src/renderer/components/+workloads-pods/pod-tolerations.tsx +++ b/src/renderer/components/+workloads-pods/pod-tolerations.tsx @@ -35,17 +35,11 @@ enum sortBy { Operator = "operator", Effect = "effect", Seconds = "seconds", + Value = "value", } -const sortingCallbacks = { - [sortBy.Key]: (toleration: IToleration) => toleration.key, - [sortBy.Operator]: (toleration: IToleration) => toleration.operator, - [sortBy.Effect]: (toleration: IToleration) => toleration.effect, - [sortBy.Seconds]: (toleration: IToleration) => toleration.tolerationSeconds, -}; - const getTableRow = (toleration: IToleration) => { - const { key, operator, effect, tolerationSeconds } = toleration; + const { key, operator, effect, tolerationSeconds, value } = toleration; return ( { > {key} {operator} + {value} {effect} {tolerationSeconds} @@ -66,20 +61,25 @@ export function PodTolerations({ tolerations }: Props) { toleration.key, + [sortBy.Operator]: toleration => toleration.operator, + [sortBy.Effect]: toleration => toleration.effect, + [sortBy.Seconds]: toleration => toleration.tolerationSeconds, + }} sortSyncWithUrl={false} className="PodTolerations" + renderRow={getTableRow} > Key Operator + Value Effect Seconds - { - tolerations.map(getTableRow) - }
); } diff --git a/src/renderer/components/+workloads-pods/pods.store.ts b/src/renderer/components/+workloads-pods/pods.store.ts index f9d5d87464..9c2b925400 100644 --- a/src/renderer/components/+workloads-pods/pods.store.ts +++ b/src/renderer/components/+workloads-pods/pods.store.ts @@ -20,17 +20,16 @@ */ import countBy from "lodash/countBy"; -import { action, observable, makeObservable } from "mobx"; +import { observable, makeObservable } from "mobx"; import { KubeObjectStore } from "../../kube-object.store"; import { autoBind, cpuUnitsToNumber, unitsToBytes } from "../../utils"; -import { IPodMetrics, Pod, PodMetrics, podMetricsApi, podsApi } from "../../api/endpoints"; +import { Pod, PodMetrics, podMetricsApi, podsApi } from "../../api/endpoints"; import { apiManager } from "../../api/api-manager"; import type { WorkloadKubeObject } from "../../api/workload-kube-object"; export class PodsStore extends KubeObjectStore { api = podsApi; - @observable metrics: IPodMetrics = null; @observable kubeMetrics = observable.array([]); constructor() { @@ -40,22 +39,11 @@ export class PodsStore extends KubeObjectStore { autoBind(this); } - @action - async loadMetrics(pod: Pod) { - this.metrics = await podsApi.getMetrics([pod], pod.getNs()); - } - - loadContainerMetrics(pod: Pod) { - return podsApi.getMetrics([pod], pod.getNs(), "container, namespace"); - } - async loadKubeMetrics(namespace?: string) { try { - const metrics = await podMetricsApi.list({ namespace }); - - this.kubeMetrics.replace(metrics); + this.kubeMetrics.replace(await podMetricsApi.list({ namespace })); } catch (error) { - console.error("loadKubeMetrics failed", error); + console.warn("loadKubeMetrics failed", error); } } @@ -82,7 +70,7 @@ export class PodsStore extends KubeObjectStore { } getStatuses(pods: Pod[]) { - return countBy(pods.map(pod => pod.getStatus())); + return countBy(pods.map(pod => pod.getStatus()).sort().reverse()); } getPodKubeMetrics(pod: Pod) { @@ -113,10 +101,6 @@ export class PodsStore extends KubeObjectStore { }; }, empty); } - - reset() { - this.metrics = null; - } } export const podsStore = new PodsStore(); diff --git a/src/renderer/components/+workloads-pods/pods.tsx b/src/renderer/components/+workloads-pods/pods.tsx index 807d531b20..b91a8a98e8 100644 --- a/src/renderer/components/+workloads-pods/pods.tsx +++ b/src/renderer/components/+workloads-pods/pods.tsx @@ -97,21 +97,21 @@ export class Pods extends React.Component { tableId = "workloads_pods" isConfigurable sortingCallbacks={{ - [columnId.name]: (pod: Pod) => pod.getName(), - [columnId.namespace]: (pod: Pod) => pod.getNs(), - [columnId.containers]: (pod: Pod) => pod.getContainers().length, - [columnId.restarts]: (pod: Pod) => pod.getRestartsCount(), - [columnId.owners]: (pod: Pod) => pod.getOwnerRefs().map(ref => ref.kind), - [columnId.qos]: (pod: Pod) => pod.getQosClass(), - [columnId.node]: (pod: Pod) => pod.getNodeName(), - [columnId.age]: (pod: Pod) => pod.getTimeDiffFromNow(), - [columnId.status]: (pod: Pod) => pod.getStatusMessage(), + [columnId.name]: pod => pod.getName(), + [columnId.namespace]: pod => pod.getNs(), + [columnId.containers]: pod => pod.getContainers().length, + [columnId.restarts]: pod => pod.getRestartsCount(), + [columnId.owners]: pod => pod.getOwnerRefs().map(ref => ref.kind), + [columnId.qos]: pod => pod.getQosClass(), + [columnId.node]: pod => pod.getNodeName(), + [columnId.age]: pod => pod.getTimeDiffFromNow(), + [columnId.status]: pod => pod.getStatusMessage(), }} searchFilters={[ - (pod: Pod) => pod.getSearchFields(), - (pod: Pod) => pod.getStatusMessage(), - (pod: Pod) => pod.status.podIP, - (pod: Pod) => pod.getNodeName(), + pod => pod.getSearchFields(), + pod => pod.getStatusMessage(), + pod => pod.status.podIP, + pod => pod.getNodeName(), ]} renderHeaderTitle="Pods" renderTableHeader={[ @@ -126,8 +126,8 @@ export class Pods extends React.Component { { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, { title: "Status", className: "status", sortBy: columnId.status, id: columnId.status }, ]} - renderTableContents={(pod: Pod) => [ - , + renderTableContents={pod => [ + , , pod.getNs(), this.renderContainersStatus(pod), @@ -145,7 +145,7 @@ export class Pods extends React.Component { ); }), pod.getNodeName() ? - + {pod.getNodeName()} diff --git a/src/renderer/components/+workloads-replicasets/replicaset-details.tsx b/src/renderer/components/+workloads-replicasets/replicaset-details.tsx index 7818be6765..7c06b56840 100644 --- a/src/renderer/components/+workloads-replicasets/replicaset-details.tsx +++ b/src/renderer/components/+workloads-replicasets/replicaset-details.tsx @@ -21,7 +21,7 @@ import "./replicaset-details.scss"; import React from "react"; -import { reaction } from "mobx"; +import { makeObservable, observable, reaction } from "mobx"; import { DrawerItem } from "../drawer"; import { Badge } from "../badge"; import { replicaSetStore } from "./replicasets.store"; @@ -31,37 +31,48 @@ import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities" import { disposeOnUnmount, observer } from "mobx-react"; import { podsStore } from "../+workloads-pods/pods.store"; import type { KubeObjectDetailsProps } from "../kube-object"; -import type { ReplicaSet } from "../../api/endpoints"; +import { getMetricsForReplicaSets, IPodMetrics, ReplicaSet } from "../../api/endpoints"; import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { ClusterMetricsResourceType } from "../../../main/cluster"; +import { boundMethod } from "../../utils"; interface Props extends KubeObjectDetailsProps { } @observer export class ReplicaSetDetails extends React.Component { + @observable metrics: IPodMetrics = null; + + constructor(props: Props) { + super(props); + makeObservable(this); + } + @disposeOnUnmount clean = reaction(() => this.props.object, () => { - replicaSetStore.reset(); + this.metrics = null; }); async componentDidMount() { podsStore.reloadAll(); } - componentWillUnmount() { - replicaSetStore.reset(); + @boundMethod + async loadMetrics() { + const { object: replicaSet } = this.props; + + this.metrics = await getMetricsForReplicaSets([replicaSet], replicaSet.getNs(), ""); } render() { const { object: replicaSet } = this.props; if (!replicaSet) return null; - const { metrics } = replicaSetStore; + const { metrics } = this; const { status } = replicaSet; const { availableReplicas, replicas } = status; const selectors = replicaSet.getSelectors(); @@ -74,7 +85,7 @@ export class ReplicaSetDetails extends React.Component {
{!isMetricHidden && podsStore.isLoaded && ( replicaSetStore.loadMetrics(replicaSet)} + loader={this.loadMetrics} tabs={podMetricTabs} object={replicaSet} params={{ metrics }} > diff --git a/src/renderer/components/+workloads-replicasets/replicasets.store.ts b/src/renderer/components/+workloads-replicasets/replicasets.store.ts index 66794bc7ad..5daa57e45d 100644 --- a/src/renderer/components/+workloads-replicasets/replicasets.store.ts +++ b/src/renderer/components/+workloads-replicasets/replicasets.store.ts @@ -18,18 +18,17 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { makeObservable } from "mobx"; -import { observable, makeObservable } from "mobx"; -import { autoBind } from "../../utils"; -import { KubeObjectStore } from "../../kube-object.store"; -import { Deployment, IPodMetrics, podsApi, ReplicaSet, replicaSetApi } from "../../api/endpoints"; import { podsStore } from "../+workloads-pods/pods.store"; import { apiManager } from "../../api/api-manager"; +import { Deployment, ReplicaSet, replicaSetApi } from "../../api/endpoints"; import { PodStatus } from "../../api/endpoints/pods.api"; +import { KubeObjectStore } from "../../kube-object.store"; +import { autoBind } from "../../utils"; export class ReplicaSetStore extends KubeObjectStore { api = replicaSetApi; - @observable metrics: IPodMetrics = null; constructor() { super(); @@ -38,18 +37,12 @@ export class ReplicaSetStore extends KubeObjectStore { autoBind(this); } - async loadMetrics(replicaSet: ReplicaSet) { - const pods = this.getChildPods(replicaSet); - - this.metrics = await podsApi.getMetrics(pods, replicaSet.getNs(), ""); - } - getChildPods(replicaSet: ReplicaSet) { return podsStore.getPodsByOwnerId(replicaSet.getId()); } getStatuses(replicaSets: ReplicaSet[]) { - const status = { failed: 0, pending: 0, running: 0 }; + const status = { running: 0, failed: 0, pending: 0 }; replicaSets.forEach(replicaSet => { const pods = this.getChildPods(replicaSet); @@ -73,10 +66,6 @@ export class ReplicaSetStore extends KubeObjectStore { !!replicaSet.getOwnerRefs().find(owner => owner.uid === deployment.getId()) ); } - - reset() { - this.metrics = null; - } } export const replicaSetStore = new ReplicaSetStore(); diff --git a/src/renderer/components/+workloads-replicasets/replicasets.tsx b/src/renderer/components/+workloads-replicasets/replicasets.tsx index 51d63fec55..447280899f 100644 --- a/src/renderer/components/+workloads-replicasets/replicasets.tsx +++ b/src/renderer/components/+workloads-replicasets/replicasets.tsx @@ -55,15 +55,15 @@ export class ReplicaSets extends React.Component { tableId="workload_replicasets" className="ReplicaSets" store={replicaSetStore} sortingCallbacks={{ - [columnId.name]: (replicaSet: ReplicaSet) => replicaSet.getName(), - [columnId.namespace]: (replicaSet: ReplicaSet) => replicaSet.getNs(), - [columnId.desired]: (replicaSet: ReplicaSet) => replicaSet.getDesired(), - [columnId.current]: (replicaSet: ReplicaSet) => replicaSet.getCurrent(), - [columnId.ready]: (replicaSet: ReplicaSet) => replicaSet.getReady(), - [columnId.age]: (replicaSet: ReplicaSet) => replicaSet.getTimeDiffFromNow(), + [columnId.name]: replicaSet => replicaSet.getName(), + [columnId.namespace]: replicaSet => replicaSet.getNs(), + [columnId.desired]: replicaSet => replicaSet.getDesired(), + [columnId.current]: replicaSet => replicaSet.getCurrent(), + [columnId.ready]: replicaSet => replicaSet.getReady(), + [columnId.age]: replicaSet => replicaSet.getTimeDiffFromNow(), }} searchFilters={[ - (replicaSet: ReplicaSet) => replicaSet.getSearchFields(), + replicaSet => replicaSet.getSearchFields(), ]} renderHeaderTitle="Replica Sets" renderTableHeader={[ @@ -75,7 +75,7 @@ export class ReplicaSets extends React.Component { { title: "Ready", className: "ready", sortBy: columnId.ready, id: columnId.ready }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(replicaSet: ReplicaSet) => [ + renderTableContents={replicaSet => [ replicaSet.getName(), , replicaSet.getNs(), diff --git a/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx b/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx index dafe8c16b6..f16578c703 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulset-details.tsx @@ -23,7 +23,7 @@ import "./statefulset-details.scss"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; -import { reaction } from "mobx"; +import { makeObservable, observable, reaction } from "mobx"; import { Badge } from "../badge"; import { DrawerItem } from "../drawer"; import { PodDetailsStatuses } from "../+workloads-pods/pod-details-statuses"; @@ -32,30 +32,41 @@ import { PodDetailsAffinities } from "../+workloads-pods/pod-details-affinities" import { podsStore } from "../+workloads-pods/pods.store"; import { statefulSetStore } from "./statefulset.store"; import type { KubeObjectDetailsProps } from "../kube-object"; -import type { StatefulSet } from "../../api/endpoints"; +import { getMetricsForStatefulSets, IPodMetrics, StatefulSet } from "../../api/endpoints"; import { ResourceMetrics, ResourceMetricsText } from "../resource-metrics"; import { PodCharts, podMetricTabs } from "../+workloads-pods/pod-charts"; import { PodDetailsList } from "../+workloads-pods/pod-details-list"; import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { ClusterMetricsResourceType } from "../../../main/cluster"; +import { boundMethod } from "../../utils"; interface Props extends KubeObjectDetailsProps { } @observer export class StatefulSetDetails extends React.Component { + @observable metrics: IPodMetrics = null; + + constructor(props: Props) { + super(props); + makeObservable(this); + } + @disposeOnUnmount clean = reaction(() => this.props.object, () => { - statefulSetStore.reset(); + this.metrics = null; }); componentDidMount() { podsStore.reloadAll(); } - componentWillUnmount() { - statefulSetStore.reset(); + @boundMethod + async loadMetrics() { + const { object: statefulSet } = this.props; + + this.metrics = await getMetricsForStatefulSets([statefulSet], statefulSet.getNs(), ""); } render() { @@ -66,15 +77,14 @@ export class StatefulSetDetails extends React.Component { const selectors = statefulSet.getSelectors(); const nodeSelector = statefulSet.getNodeSelectors(); const childPods = statefulSetStore.getChildPods(statefulSet); - const metrics = statefulSetStore.metrics; const isMetricHidden = getActiveClusterEntity()?.isMetricHidden(ClusterMetricsResourceType.StatefulSet); return (
{!isMetricHidden && podsStore.isLoaded && ( statefulSetStore.loadMetrics(statefulSet)} - tabs={podMetricTabs} object={statefulSet} params={{ metrics }} + loader={() => this.loadMetrics} + tabs={podMetricTabs} object={statefulSet} params={{ metrics: this.metrics }} > @@ -108,7 +118,7 @@ export class StatefulSetDetails extends React.Component { - +
); diff --git a/src/renderer/components/+workloads-statefulsets/statefulset.store.ts b/src/renderer/components/+workloads-statefulsets/statefulset.store.ts index 572e39447a..73c03a1b60 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulset.store.ts +++ b/src/renderer/components/+workloads-statefulsets/statefulset.store.ts @@ -18,17 +18,16 @@ * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import { makeObservable } from "mobx"; -import { observable, makeObservable } from "mobx"; -import { autoBind } from "../../utils"; -import { KubeObjectStore } from "../../kube-object.store"; -import { IPodMetrics, podsApi, PodStatus, StatefulSet, statefulSetApi } from "../../api/endpoints"; import { podsStore } from "../+workloads-pods/pods.store"; import { apiManager } from "../../api/api-manager"; +import { PodStatus, StatefulSet, statefulSetApi } from "../../api/endpoints"; +import { KubeObjectStore } from "../../kube-object.store"; +import { autoBind } from "../../utils"; export class StatefulSetStore extends KubeObjectStore { api = statefulSetApi; - @observable metrics: IPodMetrics = null; constructor() { super(); @@ -37,19 +36,12 @@ export class StatefulSetStore extends KubeObjectStore { autoBind(this); } - - async loadMetrics(statefulSet: StatefulSet) { - const pods = this.getChildPods(statefulSet); - - this.metrics = await podsApi.getMetrics(pods, statefulSet.getNs(), ""); - } - getChildPods(statefulSet: StatefulSet) { return podsStore.getPodsByOwnerId(statefulSet.getId()); } getStatuses(statefulSets: StatefulSet[]) { - const status = { failed: 0, pending: 0, running: 0 }; + const status = { running: 0, failed: 0, pending: 0 }; statefulSets.forEach(statefulSet => { const pods = this.getChildPods(statefulSet); @@ -67,10 +59,6 @@ export class StatefulSetStore extends KubeObjectStore { return status; } - - reset() { - this.metrics = null; - } } export const statefulSetStore = new StatefulSetStore(); diff --git a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx index 82b1fff151..ff6441f33a 100644 --- a/src/renderer/components/+workloads-statefulsets/statefulsets.tsx +++ b/src/renderer/components/+workloads-statefulsets/statefulsets.tsx @@ -64,13 +64,13 @@ export class StatefulSets extends React.Component { className="StatefulSets" store={statefulSetStore} dependentStores={[podsStore, nodesStore, eventStore]} sortingCallbacks={{ - [columnId.name]: (statefulSet: StatefulSet) => statefulSet.getName(), - [columnId.namespace]: (statefulSet: StatefulSet) => statefulSet.getNs(), - [columnId.age]: (statefulSet: StatefulSet) => statefulSet.getTimeDiffFromNow(), - [columnId.replicas]: (statefulSet: StatefulSet) => statefulSet.getReplicas(), + [columnId.name]: statefulSet => statefulSet.getName(), + [columnId.namespace]: statefulSet => statefulSet.getNs(), + [columnId.age]: statefulSet => statefulSet.getTimeDiffFromNow(), + [columnId.replicas]: statefulSet => statefulSet.getReplicas(), }} searchFilters={[ - (statefulSet: StatefulSet) => statefulSet.getSearchFields(), + statefulSet => statefulSet.getSearchFields(), ]} renderHeaderTitle="Stateful Sets" renderTableHeader={[ @@ -81,7 +81,7 @@ export class StatefulSets extends React.Component { { className: "warning", showWithColumn: columnId.replicas }, { title: "Age", className: "age", sortBy: columnId.age, id: columnId.age }, ]} - renderTableContents={(statefulSet: StatefulSet) => [ + renderTableContents={statefulSet => [ statefulSet.getName(), statefulSet.getNs(), this.renderPods(statefulSet), @@ -89,9 +89,7 @@ export class StatefulSets extends React.Component { , statefulSet.getAge(), ]} - renderItemMenu={(item: StatefulSet) => { - return ; - }} + renderItemMenu={item => } /> ); } diff --git a/src/renderer/components/+workloads/workloads-mixins.scss b/src/renderer/components/+workloads/workloads-mixins.scss index 61a56f03f8..03c05c2d48 100644 --- a/src/renderer/components/+workloads/workloads-mixins.scss +++ b/src/renderer/components/+workloads/workloads-mixins.scss @@ -98,7 +98,7 @@ $cronjob-condition-color-list: ( @mixin job-condition-bgs { @each $condition, $color in $job-condition-color-list { - &.#{$condition} { + .#{$condition} { color: white; background: $color; } diff --git a/src/renderer/components/+workloads/workloads.stores.ts b/src/renderer/components/+workloads/workloads.stores.ts index a06c17200d..e53ea7cb03 100644 --- a/src/renderer/components/+workloads/workloads.stores.ts +++ b/src/renderer/components/+workloads/workloads.stores.ts @@ -28,13 +28,14 @@ import { jobStore } from "../+workloads-jobs/job.store"; import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; import type { KubeResource } from "../../../common/rbac"; import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; +import type { KubeObject } from "../../api/kube-object"; -export const workloadStores: Partial> = { - "pods": podsStore, - "deployments": deploymentStore, - "daemonsets": daemonSetStore, - "statefulsets": statefulSetStore, - "replicasets": replicaSetStore, - "jobs": jobStore, - "cronjobs": cronJobStore, -}; +export const workloadStores = new Map>([ + ["pods", podsStore], + ["deployments", deploymentStore], + ["daemonsets", daemonSetStore], + ["statefulsets", statefulSetStore], + ["replicasets", replicaSetStore], + ["jobs", jobStore], + ["cronjobs", cronJobStore], +]); diff --git a/src/renderer/components/__tests__/cronjob.store.test.ts b/src/renderer/components/__tests__/cronjob.store.test.ts new file mode 100644 index 0000000000..fcd8f93023 --- /dev/null +++ b/src/renderer/components/__tests__/cronjob.store.test.ts @@ -0,0 +1,115 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +import { cronJobStore } from "../+workloads-cronjobs/cronjob.store"; +import { CronJob } from "../../api/endpoints"; + +const spec = { + schedule: "test", + concurrencyPolicy: "test", + suspend: true, + jobTemplate: { + metadata: {}, + spec: { + template: { + metadata: {}, + spec: { + containers: [] as any, + restartPolicy: "restart", + terminationGracePeriodSeconds: 1, + dnsPolicy: "no", + hostPID: true, + schedulerName: "string" + } + } + } + }, + successfulJobsHistoryLimit: 1, + failedJobsHistoryLimit: 1 +}; + +const scheduledCronJob = new CronJob({ + apiVersion: "foo", + kind: "CronJob", + metadata: { + name: "scheduledCronJob", + resourceVersion: "scheduledCronJob", + uid: "scheduledCronJob", + namespace: "default", + }, +}); + +const suspendedCronJob = new CronJob({ + apiVersion: "foo", + kind: "CronJob", + metadata: { + name: "suspendedCronJob", + resourceVersion: "suspendedCronJob", + uid: "suspendedCronJob", + namespace: "default", + } +}); + +const otherSuspendedCronJob = new CronJob({ + apiVersion: "foo", + kind: "CronJob", + metadata: { + name: "otherSuspendedCronJob", + resourceVersion: "otherSuspendedCronJob", + uid: "otherSuspendedCronJob", + namespace: "default", + }, +}); + +scheduledCronJob.spec = { ...spec }; +suspendedCronJob.spec = { ...spec }; +otherSuspendedCronJob.spec = { ...spec }; +scheduledCronJob.spec.suspend = false; + +describe("CronJob Store tests", () => { + it("gets CronJob statuses in proper sorting order", () => { + const statuses = Object.entries(cronJobStore.getStatuses([ + suspendedCronJob, + otherSuspendedCronJob, + scheduledCronJob + ])); + + expect(statuses).toEqual([ + ["scheduled", 1], + ["suspended", 2], + ]); + }); + + it("returns 0 for other statuses", () => { + let statuses = Object.entries(cronJobStore.getStatuses([scheduledCronJob])); + + expect(statuses).toEqual([ + ["scheduled", 1], + ["suspended", 0], + ]); + + statuses = Object.entries(cronJobStore.getStatuses([suspendedCronJob])); + + expect(statuses).toEqual([ + ["scheduled", 0], + ["suspended", 1], + ]); + }); +}); diff --git a/src/renderer/components/__tests__/daemonset.store.test.ts b/src/renderer/components/__tests__/daemonset.store.test.ts new file mode 100644 index 0000000000..00dcce5468 --- /dev/null +++ b/src/renderer/components/__tests__/daemonset.store.test.ts @@ -0,0 +1,181 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { observable } from "mobx"; +import { daemonSetStore } from "../+workloads-daemonsets/daemonsets.store"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { DaemonSet, Pod } from "../../api/endpoints"; + +const runningDaemonSet = new DaemonSet({ + apiVersion: "foo", + kind: "DaemonSet", + metadata: { + name: "runningDaemonSet", + resourceVersion: "runningDaemonSet", + uid: "runningDaemonSet", + namespace: "default", + }, +}); + +const failedDaemonSet = new DaemonSet({ + apiVersion: "foo", + kind: "DaemonSet", + metadata: { + name: "failedDaemonSet", + resourceVersion: "failedDaemonSet", + uid: "failedDaemonSet", + namespace: "default", + }, +}); + +const pendingDaemonSet = new DaemonSet({ + apiVersion: "foo", + kind: "DaemonSet", + metadata: { + name: "pendingDaemonSet", + resourceVersion: "pendingDaemonSet", + uid: "pendingDaemonSet", + namespace: "default", + }, +}); + +const runningPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar", + resourceVersion: "foobar", + uid: "foobar", + ownerReferences: [{ + uid: "runningDaemonSet", + }], + namespace: "default" + }, +}); + +runningPod.status = { + phase: "Running", + conditions: [ + { + type: "Initialized", + status: "True", + lastProbeTime: 1, + lastTransitionTime: "1", + }, + { + type: "Ready", + status: "True", + lastProbeTime: 1, + lastTransitionTime: "1", + } + ], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", + containerStatuses: [], + initContainerStatuses: [], +}; + +const pendingPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-pending", + resourceVersion: "foobar", + uid: "foobar-pending", + ownerReferences: [{ + uid: "pendingDaemonSet", + }], + namespace: "default" + }, +}); + +const failedPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-failed", + resourceVersion: "foobar", + uid: "foobar-failed", + ownerReferences: [{ + uid: "failedDaemonSet", + }], + namespace: "default" + }, +}); + +failedPod.status = { + phase: "Failed", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", +}; + +describe("DaemonSet Store tests", () => { + beforeAll(() => { + podsStore.items = observable.array([ + runningPod, + failedPod, + pendingPod + ]); + }); + + it("gets DaemonSet statuses in proper sorting order", () => { + const statuses = Object.entries(daemonSetStore.getStatuses([ + failedDaemonSet, + runningDaemonSet, + pendingDaemonSet + ])); + + expect(statuses).toEqual([ + ["running", 1], + ["failed", 1], + ["pending", 1], + ]); + }); + + it("returns 0 for other statuses", () => { + let statuses = Object.entries(daemonSetStore.getStatuses([runningDaemonSet])); + + expect(statuses).toEqual([ + ["running", 1], + ["failed", 0], + ["pending", 0], + ]); + + statuses = Object.entries(daemonSetStore.getStatuses([failedDaemonSet])); + + expect(statuses).toEqual([ + ["running", 0], + ["failed", 1], + ["pending", 0], + ]); + + statuses = Object.entries(daemonSetStore.getStatuses([pendingDaemonSet])); + + expect(statuses).toEqual([ + ["running", 0], + ["failed", 0], + ["pending", 1], + ]); + }); +}); diff --git a/src/renderer/components/__tests__/deployments.store.test.ts b/src/renderer/components/__tests__/deployments.store.test.ts new file mode 100644 index 0000000000..6d7552dd6b --- /dev/null +++ b/src/renderer/components/__tests__/deployments.store.test.ts @@ -0,0 +1,265 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { observable } from "mobx"; +import { deploymentStore } from "../+workloads-deployments/deployments.store"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { Deployment, Pod } from "../../api/endpoints"; + +const spec = { + containers: [{ + name: "some", + image: "someimage", + resources: { + requests: { + cpu: "2", + memory: "2Gi" + }, + }, + terminationMessagePath: "test", + terminationMessagePolicy: "test", + imagePullPolicy: "test", + }], + restartPolicy: "restart", + terminationGracePeriodSeconds: 1200, + dnsPolicy: "dns", + serviceAccountName: "test", + serviceAccount: "test", + securityContext: {}, + schedulerName: "test" +}; + +const runningDeployment = new Deployment({ + apiVersion: "foo", + kind: "Deployment", + metadata: { + name: "foobar", + resourceVersion: "foobar", + uid: "foobar", + namespace: "default", + }, +}); + +runningDeployment.spec = { + replicas: 1, + selector: { matchLabels: {} }, + strategy: { + type: "test", + rollingUpdate: { + maxSurge: 1, + maxUnavailable: 1 + } + }, + template: { + metadata: { + labels: { + "name": "kube-state-metrics" + } + }, + spec + } +}; + +const failedDeployment = new Deployment({ + apiVersion: "foo", + kind: "Deployment", + metadata: { + name: "failedDeployment", + resourceVersion: "failedDeployment", + uid: "failedDeployment", + namespace: "default", + }, +}); + +failedDeployment.spec = { + replicas: 1, + selector: { matchLabels: {} }, + strategy: { + type: "test", + rollingUpdate: { + maxSurge: 1, + maxUnavailable: 1 + } + }, + template: { + metadata: { + labels: { + "name": "failedpods" + } + }, + spec + } +}; + +const pendingDeployment = new Deployment({ + apiVersion: "foo", + kind: "Deployment", + metadata: { + name: "pendingDeployment", + resourceVersion: "pendingDeployment", + uid: "pendingDeployment", + namespace: "default", + }, +}); + +pendingDeployment.spec = { + replicas: 1, + selector: { matchLabels: {} }, + strategy: { + type: "test", + rollingUpdate: { + maxSurge: 1, + maxUnavailable: 1 + } + }, + template: { + metadata: { + labels: { + "mydeployment": "true" + } + }, + spec + } +}; + +const runningPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar", + resourceVersion: "foobar", + uid: "foobar", + labels: { + "name": "kube-state-metrics" + }, + namespace: "default" + }, +}); + +runningPod.status = { + phase: "Running", + conditions: [ + { + type: "Initialized", + status: "True", + lastProbeTime: 1, + lastTransitionTime: "1", + }, + { + type: "Ready", + status: "True", + lastProbeTime: 1, + lastTransitionTime: "1", + } + ], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", + containerStatuses: [], + initContainerStatuses: [], +}; + +const pendingPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-pending", + resourceVersion: "foobar", + uid: "foobar-pending", + labels: { + "mydeployment": "true" + }, + namespace: "default" + }, +}); + +const failedPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-failed", + resourceVersion: "foobar", + uid: "foobar-failed", + labels: { + "name": "failedpods" + }, + namespace: "default" + }, +}); + +failedPod.status = { + phase: "Failed", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", +}; + +describe("Deployment Store tests", () => { + beforeAll(() => { + // Add pods to pod store + podsStore.items = observable.array([ + runningPod, + failedPod, + pendingPod + ]); + }); + + it("gets Deployment statuses in proper sorting order", () => { + const statuses = Object.entries(deploymentStore.getStatuses([ + failedDeployment, + runningDeployment, + pendingDeployment + ])); + + expect(statuses).toEqual([ + ["running", 1], + ["failed", 1], + ["pending", 1], + ]); + }); + + it("returns 0 for other statuses", () => { + let statuses = Object.entries(deploymentStore.getStatuses([runningDeployment])); + + expect(statuses).toEqual([ + ["running", 1], + ["failed", 0], + ["pending", 0], + ]); + + statuses = Object.entries(deploymentStore.getStatuses([failedDeployment])); + + expect(statuses).toEqual([ + ["running", 0], + ["failed", 1], + ["pending", 0], + ]); + + statuses = Object.entries(deploymentStore.getStatuses([pendingDeployment])); + + expect(statuses).toEqual([ + ["running", 0], + ["failed", 0], + ["pending", 1], + ]); + }); +}); diff --git a/src/renderer/components/__tests__/job.store.test.ts b/src/renderer/components/__tests__/job.store.test.ts new file mode 100644 index 0000000000..9f3a81ba91 --- /dev/null +++ b/src/renderer/components/__tests__/job.store.test.ts @@ -0,0 +1,228 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { observable } from "mobx"; +import { jobStore } from "../+workloads-jobs/job.store"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { Job, Pod } from "../../api/endpoints"; + +const runningJob = new Job({ + apiVersion: "foo", + kind: "Job", + metadata: { + name: "runningJob", + resourceVersion: "runningJob", + uid: "runningJob", + namespace: "default", + }, +}); + +const failedJob = new Job({ + apiVersion: "foo", + kind: "Job", + metadata: { + name: "failedJob", + resourceVersion: "failedJob", + uid: "failedJob", + namespace: "default", + }, +}); + +const pendingJob = new Job({ + apiVersion: "foo", + kind: "Job", + metadata: { + name: "pendingJob", + resourceVersion: "pendingJob", + uid: "pendingJob", + namespace: "default", + }, +}); + +const succeededJob = new Job({ + apiVersion: "foo", + kind: "Job", + metadata: { + name: "succeededJob", + resourceVersion: "succeededJob", + uid: "succeededJob", + namespace: "default", + }, +}); + +const runningPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar", + resourceVersion: "foobar", + uid: "foobar", + ownerReferences: [{ + uid: "runningJob", + }], + namespace: "default" + }, +}); + +runningPod.status = { + phase: "Running", + conditions: [ + { + type: "Initialized", + status: "True", + lastProbeTime: 1, + lastTransitionTime: "1", + }, + { + type: "Ready", + status: "True", + lastProbeTime: 1, + lastTransitionTime: "1", + } + ], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", + containerStatuses: [], + initContainerStatuses: [], +}; + +const pendingPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-pending", + resourceVersion: "foobar", + uid: "foobar-pending", + ownerReferences: [{ + uid: "pendingJob", + }], + namespace: "default" + }, +}); + +const failedPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-failed", + resourceVersion: "foobar", + uid: "foobar-failed", + ownerReferences: [{ + uid: "failedJob", + }], + namespace: "default" + }, +}); + +failedPod.status = { + phase: "Failed", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", +}; + +const succeededPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-succeeded", + resourceVersion: "foobar", + uid: "foobar-succeeded", + ownerReferences: [{ + uid: "succeededJob", + }], + }, +}); + +succeededPod.status = { + phase: "Succeeded", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", +}; + +describe("Job Store tests", () => { + beforeAll(() => { + podsStore.items = observable.array([ + runningPod, + failedPod, + pendingPod, + succeededPod + ]); + }); + + it("gets Job statuses in proper sorting order", () => { + const statuses = Object.entries(jobStore.getStatuses([ + failedJob, + succeededJob, + runningJob, + pendingJob + ])); + + expect(statuses).toEqual([ + ["succeeded", 1], + ["running", 1], + ["failed", 1], + ["pending", 1], + ]); + }); + + it("returns 0 for other statuses", () => { + let statuses = Object.entries(jobStore.getStatuses([succeededJob])); + + expect(statuses).toEqual([ + ["succeeded", 1], + ["running", 0], + ["failed", 0], + ["pending", 0], + ]); + + statuses = Object.entries(jobStore.getStatuses([runningJob])); + + expect(statuses).toEqual([ + ["succeeded", 0], + ["running", 1], + ["failed", 0], + ["pending", 0], + ]); + + statuses = Object.entries(jobStore.getStatuses([failedJob])); + + expect(statuses).toEqual([ + ["succeeded", 0], + ["running", 0], + ["failed", 1], + ["pending", 0], + ]); + + statuses = Object.entries(jobStore.getStatuses([pendingJob])); + + expect(statuses).toEqual([ + ["succeeded", 0], + ["running", 0], + ["failed", 0], + ["pending", 1], + ]); + }); +}); diff --git a/src/renderer/components/__tests__/pods.store.test.ts b/src/renderer/components/__tests__/pods.store.test.ts new file mode 100644 index 0000000000..69d4f49840 --- /dev/null +++ b/src/renderer/components/__tests__/pods.store.test.ts @@ -0,0 +1,159 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { Pod } from "../../api/endpoints"; +import { podsStore } from "../+workloads-pods/pods.store"; + +const runningPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar", + resourceVersion: "foobar", + uid: "foobar", + }, +}); + +runningPod.status = { + phase: "Running", + conditions: [ + { + type: "Initialized", + status: "True", + lastProbeTime: 1, + lastTransitionTime: "1", + }, + { + type: "Ready", + status: "True", + lastProbeTime: 1, + lastTransitionTime: "1", + } + ], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", + containerStatuses: [], + initContainerStatuses: [], +}; + +const pendingPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-pending", + resourceVersion: "foobar", + uid: "foobar-pending", + }, +}); + +const failedPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-failed", + resourceVersion: "foobar", + uid: "foobar-failed", + }, +}); + +failedPod.status = { + phase: "Failed", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", +}; + +const evictedPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-evicted", + resourceVersion: "foobar", + uid: "foobar-evicted", + }, +}); + +evictedPod.status = { + phase: "Failed", + reason: "Evicted", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", +}; + +const succeededPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-succeeded", + resourceVersion: "foobar", + uid: "foobar-succeeded", + }, +}); + +succeededPod.status = { + phase: "Succeeded", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", +}; + +describe("Pod Store tests", () => { + it("gets Pod statuses in proper sorting order", () => { + const statuses = Object.entries(podsStore.getStatuses([ + pendingPod, + runningPod, + succeededPod, + failedPod, + evictedPod, + evictedPod + ])); + + expect(statuses).toEqual([ + ["Succeeded", 1], + ["Running", 1], + ["Pending", 1], + ["Failed", 1], + ["Evicted", 2], + ]); + }); + + it("counts statuses properly", () => { + const statuses = Object.entries(podsStore.getStatuses([ + pendingPod, + pendingPod, + pendingPod, + runningPod, + failedPod, + failedPod, + ])); + + expect(statuses).toEqual([ + ["Running", 1], + ["Pending", 3], + ["Failed", 2], + ]); + }); +}); diff --git a/src/renderer/components/__tests__/replicaset.store.test.ts b/src/renderer/components/__tests__/replicaset.store.test.ts new file mode 100644 index 0000000000..74404e9004 --- /dev/null +++ b/src/renderer/components/__tests__/replicaset.store.test.ts @@ -0,0 +1,181 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { observable } from "mobx"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { replicaSetStore } from "../+workloads-replicasets/replicasets.store"; +import { ReplicaSet, Pod } from "../../api/endpoints"; + +const runningReplicaSet = new ReplicaSet({ + apiVersion: "foo", + kind: "ReplicaSet", + metadata: { + name: "runningReplicaSet", + resourceVersion: "runningReplicaSet", + uid: "runningReplicaSet", + namespace: "default", + }, +}); + +const failedReplicaSet = new ReplicaSet({ + apiVersion: "foo", + kind: "ReplicaSet", + metadata: { + name: "failedReplicaSet", + resourceVersion: "failedReplicaSet", + uid: "failedReplicaSet", + namespace: "default", + }, +}); + +const pendingReplicaSet = new ReplicaSet({ + apiVersion: "foo", + kind: "ReplicaSet", + metadata: { + name: "pendingReplicaSet", + resourceVersion: "pendingReplicaSet", + uid: "pendingReplicaSet", + namespace: "default", + }, +}); + +const runningPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar", + resourceVersion: "foobar", + uid: "foobar", + ownerReferences: [{ + uid: "runningReplicaSet", + }], + namespace: "default" + }, +}); + +runningPod.status = { + phase: "Running", + conditions: [ + { + type: "Initialized", + status: "True", + lastProbeTime: 1, + lastTransitionTime: "1", + }, + { + type: "Ready", + status: "True", + lastProbeTime: 1, + lastTransitionTime: "1", + } + ], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", + containerStatuses: [], + initContainerStatuses: [], +}; + +const pendingPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-pending", + resourceVersion: "foobar", + uid: "foobar-pending", + ownerReferences: [{ + uid: "pendingReplicaSet", + }], + namespace: "default" + }, +}); + +const failedPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-failed", + resourceVersion: "foobar", + uid: "foobar-failed", + ownerReferences: [{ + uid: "failedReplicaSet", + }], + namespace: "default" + }, +}); + +failedPod.status = { + phase: "Failed", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", +}; + +describe("ReplicaSet Store tests", () => { + beforeAll(() => { + podsStore.items = observable.array([ + runningPod, + failedPod, + pendingPod + ]); + }); + + it("gets ReplicaSet statuses in proper sorting order", () => { + const statuses = Object.entries(replicaSetStore.getStatuses([ + failedReplicaSet, + runningReplicaSet, + pendingReplicaSet + ])); + + expect(statuses).toEqual([ + ["running", 1], + ["failed", 1], + ["pending", 1], + ]); + }); + + it("returns 0 for other statuses", () => { + let statuses = Object.entries(replicaSetStore.getStatuses([runningReplicaSet])); + + expect(statuses).toEqual([ + ["running", 1], + ["failed", 0], + ["pending", 0], + ]); + + statuses = Object.entries(replicaSetStore.getStatuses([failedReplicaSet])); + + expect(statuses).toEqual([ + ["running", 0], + ["failed", 1], + ["pending", 0], + ]); + + statuses = Object.entries(replicaSetStore.getStatuses([pendingReplicaSet])); + + expect(statuses).toEqual([ + ["running", 0], + ["failed", 0], + ["pending", 1], + ]); + }); +}); diff --git a/src/renderer/components/__tests__/statefulset.store.test.ts b/src/renderer/components/__tests__/statefulset.store.test.ts new file mode 100644 index 0000000000..162684f092 --- /dev/null +++ b/src/renderer/components/__tests__/statefulset.store.test.ts @@ -0,0 +1,182 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { observable } from "mobx"; +import { podsStore } from "../+workloads-pods/pods.store"; +import { statefulSetStore } from "../+workloads-statefulsets/statefulset.store"; +import { StatefulSet, Pod } from "../../api/endpoints"; + +const runningStatefulSet = new StatefulSet({ + apiVersion: "foo", + kind: "StatefulSet", + metadata: { + name: "runningStatefulSet", + resourceVersion: "runningStatefulSet", + uid: "runningStatefulSet", + namespace: "default", + }, +}); + +const failedStatefulSet = new StatefulSet({ + apiVersion: "foo", + kind: "StatefulSet", + metadata: { + name: "failedStatefulSet", + resourceVersion: "failedStatefulSet", + uid: "failedStatefulSet", + namespace: "default", + }, +}); + +const pendingStatefulSet = new StatefulSet({ + apiVersion: "foo", + kind: "StatefulSet", + metadata: { + name: "pendingStatefulSet", + resourceVersion: "pendingStatefulSet", + uid: "pendingStatefulSet", + namespace: "default", + }, +}); + +const runningPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar", + resourceVersion: "foobar", + uid: "foobar", + ownerReferences: [{ + uid: "runningStatefulSet", + }], + namespace: "default" + }, +}); + +runningPod.status = { + phase: "Running", + conditions: [ + { + type: "Initialized", + status: "True", + lastProbeTime: 1, + lastTransitionTime: "1", + }, + { + type: "Ready", + status: "True", + lastProbeTime: 1, + lastTransitionTime: "1", + } + ], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", + containerStatuses: [], + initContainerStatuses: [], +}; + +const pendingPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-pending", + resourceVersion: "foobar", + uid: "foobar-pending", + ownerReferences: [{ + uid: "pendingStatefulSet", + }], + namespace: "default" + }, +}); + +const failedPod = new Pod({ + apiVersion: "foo", + kind: "Pod", + metadata: { + name: "foobar-failed", + resourceVersion: "foobar", + uid: "foobar-failed", + ownerReferences: [{ + uid: "failedStatefulSet", + }], + namespace: "default" + }, +}); + +failedPod.status = { + phase: "Failed", + conditions: [], + hostIP: "10.0.0.1", + podIP: "10.0.0.1", + startTime: "now", +}; + +describe("StatefulSet Store tests", () => { + beforeAll(() => { + // Add pods to pod store + podsStore.items = observable.array([ + runningPod, + failedPod, + pendingPod + ]); + }); + + it("gets StatefulSet statuses in proper sorting order", () => { + const statuses = Object.entries(statefulSetStore.getStatuses([ + failedStatefulSet, + runningStatefulSet, + pendingStatefulSet + ])); + + expect(statuses).toEqual([ + ["running", 1], + ["failed", 1], + ["pending", 1], + ]); + }); + + it("returns 0 for other statuses", () => { + let statuses = Object.entries(statefulSetStore.getStatuses([runningStatefulSet])); + + expect(statuses).toEqual([ + ["running", 1], + ["failed", 0], + ["pending", 0], + ]); + + statuses = Object.entries(statefulSetStore.getStatuses([failedStatefulSet])); + + expect(statuses).toEqual([ + ["running", 0], + ["failed", 1], + ["pending", 0], + ]); + + statuses = Object.entries(statefulSetStore.getStatuses([pendingStatefulSet])); + + expect(statuses).toEqual([ + ["running", 0], + ["failed", 0], + ["pending", 1], + ]); + }); +}); diff --git a/src/renderer/components/app-init/app-init.tsx b/src/renderer/components/app-init/app-init.tsx deleted file mode 100644 index 7d1460bb1e..0000000000 --- a/src/renderer/components/app-init/app-init.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Copyright (c) 2021 OpenLens Authors - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to - * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of - * the Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER - * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN - * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ - -import "./app-init.scss"; - -import React from "react"; -import { render } from "react-dom"; -import { CubeSpinner } from "../spinner"; -import { apiBase } from "../../api"; - -interface Props { - serviceWaitingList?: string[]; -} - -export class AppInit extends React.Component { - static async start(rootElem: HTMLElement) { - render(, rootElem); // show loading indicator asap - await AppInit.readyStateCheck(rootElem); // wait while all good to run - } - - protected static async readyStateCheck(rootElem: HTMLElement) { - const waitingList = await apiBase.get("/ready"); - - if (waitingList.length > 0) { - // update waiting state - render(, rootElem); - - // check again in 1-5 seconds - return new Promise(resolve => { - const timeoutDelay = 1000 + Math.random() * 4000; - - setTimeout(() => resolve(AppInit.readyStateCheck(rootElem)), timeoutDelay); - }); - } - } - - render() { - const { serviceWaitingList = [] } = this.props; - const serviceNames = serviceWaitingList.join(", "); - - return ( -
-
-
Lens - Loading..
- {serviceWaitingList.length > 0 && ( -

- Waiting services to be running: {serviceNames} -

- )} - -
-
- ); - } -} diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 1e1c14fd4c..52c9d5d0c3 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ import React from "react"; -import { observable, makeObservable } from "mobx"; +import { observable, makeObservable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; import { Redirect, Route, Router, Switch } from "react-router"; import { history } from "../navigation"; @@ -69,6 +69,7 @@ import { Nodes } from "./+nodes"; import { Workloads } from "./+workloads"; import { Config } from "./+config"; import { Storage } from "./+storage"; +import { catalogEntityRegistry } from "../api/catalog-entity-registry"; @observer export class App extends React.Component { @@ -78,6 +79,7 @@ export class App extends React.Component { } static async init() { + catalogEntityRegistry.init(); const frameId = webFrame.routingId; const clusterId = getHostedClusterId(); @@ -86,6 +88,15 @@ export class App extends React.Component { await requestMain(clusterSetFrameIdHandler, clusterId); await getHostedCluster().whenReady; // cluster.activate() is done at this point + + const activeEntityDisposer = reaction(() => catalogEntityRegistry.getById(clusterId), (entity) => { + if (!entity) { + return; + } + catalogEntityRegistry.activeEntity = entity; + activeEntityDisposer(); + }, {fireImmediately: true}); + ExtensionLoader.getInstance().loadOnClusterRenderer(); setTimeout(() => { appEventBus.emit({ diff --git a/src/renderer/components/badge/badge.scss b/src/renderer/components/badge/badge.module.css similarity index 77% rename from src/renderer/components/badge/badge.scss rename to src/renderer/components/badge/badge.module.css index 23f8e99ff0..8fe7a0a022 100644 --- a/src/renderer/components/badge/badge.scss +++ b/src/renderer/components/badge/badge.module.css @@ -19,25 +19,37 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -.Badge { +.badge { + position: relative; display: inline-block; - white-space: nowrap; max-width: 100%; +} + +.badge.interactive:hover { + background-color: var(--mainBackground); + cursor: pointer; +} + +.badge:not(.isExpanded) { + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - - &:not(.flat) { - background: $colorVague; - color: $textColorSecondary; - border-radius: $radius; - padding: .2em .4em; - } - - &.small { - font-size: $font-size-small; - } - - &.clickable { - cursor: pointer; - } +} + +.badge:not(.flat) { + background: var(--colorVague); + border-radius: 3px; + padding: .2em .4em; +} + +.small { + font-size: var(--font-size-small); +} + +.clickable { + cursor: pointer; +} + +.disabled { + opacity: 0.5; } diff --git a/src/renderer/components/badge/badge.tsx b/src/renderer/components/badge/badge.tsx index 14959b04d1..a16b5482c0 100644 --- a/src/renderer/components/badge/badge.tsx +++ b/src/renderer/components/badge/badge.tsx @@ -19,29 +19,84 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./badge.scss"; +import styles from "./badge.module.css"; import React from "react"; +import { computed, makeObservable, observable } from "mobx"; +import { observer } from "mobx-react"; import { cssNames } from "../../utils/cssNames"; import { TooltipDecoratorProps, withTooltip } from "../tooltip"; +import { boundMethod } from "../../utils"; export interface BadgeProps extends React.HTMLAttributes, TooltipDecoratorProps { small?: boolean; flat?: boolean; label?: React.ReactNode; + expandable?: boolean; + disabled?: boolean; } +// Common handler for all Badge instances +document.addEventListener("selectionchange", () => { + Badge.badgeMeta.hasTextSelected ||= window.getSelection().toString().trim().length > 0; +}); + @withTooltip +@observer export class Badge extends React.Component { - render() { - const { className, label, small, flat, children, ...elemProps } = this.props; - const clickable = Boolean(this.props.onClick); + static defaultProps: Partial = { + expandable: true + }; - return <> - + static badgeMeta = observable({ + hasTextSelected: false + }); + + @observable.ref elem: HTMLElement; + @observable isExpanded = false; + + constructor(props: BadgeProps) { + super(props); + makeObservable(this); + } + + @computed get isExpandable() { + if (!this.props.expandable) return false; + + return this.elem?.clientWidth < this.elem?.scrollWidth; + } + + @boundMethod + onMouseUp() { + if (!this.isExpandable || Badge.badgeMeta.hasTextSelected) { + Badge.badgeMeta.hasTextSelected = false; + } else { + this.isExpanded = !this.isExpanded; + } + } + + @boundMethod + bindRef(elem: HTMLElement) { + this.elem = elem; + } + + render() { + const { className, label, disabled, small, children, flat, expandable, ...elemProps } = this.props; + const clickable = Boolean(this.props.onClick) || this.isExpandable; + const classNames = cssNames(styles.badge, className, { + [styles.small]: small, + [styles.flat]: flat, + [styles.clickable]: clickable, + [styles.interactive]: this.isExpandable, + [styles.isExpanded]: this.isExpanded, + [styles.disabled]: disabled + }); + + return ( +
{label} {children} - - ; +
+ ); } } diff --git a/src/renderer/components/chart/chart.scss b/src/renderer/components/chart/chart.scss index 3429b251f9..554d62b883 100644 --- a/src/renderer/components/chart/chart.scss +++ b/src/renderer/components/chart/chart.scss @@ -29,7 +29,7 @@ } .legend { - .Badge { + .LegendBadge { background: transparent; transition: background-color 250ms; white-space: normal; diff --git a/src/renderer/components/chart/chart.tsx b/src/renderer/components/chart/chart.tsx index b68ece5523..7f40c0e174 100644 --- a/src/renderer/components/chart/chart.tsx +++ b/src/renderer/components/chart/chart.tsx @@ -169,19 +169,20 @@ export class Chart extends React.Component { const labelElem = (title: string, color: string, tooltip?: string) => ( - +
+ {title}
)} tooltip={tooltip} + expandable={false} /> ); return ( -
+
{labels && labels.map((label: string, index) => { const { backgroundColor } = datasets[0] as any; const color = legendColors ? legendColors[index] : backgroundColor[index]; diff --git a/src/renderer/components/chart/pie-chart.scss b/src/renderer/components/chart/pie-chart.scss index 7034ee28a1..76c098e39d 100644 --- a/src/renderer/components/chart/pie-chart.scss +++ b/src/renderer/components/chart/pie-chart.scss @@ -30,7 +30,7 @@ flex-direction: column; > * { - &.Badge:hover { + &.LegendBadge:hover { background-color: transparent; } diff --git a/src/renderer/components/cluster-manager/bottom-bar.test.tsx b/src/renderer/components/cluster-manager/bottom-bar.test.tsx index bf6c11245e..066a4d3499 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.test.tsx +++ b/src/renderer/components/cluster-manager/bottom-bar.test.tsx @@ -47,7 +47,7 @@ describe("", () => { expect(container).toBeInstanceOf(HTMLElement); }); - it("renders w/o errors when .getItems() returns unexpected (not type complient) data", async () => { + it("renders w/o errors when .getItems() returns unexpected (not type compliant) data", async () => { StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => undefined); expect(() => render()).not.toThrow(); StatusBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => "hello"); diff --git a/src/renderer/components/cluster-manager/cluster-manager.scss b/src/renderer/components/cluster-manager/cluster-manager.scss index dddbb1a5e5..e82c0d3aca 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.scss +++ b/src/renderer/components/cluster-manager/cluster-manager.scss @@ -35,10 +35,6 @@ position: relative; display: flex; flex-direction: column; - - > * { - z-index: 1; - } } .HotbarMenu { @@ -52,7 +48,7 @@ #lens-views { position: absolute; left: 0; - top: 0; + top: 40px; // Move below top bar right: 0; bottom: 0; display: flex; diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index df5ae5d486..4bd14e7805 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -34,8 +34,6 @@ import { Extensions } from "../+extensions"; import { HotbarMenu } from "../hotbar/hotbar-menu"; import { EntitySettings } from "../+entity-settings"; import { Welcome } from "../+welcome"; -import { ClusterTopbar } from "./cluster-topbar"; -import { CatalogTopbar } from "./catalog-topbar"; import * as routes from "../../../common/routes"; @observer @@ -43,8 +41,6 @@ export class ClusterManager extends React.Component { render() { return (
- -
diff --git a/src/renderer/components/cluster-manager/cluster-status.scss b/src/renderer/components/cluster-manager/cluster-status.scss index 1d048e7459..77d6e7693f 100644 --- a/src/renderer/components/cluster-manager/cluster-status.scss +++ b/src/renderer/components/cluster-manager/cluster-status.scss @@ -33,6 +33,15 @@ max-width: 70vw; max-height: 40vh; white-space: pre-line; + + p { + margin-bottom: $margin; + } + } + + .Spinner { + --spinner-size: 38px; + --spinner-border: calc(var(--spinner-size) / 10); } .Icon { diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index 456f606570..366a809b59 100644 --- a/src/renderer/components/cluster-manager/cluster-status.tsx +++ b/src/renderer/components/cluster-manager/cluster-status.tsx @@ -32,7 +32,7 @@ import type { Cluster } from "../../../main/cluster"; import { cssNames, IClassName } from "../../utils"; import { Button } from "../button"; import { Icon } from "../icon"; -import { CubeSpinner } from "../spinner"; +import { Spinner } from "../spinner"; import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy"; import { navigate } from "../../navigation"; import { entitySettingsURL } from "../../../common/routes"; @@ -100,9 +100,9 @@ export class ClusterStatus extends React.Component { if (!hasErrors || this.isReconnecting) { return ( <> - +
-            

{this.isReconnecting ? "Reconnecting..." : "Connecting..."}

+

{this.isReconnecting ? "Reconnecting" : "Connecting"}…

{authOutput.map(({ data, error }, index) => { return

{data}

; })} diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index db48e051c8..3d0fea8e55 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -34,6 +34,7 @@ import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { navigate } from "../../navigation"; import { catalogURL, ClusterViewRouteParams } from "../../../common/routes"; import { previousActiveTab } from "../+catalog"; +import { ClusterTopbar } from "./cluster-topbar"; interface Props extends RouteComponentProps { } @@ -104,6 +105,7 @@ export class ClusterView extends React.Component { render() { return (
+ {this.renderStatus()}
); diff --git a/src/renderer/components/cluster-settings/cluster-settings.tsx b/src/renderer/components/cluster-settings/cluster-settings.tsx index 2f11883fa0..21081bc5ce 100644 --- a/src/renderer/components/cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/cluster-settings/cluster-settings.tsx @@ -118,3 +118,17 @@ export function MetricsSettings({ entity }: EntitySettingViewProps) { ); } + +export function NodeShellSettings({entity}: EntitySettingViewProps) { + const cluster = getClusterForEntity(entity); + + if(!cluster) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx b/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx index 6794646bea..62f426efe1 100644 --- a/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-accessible-namespaces.tsx @@ -25,6 +25,7 @@ import type { Cluster } from "../../../../main/cluster"; import { SubTitle } from "../../layout/sub-title"; import { EditableList } from "../../editable-list"; import { observable, makeObservable } from "mobx"; +import { systemName } from "../../input/input_validators"; interface Props { cluster: Cluster; @@ -49,9 +50,10 @@ export class ClusterAccessibleNamespaces extends React.Component { this.namespaces.add(newNamespace); this.props.cluster.accessibleNamespaces = Array.from(this.namespaces); }} + validators={systemName} items={Array.from(this.namespaces)} - remove={({ oldItem: oldNamesapce }) => { - this.namespaces.delete(oldNamesapce); + remove={({ oldItem: oldNamespace }) => { + this.namespaces.delete(oldNamespace); this.props.cluster.accessibleNamespaces = Array.from(this.namespaces); }} inputTheme="round-black" diff --git a/src/renderer/components/cluster-settings/components/cluster-node-shell-setting.tsx b/src/renderer/components/cluster-settings/components/cluster-node-shell-setting.tsx new file mode 100644 index 0000000000..a5b4300fe3 --- /dev/null +++ b/src/renderer/components/cluster-settings/components/cluster-node-shell-setting.tsx @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import type { Cluster } from "../../../../main/cluster"; +import { autorun, makeObservable, observable } from "mobx"; +import { SubTitle } from "../../layout/sub-title"; +import React from "react"; +import { Input } from "../../input/input"; +import { disposeOnUnmount, observer } from "mobx-react"; +import { Icon } from "../../icon/icon"; +import { initialNodeShellImage } from "../../../../common/cluster-store"; + +interface Props { + cluster: Cluster; +} + +@observer +export class ClusterNodeShellSetting extends React.Component { + @observable nodeShellImage = ""; + @observable imagePullSecret = ""; + + constructor(props: Props) { + super(props); + makeObservable(this); + } + + componentDidMount() { + disposeOnUnmount(this, + autorun(() => { + this.nodeShellImage = this.props.cluster.nodeShellImage; + this.imagePullSecret = this.props.cluster.imagePullSecret; + }) + ); + } + + onImageChange = (value: string) => { + this.nodeShellImage = value; + }; + + onSecretChange = (value: string) => { + this.imagePullSecret = value; + }; + + saveImage = () => { + this.props.cluster.preferences.nodeShellImage = this.nodeShellImage; + }; + + saveSecret = () => { + this.props.cluster.preferences.imagePullSecret = this.imagePullSecret; + }; + + resetImage = () => { + this.nodeShellImage = initialNodeShellImage; //revert to default + }; + + clearSecret = () => { + this.imagePullSecret = ""; + }; + + render() { + + return ( + <> +
+ + } + /> + + Node shell image. Used for creating node shell pod. + +
+
+ + } + /> + + Name of a pre-existing secret (optional). Used for pulling image from a private registry. + +
+ + ); + } +} diff --git a/src/renderer/components/cluster-settings/components/cluster-show-metrics.tsx b/src/renderer/components/cluster-settings/components/cluster-show-metrics.tsx index 6544f2325b..effdc16160 100644 --- a/src/renderer/components/cluster-settings/components/cluster-show-metrics.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-show-metrics.tsx @@ -69,13 +69,14 @@ export class ShowMetricsSetting extends React.Component { const tooltipId = `${name}`; return ( - + {name} this.removeMetric(name)} tooltip="Remove" + className="mx-3" /> ); diff --git a/src/renderer/components/cluster-settings/components/index.ts b/src/renderer/components/cluster-settings/components/index.ts index e82425c9c7..beb80269b7 100644 --- a/src/renderer/components/cluster-settings/components/index.ts +++ b/src/renderer/components/cluster-settings/components/index.ts @@ -28,3 +28,4 @@ export * from "./cluster-prometheus-setting"; export * from "./cluster-proxy-setting"; export * from "./cluster-show-metrics"; export * from "./cluster-icon-settings"; +export * from "./cluster-node-shell-setting"; diff --git a/src/renderer/components/command-palette/command-dialog.tsx b/src/renderer/components/command-palette/command-dialog.tsx index 7fc5c56dcf..493d9bea70 100644 --- a/src/renderer/components/command-palette/command-dialog.tsx +++ b/src/renderer/components/command-palette/command-dialog.tsx @@ -35,16 +35,17 @@ import { clusterViewURL } from "../../../common/routes"; @observer export class CommandDialog extends React.Component { @observable menuIsOpen = true; + @observable searchValue: any = undefined; constructor(props: {}) { super(props); makeObservable(this); } - + @computed get activeEntity(): CatalogEntity | undefined { return catalogEntityRegistry.activeEntity; } - + @computed get options() { const registry = CommandRegistry.getInstance(); @@ -104,14 +105,24 @@ export class CommandDialog extends React.Component { return ( isDirty ? : null} />
{ items.map((item, index) => (
-
{renderItem(item, index)}
+
+
{renderItem(item, index)}
+
remove(({ index, oldItem: item }))} />
diff --git a/src/renderer/components/error-boundary/error-boundary.tsx b/src/renderer/components/error-boundary/error-boundary.tsx index d2b0ef90c5..dd345e9f48 100644 --- a/src/renderer/components/error-boundary/error-boundary.tsx +++ b/src/renderer/components/error-boundary/error-boundary.tsx @@ -21,74 +21,54 @@ import "./error-boundary.scss"; -import React, { ErrorInfo } from "react"; -import { reaction } from "mobx"; -import { disposeOnUnmount, observer } from "mobx-react"; +import React from "react"; import { Button } from "../button"; import { navigation } from "../../navigation"; import { issuesTrackerUrl, slackUrl } from "../../../common/vars"; - -interface Props { -} - -interface State { - error?: Error; - errorInfo?: ErrorInfo; -} +import * as Sentry from "@sentry/react"; +import { observer } from "mobx-react"; @observer -export class ErrorBoundary extends React.Component { - public state: State = {}; - - @disposeOnUnmount - resetOnNavigate = reaction( - () => navigation.toString(), - () => this.setState({ error: null, errorInfo: null }) - ); - - componentDidCatch(error: Error, errorInfo: ErrorInfo) { - this.setState({ error, errorInfo }); - } - - back = () => { - navigation.goBack(); - }; - +export class ErrorBoundary extends React.Component { render() { - const { error, errorInfo } = this.state; + return ( + { + const slackLink = Slack; + const githubLink = GitHub; + const pageUrl = location.pathname; - if (error) { - const slackLink = Slack; - const githubLink = Github; - const pageUrl = location.pathname; - - return ( -
-
- App crash at {pageUrl} -
-

- To help us improve the product please report bugs to {slackLink} community or {githubLink} issues tracker. -

-
- -

Component stack:

- {errorInfo.componentStack} -
- -

Error stack:


- {error.stack} -
-
-
- ); - } - - return this.props.children; + return ( +
+
+ App crash at {pageUrl} +
+

+ To help us improve the product please report bugs to {slackLink} community or {githubLink} issues tracker. +

+
+ +

Component stack:

+ {componentStack} +
+ +

Error stack:


+ {error.stack} +
+
+
+ ); + }}> + {this.props.children} +
+ ); } } diff --git a/src/renderer/components/file-picker/file-picker.tsx b/src/renderer/components/file-picker/file-picker.tsx index cc1e850cd2..3661bb2cb2 100644 --- a/src/renderer/components/file-picker/file-picker.tsx +++ b/src/renderer/components/file-picker/file-picker.tsx @@ -127,7 +127,7 @@ export class FilePicker extends React.Component { return files; } - handleIndiviualFileSizes(files: File[]): File[] { + handleIndividualFileSizes(files: File[]): File[] { const { onOverSizeLimit, maxSize } = this.props; switch (onOverSizeLimit) { @@ -178,7 +178,7 @@ export class FilePicker extends React.Component { try { const numberLimitedFiles = this.handleFileCount(files); - const sizeLimitedFiles = this.handleIndiviualFileSizes(numberLimitedFiles); + const sizeLimitedFiles = this.handleIndividualFileSizes(numberLimitedFiles); const totalSizeLimitedFiles = this.handleTotalFileSizes(sizeLimitedFiles); if ("uploadDir" in this.props) { diff --git a/src/renderer/components/hotbar/hotbar-add-command.tsx b/src/renderer/components/hotbar/hotbar-add-command.tsx index fde97ef561..20c532e252 100644 --- a/src/renderer/components/hotbar/hotbar-add-command.tsx +++ b/src/renderer/components/hotbar/hotbar-add-command.tsx @@ -25,7 +25,7 @@ import { HotbarStore } from "../../../common/hotbar-store"; import { CommandOverlay } from "../command-palette"; import { Input, InputValidator } from "../input"; -const uniqueHotbarName: InputValidator = { +export const uniqueHotbarName: InputValidator = { condition: ({ required }) => required, message: () => "Hotbar with this name already exists", validate: value => !HotbarStore.getInstance().getByName(value), diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index b8447baf80..176abcc996 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -74,32 +74,28 @@ export const HotbarIcon = observer(({menuItems = [], size = 40, tooltip, ...prop setMenuOpen(!menuOpen); }; - const renderIcon = () => { - return ( - 0 })}> + {tooltip && {tooltip}} +
{ if (!disabled) { onClick?.(event); } }} > - {material && } - - ); - }; - - return ( -
0 })}> - {tooltip && {tooltip}} -
- {renderIcon()} + + {material && } + {children}
{ + return { value: hotbar.id, label: hotbarDisplayLabel(hotbar.id) }; + }); + } + + @action onSelect = (id: string) => { + this.hotbarId = id; + this.hotbarName = HotbarStore.getInstance().getById(this.hotbarId).name; + }; + + onSubmit = (name: string) => { + if (!name.trim()) { + return; + } + + const hotbarStore = HotbarStore.getInstance(); + const hotbar = HotbarStore.getInstance().getById(this.hotbarId); + + if (!hotbar) { + return; + } + + hotbarStore.setHotbarName(this.hotbarId, name); + CommandOverlay.close(); + }; + + renderHotbarList() { + return ( + <> + this.hotbarName = v} + placeholder="New hotbar name" + autoFocus={true} + theme="round-black" + validators={uniqueHotbarName} + onSubmit={this.onSubmit} + showValidationLine={true} + /> + + Please provide a new hotbar name (Press "Enter" to confirm or "Escape" to cancel) + + + ); + } + + render() { + + return ( + <> + {!this.hotbarId ? this.renderHotbarList() : this.renderNameInput()} + + ); + } +} diff --git a/src/renderer/components/hotbar/hotbar-selector.scss b/src/renderer/components/hotbar/hotbar-selector.scss index 4db5ae9900..d0fe3fa57c 100644 --- a/src/renderer/components/hotbar/hotbar-selector.scss +++ b/src/renderer/components/hotbar/hotbar-selector.scss @@ -33,7 +33,7 @@ top: -20px; } - .Badge { + .SelectorIndex { cursor: pointer; background: var(--secondaryBackground); width: 100%; diff --git a/src/renderer/components/hotbar/hotbar-selector.tsx b/src/renderer/components/hotbar/hotbar-selector.tsx index f0ca39d834..cc9617aa47 100644 --- a/src/renderer/components/hotbar/hotbar-selector.tsx +++ b/src/renderer/components/hotbar/hotbar-selector.tsx @@ -28,12 +28,13 @@ import { CommandOverlay } from "../command-palette"; import { HotbarSwitchCommand } from "./hotbar-switch-command"; import { hotbarDisplayIndex } from "./hotbar-display-label"; import { TooltipPosition } from "../tooltip"; +import { observer } from "mobx-react"; interface Props { hotbar: Hotbar; } -export function HotbarSelector({ hotbar }: Props) { +export const HotbarSelector = observer(({ hotbar }: Props) => { const store = HotbarStore.getInstance(); return ( @@ -49,9 +50,10 @@ export function HotbarSelector({ hotbar }: Props) { preferredPositions: [TooltipPosition.TOP, TooltipPosition.TOP_LEFT], children: hotbar.name }} + className="SelectorIndex" />
store.switchToNext()} />
); -} +}); diff --git a/src/renderer/components/hotbar/hotbar-switch-command.tsx b/src/renderer/components/hotbar/hotbar-switch-command.tsx index 142c1f6a9a..d79ccfba4b 100644 --- a/src/renderer/components/hotbar/hotbar-switch-command.tsx +++ b/src/renderer/components/hotbar/hotbar-switch-command.tsx @@ -28,11 +28,13 @@ import { CommandOverlay } from "../command-palette"; import { HotbarAddCommand } from "./hotbar-add-command"; import { HotbarRemoveCommand } from "./hotbar-remove-command"; import { hotbarDisplayLabel } from "./hotbar-display-label"; +import { HotbarRenameCommand } from "./hotbar-rename-command"; @observer export class HotbarSwitchCommand extends React.Component { private static addActionId = "__add__"; private static removeActionId = "__remove__"; + private static renameActionId = "__rename__"; constructor(props: {}) { super(props); @@ -51,11 +53,13 @@ export class HotbarSwitchCommand extends React.Component { options.push({ value: HotbarSwitchCommand.removeActionId, label: "Remove hotbar ..." }); } + options.push({ value: HotbarSwitchCommand.renameActionId, label: "Rename hotbar ..." }); + return options; } onChange(idOrAction: string): void { - switch(idOrAction) { + switch (idOrAction) { case HotbarSwitchCommand.addActionId: CommandOverlay.open(); @@ -63,6 +67,10 @@ export class HotbarSwitchCommand extends React.Component { case HotbarSwitchCommand.removeActionId: CommandOverlay.open(); + return; + case HotbarSwitchCommand.renameActionId: + CommandOverlay.open(); + return; default: HotbarStore.getInstance().activeHotbarId = idOrAction; diff --git a/src/renderer/components/icon/icon.scss b/src/renderer/components/icon/icon.scss index b10eb3708b..02501b3716 100644 --- a/src/renderer/components/icon/icon.scss +++ b/src/renderer/components/icon/icon.scss @@ -153,4 +153,4 @@ @extend .active; } } -} \ No newline at end of file +} diff --git a/src/renderer/components/input/input.scss b/src/renderer/components/input/input.scss index b683f46d3a..33369694a3 100644 --- a/src/renderer/components/input/input.scss +++ b/src/renderer/components/input/input.scss @@ -61,7 +61,6 @@ width: 0; height: 2px; background: $primary; - transition: width 250ms; } } diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index fa1692e988..e5276ffcb5 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -27,10 +27,10 @@ import { Icon } from "../icon"; import { Tooltip, TooltipProps } from "../tooltip"; import * as Validators from "./input_validators"; import type { InputValidator } from "./input_validators"; -import isString from "lodash/isString"; import isFunction from "lodash/isFunction"; import isBoolean from "lodash/isBoolean"; import uniqueId from "lodash/uniqueId"; +import { debounce } from "lodash"; const { conditionalValidators, ...InputValidators } = Validators; @@ -40,31 +40,45 @@ export type { InputValidator }; type InputElement = HTMLInputElement | HTMLTextAreaElement; type InputElementProps = InputHTMLAttributes & TextareaHTMLAttributes & DOMAttributes; -export type InputProps = Omit & { +export interface IconDataFnArg { + isDirty: boolean; +} + +/** + * One of the folloing: + * - A material icon name + * - A react node + * - Or a function that produces a react node + */ +export type IconData = string | React.ReactNode | ((opt: IconDataFnArg) => React.ReactNode); + +export type InputProps = Omit & { theme?: "round-black" | "round"; className?: string; - value?: T; - autoSelectOnFocus?: boolean + value?: string; + trim?: boolean; + autoSelectOnFocus?: boolean; + defaultValue?: string; multiLine?: boolean; // use text-area as input field maxRows?: number; // when multiLine={true} define max rows size dirty?: boolean; // show validation errors even if the field wasn't touched yet showValidationLine?: boolean; // show animated validation line for async validators showErrorsAsTooltip?: boolean | Omit; // show validation errors as a tooltip :hover (instead of block below) - iconLeft?: string | React.ReactNode; // material-icon name in case of string-type - iconRight?: string | React.ReactNode; + iconLeft?: IconData; + iconRight?: IconData; contentRight?: string | React.ReactNode; // Any component of string goes after iconRight validators?: InputValidator | InputValidator[]; - onChange?(value: T, evt: React.ChangeEvent): void; - onSubmit?(value: T, evt: React.KeyboardEvent): void; + onChange?(value: string, evt: React.ChangeEvent): void; + onSubmit?(value: string, evt: React.KeyboardEvent): void; }; interface State { - focused?: boolean; - dirty?: boolean; - dirtyOnBlur?: boolean; - valid?: boolean; - validating?: boolean; - errors?: React.ReactNode[]; + focused: boolean; + dirty: boolean; + valid: boolean; + validating: boolean; + errors: React.ReactNode[]; + submitted: boolean; } const defaultProps: Partial = { @@ -81,15 +95,14 @@ export class Input extends React.Component { public validators: InputValidator[] = []; public state: State = { - dirty: !!this.props.dirty, + focused: false, valid: true, + validating: false, + dirty: !!this.props.dirty, errors: [], + submitted: false, }; - isValid() { - return this.state.valid; - } - setValue(value = "") { if (value !== this.getValue()) { const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; @@ -102,12 +115,10 @@ export class Input extends React.Component { } getValue(): string { - const { value, defaultValue = "" } = this.props; + const { trim, value, defaultValue } = this.props; + const rawValue = value ?? this.input?.value ?? defaultValue ?? ""; - if (value !== undefined) return value; // controlled input - if (this.input) return this.input.value; // uncontrolled input - - return defaultValue as string; + return trim ? rawValue.trim() : rawValue; } focus() { @@ -138,7 +149,8 @@ export class Input extends React.Component { private validationId: string; - async validate(value = this.getValue()) { + async validate() { + const value = this.getValue(); let validationId = (this.validationId = ""); // reset every time for async validators const asyncValidators: Promise[] = []; const errors: React.ReactNode[] = []; @@ -213,7 +225,6 @@ export class Input extends React.Component { } setDirty(dirty = true) { - if (this.state.dirty === dirty) return; this.setState({ dirty }); } @@ -221,30 +232,25 @@ export class Input extends React.Component { onFocus(evt: React.FocusEvent) { const { onFocus, autoSelectOnFocus } = this.props; - if (onFocus) onFocus(evt); + onFocus?.(evt); if (autoSelectOnFocus) this.select(); this.setState({ focused: true }); } @boundMethod onBlur(evt: React.FocusEvent) { - const { onBlur } = this.props; - - if (onBlur) onBlur(evt); - if (this.state.dirtyOnBlur) this.setState({ dirty: true, dirtyOnBlur: false }); + this.props.onBlur?.(evt); this.setState({ focused: false }); } + setDirtyOnChange = debounce(() => this.setDirty(), 500); + @boundMethod - onChange(evt: React.ChangeEvent) { + onChange(evt: React.ChangeEvent) { this.props.onChange?.(evt.currentTarget.value, evt); this.validate(); this.autoFitHeight(); - - // mark input as dirty for the first time only onBlur() to avoid immediate error-state show when start typing - if (!this.state.dirty) { - this.setState({ dirtyOnBlur: true }); - } + this.setDirtyOnChange(); // re-render component when used as uncontrolled input // when used @defaultValue instead of @value changing real input.value doesn't call render() @@ -255,20 +261,24 @@ export class Input extends React.Component { @boundMethod onKeyDown(evt: React.KeyboardEvent) { - const modified = evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey; - this.props.onKeyDown?.(evt); - switch (evt.key) { - case "Enter": - if (this.props.onSubmit && !modified && !evt.repeat && this.isValid()) { - this.props.onSubmit(this.getValue(), evt); + if (evt.shiftKey || evt.metaKey || evt.altKey || evt.ctrlKey || evt.repeat) { + return; + } - if (this.isUncontrolled) { - this.setValue(); - } + if (evt.key === "Enter") { + if (this.state.valid) { + this.props.onSubmit?.(this.getValue(), evt); + this.setDirtyOnChange.cancel(); + this.setState({ submitted: true }); + + if (this.input && typeof this.props.value !== "string") { + this.input.value = ""; } - break; + } else { + this.setDirty(); + } } } @@ -291,7 +301,11 @@ export class Input extends React.Component { const { defaultValue, value, dirty, validators } = this.props; if (prevProps.value !== value || defaultValue !== prevProps.defaultValue) { - this.validate(); + if (!this.state.submitted) { + this.validate(); + } else { + this.setState({ submitted: false }); + } this.autoFitHeight(); } @@ -323,11 +337,27 @@ export class Input extends React.Component { this.input = elem; } + private renderIcon(iconData: IconData) { + if (typeof iconData === "string") { + return ; + } + + if (typeof iconData === "function") { + return iconData({ + isDirty: Boolean(this.getValue()), + }); + } + + return iconData; + } + render() { const { multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip, maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id, dirty: _dirty, // excluded from passing to input-element + defaultValue, + trim, ...inputProps } = this.props; const { focused, dirty, valid, validating, errors } = this.state; @@ -381,9 +411,9 @@ export class Input extends React.Component {
{tooltipError}