1
0
mirror of https://github.com/lensapp/lens.git synced 2025-05-20 05:10:56 +00:00

Merge branch 'master' into dependabot/npm_and_yarn/mock-fs-4.14.0

This commit is contained in:
Alex Andreev 2021-07-16 15:51:15 +03:00 committed by GitHub
commit ef7135728e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
271 changed files with 5393 additions and 3064 deletions

View File

@ -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:

View File

@ -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"],

View 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"]

View 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 }}

View 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")

View File

@ -10,6 +10,7 @@ categories:
- title: '🧰 Maintenance'
labels:
- 'chore'
- 'area/documentation'
- 'area/ci'
- 'area/tests'
- 'dependencies'

View 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'

View File

@ -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'

View File

@ -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

View File

@ -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];

View File

@ -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());
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 925 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 478 B

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View File

@ -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.

View File

@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 712 KiB

View File

@ -60,7 +60,7 @@ Implementing `onDeactivate()` gives you the opportunity to clean up after your e
1. Navigate to **File** > **Extensions** in the top menu bar.
(On Mac, it is **Lens** > **Extensions**.)
2. Click **Disable** on the extension you want to disable.
2. For the extension you want to disable, open the context menu (click on the three vertical dots) and choose **Disable**.
The example above logs messages when the extension is enabled and disabled.
@ -71,7 +71,7 @@ Use cluster pages to display information about or add functionality to the activ
It is also possible to include custom details from other clusters.
Use your extension to access Kubernetes resources in the active cluster with [`ClusterStore.getInstance()`](../stores#Clusterstore).
Add a cluster page definition to a `LensRendererExtension` subclass with the following example:
Add a cluster page definition to a `Renderer.LensExtension` subclass with the following example:
```typescript
import { Renderer } from "@k8slens/extensions";
@ -124,7 +124,7 @@ Use `clusterPageMenus`, covered in the next section, to add cluster pages to the
### `clusterPageMenus`
`clusterPageMenus` allows you to add cluster page menu items to the secondary left nav.
`clusterPageMenus` allows you to add cluster page menu items to the secondary left nav, below Lens's standard cluster menus like **Workloads**, **Custom Resources**, etc.
By expanding on the above example, you can add a cluster page menu item to the `ExampleExtension` definition:
@ -266,18 +266,18 @@ Activating this menu item toggles on and off the appearance of the submenu below
The remaining two cluster page menu objects define the contents of the submenu.
A cluster page menu object is defined to be a submenu item by setting the `parentId` field to the id of the parent of a foldout submenu, `"example"` in this case.
This is what the example will look like, including how the menu item will appear in the secondary left nav:
This is what the example could look like, including how the menu item will appear in the secondary left nav:
![Cluster Page Menus](images/clusterpagemenus.png)
### `globalPages`
Global pages are independent of the cluster dashboard and can fill the entire Lens UI.
Their primary use is to display information and provide functionality across clusters, including customized data and functionality unique to your extension.
Their primary use is to display information and provide functionality across clusters (or catalog entities), including customized data and functionality unique to your extension.
Typically, you would use a [global page menu](#globalpagemenus) located in the left nav to trigger a global page.
You can also trigger a global page with a [custom app menu selection](../main-extension#appmenus) from a Main Extension or a [custom status bar item](#statusbaritems).
Unlike cluster pages, users can trigger global pages even when there is no active cluster.
Unlike cluster pages, users can trigger global pages even when there is no active cluster (or catalog entity).
The following example defines a `LensRendererExtension` subclass with a single global page definition:
The following example defines a `Renderer.LensExtension` subclass with a single global page definition:
```typescript
import { Renderer } from '@k8slens/extensions';
@ -326,11 +326,11 @@ This allows the `HelpExtension` object to be passed in the global page definitio
This way, `HelpPage` can access all `HelpExtension` subclass data.
This example code shows how to create a global page, but not how to make that page available to the Lens user.
Global pages can be made available in the following ways:
Global pages are typically made available in the following ways:
* To add global pages to the top menu bar, see [`appMenus`](../main-extension#appmenus) in the Main Extension guide.
* To add global pages as an interactive element in the blue status bar along the bottom of the Lens UI, see [`statusBarItems`](#statusbaritems).
* To add global pages to the left side menu, see [`globalPageMenus`](#globalpagemenus).
* To add global pages to the Welcome Page, see [`welcomeMenus`](#welcomemenus).
### `welcomeMenus`
### `appPreferences`
@ -381,6 +381,7 @@ In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExampleP
```typescript
import { Renderer } from "@k8slens/extensions";
import { makeObservable } from "mobx";
import { observer } from "mobx-react";
import React from "react";
@ -399,6 +400,11 @@ export class ExamplePreferenceProps {
@observer
export class ExamplePreferenceInput extends React.Component<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.

View File

@ -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"
}
}

View File

@ -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.

View File

@ -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>
</>

View File

@ -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 && (
<>

View File

@ -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 && (
<>

View File

@ -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 && (
<>

View File

@ -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);

View File

@ -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"]`);

View File

@ -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();

View File

@ -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"
}
}

View File

@ -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

View File

@ -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(() => {

View File

@ -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(() => {

View File

@ -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);
});
});
});

