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

Merge branch 'master' into feature/customizable_shell_path

This commit is contained in:
Lauri Nevala 2021-03-06 09:05:09 +02:00 committed by GitHub
commit 34f8c00e0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
145 changed files with 2059 additions and 468 deletions

View File

@ -9,7 +9,7 @@ else
DETECTED_OS := $(shell uname) DETECTED_OS := $(shell uname)
endif endif
binaries/client: binaries/client: node_modules
yarn download-bins yarn download-bins
node_modules: yarn.lock node_modules: yarn.lock
@ -37,17 +37,24 @@ test: binaries/client
yarn test yarn test
.PHONY: integration-linux .PHONY: integration-linux
integration-linux: build-extension-types build-extensions integration-linux: binaries/client build-extension-types build-extensions
# ifdef XDF_CONFIG_HOME
# rm -rf ${XDG_CONFIG_HOME}/.config/Lens
# else
# rm -rf ${HOME}/.config/Lens
# endif
yarn build:linux yarn build:linux
yarn integration yarn integration
.PHONY: integration-mac .PHONY: integration-mac
integration-mac: build-extension-types build-extensions integration-mac: binaries/client build-extension-types build-extensions
# rm ${HOME}/Library/Application\ Support/Lens
yarn build:mac yarn build:mac
yarn integration yarn integration
.PHONY: integration-win .PHONY: integration-win
integration-win: build-extension-types build-extensions integration-win: binaries/client build-extension-types build-extensions
# rm %APPDATA%/Lens
yarn build:win yarn build:win
yarn integration yarn integration

View File

