mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Merge remote-tracking branch 'origin/master' into monaco_editor_refactoring
# Conflicts: # src/renderer/bootstrap.tsx # src/renderer/components/+apps-releases/release-details.tsx # src/renderer/components/dock/__test__/log-tab.store.test.ts # src/renderer/components/dock/create-resource.tsx # src/renderer/components/dock/dock.store.ts # src/renderer/components/dock/edit-resource.store.ts # src/renderer/components/dock/edit-resource.tsx # src/renderer/components/dock/editor-panel.tsx # src/renderer/components/dock/log-tab.store.ts # yarn.lock
This commit is contained in:
commit
e527cafde7
2
.yarnrc
2
.yarnrc
@ -1,3 +1,3 @@
|
||||
disturl "https://atom.io/download/electron"
|
||||
target "13.4.0"
|
||||
target "13.5.1"
|
||||
runtime "electron"
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
15
extensions/metrics-cluster-feature/package-lock.json
generated
15
extensions/metrics-cluster-feature/package-lock.json
generated
@ -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",
|
||||
|
||||
6
extensions/node-menu/package-lock.json
generated
6
extensions/node-menu/package-lock.json
generated
@ -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"
|
||||
},
|
||||
|
||||
6
extensions/pod-menu/package-lock.json
generated
6
extensions/pod-menu/package-lock.json
generated
@ -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"
|
||||
},
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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()}`);
|
||||
|
||||
|
||||
60
package.json
60
package.json
@ -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,10 +197,11 @@
|
||||
"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",
|
||||
"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",
|
||||
@ -208,7 +209,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",
|
||||
@ -236,16 +236,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",
|
||||
@ -273,9 +274,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",
|
||||
@ -289,19 +290,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",
|
||||
@ -312,26 +313,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": "^13.4.0",
|
||||
"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",
|
||||
@ -348,7 +349,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",
|
||||
@ -356,6 +357,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",
|
||||
@ -368,18 +370,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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
44
src/common/catalog/catalog-run-event.ts
Normal file
44
src/common/catalog/catalog-run-event.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -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[];
|
||||
@ -285,19 +286,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);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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())
|
||||
};
|
||||
|
||||
|
||||
32
src/common/routes/port-forwards.ts
Normal file
32
src/common/routes/port-forwards.ts
Normal 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);
|
||||
@ -199,6 +199,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(
|
||||
@ -273,4 +286,5 @@ export const DESCRIPTORS = {
|
||||
hiddenTableColumns,
|
||||
syncKubeconfigEntries,
|
||||
editorConfiguration,
|
||||
terminalCopyOnSelect,
|
||||
};
|
||||
|
||||
@ -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, makeObservable, observable, reaction } from "mobx";
|
||||
import { BaseStore } from "../base-store";
|
||||
@ -46,7 +46,11 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
|
||||
});
|
||||
|
||||
makeObservable(this);
|
||||
fileNameMigration();
|
||||
|
||||
if (ipcMain) {
|
||||
fileNameMigration();
|
||||
}
|
||||
|
||||
this.load();
|
||||
}
|
||||
|
||||
@ -68,6 +72,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
|
||||
@ -188,6 +193,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 {
|
||||
@ -209,6 +215,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),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
114
src/main/menu.ts
114
src/main/menu.ts
@ -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());
|
||||
@ -145,6 +157,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{
|
||||
label: "Exit",
|
||||
accelerator: "Alt+F4",
|
||||
id: "quit",
|
||||
click() {
|
||||
exitApp();
|
||||
}
|
||||
@ -152,8 +165,9 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
])
|
||||
],
|
||||
};
|
||||
const editMenu: MenuItemConstructorOptions = {
|
||||
const editMenu: MenuItemsOpts = {
|
||||
label: "Edit",
|
||||
id: "edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
@ -166,12 +180,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 +195,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{
|
||||
label: "Command Palette...",
|
||||
accelerator: "Shift+CmdOrCtrl+P",
|
||||
id: "command-palette",
|
||||
click() {
|
||||
broadcastMessage("command-palette:open");
|
||||
}
|
||||
@ -187,6 +204,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 +212,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 +220,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{
|
||||
label: "Reload",
|
||||
accelerator: "CmdOrCtrl+R",
|
||||
id: "reload",
|
||||
click() {
|
||||
windowManager.reload();
|
||||
}
|
||||
@ -214,23 +234,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 +262,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
...ignoreOnMac([
|
||||
{
|
||||
label: `About ${productName}`,
|
||||
id: "about",
|
||||
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
|
||||
showAbout(browserWindow);
|
||||
}
|
||||
@ -246,69 +271,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()]));
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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");
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -28,7 +28,7 @@ import * as ReactRouter from "react-router";
|
||||
import * as ReactRouterDom from "react-router-dom";
|
||||
import * as LensExtensionsCommonApi from "../extensions/common-api";
|
||||
import * as LensExtensionsRendererApi from "../extensions/renderer-api";
|
||||
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";
|
||||
@ -64,7 +64,7 @@ async function attachChromeDebugger() {
|
||||
}
|
||||
|
||||
type AppComponent = React.ComponentType & {
|
||||
init?(): Promise<void>;
|
||||
init?(rootElem: HTMLElement): Promise<void>;
|
||||
};
|
||||
|
||||
export async function bootstrap(App: AppComponent) {
|
||||
@ -117,16 +117,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)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
114
src/renderer/components/+catalog/catalog-entity-item.tsx
Normal file
114
src/renderer/components/+catalog/catalog-entity-item.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
@ -32,4 +32,4 @@
|
||||
.catalog {
|
||||
@apply p-5 font-bold text-2xl;
|
||||
color: var(--textColorAccent);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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) {
|
||||
|
||||
366
src/renderer/components/+catalog/catalog.test.tsx
Normal file
366
src/renderer/components/+catalog/catalog.test.tsx
Normal 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"));
|
||||
});
|
||||
});
|
||||
@ -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";
|
||||
@ -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 ?? {};
|
||||
@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
22
src/renderer/components/+network-port-forwards/index.ts
Normal file
22
src/renderer/components/+network-port-forwards/index.ts
Normal 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 "./port-forwards";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/renderer/components/+network-port-forwards/port-forwards.tsx
Normal file
103
src/renderer/components/+network-port-forwards/port-forwards.tsx
Normal 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)
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -112,6 +112,7 @@ const dummyDeployment: Deployment = {
|
||||
toPlainObject: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
};
|
||||
|
||||
describe("<DeploymentScaleDialog />", () => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 : "";
|
||||
|
||||
|
||||
@ -107,6 +107,7 @@ const dummyReplicaSet: ReplicaSet = {
|
||||
toPlainObject: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
};
|
||||
|
||||
describe("<ReplicaSetScaleDialog />", () => {
|
||||
|
||||
@ -117,6 +117,7 @@ const dummyStatefulSet: StatefulSet = {
|
||||
toPlainObject: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
};
|
||||
|
||||
describe("<StatefulSetScaleDialog />", () => {
|
||||
|
||||
@ -22,7 +22,7 @@
|
||||
import { computed } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity";
|
||||
import type { CatalogEntity } from "../../api/catalog-entity";
|
||||
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
|
||||
import { CommandOverlay } from "../command-palette";
|
||||
import { Select } from "../select";
|
||||
@ -37,7 +37,7 @@ export class ActivateEntityCommand extends React.Component {
|
||||
}
|
||||
|
||||
onSelect(entity: CatalogEntity): void {
|
||||
entity.onRun?.(catalogEntityRunContext);
|
||||
catalogEntityRegistry.onRun(entity);
|
||||
CommandOverlay.close();
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,6 +203,24 @@ 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;
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,88 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { ipcRenderer } from "electron";
|
||||
import { navigate } from "../navigation";
|
||||
|
||||
/**
|
||||
* The definition of a keyboard shortcut
|
||||
*/
|
||||
interface Shortcut {
|
||||
code?: string;
|
||||
key?: string;
|
||||
metaKey?: boolean;
|
||||
altKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
ctrlKey?: boolean;
|
||||
action: () => void;
|
||||
}
|
||||
|
||||
const shortcuts: Shortcut[] = [
|
||||
{
|
||||
key: "p",
|
||||
metaKey: true,
|
||||
shiftKey: true,
|
||||
action: () => ipcRenderer.emit("command-palette:open"),
|
||||
},
|
||||
{
|
||||
code: "Comma",
|
||||
metaKey: true,
|
||||
action: () => navigate("/preferences"),
|
||||
},
|
||||
];
|
||||
|
||||
function shortcutMatches(shortcut: Shortcut, event: KeyboardEvent): boolean {
|
||||
if (typeof shortcut.metaKey === "boolean" && shortcut.metaKey !== event.metaKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof shortcut.altKey === "boolean" && shortcut.altKey !== event.altKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof shortcut.shiftKey === "boolean" && shortcut.shiftKey !== event.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof shortcut.ctrlKey === "boolean" && shortcut.ctrlKey !== event.ctrlKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof shortcut.code === "string" && shortcut.code !== event.code) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof shortcut.key === "string" && shortcut.key !== event.key) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerKeyboardShortcuts() {
|
||||
window.addEventListener("keydown", event => {
|
||||
for (const shortcut of shortcuts) {
|
||||
if (shortcutMatches(shortcut, event)) {
|
||||
shortcut.action();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@ -36,11 +36,12 @@ import { registerIpcListeners } from "./ipc";
|
||||
import { ipcRenderer } from "electron";
|
||||
import { IpcRendererNavigationEvents } from "./navigation/events";
|
||||
import { catalogEntityRegistry } from "./api/catalog-entity-registry";
|
||||
import { registerKeyboardShortcuts } from "./keyboard-shortcuts";
|
||||
import logger from "../common/logger";
|
||||
import { unmountComponentAtNode } from "react-dom";
|
||||
|
||||
@observer
|
||||
export class LensApp extends React.Component {
|
||||
static async init() {
|
||||
static async init(rootElem: HTMLElement) {
|
||||
catalogEntityRegistry.init();
|
||||
ExtensionLoader.getInstance().loadOnClusterManagerRenderer();
|
||||
LensProtocolRouterRenderer.createInstance().init();
|
||||
@ -49,8 +50,13 @@ export class LensApp extends React.Component {
|
||||
window.addEventListener("offline", () => broadcastMessage("network:offline"));
|
||||
window.addEventListener("online", () => broadcastMessage("network:online"));
|
||||
|
||||
registerKeyboardShortcuts();
|
||||
registerIpcListeners();
|
||||
|
||||
window.onbeforeunload = () => {
|
||||
logger.info("[App]: Unload app");
|
||||
|
||||
unmountComponentAtNode(rootElem);
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
|
||||
24
src/renderer/port-forward/index.ts
Normal file
24
src/renderer/port-forward/index.ts
Normal file
@ -0,0 +1,24 @@
|
||||
/**
|
||||
* 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 "./port-forward.store";
|
||||
export * from "./port-forward-item";
|
||||
export * from "./port-forward-dialog";
|
||||
77
src/renderer/port-forward/port-forward-dialog.scss
Normal file
77
src/renderer/port-forward/port-forward-dialog.scss
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
.PortForwardDialog {
|
||||
.Wizard {
|
||||
.header {
|
||||
span {
|
||||
color: #a0a0a0;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.WizardStep {
|
||||
.step-content {
|
||||
min-height: 90px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.current-scale {
|
||||
font-weight: bold
|
||||
}
|
||||
|
||||
.desired-scale {
|
||||
flex: 1.1 0;
|
||||
}
|
||||
|
||||
.slider-container {
|
||||
flex: 1 0;
|
||||
}
|
||||
|
||||
.plus-minus-container {
|
||||
margin-left: $margin * 2;
|
||||
.Icon {
|
||||
--color-active: black;
|
||||
}
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: $colorSoftError;
|
||||
font-size: small;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.Icon {
|
||||
margin: 0;
|
||||
margin-right: $margin;
|
||||
}
|
||||
}
|
||||
|
||||
.portInput {
|
||||
display: inline-block !important;
|
||||
width: 70px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
} }
|
||||
|
||||
}
|
||||
173
src/renderer/port-forward/port-forward-dialog.tsx
Normal file
173
src/renderer/port-forward/port-forward-dialog.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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-forward-dialog.scss";
|
||||
|
||||
import React, { Component } from "react";
|
||||
import { observable, makeObservable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { Dialog, DialogProps } from "../components/dialog";
|
||||
import { Wizard, WizardStep } from "../components/wizard";
|
||||
import { Input } from "../components/input";
|
||||
import { Notifications } from "../components/notifications";
|
||||
import { cssNames } from "../utils";
|
||||
import { addPortForward, modifyPortForward } from "./port-forward.store";
|
||||
import type { ForwardedPort } from "./port-forward-item";
|
||||
import { openPortForward } from ".";
|
||||
|
||||
interface Props extends Partial<DialogProps> {
|
||||
}
|
||||
|
||||
interface PortForwardDialogOpenOptions {
|
||||
openInBrowser: boolean
|
||||
}
|
||||
|
||||
const dialogState = observable.object({
|
||||
isOpen: false,
|
||||
data: null as ForwardedPort,
|
||||
openInBrowser: false
|
||||
});
|
||||
|
||||
@observer
|
||||
export class PortForwardDialog extends Component<Props> {
|
||||
@observable ready = false;
|
||||
@observable currentPort = 0;
|
||||
@observable desiredPort = 0;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
static open(portForward: ForwardedPort, options : PortForwardDialogOpenOptions = { openInBrowser: false }) {
|
||||
dialogState.isOpen = true;
|
||||
dialogState.data = portForward;
|
||||
dialogState.openInBrowser = options.openInBrowser;
|
||||
}
|
||||
|
||||
static close() {
|
||||
dialogState.isOpen = false;
|
||||
}
|
||||
|
||||
get portForward() {
|
||||
return dialogState.data;
|
||||
}
|
||||
|
||||
close = () => {
|
||||
PortForwardDialog.close();
|
||||
};
|
||||
|
||||
onOpen = async () => {
|
||||
const { portForward } = this;
|
||||
|
||||
this.currentPort = +portForward.forwardPort;
|
||||
this.desiredPort = this.currentPort;
|
||||
this.ready = this.currentPort ? false : true;
|
||||
};
|
||||
|
||||
onClose = () => {
|
||||
this.ready = false;
|
||||
};
|
||||
|
||||
changePort = (value: string) => {
|
||||
this.desiredPort = Number(value);
|
||||
this.ready = Boolean(this.desiredPort == 0 || this.currentPort !== this.desiredPort);
|
||||
};
|
||||
|
||||
startPortForward = async () => {
|
||||
const { portForward } = this;
|
||||
const { currentPort, desiredPort, close } = this;
|
||||
|
||||
try {
|
||||
let port: number;
|
||||
|
||||
if (currentPort) {
|
||||
port = await modifyPortForward(portForward, desiredPort);
|
||||
} else {
|
||||
portForward.forwardPort = desiredPort;
|
||||
port = await addPortForward(portForward);
|
||||
}
|
||||
|
||||
if (dialogState.openInBrowser) {
|
||||
portForward.forwardPort = port;
|
||||
openPortForward(portForward);
|
||||
}
|
||||
} catch (err) {
|
||||
Notifications.error(err);
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
renderContents() {
|
||||
return (
|
||||
<>
|
||||
<div className="flex gaps align-center">
|
||||
<div className="input-container flex align-center">
|
||||
<div className="current-port" data-testid="current-port">
|
||||
Local port to forward from:
|
||||
</div>
|
||||
<Input className="portInput"
|
||||
type="number"
|
||||
min="0"
|
||||
max="65535"
|
||||
value={this.desiredPort === 0 ? "" : String(this.desiredPort)}
|
||||
placeholder={"Random"}
|
||||
onChange={this.changePort}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, ...dialogProps } = this.props;
|
||||
const resourceName = this.portForward?.name ?? "";
|
||||
const header = (
|
||||
<h5>
|
||||
Port Forwarding for <span>{resourceName}</span>
|
||||
</h5>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
{...dialogProps}
|
||||
isOpen={dialogState.isOpen}
|
||||
className={cssNames("PortForwardDialog", className)}
|
||||
onOpen={this.onOpen}
|
||||
onClose={this.onClose}
|
||||
close={this.close}
|
||||
>
|
||||
<Wizard header={header} done={this.close}>
|
||||
<WizardStep
|
||||
contentClass="flex gaps column"
|
||||
next={this.startPortForward}
|
||||
nextLabel={this.currentPort === 0 ? "Start" : "Restart"}
|
||||
disabledNext={!this.ready}
|
||||
>
|
||||
{this.renderContents()}
|
||||
</WizardStep>
|
||||
</Wizard>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
91
src/renderer/port-forward/port-forward-item.ts
Normal file
91
src/renderer/port-forward/port-forward-item.ts
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 { ItemObject } from "../../common/item.store";
|
||||
import { autoBind } from "../../common/utils";
|
||||
|
||||
export interface ForwardedPort {
|
||||
clusterId?: string;
|
||||
kind: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
port: number;
|
||||
forwardPort: number;
|
||||
}
|
||||
|
||||
export class PortForwardItem implements ItemObject {
|
||||
clusterId: string;
|
||||
kind: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
port: number;
|
||||
forwardPort: number;
|
||||
|
||||
constructor(pf: ForwardedPort) {
|
||||
this.clusterId = pf.clusterId;
|
||||
this.kind = pf.kind;
|
||||
this.namespace = pf.namespace;
|
||||
this.name = pf.name;
|
||||
this.port = pf.port;
|
||||
this.forwardPort = pf.forwardPort;
|
||||
|
||||
autoBind(this);
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
getNs() {
|
||||
return this.namespace;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.forwardPort;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return String(this.forwardPort);
|
||||
}
|
||||
|
||||
getKind() {
|
||||
return this.kind;
|
||||
}
|
||||
|
||||
getPort() {
|
||||
return this.port;
|
||||
}
|
||||
|
||||
getForwardPort() {
|
||||
return this.forwardPort;
|
||||
}
|
||||
|
||||
getSearchFields() {
|
||||
return [
|
||||
this.name,
|
||||
this.id,
|
||||
this.kind,
|
||||
this.port,
|
||||
this.forwardPort,
|
||||
];
|
||||
}
|
||||
}
|
||||
179
src/renderer/port-forward/port-forward.store.ts
Normal file
179
src/renderer/port-forward/port-forward.store.ts
Normal file
@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 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 { makeObservable, observable, reaction } from "mobx";
|
||||
import { ItemStore } from "../../common/item.store";
|
||||
import { autoBind, createStorage, disposer, getHostedClusterId, openExternal } from "../utils";
|
||||
import { ForwardedPort, PortForwardItem } from "./port-forward-item";
|
||||
import { apiBase } from "../api";
|
||||
import { waitUntilFree } from "tcp-port-used";
|
||||
import { Notifications } from "../components/notifications";
|
||||
import logger from "../../common/logger";
|
||||
|
||||
export class PortForwardStore extends ItemStore<PortForwardItem> {
|
||||
private storage = createStorage<ForwardedPort[] | undefined>("port_forwards", undefined);
|
||||
|
||||
@observable portForwards: PortForwardItem[];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
makeObservable(this);
|
||||
autoBind(this);
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
await this.storage.whenReady;
|
||||
|
||||
const savedPortForwards = this.storage.get(); // undefined on first load
|
||||
|
||||
if (Array.isArray(savedPortForwards)) {
|
||||
logger.info("[PORT_FORWARD] starting saved port-forwards");
|
||||
await Promise.all(savedPortForwards.map(addPortForward));
|
||||
}
|
||||
}
|
||||
|
||||
watch() {
|
||||
return disposer(
|
||||
reaction(() => this.portForwards, () => this.loadAll()),
|
||||
);
|
||||
}
|
||||
|
||||
loadAll() {
|
||||
return this.loadItems(async () => {
|
||||
let portForwards = await getPortForwards();
|
||||
|
||||
// filter out any not for this cluster
|
||||
portForwards = portForwards.filter(pf => pf.clusterId == getHostedClusterId());
|
||||
this.storage.set(portForwards);
|
||||
|
||||
this.reset();
|
||||
portForwards.map(pf => this.portForwards.push(new PortForwardItem(pf)));
|
||||
|
||||
return this.portForwards;
|
||||
});
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.portForwards = [];
|
||||
}
|
||||
|
||||
async removeSelectedItems() {
|
||||
return Promise.all(this.selectedItems.map(removePortForward));
|
||||
}
|
||||
}
|
||||
|
||||
interface PortForwardResult {
|
||||
port: number;
|
||||
}
|
||||
|
||||
interface PortForwardsResult {
|
||||
portForwards: ForwardedPort[];
|
||||
}
|
||||
|
||||
export async function addPortForward(portForward: ForwardedPort): Promise<number> {
|
||||
let response: PortForwardResult;
|
||||
|
||||
try {
|
||||
response = await apiBase.post<PortForwardResult>(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}`);
|
||||
|
||||
if (response?.port && response.port != +portForward.forwardPort) {
|
||||
logger.warn(`specified ${portForward.forwardPort} got ${response.port}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(error); // don't care, caller must check
|
||||
}
|
||||
portForwardStore.reset();
|
||||
|
||||
return response?.port;
|
||||
}
|
||||
|
||||
export async function getPortForward(portForward: ForwardedPort): Promise<number> {
|
||||
let response: PortForwardResult;
|
||||
|
||||
try {
|
||||
response = await apiBase.get<PortForwardResult>(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}`);
|
||||
} catch (error) {
|
||||
logger.warn(error); // don't care, caller must check
|
||||
}
|
||||
|
||||
return response?.port;
|
||||
}
|
||||
|
||||
export async function modifyPortForward(portForward: ForwardedPort, desiredPort: number): Promise<number> {
|
||||
let port = 0;
|
||||
|
||||
try {
|
||||
await removePortForward(portForward);
|
||||
portForward.forwardPort = desiredPort;
|
||||
port = await addPortForward(portForward);
|
||||
} catch (error) {
|
||||
logger.warn(error); // don't care, caller must check
|
||||
}
|
||||
portForwardStore.reset();
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
|
||||
export async function removePortForward(portForward: ForwardedPort) {
|
||||
try {
|
||||
await apiBase.del(`/pods/port-forward/${portForward.namespace}/${portForward.kind}/${portForward.name}?port=${portForward.port}&forwardPort=${portForward.forwardPort}`);
|
||||
await waitUntilFree(+portForward.forwardPort, 200, 1000);
|
||||
} catch (error) {
|
||||
logger.warn(error); // don't care, caller must check
|
||||
}
|
||||
portForwardStore.reset();
|
||||
}
|
||||
|
||||
export async function getPortForwards(): Promise<ForwardedPort[]> {
|
||||
try {
|
||||
const response = await apiBase.get<PortForwardsResult>(`/pods/port-forwards`);
|
||||
|
||||
return response.portForwards;
|
||||
} catch (error) {
|
||||
logger.warn(error); // don't care, caller must check
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function openPortForward(portForward: ForwardedPort) {
|
||||
const browseTo = `http://localhost:${portForward.forwardPort}`;
|
||||
|
||||
openExternal(browseTo)
|
||||
.catch(error => {
|
||||
logger.error(`failed to open in browser: ${error}`, {
|
||||
clusterId: portForward.clusterId,
|
||||
port: portForward.port,
|
||||
kind: portForward.kind,
|
||||
namespace: portForward.namespace,
|
||||
name: portForward.name,
|
||||
});
|
||||
Notifications.error(`Failed to open ${browseTo} in browser`);
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
export const portForwardStore = new PortForwardStore();
|
||||
Loading…
Reference in New Issue
Block a user