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:
commit
34f8c00e0d
15
Makefile
15
Makefile
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 |
|
||||||
|
|||||||
BIN
docs/extensions/guides/images/routing-diag.png
Normal file
BIN
docs/extensions/guides/images/routing-diag.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
83
docs/extensions/guides/protocol-handlers.md
Normal file
83
docs/extensions/guides/protocol-handlers.md
Normal 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:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
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.
|
||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ Lens has integration to Helm making it easy to install and manage Helm charts an
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 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.
|
||||||
|
|
||||||
|
|||||||
19
package.json
19
package.json
@ -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
1
scripts/test.sh
Executable file
@ -0,0 +1 @@
|
|||||||
|
jest --env=jsdom ${1:-src}
|
||||||
@ -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();
|
||||||
|
|||||||
101
src/common/__tests__/kube-helpers.test.ts
Normal file
101
src/common/__tests__/kube-helpers.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
3
src/common/ipc/invalid-kubeconfig/index.ts
Normal file
3
src/common/ipc/invalid-kubeconfig/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export const InvalidKubeconfigChannel = "invalid-kubeconfig";
|
||||||
|
|
||||||
|
export type InvalidKubeConfigArgs = [clusterId: string];
|
||||||
@ -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) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,12 @@ import logger from "../main/logger";
|
|||||||
import commandExists from "command-exists";
|
import commandExists from "command-exists";
|
||||||
import { ExecValidationNotFoundError } from "./custom-errors";
|
import { ExecValidationNotFoundError } from "./custom-errors";
|
||||||
|
|
||||||
|
export type KubeConfigValidationOpts = {
|
||||||
|
validateCluster?: boolean;
|
||||||
|
validateUser?: boolean;
|
||||||
|
validateExec?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config");
|
export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config");
|
||||||
|
|
||||||
function resolveTilde(filePath: string) {
|
function resolveTilde(filePath: string) {
|
||||||
@ -151,27 +157,42 @@ export function getNodeWarningConditions(node: V1Node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validates kubeconfig supplied in the add clusters screen. At present this will just validate
|
* Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required)
|
||||||
* the User struct, specifically the command passed to the exec substructure.
|
|
||||||
*/
|
*/
|
||||||
export function validateKubeConfig (config: KubeConfig) {
|
export function validateKubeConfig (config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}) {
|
||||||
// we only receive a single context, cluster & user object here so lets validate them as this
|
// we only receive a single context, cluster & user object here so lets validate them as this
|
||||||
// will be called when we add a new cluster to Lens
|
// will be called when we add a new cluster to Lens
|
||||||
logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`);
|
|
||||||
|
const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts;
|
||||||
|
|
||||||
|
const contextObject = config.getContextObject(contextName);
|
||||||
|
|
||||||
|
// Validate the Context Object
|
||||||
|
if (!contextObject) {
|
||||||
|
throw new Error(`No valid context object provided in kubeconfig for context '${contextName}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the Cluster Object
|
||||||
|
if (validateCluster && !config.getCluster(contextObject.cluster)) {
|
||||||
|
throw new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = config.getUser(contextObject.user);
|
||||||
|
|
||||||
// Validate the User Object
|
// Validate the User Object
|
||||||
const user = config.getCurrentUser();
|
if (validateUser && !user) {
|
||||||
|
throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
|
||||||
|
}
|
||||||
|
|
||||||
if (user.exec) {
|
// Validate exec command if present
|
||||||
|
if (validateExec && user?.exec) {
|
||||||
const execCommand = user.exec["command"];
|
const execCommand = user.exec["command"];
|
||||||
// check if the command is absolute or not
|
// check if the command is absolute or not
|
||||||
const isAbsolute = path.isAbsolute(execCommand);
|
const isAbsolute = path.isAbsolute(execCommand);
|
||||||
|
|
||||||
// validate the exec struct in the user object, start with the command field
|
// validate the exec struct in the user object, start with the command field
|
||||||
logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`);
|
|
||||||
|
|
||||||
if (!commandExists.sync(execCommand)) {
|
if (!commandExists.sync(execCommand)) {
|
||||||
logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${config.currentContext} not found`);
|
logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${contextName} not found`);
|
||||||
throw new ExecValidationNotFoundError(execCommand, isAbsolute);
|
throw new ExecValidationNotFoundError(execCommand, isAbsolute);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/common/protocol-handler/error.ts
Normal file
36
src/common/protocol-handler/error.ts
Normal 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/common/protocol-handler/index.ts
Normal file
2
src/common/protocol-handler/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from "./error";
|
||||||
|
export * from "./router";
|
||||||
218
src/common/protocol-handler/router.ts
Normal file
218
src/common/protocol-handler/router.ts
Normal 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)["/"];
|
||||||
|
}
|
||||||
@ -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" },
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
13
src/common/utils/type-narrowing.ts
Normal file
13
src/common/utils/type-narrowing.ts
Normal 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));
|
||||||
|
}
|
||||||
@ -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) {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/extensions/registries/protocol-handler-registry.ts
Normal file
44
src/extensions/registries/protocol-handler-registry.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
108
src/main/helm/__mocks__/helm-chart-manager.ts
Normal file
108
src/main/helm/__mocks__/helm-chart-manager.ts
Normal 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({});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/main/helm/__tests__/helm-service.test.ts
Normal file
104
src/main/helm/__tests__/helm-service.test.ts
Normal 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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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) => {
|
||||||
|
|||||||
259
src/main/protocol-handler/__test__/router.test.ts
Normal file
259
src/main/protocol-handler/__test__/router.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
1
src/main/protocol-handler/index.ts
Normal file
1
src/main/protocol-handler/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from "./router";
|
||||||
112
src/main/protocol-handler/router.ts
Normal file
112
src/main/protocol-handler/router.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 || "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)) {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 => {
|
||||||
|
|||||||
@ -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()}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
})
|
})
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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={[
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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={[
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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={[
|
||||||
|
|||||||
@ -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";
|
||||||
|
|||||||
@ -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);
|
|
||||||
@ -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(),
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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}/>)}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -26,7 +26,7 @@
|
|||||||
flex-grow: 3;
|
flex-grow: 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
&.status {
|
||||||
@include pv-status-colors;
|
@include pv-status-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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[]) => {
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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={{
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
Loading…
Reference in New Issue
Block a user