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

Merge remote-tracking branch 'origin/master' into package-upgrade/immer

# Conflicts:
#	src/renderer/components/dock/__test__/log-tab.store.test.ts
#	yarn.lock
This commit is contained in:
Roman 2021-10-12 15:01:59 +03:00
commit 8a028b1f11
112 changed files with 3286 additions and 1410 deletions

View File

@ -1,3 +1,3 @@
disturl "https://atom.io/download/electron"
target "12.0.15"
target "13.5.1"
runtime "electron"

View File

@ -2,7 +2,9 @@
Lens releases are built by CICD automatically on git tags. The typical release process flow is the following:
1. From a clean and up to date `master` run `make release-version <version-type>` where `<version-type>` is one of the following:
1. If doing a patch release checkout the `release/vMAJOR.MINOR` branch for the appropriate `MAJOR`/`MINOR` version and manually `cherry-pick` the PRs required for the patch that were commited to master. If there are any conflicts they must be resolved manually. If necessary, get assistance from the PR authors.
1. From a clean and up to date `master` (or `release/vMAJOR.MINOR` if doing a patch release) run `make release-version <version-type>` where `<version-type>` is one of the following:
- `major`
- `minor`
- `patch`
@ -17,15 +19,15 @@ Lens releases are built by CICD automatically on git tags. The typical release p
- `rc`
This assumes origin is set to https://github.com/lensapp/lens.git. If not then set GIT_REMOTE to the remote that is set to https://github.com/lensapp/lens.git. For example run `GIT_REMOTE=upstream make release-version ...`
1. Open the PR (git should have printed a link to GitHub in the console) with the contents of all the accepted PRs since the last release. The PR description needs to be filled with the draft release description. From https://github.com/lensapp/lens click on Releases, the draft release should be first in the list, click `Edit` and copy/paste the markdown to the PR description. Add the `skip-changelog` label and click `Create Pull Request`.
1. After the PR is accepted and passes CI (and before merging), go to the same branch and run `make tag-release` (set GIT_REMOTE if necessary). This additionally runs the azure jobs to build the binaries and put them on S3.
1. Open the PR (git should have printed a link to GitHub in the console) with the contents of all the accepted PRs since the last release. The PR description needs to be filled with the draft release description. From https://github.com/lensapp/lens click on Releases, the draft release should be first in the list, click `Edit` and copy/paste the markdown to the PR description. Add the `skip-changelog` label and click `Create Pull Request`. If this is a patch release be sure to set the PR base branch to `release/vMAJOR.MINOR` instead of `master`.
1. After the PR is accepted and passes CI (and before merging), go to the same branch and run `make tag-release` (set GIT_REMOTE if necessary). This additionally triggers the azure jobs to build the binaries and put them on S3.
1. If the CI fails at this stage the problem needs to be fixed. Sometimes an azure job fails due to outside service issues (e.g. Apple signing occasionally fails), in which case the specific azure job can be rerun from https://dev.azure.com/lensapp/lensapp/_build. Otherwise changes to the codebase may need to be done and committed to the release branch and pushed to https://github.com/lensapp/lens. CI will run again. As well the release tag needs to be manually set to this new commit. You can do something like:
- `git push origin :refs/tags/vX.Y.Z-beta.N` (removes the tag from https://github.com/lensapp/lens)
- `git tag -fa vX.Y.Z-beta.N` (move the tag locally to the current commit)
- `git push origin --tags` (update the tags on https://github.com/lensapp/lens to reflect this local change)
1. Once CI passes again go to the releases tab on GitHub. You can use the existing draft release prepared by k8slens-bot (select the correct tag). Or you can create a new release from the tag that was created, and make sure that the change log is the same as that of the PR, and the title is the tag. Either way, click the prerelease checkbox if this is not a new major, minor, or patch version before clicking `Publish release`.
1. Merge the release PR after the release is published. GitHub should delete the branch once it is merged.
1. Merge the release PR after the release is published. If it is a patch release then there is no need to squash the cherry-picked commits as part of the merge. GitHub should delete the branch once it is merged.
1. If you have just released a new major or minor version then create a new `release/vMAJOR.MINOR` branch from that same tag and push it to https://github.com/lensapp/lens. Given the commit of the merged release PR from the master branch you can do this like
`git push origin <commit>:refs/heads/release/vX.Y`
@ -37,4 +39,4 @@ Other tasks
- generate a changelog from the prerelease descriptions (for major/minor releases)
- announce the release on lens and lens-hq slack channels (release is announced automatically on the community slack lens channel through the above publishing process)
- announce on lens-hq that master is open for PR merges for the next release (for major/minor releases)
- update issues on github (bump those that did not make it into the release to a subsequent release) (for major/minor releases)
- update issues on github (bump those that did not make it into the release to a subsequent release) (for major/minor/patch releases)

View File

@ -8,9 +8,9 @@
"version": "file:../../src/extensions/npm/extensions",
"dev": true,
"requires": {
"@material-ui/core": "*",
"@types/node": "*",
"@types/react-select": "*",
"@material-ui/core": "4.12.3",
"@types/node": "14.17.14",
"@types/react-select": "3.1.2",
"conf": "^7.0.1",
"typed-emitter": "^1.3.1"
},

View File

@ -662,10 +662,11 @@
"version": "file:../../src/extensions/npm/extensions",
"dev": true,
"requires": {
"@material-ui/core": "*",
"@types/node": "*",
"@types/react-select": "*",
"conf": "^7.0.1"
"@material-ui/core": "4.12.3",
"@types/node": "14.17.14",
"@types/react-select": "3.1.2",
"conf": "^7.0.1",
"typed-emitter": "^1.3.1"
},
"dependencies": {
"@babel/runtime": {
@ -1329,6 +1330,12 @@
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"dev": true
},
"typed-emitter": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.3.1.tgz",
"integrity": "sha512-2h7utWyXgd2R2u2IuL8B4yu1gqMxbgUj2VS/MGVbFhEVQNJKXoQQoS5CBMh+eW31zFeSmDfEQ3qQf4xy5SlPVQ==",
"dev": true
},
"uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -1742,9 +1749,9 @@
}
},
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true
},
"ansi-styles": {

View File

@ -628,9 +628,9 @@
"version": "file:../../src/extensions/npm/extensions",
"dev": true,
"requires": {
"@material-ui/core": "*",
"@types/node": "*",
"@types/react-select": "*",
"@material-ui/core": "4.12.3",
"@types/node": "14.17.14",
"@types/react-select": "3.1.2",
"conf": "^7.0.1",
"typed-emitter": "^1.3.1"
},
@ -1707,9 +1707,9 @@
}
},
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true
},
"ansi-styles": {

View File

@ -628,9 +628,9 @@
"version": "file:../../src/extensions/npm/extensions",
"dev": true,
"requires": {
"@material-ui/core": "*",
"@types/node": "*",
"@types/react-select": "*",
"@material-ui/core": "4.12.3",
"@types/node": "14.17.14",
"@types/react-select": "3.1.2",
"conf": "^7.0.1",
"typed-emitter": "^1.3.1"
},
@ -1660,9 +1660,9 @@
}
},
"ansi-regex": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true
},
"ansi-styles": {

View File

@ -25,16 +25,24 @@
TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube
cluster and vice versa.
*/
import type { Page } from "playwright";
import type { ElectronApplication, Page } from "playwright";
import * as utils from "../helpers/utils";
describe("preferences page tests", () => {
let window: Page, cleanup: () => Promise<void>;
beforeEach(async () => {
({ window, cleanup } = await utils.start());
let app: ElectronApplication;
({ window, cleanup, app } = await utils.start());
await utils.clickWelcomeButton(window);
await window.keyboard.press("Meta+,");
await app.evaluate(async ({ app }) => {
await app.applicationMenu
.getMenuItemById(process.platform === "darwin" ? "root" : "file")
.submenu.getMenuItemById("preferences")
.click();
});
}, 10*60*1000);
afterEach(async () => {

View File

@ -19,14 +19,14 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { Page } from "playwright";
import type { ElectronApplication, Page } from "playwright";
import * as utils from "../helpers/utils";
describe("Lens command palette", () => {
let window: Page, cleanup: () => Promise<void>;
let window: Page, cleanup: () => Promise<void>, app: ElectronApplication;
beforeEach(async () => {
({ window, cleanup } = await utils.start());
({ window, cleanup, app } = await utils.start());
await utils.clickWelcomeButton(window);
}, 10*60*1000);
@ -35,8 +35,13 @@ describe("Lens command palette", () => {
}, 10*60*1000);
describe("menu", () => {
it("opens command dialog from keyboard shortcut", async () => {
await window.keyboard.press("Meta+Shift+p");
it("opens command dialog from menu", async () => {
await app.evaluate(async ({ app }) => {
await app.applicationMenu
.getMenuItemById("view")
.submenu.getMenuItemById("command-palette")
.click();
});
await window.waitForSelector(".Select__option >> text=Hotbar: Switch");
}, 10*60*1000);
});

View File

@ -104,10 +104,7 @@ function minikubeEntityId() {
* From the catalog, click the minikube entity and wait for it to connect, returning its frame
*/
export async function lauchMinikubeClusterFromCatalog(window: Page): Promise<Frame> {
await window.waitForSelector("div.TableCell");
await window.click("div.TableCell >> text='minikube'");
await window.waitForSelector("div.drawer-title-text >> text='KubernetesCluster: minikube'");
await window.click("div.EntityIcon div.HotbarIcon div div.MuiAvatar-root");
const minikubeFrame = await window.waitForSelector(`#cluster-frame-${minikubeEntityId()}`);

View File

@ -3,7 +3,7 @@
"productName": "OpenLens",
"description": "OpenLens - Open Source IDE for Kubernetes",
"homepage": "https://github.com/lensapp/lens",
"version": "5.2.0",
"version": "5.3.0-alpha.0",
"main": "static/build/main.js",
"copyright": "© 2021 OpenLens Authors",
"license": "MIT",
@ -197,11 +197,12 @@
"conf": "^7.0.1",
"crypto-js": "^4.1.1",
"electron-devtools-installer": "^3.2.0",
"electron-updater": "^4.4.3",
"electron-updater": "^4.6.0",
"electron-window-state": "^5.0.3",
"filehound": "^1.17.4",
"fs-extra": "^9.0.1",
"glob-to-regexp": "^0.4.1",
"got": "^11.8.2",
"grapheme-splitter": "^1.0.4",
"handlebars": "^4.7.7",
"http-proxy": "^1.18.1",
@ -209,7 +210,7 @@
"joi": "^17.4.2",
"js-yaml": "^3.14.0",
"jsdom": "^16.7.0",
"jsonpath": "^1.0.2",
"jsonpath": "^1.1.1",
"lodash": "^4.17.15",
"mac-ca": "^1.0.6",
"marked": "^2.0.3",
@ -224,9 +225,8 @@
"node-fetch": "^2.6.1",
"node-pty": "^0.10.1",
"npm": "^6.14.15",
"openid-client": "^3.15.2",
"p-limit": "^3.1.0",
"path-to-regexp": "^6.1.0",
"path-to-regexp": "^6.2.0",
"proper-lockfile": "^4.1.2",
"react": "^17.0.2",
"react-dom": "^17.0.2",
@ -237,16 +237,17 @@
"readable-stream": "^3.6.0",
"request": "^2.88.2",
"request-promise-native": "^1.0.9",
"rfc6902": "^4.0.2",
"semver": "^7.3.2",
"serializr": "^2.0.5",
"shell-env": "^3.0.1",
"spdy": "^4.0.2",
"tar": "^6.1.10",
"tcp-port-used": "^1.0.1",
"tempy": "^0.5.0",
"url-parse": "^1.5.1",
"tcp-port-used": "^1.0.2",
"tempy": "1.0.1",
"url-parse": "^1.5.3",
"uuid": "^8.3.2",
"win-ca": "^3.2.0",
"win-ca": "^3.4.5",
"winston": "^3.3.3",
"winston-console-format": "^1.0.8",
"winston-transport-browserconsole": "^1.0.5",
@ -274,9 +275,9 @@
"@types/glob-to-regexp": "^0.4.1",
"@types/hapi": "^18.0.6",
"@types/hoist-non-react-statics": "^3.3.1",
"@types/html-webpack-plugin": "^3.2.3",
"@types/html-webpack-plugin": "^3.2.6",
"@types/http-proxy": "^1.17.7",
"@types/jest": "^26.0.22",
"@types/jest": "^26.0.24",
"@types/js-yaml": "^3.12.4",
"@types/jsdom": "^16.2.4",
"@types/jsonpath": "^0.2.0",
@ -290,19 +291,19 @@
"@types/node-fetch": "^2.5.12",
"@types/npm": "^2.0.32",
"@types/progress-bar-webpack-plugin": "^2.1.2",
"@types/proper-lockfile": "^4.1.1",
"@types/proper-lockfile": "^4.1.2",
"@types/randomcolor": "^0.5.6",
"@types/react": "^17.0.0",
"@types/react-beautiful-dnd": "^13.1.1",
"@types/react-dom": "^17.0.9",
"@types/react-router-dom": "^5.1.6",
"@types/react-router-dom": "^5.3.1",
"@types/react-select": "3.1.2",
"@types/react-table": "^7.7.0",
"@types/react-table": "^7.7.6",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/readable-stream": "^2.3.9",
"@types/request": "^2.48.7",
"@types/request-promise-native": "^1.0.17",
"@types/request-promise-native": "^1.0.18",
"@types/semver": "^7.2.0",
"@types/sharp": "^0.28.3",
"@types/spdy": "^3.4.4",
@ -313,26 +314,26 @@
"@types/url-parse": "^1.4.3",
"@types/uuid": "^8.3.1",
"@types/webdriverio": "^4.13.0",
"@types/webpack": "^4.41.17",
"@types/webpack-dev-server": "^3.11.1",
"@types/webpack-env": "^1.15.2",
"@types/webpack": "^4.41.31",
"@types/webpack-dev-server": "^3.11.6",
"@types/webpack-env": "^1.16.2",
"@types/webpack-node-externals": "^1.7.1",
"@typescript-eslint/eslint-plugin": "^4.29.0",
"@typescript-eslint/eslint-plugin": "^4.33.0",
"@typescript-eslint/parser": "^4.29.1",
"ansi_up": "^5.0.1",
"chart.js": "^2.9.4",
"circular-dependency-plugin": "^5.2.2",
"color": "^3.1.2",
"concurrently": "^5.2.0",
"css-loader": "^5.2.6",
"deepdash": "^5.3.5",
"css-loader": "^5.2.7",
"deepdash": "^5.3.9",
"dompurify": "^2.3.1",
"electron": "^12.0.17",
"electron": "^13.5.1",
"electron-builder": "^22.11.11",
"electron-notarize": "^0.3.0",
"esbuild": "^0.12.24",
"esbuild-loader": "^2.13.1",
"eslint": "^7.7.0",
"esbuild-loader": "^2.15.1",
"eslint": "^7.32.0",
"eslint-plugin-header": "^3.1.1",
"eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0",
@ -349,7 +350,7 @@
"jest-fetch-mock": "^3.0.3",
"jest-mock-extended": "^1.0.16",
"make-plural": "^6.2.2",
"mini-css-extract-plugin": "^1.6.0",
"mini-css-extract-plugin": "^1.6.2",
"node-gyp": "7.1.2",
"node-loader": "^1.0.3",
"nodemon": "^2.0.12",
@ -357,6 +358,7 @@
"postcss": "^8.3.6",
"postcss-loader": "4.3.0",
"postinstall-postinstall": "^2.1.0",
"prettier": "^2.4.1",
"progress-bar-webpack-plugin": "^2.1.0",
"randomcolor": "^0.6.2",
"raw-loader": "^4.0.2",
@ -369,18 +371,18 @@
"react-window": "^1.8.5",
"sass": "^1.41.1",
"sass-loader": "^8.0.2",
"sharp": "^0.29.0",
"sharp": "^0.29.1",
"style-loader": "^2.0.0",
"tailwindcss": "^2.2.4",
"ts-jest": "26.5.6",
"ts-loader": "^7.0.5",
"ts-node": "^10.1.0",
"ts-node": "^10.2.1",
"type-fest": "^1.0.2",
"typed-emitter": "^1.3.1",
"typedoc": "0.21.0-beta.2",
"typedoc": "0.22.5",
"typedoc-plugin-markdown": "^3.9.0",
"typeface-roboto": "^1.1.13",
"typescript": "^4.3.2",
"typescript": "^4.4.3",
"typescript-plugin-css-modules": "^3.4.0",
"url-loader": "^4.1.1",
"webpack": "^4.46.0",

View File

@ -46,6 +46,10 @@ export abstract class BaseStore<T> extends Singleton {
protected constructor(protected params: BaseStoreParams<T>) {
super();
makeObservable(this);
if (ipcRenderer) {
params.migrations = undefined; // don't run migrations on renderer
}
}
/**

View File

@ -0,0 +1,44 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { CatalogEntity } from "../catalog";
export class CatalogRunEvent {
#defaultPrevented: boolean;
#target: CatalogEntity;
get defaultPrevented() {
return this.#defaultPrevented;
}
get target() {
return this.#target;
}
constructor({ target }: { target: CatalogEntity }) {
this.#defaultPrevented = false;
this.#target = target;
}
preventDefault() {
this.#defaultPrevented = true;
}
}

View File

@ -20,92 +20,108 @@
*/
import { CustomResourceDefinition } from "../endpoints";
import type { KubeObjectMetadata } from "../kube-object";
describe("Crds", () => {
describe("getVersion", () => {
it("should get the first version name from the list of versions", () => {
it("should throw if none of the versions are served", () => {
const crd = new CustomResourceDefinition({
apiVersion: "foo",
apiVersion: "apiextensions.k8s.io/v1",
kind: "CustomResourceDefinition",
metadata: {} as KubeObjectMetadata,
metadata: {
name: "foo",
resourceVersion: "12345",
uid: "12345",
},
spec: {
versions: [
{
name: "123",
served: false,
storage: false,
},
{
name: "1234",
served: false,
storage: false,
},
],
},
});
crd.spec = {
versions: [
{
name: "123",
served: false,
storage: false,
}
]
} as any;
expect(() => crd.getVersion()).toThrowError("Failed to find a version for CustomResourceDefinition foo");
});
it("should should get the version that is both served and stored", () => {
const crd = new CustomResourceDefinition({
apiVersion: "apiextensions.k8s.io/v1",
kind: "CustomResourceDefinition",
metadata: {
name: "foo",
resourceVersion: "12345",
uid: "12345",
},
spec: {
versions: [
{
name: "123",
served: true,
storage: true,
},
{
name: "1234",
served: false,
storage: false,
},
],
},
});
expect(crd.getVersion()).toBe("123");
});
it("should get the first version name from the list of versions (length 2)", () => {
it("should should get the version that is both served and stored even with version field", () => {
const crd = new CustomResourceDefinition({
apiVersion: "foo",
apiVersion: "apiextensions.k8s.io/v1",
kind: "CustomResourceDefinition",
metadata: {} as KubeObjectMetadata,
metadata: {
name: "foo",
resourceVersion: "12345",
uid: "12345",
},
spec: {
version: "abc",
versions: [
{
name: "123",
served: true,
storage: true,
},
{
name: "1234",
served: false,
storage: false,
},
],
},
});
crd.spec = {
versions: [
{
name: "123",
served: false,
storage: false,
},
{
name: "1234",
served: false,
storage: false,
}
]
} as any;
expect(crd.getVersion()).toBe("123");
});
it("should get the first version name from the list of versions (length 2) even with version field", () => {
it("should get the version name from the version field", () => {
const crd = new CustomResourceDefinition({
apiVersion: "foo",
apiVersion: "apiextensions.k8s.io/v1beta1",
kind: "CustomResourceDefinition",
metadata: {} as KubeObjectMetadata,
metadata: {
name: "foo",
resourceVersion: "12345",
uid: "12345",
},
spec: {
version: "abc",
}
});
crd.spec = {
version: "abc",
versions: [
{
name: "123",
served: false,
storage: false,
},
{
name: "1234",
served: false,
storage: false,
}
]
} as any;
expect(crd.getVersion()).toBe("123");
});
it("should get the first version name from the version field", () => {
const crd = new CustomResourceDefinition({
apiVersion: "foo",
kind: "CustomResourceDefinition",
metadata: {} as KubeObjectMetadata,
});
crd.spec = {
version: "abc"
} as any;
expect(crd.getVersion()).toBe("abc");
});
});

View File

@ -19,10 +19,11 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { KubeObject } from "../kube-object";
import { KubeCreationError, KubeObject } from "../kube-object";
import { KubeApi } from "../kube-api";
import { crdResourcesURL } from "../../routes";
import { isClusterPageContext } from "../../utils/cluster-id-url-parsing";
import type { KubeJsonApiData } from "../kube-json-api";
type AdditionalPrinterColumnsCommon = {
name: string;
@ -39,10 +40,21 @@ type AdditionalPrinterColumnsV1Beta = AdditionalPrinterColumnsCommon & {
JSONPath: string;
};
export interface CRDVersion {
name: string;
served: boolean;
storage: boolean;
schema?: object; // required in v1 but not present in v1beta
additionalPrinterColumns?: AdditionalPrinterColumnsV1[];
}
export interface CustomResourceDefinition {
spec: {
group: string;
version?: string; // deprecated in v1 api
/**
* @deprecated for apiextensions.k8s.io/v1 but used previously
*/
version?: string;
names: {
plural: string;
singular: string;
@ -50,19 +62,19 @@ export interface CustomResourceDefinition {
listKind: string;
};
scope: "Namespaced" | "Cluster" | string;
validation?: any;
versions?: {
name: string;
served: boolean;
storage: boolean;
schema?: unknown; // required in v1 but not present in v1beta
additionalPrinterColumns?: AdditionalPrinterColumnsV1[]
}[];
/**
* @deprecated for apiextensions.k8s.io/v1 but used previously
*/
validation?: object;
versions?: CRDVersion[];
conversion: {
strategy?: string;
webhook?: any;
};
additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; // removed in v1
/**
* @deprecated for apiextensions.k8s.io/v1 but used previously
*/
additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[];
};
status: {
conditions: {
@ -83,11 +95,23 @@ export interface CustomResourceDefinition {
};
}
export interface CRDApiData extends KubeJsonApiData {
spec: object; // TODO: make better
}
export class CustomResourceDefinition extends KubeObject {
static kind = "CustomResourceDefinition";
static namespaced = false;
static apiBase = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions";
constructor(data: CRDApiData) {
super(data);
if (!data.spec || typeof data.spec !== "object") {
throw new KubeCreationError("Cannot create a CustomResourceDefinition from an object without spec", data);
}
}
getResourceUrl() {
return crdResourcesURL({
params: {
@ -125,9 +149,36 @@ export class CustomResourceDefinition extends KubeObject {
return this.spec.scope;
}
getPreferedVersion(): CRDVersion {
// Prefer the modern `versions` over the legacy `version`
if (this.spec.versions) {
for (const version of this.spec.versions) {
/**
* If the version is not served then 404 errors will occur
* We should also prefer the storage version
*/
if (version.served && version.storage) {
return version;
}
}
} else if (this.spec.version) {
const { additionalPrinterColumns: apc } = this.spec;
const additionalPrinterColumns = apc?.map(({ JSONPath, ...apc}) => ({ ...apc, jsonPath: JSONPath }));
return {
name: this.spec.version,
served: true,
storage: true,
schema: this.spec.validation,
additionalPrinterColumns,
};
}
throw new Error(`Failed to find a version for CustomResourceDefinition ${this.metadata.name}`);
}
getVersion() {
// v1 has removed the spec.version property, if it is present it must match the first version
return this.spec.versions?.[0]?.name ?? this.spec.version;
return this.getPreferedVersion().name;
}
isNamespaced() {
@ -147,17 +198,14 @@ export class CustomResourceDefinition extends KubeObject {
}
getPrinterColumns(ignorePriority = true): AdditionalPrinterColumnsV1[] {
const columns = this.spec.versions?.find(a => this.getVersion() == a.name)?.additionalPrinterColumns
?? this.spec.additionalPrinterColumns?.map(({ JSONPath, ...rest }) => ({ ...rest, jsonPath: JSONPath })) // map to V1 shape
?? [];
const columns = this.getPreferedVersion().additionalPrinterColumns ?? [];
return columns
.filter(column => column.name != "Age")
.filter(column => ignorePriority ? true : !column.priority);
.filter(column => column.name != "Age" && (ignorePriority || !column.priority));
}
getValidation() {
return JSON.stringify(this.spec.validation ?? this.spec.versions?.[0]?.schema, null, 2);
return JSON.stringify(this.getPreferedVersion().schema, null, 2);
}
getConditions() {

View File

@ -31,11 +31,13 @@ export class PersistentVolumeClaimsApi extends KubeApi<PersistentVolumeClaim> {
}
export function getMetricsForPvc(pvc: PersistentVolumeClaim): Promise<IPvcMetrics> {
const opts = { category: "pvc", pvc: pvc.getName(), namespace: pvc.getNs() };
return metricsApi.getMetrics({
diskUsage: { category: "pvc", pvc: pvc.getName() },
diskCapacity: { category: "pvc", pvc: pvc.getName() }
diskUsage: opts,
diskCapacity: opts
}, {
namespace: pvc.getNs()
namespace: opts.namespace
});
}

View File

@ -22,19 +22,27 @@
import jsYaml from "js-yaml";
import type { KubeJsonApiData } from "../kube-json-api";
import { apiBase } from "../index";
import type { Patch } from "rfc6902";
export const resourceApplierApi = {
annotations: [
"kubectl.kubernetes.io/last-applied-configuration"
],
export const annotations = [
"kubectl.kubernetes.io/last-applied-configuration"
];
async update(resource: object | string): Promise<KubeJsonApiData | null> {
if (typeof resource === "string") {
resource = jsYaml.safeLoad(resource);
}
const [data = null] = await apiBase.post<KubeJsonApiData[]>("/stack", { data: resource });
return data;
export async function update(resource: object | string): Promise<KubeJsonApiData> {
if (typeof resource === "string") {
resource = jsYaml.safeLoad(resource);
}
};
return apiBase.post<KubeJsonApiData>("/stack", { data: resource });
}
export async function patch(name: string, kind: string, ns: string, patch: Patch): Promise<KubeJsonApiData> {
return apiBase.patch<KubeJsonApiData>("/stack", {
data: {
name,
kind,
ns,
patch,
},
});
}

View File

@ -27,8 +27,7 @@ import { stringify } from "querystring";
import { EventEmitter } from "../../common/event-emitter";
import logger from "../../common/logger";
export interface JsonApiData {
}
export interface JsonApiData {}
export interface JsonApiError {
code?: number;

View File

@ -32,6 +32,7 @@ import { parseKubeApi } from "./kube-api-parse";
import type { KubeJsonApiData } from "./kube-json-api";
import type { RequestInit } from "node-fetch";
import AbortController from "abort-controller";
import type { Patch } from "rfc6902";
export interface KubeObjectStoreLoadingParams<K extends KubeObject> {
namespaces: string[];
@ -279,19 +280,29 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
return newItem;
}
async update(item: T, data: Partial<T>): Promise<T> {
const rawItem = await item.update(data);
private postUpdate(rawItem: KubeJsonApiData): T {
const newItem = new this.api.objectConstructor(rawItem);
const index = this.items.findIndex(item => item.getId() === newItem.getId());
ensureObjectSelfLink(this.api, newItem);
const index = this.items.findIndex(item => item.getId() === newItem.getId());
this.items.splice(index, 1, newItem);
if (index < 0) {
this.items.push(newItem);
} else {
this.items[index] = newItem;
}
return newItem;
}
async patch(item: T, patch: Patch): Promise<T> {
return this.postUpdate(await item.patch(patch));
}
async update(item: T, data: Partial<T>): Promise<T> {
return this.postUpdate(await item.update(data));
}
async remove(item: T) {
await item.delete();
this.items.remove(item);

View File

@ -27,9 +27,9 @@ import { autoBind, formatDuration } from "../utils";
import type { ItemObject } from "../item.store";
import { apiKube } from "./index";
import type { JsonApiParams } from "./json-api";
import { resourceApplierApi } from "./endpoints/resource-applier.api";
import * as resourceApplierApi from "./endpoints/resource-applier.api";
import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing";
import _ from "lodash";
import type { Patch } from "rfc6902";
export type KubeObjectConstructor<K extends KubeObject> = (new (data: KubeJsonApiData | any) => K) & {
kind?: string;
@ -98,6 +98,12 @@ export interface KubeObjectStatus {
export type KubeMetaField = keyof KubeObjectMetadata;
export class KubeCreationError extends Error {
constructor(message: string, public data: any) {
super(message);
}
}
export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata, Status = any, Spec = any> implements ItemObject {
static readonly kind: string;
static readonly namespaced: boolean;
@ -191,18 +197,32 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
return Object.entries(labels).map(([name, value]) => `${name}=${value}`);
}
protected static readonly nonEditableFields = [
"apiVersion",
"kind",
"metadata.name",
"metadata.selfLink",
"metadata.resourceVersion",
"metadata.uid",
"managedFields",
"status",
/**
* These must be RFC6902 compliant paths
*/
private static readonly nonEditiablePathPrefixes = [
"/metadata/managedFields",
"/status",
];
private static readonly nonEditablePaths = new Set([
"/apiVersion",
"/kind",
"/metadata/name",
"/metadata/selfLink",
"/metadata/resourceVersion",
"/metadata/uid",
...KubeObject.nonEditiablePathPrefixes,
]);
constructor(data: KubeJsonApiData) {
if (typeof data !== "object") {
throw new TypeError(`Cannot create a KubeObject from ${typeof data}`);
}
if (!data.metadata || typeof data.metadata !== "object") {
throw new KubeCreationError(`Cannot create a KubeObject from an object without metadata`, data);
}
Object.assign(this, data);
autoBind(this);
}
@ -264,7 +284,7 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
}
getOwnerRefs() {
const refs = this.metadata?.ownerReferences || [];
const refs = this.metadata.ownerReferences || [];
const namespace = this.getNs();
return refs.map(ownerRef => ({ ...ownerRef, namespace }));
@ -286,14 +306,31 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
return JSON.parse(JSON.stringify(this));
}
// use unified resource-applier api for updating all k8s objects
async update(data: Partial<this>): Promise<KubeJsonApiData | null> {
for (const field of KubeObject.nonEditableFields) {
if (!_.isEqual(_.get(this, field), _.get(data, field))) {
throw new Error(`Failed to update Kube Object: ${field} has been modified`);
async patch(patch: Patch): Promise<KubeJsonApiData | null> {
for (const op of patch) {
if (KubeObject.nonEditablePaths.has(op.path)) {
throw new Error(`Failed to update ${this.kind}: JSON pointer ${op.path} has been modified`);
}
for (const pathPrefix of KubeObject.nonEditiablePathPrefixes) {
if (op.path.startsWith(`${pathPrefix}/`)) {
throw new Error(`Failed to update ${this.kind}: Child JSON pointer of ${op.path} has been modified`);
}
}
}
return resourceApplierApi.patch(this.getName(), this.kind, this.getNs(), patch);
}
/**
* Perform a full update (or more specifically a replace)
*
* Note: this is brittle if `data` is not actually partial (but instead whole).
* As fields such as `resourceVersion` will probably out of date. This is a
* common race condition.
*/
async update(data: Partial<this>): Promise<KubeJsonApiData | null> {
// use unified resource-applier api for updating all k8s objects
return resourceApplierApi.update({
...this.toPlainObject(),
...data,

View File

@ -30,4 +30,17 @@ export const catalogRoute: RouteProps = {
path: "/catalog/:group?/:kind?"
};
export const getPreviousTabUrl = (path: string) => {
const [group, kind] = path.split("/");
return catalogURL({
params: {
group: group || browseCatalogTab,
kind
}
});
};
export const catalogURL = buildURL<CatalogViewRouteParam>(catalogRoute.path);
export const browseCatalogTab = "browse";

View File

@ -40,6 +40,7 @@ export * from "./network-policies";
export * from "./network";
export * from "./nodes";
export * from "./pod-disruption-budgets";
export * from "./port-forwards";
export * from "./preferences";
export * from "./releases";
export * from "./resource-quotas";

View File

@ -25,6 +25,7 @@ import { endpointRoute } from "./endpoints";
import { ingressRoute } from "./ingresses";
import { networkPoliciesRoute } from "./network-policies";
import { servicesRoute, servicesURL } from "./services";
import { portForwardsRoute } from "./port-forwards";
export const networkRoute: RouteProps = {
path: [
@ -32,6 +33,7 @@ export const networkRoute: RouteProps = {
endpointRoute,
ingressRoute,
networkPoliciesRoute,
portForwardsRoute,
].map(route => route.path.toString())
};

View File

@ -0,0 +1,32 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type { RouteProps } from "react-router";
import { buildURL } from "../utils/buildUrl";
export const portForwardsRoute: RouteProps = {
path: "/port-forwards"
};
export interface PortForwardsRouteParams {
}
export const portForwardsURL = buildURL<PortForwardsRouteParams>(portForwardsRoute.path);

View File

@ -200,6 +200,19 @@ const openAtLogin: PreferenceDescription<boolean> = {
},
};
const terminalCopyOnSelect: PreferenceDescription<boolean> = {
fromStore(val) {
return val ?? false;
},
toStore(val) {
if (!val) {
return undefined;
}
return val;
},
};
const hiddenTableColumns: PreferenceDescription<[string, string[]][], Map<string, ObservableToggleSet<string>>> = {
fromStore(val) {
return new Map(
@ -274,4 +287,5 @@ export const DESCRIPTORS = {
hiddenTableColumns,
syncKubeconfigEntries,
editorConfiguration,
terminalCopyOnSelect,
};

View File

@ -19,7 +19,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { app } from "electron";
import { app, ipcMain } from "electron";
import semver from "semver";
import { action, computed, observable, reaction, makeObservable } from "mobx";
import { BaseStore } from "../base-store";
@ -48,7 +48,11 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
});
makeObservable(this);
fileNameMigration();
if (ipcMain) {
fileNameMigration();
}
this.load();
}
@ -70,6 +74,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
@observable shell?: string;
@observable downloadBinariesPath?: string;
@observable kubectlBinariesPath?: string;
@observable terminalCopyOnSelect: boolean;
/**
* Download kubectl binaries matching cluster version
@ -212,6 +217,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
this.hiddenTableColumns.replace(DESCRIPTORS.hiddenTableColumns.fromStore(preferences?.hiddenTableColumns));
this.syncKubeconfigEntries.replace(DESCRIPTORS.syncKubeconfigEntries.fromStore(preferences?.syncKubeconfigEntries));
this.editorConfiguration = DESCRIPTORS.editorConfiguration.fromStore(preferences?.editorConfiguration);
this.terminalCopyOnSelect = DESCRIPTORS.terminalCopyOnSelect.fromStore(preferences?.terminalCopyOnSelect);
}
toJSON(): UserStoreModel {
@ -233,6 +239,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
hiddenTableColumns: DESCRIPTORS.hiddenTableColumns.toStore(this.hiddenTableColumns),
syncKubeconfigEntries: DESCRIPTORS.syncKubeconfigEntries.toStore(this.syncKubeconfigEntries),
editorConfiguration: DESCRIPTORS.editorConfiguration.toStore(this.editorConfiguration),
terminalCopyOnSelect: DESCRIPTORS.terminalCopyOnSelect.toStore(this.terminalCopyOnSelect),
},
};

View File

@ -33,8 +33,9 @@ export function getBundledKubectlVersion(): string {
export async function getAppVersionFromProxyServer(proxyPort: number): Promise<string> {
const response = await requestPromise({
method: "GET",
uri: `http://localhost:${proxyPort}/version`,
resolveWithFullResponse: true
uri: `http://127.0.0.1:${proxyPort}/version`,
resolveWithFullResponse: true,
proxy: undefined,
});
return JSON.parse(response.body).version;

View File

@ -69,7 +69,7 @@ export const apiKubePrefix = "/api-kube" as string; // k8s cluster apis
// Links
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as string;
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI" as string;
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/zt-wcl8jq3k-68R5Wcmk1o95MLBE5igUDQ" as string;
export const supportUrl = "https://docs.k8slens.dev/latest/support/" as string;
export const appSemVer = new SemVer(packageInfo.version);

View File

@ -77,7 +77,7 @@ export class LensRendererExtension extends LensExtension {
}
/**
* Add a filtering function for the catalog catogries. This will be removed if the extension is disabled.
* Add a filtering function for the catalog categories. This will be removed if the extension is disabled.
* @param fn The function which should return a truthy value for those categories which should be kept.
* @returns A function to clean up the filter
*/

View File

@ -16,9 +16,9 @@
"name": "OpenLens Authors"
},
"dependencies": {
"@types/node": "*",
"@types/react-select": "*",
"@material-ui/core": "*",
"@types/node": "14.17.14",
"@types/react-select": "3.1.2",
"@material-ui/core": "4.12.3",
"conf": "^7.0.1",
"typed-emitter": "^1.3.1"
}

View File

@ -25,7 +25,7 @@ import { Singleton } from "../../common/utils";
import { LensExtension } from "../lens-extension";
export class BaseRegistry<T, I = T> extends Singleton {
private items = observable.map<T, I>([], {deep: false});
private items = observable.map<T, I>([], { deep: false });
constructor() {
super();

View File

@ -25,9 +25,9 @@ import { BaseRegistry } from "./base-registry";
import type { LensExtension } from "../lens-extension";
import type { CatalogEntity } from "../../common/catalog";
export type CommandContext = {
export interface CommandContext {
entity?: CatalogEntity;
};
}
export interface CommandRegistration {
id: string;

View File

@ -22,7 +22,8 @@
import type { CatalogCategory, CatalogEntity } from "../../common/catalog";
import { catalogEntityRegistry as registry } from "../../renderer/api/catalog-entity-registry";
import type { CatalogEntityOnBeforeRun } from "../../renderer/api/catalog-entity-registry";
import type { Disposer } from "../../common/utils";
export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog/catalog-category-registry";
export class CatalogEntityRegistry {
@ -48,6 +49,18 @@ export class CatalogEntityRegistry {
getItemsForCategory<T extends CatalogEntity>(category: CatalogCategory): T[] {
return registry.getItemsForCategory(category);
}
/**
* Add a onBeforeRun hook to a catalog entities. If `onBeforeRun` was previously
* added then it will not be added again.
* @param onBeforeRun The function to be called with a `CatalogRunEvent`
* event target will be the catalog entity. onBeforeRun hook can call event.preventDefault()
* to stop run sequence
* @returns A function to remove that hook
*/
addOnBeforeRun(onBeforeRun: CatalogEntityOnBeforeRun): Disposer {
return registry.addOnBeforeRun(onBeforeRun);
}
}
export const catalogEntities = new CatalogEntityRegistry();

View File

@ -21,10 +21,17 @@
import fetchMock from "jest-fetch-mock";
import configurePackages from "./common/configure-packages";
import { configure } from "mobx";
// setup default configuration for external npm-packages
configurePackages();
configure({
// Needed because we want to use jest.spyOn()
// ref https://github.com/mobxjs/mobx/issues/2784
safeDescriptors: false,
});
// rewire global.fetch to call 'fetchMock'
fetchMock.enableMocks();

View File

@ -0,0 +1,56 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { isLongRunningRequest } from "../lens-proxy";
describe("isLongRunningRequest", () => {
it("returns true on watches", () => {
["watch=true", "watch=1", "watch"].forEach((param) => {
expect(
isLongRunningRequest(`/api/v1/namespaces/default/pods?${param}`)
).toBeTruthy();
});
});
it("returns false on disabled watches", () => {
["watch=false", "watch=0", ""].forEach((param) => {
expect(
isLongRunningRequest(`/api/v1/namespaces/default/pods?${param}`)
).toBeFalsy();
});
});
it("returns true on follows", () => {
["follow=true", "follow=1", "follow"].forEach((param) => {
expect(
isLongRunningRequest(`/api/v1/namespaces/default/pods/foo/log?${param}`)
).toBeTruthy();
});
});
it("returns false on disabled follows", () => {
["follow=false", "follow=0", ""].forEach((param) => {
expect(
isLongRunningRequest(`/api/v1/namespaces/default/pods/foo/log?${param}`)
).toBeFalsy();
});
});
});

View File

@ -131,11 +131,11 @@ export class ClusterManager extends Singleton {
entity.spec.metrics.prometheus = prometheus;
}
// Only set the icon if the preference is set. If the preference is not set
// then let the source determine if a cluster has an icon.
if (cluster.preferences.icon) {
entity.spec.icon ??= {};
entity.spec.icon.src = cluster.preferences.icon;
} else {
entity.spec.icon = null;
}
catalogEntityRegistry.items.splice(index, 1, entity);
@ -160,6 +160,10 @@ export class ClusterManager extends Singleton {
return "connecting";
}
if (entity?.status?.phase) {
return entity.status.phase;
}
return "disconnected";
})();

View File

@ -117,10 +117,10 @@ export class ContextHandler {
return `http://127.0.0.1:${this.kubeAuthProxy.port}${path}`;
}
async getApiTarget(isWatchRequest = false): Promise<httpProxy.ServerOptions> {
const timeout = isWatchRequest ? 4 * 60 * 60 * 1000 : 30000; // 4 hours for watch request, 30 seconds for the rest
async getApiTarget(isLongRunningRequest = false): Promise<httpProxy.ServerOptions> {
const timeout = isLongRunningRequest ? 4 * 60 * 60_000 : 30_000; // 4 hours for long running request, 30 seconds for the rest
if (isWatchRequest) {
if (isLongRunningRequest) {
return this.newApiTarget(timeout);
}

View File

@ -31,6 +31,7 @@ import { Singleton } from "../common/utils";
import type { Cluster } from "./cluster";
import type { ProxyApiRequestArgs } from "./proxy-functions";
import { appEventBus } from "../common/event-bus";
import { getBoolean } from "./utils/parse-query";
type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | null;
@ -40,6 +41,15 @@ export interface LensProxyFunctions {
kubeApiRequest: (args: ProxyApiRequestArgs) => void | Promise<void>;
}
const watchParam = "watch";
const followParam = "follow";
export function isLongRunningRequest(reqUrl: string) {
const url = new URL(reqUrl, "http://localhost");
return getBoolean(url.searchParams, watchParam) || getBoolean(url.searchParams, followParam);
}
export class LensProxy extends Singleton {
protected origin: string;
protected proxyServer: http.Server;
@ -174,9 +184,8 @@ export class LensProxy extends Singleton {
if (req.url.startsWith(apiKubePrefix)) {
delete req.headers.authorization;
req.url = req.url.replace(apiKubePrefix, "");
const isWatchRequest = req.url.includes("watch=");
return contextHandler.getApiTarget(isWatchRequest);
return contextHandler.getApiTarget(isLongRunningRequest(req.url));
}
}

View File

@ -19,19 +19,23 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { app, BrowserWindow, dialog, IpcMainEvent, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron";
import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron";
import { autorun } from "mobx";
import type { WindowManager } from "./window-manager";
import { appName, isMac, isWindows, isTestEnv, docsUrl, supportUrl, productName } from "../common/vars";
import { appName, isMac, isWindows, docsUrl, supportUrl, productName } from "../common/vars";
import { MenuRegistry } from "../extensions/registries/menu-registry";
import logger from "./logger";
import { exitApp } from "./exit-app";
import { broadcastMessage, ipcMainOn } from "../common/ipc";
import { broadcastMessage } from "../common/ipc";
import * as packageJson from "../../package.json";
import { preferencesURL, extensionsURL, addClusterURL, catalogURL, welcomeURL } from "../common/routes";
export type MenuTopId = "mac" | "file" | "edit" | "view" | "help";
interface MenuItemsOpts extends MenuItemConstructorOptions {
submenu?: MenuItemConstructorOptions[];
}
export function initMenu(windowManager: WindowManager) {
return autorun(() => buildMenu(windowManager), {
delay: 100
@ -68,11 +72,13 @@ export function buildMenu(windowManager: WindowManager) {
await windowManager.navigate(url);
}
const macAppMenu: MenuItemConstructorOptions = {
const macAppMenu: MenuItemsOpts = {
label: app.getName(),
id: "root",
submenu: [
{
label: `About ${productName}`,
id: "about",
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
showAbout(browserWindow);
}
@ -81,6 +87,7 @@ export function buildMenu(windowManager: WindowManager) {
{
label: "Preferences",
accelerator: "CmdOrCtrl+,",
id: "preferences",
click() {
navigate(preferencesURL());
},
@ -88,6 +95,7 @@ export function buildMenu(windowManager: WindowManager) {
{
label: "Extensions",
accelerator: "CmdOrCtrl+Shift+E",
id: "extensions",
click() {
navigate(extensionsURL());
}
@ -102,18 +110,21 @@ export function buildMenu(windowManager: WindowManager) {
{
label: "Quit",
accelerator: "Cmd+Q",
id: "quit",
click() {
exitApp();
}
}
],
};
const fileMenu: MenuItemConstructorOptions = {
const fileMenu: MenuItemsOpts = {
label: "File",
id: "file",
submenu: [
{
label: "Add Cluster",
accelerator: "CmdOrCtrl+Shift+A",
id: "add-cluster",
click() {
navigate(addClusterURL());
}
@ -122,6 +133,7 @@ export function buildMenu(windowManager: WindowManager) {
{ type: "separator" },
{
label: "Preferences",
id: "preferences",
accelerator: "Ctrl+,",
click() {
navigate(preferencesURL());
@ -135,16 +147,22 @@ export function buildMenu(windowManager: WindowManager) {
}
}
]),
{ type: "separator" },
{
role: "close",
label: "Close Window"
},
...(isMac ? [
{
role: "close",
label: "Close Window",
accelerator: "Shift+Cmd+W"
}
] as MenuItemConstructorOptions[] : []),
...ignoreOnMac([
{ type: "separator" },
{
label: "Exit",
accelerator: "Alt+F4",
id: "quit",
click() {
exitApp();
}
@ -152,8 +170,9 @@ export function buildMenu(windowManager: WindowManager) {
])
],
};
const editMenu: MenuItemConstructorOptions = {
const editMenu: MenuItemsOpts = {
label: "Edit",
id: "edit",
submenu: [
{ role: "undo" },
{ role: "redo" },
@ -166,12 +185,14 @@ export function buildMenu(windowManager: WindowManager) {
{ role: "selectAll" },
]
};
const viewMenu: MenuItemConstructorOptions = {
const viewMenu: MenuItemsOpts = {
label: "View",
id: "view",
submenu: [
{
label: "Catalog",
accelerator: "Shift+CmdOrCtrl+C",
id: "catalog",
click() {
navigate(catalogURL());
}
@ -179,6 +200,7 @@ export function buildMenu(windowManager: WindowManager) {
{
label: "Command Palette...",
accelerator: "Shift+CmdOrCtrl+P",
id: "command-palette",
click() {
broadcastMessage("command-palette:open");
}
@ -187,6 +209,7 @@ export function buildMenu(windowManager: WindowManager) {
{
label: "Back",
accelerator: "CmdOrCtrl+[",
id: "go-back",
click() {
webContents.getAllWebContents().filter(wc => wc.getType() === "window").forEach(wc => wc.goBack());
}
@ -194,6 +217,7 @@ export function buildMenu(windowManager: WindowManager) {
{
label: "Forward",
accelerator: "CmdOrCtrl+]",
id: "go-forward",
click() {
webContents.getAllWebContents().filter(wc => wc.getType() === "window").forEach(wc => wc.goForward());
}
@ -201,6 +225,7 @@ export function buildMenu(windowManager: WindowManager) {
{
label: "Reload",
accelerator: "CmdOrCtrl+R",
id: "reload",
click() {
windowManager.reload();
}
@ -214,23 +239,27 @@ export function buildMenu(windowManager: WindowManager) {
{ role: "togglefullscreen" }
]
};
const helpMenu: MenuItemConstructorOptions = {
const helpMenu: MenuItemsOpts = {
role: "help",
id: "help",
submenu: [
{
label: "Welcome",
id: "welcome",
click() {
navigate(welcomeURL());
},
},
{
label: "Documentation",
id: "documentation",
click: async () => {
shell.openExternal(docsUrl);
},
},
{
label: "Support",
id: "support",
click: async () => {
shell.openExternal(supportUrl);
},
@ -238,6 +267,7 @@ export function buildMenu(windowManager: WindowManager) {
...ignoreOnMac([
{
label: `About ${productName}`,
id: "about",
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
showAbout(browserWindow);
}
@ -246,69 +276,28 @@ export function buildMenu(windowManager: WindowManager) {
]
};
// Prepare menu items order
const appMenu: Record<MenuTopId, MenuItemConstructorOptions> = {
mac: macAppMenu,
file: fileMenu,
edit: editMenu,
view: viewMenu,
help: helpMenu,
};
const appMenu = new Map([
["mac", macAppMenu],
["file", fileMenu],
["edit", editMenu],
["view", viewMenu],
["help", helpMenu],
]);
// Modify menu from extensions-api
MenuRegistry.getInstance().getItems().forEach(({ parentId, ...menuItem }) => {
try {
const topMenu = appMenu[parentId as MenuTopId].submenu as MenuItemConstructorOptions[];
for (const { parentId, ...menuItem } of MenuRegistry.getInstance().getItems()) {
if (!appMenu.has(parentId)) {
logger.error(`[MENU]: cannot register menu item for parentId=${parentId}, parent item doesn't exist`, { menuItem });
topMenu.push(menuItem);
} catch (err) {
logger.error(`[MENU]: can't register menu item, parentId=${parentId}`, { menuItem });
continue;
}
});
appMenu.get(parentId).submenu.push(menuItem);
}
if (!isMac) {
delete appMenu.mac;
appMenu.delete("mac");
}
const menu = Menu.buildFromTemplate(Object.values(appMenu));
Menu.setApplicationMenu(menu);
if (isTestEnv) {
// this is a workaround for the test environment (spectron) not being able to directly access
// the application menus (https://github.com/electron-userland/spectron/issues/21)
ipcMainOn("test-menu-item-click", (event: IpcMainEvent, ...names: string[]) => {
let menu: Menu = Menu.getApplicationMenu();
const parentLabels: string[] = [];
let menuItem: MenuItem;
for (const name of names) {
parentLabels.push(name);
menuItem = menu?.items?.find(item => item.label === name);
if (!menuItem) {
break;
}
menu = menuItem.submenu;
}
const menuPath: string = parentLabels.join(" -> ");
if (!menuItem) {
logger.info(`[MENU:test-menu-item-click] Cannot find menu item ${menuPath}`);
return;
}
const { enabled, visible, click } = menuItem;
if (enabled === false || visible === false || typeof click !== "function") {
logger.info(`[MENU:test-menu-item-click] Menu item ${menuPath} not clickable`);
return;
}
logger.info(`[MENU:test-menu-item-click] Menu item ${menuPath} click!`);
menuItem.click();
});
}
Menu.setApplicationMenu(Menu.buildFromTemplate([...appMenu.values()]));
}

View File

@ -38,9 +38,9 @@ export class PrometheusOperator extends PrometheusProvider {
case "cluster":
switch (queryName) {
case "memoryUsage":
return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes))`.replace(/_bytes/g, `_bytes * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}`);
return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes))`.replace(/_bytes/g, `_bytes{node=~"${opts.nodes}"}`);
case "workloadMemoryUsage":
return `sum(container_memory_working_set_bytes{container!="POD",container!="",instance=~"${opts.nodes}"}) by (component)`;
return `sum(container_memory_working_set_bytes{container!="", instance=~"${opts.nodes}"}) by (component)`;
case "memoryRequests":
return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="memory"})`;
case "memoryLimits":
@ -50,7 +50,7 @@ export class PrometheusOperator extends PrometheusProvider {
case "memoryAllocatableCapacity":
return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="memory"})`;
case "cpuUsage":
return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])* on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`;
return `sum(rate(node_cpu_seconds_total{node=~"${opts.nodes}", mode=~"user|system"}[${this.rateAccuracy}]))`;
case "cpuRequests":
return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"})`;
case "cpuLimits":
@ -66,61 +66,61 @@ export class PrometheusOperator extends PrometheusProvider {
case "podAllocatableCapacity":
return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="pods"})`;
case "fsSize":
return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`;
return `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`;
case "fsUsage":
return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"} - node_filesystem_avail_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`;
return `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"} - node_filesystem_avail_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`;
}
break;
case "nodes":
switch (queryName) {
case "memoryUsage":
return `sum((node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) * on (pod,namespace) group_left(node) kube_pod_info) by (node)`;
return `sum (node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) by (node)`;
case "workloadMemoryUsage":
return `sum(container_memory_working_set_bytes{container!="POD",container!=""}) by (node)`;
return `sum(container_memory_working_set_bytes{container!=""}) by (node)`;
case "memoryCapacity":
return `sum(kube_node_status_capacity{resource="memory"}) by (node)`;
case "memoryAllocatableCapacity":
return `sum(kube_node_status_allocatable{resource="memory"}) by (node)`;
case "cpuUsage":
return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}]) * on (pod,namespace) group_left(node) kube_pod_info) by (node)`;
return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])) by(node)`;
case "cpuCapacity":
return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`;
case "cpuAllocatableCapacity":
return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`;
case "fsSize":
return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info) by (node)`;
return `sum(node_filesystem_size_bytes{mountpoint="/"}) by (node)`;
case "fsUsage":
return `sum((node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) * on (pod,namespace) group_left(node) kube_pod_info) by (node)`;
return `sum(node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) by (node)`;
}
break;
case "pods":
switch (queryName) {
case "cpuUsage":
return `sum(rate(container_cpu_usage_seconds_total{container!="POD",container!="",image!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`;
return `sum(rate(container_cpu_usage_seconds_total{container!="", image!="", pod=~"${opts.pods}", namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`;
case "cpuRequests":
return `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}",resource="cpu",namespace="${opts.namespace}"}) by (${opts.selector})`;
return `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}", resource="cpu", namespace="${opts.namespace}"}) by (${opts.selector})`;
case "cpuLimits":
return `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}",resource="cpu",namespace="${opts.namespace}"}) by (${opts.selector})`;
return `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}", resource="cpu", namespace="${opts.namespace}"}) by (${opts.selector})`;
case "memoryUsage":
return `sum(container_memory_working_set_bytes{container!="POD",container!="",image!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`;
return `sum(container_memory_working_set_bytes{container!="", image!="", pod=~"${opts.pods}", namespace="${opts.namespace}"}) by (${opts.selector})`;
case "memoryRequests":
return `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}",resource="memory",namespace="${opts.namespace}"}) by (${opts.selector})`;
return `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}", resource="memory", namespace="${opts.namespace}"}) by (${opts.selector})`;
case "memoryLimits":
return `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}",resource="memory",namespace="${opts.namespace}"}) by (${opts.selector})`;
return `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}", resource="memory", namespace="${opts.namespace}"}) by (${opts.selector})`;
case "fsUsage":
return `sum(container_fs_usage_bytes{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`;
return `sum(container_fs_usage_bytes{container!="", pod=~"${opts.pods}", namespace="${opts.namespace}"}) by (${opts.selector})`;
case "networkReceive":
return `sum(rate(container_network_receive_bytes_total{pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`;
return `sum(rate(container_network_receive_bytes_total{pod=~"${opts.pods}", namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`;
case "networkTransmit":
return `sum(rate(container_network_transmit_bytes_total{pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`;
return `sum(rate(container_network_transmit_bytes_total{pod=~"${opts.pods}", namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`;
}
break;
case "pvc":
switch (queryName) {
case "diskUsage":
return `sum(kubelet_volume_stats_used_bytes{persistentvolumeclaim="${opts.pvc}",namespace="${opts.namespace}"}) by (persistentvolumeclaim, namespace)`;
return `sum(kubelet_volume_stats_used_bytes{persistentvolumeclaim="${opts.pvc}", namespace="${opts.namespace}"}) by (persistentvolumeclaim, namespace)`;
case "diskCapacity":
return `sum(kubelet_volume_stats_capacity_bytes{persistentvolumeclaim="${opts.pvc}",namespace="${opts.namespace}"}) by (persistentvolumeclaim, namespace)`;
return `sum(kubelet_volume_stats_capacity_bytes{persistentvolumeclaim="${opts.pvc}", namespace="${opts.namespace}"}) by (persistentvolumeclaim, namespace)`;
}
break;
case "ingress":
@ -130,9 +130,9 @@ export class PrometheusOperator extends PrometheusProvider {
case "bytesSentFailure":
return this.bytesSent(opts.ingress, opts.namespace, "^5\\\\d*");
case "requestDurationSeconds":
return `sum(rate(nginx_ingress_controller_request_duration_seconds_sum{ingress="${opts.ingress}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (ingress, namespace)`;
return `sum(rate(nginx_ingress_controller_request_duration_seconds_sum{ingress="${opts.ingress}", namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (ingress, namespace)`;
case "responseDurationSeconds":
return `sum(rate(nginx_ingress_controller_response_duration_seconds_sum{ingress="${opts.ingress}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (ingress, namespace)`;
return `sum(rate(nginx_ingress_controller_response_duration_seconds_sum{ingress="${opts.ingress}", namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (ingress, namespace)`;
}
break;
}

View File

@ -126,7 +126,7 @@ describe("protocol router tests", () => {
}
await delay(50);
expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerInternal, "lens://app/", "matched");
expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerInternal, "lens://app", "matched");
expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerExtension, "lens://extension/@mirantis/minikube", "matched");
});

View File

@ -22,55 +22,96 @@
import type { Cluster } from "./cluster";
import type { KubernetesObject } from "@kubernetes/client-node";
import { exec } from "child_process";
import fs from "fs";
import fs from "fs-extra";
import * as yaml from "js-yaml";
import path from "path";
import * as tempy from "tempy";
import logger from "./logger";
import { appEventBus } from "../common/event-bus";
import { cloneJsonObject } from "../common/utils";
import type { Patch } from "rfc6902";
import { promiseExecFile } from "./promise-exec";
export class ResourceApplier {
constructor(protected cluster: Cluster) {
constructor(protected cluster: Cluster) {}
/**
* Patch a kube resource's manifest, throwing any error that occurs.
* @param name The name of the kube resource
* @param kind The kind of the kube resource
* @param patch The list of JSON operations
* @param ns The optional namespace of the kube resource
*/
async patch(name: string, kind: string, patch: Patch, ns?: string): Promise<string> {
appEventBus.emit({ name: "resource", action: "patch" });
const kubectl = await this.cluster.ensureKubectl();
const kubectlPath = await kubectl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
const args = [
"--kubeconfig", proxyKubeconfigPath,
"patch",
kind,
name,
];
if (ns) {
args.push("--namespace", ns);
}
args.push(
"--type", "json",
"--patch", JSON.stringify(patch),
"-o", "json"
);
try {
const { stdout } = await promiseExecFile(kubectlPath, args);
return stdout;
} catch (error) {
throw error.stderr ?? error;
}
}
async apply(resource: KubernetesObject | any): Promise<string> {
resource = this.sanitizeObject(resource);
appEventBus.emit({ name: "resource", action: "apply" });
return await this.kubectlApply(yaml.safeDump(resource));
return this.kubectlApply(yaml.safeDump(resource));
}
protected async kubectlApply(content: string): Promise<string> {
const kubectl = await this.cluster.ensureKubectl();
const kubectlPath = await kubectl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
const fileName = tempy.file({ name: "resource.yaml" });
const args = [
"apply",
"--kubeconfig", proxyKubeconfigPath,
"-o", "json",
"-f", fileName,
];
return new Promise<string>((resolve, reject) => {
const fileName = tempy.file({ name: "resource.yaml" });
logger.debug(`shooting manifests with ${kubectlPath}`, { args });
fs.writeFileSync(fileName, content);
const cmd = `"${kubectlPath}" apply --kubeconfig "${proxyKubeconfigPath}" -o json -f "${fileName}"`;
const execEnv = { ...process.env };
const httpsProxy = this.cluster.preferences?.httpsProxy;
logger.debug(`shooting manifests with: ${cmd}`);
const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env);
const httpsProxy = this.cluster.preferences?.httpsProxy;
if (httpsProxy) {
execEnv.HTTPS_PROXY = httpsProxy;
}
if (httpsProxy) {
execEnv["HTTPS_PROXY"] = httpsProxy;
}
exec(cmd, { env: execEnv },
(error, stdout, stderr) => {
if (stderr != "") {
fs.unlinkSync(fileName);
reject(stderr);
try {
await fs.writeFile(fileName, content);
const { stdout } = await promiseExecFile(kubectlPath, args);
return;
}
fs.unlinkSync(fileName);
resolve(JSON.parse(stdout));
});
});
return stdout;
} catch (error) {
throw error?.stderr ?? error;
} finally {
await fs.unlink(fileName);
}
}
public async kubectlApplyAll(resources: string[], extraArgs = ["-o", "json"]): Promise<string> {

View File

@ -179,8 +179,11 @@ export class Router {
this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, MetricsRoute.routeMetrics);
this.router.add({ method: "get", path: `${apiPrefix}/metrics/providers` }, MetricsRoute.routeMetricsProviders);
// Port-forward API
this.router.add({ method: "post", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, PortForwardRoute.routePortForward);
// Port-forward API (the container port and local forwarding port are obtained from the query parameters)
this.router.add({ method: "post", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routePortForward);
this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForward);
this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forwards` }, PortForwardRoute.routeAllPortForwards);
this.router.add({ method: "delete", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForwardStop);
// Helm API
this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, HelmApiRoute.listCharts);
@ -198,5 +201,6 @@ export class Router {
// Resource Applier API
this.router.add({ method: "post", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.applyResource);
this.router.add({ method: "patch", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.patchResource);
}
}

View File

@ -23,7 +23,7 @@ import type { LensApiRequest } from "../router";
import { helmService } from "../helm/helm-service";
import logger from "../logger";
import { respondJson, respondText } from "../utils/http-responses";
import { getBoolean } from "./utils/parse-query";
import { getBoolean } from "../utils/parse-query";
export class HelmApiRoute {
static async listCharts(request: LensApiRequest) {

View File

@ -22,7 +22,6 @@
import type { LensApiRequest } from "../router";
import { spawn, ChildProcessWithoutNullStreams } from "child_process";
import { Kubectl } from "../kubectl";
import { shell } from "electron";
import * as tcpPortUsed from "tcp-port-used";
import logger from "../logger";
import { getPortFrom } from "../utils/get-port";
@ -33,7 +32,8 @@ interface PortForwardArgs {
kind: string;
namespace: string;
name: string;
port: string;
port: number;
forwardPort: number;
}
const internalPortRegex = /^forwarding from (?<address>.+) ->/i;
@ -56,8 +56,8 @@ class PortForward {
public kind: string;
public namespace: string;
public name: string;
public port: string;
public internalPort?: number;
public port: number;
public forwardPort: number;
constructor(public kubeConfig: string, args: PortForwardArgs) {
this.clusterId = args.clusterId;
@ -65,16 +65,17 @@ class PortForward {
this.namespace = args.namespace;
this.name = args.name;
this.port = args.port;
this.forwardPort = args.forwardPort;
}
public async start() {
const kubectlBin = await Kubectl.bundled().getPath();
const kubectlBin = await Kubectl.bundled().getPath(true);
const args = [
"--kubeconfig", this.kubeConfig,
"port-forward",
"-n", this.namespace,
`${this.kind}/${this.name}`,
`:${this.port}`
`${this.forwardPort ?? ""}:${this.port}`
];
this.process = spawn(kubectlBin, args, {
@ -89,12 +90,15 @@ class PortForward {
}
});
this.internalPort = await getPortFrom(this.process.stdout, {
const internalPort = await getPortFrom(this.process.stdout, {
lineRegex: internalPortRegex,
});
try {
await tcpPortUsed.waitUntilUsed(this.internalPort, 500, 15000);
await tcpPortUsed.waitUntilUsed(internalPort, 500, 15000);
// make sure this.forwardPort is set to the actual port used (if it was 0 then an available port is found by 'kubectl port-forward')
this.forwardPort = internalPort;
return true;
} catch (error) {
@ -104,58 +108,113 @@ class PortForward {
}
}
public open() {
shell.openExternal(`http://localhost:${this.internalPort}`)
.catch(error => logger.error(`[PORT-FORWARD]: failed to open external shell: ${error}`, {
clusterId: this.clusterId,
port: this.port,
kind: this.kind,
namespace: this.namespace,
name: this.name,
}));
public async stop() {
this.process.kill();
}
}
export class PortForwardRoute {
static async routePortForward(request: LensApiRequest) {
const { params, response, cluster} = request;
const { namespace, port, resourceType, resourceName } = params;
let portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: resourceType, name: resourceName,
namespace, port
});
const { params, query, response, cluster } = request;
const { namespace, resourceType, resourceName } = params;
const port = Number(query.get("port"));
const forwardPort = Number(query.get("forwardPort"));
if (!portForward) {
logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`);
portForward = new PortForward(await cluster.getProxyKubeconfigPath(), {
clusterId: cluster.id,
kind: resourceType,
namespace,
name: resourceName,
port,
try {
let portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: resourceType, name: resourceName,
namespace, port, forwardPort,
});
try {
let thePort = 0;
if (forwardPort > 0 && forwardPort < 65536) {
thePort = forwardPort;
}
if (!portForward) {
logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`);
portForward = new PortForward(await cluster.getProxyKubeconfigPath(), {
clusterId: cluster.id,
kind: resourceType,
namespace,
name: resourceName,
port,
forwardPort: thePort,
});
const started = await portForward.start();
if (!started) {
logger.warn("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { namespace, port, resourceType, resourceName });
logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { namespace, port, resourceType, resourceName });
return respondJson(response, {
message: "Failed to open port-forward"
message: `Failed to forward port ${port} to ${thePort ? forwardPort : "random port"}`
}, 400);
}
} catch (error) {
logger.warn(`[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, { namespace, port, resourceType, resourceName });
return respondJson(response, {
message: error?.toString() || "Failed to open port-forward",
}, 400);
}
respondJson(response, { port: portForward.forwardPort });
} catch (error) {
logger.error(`[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, { namespace, port, resourceType, resourceName });
return respondJson(response, {
message: `Failed to forward port ${port}`
}, 400);
}
}
portForward.open();
static async routeCurrentPortForward(request: LensApiRequest) {
const { params, query, response, cluster } = request;
const { namespace, resourceType, resourceName } = params;
const port = Number(query.get("port"));
const forwardPort = Number(query.get("forwardPort"));
respondJson(response, {});
const portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: resourceType, name: resourceName,
namespace, port, forwardPort
});
respondJson(response, { port: portForward?.forwardPort ?? null });
}
static async routeAllPortForwards(request: LensApiRequest) {
const { response } = request;
const portForwards: PortForwardArgs[] = PortForward.portForwards.map(f => (
{
clusterId: f.clusterId,
kind: f.kind,
namespace: f.namespace,
name: f.name,
port: f.port,
forwardPort: f.forwardPort,
})
);
respondJson(response, { portForwards });
}
static async routeCurrentPortForwardStop(request: LensApiRequest) {
const { params, query, response, cluster } = request;
const { namespace, resourceType, resourceName } = params;
const port = Number(query.get("port"));
const forwardPort = Number(query.get("forwardPort"));
const portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: resourceType, name: resourceName,
namespace, port, forwardPort,
});
try {
await portForward.stop();
respondJson(response, { status: true });
} catch (error) {
logger.error("[PORT-FORWARD-ROUTE]: error stopping a port-forward", { namespace, port, forwardPort, resourceType, resourceName });
return respondJson(response, {
message: `error stopping a forward port ${port}`
}, 400);
}
}
}

View File

@ -30,7 +30,19 @@ export class ResourceApplierApiRoute {
try {
const resource = await new ResourceApplier(cluster).apply(payload);
respondJson(response, [resource], 200);
respondJson(response, resource, 200);
} catch (error) {
respondText(response, error, 422);
}
}
static async patchResource(request: LensApiRequest) {
const { response, cluster, payload } = request;
try {
const resource = await new ResourceApplier(cluster).patch(payload.name, payload.kind, payload.patch, payload.ns);
respondJson(response, resource, 200);
} catch (error) {
respondText(response, error, 422);
}

View File

@ -21,14 +21,37 @@
import type http from "http";
export function respondJson(res: http.ServerResponse, content: any, status = 200) {
respond(res, JSON.stringify(content), "application/json", status);
/**
* Respond to a HTTP request with a body of JSON data
* @param res The HTTP response to write data to
* @param content The data or its JSON stringified version of it
* @param status [200] The status code to respond with
*/
export function respondJson(res: http.ServerResponse, content: Object | string, status = 200) {
const normalizedContent = typeof content === "object"
? JSON.stringify(content)
: content;
respond(res, normalizedContent, "application/json", status);
}
/**
* Respond to a HTTP request with a body of plain text data
* @param res The HTTP response to write data to
* @param content The string data to respond with
* @param status [200] The status code to respond with
*/
export function respondText(res: http.ServerResponse, content: string, status = 200) {
respond(res, content, "text/plain", status);
}
/**
* Respond to a HTTP request with a body of plain text data
* @param res The HTTP response to write data to
* @param content The string data to respond with
* @param contentType The HTTP Content-Type header value
* @param status [200] The status code to respond with
*/
export function respond(res: http.ServerResponse, content: string, contentType: string, status = 200) {
res.setHeader("Content-Type", contentType);
res.statusCode = status;

View File

@ -109,10 +109,6 @@ export class WindowManager extends Singleton {
app.dock?.hide(); // hide icon in dock (mac-os)
})
.webContents
.on("new-window", (event, url) => {
event.preventDefault();
shell.openExternal(url);
})
.on("dom-ready", () => {
appEventBus.emit({ name: "app", action: "dom-ready" });
})
@ -150,6 +146,10 @@ export class WindowManager extends Singleton {
// Always disable Node.js integration for all webviews
webPreferences.nodeIntegration = false;
}).setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: "deny" };
});
}

View File

@ -19,12 +19,12 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { app } from "electron";
import fse from "fs-extra";
import path from "path";
import { getPath } from "../../common/utils/getPath";
export function fileNameMigration() {
const userDataPath = getPath("userData");
const userDataPath = app.getPath("userData");
const configJsonPath = path.join(userDataPath, "config.json");
const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json");

View File

@ -27,8 +27,12 @@ import type { Cluster } from "../../main/cluster";
import { ClusterStore } from "../../common/cluster-store";
import { Disposer, iter } from "../utils";
import { once } from "lodash";
import logger from "../../common/logger";
import { catalogEntityRunContext } from "./catalog-entity";
import { CatalogRunEvent } from "../../common/catalog/catalog-run-event";
export type EntityFilter = (entity: CatalogEntity) => any;
export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise<void>;
export class CatalogEntityRegistry {
@observable protected activeEntityId: string | undefined = undefined;
@ -36,6 +40,9 @@ export class CatalogEntityRegistry {
protected filters = observable.set<EntityFilter>([], {
deep: false,
});
protected onBeforeRunHooks = observable.set<CatalogEntityOnBeforeRun>([], {
deep: false,
});
/**
* Buffer for keeping entities that don't yet have CatalogCategory synced
@ -169,6 +176,60 @@ export class CatalogEntityRegistry {
return once(() => void this.filters.delete(fn));
}
/**
* Add a onBeforeRun hook. If `onBeforeRun` was previously added then it will not be added again
* @param onBeforeRun The function that should return a boolean if the onRun of catalog entity should be triggered.
* @returns A function to remove that hook
*/
addOnBeforeRun(onBeforeRun: CatalogEntityOnBeforeRun): Disposer {
logger.debug(`[CATALOG-ENTITY-REGISTRY]: adding onBeforeRun hook`);
this.onBeforeRunHooks.add(onBeforeRun);
return once(() => void this.onBeforeRunHooks.delete(onBeforeRun));
}
/**
* Runs all the registered `onBeforeRun` hooks, short circuiting on the first event that's preventDefaulted
* @param entity The entity to run the hooks on
* @returns Whether the entities `onRun` method should be executed
*/
async onBeforeRun(entity: CatalogEntity): Promise<boolean> {
logger.debug(`[CATALOG-ENTITY-REGISTRY]: run onBeforeRun on ${entity.getId()}`);
const runEvent = new CatalogRunEvent({ target: entity });
for (const onBeforeRun of this.onBeforeRunHooks) {
try { 
await onBeforeRun(runEvent);
} catch (error) {
logger.warn(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onBeforeRun threw an error`, error);
}
if (runEvent.defaultPrevented) {
return false;
}
}
return true;
}
/**
* Perform the onBeforeRun check and, if successful, then proceed to call `entity`'s onRun method
* @param entity The instance to invoke the hooks and then execute the onRun
*/
onRun(entity: CatalogEntity): void {
this.onBeforeRun(entity)
.then(doOnRun => {
if (doOnRun) {
return entity.onRun?.(catalogEntityRunContext);
} else {
logger.debug(`onBeforeRun for ${entity.getId()} returned false`);
}
})
.catch(error => logger.error(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onRun threw an error`, error));
}
}
export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);

View File

@ -29,7 +29,7 @@ import * as ReactRouterDom from "react-router-dom";
import * as LensExtensionsCommonApi from "../extensions/common-api";
import * as LensExtensionsRendererApi from "../extensions/renderer-api";
import { monaco } from "react-monaco-editor";
import { render, unmountComponentAtNode } from "react-dom";
import { render } from "react-dom";
import { delay } from "../common/utils";
import { isMac, isDevelopment } from "../common/vars";
import { ClusterStore } from "../common/cluster-store";
@ -66,7 +66,7 @@ async function attachChromeDebugger() {
}
type AppComponent = React.ComponentType & {
init?(): Promise<void>;
init?(rootElem: HTMLElement): Promise<void>;
};
export async function bootstrap(App: AppComponent) {
@ -125,16 +125,8 @@ export async function bootstrap(App: AppComponent) {
// init app's dependencies if any
if (App.init) {
await App.init();
await App.init(rootElem);
}
window.addEventListener("message", (ev: MessageEvent) => {
if (ev.data === "teardown") {
UserStore.getInstance(false)?.unregisterIpcListener();
ClusterStore.getInstance(false)?.unregisterIpcListener();
unmountComponentAtNode(rootElem);
window.location.href = "about:blank";
}
});
render(<>
{isMac && <div id="draggable-top" />}
{DefaultProps(App)}

View File

@ -59,7 +59,7 @@ export class ReleaseDetails extends Component<Props> {
@observable details: IReleaseDetails;
@observable values = "";
@observable valuesLoading = false;
@observable showOnlyUserSuppliedValues = false;
@observable showOnlyUserSuppliedValues = true;
@observable saving = false;
@observable releaseSecret: Secret;
@ -165,7 +165,7 @@ export class ReleaseDetails extends Component<Props> {
onChange={text => this.values = text}
theme={ThemeStore.getInstance().activeTheme.monacoTheme}
className={cssNames("MonacoEditor", {loading: valuesLoading})}
options={{readOnly: valuesLoading || this.showOnlyUserSuppliedValues, ...UserStore.getInstance().getEditorOptions()}}
options={{readOnly: valuesLoading, ...UserStore.getInstance().getEditorOptions()}}
>
{valuesLoading && <Spinner center />}
</MonacoEditor>

View File

@ -23,13 +23,12 @@ import "./catalog-entity-details.scss";
import React, { Component } from "react";
import { observer } from "mobx-react";
import { Drawer, DrawerItem } from "../drawer";
import { catalogEntityRunContext } from "../../api/catalog-entity";
import type { CatalogCategory, CatalogEntity } from "../../../common/catalog";
import { Icon } from "../icon";
import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu";
import { CatalogEntityDetailRegistry } from "../../../extensions/registries";
import { HotbarIcon } from "../hotbar/hotbar-icon";
import type { CatalogEntityItem } from "./catalog-entity.store";
import type { CatalogEntityItem } from "./catalog-entity-item";
import { isDevelopment } from "../../../common/vars";
interface Props<T extends CatalogEntity> {
@ -68,8 +67,10 @@ export class CatalogEntityDetails<T extends CatalogEntity> extends Component<Pro
material={item.entity.spec.icon?.material}
background={item.entity.spec.icon?.background}
disabled={!item?.enabled}
onClick={() => item.onRun(catalogEntityRunContext)}
size={128} />
onClick={() => item.onRun()}
size={128}
data-testid="detail-panel-hot-bar-icon"
/>
{item?.enabled && (
<div className="IconHint">
Click to open

View File

@ -30,7 +30,7 @@ import { MenuItem } from "../menu";
import { ConfirmDialog } from "../confirm-dialog";
import { HotbarStore } from "../../../common/hotbar-store";
import { Icon } from "../icon";
import type { CatalogEntityItem } from "./catalog-entity.store";
import type { CatalogEntityItem } from "./catalog-entity-item";
export interface CatalogEntityDrawerMenuProps<T extends CatalogEntity> extends MenuActionsProps {
item: CatalogEntityItem<T> | null | undefined;

View File

@ -0,0 +1,114 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import styles from "./catalog.module.css";
import React from "react";
import { action, computed } from "mobx";
import type { CatalogEntity } from "../../api/catalog-entity";
import type { ItemObject } from "../../../common/item.store";
import { Badge } from "../badge";
import { navigation } from "../../navigation";
import { searchUrlParam } from "../input";
import { makeCss } from "../../../common/utils/makeCss";
import { KubeObject } from "../../../common/k8s-api/kube-object";
import type { CatalogEntityRegistry } from "../../api/catalog-entity-registry";
const css = makeCss(styles);
export class CatalogEntityItem<T extends CatalogEntity> implements ItemObject {
constructor(public entity: T, private registry: CatalogEntityRegistry) {}
get kind() {
return this.entity.kind;
}
get apiVersion() {
return this.entity.apiVersion;
}
get name() {
return this.entity.metadata.name;
}
getName() {
return this.entity.metadata.name;
}
get id() {
return this.entity.metadata.uid;
}
getId() {
return this.id;
}
@computed get phase() {
return this.entity.status.phase;
}
get enabled() {
return this.entity.status.enabled ?? true;
}
get labels() {
return KubeObject.stringifyLabels(this.entity.metadata.labels);
}
getLabelBadges(onClick?: React.MouseEventHandler<any>) {
return this.labels
.map(label => (
<Badge
className={css.badge}
key={label}
label={label}
title={label}
onClick={(event) => {
navigation.searchParams.set(searchUrlParam.name, label);
onClick?.(event);
event.stopPropagation();
}}
expandable={false}
/>
));
}
get source() {
return this.entity.metadata.source || "unknown";
}
get searchFields() {
return [
this.name,
this.id,
this.phase,
`source=${this.source}`,
...this.labels,
];
}
onRun() {
this.registry.onRun(this.entity);
}
@action
async onContextMenuOpen(ctx: any) {
return this.entity.onContextMenuOpen(ctx);
}
}

View File

@ -19,106 +19,16 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import styles from "./catalog.module.css";
import React from "react";
import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from "mobx";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import type { CatalogEntity, CatalogEntityActionContext } from "../../api/catalog-entity";
import { ItemObject, ItemStore } from "../../../common/item.store";
import { computed, makeObservable, observable, reaction } from "mobx";
import { catalogEntityRegistry, CatalogEntityRegistry } from "../../api/catalog-entity-registry";
import type { CatalogEntity } from "../../api/catalog-entity";
import { ItemStore } from "../../../common/item.store";
import { CatalogCategory, catalogCategoryRegistry } from "../../../common/catalog";
import { autoBind } from "../../../common/utils";
import { Badge } from "../badge";
import { navigation } from "../../navigation";
import { searchUrlParam } from "../input";
import { makeCss } from "../../../common/utils/makeCss";
import { KubeObject } from "../../../common/k8s-api/kube-object";
const css = makeCss(styles);
export class CatalogEntityItem<T extends CatalogEntity> implements ItemObject {
constructor(public entity: T) {}
get kind() {
return this.entity.kind;
}
get apiVersion() {
return this.entity.apiVersion;
}
get name() {
return this.entity.metadata.name;
}
getName() {
return this.entity.metadata.name;
}
get id() {
return this.entity.metadata.uid;
}
getId() {
return this.id;
}
@computed get phase() {
return this.entity.status.phase;
}
get enabled() {
return this.entity.status.enabled ?? true;
}
get labels() {
return KubeObject.stringifyLabels(this.entity.metadata.labels);
}
getLabelBadges(onClick?: React.MouseEventHandler<any>) {
return this.labels
.map(label => (
<Badge
className={css.badge}
key={label}
label={label}
title={label}
onClick={(event) => {
navigation.searchParams.set(searchUrlParam.name, label);
onClick?.(event);
event.stopPropagation();
}}
expandable={false}
/>
));
}
get source() {
return this.entity.metadata.source || "unknown";
}
get searchFields() {
return [
this.name,
this.id,
this.phase,
`source=${this.source}`,
...this.labels,
];
}
onRun(ctx: CatalogEntityActionContext) {
this.entity.onRun(ctx);
}
@action
async onContextMenuOpen(ctx: any) {
return this.entity.onContextMenuOpen(ctx);
}
}
import { autoBind, disposer } from "../../../common/utils";
import { CatalogEntityItem } from "./catalog-entity-item";
export class CatalogEntityStore extends ItemStore<CatalogEntityItem<CatalogEntity>> {
constructor() {
constructor(private registry: CatalogEntityRegistry = catalogEntityRegistry) {
super();
makeObservable(this);
autoBind(this);
@ -129,10 +39,10 @@ export class CatalogEntityStore extends ItemStore<CatalogEntityItem<CatalogEntit
@computed get entities() {
if (!this.activeCategory) {
return catalogEntityRegistry.filteredItems.map(entity => new CatalogEntityItem(entity));
return this.registry.filteredItems.map(entity => new CatalogEntityItem(entity, this.registry));
}
return catalogEntityRegistry.getItemsForCategory(this.activeCategory, { filtered: true }).map(entity => new CatalogEntityItem(entity));
return this.registry.getItemsForCategory(this.activeCategory, { filtered: true }).map(entity => new CatalogEntityItem(entity, this.registry));
}
@computed get selectedItem() {
@ -140,12 +50,10 @@ export class CatalogEntityStore extends ItemStore<CatalogEntityItem<CatalogEntit
}
watch() {
const disposers: IReactionDisposer[] = [
return disposer(
reaction(() => this.entities, () => this.loadAll()),
reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100})
];
return () => disposers.forEach((dispose) => dispose());
reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100}),
);
}
loadAll() {

View File

@ -32,4 +32,4 @@
.catalog {
@apply p-5 font-bold text-2xl;
color: var(--textColorAccent);
}
}

View File

@ -41,11 +41,15 @@ function getCategories() {
}
function getCategoryIcon(category: CatalogCategory) {
if (!category.metadata?.icon) return null;
const { icon } = category.metadata ?? {};
return category.metadata.icon.includes("<svg")
? <Icon small svg={category.metadata.icon}/>
: <Icon small material={category.metadata.icon}/>;
if (typeof icon === "string") {
return icon.includes("<svg")
? <Icon small svg={icon}/>
: <Icon small material={icon}/>;
}
return null;
}
function Item(props: TreeItemProps) {

View File

@ -0,0 +1,366 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Catalog } from "./catalog";
import { createMemoryHistory } from "history";
import { mockWindow } from "../../../../__mocks__/windowMock";
import { kubernetesClusterCategory } from "../../../common/catalog-entities/kubernetes-cluster";
import { catalogCategoryRegistry, CatalogCategoryRegistry } from "../../../common/catalog";
import { CatalogEntityRegistry } from "../../../renderer/api/catalog-entity-registry";
import { CatalogEntityDetailRegistry } from "../../../extensions/registries";
import { CatalogEntityItem } from "./catalog-entity-item";
import { CatalogEntityStore } from "./catalog-entity.store";
mockWindow();
// avoid TypeError: Cannot read property 'getPath' of undefined
jest.mock("@electron/remote", () => {
return {
app: {
getPath: () => {
// avoid TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received undefined
return "";
},
},
};
});
describe("<Catalog />", () => {
const history = createMemoryHistory();
const mockLocation = {
pathname: "",
search: "",
state: "",
hash: "",
};
const mockMatch = {
params: {
// will be used to match activeCategory
// need to be the same as property values in kubernetesClusterCategory
group: "entity.k8slens.dev",
kind: "KubernetesCluster",
},
isExact: true,
path: "",
url: "",
};
const catalogEntityUid = "a_catalogEntity_uid";
const catalogEntity = {
enabled: true,
apiVersion: "api",
kind: "kind",
metadata: {
uid: catalogEntityUid,
name: "a catalog entity",
labels: {
test: "label",
},
},
status: {
phase: "",
},
spec: {},
};
const catalogEntityItemMethods = {
getId: () => catalogEntity.metadata.uid,
getName: () => catalogEntity.metadata.name,
onContextMenuOpen: () => {},
onSettingsOpen: () => {},
onRun: () => {},
};
beforeEach(() => {
CatalogEntityDetailRegistry.createInstance();
// mock the return of getting CatalogCategoryRegistry.filteredItems
jest
.spyOn(catalogCategoryRegistry, "filteredItems", "get")
.mockImplementation(() => {
return [kubernetesClusterCategory];
});
// we don't care what this.renderList renders in this test case.
jest.spyOn(Catalog.prototype, "renderList").mockImplementation(() => {
return <span>empty renderList</span>;
});
});
afterEach(() => {
CatalogEntityDetailRegistry.resetInstance();
jest.clearAllMocks();
jest.restoreAllMocks();
});
it("can use catalogEntityRegistry.addOnBeforeRun to add hooks for catalog entities", (done) => {
const catalogCategoryRegistry = new CatalogCategoryRegistry();
const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry);
const onRun = jest.fn();
const catalogEntityItem = new CatalogEntityItem({
...catalogEntity,
...catalogEntityItemMethods,
onRun,
}, catalogEntityRegistry);
// mock as if there is a selected item > the detail panel opens
jest
.spyOn(catalogEntityStore, "selectedItem", "get")
.mockImplementation(() => catalogEntityItem);
catalogEntityRegistry.addOnBeforeRun(
(event) => {
expect(event.target).toMatchInlineSnapshot(`
Object {
"apiVersion": "api",
"enabled": true,
"getId": [Function],
"getName": [Function],
"kind": "kind",
"metadata": Object {
"labels": Object {
"test": "label",
},
"name": "a catalog entity",
"uid": "a_catalogEntity_uid",
},
"onContextMenuOpen": [Function],
"onRun": [MockFunction],
"onSettingsOpen": [Function],
"spec": Object {},
"status": Object {
"phase": "",
},
}
`);
setTimeout(() => {
expect(onRun).toHaveBeenCalled();
done();
}, 500);
}
);
render(
<Catalog
history={history}
location={mockLocation}
match={mockMatch}
catalogEntityStore={catalogEntityStore}
/>
);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
});
it("onBeforeRun prevents event => onRun wont be triggered", (done) => {
const catalogCategoryRegistry = new CatalogCategoryRegistry();
const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry);
const onRun = jest.fn();
const catalogEntityItem = new CatalogEntityItem({
...catalogEntity,
...catalogEntityItemMethods,
onRun,
}, catalogEntityRegistry);
// mock as if there is a selected item > the detail panel opens
jest
.spyOn(catalogEntityStore, "selectedItem", "get")
.mockImplementation(() => catalogEntityItem);
catalogEntityRegistry.addOnBeforeRun(
(e) => {
setTimeout(() => {
expect(onRun).not.toHaveBeenCalled();
done();
}, 500);
e.preventDefault();
}
);
render(
<Catalog
history={history}
location={mockLocation}
match={mockMatch}
catalogEntityStore={catalogEntityStore}
/>
);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
});
it("addOnBeforeRun throw an exception => onRun will be triggered", (done) => {
const catalogCategoryRegistry = new CatalogCategoryRegistry();
const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry);
const onRun = jest.fn();
const catalogEntityItem = new CatalogEntityItem({
...catalogEntity,
...catalogEntityItemMethods,
onRun,
}, catalogEntityRegistry);
// mock as if there is a selected item > the detail panel opens
jest
.spyOn(catalogEntityStore, "selectedItem", "get")
.mockImplementation(() => catalogEntityItem);
catalogEntityRegistry.addOnBeforeRun(
() => {
setTimeout(() => {
expect(onRun).toHaveBeenCalled();
done();
}, 500);
throw new Error("error!");
}
);
render(
<Catalog
history={history}
location={mockLocation}
match={mockMatch}
catalogEntityStore={catalogEntityStore}
/>
);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
});
it("addOnRunHook return a promise and does not prevent run event => onRun()", (done) => {
const catalogCategoryRegistry = new CatalogCategoryRegistry();
const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry);
const onRun = jest.fn(() => done());
const catalogEntityItem = new CatalogEntityItem({
...catalogEntity,
...catalogEntityItemMethods,
onRun,
}, catalogEntityRegistry);
// mock as if there is a selected item > the detail panel opens
jest
.spyOn(catalogEntityStore, "selectedItem", "get")
.mockImplementation(() => catalogEntityItem);
catalogEntityRegistry.addOnBeforeRun(
async () => {
// no op
}
);
render(
<Catalog
history={history}
location={mockLocation}
match={mockMatch}
catalogEntityStore={catalogEntityStore}
/>
);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
});
it("addOnRunHook return a promise and prevents event wont be triggered", (done) => {
const catalogCategoryRegistry = new CatalogCategoryRegistry();
const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry);
const onRun = jest.fn();
const catalogEntityItem = new CatalogEntityItem({
...catalogEntity,
...catalogEntityItemMethods,
onRun,
}, catalogEntityRegistry);
// mock as if there is a selected item > the detail panel opens
jest
.spyOn(catalogEntityStore, "selectedItem", "get")
.mockImplementation(() => catalogEntityItem);
catalogEntityRegistry.addOnBeforeRun(
async (e) => {
expect(onRun).not.toBeCalled();
setTimeout(() => {
expect(onRun).not.toBeCalled();
done();
}, 500);
e.preventDefault();
}
);
render(
<Catalog
history={history}
location={mockLocation}
match={mockMatch}
catalogEntityStore={catalogEntityStore}
/>
);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
});
it("addOnRunHook return a promise and reject => onRun will be triggered", (done) => {
const catalogCategoryRegistry = new CatalogCategoryRegistry();
const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry);
const onRun = jest.fn();
const catalogEntityItem = new CatalogEntityItem({
...catalogEntity,
...catalogEntityItemMethods,
onRun,
}, catalogEntityRegistry);
// mock as if there is a selected item > the detail panel opens
jest
.spyOn(catalogEntityStore, "selectedItem", "get")
.mockImplementation(() => catalogEntityItem);
catalogEntityRegistry.addOnBeforeRun(
async () => {
setTimeout(() => {
expect(onRun).toHaveBeenCalled();
done();
}, 500);
throw new Error("rejection!");
}
);
render(
<Catalog
history={history}
location={mockLocation}
match={mockMatch}
catalogEntityStore={catalogEntityStore}
/>
);
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
});
});

View File

@ -25,7 +25,8 @@ import React from "react";
import { disposeOnUnmount, observer } from "mobx-react";
import { ItemListLayout } from "../item-object-list";
import { action, makeObservable, observable, reaction, runInAction, when } from "mobx";
import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store";
import { CatalogEntityStore } from "./catalog-entity.store";
import type { CatalogEntityItem } from "./catalog-entity-item";
import { navigate } from "../../navigation";
import { MenuItem, MenuActions } from "../menu";
import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
@ -39,12 +40,12 @@ import { MainLayout } from "../layout/main-layout";
import { createAppStorage, cssNames } from "../../utils";
import { makeCss } from "../../../common/utils/makeCss";
import { CatalogEntityDetails } from "./catalog-entity-details";
import { catalogURL, CatalogViewRouteParam } from "../../../common/routes";
import { browseCatalogTab, catalogURL, CatalogViewRouteParam } from "../../../common/routes";
import { CatalogMenu } from "./catalog-menu";
import { HotbarIcon } from "../hotbar/hotbar-icon";
import { RenderDelay } from "../render-delay/render-delay";
export const previousActiveTab = createAppStorage("catalog-previous-active-tab", "");
export const previousActiveTab = createAppStorage("catalog-previous-active-tab", browseCatalogTab);
enum sortBy {
name = "name",
@ -55,7 +56,10 @@ enum sortBy {
const css = makeCss(styles);
interface Props extends RouteComponentProps<CatalogViewRouteParam> {}
interface Props extends RouteComponentProps<CatalogViewRouteParam> {
catalogEntityStore?: CatalogEntityStore;
}
@observer
export class Catalog extends React.Component<Props> {
@observable private catalogEntityStore?: CatalogEntityStore;
@ -65,8 +69,11 @@ export class Catalog extends React.Component<Props> {
constructor(props: Props) {
super(props);
makeObservable(this);
this.catalogEntityStore = new CatalogEntityStore();
this.catalogEntityStore = props.catalogEntityStore;
}
static defaultProps = {
catalogEntityStore: new CatalogEntityStore(),
};
get routeActiveTab(): string {
const { group, kind } = this.props.match.params ?? {};
@ -75,7 +82,7 @@ export class Catalog extends React.Component<Props> {
return `${group}/${kind}`;
}
return "";
return browseCatalogTab;
}
async componentDidMount() {
@ -89,7 +96,7 @@ export class Catalog extends React.Component<Props> {
previousActiveTab.set(this.routeActiveTab);
try {
await when(() => (routeTab === "" || !!catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab)), { timeout: 5_000 }); // we need to wait because extensions might take a while to load
await when(() => (routeTab === browseCatalogTab || !!catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab)), { timeout: 5_000 }); // we need to wait because extensions might take a while to load
const item = catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab);
runInAction(() => {
@ -117,15 +124,16 @@ export class Catalog extends React.Component<Props> {
}
}));
}
addToHotbar(item: CatalogEntityItem<CatalogEntity>): void {
HotbarStore.getInstance().addToHotbar(item.entity);
}
onDetails = (item: CatalogEntityItem<CatalogEntity>) => {
if (this.catalogEntityStore.selectedItemId === item.getId()) {
if (this.catalogEntityStore.selectedItemId) {
this.catalogEntityStore.selectedItemId = null;
} else {
this.catalogEntityStore.selectedItemId = item.getId();
item.onRun();
}
};
@ -157,7 +165,7 @@ export class Catalog extends React.Component<Props> {
if (activeCategory) {
navigate(catalogURL({ params: {group: activeCategory.spec.group, kind: activeCategory.spec.names.kind }}));
} else {
navigate(catalogURL());
navigate(catalogURL({ params: { group: browseCatalogTab }}));
}
};
@ -176,6 +184,9 @@ export class Catalog extends React.Component<Props> {
return (
<MenuActions onOpen={onOpen}>
<MenuItem key="open-details" onClick={() => this.catalogEntityStore.selectedItemId = item.getId()}>
View Details
</MenuItem>
{
this.contextMenu.menuItems.map((menuItem, index) => (
<MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}>

View File

@ -72,6 +72,8 @@ export class ConfigMapDetails extends React.Component<Props> {
<>ConfigMap <b>{configMap.getName()}</b> successfully updated.</>
</p>
);
} catch (error) {
Notifications.error(`Failed to save config map: ${error}`);
} finally {
this.isSaving = false;
}

View File

@ -26,6 +26,7 @@ import { observer } from "mobx-react";
import type { KubeObject } from "../../../common/k8s-api/kube-object";
import { DrawerItem, DrawerTitle } from "../drawer";
import { cssNames } from "../../utils";
import { LocaleDate } from "../locale-date";
import { eventStore } from "./event.store";
export interface KubeEventDetailsProps {
@ -74,7 +75,7 @@ export class KubeEventDetails extends React.Component<KubeEventDetailsProps> {
{involvedObject.fieldPath}
</DrawerItem>
<DrawerItem name="Last seen">
{lastTimestamp}
<LocaleDate date={lastTimestamp} />
</DrawerItem>
</div>
);

View File

@ -18,4 +18,5 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export * from "./parse-query";
export * from "./port-forwards";

View File

@ -0,0 +1,73 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import { boundMethod, cssNames } from "../../utils";
import { openPortForward, PortForwardItem, removePortForward } from "../../port-forward";
import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
import { MenuItem } from "../menu";
import { Icon } from "../icon";
import { PortForwardDialog } from "../../port-forward";
interface Props extends MenuActionsProps {
portForward: PortForwardItem;
hideDetails?(): void;
}
export class PortForwardMenu extends React.Component<Props> {
@boundMethod
remove() {
return removePortForward(this.props.portForward);
}
renderContent() {
const { portForward, toolbar } = this.props;
if (!portForward) return null;
return (
<>
<MenuItem onClick={() => openPortForward(this.props.portForward)}>
<Icon material="open_in_browser" interactive={toolbar} tooltip="Open in browser" />
<span className="title">Open</span>
</MenuItem>
<MenuItem onClick={() => PortForwardDialog.open(portForward)}>
<Icon material="edit" tooltip="Change port" interactive={toolbar} />
<span className="title">Edit</span>
</MenuItem>
</>
);
}
render() {
const { className, ...menuProps } = this.props;
return (
<MenuActions
{...menuProps}
className={cssNames("PortForwardMenu", className)}
removeAction={this.remove}
>
{this.renderContent()}
</MenuActions>
);
}
}

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.PortForwards {
.TableCell {
&.warning {
@include table-cell-warning;
}
}
}

View File

@ -0,0 +1,103 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./port-forwards.scss";
import React from "react";
import { disposeOnUnmount, observer } from "mobx-react";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { PortForwardItem, portForwardStore } from "../../port-forward";
import { PortForwardMenu } from "./port-forward-menu";
enum columnId {
name = "name",
namespace = "namespace",
kind = "kind",
port = "port",
forwardPort = "forwardPort",
}
@observer
export class PortForwards extends React.Component {
componentDidMount() {
disposeOnUnmount(this, [
portForwardStore.watch(),
]);
}
renderRemoveDialogMessage(selectedItems: PortForwardItem[]) {
const forwardPorts = selectedItems.map(item => item.getForwardPort()).join(", ");
return (
<div>
<>Stop forwarding from <b>{forwardPorts}</b>?</>
</div>
);
}
render() {
return (
<>
<ItemListLayout
isConfigurable
tableId="port_forwards"
className="PortForwards" store={portForwardStore}
sortingCallbacks={{
[columnId.name]: item => item.getName(),
[columnId.namespace]: item => item.getNs(),
[columnId.kind]: item => item.getKind(),
[columnId.port]: item => item.getPort(),
[columnId.forwardPort]: item => item.getForwardPort(),
}}
searchFilters={[
item => item.getSearchFields(),
]}
renderHeaderTitle="Port Forwarding"
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
{ title: "Kind", className: "kind", sortBy: columnId.kind, id: columnId.kind },
{ title: "Pod Port", className: "port", sortBy: columnId.port, id: columnId.port },
{ title: "Local Port", className: "forwardPort", sortBy: columnId.forwardPort, id: columnId.forwardPort },
]}
renderTableContents={item => [
item.getName(),
item.getNs(),
item.getKind(),
item.getPort(),
item.getForwardPort(),
]}
renderItemMenu={pf => (
<PortForwardMenu
portForward={pf}
removeConfirmationMessage={this.renderRemoveDialogMessage([pf])}
/>
)}
customizeRemoveDialog={selectedItems => ({
message: this.renderRemoveDialogMessage(selectedItems)
})}
/>
</>
);
}
}

View File

@ -32,6 +32,7 @@ import { ServicePortComponent } from "./service-port-component";
import { endpointStore } from "../+network-endpoints/endpoints.store";
import { ServiceDetailsEndpoint } from "./service-details-endpoint";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
import { portForwardStore } from "../../port-forward";
interface Props extends KubeObjectDetailsProps<Service> {
}
@ -46,6 +47,7 @@ export class ServiceDetails extends React.Component<Props> {
preload: true,
namespaces: [service.getNs()],
}),
portForwardStore.watch(),
]);
}

View File

@ -33,11 +33,13 @@
cursor: pointer;
color: $primary;
text-decoration: underline;
padding-right: 1em;
}
.Spinner {
--spinner-size: #{$unit * 2};
margin-left: $margin;
position: absolute;
.portInput {
display: inline-block !important;
width: 70px;
margin-left: 10px;
margin-right: 10px;
}
}

View File

@ -22,12 +22,15 @@
import "./service-port-component.scss";
import React from "react";
import { observer } from "mobx-react";
import { disposeOnUnmount, observer } from "mobx-react";
import type { Service, ServicePort } from "../../../common/k8s-api/endpoints";
import { apiBase } from "../../api";
import { observable, makeObservable } from "mobx";
import { observable, makeObservable, reaction } from "mobx";
import { cssNames } from "../../utils";
import { Notifications } from "../notifications";
import { Button } from "../button";
import { addPortForward, getPortForward, openPortForward, PortForwardDialog, portForwardStore, removePortForward } from "../../port-forward";
import type { ForwardedPort } from "../../port-forward";
import logger from "../../../common/logger";
import { Spinner } from "../spinner";
interface Props {
@ -38,20 +41,86 @@ interface Props {
@observer
export class ServicePortComponent extends React.Component<Props> {
@observable waiting = false;
@observable forwardPort = 0;
@observable isPortForwarded = false;
constructor(props: Props) {
super(props);
makeObservable(this);
this.init();
}
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => [ portForwardStore.portForwards, this.props.service ], () => this.init()),
]);
}
init() {
this.checkExistingPortForwarding().catch(error => {
logger.error(error);
});
}
async checkExistingPortForwarding() {
const { service, port } = this.props;
const portForward: ForwardedPort = {
kind: "service",
name: service.getName(),
namespace: service.getNs(),
port: port.port,
forwardPort: this.forwardPort,
};
const activePort = await getPortForward(portForward) ?? 0;
this.forwardPort = activePort;
this.isPortForwarded = activePort ? true : false;
}
async portForward() {
const { service, port } = this.props;
const portForward: ForwardedPort = {
kind: "service",
name: service.getName(),
namespace: service.getNs(),
port: port.port,
forwardPort: this.forwardPort,
};
this.waiting = true;
this.isPortForwarded = false;
try {
this.forwardPort = await addPortForward(portForward);
if (this.forwardPort) {
portForward.forwardPort = this.forwardPort;
openPortForward(portForward);
this.isPortForwarded = true;
}
} catch (error) {
Notifications.error(error);
} finally {
this.waiting = false;
}
}
async stopPortForward() {
const { service, port } = this.props;
const portForward: ForwardedPort = {
kind: "service",
name: service.getName(),
namespace: service.getNs(),
port: port.port,
forwardPort: this.forwardPort,
};
this.waiting = true;
try {
await apiBase.post(`/pods/${service.getNs()}/service/${service.getName()}/port-forward/${port.port}`, {});
} catch(error) {
await removePortForward(portForward);
this.isPortForwarded = false;
} catch (error) {
Notifications.error(error);
} finally {
this.waiting = false;
@ -59,16 +128,33 @@ export class ServicePortComponent extends React.Component<Props> {
}
render() {
const { port } = this.props;
const { port, service } = this.props;
const portForwardAction = async () => {
if (this.isPortForwarded) {
await this.stopPortForward();
} else {
const portForward: ForwardedPort = {
kind: "service",
name: service.getName(),
namespace: service.getNs(),
port: port.port,
forwardPort: this.forwardPort,
};
PortForwardDialog.open(portForward, { openInBrowser: true });
}
};
return (
<div className={cssNames("ServicePortComponent", { waiting: this.waiting })}>
<span title="Open in a browser" onClick={() => this.portForward() }>
<span title="Open in a browser" onClick={() => this.portForward()}>
{port.toString()}
{this.waiting && (
<Spinner />
)}
</span>
<Button onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button>
{this.waiting && (
<Spinner />
)}
</div>
);
}

View File

@ -28,6 +28,7 @@ import { Services } from "../+network-services";
import { Endpoints } from "../+network-endpoints";
import { Ingresses } from "../+network-ingresses";
import { NetworkPolicies } from "../+network-policies";
import { PortForwards } from "../+network-port-forwards";
import { isAllowedResource } from "../../../common/utils/allowed-resource";
import * as routes from "../../../common/routes";
@ -72,6 +73,13 @@ export class Network extends React.Component {
});
}
tabs.push({
title: "Port Forwarding",
component: PortForwards,
url: routes.portForwardsURL(),
routePath: routes.portForwardsRoute.path.toString(),
});
return tabs;
}

View File

@ -236,8 +236,8 @@ export class Nodes extends React.Component<Props> {
this.renderDiskUsage(node),
<>
<span id={tooltipId}>{node.getTaints().length}</span>
<Tooltip targetId={tooltipId} style={{ whiteSpace: "pre-line" }}>
{node.getTaints().map(({ key, effect }) => `${key}: ${effect}`).join("\n")}
<Tooltip targetId={tooltipId} tooltipOnParentHover={true} style={{ whiteSpace: "pre-line" }}>
{node.getTaints().map(({ key, value, effect }) => `${key}=${value}:${effect}`).join("\n")}
</Tooltip>
</>,
node.getRoleLabels(),

View File

@ -72,6 +72,20 @@ export const Application = observer(() => {
/>
</section>
<section id="terminalSelection">
<SubTitle title="Terminal copy & paste" />
<FormSwitch
label="Copy on select and paste on right-click"
control={
<Switcher
checked={UserStore.getInstance().terminalCopyOnSelect}
onChange={v => UserStore.getInstance().terminalCopyOnSelect = v.target.checked}
name="terminalCopyOnSelect"
/>
}
/>
</section>
<hr/>
<section id="other">

View File

@ -75,6 +75,7 @@ export class DeploymentReplicaSets extends React.Component<Props> {
sortable={this.sortingCallbacks}
sortByDefault={{ sortBy: sortBy.pods, orderBy: "desc" }}
sortSyncWithUrl={false}
tableId="deployment_replica_sets_view"
className="box grow"
>
<TableHead>

View File

@ -112,6 +112,7 @@ const dummyDeployment: Deployment = {
toPlainObject: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
patch: jest.fn(),
};
describe("<DeploymentScaleDialog />", () => {

View File

@ -34,11 +34,13 @@
color: $primary;
text-decoration: underline;
position: relative;
padding-right: 1em;
}
.Spinner {
--spinner-size: #{$unit * 2};
margin-left: $margin;
position: absolute;
.portInput {
display: inline-block !important;
width: 70px;
margin-left: 10px;
margin-right: 10px;
}
}
}

View File

@ -22,12 +22,15 @@
import "./pod-container-port.scss";
import React from "react";
import { observer } from "mobx-react";
import { disposeOnUnmount, observer } from "mobx-react";
import type { Pod } from "../../../common/k8s-api/endpoints";
import { apiBase } from "../../api";
import { observable, makeObservable } from "mobx";
import { observable, makeObservable, reaction } from "mobx";
import { cssNames } from "../../utils";
import { Notifications } from "../notifications";
import { Button } from "../button";
import { addPortForward, getPortForward, openPortForward, PortForwardDialog, portForwardStore, removePortForward } from "../../port-forward";
import type { ForwardedPort } from "../../port-forward";
import logger from "../../../common/logger";
import { Spinner } from "../spinner";
interface Props {
@ -42,20 +45,86 @@ interface Props {
@observer
export class PodContainerPort extends React.Component<Props> {
@observable waiting = false;
@observable forwardPort = 0;
@observable isPortForwarded = false;
constructor(props: Props) {
super(props);
makeObservable(this);
this.init();
}
componentDidMount() {
disposeOnUnmount(this, [
reaction(() => [ portForwardStore.portForwards, this.props.pod ], () => this.init()),
]);
}
init() {
this.checkExistingPortForwarding().catch(error => {
logger.error(error);
});
}
async checkExistingPortForwarding() {
const { pod, port } = this.props;
const portForward: ForwardedPort = {
kind: "pod",
name: pod.getName(),
namespace: pod.getNs(),
port: port.containerPort,
forwardPort: this.forwardPort,
};
const activePort = await getPortForward(portForward) ?? 0;
this.forwardPort = activePort;
this.isPortForwarded = activePort ? true : false;
}
async portForward() {
const { pod, port } = this.props;
const portForward: ForwardedPort = {
kind: "pod",
name: pod.getName(),
namespace: pod.getNs(),
port: port.containerPort,
forwardPort: this.forwardPort,
};
this.waiting = true;
this.isPortForwarded = false;
try {
this.forwardPort = await addPortForward(portForward);
if (this.forwardPort) {
portForward.forwardPort = this.forwardPort;
openPortForward(portForward);
this.isPortForwarded = true;
}
} catch (error) {
Notifications.error(error);
} finally {
this.waiting = false;
}
}
async stopPortForward() {
const { pod, port } = this.props;
const portForward: ForwardedPort = {
kind: "pod",
name: pod.getName(),
namespace: pod.getNs(),
port: port.containerPort,
forwardPort: this.forwardPort,
};
this.waiting = true;
try {
await apiBase.post(`/pods/${pod.getNs()}/pod/${pod.getName()}/port-forward/${port.containerPort}`, {});
} catch(error) {
await removePortForward(portForward);
this.isPortForwarded = false;
} catch (error) {
Notifications.error(error);
} finally {
this.waiting = false;
@ -63,18 +132,35 @@ export class PodContainerPort extends React.Component<Props> {
}
render() {
const { port } = this.props;
const { pod, port } = this.props;
const { name, containerPort, protocol } = port;
const text = `${name ? `${name}: ` : ""}${containerPort}/${protocol}`;
const portForwardAction = async () => {
if (this.isPortForwarded) {
await this.stopPortForward();
} else {
const portForward: ForwardedPort = {
kind: "pod",
name: pod.getName(),
namespace: pod.getNs(),
port: port.containerPort,
forwardPort: this.forwardPort,
};
PortForwardDialog.open(portForward, { openInBrowser: true });
}
};
return (
<div className={cssNames("PodContainerPort", { waiting: this.waiting })}>
<span title="Open in a browser" onClick={() => this.portForward() }>
<span title="Open in a browser" onClick={() => this.portForward()}>
{text}
{this.waiting && (
<Spinner />
)}
</span>
<Button onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button>
{this.waiting && (
<Spinner />
)}
</div>
);
}

View File

@ -35,6 +35,8 @@ import { ContainerCharts } from "./container-charts";
import { LocaleDate } from "../locale-date";
import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { portForwardStore } from "../../port-forward/port-forward.store";
import { disposeOnUnmount, observer } from "mobx-react";
interface Props {
pod: Pod;
@ -42,8 +44,15 @@ interface Props {
metrics?: { [key: string]: IMetrics };
}
@observer
export class PodDetailsContainer extends React.Component<Props> {
componentDidMount() {
disposeOnUnmount(this, [
portForwardStore.watch(),
]);
}
renderStatus(state: string, status: IPodContainerStatus) {
const ready = status ? status.ready : "";

View File

@ -107,6 +107,7 @@ const dummyReplicaSet: ReplicaSet = {
toPlainObject: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
patch: jest.fn(),
};
describe("<ReplicaSetScaleDialog />", () => {

View File

@ -117,6 +117,7 @@ const dummyStatefulSet: StatefulSet = {
toPlainObject: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
patch: jest.fn(),
};
describe("<StatefulSetScaleDialog />", () => {

View File

@ -0,0 +1,58 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { computed } from "mobx";
import { observer } from "mobx-react";
import React from "react";
import type { CatalogEntity } from "../../api/catalog-entity";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { CommandOverlay } from "../command-palette";
import { Select } from "../select";
@observer
export class ActivateEntityCommand extends React.Component {
@computed get options() {
return catalogEntityRegistry.items.map(entity => ({
label: `${entity.kind}: ${entity.getName()}`,
value: entity,
}));
}
onSelect(entity: CatalogEntity): void {
catalogEntityRegistry.onRun(entity);
CommandOverlay.close();
}
render() {
return (
<Select
menuPortalTarget={null}
onChange={(v) => this.onSelect(v.value)}
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
menuIsOpen={true}
options={this.options}
autoFocus={true}
escapeClearsValue={false}
placeholder="Activate entity ..."
/>
);
}
}

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export * from "./activate-entity-command";

View File

@ -73,6 +73,8 @@ import { getHostedClusterId } from "../utils";
import { ClusterStore } from "../../common/cluster-store";
import type { ClusterId } from "../../common/cluster-types";
import { watchHistoryState } from "../remote-helpers/history-updater";
import { unmountComponentAtNode } from "react-dom";
import { PortForwardDialog } from "../port-forward";
@observer
export class App extends React.Component {
@ -83,7 +85,7 @@ export class App extends React.Component {
makeObservable(this);
}
static async init() {
static async init(rootElem: HTMLElement) {
catalogEntityRegistry.init();
const frameId = webFrame.routingId;
@ -112,6 +114,20 @@ export class App extends React.Component {
window.addEventListener("online", () => {
window.location.reload();
});
window.addEventListener("message", (ev: MessageEvent) => {
if (ev.data === "teardown") {
unmountComponentAtNode(rootElem);
window.location.href = "about:blank";
}
});
window.onbeforeunload = () => {
logger.info(`[APP]: Unload dashboard, clusterId=${App.clusterId}, frameId=${frameId}`);
unmountComponentAtNode(rootElem);
};
whatInput.ask(); // Start to monitor user input device
// Setup hosted cluster context
@ -216,6 +232,7 @@ export class App extends React.Component {
<StatefulSetScaleDialog/>
<ReplicaSetScaleDialog/>
<CronJobTriggerDialog/>
<PortForwardDialog/>
<CommandContainer clusterId={App.clusterId}/>
</ErrorBoundary>
</Router>

View File

@ -25,7 +25,7 @@ import React from "react";
import { Redirect, Route, Switch } from "react-router";
import { disposeOnUnmount, observer } from "mobx-react";
import { BottomBar } from "./bottom-bar";
import { Catalog } from "../+catalog";
import { Catalog, previousActiveTab } from "../+catalog";
import { Preferences } from "../+preferences";
import { AddCluster } from "../+add-cluster";
import { ClusterView } from "./cluster-view";
@ -40,6 +40,7 @@ import { reaction } from "mobx";
import { navigation } from "../../navigation";
import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync";
import { TopBar } from "../layout/topbar";
import { catalogURL, getPreviousTabUrl } from "../../../common/routes";
@observer
export class ClusterManager extends React.Component {
@ -56,6 +57,7 @@ export class ClusterManager extends React.Component {
<main>
<div id="lens-views"/>
<Switch>
<Redirect exact from={catalogURL()} to={getPreviousTabUrl(previousActiveTab.get())}/>
<Route component={Welcome} {...routes.welcomeRoute} />
<Route component={Catalog} {...routes.catalogRoute} />
<Route component={Preferences} {...routes.preferencesRoute} />

View File

@ -33,7 +33,6 @@ import { clusterActivateHandler } from "../../../common/cluster-ipc";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { navigate } from "../../navigation";
import { catalogURL, ClusterViewRouteParams } from "../../../common/routes";
import { previousActiveTab } from "../+catalog";
interface Props extends RouteComponentProps<ClusterViewRouteParams> {
}
@ -85,7 +84,7 @@ export class ClusterView extends React.Component<Props> {
const disconnected = values[1];
if (hasLoadedView(this.clusterId) && disconnected) {
navigate(`${catalogURL()}/${previousActiveTab.get()}`); // redirect to catalog when active cluster get disconnected/not available
navigate(catalogURL()); // redirect to catalog when active cluster get disconnected/not available
}
}),
]);

View File

@ -25,9 +25,11 @@ import { Pod } from "../../../../common/k8s-api/endpoints";
import { ThemeStore } from "../../../theme.store";
import { dockStore } from "../dock.store";
import { logTabStore } from "../log-tab.store";
import { TerminalStore } from "../terminal.store";
import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock";
import fse from "fs-extra";
import { mockWindow } from "../../../../../__mocks__/windowMock";
mockWindow();
jest.mock("react-monaco-editor", () => null);
@ -41,12 +43,10 @@ podsStore.items.push(new Pod(dockerPod));
podsStore.items.push(new Pod(deploymentPod1));
podsStore.items.push(new Pod(deploymentPod2));
// FIXME: broken since mobx@6 migration
describe.skip("log tab store", () => {
describe("log tab store", () => {
beforeEach(() => {
UserStore.createInstance();
ThemeStore.createInstance();
TerminalStore.createInstance();
});
afterEach(() => {
@ -54,7 +54,6 @@ describe.skip("log tab store", () => {
dockStore.reset();
UserStore.resetInstance();
ThemeStore.resetInstance();
TerminalStore.resetInstance();
fse.remove("tmp");
});
@ -135,11 +134,12 @@ describe.skip("log tab store", () => {
});
});
it("closes tab if no pods left in store", () => {
// FIXME: this is failed when it's not .only == depends on something above
it.only("closes tab if no pods left in store", async () => {
const selectedPod = new Pod(deploymentPod1);
const selectedContainer = selectedPod.getInitContainers()[0];
logTabStore.createPodTab({
const id = logTabStore.createPodTab({
selectedPod,
selectedContainer
});
@ -147,6 +147,7 @@ describe.skip("log tab store", () => {
podsStore.items.clear();
expect(logTabStore.getData(dockStore.selectedTabId)).toBeUndefined();
expect(dockStore.getTabById(dockStore.selectedTabId)).toBeUndefined();
expect(logTabStore.getData(id)).toBeUndefined();
expect(dockStore.getTabById(id)).toBeUndefined();
});
});

View File

@ -33,10 +33,10 @@ import { createResourceStore } from "./create-resource.store";
import type { DockTab } from "./dock.store";
import { EditorPanel } from "./editor-panel";
import { InfoPanel } from "./info-panel";
import { resourceApplierApi } from "../../../common/k8s-api/endpoints/resource-applier.api";
import type { JsonApiErrorParsed } from "../../../common/k8s-api/json-api";
import * as resourceApplierApi from "../../../common/k8s-api/endpoints/resource-applier.api";
import { Notifications } from "../notifications";
import { monacoModelsManager } from "./monaco-model-manager";
import logger from "../../../common/logger";
interface Props {
className?: string;
@ -95,7 +95,7 @@ export class CreateResource extends React.Component<Props> {
});
};
create = async () => {
create = async (): Promise<undefined> => {
if (this.error || !this.data.trim()) {
// do not save when field is empty or there is an error
return null;
@ -103,31 +103,31 @@ export class CreateResource extends React.Component<Props> {
// skip empty documents if "---" pasted at the beginning or end
const resources = jsYaml.safeLoadAll(this.data).filter(Boolean);
const createdResources: string[] = [];
const errors: string[] = [];
await Promise.all(
resources.map(data => {
return resourceApplierApi.update(data)
.then(item => createdResources.push(item.metadata.name))
.catch((err: JsonApiErrorParsed) => errors.push(err.toString()));
})
);
if (errors.length) {
errors.forEach(error => Notifications.error(error));
if (!createdResources.length) throw errors[0];
if (resources.length === 0) {
return void logger.info("Nothing to create");
}
const successMessage = (
<p>
{createdResources.length === 1 ? "Resource" : "Resources"}{" "}
<b>{createdResources.join(", ")}</b> successfully created
</p>
);
Notifications.ok(successMessage);
const createdResources: string[] = [];
return successMessage;
for (const result of await Promise.allSettled(resources.map(resourceApplierApi.update))) {
if (result.status === "fulfilled") {
createdResources.push(result.value.metadata.name);
} else {
Notifications.error(result.reason.toString());
}
}
if (createdResources.length > 0) {
Notifications.ok((
<p>
{createdResources.length === 1 ? "Resource" : "Resources"}{" "}
<b>{createdResources.join(", ")}</b> successfully created
</p>
));
}
return undefined;
};
renderControls(){

View File

@ -29,6 +29,7 @@ import { Tab, TabProps } from "../tabs";
import { Icon } from "../icon";
import { Menu, MenuItem } from "../menu";
import { observable, makeObservable } from "mobx";
import { isMac } from "../../../common/vars";
export interface DockTabProps extends TabProps<DockTabModel> {
moreActions?: React.ReactNode;
@ -94,7 +95,7 @@ export class DockTab extends React.Component<DockTabProps> {
{!pinned && (
<Icon
small material="close"
tooltip="Close (Ctrl+Shift+W)"
tooltip={`Close ${isMac ? "⌘+W" : "Ctrl+W"}`}
onClick={prevDefault(this.close)}
/>
)}

View File

@ -27,6 +27,16 @@
display: flex;
flex-direction: column;
&:not(:focus-within) .DockTab.active {
&::after {
color: var(--halfGray);
}
&:hover::after {
color: var(--line-color-active);
}
}
&.isOpen {
&.fullSize {
position: fixed;

View File

@ -160,7 +160,7 @@ export class DockStore implements DockStorageState {
window.addEventListener("resize", throttle(this.adjustHeight, 250));
// create monaco models
this.whenReady.then(() => {this.tabs.forEach(tab => {
if (this.usesMonacoEditor(tab)) {
if (this.usesMonacoEditor(tab)) {
monacoModelsManager.addModel(tab.id);
}
});});
@ -274,7 +274,7 @@ export class DockStore implements DockStorageState {
title
};
// add monaco model
// add monaco model
if (this.usesMonacoEditor(tab)) {
monacoModelsManager.addModel(id);
}
@ -287,14 +287,14 @@ export class DockStore implements DockStorageState {
}
@action
async closeTab(tabId: TabId) {
closeTab(tabId: TabId) {
const tab = this.getTabById(tabId);
if (!tab || tab.pinned) {
return;
}
// remove monaco model
// remove monaco model
if (this.usesMonacoEditor(tab)) {
monacoModelsManager.removeModel(tabId);
}
@ -305,12 +305,6 @@ export class DockStore implements DockStorageState {
if (this.tabs.length) {
const newTab = this.tabs.slice(-1)[0]; // last
if (newTab?.kind === TabKind.TERMINAL) {
// close the dock when selected sibling inactive terminal tab
const { TerminalStore } = await import("./terminal.store");
if (!TerminalStore.getInstance(false)?.isConnected(newTab.id)) this.close();
}
this.selectTab(newTab.id);
} else {
this.selectedTabId = null;

View File

@ -46,19 +46,31 @@ interface Props {
@observer
export class Dock extends React.Component<Props> {
onKeydown = (evt: React.KeyboardEvent<HTMLElement>) => {
const { close, closeTab, selectedTab } = dockStore;
private element = React.createRef<HTMLDivElement>();
if (!selectedTab) return;
const { code, ctrlKey, shiftKey } = evt.nativeEvent;
componentDidMount() {
document.addEventListener("keydown", this.onKeyDown);
}
componentWillUnmount() {
document.removeEventListener("keydown", this.onKeyDown);
}
onKeyDown = (evt: KeyboardEvent) => {
const { close, selectedTab, closeTab } = dockStore;
const { code, ctrlKey, metaKey, shiftKey } = evt;
// Determine if user working inside <Dock/> or using any other areas in app
const dockIsFocused = this.element?.current.contains(document.activeElement);
if (!selectedTab || !dockIsFocused) return;
if (shiftKey && code === "Escape") {
close();
}
if (ctrlKey && code === "KeyW") {
if (selectedTab.pinned) close();
else closeTab(selectedTab.id);
if ((ctrlKey && code === "KeyW") || (metaKey && code === "KeyW")) {
closeTab(selectedTab.id);
this.element?.current.focus(); // Avoid loosing focus when closing tab
}
};
@ -67,6 +79,7 @@ export class Dock extends React.Component<Props> {
open();
selectTab(tab.id);
this.element?.current.focus();
};
renderTab(tab: DockTab) {
@ -105,7 +118,7 @@ export class Dock extends React.Component<Props> {
return (
<div
className={cssNames("Dock", className, { isOpen, fullSize })}
onKeyDown={this.onKeydown}
ref={this.element}
tabIndex={-1}
>
<ResizingAnchor

View File

@ -31,6 +31,7 @@ import {monacoModelsManager} from "./monaco-model-manager";
export interface EditingResource {
resource: string; // resource path, e.g. /api/v1/namespaces/default
draft?: string; // edited draft in yaml
firstDraft?: string;
}
export class EditResourceStore extends DockTabStore<EditingResource> {
@ -106,6 +107,10 @@ export class EditResourceStore extends DockTabStore<EditingResource> {
return dockStore.getTabById(tabId);
}
clearInitialDraft(tabId: TabId): void {
delete this.getData(tabId)?.firstDraft;
}
reset() {
super.reset();
Array.from(this.watchers).forEach(([tabId, dispose]) => {

View File

@ -24,7 +24,7 @@ import "./edit-resource.scss";
import React from "react";
import { action, computed, makeObservable, observable } from "mobx";
import { observer } from "mobx-react";
import jsYaml from "js-yaml";
import yaml from "js-yaml";
import type { DockTab } from "./dock.store";
import { cssNames } from "../../utils";
import { editResourceStore } from "./edit-resource.store";
@ -33,6 +33,7 @@ import { Badge } from "../badge";
import { EditorPanel } from "./editor-panel";
import { Spinner } from "../spinner";
import type { KubeObject } from "../../../common/k8s-api/kube-object";
import { createPatch } from "rfc6902";
interface Props {
className?: string;
@ -71,16 +72,17 @@ export class EditResource extends React.Component<Props> {
return draft;
}
return jsYaml.safeDump(this.resource.toPlainObject()); // dump resource first time
return yaml.safeDump(this.resource.toPlainObject()); // dump resource first time
}
@action
saveDraft(draft: string | object) {
if (typeof draft === "object") {
draft = draft ? jsYaml.safeDump(draft) : undefined;
draft = draft ? yaml.safeDump(draft) : undefined;
}
editResourceStore.setData(this.tabId, {
firstDraft: draft, // this must be before the next line
...editResourceStore.getData(this.tabId),
draft,
});
@ -95,16 +97,18 @@ export class EditResource extends React.Component<Props> {
if (this.error) {
return null;
}
const store = editResourceStore.getStore(this.tabId);
const updatedResource: KubeObject = await store.update(this.resource, jsYaml.safeLoad(this.draft));
this.saveDraft(updatedResource.toPlainObject()); // update with new resourceVersion to avoid further errors on save
const resourceType = updatedResource.kind;
const resourceName = updatedResource.getName();
const store = editResourceStore.getStore(this.tabId);
const currentVersion = yaml.safeLoad(this.draft);
const firstVersion = yaml.safeLoad(editResourceStore.getData(this.tabId).firstDraft ?? this.draft);
const patches = createPatch(firstVersion, currentVersion);
const updatedResource = await store.patch(this.resource, patches);
editResourceStore.clearInitialDraft(this.tabId);
return (
<p>
{resourceType} <b>{resourceName}</b> updated.
{updatedResource.kind} <b>{updatedResource.getName()}</b> updated.
</p>
);
};
@ -126,9 +130,9 @@ export class EditResource extends React.Component<Props> {
submittingMessage="Applying.."
controls={(
<div className="resource-info flex gaps align-center">
<span>Kind:</span> <Badge label={resource.kind}/>
<span>Kind:</span><Badge label={resource.kind}/>
<span>Name:</span><Badge label={resource.getName()}/>
<span>Namespace:</span> <Badge label={resource.getNs() || "global"}/>
<span>Namespace:</span><Badge label={resource.getNs() || "global"}/>
</div>
)}
/>

View File

@ -91,10 +91,7 @@ export class EditorPanel extends React.Component<Props> {
onChange = (value: string) => {
this.validate(value);
if (this.props.onChange) {
this.props.onChange(value, this.yamlError);
}
this.props.onChange?.(value, this.yamlError);
};
render() {

View File

@ -25,6 +25,7 @@ import { podsStore } from "../+workloads-pods/pods.store";
import { IPodContainer, Pod } from "../../../common/k8s-api/endpoints";
import type { WorkloadKubeObject } from "../../../common/k8s-api/workload-kube-object";
import logger from "../../../common/logger";
import { DockTabStore } from "./dock-tab.store";
import { dockStore, DockTabCreateSpecific, TabKind } from "./dock.store";
@ -51,17 +52,15 @@ export class LogTabStore extends DockTabStore<LogTabData> {
storageKey: "pod_logs"
});
reaction(() => podsStore.items.length, () => {
this.updateTabsData();
});
reaction(() => podsStore.items.length, () => this.updateTabsData());
}
createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): void {
createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): string {
const podOwner = selectedPod.getOwnerRefs()[0];
const pods = podsStore.getPodsByOwnerId(podOwner?.uid);
const title = `Pod ${selectedPod.getName()}`;
this.createLogsTab(title, {
return this.createLogsTab(title, {
pods: pods.length ? pods : [selectedPod],
selectedPod,
selectedContainer
@ -95,9 +94,9 @@ export class LogTabStore extends DockTabStore<LogTabData> {
...tabParams,
kind: TabKind.POD_LOGS,
}, false);
}
}
private createLogsTab(title: string, data: LogTabData) {
private createLogsTab(title: string, data: LogTabData): string {
const id = uniqueId("log-tab-");
this.createDockTab({ id, title });
@ -106,38 +105,45 @@ export class LogTabStore extends DockTabStore<LogTabData> {
showTimestamps: false,
previous: false
});
return id;
}
private async updateTabsData() {
const promises: Promise<void>[] = [];
private updateTabsData() {
for (const [tabId, tabData] of this.data) {
const pod = new Pod(tabData.selectedPod);
const pods = podsStore.getPodsByOwnerId(pod.getOwnerRefs()[0]?.uid);
const isSelectedPodInList = pods.find(item => item.getId() == pod.getId());
const selectedPod = isSelectedPodInList ? pod : pods[0];
const selectedContainer = isSelectedPodInList ? tabData.selectedContainer : pod.getAllContainers()[0];
try {
if (!tabData.selectedPod) {
tabData.selectedPod = tabData.pods[0];
}
if (pods.length) {
this.setData(tabId, {
...tabData,
selectedPod,
selectedContainer,
pods
});
this.renameTab(tabId);
} else {
promises.push(this.closeTab(tabId));
const pod = new Pod(tabData.selectedPod);
const pods = podsStore.getPodsByOwnerId(pod.getOwnerRefs()[0]?.uid);
const isSelectedPodInList = pods.find(item => item.getId() == pod.getId());
const selectedPod = isSelectedPodInList ? pod : pods[0];
const selectedContainer = isSelectedPodInList ? tabData.selectedContainer : pod.getAllContainers()[0];
if (pods.length > 0) {
this.setData(tabId, {
...tabData,
selectedPod,
selectedContainer,
pods
});
this.renameTab(tabId);
} else {
this.closeTab(tabId);
}
} catch (error) {
logger.error(`[LOG-TAB-STORE]: failed to set data for tabId=${tabId} deleting`, error,);
this.data.delete(tabId);
}
}
await Promise.all(promises);
}
private async closeTab(tabId: string) {
private closeTab(tabId: string) {
this.clearData(tabId);
await dockStore.closeTab(tabId);
dockStore.closeTab(tabId);
}
}

View File

@ -29,6 +29,8 @@ import { ThemeStore } from "../../theme.store";
import { boundMethod } from "../../utils";
import { isMac } from "../../../common/vars";
import { camelCase } from "lodash";
import { UserStore } from "../../../common/user-store";
import { clipboard } from "electron";
export class Terminal {
static spawningPool: HTMLElement;
@ -115,11 +117,13 @@ export class Terminal {
this.xterm.open(Terminal.spawningPool);
this.xterm.registerLinkMatcher(/https?:\/\/[^\s]+/i, this.onClickLink);
this.xterm.attachCustomKeyEventHandler(this.keyHandler);
this.xterm.onSelectionChange(this.onSelectionChange);
// bind events
const onDataHandler = this.xterm.onData(this.onData);
this.viewport.addEventListener("scroll", this.onScroll);
this.elem.addEventListener("contextmenu", this.onContextMenu);
this.api.onReady.addListener(this.onClear, { once: true }); // clear status logs (connecting..)
this.api.onData.addListener(this.onApiData);
window.addEventListener("resize", this.onResize);
@ -133,6 +137,7 @@ export class Terminal {
() => this.fitAddon.dispose(),
() => this.api.removeAllListeners(),
() => window.removeEventListener("resize", this.onResize),
() => this.elem.removeEventListener("contextmenu", this.onContextMenu),
);
}
@ -198,8 +203,26 @@ export class Terminal {
window.open(link, "_blank");
};
onContextMenu = () => {
const { terminalCopyOnSelect } = UserStore.getInstance();
const textFromClipboard = clipboard.readText();
if (terminalCopyOnSelect) {
this.xterm.paste(textFromClipboard);
}
};
onSelectionChange = () => {
const { terminalCopyOnSelect } = UserStore.getInstance();
const selection = this.xterm.getSelection().trim();
if (terminalCopyOnSelect && selection !== "") {
clipboard.writeText(selection);
}
};
keyHandler = (evt: KeyboardEvent): boolean => {
const { code, ctrlKey, type, metaKey } = evt;
const { code, ctrlKey, metaKey } = evt;
// Handle custom hotkey bindings
if (ctrlKey) {
@ -225,11 +248,6 @@ export class Terminal {
}
}
// Pass the event above in DOM for <Dock/> to handle common actions
if (!evt.defaultPrevented) {
this.elem.dispatchEvent(new KeyboardEvent(type, evt));
}
return true;
};
}

View File

@ -27,7 +27,7 @@ import { HotbarEntityIcon } from "./hotbar-entity-icon";
import { cssNames, IClassName } from "../../utils";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { HotbarStore } from "../../../common/hotbar-store";
import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity";
import type { CatalogEntity } from "../../api/catalog-entity";
import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd";
import { HotbarSelector } from "./hotbar-selector";
import { HotbarCell } from "./hotbar-cell";
@ -124,7 +124,7 @@ export class HotbarMenu extends React.Component<Props> {
key={index}
index={index}
entity={entity}
onClick={() => entity.onRun(catalogEntityRunContext)}
onClick={() => catalogEntityRegistry.onRun(entity)}
className={cssNames({ isDragging: snapshot.isDragging })}
remove={this.removeItem}
add={this.addItem}

View File

@ -114,14 +114,14 @@ export class Icon extends React.PureComponent<IconProps> {
};
// render as inline svg-icon
if (svg) {
if (typeof svg === "string") {
const svgIconText = svg.includes("<svg") ? svg : require(`!!raw-loader!./${svg}.svg`).default;
iconContent = <span className="icon" dangerouslySetInnerHTML={{ __html: svgIconText }}/>;
}
// render as material-icon
if (material) {
if (typeof material === "string") {
iconContent = <span className="icon" data-icon-name={material}>{material}</span>;
}

View File

@ -44,15 +44,11 @@ export class KubeObjectMenu<T extends KubeObject> extends React.Component<KubeOb
}
get isEditable() {
const { editable } = this.props;
return editable !== undefined ? editable : !!(this.store && this.store.update);
return this.props.editable ?? Boolean(this.store?.patch);
}
get isRemovable() {
const { removable } = this.props;
return removable !== undefined ? removable : !!(this.store && this.store.remove);
return this.props.removable ?? Boolean(this.store?.remove);
}
@boundMethod

View File

@ -153,7 +153,7 @@ export function openServiceAccountKubeConfig(account: ServiceAccount) {
const namespace = account.getNs();
KubeConfigDialog.open({
title: "{accountName} kubeconfig",
title: `${accountName} kubeconfig`,
loader: () => apiBase.get(`/kubeconfig/service-account/${namespace}/${accountName}`)
});
}

View File

@ -30,7 +30,6 @@ import { ipcRendererOn } from "../../../common/ipc";
import { watchHistoryState } from "../../remote-helpers/history-updater";
import { isActiveRoute, navigate } from "../../navigation";
import { catalogRoute, catalogURL } from "../../../common/routes";
import { previousActiveTab } from "../+catalog";
interface Props extends React.HTMLAttributes<any> {
}
@ -72,7 +71,7 @@ export const TopBar = observer(({ children, ...rest }: Props) => {
};
const goHome = () => {
navigate(`${catalogURL()}/${previousActiveTab.get()}`);
navigate(catalogURL());
};
const goBack = () => {

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