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

Merge branch 'master' into preferences-scrollspy

This commit is contained in:
Alex Andreev 2021-03-11 15:34:00 +03:00
commit 7d9317bdae
97 changed files with 1997 additions and 453 deletions

22
.github/workflows/maintenance.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: "Maintenance"
on:
# So that PRs touching the same files as the push are updated
push:
# So that the `dirtyLabel` is removed if conflicts are resolve
# We recommend `pull_request_target` so that github secrets are available.
# In `pull_request` we wouldn't be able to change labels of fork PRs
pull_request_target:
types: [synchronize]
jobs:
main:
runs-on: ubuntu-latest
steps:
- name: check if prs are dirty
uses: eps1lon/actions-label-merge-conflict@releases/2.x
with:
dirtyLabel: "PR: needs rebase"
removeOnDirtyLabel: "PR: ready to ship"
repoToken: "${{ secrets.GITHUB_TOKEN }}"
commentOnDirty: "This pull request has conflicts, please resolve those before we can evaluate the pull request."
commentOnClean: "Conflicts have been resolved. A maintainer will review the pull request shortly."

1
.gitignore vendored
View File

@ -17,4 +17,3 @@ types/extension-renderer-api.d.ts
extensions/*/dist extensions/*/dist
docs/extensions/api docs/extensions/api
site/ site/
.vscode/

57
.vscode/launch.json vendored Normal file
View File

@ -0,0 +1,57 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceFolder}",
"protocol": "inspector",
"preLaunchTask": "compile-dev",
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron",
"windows": {
"runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd"
},
"runtimeArgs": [
"--remote-debugging-port=9223",
"--inspect",
"."
],
"outputCapture": "std"
},
{
"name": "Renderer Process",
"type": "pwa-chrome",
"request": "attach",
"port": 9223,
"webRoot": "${workspaceFolder}",
"timeout": 30000
},
{
"name": "Integration Tests",
"type": "node",
"request": "launch",
"console": "integratedTerminal",
"runtimeArgs": [
"${workspaceFolder}/node_modules/.bin/jest",
"--runInBand",
"integration"
],
},
{
"name": "Unit Tests",
"type": "node",
"request": "launch",
"internalConsoleOptions": "openOnSessionStart",
"program": "${workspaceFolder}/node_modules/jest/bin/jest.js",
"args": [
"--env=jsdom",
"-i",
"src"
]
}
],
}

18
.vscode/tasks.json vendored Normal file
View File

@ -0,0 +1,18 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"type": "shell",
"group": "build",
"command": "yarn",
"args": [
"debug-build"
],
"problemMatcher": [],
"label": "compile-dev",
"detail": "Compiles main and extension types"
}
]
}

View File

@ -1,12 +1,12 @@
Copyright (c) 2020 Mirantis, Inc. Copyright (c) 2021 Mirantis, Inc.
Portions of this software are licensed as follows: Portions of this software are licensed as follows:
* All content residing under the "docs/" directory of this repository, if that * All content residing under the "docs/" directory of this repository, if that
directory exists, is licensed under "Creative Commons: CC BY-SA 4.0 license". directory exists, is licensed under "Creative Commons: CC BY-SA 4.0 license".
* All third party components incorporated into the Lens Software are licensed * All third party components incorporated into the Lens Software are licensed
under the original license provided by the owner of the applicable component. under the original license provided by the owner of the applicable component.
* Content outside of the above mentioned directories or restrictions above is * Content outside of the above mentioned directories or restrictions above is
available under the "MIT" license as defined below. available under the "MIT" license as defined below.
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy

View File

@ -9,7 +9,7 @@ else
DETECTED_OS := $(shell uname) DETECTED_OS := $(shell uname)
endif endif
binaries/client: binaries/client: node_modules
yarn download-bins yarn download-bins
node_modules: yarn.lock node_modules: yarn.lock
@ -37,17 +37,24 @@ test: binaries/client
yarn test yarn test
.PHONY: integration-linux .PHONY: integration-linux
integration-linux: build-extension-types build-extensions integration-linux: binaries/client build-extension-types build-extensions
# ifdef XDF_CONFIG_HOME
# rm -rf ${XDG_CONFIG_HOME}/.config/Lens
# else
# rm -rf ${HOME}/.config/Lens
# endif
yarn build:linux yarn build:linux
yarn integration yarn integration
.PHONY: integration-mac .PHONY: integration-mac
integration-mac: build-extension-types build-extensions integration-mac: binaries/client build-extension-types build-extensions
# rm ${HOME}/Library/Application\ Support/Lens
yarn build:mac yarn build:mac
yarn integration yarn integration
.PHONY: integration-win .PHONY: integration-win
integration-win: build-extension-types build-extensions integration-win: binaries/client build-extension-types build-extensions
# rm %APPDATA%/Lens
yarn build:win yarn build:win
yarn integration yarn integration

View File

