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

Merge branch 'master' into extensions/validate-lens-engine-version

Signed-off-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
Jari Kolehmainen 2021-06-09 06:58:13 +03:00
commit 2643665f3d
51 changed files with 986 additions and 1252 deletions

View File

@ -1,10 +1,8 @@
name: Publish NPM Package `master` name: Publish NPM Package `master`
on: on:
pull_request: push:
branches: branches:
- master - master
types:
- closed
jobs: jobs:
publish: publish:
name: Publish NPM Package `master` name: Publish NPM Package `master`

View File

@ -3,6 +3,7 @@ CMD_ARGS = $(filter-out $@,$(MAKECMDGOALS))
%: %:
@: @:
NPM_RELEASE_TAG ?= latest
EXTENSIONS_DIR = ./extensions EXTENSIONS_DIR = ./extensions
extensions = $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), ${dir}) extensions = $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), ${dir})
extension_node_modules = $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), ${dir}/node_modules) extension_node_modules = $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), ${dir}/node_modules)
@ -82,8 +83,12 @@ $(extension_node_modules): node_modules
$(extension_dists): src/extensions/npm/extensions/dist $(extension_dists): src/extensions/npm/extensions/dist
cd $(@:/dist=) && ../../node_modules/.bin/npm run build cd $(@:/dist=) && ../../node_modules/.bin/npm run build
.PHONY: clean-old-extensions
clean-old-extensions:
find ./extensions -mindepth 1 -maxdepth 1 -type d '!' -exec test -e '{}/package.json' \; -exec rm -rf {} \;
.PHONY: build-extensions .PHONY: build-extensions
build-extensions: node_modules $(extension_node_modules) $(extension_dists) build-extensions: node_modules clean-old-extensions $(extension_node_modules) $(extension_dists)
.PHONY: test-extensions .PHONY: test-extensions
test-extensions: $(extension_node_modules) test-extensions: $(extension_node_modules)
@ -110,7 +115,7 @@ build-extension-types: node_modules src/extensions/npm/extensions/dist
.PHONY: publish-npm .PHONY: publish-npm
publish-npm: node_modules build-npm publish-npm: node_modules build-npm
./node_modules/.bin/npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" ./node_modules/.bin/npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}"
cd src/extensions/npm/extensions && npm publish --access=public --tag=${NPM_RELEASE_TAG:-latest} cd src/extensions/npm/extensions && npm publish --access=public --tag=$(NPM_RELEASE_TAG)
git restore src/extensions/npm/extensions/package.json git restore src/extensions/npm/extensions/package.json
.PHONY: docs .PHONY: docs

View File

@ -2,6 +2,6 @@
## APIs ## APIs
- [Common](modules/_common_api_index_.md) - [Common](modules/common.md)
- [Main](modules/_main_api_index_.md) - [Main](modules/main.md)
- [Renderer](modules/_renderer_api_index_.md) - [Renderer](modules/renderer.md)

View File

