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:
commit
f988ea95b1
35
.github/workflows/publish-master-npm.yml
vendored
Normal file
35
.github/workflows/publish-master-npm.yml
vendored
Normal 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
|
||||
@ -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:
|
||||
10
Makefile
10
Makefile
@ -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:
|
||||
|
||||
@ -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`);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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`);
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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"],
|
||||
|
||||
101
extensions/pod-menu/src/attach-menu.tsx
Normal file
101
extensions/pod-menu/src/attach-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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");
|
||||
|
||||
23
package.json
23
package.json
@ -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",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>;
|
||||
}
|
||||
|
||||
@ -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>();
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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));
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
46
src/extensions/registries/catalog-entity-detail-registry.ts
Normal file
46
src/extensions/registries/catalog-entity-detail-registry.ts
Normal 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();
|
||||
@ -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";
|
||||
|
||||
@ -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 });
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@ -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: [
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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 },
|
||||
|
||||
43
src/renderer/components/+catalog/catalog-entity-details.scss
Normal file
43
src/renderer/components/+catalog/catalog-entity-details.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
129
src/renderer/components/+catalog/catalog-entity-details.tsx
Normal file
129
src/renderer/components/+catalog/catalog-entity-details.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
127
src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx
Normal file
127
src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
91
src/renderer/components/+catalog/catalog.module.css
Normal file
91
src/renderer/components/+catalog/catalog.module.css
Normal file
@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
.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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,4 +42,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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={[
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -22,6 +22,7 @@
|
||||
.Welcome {
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
|
||||
.box {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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}/>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
44
src/renderer/components/cluster-manager/cluster-topbar.tsx
Normal file
44
src/renderer/components/cluster-manager/cluster-topbar.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -39,6 +39,7 @@ interface Props extends DOMAttributes<HTMLElement> {
|
||||
errorClass?: IClassName;
|
||||
add: (item: CatalogEntity, index: number) => void;
|
||||
remove: (uid: string) => void;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
@observer
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}/>;
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@
|
||||
padding: var(--flex-gap);
|
||||
|
||||
.title {
|
||||
color: $textColorPrimary;
|
||||
color: var(--textColorTertiary);
|
||||
}
|
||||
|
||||
.info-panel {
|
||||
|
||||
@ -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)}>
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -25,7 +25,6 @@ import { searchUrlParam } from "../input/search-input-url";
|
||||
|
||||
export enum FilterType {
|
||||
SEARCH = "search",
|
||||
NAMESPACE = "namespace",
|
||||
}
|
||||
|
||||
export interface Filter {
|
||||
|
||||
@ -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
|
||||
/>
|
||||
);
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
50
src/renderer/components/layout/main-layout.module.css
Normal file
50
src/renderer/components/layout/main-layout.module.css
Normal 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) */
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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: {},
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
11
typedoc.json
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user