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.
@ -18,4 +18,4 @@ Lens will list all charts from configured Helm repositries on Apps section. To i
To update a Helm release, you can open the release details and modify the release values and click "Save" button. To upgrade or downgrade the release, click "Upgrade" button in the release details. In the release editor you can select a new chart version and edit the release values if needed and then click "Upgrade" or "Upgrade and Close" button. To update a Helm release, you can open the release details and modify the release values and click "Save" button. To upgrade or downgrade the release, click "Upgrade" button in the release details. In the release editor you can select a new chart version and edit the release values if needed and then click "Upgrade" or "Upgrade and Close" button.
## Deleting a Helm Release ## Deleting a Helm Release
To delete existing Helm release open the release details and click trash can icon on the top of the panel. Deletion removes all Kubernetes resources created by the Helm release. **Note!** If the release included any persistent volumes, those are required to remove manually! To delete existing Helm release open the release details and click trash can icon on the top of the panel. Deletion removes all Kubernetes resources created by the Helm release. **Note!** If the release included any persistent volumes, those are required to remove manually!

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, contextName: string, validationOpts: KubeConfigValidationOpts = {}) {
export function validateKubeConfig (config: KubeConfig) {
// 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