@ -20,12 +20,20 @@
*/ */
import { Renderer } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { PodAttachMenu, PodAttachMenuProps } from "./src/attach-menu";
import { PodShellMenu, PodShellMenuProps } from "./src/shell-menu"; import { PodShellMenu, PodShellMenuProps } from "./src/shell-menu";
import { PodLogsMenu, PodLogsMenuProps } from "./src/logs-menu"; import { PodLogsMenu, PodLogsMenuProps } from "./src/logs-menu";
import React from "react"; import React from "react";
export default class PodMenuRendererExtension extends Renderer.LensExtension { export default class PodMenuRendererExtension extends Renderer.LensExtension {
kubeObjectMenuItems = [ kubeObjectMenuItems = [
{
kind: "Pod",
apiVersions: ["v1"],
components: {
MenuItem: (props: PodAttachMenuProps) => <PodAttachMenu {...props} />
}
},
{ {
kind: "Pod", kind: "Pod",
apiVersions: ["v1"], apiVersions: ["v1"],

View File

@ -0,0 +1,101 @@
/**
* 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 { Renderer, Common } from "@k8slens/extensions";
type Pod = Renderer.K8sApi.Pod;
const {
Component: {
createTerminalTab,
terminalStore,
MenuItem,
Icon,
SubMenu,
StatusBrick,
},
Navigation,
} = Renderer;
const {
Util,
} = Common;
export interface PodAttachMenuProps extends Renderer.Component.KubeObjectMenuProps<Pod> {
}
export class PodAttachMenu extends React.Component<PodAttachMenuProps> {
async attachToPod(container?: string) {
const { object: pod } = this.props;
const containerParam = container ? `-c ${container}` : "";
let command = `kubectl attach -i -t -n ${pod.getNs()} ${pod.getName()} ${containerParam}`;
if (window.navigator.platform !== "Win32") {
command = `exec ${command}`;
}
const shell = createTerminalTab({
title: `Pod: ${pod.getName()} (namespace: ${pod.getNs()}) [Attached]`
});
terminalStore.sendCommand(command, {
enter: true,
tabId: shell.id
});
Navigation.hideDetails();
}
render() {
const { object, toolbar } = this.props;
const containers = object.getRunningContainers();
if (!containers.length) return null;
return (
<MenuItem onClick={Util.prevDefault(() => this.attachToPod(containers[0].name))}>
<Icon material="pageview" interactive={toolbar} title="Attach to Pod"/>
<span className="title">Attach Pod</span>
{containers.length > 1 && (
<>
<Icon className="arrow" material="keyboard_arrow_right"/>
<SubMenu>
{
containers.map(container => {
const { name } = container;
return (
<MenuItem key={name} onClick={Util.prevDefault(() => this.attachToPod(name))} className="flex align-center">
<StatusBrick/>
<span>{name}</span>
</MenuItem>
);
})
}
</SubMenu>
</>
)}
</MenuItem>
);
}
}

View File

@ -410,7 +410,7 @@ describe("Lens cluster pages", () => {
await app.client.click(".list .TableRow:first-child"); await app.client.click(".list .TableRow:first-child");
await app.client.waitForVisible(".Drawer"); await app.client.waitForVisible(".Drawer");
await app.client.waitForVisible(`ul.KubeObjectMenu li.MenuItem i[title="Logs"]`); await app.client.waitForVisible(`ul.KubeObjectMenu li.MenuItem i[title="Logs"]`);
await app.client.click(".drawer-title .Menu li:nth-child(2)"); await app.client.click("ul.KubeObjectMenu li.MenuItem i[title='Logs']");
// Check if controls are available // Check if controls are available
await app.client.waitForVisible(".LogList .VirtualList"); await app.client.waitForVisible(".LogList .VirtualList");
await app.client.waitForVisible(".LogResourceSelector"); await app.client.waitForVisible(".LogResourceSelector");

View File

@ -3,7 +3,7 @@
"productName": "OpenLens", "productName": "OpenLens",
"description": "OpenLens - Open Source IDE for Kubernetes", "description": "OpenLens - Open Source IDE for Kubernetes",
"homepage": "https://github.com/lensapp/lens", "homepage": "https://github.com/lensapp/lens",
"version": "5.0.0-beta.6", "version": "5.0.0-beta.7",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2021 OpenLens Authors", "copyright": "© 2021 OpenLens Authors",
"license": "MIT", "license": "MIT",
@ -39,8 +39,8 @@
"lint": "yarn run eslint --ext js,ts,tsx --max-warnings=0 .", "lint": "yarn run eslint --ext js,ts,tsx --max-warnings=0 .",
"lint:fix": "yarn run lint --fix", "lint:fix": "yarn run lint --fix",
"mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest", "mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest",
"verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict", "verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build",
"typedocs-extensions-api": "yarn run typedoc --ignoreCompilerErrors --readme docs/extensions/typedoc-readme.md.tpl --name @k8slens/extensions --out docs/extensions/api --mode library --excludePrivate --hideBreadcrumbs --includes src/ src/extensions/extension-api.ts", "typedocs-extensions-api": "yarn run typedoc src/extensions/extension-api.ts",
"version-checkout": "cat package.json | jq '.version' -r | xargs printf \"release/v%s\" | xargs git checkout -b", "version-checkout": "cat package.json | jq '.version' -r | xargs printf \"release/v%s\" | xargs git checkout -b",
"version-commit": "cat package.json | jq '.version' -r | xargs printf \"release v%s\" | git commit --no-edit -s -F -", "version-commit": "cat package.json | jq '.version' -r | xargs printf \"release v%s\" | git commit --no-edit -s -F -",
"version": "yarn run version-checkout && git add package.json && yarn run version-commit", "version": "yarn run version-checkout && git add package.json && yarn run version-commit",
@ -181,7 +181,7 @@
"dependencies": { "dependencies": {
"@hapi/call": "^8.0.1", "@hapi/call": "^8.0.1",
"@hapi/subtext": "^7.0.3", "@hapi/subtext": "^7.0.3",
"@kubernetes/client-node": "^0.12.0", "@kubernetes/client-node": "^0.14.3",
"abort-controller": "^3.0.0", "abort-controller": "^3.0.0",
"array-move": "^3.0.1", "array-move": "^3.0.1",
"auto-bind": "^4.0.0", "auto-bind": "^4.0.0",
@ -203,6 +203,7 @@
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
"immer": "^8.0.1", "immer": "^8.0.1",
"joi": "^17.4.0",
"js-yaml": "^3.14.0", "js-yaml": "^3.14.0",
"jsdom": "^16.4.0", "jsdom": "^16.4.0",
"jsonpath": "^1.0.2", "jsonpath": "^1.0.2",
@ -214,7 +215,7 @@
"mobx-observable-history": "^2.0.1", "mobx-observable-history": "^2.0.1",
"mobx-react": "^7.1.0", "mobx-react": "^7.1.0",
"mock-fs": "^4.12.0", "mock-fs": "^4.12.0",
"moment": "^2.26.0", "moment": "^2.29.1",
"moment-timezone": "^0.5.33", "moment-timezone": "^0.5.33",
"node-pty": "^0.9.0", "node-pty": "^0.9.0",
"npm": "^6.14.8", "npm": "^6.14.8",
@ -227,7 +228,7 @@
"react-router": "^5.2.0", "react-router": "^5.2.0",
"readable-stream": "^3.6.0", "readable-stream": "^3.6.0",
"request": "^2.88.2", "request": "^2.88.2",
"request-promise-native": "^1.0.8", "request-promise-native": "^1.0.9",
"semver": "^7.3.2", "semver": "^7.3.2",
"serializr": "^2.0.3", "serializr": "^2.0.3",
"shell-env": "^3.0.1", "shell-env": "^3.0.1",
@ -273,14 +274,14 @@
"@types/mini-css-extract-plugin": "^0.9.1", "@types/mini-css-extract-plugin": "^0.9.1",
"@types/mock-fs": "^4.10.0", "@types/mock-fs": "^4.10.0",
"@types/module-alias": "^2.0.0", "@types/module-alias": "^2.0.0",
"@types/node": "^12.12.45", "@types/node": "12.20",
"@types/npm": "^2.0.31", "@types/npm": "^2.0.31",
"@types/progress-bar-webpack-plugin": "^2.1.0", "@types/progress-bar-webpack-plugin": "^2.1.0",
"@types/proper-lockfile": "^4.1.1", "@types/proper-lockfile": "^4.1.1",
"@types/randomcolor": "^0.5.5", "@types/randomcolor": "^0.5.5",
"@types/react": "^17.0.0", "@types/react": "^17.0.0",
"@types/react-beautiful-dnd": "^13.0.0", "@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "^17.0.0", "@types/react-dom": "^17.0.6",
"@types/react-router-dom": "^5.1.6", "@types/react-router-dom": "^5.1.6",
"@types/react-select": "3.1.2", "@types/react-select": "3.1.2",
"@types/react-table": "^7.7.0", "@types/react-table": "^7.7.0",
@ -289,7 +290,7 @@
"@types/request": "^2.48.5", "@types/request": "^2.48.5",
"@types/request-promise-native": "^1.0.17", "@types/request-promise-native": "^1.0.17",
"@types/semver": "^7.2.0", "@types/semver": "^7.2.0",
"@types/sharp": "^0.26.0", "@types/sharp": "^0.28.3",
"@types/shelljs": "^0.8.8", "@types/shelljs": "^0.8.8",
"@types/spdy": "^3.4.4", "@types/spdy": "^3.4.4",
"@types/tar": "^4.0.4", "@types/tar": "^4.0.4",
@ -335,7 +336,7 @@
"jest-mock-extended": "^1.0.10", "jest-mock-extended": "^1.0.10",
"make-plural": "^6.2.2", "make-plural": "^6.2.2",
"mini-css-extract-plugin": "^1.6.0", "mini-css-extract-plugin": "^1.6.0",
"node-loader": "^0.6.0", "node-loader": "^1.0.3",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"nodemon": "^2.0.4", "nodemon": "^2.0.4",
"open": "^7.3.1", "open": "^7.3.1",
@ -363,8 +364,8 @@
"ts-node": "^8.10.2", "ts-node": "^8.10.2",
"type-fest": "^1.0.2", "type-fest": "^1.0.2",
"typed-emitter": "^1.3.1", "typed-emitter": "^1.3.1",
"typedoc": "0.17.0-3", "typedoc": "0.21.0-beta.2",
"typedoc-plugin-markdown": "^2.4.0", "typedoc-plugin-markdown": "^3.9.0",
"typeface-roboto": "^0.0.75", "typeface-roboto": "^0.0.75",
"typescript": "^4.3.2", "typescript": "^4.3.2",
"typescript-plugin-css-modules": "^3.2.0", "typescript-plugin-css-modules": "^3.2.0",

View File

@ -22,8 +22,10 @@
import fs from "fs"; import fs from "fs";
import mockFs from "mock-fs"; import mockFs from "mock-fs";
import yaml from "js-yaml"; import yaml from "js-yaml";
import path from "path";
import fse from "fs-extra";
import { Cluster } from "../../main/cluster"; import { Cluster } from "../../main/cluster";
import { ClusterStore, getClusterIdFromHost } from "../cluster-store"; import { ClusterId, ClusterStore, getClusterIdFromHost } from "../cluster-store";
import { Console } from "console"; import { Console } from "console";
import { stdout, stderr } from "process"; import { stdout, stderr } from "process";
@ -54,6 +56,15 @@ users:
token: kubeconfig-user-q4lm4:xxxyyyy token: kubeconfig-user-q4lm4:xxxyyyy
`; `;
function embed(clusterId: ClusterId, contents: any): string {
const absPath = ClusterStore.getCustomKubeConfigPath(clusterId);
fse.ensureDirSync(path.dirname(absPath));
fse.writeFileSync(absPath, contents, { encoding: "utf-8", mode: 0o600 });
return absPath;
}
jest.mock("electron", () => { jest.mock("electron", () => {
return { return {
app: { app: {
@ -102,7 +113,7 @@ describe("empty config", () => {
icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
clusterName: "minikube" clusterName: "minikube"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig) kubeConfigPath: embed("foo", kubeconfig)
}) })
); );
}); });
@ -130,7 +141,7 @@ describe("empty config", () => {
preferences: { preferences: {
clusterName: "prod" clusterName: "prod"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig) kubeConfigPath: embed("prod", kubeconfig)
}), }),
new Cluster({ new Cluster({
id: "dev", id: "dev",
@ -138,7 +149,7 @@ describe("empty config", () => {
preferences: { preferences: {
clusterName: "dev" clusterName: "dev"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig) kubeConfigPath: embed("dev", kubeconfig)
}) })
); );
}); });
@ -149,7 +160,7 @@ describe("empty config", () => {
}); });
it("check if cluster's kubeconfig file saved", () => { it("check if cluster's kubeconfig file saved", () => {
const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig"); const file = embed("boo", "kubeconfig");
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig"); expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
}); });
@ -160,6 +171,7 @@ describe("config with existing clusters", () => {
beforeEach(() => { beforeEach(() => {
ClusterStore.resetInstance(); ClusterStore.resetInstance();
const mockOpts = { const mockOpts = {
"temp-kube-config": kubeconfig,
"tmp": { "tmp": {
"lens-cluster-store.json": JSON.stringify({ "lens-cluster-store.json": JSON.stringify({
__internal__: { __internal__: {
@ -170,20 +182,20 @@ describe("config with existing clusters", () => {
clusters: [ clusters: [
{ {
id: "cluster1", id: "cluster1",
kubeConfigPath: kubeconfig, kubeConfigPath: "./temp-kube-config",
contextName: "foo", contextName: "foo",
preferences: { terminalCWD: "/foo" }, preferences: { terminalCWD: "/foo" },
workspace: "default" workspace: "default"
}, },
{ {
id: "cluster2", id: "cluster2",
kubeConfigPath: kubeconfig, kubeConfigPath: "./temp-kube-config",
contextName: "foo2", contextName: "foo2",
preferences: { terminalCWD: "/foo2" } preferences: { terminalCWD: "/foo2" }
}, },
{ {
id: "cluster3", id: "cluster3",
kubeConfigPath: kubeconfig, kubeConfigPath: "./temp-kube-config",
contextName: "foo", contextName: "foo",
preferences: { terminalCWD: "/foo" }, preferences: { terminalCWD: "/foo" },
workspace: "foo", workspace: "foo",
@ -256,6 +268,8 @@ users:
ClusterStore.resetInstance(); ClusterStore.resetInstance();
const mockOpts = { const mockOpts = {
"invalid-kube-config": invalidKubeconfig,
"valid-kube-config": kubeconfig,
"tmp": { "tmp": {
"lens-cluster-store.json": JSON.stringify({ "lens-cluster-store.json": JSON.stringify({
__internal__: { __internal__: {
@ -266,14 +280,14 @@ users:
clusters: [ clusters: [
{ {
id: "cluster1", id: "cluster1",
kubeConfigPath: invalidKubeconfig, kubeConfigPath: "./invalid-kube-config",
contextName: "test", contextName: "test",
preferences: { terminalCWD: "/foo" }, preferences: { terminalCWD: "/foo" },
workspace: "foo", workspace: "foo",
}, },
{ {
id: "cluster2", id: "cluster2",
kubeConfigPath: kubeconfig, kubeConfigPath: "./valid-kube-config",
contextName: "foo", contextName: "foo",
preferences: { terminalCWD: "/foo" }, preferences: { terminalCWD: "/foo" },
workspace: "default" workspace: "default"

View File

@ -20,7 +20,7 @@
*/ */
import { KubeConfig } from "@kubernetes/client-node"; import { KubeConfig } from "@kubernetes/client-node";
import { validateKubeConfig, loadConfig, getNodeWarningConditions } from "../kube-helpers"; import { validateKubeConfig, loadConfigFromString, getNodeWarningConditions } from "../kube-helpers";
const kubeconfig = ` const kubeconfig = `
apiVersion: v1 apiVersion: v1
@ -59,8 +59,6 @@ users:
command: foo command: foo
`; `;
const kc = new KubeConfig();
interface kubeconfig { interface kubeconfig {
apiVersion: string, apiVersion: string,
clusters: [{ clusters: [{
@ -88,6 +86,8 @@ let mockKubeConfig: kubeconfig;
describe("kube helpers", () => { describe("kube helpers", () => {
describe("validateKubeconfig", () => { describe("validateKubeconfig", () => {
const kc = new KubeConfig();
beforeAll(() => { beforeAll(() => {
kc.loadFromString(kubeconfig); kc.loadFromString(kubeconfig);
}); });
@ -164,12 +164,12 @@ describe("kube helpers", () => {
it("invalid yaml string", () => { it("invalid yaml string", () => {
const invalidYAMLString = "fancy foo config"; const invalidYAMLString = "fancy foo config";
expect(() => loadConfig(invalidYAMLString)).toThrowError("must be an object"); expect(loadConfigFromString(invalidYAMLString).error).toBeInstanceOf(Error);
}); });
it("empty contexts", () => { it("empty contexts", () => {
const emptyContexts = `apiVersion: v1\ncontexts:`; const emptyContexts = `apiVersion: v1\ncontexts: []`;
expect(() => loadConfig(emptyContexts)).not.toThrow(); expect(loadConfigFromString(emptyContexts).error).toBeUndefined();
}); });
}); });
@ -200,17 +200,17 @@ describe("kube helpers", () => {
}); });
it("single context is ok", async () => { it("single context is ok", async () => {
const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig));
expect(kc.getCurrentContext()).toBe("minikube"); expect(config.getCurrentContext()).toBe("minikube");
}); });
it("multiple context is ok", async () => { it("multiple context is ok", async () => {
mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: "cluster-2"}, name: "cluster-2"}); mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: "cluster-2"}, name: "cluster-2"});
const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig));
expect(kc.getCurrentContext()).toBe("minikube"); expect(config.getCurrentContext()).toBe("minikube");
expect(kc.contexts.length).toBe(2); expect(config.contexts.length).toBe(2);
}); });
}); });
@ -243,40 +243,40 @@ describe("kube helpers", () => {
it("empty name in context causes it to be removed", async () => { it("empty name in context causes it to be removed", async () => {
mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: "cluster-2"}, name: ""}); mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: "cluster-2"}, name: ""});
expect(mockKubeConfig.contexts.length).toBe(2); expect(mockKubeConfig.contexts.length).toBe(2);
const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig));
expect(kc.getCurrentContext()).toBe("minikube"); expect(config.getCurrentContext()).toBe("minikube");
expect(kc.contexts.length).toBe(1); expect(config.contexts.length).toBe(1);
}); });
it("empty cluster in context causes it to be removed", async () => { it("empty cluster in context causes it to be removed", async () => {
mockKubeConfig.contexts.push({context: {cluster: "", user: "cluster-2"}, name: "cluster-2"}); mockKubeConfig.contexts.push({context: {cluster: "", user: "cluster-2"}, name: "cluster-2"});
expect(mockKubeConfig.contexts.length).toBe(2); expect(mockKubeConfig.contexts.length).toBe(2);
const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig));
expect(kc.getCurrentContext()).toBe("minikube"); expect(config.getCurrentContext()).toBe("minikube");
expect(kc.contexts.length).toBe(1); expect(config.contexts.length).toBe(1);
}); });
it("empty user in context causes it to be removed", async () => { it("empty user in context causes it to be removed", async () => {
mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: ""}, name: "cluster-2"}); mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: ""}, name: "cluster-2"});
expect(mockKubeConfig.contexts.length).toBe(2); expect(mockKubeConfig.contexts.length).toBe(2);
const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig));
expect(kc.getCurrentContext()).toBe("minikube"); expect(config.getCurrentContext()).toBe("minikube");
expect(kc.contexts.length).toBe(1); expect(config.contexts.length).toBe(1);
}); });
it("invalid context in between valid contexts is removed", async () => { it("invalid context in between valid contexts is removed", async () => {
mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: ""}, name: "cluster-2"}); mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: ""}, name: "cluster-2"});
mockKubeConfig.contexts.push({context: {cluster: "cluster-3", user: "cluster-3"}, name: "cluster-3"}); mockKubeConfig.contexts.push({context: {cluster: "cluster-3", user: "cluster-3"}, name: "cluster-3"});
expect(mockKubeConfig.contexts.length).toBe(3); expect(mockKubeConfig.contexts.length).toBe(3);
const kc:KubeConfig = loadConfig(JSON.stringify(mockKubeConfig)); const { config } = loadConfigFromString(JSON.stringify(mockKubeConfig));
expect(kc.getCurrentContext()).toBe("minikube"); expect(config.getCurrentContext()).toBe("minikube");
expect(kc.contexts.length).toBe(2); expect(config.contexts.length).toBe(2);
expect(kc.contexts[0].name).toBe("minikube"); expect(config.contexts[0].name).toBe("minikube");
expect(kc.contexts[1].name).toBe("cluster-3"); expect(config.contexts[1].name).toBe("cluster-3");
}); });
}); });
}); });

View File

@ -66,19 +66,6 @@ describe("user store tests", () => {
expect(us.lastSeenAppVersion).toBe("1.2.3"); expect(us.lastSeenAppVersion).toBe("1.2.3");
}); });
it("allows adding and listing seen contexts", () => {
const us = UserStore.getInstance();
us.seenContexts.add("foo");
expect(us.seenContexts.size).toBe(1);
us.seenContexts.add("foo");
us.seenContexts.add("bar");
expect(us.seenContexts.size).toBe(2); // check 'foo' isn't added twice
expect(us.seenContexts.has("foo")).toBe(true);
expect(us.seenContexts.has("bar")).toBe(true);
});
it("allows setting and getting preferences", () => { it("allows setting and getting preferences", () => {
const us = UserStore.getInstance(); const us = UserStore.getInstance();

View File

@ -26,11 +26,9 @@ import { action, comparer, computed, makeObservable, observable, reaction } from
import { BaseStore } from "./base-store"; import { BaseStore } from "./base-store";
import { Cluster, ClusterState } from "../main/cluster"; import { Cluster, ClusterState } from "../main/cluster";
import migrations from "../migrations/cluster-store"; import migrations from "../migrations/cluster-store";
import * as uuid from "uuid";
import logger from "../main/logger"; import logger from "../main/logger";
import { appEventBus } from "./event-bus"; import { appEventBus } from "./event-bus";
import { dumpConfigYaml } from "./kube-helpers";
import { saveToAppFiles } from "./utils/saveToAppFiles";
import type { KubeConfig } from "@kubernetes/client-node";
import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc";
import { disposer, noop, toJS } from "./utils"; import { disposer, noop, toJS } from "./utils";
@ -116,19 +114,10 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs"); return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs");
} }
static getCustomKubeConfigPath(clusterId: ClusterId): string { static getCustomKubeConfigPath(clusterId: ClusterId = uuid.v4()): string {
return path.resolve(ClusterStore.storedKubeConfigFolder, clusterId); return path.resolve(ClusterStore.storedKubeConfigFolder, clusterId);
} }
static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string {
const filePath = ClusterStore.getCustomKubeConfigPath(clusterId);
const fileContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig);
saveToAppFiles(filePath, fileContents, { mode: 0o600 });
return filePath;
}
@observable clusters = observable.map<ClusterId, Cluster>(); @observable clusters = observable.map<ClusterId, Cluster>();
@observable removedClusters = observable.map<ClusterId, Cluster>(); @observable removedClusters = observable.map<ClusterId, Cluster>();

View File

@ -28,6 +28,8 @@ import logger from "../main/logger";
import commandExists from "command-exists"; import commandExists from "command-exists";
import { ExecValidationNotFoundError } from "./custom-errors"; import { ExecValidationNotFoundError } from "./custom-errors";
import { Cluster, Context, newClusters, newContexts, newUsers, User } from "@kubernetes/client-node/dist/config_types"; import { Cluster, Context, newClusters, newContexts, newUsers, User } from "@kubernetes/client-node/dist/config_types";
import { resolvePath } from "./utils";
import Joi from "joi";
export type KubeConfigValidationOpts = { export type KubeConfigValidationOpts = {
validateCluster?: boolean; validateCluster?: boolean;
@ -37,50 +39,108 @@ export type KubeConfigValidationOpts = {
export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config"); export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config");
function resolveTilde(filePath: string) { export function loadConfigFromFileSync(filePath: string): ConfigResult {
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { const content = fse.readFileSync(resolvePath(filePath), "utf-8");
return filePath.replace("~", os.homedir());
}
return filePath; return loadConfigFromString(content);
} }
function readResolvedPathSync(filePath: string): string { export async function loadConfigFromFile(filePath: string): Promise<ConfigResult> {
return fse.readFileSync(path.resolve(resolveTilde(filePath)), "utf8"); const content = await fse.readFile(resolvePath(filePath), "utf-8");
return loadConfigFromString(content);
} }
function checkRawCluster(rawCluster: any): boolean { const clusterSchema = Joi.object({
return Boolean(rawCluster?.name && rawCluster?.cluster?.server); name: Joi
} .string()
.min(1)
.required(),
cluster: Joi
.object({
server: Joi
.string()
.min(1)
.required(),
})
.required(),
});
function checkRawUser(rawUser: any): boolean { const userSchema = Joi.object({
return Boolean(rawUser?.name); name: Joi.string()
} .min(1)
.required(),
});
function checkRawContext(rawContext: any): boolean { const contextSchema = Joi.object({
return Boolean(rawContext.name && rawContext.context?.cluster && rawContext.context?.user); name: Joi.string()
} .min(1)
.required(),
context: Joi.object({
cluster: Joi.string()
.min(1)
.required(),
user: Joi.string()
.min(1)
.required(),
}),
});
const kubeConfigSchema = Joi
.object({
users: Joi
.array()
.items(userSchema)
.optional(),
clusters: Joi
.array()
.items(clusterSchema)
.optional(),
contexts: Joi
.array()
.items(contextSchema)
.optional(),
"current-context": Joi
.string()
.min(1)
.optional(),
})
.required();
export interface KubeConfigOptions { export interface KubeConfigOptions {
clusters: Cluster[]; clusters: Cluster[];
users: User[]; users: User[];
contexts: Context[]; contexts: Context[];
currentContext: string; currentContext?: string;
} }
function loadToOptions(rawYaml: string): KubeConfigOptions { export interface OptionsResult {
const obj = yaml.safeLoad(rawYaml); options: KubeConfigOptions;
error: Joi.ValidationError;
}
if (typeof obj !== "object" || !obj) { function loadToOptions(rawYaml: string): OptionsResult {
throw new TypeError("KubeConfig root entry must be an object"); const parsed = yaml.safeLoad(rawYaml);
} const { error } = kubeConfigSchema.validate(parsed, {
abortEarly: false,
allowUnknown: true,
});
const { value } = kubeConfigSchema.validate(parsed, {
abortEarly: false,
allowUnknown: true,
stripUnknown: {
arrays: true,
}
});
const { clusters: rawClusters, users: rawUsers, contexts: rawContexts, "current-context": currentContext } = value ?? {};
const clusters = newClusters(rawClusters);
const users = newUsers(rawUsers);
const contexts = newContexts(rawContexts);
const { clusters: rawClusters, users: rawUsers, contexts: rawContexts, "current-context": currentContext } = obj; return {
const clusters = newClusters(rawClusters?.filter(checkRawCluster)); options: { clusters, users, contexts, currentContext },
const users = newUsers(rawUsers?.filter(checkRawUser)); error,
const contexts = newContexts(rawContexts?.filter(checkRawContext)); };
return { clusters, users, contexts, currentContext };
} }
export function loadFromOptions(options: KubeConfigOptions): KubeConfig { export function loadFromOptions(options: KubeConfigOptions): KubeConfig {
@ -92,67 +152,44 @@ export function loadFromOptions(options: KubeConfigOptions): KubeConfig {
return kc; return kc;
} }
export function loadConfig(pathOrContent?: string): KubeConfig { export interface ConfigResult {
return loadConfigFromString( config: KubeConfig;
fse.pathExistsSync(pathOrContent) error: Joi.ValidationError;
? readResolvedPathSync(pathOrContent)
: pathOrContent
);
} }
export function loadConfigFromString(content: string): KubeConfig { export function loadConfigFromString(content: string): ConfigResult {
return loadFromOptions(loadToOptions(content)); const { options, error } = loadToOptions(content);
return {
config: loadFromOptions(options),
error,
};
} }
/** export interface SplitConfigEntry {
* KubeConfig is valid when there's at least one of each defined: config: KubeConfig,
* - User error?: string;
* - Cluster
* - Context
* @param config KubeConfig to check
*/
export function validateConfig(config: KubeConfig | string): KubeConfig {
if (typeof config == "string") {
config = loadConfig(config);
}
logger.debug(`validating kube config: ${JSON.stringify(config)}`);
if (!config.users || config.users.length == 0) {
throw new Error("No users provided in config");
}
if (!config.clusters || config.clusters.length == 0) {
throw new Error("No clusters provided in config");
}
if (!config.contexts || config.contexts.length == 0) {
throw new Error("No contexts provided in config");
}
return config;
} }
/** /**
* Breaks kube config into several configs. Each context as it own KubeConfig object * Breaks kube config into several configs. Each context as it own KubeConfig object
*/ */
export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] { export function splitConfig(kubeConfig: KubeConfig): SplitConfigEntry[] {
const configs: KubeConfig[] = []; const { contexts = [] } = kubeConfig;
if (!kubeConfig.contexts) { return contexts.map(context => {
return configs; const config = new KubeConfig();
}
kubeConfig.contexts.forEach(ctx => {
const kc = new KubeConfig();
kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n); config.clusters = [kubeConfig.getCluster(context.cluster)].filter(Boolean);
kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n); config.users = [kubeConfig.getUser(context.user)].filter(Boolean);
kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n); config.contexts = [kubeConfig.getContextObject(context.name)].filter(Boolean);
kc.setCurrentContext(ctx.name); config.setCurrentContext(context.name);
configs.push(kc); return {
config,
error: validateKubeConfig(config, context.name)?.toString(),
};
}); });
return configs;
} }
export function dumpConfigYaml(kubeConfig: Partial<KubeConfig>): string { export function dumpConfigYaml(kubeConfig: Partial<KubeConfig>): string {
@ -230,7 +267,7 @@ export function getNodeWarningConditions(node: V1Node) {
* *
* Note: This function returns an error instead of throwing it, returning `undefined` if the validation passes * Note: This function returns an error instead of throwing it, returning `undefined` if the validation passes
*/ */
export function validateKubeConfig(config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}): Error | void { export function validateKubeConfig(config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}): Error | undefined {
try { try {
// we only receive a single context, cluster & user object here so lets validate them as this // we only receive a single context, cluster & user object here so lets validate them as this
// will be called when we add a new cluster to Lens // will be called when we add a new cluster to Lens
@ -267,6 +304,8 @@ export function validateKubeConfig(config: KubeConfig, contextName: string, vali
return new ExecValidationNotFoundError(execCommand, isAbsolute); return new ExecValidationNotFoundError(execCommand, isAbsolute);
} }
} }
return undefined;
} catch (error) { } catch (error) {
return error; return error;
} }

View File

@ -22,24 +22,19 @@
import type { ThemeId } from "../renderer/theme.store"; import type { ThemeId } from "../renderer/theme.store";
import { app, remote } from "electron"; import { app, remote } from "electron";
import semver from "semver"; import semver from "semver";
import { readFile } from "fs-extra";
import { action, computed, observable, reaction, makeObservable } from "mobx"; import { action, computed, observable, reaction, makeObservable } from "mobx";
import moment from "moment-timezone"; import moment from "moment-timezone";
import { BaseStore } from "./base-store"; import { BaseStore } from "./base-store";
import migrations from "../migrations/user-store"; import migrations from "../migrations/user-store";
import { getAppVersion } from "./utils/app-version"; import { getAppVersion } from "./utils/app-version";
import { kubeConfigDefaultPath, loadConfig } from "./kube-helpers";
import { appEventBus } from "./event-bus"; import { appEventBus } from "./event-bus";
import logger from "../main/logger";
import path from "path"; import path from "path";
import os from "os"; import os from "os";
import { fileNameMigration } from "../migrations/user-store"; import { fileNameMigration } from "../migrations/user-store";
import { ObservableToggleSet, toJS } from "../renderer/utils"; import { ObservableToggleSet, toJS } from "../renderer/utils";
export interface UserStoreModel { export interface UserStoreModel {
kubeConfigPath: string;
lastSeenAppVersion: string; lastSeenAppVersion: string;
seenContexts: string[];
preferences: UserPreferencesModel; preferences: UserPreferencesModel;
} }
@ -47,7 +42,7 @@ export interface KubeconfigSyncEntry extends KubeconfigSyncValue {
filePath: string; filePath: string;
} }
export interface KubeconfigSyncValue {} export interface KubeconfigSyncValue { }
export interface UserPreferencesModel { export interface UserPreferencesModel {
httpsProxy?: string; httpsProxy?: string;
@ -77,13 +72,6 @@ export class UserStore extends BaseStore<UserStoreModel> {
} }
@observable lastSeenAppVersion = "0.0.0"; @observable lastSeenAppVersion = "0.0.0";
/**
* used in add-cluster page for providing context
*/
@observable kubeConfigPath = kubeConfigDefaultPath;
@observable seenContexts = observable.set<string>();
@observable newContexts = observable.set<string>();
@observable allowTelemetry = true; @observable allowTelemetry = true;
@observable allowUntrustedCAs = false; @observable allowUntrustedCAs = false;
@observable colorTheme = UserStore.defaultTheme; @observable colorTheme = UserStore.defaultTheme;
@ -121,10 +109,6 @@ export class UserStore extends BaseStore<UserStoreModel> {
await fileNameMigration(); await fileNameMigration();
await super.load(); await super.load();
// refresh new contexts
await this.refreshNewContexts();
reaction(() => this.kubeConfigPath, () => this.refreshNewContexts());
if (app) { if (app) {
// track telemetry availability // track telemetry availability
reaction(() => this.allowTelemetry, allowed => { reaction(() => this.allowTelemetry, allowed => {
@ -180,15 +164,6 @@ export class UserStore extends BaseStore<UserStoreModel> {
this.hiddenTableColumns.get(tableId)?.toggle(columnId); this.hiddenTableColumns.get(tableId)?.toggle(columnId);
} }
@action
resetKubeConfigPath() {
this.kubeConfigPath = kubeConfigDefaultPath;
}
@computed get isDefaultKubeConfigPath(): boolean {
return this.kubeConfigPath === kubeConfigDefaultPath;
}
@action @action
async resetTheme() { async resetTheme() {
await this.whenLoaded; await this.whenLoaded;
@ -206,44 +181,14 @@ export class UserStore extends BaseStore<UserStoreModel> {
this.localeTimezone = tz; this.localeTimezone = tz;
} }
protected async refreshNewContexts() {
try {
const kubeConfig = await readFile(this.kubeConfigPath, "utf8");
if (kubeConfig) {
this.newContexts.clear();
loadConfig(kubeConfig).getContexts()
.filter(ctx => ctx.cluster)
.filter(ctx => !this.seenContexts.has(ctx.name))
.forEach(ctx => this.newContexts.add(ctx.name));
}
} catch (err) {
logger.error(err);
this.resetKubeConfigPath();
}
}
@action
markNewContextsAsSeen() {
const { seenContexts, newContexts } = this;
this.seenContexts.replace([...seenContexts, ...newContexts]);
this.newContexts.clear();
}
@action @action
protected async fromStore(data: Partial<UserStoreModel> = {}) { protected async fromStore(data: Partial<UserStoreModel> = {}) {
const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data; const { lastSeenAppVersion, preferences } = data;
if (lastSeenAppVersion) { if (lastSeenAppVersion) {
this.lastSeenAppVersion = lastSeenAppVersion; this.lastSeenAppVersion = lastSeenAppVersion;
} }
if (kubeConfigPath) {
this.kubeConfigPath = kubeConfigPath;
}
this.seenContexts.replace(seenContexts);
if (!preferences) { if (!preferences) {
return; return;
} }
@ -287,9 +232,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
} }
const model: UserStoreModel = { const model: UserStoreModel = {
kubeConfigPath: this.kubeConfigPath,
lastSeenAppVersion: this.lastSeenAppVersion, lastSeenAppVersion: this.lastSeenAppVersion,
seenContexts: Array.from(this.seenContexts),
preferences: { preferences: {
httpsProxy: toJS(this.httpsProxy), httpsProxy: toJS(this.httpsProxy),
shell: toJS(this.shell), shell: toJS(this.shell),

View File

@ -21,7 +21,9 @@
// Common utils (main OR renderer) // Common utils (main OR renderer)
export const noop: any = () => { /* empty */ }; export function noop<T extends any[]>(...args: T): void {
return void args;
}
export * from "./app-version"; export * from "./app-version";
export * from "./autobind"; export * from "./autobind";
@ -39,8 +41,8 @@ export * from "./escapeRegExp";
export * from "./extended-map"; export * from "./extended-map";
export * from "./getRandId"; export * from "./getRandId";
export * from "./openExternal"; export * from "./openExternal";
export * from "./paths";
export * from "./reject-promise"; export * from "./reject-promise";
export * from "./saveToAppFiles";
export * from "./singleton"; export * from "./singleton";
export * from "./splitArray"; export * from "./splitArray";
export * from "./tar"; export * from "./tar";

View File

@ -19,17 +19,17 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
// Save file to electron app directory (e.g. "/Users/$USER/Library/Application Support/Lens" for MacOS)
import path from "path"; import path from "path";
import { app, remote } from "electron"; import os from "os";
import { ensureDirSync, writeFileSync } from "fs-extra";
import type { WriteFileOptions } from "fs";
export function saveToAppFiles(filePath: string, contents: any, options?: WriteFileOptions): string { function resolveTilde(filePath: string) {
const absPath = path.resolve((app || remote.app).getPath("userData"), filePath); if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
return filePath.replace("~", os.homedir());
}
ensureDirSync(path.dirname(absPath)); return filePath;
writeFileSync(absPath, contents, options); }
return absPath; export function resolvePath(filePath: string): string {
return path.resolve(resolveTilde(filePath));
} }

View File

@ -68,10 +68,5 @@ export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as stri
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI" as string; export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI" as string;
export const supportUrl = "https://docs.k8slens.dev/latest/support/" as string; export const supportUrl = "https://docs.k8slens.dev/latest/support/" as string;
// This explicitly ignores the prerelease info on the package version
export const appSemVer = new SemVer(packageInfo.version); export const appSemVer = new SemVer(packageInfo.version);
const { major, minor, patch } = appSemVer; export const docsUrl = `https://docs.k8slens.dev/main/` as string;
const mmpVersion = [major, minor, patch].join(".");
const docsVersion = isProduction ? `v${mmpVersion}` : "latest";
export const docsUrl = `https://docs.k8slens.dev/${docsVersion}`;

View File

@ -26,7 +26,7 @@ import { action, computed, makeObservable, observable, reaction, when } from "mo
import path from "path"; import path from "path";
import { getHostedCluster } from "../common/cluster-store"; import { getHostedCluster } from "../common/cluster-store";
import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc"; import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc";
import { Singleton, toJS } from "../common/utils"; import { Disposer, Singleton, toJS } from "../common/utils";
import logger from "../main/logger"; import logger from "../main/logger";
import type { InstalledExtension } from "./extension-discovery"; import type { InstalledExtension } from "./extension-discovery";
import { ExtensionsStore } from "./extensions-store"; import { ExtensionsStore } from "./extensions-store";
@ -296,7 +296,7 @@ export class ExtensionLoader extends Singleton {
}); });
} }
protected autoInitExtensions(register: (ext: LensExtension) => Promise<Function[]>) { protected autoInitExtensions(register: (ext: LensExtension) => Promise<Disposer[]>) {
return reaction(() => this.toJSON(), installedExtensions => { return reaction(() => this.toJSON(), installedExtensions => {
for (const [extId, extension] of installedExtensions) { for (const [extId, extension] of installedExtensions) {
const alreadyInit = this.instances.has(extId); const alreadyInit = this.instances.has(extId);
@ -311,8 +311,7 @@ export class ExtensionLoader extends Singleton {
const instance = new LensExtensionClass(extension); const instance = new LensExtensionClass(extension);
instance.whenEnabled(() => register(instance)); instance.enable(register);
instance.enable();
this.instances.set(extId, instance); this.instances.set(extId, instance);
} catch (err) { } catch (err) {
logger.error(`${logModule}: activation extension error`, { ext: extension, err }); logger.error(`${logModule}: activation extension error`, { ext: extension, err });

View File

@ -20,12 +20,12 @@
*/ */
import type { InstalledExtension } from "./extension-discovery"; import type { InstalledExtension } from "./extension-discovery";
import { action, observable, reaction, makeObservable } from "mobx"; import { action, observable, makeObservable } from "mobx";
import { FilesystemProvisionerStore } from "../main/extension-filesystem"; import { FilesystemProvisionerStore } from "../main/extension-filesystem";
import logger from "../main/logger"; import logger from "../main/logger";
import type { ProtocolHandlerRegistration } from "./registries"; import type { ProtocolHandlerRegistration } from "./registries";
import { disposer } from "../common/utils";
import type { PackageJson } from "type-fest"; import type { PackageJson } from "type-fest";
import { Disposer, disposer } from "../common/utils";
export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionId = string; // path to manifest (package.json)
export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension; export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension;
@ -82,59 +82,44 @@ export class LensExtension {
} }
@action @action
async enable() { async enable(register: (ext: LensExtension) => Promise<Disposer[]>) {
if (this.isEnabled) return; if (this.isEnabled) {
this.isEnabled = true; return;
this.onActivate?.(); }
logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`);
try {
await this.onActivate();
this.isEnabled = true;
this[Disposers].push(...await register(this));
logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`);
} catch (error) {
logger.error(`[EXTENSION]: failed to activate ${this.name}@${this.version}: ${error}`);
}
} }
@action @action
async disable() { async disable() {
if (!this.isEnabled) return; if (!this.isEnabled) {
this.isEnabled = false; return;
this.onDeactivate?.(); }
this[Disposers]();
logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`);
}
toggle(enable?: boolean) { this.isEnabled = false;
if (typeof enable === "boolean") {
enable ? this.enable() : this.disable(); try {
} else { await this.onDeactivate();
this.isEnabled ? this.disable() : this.enable(); this[Disposers]();
logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`);
} catch (error) {
logger.error(`[EXTENSION]: disabling ${this.name}@${this.version} threw an error: ${error}`);
} }
} }
async whenEnabled(handlers: () => Promise<Function[]>) { protected onActivate(): Promise<void> | void {
const disposers: Function[] = [];
const unregisterHandlers = () => {
disposers.forEach(unregister => unregister());
disposers.length = 0;
};
const cancelReaction = reaction(() => this.isEnabled, async (isEnabled) => {
if (isEnabled) {
const handlerDisposers = await handlers();
disposers.push(...handlerDisposers);
} else {
unregisterHandlers();
}
}, {
fireImmediately: true
});
return () => {
unregisterHandlers();
cancelReaction();
};
}
protected onActivate(): void {
return; return;
} }
protected onDeactivate(): void { protected onDeactivate(): Promise<void> | void {
return; return;
} }
} }

View File

@ -29,7 +29,7 @@ import type stream from "stream";
import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils"; import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils";
import logger from "../logger"; import logger from "../logger";
import type { KubeConfig } from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node";
import { loadConfigFromString, splitConfig, validateKubeConfig } from "../../common/kube-helpers"; import { loadConfigFromString, splitConfig } from "../../common/kube-helpers";
import { Cluster } from "../cluster"; import { Cluster } from "../cluster";
import { catalogEntityFromCluster } from "../cluster-manager"; import { catalogEntityFromCluster } from "../cluster-manager";
import { UserStore } from "../../common/user-store"; import { UserStore } from "../../common/user-store";
@ -130,18 +130,16 @@ export class KubeconfigSyncManager extends Singleton {
} }
// exported for testing // exported for testing
export function configToModels(config: KubeConfig, filePath: string): UpdateClusterModel[] { export function configToModels(rootConfig: KubeConfig, filePath: string): UpdateClusterModel[] {
const validConfigs = []; const validConfigs = [];
for (const contextConfig of splitConfig(config)) { for (const { config, error } of splitConfig(rootConfig)) {
const error = validateKubeConfig(contextConfig, contextConfig.currentContext);
if (error) { if (error) {
logger.debug(`${logPrefix} context failed validation: ${error}`, { context: contextConfig.currentContext, filePath }); logger.debug(`${logPrefix} context failed validation: ${error}`, { context: config.currentContext, filePath });
} else { } else {
validConfigs.push({ validConfigs.push({
kubeConfigPath: filePath, kubeConfigPath: filePath,
contextName: contextConfig.currentContext, contextName: config.currentContext,
}); });
} }
} }
@ -156,7 +154,13 @@ type RootSource = ObservableMap<string, RootSourceValue>;
export function computeDiff(contents: string, source: RootSource, filePath: string): void { export function computeDiff(contents: string, source: RootSource, filePath: string): void {
runInAction(() => { runInAction(() => {
try { try {
const rawModels = configToModels(loadConfigFromString(contents), filePath); const { config, error } = loadConfigFromString(contents);
if (error) {
logger.warn(`${logPrefix} encountered errors while loading config: ${error.message}`, { filePath, details: error.details });
}
const rawModels = configToModels(config, filePath);
const models = new Map(rawModels.map(m => [m.contextName, m])); const models = new Map(rawModels.map(m => [m.contextName, m]));
logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath }); logger.debug(`${logPrefix} File now has ${models.size} entries`, { filePath });

View File

@ -27,7 +27,7 @@ import { ContextHandler } from "./context-handler";
import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node";
import { Kubectl } from "./kubectl"; import { Kubectl } from "./kubectl";
import { KubeconfigManager } from "./kubeconfig-manager"; import { KubeconfigManager } from "./kubeconfig-manager";
import { loadConfig, validateKubeConfig } from "../common/kube-helpers"; import { loadConfigFromFile, loadConfigFromFileSync, validateKubeConfig } from "../common/kube-helpers";
import { apiResourceRecord, apiResources, KubeApiResource, KubeResource } from "../common/rbac"; import { apiResourceRecord, apiResources, KubeApiResource, KubeResource } from "../common/rbac";
import logger from "./logger"; import logger from "./logger";
import { VersionDetector } from "./cluster-detectors/version-detector"; import { VersionDetector } from "./cluster-detectors/version-detector";
@ -258,14 +258,14 @@ export class Cluster implements ClusterModel, ClusterState {
this.id = model.id; this.id = model.id;
this.updateModel(model); this.updateModel(model);
const kubeconfig = this.getKubeconfig(); const { config } = loadConfigFromFileSync(this.kubeConfigPath);
const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false}); const validationError = validateKubeConfig(config, this.contextName);
if (error) { if (validationError) {
throw error; throw validationError;
} }
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; this.apiUrl = config.getCluster(config.getContextObject(this.contextName).cluster).server;
if (ipcMain) { if (ipcMain) {
// for the time being, until renderer gets its own cluster type // for the time being, until renderer gets its own cluster type
@ -470,17 +470,20 @@ export class Cluster implements ClusterModel, ClusterState {
this.allowedResources = await this.getAllowedResources(); this.allowedResources = await this.getAllowedResources();
} }
protected getKubeconfig(): KubeConfig { async getKubeconfig(): Promise<KubeConfig> {
return loadConfig(this.kubeConfigPath); const { config } = await loadConfigFromFile(this.kubeConfigPath);
return config;
} }
/** /**
* @internal * @internal
*/ */
async getProxyKubeconfig(): Promise<KubeConfig> { async getProxyKubeconfig(): Promise<KubeConfig> {
const kubeconfigPath = await this.getProxyKubeconfigPath(); const proxyKCPath = await this.getProxyKubeconfigPath();
const { config } = await loadConfigFromFile(proxyKCPath);
return loadConfig(kubeconfigPath); return config;
} }
/** /**

View File

@ -93,11 +93,7 @@ if (!app.requestSingleInstanceLock()) {
for (const arg of process.argv) { for (const arg of process.argv) {
if (arg.toLowerCase().startsWith("lens://")) { if (arg.toLowerCase().startsWith("lens://")) {
try { lprm.route(arg);
lprm.route(arg);
} catch (error) {
logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg });
}
} }
} }
} }
@ -107,11 +103,7 @@ app.on("second-instance", (event, argv) => {
for (const arg of argv) { for (const arg of argv) {
if (arg.toLowerCase().startsWith("lens://")) { if (arg.toLowerCase().startsWith("lens://")) {
try { lprm.route(arg);
lprm.route(arg);
} catch (error) {
logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg });
}
} }
} }
@ -196,7 +188,7 @@ app.on("ready", async () => {
installDeveloperTools(); installDeveloperTools();
if (!startHidden) { if (!startHidden) {
windowManager.initMainWindow(); windowManager.ensureMainWindow();
} }
ipcMain.on(IpcRendererNavigationEvents.LOADED, () => { ipcMain.on(IpcRendererNavigationEvents.LOADED, () => {
@ -244,7 +236,7 @@ app.on("activate", (event, hasVisibleWindows) => {
logger.info("APP:ACTIVATE", { hasVisibleWindows }); logger.info("APP:ACTIVATE", { hasVisibleWindows });
if (!hasVisibleWindows) { if (!hasVisibleWindows) {
WindowManager.getInstance(false)?.initMainWindow(false); WindowManager.getInstance(false)?.ensureMainWindow(false);
} }
}); });
@ -274,12 +266,7 @@ app.on("will-quit", (event) => {
app.on("open-url", (event, rawUrl) => { app.on("open-url", (event, rawUrl) => {
// lens:// protocol handler // lens:// protocol handler
event.preventDefault(); event.preventDefault();
LensProtocolRouterMain.getInstance().route(rawUrl);
try {
LensProtocolRouterMain.getInstance().route(rawUrl);
} catch (error) {
logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl });
}
}); });
/** /**

View File

@ -25,7 +25,7 @@ import type { ContextHandler } from "./context-handler";
import { app } from "electron"; import { app } from "electron";
import path from "path"; import path from "path";
import fs from "fs-extra"; import fs from "fs-extra";
import { dumpConfigYaml, loadConfig } from "../common/kube-helpers"; import { dumpConfigYaml } from "../common/kube-helpers";
import logger from "./logger"; import logger from "./logger";
import { LensProxy } from "./proxy/lens-proxy"; import { LensProxy } from "./proxy/lens-proxy";
@ -86,9 +86,9 @@ export class KubeconfigManager {
*/ */
protected async createProxyKubeconfig(): Promise<string> { protected async createProxyKubeconfig(): Promise<string> {
const { configDir, cluster } = this; const { configDir, cluster } = this;
const { contextName, kubeConfigPath, id } = cluster; const { contextName, id } = cluster;
const tempFile = path.normalize(path.join(configDir, `kubeconfig-${id}`)); const tempFile = path.join(configDir, `kubeconfig-${id}`);
const kubeConfig = loadConfig(kubeConfigPath); const kubeConfig = await cluster.getKubeconfig();
const proxyConfig: Partial<KubeConfig> = { const proxyConfig: Partial<KubeConfig> = {
currentContext: contextName, currentContext: contextName,
clusters: [ clusters: [

View File

@ -21,19 +21,23 @@
import type { ClusterId } from "../common/cluster-store"; import type { ClusterId } from "../common/cluster-store";
import { makeObservable, observable } from "mobx"; import { makeObservable, observable } from "mobx";
import { app, BrowserWindow, dialog, shell, webContents } from "electron"; import { app, BrowserWindow, dialog, ipcMain, shell, webContents } from "electron";
import windowStateKeeper from "electron-window-state"; import windowStateKeeper from "electron-window-state";
import { appEventBus } from "../common/event-bus"; import { appEventBus } from "../common/event-bus";
import { subscribeToBroadcast } from "../common/ipc"; import { subscribeToBroadcast } from "../common/ipc";
import { initMenu } from "./menu"; import { initMenu } from "./menu";
import { initTray } from "./tray"; import { initTray } from "./tray";
import { Singleton } from "../common/utils"; import { delay, Singleton } from "../common/utils";
import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames"; import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames";
import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events";
import logger from "./logger"; import logger from "./logger";
import { productName } from "../common/vars"; import { productName } from "../common/vars";
import { LensProxy } from "./proxy/lens-proxy"; import { LensProxy } from "./proxy/lens-proxy";
function isHideable(window: BrowserWindow | null): boolean {
return Boolean(window && !window.isDestroyed());
}
export class WindowManager extends Singleton { export class WindowManager extends Singleton {
protected mainWindow: BrowserWindow; protected mainWindow: BrowserWindow;
protected splashWindow: BrowserWindow; protected splashWindow: BrowserWindow;
@ -54,7 +58,7 @@ export class WindowManager extends Singleton {
return `http://localhost:${LensProxy.getInstance().port}`; return `http://localhost:${LensProxy.getInstance().port}`;
} }
async initMainWindow(showSplash = true) { private async initMainWindow(showSplash: boolean) {
// Manage main window size and position with state persistence // Manage main window size and position with state persistence
if (!this.windowState) { if (!this.windowState) {
this.windowState = windowStateKeeper({ this.windowState = windowStateKeeper({
@ -120,13 +124,8 @@ export class WindowManager extends Singleton {
if (showSplash) await this.showSplash(); if (showSplash) await this.showSplash();
logger.info(`[WINDOW-MANAGER]: Loading Main window from url: ${this.mainUrl} ...`); logger.info(`[WINDOW-MANAGER]: Loading Main window from url: ${this.mainUrl} ...`);
await this.mainWindow.loadURL(this.mainUrl); await this.mainWindow.loadURL(this.mainUrl);
this.mainWindow.show();
this.splashWindow?.close();
setTimeout(() => {
appEventBus.emit({ name: "app", action: "start" });
}, 1000);
} catch (error) { } catch (error) {
logger.error("Showing main window failed", { error }); logger.error("Loading main window failed", { error });
dialog.showErrorBox("ERROR!", error.toString()); dialog.showErrorBox("ERROR!", error.toString());
} }
} }
@ -146,9 +145,32 @@ export class WindowManager extends Singleton {
}); });
} }
async ensureMainWindow(): Promise<BrowserWindow> { async ensureMainWindow(showSplash = true): Promise<BrowserWindow> {
if (!this.mainWindow) await this.initMainWindow(); // This needs to be ready to hear the IPC message before the window is loaded
this.mainWindow.show(); let viewHasLoaded = Promise.resolve();
if (!this.mainWindow) {
viewHasLoaded = new Promise<void>(resolve => {
ipcMain.once(IpcRendererNavigationEvents.LOADED, () => resolve());
});
await this.initMainWindow(showSplash);
}
try {
await viewHasLoaded;
await delay(50); // wait just a bit longer to let the first round of rendering happen
logger.info("[WINDOW-MANAGER]: Main window has reported that it has loaded");
this.mainWindow.show();
this.splashWindow?.close();
this.splashWindow = undefined;
setTimeout(() => {
appEventBus.emit({ name: "app", action: "start" });
}, 1000);
} catch (error) {
logger.error(`Showing main window failed: ${error.stack || error}`);
dialog.showErrorBox("ERROR!", error.toString());
}
return this.mainWindow; return this.mainWindow;
} }
@ -206,8 +228,13 @@ export class WindowManager extends Singleton {
} }
hide() { hide() {
if (this.mainWindow && !this.mainWindow.isDestroyed()) this.mainWindow.hide(); if (isHideable(this.mainWindow)) {
if (this.splashWindow && !this.splashWindow.isDestroyed()) this.splashWindow.hide(); this.mainWindow.hide();
}
if (isHideable(this.splashWindow)) {
this.splashWindow.hide();
}
} }
destroy() { destroy() {

View File

@ -27,7 +27,7 @@ import { app, remote } from "electron";
import { migration } from "../migration-wrapper"; import { migration } from "../migration-wrapper";
import fse from "fs-extra"; import fse from "fs-extra";
import { ClusterModel, ClusterStore } from "../../common/cluster-store"; import { ClusterModel, ClusterStore } from "../../common/cluster-store";
import { loadConfig } from "../../common/kube-helpers"; import { loadConfigFromFileSync } from "../../common/kube-helpers";
export default migration({ export default migration({
version: "3.6.0-beta.1", version: "3.6.0-beta.1",
@ -46,9 +46,13 @@ export default migration({
* migrate kubeconfig * migrate kubeconfig
*/ */
try { try {
const absPath = ClusterStore.getCustomKubeConfigPath(cluster.id);
fse.ensureDirSync(path.dirname(absPath));
fse.writeFileSync(absPath, cluster.kubeConfig, { encoding: "utf-8", mode: 0o600 });
// take the embedded kubeconfig and dump it into a file // take the embedded kubeconfig and dump it into a file
cluster.kubeConfigPath = ClusterStore.embedCustomKubeConfig(cluster.id, cluster.kubeConfig); cluster.kubeConfigPath = absPath;
cluster.contextName = loadConfig(cluster.kubeConfigPath).getCurrentContext(); cluster.contextName = loadConfigFromFileSync(cluster.kubeConfigPath).config.getCurrentContext();
delete cluster.kubeConfig; delete cluster.kubeConfig;
} catch (error) { } catch (error) {

View File

@ -26,8 +26,8 @@ import type { KubeObjectStore } from "../kube-object.store";
import type { ClusterContext } from "../components/context"; import type { ClusterContext } from "../components/context";
import plimit from "p-limit"; import plimit from "p-limit";
import { comparer, IReactionDisposer, observable, reaction, makeObservable } from "mobx"; import { comparer, observable, reaction, makeObservable } from "mobx";
import { autoBind, noop } from "../utils"; import { autoBind, Disposer, noop } from "../utils";
import type { KubeApi } from "./kube-api"; import type { KubeApi } from "./kube-api";
import type { KubeJsonApiData } from "./kube-json-api"; import type { KubeJsonApiData } from "./kube-json-api";
import { isDebugging, isProduction } from "../../common/vars"; import { isDebugging, isProduction } from "../../common/vars";
@ -80,7 +80,7 @@ export class KubeWatchApi {
}; };
} }
subscribeStores(stores: KubeObjectStore[], opts: IKubeWatchSubscribeStoreOptions = {}): () => void { subscribeStores(stores: KubeObjectStore[], opts: IKubeWatchSubscribeStoreOptions = {}): Disposer {
const { preload = true, waitUntilLoaded = true, loadOnce = false, } = opts; const { preload = true, waitUntilLoaded = true, loadOnce = false, } = opts;
const subscribingNamespaces = opts.namespaces ?? this.context?.allNamespaces ?? []; const subscribingNamespaces = opts.namespaces ?? this.context?.allNamespaces ?? [];
const unsubscribeList: Function[] = []; const unsubscribeList: Function[] = [];
@ -88,7 +88,7 @@ export class KubeWatchApi {
const load = (namespaces = subscribingNamespaces) => this.preloadStores(stores, { namespaces, loadOnce }); const load = (namespaces = subscribingNamespaces) => this.preloadStores(stores, { namespaces, loadOnce });
let preloading = preload && load(); let preloading = preload && load();
let cancelReloading: IReactionDisposer = noop; let cancelReloading: Disposer = noop;
const subscribe = () => { const subscribe = () => {
if (isUnsubscribed) return; if (isUnsubscribed) return;

View File

@ -20,35 +20,48 @@
*/ */
import "./add-cluster.scss"; import "./add-cluster.scss";
import React from "react";
import type { KubeConfig } from "@kubernetes/client-node";
import fse from "fs-extra";
import { debounce } from "lodash";
import { action, computed, observable, makeObservable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { action, observable, runInAction, makeObservable } from "mobx"; import path from "path";
import { KubeConfig } from "@kubernetes/client-node"; import React from "react";
import { catalogURL } from "../+catalog";
import { ClusterStore } from "../../../common/cluster-store";
import { appEventBus } from "../../../common/event-bus";
import { loadConfigFromString, splitConfig } from "../../../common/kube-helpers";
import { docsUrl } from "../../../common/vars";
import { navigate } from "../../navigation";
import { iter } from "../../utils";
import { AceEditor } from "../ace-editor"; import { AceEditor } from "../ace-editor";
import { Button } from "../button"; import { Button } from "../button";
import { loadConfig, splitConfig, validateKubeConfig } from "../../../common/kube-helpers";
import { ClusterStore } from "../../../common/cluster-store";
import { v4 as uuid } from "uuid";
import { navigate } from "../../navigation";
import { UserStore } from "../../../common/user-store";
import { Notifications } from "../notifications";
import { ExecValidationNotFoundError } from "../../../common/custom-errors";
import { appEventBus } from "../../../common/event-bus";
import { PageLayout } from "../layout/page-layout"; import { PageLayout } from "../layout/page-layout";
import { docsUrl } from "../../../common/vars"; import { Notifications } from "../notifications";
import { catalogURL } from "../+catalog";
import { preferencesURL } from "../+preferences"; interface Option {
import { Input } from "../input"; config: KubeConfig;
error?: string;
}
function getContexts(config: KubeConfig): Map<string, Option> {
return new Map(
splitConfig(config)
.map(({ config, error }) => [config.currentContext, {
config,
error,
}])
);
}
@observer @observer
export class AddCluster extends React.Component { export class AddCluster extends React.Component {
@observable.ref kubeConfigLocal: KubeConfig; @observable kubeContexts = observable.map<string, Option>();
@observable.ref error: React.ReactNode;
@observable customConfig = ""; @observable customConfig = "";
@observable proxyServer = "";
@observable isWaiting = false; @observable isWaiting = false;
@observable showSettings = false; @observable errorText: string;
kubeContexts = observable.map<string, KubeConfig>();
constructor(props: {}) { constructor(props: {}) {
super(props); super(props);
@ -59,159 +72,75 @@ export class AddCluster extends React.Component {
appEventBus.emit({ name: "cluster-add", action: "start" }); appEventBus.emit({ name: "cluster-add", action: "start" });
} }
componentWillUnmount() { @computed get allErrors(): string[] {
UserStore.getInstance().markNewContextsAsSeen(); return [
this.errorText,
...iter.map(this.kubeContexts.values(), ({ error }) => error)
].filter(Boolean);
} }
@action @action
refreshContexts() { refreshContexts = debounce(() => {
this.kubeContexts.clear(); const { config, error } = loadConfigFromString(this.customConfig.trim() || "{}");
try { this.kubeContexts.replace(getContexts(config));
this.error = ""; this.errorText = error?.toString();
const contexts = this.getContexts(loadConfig(this.customConfig || "{}")); }, 500);
console.log(contexts);
this.kubeContexts.replace(contexts);
} catch (err) {
this.error = String(err);
}
}
getContexts(config: KubeConfig): Map<string, KubeConfig> {
const contexts = new Map();
splitConfig(config).forEach(config => {
contexts.set(config.currentContext, config);
});
return contexts;
}
@action @action
addClusters = (): void => { addClusters = async () => {
this.isWaiting = true;
appEventBus.emit({ name: "cluster-add", action: "click" });
try { try {
const absPath = ClusterStore.getCustomKubeConfigPath();
this.error = ""; await fse.ensureDir(path.dirname(absPath));
this.isWaiting = true; await fse.writeFile(absPath, this.customConfig.trim(), { encoding: "utf-8", mode: 0o600 });
appEventBus.emit({ name: "cluster-add", action: "click" });
const newClusters = Array.from(this.kubeContexts.keys()).filter(context => {
const kubeConfig = this.kubeContexts.get(context);
const error = validateKubeConfig(kubeConfig, context);
if (error) { Notifications.ok(`Successfully added ${this.kubeContexts.size} new cluster(s)`);
this.error = error.toString();
if (error instanceof ExecValidationNotFoundError) { return navigate(catalogURL());
Notifications.error(<>Error while adding cluster(s): {this.error}</>); } catch (error) {
} Notifications.error(`Failed to add clusters: ${error}`);
}
return Boolean(!error);
}).map(context => {
const clusterId = uuid();
const kubeConfig = this.kubeContexts.get(context);
const kubeConfigPath = ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder
return {
id: clusterId,
kubeConfigPath,
contextName: kubeConfig.currentContext,
preferences: {
clusterName: kubeConfig.currentContext,
httpsProxy: this.proxyServer || undefined,
},
};
});
runInAction(() => {
ClusterStore.getInstance().addClusters(...newClusters);
Notifications.ok(
<>Successfully imported <b>{newClusters.length}</b> cluster(s)</>
);
navigate(catalogURL());
});
this.refreshContexts();
} catch (err) {
this.error = String(err);
Notifications.error(<>Error while adding cluster(s): {this.error}</>);
} finally {
this.isWaiting = false;
} }
}; };
renderInfo() { render() {
return ( return (
<p> <PageLayout className="AddClusters" showOnTop={true}>
Paste kubeconfig as a text from the clipboard to the textarea below. <h2>Add Clusters from Kubeconfig</h2>
If you want to add clusters from kubeconfigs that exists on filesystem, please add those files (or folders) to kubeconfig sync via <a onClick={() => navigate(preferencesURL())}>Preferences</a>. <p>
Read more about adding clusters <a href={`${docsUrl}/clusters/adding-clusters/`} rel="noreferrer" target="_blank">here</a>. Clusters added here are <b>not</b> merged into the <code>~/.kube/config</code> file.
</p> Read more about adding clusters <a href={`${docsUrl}/clusters/adding-clusters/`} rel="noreferrer" target="_blank">here</a>.
); </p>
}
renderKubeConfigSource() {
return (
<>
<div className="flex column"> <div className="flex column">
<AceEditor <AceEditor
autoFocus autoFocus
showGutter={false} showGutter={false}
mode="yaml" mode="yaml"
value={this.customConfig} value={this.customConfig}
wrap={true}
onChange={value => { onChange={value => {
this.customConfig = value; this.customConfig = value;
this.errorText = "";
this.refreshContexts(); this.refreshContexts();
}} }}
/> />
</div> </div>
</> {this.allErrors.length > 0 && (
); <>
} <h3>KubeConfig Yaml Validation Errors:</h3>
{...this.allErrors.map(error => <div key={error} className="error">{error}</div>)}
render() { </>
const submitDisabled = this.kubeContexts.size === 0;
return (
<PageLayout className="AddClusters" showOnTop={true}>
<h2>Add Clusters from Kubeconfig</h2>
{this.renderInfo()}
{this.renderKubeConfigSource()}
<div className="cluster-settings">
<a href="#" onClick={() => this.showSettings = !this.showSettings}>
Proxy settings
</a>
</div>
{this.showSettings && (
<div className="proxy-settings">
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
<Input
autoFocus
value={this.proxyServer}
onChange={value => this.proxyServer = value}
theme="round-black"
/>
<small className="hint">
{"A HTTP proxy server URL (format: http://<address>:<port>)."}
</small>
</div>
)} )}
{this.error && (
<div className="error">{this.error}</div>
)}
<div className="actions-panel"> <div className="actions-panel">
<Button <Button
primary primary
disabled={submitDisabled} disabled={this.kubeContexts.size === 0}
label={this.kubeContexts.keys.length < 2 ? "Add cluster" : "Add clusters"} label={this.kubeContexts.size === 1 ? "Add cluster" : "Add clusters"}
onClick={this.addClusters} onClick={this.addClusters}
waiting={this.isWaiting} waiting={this.isWaiting}
tooltip={submitDisabled ? "Paste a valid kubeconfig." : undefined} tooltip={this.kubeContexts.size === 0 || "Paste in at least one cluster to add."}
tooltipOverrideDisabled tooltipOverrideDisabled
/> />
</div> </div>

View File

@ -30,7 +30,6 @@ import type { HelmChart } from "../../api/endpoints/helm-charts.api";
import { HelmChartDetails } from "./helm-chart-details"; import { HelmChartDetails } from "./helm-chart-details";
import { navigation } from "../../navigation"; import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout"; import { ItemListLayout } from "../item-object-list/item-list-layout";
import { SearchInputUrl } from "../input";
enum columnId { enum columnId {
name = "name", name = "name",
@ -92,9 +91,12 @@ export class HelmCharts extends Component<Props> {
(chart: HelmChart) => chart.getAppVersion(), (chart: HelmChart) => chart.getAppVersion(),
(chart: HelmChart) => chart.getKeywords(), (chart: HelmChart) => chart.getKeywords(),
]} ]}
customizeHeader={() => ( customizeHeader={({ searchProps }) => ({
<SearchInputUrl placeholder="Search Helm Charts" /> searchProps: {
)} ...searchProps,
placeholder: "Search Helm Charts...",
},
})}
renderTableHeader={[ renderTableHeader={[
{ className: "icon", showWithColumn: columnId.name }, { className: "icon", showWithColumn: columnId.name },
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },

View File

@ -117,16 +117,20 @@ export class HelmReleases extends Component<Props> {
(release: HelmRelease) => release.getStatus(), (release: HelmRelease) => release.getStatus(),
(release: HelmRelease) => release.getVersion(), (release: HelmRelease) => release.getVersion(),
]} ]}
renderHeaderTitle="Releases" customizeHeader={({ filters, searchProps, ...headerPlaceholders }) => ({
customizeHeader={({ filters, ...headerPlaceholders }) => ({
filters: ( filters: (
<> <>
{filters} {filters}
<NamespaceSelectFilter /> <NamespaceSelectFilter />
</> </>
), ),
searchProps: {
...searchProps,
placeholder: "Search Releases...",
},
...headerPlaceholders, ...headerPlaceholders,
})} })}
renderHeaderTitle="Releases"
renderTableHeader={[ renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },

View File

@ -1,120 +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.
*/
.CatalogPage {
--width: 100%;
--height: 100%;
--nav-column-width: 200px;
.sidebarRegion {
justify-content: flex-start;
background-color: var(--sidebarBackground);
.sidebarHeader {
background: var(--sidebarLogoBackground);
height: var(--main-layout-header);
padding: 4px;
color: var(--textColorAccent);
font-weight: bold;
font-size: 14px;
display: flex;
align-items: center;
padding-left: 10px;
}
> .sidebar {
width: 100%;
padding: 0;
.sidebarTabs {
margin-top: 5px;
.Tab {
padding: 7px 10px;
font-weight: normal;
font-size: 14px;
border-radius: 0;
height: 36px;
&.active {
background-color: var(--blue);
color: white;
}
}
}
}
}
.contentRegion {
> .content {
padding: 20px 20px;
}
}
.TableCell.icon {
max-width: 40px;
display: flex;
align-items: center;
}
.TableCell.kind {
max-width: 150px;
}
.TableCell.source {
max-width: 100px;
}
.TableCell.status {
max-width: 100px;
&.connected {
color: var(--colorSuccess);
}
&.disconnected {
color: var(--halfGray);
}
}
.TableCell.labels {
overflow-x: scroll;
text-overflow: unset;
&::-webkit-scrollbar {
display: none;
}
.Badge {
overflow: unset;
text-overflow: unset;
max-width: unset;
&:not(:first-child) {
margin-left: 0.5em;
}
}
}
.catalogIcon {
font-size: 10px;
-webkit-font-smoothing: auto;
}
}

View File

@ -205,7 +205,6 @@ export class Catalog extends React.Component<Props> {
return ( return (
<ItemListLayout <ItemListLayout
renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name ?? "Browse All"} renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name ?? "Browse All"}
isSearchable={true}
isSelectable={false} isSelectable={false}
className="CatalogItemList" className="CatalogItemList"
store={this.catalogEntityStore} store={this.catalogEntityStore}
@ -219,11 +218,11 @@ export class Catalog extends React.Component<Props> {
(entity: CatalogEntityItem) => entity.searchFields, (entity: CatalogEntityItem) => entity.searchFields,
]} ]}
renderTableHeader={[ renderTableHeader={[
{ title: "", className: "icon" }, { title: "", className: styles.iconCell },
{ title: "Name", className: "name", sortBy: sortBy.name }, { title: "Name", className: styles.nameCell, sortBy: sortBy.name },
{ title: "Source", className: "source", sortBy: sortBy.source }, { title: "Source", className: styles.sourceCell, sortBy: sortBy.source },
{ title: "Labels", className: "labels" }, { title: "Labels", className: styles.labelsCell },
{ title: "Status", className: "status", sortBy: sortBy.status }, { title: "Status", className: styles.statusCell, sortBy: sortBy.status },
]} ]}
renderTableContents={(item: CatalogEntityItem) => [ renderTableContents={(item: CatalogEntityItem) => [
this.renderIcon(item), this.renderIcon(item),
@ -242,7 +241,6 @@ export class Catalog extends React.Component<Props> {
return ( return (
<ItemListLayout <ItemListLayout
renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name ?? "Browse All"} renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name ?? "Browse All"}
isSearchable={true}
isSelectable={false} isSelectable={false}
className="CatalogItemList" className="CatalogItemList"
store={this.catalogEntityStore} store={this.catalogEntityStore}
@ -257,12 +255,11 @@ export class Catalog extends React.Component<Props> {
(entity: CatalogEntityItem) => entity.searchFields, (entity: CatalogEntityItem) => entity.searchFields,
]} ]}
renderTableHeader={[ renderTableHeader={[
{ title: "", className: "icon" }, { title: "", className: styles.iconCell },
{ title: "Name", className: "name", sortBy: sortBy.name }, { title: "Name", className: styles.nameCell, sortBy: sortBy.name },
{ title: "Kind", className: "kind", sortBy: sortBy.kind }, { title: "Source", className: styles.sourceCell, sortBy: sortBy.source },
{ title: "Source", className: "source", sortBy: sortBy.source }, { title: "Labels", className: styles.labelsCell },
{ title: "Labels", className: "labels" }, { title: "Status", className: styles.statusCell, sortBy: sortBy.status },
{ title: "Status", className: "status", sortBy: sortBy.status },
]} ]}
renderTableContents={(item: CatalogEntityItem) => [ renderTableContents={(item: CatalogEntityItem) => [
this.renderIcon(item), this.renderIcon(item),

View File

@ -42,4 +42,8 @@
} }
} }
} }
}
.SearchInput {
width: 300px;
}
}

View File

@ -95,7 +95,7 @@ export class CrdList extends React.Component {
sortingCallbacks={sortingCallbacks} sortingCallbacks={sortingCallbacks}
searchFilters={Object.values(sortingCallbacks)} searchFilters={Object.values(sortingCallbacks)}
renderHeaderTitle="Custom Resources" renderHeaderTitle="Custom Resources"
customizeHeader={() => { customizeHeader={({ filters, ...headerPlaceholders }) => {
let placeholder = <>All groups</>; let placeholder = <>All groups</>;
if (selectedGroups.length == 1) placeholder = <>Group: {selectedGroups[0]}</>; if (selectedGroups.length == 1) placeholder = <>Group: {selectedGroups[0]}</>;
@ -104,26 +104,30 @@ export class CrdList extends React.Component {
return { return {
// todo: move to global filters // todo: move to global filters
filters: ( filters: (
<Select <>
className="group-select" {filters}
placeholder={placeholder} <Select
options={Object.keys(crdStore.groups)} className="group-select"
onChange={({ value: group }: SelectOption) => this.toggleSelection(group)} placeholder={placeholder}
closeMenuOnSelect={false} options={Object.keys(crdStore.groups)}
controlShouldRenderValue={false} onChange={({ value: group }: SelectOption) => this.toggleSelection(group)}
formatOptionLabel={({ value: group }: SelectOption) => { closeMenuOnSelect={false}
const isSelected = selectedGroups.includes(group); controlShouldRenderValue={false}
formatOptionLabel={({ value: group }: SelectOption) => {
return ( const isSelected = selectedGroups.includes(group);
<div className="flex gaps align-center">
<Icon small material="folder"/> return (
<span>{group}</span> <div className="flex gaps align-center">
{isSelected && <Icon small material="check" className="box right"/>} <Icon small material="folder"/>
</div> <span>{group}</span>
); {isSelected && <Icon small material="check" className="box right"/>}
}} </div>
/> );
) }}
/>
</>
),
...headerPlaceholders,
}; };
}} }}
renderTableHeader={[ renderTableHeader={[

View File

@ -101,6 +101,13 @@ export class CrdResources extends React.Component<Props> {
(item: KubeObject) => item.getSearchFields(), (item: KubeObject) => item.getSearchFields(),
]} ]}
renderHeaderTitle={crd.getResourceTitle()} renderHeaderTitle={crd.getResourceTitle()}
customizeHeader={({ searchProps, ...headerPlaceholders }) => ({
searchProps: {
...searchProps,
placeholder: `Search ${crd.getResourceTitle()}...`,
},
...headerPlaceholders
})}
renderTableHeader={[ renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
isNamespaced && { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, isNamespaced && { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },

View File

@ -30,7 +30,7 @@ import { EventStore, eventStore } from "./event.store";
import { getDetailsUrl, KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object"; import { getDetailsUrl, KubeObjectListLayout, KubeObjectListLayoutProps } from "../kube-object";
import type { KubeEvent } from "../../api/endpoints/events.api"; import type { KubeEvent } from "../../api/endpoints/events.api";
import type { TableSortCallbacks, TableSortParams, TableProps } from "../table"; import type { TableSortCallbacks, TableSortParams, TableProps } from "../table";
import type { IHeaderPlaceholders } from "../item-object-list"; import type { HeaderCustomizer } from "../item-object-list";
import { Tooltip } from "../tooltip"; import { Tooltip } from "../tooltip";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { cssNames, IClassName, stopPropagation } from "../../utils"; import { cssNames, IClassName, stopPropagation } from "../../utils";
@ -112,19 +112,21 @@ export class Events extends React.Component<Props> {
return this.items; return this.items;
} }
customizeHeader = ({ info, title }: IHeaderPlaceholders) => { customizeHeader: HeaderCustomizer = ({ info, title, ...headerPlaceholders }) => {
const { compact } = this.props; const { compact } = this.props;
const { store, items, visibleItems } = this; const { store, items, visibleItems } = this;
const allEventsAreShown = visibleItems.length === items.length; const allEventsAreShown = visibleItems.length === items.length;
// handle "compact"-mode header // handle "compact"-mode header
if (compact) { if (compact) {
if (allEventsAreShown) return title; // title == "Events" if (allEventsAreShown) {
return { title };
}
return <> return {
{title} title,
<span> ({visibleItems.length} of <Link to={eventsURL()}>{items.length}</Link>)</span> info: <span> ({visibleItems.length} of <Link to={eventsURL()}>{items.length}</Link>)</span>,
</>; };
} }
return { return {
@ -136,7 +138,9 @@ export class Events extends React.Component<Props> {
className="help-icon" className="help-icon"
tooltip={`Limited to ${store.limit}`} tooltip={`Limited to ${store.limit}`}
/> />
</> </>,
title,
...headerPlaceholders
}; };
}; };

View File

@ -26,8 +26,6 @@ import { observer } from "mobx-react";
import { components, PlaceholderProps } from "react-select"; import { components, PlaceholderProps } from "react-select";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { FilterIcon } from "../item-object-list/filter-icon";
import { FilterType } from "../item-object-list/page-filters.store";
import { NamespaceSelect } from "./namespace-select"; import { NamespaceSelect } from "./namespace-select";
import { namespaceStore } from "./namespace.store"; import { namespaceStore } from "./namespace.store";
@ -63,7 +61,7 @@ export class NamespaceSelectFilter extends React.Component<SelectProps> {
return ( return (
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<FilterIcon type={FilterType.NAMESPACE}/> <Icon small material="layers" />
<span>{namespace}</span> <span>{namespace}</span>
{isSelected && <Icon small material="check" className="box right"/>} {isSelected && <Icon small material="check" className="box right"/>}
</div> </div>

View File

@ -19,21 +19,23 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy";
import "./cluster-status.scss"; import "./cluster-status.scss";
import React from "react";
import { observer } from "mobx-react";
import { ipcRenderer } from "electron"; import { ipcRenderer } from "electron";
import { computed, observable, makeObservable } from "mobx"; import { computed, observable, makeObservable } from "mobx";
import { requestMain, subscribeToBroadcast } from "../../../common/ipc"; import { observer } from "mobx-react";
import { Icon } from "../icon"; import React from "react";
import { Button } from "../button";
import { cssNames, IClassName } from "../../utils";
import type { Cluster } from "../../../main/cluster";
import { ClusterId, ClusterStore } from "../../../common/cluster-store";
import { CubeSpinner } from "../spinner";
import { clusterActivateHandler } from "../../../common/cluster-ipc"; import { clusterActivateHandler } from "../../../common/cluster-ipc";
import { ClusterId, ClusterStore } from "../../../common/cluster-store";
import { requestMain, subscribeToBroadcast } from "../../../common/ipc";
import type { Cluster } from "../../../main/cluster";
import { cssNames, IClassName } from "../../utils";
import { Button } from "../button";
import { Icon } from "../icon";
import { CubeSpinner } from "../spinner";
import type { KubeAuthProxyLog } from "../../../main/kube-auth-proxy";
import { navigate } from "../../navigation";
import { entitySettingsURL } from "../+entity-settings";
interface Props { interface Props {
className?: IClassName; className?: IClassName;
@ -82,6 +84,15 @@ export class ClusterStatus extends React.Component<Props> {
this.isReconnecting = false; this.isReconnecting = false;
}; };
manageProxySettings = () => {
navigate(entitySettingsURL({
params: {
entityId: this.props.clusterId,
},
fragment: "http-proxy",
}));
};
renderContent() { renderContent() {
const { authOutput, cluster, hasErrors } = this; const { authOutput, cluster, hasErrors } = this;
const failureReason = cluster.failureReason; const failureReason = cluster.failureReason;
@ -89,7 +100,7 @@ export class ClusterStatus extends React.Component<Props> {
if (!hasErrors || this.isReconnecting) { if (!hasErrors || this.isReconnecting) {
return ( return (
<> <>
<CubeSpinner/> <CubeSpinner />
<pre className="kube-auth-out"> <pre className="kube-auth-out">
<p>{this.isReconnecting ? "Reconnecting..." : "Connecting..."}</p> <p>{this.isReconnecting ? "Reconnecting..." : "Connecting..."}</p>
{authOutput.map(({ data, error }, index) => { {authOutput.map(({ data, error }, index) => {
@ -102,7 +113,7 @@ export class ClusterStatus extends React.Component<Props> {
return ( return (
<> <>
<Icon material="cloud_off" className="error"/> <Icon material="cloud_off" className="error" />
<h2> <h2>
{cluster.preferences.clusterName} {cluster.preferences.clusterName}
</h2> </h2>
@ -121,6 +132,12 @@ export class ClusterStatus extends React.Component<Props> {
onClick={this.reconnect} onClick={this.reconnect}
waiting={this.isReconnecting} waiting={this.isReconnecting}
/> />
<Button
primary
label="Manage Proxy Settings"
className="box center"
onClick={this.manageProxySettings}
/>
</> </>
); );
} }

View File

@ -58,7 +58,7 @@ export class ClusterProxySetting extends React.Component<Props> {
render() { render() {
return ( return (
<> <>
<SubTitle title="HTTP Proxy" /> <SubTitle title="HTTP Proxy" id="http-proxy" />
<Input <Input
theme="round-black" theme="round-black"
value={this.proxy} value={this.proxy}

View File

@ -71,7 +71,7 @@
&:hover { &:hover {
&:not(.active) { &:not(.active) {
box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px #ffffff30; box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px #ffffff50;
} }
} }
} }

View File

@ -62,7 +62,7 @@
height: var(--cellHeight); height: var(--cellHeight);
min-height: var(--cellHeight); min-height: var(--cellHeight);
margin: 12px; margin: 12px;
background: var(--layoutBackground); background: var(--clusterMenuCellBackground);
border-radius: 6px; border-radius: 6px;
position: relative; position: relative;
@ -136,4 +136,4 @@
100% { 100% {
margin-top: 2px; margin-top: 2px;
} }
} }

View File

@ -32,12 +32,12 @@ export const searchUrlParam = createPageParam({
defaultValue: "", defaultValue: "",
}); });
interface Props extends InputProps { export interface SearchInputUrlProps extends InputProps {
compact?: boolean; // show only search-icon when not focused compact?: boolean; // show only search-icon when not focused
} }
@observer @observer
export class SearchInputUrl extends React.Component<Props> { export class SearchInputUrl extends React.Component<SearchInputUrlProps> {
@observable inputVal = ""; // fix: use empty string on init to avoid react warnings @observable inputVal = ""; // fix: use empty string on init to avoid react warnings
@disposeOnUnmount @disposeOnUnmount
@ -62,7 +62,7 @@ export class SearchInputUrl extends React.Component<Props> {
} }
}; };
constructor(props: Props) { constructor(props: SearchInputUrlProps) {
super(props); super(props);
makeObservable(this); makeObservable(this);
} }

View File

@ -31,9 +31,6 @@ export function FilterIcon(props: Props) {
const { type, ...iconProps } = props; const { type, ...iconProps } = props;
switch (type) { switch (type) {
case FilterType.NAMESPACE:
return <Icon small material="layers" {...iconProps}/>;
case FilterType.SEARCH: case FilterType.SEARCH:
return <Icon small material="search" {...iconProps}/>; return <Icon small material="search" {...iconProps}/>;

View File

@ -32,31 +32,30 @@ import { AddRemoveButtons, AddRemoveButtonsProps } from "../add-remove-buttons";
import { NoItems } from "../no-items"; import { NoItems } from "../no-items";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import type { ItemObject, ItemStore } from "../../item.store"; import type { ItemObject, ItemStore } from "../../item.store";
import { SearchInputUrl } from "../input"; import { SearchInputUrlProps, SearchInputUrl } from "../input";
import { Filter, FilterType, pageFilters } from "./page-filters.store"; import { Filter, FilterType, pageFilters } from "./page-filters.store";
import { PageFiltersList } from "./page-filters-list"; import { PageFiltersList } from "./page-filters-list";
import { PageFiltersSelect } from "./page-filters-select";
import { ThemeStore } from "../../theme.store"; import { ThemeStore } from "../../theme.store";
import { MenuActions } from "../menu/menu-actions"; import { MenuActions } from "../menu/menu-actions";
import { MenuItem } from "../menu"; import { MenuItem } from "../menu";
import { Checkbox } from "../checkbox"; import { Checkbox } from "../checkbox";
import { UserStore } from "../../../common/user-store"; import { UserStore } from "../../../common/user-store";
import { namespaceStore } from "../+namespaces/namespace.store"; import { namespaceStore } from "../+namespaces/namespace.store";
import { KubeObjectStore } from "../../kube-object.store";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
// todo: refactor, split to small re-usable components
export type SearchFilter<T extends ItemObject = any> = (item: T) => string | number | (string | number)[]; export type SearchFilter<T extends ItemObject = any> = (item: T) => string | number | (string | number)[];
export type ItemsFilter<T extends ItemObject = any> = (items: T[]) => T[]; export type ItemsFilter<T extends ItemObject = any> = (items: T[]) => T[];
export interface IHeaderPlaceholders { export interface HeaderPlaceholders {
title: ReactNode; title?: ReactNode;
search: ReactNode; searchProps?: SearchInputUrlProps;
filters: ReactNode; filters?: ReactNode;
info: ReactNode; info?: ReactNode;
} }
export type HeaderCustomizer = (placeholders: HeaderPlaceholders) => HeaderPlaceholders;
export interface ItemListLayoutProps<T extends ItemObject = ItemObject> { export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
tableId?: string; tableId?: string;
className: IClassName; className: IClassName;
@ -73,12 +72,11 @@ export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
showHeader?: boolean; showHeader?: boolean;
headerClassName?: IClassName; headerClassName?: IClassName;
renderHeaderTitle?: ReactNode | ((parent: ItemListLayout) => ReactNode); renderHeaderTitle?: ReactNode | ((parent: ItemListLayout) => ReactNode);
customizeHeader?: (placeholders: IHeaderPlaceholders, content: ReactNode) => Partial<IHeaderPlaceholders> | ReactNode; customizeHeader?: HeaderCustomizer | HeaderCustomizer[];
// items list configuration // items list configuration
isReady?: boolean; // show loading indicator while not ready isReady?: boolean; // show loading indicator while not ready
isSelectable?: boolean; // show checkbox in rows for selecting items isSelectable?: boolean; // show checkbox in rows for selecting items
isSearchable?: boolean; // apply search-filter & add search-input
isConfigurable?: boolean; isConfigurable?: boolean;
copyClassNameFromHeadCells?: boolean; copyClassNameFromHeadCells?: boolean;
sortingCallbacks?: { [sortBy: string]: TableSortCallback }; sortingCallbacks?: { [sortBy: string]: TableSortCallback };
@ -102,12 +100,13 @@ export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
const defaultProps: Partial<ItemListLayoutProps> = { const defaultProps: Partial<ItemListLayoutProps> = {
showHeader: true, showHeader: true,
isSearchable: true,
isSelectable: true, isSelectable: true,
isConfigurable: false, isConfigurable: false,
copyClassNameFromHeadCells: true, copyClassNameFromHeadCells: true,
preloadStores: true, preloadStores: true,
dependentStores: [], dependentStores: [],
searchFilters: [],
customizeHeader: [],
filterItems: [], filterItems: [],
hasDetailsView: true, hasDetailsView: true,
onDetails: noop, onDetails: noop,
@ -161,10 +160,10 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
private filterCallbacks: { [type: string]: ItemsFilter } = { private filterCallbacks: { [type: string]: ItemsFilter } = {
[FilterType.SEARCH]: items => { [FilterType.SEARCH]: items => {
const { searchFilters, isSearchable } = this.props; const { searchFilters } = this.props;
const search = pageFilters.getValues(FilterType.SEARCH)[0] || ""; const search = pageFilters.getValues(FilterType.SEARCH)[0] || "";
if (search && isSearchable && searchFilters) { if (search && searchFilters.length) {
const normalizeText = (text: string) => String(text).toLowerCase(); const normalizeText = (text: string) => String(text).toLowerCase();
const searchTexts = [search].map(normalizeText); const searchTexts = [search].map(normalizeText);
@ -179,16 +178,6 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
return items; return items;
}, },
[FilterType.NAMESPACE]: items => {
const filterValues = pageFilters.getValues(FilterType.NAMESPACE);
if (filterValues.length > 0) {
return items.filter(item => filterValues.includes(item.getNs()));
}
return items;
},
}; };
@computed get isReady() { @computed get isReady() {
@ -201,9 +190,9 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
@computed get filters() { @computed get filters() {
let { activeFilters } = pageFilters; let { activeFilters } = pageFilters;
const { isSearchable, searchFilters } = this.props; const { searchFilters } = this.props;
if (!(isSearchable && searchFilters)) { if (searchFilters.length === 0) {
activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH); activeFilters = activeFilters.filter(({ type }) => type !== FilterType.SEARCH);
} }
@ -359,18 +348,22 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
return this.items.map(item => this.getRow(item.getId())); return this.items.map(item => this.getRow(item.getId()));
} }
renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode { renderHeaderContent(placeholders: HeaderPlaceholders): ReactNode {
const { isSearchable, searchFilters } = this.props; const { searchFilters } = this.props;
const { title, filters, search, info } = placeholders; const { title, filters, searchProps, info } = placeholders;
return ( return (
<> <>
{title} {title}
<div className="info-panel box grow"> {
{info} info && (
</div> <div className="info-panel box grow">
{info}
</div>
)
}
{filters} {filters}
{isSearchable && searchFilters && search} {searchFilters.length > 0 && searchProps && <SearchInputUrl {...searchProps} />}
</> </>
); );
} }
@ -396,35 +389,15 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
return null; return null;
} }
const showNamespaceSelectFilter = this.props.store instanceof KubeObjectStore && this.props.store.api.isNamespaced;
const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle; const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle;
const placeholders: IHeaderPlaceholders = { const customizeHeaders = [customizeHeader].flat().filter(Boolean);
const initialPlaceholders: HeaderPlaceholders = {
title: <h5 className="title">{title}</h5>, title: <h5 className="title">{title}</h5>,
info: this.renderInfo(), info: this.renderInfo(),
filters: ( searchProps: {},
<>
{showNamespaceSelectFilter && <NamespaceSelectFilter />}
<PageFiltersSelect allowEmpty disableFilters={{
[FilterType.NAMESPACE]: true, // namespace-select used instead
}} />
</>
),
search: <SearchInputUrl />,
}; };
let header = this.renderHeaderContent(placeholders); const headerPlaceholders = customizeHeaders.reduce((prevPlaceholders, customizer) => customizer(prevPlaceholders), initialPlaceholders);
const header = this.renderHeaderContent(headerPlaceholders);
if (customizeHeader) {
const modifiedHeader = customizeHeader(placeholders, header) ?? {};
if (isReactNode(modifiedHeader)) {
header = modifiedHeader;
} else {
header = this.renderHeaderContent({
...placeholders,
...modifiedHeader as IHeaderPlaceholders,
});
}
}
return ( return (
<div className={cssNames("header flex gaps align-center", headerClassName)}> <div className={cssNames("header flex gaps align-center", headerClassName)}>

View File

@ -1,136 +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 React from "react";
import { observer } from "mobx-react";
import { computed, makeObservable } from "mobx";
import { GroupSelectOption, Select, SelectOption, SelectProps } from "../select";
import { FilterType, pageFilters } from "./page-filters.store";
import { namespaceStore } from "../+namespaces/namespace.store";
import { Icon } from "../icon";
import { FilterIcon } from "./filter-icon";
export interface SelectOptionFilter extends SelectOption {
type: FilterType;
selected?: boolean;
}
interface Props extends SelectProps {
allowEmpty?: boolean;
disableFilters?: {
[filterType: string]: boolean;
};
}
@observer
export class PageFiltersSelect extends React.Component<Props> {
static defaultProps: Props = {
allowEmpty: true,
disableFilters: {},
};
constructor(props: Props) {
super(props);
makeObservable(this);
}
@computed get groupedOptions() {
const options: GroupSelectOption<SelectOptionFilter>[] = [];
const { disableFilters } = this.props;
if (!disableFilters[FilterType.NAMESPACE]) {
const selectedValues = pageFilters.getValues(FilterType.NAMESPACE);
options.push({
label: "Namespace",
options: namespaceStore.items.map(ns => {
const name = ns.getName();
return {
type: FilterType.NAMESPACE,
value: name,
icon: <Icon small material="layers"/>,
selected: selectedValues.includes(name),
};
})
});
}
return options;
}
@computed get options(): SelectOptionFilter[] {
return this.groupedOptions.reduce((options, optGroup) => {
options.push(...optGroup.options);
return options;
}, []);
}
private formatLabel = (option: SelectOptionFilter) => {
const { label, value, type, selected } = option;
return (
<div className="flex gaps">
<FilterIcon type={type}/>
<span>{label || String(value)}</span>
{selected && <Icon small material="check" className="box right"/>}
</div>
);
};
private onSelect = (option: SelectOptionFilter) => {
const { type, value, selected } = option;
const { addFilter, removeFilter } = pageFilters;
const filter = { type, value };
if (!selected) {
addFilter(filter);
}
else {
removeFilter(filter);
}
};
render() {
const { groupedOptions, formatLabel, onSelect, options } = this;
if (!options.length && this.props.allowEmpty) {
return null;
}
const { allowEmpty, disableFilters, ...selectProps } = this.props;
const selectedOptions = options.filter(opt => opt.selected);
return (
<Select
{...selectProps}
placeholder={`Filters (${selectedOptions.length}/${options.length})`}
noOptionsMessage={() => `No filters available.`}
autoConvertOptions={false}
tabSelectsValue={false}
controlShouldRenderValue={false}
options={groupedOptions}
formatOptionLabel={formatLabel}
onChange={onSelect}
/>
);
}
}

View File

@ -25,7 +25,6 @@ import { searchUrlParam } from "../input/search-input-url";
export enum FilterType { export enum FilterType {
SEARCH = "search", SEARCH = "search",
NAMESPACE = "namespace",
} }
export interface Filter { export interface Filter {

View File

@ -30,6 +30,8 @@ import { KubeObjectMenu } from "./kube-object-menu";
import { kubeSelectedUrlParam, showDetails } from "./kube-object-details"; import { kubeSelectedUrlParam, showDetails } from "./kube-object-details";
import { kubeWatchApi } from "../../api/kube-watch-api"; import { kubeWatchApi } from "../../api/kube-watch-api";
import { clusterContext } from "../context"; import { clusterContext } from "../context";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
import { ResourceKindMap, ResourceNames } from "../../utils/rbac";
export interface KubeObjectListLayoutProps extends ItemListLayoutProps { export interface KubeObjectListLayoutProps extends ItemListLayoutProps {
store: KubeObjectStore; store: KubeObjectStore;
@ -66,7 +68,8 @@ export class KubeObjectListLayout extends React.Component<KubeObjectListLayoutPr
} }
render() { render() {
const { className, store, items = store.contextItems, ...layoutProps } = this.props; const { className, customizeHeader, store, items = store.contextItems, ...layoutProps } = this.props;
const placeholderString = ResourceNames[ResourceKindMap[store.api.kind]] || store.api.kind;
return ( return (
<ItemListLayout <ItemListLayout
@ -76,6 +79,22 @@ export class KubeObjectListLayout extends React.Component<KubeObjectListLayoutPr
items={items} items={items}
preloadStores={false} // loading handled in kubeWatchApi.subscribeStores() preloadStores={false} // loading handled in kubeWatchApi.subscribeStores()
detailsItem={this.selectedItem} detailsItem={this.selectedItem}
customizeHeader={[
({ filters, searchProps, ...headerPlaceHolders }) => ({
filters: (
<>
{filters}
{store.api.isNamespaced && <NamespaceSelectFilter />}
</>
),
searchProps: {
...searchProps,
placeholder: `Search ${placeholderString}...`,
},
...headerPlaceHolders,
}),
...[customizeHeader].flat(),
]}
renderItemMenu={(item: KubeObject) => <KubeObjectMenu object={item} />} // safe because we are dealing with KubeObjects here renderItemMenu={(item: KubeObject) => <KubeObjectMenu object={item} />} // safe because we are dealing with KubeObjects here
/> />
); );

View File

@ -49,6 +49,9 @@ export class LensApp extends React.Component {
window.addEventListener("online", () => broadcastMessage("network:online")); window.addEventListener("online", () => broadcastMessage("network:online"));
registerIpcHandlers(); registerIpcHandlers();
}
componentDidMount() {
ipcRenderer.send(IpcRendererNavigationEvents.LOADED); ipcRenderer.send(IpcRendererNavigationEvents.LOADED);
} }
@ -57,11 +60,11 @@ export class LensApp extends React.Component {
<Router history={history}> <Router history={history}>
<ErrorBoundary> <ErrorBoundary>
<Switch> <Switch>
<Route component={ClusterManager}/> <Route component={ClusterManager} />
</Switch> </Switch>
</ErrorBoundary> </ErrorBoundary>
<Notifications/> <Notifications />
<ConfirmDialog/> <ConfirmDialog />
<CommandContainer /> <CommandContainer />
</Router> </Router>
); );

View File

@ -106,6 +106,7 @@
"drawerItemValueColor": "#a0a0a0", "drawerItemValueColor": "#a0a0a0",
"clusterMenuBackground": "#252729", "clusterMenuBackground": "#252729",
"clusterMenuBorderColor": "#252729", "clusterMenuBorderColor": "#252729",
"clusterMenuCellBackground": "#2e3136",
"clusterSettingsBackground": "#1e2124", "clusterSettingsBackground": "#1e2124",
"addClusterIconColor": "#252729", "addClusterIconColor": "#252729",
"boxShadow": "#0000003a", "boxShadow": "#0000003a",

View File

@ -105,8 +105,9 @@
"drawerSubtitleBackground": "#f1f1f1", "drawerSubtitleBackground": "#f1f1f1",
"drawerItemNameColor": "#727272", "drawerItemNameColor": "#727272",
"drawerItemValueColor": "#555555", "drawerItemValueColor": "#555555",
"clusterMenuBackground": "#e8e8e8", "clusterMenuBackground": "#d7d8da",
"clusterMenuBorderColor": "#c9cfd3", "clusterMenuBorderColor": "#c9cfd3",
"clusterMenuCellBackground": "#bbbbbb",
"clusterSettingsBackground": "#ffffff", "clusterSettingsBackground": "#ffffff",
"addClusterIconColor": "#8d8d8d", "addClusterIconColor": "#8d8d8d",
"boxShadow": "#0000003a", "boxShadow": "#0000003a",

View File

@ -19,7 +19,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import type { KubeResource } from "../../common/rbac"; import { apiResourceRecord, KubeResource } from "../../common/rbac";
export const ResourceNames: Record<KubeResource, string> = { export const ResourceNames: Record<KubeResource, string> = {
"namespaces": "Namespaces", "namespaces": "Namespaces",
@ -53,3 +53,8 @@ export const ResourceNames: Record<KubeResource, string> = {
"clusterroles": "Cluster Roles", "clusterroles": "Cluster Roles",
"serviceaccounts": "Service Accounts" "serviceaccounts": "Service Accounts"
}; };
export const ResourceKindMap: Record<string, KubeResource> = Object.fromEntries(
Object.entries(apiResourceRecord)
.map(([resource, { kind }]) => [kind, resource as KubeResource])
);

11
typedoc.json Normal file
View File

@ -0,0 +1,11 @@
{
"readme": "docs/extensions/typedoc-readme.md.tpl",
"name": "@k8slens/extensions",
"out": "docs/extensions/api",
"excludePrivate": true,
"includes": [
"src/"
],
"hideBreadcrumbs": true,
"disableSources": true
}

660
yarn.lock

File diff suppressed because it is too large Load Diff