Merge branch 'master' into dependabot/npm_and_yarn/mock-fs-4.14.0
@ -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:
|
||||
|
||||
@ -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"],
|
||||
|
||||
11
.github/actions/add-card-to-project/Dockerfile
vendored
Normal file
@ -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"]
|
||||
23
.github/actions/add-card-to-project/action.yml
vendored
Normal file
@ -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:<card id>".'
|
||||
required: false
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: 'Dockerfile'
|
||||
args:
|
||||
- ${{ inputs.project }}
|
||||
- ${{ inputs.column_name }}
|
||||
- ${{ inputs.card_position }}
|
||||
157
.github/actions/add-card-to-project/entrypoint.sh
vendored
Normal file
@ -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:?<Error> 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:?<Error> 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")
|
||||
1
.github/release-drafter.yml
vendored
@ -10,6 +10,7 @@ categories:
|
||||
- title: '🧰 Maintenance'
|
||||
labels:
|
||||
- 'chore'
|
||||
- 'area/documentation'
|
||||
- 'area/ci'
|
||||
- 'area/tests'
|
||||
- 'dependencies'
|
||||
|
||||
33
.github/workflows/add-to-project-board.yaml
vendored
Normal file
@ -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'
|
||||
13
.github/workflows/test.yml
vendored
@ -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'
|
||||
|
||||
23
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
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
|
||||
BIN
build/tray/trayIconDarkTemplate.png
Normal file
|
After Width: | Height: | Size: 422 B |
BIN
build/tray/trayIconDarkTemplate@2x.png
Normal file
|
After Width: | Height: | Size: 925 B |
BIN
build/tray/trayIconDarkTemplate@3x.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 478 B After Width: | Height: | Size: 460 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.8 KiB |
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 251 KiB After Width: | Height: | Size: 712 KiB |
@ -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:
|
||||
|
||||

