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/.eslintrc.js b/.eslintrc.js index 09e6bd3819..7ef30d22ff 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -129,6 +129,7 @@ module.exports = { "avoidEscape": true, "allowTemplateLiterals": true, }], + "react/prop-types": "off", "semi": "off", "@typescript-eslint/semi": ["error"], "linebreak-style": ["error", "unix"], @@ -196,6 +197,7 @@ module.exports = { "avoidEscape": true, "allowTemplateLiterals": true, }], + "react/prop-types": "off", "semi": "off", "@typescript-eslint/semi": ["error"], "linebreak-style": ["error", "unix"], 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/release-drafter.yml b/.github/release-drafter.yml index f7b3529b74..93536874e2 100644 --- a/.github/release-drafter.yml +++ b/.github/release-drafter.yml @@ -10,6 +10,7 @@ categories: - title: '🧰 Maintenance' labels: - 'chore' + - 'area/documentation' - 'area/ci' - 'area/tests' - 'dependencies' 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/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 f2c9d0eb13..1a8baf0d37 100644 --- a/Makefile +++ b/Makefile @@ -44,35 +44,24 @@ release-version: .PHONY: tag-release tag-release: - scripts/tag-release.sh + scripts/tag-release.sh $(CMD_ARGS) .PHONY: test 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 build: node_modules binaries/client yarn run npm:fix-build-version - $(MAKE) build-extensions + $(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/build/build_tray_icon.ts b/build/build_tray_icon.ts index 9669783631..026edeb217 100644 --- a/build/build_tray_icon.ts +++ b/build/build_tray_icon.ts @@ -25,20 +25,20 @@ import fs from "fs-extra"; export async function generateTrayIcon( { - outputFilename = "tray_icon", // e.g. output tray_icon_dark@2x.png + outputFilename = "trayIcon", svgIconPath = path.resolve(__dirname, "../src/renderer/components/icon/logo-lens.svg"), outputFolder = path.resolve(__dirname, "./tray"), dpiSuffix = "2x", pixelSize = 32, shouldUseDarkColors = false, // managed by electron.nativeTheme.shouldUseDarkColors } = {}) { - outputFilename += shouldUseDarkColors ? "_dark" : ""; + outputFilename += `${shouldUseDarkColors ? "Dark" : ""}Template`; // e.g. output trayIconDarkTemplate@2x.png dpiSuffix = dpiSuffix !== "1x" ? `@${dpiSuffix}` : ""; const pngIconDestPath = path.resolve(outputFolder, `${outputFilename}${dpiSuffix}.png`); try { // Modify .SVG colors - const trayIconColor = shouldUseDarkColors ? "white" : "black"; + const trayIconColor = shouldUseDarkColors ? "black" : "white"; const svgDom = await jsdom.JSDOM.fromFile(svgIconPath); const svgRoot = svgDom.window.document.body.getElementsByTagName("svg")[0]; diff --git a/build/set_build_version.ts b/build/set_build_version.ts index 80fd385c59..ebf74d6fab 100644 --- a/build/set_build_version.ts +++ b/build/set_build_version.ts @@ -29,9 +29,6 @@ const versionInfo = semver.parse(appInfo.version); const buildNumber = process.env.BUILD_NUMBER || Date.now().toString(); function getBuildChannel(): string { - /** - * Note: it is by design that we don't use `rc` as a build channel for these versions - */ switch (versionInfo.prerelease?.[0]) { case "beta": return "beta"; @@ -64,7 +61,7 @@ async function writeOutNewVersions() { function main() { const prereleaseParts: string[] = [getBuildChannel()]; - if (versionInfo.prerelease) { + if (versionInfo.prerelease && versionInfo.prerelease.length > 1) { prereleaseParts.push(versionInfo.prerelease[1].toString()); } diff --git a/build/tray/trayIconDarkTemplate.png b/build/tray/trayIconDarkTemplate.png new file mode 100644 index 0000000000..d0a4bdce20 Binary files /dev/null and b/build/tray/trayIconDarkTemplate.png differ diff --git a/build/tray/trayIconDarkTemplate@2x.png b/build/tray/trayIconDarkTemplate@2x.png new file mode 100644 index 0000000000..46615aa5bf Binary files /dev/null and b/build/tray/trayIconDarkTemplate@2x.png differ diff --git a/build/tray/trayIconDarkTemplate@3x.png b/build/tray/trayIconDarkTemplate@3x.png new file mode 100644 index 0000000000..0c018f3ed3 Binary files /dev/null and b/build/tray/trayIconDarkTemplate@3x.png differ diff --git a/build/tray/trayIconTemplate.png b/build/tray/trayIconTemplate.png index 568d13e00b..16ada5ad31 100644 Binary files a/build/tray/trayIconTemplate.png and b/build/tray/trayIconTemplate.png differ diff --git a/build/tray/trayIconTemplate@2x.png b/build/tray/trayIconTemplate@2x.png index 3f28605dbf..73c40de994 100644 Binary files a/build/tray/trayIconTemplate@2x.png and b/build/tray/trayIconTemplate@2x.png differ diff --git a/build/tray/trayIconTemplate@3x.png b/build/tray/trayIconTemplate@3x.png index 5e682a5d82..d7a39b9260 100644 Binary files a/build/tray/trayIconTemplate@3x.png and b/build/tray/trayIconTemplate@3x.png differ diff --git a/docs/README.md b/docs/README.md index f9ca9e6779..67369f86fc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -51,4 +51,4 @@ To provide feedback on the documentation or issues with the Lens Extension API, ## Downloading Lens -[Download Lens](https://github.com/lensapp/lens/releases) for macOS, Windows, or Linux. +[Download Lens](https://k8slens.dev/) for macOS, Windows, or Linux. diff --git a/docs/extensions/get-started/your-first-extension.md b/docs/extensions/get-started/your-first-extension.md index fa570e6932..c8c1167943 100644 --- a/docs/extensions/get-started/your-first-extension.md +++ b/docs/extensions/get-started/your-first-extension.md @@ -78,7 +78,7 @@ npm run dev You must restart Lens for the extension to load. After this initial restart, reload Lens and it will automatically pick up changes any time the extension rebuilds. -With Lens running, either connect to an existing cluster or create a new one - refer to the latest [Lens Documentation](https://docs.k8slens.dev/latest/clusters/adding-clusters) for details on how to add a cluster in Lens IDE. +With Lens running, either connect to an existing cluster or create a new one - refer to the latest [Lens Documentation](https://docs.k8slens.dev/main/catalog/) for details on how to add a cluster in Lens IDE. You will see the "Hello World" page in the left-side cluster menu. ## Develop the Extension diff --git a/docs/extensions/guides/images/clusterpagemenus.png b/docs/extensions/guides/images/clusterpagemenus.png index 3ed1c79e5b..77a3ec118a 100644 Binary files a/docs/extensions/guides/images/clusterpagemenus.png and b/docs/extensions/guides/images/clusterpagemenus.png differ diff --git a/docs/extensions/guides/renderer-extension.md b/docs/extensions/guides/renderer-extension.md index 110cf3331f..527dc0c623 100644 --- a/docs/extensions/guides/renderer-extension.md +++ b/docs/extensions/guides/renderer-extension.md @@ -60,7 +60,7 @@ Implementing `onDeactivate()` gives you the opportunity to clean up after your e 1. Navigate to **File** > **Extensions** in the top menu bar. (On Mac, it is **Lens** > **Extensions**.) - 2. Click **Disable** on the extension you want to disable. + 2. For the extension you want to disable, open the context menu (click on the three vertical dots) and choose **Disable**. The example above logs messages when the extension is enabled and disabled. @@ -71,7 +71,7 @@ Use cluster pages to display information about or add functionality to the activ It is also possible to include custom details from other clusters. Use your extension to access Kubernetes resources in the active cluster with [`ClusterStore.getInstance()`](../stores#Clusterstore). -Add a cluster page definition to a `LensRendererExtension` subclass with the following example: +Add a cluster page definition to a `Renderer.LensExtension` subclass with the following example: ```typescript import { Renderer } from "@k8slens/extensions"; @@ -124,7 +124,7 @@ Use `clusterPageMenus`, covered in the next section, to add cluster pages to the ### `clusterPageMenus` -`clusterPageMenus` allows you to add cluster page menu items to the secondary left nav. +`clusterPageMenus` allows you to add cluster page menu items to the secondary left nav, below Lens's standard cluster menus like **Workloads**, **Custom Resources**, etc. By expanding on the above example, you can add a cluster page menu item to the `ExampleExtension` definition: @@ -266,18 +266,18 @@ Activating this menu item toggles on and off the appearance of the submenu below The remaining two cluster page menu objects define the contents of the submenu. A cluster page menu object is defined to be a submenu item by setting the `parentId` field to the id of the parent of a foldout submenu, `"example"` in this case. -This is what the example will look like, including how the menu item will appear in the secondary left nav: +This is what the example could look like, including how the menu item will appear in the secondary left nav: + +![Cluster Page Menus](images/clusterpagemenus.png) ### `globalPages` Global pages are independent of the cluster dashboard and can fill the entire Lens UI. -Their primary use is to display information and provide functionality across clusters, including customized data and functionality unique to your extension. +Their primary use is to display information and provide functionality across clusters (or catalog entities), including customized data and functionality unique to your extension. -Typically, you would use a [global page menu](#globalpagemenus) located in the left nav to trigger a global page. -You can also trigger a global page with a [custom app menu selection](../main-extension#appmenus) from a Main Extension or a [custom status bar item](#statusbaritems). -Unlike cluster pages, users can trigger global pages even when there is no active cluster. +Unlike cluster pages, users can trigger global pages even when there is no active cluster (or catalog entity). -The following example defines a `LensRendererExtension` subclass with a single global page definition: +The following example defines a `Renderer.LensExtension` subclass with a single global page definition: ```typescript import { Renderer } from '@k8slens/extensions'; @@ -326,11 +326,11 @@ This allows the `HelpExtension` object to be passed in the global page definitio This way, `HelpPage` can access all `HelpExtension` subclass data. This example code shows how to create a global page, but not how to make that page available to the Lens user. -Global pages can be made available in the following ways: +Global pages are typically made available in the following ways: * To add global pages to the top menu bar, see [`appMenus`](../main-extension#appmenus) in the Main Extension guide. * To add global pages as an interactive element in the blue status bar along the bottom of the Lens UI, see [`statusBarItems`](#statusbaritems). -* To add global pages to the left side menu, see [`globalPageMenus`](#globalpagemenus). +* To add global pages to the Welcome Page, see [`welcomeMenus`](#welcomemenus). ### `welcomeMenus` ### `appPreferences` @@ -381,6 +381,7 @@ In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExampleP ```typescript import { Renderer } from "@k8slens/extensions"; +import { makeObservable } from "mobx"; import { observer } from "mobx-react"; import React from "react"; @@ -399,6 +400,11 @@ export class ExamplePreferenceProps { @observer export class ExamplePreferenceInput extends React.Component { + public constructor() { + super({preference: { enabled: false}}); + makeObservable(this); + } + render() { const { preference } = this.props; return ( @@ -432,7 +438,7 @@ It is used to indicate the state of the preference, and it is bound to the check `ExamplePreferenceHint` is a simple text span. -The above example introduces the decorators `observable` and `observer` from the [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) packages. +The above example introduces the decorators `makeObservable` and `observer` from the [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) packages. `mobx` simplifies state management. Without it, this example would not visually update the checkbox properly when the user activates it. [Lens uses `mobx`](../working-with-mobx) extensively for state management of its own UI elements. 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/extensions/metrics-cluster-feature/src/metrics-settings.tsx b/extensions/metrics-cluster-feature/src/metrics-settings.tsx index 5399cd638a..f05854b0f2 100644 --- a/extensions/metrics-cluster-feature/src/metrics-settings.tsx +++ b/extensions/metrics-cluster-feature/src/metrics-settings.tsx @@ -281,7 +281,9 @@ export class MetricsSettings extends React.Component { waiting={this.inProgress} onClick={() => this.save()} primary - disabled={!this.changed} /> + disabled={!this.changed} + className="w-60 h-14" + /> {this.canUpgrade && ( An update is available for enabled metrics components. diff --git a/extensions/node-menu/src/node-menu.tsx b/extensions/node-menu/src/node-menu.tsx index fd8186b18e..12ded04899 100644 --- a/extensions/node-menu/src/node-menu.tsx +++ b/extensions/node-menu/src/node-menu.tsx @@ -86,23 +86,26 @@ export function NodeMenu(props: NodeMenuProps) { return ( <> - + Shell - {!node.isUnschedulable() && ( - - - Cordon - - )} - {node.isUnschedulable() && ( - - - Uncordon - - )} + { + node.isUnschedulable() + ? ( + + + Uncordon + + ) + : ( + + + Cordon + + ) + } - + Drain diff --git a/extensions/pod-menu/src/attach-menu.tsx b/extensions/pod-menu/src/attach-menu.tsx index bb43d151bc..ad02782641 100644 --- a/extensions/pod-menu/src/attach-menu.tsx +++ b/extensions/pod-menu/src/attach-menu.tsx @@ -74,7 +74,7 @@ export class PodAttachMenu extends React.Component { return ( this.attachToPod(containers[0].name))}> - + Attach Pod {containers.length > 1 && ( <> diff --git a/extensions/pod-menu/src/logs-menu.tsx b/extensions/pod-menu/src/logs-menu.tsx index 042490f568..41deb92e1a 100644 --- a/extensions/pod-menu/src/logs-menu.tsx +++ b/extensions/pod-menu/src/logs-menu.tsx @@ -62,7 +62,7 @@ export class PodLogsMenu extends React.Component { return ( this.showLogs(containers[0]))}> - + Logs {containers.length > 1 && ( <> diff --git a/extensions/pod-menu/src/shell-menu.tsx b/extensions/pod-menu/src/shell-menu.tsx index e1c72391aa..4c52379525 100644 --- a/extensions/pod-menu/src/shell-menu.tsx +++ b/extensions/pod-menu/src/shell-menu.tsx @@ -79,7 +79,7 @@ export class PodShellMenu extends React.Component { return ( this.execShell(containers[0].name))}> - + Shell {containers.length > 1 && ( <> diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 24ad38dcfc..27a0b508a8 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -41,40 +41,38 @@ describe("Lens cluster pages", () => { const BACKSPACE = "\uE003"; let app: Application; const ready = minikubeReady(TEST_NAMESPACE); + let clusterAdded = false; utils.describeIf(ready)("test common pages", () => { - let clusterAdded = false; const addCluster = async () => { - await app.client.waitUntilTextExists("div", "Catalog"); await waitForMinikubeDashboard(app); await app.client.click('a[href="/nodes"]'); await app.client.waitUntilTextExists("div.TableCell", "Ready"); }; + const appStartAddCluster = async () => { + app = await utils.appStart(); + await addCluster(); + clusterAdded = true; + }; + + const tearDown = async () => { + await utils.tearDown(app); + clusterAdded = false; + }; + describe("cluster add", () => { utils.beforeAllWrapped(async () => { app = await utils.appStart(); }); - utils.afterAllWrapped(async () => { - if (app?.isRunning()) { - return utils.tearDown(app); - } - }); + utils.afterAllWrapped(tearDown); it("allows to add a cluster", async () => { await addCluster(); - clusterAdded = true; }); }); - const appStartAddCluster = async () => { - if (clusterAdded) { - app = await utils.appStart(); - await addCluster(); - } - }; - function getSidebarSelectors(itemId: string) { const root = `.SidebarItem[data-test-id="${itemId}"]`; @@ -86,12 +84,7 @@ describe("Lens cluster pages", () => { describe("cluster pages", () => { utils.beforeAllWrapped(appStartAddCluster); - - utils.afterAllWrapped(async () => { - if (app?.isRunning()) { - return utils.tearDown(app); - } - }); + utils.afterAllWrapped(tearDown); const tests: { drawer?: string @@ -376,12 +369,7 @@ describe("Lens cluster pages", () => { describe("viewing pod logs", () => { utils.beforeEachWrapped(appStartAddCluster); - - utils.afterEachWrapped(async () => { - if (app?.isRunning()) { - return utils.tearDown(app); - } - }); + utils.afterEachWrapped(tearDown); it(`shows a log for a pod`, async () => { expect(clusterAdded).toBe(true); @@ -409,8 +397,12 @@ describe("Lens cluster pages", () => { // Open logs tab in dock await app.client.click(".list .TableRow:first-child"); await app.client.waitForVisible(".Drawer"); - await app.client.waitForVisible(`ul.KubeObjectMenu li.MenuItem i[title="Logs"]`); - await app.client.click("ul.KubeObjectMenu li.MenuItem i[title='Logs']"); + + const logsButton = "ul.KubeObjectMenu li.MenuItem i.Icon span[data-icon-name='subject']"; + + await app.client.waitForVisible(logsButton); + await app.client.click(logsButton); + // Check if controls are available await app.client.waitForVisible(".LogList .VirtualList"); await app.client.waitForVisible(".LogResourceSelector"); @@ -427,12 +419,7 @@ describe("Lens cluster pages", () => { describe("cluster operations", () => { utils.beforeEachWrapped(appStartAddCluster); - - utils.afterEachWrapped(async () => { - if (app?.isRunning()) { - return utils.tearDown(app); - } - }); + utils.afterEachWrapped(tearDown); it("shows default namespace", async () => { expect(clusterAdded).toBe(true); diff --git a/integration/helpers/minikube.ts b/integration/helpers/minikube.ts index bbe411047b..8698bd6be2 100644 --- a/integration/helpers/minikube.ts +++ b/integration/helpers/minikube.ts @@ -65,6 +65,7 @@ export async function waitForMinikubeDashboard(app: Application) { await app.client.waitUntilTextExists("div.TableCell", "minikube"); await app.client.click("div.TableRow"); await app.client.waitUntilTextExists("div.drawer-title-text", "KubernetesCluster: minikube"); + await app.client.waitForExist("div.EntityIcon div.HotbarIcon div div.MuiAvatar-root"); await app.client.click("div.EntityIcon div.HotbarIcon div div.MuiAvatar-root"); await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); await app.client.waitForExist(`iframe[name="minikube"]`); diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index 9ce33b21c3..d6f633e416 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -37,27 +37,27 @@ interface DoneCallback { * This is necessary because Jest doesn't do this correctly. * @param fn The function to call */ -export function wrapJestLifecycle(fn: () => Promise): (done: DoneCallback) => void { +export function wrapJestLifecycle(fn: () => Promise | void): (done: DoneCallback) => void { return function (done: DoneCallback) { - fn() + (async () => fn())() .then(() => done()) .catch(error => done.fail(error)); }; } -export function beforeAllWrapped(fn: () => Promise): void { +export function beforeAllWrapped(fn: () => Promise | void): void { beforeAll(wrapJestLifecycle(fn)); } -export function beforeEachWrapped(fn: () => Promise): void { +export function beforeEachWrapped(fn: () => Promise | void): void { beforeEach(wrapJestLifecycle(fn)); } -export function afterAllWrapped(fn: () => Promise): void { +export function afterAllWrapped(fn: () => Promise | void): void { afterAll(wrapJestLifecycle(fn)); } -export function afterEachWrapped(fn: () => Promise): void { +export function afterEachWrapped(fn: () => Promise | void): void { afterEach(wrapJestLifecycle(fn)); } @@ -99,13 +99,17 @@ export async function appStart() { } export async function showCatalog(app: Application) { - await app.client.waitUntilTextExists("[data-test-id=catalog-link]", "Catalog"); - await app.client.click("[data-test-id=catalog-link]"); + await app.client.waitForExist("#hotbarIcon-catalog-entity .Icon"); + await app.client.click("#hotbarIcon-catalog-entity .Icon"); } type AsyncPidGetter = () => Promise; -export async function tearDown(app: Application) { +export async function tearDown(app?: Application) { + if (!app?.isRunning()) { + return; + } + const pid = await (app.mainProcess.pid as any as AsyncPidGetter)(); await app.stop(); diff --git a/package.json b/package.json index 083fab3eb3..952113d365 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-beta.11", + "version": "5.1.0", "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.18.15", - "bundledHelmVersion": "3.5.4" + "bundledHelmVersion": "3.5.4", + "sentryDsn": "" }, "engines": { "node": ">=12 <13" @@ -61,8 +63,7 @@ }, "moduleNameMapper": { "\\.(css|scss)$": "/__mocks__/styleMock.ts", - "\\.(svg)$": "/__mocks__/imageMock.ts", - "^@lingui/macro$": "/__mocks__/@linguiMacro.ts" + "\\.(svg)$": "/__mocks__/imageMock.ts" }, "modulePathIgnorePatterns": [ "/dist", @@ -182,6 +183,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,7 +200,6 @@ "electron-updater": "^4.3.1", "electron-window-state": "^5.0.3", "filehound": "^1.17.4", - "filenamify": "^4.1.0", "fs-extra": "^9.0.1", "grapheme-splitter": "^1.0.4", "handlebars": "^4.7.7", @@ -250,6 +252,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", @@ -273,7 +277,7 @@ "@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", @@ -315,26 +319,28 @@ "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", + "esbuild": "^0.12.12", + "esbuild-loader": "^2.13.1", "eslint": "^7.7.0", "eslint-plugin-header": "^3.1.1", - "eslint-plugin-react": "^7.21.5", + "eslint-plugin-react": "^7.24.0", "eslint-plugin-react-hooks": "^4.2.0", "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", @@ -347,7 +353,7 @@ "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", @@ -377,6 +383,6 @@ "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/scripts/tag-release.sh b/scripts/tag-release.sh index 82b7f14e6c..ce2ddffb2b 100755 --- a/scripts/tag-release.sh +++ b/scripts/tag-release.sh @@ -1,10 +1,21 @@ #!/bin/bash +while [[ $# -gt 0 ]]; do + key="$1" + + case $key in + -f|--force) + FORCE="--force" + shift # past argument + ;; + esac +done + if [[ `git branch --show-current` =~ ^release/v ]] then VERSION_STRING=$(cat package.json | jq '.version' -r | xargs printf "v%s") - git tag ${VERSION_STRING} - git push ${GIT_REMOTE:-origin} ${VERSION_STRING} + git tag ${VERSION_STRING} ${FORCE} + git push ${GIT_REMOTE:-origin} ${VERSION_STRING} ${FORCE} else echo "You must be in a release branch" fi diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 92839bcb30..6d4198d8d7 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -95,7 +95,7 @@ describe("empty config", () => { mockFs(mockOpts); - await ClusterStore.createInstance().load(); + ClusterStore.createInstance(); }); afterEach(() => { @@ -203,7 +203,7 @@ describe("config with existing clusters", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { @@ -285,7 +285,7 @@ users: mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { @@ -344,7 +344,7 @@ describe("pre 2.0 config with an existing cluster", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { @@ -414,7 +414,7 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () => mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { @@ -456,7 +456,7 @@ describe("pre 2.6.0 config with a cluster icon", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { @@ -495,7 +495,7 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { @@ -531,7 +531,7 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => { mockFs(mockOpts); - return ClusterStore.createInstance().load(); + return ClusterStore.createInstance(); }); afterEach(() => { diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index 51002fee23..267fbe3e8e 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -23,7 +23,7 @@ import mockFs from "mock-fs"; import { ClusterStore } from "../cluster-store"; import { HotbarStore } from "../hotbar-store"; -jest.mock("../../renderer/api/catalog-entity-registry", () => ({ +jest.mock("../../main/catalog/catalog-entity-registry", () => ({ catalogEntityRegistry: { items: [ { @@ -39,7 +39,14 @@ jest.mock("../../renderer/api/catalog-entity-registry", () => ({ name: "my_shiny_cluster", source: "remote" } - } + }, + { + metadata: { + uid: "catalog-entity", + name: "Catalog", + source: "app" + }, + }, ] } })); @@ -120,29 +127,31 @@ jest.mock("electron", () => { describe("HotbarStore", () => { beforeEach(() => { - ClusterStore.resetInstance(); + mockFs({ + "tmp": { + "lens-hotbar-store.json": JSON.stringify({}) + } + }); ClusterStore.createInstance(); - - HotbarStore.resetInstance(); - mockFs({ tmp: { "lens-hotbar-store.json": "{}" } }); + HotbarStore.createInstance(); }); afterEach(() => { + ClusterStore.resetInstance(); + HotbarStore.resetInstance(); mockFs.restore(); }); describe("load", () => { it("loads one hotbar by default", () => { - HotbarStore.createInstance().load(); expect(HotbarStore.getInstance().hotbars.length).toEqual(1); }); }); describe("add", () => { it("adds a hotbar", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.add({ name: "hottest" }); expect(hotbarStore.hotbars.length).toEqual(2); }); @@ -150,106 +159,104 @@ describe("HotbarStore", () => { describe("hotbar items", () => { it("initially creates 12 empty cells", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); expect(hotbarStore.getActive().items.length).toEqual(12); }); - it("adds items", () => { - const hotbarStore = HotbarStore.createInstance(); + it("initially adds catalog entity as first item", () => { + const hotbarStore = HotbarStore.getInstance(); + + expect(hotbarStore.getActive().items[0].entity.name).toEqual("Catalog"); + }); + + it("adds items", () => { + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); const items = hotbarStore.getActive().items.filter(Boolean); - expect(items.length).toEqual(1); + expect(items.length).toEqual(2); }); it("removes items", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.removeFromHotbar("test"); + hotbarStore.removeFromHotbar("catalog-entity"); const items = hotbarStore.getActive().items.filter(Boolean); expect(items.length).toEqual(0); }); it("does nothing if removing with invalid uid", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.removeFromHotbar("invalid uid"); const items = hotbarStore.getActive().items.filter(Boolean); - expect(items.length).toEqual(1); + expect(items.length).toEqual(2); }); it("moves item to empty cell", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.addToHotbar(minikubeCluster); hotbarStore.addToHotbar(awsCluster); - expect(hotbarStore.getActive().items[5]).toBeNull(); + expect(hotbarStore.getActive().items[6]).toBeNull(); hotbarStore.restackItems(1, 5); expect(hotbarStore.getActive().items[5]).toBeTruthy(); - expect(hotbarStore.getActive().items[5].entity.uid).toEqual("minikube"); + expect(hotbarStore.getActive().items[5].entity.uid).toEqual("test"); }); it("moves items down", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.addToHotbar(minikubeCluster); hotbarStore.addToHotbar(awsCluster); - // aws -> test - hotbarStore.restackItems(2, 0); + // aws -> catalog + hotbarStore.restackItems(3, 0); const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); - expect(items.slice(0, 4)).toEqual(["aws", "test", "minikube", null]); + expect(items.slice(0, 4)).toEqual(["aws", "catalog-entity", "test", "minikube"]); }); it("moves items up", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.addToHotbar(minikubeCluster); hotbarStore.addToHotbar(awsCluster); // test -> aws - hotbarStore.restackItems(0, 2); + hotbarStore.restackItems(1, 3); const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); - expect(items.slice(0, 4)).toEqual(["minikube", "aws", "test", null]); + expect(items.slice(0, 4)).toEqual(["catalog-entity", "minikube", "aws", "test"]); }); it("does nothing when item moved to same cell", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); - hotbarStore.restackItems(0, 0); + hotbarStore.restackItems(1, 1); - expect(hotbarStore.getActive().items[0].entity.uid).toEqual("test"); + expect(hotbarStore.getActive().items[1].entity.uid).toEqual("test"); }); it("new items takes first empty cell", () => { - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.addToHotbar(awsCluster); hotbarStore.restackItems(0, 3); @@ -260,13 +267,13 @@ describe("HotbarStore", () => { it("throws if invalid arguments provided", () => { // Prevent writing to stderr during this render. - const err = console.error; + const { error, warn } = console; console.error = jest.fn(); + console.warn = jest.fn(); - const hotbarStore = HotbarStore.createInstance(); + const hotbarStore = HotbarStore.getInstance(); - hotbarStore.load(); hotbarStore.addToHotbar(testCluster); expect(() => hotbarStore.restackItems(-5, 0)).toThrow(); @@ -275,7 +282,8 @@ describe("HotbarStore", () => { expect(() => hotbarStore.restackItems(11, 112)).toThrow(); // Restore writing to stderr. - console.error = err; + console.error = error; + console.warn = warn; }); }); @@ -346,7 +354,7 @@ describe("HotbarStore", () => { mockFs(mockOpts); - return HotbarStore.createInstance().load(); + HotbarStore.createInstance(); }); afterEach(() => { diff --git a/src/common/__tests__/user-store.test.ts b/src/common/__tests__/user-store.test.ts index 465c598bb0..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()); - - return UserStore.getInstance().load(); }); 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"); @@ -81,11 +78,9 @@ describe("user store tests", () => { it("correctly resets theme to default value", async () => { const us = UserStore.getInstance(); - us.isLoaded = true; - us.colorTheme = "some other theme"; - await us.resetTheme(); - expect(us.colorTheme).toBe(UserStore.defaultTheme); + us.resetTheme(); + expect(us.colorTheme).toBe(ThemeStore.defaultTheme); }); it("correctly calculates if the last seen version is an old release", () => { @@ -100,21 +95,39 @@ 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", + } } }); - return UserStore.createInstance().load(); + UserStore.createInstance(); }); afterEach(() => { + UserStore.resetInstance(); mockFs.restore(); }); @@ -123,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 842e0304b4..095e0c1877 100644 --- a/src/common/base-store.ts +++ b/src/common/base-store.ts @@ -23,41 +23,48 @@ import path from "path"; import Config from "conf"; import type { Options as ConfOptions } from "conf/dist/source/types"; import { app, ipcMain, ipcRenderer, remote } from "electron"; -import { IReactionOptions, makeObservable, observable, reaction, runInAction, when } from "mobx"; +import { IReactionOptions, makeObservable, reaction, runInAction } from "mobx"; import { getAppVersion, Singleton, toJS, Disposer } from "./utils"; import logger from "../main/logger"; import { broadcastMessage, ipcMainOn, ipcRendererOn } from "./ipc"; import isEqual from "lodash/isEqual"; -export interface BaseStoreParams extends ConfOptions { - autoLoad?: boolean; - syncEnabled?: boolean; +export interface BaseStoreParams extends ConfOptions { syncOptions?: IReactionOptions; } /** * Note: T should only contain base JSON serializable types. */ -export abstract class BaseStore extends Singleton { +export abstract class BaseStore extends Singleton { protected storeConfig?: Config; protected syncDisposers: Disposer[] = []; - @observable isLoaded = false; - - get whenLoaded() { - return when(() => this.isLoaded); - } - - protected constructor(protected params: BaseStoreParams) { + protected constructor(protected params: BaseStoreParams) { super(); makeObservable(this); + } - this.params = { - autoLoad: false, - syncEnabled: true, - ...params, - }; - this.init(); + /** + * This must be called after the last child's constructor is finished (or just before it finishes) + */ + load() { + this.storeConfig = new Config({ + ...this.params, + projectName: "lens", + projectVersion: getAppVersion(), + cwd: this.cwd(), + }); + + 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() { @@ -76,31 +83,6 @@ export abstract class BaseStore extends Singleton { return this.storeConfig?.path || ""; } - protected async init() { - if (this.params.autoLoad) { - await this.load(); - } - - if (this.params.syncEnabled) { - await this.whenLoaded; - this.enableSync(); - } - } - - async load() { - const { autoLoad, syncEnabled, ...confOptions } = this.params; - - this.storeConfig = new Config({ - ...confOptions, - projectName: "lens", - projectVersion: getAppVersion(), - cwd: this.cwd(), - }); - logger.info(`[STORE]: LOADED from ${this.path}`); - this.fromStore(this.storeConfig.store); - this.isLoaded = true; - } - protected cwd() { return (app || remote.app).getPath("userData"); } @@ -159,10 +141,7 @@ export abstract class BaseStore extends Singleton { protected applyWithoutSync(callback: () => void) { this.disableSync(); runInAction(callback); - - if (this.params.syncEnabled) { - this.enableSync(); - } + this.enableSync(); } protected onSync(model: T) { @@ -184,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/general.ts b/src/common/catalog-entities/general.ts new file mode 100644 index 0000000000..5d13eeabee --- /dev/null +++ b/src/common/catalog-entities/general.ts @@ -0,0 +1,76 @@ +/** + * 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 { navigate } from "../../renderer/navigation"; +import { CatalogCategory, CatalogEntity, CatalogEntityMetadata, CatalogEntitySpec, CatalogEntityStatus } from "../catalog"; +import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; + +interface GeneralEntitySpec extends CatalogEntitySpec { + path: string; + icon?: { + material?: string; + background?: string; + }; +} + +export class GeneralEntity extends CatalogEntity { + public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; + public readonly kind = "General"; + + async onRun() { + navigate(this.spec.path); + } + + public onSettingsOpen(): void { + return; + } + + public onDetailsOpen(): void { + return; + } + + public onContextMenuOpen(): void { + return; + } +} + +export class GeneralCategory extends CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + public metadata = { + name: "General", + icon: "settings" + }; + public spec = { + group: "entity.k8slens.dev", + versions: [ + { + name: "v1alpha1", + entityClass: GeneralEntity + } + ], + names: { + kind: "General" + } + }; +} + +catalogCategoryRegistry.add(new GeneralCategory()); diff --git a/src/common/catalog-entities/index.ts b/src/common/catalog-entities/index.ts index 621383c6ec..ee8a41b552 100644 --- a/src/common/catalog-entities/index.ts +++ b/src/common/catalog-entities/index.ts @@ -19,5 +19,6 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +export * from "./general"; export * from "./kubernetes-cluster"; export * from "./web-link"; diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 1f28f3453f..83a94547bb 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -27,9 +27,10 @@ import { requestMain } from "../ipc"; import { CatalogCategory, CatalogCategorySpec } from "../catalog"; import { addClusterURL } from "../routes"; import { app } from "electron"; +import type { CatalogEntitySpec } from "../catalog/catalog-entity"; import { HotbarStore } from "../hotbar-store"; -export type KubernetesClusterPrometheusMetrics = { +export interface KubernetesClusterPrometheusMetrics { address?: { namespace: string; service: string; @@ -37,56 +38,55 @@ export type KubernetesClusterPrometheusMetrics = { prefix: string; }; type?: string; -}; +} -export type KubernetesClusterSpec = { +export interface KubernetesClusterSpec extends CatalogEntitySpec { kubeconfigPath: string; kubeconfigContext: string; - iconData?: string; metrics?: { source: string; prometheus?: KubernetesClusterPrometheusMetrics; - } -}; - -export interface KubernetesClusterStatus extends CatalogEntityStatus { - phase: "connected" | "disconnected" | "deleting"; + }; + icon?: { + // TODO: move to CatalogEntitySpec once any-entity icons are supported + src?: string; + material?: string; + background?: string; + }; } -export class KubernetesCluster extends CatalogEntity { - public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; - public readonly kind = "KubernetesCluster"; +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 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) { - const cluster = ClusterStore.getInstance().getById(this.metadata.uid); - - if (!cluster) return; - - await cluster.activate(); - - return; + await ClusterStore.getInstance().getById(this.metadata.uid)?.activate(); + } else { + await requestMain(clusterActivateHandler, this.metadata.uid, false); } - - await requestMain(clusterActivateHandler, this.metadata.uid, false); - - return; } async disconnect(): Promise { if (app) { - const cluster = ClusterStore.getInstance().getById(this.metadata.uid); - - if (!cluster) return; - - cluster.disconnect(); - - return; + ClusterStore.getInstance().getById(this.metadata.uid)?.disconnect(); + } else { + await requestMain(clusterDisconnectHandler, this.metadata.uid, false); } - - await requestMain(clusterDisconnectHandler, this.metadata.uid, false); - - return; } async onRun(context: CatalogEntityActionContext) { @@ -118,29 +118,33 @@ export class KubernetesCluster extends CatalogEntity tag with better formatting once this code can accept it. - message: `Delete the "${this.metadata.name}" context from "${this.metadata.labels.file}"?` + message: `Delete the "${this.metadata.name}" context from "${this.spec.kubeconfigPath}"?` } }, ); } - if (this.status.phase == "connected") { - context.menuItems.push({ - title: "Disconnect", - icon: "link_off", - onClick: () => requestMain(clusterDisconnectHandler, this.metadata.uid) - }); - } else { - context.menuItems.push({ - title: "Connect", - icon: "link", - onClick: () => context.navigate(`/cluster/${this.metadata.uid}`) - }); + switch (this.status.phase) { + case "connected": + case "connecting": + context.menuItems.push({ + title: "Disconnect", + icon: "link_off", + onClick: () => requestMain(clusterDisconnectHandler, this.metadata.uid) + }); + break; + case "disconnected": + context.menuItems.push({ + title: "Connect", + icon: "link", + onClick: () => context.navigate(`/cluster/${this.metadata.uid}`) + }); + break; } - const category = catalogCategoryRegistry.getCategoryForEntity(this); - - if (category) category.emit("contextMenuOpen", this, context); + catalogCategoryRegistry + .getCategoryForEntity(this) + ?.emit("contextMenuOpen", this, context); } } diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index 8b8d04f6cb..bc3e360a7b 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -149,6 +149,7 @@ export interface CatalogEntityAddMenuContext { export type CatalogEntitySpec = Record; + export interface CatalogEntityData< Metadata extends CatalogEntityMetadata = CatalogEntityMetadata, Status extends CatalogEntityStatus = CatalogEntityStatus, diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 26f0e24370..b6e198a213 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -71,8 +71,13 @@ export interface ClusterModel { */ workspace?: string; + /** + * @deprecated this is used only for hotbar migrations from 4.2.X + */ + workspaces?: string[]; + /** User context in kubeconfig */ - contextName?: string; + contextName: string; /** Preferences */ preferences?: ClusterPreferences; @@ -96,6 +101,8 @@ export interface ClusterPreferences extends ClusterPrometheusPreferences { icon?: string; httpsProxy?: string; hiddenMetrics?: string[]; + nodeShellImage?: string; + imagePullSecret?: string; } export interface ClusterPrometheusPreferences { @@ -110,6 +117,10 @@ 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"; @@ -121,8 +132,8 @@ export class ClusterStore extends BaseStore { return path.resolve(ClusterStore.storedKubeConfigFolder, clusterId); } - @observable clusters = observable.map(); - @observable removedClusters = observable.map(); + clusters = observable.map(); + removedClusters = observable.map(); protected disposer = disposer(); @@ -137,31 +148,27 @@ export class ClusterStore extends BaseStore { }); makeObservable(this); - + this.load(); this.pushStateToViewsAutomatically(); } - async load() { - const initialStates = "cluster:states"; + async loadInitialOnRenderer() { + logger.info("[CLUSTER-STORE] requesting initial state sync"); - await super.load(); - - if (ipcRenderer) { - logger.info("[CLUSTER-STORE] requesting initial state sync"); - - for (const { id, state } of await requestMain(initialStates)) { - this.getById(id)?.setState(state); - } - } else if (ipcMain) { - ipcMainHandle(initialStates, () => { - return this.clustersList.map(cluster => ({ - id: cluster.id, - state: cluster.getState(), - })); - }); + for (const { id, state } of await requestMain(initialStates)) { + this.getById(id)?.setState(state); } } + provideInitialFromMain() { + ipcMainHandle(initialStates, () => { + return this.clustersList.map(cluster => ({ + id: cluster.id, + state: cluster.getState(), + })); + }); + } + protected pushStateToViewsAutomatically() { if (ipcMain) { this.disposer.push( @@ -243,8 +250,8 @@ export class ClusterStore extends BaseStore { cluster = new Cluster(clusterModel); } newClusters.set(clusterModel.id, cluster); - } catch { - // ignore + } catch (error) { + logger.warn(`[CLUSTER-STORE]: Failed to update/create a cluster: ${error}`); } } diff --git a/src/renderer/components/layout/login-layout.scss b/src/common/getTSLoader.ts old mode 100755 new mode 100644 similarity index 54% rename from src/renderer/components/layout/login-layout.scss rename to src/common/getTSLoader.ts index 9996d07ed0..6c76770d5d --- a/src/renderer/components/layout/login-layout.scss +++ b/src/common/getTSLoader.ts @@ -19,53 +19,45 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import esbuild from "esbuild"; -.LoginLayout { - $logo-size: 6 * $unit; - height: 100vh; - padding: $padding * 2; +/** + * A function returning webpack ts/tsx loader + * + * depends on env LENS_DEV_USE_ESBUILD_LOADER to use esbuild-loader (faster) or good-old ts-loader + * + * @param testRegExp - the regex for webpack to conditional find the files + * @returns ts/tsx webpack loader configuration object + */ +const getTSLoader = ( + testRegExp: RegExp, transpileOnly = true +) => { + const useEsbuildLoader = process.env.LENS_DEV_USE_ESBUILD_LOADER === "true"; - .logo { - width: $logo-size; - height: $logo-size; - margin: auto; + useEsbuildLoader && console.info(`\n🚀 using esbuild-loader for ts(x)`); - svg * { - fill: $lensBlue; - } + if (useEsbuildLoader) { + return { + test: testRegExp, + loader: "esbuild-loader", + options: { + loader: "tsx", + target: "es2015", + implementation: esbuild + }, + }; } - .header { - font-size: $font-size-small; - } - - .main { - $spacing: $padding * 3; - width: 100%; - min-width: 34 * $unit; - max-width: 42 * $unit; - margin: $padding * 2 0; - overflow: hidden; - - > * { - padding: $spacing; - } - - .title { - background: $layoutBackground; - text-align: center; - - h5 { - color: $textColorAccent; + return { + test: testRegExp, + exclude: /node_modules/, + use: { + loader: "ts-loader", + options: { + transpileOnly, } } + }; +}; - .content { - background: $contentColor; - } - } - - .footer { - font-size: $font-size-small; - } -} \ No newline at end of file +export default getTSLoader; diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index 6d15ace11e..90694e9976 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -26,6 +26,7 @@ import * as uuid from "uuid"; import isNull from "lodash/isNull"; import { toJS } from "./utils"; import { CatalogEntity } from "./catalog"; +import { catalogEntity } from "../main/catalog-sources/general"; export interface HotbarItem { entity: { @@ -38,16 +39,12 @@ export interface HotbarItem { } } -export interface Hotbar { - id: string; - name: string; - items: HotbarItem[]; -} +export type Hotbar = Required; export interface HotbarCreateOptions { id?: string; name: string; - items?: HotbarItem[]; + items?: (HotbarItem | null)[]; } export interface HotbarStoreModel { @@ -71,6 +68,7 @@ export class HotbarStore extends BaseStore { migrations, }); makeObservable(this); + this.load(); } get activeHotbarId() { @@ -91,16 +89,17 @@ export class HotbarStore extends BaseStore { return this.hotbarIndex(this.activeHotbarId); } - get initialItems() { + static getInitialItems() { return [...Array.from(Array(defaultHotbarCells).fill(null))]; } - @action protected async fromStore(data: Partial = {}) { - if (data.hotbars?.length === 0) { + @action + protected fromStore(data: Partial = {}) { + if (!data.hotbars || !data.hotbars.length) { this.hotbars = [{ id: uuid.v4(), name: "Default", - items: this.initialItems, + items: this.defaultHotbarInitialItems, }]; } else { this.hotbars = data.hotbars; @@ -117,6 +116,16 @@ export class HotbarStore extends BaseStore { } } + get defaultHotbarInitialItems() { + const { metadata: { uid, name, source } } = catalogEntity; + const initialItem = { entity: { uid, name, source }}; + + return [ + initialItem, + ...Array.from(Array(defaultHotbarCells - 1).fill(null)) + ]; + } + getActive() { return this.getById(this.activeHotbarId); } @@ -129,18 +138,19 @@ export class HotbarStore extends BaseStore { return this.hotbars.find((hotbar) => hotbar.id === id); } - add(data: HotbarCreateOptions) { + @action + add(data: HotbarCreateOptions, { setActive = false } = {}) { const { id = uuid.v4(), - items = this.initialItems, + items = HotbarStore.getInitialItems(), name, } = data; - const hotbar = { id, name, items }; + this.hotbars.push({ id, name, items }); - this.hotbars.push(hotbar as Hotbar); - - return hotbar as Hotbar; + if (setActive) { + this._activeHotbarId = id; + } } @action diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index 50f971c356..c387b18a55 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -30,6 +30,8 @@ import { ExtensionsStore } from "../../extensions/extensions-store"; import { ExtensionLoader } from "../../extensions/extension-loader"; import type { LensExtension } from "../../extensions/lens-extension"; import type { RouteHandler, RouteParams } from "../../extensions/registries/protocol-handler"; +import { when } from "mobx"; +import { ipcRenderer } from "electron"; // IPC channel for protocol actions. Main broadcasts the open-url events to this channel. export const ProtocolHandlerIpcPrefix = "protocol-handler"; @@ -178,16 +180,28 @@ export abstract class LensProtocolRouter extends Singleton { const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params; const name = [publisher, partialName].filter(Boolean).join("/"); + const extensionLoader = ExtensionLoader.getInstance(); - const extension = ExtensionLoader.getInstance().getInstanceByName(name); - - if (!extension) { - logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed`); + try { + /** + * Note, if `getInstanceByName` returns `null` that means we won't be getting an instance + */ + await when(() => extensionLoader.getInstanceByName(name) !== (void 0), { timeout: 5_000 }); + } catch(error) { + logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed (${error})`); return name; } - if (!ExtensionsStore.getInstance().isEnabled(extension.id)) { + const extension = extensionLoader.getInstanceByName(name); + + if (!extension) { + logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but does not have a class for ${ipcRenderer ? "renderer" : "main"}`); + + return name; + } + + if (!ExtensionsStore.getInstance().isEnabled(extension)) { logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`); return name; 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.ts b/src/common/user-store.ts deleted file mode 100644 index f7ab221bb8..0000000000 --- a/src/common/user-store.ts +++ /dev/null @@ -1,263 +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 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 path from "path"; -import os from "os"; -import { fileNameMigration } from "../migrations/user-store"; -import { ObservableToggleSet, toJS } from "../renderer/utils"; - -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"; - - constructor() { - super({ - configName: "lens-user-store", - migrations, - }); - makeObservable(this); - } - - @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"; - @observable httpsProxy?: string; - @observable shell?: string; - @observable downloadBinariesPath?: string; - @observable kubectlBinariesPath?: string; - - /** - * Download kubectl binaries matching cluster version - */ - @observable downloadKubectlBinaries = true; - @observable openAtLogin = false; - - /** - * The column IDs under each configurable table ID that have been configured - * to not be shown - */ - hiddenTableColumns = observable.map>(); - - /** - * The set of file/folder paths to be synced - */ - syncKubeconfigEntries = observable.map([ - [path.join(os.homedir(), ".kube"), {}] - ]); - - async load(): Promise { - /** - * This has to be here before the call to `new Config` in `super.load()` - * as we have to make sure that file is in the expected place for that call - */ - await fileNameMigration(); - await super.load(); - - if (app) { - // track telemetry availability - reaction(() => this.allowTelemetry, allowed => { - appEventBus.emit({ name: "telemetry", action: allowed ? "enabled" : "disabled" }); - }); - - // open at system start-up - reaction(() => this.openAtLogin, openAtLogin => { - app.setLoginItemSettings({ - openAtLogin, - openAsHidden: true, - args: ["--hidden"] - }); - }, { - fireImmediately: true, - }); - } - } - - @computed get isNewVersion() { - return semver.gt(getAppVersion(), this.lastSeenAppVersion); - } - - @computed get resolvedShell(): string | undefined { - return this.shell || process.env.SHELL || process.env.PTYSHELL; - } - - /** - * Checks if a column (by ID) for a table (by ID) is configured to be hidden - * @param tableId The ID of the table to be checked against - * @param columnIds The list of IDs the check if one is hidden - * @returns true if at least one column under the table is set to hidden - */ - isTableColumnHidden(tableId: string, ...columnIds: string[]): boolean { - if (columnIds.length === 0) { - return true; - } - - const config = this.hiddenTableColumns.get(tableId); - - if (!config) { - return true; - } - - return columnIds.some(columnId => config.has(columnId)); - } - - @action - /** - * Toggles the hidden configuration of a table's column - */ - toggleTableColumnVisibility(tableId: string, columnId: string) { - this.hiddenTableColumns.get(tableId)?.toggle(columnId); - } - - @action - async resetTheme() { - await this.whenLoaded; - this.colorTheme = UserStore.defaultTheme; - } - - @action - saveLastSeenAppVersion() { - appEventBus.emit({ name: "app", action: "whats-new-seen" }); - this.lastSeenAppVersion = getAppVersion(); - } - - @action - setLocaleTimezone(tz: string) { - this.localeTimezone = tz; - } - - @action - protected async fromStore(data: Partial = {}) { - const { lastSeenAppVersion, preferences } = data; - - 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]) - ); - } - } - - 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, - }, - }; - - return toJS(model); - } -} - -/** - * Getting default directory to download kubectl binaries - * @returns string - */ -export function getDefaultKubectlPath(): string { - return path.join((app || remote.app).getPath("userData"), "binaries"); -} diff --git a/src/renderer/components/app-init/app-init.scss b/src/common/user-store/index.ts similarity index 88% rename from src/renderer/components/app-init/app-init.scss rename to src/common/user-store/index.ts index 7583111eba..a8dc6eb028 100644 --- a/src/renderer/components/app-init/app-init.scss +++ b/src/common/user-store/index.ts @@ -19,11 +19,5 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -.AppInit { - height: 100%; - - .waiting-services { - font-size: small; - opacity: .75; - } -} \ No newline at end of file +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..3d205cd380 --- /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, columnes] of val) { + if (columnes.size) { + res.push([table, Array.from(columnes)]); + } + } + + 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/user-store.ts b/src/common/user-store/user-store.ts new file mode 100644 index 0000000000..beeb2078e1 --- /dev/null +++ b/src/common/user-store/user-store.ts @@ -0,0 +1,214 @@ +/** + * 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, remote } from "electron"; +import semver from "semver"; +import { action, computed, observable, reaction, makeObservable } from "mobx"; +import { BaseStore } from "../base-store"; +import migrations from "../../migrations/user-store"; +import { getAppVersion } from "../utils/app-version"; +import { kubeConfigDefaultPath } from "../kube-helpers"; +import { appEventBus } from "../event-bus"; +import path from "path"; +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 class UserStore extends BaseStore /* implements UserStoreFlatModel (when strict null is enabled) */ { + constructor() { + super({ + configName: "lens-user-store", + migrations, + }); + + makeObservable(this); + fileNameMigration(); + this.load(); + } + + @observable lastSeenAppVersion = "0.0.0"; + + /** + * 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; + @observable kubectlBinariesPath?: string; + + /** + * Download kubectl binaries matching cluster version + */ + @observable downloadKubectlBinaries: boolean; + @observable openAtLogin: boolean; + + /** + * The column IDs under each configurable table ID that have been configured + * to not be shown + */ + hiddenTableColumns = observable.map>(); + + /** + * The set of file/folder paths to be synced + */ + syncKubeconfigEntries = observable.map(); + + @computed get isNewVersion() { + return semver.gt(getAppVersion(), this.lastSeenAppVersion); + } + + @computed get resolvedShell(): string | undefined { + return this.shell || process.env.SHELL || process.env.PTYSHELL; + } + + startMainReactions() { + // track telemetry availability + reaction(() => this.allowTelemetry, allowed => { + appEventBus.emit({ name: "telemetry", action: allowed ? "enabled" : "disabled" }); + }); + + // open at system start-up + reaction(() => this.openAtLogin, openAtLogin => { + app.setLoginItemSettings({ + openAtLogin, + openAsHidden: true, + args: ["--hidden"] + }); + }, { + fireImmediately: true, + }); + } + + /** + * Checks if a column (by ID) for a table (by ID) is configured to be hidden + * @param tableId The ID of the table to be checked against + * @param columnIds The list of IDs the check if one is hidden + * @returns true if at least one column under the table is set to hidden + */ + isTableColumnHidden(tableId: string, ...columnIds: string[]): boolean { + if (columnIds.length === 0) { + return false; + } + + const config = this.hiddenTableColumns.get(tableId); + + if (!config) { + return false; + } + + return columnIds.some(columnId => config.has(columnId)); + } + + @action + /** + * Toggles the hidden configuration of a table's column + */ + toggleTableColumnVisibility(tableId: string, columnId: string) { + this.hiddenTableColumns.get(tableId)?.toggle(columnId); + } + + @action + resetTheme() { + this.colorTheme = DESCRIPTORS.colorTheme.fromStore(undefined); + } + + @action + saveLastSeenAppVersion() { + appEventBus.emit({ name: "app", action: "whats-new-seen" }); + this.lastSeenAppVersion = getAppVersion(); + } + + @action + setLocaleTimezone(tz: string) { + this.localeTimezone = tz; + } + + @action + protected fromStore({ lastSeenAppVersion, preferences }: Partial = {}) { + logger.debug("UserStore.fromStore()", { lastSeenAppVersion, preferences }); + + if (lastSeenAppVersion) { + this.lastSeenAppVersion = lastSeenAppVersion; + } + + 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 model: UserStoreModel = { + lastSeenAppVersion: this.lastSeenAppVersion, + preferences: { + 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), + }, + }; + + return toJS(model); + } +} + +/** + * Getting default directory to download kubectl binaries + * @returns string + */ +export function getDefaultKubectlPath(): string { + return path.join((app || remote.app).getPath("userData"), "binaries"); +} 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/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 0f66725545..a91f83afa2 100644 --- a/src/common/weblink-store.ts +++ b/src/common/weblink-store.ts @@ -55,9 +55,11 @@ export class WeblinkStore extends BaseStore { migrations, }); makeObservable(this); + 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-discovery.test.ts b/src/extensions/__tests__/extension-discovery.test.ts index 347487e556..6db33acf19 100644 --- a/src/extensions/__tests__/extension-discovery.test.ts +++ b/src/extensions/__tests__/extension-discovery.test.ts @@ -39,6 +39,12 @@ jest.mock("../extension-installer", () => ({ installPackage: jest.fn() } })); +jest.mock("electron", () => ({ + app: { + getPath: () => "tmp", + setLoginItemSettings: jest.fn(), + }, +})); console = new Console(process.stdout, process.stderr); // fix mockFS const mockedWatch = watch as jest.MockedFunction; diff --git a/src/extensions/extension-compatibility.ts b/src/extensions/extension-compatibility.ts new file mode 100644 index 0000000000..9ca48a3f2d --- /dev/null +++ b/src/extensions/extension-compatibility.ts @@ -0,0 +1,33 @@ +/** + * 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 } 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; +} diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index ea34798271..70f32de110 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 { isCompatibleExtension } from "./extension-compatibility"; export interface InstalledExtension { id: LensExtensionId; @@ -357,21 +356,17 @@ export class ExtensionDiscovery extends Singleton { protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise { try { const manifest = await fse.readJson(manifestPath) as LensExtensionManifest; - const installedManifestPath = this.getInstalledManifestPath(manifest.name); - const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath); + const id = this.getInstalledManifestPath(manifest.name); + const isEnabled = ExtensionsStore.getInstance().isEnabled({ id, isBundled }); 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 || isCompatibleExtension(manifest); return { - id: installedManifestPath, + id, absolutePath, - manifestPath: installedManifestPath, + manifestPath: id, manifest, isBundled, isEnabled, diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 68e54828dc..af69a6bef8 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -48,6 +48,13 @@ export class ExtensionLoader extends Singleton { protected extensions = observable.map(); protected instances = observable.map(); + /** + * This is the set of extensions that don't come with either + * - Main.LensExtension when running in the main process + * - Renderer.LensExtension when running in the renderer process + */ + protected nonInstancesByName = observable.set(); + /** * This is updated by the `observe` in the constructor. DO NOT write directly to it */ @@ -77,7 +84,7 @@ export class ExtensionLoader extends Singleton { if (this.instancesByName.has(change.newValue.name)) { throw new TypeError("Extension names must be unique"); } - + this.instancesByName.set(change.newValue.name, change.newValue); break; case "delete": @@ -101,7 +108,20 @@ export class ExtensionLoader extends Singleton { return extensions; } - getInstanceByName(name: string): LensExtension | undefined { + /** + * Get the extension instance by its manifest name + * @param name The name of the extension + * @returns one of the following: + * - the instance of `Main.LensExtension` on the main process if created + * - the instance of `Renderer.LensExtension` on the renderer process if created + * - `null` if no class definition is provided for the current process + * - `undefined` if the name is not known about + */ + getInstanceByName(name: string): LensExtension | null | undefined { + if (this.nonInstancesByName.has(name)) { + return null; + } + return this.instancesByName.get(name); } @@ -124,7 +144,7 @@ export class ExtensionLoader extends Singleton { await this.initMain(); } - await Promise.all([this.whenLoaded, ExtensionsStore.getInstance().whenLoaded]); + await Promise.all([this.whenLoaded]); // broadcasting extensions between main/renderer processes reaction(() => this.toJSON(), () => this.broadcastExtensions(), { @@ -145,6 +165,7 @@ export class ExtensionLoader extends Singleton { this.extensions.set(extension.id, extension); } + @action removeInstance(lensExtensionId: LensExtensionId) { logger.info(`${logModule} deleting extension instance ${lensExtensionId}`); const instance = this.instances.get(lensExtensionId); @@ -157,6 +178,7 @@ export class ExtensionLoader extends Singleton { instance.disable(); this.events.emit("remove", instance); this.instances.delete(lensExtensionId); + this.nonInstancesByName.delete(instance.name); } catch (error) { logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); } @@ -170,6 +192,10 @@ export class ExtensionLoader extends Singleton { } } + setIsEnabled(lensExtensionId: LensExtensionId, isEnabled: boolean) { + this.extensions.get(lensExtensionId).isEnabled = isEnabled; + } + protected async initMain() { this.isLoaded = true; this.loadOnMain(); @@ -302,13 +328,14 @@ export class ExtensionLoader extends Singleton { protected autoInitExtensions(register: (ext: LensExtension) => Promise) { return reaction(() => this.toJSON(), installedExtensions => { for (const [extId, extension] of installedExtensions) { - const alreadyInit = this.instances.has(extId); + const alreadyInit = this.instances.has(extId) || this.nonInstancesByName.has(extension.manifest.name); if (extension.isCompatible && extension.isEnabled && !alreadyInit) { try { const LensExtensionClass = this.requireExtension(extension); if (!LensExtensionClass) { + this.nonInstancesByName.add(extension.manifest.name); continue; } diff --git a/src/extensions/extension-store.ts b/src/extensions/extension-store.ts index 2c5eb44262..6b4e9c5489 100644 --- a/src/extensions/extension-store.ts +++ b/src/extensions/extension-store.ts @@ -26,13 +26,13 @@ import type { LensExtension } from "./lens-extension"; export abstract class ExtensionStore extends BaseStore { protected extension: LensExtension; - async loadExtension(extension: LensExtension) { + loadExtension(extension: LensExtension) { this.extension = extension; return super.load(); } - async load() { + load() { if (!this.extension) { return; } return super.load(); diff --git a/src/extensions/extensions-store.ts b/src/extensions/extensions-store.ts index 86a90e0238..be40181242 100644 --- a/src/extensions/extensions-store.ts +++ b/src/extensions/extensions-store.ts @@ -39,6 +39,7 @@ export class ExtensionsStore extends BaseStore { configName: "lens-extensions", }); makeObservable(this); + this.load(); } @computed @@ -50,12 +51,10 @@ export class ExtensionsStore extends BaseStore { protected state = observable.map(); - isEnabled(extId: LensExtensionId): boolean { - const state = this.state.get(extId); - + isEnabled({ id, isBundled }: { id: string, isBundled: boolean }): boolean { // By default false, so that copied extensions are disabled by default. // If user installs the extension from the UI, the Extensions component will specifically enable it. - return Boolean(state?.enabled); + return isBundled || Boolean(this.state.get(id)?.enabled); } @action 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/npm/extensions/package.json b/src/extensions/npm/extensions/package.json index 6857b12dda..594f26f56e 100644 --- a/src/extensions/npm/extensions/package.json +++ b/src/extensions/npm/extensions/package.json @@ -19,6 +19,7 @@ "@types/node": "*", "@types/react-select": "*", "@material-ui/core": "*", - "conf": "^7.0.1" + "conf": "^7.0.1", + "typed-emitter": "^1.3.1" } } 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/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 49b3ef933c..cb4cfd0377 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -20,6 +20,8 @@ */ // layouts +export * from "../../renderer/components/layout/main-layout"; +export * from "../../renderer/components/layout/setting-layout"; export * from "../../renderer/components/layout/page-layout"; export * from "../../renderer/components/layout/wizard-layout"; export * from "../../renderer/components/layout/tab-layout"; @@ -65,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 ad3b42f5d2..0ba3a2d028 100644 --- a/src/main/__test__/context-handler.test.ts +++ b/src/main/__test__/context-handler.test.ts @@ -22,6 +22,14 @@ import { UserStore } from "../../common/user-store"; import { ContextHandler } from "../context-handler"; import { PrometheusProvider, PrometheusProviderRegistry, PrometheusService } from "../prometheus"; +import mockFs from "mock-fs"; + +jest.mock("electron", () => ({ + app: { + getPath: () => "tmp", + setLoginItemSettings: jest.fn(), + }, +})); enum ServiceResult { Success, @@ -70,6 +78,10 @@ function getHandler() { describe("ContextHandler", () => { beforeEach(() => { + mockFs({ + "tmp": {} + }); + PrometheusProviderRegistry.createInstance(); UserStore.createInstance(); }); @@ -77,6 +89,7 @@ describe("ContextHandler", () => { afterEach(() => { PrometheusProviderRegistry.resetInstance(); UserStore.resetInstance(); + mockFs.restore(); }); describe("getPrometheusService", () => { diff --git a/src/main/__test__/kube-auth-proxy.test.ts b/src/main/__test__/kube-auth-proxy.test.ts index 5006859637..e0dd7f73c6 100644 --- a/src/main/__test__/kube-auth-proxy.test.ts +++ b/src/main/__test__/kube-auth-proxy.test.ts @@ -46,7 +46,8 @@ jest.mock("winston", () => ({ jest.mock("electron", () => ({ app: { - getPath: () => "/foo", + getPath: () => "tmp", + setLoginItemSettings: jest.fn(), }, })); @@ -77,8 +78,6 @@ const mockWaitUntilUsed = waitUntilUsed as jest.MockedFunction { beforeEach(() => { jest.clearAllMocks(); - UserStore.resetInstance(); - UserStore.createInstance(); const mockMinikubeConfig = { "minikube-config.yml": JSON.stringify({ @@ -102,13 +101,16 @@ describe("kube auth proxy tests", () => { }], kind: "Config", preferences: {}, - }) + }), + "tmp": {}, }; mockFs(mockMinikubeConfig); + UserStore.createInstance(); }); afterEach(() => { + UserStore.resetInstance(); mockFs.restore(); }); diff --git a/src/main/catalog-sources/general.ts b/src/main/catalog-sources/general.ts new file mode 100644 index 0000000000..6b16984d1d --- /dev/null +++ b/src/main/catalog-sources/general.ts @@ -0,0 +1,72 @@ +/** + * 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 { GeneralEntity } from "../../common/catalog-entities/general"; +import { catalogURL, preferencesURL } from "../../common/routes"; +import { catalogEntityRegistry } from "../catalog"; + +export const catalogEntity = new GeneralEntity({ + metadata: { + uid: "catalog-entity", + name: "Catalog", + source: "app", + labels: {} + }, + spec: { + path: catalogURL(), + icon: { + material: "view_list", + background: "#3d90ce" + } + }, + status: { + phase: "active", + } +}); + +const preferencesEntity = new GeneralEntity({ + metadata: { + uid: "preferences-entity", + name: "Preferences", + source: "app", + labels: {} + }, + spec: { + path: preferencesURL(), + icon: { + material: "settings", + background: "#3d90ce" + } + }, + status: { + phase: "active", + } +}); + +const generalEntities = observable([ + catalogEntity, + preferencesEntity +]); + +export function syncGeneralEntities() { + catalogEntityRegistry.addObservableSource("lens:general", generalEntities); +} diff --git a/src/main/catalog-sources/index.ts b/src/main/catalog-sources/index.ts index 81f0182ca7..70c2d073fb 100644 --- a/src/main/catalog-sources/index.ts +++ b/src/main/catalog-sources/index.ts @@ -19,5 +19,6 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export { initializeWeblinks } from "./weblinks"; +export { syncWeblinks } from "./weblinks"; export { KubeconfigSyncManager } from "./kubeconfig-sync"; +export { syncGeneralEntities } from "./general"; diff --git a/src/main/catalog-sources/weblinks.ts b/src/main/catalog-sources/weblinks.ts index b6b1ec5a39..2593fedc7c 100644 --- a/src/main/catalog-sources/weblinks.ts +++ b/src/main/catalog-sources/weblinks.ts @@ -69,7 +69,7 @@ async function validateLink(link: WebLink) { } -export function initializeWeblinks() { +export function syncWeblinks() { const weblinkStore = WeblinkStore.getInstance(); const weblinks = observable.array(defaultLinks); 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 5f2ef9899d..73d2c50f46 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -23,12 +23,12 @@ 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"; import { catalogEntityRegistry } from "./catalog"; -import { KubernetesCluster, KubernetesClusterPrometheusMetrics } from "../common/catalog-entities/kubernetes-cluster"; +import { KubernetesCluster, KubernetesClusterPrometheusMetrics, KubernetesClusterStatusPhase } from "../common/catalog-entities/kubernetes-cluster"; import { ipcMainOn } from "../common/ipc"; import { once } from "lodash"; @@ -102,13 +102,14 @@ export class ClusterManager extends Singleton { const entity = catalogEntityRegistry.items[index] as KubernetesCluster; this.updateEntityStatus(entity, cluster); - - entity.metadata.labels = Object.assign({}, cluster.labels, entity.metadata.labels); - - if (cluster.preferences?.clusterName) { - entity.metadata.name = cluster.preferences.clusterName; - } + entity.metadata.labels = { + ...entity.metadata.labels, + ...cluster.labels, + }; + entity.metadata.distro = cluster.distribution; + entity.metadata.kubeVersion = cluster.version; + entity.metadata.name = cluster.name; entity.spec.metrics ||= { source: "local" }; if (entity.spec.metrics.source === "local") { @@ -119,7 +120,12 @@ export class ClusterManager extends Singleton { entity.spec.metrics.prometheus = prometheus; } - entity.spec.iconData = cluster.preferences.icon; + if (cluster.preferences.icon) { + entity.spec.icon ??= {}; + entity.spec.icon.src = cluster.preferences.icon; + } else { + entity.spec.icon = null; + } catalogEntityRegistry.items.splice(index, 1, entity); } @@ -130,7 +136,22 @@ export class ClusterManager extends Singleton { entity.status.phase = "deleting"; entity.status.enabled = false; } else { - entity.status.phase = cluster?.accessible ? "connected" : "disconnected"; + entity.status.phase = ((): KubernetesClusterStatusPhase => { + if (!cluster) { + return "disconnected"; + } + + if (cluster.accessible) { + return "connected"; + } + + if (!cluster.disconnected) { + return "connecting"; + } + + return "disconnected"; + })(); + entity.status.enabled = true; } } @@ -215,12 +236,15 @@ 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, - kubeconfigContext: cluster.contextName + kubeconfigContext: cluster.contextName, + icon: {} }, status: { phase: cluster.disconnected ? "disconnected" : "connected", diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 6675bd0151..58dbbf944f 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, @@ -120,6 +121,10 @@ export class Cluster implements ClusterModel, ClusterState { * @deprecated */ @observable workspace: string; + /** + * @deprecated + */ + @observable workspaces: string[]; /** * Kubernetes API server URL * @@ -230,8 +235,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"; } /** @@ -246,13 +261,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; @@ -294,6 +302,10 @@ export class Cluster implements ClusterModel, ClusterState { this.workspace = model.workspace; } + if (model.workspaces) { + this.workspaces = model.workspaces; + } + if (model.contextName) { this.contextName = model.contextName; } @@ -589,6 +601,7 @@ export class Cluster implements ClusterModel, ClusterState { contextName: this.contextName, kubeConfigPath: this.kubeConfigPath, workspace: this.workspace, + workspaces: this.workspaces, preferences: this.preferences, metadata: this.metadata, accessibleNamespaces: this.accessibleNamespaces, @@ -736,4 +749,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/extension-filesystem.ts b/src/main/extension-filesystem.ts index 402619bdc9..46afe45d6e 100644 --- a/src/main/extension-filesystem.ts +++ b/src/main/extension-filesystem.ts @@ -42,6 +42,7 @@ export class FilesystemProvisionerStore extends BaseStore { accessPropertiesByDotNotation: false, // To make dots safe in cluster context names }); makeObservable(this); + this.load(); } /** 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-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 dc36de80dc..684a2e5fb2 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -48,11 +48,22 @@ import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import { pushCatalogToRenderer } from "./catalog-pusher"; import { catalogEntityRegistry } from "./catalog"; import { HelmRepoManager } from "./helm/helm-repo-manager"; -import { KubeconfigSyncManager } from "./catalog-sources"; +import { syncGeneralEntities, syncWeblinks, KubeconfigSyncManager } from "./catalog-sources"; import { handleWsUpgrade } from "./proxy/ws-upgrade"; import configurePackages from "../common/configure-packages"; import { PrometheusProviderRegistry } from "./prometheus"; import * as initializers from "./initializers"; +import { ClusterStore } from "../common/cluster-store"; +import { HotbarStore } from "../common/hotbar-store"; +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(); @@ -123,8 +134,28 @@ app.on("ready", async () => { PrometheusProviderRegistry.createInstance(); initializers.initPrometheusProviderRegistry(); - await initializers.initializeStores(); - initializers.initializeWeblinks(); + /** + * 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 + */ + 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(); + + syncWeblinks(); HelmRepoManager.createInstance(); // create the instance @@ -132,7 +163,7 @@ app.on("ready", async () => { handleWsUpgrade, req => ClusterManager.getInstance().getClusterForRequest(req), ); - + ClusterManager.createInstance().init(); KubeconfigSyncManager.createInstance(); @@ -186,10 +217,6 @@ app.on("ready", async () => { LensProtocolRouterMain.getInstance().rendererLoaded = true; }); - ExtensionLoader.getInstance().whenLoaded.then(() => { - LensProtocolRouterMain.getInstance().extensionsLoaded = true; - }); - logger.info("🧩 Initializing extensions"); // call after windowManager to see splash earlier diff --git a/src/main/initializers/index.ts b/src/main/initializers/index.ts index 37ff00bd77..526b61d4ed 100644 --- a/src/main/initializers/index.ts +++ b/src/main/initializers/index.ts @@ -22,5 +22,3 @@ export * from "./registries"; export * from "./metrics-providers"; export * from "./ipc"; -export * from "./weblinks"; -export * from "./stores"; 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/initializers/stores.ts b/src/main/initializers/stores.ts deleted file mode 100644 index 4ea6322457..0000000000 --- a/src/main/initializers/stores.ts +++ /dev/null @@ -1,48 +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 { HotbarStore } from "../../common/hotbar-store"; -import { ClusterStore } from "../../common/cluster-store"; -import { UserStore } from "../../common/user-store"; -import { ExtensionsStore } from "../../extensions/extensions-store"; -import { FilesystemProvisionerStore } from "../extension-filesystem"; -import { WeblinkStore } from "../../common/weblink-store"; -import logger from "../logger"; - -export async function initializeStores() { - const userStore = UserStore.createInstance(); - const clusterStore = ClusterStore.createInstance(); - const hotbarStore = HotbarStore.createInstance(); - const extensionsStore = ExtensionsStore.createInstance(); - const filesystemStore = FilesystemProvisionerStore.createInstance(); - const weblinkStore = WeblinkStore.createInstance(); - - logger.info("💾 Loading stores"); - // preload - await Promise.all([ - userStore.load(), - clusterStore.load(), - hotbarStore.load(), - extensionsStore.load(), - filesystemStore.load(), - weblinkStore.load() - ]); -} 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/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts index 6c1d9e777a..1216403838 100644 --- a/src/main/protocol-handler/__test__/router.test.ts +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -28,9 +28,17 @@ import { LensExtension } from "../../../extensions/main-api"; import { ExtensionLoader } from "../../../extensions/extension-loader"; import { ExtensionsStore } from "../../../extensions/extensions-store"; import { LensProtocolRouterMain } from "../router"; +import mockFs from "mock-fs"; jest.mock("../../../common/ipc"); +jest.mock("electron", () => ({ + app: { + getPath: () => "tmp", + setLoginItemSettings: jest.fn(), + }, +})); + function throwIfDefined(val: any): void { if (val != null) { throw val; @@ -39,12 +47,14 @@ function throwIfDefined(val: any): void { describe("protocol router tests", () => { beforeEach(() => { + mockFs({ + "tmp": {} + }); ExtensionsStore.createInstance(); ExtensionLoader.createInstance(); const lpr = LensProtocolRouterMain.createInstance(); - lpr.extensionsLoaded = true; lpr.rendererLoaded = true; }); @@ -54,23 +64,24 @@ describe("protocol router tests", () => { ExtensionsStore.resetInstance(); ExtensionLoader.resetInstance(); LensProtocolRouterMain.resetInstance(); + mockFs.restore(); }); - it("should throw on non-lens URLS", () => { + it("should throw on non-lens URLS", async () => { try { const lpr = LensProtocolRouterMain.getInstance(); - expect(lpr.route("https://google.ca")).toBeUndefined(); + expect(await lpr.route("https://google.ca")).toBeUndefined(); } catch (error) { expect(error).toBeInstanceOf(Error); } }); - it("should throw when host not internal or extension", () => { + it("should throw when host not internal or extension", async () => { try { const lpr = LensProtocolRouterMain.getInstance(); - expect(lpr.route("lens://foobar")).toBeUndefined(); + expect(await lpr.route("lens://foobar")).toBeUndefined(); } catch (error) { expect(error).toBeInstanceOf(Error); } @@ -103,13 +114,13 @@ describe("protocol router tests", () => { lpr.addInternalHandler("/", noop); try { - expect(lpr.route("lens://app")).toBeUndefined(); + expect(await lpr.route("lens://app")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } try { - expect(lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined(); + expect(await lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } @@ -119,14 +130,14 @@ describe("protocol router tests", () => { expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerExtension, "lens://extension/@mirantis/minikube", "matched"); }); - it("should call handler if matches", () => { + it("should call handler if matches", async () => { const lpr = LensProtocolRouterMain.getInstance(); let called = false; lpr.addInternalHandler("/page", () => { called = true; }); try { - expect(lpr.route("lens://app/page")).toBeUndefined(); + expect(await lpr.route("lens://app/page")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } @@ -135,7 +146,7 @@ describe("protocol router tests", () => { expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page", "matched"); }); - it("should call most exact handler", () => { + it("should call most exact handler", async () => { const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; @@ -143,7 +154,7 @@ describe("protocol router tests", () => { lpr.addInternalHandler("/page/:id", params => { called = params.pathname.id; }); try { - expect(lpr.route("lens://app/page/foo")).toBeUndefined(); + expect(await lpr.route("lens://app/page/foo")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } @@ -183,7 +194,7 @@ describe("protocol router tests", () => { (ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); try { - expect(lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); + expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } @@ -251,7 +262,7 @@ describe("protocol router tests", () => { (ExtensionsStore.getInstance() as any).state.set("icecream", { enabled: true, name: "icecream" }); try { - expect(lpr.route("lens://extension/icecream/page")).toBeUndefined(); + expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } @@ -268,7 +279,7 @@ describe("protocol router tests", () => { expect(() => lpr.addInternalHandler("/:@", noop)).toThrowError(); }); - it("should call most exact handler with 3 found handlers", () => { + it("should call most exact handler with 3 found handlers", async () => { const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; @@ -278,7 +289,7 @@ describe("protocol router tests", () => { lpr.addInternalHandler("/page/bar", () => { called = 4; }); try { - expect(lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); + expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } @@ -287,7 +298,7 @@ describe("protocol router tests", () => { expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat", "matched"); }); - it("should call most exact handler with 2 found handlers", () => { + it("should call most exact handler with 2 found handlers", async () => { const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; @@ -296,7 +307,7 @@ describe("protocol router tests", () => { lpr.addInternalHandler("/page/bar", () => { called = 4; }); try { - expect(lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); + expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } diff --git a/src/main/protocol-handler/router.ts b/src/main/protocol-handler/router.ts index 6a8090617b..fe178705e7 100644 --- a/src/main/protocol-handler/router.ts +++ b/src/main/protocol-handler/router.ts @@ -54,7 +54,6 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { private missingExtensionHandlers: FallbackHandler[] = []; @observable rendererLoaded = false; - @observable extensionsLoaded = false; protected disposers = disposer(); @@ -74,7 +73,7 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { * This will send an IPC message to the renderer router to do the same * in the renderer. */ - public route(rawUrl: string) { + public async route(rawUrl: string) { try { const url = new URLParse(rawUrl, true); @@ -82,15 +81,15 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url); } + WindowManager.getInstance(false)?.ensureMainWindow().catch(noop); const routeInternally = checkHost(url); logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: routing ${url.toString()}`); - WindowManager.getInstance(false)?.ensureMainWindow().catch(noop); if (routeInternally) { this._routeToInternal(url); } else { - this.disposers.push(when(() => this.extensionsLoaded, () => this._routeToExtension(url))); + await this._routeToExtension(url); } } catch (error) { broadcastMessage(ProtocolHandlerInvalid, error.toString(), rawUrl); diff --git a/src/main/proxy/lens-proxy.ts b/src/main/proxy/lens-proxy.ts index 00ef5fbb48..480a4b047a 100644 --- a/src/main/proxy/lens-proxy.ts +++ b/src/main/proxy/lens-proxy.ts @@ -168,7 +168,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 +180,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)}`); @@ -196,7 +201,7 @@ export class LensProxy extends Singleton { } 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/shell-session/local-shell-session.ts b/src/main/shell-session/local-shell-session.ts index 384e9317a8..35a1369339 100644 --- a/src/main/shell-session/local-shell-session.ts +++ b/src/main/shell-session/local-shell-session.ts @@ -51,7 +51,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..7542baecd6 100644 --- a/src/main/shell-session/node-shell-session.ts +++ b/src/main/shell-session/node-shell-session.ts @@ -77,13 +77,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/utils/get-port.ts b/src/main/utils/get-port.ts index cdce0ce740..c44e843712 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,6 +69,7 @@ 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); diff --git a/src/main/utils/shell-env.ts b/src/main/utils/shell-env.ts index b544603075..18a9545af9 100644 --- a/src/main/utils/shell-env.ts +++ b/src/main/utils/shell-env.ts @@ -29,7 +29,7 @@ export interface EnvironmentVariables { let shellSyncFailed = false; /** - * Attempts to get the shell environment per the user's existing startup scripts. + * Attempts to get the shell environment per the user's existing startup scripts. * If the environment can't be retrieved after 5 seconds an error message is logged. * Subsequent calls after such a timeout simply log an error message without trying * to get the environment, unless forceRetry is true. @@ -52,7 +52,7 @@ export async function shellEnv(shell?: string, forceRetry = false) : Promise setTimeout(() => { reject(new Error("Resolving shell environment is taking very long. Please review your shell configuration.")); - }, 5_000)) + }, 30_000)) ]); } catch (error) { logger.error(`shellEnv: ${error}`); @@ -61,6 +61,6 @@ export async function shellEnv(shell?: string, forceRetry = false) : Promise(); // mapping from WorkspaceId to name @@ -45,9 +45,7 @@ export default { workspaces.set(id, name); } - migrationLog("workspaces", JSON.stringify([...workspaces.entries()])); - - const clusters: ClusterModel[] = store.get("clusters"); + const clusters: ClusterModel[] = store.get("clusters") ?? []; for (const cluster of clusters) { if (cluster.workspace && workspaces.has(cluster.workspace)) { @@ -58,8 +56,6 @@ export default { store.set("clusters", clusters); } catch (error) { - migrationLog("error", error.path); - if (!(error.code === "ENOENT" && error.path.endsWith("lens-workspace-store.json"))) { // ignore lens-workspace-store.json being missing throw error; diff --git a/src/migrations/cluster-store/5.0.0-beta.13.ts b/src/migrations/cluster-store/5.0.0-beta.13.ts new file mode 100644 index 0000000000..25e0cd1543 --- /dev/null +++ b/src/migrations/cluster-store/5.0.0-beta.13.ts @@ -0,0 +1,133 @@ +/** + * 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 { ClusterModel, ClusterPreferences, ClusterPrometheusPreferences } from "../../common/cluster-store"; +import { MigrationDeclaration, migrationLog } from "../helpers"; +import { generateNewIdFor } from "../utils"; +import path from "path"; +import { app } from "electron"; +import { moveSync, removeSync } from "fs-extra"; + +function mergePrometheusPreferences(left: ClusterPrometheusPreferences, right: ClusterPrometheusPreferences): ClusterPrometheusPreferences { + if (left.prometheus && left.prometheusProvider) { + return { + prometheus: left.prometheus, + prometheusProvider: left.prometheusProvider, + }; + } + + if (right.prometheus && right.prometheusProvider) { + return { + prometheus: right.prometheus, + prometheusProvider: right.prometheusProvider, + }; + } + + return {}; +} + +function mergePreferences(left: ClusterPreferences, right: ClusterPreferences): ClusterPreferences { + return { + terminalCWD: left.terminalCWD || right.terminalCWD || undefined, + clusterName: left.clusterName || right.clusterName || undefined, + iconOrder: left.iconOrder || right.iconOrder || undefined, + icon: left.icon || right.icon || undefined, + httpsProxy: left.httpsProxy || right.httpsProxy || undefined, + hiddenMetrics: mergeSet(left.hiddenMetrics ?? [], right.hiddenMetrics ?? []), + ...mergePrometheusPreferences(left, right), + }; +} + +function mergeLabels(left: Record, right: Record): Record { + return { + ...right, + ...left + }; +} + +function mergeSet(...iterables: Iterable[]): string[] { + const res = new Set(); + + for (const iterable of iterables) { + for (const val of iterable) { + res.add(val); + } + } + + return [...res]; +} + +function mergeClusterModel(prev: ClusterModel, right: Omit): ClusterModel { + return { + id: prev.id, + kubeConfigPath: prev.kubeConfigPath, + contextName: prev.contextName, + preferences: mergePreferences(prev.preferences ?? {}, right.preferences ?? {}), + metadata: prev.metadata, + labels: mergeLabels(prev.labels ?? {}, right.labels ?? {}), + accessibleNamespaces: mergeSet(prev.accessibleNamespaces ?? [], right.accessibleNamespaces ?? []), + workspace: prev.workspace || right.workspace, + workspaces: mergeSet([prev.workspace, right.workspace], prev.workspaces ?? [], right.workspaces ?? []), + }; +} + +function moveStorageFolder({ folder, newId, oldId }: { folder: string, newId: string, oldId: string }): void { + const oldPath = path.resolve(folder, `${oldId}.json`); + const newPath = path.resolve(folder, `${newId}.json`); + + try { + moveSync(oldPath, newPath); + } catch (error) { + if (String(error).includes("dest already exists")) { + migrationLog(`Multiple old lens-local-storage files for newId=${newId}. Removing ${oldId}.json`); + removeSync(oldPath); + } + } +} + +export default { + version: "5.0.0-beta.13", + run(store) { + const folder = path.resolve(app.getPath("userData"), "lens-local-storage"); + + const oldClusters: ClusterModel[] = store.get("clusters"); + const clusters = new Map(); + + for (const { id: oldId, ...cluster } of oldClusters) { + const newId = generateNewIdFor(cluster); + + if (clusters.has(newId)) { + migrationLog(`Duplicate entries for ${newId}`, { oldId }); + clusters.set(newId, mergeClusterModel(clusters.get(newId), cluster)); + } else { + migrationLog(`First entry for ${newId}`, { oldId }); + clusters.set(newId, { + ...cluster, + id: newId, + workspaces: [cluster.workspace].filter(Boolean), + }); + moveStorageFolder({ folder, newId, oldId }); + } + } + + store.set("clusters", [...clusters.values()]); + } +} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/index.ts b/src/migrations/cluster-store/index.ts index 9bf870825c..b68e098a16 100644 --- a/src/migrations/cluster-store/index.ts +++ b/src/migrations/cluster-store/index.ts @@ -31,6 +31,7 @@ import version270Beta0 from "./2.7.0-beta.0"; import version270Beta1 from "./2.7.0-beta.1"; import version360Beta1 from "./3.6.0-beta.1"; import version500Beta10 from "./5.0.0-beta.10"; +import version500Beta13 from "./5.0.0-beta.13"; import snap from "./snap"; export default joinMigrations( @@ -42,5 +43,6 @@ export default joinMigrations( version270Beta1, version360Beta1, version500Beta10, + version500Beta13, snap, ); 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-alpha.0.ts b/src/migrations/hotbar-store/5.0.0-alpha.0.ts index 7cd8011414..1a61ce8ef3 100644 --- a/src/migrations/hotbar-store/5.0.0-alpha.0.ts +++ b/src/migrations/hotbar-store/5.0.0-alpha.0.ts @@ -20,34 +20,24 @@ */ // Cleans up a store that had the state related data stored -import type { Hotbar } from "../../common/hotbar-store"; -import { ClusterStore } from "../../common/cluster-store"; +import { Hotbar, HotbarStore } from "../../common/hotbar-store"; import * as uuid from "uuid"; import type { MigrationDeclaration } from "../helpers"; +import { catalogEntity } from "../../main/catalog-sources/general"; export default { version: "5.0.0-alpha.0", run(store) { - const hotbars: Hotbar[] = []; + const hotbar: Hotbar = { + id: uuid.v4(), + name: "default", + items: HotbarStore.getInitialItems(), + }; - ClusterStore.getInstance().clustersList.forEach((cluster: any) => { - const name = cluster.workspace; + const { metadata: { uid, name, source } } = catalogEntity; - if (!name) return; + hotbar.items[0] = { entity: { uid, name, source } }; - let hotbar = hotbars.find((h) => h.name === name); - - if (!hotbar) { - hotbar = { id: uuid.v4(), name, items: [] }; - hotbars.push(hotbar); - } - - hotbar.items.push({ - entity: { uid: cluster.id }, - params: {} - }); - }); - - store.set("hotbars", hotbars); + store.set("hotbars", [hotbar]); } } as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/5.0.0-beta.10.ts b/src/migrations/hotbar-store/5.0.0-beta.10.ts new file mode 100644 index 0000000000..0bf780b201 --- /dev/null +++ b/src/migrations/hotbar-store/5.0.0-beta.10.ts @@ -0,0 +1,157 @@ +/** + * 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 fse from "fs-extra"; +import { isNull } from "lodash"; +import path from "path"; +import * as uuid from "uuid"; +import type { ClusterStoreModel } from "../../common/cluster-store"; +import { defaultHotbarCells, Hotbar, HotbarStore } from "../../common/hotbar-store"; +import { catalogEntity } from "../../main/catalog-sources/general"; +import { MigrationDeclaration, migrationLog } from "../helpers"; +import { generateNewIdFor } from "../utils"; + +interface Pre500WorkspaceStoreModel { + workspaces: { + id: string; + name: string; + }[]; +} + +export default { + version: "5.0.0-beta.10", + run(store) { + const hotbars: Hotbar[] = store.get("hotbars"); + const userDataPath = app.getPath("userData"); + + try { + const workspaceStoreData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json")); + const { clusters }: ClusterStoreModel = fse.readJSONSync(path.join(userDataPath, "lens-cluster-store.json")); + const workspaceHotbars = new Map(); // mapping from WorkspaceId to HotBar + + for (const { id, name } of workspaceStoreData.workspaces) { + migrationLog(`Creating new hotbar for ${name}`); + workspaceHotbars.set(id, { + id: uuid.v4(), // don't use the old IDs as they aren't necessarily UUIDs + items: [], + name: `Workspace: ${name}`, + }); + } + + { + // grab the default named hotbar or the first. + const defaultHotbarIndex = Math.max(0, hotbars.findIndex(hotbar => hotbar.name === "default")); + const [{ name, id, items }] = hotbars.splice(defaultHotbarIndex, 1); + + workspaceHotbars.set("default", { + name, + id, + items: items.filter(Boolean), + }); + } + + for (const cluster of clusters) { + const uid = generateNewIdFor(cluster); + + 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) { + workspaceHotbar.items.push({ + entity: { + uid: generateNewIdFor(cluster), + name: cluster.preferences.clusterName || cluster.contextName, + } + }); + } + } + } + + for (const hotbar of workspaceHotbars.values()) { + if (hotbar.items.length === 0) { + migrationLog(`Skipping ${hotbar.name} due to it being empty`); + continue; + } + + while (hotbar.items.length < defaultHotbarCells) { + hotbar.items.push(null); + } + + hotbars.push(hotbar); + } + + /** + * Finally, make sure that the catalog entity hotbar item is in place. + * Just in case something else removed it. + * + * if every hotbar has elements that all not the `catalog-entity` item + */ + if (hotbars.every(hotbar => hotbar.items.every(item => item?.entity?.uid !== "catalog-entity"))) { + // note, we will add a new whole hotbar here called "default" if that was previously removed + const defaultHotbar = hotbars.find(hotbar => hotbar.name === "default"); + const { metadata: { uid, name, source } } = catalogEntity; + + if (defaultHotbar) { + const freeIndex = defaultHotbar.items.findIndex(isNull); + + if (freeIndex === -1) { + // making a new hotbar is less destructive if the first hotbar + // called "default" is full than overriding a hotbar item + const hotbar = { + id: uuid.v4(), + name: "initial", + items: HotbarStore.getInitialItems(), + }; + + hotbar.items[0] = { entity: { uid, name, source } }; + hotbars.unshift(hotbar); + } else { + defaultHotbar.items[freeIndex] = { entity: { uid, name, source } }; + } + } else { + const hotbar = { + id: uuid.v4(), + name: "default", + items: HotbarStore.getInitialItems(), + }; + + hotbar.items[0] = { entity: { uid, name, source } }; + hotbars.unshift(hotbar); + } + } + + store.set("hotbars", hotbars); + } catch (error) { + // ignore files being missing + if (error.code !== "ENOENT") { + throw error; + } + } + } +} as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/5.0.0-beta.11.ts b/src/migrations/hotbar-store/5.0.0-beta.11.ts deleted file mode 100644 index 9ad4feb0bf..0000000000 --- a/src/migrations/hotbar-store/5.0.0-beta.11.ts +++ /dev/null @@ -1,85 +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 } from "electron"; -import fse from "fs-extra"; -import path from "path"; -import * as uuid from "uuid"; -import type { ClusterStoreModel } from "../../common/cluster-store"; -import { defaultHotbarCells, Hotbar } from "../../common/hotbar-store"; -import type { MigrationDeclaration } from "../helpers"; - -interface Pre500WorkspaceStoreModel { - workspaces: { - id: string; - name: string; - }[]; -} - -export default { - version: "5.0.0-beta.11", - run(store) { - const hotbars: Hotbar[] = store.get("hotbars"); - const userDataPath = app.getPath("userData"); - - try { - const workspaceStoreData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json")); - const { clusters }: ClusterStoreModel = fse.readJSONSync(path.join(userDataPath, "lens-cluster-store.json")); - const workspaceHotbars = new Map(); // mapping from WorkspaceId to HotBar - - for (const { id, name } of workspaceStoreData.workspaces) { - workspaceHotbars.set(id, { - id: uuid.v4(), // don't use the old IDs as they aren't necessarily UUIDs - items: [], - name, - }); - } - - for (const cluster of clusters) { - const workspaceHotbar = workspaceHotbars.get(cluster.workspace); - - if (workspaceHotbar?.items.length < defaultHotbarCells) { - workspaceHotbar.items.push({ - entity: { - uid: cluster.id, - name: cluster.preferences.clusterName || cluster.contextName, - } - }); - } - } - - for (const hotbar of workspaceHotbars.values()) { - while (hotbar.items.length < defaultHotbarCells) { - hotbar.items.push(null); - } - - hotbars.push(hotbar); - } - - store.set("hotbars", hotbars); - } catch (error) { - if (!(error.code === "ENOENT" && error.path.endsWith("lens-workspace-store.json"))) { - // ignore lens-workspace-store.json being missing - throw error; - } - } - } -} as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/5.0.0-beta.5.ts b/src/migrations/hotbar-store/5.0.0-beta.5.ts index 1cc8bc2840..8d589e9543 100644 --- a/src/migrations/hotbar-store/5.0.0-beta.5.ts +++ b/src/migrations/hotbar-store/5.0.0-beta.5.ts @@ -20,7 +20,7 @@ */ import type { Hotbar } from "../../common/hotbar-store"; -import { catalogEntityRegistry } from "../../renderer/api/catalog-entity-registry"; +import { catalogEntityRegistry } from "../../main/catalog"; import type { MigrationDeclaration } from "../helpers"; export default { diff --git a/src/migrations/hotbar-store/index.ts b/src/migrations/hotbar-store/index.ts index a18a27bcad..44c5ebcdf4 100644 --- a/src/migrations/hotbar-store/index.ts +++ b/src/migrations/hotbar-store/index.ts @@ -26,11 +26,11 @@ import { joinMigrations } from "../helpers"; import version500alpha0 from "./5.0.0-alpha.0"; import version500alpha2 from "./5.0.0-alpha.2"; import version500beta5 from "./5.0.0-beta.5"; -import version500beta11 from "./5.0.0-beta.11"; +import version500beta10 from "./5.0.0-beta.10"; export default joinMigrations( version500alpha0, version500alpha2, version500beta5, - version500beta11, + version500beta10, ); 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/file-name-migration.ts b/src/migrations/user-store/file-name-migration.ts index e0c848d3a8..122cd78102 100644 --- a/src/migrations/user-store/file-name-migration.ts +++ b/src/migrations/user-store/file-name-migration.ts @@ -23,18 +23,18 @@ import fse from "fs-extra"; import { app, remote } from "electron"; import path from "path"; -export async function fileNameMigration() { +export function fileNameMigration() { const userDataPath = (app || remote.app).getPath("userData"); const configJsonPath = path.join(userDataPath, "config.json"); const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json"); try { - await fse.move(configJsonPath, lensUserStoreJsonPath); + fse.moveSync(configJsonPath, lensUserStoreJsonPath); } catch (error) { if (error.code === "ENOENT" && error.path === configJsonPath) { // (No such file or directory) return; // file already moved } else if (error.message === "dest already exists.") { - await fse.remove(configJsonPath); + fse.removeSync(configJsonPath); } else { // pass other errors along throw error; 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/main/initializers/weblinks.ts b/src/migrations/utils.ts similarity index 82% rename from src/main/initializers/weblinks.ts rename to src/migrations/utils.ts index 2bd65d0894..f9f9985e38 100644 --- a/src/main/initializers/weblinks.ts +++ b/src/migrations/utils.ts @@ -19,4 +19,8 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -export { initializeWeblinks } from "../catalog-sources"; +import { createHash } from "crypto"; + +export function generateNewIdFor(cluster: { kubeConfigPath: string, contextName: string }): string { + return createHash("md5").update(`${cluster.kubeConfigPath}:${cluster.contextName}`).digest("hex"); +} 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__/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..9477446386 100644 --- a/src/renderer/api/api-manager.ts +++ b/src/renderer/api/api-manager.ts @@ -58,8 +58,14 @@ 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); + } return api; } diff --git a/src/renderer/api/endpoints/pods.api.ts b/src/renderer/api/endpoints/pods.api.ts index 8ac8024227..5b85a3d42b 100644 --- a/src/renderer/api/endpoints/pods.api.ts +++ b/src/renderer/api/endpoints/pods.api.ts @@ -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/kube-api-parse.ts b/src/renderer/api/kube-api-parse.ts index b02067fb63..10e280d6c8 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 = ""; @@ -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/bootstrap.tsx b/src/renderer/bootstrap.tsx index df0bd00feb..2a36c15089 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -42,6 +42,13 @@ import { ExtensionInstallationStateStore } from "./components/+extensions/extens import { DefaultProps } from "./mui-base-theme"; import configurePackages from "../common/configure-packages"; import * as initializers from "./initializers"; +import { HotbarStore } from "../common/hotbar-store"; +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(); @@ -73,19 +80,39 @@ export async function bootstrap(App: AppComponent) { initializers.intiKubeObjectDetailRegistry(); initializers.initWelcomeMenuRegistry(); initializers.initWorkloadsOverviewDetailRegistry(); + initializers.initCatalogEntityDetailRegistry(); initializers.initCatalog(); initializers.initIpcRendererListeners(); ExtensionLoader.createInstance().init(); ExtensionDiscovery.createInstance().init(); - await initializers.initStores(); + UserStore.createInstance(); + + 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/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index b04c9a2989..7ee8529492 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -38,8 +38,8 @@ import { navigate } from "../../navigation"; import { iter } from "../../utils"; import { AceEditor } from "../ace-editor"; import { Button } from "../button"; -import { PageLayout } from "../layout/page-layout"; import { Notifications } from "../notifications"; +import { SettingLayout } from "../layout/setting-layout"; interface Option { config: KubeConfig; @@ -108,11 +108,11 @@ export class AddCluster extends React.Component { render() { return ( - +

Add Clusters from Kubeconfig

Clusters added here are not merged into the ~/.kube/config file. - Read more about adding clusters here. + Read more about adding clusters here.

- + ); } } 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-releases/release-details.scss b/src/renderer/components/+apps-releases/release-details.scss index 3afe13961c..a4bd53610c 100644 --- a/src/renderer/components/+apps-releases/release-details.scss +++ b/src/renderer/components/+apps-releases/release-details.scss @@ -27,13 +27,7 @@ } .status { - .Badge { - @include release-status-bgs; - - &:first-child { - margin-left: 0; - } - } + @include release-status-bgs; } .chart { diff --git a/src/renderer/components/+apps-releases/release-details.tsx b/src/renderer/components/+apps-releases/release-details.tsx index d7b6b76279..fae2e3ba71 100644 --- a/src/renderer/components/+apps-releases/release-details.tsx +++ b/src/renderer/components/+apps-releases/release-details.tsx @@ -208,7 +208,7 @@ export class ReleaseDetails extends Component { {items.map(item => { const name = item.getName(); const namespace = item.getNs(); - const api = apiManager.getApi(item.metadata.selfLink); + const api = apiManager.getApi(api => api.kind === kind && api.apiVersionWithGroup == item.apiVersion); const detailsUrl = api ? getDetailsUrl(api.getUrl({ name, namespace })) : ""; return ( @@ -272,7 +272,7 @@ export class ReleaseDetails extends Component { {this.renderValues()} diff --git a/src/renderer/components/+apps-releases/release-menu.tsx b/src/renderer/components/+apps-releases/release-menu.tsx index 2e85c40c05..30f048c8cb 100644 --- a/src/renderer/components/+apps-releases/release-menu.tsx +++ b/src/renderer/components/+apps-releases/release-menu.tsx @@ -63,7 +63,7 @@ export class HelmReleaseMenu extends React.Component { <> {hasRollback && ( - + Rollback )} diff --git a/src/renderer/components/+apps-releases/release.store.ts b/src/renderer/components/+apps-releases/release.store.ts index ae69d38bc1..d3ffdadb43 100644 --- a/src/renderer/components/+apps-releases/release.store.ts +++ b/src/renderer/components/+apps-releases/release.store.ts @@ -91,7 +91,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/+catalog/catalog-entity-details.tsx b/src/renderer/components/+catalog/catalog-entity-details.tsx index b0df7371a6..fd1b874c82 100644 --- a/src/renderer/components/+catalog/catalog-entity-details.tsx +++ b/src/renderer/components/+catalog/catalog-entity-details.tsx @@ -30,6 +30,7 @@ import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu"; import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; import { HotbarIcon } from "../hotbar/hotbar-icon"; import type { CatalogEntityItem } from "./catalog-entity.store"; +import { isDevelopment } from "../../../common/vars"; interface Props { item: CatalogEntityItem | null | undefined; @@ -63,7 +64,9 @@ export class CatalogEntityDetails extends Component item.onRun(catalogEntityRunContext)} size={128} /> @@ -89,6 +92,11 @@ export class CatalogEntityDetails extends Component {...item.getLabelBadges(this.props.hideDetails)} + {isDevelopment && ( + + {item.getId()} + + )} )} diff --git a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx index 545eebb933..af3104afe2 100644 --- a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx +++ b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx @@ -91,7 +91,7 @@ export class CatalogEntityDrawerMenu extends React.Comp items.push( this.onMenuItemClick(menuItem)}> @@ -100,7 +100,7 @@ export class CatalogEntityDrawerMenu extends React.Comp items.push( this.addToHotbar(entity) }> - + ); 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.tsx b/src/renderer/components/+catalog/catalog.tsx index a55b2ae0af..76339e79a3 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -24,7 +24,7 @@ import styles from "./catalog.module.css"; import React from "react"; import { disposeOnUnmount, observer } from "mobx-react"; import { ItemListLayout } from "../item-object-list"; -import { action, makeObservable, observable, reaction, when } from "mobx"; +import { action, makeObservable, observable, reaction, runInAction, when } from "mobx"; import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store"; import { navigate } from "../../navigation"; import { MenuItem, MenuActions } from "../menu"; @@ -36,13 +36,15 @@ import { CatalogAddButton } from "./catalog-add-button"; import type { RouteComponentProps } from "react-router"; import { Notifications } from "../notifications"; import { MainLayout } from "../layout/main-layout"; -import { cssNames } from "../../utils"; +import { createAppStorage, cssNames } from "../../utils"; import { makeCss } from "../../../common/utils/makeCss"; import { CatalogEntityDetails } from "./catalog-entity-details"; import { catalogURL, CatalogViewRouteParam } from "../../../common/routes"; import { CatalogMenu } from "./catalog-menu"; import { HotbarIcon } from "../hotbar/hotbar-icon"; +export const previousActiveTab = createAppStorage("catalog-previous-active-tab", ""); + enum sortBy { name = "name", kind = "kind", @@ -62,16 +64,17 @@ export class Catalog extends React.Component { constructor(props: Props) { super(props); makeObservable(this); + this.catalogEntityStore = new CatalogEntityStore(); } - get routeActiveTab(): string | undefined { + get routeActiveTab(): string { const { group, kind } = this.props.match.params ?? {}; if (group && kind) { return `${group}/${kind}`; } - return undefined; + return ""; } async componentDidMount() { @@ -79,17 +82,21 @@ export class Catalog extends React.Component { menuItems: observable.array([]), navigate: (url: string) => navigate(url), }; - this.catalogEntityStore = new CatalogEntityStore(); disposeOnUnmount(this, [ this.catalogEntityStore.watch(), reaction(() => this.routeActiveTab, async (routeTab) => { - await when(() => catalogCategoryRegistry.items.length > 0); - const item = catalogCategoryRegistry.items.find(i => i.getId() === routeTab); + previousActiveTab.set(this.routeActiveTab); - if (item || routeTab === undefined) { - this.activeTab = routeTab; - this.catalogEntityStore.activeCategory = item; - } else { + try { + await when(() => (routeTab === "" || !!catalogCategoryRegistry.items.find(i => i.getId() === routeTab)), { timeout: 5_000 }); // we need to wait because extensions might take a while to load + const item = catalogCategoryRegistry.items.find(i => i.getId() === routeTab); + + runInAction(() => { + this.activeTab = routeTab; + this.catalogEntityStore.activeCategory = item; + }); + } catch(error) { + console.error(error); Notifications.error(

Unknown category: {routeTab}

); } }, {fireImmediately: true}), @@ -166,12 +173,14 @@ export class Catalog extends React.Component { renderIcon(item: CatalogEntityItem) { return ( this.onDetails(item)} - size={24} /> + src={item.entity.spec.icon?.src} + material={item.entity.spec.icon?.material} + background={item.entity.spec.icon?.background} + size={24} + /> ); } @@ -261,6 +270,14 @@ export class Catalog extends React.Component { ); } + renderCategoryList() { + if (this.activeTab === undefined) { + return null; + } + + return this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList(); + } + render() { if (!this.catalogEntityStore) { return null; @@ -269,7 +286,7 @@ export class Catalog extends React.Component { return (
- { this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList() } + { this.renderCategoryList() }
{ this.catalogEntityStore.selectedItem @@ -277,8 +294,8 @@ export class Catalog extends React.Component { item={this.catalogEntityStore.selectedItem} hideDetails={() => this.catalogEntityStore.selectedItemId = null} /> - : }
diff --git a/src/renderer/components/+cluster/cluster-pie-charts.tsx b/src/renderer/components/+cluster/cluster-pie-charts.tsx index 609f41d1ad..979a91f0bb 100644 --- a/src/renderer/components/+cluster/cluster-pie-charts.tsx +++ b/src/renderer/components/+cluster/cluster-pie-charts.tsx @@ -33,6 +33,10 @@ import { bytesToUnits } from "../../utils"; import { ThemeStore } from "../../theme.store"; import { getMetricLastPoints } from "../../api/endpoints/metrics.api"; +function createLabels(rawLabelData: [string, number | undefined][]): string[] { + return rawLabelData.map(([key, value]) => `${key}: ${value?.toFixed(2) || "N/A"}`); +} + export const ClusterPieCharts = observer(() => { const renderLimitWarning = () => { return ( @@ -51,7 +55,7 @@ export const ClusterPieCharts = observer(() => { const cpuLimitsOverload = cpuLimits > cpuAllocatableCapacity; const memoryLimitsOverload = memoryLimits > memoryAllocatableCapacity; const defaultColor = ThemeStore.getInstance().activeTheme.colors.pieChartDefaultColor; - + if (!memoryCapacity || !cpuCapacity || !podCapacity || !memoryAllocatableCapacity || !cpuAllocatableCapacity || !podAllocatableCapacity) return null; const cpuData: ChartData = { datasets: [ @@ -92,13 +96,13 @@ export const ClusterPieCharts = observer(() => { label: "Limits" }, ], - labels: [ - `Usage: ${cpuUsage ? cpuUsage.toFixed(2) : "N/A"}`, - `Requests: ${cpuRequests ? cpuRequests.toFixed(2) : "N/A"}`, - `Limits: ${cpuLimits ? cpuLimits.toFixed(2) : "N/A"}`, - `Allocatable Capacity: ${cpuAllocatableCapacity || "N/A"}`, - `Capacity: ${cpuCapacity || "N/A"}` - ] + labels: createLabels([ + ["Usage", cpuUsage], + ["Requests", cpuRequests], + ["Limits", cpuLimits], + ["Allocatable Capacity", cpuAllocatableCapacity], + ["Capacity", cpuCapacity], + ]), }; const memoryData: ChartData = { datasets: [ 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..539bbb18d7 100644 --- a/src/renderer/components/+config-autoscalers/hpa.tsx +++ b/src/renderer/components/+config-autoscalers/hpa.tsx @@ -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-resource-quotas/add-quota-dialog.tsx b/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx index 1227e97049..17dc3e5eae 100644 --- a/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx +++ b/src/renderer/components/+config-resource-quotas/add-quota-dialog.tsx @@ -218,7 +218,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-secrets/secrets.tsx b/src/renderer/components/+config-secrets/secrets.tsx index d74305a499..3bfeada163 100644 --- a/src/renderer/components/+config-secrets/secrets.tsx +++ b/src/renderer/components/+config-secrets/secrets.tsx @@ -79,7 +79,7 @@ export class Secrets extends React.Component { 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..e8bfee160d 100644 --- a/src/renderer/components/+custom-resources/crd-list.tsx +++ b/src/renderer/components/+custom-resources/crd-list.tsx @@ -139,7 +139,7 @@ export class CrdList extends React.Component { ]} renderTableContents={(crd: CustomResourceDefinition) => [ - {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.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/+entity-settings/entity-settings.tsx b/src/renderer/components/+entity-settings/entity-settings.tsx index 16c79412cf..ad0713e342 100644 --- a/src/renderer/components/+entity-settings/entity-settings.tsx +++ b/src/renderer/components/+entity-settings/entity-settings.tsx @@ -25,7 +25,6 @@ import React from "react"; import { observable, makeObservable } from "mobx"; import type { RouteComponentProps } from "react-router"; import { observer } from "mobx-react"; -import { PageLayout } from "../layout/page-layout"; import { navigation } from "../../navigation"; import { Tabs, Tab } from "../tabs"; import type { CatalogEntity } from "../../api/catalog-entity"; @@ -33,6 +32,8 @@ import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { EntitySettingRegistry } from "../../../extensions/registries"; import type { EntitySettingsRouteParams } from "../../../common/routes"; import { groupBy } from "lodash"; +import { SettingLayout } from "../layout/setting-layout"; +import { HotbarIcon } from "../hotbar/hotbar-icon"; interface Props extends RouteComponentProps { } @@ -83,10 +84,19 @@ export class EntitySettings extends React.Component { return ( <> -

{this.entity.metadata.name}

+
+ +

{this.entity.metadata.name}

+
{ groups.map((group, groupIndex) => ( +
{group[0]}
{ group[1].map((setting, index) => ( { const activeSetting = this.menuItems.find((setting) => setting.id === this.activeTab); return ( -
@@ -132,7 +141,7 @@ export class EntitySettings extends React.Component {
-
+ ); } } diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 3361362b4b..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(); - await UserStore.createInstance().load(); - - 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/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 831ec6c1cd..b135487343 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -39,12 +39,13 @@ import logger from "../../../main/logger"; import { Button } from "../button"; import { ConfirmDialog } from "../confirm-dialog"; import { DropFileInput, InputValidators } from "../input"; -import { PageLayout } from "../layout/page-layout"; import { Notifications } from "../notifications"; import { ExtensionInstallationState, ExtensionInstallationStateStore } from "./extension-install.store"; import { Install } from "./install"; import { InstalledExtensions } from "./installed-extensions"; import { Notice } from "./notice"; +import { SettingLayout } from "../layout/setting-layout"; +import { docsUrl } from "../../../common/vars"; function getMessageFromError(error: any): string { if (!error || typeof error !== "object") { @@ -277,7 +278,7 @@ async function unpackExtension(request: InstallRequestValidated, disposeDownload await when(() => ExtensionLoader.getInstance().userExtensions.has(id)); // Enable installed extensions by default. - ExtensionLoader.getInstance().userExtensions.get(id).isEnabled = true; + ExtensionLoader.getInstance().setIsEnabled(id, true); Notifications.ok(

Extension {displayName} successfully installed!

@@ -317,7 +318,7 @@ export async function attemptInstallByInfo({ name, version, requireConfirmation version = json["dist-tags"][version]; } else { Notifications.error(

The {name} extension does not have a version or tag {version}.

); - + return disposer(); } } @@ -510,11 +511,17 @@ export class Extensions extends React.Component { return ( - +

Extensions

- + +

+ Add new features via Lens Extensions.{" "} + Check out docs{" "} + and list of available extensions. +

+
{ uninstall={confirmUninstallExtension} />
-
+
); } diff --git a/src/renderer/components/+extensions/install.tsx b/src/renderer/components/+extensions/install.tsx index aab8477deb..36ec5be2f9 100644 --- a/src/renderer/components/+extensions/install.tsx +++ b/src/renderer/components/+extensions/install.tsx @@ -29,7 +29,6 @@ import { SubTitle } from "../layout/sub-title"; import { TooltipPosition } from "../tooltip"; import { ExtensionInstallationStateStore } from "./extension-install.store"; import { observer } from "mobx-react"; -import { MaterialTooltip } from "../material-tooltip/material-tooltip"; interface Props { installPath: string; @@ -71,13 +70,12 @@ export const Install = observer((props: Props) => { onChange={onChange} onSubmit={installFromInput} iconRight={ - - - + } />
diff --git a/src/renderer/components/+extensions/notice.tsx b/src/renderer/components/+extensions/notice.tsx index d224b65a7d..e4b844a0ae 100644 --- a/src/renderer/components/+extensions/notice.tsx +++ b/src/renderer/components/+extensions/notice.tsx @@ -20,17 +20,14 @@ */ import styles from "./notice.module.css"; -import React from "react"; -import { docsUrl } from "../../../common/vars"; +import React, { DOMAttributes } from "react"; -export function Notice() { +interface Props extends DOMAttributes {} + +export function Notice(props: Props) { return (
-

- Add new features via Lens Extensions.{" "} - Check out docs{" "} - and list of available extensions. -

+ {props.children}
); } diff --git a/src/renderer/components/+namespaces/namespace-select-filter.tsx b/src/renderer/components/+namespaces/namespace-select-filter.tsx index 8ae2e97958..514749ed44 100644 --- a/src/renderer/components/+namespaces/namespace-select-filter.tsx +++ b/src/renderer/components/+namespaces/namespace-select-filter.tsx @@ -30,6 +30,8 @@ import { NamespaceSelect } from "./namespace-select"; import { namespaceStore } from "./namespace.store"; import type { SelectOption, SelectProps } from "../select"; +import { isLinux, isMac, isWindows } from "../../../common/vars"; +import { observable } from "mobx"; const Placeholder = observer((props: PlaceholderProps) => { const getPlaceholder = (): React.ReactNode => { @@ -55,13 +57,16 @@ const Placeholder = observer((props: PlaceholderProps) => { @observer export class NamespaceSelectFilter extends React.Component { + static isMultiSelection = observable.box(false); + static isMenuOpen = observable.box(false); + formatOptionLabel({ value: namespace, label }: SelectOption) { if (namespace) { const isSelected = namespaceStore.hasContext(namespace); return (
- + {namespace} {isSelected && }
@@ -72,26 +77,55 @@ export class NamespaceSelectFilter extends React.Component { } onChange([{ value: namespace }]: SelectOption[]) { - if (namespace) { + if (NamespaceSelectFilter.isMultiSelection.get() && namespace) { namespaceStore.toggleContext(namespace); + } else if (!NamespaceSelectFilter.isMultiSelection.get() && namespace) { + namespaceStore.toggleSingle(namespace); } else { - namespaceStore.toggleAll(false); // "All namespaces" clicked + namespaceStore.toggleAll(true); // "All namespaces" clicked } } + onKeyDown = (e: React.KeyboardEvent) => { + if (isMac && e.metaKey || (isWindows || isLinux) && e.ctrlKey) { + NamespaceSelectFilter.isMultiSelection.set(true); + } + }; + + onKeyUp = (e: React.KeyboardEvent) => { + if (isMac && e.key === "Meta" || (isWindows || isLinux) && e.key === "Control") { + NamespaceSelectFilter.isMultiSelection.set(false); + } + }; + + onClick = () => { + if (!NamespaceSelectFilter.isMultiSelection.get()) { + NamespaceSelectFilter.isMenuOpen.set(!NamespaceSelectFilter.isMenuOpen.get()); + } + }; + + reset = () => { + NamespaceSelectFilter.isMultiSelection.set(false); + NamespaceSelectFilter.isMenuOpen.set(false); + }; + render() { return ( - +
+ +
); } } diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index c292f0f9fa..e8db0948ca 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -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/+nodes/node-details-resources.tsx b/src/renderer/components/+nodes/node-details-resources.tsx index 0906857256..636a3f55f0 100644 --- a/src/renderer/components/+nodes/node-details-resources.tsx +++ b/src/renderer/components/+nodes/node-details-resources.tsx @@ -35,7 +35,7 @@ interface Props { export class NodeDetailsResources extends React.Component { toMi(resource: string) { - if (resource.endsWith("Ki")) { + if (resource?.endsWith("Ki")) { return `${(parseInt(resource) / 1024).toFixed(1)}Mi`; } @@ -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/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/+preferences/kubeconfig-syncs.tsx b/src/renderer/components/+preferences/kubeconfig-syncs.tsx index 152fad13ba..c1e049cbde 100644 --- a/src/renderer/components/+preferences/kubeconfig-syncs.tsx +++ b/src/renderer/components/+preferences/kubeconfig-syncs.tsx @@ -32,6 +32,7 @@ import { SubTitle } from "../layout/sub-title"; import { Spinner } from "../spinner"; import logger from "../../../main/logger"; import { iter } from "../../utils"; +import { isWindows } from "../../../common/vars"; interface SyncInfo { type: "file" | "folder" | "unknown"; @@ -69,6 +70,8 @@ async function getMapEntry({ filePath, ...data}: KubeconfigSyncEntry): Promise<[ } } +type SelectPathOptions = ("openFile" | "openDirectory")[]; + @observer export class KubeconfigSyncs extends React.Component { syncs = observable.map(); @@ -106,11 +109,11 @@ export class KubeconfigSyncs extends React.Component { } @action - openFileDialog = async () => { + async openDialog(message: string, actions: SelectPathOptions) { const { dialog, BrowserWindow } = remote; const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { - properties: ["openFile", "showHiddenFiles", "multiSelections", "openDirectory"], - message: "Select kubeconfig file(s) and folder(s)", + properties: ["showHiddenFiles", "multiSelections", ...actions], + message, buttonLabel: "Sync", }); @@ -123,7 +126,7 @@ export class KubeconfigSyncs extends React.Component { for (const [filePath, info] of newEntries) { this.syncs.set(filePath, info); } - }; + } renderEntryIcon(entry: Entry) { switch (entry.info.type) { @@ -181,16 +184,41 @@ export class KubeconfigSyncs extends React.Component { ); } + renderSyncButtons() { + if (isWindows) { + return ( +
+
+ ); + } + + return ( +
)} tooltip={tooltip} + expandable={false} /> ); 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.scss b/src/renderer/components/cluster-manager/bottom-bar.scss index d89eefdc3e..c09e75889f 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.scss +++ b/src/renderer/components/cluster-manager/bottom-bar.scss @@ -27,18 +27,6 @@ padding: 0 2px; height: var(--bottom-bar-height); - #catalog-link { - font-size: var(--font-size-small); - color: white; - padding: $padding / 4 $padding / 2; - cursor: pointer; - - &:hover { - background-color: #ffffff33; - cursor: pointer; - } - } - .extensions { font-size: var(--font-size-small); color: white; diff --git a/src/renderer/components/cluster-manager/bottom-bar.tsx b/src/renderer/components/cluster-manager/bottom-bar.tsx index 8e37128198..0f61a03833 100644 --- a/src/renderer/components/cluster-manager/bottom-bar.tsx +++ b/src/renderer/components/cluster-manager/bottom-bar.tsx @@ -24,9 +24,6 @@ import "./bottom-bar.scss"; import React from "react"; import { observer } from "mobx-react"; import { StatusBarRegistration, StatusBarRegistry } from "../../../extensions/registries"; -import { navigate } from "../../navigation"; -import { Icon } from "../icon"; -import { catalogURL } from "../../../common/routes"; @observer export class BottomBar extends React.Component { @@ -67,10 +64,6 @@ export class BottomBar extends React.Component { render() { return (
- {this.renderRegisteredItems()}
); diff --git a/src/renderer/components/cluster-manager/catalog-topbar.tsx b/src/renderer/components/cluster-manager/catalog-topbar.tsx index 3027765fc9..7e70834ae3 100644 --- a/src/renderer/components/cluster-manager/catalog-topbar.tsx +++ b/src/renderer/components/cluster-manager/catalog-topbar.tsx @@ -24,15 +24,17 @@ import { welcomeURL } from "../../../common/routes"; import { navigate } from "../../navigation"; import { Icon } from "../icon"; import { TopBar } from "../layout/topbar"; -import { MaterialTooltip } from "../material-tooltip/material-tooltip"; export function CatalogTopbar() { return (
- - navigate(welcomeURL())}/> - + navigate(welcomeURL())} + tooltip="Close Catalog" + />
); diff --git a/src/renderer/components/cluster-manager/cluster-status.scss b/src/renderer/components/cluster-manager/cluster-status.scss index 1d048e7459..86527eb75f 100644 --- a/src/renderer/components/cluster-manager/cluster-status.scss +++ b/src/renderer/components/cluster-manager/cluster-status.scss @@ -35,6 +35,11 @@ white-space: pre-line; } + .Spinner { + --spinner-size: 48px; + --spinner-border: calc(var(--spinner-size) / 10); + } + .Icon { --size: 70px; margin: auto; diff --git a/src/renderer/components/cluster-manager/cluster-status.tsx b/src/renderer/components/cluster-manager/cluster-status.tsx index 456f606570..30065e2dd0 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,7 +100,7 @@ export class ClusterStatus extends React.Component { if (!hasErrors || this.isReconnecting) { return ( <> - +
             

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

{authOutput.map(({ data, error }, index) => { diff --git a/src/renderer/components/cluster-manager/cluster-topbar.tsx b/src/renderer/components/cluster-manager/cluster-topbar.tsx index 7dcc3c7d5a..20a6120ba1 100644 --- a/src/renderer/components/cluster-manager/cluster-topbar.tsx +++ b/src/renderer/components/cluster-manager/cluster-topbar.tsx @@ -18,18 +18,20 @@ * 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 { observer } from "mobx-react"; import React from "react"; -import type { RouteComponentProps } from "react-router"; + +import { previousActiveTab } from "../+catalog"; +import { ClusterStore } from "../../../common/cluster-store"; import { catalogURL } from "../../../common/routes"; import { navigate } from "../../navigation"; import { Icon } from "../icon"; import { TopBar } from "../layout/topbar"; -import { MaterialTooltip } from "../material-tooltip/material-tooltip"; -import type { Cluster } from "../../../main/cluster"; -import { ClusterStore } from "../../../common/cluster-store"; + +import type { RouteComponentProps } from "react-router"; import type { ClusterViewRouteParams } from "../../../common/routes"; +import type { Cluster } from "../../../main/cluster"; +import { TooltipPosition } from "../tooltip"; interface Props extends RouteComponentProps { } @@ -42,9 +44,17 @@ export const ClusterTopbar = observer((props: Props) => { return (
- - navigate(catalogURL())}/> - + { + navigate(`${catalogURL()}/${previousActiveTab.get()}`); + }} + tooltip={{ + preferredPositions: TooltipPosition.BOTTOM_RIGHT, + children: "Back to Catalog" + }} + />
); diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index 78626cccb1..db48e051c8 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -33,6 +33,7 @@ import { clusterActivateHandler } from "../../../common/cluster-ipc"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { navigate } from "../../navigation"; import { catalogURL, ClusterViewRouteParams } from "../../../common/routes"; +import { previousActiveTab } from "../+catalog"; interface Props extends RouteComponentProps { } @@ -84,7 +85,7 @@ export class ClusterView extends React.Component { const disconnected = values[1]; if (hasLoadedView(this.clusterId) && disconnected) { - navigate(catalogURL()); // redirect to catalog when active cluster get disconnected/not available + navigate(`${catalogURL()}/${previousActiveTab.get()}`); // redirect to catalog when active cluster get disconnected/not available } }), ]); diff --git a/src/renderer/components/cluster-settings/cluster-settings.tsx b/src/renderer/components/cluster-settings/cluster-settings.tsx index ed01ce4fb7..21081bc5ce 100644 --- a/src/renderer/components/cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/cluster-settings/cluster-settings.tsx @@ -40,12 +40,16 @@ export function GeneralSettings({ entity }: EntitySettingViewProps) { return (
- +
+
+ +
+
+ +
+
-
- -
-
+
@@ -106,12 +110,25 @@ 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 ab2a03f73c..0c087083fe 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,11 +50,13 @@ 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); this.props.cluster.accessibleNamespaces = Array.from(this.namespaces); }} + inputTheme="round-black" /> This setting is useful for manually specifying which namespaces you have access to. This is useful when you do not have permissions to list namespaces. diff --git a/src/renderer/components/cluster-settings/components/cluster-icon-settings.tsx b/src/renderer/components/cluster-settings/components/cluster-icon-settings.tsx index 2800586a11..980ba88633 100644 --- a/src/renderer/components/cluster-settings/components/cluster-icon-settings.tsx +++ b/src/renderer/components/cluster-settings/components/cluster-icon-settings.tsx @@ -21,15 +21,13 @@ import React from "react"; import type { Cluster } from "../../../../main/cluster"; -//import { FilePicker, OverSizeLimitStyle } from "../../file-picker"; import { boundMethod } from "../../../utils"; -import { Button } from "../../button"; import { observable } from "mobx"; import { observer } from "mobx-react"; -import { SubTitle } from "../../layout/sub-title"; import { HotbarIcon } from "../../hotbar/hotbar-icon"; import type { KubernetesCluster } from "../../../../common/catalog-entities"; import { FilePicker, OverSizeLimitStyle } from "../../file-picker"; +import { MenuActions, MenuItem } from "../../menu"; enum GeneralInputStatus { CLEAN = "clean", @@ -46,65 +44,74 @@ export class ClusterIconSetting extends React.Component { @observable status = GeneralInputStatus.CLEAN; @observable errorText?: string; + private element = React.createRef(); + @boundMethod async onIconPick([file]: File[]) { + if (!file) { + return; + } + const { cluster } = this.props; try { - if (file) { - const buf = Buffer.from(await file.arrayBuffer()); + const buf = Buffer.from(await file.arrayBuffer()); - cluster.preferences.icon = `data:${file.type};base64,${buf.toString("base64")}`; - } else { - // this has to be done as a seperate branch (and not always) because `cluster` - // is observable and triggers an update loop. - cluster.preferences.icon = undefined; - } + cluster.preferences.icon = `data:${file.type};base64,${buf.toString("base64")}`; } catch (e) { this.errorText = e.toString(); this.status = GeneralInputStatus.ERROR; } } - getClearButton() { - if (this.props.cluster.preferences.icon) { - return
- ); - } - - 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 565168efac..cc1e850cd2 100644 --- a/src/renderer/components/file-picker/file-picker.tsx +++ b/src/renderer/components/file-picker/file-picker.tsx @@ -71,15 +71,15 @@ export interface BaseProps { // the larger number is upper limit, the lower is lower limit // the lower limit is capped at 0 and the upper limit is capped at Infinity limit?: [number, number]; - + // default is "Reject" onOverLimit?: OverLimitStyle; - + // individual files are checked before the total size. maxSize?: number; // default is "Reject" onOverSizeLimit?: OverSizeLimitStyle; - + maxTotalSize?: number; // default is "Reject" onOverTotalSizeLimit?: OverTotalSizeLimitStyle; @@ -158,7 +158,7 @@ export class FilePicker extends React.Component { files = _.orderBy(files, ["size"]); case OverTotalSizeLimitStyle.FILTER_LAST: let newTotalSize = totalSize; - + for (;files.length > 0;) { newTotalSize -= files.pop().size; @@ -180,12 +180,12 @@ export class FilePicker extends React.Component { const numberLimitedFiles = this.handleFileCount(files); const sizeLimitedFiles = this.handleIndiviualFileSizes(numberLimitedFiles); const totalSizeLimitedFiles = this.handleTotalFileSizes(sizeLimitedFiles); - + if ("uploadDir" in this.props) { const { uploadDir } = this.props; this.status = FileInputStatus.PROCESSING; - + const paths: string[] = []; const promises = totalSizeLimitedFiles.map(async file => { const destinationPath = path.join(uploadDir, file.name); @@ -214,9 +214,9 @@ export class FilePicker extends React.Component { return
- { getIconRight(): React.ReactNode { switch (this.status) { - case FileInputStatus.CLEAR: - return ; case FileInputStatus.PROCESSING: return ; case FileInputStatus.ERROR: - return ; + return ; + default: + return null; } } } diff --git a/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx new file mode 100644 index 0000000000..119212753d --- /dev/null +++ b/src/renderer/components/hotbar/__tests__/hotbar-remove-command.test.tsx @@ -0,0 +1,87 @@ +/** + * 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 "@testing-library/jest-dom/extend-expect"; +import { HotbarRemoveCommand } from "../hotbar-remove-command"; +import { render, fireEvent } from "@testing-library/react"; +import React from "react"; +import { ThemeStore } from "../../../theme.store"; +import { UserStore } from "../../../../common/user-store"; +import { Notifications } from "../../notifications"; +import mockFs from "mock-fs"; + +jest.mock("electron", () => ({ + app: { + getPath: () => "tmp", + setLoginItemSettings: jest.fn(), + }, +})); + +const mockHotbars: {[id: string]: any} = { + "1": { + id: "1", + name: "Default", + items: [] as any + } +}; + +jest.mock("../../../../common/hotbar-store", () => ({ + HotbarStore: { + getInstance: () => ({ + hotbars: [mockHotbars["1"]], + getById: (id: string) => mockHotbars[id], + remove: () => {}, + hotbarIndex: () => 0 + }) + } +})); + +describe("", () => { + beforeEach(() => { + mockFs({ + "tmp": {} + }); + UserStore.createInstance(); + ThemeStore.createInstance(); + }); + + afterEach(() => { + UserStore.resetInstance(); + ThemeStore.resetInstance(); + mockFs.restore(); + }); + + it("renders w/o errors", () => { + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("displays error notification if user tries to remove last hotbar", () => { + const spy = jest.spyOn(Notifications, "error"); + const { getByText } = render(); + + fireEvent.click(getByText("1: Default")); + + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/src/renderer/components/hotbar/hotbar-add-command.tsx b/src/renderer/components/hotbar/hotbar-add-command.tsx index 8141594b7c..fde97ef561 100644 --- a/src/renderer/components/hotbar/hotbar-add-command.tsx +++ b/src/renderer/components/hotbar/hotbar-add-command.tsx @@ -33,22 +33,14 @@ const uniqueHotbarName: InputValidator = { @observer export class HotbarAddCommand extends React.Component { - - onSubmit(name: string) { + onSubmit = (name: string) => { if (!name.trim()) { return; } - const hotbarStore = HotbarStore.getInstance(); - - const hotbar = hotbarStore.add({ - name - }); - - hotbarStore.activeHotbarId = hotbar.id; - + HotbarStore.getInstance().add({ name }, { setActive: true }); CommandOverlay.close(); - } + }; render() { return ( @@ -58,10 +50,11 @@ export class HotbarAddCommand extends React.Component { autoFocus={true} theme="round-black" data-test-id="command-palette-hotbar-add-name" - validators={[uniqueHotbarName]} - onSubmit={(v) => this.onSubmit(v)} + validators={uniqueHotbarName} + onSubmit={this.onSubmit} dirty={true} - showValidationLine={true} /> + showValidationLine={true} + /> Please provide a new hotbar name (Press "Enter" to confirm or "Escape" to cancel) diff --git a/src/renderer/components/hotbar/hotbar-entity-icon.tsx b/src/renderer/components/hotbar/hotbar-entity-icon.tsx index bde9347d39..fc6628f3eb 100644 --- a/src/renderer/components/hotbar/hotbar-entity-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-entity-icon.tsx @@ -74,6 +74,10 @@ export class HotbarEntityIcon extends React.Component { } get ledIcon() { + if (this.props.entity.kind !== "KubernetesCluster") { + return null; + } + const className = cssNames("led", { online: this.props.entity.status.phase == "connected"}); // TODO: make it more generic return
; @@ -129,11 +133,14 @@ export class HotbarEntityIcon extends React.Component { uid={entity.metadata.uid} title={entity.metadata.name} source={entity.metadata.source} - icon={entity.spec.iconData} + src={entity.spec.icon?.src} + material={entity.spec.icon?.material} + background={entity.spec.icon?.background} className={className} active={isActive} onMenuOpen={onOpen} menuItems={this.contextMenu.menuItems} + tooltip={`${entity.metadata.name} (${entity.metadata.source})`} {...elemProps} > { this.ledIcon } diff --git a/src/renderer/components/hotbar/hotbar-icon.scss b/src/renderer/components/hotbar/hotbar-icon.scss index 9e3451ab94..2f9ad6a696 100644 --- a/src/renderer/components/hotbar/hotbar-icon.scss +++ b/src/renderer/components/hotbar/hotbar-icon.scss @@ -20,7 +20,6 @@ */ .HotbarMenu { - .HotbarIconMenu { left: 30px; min-width: 250px; @@ -50,6 +49,10 @@ cursor: default; filter: grayscale(0.7); + &.contextMenuAvailable { + cursor: context-menu; + } + &:hover { &:not(.active) { box-shadow: none; @@ -69,9 +72,11 @@ box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px var(--textColorAccent); } - &:hover { - &:not(.active) { - box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px #ffffff50; + &.interactive { + &:hover { + &:not(.active) { + box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px #ffffff50; + } } } } @@ -113,7 +118,6 @@ } img { - padding: 3px; border-radius: 6px; &.active { @@ -126,4 +130,11 @@ } } } + + .materialIcon { + margin-left: 1px; + margin-top: 1px; + text-shadow: none; + font-size: 180%; + } } diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index 7d3008231e..b8447baf80 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -27,21 +27,25 @@ import type { CatalogEntityContextMenu } from "../../../common/catalog"; import { cssNames, IClassName } from "../../utils"; import { ConfirmDialog } from "../confirm-dialog"; import { Menu, MenuItem } from "../menu"; -import { MaterialTooltip } from "../material-tooltip/material-tooltip"; import { observer } from "mobx-react"; import { Avatar } from "../avatar/avatar"; +import { Icon } from "../icon"; +import { Tooltip } from "../tooltip"; export interface HotbarIconProps extends DOMAttributes { uid: string; title: string; source: string; - icon?: string; + src?: string; + material?: string; onMenuOpen?: () => void; className?: IClassName; active?: boolean; menuItems?: CatalogEntityContextMenu[]; disabled?: boolean; size?: number; + background?: string; + tooltip?: string; } function onMenuItemClick(menuItem: CatalogEntityContextMenu) { @@ -61,8 +65,8 @@ function onMenuItemClick(menuItem: CatalogEntityContextMenu) { } } -export const HotbarIcon = observer(({menuItems = [], size = 40, ...props}: HotbarIconProps) => { - const { uid, title, icon, active, className, source, disabled, onMenuOpen, onClick, children, ...rest } = props; +export const HotbarIcon = observer(({menuItems = [], size = 40, tooltip, ...props}: HotbarIconProps) => { + const { uid, title, src, material, active, className, source, disabled, onMenuOpen, onClick, children, ...rest } = props; const id = `hotbarIcon-${uid}`; const [menuOpen, setMenuOpen] = useState(false); @@ -71,44 +75,33 @@ export const HotbarIcon = observer(({menuItems = [], size = 40, ...props}: Hotba }; const renderIcon = () => { - if (icon) { - return { - if (!disabled) { - onClick?.(event); - } - }} - />; - } else { - return { if (!disabled) { onClick?.(event); } }} - />; - } + > + {material && } + + ); }; return ( -
- -
- {renderIcon()} - {children} -
-
+
0 })}> + {tooltip && {tooltip}} +
+ {renderIcon()} + {children} +
{ - if (!disabled) { - onMenuOpen?.(); - toggleMenu(); - } + onMenuOpen?.(); + toggleMenu(); }} close={() => toggleMenu()}> - { menuItems.map((menuItem) => { - return ( - onMenuItemClick(menuItem) }> + { + menuItems.map((menuItem) => ( + onMenuItemClick(menuItem)}> {menuItem.title} - ); - })} + )) + }
); diff --git a/src/renderer/components/hotbar/hotbar-menu.tsx b/src/renderer/components/hotbar/hotbar-menu.tsx index 51f489a537..56fa7a25fc 100644 --- a/src/renderer/components/hotbar/hotbar-menu.tsx +++ b/src/renderer/components/hotbar/hotbar-menu.tsx @@ -27,7 +27,7 @@ import { HotbarEntityIcon } from "./hotbar-entity-icon"; import { cssNames, IClassName } from "../../utils"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { defaultHotbarCells, HotbarItem, HotbarStore } from "../../../common/hotbar-store"; -import { CatalogEntity, CatalogEntityContextMenu, catalogEntityRunContext } from "../../api/catalog-entity"; +import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd"; import { HotbarSelector } from "./hotbar-selector"; import { HotbarCell } from "./hotbar-cell"; @@ -110,12 +110,6 @@ export class HotbarMenu extends React.Component { renderGrid() { return this.items.map((item, index) => { const entity = this.getEntity(item); - const disabledMenuItems: CatalogEntityContextMenu[] = [ - { - title: "Unpin from Hotbar", - onClick: () => this.removeItem(item.entity.uid) - } - ]; return ( @@ -160,10 +154,16 @@ export class HotbarMenu extends React.Component { /> ) : ( this.removeItem(item.entity.uid) + } + ]} disabled size={40} /> diff --git a/src/renderer/components/hotbar/hotbar-remove-command.tsx b/src/renderer/components/hotbar/hotbar-remove-command.tsx index de75bf0259..58aa1d849d 100644 --- a/src/renderer/components/hotbar/hotbar-remove-command.tsx +++ b/src/renderer/components/hotbar/hotbar-remove-command.tsx @@ -27,6 +27,7 @@ import { HotbarStore } from "../../../common/hotbar-store"; import { hotbarDisplayLabel } from "./hotbar-display-label"; import { CommandOverlay } from "../command-palette"; import { ConfirmDialog } from "../confirm-dialog"; +import { Notifications } from "../notifications"; @observer export class HotbarRemoveCommand extends React.Component { @@ -45,11 +46,18 @@ export class HotbarRemoveCommand extends React.Component { const hotbarStore = HotbarStore.getInstance(); const hotbar = hotbarStore.getById(id); + CommandOverlay.close(); + if (!hotbar) { return; } - CommandOverlay.close(); + if (hotbarStore.hotbars.length === 1) { + Notifications.error("Can't remove the last hotbar"); + + return; + } + ConfirmDialog.open({ okButtonProps: { label: `Remove Hotbar`, 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 0e74f0e9a2..3bac4475fe 100644 --- a/src/renderer/components/hotbar/hotbar-selector.tsx +++ b/src/renderer/components/hotbar/hotbar-selector.tsx @@ -27,7 +27,7 @@ import { Hotbar, HotbarStore } from "../../../common/hotbar-store"; import { CommandOverlay } from "../command-palette"; import { HotbarSwitchCommand } from "./hotbar-switch-command"; import { hotbarDisplayIndex } from "./hotbar-display-label"; -import { MaterialTooltip } from "../material-tooltip/material-tooltip"; +import { TooltipPosition } from "../tooltip"; interface Props { hotbar: Hotbar; @@ -40,14 +40,17 @@ export function HotbarSelector({ hotbar }: Props) {
store.switchToPrevious()} />
- - CommandOverlay.open()} - /> - + CommandOverlay.open()} + tooltip={{ + preferredPositions: [TooltipPosition.TOP, TooltipPosition.TOP_LEFT], + children: hotbar.name + }} + className="SelectorIndex" + />
store.switchToNext()} />
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/icon/icon.tsx b/src/renderer/components/icon/icon.tsx index ec541f7457..ee24b6e3fa 100644 --- a/src/renderer/components/icon/icon.tsx +++ b/src/renderer/components/icon/icon.tsx @@ -122,7 +122,7 @@ export class Icon extends React.PureComponent { // render as material-icon if (material) { - iconContent = {material}; + iconContent = {material}; } // wrap icon's content passed from decorator diff --git a/src/renderer/components/input/input.scss b/src/renderer/components/input/input.scss index 97ad5c058a..b683f46d3a 100644 --- a/src/renderer/components/input/input.scss +++ b/src/renderer/components/input/input.scss @@ -136,10 +136,15 @@ border-color: var(--inputControlBorder); color: var(--textColorTertiary); padding: $padding; + transition: border-color 0.1s; &:hover { border-color: var(--inputControlHoverBorder); } + + &:focus-within { + border-color: $colorInfo; + } } } } diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index fa1692e988..d182a8c202 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -31,6 +31,7 @@ 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; @@ -59,12 +60,12 @@ export type InputProps = Omit = { @@ -81,15 +82,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; @@ -213,7 +213,6 @@ export class Input extends React.Component { } setDirty(dirty = true) { - if (this.state.dirty === dirty) return; this.setState({ dirty }); } @@ -221,30 +220,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 +249,20 @@ 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(); - } - } - break; + if (evt.key === "Enter") { + if (this.state.valid) { + this.props.onSubmit?.(this.getValue(), evt); + this.setDirtyOnChange.cancel(); + this.setState({ submitted: true }); + } else { + this.setDirty(); + } } } @@ -291,7 +285,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(); } diff --git a/src/renderer/components/item-object-list/page-filters-list.tsx b/src/renderer/components/item-object-list/page-filters-list.tsx index 49d3245bf9..6620350b01 100644 --- a/src/renderer/components/item-object-list/page-filters-list.tsx +++ b/src/renderer/components/item-object-list/page-filters-list.tsx @@ -27,6 +27,7 @@ import { cssNames } from "../../utils"; import { Filter, pageFilters } from "./page-filters.store"; import { FilterIcon } from "./filter-icon"; import { Icon } from "../icon"; +import { searchUrlParam } from "../input"; interface Props { filters?: Filter[]; @@ -41,7 +42,10 @@ export class PageFiltersList extends React.Component { }; reset = () => pageFilters.reset(); - remove = (filter: Filter) => pageFilters.removeFilter(filter); + remove = (filter: Filter) => { + pageFilters.removeFilter(filter); + searchUrlParam.clear(); + }; renderContent() { const { filters } = this.props; @@ -66,7 +70,7 @@ export class PageFiltersList extends React.Component { diff --git a/src/renderer/components/item-object-list/page-filters.store.ts b/src/renderer/components/item-object-list/page-filters.store.ts index a6d840dc93..f4fe8ac2f4 100644 --- a/src/renderer/components/item-object-list/page-filters.store.ts +++ b/src/renderer/components/item-object-list/page-filters.store.ts @@ -83,10 +83,6 @@ export class PageFiltersStore { if (filterCopy) this.filters.remove(filterCopy); } - - if (filter.type === FilterType.SEARCH) { - searchUrlParam.clear(); - } } getByType(type: FilterType, value?: any): Filter { diff --git a/src/renderer/components/kube-object/kube-object-details.tsx b/src/renderer/components/kube-object/kube-object-details.tsx index 8098f77064..1f0c494b4e 100644 --- a/src/renderer/components/kube-object/kube-object-details.tsx +++ b/src/renderer/components/kube-object/kube-object-details.tsx @@ -34,6 +34,7 @@ import { CrdResourceDetails } from "../+custom-resources"; import { KubeObjectMenu } from "./kube-object-menu"; import type { CustomResourceDefinition } from "../../api/endpoints"; import { KubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; +import logger from "../../../main/logger"; /** * Used to store `object.selfLink` to show more info about resource in the details panel. @@ -67,6 +68,7 @@ export function hideDetails() { } export function getDetailsUrl(selfLink: string, resetSelected = false, mergeGlobals = true) { + logger.debug("getDetailsUrl", { selfLink, resetSelected, mergeGlobals }); const params = new URLSearchParams(mergeGlobals ? navigation.searchParams : ""); params.set(kubeDetailsUrlParam.name, selfLink); @@ -100,10 +102,12 @@ export class KubeObjectDetails extends React.Component { } @computed get object() { - const store = apiManager.getStore(this.path); - - if (store) { - return store.getByPath(this.path); + try { + return apiManager + .getStore(this.path) + ?.getByPath(this.path); + } catch (error) { + logger.error(`[KUBE-OBJECT-DETAILS]: failed to get store or object: ${error}`, { path: this.path }); } } diff --git a/src/renderer/components/kube-object/kube-object-menu.tsx b/src/renderer/components/kube-object/kube-object-menu.tsx index da576fcba0..f614ffc980 100644 --- a/src/renderer/components/kube-object/kube-object-menu.tsx +++ b/src/renderer/components/kube-object/kube-object-menu.tsx @@ -83,7 +83,9 @@ export class KubeObjectMenu extends React.Component extends React.Component extends React.Component - {this.getMenuItems(object)} + {this.getMenuItems()} ); } diff --git a/src/renderer/components/layout/main-layout.tsx b/src/renderer/components/layout/main-layout.tsx index a85e90b158..34b0c1ed95 100755 --- a/src/renderer/components/layout/main-layout.tsx +++ b/src/renderer/components/layout/main-layout.tsx @@ -34,6 +34,11 @@ interface Props { footer?: React.ReactNode; } +/** + * Main layout is commonly used as a wrapper for "global pages" + * + * @link https://api-docs.k8slens.dev/master/extensions/capabilities/common-capabilities/#global-pages + */ @observer export class MainLayout extends React.Component { onSidebarResize = (width: number) => { diff --git a/src/renderer/components/layout/page-layout.tsx b/src/renderer/components/layout/page-layout.tsx index 6978cdc218..a393603885 100644 --- a/src/renderer/components/layout/page-layout.tsx +++ b/src/renderer/components/layout/page-layout.tsx @@ -19,96 +19,11 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./page-layout.scss"; +import { SettingLayout } from "./setting-layout"; -import React from "react"; -import { observer } from "mobx-react"; -import { boundMethod, cssNames, IClassName } from "../../utils"; -import { navigation } from "../../navigation"; -import { Icon } from "../icon"; - -export interface PageLayoutProps extends React.DOMAttributes { - className?: IClassName; - contentClass?: IClassName; - provideBackButtonNavigation?: boolean; - contentGaps?: boolean; - showOnTop?: boolean; // covers whole app view - navigation?: React.ReactNode; - back?: (evt: React.MouseEvent | KeyboardEvent) => void; -} - -const defaultProps: Partial = { - provideBackButtonNavigation: true, - contentGaps: true, -}; - -@observer -export class PageLayout extends React.Component { - static defaultProps = defaultProps as object; - - @boundMethod - back(evt?: React.MouseEvent | KeyboardEvent) { - if (this.props.back) { - this.props.back(evt); - } else { - navigation.goBack(); - } - } - - async componentDidMount() { - window.addEventListener("keydown", this.onEscapeKey); - } - - componentWillUnmount() { - window.removeEventListener("keydown", this.onEscapeKey); - } - - onEscapeKey = (evt: KeyboardEvent) => { - if (!this.props.provideBackButtonNavigation) { - return; - } - - if (evt.code === "Escape") { - evt.stopPropagation(); - this.back(evt); - } - }; - - render() { - const { - contentClass, provideBackButtonNavigation, - contentGaps, showOnTop, navigation, children, ...elemProps - } = this.props; - const className = cssNames("PageLayout", { showOnTop, showNavigation: navigation }, this.props.className); - - return ( -
- { navigation && ( - - )} -
-
- {children} -
-
- { this.props.provideBackButtonNavigation && ( -
-
- -
- - -
- )} -
-
-
- ); - } -} +/** + * PageLayout is deprecated. See MainLayout & SettingLayout + * + * @deprecated + */ +export class PageLayout extends SettingLayout {} diff --git a/src/renderer/components/layout/page-layout.scss b/src/renderer/components/layout/setting-layout.scss similarity index 85% rename from src/renderer/components/layout/page-layout.scss rename to src/renderer/components/layout/setting-layout.scss index 8b2c30494e..3e6bfd8726 100644 --- a/src/renderer/components/layout/page-layout.scss +++ b/src/renderer/components/layout/setting-layout.scss @@ -19,20 +19,20 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -.PageLayout { - --width: 75%; - --nav-width: 180px; +.SettingLayout { --nav-column-width: 30vw; - - position: relative; width: 100%; height: 100%; - display: grid !important; + display: grid; color: var(--settingsColor); - - @include media("<1000px") { - --width: 85%; - } + position: fixed; + z-index: 13!important; + left: 0; + top: 0; + right: 0; + bottom: 0; + height: unset; + background-color: var(--settingsBackground); &.showNavigation { grid-template-columns: var(--nav-column-width) 1fr; @@ -42,18 +42,6 @@ } } - // covers whole app view area - &.showOnTop { - position: fixed !important; // allow to cover ClustersMenu - z-index: 13; - left: 0; - top: 0; - right: 0; - bottom: 0; - height: unset; - background-color: var(--settingsBackground); - } - > .sidebarRegion { display: flex; justify-content: flex-end; @@ -65,12 +53,25 @@ padding: 60px 0 60px 20px; h2 { - margin-bottom: 10px; - font-size: 18px; - padding: 6px 10px; + font-size: 15px; overflow-wrap: anywhere; color: var(--textColorAccent); font-weight: 600; + padding-right: 20px; + word-break: break-word; + } + + hr { + margin-top: 10px; + margin-bottom: 10px; + margin-left: 10px; + margin-right: 20px; + height: 1px; + border-top: thin solid var(--hrColor); + + &:first-child { + display: none; + } } .Tabs { @@ -80,6 +81,7 @@ font-weight: 800; line-height: 16px; text-transform: uppercase; + color: var(--textColorPrimary); &:first-child { padding-top: 0; @@ -114,9 +116,14 @@ > .label { width: 100%; + font-weight: 500; } } } + + .HotbarIcon { + margin: 0 11px; + } } } @@ -126,7 +133,9 @@ justify-content: center; > .content { - width: var(--width); + width: 100%; + max-width: 740px; + min-width: 460px; padding: 60px 40px 80px; > section { @@ -159,7 +168,7 @@ } .Icon { - color: var(--textColorSecondary); + color: var(--textColorTertiary); } } @@ -202,7 +211,7 @@ font-size: 18px; line-height: 20px; font-weight: 600; - margin-bottom: 20px; + margin-bottom: 30px; } a { @@ -212,6 +221,7 @@ .hint { margin-top: 8px; font-size: 14px; + line-height: 20px; } .SubTitle { diff --git a/src/renderer/components/layout/setting-layout.tsx b/src/renderer/components/layout/setting-layout.tsx new file mode 100644 index 0000000000..53e54e07ac --- /dev/null +++ b/src/renderer/components/layout/setting-layout.tsx @@ -0,0 +1,116 @@ +/** + * 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 "./setting-layout.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { boundMethod, cssNames, IClassName } from "../../utils"; +import { navigation } from "../../navigation"; +import { Icon } from "../icon"; + +export interface SettingLayoutProps extends React.DOMAttributes { + className?: IClassName; + contentClass?: IClassName; + provideBackButtonNavigation?: boolean; + contentGaps?: boolean; + navigation?: React.ReactNode; + back?: (evt: React.MouseEvent | KeyboardEvent) => void; +} + +const defaultProps: Partial = { + provideBackButtonNavigation: true, + contentGaps: true, +}; + +/** + * Layout for settings like pages with navigation + */ +@observer +export class SettingLayout extends React.Component { + static defaultProps = defaultProps as object; + + @boundMethod + back(evt?: React.MouseEvent | KeyboardEvent) { + if (this.props.back) { + this.props.back(evt); + } else { + navigation.goBack(); + } + } + + async componentDidMount() { + window.addEventListener("keydown", this.onEscapeKey); + } + + componentWillUnmount() { + window.removeEventListener("keydown", this.onEscapeKey); + } + + onEscapeKey = (evt: KeyboardEvent) => { + if (!this.props.provideBackButtonNavigation) { + return; + } + + if (evt.code === "Escape") { + evt.stopPropagation(); + this.back(evt); + } + }; + + render() { + const { + contentClass, provideBackButtonNavigation, + contentGaps, navigation, children, ...elemProps + } = this.props; + const className = cssNames("SettingLayout", { showNavigation: navigation }, this.props.className); + + return ( +
+ { navigation && ( + + )} +
+
+ {children} +
+
+ { this.props.provideBackButtonNavigation && ( +
+
+ +
+ + +
+ )} +
+
+
+ ); + } +} diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index 2aa3f37488..9842d9e45e 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -73,7 +73,7 @@ export class Sidebar extends React.Component { key={crd.getResourceApiBase()} id={`crd-resource:${crd.getResourceApiBase()}`} url={crd.getResourceUrl()} - text={crd.getResourceTitle()} + text={crd.getResourceKind()} /> ))} diff --git a/src/renderer/components/menu/menu-actions.tsx b/src/renderer/components/menu/menu-actions.tsx index 6017c699f4..a69f226e5e 100644 --- a/src/renderer/components/menu/menu-actions.tsx +++ b/src/renderer/components/menu/menu-actions.tsx @@ -137,13 +137,13 @@ export class MenuActions extends React.Component { {children} {updateAction && ( - + Edit )} {removeAction && ( - + Remove )} diff --git a/src/renderer/components/notifications/notifications.tsx b/src/renderer/components/notifications/notifications.tsx index c940ec469a..b4e9344aa0 100644 --- a/src/renderer/components/notifications/notifications.tsx +++ b/src/renderer/components/notifications/notifications.tsx @@ -88,7 +88,7 @@ export class Notifications extends React.Component { getMessage(notification: Notification) { let { message } = notification; - if (message instanceof JsonApiErrorParsed) { + if (message instanceof JsonApiErrorParsed || message instanceof Error) { message = message.toString(); } diff --git a/src/renderer/components/select/select.scss b/src/renderer/components/select/select.scss index 55fddec850..84f348f4c0 100644 --- a/src/renderer/components/select/select.scss +++ b/src/renderer/components/select/select.scss @@ -213,6 +213,7 @@ html { :hover { &.Select__control { box-shadow: 0 0 0 1px var(--inputControlHoverBorder); + transition: box-shadow 0.1s; } } diff --git a/src/renderer/components/spinner/cube-spinner.scss b/src/renderer/components/spinner/cube-spinner.scss deleted file mode 100644 index 18dab776d7..0000000000 --- a/src/renderer/components/spinner/cube-spinner.scss +++ /dev/null @@ -1,114 +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. - */ - -// http://tobiasahlin.com/spinkit/ - -.CubeSpinner { - --size: 70px; - - &.center { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%); - } - - .sk-cube-grid { - $size: 70px; - width: $size; - height: $size; - transform: rotate(45deg); - margin: $margin * 4 auto; - border-bottom-left-radius: $radius; - border-top-right-radius: $radius; - overflow: hidden; - } - - .sk-cube-grid .sk-cube { - width: 33%; - height: 33%; - float: left; - -webkit-animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out; - animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out; - } - - .sk-cube-grid .sk-cube1 { - -webkit-animation-delay: 0.2s; - animation-delay: 0.2s; - } - - .sk-cube-grid .sk-cube2 { - -webkit-animation-delay: 0.3s; - animation-delay: 0.3s; - } - - .sk-cube-grid .sk-cube3 { - -webkit-animation-delay: 0.4s; - animation-delay: 0.4s; - } - - .sk-cube-grid .sk-cube4 { - -webkit-animation-delay: 0.1s; - animation-delay: 0.1s; - } - - .sk-cube-grid .sk-cube5 { - -webkit-animation-delay: 0.2s; - animation-delay: 0.2s; - } - - .sk-cube-grid .sk-cube6 { - -webkit-animation-delay: 0.3s; - animation-delay: 0.3s; - } - - .sk-cube-grid .sk-cube7 { - -webkit-animation-delay: 0s; - animation-delay: 0s; - } - - .sk-cube-grid .sk-cube8 { - -webkit-animation-delay: 0.1s; - animation-delay: 0.1s; - } - - .sk-cube-grid .sk-cube9 { - -webkit-animation-delay: 0.2s; - animation-delay: 0.2s; - } - - @keyframes sk-cubeGridScaleDelay { - 0%, 35% { - background-color: #36393e; - } - 0%, 70%, 100% { - -webkit-transform: scale3D(1, 1, 1); - transform: scale3D(1, 1, 1); - } - 35% { - -webkit-transform: scale3D(0, 0, 1); - transform: scale3D(0, 0, 1); - } - 100% { - background-color: #3389ca; - } - } -} \ No newline at end of file diff --git a/src/renderer/components/spinner/index.ts b/src/renderer/components/spinner/index.ts index d060b78aa0..53ed99304a 100644 --- a/src/renderer/components/spinner/index.ts +++ b/src/renderer/components/spinner/index.ts @@ -20,4 +20,3 @@ */ export * from "./spinner"; -export * from "./cube-spinner"; diff --git a/src/renderer/components/table/table-head.scss b/src/renderer/components/table/table-head.scss index 4e194b2708..efa0bdb9b9 100644 --- a/src/renderer/components/table/table-head.scss +++ b/src/renderer/components/table/table-head.scss @@ -38,7 +38,7 @@ position: -webkit-sticky; // safari position: sticky; z-index: 1; - top: 0; // turn on sticky behaviour + top: 0; } &.nowrap { @@ -48,6 +48,7 @@ .TableCell { display: flex; align-items: center; + word-break: normal; &.checkbox { > .Checkbox { @@ -55,4 +56,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/renderer/components/table/table.mixins.scss b/src/renderer/components/table/table.mixins.scss index 3f7a021aff..12e009631d 100644 --- a/src/renderer/components/table/table.mixins.scss +++ b/src/renderer/components/table/table.mixins.scss @@ -49,8 +49,4 @@ @mixin table-cell-labels-offsets { padding-top: $padding / 2; padding-bottom: 0; - - .Badge + .Badge { - margin-left: $padding / 2; - } } \ No newline at end of file diff --git a/src/renderer/components/tabs/tabs.scss b/src/renderer/components/tabs/tabs.scss index 790476143f..7b5b6d8e44 100644 --- a/src/renderer/components/tabs/tabs.scss +++ b/src/renderer/components/tabs/tabs.scss @@ -72,7 +72,7 @@ bottom: 0; width: 0; height: $unit /2; - transition: width 250ms; + transition: width 150ms; background: currentColor; color: $halfGray } @@ -95,4 +95,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/renderer/components/tabs/tabs.tsx b/src/renderer/components/tabs/tabs.tsx index fb3fb5e58c..8a910211fa 100644 --- a/src/renderer/components/tabs/tabs.tsx +++ b/src/renderer/components/tabs/tabs.tsx @@ -84,7 +84,7 @@ export interface TabProps extends DOMAttributes { export class Tab extends React.PureComponent { static contextType = TabsContext; declare context: TabsContextValue; - public elem: HTMLElement; + public ref = React.createRef(); get isActive() { const { active, value } = this.props; @@ -93,11 +93,11 @@ export class Tab extends React.PureComponent { } focus() { - this.elem.focus(); + this.ref.current?.focus(); } scrollIntoView() { - this.elem.scrollIntoView({ + this.ref.current?.scrollIntoView?.({ behavior: "smooth", inline: "center", }); @@ -106,30 +106,28 @@ export class Tab extends React.PureComponent { @boundMethod onClick(evt: React.MouseEvent) { const { value, active, disabled, onClick } = this.props; - const { onChange } = this.context; - if (disabled || active) return; - if (onClick) onClick(evt); - if (onChange) onChange(value); + if (disabled || active) { + return; + } + + onClick?.(evt); + this.context.onChange?.(value); } @boundMethod onFocus(evt: React.FocusEvent) { - const { onFocus } = this.props; - - if (onFocus) onFocus(evt); + this.props.onFocus?.(evt); this.scrollIntoView(); } @boundMethod onKeyDown(evt: React.KeyboardEvent) { - const ENTER_KEY = evt.keyCode === 13; - const SPACE_KEY = evt.keyCode === 32; + if (evt.key === " " || evt.key === "Enter") { + this.ref.current?.click(); + } - if (SPACE_KEY || ENTER_KEY) this.elem.click(); - const { onKeyDown } = this.props; - - if (onKeyDown) onKeyDown(evt); + this.props?.onKeyDown(evt); } componentDidMount() { @@ -138,11 +136,6 @@ export class Tab extends React.PureComponent { } } - @boundMethod - protected bindRef(elem: HTMLElement) { - this.elem = elem; - } - render() { const { active, disabled, icon, label, value, ...elemProps } = this.props; let { className } = this.props; @@ -160,7 +153,7 @@ export class Tab extends React.PureComponent { onClick={this.onClick} onFocus={this.onFocus} onKeyDown={this.onKeyDown} - ref={this.bindRef} + ref={this.ref} > {typeof icon === "string" ? : icon}
diff --git a/src/renderer/components/tooltip/tooltip.scss b/src/renderer/components/tooltip/tooltip.scss index 8e329dbbfd..a53cc29a57 100644 --- a/src/renderer/components/tooltip/tooltip.scss +++ b/src/renderer/components/tooltip/tooltip.scss @@ -23,7 +23,7 @@ .Tooltip { --bgc: #{$mainBackground}; --radius: #{$radius}; - --color: #{$textColorSecondary}; + --color: #{$textColorAccent}; --border: 1px solid #{$borderColor}; // use positioning relative to viewport (window) @@ -33,17 +33,18 @@ background: var(--bgc); font-size: small; font-weight: normal; - border: var(--border); border-radius: var(--radius); color: var(--color); white-space: normal; padding: .5em; text-align: center; pointer-events: none; - transition: opacity 150ms 25ms ease-in-out; + transition: opacity 150ms 150ms ease-in-out; z-index: 100000; + opacity: 1; + box-shadow: 0 8px 16px rgba(0,0,0,0.24); - &.hidden { + &.invisible { left: 0; top: 0; opacity: 0; diff --git a/src/renderer/components/tooltip/tooltip.tsx b/src/renderer/components/tooltip/tooltip.tsx index c2b5702545..86af502669 100644 --- a/src/renderer/components/tooltip/tooltip.tsx +++ b/src/renderer/components/tooltip/tooltip.tsx @@ -228,7 +228,7 @@ export class Tooltip extends React.Component { render() { const { style, formatters, usePortal, children } = this.props; const className = cssNames("Tooltip", this.props.className, formatters, this.activePosition, { - hidden: !this.isVisible, + invisible: !this.isVisible, formatter: !!formatters, }); const tooltip = ( diff --git a/src/renderer/components/virtual-list/virtual-list.scss b/src/renderer/components/virtual-list/virtual-list.scss index 9f26247a44..3953be4756 100644 --- a/src/renderer/components/virtual-list/virtual-list.scss +++ b/src/renderer/components/virtual-list/virtual-list.scss @@ -20,9 +20,8 @@ */ .VirtualList { - overflow: hidden; - - > .list { + > div > .list { + /* Render scrollbar in content area, preventing to occupy extra space */ overflow-y: overlay!important; } -} \ No newline at end of file +} diff --git a/src/renderer/components/virtual-list/virtual-list.tsx b/src/renderer/components/virtual-list/virtual-list.tsx index c5a32edc43..ad6d93af3a 100644 --- a/src/renderer/components/virtual-list/virtual-list.tsx +++ b/src/renderer/components/virtual-list/virtual-list.tsx @@ -75,23 +75,27 @@ export class VirtualList extends Component { const { items, rowHeights } = this.props; if (prevProps.items.length !== items.length || !isEqual(prevProps.rowHeights, rowHeights)) { - this.listRef.current.resetAfterIndex(0, false); + this.listRef.current?.resetAfterIndex(0, false); } } getItemSize = (index: number) => this.props.rowHeights[index]; scrollToSelectedItem = debounce(() => { - if (!this.props.selectedItemId) return; + if (!this.props.selectedItemId) { + return; + } + const { items, selectedItemId } = this.props; const index = items.findIndex(item => item.getId() == selectedItemId); - if (index === -1) return; - this.listRef.current.scrollToItem(index, "start"); + if (index >= 0) { + this.listRef.current?.scrollToItem(index, "start"); + } }); scrollToItem = (index: number, align: Align) => { - this.listRef.current.scrollToItem(index, align); + this.listRef.current?.scrollToItem(index, align); }; render() { diff --git a/src/renderer/components/spinner/cube-spinner.tsx b/src/renderer/initializers/catalog-entity-detail-registry.tsx similarity index 52% rename from src/renderer/components/spinner/cube-spinner.tsx rename to src/renderer/initializers/catalog-entity-detail-registry.tsx index 80b9fea57f..8cae26afbf 100644 --- a/src/renderer/components/spinner/cube-spinner.tsx +++ b/src/renderer/initializers/catalog-entity-detail-registry.tsx @@ -19,33 +19,32 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./cube-spinner.scss"; import React from "react"; -import { cssNames } from "../../utils"; +import { KubernetesCluster } from "../../common/catalog-entities"; +import { CatalogEntityDetailRegistry, CatalogEntityDetailsProps } from "../../extensions/registries"; +import { DrawerItem, DrawerTitle } from "../components/drawer"; -export interface CubeSpinnerProps { - className?: string; - center?: boolean; -} - -export class CubeSpinner extends React.Component { - render() { - const { className, center } = this.props; - - return ( -
-
-
-
-
-
-
-
-
-
-
-
-
- ); - } +export function initCatalogEntityDetailRegistry() { + CatalogEntityDetailRegistry.getInstance() + .add([ + { + apiVersions: [KubernetesCluster.apiVersion], + kind: KubernetesCluster.kind, + components: { + Details: ({ entity }: CatalogEntityDetailsProps) => ( + <> + +
+ + {entity.metadata.distro || "unknown"} + + + {entity.metadata.kubeVersion || "unknown"} + +
+ + ), + }, + } + ]); } diff --git a/src/renderer/initializers/entity-settings-registry.ts b/src/renderer/initializers/entity-settings-registry.ts index e7203ca97b..31c8c81c21 100644 --- a/src/renderer/initializers/entity-settings-registry.ts +++ b/src/renderer/initializers/entity-settings-registry.ts @@ -30,6 +30,7 @@ export function initEntitySettingsRegistry() { kind: "KubernetesCluster", source: "local", title: "General", + group: "Settings", components: { View: clusterSettings.GeneralSettings, } @@ -38,6 +39,7 @@ export function initEntitySettingsRegistry() { apiVersions: ["entity.k8slens.dev/v1alpha1"], kind: "KubernetesCluster", title: "Proxy", + group: "Settings", components: { View: clusterSettings.ProxySettings, } @@ -46,6 +48,7 @@ export function initEntitySettingsRegistry() { apiVersions: ["entity.k8slens.dev/v1alpha1"], kind: "KubernetesCluster", title: "Terminal", + group: "Settings", components: { View: clusterSettings.TerminalSettings, } @@ -54,6 +57,7 @@ export function initEntitySettingsRegistry() { apiVersions: ["entity.k8slens.dev/v1alpha1"], kind: "KubernetesCluster", title: "Namespaces", + group: "Settings", components: { View: clusterSettings.NamespacesSettings, } @@ -62,9 +66,19 @@ export function initEntitySettingsRegistry() { apiVersions: ["entity.k8slens.dev/v1alpha1"], kind: "KubernetesCluster", title: "Metrics", + group: "Settings", components: { View: clusterSettings.MetricsSettings, } + }, + { + apiVersions: ["entity.k8slens.dev/v1alpha1"], + kind: "KubernetesCluster", + title: "Node Shell", + group: "Settings", + components: { + View: clusterSettings.NodeShellSettings, + } } ]); } diff --git a/src/renderer/initializers/index.ts b/src/renderer/initializers/index.ts index ed2d8e8575..943fef6a30 100644 --- a/src/renderer/initializers/index.ts +++ b/src/renderer/initializers/index.ts @@ -19,13 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +export * from "./catalog-entity-detail-registry"; +export * from "./catalog"; export * from "./command-registry"; export * from "./entity-settings-registry"; +export * from "./ipc"; export * from "./kube-object-detail-registry"; export * from "./kube-object-menu-registry"; export * from "./registries"; export * from "./welcome-menu-registry"; export * from "./workloads-overview-detail-registry"; -export * from "./catalog"; -export * from "./stores"; -export * from "./ipc"; diff --git a/src/renderer/initializers/stores.ts b/src/renderer/initializers/stores.ts deleted file mode 100644 index 37bef7530a..0000000000 --- a/src/renderer/initializers/stores.ts +++ /dev/null @@ -1,50 +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 { HotbarStore } from "../../common/hotbar-store"; -import { ClusterStore } from "../../common/cluster-store"; -import { UserStore } from "../../common/user-store"; -import { ExtensionsStore } from "../../extensions/extensions-store"; -import { FilesystemProvisionerStore } from "../../main/extension-filesystem"; - -import { ThemeStore } from "../theme.store"; -import { WeblinkStore } from "../../common/weblink-store"; - -export async function initStores() { - const userStore = UserStore.createInstance(); - const clusterStore = ClusterStore.createInstance(); - const extensionsStore = ExtensionsStore.createInstance(); - const filesystemStore = FilesystemProvisionerStore.createInstance(); - const themeStore = ThemeStore.createInstance(); - const hotbarStore = HotbarStore.createInstance(); - const weblinkStore = WeblinkStore.createInstance(); - - // preload common stores - await Promise.all([ - userStore.load(), - hotbarStore.load(), - clusterStore.load(), - extensionsStore.load(), - filesystemStore.load(), - themeStore.init(), - weblinkStore.load() - ]); -} diff --git a/src/renderer/initializers/welcome-menu-registry.ts b/src/renderer/initializers/welcome-menu-registry.ts index 9fce6a8c61..979522b519 100644 --- a/src/renderer/initializers/welcome-menu-registry.ts +++ b/src/renderer/initializers/welcome-menu-registry.ts @@ -27,9 +27,9 @@ export function initWelcomeMenuRegistry() { WelcomeMenuRegistry.getInstance() .add([ { - title: "Browse Clusters", + title: "Browse Clusters in Catalog", icon: "view_list", - click: () => navigate(catalogURL({ params: { group: "entity.k8slens.dev", kind: "KubernetesCluster" } } )) + click: () => navigate(catalogURL({ params: { group: "entity.k8slens.dev", kind: "KubernetesCluster" } } )), } ]); } diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index 51a50f1b01..338f71c38d 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -59,8 +59,8 @@ function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, update (
Update Available -

Version {updateInfo.version} of Lens IDE is now available. Would you like to update?

-

Manual restart required!

+

Version {updateInfo.version} of Lens IDE is available and ready to be installed. Would you like to update now?

+

Lens should restart automatically, if it doesn't please restart manually. Installed extensions might require updating.