@ -21,12 +21,13 @@ Each guide or code sample includes the following:
| [Components](components.md) | | | [Components](components.md) | |
| [KubeObjectListLayout](kube-object-list-layout.md) | | | [KubeObjectListLayout](kube-object-list-layout.md) | |
| [Working with mobx](working-with-mobx.md) | | | [Working with mobx](working-with-mobx.md) | |
| [Protocol Handlers](protocol-handlers.md) | |
## Samples ## Samples
| Sample | APIs | | Sample | APIs |
| ----- | ----- | | ----- | ----- |
[helloworld](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |
[minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension <br> Store.clusterStore <br> Store.workspaceStore | [minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension <br> Store.clusterStore <br> Store.workspaceStore |
[styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |
[styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps |

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,83 @@
# Lens Protocol Handlers
Lens has a file association with the `lens://` protocol.
This means that Lens can be opened by external programs by providing a link that has `lens` as its protocol.
Lens provides a routing mechanism that extensions can use to register custom handlers.
## Registering A Protocol Handler
The field `protocolHandlers` exists both on [`LensMainExtension`](extensions/api/classes/lensmainextension/#protocolhandlers) and on [`LensRendererExtension`](extensions/api/classes/lensrendererextension/#protocolhandlers).
This field will be iterated through every time a `lens://` request gets sent to the application.
The `pathSchema` argument must comply with the [path-to-regexp](https://www.npmjs.com/package/path-to-regexp) package's `compileToRegex` function.
Once you have registered a handler it will be called when a user opens a link on their computer.
Handlers will be run in both `main` and `renderer` in parallel with no synchronization between the two processes.
Furthermore, both `main` and `renderer` are routed separately.
In other words, which handler is selected in either process is independent from the list of possible handlers in the other.
Example of registering a handler:
```typescript
import { LensMainExtension, Interface } from "@k8slens/extensions";
function rootHandler(params: Iterface.ProtocolRouteParams) {
console.log("routed to ExampleExtension", params);
}
export default class ExampleExtensionMain extends LensMainExtension {
protocolHandlers = [
pathSchema: "/",
handler: rootHandler,
]
}
```
For testing the routing of URIs the `open` (on macOS) or `xdg-open` (on most linux) CLI utilities can be used.
For the above handler, the following URI would be always routed to it:
```
open lens://extension/example-extension/
```
## Deregistering A Protocol Handler
All that is needed to deregister a handler is to remove it from the array of handlers.
## Routing Algorithm
The routing mechanism for extensions is quite straight forward.
For example consider an extension `example-extension` which is published by the `@mirantis` org.
If it were to register a handler with `"/display/:type"` as its corresponding link then we would match the following URI like this:
![Lens Protocol Link Resolution](images/routing-diag.png)
Once matched, the handler would be called with the following argument (note both `"search"` and `"pathname"` will always be defined):
```json
{
"search": {
"text": "Hello"
},
"pathname": {
"type": "notification"
}
}
```
As the diagram above shows, the search (or query) params are not considered as part of the handler resolution.
If the URI had instead been `lens://extension/@mirantis/example-extension/display/notification/green` then a third (and optional) field will have the rest of the path.
The `tail` field would be filled with `"/green"`.
If multiple `pathSchema`'s match a given URI then the most specific handler will be called.
For example consider the following `pathSchema`'s:
1. `"/"`
1. `"/display"`
1. `"/display/:type"`
1. `"/show/:id"`
The URI sub-path `"/display"` would be routed to #2 since it is an exact match.
On the other hand, the subpath `"/display/notification"` would be routed to #3.
The URI is routed to the most specific matching `pathSchema`.
This way the `"/"` (root) `pathSchema` acts as a sort of catch all or default route if no other route matches.

View File

@ -5,7 +5,7 @@ Lens is lightweight and simple to install. You'll be up and running in just a fe
## System Requirements ## System Requirements
Review the [System Requirements](/supporting/requirements/) to check if your computer configuration is supported. Review the [System Requirements](../supporting/requirements.md) to check if your computer configuration is supported.
## macOS ## macOS
@ -28,6 +28,28 @@ Review the [System Requirements](/supporting/requirements/) to check if your com
See the [Download Lens](https://github.com/lensapp/lens/releases) page for a complete list of available installation options. See the [Download Lens](https://github.com/lensapp/lens/releases) page for a complete list of available installation options.
After installing Lens manually (not using a package manager file such as `.deb` or `.rpm`) the following will need to be done to allow protocol handling.
This assumes that your linux distribution uses `xdg-open` and the `xdg-*` suite of programs for determining which application can handle custom URIs.
1. Create a file called `lens.desktop` in either `~/.local/share/applications/` or `/usr/share/applications` (if you have permissions and are installing Lens for all users).
1. That file should have the following contents, with `<path/to/executable>` being the absolute path to where you have installed the unpacked `Lens` executable:
```
[Desktop Entry]
Name=Lens
Exec=<path/to/executable> %U
Terminal=false
Type=Application
Icon=lens
StartupWMClass=Lens
Comment=Lens - The Kubernetes IDE
MimeType=x-scheme-handler/lens;
Categories=Network;
```
1. Then run the following command:
```
xdg-settings set default-url-scheme-handler lens lens.desktop
```
1. If that succeeds (exits with code `0`) then your Lens install should be set up to handle `lens://` URIs.
### Snap ### Snap
@ -52,4 +74,3 @@ To stay current with the Lens features, you can review the [release notes](https
- [Add clusters](../clusters/adding-clusters.md) - [Add clusters](../clusters/adding-clusters.md)
- [Watch introductory videos](./introductory-videos.md) - [Watch introductory videos](./introductory-videos.md)

View File

@ -4,7 +4,7 @@ Lens has integration to Helm making it easy to install and manage Helm charts an
![Helm Charts](images/helm-charts.png) ![Helm Charts](images/helm-charts.png)
## Managing Helm Reporistories ## Managing Helm Repositories
Used Helm repositories are possible to configure in the [Preferences](/getting-started/preferences). Lens app will fetch available Helm repositories from the [Artifact HUB](https://artifacthub.io/) and automatically add `bitnami` repository by default if no other repositories are already configured. If any other repositories are needed to add, those can be added manually via command line. **Note!** Configured Helm repositories are added globally to user's computer, so other processes can see those as well. Used Helm repositories are possible to configure in the [Preferences](/getting-started/preferences). Lens app will fetch available Helm repositories from the [Artifact HUB](https://artifacthub.io/) and automatically add `bitnami` repository by default if no other repositories are already configured. If any other repositories are needed to add, those can be added manually via command line. **Note!** Configured Helm repositories are added globally to user's computer, so other processes can see those as well.

View File

@ -2,7 +2,7 @@
"name": "kontena-lens", "name": "kontena-lens",
"productName": "Lens", "productName": "Lens",
"description": "Lens - The Kubernetes IDE", "description": "Lens - The Kubernetes IDE",
"version": "4.1.0", "version": "4.2.0-alpha.0",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2020, Mirantis, Inc.", "copyright": "© 2020, Mirantis, Inc.",
"license": "MIT", "license": "MIT",
@ -25,7 +25,7 @@
"build:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens", "build:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens",
"build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens", "build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens",
"build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens", "build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens",
"test": "jest --env=jsdom src $@", "test": "scripts/test.sh",
"integration": "jest --runInBand integration", "integration": "jest --runInBand integration",
"dist": "yarn run compile && electron-builder --publish onTag", "dist": "yarn run compile && electron-builder --publish onTag",
"dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32", "dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32",
@ -170,7 +170,14 @@
"repo": "lens", "repo": "lens",
"owner": "lensapp" "owner": "lensapp"
} }
] ],
"protocols": {
"name": "Lens Protocol Handler",
"schemes": [
"lens"
],
"role": "Viewer"
}
}, },
"lens": { "lens": {
"extensions": [ "extensions": [
@ -187,6 +194,7 @@
"@hapi/call": "^8.0.0", "@hapi/call": "^8.0.0",
"@hapi/subtext": "^7.0.3", "@hapi/subtext": "^7.0.3",
"@kubernetes/client-node": "^0.12.0", "@kubernetes/client-node": "^0.12.0",
"abort-controller": "^3.0.0",
"array-move": "^3.0.0", "array-move": "^3.0.0",
"await-lock": "^2.1.0", "await-lock": "^2.1.0",
"byline": "^5.0.0", "byline": "^5.0.0",
@ -213,6 +221,7 @@
"mobx-observable-history": "^1.0.3", "mobx-observable-history": "^1.0.3",
"mobx-react": "^6.2.2", "mobx-react": "^6.2.2",
"mock-fs": "^4.12.0", "mock-fs": "^4.12.0",
"moment": "^2.26.0",
"node-pty": "^0.9.0", "node-pty": "^0.9.0",
"npm": "^6.14.8", "npm": "^6.14.8",
"openid-client": "^3.15.2", "openid-client": "^3.15.2",
@ -232,6 +241,7 @@
"tar": "^6.0.5", "tar": "^6.0.5",
"tcp-port-used": "^1.0.1", "tcp-port-used": "^1.0.1",
"tempy": "^0.5.0", "tempy": "^0.5.0",
"url-parse": "^1.4.7",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"win-ca": "^3.2.0", "win-ca": "^3.2.0",
"winston": "^3.2.1", "winston": "^3.2.1",
@ -289,6 +299,7 @@
"@types/tempy": "^0.3.0", "@types/tempy": "^0.3.0",
"@types/terser-webpack-plugin": "^3.0.0", "@types/terser-webpack-plugin": "^3.0.0",
"@types/universal-analytics": "^0.4.4", "@types/universal-analytics": "^0.4.4",
"@types/url-parse": "^1.4.3",
"@types/uuid": "^8.3.0", "@types/uuid": "^8.3.0",
"@types/webdriverio": "^4.13.0", "@types/webdriverio": "^4.13.0",
"@types/webpack": "^4.41.17", "@types/webpack": "^4.41.17",
@ -325,10 +336,10 @@
"jest-mock-extended": "^1.0.10", "jest-mock-extended": "^1.0.10",
"make-plural": "^6.2.2", "make-plural": "^6.2.2",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^0.9.0",
"moment": "^2.26.0",
"node-loader": "^0.6.0", "node-loader": "^0.6.0",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"nodemon": "^2.0.4", "nodemon": "^2.0.4",
"open": "^7.3.1",
"patch-package": "^6.2.2", "patch-package": "^6.2.2",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"prettier": "^2.2.0", "prettier": "^2.2.0",

1
scripts/test.sh Executable file
View File

@ -0,0 +1 @@
jest --env=jsdom ${1:-src}

View File

@ -6,6 +6,29 @@ import { ClusterStore, getClusterIdFromHost } from "../cluster-store";
import { workspaceStore } from "../workspace-store"; import { workspaceStore } from "../workspace-store";
const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png"); const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png");
const kubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test
contexts:
- context:
cluster: test
user: test
name: foo
- context:
cluster: test
user: test
name: foo2
current-context: test
kind: Config
preferences: {}
users:
- name: test
user:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
jest.mock("electron", () => { jest.mock("electron", () => {
return { return {
@ -47,13 +70,13 @@ describe("empty config", () => {
clusterStore.addCluster( clusterStore.addCluster(
new Cluster({ new Cluster({
id: "foo", id: "foo",
contextName: "minikube", contextName: "foo",
preferences: { preferences: {
terminalCWD: "/tmp", terminalCWD: "/tmp",
icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5",
clusterName: "minikube" clusterName: "minikube"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", "fancy foo config"), kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig),
workspace: workspaceStore.currentWorkspaceId workspace: workspaceStore.currentWorkspaceId
}) })
); );
@ -91,20 +114,20 @@ describe("empty config", () => {
clusterStore.addClusters( clusterStore.addClusters(
new Cluster({ new Cluster({
id: "prod", id: "prod",
contextName: "prod", contextName: "foo",
preferences: { preferences: {
clusterName: "prod" clusterName: "prod"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", "fancy config"), kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig),
workspace: "workstation" workspace: "workstation"
}), }),
new Cluster({ new Cluster({
id: "dev", id: "dev",
contextName: "dev", contextName: "foo2",
preferences: { preferences: {
clusterName: "dev" clusterName: "dev"
}, },
kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", "fancy config"), kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig),
workspace: "workstation" workspace: "workstation"
}) })
); );
@ -177,20 +200,20 @@ describe("config with existing clusters", () => {
clusters: [ clusters: [
{ {
id: "cluster1", id: "cluster1",
kubeConfig: "foo", kubeConfigPath: kubeconfig,
contextName: "foo", contextName: "foo",
preferences: { terminalCWD: "/foo" }, preferences: { terminalCWD: "/foo" },
workspace: "default" workspace: "default"
}, },
{ {
id: "cluster2", id: "cluster2",
kubeConfig: "foo2", kubeConfigPath: kubeconfig,
contextName: "foo2", contextName: "foo2",
preferences: { terminalCWD: "/foo2" } preferences: { terminalCWD: "/foo2" }
}, },
{ {
id: "cluster3", id: "cluster3",
kubeConfig: "foo", kubeConfigPath: kubeconfig,
contextName: "foo", contextName: "foo",
preferences: { terminalCWD: "/foo" }, preferences: { terminalCWD: "/foo" },
workspace: "foo", workspace: "foo",
@ -247,6 +270,78 @@ describe("config with existing clusters", () => {
}); });
}); });
describe("config with invalid cluster kubeconfig", () => {
beforeEach(() => {
const invalidKubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test2
contexts:
- context:
cluster: test
user: test
name: test
current-context: test
kind: Config
preferences: {}
users:
- name: test
user:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
ClusterStore.resetInstance();
const mockOpts = {
"tmp": {
"lens-cluster-store.json": JSON.stringify({
__internal__: {
migrations: {
version: "99.99.99"
}
},
clusters: [
{
id: "cluster1",
kubeConfigPath: invalidKubeconfig,
contextName: "test",
preferences: { terminalCWD: "/foo" },
workspace: "foo",
},
{
id: "cluster2",
kubeConfigPath: kubeconfig,
contextName: "foo",
preferences: { terminalCWD: "/foo" },
workspace: "default"
},
]
})
}
};
mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load();
});
afterEach(() => {
mockFs.restore();
});
it("does not enable clusters with invalid kubeconfig", () => {
const storedClusters = clusterStore.clustersList;
expect(storedClusters.length).toBe(2);
expect(storedClusters[0].enabled).toBeFalsy;
expect(storedClusters[1].id).toBe("cluster2");
expect(storedClusters[1].enabled).toBeTruthy;
});
});
describe("pre 2.0 config with an existing cluster", () => { describe("pre 2.0 config with an existing cluster", () => {
beforeEach(() => { beforeEach(() => {
ClusterStore.resetInstance(); ClusterStore.resetInstance();

View File

@ -0,0 +1,101 @@
import { KubeConfig } from "@kubernetes/client-node";
import { validateKubeConfig } from "../kube-helpers";
const kubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test
contexts:
- context:
cluster: test
user: test
name: valid
- context:
cluster: test2
user: test
name: invalidCluster
- context:
cluster: test
user: test2
name: invalidUser
- context:
cluster: test
user: invalidExec
name: invalidExec
current-context: test
kind: Config
preferences: {}
users:
- name: test
user:
exec:
command: echo
- name: invalidExec
user:
exec:
command: foo
`;
const kc = new KubeConfig();
describe("validateKubeconfig", () => {
beforeAll(() => {
kc.loadFromString(kubeconfig);
});
describe("with default validation options", () => {
describe("with valid kubeconfig", () => {
it("does not raise exceptions", () => {
expect(() => { validateKubeConfig(kc, "valid");}).not.toThrow();
});
});
describe("with invalid context object", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalid");}).toThrow("No valid context object provided in kubeconfig for context 'invalid'");
});
});
describe("with invalid cluster object", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalidCluster");}).toThrow("No valid cluster object provided in kubeconfig for context 'invalidCluster'");
});
});
describe("with invalid user object", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalidUser");}).toThrow("No valid user object provided in kubeconfig for context 'invalidUser'");
});
});
describe("with invalid exec command", () => {
it("it raises exception", () => {
expect(() => { validateKubeConfig(kc, "invalidExec");}).toThrow("User Exec command \"foo\" not found on host. Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig");
});
});
});
describe("with validateCluster as false", () => {
describe("with invalid cluster object", () => {
it("does not raise exception", () => {
expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).not.toThrow();
});
});
});
describe("with validateUser as false", () => {
describe("with invalid user object", () => {
it("does not raise excpetions", () => {
expect(() => { validateKubeConfig(kc, "invalidUser", { validateUser: false });}).not.toThrow();
});
});
});
describe("with validateExec as false", () => {
describe("with invalid exec object", () => {
it("does not raise excpetions", () => {
expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow();
});
});
});
});

View File

@ -323,7 +323,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} else { } else {
cluster = new Cluster(clusterModel); cluster = new Cluster(clusterModel);
if (!cluster.isManaged) { if (!cluster.isManaged && cluster.apiUrl) {
cluster.enabled = true; cluster.enabled = true;
} }
} }
@ -337,7 +337,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
} }
}); });
this.activeCluster = newClusters.has(activeCluster) ? activeCluster : null; this.activeCluster = newClusters.get(activeCluster)?.enabled ? activeCluster : null;
this.clusters.replace(newClusters); this.clusters.replace(newClusters);
this.removedClusters.replace(removedClusters); this.removedClusters.replace(removedClusters);
} }

View File

@ -1,3 +1,4 @@
export * from "./ipc"; export * from "./ipc";
export * from "./invalid-kubeconfig";
export * from "./update-available"; export * from "./update-available";
export * from "./type-enforced-ipc"; export * from "./type-enforced-ipc";

View File

@ -0,0 +1,3 @@
export const InvalidKubeconfigChannel = "invalid-kubeconfig";
export type InvalidKubeConfigArgs = [clusterId: string];

View File

@ -47,7 +47,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) {
view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args); view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args);
} }
} catch (error) { } catch (error) {
logger.error("[IPC]: failed to send IPC message", { error }); logger.error("[IPC]: failed to send IPC message", { error: String(error) });
} }
} }
} }

View File

@ -7,6 +7,12 @@ import logger from "../main/logger";
import commandExists from "command-exists"; import commandExists from "command-exists";
import { ExecValidationNotFoundError } from "./custom-errors"; import { ExecValidationNotFoundError } from "./custom-errors";
export type KubeConfigValidationOpts = {
validateCluster?: boolean;
validateUser?: boolean;
validateExec?: boolean;
};
export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config"); export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config");
function resolveTilde(filePath: string) { function resolveTilde(filePath: string) {
@ -151,27 +157,42 @@ export function getNodeWarningConditions(node: V1Node) {
} }
/** /**
* Validates kubeconfig supplied in the add clusters screen. At present this will just validate * Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required)
* the User struct, specifically the command passed to the exec substructure.
*/ */
export function validateKubeConfig (config: KubeConfig) { export function validateKubeConfig (config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}) {
// we only receive a single context, cluster & user object here so lets validate them as this // we only receive a single context, cluster & user object here so lets validate them as this
// will be called when we add a new cluster to Lens // will be called when we add a new cluster to Lens
logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`);
const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts;
const contextObject = config.getContextObject(contextName);
// Validate the Context Object
if (!contextObject) {
throw new Error(`No valid context object provided in kubeconfig for context '${contextName}'`);
}
// Validate the Cluster Object
if (validateCluster && !config.getCluster(contextObject.cluster)) {
throw new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`);
}
const user = config.getUser(contextObject.user);
// Validate the User Object // Validate the User Object
const user = config.getCurrentUser(); if (validateUser && !user) {
throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
}
if (user.exec) { // Validate exec command if present
if (validateExec && user?.exec) {
const execCommand = user.exec["command"]; const execCommand = user.exec["command"];
// check if the command is absolute or not // check if the command is absolute or not
const isAbsolute = path.isAbsolute(execCommand); const isAbsolute = path.isAbsolute(execCommand);
// validate the exec struct in the user object, start with the command field // validate the exec struct in the user object, start with the command field
logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`);
if (!commandExists.sync(execCommand)) { if (!commandExists.sync(execCommand)) {
logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${config.currentContext} not found`); logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${contextName} not found`);
throw new ExecValidationNotFoundError(execCommand, isAbsolute); throw new ExecValidationNotFoundError(execCommand, isAbsolute);
} }
} }

View File

@ -0,0 +1,36 @@
import Url from "url-parse";
export enum RoutingErrorType {
INVALID_PROTOCOL = "invalid-protocol",
INVALID_HOST = "invalid-host",
INVALID_PATHNAME = "invalid-pathname",
NO_HANDLER = "no-handler",
NO_EXTENSION_ID = "no-ext-id",
MISSING_EXTENSION = "missing-ext",
}
export class RoutingError extends Error {
/**
* Will be set if the routing error originated in an extension route table
*/
public extensionName?: string;
constructor(public type: RoutingErrorType, public url: Url) {
super("routing error");
}
toString(): string {
switch (this.type) {
case RoutingErrorType.INVALID_HOST:
return "invalid host";
case RoutingErrorType.INVALID_PROTOCOL:
return "invalid protocol";
case RoutingErrorType.INVALID_PATHNAME:
return "invalid pathname";
case RoutingErrorType.NO_EXTENSION_ID:
return "no extension ID";
case RoutingErrorType.MISSING_EXTENSION:
return "extension not found";
}
}
}

View File

@ -0,0 +1,2 @@
export * from "./error";
export * from "./router";

View File

@ -0,0 +1,218 @@
import { match, matchPath } from "react-router";
import { countBy } from "lodash";
import { Singleton } from "../utils";
import { pathToRegexp } from "path-to-regexp";
import logger from "../../main/logger";
import Url from "url-parse";
import { RoutingError, RoutingErrorType } from "./error";
import { extensionsStore } from "../../extensions/extensions-store";
import { extensionLoader } from "../../extensions/extension-loader";
import { LensExtension } from "../../extensions/lens-extension";
import { RouteHandler, RouteParams } from "../../extensions/registries/protocol-handler-registry";
// IPC channel for protocol actions. Main broadcasts the open-url events to this channel.
export const ProtocolHandlerIpcPrefix = "protocol-handler";
export const ProtocolHandlerInternal = `${ProtocolHandlerIpcPrefix}:internal`;
export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`;
/**
* These two names are long and cumbersome by design so as to decrease the chances
* of an extension using the same names.
*
* Though under the current (2021/01/18) implementation, these are never matched
* against in the final matching so their names are less of a concern.
*/
const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
export abstract class LensProtocolRouter extends Singleton {
// Map between path schemas and the handlers
protected internalRoutes = new Map<string, RouteHandler>();
public static readonly LoggingPrefix = "[PROTOCOL ROUTER]";
protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
/**
*
* @param url the parsed URL that initiated the `lens://` protocol
*/
protected _routeToInternal(url: Url): void {
this._route(Array.from(this.internalRoutes.entries()), url);
}
/**
* match against all matched URIs, returning either the first exact match or
* the most specific match if none are exact.
* @param routes the array of path schemas, handler pairs to match against
* @param url the url (in its current state)
*/
protected _findMatchingRoute(routes: [string, RouteHandler][], url: Url): null | [match<Record<string, string>>, RouteHandler] {
const matches: [match<Record<string, string>>, RouteHandler][] = [];
for (const [schema, handler] of routes) {
const match = matchPath(url.pathname, { path: schema });
if (!match) {
continue;
}
// prefer an exact match
if (match.isExact) {
return [match, handler];
}
matches.push([match, handler]);
}
// if no exact match pick the one that is the most specific
return matches.sort(([a], [b]) => compareMatches(a, b))[0] ?? null;
}
/**
* find the most specific matching handler and call it
* @param routes the array of (path schemas, handler) paris to match against
* @param url the url (in its current state)
*/
protected _route(routes: [string, RouteHandler][], url: Url, extensionName?: string): void {
const route = this._findMatchingRoute(routes, url);
if (!route) {
const data: Record<string, string> = { url: url.toString() };
if (extensionName) {
data.extensionName = extensionName;
}
return void logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data);
}
const [match, handler] = route;
const params: RouteParams = {
pathname: match.params,
search: url.query,
};
if (!match.isExact) {
params.tail = url.pathname.slice(match.url.length);
}
handler(params);
}
/**
* Tries to find the matching LensExtension instance
*
* Note: this needs to be async so that `main`'s overloaded version can also be async
* @param url the protocol request URI that was "open"-ed
* @returns either the found name or the instance of `LensExtension`
*/
protected async _findMatchingExtensionByName(url: Url): Promise<LensExtension | string> {
interface ExtensionUrlMatch {
[EXTENSION_PUBLISHER_MATCH]: string;
[EXTENSION_NAME_MATCH]: string;
}
const match = matchPath<ExtensionUrlMatch>(url.pathname, LensProtocolRouter.ExtensionUrlSchema);
if (!match) {
throw new RoutingError(RoutingErrorType.NO_EXTENSION_ID, url);
}
const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params;
const name = [publisher, partialName].filter(Boolean).join("/");
const extension = extensionLoader.userExtensionsByName.get(name);
if (!extension) {
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed`);
return name;
}
if (!extensionsStore.isEnabled(extension.id)) {
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`);
return name;
}
logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched`);
return extension;
}
/**
* Find a matching extension by the first one or two path segments of `url` and then try to `_route`
* its correspondingly registered handlers.
*
* If no handlers are found or the extension is not enabled then `_missingHandlers` is called before
* checking if more handlers have been added.
*
* Note: this function modifies its argument, do not reuse
* @param url the protocol request URI that was "open"-ed
*/
protected async _routeToExtension(url: Url): Promise<void> {
const extension = await this._findMatchingExtensionByName(url);
if (typeof extension === "string") {
// failed to find an extension, it returned its name
return;
}
// remove the extension name from the path name so we don't need to match on it anymore
url.set("pathname", url.pathname.slice(extension.name.length + 1));
const handlers = extension
.protocolHandlers
.map<[string, RouteHandler]>(({ pathSchema, handler }) => [pathSchema, handler]);
try {
this._route(handlers, url, extension.name);
} catch (error) {
if (error instanceof RoutingError) {
error.extensionName = extension.name;
}
throw error;
}
}
/**
* Add a handler under the `lens://app` tree of routing.
* @param pathSchema the URI path schema to match against for this handler
* @param handler a function that will be called if a protocol path matches
*/
public addInternalHandler(urlSchema: string, handler: RouteHandler): void {
pathToRegexp(urlSchema); // verify now that the schema is valid
logger.info(`${LensProtocolRouter.LoggingPrefix}: internal registering ${urlSchema}`);
this.internalRoutes.set(urlSchema, handler);
}
/**
* Remove an internal protocol handler.
* @param pathSchema the path schema that the handler was registered under
*/
public removeInternalHandler(urlSchema: string): void {
this.internalRoutes.delete(urlSchema);
}
}
/**
* a comparison function for `array.sort(...)`. Sort order should be most path
* parts to least path parts.
* @param a the left side to compare
* @param b the right side to compare
*/
function compareMatches<T>(a: match<T>, b: match<T>): number {
if (a.path === "/") {
return 1;
}
if (b.path === "/") {
return -1;
}
return countBy(b.path)["/"] - countBy(a.path)["/"];
}

View File

@ -31,7 +31,7 @@ export const apiResources: KubeApiResource[] = [
{ kind: "PersistentVolume", apiName: "persistentvolumes" }, { kind: "PersistentVolume", apiName: "persistentvolumes" },
{ kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" }, { kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" },
{ kind: "Pod", apiName: "pods" }, { kind: "Pod", apiName: "pods" },
{ kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets" }, { kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets", group: "policy" },
{ kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" }, { kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" },
{ kind: "ResourceQuota", apiName: "resourcequotas" }, { kind: "ResourceQuota", apiName: "resourcequotas" },
{ kind: "ReplicaSet", apiName: "replicasets", group: "apps" }, { kind: "ReplicaSet", apiName: "replicasets", group: "apps" },

View File

@ -1,8 +1,19 @@
import { AbortController } from "abort-controller";
/** /**
* Return a promise that will be resolved after at least `timeout` ms have * Return a promise that will be resolved after at least `timeout` ms have
* passed * passed. If `failFast` is provided then the promise is also resolved if it has
* been aborted.
* @param timeout The number of milliseconds before resolving * @param timeout The number of milliseconds before resolving
* @param failFast An abort controller instance to cause the delay to short-circuit
*/ */
export function delay(timeout = 1000): Promise<void> { export function delay(timeout = 1000, failFast?: AbortController): Promise<void> {
return new Promise(resolve => setTimeout(resolve, timeout)); return new Promise(resolve => {
const timeoutId = setTimeout(resolve, timeout);
failFast?.signal.addEventListener("abort", () => {
clearTimeout(timeoutId);
resolve();
});
});
} }

View File

@ -18,4 +18,4 @@ export * from "./openExternal";
export * from "./downloadFile"; export * from "./downloadFile";
export * from "./escapeRegExp"; export * from "./escapeRegExp";
export * from "./tar"; export * from "./tar";
export * from "./delay"; export * from "./type-narrowing";

View File

@ -0,0 +1,13 @@
/**
* Narrows `val` to include the property `key` (if true is returned)
* @param val The object to be tested
* @param key The key to test if it is present on the object
*/
export function hasOwnProperty<V extends object, K extends PropertyKey>(val: V, key: K): val is (V & { [key in K]: unknown }) {
// this call syntax is for when `val` was created by `Object.create(null)`
return Object.prototype.hasOwnProperty.call(val, key);
}
export function hasOwnProperties<V extends object, K extends PropertyKey>(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) {
return keys.every(key => hasOwnProperty(val, key));
}

View File

@ -14,7 +14,7 @@ import type { LensRendererExtension } from "./lens-renderer-extension";
import * as registries from "./registries"; import * as registries from "./registries";
import fs from "fs"; import fs from "fs";
// lazy load so that we get correct userData
export function extensionPackagesRoot() { export function extensionPackagesRoot() {
return path.join((app || remote.app).getPath("userData")); return path.join((app || remote.app).getPath("userData"));
} }
@ -52,6 +52,30 @@ export class ExtensionLoader {
return extensions; return extensions;
} }
@computed get userExtensionsByName(): Map<string, LensExtension> {
const extensions = new Map();
for (const [, val] of this.instances.toJS()) {
if (val.isBundled) {
continue;
}
extensions.set(val.manifest.name, val);
}
return extensions;
}
getExtensionByName(name: string): LensExtension | null {
for (const [, val] of this.instances) {
if (val.name === name) {
return val;
}
}
return null;
}
// Transform userExtensions to a state object for storing into ExtensionsStore // Transform userExtensions to a state object for storing into ExtensionsStore
@computed get storeState() { @computed get storeState() {
return Object.fromEntries( return Object.fromEntries(
@ -102,7 +126,6 @@ export class ExtensionLoader {
} catch (error) { } catch (error) {
logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error });
} }
} }
removeExtension(lensExtensionId: LensExtensionId) { removeExtension(lensExtensionId: LensExtensionId) {

View File

@ -27,7 +27,7 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
protected state = observable.map<LensExtensionId, LensExtensionState>(); protected state = observable.map<LensExtensionId, LensExtensionState>();
isEnabled(extId: LensExtensionId) { isEnabled(extId: LensExtensionId): boolean {
const state = this.state.get(extId); const state = this.state.get(extId);
// By default false, so that copied extensions are disabled by default. // By default false, so that copied extensions are disabled by default.

View File

@ -6,3 +6,4 @@ export type { KubeObjectStatusRegistration } from "../registries/kube-object-sta
export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry"; export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry";
export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry"; export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry";
export type { StatusBarRegistration } from "../registries/status-bar-registry"; export type { StatusBarRegistration } from "../registries/status-bar-registry";
export type { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../registries/protocol-handler-registry";

View File

@ -2,6 +2,7 @@ import type { InstalledExtension } from "./extension-discovery";
import { action, observable, reaction } from "mobx"; import { action, observable, reaction } from "mobx";
import { filesystemProvisionerStore } from "../main/extension-filesystem"; import { filesystemProvisionerStore } from "../main/extension-filesystem";
import logger from "../main/logger"; import logger from "../main/logger";
import { ProtocolHandlerRegistration } from "./registries/protocol-handler-registry";
export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionId = string; // path to manifest (package.json)
export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension; export type LensExtensionConstructor = new (...args: ConstructorParameters<typeof LensExtension>) => LensExtension;
@ -21,6 +22,8 @@ export class LensExtension {
readonly manifestPath: string; readonly manifestPath: string;
readonly isBundled: boolean; readonly isBundled: boolean;
protocolHandlers: ProtocolHandlerRegistration[] = [];
@observable private isEnabled = false; @observable private isEnabled = false;
constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) { constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) {

View File

@ -31,8 +31,7 @@ export class LensRendererExtension extends LensExtension {
/** /**
* Defines if extension is enabled for a given cluster. Defaults to `true`. * Defines if extension is enabled for a given cluster. Defaults to `true`.
*/ */
// eslint-disable-next-line unused-imports/no-unused-vars-ts
async isEnabledForCluster(cluster: Cluster): Promise<Boolean> { async isEnabledForCluster(cluster: Cluster): Promise<Boolean> {
return true; return (void cluster) || true;
} }
} }

View File

@ -0,0 +1,44 @@
/**
* ProtocolHandlerRegistration is the data required for an extension to register
* a handler to a specific path or dynamic path.
*/
export interface ProtocolHandlerRegistration {
pathSchema: string;
handler: RouteHandler;
}
/**
* The collection of the dynamic parts of a URI which initiated a `lens://`
* protocol request
*/
export interface RouteParams {
/**
* the parts of the URI query string
*/
search: Record<string, string>;
/**
* the matching parts of the path. The dynamic parts of the URI path.
*/
pathname: Record<string, string>;
/**
* if the most specific path schema that is matched does not cover the whole
* of the URI's path. Then this field will be set to the remaining path
* segments.
*
* Example:
*
* If the path schema `/landing/:type` is the matched schema for the URI
* `/landing/soft/easy` then this field will be set to `"/easy"`.
*/
tail?: string;
}
/**
* RouteHandler represents the function signature of the handler function for
* `lens://` protocol routing.
*/
export interface RouteHandler {
(params: RouteParams): void;
}

View File

@ -8,7 +8,7 @@ interface StatusBarComponents {
} }
interface StatusBarRegistrationV2 { interface StatusBarRegistrationV2 {
components: StatusBarComponents; components?: StatusBarComponents; // has to be optional for backwards compatability
} }
export interface StatusBarRegistration extends StatusBarRegistrationV2 { export interface StatusBarRegistration extends StatusBarRegistrationV2 {

View File

@ -3,19 +3,20 @@ import logger from "./logger";
import { isDevelopment, isTestEnv } from "../common/vars"; import { isDevelopment, isTestEnv } from "../common/vars";
import { delay } from "../common/utils"; import { delay } from "../common/utils";
import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc"; import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc";
import { ipcMain } from "electron"; import { once } from "lodash";
import { app, ipcMain } from "electron";
let installVersion: null | string = null;
function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: UpdateAvailableToBackchannel) { function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: UpdateAvailableToBackchannel) {
if (arg.doUpdate) { if (arg.doUpdate) {
if (arg.now) { if (arg.now) {
logger.info(`${AutoUpdateLogPrefix}: User chose to update now`); logger.info(`${AutoUpdateLogPrefix}: User chose to update now`);
autoUpdater.on("update-downloaded", () => autoUpdater.quitAndInstall()); autoUpdater.quitAndInstall(true, true);
autoUpdater.downloadUpdate().catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download or install update`, { error })); app.exit(); // this is needed for the installer not to fail on windows.
} else { } else {
logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`); logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`);
autoUpdater.autoInstallOnAppQuit = true; autoUpdater.autoInstallOnAppQuit = true;
autoUpdater.downloadUpdate()
.catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download update`, { error }));
} }
} else { } else {
logger.info(`${AutoUpdateLogPrefix}: User chose not to update`); logger.info(`${AutoUpdateLogPrefix}: User chose not to update`);
@ -26,7 +27,7 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd
* starts the automatic update checking * starts the automatic update checking
* @param interval milliseconds between interval to check on, defaults to 24h * @param interval milliseconds between interval to check on, defaults to 24h
*/ */
export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void { export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24): void {
if (isDevelopment || isTestEnv) { if (isDevelopment || isTestEnv) {
return; return;
} }
@ -36,9 +37,29 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void {
autoUpdater.autoInstallOnAppQuit = false; autoUpdater.autoInstallOnAppQuit = false;
autoUpdater autoUpdater
.on("update-available", (args: UpdateInfo) => { .on("update-available", (info: UpdateInfo) => {
if (autoUpdater.autoInstallOnAppQuit) {
// a previous auto-update loop was completed with YES+LATER, check if same version
if (installVersion === info.version) {
// same version, don't broadcast
return;
}
}
/**
* This should be always set to false here because it is the reasonable
* default. Namely, if a don't auto update to a version that the user
* didn't ask for.
*/
autoUpdater.autoInstallOnAppQuit = false;
installVersion = info.version;
autoUpdater.downloadUpdate()
.catch(error => logger.error(`${AutoUpdateLogPrefix}: failed to download update`, { error: String(error) }));
})
.on("update-downloaded", (info: UpdateInfo) => {
try { try {
const backchannel = `auto-update:${args.version}`; const backchannel = `auto-update:${info.version}`;
ipcMain.removeAllListeners(backchannel); // only one handler should be present ipcMain.removeAllListeners(backchannel); // only one handler should be present
@ -49,10 +70,11 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void {
listener: handleAutoUpdateBackChannel, listener: handleAutoUpdateBackChannel,
verifier: areArgsUpdateAvailableToBackchannel, verifier: areArgsUpdateAvailableToBackchannel,
}); });
logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: args.version }); logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: info.version });
broadcastMessage(UpdateAvailableChannel, backchannel, args); broadcastMessage(UpdateAvailableChannel, backchannel, info);
} catch (error) { } catch (error) {
logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error }); logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error });
installVersion = undefined;
} }
}); });
@ -64,7 +86,7 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void {
} }
helper(); helper();
} });
export async function checkForUpdates(): Promise<void> { export async function checkForUpdates(): Promise<void> {
try { try {

View File

@ -4,12 +4,12 @@ import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
import type { WorkspaceId } from "../common/workspace-store"; import type { WorkspaceId } from "../common/workspace-store";
import { action, comparer, computed, observable, reaction, toJS, when } from "mobx"; import { action, comparer, computed, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars"; import { apiKubePrefix } from "../common/vars";
import { broadcastMessage } from "../common/ipc"; import { broadcastMessage, InvalidKubeconfigChannel } from "../common/ipc";
import { ContextHandler } from "./context-handler"; import { ContextHandler } from "./context-handler";
import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node";
import { Kubectl } from "./kubectl"; import { Kubectl } from "./kubectl";
import { KubeconfigManager } from "./kubeconfig-manager"; import { KubeconfigManager } from "./kubeconfig-manager";
import { loadConfig } from "../common/kube-helpers"; import { loadConfig, validateKubeConfig } from "../common/kube-helpers";
import request, { RequestPromiseOptions } from "request-promise-native"; import request, { RequestPromiseOptions } from "request-promise-native";
import { apiResources, KubeApiResource } from "../common/rbac"; import { apiResources, KubeApiResource } from "../common/rbac";
import logger from "./logger"; import logger from "./logger";
@ -177,6 +177,7 @@ export class Cluster implements ClusterModel, ClusterState {
* @observable * @observable
*/ */
@observable isAdmin = false; @observable isAdmin = false;
/** /**
* Global watch-api accessibility , e.g. "/api/v1/services?watch=1" * Global watch-api accessibility , e.g. "/api/v1/services?watch=1"
* *
@ -256,10 +257,16 @@ export class Cluster implements ClusterModel, ClusterState {
constructor(model: ClusterModel) { constructor(model: ClusterModel) {
this.updateModel(model); this.updateModel(model);
const kubeconfig = this.getKubeconfig();
if (kubeconfig.getContextObject(this.contextName)) { try {
const kubeconfig = this.getKubeconfig();
validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false});
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
} catch(err) {
logger.error(err);
logger.error(`[CLUSTER] Failed to load kubeconfig for the cluster '${this.name || this.contextName}' (context: ${this.contextName}, kubeconfig: ${this.kubeConfigPath}).`);
broadcastMessage(InvalidKubeconfigChannel, model.id);
} }
} }

View File

@ -0,0 +1,108 @@
import { HelmRepo, HelmRepoManager } from "../helm-repo-manager";
export class HelmChartManager {
private cache: any = {};
private repo: HelmRepo;
constructor(repo: HelmRepo){
this.cache = HelmRepoManager.cache;
this.repo = repo;
}
public async charts(): Promise<any> {
switch (this.repo.name) {
case "stable":
return Promise.resolve({
"apm-server": [
{
apiVersion: "3.0.0",
name: "apm-server",
version: "2.1.7",
repo: "stable",
digest: "test"
},
{
apiVersion: "3.0.0",
name: "apm-server",
version: "2.1.6",
repo: "stable",
digest: "test"
}
],
"redis": [
{
apiVersion: "3.0.0",
name: "apm-server",
version: "1.0.0",
repo: "stable",
digest: "test"
},
{
apiVersion: "3.0.0",
name: "apm-server",
version: "0.0.9",
repo: "stable",
digest: "test"
}
]
});
case "experiment":
return Promise.resolve({
"fairwind": [
{
apiVersion: "3.0.0",
name: "fairwind",
version: "0.0.1",
repo: "experiment",
digest: "test"
},
{
apiVersion: "3.0.0",
name: "fairwind",
version: "0.0.2",
repo: "experiment",
digest: "test",
deprecated: true
}
]
});
case "bitnami":
return Promise.resolve({
"hotdog": [
{
apiVersion: "3.0.0",
name: "hotdog",
version: "1.0.1",
repo: "bitnami",
digest: "test"
},
{
apiVersion: "3.0.0",
name: "hotdog",
version: "1.0.2",
repo: "bitnami",
digest: "test",
}
],
"pretzel": [
{
apiVersion: "3.0.0",
name: "pretzel",
version: "1.0",
repo: "bitnami",
digest: "test",
},
{
apiVersion: "3.0.0",
name: "pretzel",
version: "1.0.1",
repo: "bitnami",
digest: "test"
}
]
});
default:
return Promise.resolve({});
}
}
}

View File

@ -0,0 +1,104 @@
import { helmService } from "../helm-service";
import { repoManager } from "../helm-repo-manager";
jest.spyOn(repoManager, "init").mockImplementation();
jest.mock("../helm-chart-manager");
describe("Helm Service tests", () => {
test("list charts without deprecated ones", async () => {
jest.spyOn(repoManager, "repositories").mockImplementation(async () => {
return [
{ name: "stable", url: "stableurl" },
{ name: "experiment", url: "experimenturl" }
];
});
const charts = await helmService.listCharts();
expect(charts).toEqual({
stable: {
"apm-server": [
{
apiVersion: "3.0.0",
name: "apm-server",
version: "2.1.7",
repo: "stable",
digest: "test"
},
{
apiVersion: "3.0.0",
name: "apm-server",
version: "2.1.6",
repo: "stable",
digest: "test"
}
],
"redis": [
{
apiVersion: "3.0.0",
name: "apm-server",
version: "1.0.0",
repo: "stable",
digest: "test"
},
{
apiVersion: "3.0.0",
name: "apm-server",
version: "0.0.9",
repo: "stable",
digest: "test"
}
]
},
experiment: {}
});
});
test("list charts sorted by version in descending order", async () => {
jest.spyOn(repoManager, "repositories").mockImplementation(async () => {
return [
{ name: "bitnami", url: "bitnamiurl" }
];
});
const charts = await helmService.listCharts();
expect(charts).toEqual({
bitnami: {
"hotdog": [
{
apiVersion: "3.0.0",
name: "hotdog",
version: "1.0.2",
repo: "bitnami",
digest: "test",
},
{
apiVersion: "3.0.0",
name: "hotdog",
version: "1.0.1",
repo: "bitnami",
digest: "test"
},
],
"pretzel": [
{
apiVersion: "3.0.0",
name: "pretzel",
version: "1.0.1",
repo: "bitnami",
digest: "test",
},
{
apiVersion: "3.0.0",
name: "pretzel",
version: "1.0",
repo: "bitnami",
digest: "test"
}
]
}
});
});
});

View File

@ -4,9 +4,10 @@ import { HelmRepo, HelmRepoManager } from "./helm-repo-manager";
import logger from "../logger"; import logger from "../logger";
import { promiseExec } from "../promise-exec"; import { promiseExec } from "../promise-exec";
import { helmCli } from "./helm-cli"; import { helmCli } from "./helm-cli";
import type { RepoHelmChartList } from "../../renderer/api/endpoints/helm-charts.api";
type CachedYaml = { type CachedYaml = {
entries: any; // todo: types entries: RepoHelmChartList
}; };
export class HelmChartManager { export class HelmChartManager {
@ -24,15 +25,15 @@ export class HelmChartManager {
return charts[name]; return charts[name];
} }
public async charts(): Promise<any> { public async charts(): Promise<RepoHelmChartList> {
try { try {
const cachedYaml = await this.cachedYaml(); const cachedYaml = await this.cachedYaml();
return cachedYaml["entries"]; return cachedYaml["entries"];
} catch(error) { } catch(error) {
logger.error(error); logger.error("HELM-CHART-MANAGER]: failed to list charts", { error });
return []; return {};
} }
} }

View File

@ -1,8 +1,10 @@
import semver from "semver";
import { Cluster } from "../cluster"; import { Cluster } from "../cluster";
import logger from "../logger"; import logger from "../logger";
import { repoManager } from "./helm-repo-manager"; import { repoManager } from "./helm-repo-manager";
import { HelmChartManager } from "./helm-chart-manager"; import { HelmChartManager } from "./helm-chart-manager";
import { releaseManager } from "./helm-release-manager"; import { releaseManager } from "./helm-release-manager";
import { HelmChartList, RepoHelmChartList } from "../../renderer/api/endpoints/helm-charts.api";
class HelmService { class HelmService {
public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) { public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) {
@ -10,7 +12,7 @@ class HelmService {
} }
public async listCharts() { public async listCharts() {
const charts: any = {}; const charts: HelmChartList = {};
await repoManager.init(); await repoManager.init();
const repositories = await repoManager.repositories(); const repositories = await repoManager.repositories();
@ -18,14 +20,10 @@ class HelmService {
for (const repo of repositories) { for (const repo of repositories) {
charts[repo.name] = {}; charts[repo.name] = {};
const manager = new HelmChartManager(repo); const manager = new HelmChartManager(repo);
let entries = await manager.charts(); const sortedCharts = this.sortChartsByVersion(await manager.charts());
const enabledCharts = this.excludeDeprecatedChartGroups(sortedCharts);
entries = this.excludeDeprecated(entries); charts[repo.name] = enabledCharts;
for (const key in entries) {
entries[key] = entries[key][0];
}
charts[repo.name] = entries;
} }
return charts; return charts;
@ -96,20 +94,30 @@ class HelmService {
return { message: output }; return { message: output };
} }
protected excludeDeprecated(entries: any) { private excludeDeprecatedChartGroups(chartGroups: RepoHelmChartList) {
for (const key in entries) { const groups = new Map(Object.entries(chartGroups));
entries[key] = entries[key].filter((entry: any) => {
if (Array.isArray(entry)) {
return entry[0]["deprecated"] != true;
}
return entry["deprecated"] != true; for (const [chartName, charts] of groups) {
if (charts[0].deprecated) {
groups.delete(chartName);
}
}
return Object.fromEntries(groups);
}
private sortChartsByVersion(chartGroups: RepoHelmChartList) {
for (const key in chartGroups) {
chartGroups[key] = chartGroups[key].sort((first, second) => {
const firstVersion = semver.coerce(first.version || 0);
const secondVersion = semver.coerce(second.version || 0);
return semver.compare(secondVersion, firstVersion);
}); });
} }
return entries; return chartGroups;
} }
} }
export const helmService = new HelmService(); export const helmService = new HelmService();

View File

@ -4,7 +4,7 @@ import "../common/system-ca";
import "../common/prometheus-providers"; import "../common/prometheus-providers";
import * as Mobx from "mobx"; import * as Mobx from "mobx";
import * as LensExtensions from "../extensions/core-api"; import * as LensExtensions from "../extensions/core-api";
import { app, autoUpdater, dialog, powerMonitor } from "electron"; import { app, autoUpdater, ipcMain, dialog, powerMonitor } from "electron";
import { appName } from "../common/vars"; import { appName } from "../common/vars";
import path from "path"; import path from "path";
import { LensProxy } from "./lens-proxy"; import { LensProxy } from "./lens-proxy";
@ -25,6 +25,7 @@ import { InstalledExtension, extensionDiscovery } from "../extensions/extension-
import type { LensExtensionId } from "../extensions/lens-extension"; import type { LensExtensionId } from "../extensions/lens-extension";
import { installDeveloperTools } from "./developer-tools"; import { installDeveloperTools } from "./developer-tools";
import { filesystemProvisionerStore } from "./extension-filesystem"; import { filesystemProvisionerStore } from "./extension-filesystem";
import { LensProtocolRouterMain } from "./protocol-handler";
import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils";
import { bindBroadcastHandlers } from "../common/ipc"; import { bindBroadcastHandlers } from "../common/ipc";
import { startUpdateChecking } from "./app-updater"; import { startUpdateChecking } from "./app-updater";
@ -37,30 +38,54 @@ let windowManager: WindowManager;
app.setName(appName); app.setName(appName);
logger.info("📟 Setting Lens as protocol client for lens://");
if (app.setAsDefaultProtocolClient("lens")) {
logger.info("📟 succeeded ✅");
} else {
logger.info("📟 failed ❗");
}
if (!process.env.CICD) { if (!process.env.CICD) {
app.setPath("userData", workingDir); app.setPath("userData", workingDir);
} }
if (process.env.LENS_DISABLE_GPU) {
app.disableHardwareAcceleration();
}
mangleProxyEnv(); mangleProxyEnv();
if (app.commandLine.getSwitchValue("proxy-server") !== "") { if (app.commandLine.getSwitchValue("proxy-server") !== "") {
process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server"); process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server");
} }
const instanceLock = app.requestSingleInstanceLock(); if (!app.requestSingleInstanceLock()) {
if (!instanceLock) {
app.exit(); app.exit();
} else {
const lprm = LensProtocolRouterMain.getInstance<LensProtocolRouterMain>();
for (const arg of process.argv) {
if (arg.toLowerCase().startsWith("lens://")) {
lprm.route(arg)
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg }));
}
}
} }
app.on("second-instance", () => { app.on("second-instance", (event, argv) => {
const lprm = LensProtocolRouterMain.getInstance<LensProtocolRouterMain>();
for (const arg of argv) {
if (arg.toLowerCase().startsWith("lens://")) {
lprm.route(arg)
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg }));
}
}
windowManager?.ensureMainWindow(); windowManager?.ensureMainWindow();
}); });
if (process.env.LENS_DISABLE_GPU) {
app.disableHardwareAcceleration();
}
app.on("ready", async () => { app.on("ready", async () => {
logger.info(`🚀 Starting Lens from "${workingDir}"`); logger.info(`🚀 Starting Lens from "${workingDir}"`);
logger.info("🐚 Syncing shell environment"); logger.info("🐚 Syncing shell environment");
@ -128,7 +153,19 @@ app.on("ready", async () => {
logger.info("🖥️ Starting WindowManager"); logger.info("🖥️ Starting WindowManager");
windowManager = WindowManager.getInstance<WindowManager>(proxyPort); windowManager = WindowManager.getInstance<WindowManager>(proxyPort);
windowManager.whenLoaded.then(() => startUpdateChecking());
ipcMain.on("renderer:loaded", () => {
startUpdateChecking();
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()
.rendererLoaded = true;
});
extensionLoader.whenLoaded.then(() => {
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()
.extensionsLoaded = true;
});
logger.info("🧩 Initializing extensions"); logger.info("🧩 Initializing extensions");
@ -174,8 +211,8 @@ let blockQuit = true;
autoUpdater.on("before-quit-for-update", () => blockQuit = false); autoUpdater.on("before-quit-for-update", () => blockQuit = false);
// Quit app on Cmd+Q (MacOS)
app.on("will-quit", (event) => { app.on("will-quit", (event) => {
// Quit app on Cmd+Q (MacOS)
logger.info("APP:QUIT"); logger.info("APP:QUIT");
appEventBus.emit({name: "app", action: "close"}); appEventBus.emit({name: "app", action: "close"});
@ -188,6 +225,16 @@ app.on("will-quit", (event) => {
} }
}); });
app.on("open-url", (event, rawUrl) => {
// lens:// protocol handler
event.preventDefault();
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()
.route(rawUrl)
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl }));
});
// Extensions-api runtime exports // Extensions-api runtime exports
export const LensExtensionsApi = { export const LensExtensionsApi = {
...LensExtensions, ...LensExtensions,

View File

@ -120,12 +120,18 @@ export class LensProxy {
protected createProxy(): httpProxy { protected createProxy(): httpProxy {
const proxy = httpProxy.createProxyServer(); const proxy = httpProxy.createProxyServer();
proxy.on("proxyRes", (proxyRes, req) => { proxy.on("proxyRes", (proxyRes, req, res) => {
const retryCounterId = this.getRequestId(req); const retryCounterId = this.getRequestId(req);
if (this.retryCounters.has(retryCounterId)) { if (this.retryCounters.has(retryCounterId)) {
this.retryCounters.delete(retryCounterId); this.retryCounters.delete(retryCounterId);
} }
if (!res.headersSent && req.url) {
const url = new URL(req.url, "http://localhost");
if (url.searchParams.has("watch")) res.flushHeaders();
}
}); });
proxy.on("error", (error, req, res, target) => { proxy.on("error", (error, req, res, target) => {

View File

@ -0,0 +1,259 @@
import { LensProtocolRouterMain } from "../router";
import { noop } from "../../../common/utils";
import { extensionsStore } from "../../../extensions/extensions-store";
import { extensionLoader } from "../../../extensions/extension-loader";
import * as uuid from "uuid";
import { LensMainExtension } from "../../../extensions/core-api";
import { broadcastMessage } from "../../../common/ipc";
import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler";
jest.mock("../../../common/ipc");
function throwIfDefined(val: any): void {
if (val != null) {
throw val;
}
}
describe("protocol router tests", () => {
let lpr: LensProtocolRouterMain;
beforeEach(() => {
jest.clearAllMocks();
(extensionsStore as any).state.clear();
(extensionLoader as any).instances.clear();
LensProtocolRouterMain.resetInstance();
lpr = LensProtocolRouterMain.getInstance<LensProtocolRouterMain>();
lpr.extensionsLoaded = true;
lpr.rendererLoaded = true;
});
it("should throw on non-lens URLS", async () => {
try {
expect(await lpr.route("https://google.ca")).toBeUndefined();
} catch (error) {
expect(error).toBeInstanceOf(Error);
}
});
it("should throw when host not internal or extension", async () => {
try {
expect(await lpr.route("lens://foobar")).toBeUndefined();
} catch (error) {
expect(error).toBeInstanceOf(Error);
}
});
it("should not throw when has valid host", async () => {
const extId = uuid.v4();
const ext = new LensMainExtension({
id: extId,
manifestPath: "/foo/bar",
manifest: {
name: "@mirantis/minikube",
version: "0.1.1",
},
isBundled: false,
isEnabled: true,
absolutePath: "/foo/bar",
});
ext.protocolHandlers.push({
pathSchema: "/",
handler: noop,
});
(extensionLoader as any).instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "@mirantis/minikube" });
lpr.addInternalHandler("/", noop);
try {
expect(await lpr.route("lens://app")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
try {
expect(await lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(broadcastMessage).toHaveBeenNthCalledWith(1, ProtocolHandlerInternal, "lens://app");
expect(broadcastMessage).toHaveBeenNthCalledWith(2, ProtocolHandlerExtension, "lens://extension/@mirantis/minikube");
});
it("should call handler if matches", async () => {
let called = false;
lpr.addInternalHandler("/page", () => { called = true; });
try {
expect(await lpr.route("lens://app/page")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe(true);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page");
});
it("should call most exact handler", async () => {
let called: any = 0;
lpr.addInternalHandler("/page", () => { called = 1; });
lpr.addInternalHandler("/page/:id", params => { called = params.pathname.id; });
try {
expect(await lpr.route("lens://app/page/foo")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe("foo");
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo");
});
it("should call most exact handler for an extension", async () => {
let called: any = 0;
const extId = uuid.v4();
const ext = new LensMainExtension({
id: extId,
manifestPath: "/foo/bar",
manifest: {
name: "@foobar/icecream",
version: "0.1.1",
},
isBundled: false,
isEnabled: true,
absolutePath: "/foo/bar",
});
ext.protocolHandlers
.push({
pathSchema: "/page",
handler: () => { called = 1; },
}, {
pathSchema: "/page/:id",
handler: params => { called = params.pathname.id; },
});
(extensionLoader as any).instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" });
try {
expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe("foob");
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/@foobar/icecream/page/foob");
});
it("should work with non-org extensions", async () => {
let called: any = 0;
{
const extId = uuid.v4();
const ext = new LensMainExtension({
id: extId,
manifestPath: "/foo/bar",
manifest: {
name: "@foobar/icecream",
version: "0.1.1",
},
isBundled: false,
isEnabled: true,
absolutePath: "/foo/bar",
});
ext.protocolHandlers
.push({
pathSchema: "/page/:id",
handler: params => { called = params.pathname.id; },
});
(extensionLoader as any).instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" });
}
{
const extId = uuid.v4();
const ext = new LensMainExtension({
id: extId,
manifestPath: "/foo/bar",
manifest: {
name: "icecream",
version: "0.1.1",
},
isBundled: false,
isEnabled: true,
absolutePath: "/foo/bar",
});
ext.protocolHandlers
.push({
pathSchema: "/page",
handler: () => { called = 1; },
});
(extensionLoader as any).instances.set(extId, ext);
(extensionsStore as any).state.set(extId, { enabled: true, name: "icecream" });
}
(extensionsStore as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" });
(extensionsStore as any).state.set("icecream", { enabled: true, name: "icecream" });
try {
expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe(1);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/icecream/page");
});
it("should throw if urlSchema is invalid", () => {
expect(() => lpr.addInternalHandler("/:@", noop)).toThrowError();
});
it("should call most exact handler with 3 found handlers", async () => {
let called: any = 0;
lpr.addInternalHandler("/", () => { called = 2; });
lpr.addInternalHandler("/page", () => { called = 1; });
lpr.addInternalHandler("/page/foo", () => { called = 3; });
lpr.addInternalHandler("/page/bar", () => { called = 4; });
try {
expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe(3);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat");
});
it("should call most exact handler with 2 found handlers", async () => {
let called: any = 0;
lpr.addInternalHandler("/", () => { called = 2; });
lpr.addInternalHandler("/page", () => { called = 1; });
lpr.addInternalHandler("/page/bar", () => { called = 4; });
try {
expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined();
} catch (error) {
expect(throwIfDefined(error)).not.toThrow();
}
expect(called).toBe(1);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat");
});
});

View File

@ -0,0 +1 @@
export * from "./router";

View File

@ -0,0 +1,112 @@
import logger from "../logger";
import * as proto from "../../common/protocol-handler";
import Url from "url-parse";
import { LensExtension } from "../../extensions/lens-extension";
import { broadcastMessage } from "../../common/ipc";
import { observable, when } from "mobx";
export interface FallbackHandler {
(name: string): Promise<boolean>;
}
export class LensProtocolRouterMain extends proto.LensProtocolRouter {
private missingExtensionHandlers: FallbackHandler[] = [];
@observable rendererLoaded = false;
@observable extensionsLoaded = false;
/**
* Find the most specific registered handler, if it exists, and invoke it.
*
* This will send an IPC message to the renderer router to do the same
* in the renderer.
*/
public async route(rawUrl: string): Promise<void> {
try {
const url = new Url(rawUrl, true);
if (url.protocol.toLowerCase() !== "lens:") {
throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url);
}
logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: routing ${url.toString()}`);
switch (url.host) {
case "app":
return this._routeToInternal(url);
case "extension":
await when(() => this.extensionsLoaded);
return this._routeToExtension(url);
default:
throw new proto.RoutingError(proto.RoutingErrorType.INVALID_HOST, url);
}
} catch (error) {
if (error instanceof proto.RoutingError) {
logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url });
} else {
logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { rawUrl });
}
}
}
protected async _executeMissingExtensionHandlers(extensionName: string): Promise<boolean> {
for (const handler of this.missingExtensionHandlers) {
if (await handler(extensionName)) {
return true;
}
}
return false;
}
protected async _findMatchingExtensionByName(url: Url): Promise<LensExtension | string> {
const firstAttempt = await super._findMatchingExtensionByName(url);
if (typeof firstAttempt !== "string") {
return firstAttempt;
}
if (await this._executeMissingExtensionHandlers(firstAttempt)) {
return super._findMatchingExtensionByName(url);
}
return "";
}
protected async _routeToInternal(url: Url): Promise<void> {
const rawUrl = url.toString(); // for sending to renderer
super._routeToInternal(url);
await when(() => this.rendererLoaded);
return broadcastMessage(proto.ProtocolHandlerInternal, rawUrl);
}
protected async _routeToExtension(url: Url): Promise<void> {
const rawUrl = url.toString(); // for sending to renderer
/**
* This needs to be done first, so that the missing extension handlers can
* be called before notifying the renderer.
*
* Note: this needs to clone the url because _routeToExtension modifies its
* argument.
*/
await super._routeToExtension(new Url(url.toString(), true));
await when(() => this.rendererLoaded);
return broadcastMessage(proto.ProtocolHandlerExtension, rawUrl);
}
/**
* Add a function to the list which will be sequentially called if an extension
* is not found while routing to the extensions
* @param handler A function that tries to find an extension
*/
public addMissingExtensionHandler(handler: FallbackHandler): void {
this.missingExtensionHandlers.push(handler);
}
}

View File

@ -111,6 +111,14 @@ export class ShellSession extends EventEmitter {
env["SystemRoot"] = process.env.SystemRoot; env["SystemRoot"] = process.env.SystemRoot;
env["PTYSHELL"] = shell || "powershell.exe"; env["PTYSHELL"] = shell || "powershell.exe";
env["PATH"] = pathStr; env["PATH"] = pathStr;
env["LENS_SESSION"] = "true";
const lensWslEnv = "KUBECONFIG/up:LENS_SESSION/u";
if (process.env.WSLENV != undefined) {
env["WSLENV"] = `${process.env["WSLENV"]}:${lensWslEnv}`;
} else {
env["WSLENV"] = lensWslEnv;
}
} else if(shell !== undefined) { } else if(shell !== undefined) {
env["PTYSHELL"] = shell; env["PTYSHELL"] = shell;
env["PATH"] = pathStr; env["PATH"] = pathStr;

View File

@ -1,5 +1,5 @@
import type { ClusterId } from "../common/cluster-store"; import type { ClusterId } from "../common/cluster-store";
import { observable, when } from "mobx"; import { observable } from "mobx";
import { app, BrowserWindow, dialog, shell, webContents } from "electron"; import { app, BrowserWindow, dialog, shell, webContents } from "electron";
import windowStateKeeper from "electron-window-state"; import windowStateKeeper from "electron-window-state";
import { appEventBus } from "../common/event-bus"; import { appEventBus } from "../common/event-bus";
@ -16,9 +16,6 @@ export class WindowManager extends Singleton {
protected windowState: windowStateKeeper.State; protected windowState: windowStateKeeper.State;
protected disposers: Record<string, Function> = {}; protected disposers: Record<string, Function> = {};
@observable mainViewInitiallyLoaded = false;
whenLoaded = when(() => this.mainViewInitiallyLoaded);
@observable activeClusterId: ClusterId; @observable activeClusterId: ClusterId;
constructor(protected proxyPort: number) { constructor(protected proxyPort: number) {
@ -104,7 +101,6 @@ export class WindowManager extends Singleton {
setTimeout(() => { setTimeout(() => {
appEventBus.emit({ name: "app", action: "start" }); appEventBus.emit({ name: "app", action: "start" });
}, 1000); }, 1000);
this.mainViewInitiallyLoaded = true;
} catch (err) { } catch (err) {
dialog.showErrorBox("ERROR!", err.toString()); dialog.showErrorBox("ERROR!", err.toString());
} }

View File

@ -3,11 +3,8 @@ import { apiBase } from "../index";
import { stringify } from "querystring"; import { stringify } from "querystring";
import { autobind } from "../../utils"; import { autobind } from "../../utils";
interface IHelmChartList { export type RepoHelmChartList = Record<string, HelmChart[]>;
[repo: string]: { export type HelmChartList = Record<string, RepoHelmChartList>;
[name: string]: HelmChart;
};
}
export interface IHelmChartDetails { export interface IHelmChartDetails {
readme: string; readme: string;
@ -22,12 +19,12 @@ const endpoint = compile(`/v2/charts/:repo?/:name?`) as (params?: {
export const helmChartsApi = { export const helmChartsApi = {
list() { list() {
return apiBase return apiBase
.get<IHelmChartList>(endpoint()) .get<HelmChartList>(endpoint())
.then(data => { .then(data => {
return Object return Object
.values(data) .values(data)
.reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), []) .reduce((allCharts, repoCharts) => allCharts.concat(Object.values(repoCharts)), [])
.map(HelmChart.create); .map(([chart]) => HelmChart.create(chart));
}); });
}, },

View File

@ -187,7 +187,7 @@ export class HelmRelease implements ItemObject {
} }
getVersion() { getVersion() {
const versions = this.chart.match(/(v?\d+)[^-].*$/); const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/);
if (versions) { if (versions) {
return versions[0]; return versions[0];
@ -198,10 +198,9 @@ export class HelmRelease implements ItemObject {
} }
getUpdated(humanize = true, compact = true) { getUpdated(humanize = true, compact = true) {
const now = new Date().getTime();
const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date() const updated = this.updated.replace(/\s\w*$/, ""); // 2019-11-26 10:58:09 +0300 MSK -> 2019-11-26 10:58:09 +0300 to pass into Date()
const updatedDate = new Date(updated).getTime(); const updatedDate = new Date(updated).getTime();
const diff = now - updatedDate; const diff = Date.now() - updatedDate;
if (humanize) { if (humanize) {
return formatDuration(diff, compact); return formatDuration(diff, compact);

View File

@ -63,10 +63,16 @@ export class PersistentVolume extends KubeObject {
return this.status.phase || "-"; return this.status.phase || "-";
} }
getClaimRefName() { getStorageClass(): string {
const { claimRef } = this.spec; return this.spec.storageClassName;
}
return claimRef ? claimRef.name : ""; getClaimRefName(): string {
return this.spec.claimRef?.name ?? "";
}
getStorageClassName() {
return this.spec.storageClassName || "";
} }
} }

View File

@ -1,11 +1,11 @@
import { JsonApi, JsonApiErrorParsed } from "./json-api"; import { JsonApi, JsonApiErrorParsed } from "./json-api";
import { KubeJsonApi } from "./kube-json-api"; import { KubeJsonApi } from "./kube-json-api";
import { Notifications } from "../components/notifications"; import { Notifications } from "../components/notifications";
import { apiKubePrefix, apiPrefix, isDevelopment } from "../../common/vars"; import { apiKubePrefix, apiPrefix, isDebugging, isDevelopment } from "../../common/vars";
export const apiBase = new JsonApi({ export const apiBase = new JsonApi({
apiBase: apiPrefix, apiBase: apiPrefix,
debug: isDevelopment, debug: isDevelopment || isDebugging,
}); });
export const apiKube = new KubeJsonApi({ export const apiKube = new KubeJsonApi({
apiBase: apiKubePrefix, apiBase: apiKubePrefix,

View File

@ -272,6 +272,7 @@ export class KubeApi<T extends KubeObject = any> {
} }
protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any {
if (!data) return;
const KubeObjectConstructor = this.objectConstructor; const KubeObjectConstructor = this.objectConstructor;
if (KubeObject.isJsonApiData(data)) { if (KubeObject.isJsonApiData(data)) {

View File

@ -123,7 +123,7 @@ export class KubeObject implements ItemObject {
} }
getTimeDiffFromNow(): number { getTimeDiffFromNow(): number {
return new Date().getTime() - new Date(this.metadata.creationTimestamp).getTime(); return Date.now() - new Date(this.metadata.creationTimestamp).getTime();
} }
getAge(humanize = true, compact = true, fromNow = false): string | number { getAge(humanize = true, compact = true, fromNow = false): string | number {

View File

@ -147,7 +147,7 @@ export class AddCluster extends React.Component {
try { try {
const kubeConfig = this.kubeContexts.get(context); const kubeConfig = this.kubeContexts.get(context);
validateKubeConfig(kubeConfig); validateKubeConfig(kubeConfig, context);
return true; return true;
} catch (err) { } catch (err) {

View File

@ -82,9 +82,9 @@ export class HelmChartDetails extends Component<Props> {
<div className="intro-contents box grow"> <div className="intro-contents box grow">
<div className="description flex align-center justify-space-between"> <div className="description flex align-center justify-space-between">
{selectedChart.getDescription()} {selectedChart.getDescription()}
<Button primary label={`Install`} onClick={this.install} /> <Button primary label="Install" onClick={this.install} />
</div> </div>
<DrawerItem name={`Version`} className="version" onClick={stopPropagation}> <DrawerItem name="Version" className="version" onClick={stopPropagation}>
<Select <Select
themeName="outlined" themeName="outlined"
menuPortalTarget={null} menuPortalTarget={null}
@ -93,16 +93,16 @@ export class HelmChartDetails extends Component<Props> {
onChange={onVersionChange} onChange={onVersionChange}
/> />
</DrawerItem> </DrawerItem>
<DrawerItem name={`Home`}> <DrawerItem name="Home">
<a href={selectedChart.getHome()} target="_blank" rel="noreferrer">{selectedChart.getHome()}</a> <a href={selectedChart.getHome()} target="_blank" rel="noreferrer">{selectedChart.getHome()}</a>
</DrawerItem> </DrawerItem>
<DrawerItem name={`Maintainers`} className="maintainers"> <DrawerItem name="Maintainers" className="maintainers">
{selectedChart.getMaintainers().map(({ name, email, url }) => {selectedChart.getMaintainers().map(({ name, email, url }) =>
<a key={name} href={url || `mailto:${email}`} target="_blank" rel="noreferrer">{name}</a> <a key={name} href={url || `mailto:${email}`} target="_blank" rel="noreferrer">{name}</a>
)} )}
</DrawerItem> </DrawerItem>
{selectedChart.getKeywords().length > 0 && ( {selectedChart.getKeywords().length > 0 && (
<DrawerItem name={`Keywords`} labelsOnly> <DrawerItem name="Keywords" labelsOnly>
{selectedChart.getKeywords().map(key => <Badge key={key} label={key} />)} {selectedChart.getKeywords().map(key => <Badge key={key} label={key} />)}
</DrawerItem> </DrawerItem>
)} )}

View File

@ -72,11 +72,8 @@ export class HelmCharts extends Component<Props> {
(chart: HelmChart) => chart.getAppVersion(), (chart: HelmChart) => chart.getAppVersion(),
(chart: HelmChart) => chart.getKeywords(), (chart: HelmChart) => chart.getKeywords(),
]} ]}
filterItems={[
(items: HelmChart[]) => items.filter(item => !item.deprecated)
]}
customizeHeader={() => ( customizeHeader={() => (
<SearchInputUrl placeholder={`Search Helm Charts`} /> <SearchInputUrl placeholder="Search Helm Charts" />
)} )}
renderTableHeader={[ renderTableHeader={[
{ className: "icon", showWithColumn: columnId.name }, { className: "icon", showWithColumn: columnId.name },

View File

@ -111,7 +111,7 @@ export class ReleaseDetails extends Component<Props> {
return ( return (
<div className="values"> <div className="values">
<DrawerTitle title={`Values`}/> <DrawerTitle title="Values"/>
<div className="flex column gaps"> <div className="flex column gaps">
<AceEditor <AceEditor
mode="yaml" mode="yaml"
@ -120,7 +120,7 @@ export class ReleaseDetails extends Component<Props> {
/> />
<Button <Button
primary primary
label={`Save`} label="Save"
waiting={saving} waiting={saving}
onClick={this.updateValues} onClick={this.updateValues}
/> />
@ -200,7 +200,7 @@ export class ReleaseDetails extends Component<Props> {
<span>{release.getChart()}</span> <span>{release.getChart()}</span>
<Button <Button
primary primary
label={`Upgrade`} label="Upgrade"
className="box right upgrade" className="box right upgrade"
onClick={this.upgradeVersion} onClick={this.upgradeVersion}
/> />
@ -226,9 +226,9 @@ export class ReleaseDetails extends Component<Props> {
/> />
</DrawerItem> </DrawerItem>
{this.renderValues()} {this.renderValues()}
<DrawerTitle title={`Notes`}/> <DrawerTitle title="Notes"/>
{this.renderNotes()} {this.renderNotes()}
<DrawerTitle title={`Resources`}/> <DrawerTitle title="Resources"/>
{this.renderResources()} {this.renderResources()}
</div> </div>
); );

View File

@ -42,7 +42,7 @@ export class HelmReleaseMenu extends React.Component<Props> {
<> <>
{hasRollback && ( {hasRollback && (
<MenuItem onClick={this.rollback}> <MenuItem onClick={this.rollback}>
<Icon material="history" interactive={toolbar} title={`Rollback`}/> <Icon material="history" interactive={toolbar} title="Rollback"/>
<span className="title">Rollback</span> <span className="title">Rollback</span>
</MenuItem> </MenuItem>
)} )}

View File

@ -143,7 +143,7 @@ export const ClusterPieCharts = observer(() => {
<div className="chart flex column align-center box grow"> <div className="chart flex column align-center box grow">
<PieChart <PieChart
data={cpuData} data={cpuData}
title={`CPU`} title="CPU"
legendColors={["#c93dce", "#4caf50", "#3d90ce", defaultColor]} legendColors={["#c93dce", "#4caf50", "#3d90ce", defaultColor]}
/> />
{cpuLimitsOverload && renderLimitWarning()} {cpuLimitsOverload && renderLimitWarning()}
@ -151,7 +151,7 @@ export const ClusterPieCharts = observer(() => {
<div className="chart flex column align-center box grow"> <div className="chart flex column align-center box grow">
<PieChart <PieChart
data={memoryData} data={memoryData}
title={`Memory`} title="Memory"
legendColors={["#c93dce", "#4caf50", "#3d90ce", defaultColor]} legendColors={["#c93dce", "#4caf50", "#3d90ce", defaultColor]}
/> />
{memoryLimitsOverload && renderLimitWarning()} {memoryLimitsOverload && renderLimitWarning()}
@ -159,7 +159,7 @@ export const ClusterPieCharts = observer(() => {
<div className="chart flex column align-center box grow"> <div className="chart flex column align-center box grow">
<PieChart <PieChart
data={podsData} data={podsData}
title={`Pods`} title="Pods"
legendColors={["#4caf50", defaultColor]} legendColors={["#4caf50", defaultColor]}
/> />
</div> </div>

View File

@ -63,21 +63,21 @@ export class LimitRangeDetails extends React.Component<Props> {
<KubeObjectMeta object={limitRange}/> <KubeObjectMeta object={limitRange}/>
{containerLimits.length > 0 && {containerLimits.length > 0 &&
<DrawerItem name={`Container Limits`} labelsOnly> <DrawerItem name="Container Limits" labelsOnly>
{ {
renderLimitDetails(containerLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) renderLimitDetails(containerLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE])
} }
</DrawerItem> </DrawerItem>
} }
{podLimits.length > 0 && {podLimits.length > 0 &&
<DrawerItem name={`Pod Limits`} labelsOnly> <DrawerItem name="Pod Limits" labelsOnly>
{ {
renderLimitDetails(podLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE]) renderLimitDetails(podLimits, [Resource.CPU, Resource.MEMORY, Resource.EPHEMERAL_STORAGE])
} }
</DrawerItem> </DrawerItem>
} }
{pvcLimits.length > 0 && {pvcLimits.length > 0 &&
<DrawerItem name={`Persistent Volume Claim Limits`} labelsOnly> <DrawerItem name="Persistent Volume Claim Limits" labelsOnly>
{ {
renderLimitDetails(pvcLimits, [Resource.STORAGE]) renderLimitDetails(pvcLimits, [Resource.STORAGE])
} }

View File

@ -30,7 +30,7 @@ export class LimitRanges extends React.Component<Props> {
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (item: LimitRange) => item.getName(), [columnId.name]: (item: LimitRange) => item.getName(),
[columnId.namespace]: (item: LimitRange) => item.getNs(), [columnId.namespace]: (item: LimitRange) => item.getNs(),
[columnId.age]: (item: LimitRange) => item.metadata.creationTimestamp, [columnId.age]: (item: LimitRange) => item.getTimeDiffFromNow(),
}} }}
searchFilters={[ searchFilters={[
(item: LimitRange) => item.getName(), (item: LimitRange) => item.getName(),

View File

@ -31,7 +31,7 @@ export class ConfigMaps extends React.Component<Props> {
[columnId.name]: (item: ConfigMap) => item.getName(), [columnId.name]: (item: ConfigMap) => item.getName(),
[columnId.namespace]: (item: ConfigMap) => item.getNs(), [columnId.namespace]: (item: ConfigMap) => item.getNs(),
[columnId.keys]: (item: ConfigMap) => item.getKeys(), [columnId.keys]: (item: ConfigMap) => item.getKeys(),
[columnId.age]: (item: ConfigMap) => item.metadata.creationTimestamp, [columnId.age]: (item: ConfigMap) => item.getTimeDiffFromNow(),
}} }}
searchFilters={[ searchFilters={[
(item: ConfigMap) => item.getSearchFields(), (item: ConfigMap) => item.getSearchFields(),

View File

@ -146,7 +146,7 @@ export class AddQuotaDialog extends React.Component<Props> {
<div className="flex gaps"> <div className="flex gaps">
<Input <Input
required autoFocus required autoFocus
placeholder={`ResourceQuota name`} placeholder="ResourceQuota name"
validators={systemName} validators={systemName}
value={this.quotaName} onChange={v => this.quotaName = v.toLowerCase()} value={this.quotaName} onChange={v => this.quotaName = v.toLowerCase()}
className="box grow" className="box grow"
@ -156,7 +156,7 @@ export class AddQuotaDialog extends React.Component<Props> {
<SubTitle title="Namespace" /> <SubTitle title="Namespace" />
<NamespaceSelect <NamespaceSelect
value={this.namespace} value={this.namespace}
placeholder={`Namespace`} placeholder="Namespace"
themeName="light" themeName="light"
className="box grow" className="box grow"
onChange={({ value }) => this.namespace = value} onChange={({ value }) => this.namespace = value}
@ -167,14 +167,14 @@ export class AddQuotaDialog extends React.Component<Props> {
<Select <Select
className="quota-select" className="quota-select"
themeName="light" themeName="light"
placeholder={`Select a quota..`} placeholder="Select a quota.."
options={this.quotaOptions} options={this.quotaOptions}
value={this.quotaSelectValue} value={this.quotaSelectValue}
onChange={({ value }) => this.quotaSelectValue = value} onChange={({ value }) => this.quotaSelectValue = value}
/> />
<Input <Input
maxLength={10} maxLength={10}
placeholder={`Value`} placeholder="Value"
value={this.quotaInputValue} value={this.quotaInputValue}
onChange={v => this.quotaInputValue = v} onChange={v => this.quotaInputValue = v}
onKeyDown={this.onInputQuota} onKeyDown={this.onInputQuota}
@ -183,7 +183,7 @@ export class AddQuotaDialog extends React.Component<Props> {
<Button round primary onClick={this.setQuota}> <Button round primary onClick={this.setQuota}>
<Icon <Icon
material={this.quotas[this.quotaSelectValue] ? "edit" : "add"} material={this.quotas[this.quotaSelectValue] ? "edit" : "add"}
tooltip={`Set quota`} tooltip="Set quota"
/> />
</Button> </Button>
</div> </div>

View File

@ -31,7 +31,7 @@ export class ResourceQuotas extends React.Component<Props> {
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (item: ResourceQuota) => item.getName(), [columnId.name]: (item: ResourceQuota) => item.getName(),
[columnId.namespace]: (item: ResourceQuota) => item.getNs(), [columnId.namespace]: (item: ResourceQuota) => item.getNs(),
[columnId.age]: (item: ResourceQuota) => item.metadata.creationTimestamp, [columnId.age]: (item: ResourceQuota) => item.getTimeDiffFromNow(),
}} }}
searchFilters={[ searchFilters={[
(item: ResourceQuota) => item.getSearchFields(), (item: ResourceQuota) => item.getSearchFields(),

View File

@ -133,7 +133,7 @@ export class AddSecretDialog extends React.Component<Props> {
<SubTitle compact className="fields-title" title={upperFirst(field.toString())}> <SubTitle compact className="fields-title" title={upperFirst(field.toString())}>
<Icon <Icon
small small
tooltip={`Add field`} tooltip="Add field"
material="add_circle_outline" material="add_circle_outline"
onClick={() => this.addField(field)} onClick={() => this.addField(field)}
/> />
@ -146,7 +146,7 @@ export class AddSecretDialog extends React.Component<Props> {
<div key={index} className="secret-field flex gaps auto align-center"> <div key={index} className="secret-field flex gaps auto align-center">
<Input <Input
className="key" className="key"
placeholder={`Name`} placeholder="Name"
title={key} title={key}
tabIndex={required ? -1 : 0} tabIndex={required ? -1 : 0}
readOnly={required} readOnly={required}
@ -156,7 +156,7 @@ export class AddSecretDialog extends React.Component<Props> {
multiLine maxRows={5} multiLine maxRows={5}
required={required} required={required}
className="value" className="value"
placeholder={`Value`} placeholder="Value"
value={value} onChange={v => item.value = v} value={value} onChange={v => item.value = v}
/> />
<Icon <Icon
@ -194,7 +194,7 @@ export class AddSecretDialog extends React.Component<Props> {
<SubTitle title="Secret name" /> <SubTitle title="Secret name" />
<Input <Input
autoFocus required autoFocus required
placeholder={`Name`} placeholder="Name"
validators={systemName} validators={systemName}
value={name} onChange={v => this.name = v} value={name} onChange={v => this.name = v}
/> />

View File

@ -69,7 +69,7 @@ export class SecretDetails extends React.Component<Props> {
</DrawerItem> </DrawerItem>
{!isEmpty(this.data) && ( {!isEmpty(this.data) && (
<> <>
<DrawerTitle title={`Data`}/> <DrawerTitle title="Data"/>
{ {
Object.entries(this.data).map(([name, value]) => { Object.entries(this.data).map(([name, value]) => {
const revealSecret = this.revealSecret[name]; const revealSecret = this.revealSecret[name];
@ -107,7 +107,7 @@ export class SecretDetails extends React.Component<Props> {
} }
<Button <Button
primary primary
label={`Save`} waiting={this.isSaving} label="Save" waiting={this.isSaving}
className="save-btn" className="save-btn"
onClick={this.saveSecret} onClick={this.saveSecret}
/> />

View File

@ -38,7 +38,7 @@ export class Secrets extends React.Component<Props> {
[columnId.labels]: (item: Secret) => item.getLabels(), [columnId.labels]: (item: Secret) => item.getLabels(),
[columnId.keys]: (item: Secret) => item.getKeys(), [columnId.keys]: (item: Secret) => item.getKeys(),
[columnId.type]: (item: Secret) => item.type, [columnId.type]: (item: Secret) => item.type,
[columnId.age]: (item: Secret) => item.metadata.creationTimestamp, [columnId.age]: (item: Secret) => item.getTimeDiffFromNow(),
}} }}
searchFilters={[ searchFilters={[
(item: Secret) => item.getSearchFields(), (item: Secret) => item.getSearchFields(),

View File

@ -1,12 +1,21 @@
import { RouteProps } from "react-router"; import { RouteProps } from "react-router";
import { Config } from "./config";
import { IURLParams } from "../../../common/utils/buildUrl"; import { IURLParams } from "../../../common/utils/buildUrl";
import { configMapsURL } from "../+config-maps/config-maps.route"; import { configMapsRoute, configMapsURL } from "../+config-maps/config-maps.route";
import { hpaRoute } from "../+config-autoscalers";
import { limitRangesRoute } from "../+config-limit-ranges";
import { pdbRoute } from "../+config-pod-disruption-budgets";
import { resourceQuotaRoute } from "../+config-resource-quotas";
import { secretsRoute } from "../+config-secrets";
export const configRoute: RouteProps = { export const configRoute: RouteProps = {
get path() { path: [
return Config.tabRoutes.map(({ routePath }) => routePath).flat(); configMapsRoute,
} secretsRoute,
resourceQuotaRoute,
limitRangesRoute,
hpaRoute,
pdbRoute
].map(route => route.path.toString())
}; };
export const configURL = (params?: IURLParams) => configMapsURL(params); export const configURL = (params?: IURLParams) => configMapsURL(params);

View File

@ -57,7 +57,7 @@ export class CrdResources extends React.Component<Props> {
const sortingCallbacks: { [sortBy: string]: TableSortCallback } = { const sortingCallbacks: { [sortBy: string]: TableSortCallback } = {
[columnId.name]: (item: KubeObject) => item.getName(), [columnId.name]: (item: KubeObject) => item.getName(),
[columnId.namespace]: (item: KubeObject) => item.getNs(), [columnId.namespace]: (item: KubeObject) => item.getNs(),
[columnId.age]: (item: KubeObject) => item.metadata.creationTimestamp, [columnId.age]: (item: KubeObject) => item.getTimeDiffFromNow(),
}; };
extraColumns.forEach(column => { extraColumns.forEach(column => {

View File

@ -33,20 +33,18 @@ export class AddNamespaceDialog extends React.Component<Props> {
this.namespace = ""; this.namespace = "";
}; };
close = () => {
AddNamespaceDialog.close();
};
addNamespace = async () => { addNamespace = async () => {
const { namespace } = this; const { namespace } = this;
const { onSuccess, onError } = this.props; const { onSuccess, onError } = this.props;
try { try {
await namespaceStore.create({ name: namespace }).then(onSuccess); const created = await namespaceStore.create({ name: namespace });
this.close();
onSuccess?.(created);
AddNamespaceDialog.close();
} catch (err) { } catch (err) {
Notifications.error(err); Notifications.error(err);
onError && onError(err); onError?.(err);
} }
}; };
@ -61,9 +59,9 @@ export class AddNamespaceDialog extends React.Component<Props> {
className="AddNamespaceDialog" className="AddNamespaceDialog"
isOpen={AddNamespaceDialog.isOpen} isOpen={AddNamespaceDialog.isOpen}
onOpen={this.reset} onOpen={this.reset}
close={this.close} close={AddNamespaceDialog.close}
> >
<Wizard header={header} done={this.close}> <Wizard header={header} done={AddNamespaceDialog.close}>
<WizardStep <WizardStep
contentClass="flex gaps column" contentClass="flex gaps column"
nextLabel="Create" nextLabel="Create"
@ -72,7 +70,7 @@ export class AddNamespaceDialog extends React.Component<Props> {
<Input <Input
required autoFocus required autoFocus
iconLeft="layers" iconLeft="layers"
placeholder={`Namespace`} placeholder="Namespace"
validators={systemName} validators={systemName}
value={namespace} onChange={v => this.namespace = v.toLowerCase()} value={namespace} onChange={v => this.namespace = v.toLowerCase()}
/> />

View File

@ -60,7 +60,7 @@ export class NamespaceDetails extends React.Component<Props> {
); );
})} })}
</DrawerItem> </DrawerItem>
<DrawerItem name={`Limit Ranges`}> <DrawerItem name="Limit Ranges">
{!this.limitranges && limitRangeStore.isLoading && <Spinner/>} {!this.limitranges && limitRangeStore.isLoading && <Spinner/>}
{this.limitranges.map(limitrange => { {this.limitranges.map(limitrange => {
return ( return (

View File

@ -56,7 +56,7 @@ export class NamespaceSelectFilter extends React.Component {
if (namespace) { if (namespace) {
namespaceStore.toggleContext(namespace); namespaceStore.toggleContext(namespace);
} else { } else {
namespaceStore.resetContext(); // "All namespaces" clicked, empty list considered as "all" namespaceStore.toggleAll(false); // "All namespaces" clicked
} }
} }

View File

@ -29,6 +29,7 @@ export class NamespaceSelect extends React.Component<Props> {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
kubeWatchApi.subscribeStores([namespaceStore], { kubeWatchApi.subscribeStores([namespaceStore], {
preload: true, preload: true,
loadOnce: true, // skip reloading namespaces on every render / page visit
}) })
]); ]);
} }

View File

@ -5,7 +5,7 @@ import { Namespace, namespacesApi } from "../../api/endpoints/namespaces.api";
import { createPageParam } from "../../navigation"; import { createPageParam } from "../../navigation";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
const storage = createStorage<string[]>("context_namespaces", []); const storage = createStorage<string[]>("context_namespaces");
export const namespaceUrlParam = createPageParam<string[]>({ export const namespaceUrlParam = createPageParam<string[]>({
name: "namespaces", name: "namespaces",
@ -74,11 +74,11 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
@computed @computed
private get initialNamespaces(): string[] { private get initialNamespaces(): string[] {
const namespaces = new Set(this.allowedNamespaces); const namespaces = new Set(this.allowedNamespaces);
const prevSelected = storage.get().filter(namespace => namespaces.has(namespace)); const prevSelectedNamespaces = storage.get();
// return previously saved namespaces from local-storage // return previously saved namespaces from local-storage (if any)
if (prevSelected.length > 0) { if (prevSelectedNamespaces) {
return prevSelected; return prevSelectedNamespaces.filter(namespace => namespaces.has(namespace));
} }
// otherwise select "default" or first allowed namespace // otherwise select "default" or first allowed namespace
@ -120,7 +120,7 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
protected async loadItems(params: KubeObjectStoreLoadingParams) { protected async loadItems(params: KubeObjectStoreLoadingParams) {
const { allowedNamespaces } = this; const { allowedNamespaces } = this;
let namespaces = await super.loadItems(params); let namespaces = (await super.loadItems(params)) || [];
namespaces = namespaces.filter(namespace => allowedNamespaces.includes(namespace.getName())); namespaces = namespaces.filter(namespace => allowedNamespaces.includes(namespace.getName()));
@ -166,7 +166,7 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
if (showAll) { if (showAll) {
this.setContext(this.allowedNamespaces); this.setContext(this.allowedNamespaces);
} else { } else {
this.contextNs.clear(); this.resetContext(); // empty context considered as "All namespaces"
} }
} else { } else {
this.toggleAll(!this.hasAllContexts); this.toggleAll(!this.hasAllContexts);

View File

@ -33,7 +33,7 @@ export class Namespaces extends React.Component<Props> {
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (ns: Namespace) => ns.getName(), [columnId.name]: (ns: Namespace) => ns.getName(),
[columnId.labels]: (ns: Namespace) => ns.getLabels(), [columnId.labels]: (ns: Namespace) => ns.getLabels(),
[columnId.age]: (ns: Namespace) => ns.metadata.creationTimestamp, [columnId.age]: (ns: Namespace) => ns.getTimeDiffFromNow(),
[columnId.status]: (ns: Namespace) => ns.getStatus(), [columnId.status]: (ns: Namespace) => ns.getStatus(),
}} }}
searchFilters={[ searchFilters={[

View File

@ -30,7 +30,7 @@ export class Endpoints extends React.Component<Props> {
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (endpoint: Endpoint) => endpoint.getName(), [columnId.name]: (endpoint: Endpoint) => endpoint.getName(),
[columnId.namespace]: (endpoint: Endpoint) => endpoint.getNs(), [columnId.namespace]: (endpoint: Endpoint) => endpoint.getNs(),
[columnId.age]: (endpoint: Endpoint) => endpoint.metadata.creationTimestamp, [columnId.age]: (endpoint: Endpoint) => endpoint.getTimeDiffFromNow(),
}} }}
searchFilters={[ searchFilters={[
(endpoint: Endpoint) => endpoint.getSearchFields() (endpoint: Endpoint) => endpoint.getSearchFields()

View File

@ -31,7 +31,7 @@ export class Ingresses extends React.Component<Props> {
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (ingress: Ingress) => ingress.getName(), [columnId.name]: (ingress: Ingress) => ingress.getName(),
[columnId.namespace]: (ingress: Ingress) => ingress.getNs(), [columnId.namespace]: (ingress: Ingress) => ingress.getNs(),
[columnId.age]: (ingress: Ingress) => ingress.metadata.creationTimestamp, [columnId.age]: (ingress: Ingress) => ingress.getTimeDiffFromNow(),
}} }}
searchFilters={[ searchFilters={[
(ingress: Ingress) => ingress.getSearchFields(), (ingress: Ingress) => ingress.getSearchFields(),

View File

@ -30,7 +30,7 @@ export class NetworkPolicies extends React.Component<Props> {
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (item: NetworkPolicy) => item.getName(), [columnId.name]: (item: NetworkPolicy) => item.getName(),
[columnId.namespace]: (item: NetworkPolicy) => item.getNs(), [columnId.namespace]: (item: NetworkPolicy) => item.getNs(),
[columnId.age]: (item: NetworkPolicy) => item.metadata.creationTimestamp, [columnId.age]: (item: NetworkPolicy) => item.getTimeDiffFromNow(),
}} }}
searchFilters={[ searchFilters={[
(item: NetworkPolicy) => item.getSearchFields(), (item: NetworkPolicy) => item.getSearchFields(),

View File

@ -117,7 +117,7 @@ export class NetworkPolicyDetails extends React.Component<Props> {
{ingress && ( {ingress && (
<> <>
<DrawerTitle title={`Ingress`}/> <DrawerTitle title="Ingress"/>
{ingress.map((ingress, i) => { {ingress.map((ingress, i) => {
const { ports } = ingress; const { ports } = ingress;

View File

@ -50,7 +50,7 @@ export class ServiceDetails extends React.Component<Props> {
{spec.sessionAffinity} {spec.sessionAffinity}
</DrawerItem> </DrawerItem>
<DrawerTitle title={`Connection`}/> <DrawerTitle title="Connection"/>
<DrawerItem name="Cluster IP"> <DrawerItem name="Cluster IP">
{spec.clusterIP} {spec.clusterIP}
@ -77,7 +77,7 @@ export class ServiceDetails extends React.Component<Props> {
{spec.loadBalancerIP} {spec.loadBalancerIP}
</DrawerItem> </DrawerItem>
)} )}
<DrawerTitle title={`Endpoint`}/> <DrawerTitle title="Endpoint"/>
<ServiceDetailsEndpoint endpoint={endpoint}/> <ServiceDetailsEndpoint endpoint={endpoint}/>
</div> </div>

View File

@ -37,7 +37,7 @@ export class ServicePortComponent extends React.Component<Props> {
return ( return (
<div className={cssNames("ServicePortComponent", { waiting: this.waiting })}> <div className={cssNames("ServicePortComponent", { waiting: this.waiting })}>
<span title={`Open in a browser`} onClick={() => this.portForward() }> <span title="Open in a browser" onClick={() => this.portForward() }>
{port.toString()} {port.toString()}
{this.waiting && ( {this.waiting && (
<Spinner /> <Spinner />

View File

@ -40,7 +40,7 @@ export class Services extends React.Component<Props> {
[columnId.ports]: (service: Service) => (service.spec.ports || []).map(({ port }) => port)[0], [columnId.ports]: (service: Service) => (service.spec.ports || []).map(({ port }) => port)[0],
[columnId.clusterIp]: (service: Service) => service.getClusterIp(), [columnId.clusterIp]: (service: Service) => service.getClusterIp(),
[columnId.type]: (service: Service) => service.getType(), [columnId.type]: (service: Service) => service.getType(),
[columnId.age]: (service: Service) => service.metadata.creationTimestamp, [columnId.age]: (service: Service) => service.getTimeDiffFromNow(),
[columnId.status]: (service: Service) => service.getStatus(), [columnId.status]: (service: Service) => service.getStatus(),
}} }}
searchFilters={[ searchFilters={[

View File

@ -1,12 +1,17 @@
import { RouteProps } from "react-router"; import { RouteProps } from "react-router";
import { Network } from "./network"; import { endpointRoute } from "../+network-endpoints";
import { servicesURL } from "../+network-services"; import { ingressRoute } from "../+network-ingresses";
import { networkPoliciesRoute } from "../+network-policies";
import { servicesRoute, servicesURL } from "../+network-services";
import { IURLParams } from "../../../common/utils/buildUrl"; import { IURLParams } from "../../../common/utils/buildUrl";
export const networkRoute: RouteProps = { export const networkRoute: RouteProps = {
get path() { path: [
return Network.tabRoutes.map(({ routePath }) => routePath).flat(); servicesRoute,
} endpointRoute,
ingressRoute,
networkPoliciesRoute
].map(route => route.path.toString())
}; };
export const networkURL = (params?: IURLParams) => servicesURL(params); export const networkURL = (params?: IURLParams) => servicesURL(params);

View File

@ -150,7 +150,7 @@ export class Nodes extends React.Component<Props> {
[columnId.conditions]: (node: Node) => node.getNodeConditionText(), [columnId.conditions]: (node: Node) => node.getNodeConditionText(),
[columnId.taints]: (node: Node) => node.getTaints().length, [columnId.taints]: (node: Node) => node.getTaints().length,
[columnId.roles]: (node: Node) => node.getRoleLabels(), [columnId.roles]: (node: Node) => node.getRoleLabels(),
[columnId.age]: (node: Node) => node.metadata.creationTimestamp, [columnId.age]: (node: Node) => node.getTimeDiffFromNow(),
[columnId.version]: (node: Node) => node.getKubeletVersion(), [columnId.version]: (node: Node) => node.getKubeletVersion(),
}} }}
searchFilters={[ searchFilters={[

View File

@ -1,3 +1,2 @@
export * from "./pod-security-policies.route";
export * from "./pod-security-policies"; export * from "./pod-security-policies";
export * from "./pod-security-policy-details"; export * from "./pod-security-policy-details";

View File

@ -1,8 +0,0 @@
import type { RouteProps } from "react-router";
import { buildURL } from "../../../common/utils/buildUrl";
export const podSecurityPoliciesRoute: RouteProps = {
path: "/pod-security-policies"
};
export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path);

View File

@ -28,7 +28,7 @@ export class PodSecurityPolicies extends React.Component {
[columnId.name]: (item: PodSecurityPolicy) => item.getName(), [columnId.name]: (item: PodSecurityPolicy) => item.getName(),
[columnId.volumes]: (item: PodSecurityPolicy) => item.getVolumes(), [columnId.volumes]: (item: PodSecurityPolicy) => item.getVolumes(),
[columnId.privileged]: (item: PodSecurityPolicy) => +item.isPrivileged(), [columnId.privileged]: (item: PodSecurityPolicy) => +item.isPrivileged(),
[columnId.age]: (item: PodSecurityPolicy) => item.metadata.creationTimestamp, [columnId.age]: (item: PodSecurityPolicy) => item.getTimeDiffFromNow(),
}} }}
searchFilters={[ searchFilters={[
(item: PodSecurityPolicy) => item.getSearchFields(), (item: PodSecurityPolicy) => item.getSearchFields(),

View File

@ -111,7 +111,7 @@ export class AddHelmRepoDialog extends React.Component<Props> {
<> <>
<SubTitle title="Security settings" /> <SubTitle title="Security settings" />
<Checkbox <Checkbox
label={`Skip TLS certificate checks for the repository`} label="Skip TLS certificate checks for the repository"
value={this.helmRepo.insecureSkipTlsVerify} value={this.helmRepo.insecureSkipTlsVerify}
onChange={v => this.helmRepo.insecureSkipTlsVerify = v} onChange={v => this.helmRepo.insecureSkipTlsVerify = v}
/> />
@ -120,12 +120,12 @@ export class AddHelmRepoDialog extends React.Component<Props> {
{this.renderFileInput(`Cerificate file`, FileType.CertFile, AddHelmRepoDialog.certExtensions)} {this.renderFileInput(`Cerificate file`, FileType.CertFile, AddHelmRepoDialog.certExtensions)}
<SubTitle title="Chart Repository Credentials" /> <SubTitle title="Chart Repository Credentials" />
<Input <Input
placeholder={`Username`} placeholder="Username"
value={this.helmRepo.username} onChange= {v => this.helmRepo.username = v} value={this.helmRepo.username} onChange= {v => this.helmRepo.username = v}
/> />
<Input <Input
type="password" type="password"
placeholder={`Password`} placeholder="Password"
value={this.helmRepo.password} onChange={v => this.helmRepo.password = v} value={this.helmRepo.password} onChange={v => this.helmRepo.password = v}
/> />
</>); </>);
@ -148,13 +148,13 @@ export class AddHelmRepoDialog extends React.Component<Props> {
<div className="flex column gaps"> <div className="flex column gaps">
<Input <Input
autoFocus required autoFocus required
placeholder={`Helm repo name`} placeholder="Helm repo name"
validators={systemName} validators={systemName}
value={this.helmRepo.name} onChange={v => this.helmRepo.name = v} value={this.helmRepo.name} onChange={v => this.helmRepo.name = v}
/> />
<Input <Input
required required
placeholder={`URL`} placeholder="URL"
validators={isUrl} validators={isUrl}
value={this.helmRepo.url} onChange={v => this.helmRepo.url = v} value={this.helmRepo.url} onChange={v => this.helmRepo.url = v}
/> />
@ -162,7 +162,7 @@ export class AddHelmRepoDialog extends React.Component<Props> {
More More
<Icon <Icon
small small
tooltip={`More`} tooltip="More"
material={this.showOptions ? "remove" : "add"} material={this.showOptions ? "remove" : "add"}
/> />
</Button> </Button>

View File

@ -133,7 +133,7 @@ export class Preferences extends React.Component {
<h2>HTTP Proxy</h2> <h2>HTTP Proxy</h2>
<Input <Input
theme="round-black" theme="round-black"
placeholder={`Type HTTP proxy url (example: http://proxy.acme.org:8080)`} placeholder="Type HTTP proxy url (example: http://proxy.acme.org:8080)"
value={this.httpProxy} value={this.httpProxy}
onChange={v => this.httpProxy = v} onChange={v => this.httpProxy = v}
onBlur={() => preferences.httpsProxy = this.httpProxy} onBlur={() => preferences.httpsProxy = this.httpProxy}

View File

@ -10,14 +10,22 @@ import { KubeObjectDetailsProps } from "../kube-object";
import { StorageClass } from "../../api/endpoints"; import { StorageClass } from "../../api/endpoints";
import { KubeObjectMeta } from "../kube-object/kube-object-meta"; import { KubeObjectMeta } from "../kube-object/kube-object-meta";
import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry"; import { kubeObjectDetailRegistry } from "../../api/kube-object-detail-registry";
import { storageClassStore } from "./storage-class.store";
import { VolumeDetailsList } from "../+storage-volumes/volume-details-list";
import { volumesStore } from "../+storage-volumes/volumes.store";
interface Props extends KubeObjectDetailsProps<StorageClass> { interface Props extends KubeObjectDetailsProps<StorageClass> {
} }
@observer @observer
export class StorageClassDetails extends React.Component<Props> { export class StorageClassDetails extends React.Component<Props> {
async componentDidMount() {
volumesStore.reloadAll();
}
render() { render() {
const { object: storageClass } = this.props; const { object: storageClass } = this.props;
const persistentVolumes = storageClassStore.getPersistentVolumes(storageClass);
if (!storageClass) return null; if (!storageClass) return null;
const { provisioner, parameters, mountOptions } = storageClass; const { provisioner, parameters, mountOptions } = storageClass;
@ -45,7 +53,7 @@ export class StorageClassDetails extends React.Component<Props> {
)} )}
{parameters && ( {parameters && (
<> <>
<DrawerTitle title={`Parameters`}/> <DrawerTitle title="Parameters"/>
{ {
Object.entries(parameters).map(([name, value]) => ( Object.entries(parameters).map(([name, value]) => (
<DrawerItem key={name + value} name={startCase(name)}> <DrawerItem key={name + value} name={startCase(name)}>
@ -55,6 +63,7 @@ export class StorageClassDetails extends React.Component<Props> {
} }
</> </>
)} )}
<VolumeDetailsList persistentVolumes={persistentVolumes}/>
</div> </div>
); );
} }

View File

@ -2,10 +2,15 @@ import { KubeObjectStore } from "../../kube-object.store";
import { autobind } from "../../utils"; import { autobind } from "../../utils";
import { StorageClass, storageClassApi } from "../../api/endpoints/storage-class.api"; import { StorageClass, storageClassApi } from "../../api/endpoints/storage-class.api";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
import { volumesStore } from "../+storage-volumes/volumes.store";
@autobind() @autobind()
export class StorageClassStore extends KubeObjectStore<StorageClass> { export class StorageClassStore extends KubeObjectStore<StorageClass> {
api = storageClassApi; api = storageClassApi;
getPersistentVolumes(storageClass: StorageClass) {
return volumesStore.getByStorageClass(storageClass);
}
} }
export const storageClassStore = new StorageClassStore(); export const storageClassStore = new StorageClassStore();

View File

@ -31,7 +31,7 @@ export class StorageClasses extends React.Component<Props> {
store={storageClassStore} isClusterScoped store={storageClassStore} isClusterScoped
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (item: StorageClass) => item.getName(), [columnId.name]: (item: StorageClass) => item.getName(),
[columnId.age]: (item: StorageClass) => item.metadata.creationTimestamp, [columnId.age]: (item: StorageClass) => item.getTimeDiffFromNow(),
[columnId.provisioner]: (item: StorageClass) => item.provisioner, [columnId.provisioner]: (item: StorageClass) => item.provisioner,
[columnId.reclaimPolicy]: (item: StorageClass) => item.reclaimPolicy, [columnId.reclaimPolicy]: (item: StorageClass) => item.reclaimPolicy,
}} }}

View File

@ -71,7 +71,7 @@ export class PersistentVolumeClaimDetails extends React.Component<Props> {
{volumeClaim.getStatus()} {volumeClaim.getStatus()}
</DrawerItem> </DrawerItem>
<DrawerTitle title={`Selector`}/> <DrawerTitle title="Selector"/>
<DrawerItem name="Match Labels" labelsOnly> <DrawerItem name="Match Labels" labelsOnly>
{volumeClaim.getMatchLabels().map(label => <Badge key={label} label={label}/>)} {volumeClaim.getMatchLabels().map(label => <Badge key={label} label={label}/>)}

View File

@ -43,7 +43,7 @@ export class PersistentVolumeClaims extends React.Component<Props> {
[columnId.status]: (pvc: PersistentVolumeClaim) => pvc.getStatus(), [columnId.status]: (pvc: PersistentVolumeClaim) => pvc.getStatus(),
[columnId.size]: (pvc: PersistentVolumeClaim) => unitsToBytes(pvc.getStorage()), [columnId.size]: (pvc: PersistentVolumeClaim) => unitsToBytes(pvc.getStorage()),
[columnId.storageClass]: (pvc: PersistentVolumeClaim) => pvc.spec.storageClassName, [columnId.storageClass]: (pvc: PersistentVolumeClaim) => pvc.spec.storageClassName,
[columnId.age]: (pvc: PersistentVolumeClaim) => pvc.metadata.creationTimestamp, [columnId.age]: (pvc: PersistentVolumeClaim) => pvc.getTimeDiffFromNow(),
}} }}
searchFilters={[ searchFilters={[
(item: PersistentVolumeClaim) => item.getSearchFields(), (item: PersistentVolumeClaim) => item.getSearchFields(),

View File

@ -0,0 +1,31 @@
@import "../+storage/storage-mixins";
.VolumeDetailsList {
position: relative;
.Table {
margin: 0 (-$margin * 3);
&.virtual {
height: 500px; // applicable for 100+ items
}
}
.TableCell {
&:first-child {
margin-left: $margin;
}
&:last-child {
margin-right: $margin;
}
&.name {
flex-grow: 2;
}
&.status {
@include pv-status-colors;
}
}
}

View File

@ -0,0 +1,88 @@
import "./volume-details-list.scss";
import React from "react";
import { observer } from "mobx-react";
import { PersistentVolume } from "../../api/endpoints/persistent-volume.api";
import { autobind } from "../../../common/utils/autobind";
import { TableRow } from "../table/table-row";
import { cssNames, prevDefault } from "../../utils";
import { showDetails } from "../kube-object/kube-object-details";
import { TableCell } from "../table/table-cell";
import { Spinner } from "../spinner/spinner";
import { DrawerTitle } from "../drawer/drawer-title";
import { Table } from "../table/table";
import { TableHead } from "../table/table-head";
import { volumesStore } from "./volumes.store";
import kebabCase from "lodash/kebabCase";
interface Props {
persistentVolumes: PersistentVolume[];
}
enum sortBy {
name = "name",
status = "status",
capacity = "capacity",
}
@observer
export class VolumeDetailsList extends React.Component<Props> {
private sortingCallbacks = {
[sortBy.name]: (volume: PersistentVolume) => volume.getName(),
[sortBy.capacity]: (volume: PersistentVolume) => volume.getCapacity(),
[sortBy.status]: (volume: PersistentVolume) => volume.getStatus(),
};
@autobind()
getTableRow(uid: string) {
const { persistentVolumes } = this.props;
const volume = persistentVolumes.find(volume => volume.getId() === uid);
return (
<TableRow
key={volume.getId()}
sortItem={volume}
nowrap
onClick={prevDefault(() => showDetails(volume.selfLink, false))}
>
<TableCell className="name">{volume.getName()}</TableCell>
<TableCell className="capacity">{volume.getCapacity()}</TableCell>
<TableCell className={cssNames("status", kebabCase(volume.getStatus()))}>{volume.getStatus()}</TableCell>
</TableRow>
);
}
render() {
const { persistentVolumes } = this.props;
const virtual = persistentVolumes.length > 100;
if (!persistentVolumes.length) {
return !volumesStore.isLoaded && <Spinner center/>;
}
return (
<div className="VolumeDetailsList flex column">
<DrawerTitle title="Persistent Volumes"/>
<Table
items={persistentVolumes}
selectable
virtual={virtual}
sortable={this.sortingCallbacks}
sortByDefault={{ sortBy: sortBy.name, orderBy: "desc" }}
sortSyncWithUrl={false}
getTableRow={this.getTableRow}
className="box grow"
>
<TableHead>
<TableCell className="name" sortBy={sortBy.name}>Name</TableCell>
<TableCell className="capacity" sortBy={sortBy.capacity}>Capacity</TableCell>
<TableCell className="status" sortBy={sortBy.status}>Status</TableCell>
</TableHead>
{
!virtual && persistentVolumes.map(volume => this.getTableRow(volume.getId()))
}
</Table>
</div>
);
}
}

View File

@ -26,7 +26,7 @@
flex-grow: 3; flex-grow: 3;
} }
.status { &.status {
@include pv-status-colors; @include pv-status-colors;
} }

View File

@ -2,10 +2,17 @@ import { KubeObjectStore } from "../../kube-object.store";
import { autobind } from "../../utils"; import { autobind } from "../../utils";
import { PersistentVolume, persistentVolumeApi } from "../../api/endpoints/persistent-volume.api"; import { PersistentVolume, persistentVolumeApi } from "../../api/endpoints/persistent-volume.api";
import { apiManager } from "../../api/api-manager"; import { apiManager } from "../../api/api-manager";
import { StorageClass } from "../../api/endpoints/storage-class.api";
@autobind() @autobind()
export class PersistentVolumesStore extends KubeObjectStore<PersistentVolume> { export class PersistentVolumesStore extends KubeObjectStore<PersistentVolume> {
api = persistentVolumeApi; api = persistentVolumeApi;
getByStorageClass(storageClass: StorageClass): PersistentVolume[] {
return this.items.filter(volume =>
volume.getStorageClassName() === storageClass.getName()
);
}
} }
export const volumesStore = new PersistentVolumesStore(); export const volumesStore = new PersistentVolumesStore();

View File

@ -34,10 +34,10 @@ export class PersistentVolumes extends React.Component<Props> {
store={volumesStore} isClusterScoped store={volumesStore} isClusterScoped
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (item: PersistentVolume) => item.getName(), [columnId.name]: (item: PersistentVolume) => item.getName(),
[columnId.storageClass]: (item: PersistentVolume) => item.spec.storageClassName, [columnId.storageClass]: (item: PersistentVolume) => item.getStorageClass(),
[columnId.capacity]: (item: PersistentVolume) => item.getCapacity(true), [columnId.capacity]: (item: PersistentVolume) => item.getCapacity(true),
[columnId.status]: (item: PersistentVolume) => item.getStatus(), [columnId.status]: (item: PersistentVolume) => item.getStatus(),
[columnId.age]: (item: PersistentVolume) => item.metadata.creationTimestamp, [columnId.age]: (item: PersistentVolume) => item.getTimeDiffFromNow(),
}} }}
searchFilters={[ searchFilters={[
(item: PersistentVolume) => item.getSearchFields(), (item: PersistentVolume) => item.getSearchFields(),

View File

@ -1,12 +1,15 @@
import { RouteProps } from "react-router"; import { RouteProps } from "react-router";
import { volumeClaimsURL } from "../+storage-volume-claims"; import { storageClassesRoute } from "../+storage-classes";
import { Storage } from "./storage"; import { volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims";
import { volumesRoute } from "../+storage-volumes";
import { IURLParams } from "../../../common/utils/buildUrl"; import { IURLParams } from "../../../common/utils/buildUrl";
export const storageRoute: RouteProps = { export const storageRoute: RouteProps = {
get path() { path: [
return Storage.tabRoutes.map(({ routePath }) => routePath).flat(); volumeClaimsRoute,
} volumesRoute,
storageClassesRoute
].map(route => route.path.toString())
}; };
export const storageURL = (params?: IURLParams) => volumeClaimsURL(params); export const storageURL = (params?: IURLParams) => volumeClaimsURL(params);

View File

@ -205,7 +205,7 @@ export class AddRoleBindingDialog extends React.Component<Props> {
<Select <Select
key={this.selectedRoleId} key={this.selectedRoleId}
themeName="light" themeName="light"
placeholder={`Select role..`} placeholder="Select role.."
isDisabled={this.isEditing} isDisabled={this.isEditing}
options={this.roleOptions} options={this.roleOptions}
value={this.selectedRoleId} value={this.selectedRoleId}
@ -224,7 +224,7 @@ export class AddRoleBindingDialog extends React.Component<Props> {
!this.useRoleForBindingName && ( !this.useRoleForBindingName && (
<Input <Input
autoFocus autoFocus
placeholder={`Name`} placeholder="Name"
disabled={this.isEditing} disabled={this.isEditing}
value={this.bindingName} value={this.bindingName}
onChange={v => this.bindingName = v} onChange={v => this.bindingName = v}
@ -239,7 +239,7 @@ export class AddRoleBindingDialog extends React.Component<Props> {
<Select <Select
isMulti isMulti
themeName="light" themeName="light"
placeholder={`Select service accounts`} placeholder="Select service accounts"
autoConvertOptions={false} autoConvertOptions={false}
options={this.serviceAccountOptions} options={this.serviceAccountOptions}
onChange={(opts: BindingSelectOption[]) => { onChange={(opts: BindingSelectOption[]) => {

View File

@ -117,8 +117,8 @@ export class RoleBindingDetails extends React.Component<Props> {
<AddRemoveButtons <AddRemoveButtons
onAdd={() => AddRoleBindingDialog.open(roleBinding)} onAdd={() => AddRoleBindingDialog.open(roleBinding)}
onRemove={selectedSubjects.length ? this.removeSelectedSubjects : null} onRemove={selectedSubjects.length ? this.removeSelectedSubjects : null}
addTooltip="Add bindings to {name}" addTooltip={`Add bindings to ${roleRef.name}`}
removeTooltip="Remove selected bindings from ${name}" removeTooltip={`Remove selected bindings from ${roleRef.name}`}
/> />
</div> </div>
); );

View File

@ -33,7 +33,7 @@ export class RoleBindings extends React.Component<Props> {
[columnId.name]: (binding: RoleBinding) => binding.getName(), [columnId.name]: (binding: RoleBinding) => binding.getName(),
[columnId.namespace]: (binding: RoleBinding) => binding.getNs(), [columnId.namespace]: (binding: RoleBinding) => binding.getNs(),
[columnId.bindings]: (binding: RoleBinding) => binding.getSubjectNames(), [columnId.bindings]: (binding: RoleBinding) => binding.getSubjectNames(),
[columnId.age]: (binding: RoleBinding) => binding.metadata.creationTimestamp, [columnId.age]: (binding: RoleBinding) => binding.getTimeDiffFromNow(),
}} }}
searchFilters={[ searchFilters={[
(binding: RoleBinding) => binding.getSearchFields(), (binding: RoleBinding) => binding.getSearchFields(),
@ -50,8 +50,8 @@ export class RoleBindings extends React.Component<Props> {
renderTableContents={(binding: RoleBinding) => [ renderTableContents={(binding: RoleBinding) => [
binding.getName(), binding.getName(),
<KubeObjectStatusIcon key="icon" object={binding} />, <KubeObjectStatusIcon key="icon" object={binding} />,
binding.getSubjectNames(),
binding.getNs() || "-", binding.getNs() || "-",
binding.getSubjectNames(),
binding.getAge(), binding.getAge(),
]} ]}
addRemoveButtons={{ addRemoveButtons={{

View File

@ -71,7 +71,7 @@ export class AddRoleDialog extends React.Component<Props> {
<SubTitle title="Role Name" /> <SubTitle title="Role Name" />
<Input <Input
required autoFocus required autoFocus
placeholder={`Name`} placeholder="Name"
iconLeft="supervisor_account" iconLeft="supervisor_account"
value={this.roleName} value={this.roleName}
onChange={v => this.roleName = v} onChange={v => this.roleName = v}

View File

@ -32,7 +32,7 @@ export class Roles extends React.Component<Props> {
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (role: Role) => role.getName(), [columnId.name]: (role: Role) => role.getName(),
[columnId.namespace]: (role: Role) => role.getNs(), [columnId.namespace]: (role: Role) => role.getNs(),
[columnId.age]: (role: Role) => role.metadata.creationTimestamp, [columnId.age]: (role: Role) => role.getTimeDiffFromNow(),
}} }}
searchFilters={[ searchFilters={[
(role: Role) => role.getSearchFields(), (role: Role) => role.getSearchFields(),

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