|
||||
|
||||
### `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<ExamplePreferenceProps> {
|
||||
|
||||
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.
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -281,7 +281,9 @@ export class MetricsSettings extends React.Component<Props> {
|
||||
waiting={this.inProgress}
|
||||
onClick={() => this.save()}
|
||||
primary
|
||||
disabled={!this.changed} />
|
||||
disabled={!this.changed}
|
||||
className="w-60 h-14"
|
||||
/>
|
||||
|
||||
{this.canUpgrade && (<small className="hint">
|
||||
An update is available for enabled metrics components.
|
||||
|
||||
@ -86,23 +86,26 @@ export function NodeMenu(props: NodeMenuProps) {
|
||||
return (
|
||||
<>
|
||||
<MenuItem onClick={shell}>
|
||||
<Icon svg="ssh" interactive={toolbar} title="Node shell"/>
|
||||
<Icon svg="ssh" interactive={toolbar} tooltip={toolbar && "Node shell"}/>
|
||||
<span className="title">Shell</span>
|
||||
</MenuItem>
|
||||
{!node.isUnschedulable() && (
|
||||
<MenuItem onClick={cordon}>
|
||||
<Icon material="pause_circle_filled" title="Cordon" interactive={toolbar}/>
|
||||
<span className="title">Cordon</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{node.isUnschedulable() && (
|
||||
<MenuItem onClick={unCordon}>
|
||||
<Icon material="play_circle_filled" title="Uncordon" interactive={toolbar}/>
|
||||
<span className="title">Uncordon</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
{
|
||||
node.isUnschedulable()
|
||||
? (
|
||||
<MenuItem onClick={unCordon}>
|
||||
<Icon material="play_circle_filled" tooltip={toolbar && "Uncordon"} interactive={toolbar} />
|
||||
<span className="title">Uncordon</span>
|
||||
</MenuItem>
|
||||
)
|
||||
: (
|
||||
<MenuItem onClick={cordon}>
|
||||
<Icon material="pause_circle_filled" tooltip={toolbar && "Cordon"} interactive={toolbar} />
|
||||
<span className="title">Cordon</span>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
<MenuItem onClick={drain}>
|
||||
<Icon material="delete_sweep" title="Drain" interactive={toolbar}/>
|
||||
<Icon material="delete_sweep" tooltip={toolbar && "Drain"} interactive={toolbar}/>
|
||||
<span className="title">Drain</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
|
||||
@ -74,7 +74,7 @@ export class PodAttachMenu extends React.Component<PodAttachMenuProps> {
|
||||
|
||||
return (
|
||||
<MenuItem onClick={Util.prevDefault(() => this.attachToPod(containers[0].name))}>
|
||||
<Icon material="pageview" interactive={toolbar} title="Attach to Pod"/>
|
||||
<Icon material="pageview" interactive={toolbar} tooltip={toolbar && "Attach to Pod"}/>
|
||||
<span className="title">Attach Pod</span>
|
||||
{containers.length > 1 && (
|
||||
<>
|
||||
|
||||
@ -62,7 +62,7 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
|
||||
|
||||
return (
|
||||
<MenuItem onClick={Util.prevDefault(() => this.showLogs(containers[0]))}>
|
||||
<Icon material="subject" title="Logs" interactive={toolbar}/>
|
||||
<Icon material="subject" interactive={toolbar} tooltip={toolbar && "Pod Logs"}/>
|
||||
<span className="title">Logs</span>
|
||||
{containers.length > 1 && (
|
||||
<>
|
||||
|
||||
@ -79,7 +79,7 @@ export class PodShellMenu extends React.Component<PodShellMenuProps> {
|
||||
|
||||
return (
|
||||
<MenuItem onClick={Util.prevDefault(() => this.execShell(containers[0].name))}>
|
||||
<Icon svg="ssh" interactive={toolbar} title="Pod shell"/>
|
||||
<Icon svg="ssh" interactive={toolbar} tooltip={toolbar && "Pod Shell"} />
|
||||
<span className="title">Shell</span>
|
||||
{containers.length > 1 && (
|
||||
<>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"]`);
|
||||
|
||||
@ -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<void>): (done: DoneCallback) => void {
|
||||
export function wrapJestLifecycle(fn: () => Promise<void> | void): (done: DoneCallback) => void {
|
||||
return function (done: DoneCallback) {
|
||||
fn()
|
||||
(async () => fn())()
|
||||
.then(() => done())
|
||||
.catch(error => done.fail(error));
|
||||
};
|
||||
}
|
||||
|
||||
export function beforeAllWrapped(fn: () => Promise<void>): void {
|
||||
export function beforeAllWrapped(fn: () => Promise<void> | void): void {
|
||||
beforeAll(wrapJestLifecycle(fn));
|
||||
}
|
||||
|
||||
export function beforeEachWrapped(fn: () => Promise<void>): void {
|
||||
export function beforeEachWrapped(fn: () => Promise<void> | void): void {
|
||||
beforeEach(wrapJestLifecycle(fn));
|
||||
}
|
||||
|
||||
export function afterAllWrapped(fn: () => Promise<void>): void {
|
||||
export function afterAllWrapped(fn: () => Promise<void> | void): void {
|
||||
afterAll(wrapJestLifecycle(fn));
|
||||
}
|
||||
|
||||
export function afterEachWrapped(fn: () => Promise<void>): void {
|
||||
export function afterEachWrapped(fn: () => Promise<void> | 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<number>;
|
||||
|
||||
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();
|
||||
|
||||
36
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)$": "<rootDir>/__mocks__/styleMock.ts",
|
||||
"\\.(svg)$": "<rootDir>/__mocks__/imageMock.ts",
|
||||
"^@lingui/macro$": "<rootDir>/__mocks__/@linguiMacro.ts"
|
||||
"\\.(svg)$": "<rootDir>/__mocks__/imageMock.ts"
|
||||
},
|
||||
"modulePathIgnorePatterns": [
|
||||
"<rootDir>/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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<T = any> extends ConfOptions<T> {
|
||||
autoLoad?: boolean;
|
||||
syncEnabled?: boolean;
|
||||
export interface BaseStoreParams<T> extends ConfOptions<T> {
|
||||
syncOptions?: IReactionOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: T should only contain base JSON serializable types.
|
||||
*/
|
||||
export abstract class BaseStore<T = any> extends Singleton {
|
||||
export abstract class BaseStore<T> extends Singleton {
|
||||
protected storeConfig?: Config<T>;
|
||||
protected syncDisposers: Disposer[] = [];
|
||||
|
||||
@observable isLoaded = false;
|
||||
|
||||
get whenLoaded() {
|
||||
return when(() => this.isLoaded);
|
||||
}
|
||||
|
||||
protected constructor(protected params: BaseStoreParams) {
|
||||
protected constructor(protected params: BaseStoreParams<T>) {
|
||||
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<T>'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<T = any> 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<T = any> 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<T = any> 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;
|
||||
|
||||
76
src/common/catalog-entities/general.ts
Normal file
@ -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<CatalogEntityMetadata, CatalogEntityStatus, GeneralEntitySpec> {
|
||||
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());
|
||||
@ -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";
|
||||
|
||||
@ -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<CatalogEntityMetadata, KubernetesClusterStatus, KubernetesClusterSpec> {
|
||||
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<KubernetesClusterMetadata, KubernetesClusterStatus, KubernetesClusterSpec> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<CatalogEntityMetadata, Kube
|
||||
},
|
||||
confirm: {
|
||||
// TODO: change this to be a <p> 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<KubernetesClusterCategory>(this);
|
||||
|
||||
if (category) category.emit("contextMenuOpen", this, context);
|
||||
catalogCategoryRegistry
|
||||
.getCategoryForEntity<KubernetesClusterCategory>(this)
|
||||
?.emit("contextMenuOpen", this, context);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -149,6 +149,7 @@ export interface CatalogEntityAddMenuContext {
|
||||
|
||||
export type CatalogEntitySpec = Record<string, any>;
|
||||
|
||||
|
||||
export interface CatalogEntityData<
|
||||
Metadata extends CatalogEntityMetadata = CatalogEntityMetadata,
|
||||
Status extends CatalogEntityStatus = CatalogEntityStatus,
|
||||
|
||||
@ -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<ClusterStoreModel> {
|
||||
private static StateChannel = "cluster:state";
|
||||
|
||||
@ -121,8 +132,8 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
||||
return path.resolve(ClusterStore.storedKubeConfigFolder, clusterId);
|
||||
}
|
||||
|
||||
@observable clusters = observable.map<ClusterId, Cluster>();
|
||||
@observable removedClusters = observable.map<ClusterId, Cluster>();
|
||||
clusters = observable.map<ClusterId, Cluster>();
|
||||
removedClusters = observable.map<ClusterId, Cluster>();
|
||||
|
||||
protected disposer = disposer();
|
||||
|
||||
@ -137,31 +148,27 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
||||
});
|
||||
|
||||
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<ClusterStoreModel> {
|
||||
cluster = new Cluster(clusterModel);
|
||||
}
|
||||
newClusters.set(clusterModel.id, cluster);
|
||||
} catch {
|
||||
// ignore
|
||||
} catch (error) {
|
||||
logger.warn(`[CLUSTER-STORE]: Failed to update/create a cluster: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
76
src/renderer/components/layout/login-layout.scss → src/common/getTSLoader.ts
Executable file → Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
export default getTSLoader;
|
||||
@ -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<HotbarCreateOptions>;
|
||||
|
||||
export interface HotbarCreateOptions {
|
||||
id?: string;
|
||||
name: string;
|
||||
items?: HotbarItem[];
|
||||
items?: (HotbarItem | null)[];
|
||||
}
|
||||
|
||||
export interface HotbarStoreModel {
|
||||
@ -71,6 +68,7 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
|
||||
migrations,
|
||||
});
|
||||
makeObservable(this);
|
||||
this.load();
|
||||
}
|
||||
|
||||
get activeHotbarId() {
|
||||
@ -91,16 +89,17 @@ export class HotbarStore extends BaseStore<HotbarStoreModel> {
|
||||
return this.hotbarIndex(this.activeHotbarId);
|
||||
}
|
||||
|
||||
get initialItems() {
|
||||
static getInitialItems() {
|
||||
return [...Array.from(Array(defaultHotbarCells).fill(null))];
|
||||
}
|
||||
|
||||
@action protected async fromStore(data: Partial<HotbarStoreModel> = {}) {
|
||||
if (data.hotbars?.length === 0) {
|
||||
@action
|
||||
protected fromStore(data: Partial<HotbarStoreModel> = {}) {
|
||||
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<HotbarStoreModel> {
|
||||
}
|
||||
}
|
||||
|
||||
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<HotbarStoreModel> {
|
||||
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
|
||||
|
||||
@ -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;
|
||||
|
||||
77
src/common/sentry.ts
Normal file
@ -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",
|
||||
});
|
||||
}
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<UserStoreModel> {
|
||||
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<string, ObservableToggleSet<string>>();
|
||||
|
||||
/**
|
||||
* The set of file/folder paths to be synced
|
||||
*/
|
||||
syncKubeconfigEntries = observable.map<string, KubeconfigSyncValue>([
|
||||
[path.join(os.homedir(), ".kube"), {}]
|
||||
]);
|
||||
|
||||
async load(): Promise<void> {
|
||||
/**
|
||||
* 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<UserStoreModel> = {}) {
|
||||
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");
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
export * from "./user-store";
|
||||
export type { KubeconfigSyncEntry, KubeconfigSyncValue, UserPreferencesModel } from "./preferences-helpers";
|
||||
251
src/common/user-store/preferences-helpers.ts
Normal file
@ -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<T, R = T> {
|
||||
fromStore(val: T | undefined): R;
|
||||
toStore(val: R): T | undefined;
|
||||
}
|
||||
|
||||
const httpsProxy: PreferenceDescription<string | undefined> = {
|
||||
fromStore(val) {
|
||||
return val;
|
||||
},
|
||||
toStore(val) {
|
||||
return val || undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const shell: PreferenceDescription<string | undefined> = {
|
||||
fromStore(val) {
|
||||
return val;
|
||||
},
|
||||
toStore(val) {
|
||||
return val || undefined;
|
||||
},
|
||||
};
|
||||
|
||||
const colorTheme: PreferenceDescription<string> = {
|
||||
fromStore(val) {
|
||||
return val || ThemeStore.defaultTheme;
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val || val === ThemeStore.defaultTheme) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const localeTimezone: PreferenceDescription<string> = {
|
||||
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<boolean> = {
|
||||
fromStore(val) {
|
||||
return val ?? false;
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const allowTelemetry: PreferenceDescription<boolean> = {
|
||||
fromStore(val) {
|
||||
return val ?? true;
|
||||
},
|
||||
toStore(val) {
|
||||
if (val === true) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const allowErrorReporting: PreferenceDescription<boolean> = {
|
||||
fromStore(val) {
|
||||
return val ?? true;
|
||||
},
|
||||
toStore(val) {
|
||||
if (val === true) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const downloadMirror: PreferenceDescription<string> = {
|
||||
fromStore(val) {
|
||||
return val ?? "default";
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val || val === "default") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const downloadKubectlBinaries: PreferenceDescription<boolean> = {
|
||||
fromStore(val) {
|
||||
return val ?? true;
|
||||
},
|
||||
toStore(val) {
|
||||
if (val === true) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const downloadBinariesPath: PreferenceDescription<string | undefined> = {
|
||||
fromStore(val) {
|
||||
return val;
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const kubectlBinariesPath: PreferenceDescription<string | undefined> = {
|
||||
fromStore(val) {
|
||||
return val;
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const openAtLogin: PreferenceDescription<boolean> = {
|
||||
fromStore(val) {
|
||||
return val ?? false;
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const hiddenTableColumns: PreferenceDescription<[string, string[]][], Map<string, ObservableToggleSet<string>>> = {
|
||||
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<KubeconfigSyncEntry[], Map<string, KubeconfigSyncValue>> = {
|
||||
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<field extends keyof typeof DESCRIPTORS> = typeof DESCRIPTORS[field] extends PreferenceDescription<infer T, any> ? T : never;
|
||||
type UserStoreModelType<field extends keyof typeof DESCRIPTORS> = typeof DESCRIPTORS[field] extends PreferenceDescription<any, infer T> ? T : never;
|
||||
|
||||
export type UserStoreFlatModel = {
|
||||
[field in keyof typeof DESCRIPTORS]: UserStoreModelType<field>;
|
||||
};
|
||||
|
||||
export type UserPreferencesModel = {
|
||||
[field in keyof typeof DESCRIPTORS]: PreferencesModelType<field>;
|
||||
};
|
||||
|
||||
export const DESCRIPTORS = {
|
||||
httpsProxy,
|
||||
shell,
|
||||
colorTheme,
|
||||
localeTimezone,
|
||||
allowUntrustedCAs,
|
||||
allowTelemetry,
|
||||
allowErrorReporting,
|
||||
downloadMirror,
|
||||
downloadKubectlBinaries,
|
||||
downloadBinariesPath,
|
||||
kubectlBinariesPath,
|
||||
openAtLogin,
|
||||
hiddenTableColumns,
|
||||
syncKubeconfigEntries,
|
||||
};
|
||||
214
src/common/user-store/user-store.ts
Normal file
@ -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<UserStoreModel> /* 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<string>();
|
||||
@observable newContexts = observable.set<string>();
|
||||
@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<string, ObservableToggleSet<string>>();
|
||||
|
||||
/**
|
||||
* The set of file/folder paths to be synced
|
||||
*/
|
||||
syncKubeconfigEntries = observable.map<string, KubeconfigSyncValue>();
|
||||
|
||||
@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<UserStoreModel> = {}) {
|
||||
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");
|
||||
}
|
||||
130
src/common/utils/__tests__/paths.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -46,12 +46,15 @@ export class Singleton {
|
||||
static createInstance<T, R extends any[]>(this: StaticThis<T, R>, ...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;
|
||||
|
||||
55
src/common/utils/sort-compare.ts
Normal file
@ -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<T>(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);
|
||||
}
|
||||
@ -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 ?? "";
|
||||
|
||||
@ -55,9 +55,11 @@ export class WeblinkStore extends BaseStore<WeblinkStoreModel> {
|
||||
migrations,
|
||||
});
|
||||
makeObservable(this);
|
||||
this.load();
|
||||
}
|
||||
|
||||
@action protected async fromStore(data: Partial<WeblinkStoreModel> = {}) {
|
||||
@action
|
||||
protected fromStore(data: Partial<WeblinkStoreModel> = {}) {
|
||||
this.weblinks = data.weblinks || [];
|
||||
}
|
||||
|
||||
|
||||
138
src/extensions/__tests__/extension-compatibility.test.ts
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<typeof watch>;
|
||||
|
||||
33
src/extensions/extension-compatibility.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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<InstalledExtension | null> {
|
||||
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,
|
||||
|
||||
@ -48,6 +48,13 @@ export class ExtensionLoader extends Singleton {
|
||||
protected extensions = observable.map<LensExtensionId, InstalledExtension>();
|
||||
protected instances = observable.map<LensExtensionId, LensExtension>();
|
||||
|
||||
/**
|
||||
* 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<string>();
|
||||
|
||||
/**
|
||||
* 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<Disposer[]>) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -26,13 +26,13 @@ import type { LensExtension } from "./lens-extension";
|
||||
export abstract class ExtensionStore<T> extends BaseStore<T> {
|
||||
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();
|
||||
|
||||
@ -39,6 +39,7 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
|
||||
configName: "lens-extensions",
|
||||
});
|
||||
makeObservable(this);
|
||||
this.load();
|
||||
}
|
||||
|
||||
@computed
|
||||
@ -50,12 +51,10 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
|
||||
|
||||
protected state = observable.map<LensExtensionId, LensExtensionState>();
|
||||
|
||||
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
|
||||
|
||||
@ -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<CatalogEntity>[] = [];
|
||||
topBarItems: registries.TopBarRegistration[] = [];
|
||||
|
||||
async navigate<P extends object>(pageId?: string, params?: P) {
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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<any>;
|
||||
export interface CatalogEntityDetailsProps<T extends CatalogEntity> {
|
||||
entity: T;
|
||||
}
|
||||
|
||||
export interface CatalogEntityDetailRegistration {
|
||||
export interface CatalogEntityDetailComponents<T extends CatalogEntity> {
|
||||
Details: React.ComponentType<CatalogEntityDetailsProps<T>>;
|
||||
}
|
||||
|
||||
export interface CatalogEntityDetailRegistration<T extends CatalogEntity> {
|
||||
kind: string;
|
||||
apiVersions: string[];
|
||||
components: CatalogEntityDetailComponents;
|
||||
components: CatalogEntityDetailComponents<T>;
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export class CatalogEntityDetailRegistry extends BaseRegistry<CatalogEntityDetailRegistration> {
|
||||
export class CatalogEntityDetailRegistry extends BaseRegistry<CatalogEntityDetailRegistration<CatalogEntity>> {
|
||||
getItemsForKind(kind: string, apiVersion: string) {
|
||||
const items = this.getItems().filter((item) => {
|
||||
return item.kind === kind && item.apiVersions.includes(apiVersion);
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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<typeof waitUntilU
|
||||
describe("kube auth proxy tests", () => {
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
72
src/main/catalog-sources/general.ts
Normal file
@ -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);
|
||||
}
|
||||
@ -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";
|
||||
|
||||
@ -69,7 +69,7 @@ async function validateLink(link: WebLink) {
|
||||
}
|
||||
|
||||
|
||||
export function initializeWeblinks() {
|
||||
export function syncWeblinks() {
|
||||
const weblinkStore = WeblinkStore.getInstance();
|
||||
const weblinks = observable.array(defaultLinks);
|
||||
|
||||
|
||||
@ -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<T extends CatalogEntity>({ apiVersion, kind }: CatalogEntityKindData): T[] {
|
||||
return this.getItemsForApiKind(apiVersion, kind);
|
||||
}
|
||||
}
|
||||
|
||||
export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 || "";
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,6 +42,7 @@ export class FilesystemProvisionerStore extends BaseStore<FSProvisionModel> {
|
||||
accessPropertiesByDotNotation: false, // To make dots safe in cluster context names
|
||||
});
|
||||
makeObservable(this);
|
||||
this.load();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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)])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -22,5 +22,3 @@
|
||||
export * from "./registries";
|
||||
export * from "./metrics-providers";
|
||||
export * from "./ipc";
|
||||
export * from "./weblinks";
|
||||
export * from "./stores";
|
||||
|
||||
@ -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<KubernetesCluster>(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;
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
]);
|
||||
}
|
||||
@ -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<PrometheusService | undefined> {
|
||||
return this.getFirstNamespacedServer(client, "app=prometheus,component=server,heritage=Helm");
|
||||
|
||||
@ -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<PrometheusService | undefined> {
|
||||
try {
|
||||
|
||||
@ -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<PrometheusService | undefined> {
|
||||
return this.getFirstNamespacedServer(client, "operated-prometheus=true", "self-monitor=true");
|
||||
|
||||
@ -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<PrometheusService | undefined> {
|
||||
try {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
}]
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<number> {
|
||||
const logLines: string[] = [];
|
||||
|
||||
return new Promise<number>((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<number
|
||||
};
|
||||
const timeoutID = setTimeout(() => {
|
||||
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);
|
||||
|
||||
|
||||
@ -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<Env
|
||||
shellEnvironment(shell),
|
||||
new Promise((_resolve, reject) => 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<Env
|
||||
} else {
|
||||
logger.error("shellSync(): Resolving shell environment took too long. Please review your shell configuration.");
|
||||
}
|
||||
|
||||
|
||||
return envVars;
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ import path from "path";
|
||||
import { app } from "electron";
|
||||
import fse from "fs-extra";
|
||||
import type { ClusterModel } from "../../common/cluster-store";
|
||||
import { MigrationDeclaration, migrationLog } from "../helpers";
|
||||
import type { MigrationDeclaration } from "../helpers";
|
||||
|
||||
interface Pre500WorkspaceStoreModel {
|
||||
workspaces: {
|
||||
@ -36,7 +36,7 @@ export default {
|
||||
version: "5.0.0-beta.10",
|
||||
run(store) {
|
||||
const userDataPath = app.getPath("userData");
|
||||
|
||||
|
||||
try {
|
||||
const workspaceData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json"));
|
||||
const workspaces = new Map<string, string>(); // 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;
|
||||
|
||||
133
src/migrations/cluster-store/5.0.0-beta.13.ts
Normal file
@ -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<string, string>, right: Record<string, string>): Record<string, string> {
|
||||
return {
|
||||
...right,
|
||||
...left
|
||||
};
|
||||
}
|
||||
|
||||
function mergeSet(...iterables: Iterable<string>[]): string[] {
|
||||
const res = new Set<string>();
|
||||
|
||||
for (const iterable of iterables) {
|
||||
for (const val of iterable) {
|
||||
res.add(val);
|
||||
}
|
||||
}
|
||||
|
||||
return [...res];
|
||||
}
|
||||
|
||||
function mergeClusterModel(prev: ClusterModel, right: Omit<ClusterModel, "id">): 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<string, ClusterModel>();
|
||||
|
||||
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;
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -44,8 +44,10 @@ export function joinMigrations(...declarations: MigrationDeclaration[]): Migrati
|
||||
|
||||
return Object.fromEntries(
|
||||
iter.map(
|
||||
migrations,
|
||||
migrations,
|
||||
([v, fns]) => [v, (store: Conf<any>) => {
|
||||
migrationLog(`Running ${v} migration for ${store.path}`);
|
||||
|
||||
for (const fn of fns) {
|
||||
fn(store);
|
||||
}
|
||||
|
||||