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.
|
||||||
|
|
||||||
@ -18,4 +18,4 @@ Lens will list all charts from configured Helm repositries on Apps section. To i
|
|||||||
To update a Helm release, you can open the release details and modify the release values and click "Save" button. To upgrade or downgrade the release, click "Upgrade" button in the release details. In the release editor you can select a new chart version and edit the release values if needed and then click "Upgrade" or "Upgrade and Close" button.
|
To update a Helm release, you can open the release details and modify the release values and click "Save" button. To upgrade or downgrade the release, click "Upgrade" button in the release details. In the release editor you can select a new chart version and edit the release values if needed and then click "Upgrade" or "Upgrade and Close" button.
|
||||||
|
|
||||||
## Deleting a Helm Release
|
## Deleting a Helm Release
|
||||||
To delete existing Helm release open the release details and click trash can icon on the top of the panel. Deletion removes all Kubernetes resources created by the Helm release. **Note!** If the release included any persistent volumes, those are required to remove manually!
|
To delete existing Helm release open the release details and click trash can icon on the top of the panel. Deletion removes all Kubernetes resources created by the Helm release. **Note!** If the release included any persistent volumes, those are required to remove manually!
|
||||||
|
|||||||
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, contextName: string, validationOpts: KubeConfigValidationOpts = {}) {
|
||||||
export function validateKubeConfig (config: KubeConfig) {
|
|
||||||
// we only receive a single context, cluster & user object here so lets validate them as this
|
// we only receive a single context, cluster & user object here so lets validate them as this
|
||||||
// will be called when we add a new cluster to Lens
|
// will be called when we add a new cluster to Lens
|
||||||
logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`);
|
|
||||||
|
const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts;
|
||||||
|
|
||||||
|
const contextObject = config.getContextObject(contextName);
|
||||||
|
|
||||||
|
// Validate the Context Object
|
||||||
|
if (!contextObject) {
|
||||||
|
throw new Error(`No valid context object provided in kubeconfig for context '${contextName}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the Cluster Object
|
||||||
|
if (validateCluster && !config.getCluster(contextObject.cluster)) {
|
||||||
|
throw new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = config.getUser(contextObject.user);
|
||||||
|
|
||||||
// Validate the User Object
|
// Validate the User Object
|
||||||
const user = config.getCurrentUser();
|
if (validateUser && !user) {
|
||||||
|
throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`);
|
||||||
if (user.exec) {
|
}
|
||||||
|
|
||||||
|
// Validate exec command if present
|
||||||
|
if (validateExec && user?.exec) {
|
||||||
const execCommand = user.exec["command"];
|
const execCommand = user.exec["command"];
|
||||||
// check if the command is absolute or not
|
// check if the command is absolute or not
|
||||||
const isAbsolute = path.isAbsolute(execCommand);
|
const isAbsolute = path.isAbsolute(execCommand);
|
||||||
|
|
||||||
// validate the exec struct in the user object, start with the command field
|
// validate the exec struct in the user object, start with the command field
|
||||||
logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`);
|
|
||||||
|
|
||||||
if (!commandExists.sync(execCommand)) {
|
if (!commandExists.sync(execCommand)) {
|
||||||
logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${config.currentContext} not found`);
|
logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${contextName} not found`);
|
||||||
throw new ExecValidationNotFoundError(execCommand, isAbsolute);
|
throw new ExecValidationNotFoundError(execCommand, isAbsolute);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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