@ -21,12 +21,13 @@ Each guide or code sample includes the following:
| [Components](components.md) | | | [Components](components.md) | |
| [KubeObjectListLayout](kube-object-list-layout.md) | | | [KubeObjectListLayout](kube-object-list-layout.md) | |
| [Working with mobx](working-with-mobx.md) | | | [Working with mobx](working-with-mobx.md) | |
| [Protocol Handlers](protocol-handlers.md) | |
## Samples ## Samples
| Sample | APIs | | Sample | APIs |
| ----- | ----- | | ----- | ----- |
[helloworld](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |
[minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension <br> Store.clusterStore <br> Store.workspaceStore | [minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension <br> Store.clusterStore <br> Store.workspaceStore |
[styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |
[styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,83 @@
# Lens Protocol Handlers
Lens has a file association with the `lens://` protocol.
This means that Lens can be opened by external programs by providing a link that has `lens` as its protocol.
Lens provides a routing mechanism that extensions can use to register custom handlers.
## Registering A Protocol Handler
The field `protocolHandlers` exists both on [`LensMainExtension`](extensions/api/classes/lensmainextension/#protocolhandlers) and on [`LensRendererExtension`](extensions/api/classes/lensrendererextension/#protocolhandlers).
This field will be iterated through every time a `lens://` request gets sent to the application.
The `pathSchema` argument must comply with the [path-to-regexp](https://www.npmjs.com/package/path-to-regexp) package's `compileToRegex` function.
Once you have registered a handler it will be called when a user opens a link on their computer.
Handlers will be run in both `main` and `renderer` in parallel with no synchronization between the two processes.
Furthermore, both `main` and `renderer` are routed separately.
In other words, which handler is selected in either process is independent from the list of possible handlers in the other.
Example of registering a handler:
```typescript
import { LensMainExtension, Interface } from "@k8slens/extensions";
function rootHandler(params: Iterface.ProtocolRouteParams) {
console.log("routed to ExampleExtension", params);
}
export default class ExampleExtensionMain extends LensMainExtension {
protocolHandlers = [
pathSchema: "/",
handler: rootHandler,
]
}
```
For testing the routing of URIs the `open` (on macOS) or `xdg-open` (on most linux) CLI utilities can be used.
For the above handler, the following URI would be always routed to it:
```
open lens://extension/example-extension/
```
## Deregistering A Protocol Handler
All that is needed to deregister a handler is to remove it from the array of handlers.
## Routing Algorithm
The routing mechanism for extensions is quite straight forward.
For example consider an extension `example-extension` which is published by the `@mirantis` org.
If it were to register a handler with `"/display/:type"` as its corresponding link then we would match the following URI like this:
![Lens Protocol Link Resolution](images/routing-diag.png)
Once matched, the handler would be called with the following argument (note both `"search"` and `"pathname"` will always be defined):
```json
{
"search": {
"text": "Hello"
},
"pathname": {
"type": "notification"
}
}
```
As the diagram above shows, the search (or query) params are not considered as part of the handler resolution.
If the URI had instead been `lens://extension/@mirantis/example-extension/display/notification/green` then a third (and optional) field will have the rest of the path.
The `tail` field would be filled with `"/green"`.
If multiple `pathSchema`'s match a given URI then the most specific handler will be called.
For example consider the following `pathSchema`'s:
1. `"/"`
1. `"/display"`
1. `"/display/:type"`
1. `"/show/:id"`
The URI sub-path `"/display"` would be routed to #2 since it is an exact match.
On the other hand, the subpath `"/display/notification"` would be routed to #3.
The URI is routed to the most specific matching `pathSchema`.
This way the `"/"` (root) `pathSchema` acts as a sort of catch all or default route if no other route matches.

View File

@ -28,6 +28,28 @@ Review the [System Requirements](../supporting/requirements.md) to check if your
See the [Download Lens](https://github.com/lensapp/lens/releases) page for a complete list of available installation options. See the [Download Lens](https://github.com/lensapp/lens/releases) page for a complete list of available installation options.
After installing Lens manually (not using a package manager file such as `.deb` or `.rpm`) the following will need to be done to allow protocol handling.
This assumes that your linux distribution uses `xdg-open` and the `xdg-*` suite of programs for determining which application can handle custom URIs.
1. Create a file called `lens.desktop` in either `~/.local/share/applications/` or `/usr/share/applications` (if you have permissions and are installing Lens for all users).
1. That file should have the following contents, with `<path/to/executable>` being the absolute path to where you have installed the unpacked `Lens` executable:
```
[Desktop Entry]
Name=Lens
Exec=<path/to/executable> %U
Terminal=false
Type=Application
Icon=lens
StartupWMClass=Lens
Comment=Lens - The Kubernetes IDE
MimeType=x-scheme-handler/lens;
Categories=Network;
```
1. Then run the following command:
```
xdg-settings set default-url-scheme-handler lens lens.desktop
```
1. If that succeeds (exits with code `0`) then your Lens install should be set up to handle `lens://` URIs.
### Snap ### Snap
@ -52,4 +74,3 @@ To stay current with the Lens features, you can review the [release notes](https
- [Add clusters](../clusters/adding-clusters.md) - [Add clusters](../clusters/adding-clusters.md)
- [Watch introductory videos](./introductory-videos.md) - [Watch introductory videos](./introductory-videos.md)

View File

@ -25,6 +25,7 @@ describe("Lens cluster pages", () => {
let clusterAdded = false; let clusterAdded = false;
const addCluster = async () => { const addCluster = async () => {
await utils.clickWhatsNew(app); await utils.clickWhatsNew(app);
await utils.clickWelcomeNotification(app);
await addMinikubeCluster(app); await addMinikubeCluster(app);
await waitForMinikubeDashboard(app); await waitForMinikubeDashboard(app);
await app.client.click('a[href="/nodes"]'); await app.client.click('a[href="/nodes"]');
@ -345,7 +346,7 @@ describe("Lens cluster pages", () => {
} }
}); });
it(`shows a logs for a pod`, async () => { it(`shows a log for a pod`, async () => {
expect(clusterAdded).toBe(true); expect(clusterAdded).toBe(true);
// Go to Pods page // Go to Pods page
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text"); await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text");

View File

@ -39,7 +39,7 @@ export function minikubeReady(testNamespace: string): boolean {
} }
export async function addMinikubeCluster(app: Application) { export async function addMinikubeCluster(app: Application) {
await app.client.click("div.add-cluster"); await app.client.click("button.add-button");
await app.client.waitUntilTextExists("div", "Select kubeconfig file"); await app.client.waitUntilTextExists("div", "Select kubeconfig file");
await app.client.click("div.Select__control"); // show the context drop-down list await app.client.click("div.Select__control"); // show the context drop-down list
await app.client.waitUntilTextExists("div", "minikube"); await app.client.waitUntilTextExists("div", "minikube");

View File

@ -47,7 +47,17 @@ export async function appStart() {
export async function clickWhatsNew(app: Application) { export async function clickWhatsNew(app: Application) {
await app.client.waitUntilTextExists("h1", "What's new?"); await app.client.waitUntilTextExists("h1", "What's new?");
await app.client.click("button.primary"); await app.client.click("button.primary");
await app.client.waitUntilTextExists("h1", "Welcome"); await app.client.waitUntilTextExists("h2", "default");
}
export async function clickWelcomeNotification(app: Application) {
const itemsText = await app.client.$("div.info-panel").getText();
if (itemsText === "0 item") {
// welcome notification should be present, dismiss it
await app.client.waitUntilTextExists("div.message", "Welcome!");
await app.client.click("i.Icon.close");
}
} }
type AsyncPidGetter = () => Promise<number>; type AsyncPidGetter = () => Promise<number>;

View File

@ -5,7 +5,7 @@ site_url: https://docs.k8slens.dev
docs_dir: docs/ docs_dir: docs/
repo_name: GitHub repo_name: GitHub
repo_url: https://github.com/lensapp/lens repo_url: https://github.com/lensapp/lens
copyright: Copyright &copy; 2020 <a href="https://mirantis.com/">Mirantis Inc.</a> - All rights reserved. copyright: Copyright &copy; 2021 <a href="https://mirantis.com/">Mirantis Inc.</a> - All rights reserved.
edit_uri: "" edit_uri: ""
nav: nav:
- Overview: README.md - Overview: README.md

View File

@ -2,9 +2,9 @@
"name": "kontena-lens", "name": "kontena-lens",
"productName": "Lens", "productName": "Lens",
"description": "Lens - The Kubernetes IDE", "description": "Lens - The Kubernetes IDE",
"version": "4.2.0-alpha.0", "version": "4.2.0-alpha.1",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2020, Mirantis, Inc.", "copyright": "© 2021, Mirantis, Inc.",
"license": "MIT", "license": "MIT",
"author": { "author": {
"name": "Mirantis, Inc.", "name": "Mirantis, Inc.",
@ -13,7 +13,8 @@
"scripts": { "scripts": {
"dev": "concurrently -k \"yarn run dev-run -C\" yarn:dev:*", "dev": "concurrently -k \"yarn run dev-run -C\" yarn:dev:*",
"dev-build": "concurrently yarn:compile:*", "dev-build": "concurrently yarn:compile:*",
"dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\"", "debug-build": "concurrently yarn:compile:main yarn:compile:extension-types",
"dev-run": "nodemon --watch static/build/main.js --exec \"electron --remote-debugging-port=9223 --inspect .\"",
"dev:main": "yarn run compile:main --watch", "dev:main": "yarn run compile:main --watch",
"dev:renderer": "yarn run webpack-dev-server --config webpack.renderer.ts", "dev:renderer": "yarn run webpack-dev-server --config webpack.renderer.ts",
"dev:extension-types": "yarn run compile:extension-types --watch --progress", "dev:extension-types": "yarn run compile:extension-types --watch --progress",
@ -25,7 +26,7 @@
"build:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens", "build:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens",
"build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens", "build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens",
"build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens", "build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens",
"test": "jest --env=jsdom src $@", "test": "scripts/test.sh",
"integration": "jest --runInBand integration", "integration": "jest --runInBand integration",
"dist": "yarn run compile && electron-builder --publish onTag", "dist": "yarn run compile && electron-builder --publish onTag",
"dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32", "dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32",
@ -170,7 +171,14 @@
"repo": "lens", "repo": "lens",
"owner": "lensapp" "owner": "lensapp"
} }
] ],
"protocols": {
"name": "Lens Protocol Handler",
"schemes": [
"lens"
],
"role": "Viewer"
}
}, },
"lens": { "lens": {
"extensions": [ "extensions": [
@ -187,6 +195,7 @@
"@hapi/call": "^8.0.0", "@hapi/call": "^8.0.0",
"@hapi/subtext": "^7.0.3", "@hapi/subtext": "^7.0.3",
"@kubernetes/client-node": "^0.12.0", "@kubernetes/client-node": "^0.12.0",
"abort-controller": "^3.0.0",
"array-move": "^3.0.0", "array-move": "^3.0.0",
"await-lock": "^2.1.0", "await-lock": "^2.1.0",
"byline": "^5.0.0", "byline": "^5.0.0",
@ -213,6 +222,7 @@
"mobx-observable-history": "^1.0.3", "mobx-observable-history": "^1.0.3",
"mobx-react": "^6.2.2", "mobx-react": "^6.2.2",
"mock-fs": "^4.12.0", "mock-fs": "^4.12.0",
"moment": "^2.26.0",
"node-pty": "^0.9.0", "node-pty": "^0.9.0",
"npm": "^6.14.8", "npm": "^6.14.8",
"openid-client": "^3.15.2", "openid-client": "^3.15.2",
@ -232,6 +242,7 @@
"tar": "^6.0.5", "tar": "^6.0.5",
"tcp-port-used": "^1.0.1", "tcp-port-used": "^1.0.1",
"tempy": "^0.5.0", "tempy": "^0.5.0",
"url-parse": "^1.4.7",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"win-ca": "^3.2.0", "win-ca": "^3.2.0",
"winston": "^3.2.1", "winston": "^3.2.1",
@ -290,6 +301,7 @@
"@types/tempy": "^0.3.0", "@types/tempy": "^0.3.0",
"@types/terser-webpack-plugin": "^3.0.0", "@types/terser-webpack-plugin": "^3.0.0",
"@types/universal-analytics": "^0.4.4", "@types/universal-analytics": "^0.4.4",
"@types/url-parse": "^1.4.3",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@types/webdriverio": "^4.13.0", "@types/webdriverio": "^4.13.0",
"@types/webpack": "^4.41.17", "@types/webpack": "^4.41.17",
@ -327,10 +339,10 @@
"jest-mock-extended": "^1.0.10", "jest-mock-extended": "^1.0.10",
"make-plural": "^6.2.2", "make-plural": "^6.2.2",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"moment": "^2.26.0",
"node-loader": "^0.6.0", "node-loader": "^0.6.0",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"nodemon": "^2.0.4", "nodemon": "^2.0.4",
"open": "^7.3.1",
"patch-package": "^6.2.2", "patch-package": "^6.2.2",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"prettier": "^2.2.0", "prettier": "^2.2.0",

1
scripts/test.sh Executable file
View File

@ -0,0 +1 @@
jest --env=jsdom ${1:-src}

View File

@ -6,6 +6,29 @@ import { ClusterStore, getClusterIdFromHost } from "../cluster-store";
import { workspaceStore } from "../workspace-store"; import { workspaceStore } from "../workspace-store";
const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png"); const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png");
const kubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test
contexts:
- context:
cluster: test
user: test
name: foo
- context:
cluster: test
user: test
name: foo2
current-context: test
kind: Config
preferences: {}
users:
- name: test
user:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
jest.mock("electron", () => { jest.mock("electron", () => {
return { return {
@ -47,13 +70,13 @@ describe("empty config", () => {
clusterStore.addCluster( clusterStore.addCluster(
new Cluster({ new Cluster({
id: "foo", id: "foo",
contextName: "minikube", contextName: "foo",
preferences: { preferences: {
terminalCWD: "/tmp", terminalCWD: "/tmp",
icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
clusterName: "minikube" clusterName: "minikube"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", "fancy foo config"), kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig),
workspace: workspaceStore.currentWorkspaceId workspace: workspaceStore.currentWorkspaceId
}) })
); );
@ -91,20 +114,20 @@ describe("empty config", () => {
clusterStore.addClusters( clusterStore.addClusters(
new Cluster({ new Cluster({
id: "prod", id: "prod",
contextName: "prod", contextName: "foo",
preferences: { preferences: {
clusterName: "prod" clusterName: "prod"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", "fancy config"), kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig),
workspace: "workstation" workspace: "workstation"
}), }),
new Cluster({ new Cluster({
id: "dev", id: "dev",
contextName: "dev", contextName: "foo2",
preferences: { preferences: {
clusterName: "dev" clusterName: "dev"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", "fancy config"), kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig),
workspace: "workstation" workspace: "workstation"
}) })
); );
@ -177,20 +200,20 @@ describe("config with existing clusters", () => {
clusters: [ clusters: [
{ {
id: "cluster1", id: "cluster1",
kubeConfig: "foo", kubeConfigPath: kubeconfig,
contextName: "foo", contextName: "foo",
preferences: { terminalCWD: "/foo" }, preferences: { terminalCWD: "/foo" },
workspace: "default" workspace: "default"
}, },
{ {
id: "cluster2", id: "cluster2",
kubeConfig: "foo2", kubeConfigPath: kubeconfig,
contextName: "foo2", contextName: "foo2",
preferences: { terminalCWD: "/foo2" } preferences: { terminalCWD: "/foo2" }
}, },
{ {
id: "cluster3", id: "cluster3",
kubeConfig: "foo", kubeConfigPath: kubeconfig,
contextName: "foo", contextName: "foo",
preferences: { terminalCWD: "/foo" }, preferences: { terminalCWD: "/foo" },
workspace: "foo", workspace: "foo",
@ -247,6 +270,78 @@ describe("config with existing clusters", () => {
}); });
}); });
describe("config with invalid cluster kubeconfig", () => {
beforeEach(() => {
const invalidKubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test2
contexts:
- context:
cluster: test
user: test
name: test
current-context: test
kind: Config
preferences: {}
users:
- name: test
user:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
ClusterStore.resetInstance();
const mockOpts = {
"tmp": {
"lens-cluster-store.json": JSON.stringify({
__internal__: {
migrations: {
version: "99.99.99"
}
},
clusters: [
{
id: "cluster1",
kubeConfigPath: invalidKubeconfig,
contextName: "test",
preferences: { terminalCWD: "/foo" },
workspace: "foo",
},
{
id: "cluster2",
kubeConfigPath: kubeconfig,
contextName: "foo",
preferences: { terminalCWD: "/foo" },
workspace: "default"
},
]
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
afterEach(() => {
mockFs.restore();
});
it("does not enable clusters with invalid kubeconfig", () => {
const storedClusters = clusterStore.clustersList;
expect(storedClusters.length).toBe(2);
expect(storedClusters[0].enabled).toBeFalsy;
expect(storedClusters[1].id).toBe("cluster2");
expect(storedClusters[1].enabled).toBeTruthy;
});
});
describe("pre 2.0 config with an existing cluster", () => { describe("pre 2.0 config with an existing cluster", () => {
beforeEach(() => { beforeEach(() => {
ClusterStore.resetInstance(); ClusterStore.resetInstance();

View File

@ -0,0 +1,101 @@
import { KubeConfig } from "@kubernetes/client-node";
import { validateKubeConfig } from "../kube-helpers";
const kubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test
contexts:
- context:
cluster: test
user: test
name: valid
- context:
cluster: test2
user: test
name: invalidCluster
- context:
cluster: test
user: test2
name: invalidUser
- context:
cluster: test
user: invalidExec
name: invalidExec
current-context: test
kind: Config
preferences: {}
users:
- name: test
user:
exec:
command: echo
- name: invalidExec
user:
exec:
command: foo
`;
const kc = new KubeConfig();
describe("validateKubeconfig", () => {
beforeAll(() => {
kc.loadFromString(kubeconfig);
});
describe("with default validation options", () => {
describe("with valid kubeconfig", () => {
it("does not raise exceptions", () => {
expect(() => { validateKubeConfig(kc, "valid");}).not.toThrow();
});
});
describe("with invalid context object", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalid");}).toThrow("No valid context object provided in kubeconfig for context 'invalid'");
});
});
describe("with invalid cluster object", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalidCluster");}).toThrow("No valid cluster object provided in kubeconfig for context 'invalidCluster'");
});
});
describe("with invalid user object", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalidUser");}).toThrow("No valid user object provided in kubeconfig for context 'invalidUser'");
});
});
describe("with invalid exec command", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalidExec");}).toThrow("User Exec command \"foo\" not found on host. Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig");
});
});
});
describe("with validateCluster as false", () => {
describe("with invalid cluster object", () => {
it("does not raise exception", () => {
expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).not.toThrow();
});
});
});
describe("with validateUser as false", () => {
describe("with invalid user object", () => {
it("does not raise excpetions", () => {
expect(() => { validateKubeConfig(kc, "invalidUser", { validateUser: false });}).not.toThrow();
});
});
});
describe("with validateExec as false", () => {
describe("with invalid exec object", () => {
it("does not raise excpetions", () => {
expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow();
});
});
});
});

View File

@ -323,7 +323,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} else { } else {
cluster = new Cluster(clusterModel); cluster = new Cluster(clusterModel);
if (!cluster.isManaged) { if (!cluster.isManaged && cluster.apiUrl) {
cluster.enabled = true; cluster.enabled = true;
} }
} }
@ -337,7 +337,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} }
}); });
this.activeCluster = newClusters.has(activeCluster) ? activeCluster : null; this.activeCluster = newClusters.get(activeCluster)?.enabled ? activeCluster : null;
this.clusters.replace(newClusters); this.clusters.replace(newClusters);
this.removedClusters.replace(removedClusters); this.removedClusters.replace(removedClusters);
} }

View File

@ -1,3 +1,4 @@
export * from "./ipc"; export * from "./ipc";
export * from "./invalid-kubeconfig";
export * from "./update-available"; export * from "./update-available";
export * from "./type-enforced-ipc"; export * from "./type-enforced-ipc";

View File

@ -0,0 +1,3 @@
export const InvalidKubeconfigChannel = "invalid-kubeconfig";
export type InvalidKubeConfigArgs = [clusterId: string];

View File

@ -47,7 +47,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) {
view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args); view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args);
} }
} catch (error) { } catch (error) {
logger.error("[IPC]: failed to send IPC message", { error }); logger.error("[IPC]: failed to send IPC message", { error: String(error) });
} }
} }
} }

View File

@ -7,6 +7,12 @@ import logger from "../main/logger";
import commandExists from "command-exists"; import commandExists from "command-exists";
import { ExecValidationNotFoundError } from "./custom-errors"; import { ExecValidationNotFoundError } from "./custom-errors";
export type KubeConfigValidationOpts = {
validateCluster?: boolean;
validateUser?: boolean;
validateExec?: boolean;
};
export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config"); export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config");
function resolveTilde(filePath: string) { function resolveTilde(filePath: string) {
@ -151,27 +157,42 @@ export function getNodeWarningConditions(node: V1Node) {
} }
/** /**
* Validates kubeconfig supplied in the add clusters screen. At present this will just validate * Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required)
* the User struct, specifically the command passed to the exec substructure. */
*/ export function validateKubeConfig (config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}) {
export function validateKubeConfig (config: KubeConfig) {
// we only receive a single context, cluster & user object here so lets validate them as this // we only receive a single context, cluster & user object here so lets validate them as this
// will be called when we add a new cluster to Lens // will be called when we add a new cluster to Lens
logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`);
const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts;
const contextObject = config.getContextObject(contextName);
// Validate the Context Object
if (!contextObject) {
throw new Error(`No valid context object provided in kubeconfig for context '${contextName}'`);
}
// Validate the Cluster Object
if (validateCluster && !config.getCluster(contextObject.cluster)) {
throw new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`);
}
const user = config.getUser(contextObject.user);
// Validate the User Object // Validate the User Object
const user = config.getCurrentUser(); if (validateUser && !user) {
throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
if (user.exec) { }
// Validate exec command if present
if (validateExec && user?.exec) {
const execCommand = user.exec["command"]; const execCommand = user.exec["command"];
// check if the command is absolute or not // check if the command is absolute or not
const isAbsolute = path.isAbsolute(execCommand); const isAbsolute = path.isAbsolute(execCommand);
// validate the exec struct in the user object, start with the command field // validate the exec struct in the user object, start with the command field
logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`);
if (!commandExists.sync(execCommand)) { if (!commandExists.sync(execCommand)) {
logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${config.currentContext} not found`); logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${contextName} not found`);
throw new ExecValidationNotFoundError(execCommand, isAbsolute); throw new ExecValidationNotFoundError(execCommand, isAbsolute);
} }
} }

View File

@ -0,0 +1,36 @@
import Url from "url-parse";
export enum RoutingErrorType {
INVALID_PROTOCOL = "invalid-protocol",
INVALID_HOST = "invalid-host",
INVALID_PATHNAME = "invalid-pathname",
NO_HANDLER = "no-handler",
NO_EXTENSION_ID = "no-ext-id",
MISSING_EXTENSION = "missing-ext",
}
export class RoutingError extends Error {
/**
* Will be set if the routing error originated in an extension route table
*/
public extensionName?: string;
constructor(public type: RoutingErrorType, public url: Url) {
super("routing error");
}
toString(): string {
switch (this.type) {
case RoutingErrorType.INVALID_HOST:
return "invalid host";
case RoutingErrorType.INVALID_PROTOCOL:
return "invalid protocol";
case RoutingErrorType.INVALID_PATHNAME:
return "invalid pathname";
case RoutingErrorType.NO_EXTENSION_ID:
return "no extension ID";
case RoutingErrorType.MISSING_EXTENSION:
return "extension not found";
}
}
}

View File

@ -0,0 +1,2 @@
export * from "./error";
export * from "./router";

View File

@ -0,0 +1,218 @@
import { match, matchPath } from "react-router";
import { countBy } from "lodash";
import { Singleton } from "../utils";
import { pathToRegexp } from "path-to-regexp";
import logger from "../../main/logger";
import Url from "url-parse";
import { RoutingError, RoutingErrorType } from "./error";
import { extensionsStore } from "../../extensions/extensions-store";
import { extensionLoader } from "../../extensions/extension-loader";
import { LensExtension } from "../../extensions/lens-extension";
import { RouteHandler, RouteParams } from "../../extensions/registries/protocol-handler-registry";
// IPC channel for protocol actions. Main broadcasts the open-url events to this channel.
export const ProtocolHandlerIpcPrefix = "protocol-handler";
export const ProtocolHandlerInternal = `${ProtocolHandlerIpcPrefix}:internal`;
export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`;
/**
* These two names are long and cumbersome by design so as to decrease the chances
* of an extension using the same names.
*
* Though under the current (2021/01/18) implementation, these are never matched
* against in the final matching so their names are less of a concern.
*/
const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
export abstract class LensProtocolRouter extends Singleton {
// Map between path schemas and the handlers
protected internalRoutes = new Map<string, RouteHandler>();
public static readonly LoggingPrefix = "[PROTOCOL ROUTER]";
protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
/**
*
* @param url the parsed URL that initiated the `lens://` protocol
*/
protected _routeToInternal(url: Url): void {
this._route(Array.from(this.internalRoutes.entries()), url);
}
/**
* match against all matched URIs, returning either the first exact match or
* the most specific match if none are exact.
* @param routes the array of path schemas, handler pairs to match against
* @param url the url (in its current state)
*/
protected _findMatchingRoute(routes: [string, RouteHandler][], url: Url): null | [match<Record<string, string>>, RouteHandler] {
const matches: [match<Record<string, string>>, RouteHandler][] = [];
for (const [schema, handler] of routes) {
const match = matchPath(url.pathname, { path: schema });
if (!match) {
continue;
}
// prefer an exact match
if (match.isExact) {
return [match, handler];
}
matches.push([match, handler]);
}
// if no exact match pick the one that is the most specific
return matches.sort(([a], [b]) => compareMatches(a, b))[0] ?? null;
}
/**
* find the most specific matching handler and call it
* @param routes the array of (path schemas, handler) paris to match against
* @param url the url (in its current state)
*/
protected _route(routes: [string, RouteHandler][], url: Url, extensionName?: string): void {
const route = this._findMatchingRoute(routes, url);
if (!route) {
const data: Record<string, string> = { url: url.toString() };
if (extensionName) {
data.extensionName = extensionName;
}
return void logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data);
}
const [match, handler] = route;
const params: RouteParams = {
pathname: match.params,
search: url.query,
};
if (!match.isExact) {
params.tail = url.pathname.slice(match.url.length);
}
handler(params);
}
/**
* Tries to find the matching LensExtension instance
*
* Note: this needs to be async so that `main`'s overloaded version can also be async
* @param url the protocol request URI that was "open"-ed
* @returns either the found name or the instance of `LensExtension`
*/
protected async _findMatchingExtensionByName(url: Url): Promise<LensExtension | string> {
interface ExtensionUrlMatch {
[EXTENSION_PUBLISHER_MATCH]: string;
[EXTENSION_NAME_MATCH]: string;
}
const match = matchPath<ExtensionUrlMatch>(url.pathname, LensProtocolRouter.ExtensionUrlSchema);
if (!match) {
throw new RoutingError(RoutingErrorType.NO_EXTENSION_ID, url);
}
const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params;
const name = [publisher, partialName].filter(Boolean).join("/");
const extension = extensionLoader.userExtensionsByName.get(name);
if (!extension) {
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed`);
return name;
}
if (!extensionsStore.isEnabled(extension.id)) {
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`);
return name;
}
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched`);
return extension;
}
/**
* Find a matching extension by the first one or two path segments of `url` and then try to `_route`
* its correspondingly registered handlers.
*
* If no handlers are found or the extension is not enabled then `_missingHandlers` is called before
* checking if more handlers have been added.
*
* Note: this function modifies its argument, do not reuse
* @param url the protocol request URI that was "open"-ed
*/
protected async _routeToExtension(url: Url): Promise<void> {
const extension = await this._findMatchingExtensionByName(url);
if (typeof extension === "string") {
// failed to find an extension, it returned its name
return;
}
// remove the extension name from the path name so we don't need to match on it anymore
url.set("pathname", url.pathname.slice(extension.name.length + 1));
const handlers = extension
.protocolHandlers
.map<[string, RouteHandler]>(({ pathSchema, handler }) => [pathSchema, handler]);
try {
this._route(handlers, url, extension.name);
} catch (error) {
if (error instanceof RoutingError) {
error.extensionName = extension.name;
}
throw error;
}
}
/**
* Add a handler under the `lens://app` tree of routing.
* @param pathSchema the URI path schema to match against for this handler
* @param handler a function that will be called if a protocol path matches
*/
public addInternalHandler(urlSchema: string, handler: RouteHandler): void {
pathToRegexp(urlSchema); // verify now that the schema is valid
logger.info(`${LensProtocolRouter.LoggingPrefix}: internal registering ${urlSchema}`);
this.internalRoutes.set(urlSchema, handler);
}
/**
* Remove an internal protocol handler.
* @param pathSchema the path schema that the handler was registered under
*/
public removeInternalHandler(urlSchema: string): void {
this.internalRoutes.delete(urlSchema);
}
}
/**
* a comparison function for `array.sort(...)`. Sort order should be most path
* parts to least path parts.
* @param a the left side to compare
* @param b the right side to compare
*/
function compareMatches<T>(a: match<T>, b: match<T>): number {
if (a.path === "/") {
return 1;
}
if (b.path === "/") {
return -1;
}
return countBy(b.path)["/"] - countBy(a.path)["/"];
}

View File

@ -31,7 +31,7 @@ export const apiResources: KubeApiResource[] = [
{ kind: "PersistentVolume", apiName: "persistentvolumes" }, { kind: "PersistentVolume", apiName: "persistentvolumes" },
{ kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" }, { kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" },
{ kind: "Pod", apiName: "pods" }, { kind: "Pod", apiName: "pods" },
{ kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets" }, { kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets", group: "policy" },
{ kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" }, { kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" },
{ kind: "ResourceQuota", apiName: "resourcequotas" }, { kind: "ResourceQuota", apiName: "resourcequotas" },
{ kind: "ReplicaSet", apiName: "replicasets", group: "apps" }, { kind: "ReplicaSet", apiName: "replicasets", group: "apps" },

View File

@ -1,8 +1,19 @@
import { AbortController } from "abort-controller";
/** /**
* Return a promise that will be resolved after at least `timeout` ms have * Return a promise that will be resolved after at least `timeout` ms have
* passed * passed. If `failFast` is provided then the promise is also resolved if it has
* been aborted.
* @param timeout The number of milliseconds before resolving * @param timeout The number of milliseconds before resolving
* @param failFast An abort controller instance to cause the delay to short-circuit
*/ */
export function delay(timeout = 1000): Promise<void> { export function delay(timeout = 1000, failFast?: AbortController): Promise<void> {
return new Promise(resolve => setTimeout(resolve, timeout)); return new Promise(resolve => {
const timeoutId = setTimeout(resolve, timeout);
failFast?.signal.addEventListener("abort", () => {
clearTimeout(timeoutId);
resolve();
});
});
} }

View File

@ -18,4 +18,4 @@ export * from "./openExternal";
export * from "./downloadFile"; export * from "./downloadFile";
export * from "./escapeRegExp"; export * from "./escapeRegExp";
export * from "./tar"; export * from "./tar";
export * from "./delay"; export * from "./type-narrowing";

View File

@ -0,0 +1,13 @@
/**
* Narrows `val` to include the property `key` (if true is returned)
* @param val The object to be tested
* @param key The key to test if it is present on the object
*/
export function hasOwnProperty<V extends object, K extends PropertyKey>(val: V, key: K): val is (V & { [key in K]: unknown }) {
// this call syntax is for when `val` was created by `Object.create(null)`
return Object.prototype.hasOwnProperty.call(val, key);
}
export function hasOwnProperties<V extends object, K extends PropertyKey>(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) {
return keys.every(key => hasOwnProperty(val, key));
}

View File

@ -14,7 +14,7 @@ import type { LensRendererExtension } from "./lens-renderer-extension";
import * as registries from "./registries"; import * as registries from "./registries";
import fs from "fs"; import fs from "fs";
// lazy load so that we get correct userData
export function extensionPackagesRoot() { export function extensionPackagesRoot() {
return path.join((app || remote.app).getPath("userData")); return path.join((app || remote.app).getPath("userData"));
} }
@ -52,6 +52,30 @@ export class ExtensionLoader {
return extensions; return extensions;
} }
@computed get userExtensionsByName(): Map<string, LensExtension> {
const extensions = new Map();
for (const [, val] of this.instances.toJS()) {
if (val.isBundled) {
continue;
}
extensions.set(val.manifest.name, val);
}
return extensions;
}
getExtensionByName(name: string): LensExtension | null {
for (const [, val] of this.instances) {
if (val.name === name) {
return val;
}
}
return null;
}
// Transform userExtensions to a state object for storing into ExtensionsStore // Transform userExtensions to a state object for storing into ExtensionsStore
@computed get storeState() { @computed get storeState() {
return Object.fromEntries( return Object.fromEntries(
@ -102,7 +126,6 @@ export class ExtensionLoader {
} catch (error) { } catch (error) {
logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error });
} }
} }
removeExtension(lensExtensionId: LensExtensionId) { removeExtension(lensExtensionId: LensExtensionId) {

View File

@ -27,7 +27,7 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
protected state = observable.map<LensExtensionId, LensExtensionState>(); protected state = observable.map<LensExtensionId, LensExtensionState>();
isEnabled(extId: LensExtensionId) { isEnabled(extId: LensExtensionId): boolean {
const state = this.state.get(extId); const state = this.state.get(extId);
// By default false, so that copied extensions are disabled by default. // By default false, so that copied extensions are disabled by default.

View File

@ -6,3 +6,4 @@ export type { KubeObjectStatusRegistration } from "../registries/kube-object-sta
export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry"; export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry";
export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry"; export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry";
export type { StatusBarRegistration } from "../registries/status-bar-registry"; export type { StatusBarRegistration } from "../registries/status-bar-registry";
export type { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../registries/protocol-handler-registry";

View File

@ -2,6 +2,7 @@ import type { InstalledExtension } from "./extension-discovery";
import { action, observable, reaction } from "mobx"; import { action, observable, reaction } from "mobx";
import { filesystemProvisionerStore } from "../main/extension-filesystem"; import { filesystemProvisionerStore } from "../main/extension-filesystem";
import logger from "../main/logger"; import logger from "../main/logger";
import { ProtocolHandlerRegistration } from "./registries/protocol-handler-registry";
export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionId = string; // path to manifest (package.json)
export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension; export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension;
@ -21,6 +22,8 @@ export class LensExtension {
readonly manifestPath: string; readonly manifestPath: string;
readonly isBundled: boolean; readonly isBundled: boolean;
protocolHandlers: ProtocolHandlerRegistration[] = [];
@observable private isEnabled = false; @observable private isEnabled = false;
constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) { constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) {

View File

@ -31,8 +31,7 @@ export class LensRendererExtension extends LensExtension {
/** /**
* Defines if extension is enabled for a given cluster. Defaults to `true`. * Defines if extension is enabled for a given cluster. Defaults to `true`.
*/ */
// eslint-disable-next-line unused-imports/no-unused-vars-ts
async isEnabledForCluster(cluster: Cluster): Promise<Boolean> { async isEnabledForCluster(cluster: Cluster): Promise<Boolean> {
return true; return (void cluster) || true;
} }
} }

View File

@ -3,7 +3,7 @@
"productName": "Lens extensions", "productName": "Lens extensions",
"description": "Lens - The Kubernetes IDE: extensions", "description": "Lens - The Kubernetes IDE: extensions",
"version": "0.0.0", "version": "0.0.0",
"copyright": "© 2020, Mirantis, Inc.", "copyright": "© 2021, Mirantis, Inc.",
"license": "MIT", "license": "MIT",
"main": "dist/src/extensions/extension-api.js", "main": "dist/src/extensions/extension-api.js",
"types": "dist/src/extensions/extension-api.d.ts", "types": "dist/src/extensions/extension-api.d.ts",

View File

@ -0,0 +1,44 @@
/**
* ProtocolHandlerRegistration is the data required for an extension to register
* a handler to a specific path or dynamic path.
*/
export interface ProtocolHandlerRegistration {
pathSchema: string;
handler: RouteHandler;
}
/**
* The collection of the dynamic parts of a URI which initiated a `lens://`
* protocol request
*/
export interface RouteParams {
/**
* the parts of the URI query string
*/
search: Record<string, string>;
/**
* the matching parts of the path. The dynamic parts of the URI path.
*/
pathname: Record<string, string>;
/**
* if the most specific path schema that is matched does not cover the whole
* of the URI's path. Then this field will be set to the remaining path
* segments.
*
* Example:
*
* If the path schema `/landing/:type` is the matched schema for the URI
* `/landing/soft/easy` then this field will be set to `"/easy"`.
*/
tail?: string;
}
/**
* RouteHandler represents the function signature of the handler function for
* `lens://` protocol routing.
*/
export interface RouteHandler {
(params: RouteParams): void;
}

View File

@ -8,7 +8,7 @@ interface StatusBarComponents {
} }
interface StatusBarRegistrationV2 { interface StatusBarRegistrationV2 {
components: StatusBarComponents; components?: StatusBarComponents; // has to be optional for backwards compatability
} }
export interface StatusBarRegistration extends StatusBarRegistrationV2 { export interface StatusBarRegistration extends StatusBarRegistrationV2 {

View File

@ -3,7 +3,8 @@ import logger from "./logger";
import { isDevelopment, isTestEnv } from "../common/vars"; import { isDevelopment, isTestEnv } from "../common/vars";
import { delay } from "../common/utils"; import { delay } from "../common/utils";
import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc"; import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc";
import { ipcMain } from "electron"; import { once } from "lodash";
import { app, ipcMain } from "electron";
let installVersion: null | string = null; let installVersion: null | string = null;
@ -11,13 +12,11 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd
if (arg.doUpdate) { if (arg.doUpdate) {
if (arg.now) { if (arg.now) {
logger.info(`${AutoUpdateLogPrefix}: User chose to update now`); logger.info(`${AutoUpdateLogPrefix}: User chose to update now`);
autoUpdater.on("update-downloaded", () => autoUpdater.quitAndInstall()); autoUpdater.quitAndInstall(true, true);
autoUpdater.downloadUpdate().catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download or install update`, { error })); app.exit(); // this is needed for the installer not to fail on windows.
} else { } else {
logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`); logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`);
autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.downloadUpdate()
.catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download update`, { error }));
} }
} else { } else {
logger.info(`${AutoUpdateLogPrefix}: User chose not to update`); logger.info(`${AutoUpdateLogPrefix}: User chose not to update`);
@ -28,7 +27,7 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd
* starts the automatic update checking * starts the automatic update checking
* @param interval milliseconds between interval to check on, defaults to 24h * @param interval milliseconds between interval to check on, defaults to 24h
*/ */
export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void { export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24): void {
if (isDevelopment || isTestEnv) { if (isDevelopment || isTestEnv) {
return; return;
} }
@ -38,10 +37,10 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void {
autoUpdater.autoInstallOnAppQuit = false; autoUpdater.autoInstallOnAppQuit = false;
autoUpdater autoUpdater
.on("update-available", (args: UpdateInfo) => { .on("update-available", (info: UpdateInfo) => {
if (autoUpdater.autoInstallOnAppQuit) { if (autoUpdater.autoInstallOnAppQuit) {
// a previous auto-update loop was completed with YES+LATER, check if same version // a previous auto-update loop was completed with YES+LATER, check if same version
if (installVersion === args.version) { if (installVersion === info.version) {
// same version, don't broadcast // same version, don't broadcast
return; return;
} }
@ -53,10 +52,14 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void {
* didn't ask for. * didn't ask for.
*/ */
autoUpdater.autoInstallOnAppQuit = false; autoUpdater.autoInstallOnAppQuit = false;
installVersion = args.version; installVersion = info.version;
autoUpdater.downloadUpdate()
.catch(error => logger.error(`${AutoUpdateLogPrefix}: failed to download update`, { error: String(error) }));
})
.on("update-downloaded", (info: UpdateInfo) => {
try { try {
const backchannel = `auto-update:${args.version}`; const backchannel = `auto-update:${info.version}`;
ipcMain.removeAllListeners(backchannel); // only one handler should be present ipcMain.removeAllListeners(backchannel); // only one handler should be present
@ -67,8 +70,8 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void {
listener: handleAutoUpdateBackChannel, listener: handleAutoUpdateBackChannel,
verifier: areArgsUpdateAvailableToBackchannel, verifier: areArgsUpdateAvailableToBackchannel,
}); });
logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: args.version }); logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: info.version });
broadcastMessage(UpdateAvailableChannel, backchannel, args); broadcastMessage(UpdateAvailableChannel, backchannel, info);
} catch (error) { } catch (error) {
logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error }); logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error });
installVersion = undefined; installVersion = undefined;
@ -83,7 +86,7 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void {
} }
helper(); helper();
} });
export async function checkForUpdates(): Promise<void> { export async function checkForUpdates(): Promise<void> {
try { try {

View File

@ -4,12 +4,12 @@ import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
import type { WorkspaceId } from "../common/workspace-store"; import type { WorkspaceId } from "../common/workspace-store";
import { action, comparer, computed, observable, reaction, toJS, when } from "mobx"; import { action, comparer, computed, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars"; import { apiKubePrefix } from "../common/vars";
import { broadcastMessage } from "../common/ipc"; import { broadcastMessage, InvalidKubeconfigChannel } from "../common/ipc";
import { ContextHandler } from "./context-handler"; import { ContextHandler } from "./context-handler";
import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node";
import { Kubectl } from "./kubectl"; import { Kubectl } from "./kubectl";
import { KubeconfigManager } from "./kubeconfig-manager"; import { KubeconfigManager } from "./kubeconfig-manager";
import { loadConfig } from "../common/kube-helpers"; import { loadConfig, validateKubeConfig } from "../common/kube-helpers";
import request, { RequestPromiseOptions } from "request-promise-native"; import request, { RequestPromiseOptions } from "request-promise-native";
import { apiResources, KubeApiResource } from "../common/rbac"; import { apiResources, KubeApiResource } from "../common/rbac";
import logger from "./logger"; import logger from "./logger";
@ -177,6 +177,7 @@ export class Cluster implements ClusterModel, ClusterState {
* @observable * @observable
*/ */
@observable isAdmin = false; @observable isAdmin = false;
/** /**
* Global watch-api accessibility , e.g. "/api/v1/services?watch=1" * Global watch-api accessibility , e.g. "/api/v1/services?watch=1"
* *
@ -251,15 +252,21 @@ export class Cluster implements ClusterModel, ClusterState {
* Kubernetes version * Kubernetes version
*/ */
get version(): string { get version(): string {
return String(this.metadata?.version) || ""; return String(this.metadata?.version || "");
} }
constructor(model: ClusterModel) { constructor(model: ClusterModel) {
this.updateModel(model); this.updateModel(model);
const kubeconfig = this.getKubeconfig();
if (kubeconfig.getContextObject(this.contextName)) { try {
const kubeconfig = this.getKubeconfig();
validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
} catch(err) {
logger.error(err);
logger.error(`[CLUSTER] Failed to load kubeconfig for the cluster '${this.name || this.contextName}' (context: ${this.contextName}, kubeconfig: ${this.kubeConfigPath}).`);
broadcastMessage(InvalidKubeconfigChannel, model.id);
} }
} }

View File

@ -4,7 +4,7 @@ import "../common/system-ca";
import "../common/prometheus-providers"; import "../common/prometheus-providers";
import * as Mobx from "mobx"; import * as Mobx from "mobx";
import * as LensExtensions from "../extensions/core-api"; import * as LensExtensions from "../extensions/core-api";
import { app, autoUpdater, dialog, powerMonitor } from "electron"; import { app, autoUpdater, ipcMain, dialog, powerMonitor } from "electron";
import { appName } from "../common/vars"; import { appName } from "../common/vars";
import path from "path"; import path from "path";
import { LensProxy } from "./lens-proxy"; import { LensProxy } from "./lens-proxy";
@ -25,6 +25,7 @@ import { InstalledExtension, extensionDiscovery } from "../extensions/extension-
import type { LensExtensionId } from "../extensions/lens-extension"; import type { LensExtensionId } from "../extensions/lens-extension";
import { installDeveloperTools } from "./developer-tools"; import { installDeveloperTools } from "./developer-tools";
import { filesystemProvisionerStore } from "./extension-filesystem"; import { filesystemProvisionerStore } from "./extension-filesystem";
import { LensProtocolRouterMain } from "./protocol-handler";
import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils";
import { bindBroadcastHandlers } from "../common/ipc"; import { bindBroadcastHandlers } from "../common/ipc";
import { startUpdateChecking } from "./app-updater"; import { startUpdateChecking } from "./app-updater";
@ -37,30 +38,54 @@ let windowManager: WindowManager;
app.setName(appName); app.setName(appName);
logger.info("📟 Setting Lens as protocol client for lens://");
if (app.setAsDefaultProtocolClient("lens")) {
logger.info("📟 succeeded ✅");
} else {
logger.info("📟 failed ❗");
}
if (!process.env.CICD) { if (!process.env.CICD) {
app.setPath("userData", workingDir); app.setPath("userData", workingDir);
} }
if (process.env.LENS_DISABLE_GPU) {
app.disableHardwareAcceleration();
}
mangleProxyEnv(); mangleProxyEnv();
if (app.commandLine.getSwitchValue("proxy-server") !== "") { if (app.commandLine.getSwitchValue("proxy-server") !== "") {
process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server"); process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server");
} }
const instanceLock = app.requestSingleInstanceLock(); if (!app.requestSingleInstanceLock()) {
if (!instanceLock) {
app.exit(); app.exit();
} else {
const lprm = LensProtocolRouterMain.getInstance<LensProtocolRouterMain>();
for (const arg of process.argv) {
if (arg.toLowerCase().startsWith("lens://")) {
lprm.route(arg)
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg }));
}
}
} }
app.on("second-instance", () => { app.on("second-instance", (event, argv) => {
const lprm = LensProtocolRouterMain.getInstance<LensProtocolRouterMain>();
for (const arg of argv) {
if (arg.toLowerCase().startsWith("lens://")) {
lprm.route(arg)
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg }));
}
}
windowManager?.ensureMainWindow(); windowManager?.ensureMainWindow();
}); });
if (process.env.LENS_DISABLE_GPU) {
app.disableHardwareAcceleration();
}
app.on("ready", async () => { app.on("ready", async () => {
logger.info(`🚀 Starting Lens from "${workingDir}"`); logger.info(`🚀 Starting Lens from "${workingDir}"`);
logger.info("🐚 Syncing shell environment"); logger.info("🐚 Syncing shell environment");
@ -128,7 +153,19 @@ app.on("ready", async () => {
logger.info("🖥️ Starting WindowManager"); logger.info("🖥️ Starting WindowManager");
windowManager = WindowManager.getInstance<WindowManager>(proxyPort); windowManager = WindowManager.getInstance<WindowManager>(proxyPort);
windowManager.whenLoaded.then(() => startUpdateChecking());
ipcMain.on("renderer:loaded", () => {
startUpdateChecking();
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()
.rendererLoaded = true;
});
extensionLoader.whenLoaded.then(() => {
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()
.extensionsLoaded = true;
});
logger.info("🧩 Initializing extensions"); logger.info("🧩 Initializing extensions");
@ -174,8 +211,8 @@ let blockQuit = true;
autoUpdater.on("before-quit-for-update", () => blockQuit = false); autoUpdater.on("before-quit-for-update", () => blockQuit = false);
// Quit app on Cmd+Q (MacOS)
app.on("will-quit", (event) => { app.on("will-quit", (event) => {
// Quit app on Cmd+Q (MacOS)
logger.info("APP:QUIT"); logger.info("APP:QUIT");
appEventBus.emit({name: "app", action: "close"}); appEventBus.emit({name: "app", action: "close"});
@ -188,6 +225,16 @@ app.on("will-quit", (event) => {
} }
}); });
app.on("open-url", (event, rawUrl) => {
// lens:// protocol handler
event.preventDefault();
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()
.route(rawUrl)
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl }));
});
// Extensions-api runtime exports // Extensions-api runtime exports
export const LensExtensionsApi = { export const LensExtensionsApi = {
...LensExtensions, ...LensExtensions,

View File

@ -120,12 +120,18 @@ export class LensProxy {
protected createProxy(): httpProxy { protected createProxy(): httpProxy {
const proxy = httpProxy.createProxyServer(); const proxy = httpProxy.createProxyServer();
proxy.on("proxyRes", (proxyRes, req) => { proxy.on("proxyRes", (proxyRes, req, res) => {
const retryCounterId = this.getRequestId(req); const retryCounterId = this.getRequestId(req);
if (this.retryCounters.has(retryCounterId)) { if (this.retryCounters.has(retryCounterId)) {
this.retryCounters.delete(retryCounterId); this.retryCounters.delete(retryCounterId);
} }
if (!res.headersSent && req.url) {
const url = new URL(req.url, "http://localhost");
if (url.searchParams.has("watch")) res.flushHeaders();
}
}); });
proxy.on("error", (error, req, res, target) => { proxy.on("error", (error, req, res, target) => {

View File

@ -11,6 +11,7 @@ import { menuRegistry } from "../extensions/registries/menu-registry";
import logger from "./logger"; import logger from "./logger";
import { exitApp } from "./exit-app"; import { exitApp } from "./exit-app";
import { broadcastMessage } from "../common/ipc"; import { broadcastMessage } from "../common/ipc";
import * as packageJson from "../../package.json";
export type MenuTopId = "mac" | "file" | "edit" | "view" | "help"; export type MenuTopId = "mac" | "file" | "edit" | "view" | "help";
@ -26,7 +27,7 @@ export function showAbout(browserWindow: BrowserWindow) {
`Electron: ${process.versions.electron}`, `Electron: ${process.versions.electron}`,
`Chrome: ${process.versions.chrome}`, `Chrome: ${process.versions.chrome}`,
`Node: ${process.versions.node}`, `Node: ${process.versions.node}`,
`Copyright 2020 Mirantis, Inc.`, packageJson.copyright,
]; ];
dialog.showMessageBoxSync(browserWindow, { dialog.showMessageBoxSync(browserWindow, {

View File

@ -0,0 +1,259 @@
import { LensProtocolRouterMain } from "../router";
import { noop } from "../../../common/utils";
import { extensionsStore } from "../../../extensions/extensions-store";
import { extensionLoader } from "../../../extensions/extension-loader";
import * as uuid from "uuid";
import { LensMainExtension } from "../../../extensions/core-api";
import { broadcastMessage } from "../../../common/ipc";
import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler";
jest.mock("../../../common/ipc");
function throwIfDefined(val: any): void {
if (val != null) {
throw val;
}
}
describe("protocol router tests", () => {
let lpr: LensProtocolRouterMain;
beforeEach(() => {
jest.clearAllMocks();
(extensionsStore as any).state.clear();
(extensionLoader as any).instances.clear();
LensProtocolRouterMain.resetInstance();
lpr = LensProtocolRouterMain.getInstance<LensProtocolRouterMain>();
lpr.extensionsLoaded = true;
lpr.rendererLoaded = true;
});
it("should throw on non-lens URLS", async () => {
try {
expect(await lpr.route("https://google.ca")).toBeUndefined();
} catch (error) {
expect(error).toBeInstanceOf(Error);
}
});
it("should throw when host not internal or extension", async () => {
try {
expect(await lpr.route("lens://foobar")).toBeUndefined();
} catch (error) {
expect(error).toBeInstanceOf(Error);
}
});
it("should not throw when has valid host", async () => {
const extId = uuid.v4();
const ext = new LensMainExtension({
id: extId,
manifestPath: "/foo/bar",
manifest: {
name: "@mirantis/minikube",
version: "0.1.1",
},
isBundled: false,
isEnabled: true,
absolutePath: "/foo/bar",
});
ext.protocolHandlers.push({
pathSchema: "/",
handler: noop,
});
(extensionLoader as any).instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "@mirantis/minikube" });
lpr.addInternalHandler("/", noop);
try {
expect(await lpr.route("lens://app")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
try {
expect(await lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(broadcastMessage).toHaveBeenNthCalledWith(1, ProtocolHandlerInternal, "lens://app");
expect(broadcastMessage).toHaveBeenNthCalledWith(2, ProtocolHandlerExtension, "lens://extension/@mirantis/minikube");
});
it("should call handler if matches", async () => {
let called = false;
lpr.addInternalHandler("/page", () => { called = true; });
try {
expect(await lpr.route("lens://app/page")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe(true);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page");
});
it("should call most exact handler", async () => {
let called: any = 0;
lpr.addInternalHandler("/page", () => { called = 1; });
lpr.addInternalHandler("/page/:id", params => { called = params.pathname.id; });
try {
expect(await lpr.route("lens://app/page/foo")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe("foo");
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo");
});
it("should call most exact handler for an extension", async () => {
let called: any = 0;
const extId = uuid.v4();
const ext = new LensMainExtension({
id: extId,
manifestPath: "/foo/bar",
manifest: {
name: "@foobar/icecream",
version: "0.1.1",
},
isBundled: false,
isEnabled: true,
absolutePath: "/foo/bar",
});
ext.protocolHandlers
.push({
pathSchema: "/page",
handler: () => { called = 1; },
}, {
pathSchema: "/page/:id",
handler: params => { called = params.pathname.id; },
});
(extensionLoader as any).instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" });
try {
expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe("foob");
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/@foobar/icecream/page/foob");
});
it("should work with non-org extensions", async () => {
let called: any = 0;
{
const extId = uuid.v4();
const ext = new LensMainExtension({
id: extId,
manifestPath: "/foo/bar",
manifest: {
name: "@foobar/icecream",
version: "0.1.1",
},
isBundled: false,
isEnabled: true,
absolutePath: "/foo/bar",
});
ext.protocolHandlers
.push({
pathSchema: "/page/:id",
handler: params => { called = params.pathname.id; },
});
(extensionLoader as any).instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" });
}
{
const extId = uuid.v4();
const ext = new LensMainExtension({
id: extId,
manifestPath: "/foo/bar",
manifest: {
name: "icecream",
version: "0.1.1",
},
isBundled: false,
isEnabled: true,
absolutePath: "/foo/bar",
});
ext.protocolHandlers
.push({
pathSchema: "/page",
handler: () => { called = 1; },
});
(extensionLoader as any).instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "icecream" });
}
(extensionsStore as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" });
(extensionsStore as any).state.set("icecream", { enabled: true, name: "icecream" });
try {
expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe(1);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/icecream/page");
});
it("should throw if urlSchema is invalid", () => {
expect(() => lpr.addInternalHandler("/:@", noop)).toThrowError();
});
it("should call most exact handler with 3 found handlers", async () => {
let called: any = 0;
lpr.addInternalHandler("/", () => { called = 2; });
lpr.addInternalHandler("/page", () => { called = 1; });
lpr.addInternalHandler("/page/foo", () => { called = 3; });
lpr.addInternalHandler("/page/bar", () => { called = 4; });
try {
expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe(3);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat");
});
it("should call most exact handler with 2 found handlers", async () => {
let called: any = 0;
lpr.addInternalHandler("/", () => { called = 2; });
lpr.addInternalHandler("/page", () => { called = 1; });
lpr.addInternalHandler("/page/bar", () => { called = 4; });
try {
expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe(1);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat");
});
});

View File

@ -0,0 +1 @@
export * from "./router";

View File

@ -0,0 +1,112 @@
import logger from "../logger";
import * as proto from "../../common/protocol-handler";
import Url from "url-parse";
import { LensExtension } from "../../extensions/lens-extension";
import { broadcastMessage } from "../../common/ipc";
import { observable, when } from "mobx";
export interface FallbackHandler {
(name: string): Promise<boolean>;
}
export class LensProtocolRouterMain extends proto.LensProtocolRouter {
private missingExtensionHandlers: FallbackHandler[] = [];
@observable rendererLoaded = false;
@observable extensionsLoaded = false;
/**
* Find the most specific registered handler, if it exists, and invoke it.
*
* This will send an IPC message to the renderer router to do the same
* in the renderer.
*/
public async route(rawUrl: string): Promise<void> {
try {
const url = new Url(rawUrl, true);
if (url.protocol.toLowerCase() !== "lens:") {
throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url);
}
logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: routing ${url.toString()}`);
switch (url.host) {
case "app":
return this._routeToInternal(url);
case "extension":
await when(() => this.extensionsLoaded);
return this._routeToExtension(url);
default:
throw new proto.RoutingError(proto.RoutingErrorType.INVALID_HOST, url);
}
} catch (error) {
if (error instanceof proto.RoutingError) {
logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url });
} else {
logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { rawUrl });
}
}
}
protected async _executeMissingExtensionHandlers(extensionName: string): Promise<boolean> {
for (const handler of this.missingExtensionHandlers) {
if (await handler(extensionName)) {
return true;
}
}
return false;
}
protected async _findMatchingExtensionByName(url: Url): Promise<LensExtension | string> {
const firstAttempt = await super._findMatchingExtensionByName(url);
if (typeof firstAttempt !== "string") {
return firstAttempt;
}
if (await this._executeMissingExtensionHandlers(firstAttempt)) {
return super._findMatchingExtensionByName(url);
}
return "";
}
protected async _routeToInternal(url: Url): Promise<void> {
const rawUrl = url.toString(); // for sending to renderer
super._routeToInternal(url);
await when(() => this.rendererLoaded);
return broadcastMessage(proto.ProtocolHandlerInternal, rawUrl);
}
protected async _routeToExtension(url: Url): Promise<void> {
const rawUrl = url.toString(); // for sending to renderer
/**
* This needs to be done first, so that the missing extension handlers can
* be called before notifying the renderer.
*
* Note: this needs to clone the url because _routeToExtension modifies its
* argument.
*/
await super._routeToExtension(new Url(url.toString(), true));
await when(() => this.rendererLoaded);
return broadcastMessage(proto.ProtocolHandlerExtension, rawUrl);
}
/**
* Add a function to the list which will be sequentially called if an extension
* is not found while routing to the extensions
* @param handler A function that tries to find an extension
*/
public addMissingExtensionHandler(handler: FallbackHandler): void {
this.missingExtensionHandlers.push(handler);
}
}

View File

@ -110,6 +110,14 @@ export class ShellSession extends EventEmitter {
env["SystemRoot"] = process.env.SystemRoot; env["SystemRoot"] = process.env.SystemRoot;
env["PTYSHELL"] = process.env.SHELL || "powershell.exe"; env["PTYSHELL"] = process.env.SHELL || "powershell.exe";
env["PATH"] = pathStr; env["PATH"] = pathStr;
env["LENS_SESSION"] = "true";
const lensWslEnv = "KUBECONFIG/up:LENS_SESSION/u";
if (process.env.WSLENV != undefined) {
env["WSLENV"] = `${process.env["WSLENV"]}:${lensWslEnv}`;
} else {
env["WSLENV"] = lensWslEnv;
}
} else if(typeof(process.env.SHELL) != "undefined") { } else if(typeof(process.env.SHELL) != "undefined") {
env["PTYSHELL"] = process.env.SHELL; env["PTYSHELL"] = process.env.SHELL;
env["PATH"] = pathStr; env["PATH"] = pathStr;

View File

@ -1,5 +1,5 @@
import type { ClusterId } from "../common/cluster-store"; import type { ClusterId } from "../common/cluster-store";
import { observable, when } from "mobx"; import { observable } from "mobx";
import { app, BrowserWindow, dialog, shell, webContents } from "electron"; import { app, BrowserWindow, dialog, shell, webContents } from "electron";
import windowStateKeeper from "electron-window-state"; import windowStateKeeper from "electron-window-state";
import { appEventBus } from "../common/event-bus"; import { appEventBus } from "../common/event-bus";
@ -16,9 +16,6 @@ export class WindowManager extends Singleton {
protected windowState: windowStateKeeper.State; protected windowState: windowStateKeeper.State;
protected disposers: Record<string, Function> = {}; protected disposers: Record<string, Function> = {};
@observable mainViewInitiallyLoaded = false;
whenLoaded = when(() => this.mainViewInitiallyLoaded);
@observable activeClusterId: ClusterId; @observable activeClusterId: ClusterId;
constructor(protected proxyPort: number) { constructor(protected proxyPort: number) {
@ -104,7 +101,6 @@ export class WindowManager extends Singleton {
setTimeout(() => { setTimeout(() => {
appEventBus.emit({ name: "app", action: "start" }); appEventBus.emit({ name: "app", action: "start" });
}, 1000); }, 1000);
this.mainViewInitiallyLoaded = true;
} catch (err) { } catch (err) {
dialog.showErrorBox("ERROR!", err.toString()); dialog.showErrorBox("ERROR!", err.toString());
} }

View File

@ -83,7 +83,7 @@ export class HelmChart {
tillerVersion?: string; tillerVersion?: string;
getId() { getId() {
return `${this.apiVersion}/${this.name}@${this.getAppVersion()}`; return `${this.repo}:${this.apiVersion}/${this.name}@${this.getAppVersion()}+${this.digest}`;
} }
getName() { getName() {

View File

@ -187,7 +187,7 @@ export class HelmRelease implements ItemObject {
} }
getVersion() { getVersion() {
const versions = this.chart.match(/(v?\d+)[^-].*$/); const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/);
if (versions) { if (versions) {
return versions[0]; return versions[0];

View File

@ -1,11 +1,11 @@
import { JsonApi, JsonApiErrorParsed } from "./json-api"; import { JsonApi, JsonApiErrorParsed } from "./json-api";
import { KubeJsonApi } from "./kube-json-api"; import { KubeJsonApi } from "./kube-json-api";
import { Notifications } from "../components/notifications"; import { Notifications } from "../components/notifications";
import { apiKubePrefix, apiPrefix, isDevelopment } from "../../common/vars"; import { apiKubePrefix, apiPrefix, isDebugging, isDevelopment } from "../../common/vars";
export const apiBase = new JsonApi({ export const apiBase = new JsonApi({
apiBase: apiPrefix, apiBase: apiPrefix,
debug: isDevelopment, debug: isDevelopment || isDebugging,
}); });
export const apiKube = new KubeJsonApi({ export const apiKube = new KubeJsonApi({
apiBase: apiKubePrefix, apiBase: apiKubePrefix,

View File

@ -272,6 +272,7 @@ export class KubeApi<T extends KubeObject = any> {
} }
protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any {
if (!data) return;
const KubeObjectConstructor = this.objectConstructor; const KubeObjectConstructor = this.objectConstructor;
if (KubeObject.isJsonApiData(data)) { if (KubeObject.isJsonApiData(data)) {

View File

@ -8,7 +8,8 @@ import * as ReactRouterDom from "react-router-dom";
import { render, unmountComponentAtNode } from "react-dom"; import { render, unmountComponentAtNode } from "react-dom";
import { clusterStore } from "../common/cluster-store"; import { clusterStore } from "../common/cluster-store";
import { userStore } from "../common/user-store"; import { userStore } from "../common/user-store";
import { isMac } from "../common/vars"; import { delay } from "../common/utils";
import { isMac, isDevelopment } from "../common/vars";
import { workspaceStore } from "../common/workspace-store"; import { workspaceStore } from "../common/workspace-store";
import * as LensExtensions from "../extensions/extension-api"; import * as LensExtensions from "../extensions/extension-api";
import { extensionDiscovery } from "../extensions/extension-discovery"; import { extensionDiscovery } from "../extensions/extension-discovery";
@ -19,6 +20,17 @@ import { App } from "./components/app";
import { LensApp } from "./lens-app"; import { LensApp } from "./lens-app";
import { themeStore } from "./theme.store"; import { themeStore } from "./theme.store";
/**
* If this is a development buid, wait a second to attach
* Chrome Debugger to renderer process
* https://stackoverflow.com/questions/52844870/debugging-electron-renderer-process-with-vscode
*/
async function attachChromeDebugger() {
if (isDevelopment) {
await delay(1000);
}
}
type AppComponent = React.ComponentType & { type AppComponent = React.ComponentType & {
init?(): Promise<void>; init?(): Promise<void>;
}; };
@ -35,6 +47,7 @@ export {
export async function bootstrap(App: AppComponent) { export async function bootstrap(App: AppComponent) {
const rootElem = document.getElementById("app"); const rootElem = document.getElementById("app");
await attachChromeDebugger();
rootElem.classList.toggle("is-mac", isMac); rootElem.classList.toggle("is-mac", isMac);
extensionLoader.init(); extensionLoader.init();

View File

@ -147,7 +147,7 @@ export class AddCluster extends React.Component {
try { try {
const kubeConfig = this.kubeContexts.get(context); const kubeConfig = this.kubeContexts.get(context);
validateKubeConfig(kubeConfig); validateKubeConfig(kubeConfig, context);
return true; return true;
} catch (err) { } catch (err) {
@ -352,7 +352,7 @@ export class AddCluster extends React.Component {
return ( return (
<DropFileInput onDropFiles={this.onDropKubeConfig}> <DropFileInput onDropFiles={this.onDropKubeConfig}>
<PageLayout className="AddClusters" header={<h2>Add Clusters</h2>}> <PageLayout className="AddClusters" header={<><Icon svg="logo-lens" big /> <h2>Add Clusters</h2></>} showOnTop={true}>
<h2>Add Clusters from Kubeconfig</h2> <h2>Add Clusters from Kubeconfig</h2>
{this.renderInfo()} {this.renderInfo()}
{this.renderKubeConfigSource()} {this.renderKubeConfigSource()}

View File

@ -59,7 +59,7 @@ export class ClusterSettings extends React.Component<Props> {
); );
return ( return (
<PageLayout className="ClusterSettings" header={header}> <PageLayout className="ClusterSettings" header={header} showOnTop={true}>
<Status cluster={cluster}></Status> <Status cluster={cluster}></Status>
<General cluster={cluster}></General> <General cluster={cluster}></General>
<Features cluster={cluster}></Features> <Features cluster={cluster}></Features>

View File

@ -1,12 +1,21 @@
import { RouteProps } from "react-router"; import { RouteProps } from "react-router";
import { Config } from "./config";
import { IURLParams } from "../../../common/utils/buildUrl"; import { IURLParams } from "../../../common/utils/buildUrl";
import { configMapsURL } from "../+config-maps/config-maps.route"; import { configMapsRoute, configMapsURL } from "../+config-maps/config-maps.route";
import { hpaRoute } from "../+config-autoscalers";
import { limitRangesRoute } from "../+config-limit-ranges";
import { pdbRoute } from "../+config-pod-disruption-budgets";
import { resourceQuotaRoute } from "../+config-resource-quotas";
import { secretsRoute } from "../+config-secrets";
export const configRoute: RouteProps = { export const configRoute: RouteProps = {
get path() { path: [
return Config.tabRoutes.map(({ routePath }) => routePath).flat(); configMapsRoute,
} secretsRoute,
resourceQuotaRoute,
limitRangesRoute,
hpaRoute,
pdbRoute
].map(route => route.path.toString())
}; };
export const configURL = (params?: IURLParams) => configMapsURL(params); export const configURL = (params?: IURLParams) => configMapsURL(params);

View File

@ -1,60 +1,15 @@
.LandingPage { .PageLayout.LandingOverview {
width: 100%; --width: 100%;
height: 100%; --height: 100%;
text-align: center; text-align: center;
z-index: 0;
&::after {
content: "";
background: url(../../components/icon/crane.svg) no-repeat;
background-position: 0 35%;
background-size: 85%;
background-clip: content-box;
opacity: .75;
top: 0;
left: 0;
bottom: 0;
right: 0;
position: absolute;
z-index: -1;
.theme-light & {
opacity: 0.2; .content-wrapper {
.content {
margin: unset;
max-width: unset;
} }
} }
}
.startup-hint {
$bgc: $mainBackground;
$arrowSize: 10px;
position: absolute;
left: 0;
top: 25px;
margin: $padding;
padding: $padding * 2;
width: 320px;
background: $bgc;
color: $textColorAccent;
filter: drop-shadow(0 0px 2px #ffffff33);
&:before {
content: "";
position: absolute;
width: 0;
height: 0;
border-top: $arrowSize solid transparent;
border-bottom: $arrowSize solid transparent;
border-right: $arrowSize solid $bgc;
right: 100%;
}
.theme-light & {
filter: drop-shadow(0 0px 2px #777);
background: white;
&:before {
border-right-color: white;
}
}
}
}

View File

@ -1,40 +1,47 @@
import "./landing-page.scss"; import "./landing-page.scss";
import React from "react"; import React from "react";
import { observable } from "mobx"; import { computed, observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { clusterStore } from "../../../common/cluster-store"; import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store"; import { Workspace, workspaceStore } from "../../../common/workspace-store";
import { WorkspaceOverview } from "./workspace-overview";
import { PageLayout } from "../layout/page-layout";
import { Notifications } from "../notifications";
import { Icon } from "../icon";
@observer @observer
export class LandingPage extends React.Component { export class LandingPage extends React.Component {
@observable showHint = true; @observable showHint = true;
get workspace(): Workspace {
return workspaceStore.currentWorkspace;
}
@computed
get clusters() {
return clusterStore.getByWorkspaceId(this.workspace.id);
}
componentDidMount() {
const noClustersInScope = !this.clusters.length;
const showStartupHint = this.showHint;
if (showStartupHint && noClustersInScope) {
Notifications.info(<><b>Welcome!</b><p>Get started by associating one or more clusters to Lens</p></>, {
timeout: 30_000,
id: "landing-welcome"
});
}
}
render() { render() {
const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId); const showBackButton = this.clusters.length > 0;
const noClustersInScope = !clusters.length; const header = <><Icon svg="logo-lens" big /> <h2>{this.workspace.name}</h2></>;
const showStartupHint = this.showHint && noClustersInScope;
return ( return (
<div className="LandingPage flex"> <PageLayout className="LandingOverview flex" header={header} provideBackButtonNavigation={showBackButton} showOnTop={true}>
{showStartupHint && ( <WorkspaceOverview workspace={this.workspace}/>
<div className="startup-hint flex column gaps" onMouseEnter={() => this.showHint = false}> </PageLayout>
<p>This is the quick launch menu.</p>
<p>
Associate clusters and choose the ones you want to access via quick launch menu by clicking the + button.
</p>
</div>
)}
{noClustersInScope && (
<div className="no-clusters flex column gaps box center">
<h1>
Welcome!
</h1>
<p>
Get started by associating one or more clusters to Lens.
</p>
</div>
)}
</div>
); );
} }
} }

View File

@ -0,0 +1,74 @@
import React from "react";
import { ClusterItem, WorkspaceClusterStore } from "./workspace-cluster.store";
import { autobind, cssNames } from "../../utils";
import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
import { MenuItem } from "../menu";
import { Icon } from "../icon";
import { Workspace } from "../../../common/workspace-store";
import { clusterSettingsURL } from "../+cluster-settings";
import { navigate } from "../../navigation";
interface Props extends MenuActionsProps {
clusterItem: ClusterItem;
workspace: Workspace;
workspaceClusterStore: WorkspaceClusterStore;
}
export class WorkspaceClusterMenu extends React.Component<Props> {
@autobind()
remove() {
const { clusterItem, workspaceClusterStore } = this.props;
return workspaceClusterStore.remove(clusterItem);
}
@autobind()
gotoSettings() {
const { clusterItem } = this.props;
navigate(clusterSettingsURL({
params: {
clusterId: clusterItem.id
}
}));
}
@autobind()
renderRemoveMessage() {
const { clusterItem, workspace } = this.props;
return (
<p>Remove cluster <b>{clusterItem.name}</b> from workspace <b>{workspace.name}</b>?</p>
);
}
renderContent() {
const { toolbar } = this.props;
return (
<>
<MenuItem onClick={this.gotoSettings}>
<Icon material="settings" interactive={toolbar} title="Settings"/>
<span className="title">Settings</span>
</MenuItem>
</>
);
}
render() {
const { clusterItem: { cluster: { isManaged } }, className, ...menuProps } = this.props;
return (
<MenuActions
{...menuProps}
className={cssNames("WorkspaceClusterMenu", className)}
removeAction={isManaged ? null : this.remove}
removeConfirmationMessage={this.renderRemoveMessage}
>
{this.renderContent()}
</MenuActions>
);
}
}

View File

@ -0,0 +1,72 @@
import { WorkspaceId } from "../../../common/workspace-store";
import { Cluster } from "../../../main/cluster";
import { clusterStore } from "../../../common/cluster-store";
import { ItemObject, ItemStore } from "../../item.store";
import { autobind } from "../../utils";
export class ClusterItem implements ItemObject {
constructor(public cluster: Cluster) {}
get name() {
return this.cluster.name;
}
get distribution() {
return this.cluster.metadata?.distribution?.toString() ?? "unknown";
}
get version() {
return this.cluster.version;
}
get connectionStatus() {
return this.cluster.online ? "connected" : "disconnected";
}
getName() {
return this.name;
}
get id() {
return this.cluster.id;
}
get clusterId() {
return this.cluster.id;
}
getId() {
return this.id;
}
}
/** an ItemStore of the clusters belonging to a given workspace */
@autobind()
export class WorkspaceClusterStore extends ItemStore<ClusterItem> {
workspaceId: WorkspaceId;
constructor(workspaceId: WorkspaceId) {
super();
this.workspaceId = workspaceId;
}
loadAll() {
return this.loadItems(
() => (
clusterStore
.getByWorkspaceId(this.workspaceId)
.filter(cluster => cluster.enabled)
.map(cluster => new ClusterItem(cluster))
)
);
}
async remove(clusterItem: ClusterItem) {
const { cluster: { isManaged, id: clusterId }} = clusterItem;
if (!isManaged) {
return super.removeItem(clusterItem, () => clusterStore.removeById(clusterId));
}
}
}

View File

@ -0,0 +1,32 @@
.WorkspaceOverview {
max-height: 50%;
.Table {
padding-bottom: 60px;
}
.TableCell {
display: flex;
align-items: left;
&.cluster-icon {
align-items: center;
flex-grow: 0.2;
padding: 0;
}
&.connected {
color: var(--colorSuccess);
}
}
.TableCell.status {
flex: 0.1;
}
.TableCell.distribution {
flex: 0.2;
}
.TableCell.version {
flex: 0.2;
}
}

View File

@ -0,0 +1,75 @@
import "./workspace-overview.scss";
import React, { Component } from "react";
import { Workspace } from "../../../common/workspace-store";
import { observer } from "mobx-react";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { ClusterItem, WorkspaceClusterStore } from "./workspace-cluster.store";
import { navigate } from "../../navigation";
import { clusterViewURL } from "../cluster-manager/cluster-view.route";
import { WorkspaceClusterMenu } from "./workspace-cluster-menu";
import { kebabCase } from "lodash";
import { addClusterURL } from "../+add-cluster";
interface Props {
workspace: Workspace;
}
enum sortBy {
name = "name",
distribution = "distribution",
version = "version",
online = "online"
}
@observer
export class WorkspaceOverview extends Component<Props> {
showCluster = ({ clusterId }: ClusterItem) => {
navigate(clusterViewURL({ params: { clusterId } }));
};
render() {
const { workspace } = this.props;
const workspaceClusterStore = new WorkspaceClusterStore(workspace.id);
workspaceClusterStore.loadAll();
return (
<ItemListLayout
renderHeaderTitle={<div>Clusters</div>}
isClusterScoped
isSearchable={false}
isSelectable={false}
className="WorkspaceOverview"
store={workspaceClusterStore}
sortingCallbacks={{
[sortBy.name]: (item: ClusterItem) => item.name,
[sortBy.distribution]: (item: ClusterItem) => item.distribution,
[sortBy.version]: (item: ClusterItem) => item.version,
[sortBy.online]: (item: ClusterItem) => item.connectionStatus,
}}
renderTableHeader={[
{ title: "Name", className: "name", sortBy: sortBy.name },
{ title: "Distribution", className: "distribution", sortBy: sortBy.distribution },
{ title: "Version", className: "version", sortBy: sortBy.version },
{ title: "Status", className: "status", sortBy: sortBy.online },
]}
renderTableContents={(item: ClusterItem) => [
item.name,
item.distribution,
item.version,
{ title: item.connectionStatus, className: kebabCase(item.connectionStatus) }
]}
onDetails={this.showCluster}
addRemoveButtons={{
addTooltip: "Add Cluster",
onAdd: () => navigate(addClusterURL()),
}}
renderItemMenu={(clusterItem: ClusterItem) => (
<WorkspaceClusterMenu clusterItem={clusterItem} workspace={workspace} workspaceClusterStore={workspaceClusterStore}/>
)}
/>
);
}
}

View File

@ -33,20 +33,18 @@ export class AddNamespaceDialog extends React.Component<Props> {
this.namespace = ""; this.namespace = "";
}; };
close = () => {
AddNamespaceDialog.close();
};
addNamespace = async () => { addNamespace = async () => {
const { namespace } = this; const { namespace } = this;
const { onSuccess, onError } = this.props; const { onSuccess, onError } = this.props;
try { try {
await namespaceStore.create({ name: namespace }).then(onSuccess); const created = await namespaceStore.create({ name: namespace });
this.close();
onSuccess?.(created);
AddNamespaceDialog.close();
} catch (err) { } catch (err) {
Notifications.error(err); Notifications.error(err);
onError && onError(err); onError?.(err);
} }
}; };
@ -61,9 +59,9 @@ export class AddNamespaceDialog extends React.Component<Props> {
className="AddNamespaceDialog" className="AddNamespaceDialog"
isOpen={AddNamespaceDialog.isOpen} isOpen={AddNamespaceDialog.isOpen}
onOpen={this.reset} onOpen={this.reset}
close={this.close} close={AddNamespaceDialog.close}
> >
<Wizard header={header} done={this.close}> <Wizard header={header} done={AddNamespaceDialog.close}>
<WizardStep <WizardStep
contentClass="flex gaps column" contentClass="flex gaps column"
nextLabel="Create" nextLabel="Create"

View File

@ -120,7 +120,7 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
protected async loadItems(params: KubeObjectStoreLoadingParams) { protected async loadItems(params: KubeObjectStoreLoadingParams) {
const { allowedNamespaces } = this; const { allowedNamespaces } = this;
let namespaces = await super.loadItems(params); let namespaces = (await super.loadItems(params)) || [];
namespaces = namespaces.filter(namespace => allowedNamespaces.includes(namespace.getName())); namespaces = namespaces.filter(namespace => allowedNamespaces.includes(namespace.getName()));

View File

@ -1,12 +1,17 @@
import { RouteProps } from "react-router"; import { RouteProps } from "react-router";
import { Network } from "./network"; import { endpointRoute } from "../+network-endpoints";
import { servicesURL } from "../+network-services"; import { ingressRoute } from "../+network-ingresses";
import { networkPoliciesRoute } from "../+network-policies";
import { servicesRoute, servicesURL } from "../+network-services";
import { IURLParams } from "../../../common/utils/buildUrl"; import { IURLParams } from "../../../common/utils/buildUrl";
export const networkRoute: RouteProps = { export const networkRoute: RouteProps = {
get path() { path: [
return Network.tabRoutes.map(({ routePath }) => routePath).flat(); servicesRoute,
} endpointRoute,
ingressRoute,
networkPoliciesRoute
].map(route => route.path.toString())
}; };
export const networkURL = (params?: IURLParams) => servicesURL(params); export const networkURL = (params?: IURLParams) => servicesURL(params);

View File

@ -1,3 +1,2 @@
export * from "./pod-security-policies.route";
export * from "./pod-security-policies"; export * from "./pod-security-policies";
export * from "./pod-security-policy-details"; export * from "./pod-security-policy-details";

View File

@ -1,8 +0,0 @@
import type { RouteProps } from "react-router";
import { buildURL } from "../../../common/utils/buildUrl";
export const podSecurityPoliciesRoute: RouteProps = {
path: "/pod-security-policies"
};
export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path);

View File

@ -1,12 +1,15 @@
import { RouteProps } from "react-router"; import { RouteProps } from "react-router";
import { volumeClaimsURL } from "../+storage-volume-claims"; import { storageClassesRoute } from "../+storage-classes";
import { Storage } from "./storage"; import { volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims";
import { volumesRoute } from "../+storage-volumes";
import { IURLParams } from "../../../common/utils/buildUrl"; import { IURLParams } from "../../../common/utils/buildUrl";
export const storageRoute: RouteProps = { export const storageRoute: RouteProps = {
get path() { path: [
return Storage.tabRoutes.map(({ routePath }) => routePath).flat(); volumeClaimsRoute,
} volumesRoute,
storageClassesRoute
].map(route => route.path.toString())
}; };
export const storageURL = (params?: IURLParams) => volumeClaimsURL(params); export const storageURL = (params?: IURLParams) => volumeClaimsURL(params);

View File

@ -1,12 +1,5 @@
import type { RouteProps } from "react-router"; import type { RouteProps } from "react-router";
import { buildURL, IURLParams } from "../../../common/utils/buildUrl"; import { buildURL, IURLParams } from "../../../common/utils/buildUrl";
import { UserManagement } from "./user-management";
export const usersManagementRoute: RouteProps = {
get path() {
return UserManagement.tabRoutes.map(({ routePath }) => routePath).flat();
}
};
// Routes // Routes
export const serviceAccountsRoute: RouteProps = { export const serviceAccountsRoute: RouteProps = {
@ -18,6 +11,18 @@ export const rolesRoute: RouteProps = {
export const roleBindingsRoute: RouteProps = { export const roleBindingsRoute: RouteProps = {
path: "/role-bindings" path: "/role-bindings"
}; };
export const podSecurityPoliciesRoute: RouteProps = {
path: "/pod-security-policies"
};
export const usersManagementRoute: RouteProps = {
path: [
serviceAccountsRoute,
roleBindingsRoute,
rolesRoute,
podSecurityPoliciesRoute
].map(route => route.path.toString())
};
// Route params // Route params
export interface IServiceAccountsRouteParams { export interface IServiceAccountsRouteParams {
@ -34,3 +39,4 @@ export const usersManagementURL = (params?: IURLParams) => serviceAccountsURL(pa
export const serviceAccountsURL = buildURL<IServiceAccountsRouteParams>(serviceAccountsRoute.path); export const serviceAccountsURL = buildURL<IServiceAccountsRouteParams>(serviceAccountsRoute.path);
export const roleBindingsURL = buildURL<IRoleBindingsRouteParams>(roleBindingsRoute.path); export const roleBindingsURL = buildURL<IRoleBindingsRouteParams>(roleBindingsRoute.path);
export const rolesURL = buildURL<IRoleBindingsRouteParams>(rolesRoute.path); export const rolesURL = buildURL<IRoleBindingsRouteParams>(rolesRoute.path);
export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path);

View File

@ -5,9 +5,9 @@ import { TabLayout, TabLayoutRoute } from "../layout/tab-layout";
import { Roles } from "../+user-management-roles"; import { Roles } from "../+user-management-roles";
import { RoleBindings } from "../+user-management-roles-bindings"; import { RoleBindings } from "../+user-management-roles-bindings";
import { ServiceAccounts } from "../+user-management-service-accounts"; import { ServiceAccounts } from "../+user-management-service-accounts";
import { roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route"; import { podSecurityPoliciesRoute, podSecurityPoliciesURL, roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route";
import { namespaceUrlParam } from "../+namespaces/namespace.store"; import { namespaceUrlParam } from "../+namespaces/namespace.store";
import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies"; import { PodSecurityPolicies } from "../+pod-security-policies";
import { isAllowedResource } from "../../../common/rbac"; import { isAllowedResource } from "../../../common/rbac";
@observer @observer

View File

@ -53,6 +53,7 @@ export class PodDetailsContainer extends React.Component<Props> {
const state = status ? Object.keys(status.state)[0] : ""; const state = status ? Object.keys(status.state)[0] : "";
const lastState = status ? Object.keys(status.lastState)[0] : ""; const lastState = status ? Object.keys(status.lastState)[0] : "";
const ready = status ? status.ready : ""; const ready = status ? status.ready : "";
const imageId = status? status.imageID : "";
const liveness = pod.getLivenessProbe(container); const liveness = pod.getLivenessProbe(container);
const readiness = pod.getReadinessProbe(container); const readiness = pod.getReadinessProbe(container);
const startup = pod.getStartupProbe(container); const startup = pod.getStartupProbe(container);
@ -84,7 +85,7 @@ export class PodDetailsContainer extends React.Component<Props> {
</DrawerItem> </DrawerItem>
} }
<DrawerItem name="Image"> <DrawerItem name="Image">
{image} <Badge label={image} tooltip={imageId}/>
</DrawerItem> </DrawerItem>
{imagePullPolicy && imagePullPolicy !== "IfNotPresent" && {imagePullPolicy && imagePullPolicy !== "IfNotPresent" &&
<DrawerItem name="ImagePullPolicy"> <DrawerItem name="ImagePullPolicy">

View File

@ -1,7 +1,7 @@
.PodDetailsSecrets { .PodDetailsSecrets {
a { > * {
display: block; display: block;
margin-bottom: $margin; margin-bottom: var(--margin);
&:last-child { &:last-child {
margin-bottom: 0; margin-bottom: 0;

View File

@ -13,33 +13,49 @@ interface Props {
@observer @observer
export class PodDetailsSecrets extends Component<Props> { export class PodDetailsSecrets extends Component<Props> {
@observable secrets: Secret[] = []; @observable secrets: Map<string, Secret> = observable.map<string, Secret>();
@disposeOnUnmount @disposeOnUnmount
secretsLoader = autorun(async () => { secretsLoader = autorun(async () => {
const { pod } = this.props; const { pod } = this.props;
this.secrets = await Promise.all( const secrets = await Promise.all(
pod.getSecrets().map(secretName => secretsApi.get({ pod.getSecrets().map(secretName => secretsApi.get({
name: secretName, name: secretName,
namespace: pod.getNs(), namespace: pod.getNs(),
})) }))
); );
secrets.forEach(secret => secret && this.secrets.set(secret.getName(), secret));
}); });
render() { render() {
const { pod } = this.props;
return ( return (
<div className="PodDetailsSecrets"> <div className="PodDetailsSecrets">
{ {
this.secrets.map(secret => { pod.getSecrets().map(secretName => {
return ( const secret = this.secrets.get(secretName);
<Link key={secret.getId()} to={getDetailsUrl(secret.selfLink)}>
{secret.getName()} if (secret) {
</Link> return this.renderSecretLink(secret);
); } else {
return (
<span key={secretName}>{secretName}</span>
);
}
}) })
} }
</div> </div>
); );
} }
protected renderSecretLink(secret: Secret) {
return (
<Link key={secret.getId()} to={getDetailsUrl(secret.selfLink)}>
{secret.getName()}
</Link>
);
}
} }

View File

@ -1,13 +1,6 @@
import type { RouteProps } from "react-router"; import type { RouteProps } from "react-router";
import { buildURL, IURLParams } from "../../../common/utils/buildUrl"; import { buildURL, IURLParams } from "../../../common/utils/buildUrl";
import { KubeResource } from "../../../common/rbac"; import { KubeResource } from "../../../common/rbac";
import { Workloads } from "./workloads";
export const workloadsRoute: RouteProps = {
get path() {
return Workloads.tabRoutes.map(({ routePath }) => routePath).flat();
}
};
// Routes // Routes
export const overviewRoute: RouteProps = { export const overviewRoute: RouteProps = {
@ -35,6 +28,19 @@ export const cronJobsRoute: RouteProps = {
path: "/cronjobs" path: "/cronjobs"
}; };
export const workloadsRoute: RouteProps = {
path: [
overviewRoute,
podsRoute,
deploymentsRoute,
daemonSetsRoute,
statefulSetsRoute,
replicaSetsRoute,
jobsRoute,
cronJobsRoute
].map(route => route.path.toString())
};
// Route params // Route params
export interface IWorkloadsOverviewRouteParams { export interface IWorkloadsOverviewRouteParams {
} }

View File

@ -14,6 +14,7 @@ import { clusterViewURL } from "../cluster-manager/cluster-view.route";
@observer @observer
export class ChooseWorkspace extends React.Component { export class ChooseWorkspace extends React.Component {
private static overviewActionId = "__overview__";
private static addActionId = "__add__"; private static addActionId = "__add__";
private static removeActionId = "__remove__"; private static removeActionId = "__remove__";
private static editActionId = "__edit__"; private static editActionId = "__edit__";
@ -23,6 +24,8 @@ export class ChooseWorkspace extends React.Component {
return { value: workspace.id, label: workspace.name }; return { value: workspace.id, label: workspace.name };
}); });
options.push({ value: ChooseWorkspace.overviewActionId, label: "Show current workspace overview ..." });
options.push({ value: ChooseWorkspace.addActionId, label: "Add workspace ..." }); options.push({ value: ChooseWorkspace.addActionId, label: "Add workspace ..." });
if (options.length > 1) { if (options.length > 1) {
@ -37,6 +40,13 @@ export class ChooseWorkspace extends React.Component {
} }
onChange(id: string) { onChange(id: string) {
if (id === ChooseWorkspace.overviewActionId) {
navigate(landingURL()); // overview of active workspace. TODO: change name from landing
CommandOverlay.close();
return;
}
if (id === ChooseWorkspace.addActionId) { if (id === ChooseWorkspace.addActionId) {
CommandOverlay.open(<AddWorkspace />); CommandOverlay.open(<AddWorkspace />);

View File

@ -151,7 +151,7 @@ code {
vertical-align: middle; vertical-align: middle;
border-radius: $radius; border-radius: $radius;
font-family: $font-monospace; font-family: $font-monospace;
font-size: calc($font-size * .9); font-size: calc(var(--font-size) * .9);
color: #b4b5b4; color: #b4b5b4;
&.block { &.block {

View File

@ -91,27 +91,15 @@ export class App extends React.Component {
reaction(() => this.warningsTotal, (count: number) => { reaction(() => this.warningsTotal, (count: number) => {
broadcastMessage(`cluster-warning-event-count:${getHostedCluster().id}`, count); broadcastMessage(`cluster-warning-event-count:${getHostedCluster().id}`, count);
}), }),
reaction(() => clusterPageMenuRegistry.getRootItems(), (rootItems) => {
this.generateExtensionTabLayoutRoutes(rootItems);
}, {
fireImmediately: true
})
]); ]);
} }
@observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? clusterURL() : workloadsURL();
@computed get warningsTotal(): number { @computed get warningsTotal(): number {
return nodesStore.getWarningsCount() + eventStore.getWarningsCount(); return nodesStore.getWarningsCount() + eventStore.getWarningsCount();
} }
get startURL() {
if (isAllowedResource(["events", "nodes", "pods"])) {
return clusterURL();
}
return workloadsURL();
}
getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) { getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) {
const routes: TabLayoutRoute[] = []; const routes: TabLayoutRoute[] = [];
@ -152,38 +140,6 @@ export class App extends React.Component {
}); });
} }
@observable extensionRoutes: Map<ClusterPageMenuRegistration, React.ReactNode> = new Map();
generateExtensionTabLayoutRoutes(rootItems: ClusterPageMenuRegistration[]) {
rootItems.forEach((menu, index) => {
let route = this.extensionRoutes.get(menu);
if (!route) {
const tabRoutes = this.getTabLayoutRoutes(menu);
if (tabRoutes.length > 0) {
const pageComponent = () => <TabLayout tabs={tabRoutes}/>;
route = <Route key={`extension-tab-layout-route-${index}`} component={pageComponent} path={tabRoutes.map((tab) => tab.routePath)}/>;
this.extensionRoutes.set(menu, route);
} else {
const page = clusterPageRegistry.getByPageTarget(menu.target);
if (page) {
route = <Route key={`extension-tab-layout-route-${index}`} path={page.url} component={page.components.Page}/>;
this.extensionRoutes.set(menu, route);
}
}
}
});
for (const menu of this.extensionRoutes.keys()) {
if (!rootItems.includes(menu)) {
this.extensionRoutes.delete(menu);
}
}
}
renderExtensionRoutes() { renderExtensionRoutes() {
return clusterPageRegistry.getItems().map((page, index) => { return clusterPageRegistry.getItems().map((page, index) => {
const menu = clusterPageMenuRegistry.getByPage(page); const menu = clusterPageMenuRegistry.getByPage(page);
@ -195,8 +151,6 @@ export class App extends React.Component {
} }
render() { render() {
const cluster = getHostedCluster();
return ( return (
<Router history={history}> <Router history={history}>
<ErrorBoundary> <ErrorBoundary>
@ -215,7 +169,7 @@ export class App extends React.Component {
<Route component={Apps} {...appsRoute}/> <Route component={Apps} {...appsRoute}/>
{this.renderExtensionTabLayoutRoutes()} {this.renderExtensionTabLayoutRoutes()}
{this.renderExtensionRoutes()} {this.renderExtensionRoutes()}
<Redirect exact from="/" to={this.startURL}/> <Redirect exact from="/" to={this.startUrl}/>
<Route component={NotFound}/> <Route component={NotFound}/>
</Switch> </Switch>
</MainLayout> </MainLayout>
@ -228,7 +182,7 @@ export class App extends React.Component {
<StatefulSetScaleDialog/> <StatefulSetScaleDialog/>
<ReplicaSetScaleDialog/> <ReplicaSetScaleDialog/>
<CronJobTriggerDialog/> <CronJobTriggerDialog/>
<CommandContainer cluster={cluster}/> <CommandContainer clusterId={getHostedCluster()?.id}/>
</ErrorBoundary> </ErrorBoundary>
</Router> </Router>
); );

View File

@ -1,42 +0,0 @@
import ChartJS from "chart.js";
import get from "lodash/get";
const defaultOptions = {
interval: 61,
coverBars: 3,
borderColor: "#44474A",
backgroundColor: "#00000033"
};
export const BackgroundBlock = {
options: {},
getOptions(chart: ChartJS) {
return get(chart, "options.plugins.BackgroundBlock");
},
afterInit(chart: ChartJS) {
this.options = {
...defaultOptions,
...this.getOptions(chart)
};
},
beforeDraw(chart: ChartJS) {
if (!chart.chartArea) return;
const { interval, coverBars, borderColor, backgroundColor } = this.options;
const { ctx, chartArea } = chart;
const { left, right, top, bottom } = chartArea;
const blockWidth = (right - left) / interval * coverBars;
ctx.save();
ctx.fillStyle = backgroundColor;
ctx.strokeStyle = borderColor;
ctx.fillRect(right - blockWidth, top, blockWidth, bottom - top);
ctx.beginPath();
ctx.moveTo(right - blockWidth + 1.5, top);
ctx.lineTo(right - blockWidth + 1.5, bottom);
ctx.stroke();
ctx.restore();
}
};

View File

@ -1,45 +0,0 @@
import moment from "moment";
import { useState, useEffect } from "react";
import { useInterval } from "../../hooks";
type IMetricValues = [number, string][];
type IChartData = { x: number; y: string }[];
const defaultParams = {
fetchInterval: 15,
updateInterval: 5
};
export function useRealTimeMetrics(metrics: IMetricValues, chartData: IChartData, params = defaultParams) {
const [index, setIndex] = useState(0);
const { fetchInterval, updateInterval } = params;
const rangeMetrics = metrics.slice(-updateInterval);
const steps = fetchInterval / updateInterval;
const data = [...chartData];
useEffect(() => {
setIndex(0);
}, [metrics]);
useInterval(() => {
if (index < steps + 1) {
setIndex(index + steps - 1);
}
}, updateInterval * 1000);
if (data.length && metrics.length) {
const lastTime = data[data.length - 1].x;
const values = [];
for (let i = 0; i < 3; i++) {
values[i] = moment.unix(lastTime).add(i + 1, "m").unix();
}
data.push(
{ x: values[0], y: "0" },
{ x: values[1], y: parseFloat(rangeMetrics[index][1]).toFixed(3) },
{ x: values[2], y: "0" }
);
}
return data;
}

View File

@ -27,14 +27,16 @@
} }
} }
> .add-cluster { > .WorkspaceMenu {
position: relative; position: relative;
margin-bottom: $margin;
.Icon { .Icon {
margin-bottom: $margin * 1.5;
border-radius: $radius; border-radius: $radius;
padding: $padding / 3; padding: $padding / 3;
color: $addClusterIconColor; color: #ffffff66;
background: #ffffff66; background: unset;
cursor: pointer; cursor: pointer;
&.active { &.active {
@ -43,28 +45,10 @@
&:hover { &:hover {
box-shadow: none; box-shadow: none;
background: #ffffff; color: #ffffff;
background-color: unset;
} }
} }
.Badge {
$boxSize: 17px;
position: absolute;
bottom: 0px;
transform: translateX(-50%) translateY(50%);
font-size: $font-size-small;
line-height: $boxSize;
min-width: $boxSize;
min-height: $boxSize;
text-align: center;
color: white;
background: $colorSuccess;
font-weight: normal;
border-radius: $radius;
padding: 0;
pointer-events: none;
}
} }
> .extensions { > .extensions {

View File

@ -6,26 +6,24 @@ import { requestMain } from "../../../common/ipc";
import type { Cluster } from "../../../main/cluster"; import type { Cluster } from "../../../main/cluster";
import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd"; import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { userStore } from "../../../common/user-store";
import { ClusterId, clusterStore } from "../../../common/cluster-store"; import { ClusterId, clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store"; import { workspaceStore } from "../../../common/workspace-store";
import { ClusterIcon } from "../cluster-icon"; import { ClusterIcon } from "../cluster-icon";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { autobind, cssNames, IClassName } from "../../utils"; import { autobind, cssNames, IClassName } from "../../utils";
import { Badge } from "../badge";
import { isActiveRoute, navigate } from "../../navigation"; import { isActiveRoute, navigate } from "../../navigation";
import { addClusterURL } from "../+add-cluster"; import { addClusterURL } from "../+add-cluster";
import { clusterSettingsURL } from "../+cluster-settings"; import { clusterSettingsURL } from "../+cluster-settings";
import { landingURL } from "../+landing-page"; import { landingURL } from "../+landing-page";
import { Tooltip } from "../tooltip";
import { ConfirmDialog } from "../confirm-dialog"; import { ConfirmDialog } from "../confirm-dialog";
import { clusterViewURL } from "./cluster-view.route"; import { clusterViewURL } from "./cluster-view.route";
import { getExtensionPageUrl, globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries"; import { getExtensionPageUrl, globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries";
import { clusterDisconnectHandler } from "../../../common/cluster-ipc"; import { clusterDisconnectHandler } from "../../../common/cluster-ipc";
import { commandRegistry } from "../../../extensions/registries/command-registry"; import { commandRegistry } from "../../../extensions/registries/command-registry";
import { CommandOverlay } from "../command-palette/command-container"; import { CommandOverlay } from "../command-palette/command-container";
import { computed } from "mobx"; import { computed, observable } from "mobx";
import { Select } from "../select"; import { Select } from "../select";
import { Menu, MenuItem } from "../menu";
interface Props { interface Props {
className?: IClassName; className?: IClassName;
@ -33,14 +31,12 @@ interface Props {
@observer @observer
export class ClustersMenu extends React.Component<Props> { export class ClustersMenu extends React.Component<Props> {
@observable workspaceMenuVisible = false;
showCluster = (clusterId: ClusterId) => { showCluster = (clusterId: ClusterId) => {
navigate(clusterViewURL({ params: { clusterId } })); navigate(clusterViewURL({ params: { clusterId } }));
}; };
addCluster = () => {
navigate(addClusterURL());
};
showContextMenu = (cluster: Cluster) => { showContextMenu = (cluster: Cluster) => {
const { Menu, MenuItem } = remote; const { Menu, MenuItem } = remote;
const menu = new Menu(); const menu = new Menu();
@ -111,7 +107,6 @@ export class ClustersMenu extends React.Component<Props> {
render() { render() {
const { className } = this.props; const { className } = this.props;
const { newContexts } = userStore;
const workspace = workspaceStore.getById(workspaceStore.currentWorkspaceId); const workspace = workspaceStore.getById(workspaceStore.currentWorkspaceId);
const clusters = clusterStore.getByWorkspaceId(workspace.id).filter(cluster => cluster.enabled); const clusters = clusterStore.getByWorkspaceId(workspace.id).filter(cluster => cluster.enabled);
const activeClusterId = clusterStore.activeCluster; const activeClusterId = clusterStore.activeCluster;
@ -149,14 +144,25 @@ export class ClustersMenu extends React.Component<Props> {
</Droppable> </Droppable>
</DragDropContext> </DragDropContext>
</div> </div>
<div className="add-cluster">
<Tooltip targetId="add-cluster-icon"> <div className="WorkspaceMenu">
Add Cluster <Icon big material="menu" id="workspace-menu-icon" data-test-id="workspace-menu" />
</Tooltip> <Menu
<Icon big material="add" id="add-cluster-icon" disabled={workspace.isManaged} onClick={this.addCluster}/> usePortal
{newContexts.size > 0 && ( htmlFor="workspace-menu-icon"
<Badge className="counter" label={newContexts.size} tooltip="new"/> className="WorkspaceMenu"
)} isOpen={this.workspaceMenuVisible}
open={() => this.workspaceMenuVisible = true}
close={() => this.workspaceMenuVisible = false}
toggleEvent="click"
>
<MenuItem onClick={() => navigate(addClusterURL())} data-test-id="add-cluster-menu-item">
<Icon small material="add" /> Add Cluster
</MenuItem>
<MenuItem onClick={() => navigate(landingURL())} data-test-id="workspace-overview-menu-item">
<Icon small material="dashboard" /> Workspace Overview
</MenuItem>
</Menu>
</div> </div>
<div className="extensions"> <div className="extensions">
{globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => { {globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => {

View File

@ -10,7 +10,6 @@ import { CommandDialog } from "./command-dialog";
import { CommandRegistration, commandRegistry } from "../../../extensions/registries/command-registry"; import { CommandRegistration, commandRegistry } from "../../../extensions/registries/command-registry";
import { clusterStore } from "../../../common/cluster-store"; import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store"; import { workspaceStore } from "../../../common/workspace-store";
import { Cluster } from "../../../main/cluster";
export type CommandDialogEvent = { export type CommandDialogEvent = {
component: React.ReactElement component: React.ReactElement
@ -29,7 +28,7 @@ export class CommandOverlay {
} }
@observer @observer
export class CommandContainer extends React.Component<{cluster?: Cluster}> { export class CommandContainer extends React.Component<{ clusterId?: string }> {
@observable.ref commandComponent: React.ReactElement; @observable.ref commandComponent: React.ReactElement;
private escHandler(event: KeyboardEvent) { private escHandler(event: KeyboardEvent) {
@ -56,8 +55,8 @@ export class CommandContainer extends React.Component<{cluster?: Cluster}> {
} }
componentDidMount() { componentDidMount() {
if (this.props.cluster) { if (this.props.clusterId) {
subscribeToBroadcast(`command-palette:run-action:${this.props.cluster.id}`, (event, commandId: string) => { subscribeToBroadcast(`command-palette:run-action:${this.props.clusterId}`, (event, commandId: string) => {
const command = this.findCommandById(commandId); const command = this.findCommandById(commandId);
if (command) { if (command) {

View File

@ -52,7 +52,7 @@ export class CreateResource extends React.Component<Props> {
); );
if (errors.length) { if (errors.length) {
errors.forEach(Notifications.error); errors.forEach(error => Notifications.error(error));
if (!createdResources.length) throw errors[0]; if (!createdResources.length) throw errors[0];
} }
const successMessage = ( const successMessage = (

View File

@ -89,7 +89,8 @@ const defaultProps: Partial<ItemListLayoutProps> = {
filterItems: [], filterItems: [],
hasDetailsView: true, hasDetailsView: true,
onDetails: noop, onDetails: noop,
virtual: true virtual: true,
customizeTableRowProps: () => ({} as TableRowProps),
}; };
interface ItemListLayoutUserSettings { interface ItemListLayoutUserSettings {
@ -196,17 +197,12 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
return filters.reduce((items, filter) => filter(items), items); return filters.reduce((items, filter) => filter(items), items);
} }
@computed get allItems() {
const { filterItems, store } = this.props;
return this.applyFilters(filterItems, store.items);
}
@computed get items() { @computed get items() {
const { allItems, filters, filterCallbacks } = this; const {filters, filterCallbacks } = this;
const filterItems: ItemsFilter[] = [];
const filterGroups = groupBy<Filter>(filters, ({ type }) => type); const filterGroups = groupBy<Filter>(filters, ({ type }) => type);
const filterItems: ItemsFilter[] = [];
Object.entries(filterGroups).forEach(([type, filtersGroup]) => { Object.entries(filterGroups).forEach(([type, filtersGroup]) => {
const filterCallback = filterCallbacks[type]; const filterCallback = filterCallbacks[type];
@ -215,9 +211,9 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
} }
}); });
const items = this.props.items ?? allItems; const items = this.props.items ?? this.props.store.items;
return this.applyFilters(filterItems, items); return this.applyFilters(filterItems.concat(this.props.filterItems), items);
} }
@autobind() @autobind()
@ -241,7 +237,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
sortItem={item} sortItem={item}
selected={detailsItem && detailsItem.getId() === itemId} selected={detailsItem && detailsItem.getId() === itemId}
onClick={hasDetailsView ? prevDefault(() => onDetails(item)) : undefined} onClick={hasDetailsView ? prevDefault(() => onDetails(item)) : undefined}
{...(customizeTableRowProps ? customizeTableRowProps(item) : {})} {...customizeTableRowProps(item)}
> >
{isSelectable && ( {isSelectable && (
<TableCell <TableCell
@ -322,6 +318,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
} }
renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode { renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode {
const { isSearchable, searchFilters } = this.props;
const { title, filters, search, info } = placeholders; const { title, filters, search, info } = placeholders;
return ( return (
@ -331,7 +328,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
{this.isReady && info} {this.isReady && info}
</div> </div>
{filters} {filters}
{search} {isSearchable && searchFilters && search}
</> </>
); );
} }
@ -392,19 +389,21 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
} }
renderTableHeader() { renderTableHeader() {
const { renderTableHeader, isSelectable, isConfigurable, store } = this.props; const { customizeTableRowProps, renderTableHeader, isSelectable, isConfigurable, store } = this.props;
if (!renderTableHeader) { if (!renderTableHeader) {
return; return;
} }
const enabledItems = this.items.filter(item => !customizeTableRowProps(item).disabled);
return ( return (
<TableHead showTopLine nowrap> <TableHead showTopLine nowrap>
{isSelectable && ( {isSelectable && (
<TableCell <TableCell
checkbox checkbox
isChecked={store.isSelectedAll(this.items)} isChecked={store.isSelectedAll(enabledItems)}
onClick={prevDefault(() => store.toggleSelectionAll(this.items))} onClick={prevDefault(() => store.toggleSelectionAll(enabledItems))}
/> />
)} )}
{renderTableHeader.map((cellProps, index) => { {renderTableHeader.map((cellProps, index) => {

View File

@ -1,3 +1,4 @@
import { observer } from "mobx-react";
import React from "react"; import React from "react";
import { clusterSettingsURL } from "../+cluster-settings"; import { clusterSettingsURL } from "../+cluster-settings";
@ -11,7 +12,7 @@ interface Props {
className?: string className?: string
} }
export function MainLayoutHeader({ cluster, className }: Props) { export const MainLayoutHeader = observer(({ cluster, className }: Props) => {
return ( return (
<header className={cssNames("flex gaps align-center justify-space-between", className)}> <header className={cssNames("flex gaps align-center justify-space-between", className)}>
<span className="cluster">{cluster.name}</span> <span className="cluster">{cluster.name}</span>
@ -29,4 +30,4 @@ export function MainLayoutHeader({ cluster, className }: Props) {
/> />
</header> </header>
); );
} });

View File

@ -28,8 +28,9 @@
left: 0; left: 0;
top: 0; top: 0;
right: 0; right: 0;
bottom: 0; bottom: 24px;
background-color: var(--mainBackground); height: unset;
background-color: $mainBackground;
// adds extra space for traffic-light top buttons (mac only) // adds extra space for traffic-light top buttons (mac only)
.is-mac & > .header { .is-mac & > .header {
@ -115,4 +116,4 @@
margin-top: 0; margin-top: 0;
} }
} }
} }

View File

@ -21,11 +21,12 @@ export class Notifications extends React.Component {
}); });
} }
static error(message: NotificationMessage) { static error(message: NotificationMessage, customOpts: Partial<Notification> = {}) {
notificationsStore.add({ notificationsStore.add({
message, message,
timeout: 10000, timeout: 10000,
status: NotificationStatus.ERROR status: NotificationStatus.ERROR,
...customOpts
}); });
} }

View File

@ -4,7 +4,7 @@ import { areArgsUpdateAvailableFromMain, UpdateAvailableChannel, onCorrect, Upda
import { Notifications, notificationsStore } from "../components/notifications"; import { Notifications, notificationsStore } from "../components/notifications";
import { Button } from "../components/button"; import { Button } from "../components/button";
import { isMac } from "../../common/vars"; import { isMac } from "../../common/vars";
import * as uuid from "uuid"; import { invalidKubeconfigHandler } from "./invalid-kubeconfig-handler";
function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void { function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void {
notificationsStore.remove(notificationId); notificationsStore.remove(notificationId);
@ -30,7 +30,7 @@ function RenderYesButtons(props: { backchannel: string, notificationId: string }
} }
function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, updateInfo]: UpdateAvailableFromMain): void { function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, updateInfo]: UpdateAvailableFromMain): void {
const notificationId = uuid.v4(); const notificationId = `update-available:${updateInfo.version}`;
Notifications.info( Notifications.info(
( (
@ -58,4 +58,5 @@ export function registerIpcHandlers() {
listener: UpdateAvailableHandler, listener: UpdateAvailableHandler,
verifier: areArgsUpdateAvailableFromMain, verifier: areArgsUpdateAvailableFromMain,
}); });
onCorrect(invalidKubeconfigHandler);
} }

View File

@ -0,0 +1,46 @@
import React from "react";
import { ipcRenderer, IpcRendererEvent, shell } from "electron";
import { clusterStore } from "../../common/cluster-store";
import { InvalidKubeConfigArgs, InvalidKubeconfigChannel } from "../../common/ipc/invalid-kubeconfig";
import { Notifications, notificationsStore } from "../components/notifications";
import { Button } from "../components/button";
export const invalidKubeconfigHandler = {
source: ipcRenderer,
channel: InvalidKubeconfigChannel,
listener: InvalidKubeconfigListener,
verifier: (args: [unknown]): args is InvalidKubeConfigArgs => {
return args.length === 1 && typeof args[0] === "string" && !!clusterStore.getById(args[0]);
},
};
function InvalidKubeconfigListener(event: IpcRendererEvent, ...[clusterId]: InvalidKubeConfigArgs): void {
const notificationId = `invalid-kubeconfig:${clusterId}`;
const cluster = clusterStore.getById(clusterId);
const contextName = cluster.name !== cluster.contextName ? `(context: ${cluster.contextName})` : "";
Notifications.error(
(
<div className="flex column gaps">
<b>Cluster with Invalid Kubeconfig Detected!</b>
<p>Cluster <b>{cluster.name}</b> has invalid kubeconfig {contextName} and cannot be displayed.
Please fix the <a href="#" onClick={(e) => { e.preventDefault(); shell.showItemInFolder(cluster.kubeConfigPath); }}>kubeconfig</a> manually and restart Lens
or remove the cluster.</p>
<p>Do you want to remove the cluster now?</p>
<div className="flex gaps row align-left box grow">
<Button active outlined label="Remove" onClick={()=> {
clusterStore.removeById(clusterId);
notificationsStore.remove(notificationId);
}} />
<Button active outlined label="Cancel" onClick={() => notificationsStore.remove(notificationId)} />
</div>
</div>
),
{
id: notificationId,
timeout: 0
}
);
}

View File

@ -12,12 +12,15 @@ import { ConfirmDialog } from "./components/confirm-dialog";
import { extensionLoader } from "../extensions/extension-loader"; import { extensionLoader } from "../extensions/extension-loader";
import { broadcastMessage } from "../common/ipc"; import { broadcastMessage } from "../common/ipc";
import { CommandContainer } from "./components/command-palette/command-container"; import { CommandContainer } from "./components/command-palette/command-container";
import { LensProtocolRouterRenderer } from "./protocol-handler/router";
import { registerIpcHandlers } from "./ipc"; import { registerIpcHandlers } from "./ipc";
import { ipcRenderer } from "electron";
@observer @observer
export class LensApp extends React.Component { export class LensApp extends React.Component {
static async init() { static async init() {
extensionLoader.loadOnClusterManagerRenderer(); extensionLoader.loadOnClusterManagerRenderer();
LensProtocolRouterRenderer.getInstance<LensProtocolRouterRenderer>().init();
window.addEventListener("offline", () => { window.addEventListener("offline", () => {
broadcastMessage("network:offline"); broadcastMessage("network:offline");
}); });
@ -26,6 +29,7 @@ export class LensApp extends React.Component {
}); });
registerIpcHandlers(); registerIpcHandlers();
ipcRenderer.send("renderer:loaded");
} }
render() { render() {

View File

@ -1,8 +1,10 @@
// Navigation (renderer) // Navigation (renderer)
import { bindEvents } from "./events"; import { bindEvents } from "./events";
import { bindProtocolHandlers } from "./protocol-handlers";
export * from "./history"; export * from "./history";
export * from "./helpers"; export * from "./helpers";
bindEvents(); bindEvents();
bindProtocolHandlers();

View File

@ -0,0 +1,10 @@
import { LensProtocolRouterRenderer } from "../protocol-handler/router";
import { navigate } from "./helpers";
export function bindProtocolHandlers() {
const lprr = LensProtocolRouterRenderer.getInstance<LensProtocolRouterRenderer>();
lprr.addInternalHandler("/preferences", () => {
navigate("/preferences");
});
}

View File

@ -0,0 +1 @@
export * from "./router.ts";

View File

@ -0,0 +1,40 @@
import { ipcRenderer } from "electron";
import * as proto from "../../common/protocol-handler";
import logger from "../../main/logger";
import Url from "url-parse";
import { autobind } from "../utils";
export class LensProtocolRouterRenderer extends proto.LensProtocolRouter {
/**
* This function is needed to be called early on in the renderers lifetime.
*/
public init(): void {
ipcRenderer
.on(proto.ProtocolHandlerInternal, this.ipcInternalHandler)
.on(proto.ProtocolHandlerExtension, this.ipcExtensionHandler);
}
@autobind()
private ipcInternalHandler(event: Electron.IpcRendererEvent, ...args: any[]): void {
if (args.length !== 1) {
return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: unexpected number of args`, { args });
}
const [rawUrl] = args;
const url = new Url(rawUrl, true);
this._routeToInternal(url);
}
@autobind()
private ipcExtensionHandler(event: Electron.IpcRendererEvent, ...args: any[]): void {
if (args.length !== 1) {
return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: unexpected number of args`, { args });
}
const [rawUrl] = args;
const url = new Url(rawUrl, true);
this._routeToExtension(url);
}
}

View File

@ -1,7 +1,5 @@
// Common usage utils & helpers // Common usage utils & helpers
export const isElectron = !!navigator.userAgent.match(/Electron/);
export * from "../../common/utils"; export * from "../../common/utils";
export * from "./cssVar"; export * from "./cssVar";

View File

@ -2,7 +2,49 @@
Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights! Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights!
## 4.1.0 (current version) ## 4.2.0-alpha.1 (current version)
- Add lens:// protocol handling with a routing mechanism
- Notify about update after it has been downloaded
- Add persistent volumes info to storage class submenu
- Fix: proper sorting resources by age column
- Fix: events sorting with compact=true is broken
## 4.1.4
- Ignore clusters with invalid kubeconfig
- Render only secret name on pod details without access to secrets
- Pass Lens wslenvs to terminal session on Windows
- Prevent top-level re-rendering on cluster refresh
- Extract chart version ignoring numbers in chart name
- The select all checkbox should not select disabled items
- Fix: Pdb should have policy group
- Fix: kubectl rollout not exiting properly on Lens terminal
## 4.1.3
- Don't reset selected namespaces to defaults in case of "All namespaces" on page reload
- Fix loading all namespaces for users with limited cluster access
- Display environment variables coming from secret in pod details
- Fix deprecated helm chart filtering
- Fix RoleBindings Namespace and Bindings field not displaying the correct data
- Fix RoleBindingDetails not rendering the name of the role binding
- Fix auto update on quit with newer version
## 4.1.2
**Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more.
- Fix an issue where a cluster gets stuck on "Connecting ..." phase
- Fix an issue with auto-update
## 4.1.1
- Fix an issue where users with rights to a single namespace were seeing an empty dashboard
- Windows: use SHELL for terminal if set
- Keep highlighted table row during navigation in the details panel
## 4.1.0
**Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more. **Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more.

View File

@ -1791,6 +1791,11 @@
resolved "https://registry.yarnpkg.com/@types/universal-analytics/-/universal-analytics-0.4.4.tgz#496a52b92b599a0112bec7c12414062de6ea8449" resolved "https://registry.yarnpkg.com/@types/universal-analytics/-/universal-analytics-0.4.4.tgz#496a52b92b599a0112bec7c12414062de6ea8449"
integrity sha512-9g3F0SGxVr4UDd6y07bWtFnkpSSX1Ake7U7AGHgSFrwM6pF53/fV85bfxT2JLWS/3sjLCcyzoYzQlCxpkVo7wA== integrity sha512-9g3F0SGxVr4UDd6y07bWtFnkpSSX1Ake7U7AGHgSFrwM6pF53/fV85bfxT2JLWS/3sjLCcyzoYzQlCxpkVo7wA==
"@types/url-parse@^1.4.3":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@types/url-parse/-/url-parse-1.4.3.tgz#fba49d90f834951cb000a674efee3d6f20968329"
integrity sha512-4kHAkbV/OfW2kb5BLVUuUMoumB3CP8rHqlw48aHvFy5tf9ER0AfOonBlX29l/DD68G70DmyhRlSYfQPSYpC5Vw==
"@types/uuid@^8.3.0": "@types/uuid@^8.3.0":
version "8.3.0" version "8.3.0"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f"
@ -2157,6 +2162,13 @@ abbrev@1, abbrev@~1.1.1:
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
abort-controller@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==
dependencies:
event-target-shim "^5.0.0"
accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
version "1.3.7" version "1.3.7"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
@ -5364,6 +5376,11 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
event-target-shim@^5.0.0:
version "5.0.1"
resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
eventemitter3@^4.0.0: eventemitter3@^4.0.0:
version "4.0.4" version "4.0.4"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384"
@ -10130,6 +10147,14 @@ onetime@^5.1.0:
dependencies: dependencies:
mimic-fn "^2.1.0" mimic-fn "^2.1.0"
open@^7.3.1:
version "7.3.1"
resolved "https://registry.yarnpkg.com/open/-/open-7.3.1.tgz#111119cb919ca1acd988f49685c4fdd0f4755356"
integrity sha512-f2wt9DCBKKjlFbjzGb8MOAW8LH8F0mrs1zc7KTjAJ9PZNQbfenzWbNP1VZJvw6ICMG9r14Ah6yfwPn7T7i646A==
dependencies:
is-docker "^2.0.0"
is-wsl "^2.1.1"
opener@^1.5.1: opener@^1.5.1:
version "1.5.2" version "1.5.2"
resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598"
@ -13792,7 +13817,7 @@ url-parse-lax@^3.0.0:
dependencies: dependencies:
prepend-http "^2.0.0" prepend-http "^2.0.0"
url-parse@^1.4.3: url-parse@^1.4.3, url-parse@^1.4.7:
version "1.4.7" version "1.4.7"
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278"
integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg==