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

Merge branch 'master' of github.com:lensapp/lens into split-user-management

This commit is contained in:
Sebastian Malton 2021-06-08 14:06:57 -04:00
commit f988ea95b1
92 changed files with 1769 additions and 1596 deletions

View File

@ -0,0 +1,35 @@
name: Publish NPM Package `master`
on:
push:
branches:
- master
jobs:
publish:
name: Publish NPM Package `master`
runs-on: ubuntu-latest
if: |
${{ github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'area/extension') }}
strategy:
matrix:
node-version: [12.x]
steps:
- name: Checkout Release
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Using Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Generate NPM package
run: |
make build-npm
- name: publish new release
run: |
make publish-npm
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_RELEASE_TAG: master

View File

@ -1,11 +1,11 @@
name: Publish NPM
name: Publish NPM Package Release
on:
release:
types:
- published
jobs:
publish:
name: Publish NPM
name: Publish NPM Package Release
runs-on: ubuntu-latest
strategy:
matrix:

View File

@ -3,6 +3,7 @@ CMD_ARGS = $(filter-out $@,$(MAKECMDGOALS))
%:
@:
NPM_RELEASE_TAG ?= latest
EXTENSIONS_DIR = ./extensions
extensions = $(foreach dir, $(wildcard $(EXTENSIONS_DIR)/*), ${dir})
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
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
build-extensions: node_modules $(extension_node_modules) $(extension_dists)
build-extensions: node_modules clean-old-extensions $(extension_node_modules) $(extension_dists)
.PHONY: test-extensions
test-extensions: $(extension_node_modules)
@ -110,7 +115,8 @@ build-extension-types: node_modules src/extensions/npm/extensions/dist
.PHONY: publish-npm
publish-npm: node_modules build-npm
./node_modules/.bin/npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}"
cd src/extensions/npm/extensions && npm publish --access=public
cd src/extensions/npm/extensions && npm publish --access=public --tag=$(NPM_RELEASE_TAG)
git restore src/extensions/npm/extensions/package.json
.PHONY: docs
docs:

View File

@ -22,6 +22,7 @@ import * as fs from "fs";
import * as path from "path";
import appInfo from "../package.json";
import semver from "semver";
import fastGlob from "fast-glob";
const packagePath = path.join(__dirname, "../package.json");
const versionInfo = semver.parse(appInfo.version);
@ -41,3 +42,14 @@ if (versionInfo.prerelease) {
fs.writeFileSync(packagePath, `${JSON.stringify(appInfo, null, 2)}\n`);
const extensionManifests = fastGlob.sync(["extensions/*/package.json"]);
for (const manifestPath of extensionManifests) {
const packagePath = path.join(__dirname, "..", manifestPath);
import(packagePath).then((packageInfo) => {
packageInfo.default.version = `${versionInfo.raw}.${Date.now()}`;
fs.writeFileSync(packagePath, `${JSON.stringify(packageInfo.default, null, 2)}\n`);
});
}

View File

@ -22,8 +22,20 @@ import * as fs from "fs";
import * as path from "path";
import packageInfo from "../src/extensions/npm/extensions/package.json";
import appInfo from "../package.json";
import { SemVer } from "semver";
import { execSync } from "child_process";
const packagePath = path.join(__dirname, "../src/extensions/npm/extensions/package.json");
const { NPM_RELEASE_TAG = "latest" } = process.env;
const version = new SemVer(appInfo.version);
packageInfo.version = appInfo.version;
fs.writeFileSync(packagePath, `${JSON.stringify(packageInfo, null, 2)}\n`);
if (NPM_RELEASE_TAG !== "latest") {
const gitRef = execSync("git rev-parse --short HEAD", {
encoding: "utf-8",
});
version.inc("prerelease", `git.${gitRef.trim()}`);
}
packageInfo.version = version.format();
fs.writeFileSync(path.join(__dirname, "../src/extensions/npm/extensions/package.json"), `${JSON.stringify(packageInfo, null, 2)}\n`);

View File

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

View File

@ -1,6 +1,6 @@
{
"name": "kube-object-event-status",
"version": "0.1.0",
"version": "0.0.1",
"description": "Adds kube object status from events",
"renderer": "dist/renderer.js",
"lens": {

View File

@ -1,6 +1,6 @@
{
"name": "lens-metrics-cluster-feature",
"version": "0.1.0",
"version": "0.0.1",
"description": "Lens metrics cluster feature",
"renderer": "dist/renderer.js",
"lens": {

View File

@ -1,6 +1,6 @@
{
"name": "lens-node-menu",
"version": "0.1.0",
"version": "0.0.1",
"description": "Lens node menu",
"renderer": "dist/renderer.js",
"lens": {

View File

@ -1,6 +1,6 @@
{
"name": "lens-pod-menu",
"version": "0.1.0",
"version": "0.0.1",
"description": "Lens pod menu",
"renderer": "dist/renderer.js",
"lens": {

View File

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

View File

@ -64,6 +64,8 @@ export async function waitForMinikubeDashboard(app: Application) {
await app.client.setValue(".Input.SearchInput input", "minikube");
await app.client.waitUntilTextExists("div.TableCell", "minikube");
await app.client.click("div.TableRow");
await app.client.waitUntilTextExists("div.drawer-title-text", "KubernetesCluster: minikube");
await app.client.click("div.EntityIcon div.HotbarIcon div div.MuiAvatar-root");
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`);
await app.client.frame("minikube");

View File

@ -3,7 +3,7 @@
"productName": "OpenLens",
"description": "OpenLens - Open Source IDE for Kubernetes",
"homepage": "https://github.com/lensapp/lens",
"version": "5.0.0-beta.6",
"version": "5.0.0-beta.7",
"main": "static/build/main.js",
"copyright": "© 2021 OpenLens Authors",
"license": "MIT",
@ -39,8 +39,8 @@
"lint": "yarn run eslint --ext js,ts,tsx --max-warnings=0 .",
"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",
"verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict",
"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",
"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 src/extensions/extension-api.ts",
"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": "yarn run version-checkout && git add package.json && yarn run version-commit",
@ -181,7 +181,7 @@
"dependencies": {
"@hapi/call": "^8.0.1",
"@hapi/subtext": "^7.0.3",
"@kubernetes/client-node": "^0.12.0",
"@kubernetes/client-node": "^0.14.3",
"abort-controller": "^3.0.0",
"array-move": "^3.0.1",
"auto-bind": "^4.0.0",
@ -203,6 +203,7 @@
"handlebars": "^4.7.7",
"http-proxy": "^1.18.1",
"immer": "^8.0.1",
"joi": "^17.4.0",
"js-yaml": "^3.14.0",
"jsdom": "^16.4.0",
"jsonpath": "^1.0.2",
@ -214,7 +215,7 @@
"mobx-observable-history": "^2.0.1",
"mobx-react": "^7.1.0",
"mock-fs": "^4.12.0",
"moment": "^2.26.0",
"moment": "^2.29.1",
"moment-timezone": "^0.5.33",
"node-pty": "^0.9.0",
"npm": "^6.14.8",
@ -273,14 +274,14 @@
"@types/mini-css-extract-plugin": "^0.9.1",
"@types/mock-fs": "^4.10.0",
"@types/module-alias": "^2.0.0",
"@types/node": "^12.12.45",
"@types/node": "12.20",
"@types/npm": "^2.0.31",
"@types/progress-bar-webpack-plugin": "^2.1.0",
"@types/proper-lockfile": "^4.1.1",
"@types/randomcolor": "^0.5.5",
"@types/react": "^17.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-select": "3.1.2",
"@types/react-table": "^7.7.0",
@ -289,7 +290,7 @@
"@types/request": "^2.48.5",
"@types/request-promise-native": "^1.0.17",
"@types/semver": "^7.2.0",
"@types/sharp": "^0.26.0",
"@types/sharp": "^0.28.3",
"@types/shelljs": "^0.8.8",
"@types/spdy": "^3.4.4",
"@types/tar": "^4.0.4",
@ -335,7 +336,7 @@
"jest-mock-extended": "^1.0.10",
"make-plural": "^6.2.2",
"mini-css-extract-plugin": "^1.6.0",
"node-loader": "^0.6.0",
"node-loader": "^1.0.3",
"node-sass": "^4.14.1",
"nodemon": "^2.0.4",
"open": "^7.3.1",
@ -363,8 +364,8 @@
"ts-node": "^8.10.2",
"type-fest": "^1.0.2",
"typed-emitter": "^1.3.1",
"typedoc": "0.17.0-3",
"typedoc-plugin-markdown": "^2.4.0",
"typedoc": "0.21.0-beta.2",
"typedoc-plugin-markdown": "^3.9.0",
"typeface-roboto": "^0.0.75",
"typescript": "^4.3.2",
"typescript-plugin-css-modules": "^3.2.0",

View File

@ -22,8 +22,10 @@
import fs from "fs";
import mockFs from "mock-fs";
import yaml from "js-yaml";
import path from "path";
import fse from "fs-extra";
import { Cluster } from "../../main/cluster";
import { ClusterStore, getClusterIdFromHost } from "../cluster-store";
import { ClusterId, ClusterStore, getClusterIdFromHost } from "../cluster-store";
import { Console } from "console";
import { stdout, stderr } from "process";
@ -54,6 +56,15 @@ users:
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", () => {
return {
app: {
@ -102,7 +113,7 @@ describe("empty config", () => {
icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
clusterName: "minikube"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig)
kubeConfigPath: embed("foo", kubeconfig)
})
);
});
@ -130,7 +141,7 @@ describe("empty config", () => {
preferences: {
clusterName: "prod"
},
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig)
kubeConfigPath: embed("prod", kubeconfig)
}),
new Cluster({
id: "dev",
@ -138,7 +149,7 @@ describe("empty config", () => {
preferences: {
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", () => {
const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig");
const file = embed("boo", "kubeconfig");
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
});
@ -160,6 +171,7 @@ describe("config with existing clusters", () => {
beforeEach(() => {
ClusterStore.resetInstance();
const mockOpts = {
"temp-kube-config": kubeconfig,
"tmp": {
"lens-cluster-store.json": JSON.stringify({
__internal__: {
@ -170,20 +182,20 @@ describe("config with existing clusters", () => {
clusters: [
{
id: "cluster1",
kubeConfigPath: kubeconfig,
kubeConfigPath: "./temp-kube-config",
contextName: "foo",
preferences: { terminalCWD: "/foo" },
workspace: "default"
},
{
id: "cluster2",
kubeConfigPath: kubeconfig,
kubeConfigPath: "./temp-kube-config",
contextName: "foo2",
preferences: { terminalCWD: "/foo2" }
},
{
id: "cluster3",
kubeConfigPath: kubeconfig,
kubeConfigPath: "./temp-kube-config",
contextName: "foo",
preferences: { terminalCWD: "/foo" },
workspace: "foo",
@ -256,6 +268,8 @@ users:
ClusterStore.resetInstance();
const mockOpts = {
"invalid-kube-config": invalidKubeconfig,
"valid-kube-config": kubeconfig,
"tmp": {
"lens-cluster-store.json": JSON.stringify({
__internal__: {
@ -266,14 +280,14 @@ users:
clusters: [
{
id: "cluster1",
kubeConfigPath: invalidKubeconfig,
kubeConfigPath: "./invalid-kube-config",
contextName: "test",
preferences: { terminalCWD: "/foo" },
workspace: "foo",
},
{
id: "cluster2",
kubeConfigPath: kubeconfig,
kubeConfigPath: "./valid-kube-config",
contextName: "foo",
preferences: { terminalCWD: "/foo" },
workspace: "default"

View File

@ -20,7 +20,7 @@
*/
import { KubeConfig } from "@kubernetes/client-node";
import { validateKubeConfig, loadConfig, getNodeWarningConditions } from "../kube-helpers";
import { validateKubeConfig, loadConfigFromString, getNodeWarningConditions } from "../kube-helpers";
const kubeconfig = `
apiVersion: v1
@ -59,8 +59,6 @@ users:
command: foo
`;
const kc = new KubeConfig();
interface kubeconfig {
apiVersion: string,
clusters: [{
@ -88,6 +86,8 @@ let mockKubeConfig: kubeconfig;
describe("kube helpers", () => {
describe("validateKubeconfig", () => {
const kc = new KubeConfig();
beforeAll(() => {
kc.loadFromString(kubeconfig);
});
@ -164,12 +164,12 @@ describe("kube helpers", () => {
it("invalid yaml string", () => {
const invalidYAMLString = "fancy foo config";
expect(() => loadConfig(invalidYAMLString)).toThrowError("must be an object");
expect(loadConfigFromString(invalidYAMLString).error).toBeInstanceOf(Error);
});
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 () => {
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 () => {
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(kc.contexts.length).toBe(2);
expect(config.getCurrentContext()).toBe("minikube");
expect(config.contexts.length).toBe(2);
});
});
@ -243,40 +243,40 @@ describe("kube helpers", () => {
it("empty name in context causes it to be removed", async () => {
mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: "cluster-2"}, name: ""});
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(kc.contexts.length).toBe(1);
expect(config.getCurrentContext()).toBe("minikube");
expect(config.contexts.length).toBe(1);
});
it("empty cluster in context causes it to be removed", async () => {
mockKubeConfig.contexts.push({context: {cluster: "", user: "cluster-2"}, name: "cluster-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(kc.contexts.length).toBe(1);
expect(config.getCurrentContext()).toBe("minikube");
expect(config.contexts.length).toBe(1);
});
it("empty user in context causes it to be removed", async () => {
mockKubeConfig.contexts.push({context: {cluster: "cluster-2", user: ""}, name: "cluster-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(kc.contexts.length).toBe(1);
expect(config.getCurrentContext()).toBe("minikube");
expect(config.contexts.length).toBe(1);
});
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-3", user: "cluster-3"}, name: "cluster-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(kc.contexts.length).toBe(2);
expect(kc.contexts[0].name).toBe("minikube");
expect(kc.contexts[1].name).toBe("cluster-3");
expect(config.getCurrentContext()).toBe("minikube");
expect(config.contexts.length).toBe(2);
expect(config.contexts[0].name).toBe("minikube");
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");
});
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", () => {
const us = UserStore.getInstance();

View File

@ -104,6 +104,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
context.menuItems = [
{
title: "Settings",
icon: "edit",
onlyVisibleForSource: "local",
onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`)
},
@ -112,6 +113,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) {
context.menuItems.push({
title: "Delete",
icon: "delete",
onlyVisibleForSource: "local",
onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid),
confirm: {
@ -123,6 +125,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
if (this.status.phase == "connected") {
context.menuItems.push({
title: "Disconnect",
icon: "link_off",
onClick: async () => {
requestMain(clusterDisconnectHandler, this.metadata.uid);
}
@ -130,6 +133,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
} else {
context.menuItems.push({
title: "Connect",
icon: "link",
onClick: async () => {
context.navigate(`/cluster/${this.metadata.uid}`);
}
@ -147,7 +151,7 @@ export class KubernetesClusterCategory extends CatalogCategory {
public readonly kind = "CatalogCategory";
public metadata = {
name: "Kubernetes Clusters",
icon: require(`!!raw-loader!./icons/kubernetes.svg`).default // eslint-disable-line
icon: require(`!!raw-loader!./icons/kubernetes.svg`).default, // eslint-disable-line
};
public spec: CatalogCategorySpec = {
group: "entity.k8slens.dev",

View File

@ -96,9 +96,25 @@ export interface CatalogEntityActionContext {
}
export interface CatalogEntityContextMenu {
/**
* Menu title
*/
title: string;
onlyVisibleForSource?: string; // show only if empty or if matches with entity source
/**
* Menu icon
*/
icon?: string;
/**
* Show only if empty or if value matches with entity.metadata.source
*/
onlyVisibleForSource?: string;
/**
* OnClick handler
*/
onClick: () => void | Promise<void>;
/**
* Confirm click with a message
*/
confirm?: {
message: string;
}
@ -175,7 +191,6 @@ export abstract class CatalogEntity<
}
public abstract onRun?(context: CatalogEntityActionContext): void | Promise<void>;
public abstract onDetailsOpen(context: CatalogEntityActionContext): void | Promise<void>;
public abstract onContextMenuOpen(context: CatalogEntityContextMenuContext): void | Promise<void>;
public abstract onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise<void>;
}

View File

@ -26,11 +26,9 @@ import { action, comparer, computed, makeObservable, observable, reaction } from
import { BaseStore } from "./base-store";
import { Cluster, ClusterState } from "../main/cluster";
import migrations from "../migrations/cluster-store";
import * as uuid from "uuid";
import logger from "../main/logger";
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 { disposer, noop, toJS } from "./utils";
@ -116,19 +114,10 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
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);
}
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 removedClusters = observable.map<ClusterId, Cluster>();

View File

@ -28,6 +28,8 @@ import logger from "../main/logger";
import commandExists from "command-exists";
import { ExecValidationNotFoundError } from "./custom-errors";
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 = {
validateCluster?: boolean;
@ -37,50 +39,108 @@ export type KubeConfigValidationOpts = {
export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config");
function resolveTilde(filePath: string) {
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
return filePath.replace("~", os.homedir());
}
export function loadConfigFromFileSync(filePath: string): ConfigResult {
const content = fse.readFileSync(resolvePath(filePath), "utf-8");
return filePath;
return loadConfigFromString(content);
}
function readResolvedPathSync(filePath: string): string {
return fse.readFileSync(path.resolve(resolveTilde(filePath)), "utf8");
export async function loadConfigFromFile(filePath: string): Promise<ConfigResult> {
const content = await fse.readFile(resolvePath(filePath), "utf-8");
return loadConfigFromString(content);
}
function checkRawCluster(rawCluster: any): boolean {
return Boolean(rawCluster?.name && rawCluster?.cluster?.server);
}
const clusterSchema = Joi.object({
name: Joi
.string()
.min(1)
.required(),
cluster: Joi
.object({
server: Joi
.string()
.min(1)
.required(),
})
.required(),
});
function checkRawUser(rawUser: any): boolean {
return Boolean(rawUser?.name);
}
const userSchema = Joi.object({
name: Joi.string()
.min(1)
.required(),
});
function checkRawContext(rawContext: any): boolean {
return Boolean(rawContext.name && rawContext.context?.cluster && rawContext.context?.user);
}
const contextSchema = Joi.object({
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 {
clusters: Cluster[];
users: User[];
contexts: Context[];
currentContext: string;
currentContext?: string;
}
function loadToOptions(rawYaml: string): KubeConfigOptions {
const obj = yaml.safeLoad(rawYaml);
export interface OptionsResult {
options: KubeConfigOptions;
error: Joi.ValidationError;
}
if (typeof obj !== "object" || !obj) {
throw new TypeError("KubeConfig root entry must be an object");
}
function loadToOptions(rawYaml: string): OptionsResult {
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;
const clusters = newClusters(rawClusters?.filter(checkRawCluster));
const users = newUsers(rawUsers?.filter(checkRawUser));
const contexts = newContexts(rawContexts?.filter(checkRawContext));
return { clusters, users, contexts, currentContext };
return {
options: { clusters, users, contexts, currentContext },
error,
};
}
export function loadFromOptions(options: KubeConfigOptions): KubeConfig {
@ -92,67 +152,44 @@ export function loadFromOptions(options: KubeConfigOptions): KubeConfig {
return kc;
}
export function loadConfig(pathOrContent?: string): KubeConfig {
return loadConfigFromString(
fse.pathExistsSync(pathOrContent)
? readResolvedPathSync(pathOrContent)
: pathOrContent
);
export interface ConfigResult {
config: KubeConfig;
error: Joi.ValidationError;
}
export function loadConfigFromString(content: string): KubeConfig {
return loadFromOptions(loadToOptions(content));
export function loadConfigFromString(content: string): ConfigResult {
const { options, error } = loadToOptions(content);
return {
config: loadFromOptions(options),
error,
};
}
/**
* KubeConfig is valid when there's at least one of each defined:
* - User
* - 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;
export interface SplitConfigEntry {
config: KubeConfig,
error?: string;
}
/**
* Breaks kube config into several configs. Each context as it own KubeConfig object
*/
export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] {
const configs: KubeConfig[] = [];
export function splitConfig(kubeConfig: KubeConfig): SplitConfigEntry[] {
const { contexts = [] } = kubeConfig;
if (!kubeConfig.contexts) {
return configs;
}
kubeConfig.contexts.forEach(ctx => {
const kc = new KubeConfig();
return contexts.map(context => {
const config = new KubeConfig();
kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n);
kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n);
kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n);
kc.setCurrentContext(ctx.name);
config.clusters = [kubeConfig.getCluster(context.cluster)].filter(Boolean);
config.users = [kubeConfig.getUser(context.user)].filter(Boolean);
config.contexts = [kubeConfig.getContextObject(context.name)].filter(Boolean);
config.setCurrentContext(context.name);
configs.push(kc);
return {
config,
error: validateKubeConfig(config, context.name)?.toString(),
};
});
return configs;
}
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
*/
export function validateKubeConfig(config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}): Error | void {
export function validateKubeConfig(config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}): Error | undefined {
try {
// 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
@ -267,6 +304,8 @@ export function validateKubeConfig(config: KubeConfig, contextName: string, vali
return new ExecValidationNotFoundError(execCommand, isAbsolute);
}
}
return undefined;
} catch (error) {
return error;
}

View File

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

View File

@ -21,7 +21,9 @@
// 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 "./autobind";
@ -39,8 +41,8 @@ export * from "./getRandId";
export * from "./hash-set";
export * from "./n-fircate";
export * from "./openExternal";
export * from "./paths";
export * from "./reject-promise";
export * from "./saveToAppFiles";
export * from "./singleton";
export * from "./splitArray";
export * from "./tar";

View File

@ -18,7 +18,18 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export default {
Trans: ({ children }: { children: React.ReactNode }) => children,
t: (message: string) => message
};
import path from "path";
import os from "os";
function resolveTilde(filePath: string) {
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
return filePath.replace("~", os.homedir());
}
return filePath;
}
export function resolvePath(filePath: string): string {
return path.resolve(resolveTilde(filePath));
}

View File

@ -21,7 +21,6 @@
// App's common configuration for any process (main, renderer, build pipeline, etc.)
import path from "path";
import { SemVer } from "semver";
import packageInfo from "../../package.json";
import { defineGlobal } from "./utils/defineGlobal";
@ -67,10 +66,4 @@ export const apiKubePrefix = "/api-kube" as string; // k8s cluster apis
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as string;
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI" as string;
export const supportUrl = "https://docs.k8slens.dev/latest/support/" as string;
// This explicitly ignores the prerelease info on the package version
const { major, minor, patch } = new SemVer(packageInfo.version);
const mmpVersion = [major, minor, patch].join(".");
const docsVersion = isProduction ? `v${mmpVersion}` : "latest";
export const docsUrl = `https://docs.k8slens.dev/${docsVersion}`;
export const docsUrl = `https://docs.k8slens.dev/main/` as string;

View File

@ -26,7 +26,7 @@ import { action, computed, makeObservable, observable, reaction, when } from "mo
import path from "path";
import { getHostedCluster } from "../common/cluster-store";
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 type { InstalledExtension } from "./extension-discovery";
import { ExtensionsStore } from "./extensions-store";
@ -250,6 +250,7 @@ export class ExtensionLoader extends Singleton {
registries.statusBarRegistry.add(extension.statusBarItems),
registries.commandRegistry.add(extension.commands),
registries.welcomeMenuRegistry.add(extension.welcomeMenus),
registries.catalogEntityDetailRegistry.add(extension.catalogEntityDetailItems),
];
this.events.on("remove", (removedExtension: LensRendererExtension) => {
@ -295,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 => {
for (const [extId, extension] of installedExtensions) {
const alreadyInit = this.instances.has(extId);
@ -310,8 +311,7 @@ export class ExtensionLoader extends Singleton {
const instance = new LensExtensionClass(extension);
instance.whenEnabled(() => register(instance));
instance.enable();
instance.enable(register);
this.instances.set(extId, instance);
} catch (err) {
logger.error(`${logModule}: activation extension error`, { ext: extension, err });

View File

@ -20,11 +20,11 @@
*/
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 logger from "../main/logger";
import type { ProtocolHandlerRegistration } from "./registries";
import { disposer } from "../common/utils";
import { Disposer, disposer } from "../common/utils";
export type LensExtensionId = string; // path to manifest (package.json)
export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension;
@ -83,59 +83,44 @@ export class LensExtension {
}
@action
async enable() {
if (this.isEnabled) return;
this.isEnabled = true;
this.onActivate?.();
logger.info(`[EXTENSION]: enabled ${this.name}@${this.version}`);
async enable(register: (ext: LensExtension) => Promise<Disposer[]>) {
if (this.isEnabled) {
return;
}
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
async disable() {
if (!this.isEnabled) return;
this.isEnabled = false;
this.onDeactivate?.();
this[Disposers]();
logger.info(`[EXTENSION]: disabled ${this.name}@${this.version}`);
}
if (!this.isEnabled) {
return;
}
toggle(enable?: boolean) {
if (typeof enable === "boolean") {
enable ? this.enable() : this.disable();
} else {
this.isEnabled ? this.disable() : this.enable();
this.isEnabled = false;
try {
await this.onDeactivate();
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[]>) {
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 {
protected onActivate(): Promise<void> | void {
return;
}
protected onDeactivate(): void {
protected onDeactivate(): Promise<void> | void {
return;
}
}

View File

@ -20,7 +20,7 @@
*/
import type {
AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration,
AppPreferenceRegistration, CatalogEntityDetailRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration,
KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, WelcomeMenuRegistration, WorkloadsOverviewDetailRegistration,
} from "./registries";
import type { Cluster } from "../main/cluster";
@ -43,6 +43,7 @@ export class LensRendererExtension extends LensExtension {
kubeWorkloadsOverviewItems: WorkloadsOverviewDetailRegistration[] = [];
commands: CommandRegistration[] = [];
welcomeMenus: WelcomeMenuRegistration[] = [];
catalogEntityDetailItems: CatalogEntityDetailRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation");

View File

@ -0,0 +1,46 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type React from "react";
import { BaseRegistry } from "./base-registry";
export interface CatalogEntityDetailComponents {
Details: React.ComponentType<any>;
}
export interface CatalogEntityDetailRegistration {
kind: string;
apiVersions: string[];
components: CatalogEntityDetailComponents;
priority?: number;
}
export class CatalogEntityDetailRegistry extends BaseRegistry<CatalogEntityDetailRegistration> {
getItemsForKind(kind: string, apiVersion: string) {
const items = this.getItems().filter((item) => {
return item.kind === kind && item.apiVersions.includes(apiVersion);
});
return items.sort((a, b) => (b.priority ?? 50) - (a.priority ?? 50));
}
}
export const catalogEntityDetailRegistry = new CatalogEntityDetailRegistry();

View File

@ -33,4 +33,5 @@ export * from "./command-registry";
export * from "./entity-setting-registry";
export * from "./welcome-menu-registry";
export * from "./protocol-handler-registry";
export * from "./catalog-entity-detail-registry";
export * from "./workloads-overview-detail-registry";

View File

@ -29,7 +29,7 @@ import type stream from "stream";
import { Disposer, ExtendedObservableMap, iter, Singleton } from "../../common/utils";
import logger from "../logger";
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 { catalogEntityFromCluster } from "../cluster-manager";
import { UserStore } from "../../common/user-store";
@ -130,18 +130,16 @@ export class KubeconfigSyncManager extends Singleton {
}
// exported for testing
export function configToModels(config: KubeConfig, filePath: string): UpdateClusterModel[] {
export function configToModels(rootConfig: KubeConfig, filePath: string): UpdateClusterModel[] {
const validConfigs = [];
for (const contextConfig of splitConfig(config)) {
const error = validateKubeConfig(contextConfig, contextConfig.currentContext);
for (const { config, error } of splitConfig(rootConfig)) {
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 {
validConfigs.push({
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 {
runInAction(() => {
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]));
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 { Kubectl } from "./kubectl";
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 logger from "./logger";
import { VersionDetector } from "./cluster-detectors/version-detector";
@ -258,14 +258,14 @@ export class Cluster implements ClusterModel, ClusterState {
this.id = model.id;
this.updateModel(model);
const kubeconfig = this.getKubeconfig();
const error = validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
const { config } = loadConfigFromFileSync(this.kubeConfigPath);
const validationError = validateKubeConfig(config, this.contextName);
if (error) {
throw error;
if (validationError) {
throw validationError;
}
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
this.apiUrl = config.getCluster(config.getContextObject(this.contextName).cluster).server;
if (ipcMain) {
// 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();
}
protected getKubeconfig(): KubeConfig {
return loadConfig(this.kubeConfigPath);
async getKubeconfig(): Promise<KubeConfig> {
const { config } = await loadConfigFromFile(this.kubeConfigPath);
return config;
}
/**
* @internal
*/
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) {
if (arg.toLowerCase().startsWith("lens://")) {
try {
lprm.route(arg);
} catch (error) {
logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg });
}
lprm.route(arg);
}
}
}
@ -107,11 +103,7 @@ app.on("second-instance", (event, argv) => {
for (const arg of argv) {
if (arg.toLowerCase().startsWith("lens://")) {
try {
lprm.route(arg);
} catch (error) {
logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg });
}
lprm.route(arg);
}
}
@ -196,7 +188,7 @@ app.on("ready", async () => {
installDeveloperTools();
if (!startHidden) {
windowManager.initMainWindow();
windowManager.ensureMainWindow();
}
ipcMain.on(IpcRendererNavigationEvents.LOADED, () => {
@ -244,7 +236,7 @@ app.on("activate", (event, hasVisibleWindows) => {
logger.info("APP:ACTIVATE", { 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) => {
// lens:// protocol handler
event.preventDefault();
try {
LensProtocolRouterMain.getInstance().route(rawUrl);
} catch (error) {
logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl });
}
LensProtocolRouterMain.getInstance().route(rawUrl);
});
/**

View File

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

View File

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

View File

@ -27,7 +27,7 @@ import { app, remote } from "electron";
import { migration } from "../migration-wrapper";
import fse from "fs-extra";
import { ClusterModel, ClusterStore } from "../../common/cluster-store";
import { loadConfig } from "../../common/kube-helpers";
import { loadConfigFromFileSync } from "../../common/kube-helpers";
export default migration({
version: "3.6.0-beta.1",
@ -46,9 +46,13 @@ export default migration({
* migrate kubeconfig
*/
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
cluster.kubeConfigPath = ClusterStore.embedCustomKubeConfig(cluster.id, cluster.kubeConfig);
cluster.contextName = loadConfig(cluster.kubeConfigPath).getCurrentContext();
cluster.kubeConfigPath = absPath;
cluster.contextName = loadConfigFromFileSync(cluster.kubeConfigPath).config.getCurrentContext();
delete cluster.kubeConfig;
} catch (error) {

View File

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

View File

@ -20,35 +20,48 @@
*/
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 { action, observable, runInAction, makeObservable } from "mobx";
import { KubeConfig } from "@kubernetes/client-node";
import path from "path";
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 { 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 { docsUrl } from "../../../common/vars";
import { catalogURL } from "../+catalog";
import { preferencesURL } from "../+preferences";
import { Input } from "../input";
import { Notifications } from "../notifications";
interface Option {
config: KubeConfig;
error?: string;
}
function getContexts(config: KubeConfig): Map<string, Option> {
return new Map(
splitConfig(config)
.map(({ config, error }) => [config.currentContext, {
config,
error,
}])
);
}
@observer
export class AddCluster extends React.Component {
@observable.ref kubeConfigLocal: KubeConfig;
@observable.ref error: React.ReactNode;
@observable kubeContexts = observable.map<string, Option>();
@observable customConfig = "";
@observable proxyServer = "";
@observable isWaiting = false;
@observable showSettings = false;
kubeContexts = observable.map<string, KubeConfig>();
@observable errorText: string;
constructor(props: {}) {
super(props);
@ -59,159 +72,75 @@ export class AddCluster extends React.Component {
appEventBus.emit({ name: "cluster-add", action: "start" });
}
componentWillUnmount() {
UserStore.getInstance().markNewContextsAsSeen();
@computed get allErrors(): string[] {
return [
this.errorText,
...iter.map(this.kubeContexts.values(), ({ error }) => error)
].filter(Boolean);
}
@action
refreshContexts() {
this.kubeContexts.clear();
refreshContexts = debounce(() => {
const { config, error } = loadConfigFromString(this.customConfig.trim() || "{}");
try {
this.error = "";
const contexts = this.getContexts(loadConfig(this.customConfig || "{}"));
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;
}
this.kubeContexts.replace(getContexts(config));
this.errorText = error?.toString();
}, 500);
@action
addClusters = (): void => {
addClusters = async () => {
this.isWaiting = true;
appEventBus.emit({ name: "cluster-add", action: "click" });
try {
const absPath = ClusterStore.getCustomKubeConfigPath();
this.error = "";
this.isWaiting = true;
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);
await fse.ensureDir(path.dirname(absPath));
await fse.writeFile(absPath, this.customConfig.trim(), { encoding: "utf-8", mode: 0o600 });
if (error) {
this.error = error.toString();
Notifications.ok(`Successfully added ${this.kubeContexts.size} new cluster(s)`);
if (error instanceof ExecValidationNotFoundError) {
Notifications.error(<>Error while adding cluster(s): {this.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;
return navigate(catalogURL());
} catch (error) {
Notifications.error(`Failed to add clusters: ${error}`);
}
};
renderInfo() {
render() {
return (
<p>
Paste kubeconfig as a text from the clipboard to the textarea below.
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>.
Read more about adding clusters <a href={`${docsUrl}/clusters/adding-clusters/`} rel="noreferrer" target="_blank">here</a>.
</p>
);
}
renderKubeConfigSource() {
return (
<>
<PageLayout className="AddClusters" showOnTop={true}>
<h2>Add Clusters from Kubeconfig</h2>
<p>
Clusters added here are <b>not</b> merged into the <code>~/.kube/config</code> file.
Read more about adding clusters <a href={`${docsUrl}/clusters/adding-clusters/`} rel="noreferrer" target="_blank">here</a>.
</p>
<div className="flex column">
<AceEditor
autoFocus
showGutter={false}
mode="yaml"
value={this.customConfig}
wrap={true}
onChange={value => {
this.customConfig = value;
this.errorText = "";
this.refreshContexts();
}}
/>
</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.allErrors.length > 0 && (
<>
<h3>KubeConfig Yaml Validation Errors:</h3>
{...this.allErrors.map(error => <div key={error} className="error">{error}</div>)}
</>
)}
{this.error && (
<div className="error">{this.error}</div>
)}
<div className="actions-panel">
<Button
primary
disabled={submitDisabled}
label={this.kubeContexts.keys.length < 2 ? "Add cluster" : "Add clusters"}
disabled={this.kubeContexts.size === 0}
label={this.kubeContexts.size === 1 ? "Add cluster" : "Add clusters"}
onClick={this.addClusters}
waiting={this.isWaiting}
tooltip={submitDisabled ? "Paste a valid kubeconfig." : undefined}
tooltip={this.kubeContexts.size === 0 || "Paste in at least one cluster to add."}
tooltipOverrideDisabled
/>
</div>

View File

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

View File

@ -34,6 +34,7 @@ import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout";
import { HelmReleaseMenu } from "./release-menu";
import { secretsStore } from "../+config-secrets/secrets.store";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
enum columnId {
name = "name",
@ -116,6 +117,19 @@ export class HelmReleases extends Component<Props> {
(release: HelmRelease) => release.getStatus(),
(release: HelmRelease) => release.getVersion(),
]}
customizeHeader={({ filters, searchProps, ...headerPlaceholders }) => ({
filters: (
<>
{filters}
<NamespaceSelectFilter />
</>
),
searchProps: {
...searchProps,
placeholder: "Search Releases...",
},
...headerPlaceholders,
})}
renderHeaderTitle="Releases"
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },

View File

@ -0,0 +1,43 @@
/**
* 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.
*/
.CatalogEntityDetails {
.EntityMetadata {
margin-right: $margin;
}
.EntityIcon.box.top.left {
margin-right: $margin * 2;
.IconHint {
text-align: center;
font-size: var(--font-size-small);
text-transform: uppercase;
margin-top: $margin;
cursor: default;
user-select: none;
opacity: 0.5;
}
div * {
font-size: 1.5em;
}
}
}

View File

@ -0,0 +1,129 @@
/**
* 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 "./catalog-entity-details.scss";
import React, { Component } from "react";
import { observer } from "mobx-react";
import { Drawer, DrawerItem, DrawerItemLabels } from "../drawer";
import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity";
import type { CatalogCategory } from "../../../common/catalog";
import { Icon } from "../icon";
import { KubeObject } from "../../api/kube-object";
import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu";
import { catalogEntityDetailRegistry } from "../../../extensions/registries";
import { HotbarIcon } from "../hotbar/hotbar-icon";
interface Props {
entity: CatalogEntity;
hideDetails(): void;
}
@observer
export class CatalogEntityDetails extends Component<Props> {
private abortController?: AbortController;
constructor(props: Props) {
super(props);
}
componentWillUnmount() {
this.abortController?.abort();
}
categoryIcon(category: CatalogCategory) {
if (category.metadata.icon.includes("<svg")) {
return <Icon svg={category.metadata.icon} smallest />;
} else {
return <Icon material={category.metadata.icon} smallest />;
}
}
openEntity() {
this.props.entity.onRun(catalogEntityRunContext);
}
renderContent() {
const { entity } = this.props;
const labels = KubeObject.stringifyLabels(entity.metadata.labels);
const detailItems = catalogEntityDetailRegistry.getItemsForKind(entity.kind, entity.apiVersion);
const details = detailItems.map((item, index) => {
return <item.components.Details entity={entity} key={index}/>;
});
const showDetails = detailItems.find((item) => item.priority > 999) === undefined;
return (
<>
{showDetails && (
<div className="flex CatalogEntityDetails">
<div className="EntityIcon box top left">
<HotbarIcon
uid={entity.metadata.uid}
title={entity.metadata.name}
source={entity.metadata.source}
onClick={() => this.openEntity()}
size={128} />
<div className="IconHint">
Click to open
</div>
</div>
<div className="box grow EntityMetadata">
<DrawerItem name="Name">
{entity.metadata.name}
</DrawerItem>
<DrawerItem name="Kind">
{entity.kind}
</DrawerItem>
<DrawerItem name="Source">
{entity.metadata.source}
</DrawerItem>
<DrawerItemLabels
name="Labels"
labels={labels}
/>
</div>
</div>
)}
<div className="box grow">
{details}
</div>
</>
);
}
render() {
const { entity, hideDetails } = this.props;
const title = `${entity.kind}: ${entity.metadata.name}`;
return (
<Drawer
className="CatalogEntityDetails"
usePortal={true}
open={true}
title={title}
toolbar={<CatalogEntityDrawerMenu entity={entity} key={entity.getId()} />}
onClose={hideDetails}
>
{this.renderContent()}
</Drawer>
);
}
}

View File

@ -0,0 +1,127 @@
/**
* 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 { cssNames } from "../../utils";
import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
import { observer } from "mobx-react";
import { makeObservable, observable } from "mobx";
import { navigate } from "../../navigation";
import { MenuItem } from "../menu";
import { ConfirmDialog } from "../confirm-dialog";
import { HotbarStore } from "../../../common/hotbar-store";
import { Icon } from "../icon";
export interface CatalogEntityDrawerMenuProps<T extends CatalogEntity> extends MenuActionsProps {
entity: T | null | undefined;
}
@observer
export class CatalogEntityDrawerMenu<T extends CatalogEntity> extends React.Component<CatalogEntityDrawerMenuProps<T>> {
@observable private contextMenu: CatalogEntityContextMenuContext;
constructor(props: CatalogEntityDrawerMenuProps<T>) {
super(props);
makeObservable(this);
}
componentDidMount() {
this.contextMenu = {
menuItems: [],
navigate: (url: string) => navigate(url)
};
this.props.entity?.onContextMenuOpen(this.contextMenu);
}
onMenuItemClick(menuItem: CatalogEntityContextMenu) {
if (menuItem.confirm) {
ConfirmDialog.open({
okButtonProps: {
primary: false,
accent: true,
},
ok: () => {
menuItem.onClick();
},
message: menuItem.confirm.message
});
} else {
menuItem.onClick();
}
}
addToHotbar(entity: CatalogEntity): void {
HotbarStore.getInstance().addToHotbar(entity);
}
getMenuItems(entity: T): React.ReactChild[] {
if (!entity) {
return [];
}
const menuItems = this.contextMenu.menuItems.filter((menuItem) => {
return menuItem.icon && !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === entity.metadata.source;
});
const items = menuItems.map((menuItem, index) => {
const props = menuItem.icon.includes("<svg") ? { svg: menuItem.icon } : { material: menuItem.icon };
return (
<MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}>
<Icon
title={menuItem.title}
{...props}
/>
</MenuItem>
);
});
items.unshift(
<MenuItem key="add-to-hotbar" onClick={() => this.addToHotbar(entity) }>
<Icon material="playlist_add" small title="Add to Hotbar" />
</MenuItem>
);
items.reverse();
return items;
}
render() {
if (!this.contextMenu) {
return null;
}
const { className, entity, ...menuProps } = this.props;
return (
<MenuActions
className={cssNames("CatalogEntityDrawerMenu", className)}
toolbar
{...menuProps}
>
{this.getMenuItems(entity)}
</MenuActions>
);
}
}

View File

@ -0,0 +1,91 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.iconCell {
max-width: 40px;
display: flex;
align-items: center;
}
.nameCell {
}
.sourceCell {
max-width: 100px;
}
.statusCell {
max-width: 100px;
}
.connected {
color: var(--colorSuccess);
}
.disconnected {
color: var(--halfGray);
}
.labelsCell {
overflow-x: scroll;
text-overflow: unset;
}
.labelsCell::-webkit-scrollbar {
display: none;
}
.badge {
overflow: unset;
text-overflow: unset;
max-width: unset;
}
.badge:not(:first-child) {
margin-left: 0.5em;
}
.catalogIcon {
font-size: 10px;
-webkit-font-smoothing: auto;
}
.tabs {
@apply flex flex-grow flex-col;
}
.tab {
@apply px-8 py-4;
}
.tab:hover {
background-color: var(--sidebarItemHoverBackground);
--color-active: var(--textColorTertiary);
}
.tab::after {
display: none;
}
.activeTab, .activeTab:hover {
background-color: var(--blue);
--color-active: white;
}

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

@ -19,7 +19,8 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./catalog.scss";
import styles from "./catalog.module.css";
import React from "react";
import { disposeOnUnmount, observer } from "mobx-react";
import { ItemListLayout } from "../item-object-list";
@ -27,9 +28,8 @@ import { action, makeObservable, observable, reaction, when } from "mobx";
import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store";
import { navigate } from "../../navigation";
import { kebabCase } from "lodash";
import { PageLayout } from "../layout/page-layout";
import { MenuItem, MenuActions } from "../menu";
import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity";
import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
import { Badge } from "../badge";
import { HotbarStore } from "../../../common/hotbar-store";
import { ConfirmDialog } from "../confirm-dialog";
@ -40,6 +40,13 @@ import type { RouteComponentProps } from "react-router";
import type { ICatalogViewRouteParam } from "./catalog.route";
import { Notifications } from "../notifications";
import { Avatar } from "../avatar/avatar";
import { MainLayout } from "../layout/main-layout";
import { cssNames } from "../../utils";
import { TopBar } from "../layout/topbar";
import { welcomeURL } from "../+welcome";
import { Icon } from "../icon";
import { MaterialTooltip } from "../material-tooltip/material-tooltip";
import { CatalogEntityDetails } from "./catalog-entity-details";
enum sortBy {
name = "name",
@ -55,6 +62,7 @@ export class Catalog extends React.Component<Props> {
@observable private catalogEntityStore?: CatalogEntityStore;
@observable private contextMenu: CatalogEntityContextMenuContext;
@observable activeTab?: string;
@observable selectedItem?: CatalogEntityItem;
constructor(props: Props) {
super(props);
@ -103,7 +111,7 @@ export class Catalog extends React.Component<Props> {
}
onDetails(item: CatalogEntityItem) {
item.onRun(catalogEntityRunContext);
this.selectedItem = item;
}
onMenuItemClick(menuItem: CatalogEntityContextMenu) {
@ -137,14 +145,14 @@ export class Catalog extends React.Component<Props> {
renderNavigation() {
return (
<Tabs className="flex column" scrollable={false} onChange={this.onTabChange} value={this.activeTab}>
<div className="sidebarHeader">Catalog</div>
<div className="sidebarTabs">
<Tabs className={cssNames(styles.tabs, "flex column")} scrollable={false} onChange={this.onTabChange} value={this.activeTab}>
<div>
<Tab
value={undefined}
key="*"
label="Browse"
data-testid="*-tab"
className={cssNames(styles.tab, { [styles.activeTab]: this.activeTab == null })}
/>
{
this.categories.map(category => (
@ -153,6 +161,7 @@ export class Catalog extends React.Component<Props> {
key={category.getId()}
label={category.metadata.name}
data-testid={`${category.getId()}-tab`}
className={cssNames(styles.tab, { [styles.activeTab]: this.activeTab == category.getId() })}
/>
))
}
@ -181,19 +190,13 @@ export class Catalog extends React.Component<Props> {
};
renderIcon(item: CatalogEntityItem) {
const category = catalogCategoryRegistry.getCategoryForEntity(item.entity);
if (!category) {
return null;
}
return (
<Avatar
title={item.name}
colorHash={`${item.name}-${item.source}`}
width={24}
height={24}
className="catalogIcon"
className={styles.catalogIcon}
/>
);
}
@ -202,7 +205,6 @@ export class Catalog extends React.Component<Props> {
return (
<ItemListLayout
renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name ?? "Browse All"}
isSearchable={true}
isSelectable={false}
className="CatalogItemList"
store={this.catalogEntityStore}
@ -216,11 +218,11 @@ export class Catalog extends React.Component<Props> {
(entity: CatalogEntityItem) => entity.searchFields,
]}
renderTableHeader={[
{ title: "", className: "icon" },
{ title: "Name", className: "name", sortBy: sortBy.name },
{ title: "Source", className: "source", sortBy: sortBy.source },
{ title: "Labels", className: "labels" },
{ title: "Status", className: "status", sortBy: sortBy.status },
{ title: "", className: styles.iconCell },
{ title: "Name", className: styles.nameCell, sortBy: sortBy.name },
{ title: "Source", className: styles.sourceCell, sortBy: sortBy.source },
{ title: "Labels", className: styles.labelsCell },
{ title: "Status", className: styles.statusCell, sortBy: sortBy.status },
]}
renderTableContents={(item: CatalogEntityItem) => [
this.renderIcon(item),
@ -239,7 +241,6 @@ export class Catalog extends React.Component<Props> {
return (
<ItemListLayout
renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name ?? "Browse All"}
isSearchable={true}
isSelectable={false}
className="CatalogItemList"
store={this.catalogEntityStore}
@ -254,12 +255,11 @@ export class Catalog extends React.Component<Props> {
(entity: CatalogEntityItem) => entity.searchFields,
]}
renderTableHeader={[
{ title: "", className: "icon" },
{ title: "Name", className: "name", sortBy: sortBy.name },
{ title: "Kind", className: "kind", sortBy: sortBy.kind },
{ title: "Source", className: "source", sortBy: sortBy.source },
{ title: "Labels", className: "labels" },
{ title: "Status", className: "status", sortBy: sortBy.status },
{ title: "", className: styles.iconCell },
{ title: "Name", className: styles.nameCell, sortBy: sortBy.name },
{ title: "Source", className: styles.sourceCell, sortBy: sortBy.source },
{ title: "Labels", className: styles.labelsCell },
{ title: "Status", className: styles.statusCell, sortBy: sortBy.status },
]}
renderTableContents={(item: CatalogEntityItem) => [
this.renderIcon(item),
@ -269,6 +269,7 @@ export class Catalog extends React.Component<Props> {
item.labels.map((label) => <Badge key={label} label={label} title={label} />),
{ title: item.phase, className: kebabCase(item.phase) }
]}
detailsItem={this.selectedItem}
onDetails={(item: CatalogEntityItem) => this.onDetails(item) }
renderItemMenu={this.renderItemMenu}
/>
@ -281,14 +282,29 @@ export class Catalog extends React.Component<Props> {
}
return (
<PageLayout
className="CatalogPage"
navigation={this.renderNavigation()}
provideBackButtonNavigation={false}
contentGaps={false}>
{ this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList() }
<CatalogAddButton category={this.catalogEntityStore.activeCategory} />
</PageLayout>
<>
<TopBar label="Catalog">
<div>
<MaterialTooltip title="Close Catalog" placement="left">
<Icon style={{ cursor: "default" }} material="close" onClick={() => navigate(welcomeURL())}/>
</MaterialTooltip>
</div>
</TopBar>
<MainLayout sidebar={this.renderNavigation()}>
<div className="p-6 h-full">
{ this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList() }
</div>
{ !this.selectedItem && (
<CatalogAddButton category={this.catalogEntityStore.activeCategory} />
)}
{ this.selectedItem && (
<CatalogEntityDetails
entity={this.selectedItem.entity}
hideDetails={() => this.selectedItem = null}
/>
)}
</MainLayout>
</>
);
}
}

View File

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

View File

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

View File

@ -101,6 +101,13 @@ export class CrdResources extends React.Component<Props> {
(item: KubeObject) => item.getSearchFields(),
]}
renderHeaderTitle={crd.getResourceTitle()}
customizeHeader={({ searchProps, ...headerPlaceholders }) => ({
searchProps: {
...searchProps,
placeholder: `Search ${crd.getResourceTitle()}...`,
},
...headerPlaceholders
})}
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
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 type { KubeEvent } from "../../api/endpoints/events.api";
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 { Link } from "react-router-dom";
import { cssNames, IClassName, stopPropagation } from "../../utils";
@ -112,19 +112,21 @@ export class Events extends React.Component<Props> {
return this.items;
}
customizeHeader = ({ info, title }: IHeaderPlaceholders) => {
customizeHeader: HeaderCustomizer = ({ info, title, ...headerPlaceholders }) => {
const { compact } = this.props;
const { store, items, visibleItems } = this;
const allEventsAreShown = visibleItems.length === items.length;
// handle "compact"-mode header
if (compact) {
if (allEventsAreShown) return title; // title == "Events"
if (allEventsAreShown) {
return { title };
}
return <>
{title}
<span> ({visibleItems.length} of <Link to={eventsURL()}>{items.length}</Link>)</span>
</>;
return {
title,
info: <span> ({visibleItems.length} of <Link to={eventsURL()}>{items.length}</Link>)</span>,
};
}
return {
@ -136,7 +138,9 @@ export class Events extends React.Component<Props> {
className="help-icon"
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 { 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 { namespaceStore } from "./namespace.store";
@ -63,7 +61,7 @@ export class NamespaceSelectFilter extends React.Component<SelectProps> {
return (
<div className="flex gaps align-center">
<FilterIcon type={FilterType.NAMESPACE}/>
<Icon small material="layers" />
<span>{namespace}</span>
{isSelected && <Icon small material="check" className="box right"/>}
</div>

View File

@ -22,6 +22,7 @@
.Welcome {
text-align: center;
width: 100%;
height: 100%;
z-index: 1;
.box {

View File

@ -39,7 +39,7 @@
--font-weight-normal: 400;
--font-weight-bold: 500;
--main-layout-header: 40px;
--drag-region-height: 22px
--drag-region-height: 22px;
}
*, *:before, *:after {

View File

@ -70,6 +70,8 @@ import { CommandContainer } from "./command-palette/command-container";
import { KubeObjectStore } from "../kube-object.store";
import { clusterContext } from "./context";
import { namespaceStore } from "./+namespaces/namespace.store";
import { Sidebar } from "./layout/sidebar";
import { Dock } from "./dock";
@observer
export class App extends React.Component {
@ -175,7 +177,7 @@ export class App extends React.Component {
return (
<Router history={history}>
<ErrorBoundary>
<MainLayout>
<MainLayout sidebar={<Sidebar/>} footer={<Dock/>}>
<Switch>
<Route component={ClusterOverview} {...clusterRoute}/>
<Route component={Nodes} {...nodesRoute}/>

View File

@ -32,6 +32,7 @@
grid-area: main;
position: relative;
display: flex;
flex-direction: column;
}
.HotbarMenu {
@ -45,7 +46,7 @@
#lens-views {
position: absolute;
left: 0;
top: 0;
top: var(--main-layout-header); // Move below the TopBar
right: 0;
bottom: 0;
display: flex;

View File

@ -19,21 +19,23 @@
* 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 React from "react";
import { observer } from "mobx-react";
import { ipcRenderer } from "electron";
import { computed, observable, makeObservable } from "mobx";
import { requestMain, subscribeToBroadcast } from "../../../common/ipc";
import { Icon } from "../icon";
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 { observer } from "mobx-react";
import React from "react";
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 {
className?: IClassName;
@ -82,6 +84,15 @@ export class ClusterStatus extends React.Component<Props> {
this.isReconnecting = false;
};
manageProxySettings = () => {
navigate(entitySettingsURL({
params: {
entityId: this.props.clusterId,
},
fragment: "http-proxy",
}));
};
renderContent() {
const { authOutput, cluster, hasErrors } = this;
const failureReason = cluster.failureReason;
@ -89,7 +100,7 @@ export class ClusterStatus extends React.Component<Props> {
if (!hasErrors || this.isReconnecting) {
return (
<>
<CubeSpinner/>
<CubeSpinner />
<pre className="kube-auth-out">
<p>{this.isReconnecting ? "Reconnecting..." : "Connecting..."}</p>
{authOutput.map(({ data, error }, index) => {
@ -102,7 +113,7 @@ export class ClusterStatus extends React.Component<Props> {
return (
<>
<Icon material="cloud_off" className="error"/>
<Icon material="cloud_off" className="error" />
<h2>
{cluster.preferences.clusterName}
</h2>
@ -121,6 +132,12 @@ export class ClusterStatus extends React.Component<Props> {
onClick={this.reconnect}
waiting={this.isReconnecting}
/>
<Button
primary
label="Manage Proxy Settings"
className="box center"
onClick={this.manageProxySettings}
/>
</>
);
}

View File

@ -0,0 +1,44 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import { catalogURL } from "../+catalog";
import type { Cluster } from "../../../main/cluster";
import { navigate } from "../../navigation";
import { Icon } from "../icon";
import { TopBar } from "../layout/topbar";
import { MaterialTooltip } from "../material-tooltip/material-tooltip";
interface Props {
cluster: Cluster
}
export function ClusterTopbar({ cluster }: Props) {
return (
<TopBar label={cluster.name}>
<div>
<MaterialTooltip title="Back to Catalog" placement="left">
<Icon style={{ cursor: "default" }} material="close" onClick={() => navigate(catalogURL())}/>
</MaterialTooltip>
</div>
</TopBar>
);
}

View File

@ -32,13 +32,13 @@ import { clusterActivateHandler } from "../../../common/cluster-ipc";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { navigate } from "../../navigation";
import { catalogURL } from "../+catalog/catalog.route";
import { ClusterTopbar } from "./cluster-topbar";
import type { RouteComponentProps } from "react-router-dom";
import type { IClusterViewRouteParams } from "./cluster-view.route";
interface Props extends RouteComponentProps<IClusterViewRouteParams> {
}
@observer
export class ClusterView extends React.Component<Props> {
private store = ClusterStore.getInstance();
@ -103,7 +103,8 @@ export class ClusterView extends React.Component<Props> {
render() {
return (
<div className="ClusterView flex align-center">
<div className="ClusterView flex column align-center">
{this.cluster && <ClusterTopbar cluster={this.cluster}/>}
{this.renderStatus()}
</div>
);

View File

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

View File

@ -39,6 +39,7 @@ interface Props extends DOMAttributes<HTMLElement> {
errorClass?: IClassName;
add: (item: CatalogEntity, index: number) => void;
remove: (uid: string) => void;
size?: number;
}
@observer

View File

@ -71,7 +71,7 @@
&:hover {
&: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

@ -31,7 +31,7 @@ import { MaterialTooltip } from "../material-tooltip/material-tooltip";
import { observer } from "mobx-react";
import { Avatar } from "../avatar/avatar";
interface Props extends DOMAttributes<HTMLElement> {
export interface HotbarIconProps extends DOMAttributes<HTMLElement> {
uid: string;
title: string;
source: string;
@ -40,6 +40,7 @@ interface Props extends DOMAttributes<HTMLElement> {
active?: boolean;
menuItems?: CatalogEntityContextMenu[];
disabled?: boolean;
size?: number;
}
function onMenuItemClick(menuItem: CatalogEntityContextMenu) {
@ -59,7 +60,7 @@ function onMenuItemClick(menuItem: CatalogEntityContextMenu) {
}
}
export const HotbarIcon = observer(({menuItems = [], ...props}: Props) => {
export const HotbarIcon = observer(({menuItems = [], size = 40, ...props}: HotbarIconProps) => {
const { uid, title, active, className, source, disabled, onMenuOpen, children, ...rest } = props;
const id = `hotbarIcon-${uid}`;
const [menuOpen, setMenuOpen] = useState(false);
@ -77,8 +78,8 @@ export const HotbarIcon = observer(({menuItems = [], ...props}: Props) => {
title={title}
colorHash={`${title}-${source}`}
className={active ? "active" : "default"}
width={40}
height={40}
width={size}
height={size}
/>
{children}
</div>

View File

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

View File

@ -157,6 +157,7 @@ export class HotbarMenu extends React.Component<Props> {
className={cssNames({ isDragging: snapshot.isDragging })}
remove={this.removeItem}
add={this.addItem}
size={40}
/>
) : (
<HotbarIcon
@ -165,6 +166,7 @@ export class HotbarMenu extends React.Component<Props> {
source={item.entity.source}
menuItems={disabledMenuItems}
disabled
size={40}
/>
)}
</div>

View File

@ -135,7 +135,7 @@
&.interactive {
cursor: pointer;
transition: 250ms color, 250ms opacity, 150ms background-color, 150ms box-shadow;
border-radius: 50%;
border-radius: var(--border-radius);
&.focusable:focus:not(:hover) {
box-shadow: 0 0 0 2px var(--focus-color);

View File

@ -32,12 +32,12 @@ export const searchUrlParam = createPageParam({
defaultValue: "",
});
interface Props extends InputProps {
export interface SearchInputUrlProps extends InputProps {
compact?: boolean; // show only search-icon when not focused
}
@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
@disposeOnUnmount
@ -62,7 +62,7 @@ export class SearchInputUrl extends React.Component<Props> {
}
};
constructor(props: Props) {
constructor(props: SearchInputUrlProps) {
super(props);
makeObservable(this);
}

View File

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

View File

@ -28,7 +28,7 @@
padding: var(--flex-gap);
.title {
color: $textColorPrimary;
color: var(--textColorTertiary);
}
.info-panel {

View File

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

View File

@ -30,6 +30,8 @@ import { KubeObjectMenu } from "./kube-object-menu";
import { kubeSelectedUrlParam, showDetails } from "./kube-object-details";
import { kubeWatchApi } from "../../api/kube-watch-api";
import { clusterContext } from "../context";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
import { ResourceKindMap, ResourceNames } from "../../utils/rbac";
export interface KubeObjectListLayoutProps extends ItemListLayoutProps {
store: KubeObjectStore;
@ -66,7 +68,8 @@ export class KubeObjectListLayout extends React.Component<KubeObjectListLayoutPr
}
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 (
<ItemListLayout
@ -76,6 +79,22 @@ export class KubeObjectListLayout extends React.Component<KubeObjectListLayoutPr
items={items}
preloadStores={false} // loading handled in kubeWatchApi.subscribeStores()
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
/>
);

View File

@ -1,105 +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.
*/
jest.mock("../../../../common/ipc");
import React from "react";
import { render } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import { MainLayoutHeader } from "../main-layout-header";
import { Cluster } from "../../../../main/cluster";
import { ClusterStore } from "../../../../common/cluster-store";
import mockFs from "mock-fs";
import { ThemeStore } from "../../../theme.store";
import { UserStore } from "../../../../common/user-store";
jest.mock("electron", () => {
return {
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en",
setLoginItemSettings: jest.fn(),
},
};
});
describe("<MainLayoutHeader />", () => {
let cluster: Cluster;
beforeEach(() => {
const mockOpts = {
"minikube-config.yml": JSON.stringify({
apiVersion: "v1",
clusters: [{
name: "minikube",
cluster: {
server: "https://192.168.64.3:8443",
},
}],
contexts: [{
context: {
cluster: "minikube",
user: "minikube",
},
name: "minikube",
}],
users: [{
name: "minikube",
}],
kind: "Config",
preferences: {},
})
};
mockFs(mockOpts);
UserStore.createInstance();
ThemeStore.createInstance();
ClusterStore.createInstance();
cluster = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
});
});
afterEach(() => {
ClusterStore.resetInstance();
ThemeStore.resetInstance();
UserStore.resetInstance();
mockFs.restore();
});
it("renders w/o errors", () => {
const { container } = render(<MainLayoutHeader cluster={cluster} />);
expect(container).toBeInstanceOf(HTMLElement);
});
it("renders cluster name", () => {
const { getByText } = render(<MainLayoutHeader cluster={cluster} />);
expect(getByText("minikube")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,50 @@
/**
* 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.
*/
.mainLayout {
display: grid;
grid-template-areas:
"sidebar contents"
"sidebar footer";
grid-template-rows: [contents] 1fr [footer] auto;
grid-template-columns: [sidebar] var(--sidebar-width) [contents] 1fr;
width: 100%;
z-index: 1;
height: calc(100% - var(--main-layout-header));
}
.sidebar {
grid-area: sidebar;
display: flex;
position: relative;
background: var(--sidebarBackground);
}
.contents {
grid-area: contents;
overflow: auto;
}
.footer {
position: relative;
grid-area: footer;
min-width: 0; /* restrict size when overflow content (e.g. <Dock> tabs scrolling) */
}

View File

@ -1,76 +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.
*/
.MainLayout {
display: grid;
grid-template-areas:
"aside header"
"aside tabs"
"aside main"
"aside footer";
grid-template-rows: [header] var(--main-layout-header) [tabs] min-content [main] 1fr [footer] auto;
grid-template-columns: [sidebar] minmax(var(--main-layout-header), min-content) [main] 1fr;
height: 100%;
> header {
grid-area: header;
background: $layoutBackground;
padding: $padding $padding * 2;
}
> aside {
grid-area: aside;
position: relative;
background: $sidebarBackground;
white-space: nowrap;
transition: width 150ms cubic-bezier(0.4, 0, 0.2, 1);
width: var(--sidebar-width);
&.compact {
position: absolute;
width: var(--main-layout-header);
height: 100%;
overflow: hidden;
&:hover {
width: var(--sidebar-width);
transition-delay: 750ms;
box-shadow: 3px 3px 16px rgba(0, 0, 0, 0.35);
z-index: $zIndex-sidebar-hover;
}
}
}
> main {
display: contents;
> * {
grid-area: main;
overflow: auto;
}
}
footer {
position: relative;
grid-area: footer;
min-width: 0; // restrict size when overflow content (e.g. <Dock> tabs scrolling)
}
}

View File

@ -19,73 +19,53 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./main-layout.scss";
import styles from "./main-layout.module.css";
import React from "react";
import { observer } from "mobx-react";
import { getHostedCluster } from "../../../common/cluster-store";
import { cssNames } from "../../utils";
import { Dock } from "../dock";
import { ErrorBoundary } from "../error-boundary";
import { ResizeDirection, ResizeGrowthDirection, ResizeSide, ResizingAnchor } from "../resizing-anchor";
import { MainLayoutHeader } from "./main-layout-header";
import { Sidebar } from "./sidebar";
import { sidebarStorage } from "./sidebar-storage";
export interface MainLayoutProps {
className?: any;
interface Props {
sidebar: React.ReactNode;
className?: string;
footer?: React.ReactNode;
headerClass?: string;
footerClass?: string;
}
@observer
export class MainLayout extends React.Component<MainLayoutProps> {
onSidebarCompactModeChange = () => {
sidebarStorage.merge(draft => {
draft.compact = !draft.compact;
});
};
export class MainLayout extends React.Component<Props> {
onSidebarResize = (width: number) => {
sidebarStorage.merge({ width });
};
render() {
const cluster = getHostedCluster();
const { onSidebarCompactModeChange, onSidebarResize } = this;
const { className, headerClass, footer, footerClass, children } = this.props;
const { compact, width: sidebarWidth } = sidebarStorage.get();
const { onSidebarResize } = this;
const { className, footer, children, sidebar } = this.props;
const { width: sidebarWidth } = sidebarStorage.get();
const style = { "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties;
if (!cluster) {
return null; // fix: skip render when removing active (visible) cluster
}
return (
<div className={cssNames("MainLayout", className)} style={style}>
<MainLayoutHeader className={headerClass} cluster={cluster}/>
<aside className={cssNames("flex column", { compact })}>
<Sidebar className="box grow" compact={compact} toggle={onSidebarCompactModeChange}/>
<div className={cssNames(styles.mainLayout, className)} style={style}>
<div className={styles.sidebar}>
{sidebar}
<ResizingAnchor
direction={ResizeDirection.HORIZONTAL}
placement={ResizeSide.TRAILING}
growthDirection={ResizeGrowthDirection.LEFT_TO_RIGHT}
getCurrentExtent={() => sidebarWidth}
onDrag={onSidebarResize}
onDoubleClick={onSidebarCompactModeChange}
disabled={compact}
minExtent={120}
maxExtent={400}
/>
</aside>
</div>
<main>
<div className={styles.contents}>
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</div>
<footer className={footerClass}>{footer ?? <Dock/>}</footer>
<div className={styles.footer}>{footer}</div>
</div>
);
}

View File

@ -62,10 +62,6 @@ export class SidebarItem extends React.Component<SidebarItemProps> {
return this.props.id;
}
@computed get compact(): boolean {
return Boolean(sidebarStorage.get().compact);
}
@computed get expanded(): boolean {
return Boolean(sidebarStorage.get().expanded[this.id]);
}
@ -78,8 +74,6 @@ export class SidebarItem extends React.Component<SidebarItemProps> {
}
@computed get isExpandable(): boolean {
if (this.compact) return false; // not available in compact-mode currently
return Boolean(this.props.children);
}
@ -108,10 +102,8 @@ export class SidebarItem extends React.Component<SidebarItemProps> {
if (isHidden) return null;
const { isActive, id, compact, expanded, isExpandable, toggleExpand } = this;
const classNames = cssNames(SidebarItem.displayName, className, {
compact,
});
const { isActive, id, expanded, isExpandable, toggleExpand } = this;
const classNames = cssNames(SidebarItem.displayName, className);
return (
<div className={classNames} data-test-id={id}>

View File

@ -23,14 +23,12 @@ import { createStorage } from "../../utils";
export interface SidebarStorageState {
width: number;
compact: boolean;
expanded: {
[itemId: string]: boolean;
}
}
export const sidebarStorage = createStorage<SidebarStorageState>("sidebar", {
width: 200, // sidebar size in non-compact mode
compact: false, // compact-mode (icons only)
width: 200,
expanded: {},
});

View File

@ -23,49 +23,9 @@
$iconSize: 24px;
$itemSpacing: floor($unit / 2.6) floor($unit / 1.6);
&.compact {
.sidebar-nav {
@include hidden-scrollbar; // fix: scrollbar overlaps icons
}
}
.header {
background: $sidebarLogoBackground;
padding: $padding / 2;
height: var(--main-layout-header);
a {
font-size: 18.5px;
text-decoration: none;
}
div.logo-text {
position: absolute;
left: 42px;
top: 11px;
}
.logo-icon {
width: 28px;
height: 28px;
margin-left: 2px;
margin-top: 2px;
margin-right: 10px;
svg {
--size: 28px;
padding: 2px;
}
}
.pin-icon {
margin: auto;
margin-right: $padding / 2;
}
}
.sidebar-nav {
padding: $padding / 1.5 0;
width: var(--sidebar-width);
padding-bottom: calc(var(--padding) * 3);
overflow: auto;
.Icon {

View File

@ -24,7 +24,6 @@ import type { TabLayoutRoute } from "./tab-layout";
import React from "react";
import { observer } from "mobx-react";
import { NavLink } from "react-router-dom";
import { cssNames } from "../../utils";
import { Icon } from "../icon";
import { workloadsRoute, workloadsURL } from "../+workloads/workloads.route";
@ -52,8 +51,6 @@ import { SidebarItem } from "./sidebar-item";
interface Props {
className?: string;
compact?: boolean; // compact-mode view: show only icons and expand on :hover
toggle(): void; // compact-mode updater
}
@observer
@ -173,24 +170,11 @@ export class Sidebar extends React.Component<Props> {
}
render() {
const { toggle, compact, className } = this.props;
const { className } = this.props;
return (
<div className={cssNames(Sidebar.displayName, "flex column", { compact }, className)}>
<div className="header flex align-center">
<NavLink exact to="/" className="box grow">
<Icon svg="logo-lens" className="logo-icon"/>
<div className="logo-text">Lens</div>
</NavLink>
<Icon
focusable={false}
className="pin-icon"
tooltip="Compact view"
material={compact ? "keyboard_arrow_right" : "keyboard_arrow_left"}
onClick={toggle}
/>
</div>
<div className={cssNames("sidebar-nav flex column box grow-fixed", { compact })}>
<div className={cssNames(Sidebar.displayName, "flex column", className)}>
<div className={cssNames("sidebar-nav flex column box grow-fixed")}>
<SidebarItem
id="cluster"
text="Cluster"

View File

@ -21,18 +21,19 @@
.TabLayout {
display: contents;
display: flex;
flex-direction: column;
height: 100%;
> .Tabs {
grid-area: tabs;
background: $layoutTabsBackground;
min-height: 32px;
}
main {
$spacing: $margin * 2;
grid-area: main;
flex-grow: 1;
overflow-y: scroll; // always reserve space for scrollbar (17px)
overflow-x: auto;
margin: $spacing;

View File

@ -19,17 +19,27 @@
* 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 { app, remote } from "electron";
import { ensureDirSync, writeFileSync } from "fs-extra";
import type { WriteFileOptions } from "fs";
export function saveToAppFiles(filePath: string, contents: any, options?: WriteFileOptions): string {
const absPath = path.resolve((app || remote.app).getPath("userData"), filePath);
ensureDirSync(path.dirname(absPath));
writeFileSync(absPath, contents, options);
return absPath;
.topBar {
display: grid;
grid-template-columns: [title] 1fr [controls] auto;
grid-template-rows: var(--main-layout-header);
grid-template-areas: "title controls";
background-color: var(--layoutBackground);
z-index: 1;
width: 100%;
}
.title {
@apply font-bold px-6;
color: var(--textColorAccent);
align-items: center;
display: flex;
}
.controls {
align-self: flex-end;
padding-right: 1.5rem;
align-items: center;
display: flex;
height: 100%;
}

View File

@ -19,20 +19,19 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import styles from "./topbar.module.css";
import React from "react";
import { observer } from "mobx-react";
import type { Cluster } from "../../../main/cluster";
import { cssNames } from "../../utils";
interface Props {
cluster: Cluster
className?: string
interface Props extends React.HTMLAttributes<any> {
label: React.ReactNode;
}
export const MainLayoutHeader = observer(({ cluster, className }: Props) => {
export const TopBar = observer(({ label, children, ...rest }: Props) => {
return (
<header className={cssNames("flex gaps align-center justify-space-between", className)}>
<span className="cluster">{cluster.name}</span>
</header>
<div className={styles.topBar} {...rest}>
<div className={styles.title}>{label}</div>
<div className={styles.controls}>{children}</div>
</div>
);
});

View File

@ -31,6 +31,23 @@ body.resizing {
position: absolute;
z-index: 10;
&::after {
content: " ";
display: block;
width: 3px;
height: 100%;
margin-left: 50%;
background: transparent;
transition: all 0.2s 0s;
}
&:hover {
&::after {
background: var(--blue);
transition: all 0.2s 0.5s;
}
}
&.disabled {
display: none;
}
@ -56,6 +73,17 @@ body.resizing {
cursor: col-resize;
width: $dimension;
// Expand hoverable area while resizing to keep highlighting resizer.
// Otherwise, cursor can move far away dropping hover indicator.
.resizing & {
$expandedWidth: 200px;
width: $expandedWidth;
&.trailing {
right: -$expandedWidth / 2;
}
}
&.leading {
left: -$dimension / 2;
}

View File

@ -27,7 +27,7 @@ import { KubeObject, KubeStatus } from "./api/kube-object";
import type { IKubeWatchEvent } from "./api/kube-watch-api";
import { ItemStore } from "./item.store";
import { apiManager } from "./api/api-manager";
import { IKubeApiQueryParams, KubeApi, parseKubeApi } from "./api/kube-api";
import { ensureObjectSelfLink, IKubeApiQueryParams, KubeApi, parseKubeApi } from "./api/kube-api";
import type { KubeJsonApiData } from "./api/kube-json-api";
import { Notifications } from "./components/notifications";
@ -280,6 +280,9 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
async update(item: T, data: Partial<T>): Promise<T> {
const newItem = await item.update<T>(data);
ensureObjectSelfLink(this.api, newItem);
const index = this.items.findIndex(item => item.getId() === newItem.getId());
this.items.splice(index, 1, newItem);

View File

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

View File

@ -26,6 +26,7 @@
"sidebarLogoBackground": "#414448",
"sidebarActiveColor": "#ffffff",
"sidebarSubmenuActiveColor": "#ffffff",
"sidebarItemHoverBackground": "#3a3e44",
"buttonPrimaryBackground": "#3d90ce",
"buttonDefaultBackground": "#414448",
"buttonLightBackground": "#f1f1f1",
@ -105,11 +106,12 @@
"drawerItemValueColor": "#a0a0a0",
"clusterMenuBackground": "#252729",
"clusterMenuBorderColor": "#252729",
"clusterMenuCellBackground": "#2e3136",
"clusterSettingsBackground": "#1e2124",
"addClusterIconColor": "#252729",
"boxShadow": "#0000003a",
"iconActiveColor": "#ffffff",
"iconActiveBackground": "#ffffff22",
"iconActiveBackground": "#ffffff18",
"filterAreaBackground": "#23272b",
"chartLiveBarBackgound": "#00000033",
"chartStripesColor": "#ffffff08",

View File

@ -27,6 +27,7 @@
"sidebarActiveColor": "#ffffff",
"sidebarSubmenuActiveColor": "#3d90ce",
"sidebarBackground": "#e8e8e8",
"sidebarItemHoverBackground": "#f0f2f5",
"buttonPrimaryBackground": "#3d90ce",
"buttonDefaultBackground": "#414448",
"buttonLightBackground": "#f1f1f1",
@ -104,8 +105,9 @@
"drawerSubtitleBackground": "#f1f1f1",
"drawerItemNameColor": "#727272",
"drawerItemValueColor": "#555555",
"clusterMenuBackground": "#e8e8e8",
"clusterMenuBackground": "#d7d8da",
"clusterMenuBorderColor": "#c9cfd3",
"clusterMenuCellBackground": "#bbbbbb",
"clusterSettingsBackground": "#ffffff",
"addClusterIconColor": "#8d8d8d",
"boxShadow": "#0000003a",

View File

@ -19,7 +19,7 @@
* 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> = {
"namespaces": "Namespaces",
@ -53,3 +53,8 @@ export const ResourceNames: Record<KubeResource, string> = {
"clusterroles": "Cluster Roles",
"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
}

640
yarn.lock

File diff suppressed because it is too large Load Diff