View File

@ -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;

View 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());

View File

@ -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";

View File

@ -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);
}
}

View File

@ -149,6 +149,7 @@ export interface CatalogEntityAddMenuContext {
export type CatalogEntitySpec = Record<string, any>;
export interface CatalogEntityData<
Metadata extends CatalogEntityMetadata = CatalogEntityMetadata,
Status extends CatalogEntityStatus = CatalogEntityStatus,

View File

@ -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}`);
}
}

View 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;

View File

@ -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

View File

@ -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
View 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",
});
}

View File

@ -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}`);
}
}

View File

@ -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");
}

View File

@ -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";

View 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,
};

View 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");
}

View 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);
});
});
});

View File

@ -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";

View File

@ -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;
}

View File

@ -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;

View 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);
}

View File

@ -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 ?? "";

View File

@ -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 || [];
}

View 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);
});
});
});

View File

@ -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>;

View 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;
}

View File

@ -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,

View File

@ -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;
}

View File

@ -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();

View File

@ -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

View File

@ -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) {

View File

@ -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"
}
}

View File

@ -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();
});
});
});
});

View File

@ -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);

View File

@ -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";

View File

@ -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", () => {

View File

@ -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();
});

View 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);
}

View File

@ -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";

View File

@ -69,7 +69,7 @@ async function validateLink(link: WebLink) {
}
export function initializeWeblinks() {
export function syncWeblinks() {
const weblinkStore = WeblinkStore.getInstance();
const weblinks = observable.array(defaultLinks);

View File

@ -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);

View File

@ -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",

View File

@ -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 || "";
}
}

View File

@ -42,6 +42,7 @@ export class FilesystemProvisionerStore extends BaseStore<FSProvisionModel> {
accessPropertiesByDotNotation: false, // To make dots safe in cluster context names
});
makeObservable(this);
this.load();
}
/**

View File

@ -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",

View File

@ -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",

View File

@ -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)])
);
}
}

View File

@ -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

View File

@ -22,5 +22,3 @@
export * from "./registries";
export * from "./metrics-providers";
export * from "./ipc";
export * from "./weblinks";
export * from "./stores";

View File

@ -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;
}

View File

@ -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()
]);
}

View File

@ -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");

View File

@ -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 {

View File

@ -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");

View File

@ -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 {

View File

@ -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();
}

View File

@ -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);

View File

@ -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);
}

View File

@ -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:

View File

@ -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,
}]
}
});
}

View File

@ -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);

View File

@ -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;
}

View File

@ -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;

View 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;

View File

@ -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,
);

View File

@ -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);
}

Some files were not shown because too many files have changed in this diff Show More