mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Merge remote-tracking branch 'origin/master' into package-upgrade/immer
# Conflicts: # src/renderer/components/dock/__test__/log-tab.store.test.ts # yarn.lock
This commit is contained in:
commit
8a028b1f11
2
.yarnrc
2
.yarnrc
@ -1,3 +1,3 @@
|
||||
disturl "https://atom.io/download/electron"
|
||||
target "12.0.15"
|
||||
target "13.5.1"
|
||||
runtime "electron"
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
Lens releases are built by CICD automatically on git tags. The typical release process flow is the following:
|
||||
|
||||
1. From a clean and up to date `master` run `make release-version <version-type>` where `<version-type>` is one of the following:
|
||||
1. If doing a patch release checkout the `release/vMAJOR.MINOR` branch for the appropriate `MAJOR`/`MINOR` version and manually `cherry-pick` the PRs required for the patch that were commited to master. If there are any conflicts they must be resolved manually. If necessary, get assistance from the PR authors.
|
||||
|
||||
1. From a clean and up to date `master` (or `release/vMAJOR.MINOR` if doing a patch release) run `make release-version <version-type>` where `<version-type>` is one of the following:
|
||||
- `major`
|
||||
- `minor`
|
||||
- `patch`
|
||||
@ -17,15 +19,15 @@ Lens releases are built by CICD automatically on git tags. The typical release p
|
||||
- `rc`
|
||||
|
||||
This assumes origin is set to https://github.com/lensapp/lens.git. If not then set GIT_REMOTE to the remote that is set to https://github.com/lensapp/lens.git. For example run `GIT_REMOTE=upstream make release-version ...`
|
||||
1. Open the PR (git should have printed a link to GitHub in the console) with the contents of all the accepted PRs since the last release. The PR description needs to be filled with the draft release description. From https://github.com/lensapp/lens click on Releases, the draft release should be first in the list, click `Edit` and copy/paste the markdown to the PR description. Add the `skip-changelog` label and click `Create Pull Request`.
|
||||
1. After the PR is accepted and passes CI (and before merging), go to the same branch and run `make tag-release` (set GIT_REMOTE if necessary). This additionally runs the azure jobs to build the binaries and put them on S3.
|
||||
1. Open the PR (git should have printed a link to GitHub in the console) with the contents of all the accepted PRs since the last release. The PR description needs to be filled with the draft release description. From https://github.com/lensapp/lens click on Releases, the draft release should be first in the list, click `Edit` and copy/paste the markdown to the PR description. Add the `skip-changelog` label and click `Create Pull Request`. If this is a patch release be sure to set the PR base branch to `release/vMAJOR.MINOR` instead of `master`.
|
||||
1. After the PR is accepted and passes CI (and before merging), go to the same branch and run `make tag-release` (set GIT_REMOTE if necessary). This additionally triggers the azure jobs to build the binaries and put them on S3.
|
||||
1. If the CI fails at this stage the problem needs to be fixed. Sometimes an azure job fails due to outside service issues (e.g. Apple signing occasionally fails), in which case the specific azure job can be rerun from https://dev.azure.com/lensapp/lensapp/_build. Otherwise changes to the codebase may need to be done and committed to the release branch and pushed to https://github.com/lensapp/lens. CI will run again. As well the release tag needs to be manually set to this new commit. You can do something like:
|
||||
- `git push origin :refs/tags/vX.Y.Z-beta.N` (removes the tag from https://github.com/lensapp/lens)
|
||||
- `git tag -fa vX.Y.Z-beta.N` (move the tag locally to the current commit)
|
||||
- `git push origin --tags` (update the tags on https://github.com/lensapp/lens to reflect this local change)
|
||||
|
||||
1. Once CI passes again go to the releases tab on GitHub. You can use the existing draft release prepared by k8slens-bot (select the correct tag). Or you can create a new release from the tag that was created, and make sure that the change log is the same as that of the PR, and the title is the tag. Either way, click the prerelease checkbox if this is not a new major, minor, or patch version before clicking `Publish release`.
|
||||
1. Merge the release PR after the release is published. GitHub should delete the branch once it is merged.
|
||||
1. Merge the release PR after the release is published. If it is a patch release then there is no need to squash the cherry-picked commits as part of the merge. GitHub should delete the branch once it is merged.
|
||||
1. If you have just released a new major or minor version then create a new `release/vMAJOR.MINOR` branch from that same tag and push it to https://github.com/lensapp/lens. Given the commit of the merged release PR from the master branch you can do this like
|
||||
|
||||
`git push origin <commit>:refs/heads/release/vX.Y`
|
||||
@ -37,4 +39,4 @@ Other tasks
|
||||
- generate a changelog from the prerelease descriptions (for major/minor releases)
|
||||
- announce the release on lens and lens-hq slack channels (release is announced automatically on the community slack lens channel through the above publishing process)
|
||||
- announce on lens-hq that master is open for PR merges for the next release (for major/minor releases)
|
||||
- update issues on github (bump those that did not make it into the release to a subsequent release) (for major/minor releases)
|
||||
- update issues on github (bump those that did not make it into the release to a subsequent release) (for major/minor/patch releases)
|
||||
@ -8,9 +8,9 @@
|
||||
"version": "file:../../src/extensions/npm/extensions",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@material-ui/core": "*",
|
||||
"@types/node": "*",
|
||||
"@types/react-select": "*",
|
||||
"@material-ui/core": "4.12.3",
|
||||
"@types/node": "14.17.14",
|
||||
"@types/react-select": "3.1.2",
|
||||
"conf": "^7.0.1",
|
||||
"typed-emitter": "^1.3.1"
|
||||
},
|
||||
|
||||
21
extensions/metrics-cluster-feature/package-lock.json
generated
21
extensions/metrics-cluster-feature/package-lock.json
generated
@ -662,10 +662,11 @@
|
||||
"version": "file:../../src/extensions/npm/extensions",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@material-ui/core": "*",
|
||||
"@types/node": "*",
|
||||
"@types/react-select": "*",
|
||||
"conf": "^7.0.1"
|
||||
"@material-ui/core": "4.12.3",
|
||||
"@types/node": "14.17.14",
|
||||
"@types/react-select": "3.1.2",
|
||||
"conf": "^7.0.1",
|
||||
"typed-emitter": "^1.3.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": {
|
||||
@ -1329,6 +1330,12 @@
|
||||
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
|
||||
"dev": true
|
||||
},
|
||||
"typed-emitter": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-1.3.1.tgz",
|
||||
"integrity": "sha512-2h7utWyXgd2R2u2IuL8B4yu1gqMxbgUj2VS/MGVbFhEVQNJKXoQQoS5CBMh+eW31zFeSmDfEQ3qQf4xy5SlPVQ==",
|
||||
"dev": true
|
||||
},
|
||||
"uri-js": {
|
||||
"version": "4.4.1",
|
||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||
@ -1742,9 +1749,9 @@
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
|
||||
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"ansi-styles": {
|
||||
|
||||
12
extensions/node-menu/package-lock.json
generated
12
extensions/node-menu/package-lock.json
generated
@ -628,9 +628,9 @@
|
||||
"version": "file:../../src/extensions/npm/extensions",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@material-ui/core": "*",
|
||||
"@types/node": "*",
|
||||
"@types/react-select": "*",
|
||||
"@material-ui/core": "4.12.3",
|
||||
"@types/node": "14.17.14",
|
||||
"@types/react-select": "3.1.2",
|
||||
"conf": "^7.0.1",
|
||||
"typed-emitter": "^1.3.1"
|
||||
},
|
||||
@ -1707,9 +1707,9 @@
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
|
||||
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"ansi-styles": {
|
||||
|
||||
12
extensions/pod-menu/package-lock.json
generated
12
extensions/pod-menu/package-lock.json
generated
@ -628,9 +628,9 @@
|
||||
"version": "file:../../src/extensions/npm/extensions",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@material-ui/core": "*",
|
||||
"@types/node": "*",
|
||||
"@types/react-select": "*",
|
||||
"@material-ui/core": "4.12.3",
|
||||
"@types/node": "14.17.14",
|
||||
"@types/react-select": "3.1.2",
|
||||
"conf": "^7.0.1",
|
||||
"typed-emitter": "^1.3.1"
|
||||
},
|
||||
@ -1660,9 +1660,9 @@
|
||||
}
|
||||
},
|
||||
"ansi-regex": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz",
|
||||
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"ansi-styles": {
|
||||
|
||||
@ -25,16 +25,24 @@
|
||||
TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube
|
||||
cluster and vice versa.
|
||||
*/
|
||||
import type { Page } from "playwright";
|
||||
import type { ElectronApplication, Page } from "playwright";
|
||||
import * as utils from "../helpers/utils";
|
||||
|
||||
describe("preferences page tests", () => {
|
||||
let window: Page, cleanup: () => Promise<void>;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ window, cleanup } = await utils.start());
|
||||
let app: ElectronApplication;
|
||||
|
||||
({ window, cleanup, app } = await utils.start());
|
||||
await utils.clickWelcomeButton(window);
|
||||
await window.keyboard.press("Meta+,");
|
||||
|
||||
await app.evaluate(async ({ app }) => {
|
||||
await app.applicationMenu
|
||||
.getMenuItemById(process.platform === "darwin" ? "root" : "file")
|
||||
.submenu.getMenuItemById("preferences")
|
||||
.click();
|
||||
});
|
||||
}, 10*60*1000);
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
@ -19,14 +19,14 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import type { Page } from "playwright";
|
||||
import type { ElectronApplication, Page } from "playwright";
|
||||
import * as utils from "../helpers/utils";
|
||||
|
||||
describe("Lens command palette", () => {
|
||||
let window: Page, cleanup: () => Promise<void>;
|
||||
let window: Page, cleanup: () => Promise<void>, app: ElectronApplication;
|
||||
|
||||
beforeEach(async () => {
|
||||
({ window, cleanup } = await utils.start());
|
||||
({ window, cleanup, app } = await utils.start());
|
||||
await utils.clickWelcomeButton(window);
|
||||
}, 10*60*1000);
|
||||
|
||||
@ -35,8 +35,13 @@ describe("Lens command palette", () => {
|
||||
}, 10*60*1000);
|
||||
|
||||
describe("menu", () => {
|
||||
it("opens command dialog from keyboard shortcut", async () => {
|
||||
await window.keyboard.press("Meta+Shift+p");
|
||||
it("opens command dialog from menu", async () => {
|
||||
await app.evaluate(async ({ app }) => {
|
||||
await app.applicationMenu
|
||||
.getMenuItemById("view")
|
||||
.submenu.getMenuItemById("command-palette")
|
||||
.click();
|
||||
});
|
||||
await window.waitForSelector(".Select__option >> text=Hotbar: Switch");
|
||||
}, 10*60*1000);
|
||||
});
|
||||
|
||||
@ -104,10 +104,7 @@ function minikubeEntityId() {
|
||||
* From the catalog, click the minikube entity and wait for it to connect, returning its frame
|
||||
*/
|
||||
export async function lauchMinikubeClusterFromCatalog(window: Page): Promise<Frame> {
|
||||
await window.waitForSelector("div.TableCell");
|
||||
await window.click("div.TableCell >> text='minikube'");
|
||||
await window.waitForSelector("div.drawer-title-text >> text='KubernetesCluster: minikube'");
|
||||
await window.click("div.EntityIcon div.HotbarIcon div div.MuiAvatar-root");
|
||||
|
||||
const minikubeFrame = await window.waitForSelector(`#cluster-frame-${minikubeEntityId()}`);
|
||||
|
||||
|
||||
60
package.json
60
package.json
@ -3,7 +3,7 @@
|
||||
"productName": "OpenLens",
|
||||
"description": "OpenLens - Open Source IDE for Kubernetes",
|
||||
"homepage": "https://github.com/lensapp/lens",
|
||||
"version": "5.2.0",
|
||||
"version": "5.3.0-alpha.0",
|
||||
"main": "static/build/main.js",
|
||||
"copyright": "© 2021 OpenLens Authors",
|
||||
"license": "MIT",
|
||||
@ -197,11 +197,12 @@
|
||||
"conf": "^7.0.1",
|
||||
"crypto-js": "^4.1.1",
|
||||
"electron-devtools-installer": "^3.2.0",
|
||||
"electron-updater": "^4.4.3",
|
||||
"electron-updater": "^4.6.0",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"filehound": "^1.17.4",
|
||||
"fs-extra": "^9.0.1",
|
||||
"glob-to-regexp": "^0.4.1",
|
||||
"got": "^11.8.2",
|
||||
"grapheme-splitter": "^1.0.4",
|
||||
"handlebars": "^4.7.7",
|
||||
"http-proxy": "^1.18.1",
|
||||
@ -209,7 +210,7 @@
|
||||
"joi": "^17.4.2",
|
||||
"js-yaml": "^3.14.0",
|
||||
"jsdom": "^16.7.0",
|
||||
"jsonpath": "^1.0.2",
|
||||
"jsonpath": "^1.1.1",
|
||||
"lodash": "^4.17.15",
|
||||
"mac-ca": "^1.0.6",
|
||||
"marked": "^2.0.3",
|
||||
@ -224,9 +225,8 @@
|
||||
"node-fetch": "^2.6.1",
|
||||
"node-pty": "^0.10.1",
|
||||
"npm": "^6.14.15",
|
||||
"openid-client": "^3.15.2",
|
||||
"p-limit": "^3.1.0",
|
||||
"path-to-regexp": "^6.1.0",
|
||||
"path-to-regexp": "^6.2.0",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
@ -237,16 +237,17 @@
|
||||
"readable-stream": "^3.6.0",
|
||||
"request": "^2.88.2",
|
||||
"request-promise-native": "^1.0.9",
|
||||
"rfc6902": "^4.0.2",
|
||||
"semver": "^7.3.2",
|
||||
"serializr": "^2.0.5",
|
||||
"shell-env": "^3.0.1",
|
||||
"spdy": "^4.0.2",
|
||||
"tar": "^6.1.10",
|
||||
"tcp-port-used": "^1.0.1",
|
||||
"tempy": "^0.5.0",
|
||||
"url-parse": "^1.5.1",
|
||||
"tcp-port-used": "^1.0.2",
|
||||
"tempy": "1.0.1",
|
||||
"url-parse": "^1.5.3",
|
||||
"uuid": "^8.3.2",
|
||||
"win-ca": "^3.2.0",
|
||||
"win-ca": "^3.4.5",
|
||||
"winston": "^3.3.3",
|
||||
"winston-console-format": "^1.0.8",
|
||||
"winston-transport-browserconsole": "^1.0.5",
|
||||
@ -274,9 +275,9 @@
|
||||
"@types/glob-to-regexp": "^0.4.1",
|
||||
"@types/hapi": "^18.0.6",
|
||||
"@types/hoist-non-react-statics": "^3.3.1",
|
||||
"@types/html-webpack-plugin": "^3.2.3",
|
||||
"@types/html-webpack-plugin": "^3.2.6",
|
||||
"@types/http-proxy": "^1.17.7",
|
||||
"@types/jest": "^26.0.22",
|
||||
"@types/jest": "^26.0.24",
|
||||
"@types/js-yaml": "^3.12.4",
|
||||
"@types/jsdom": "^16.2.4",
|
||||
"@types/jsonpath": "^0.2.0",
|
||||
@ -290,19 +291,19 @@
|
||||
"@types/node-fetch": "^2.5.12",
|
||||
"@types/npm": "^2.0.32",
|
||||
"@types/progress-bar-webpack-plugin": "^2.1.2",
|
||||
"@types/proper-lockfile": "^4.1.1",
|
||||
"@types/proper-lockfile": "^4.1.2",
|
||||
"@types/randomcolor": "^0.5.6",
|
||||
"@types/react": "^17.0.0",
|
||||
"@types/react-beautiful-dnd": "^13.1.1",
|
||||
"@types/react-dom": "^17.0.9",
|
||||
"@types/react-router-dom": "^5.1.6",
|
||||
"@types/react-router-dom": "^5.3.1",
|
||||
"@types/react-select": "3.1.2",
|
||||
"@types/react-table": "^7.7.0",
|
||||
"@types/react-table": "^7.7.6",
|
||||
"@types/react-virtualized-auto-sizer": "^1.0.1",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"@types/readable-stream": "^2.3.9",
|
||||
"@types/request": "^2.48.7",
|
||||
"@types/request-promise-native": "^1.0.17",
|
||||
"@types/request-promise-native": "^1.0.18",
|
||||
"@types/semver": "^7.2.0",
|
||||
"@types/sharp": "^0.28.3",
|
||||
"@types/spdy": "^3.4.4",
|
||||
@ -313,26 +314,26 @@
|
||||
"@types/url-parse": "^1.4.3",
|
||||
"@types/uuid": "^8.3.1",
|
||||
"@types/webdriverio": "^4.13.0",
|
||||
"@types/webpack": "^4.41.17",
|
||||
"@types/webpack-dev-server": "^3.11.1",
|
||||
"@types/webpack-env": "^1.15.2",
|
||||
"@types/webpack": "^4.41.31",
|
||||
"@types/webpack-dev-server": "^3.11.6",
|
||||
"@types/webpack-env": "^1.16.2",
|
||||
"@types/webpack-node-externals": "^1.7.1",
|
||||
"@typescript-eslint/eslint-plugin": "^4.29.0",
|
||||
"@typescript-eslint/eslint-plugin": "^4.33.0",
|
||||
"@typescript-eslint/parser": "^4.29.1",
|
||||
"ansi_up": "^5.0.1",
|
||||
"chart.js": "^2.9.4",
|
||||
"circular-dependency-plugin": "^5.2.2",
|
||||
"color": "^3.1.2",
|
||||
"concurrently": "^5.2.0",
|
||||
"css-loader": "^5.2.6",
|
||||
"deepdash": "^5.3.5",
|
||||
"css-loader": "^5.2.7",
|
||||
"deepdash": "^5.3.9",
|
||||
"dompurify": "^2.3.1",
|
||||
"electron": "^12.0.17",
|
||||
"electron": "^13.5.1",
|
||||
"electron-builder": "^22.11.11",
|
||||
"electron-notarize": "^0.3.0",
|
||||
"esbuild": "^0.12.24",
|
||||
"esbuild-loader": "^2.13.1",
|
||||
"eslint": "^7.7.0",
|
||||
"esbuild-loader": "^2.15.1",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-react": "^7.24.0",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
@ -349,7 +350,7 @@
|
||||
"jest-fetch-mock": "^3.0.3",
|
||||
"jest-mock-extended": "^1.0.16",
|
||||
"make-plural": "^6.2.2",
|
||||
"mini-css-extract-plugin": "^1.6.0",
|
||||
"mini-css-extract-plugin": "^1.6.2",
|
||||
"node-gyp": "7.1.2",
|
||||
"node-loader": "^1.0.3",
|
||||
"nodemon": "^2.0.12",
|
||||
@ -357,6 +358,7 @@
|
||||
"postcss": "^8.3.6",
|
||||
"postcss-loader": "4.3.0",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^2.4.1",
|
||||
"progress-bar-webpack-plugin": "^2.1.0",
|
||||
"randomcolor": "^0.6.2",
|
||||
"raw-loader": "^4.0.2",
|
||||
@ -369,18 +371,18 @@
|
||||
"react-window": "^1.8.5",
|
||||
"sass": "^1.41.1",
|
||||
"sass-loader": "^8.0.2",
|
||||
"sharp": "^0.29.0",
|
||||
"sharp": "^0.29.1",
|
||||
"style-loader": "^2.0.0",
|
||||
"tailwindcss": "^2.2.4",
|
||||
"ts-jest": "26.5.6",
|
||||
"ts-loader": "^7.0.5",
|
||||
"ts-node": "^10.1.0",
|
||||
"ts-node": "^10.2.1",
|
||||
"type-fest": "^1.0.2",
|
||||
"typed-emitter": "^1.3.1",
|
||||
"typedoc": "0.21.0-beta.2",
|
||||
"typedoc": "0.22.5",
|
||||
"typedoc-plugin-markdown": "^3.9.0",
|
||||
"typeface-roboto": "^1.1.13",
|
||||
"typescript": "^4.3.2",
|
||||
"typescript": "^4.4.3",
|
||||
"typescript-plugin-css-modules": "^3.4.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack": "^4.46.0",
|
||||
|
||||
@ -46,6 +46,10 @@ export abstract class BaseStore<T> extends Singleton {
|
||||
protected constructor(protected params: BaseStoreParams<T>) {
|
||||
super();
|
||||
makeObservable(this);
|
||||
|
||||
if (ipcRenderer) {
|
||||
params.migrations = undefined; // don't run migrations on renderer
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
44
src/common/catalog/catalog-run-event.ts
Normal file
44
src/common/catalog/catalog-run-event.ts
Normal file
@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import type { CatalogEntity } from "../catalog";
|
||||
|
||||
export class CatalogRunEvent {
|
||||
#defaultPrevented: boolean;
|
||||
#target: CatalogEntity;
|
||||
|
||||
get defaultPrevented() {
|
||||
return this.#defaultPrevented;
|
||||
}
|
||||
|
||||
get target() {
|
||||
return this.#target;
|
||||
}
|
||||
|
||||
constructor({ target }: { target: CatalogEntity }) {
|
||||
this.#defaultPrevented = false;
|
||||
this.#target = target;
|
||||
}
|
||||
|
||||
preventDefault() {
|
||||
this.#defaultPrevented = true;
|
||||
}
|
||||
}
|
||||
@ -20,92 +20,108 @@
|
||||
*/
|
||||
|
||||
import { CustomResourceDefinition } from "../endpoints";
|
||||
import type { KubeObjectMetadata } from "../kube-object";
|
||||
|
||||
describe("Crds", () => {
|
||||
describe("getVersion", () => {
|
||||
it("should get the first version name from the list of versions", () => {
|
||||
it("should throw if none of the versions are served", () => {
|
||||
const crd = new CustomResourceDefinition({
|
||||
apiVersion: "foo",
|
||||
apiVersion: "apiextensions.k8s.io/v1",
|
||||
kind: "CustomResourceDefinition",
|
||||
metadata: {} as KubeObjectMetadata,
|
||||
metadata: {
|
||||
name: "foo",
|
||||
resourceVersion: "12345",
|
||||
uid: "12345",
|
||||
},
|
||||
spec: {
|
||||
versions: [
|
||||
{
|
||||
name: "123",
|
||||
served: false,
|
||||
storage: false,
|
||||
},
|
||||
{
|
||||
name: "1234",
|
||||
served: false,
|
||||
storage: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
crd.spec = {
|
||||
versions: [
|
||||
{
|
||||
name: "123",
|
||||
served: false,
|
||||
storage: false,
|
||||
}
|
||||
]
|
||||
} as any;
|
||||
expect(() => crd.getVersion()).toThrowError("Failed to find a version for CustomResourceDefinition foo");
|
||||
});
|
||||
|
||||
it("should should get the version that is both served and stored", () => {
|
||||
const crd = new CustomResourceDefinition({
|
||||
apiVersion: "apiextensions.k8s.io/v1",
|
||||
kind: "CustomResourceDefinition",
|
||||
metadata: {
|
||||
name: "foo",
|
||||
resourceVersion: "12345",
|
||||
uid: "12345",
|
||||
},
|
||||
spec: {
|
||||
versions: [
|
||||
{
|
||||
name: "123",
|
||||
served: true,
|
||||
storage: true,
|
||||
},
|
||||
{
|
||||
name: "1234",
|
||||
served: false,
|
||||
storage: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(crd.getVersion()).toBe("123");
|
||||
});
|
||||
|
||||
it("should get the first version name from the list of versions (length 2)", () => {
|
||||
it("should should get the version that is both served and stored even with version field", () => {
|
||||
const crd = new CustomResourceDefinition({
|
||||
apiVersion: "foo",
|
||||
apiVersion: "apiextensions.k8s.io/v1",
|
||||
kind: "CustomResourceDefinition",
|
||||
metadata: {} as KubeObjectMetadata,
|
||||
metadata: {
|
||||
name: "foo",
|
||||
resourceVersion: "12345",
|
||||
uid: "12345",
|
||||
},
|
||||
spec: {
|
||||
version: "abc",
|
||||
versions: [
|
||||
{
|
||||
name: "123",
|
||||
served: true,
|
||||
storage: true,
|
||||
},
|
||||
{
|
||||
name: "1234",
|
||||
served: false,
|
||||
storage: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
crd.spec = {
|
||||
versions: [
|
||||
{
|
||||
name: "123",
|
||||
served: false,
|
||||
storage: false,
|
||||
},
|
||||
{
|
||||
name: "1234",
|
||||
served: false,
|
||||
storage: false,
|
||||
}
|
||||
]
|
||||
} as any;
|
||||
|
||||
expect(crd.getVersion()).toBe("123");
|
||||
});
|
||||
|
||||
it("should get the first version name from the list of versions (length 2) even with version field", () => {
|
||||
it("should get the version name from the version field", () => {
|
||||
const crd = new CustomResourceDefinition({
|
||||
apiVersion: "foo",
|
||||
apiVersion: "apiextensions.k8s.io/v1beta1",
|
||||
kind: "CustomResourceDefinition",
|
||||
metadata: {} as KubeObjectMetadata,
|
||||
metadata: {
|
||||
name: "foo",
|
||||
resourceVersion: "12345",
|
||||
uid: "12345",
|
||||
},
|
||||
spec: {
|
||||
version: "abc",
|
||||
}
|
||||
});
|
||||
|
||||
crd.spec = {
|
||||
version: "abc",
|
||||
versions: [
|
||||
{
|
||||
name: "123",
|
||||
served: false,
|
||||
storage: false,
|
||||
},
|
||||
{
|
||||
name: "1234",
|
||||
served: false,
|
||||
storage: false,
|
||||
}
|
||||
]
|
||||
} as any;
|
||||
|
||||
expect(crd.getVersion()).toBe("123");
|
||||
});
|
||||
|
||||
it("should get the first version name from the version field", () => {
|
||||
const crd = new CustomResourceDefinition({
|
||||
apiVersion: "foo",
|
||||
kind: "CustomResourceDefinition",
|
||||
metadata: {} as KubeObjectMetadata,
|
||||
});
|
||||
|
||||
crd.spec = {
|
||||
version: "abc"
|
||||
} as any;
|
||||
|
||||
expect(crd.getVersion()).toBe("abc");
|
||||
});
|
||||
});
|
||||
|
||||
@ -19,10 +19,11 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { KubeObject } from "../kube-object";
|
||||
import { KubeCreationError, KubeObject } from "../kube-object";
|
||||
import { KubeApi } from "../kube-api";
|
||||
import { crdResourcesURL } from "../../routes";
|
||||
import { isClusterPageContext } from "../../utils/cluster-id-url-parsing";
|
||||
import type { KubeJsonApiData } from "../kube-json-api";
|
||||
|
||||
type AdditionalPrinterColumnsCommon = {
|
||||
name: string;
|
||||
@ -39,10 +40,21 @@ type AdditionalPrinterColumnsV1Beta = AdditionalPrinterColumnsCommon & {
|
||||
JSONPath: string;
|
||||
};
|
||||
|
||||
export interface CRDVersion {
|
||||
name: string;
|
||||
served: boolean;
|
||||
storage: boolean;
|
||||
schema?: object; // required in v1 but not present in v1beta
|
||||
additionalPrinterColumns?: AdditionalPrinterColumnsV1[];
|
||||
}
|
||||
|
||||
export interface CustomResourceDefinition {
|
||||
spec: {
|
||||
group: string;
|
||||
version?: string; // deprecated in v1 api
|
||||
/**
|
||||
* @deprecated for apiextensions.k8s.io/v1 but used previously
|
||||
*/
|
||||
version?: string;
|
||||
names: {
|
||||
plural: string;
|
||||
singular: string;
|
||||
@ -50,19 +62,19 @@ export interface CustomResourceDefinition {
|
||||
listKind: string;
|
||||
};
|
||||
scope: "Namespaced" | "Cluster" | string;
|
||||
validation?: any;
|
||||
versions?: {
|
||||
name: string;
|
||||
served: boolean;
|
||||
storage: boolean;
|
||||
schema?: unknown; // required in v1 but not present in v1beta
|
||||
additionalPrinterColumns?: AdditionalPrinterColumnsV1[]
|
||||
}[];
|
||||
/**
|
||||
* @deprecated for apiextensions.k8s.io/v1 but used previously
|
||||
*/
|
||||
validation?: object;
|
||||
versions?: CRDVersion[];
|
||||
conversion: {
|
||||
strategy?: string;
|
||||
webhook?: any;
|
||||
};
|
||||
additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[]; // removed in v1
|
||||
/**
|
||||
* @deprecated for apiextensions.k8s.io/v1 but used previously
|
||||
*/
|
||||
additionalPrinterColumns?: AdditionalPrinterColumnsV1Beta[];
|
||||
};
|
||||
status: {
|
||||
conditions: {
|
||||
@ -83,11 +95,23 @@ export interface CustomResourceDefinition {
|
||||
};
|
||||
}
|
||||
|
||||
export interface CRDApiData extends KubeJsonApiData {
|
||||
spec: object; // TODO: make better
|
||||
}
|
||||
|
||||
export class CustomResourceDefinition extends KubeObject {
|
||||
static kind = "CustomResourceDefinition";
|
||||
static namespaced = false;
|
||||
static apiBase = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions";
|
||||
|
||||
constructor(data: CRDApiData) {
|
||||
super(data);
|
||||
|
||||
if (!data.spec || typeof data.spec !== "object") {
|
||||
throw new KubeCreationError("Cannot create a CustomResourceDefinition from an object without spec", data);
|
||||
}
|
||||
}
|
||||
|
||||
getResourceUrl() {
|
||||
return crdResourcesURL({
|
||||
params: {
|
||||
@ -125,9 +149,36 @@ export class CustomResourceDefinition extends KubeObject {
|
||||
return this.spec.scope;
|
||||
}
|
||||
|
||||
getPreferedVersion(): CRDVersion {
|
||||
// Prefer the modern `versions` over the legacy `version`
|
||||
if (this.spec.versions) {
|
||||
for (const version of this.spec.versions) {
|
||||
/**
|
||||
* If the version is not served then 404 errors will occur
|
||||
* We should also prefer the storage version
|
||||
*/
|
||||
if (version.served && version.storage) {
|
||||
return version;
|
||||
}
|
||||
}
|
||||
} else if (this.spec.version) {
|
||||
const { additionalPrinterColumns: apc } = this.spec;
|
||||
const additionalPrinterColumns = apc?.map(({ JSONPath, ...apc}) => ({ ...apc, jsonPath: JSONPath }));
|
||||
|
||||
return {
|
||||
name: this.spec.version,
|
||||
served: true,
|
||||
storage: true,
|
||||
schema: this.spec.validation,
|
||||
additionalPrinterColumns,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Failed to find a version for CustomResourceDefinition ${this.metadata.name}`);
|
||||
}
|
||||
|
||||
getVersion() {
|
||||
// v1 has removed the spec.version property, if it is present it must match the first version
|
||||
return this.spec.versions?.[0]?.name ?? this.spec.version;
|
||||
return this.getPreferedVersion().name;
|
||||
}
|
||||
|
||||
isNamespaced() {
|
||||
@ -147,17 +198,14 @@ export class CustomResourceDefinition extends KubeObject {
|
||||
}
|
||||
|
||||
getPrinterColumns(ignorePriority = true): AdditionalPrinterColumnsV1[] {
|
||||
const columns = this.spec.versions?.find(a => this.getVersion() == a.name)?.additionalPrinterColumns
|
||||
?? this.spec.additionalPrinterColumns?.map(({ JSONPath, ...rest }) => ({ ...rest, jsonPath: JSONPath })) // map to V1 shape
|
||||
?? [];
|
||||
const columns = this.getPreferedVersion().additionalPrinterColumns ?? [];
|
||||
|
||||
return columns
|
||||
.filter(column => column.name != "Age")
|
||||
.filter(column => ignorePriority ? true : !column.priority);
|
||||
.filter(column => column.name != "Age" && (ignorePriority || !column.priority));
|
||||
}
|
||||
|
||||
getValidation() {
|
||||
return JSON.stringify(this.spec.validation ?? this.spec.versions?.[0]?.schema, null, 2);
|
||||
return JSON.stringify(this.getPreferedVersion().schema, null, 2);
|
||||
}
|
||||
|
||||
getConditions() {
|
||||
|
||||
@ -31,11 +31,13 @@ export class PersistentVolumeClaimsApi extends KubeApi<PersistentVolumeClaim> {
|
||||
}
|
||||
|
||||
export function getMetricsForPvc(pvc: PersistentVolumeClaim): Promise<IPvcMetrics> {
|
||||
const opts = { category: "pvc", pvc: pvc.getName(), namespace: pvc.getNs() };
|
||||
|
||||
return metricsApi.getMetrics({
|
||||
diskUsage: { category: "pvc", pvc: pvc.getName() },
|
||||
diskCapacity: { category: "pvc", pvc: pvc.getName() }
|
||||
diskUsage: opts,
|
||||
diskCapacity: opts
|
||||
}, {
|
||||
namespace: pvc.getNs()
|
||||
namespace: opts.namespace
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -22,19 +22,27 @@
|
||||
import jsYaml from "js-yaml";
|
||||
import type { KubeJsonApiData } from "../kube-json-api";
|
||||
import { apiBase } from "../index";
|
||||
import type { Patch } from "rfc6902";
|
||||
|
||||
export const resourceApplierApi = {
|
||||
annotations: [
|
||||
"kubectl.kubernetes.io/last-applied-configuration"
|
||||
],
|
||||
export const annotations = [
|
||||
"kubectl.kubernetes.io/last-applied-configuration"
|
||||
];
|
||||
|
||||
async update(resource: object | string): Promise<KubeJsonApiData | null> {
|
||||
if (typeof resource === "string") {
|
||||
resource = jsYaml.safeLoad(resource);
|
||||
}
|
||||
|
||||
const [data = null] = await apiBase.post<KubeJsonApiData[]>("/stack", { data: resource });
|
||||
|
||||
return data;
|
||||
export async function update(resource: object | string): Promise<KubeJsonApiData> {
|
||||
if (typeof resource === "string") {
|
||||
resource = jsYaml.safeLoad(resource);
|
||||
}
|
||||
};
|
||||
|
||||
return apiBase.post<KubeJsonApiData>("/stack", { data: resource });
|
||||
}
|
||||
|
||||
export async function patch(name: string, kind: string, ns: string, patch: Patch): Promise<KubeJsonApiData> {
|
||||
return apiBase.patch<KubeJsonApiData>("/stack", {
|
||||
data: {
|
||||
name,
|
||||
kind,
|
||||
ns,
|
||||
patch,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -27,8 +27,7 @@ import { stringify } from "querystring";
|
||||
import { EventEmitter } from "../../common/event-emitter";
|
||||
import logger from "../../common/logger";
|
||||
|
||||
export interface JsonApiData {
|
||||
}
|
||||
export interface JsonApiData {}
|
||||
|
||||
export interface JsonApiError {
|
||||
code?: number;
|
||||
|
||||
@ -32,6 +32,7 @@ import { parseKubeApi } from "./kube-api-parse";
|
||||
import type { KubeJsonApiData } from "./kube-json-api";
|
||||
import type { RequestInit } from "node-fetch";
|
||||
import AbortController from "abort-controller";
|
||||
import type { Patch } from "rfc6902";
|
||||
|
||||
export interface KubeObjectStoreLoadingParams<K extends KubeObject> {
|
||||
namespaces: string[];
|
||||
@ -279,19 +280,29 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
|
||||
return newItem;
|
||||
}
|
||||
|
||||
async update(item: T, data: Partial<T>): Promise<T> {
|
||||
const rawItem = await item.update(data);
|
||||
private postUpdate(rawItem: KubeJsonApiData): T {
|
||||
const newItem = new this.api.objectConstructor(rawItem);
|
||||
const index = this.items.findIndex(item => item.getId() === newItem.getId());
|
||||
|
||||
ensureObjectSelfLink(this.api, newItem);
|
||||
|
||||
const index = this.items.findIndex(item => item.getId() === newItem.getId());
|
||||
|
||||
this.items.splice(index, 1, newItem);
|
||||
if (index < 0) {
|
||||
this.items.push(newItem);
|
||||
} else {
|
||||
this.items[index] = newItem;
|
||||
}
|
||||
|
||||
return newItem;
|
||||
}
|
||||
|
||||
async patch(item: T, patch: Patch): Promise<T> {
|
||||
return this.postUpdate(await item.patch(patch));
|
||||
}
|
||||
|
||||
async update(item: T, data: Partial<T>): Promise<T> {
|
||||
return this.postUpdate(await item.update(data));
|
||||
}
|
||||
|
||||
async remove(item: T) {
|
||||
await item.delete();
|
||||
this.items.remove(item);
|
||||
|
||||
@ -27,9 +27,9 @@ import { autoBind, formatDuration } from "../utils";
|
||||
import type { ItemObject } from "../item.store";
|
||||
import { apiKube } from "./index";
|
||||
import type { JsonApiParams } from "./json-api";
|
||||
import { resourceApplierApi } from "./endpoints/resource-applier.api";
|
||||
import * as resourceApplierApi from "./endpoints/resource-applier.api";
|
||||
import { hasOptionalProperty, hasTypedProperty, isObject, isString, bindPredicate, isTypedArray, isRecord } from "../../common/utils/type-narrowing";
|
||||
import _ from "lodash";
|
||||
import type { Patch } from "rfc6902";
|
||||
|
||||
export type KubeObjectConstructor<K extends KubeObject> = (new (data: KubeJsonApiData | any) => K) & {
|
||||
kind?: string;
|
||||
@ -98,6 +98,12 @@ export interface KubeObjectStatus {
|
||||
|
||||
export type KubeMetaField = keyof KubeObjectMetadata;
|
||||
|
||||
export class KubeCreationError extends Error {
|
||||
constructor(message: string, public data: any) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata, Status = any, Spec = any> implements ItemObject {
|
||||
static readonly kind: string;
|
||||
static readonly namespaced: boolean;
|
||||
@ -191,18 +197,32 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
|
||||
return Object.entries(labels).map(([name, value]) => `${name}=${value}`);
|
||||
}
|
||||
|
||||
protected static readonly nonEditableFields = [
|
||||
"apiVersion",
|
||||
"kind",
|
||||
"metadata.name",
|
||||
"metadata.selfLink",
|
||||
"metadata.resourceVersion",
|
||||
"metadata.uid",
|
||||
"managedFields",
|
||||
"status",
|
||||
/**
|
||||
* These must be RFC6902 compliant paths
|
||||
*/
|
||||
private static readonly nonEditiablePathPrefixes = [
|
||||
"/metadata/managedFields",
|
||||
"/status",
|
||||
];
|
||||
private static readonly nonEditablePaths = new Set([
|
||||
"/apiVersion",
|
||||
"/kind",
|
||||
"/metadata/name",
|
||||
"/metadata/selfLink",
|
||||
"/metadata/resourceVersion",
|
||||
"/metadata/uid",
|
||||
...KubeObject.nonEditiablePathPrefixes,
|
||||
]);
|
||||
|
||||
constructor(data: KubeJsonApiData) {
|
||||
if (typeof data !== "object") {
|
||||
throw new TypeError(`Cannot create a KubeObject from ${typeof data}`);
|
||||
}
|
||||
|
||||
if (!data.metadata || typeof data.metadata !== "object") {
|
||||
throw new KubeCreationError(`Cannot create a KubeObject from an object without metadata`, data);
|
||||
}
|
||||
|
||||
Object.assign(this, data);
|
||||
autoBind(this);
|
||||
}
|
||||
@ -264,7 +284,7 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
|
||||
}
|
||||
|
||||
getOwnerRefs() {
|
||||
const refs = this.metadata?.ownerReferences || [];
|
||||
const refs = this.metadata.ownerReferences || [];
|
||||
const namespace = this.getNs();
|
||||
|
||||
return refs.map(ownerRef => ({ ...ownerRef, namespace }));
|
||||
@ -286,14 +306,31 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
|
||||
return JSON.parse(JSON.stringify(this));
|
||||
}
|
||||
|
||||
// use unified resource-applier api for updating all k8s objects
|
||||
async update(data: Partial<this>): Promise<KubeJsonApiData | null> {
|
||||
for (const field of KubeObject.nonEditableFields) {
|
||||
if (!_.isEqual(_.get(this, field), _.get(data, field))) {
|
||||
throw new Error(`Failed to update Kube Object: ${field} has been modified`);
|
||||
async patch(patch: Patch): Promise<KubeJsonApiData | null> {
|
||||
for (const op of patch) {
|
||||
if (KubeObject.nonEditablePaths.has(op.path)) {
|
||||
throw new Error(`Failed to update ${this.kind}: JSON pointer ${op.path} has been modified`);
|
||||
}
|
||||
|
||||
for (const pathPrefix of KubeObject.nonEditiablePathPrefixes) {
|
||||
if (op.path.startsWith(`${pathPrefix}/`)) {
|
||||
throw new Error(`Failed to update ${this.kind}: Child JSON pointer of ${op.path} has been modified`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return resourceApplierApi.patch(this.getName(), this.kind, this.getNs(), patch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a full update (or more specifically a replace)
|
||||
*
|
||||
* Note: this is brittle if `data` is not actually partial (but instead whole).
|
||||
* As fields such as `resourceVersion` will probably out of date. This is a
|
||||
* common race condition.
|
||||
*/
|
||||
async update(data: Partial<this>): Promise<KubeJsonApiData | null> {
|
||||
// use unified resource-applier api for updating all k8s objects
|
||||
return resourceApplierApi.update({
|
||||
...this.toPlainObject(),
|
||||
...data,
|
||||
|
||||
@ -30,4 +30,17 @@ export const catalogRoute: RouteProps = {
|
||||
path: "/catalog/:group?/:kind?"
|
||||
};
|
||||
|
||||
export const getPreviousTabUrl = (path: string) => {
|
||||
const [group, kind] = path.split("/");
|
||||
|
||||
return catalogURL({
|
||||
params: {
|
||||
group: group || browseCatalogTab,
|
||||
kind
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const catalogURL = buildURL<CatalogViewRouteParam>(catalogRoute.path);
|
||||
|
||||
export const browseCatalogTab = "browse";
|
||||
|
||||
@ -40,6 +40,7 @@ export * from "./network-policies";
|
||||
export * from "./network";
|
||||
export * from "./nodes";
|
||||
export * from "./pod-disruption-budgets";
|
||||
export * from "./port-forwards";
|
||||
export * from "./preferences";
|
||||
export * from "./releases";
|
||||
export * from "./resource-quotas";
|
||||
|
||||
@ -25,6 +25,7 @@ import { endpointRoute } from "./endpoints";
|
||||
import { ingressRoute } from "./ingresses";
|
||||
import { networkPoliciesRoute } from "./network-policies";
|
||||
import { servicesRoute, servicesURL } from "./services";
|
||||
import { portForwardsRoute } from "./port-forwards";
|
||||
|
||||
export const networkRoute: RouteProps = {
|
||||
path: [
|
||||
@ -32,6 +33,7 @@ export const networkRoute: RouteProps = {
|
||||
endpointRoute,
|
||||
ingressRoute,
|
||||
networkPoliciesRoute,
|
||||
portForwardsRoute,
|
||||
].map(route => route.path.toString())
|
||||
};
|
||||
|
||||
|
||||
32
src/common/routes/port-forwards.ts
Normal file
32
src/common/routes/port-forwards.ts
Normal file
@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import type { RouteProps } from "react-router";
|
||||
import { buildURL } from "../utils/buildUrl";
|
||||
|
||||
export const portForwardsRoute: RouteProps = {
|
||||
path: "/port-forwards"
|
||||
};
|
||||
|
||||
export interface PortForwardsRouteParams {
|
||||
}
|
||||
|
||||
export const portForwardsURL = buildURL<PortForwardsRouteParams>(portForwardsRoute.path);
|
||||
@ -200,6 +200,19 @@ const openAtLogin: PreferenceDescription<boolean> = {
|
||||
},
|
||||
};
|
||||
|
||||
const terminalCopyOnSelect: PreferenceDescription<boolean> = {
|
||||
fromStore(val) {
|
||||
return val ?? false;
|
||||
},
|
||||
toStore(val) {
|
||||
if (!val) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return val;
|
||||
},
|
||||
};
|
||||
|
||||
const hiddenTableColumns: PreferenceDescription<[string, string[]][], Map<string, ObservableToggleSet<string>>> = {
|
||||
fromStore(val) {
|
||||
return new Map(
|
||||
@ -274,4 +287,5 @@ export const DESCRIPTORS = {
|
||||
hiddenTableColumns,
|
||||
syncKubeconfigEntries,
|
||||
editorConfiguration,
|
||||
terminalCopyOnSelect,
|
||||
};
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { app } from "electron";
|
||||
import { app, ipcMain } from "electron";
|
||||
import semver from "semver";
|
||||
import { action, computed, observable, reaction, makeObservable } from "mobx";
|
||||
import { BaseStore } from "../base-store";
|
||||
@ -48,7 +48,11 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
|
||||
});
|
||||
|
||||
makeObservable(this);
|
||||
fileNameMigration();
|
||||
|
||||
if (ipcMain) {
|
||||
fileNameMigration();
|
||||
}
|
||||
|
||||
this.load();
|
||||
}
|
||||
|
||||
@ -70,6 +74,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
|
||||
@observable shell?: string;
|
||||
@observable downloadBinariesPath?: string;
|
||||
@observable kubectlBinariesPath?: string;
|
||||
@observable terminalCopyOnSelect: boolean;
|
||||
|
||||
/**
|
||||
* Download kubectl binaries matching cluster version
|
||||
@ -212,6 +217,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
|
||||
this.hiddenTableColumns.replace(DESCRIPTORS.hiddenTableColumns.fromStore(preferences?.hiddenTableColumns));
|
||||
this.syncKubeconfigEntries.replace(DESCRIPTORS.syncKubeconfigEntries.fromStore(preferences?.syncKubeconfigEntries));
|
||||
this.editorConfiguration = DESCRIPTORS.editorConfiguration.fromStore(preferences?.editorConfiguration);
|
||||
this.terminalCopyOnSelect = DESCRIPTORS.terminalCopyOnSelect.fromStore(preferences?.terminalCopyOnSelect);
|
||||
}
|
||||
|
||||
toJSON(): UserStoreModel {
|
||||
@ -233,6 +239,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
|
||||
hiddenTableColumns: DESCRIPTORS.hiddenTableColumns.toStore(this.hiddenTableColumns),
|
||||
syncKubeconfigEntries: DESCRIPTORS.syncKubeconfigEntries.toStore(this.syncKubeconfigEntries),
|
||||
editorConfiguration: DESCRIPTORS.editorConfiguration.toStore(this.editorConfiguration),
|
||||
terminalCopyOnSelect: DESCRIPTORS.terminalCopyOnSelect.toStore(this.terminalCopyOnSelect),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -33,8 +33,9 @@ export function getBundledKubectlVersion(): string {
|
||||
export async function getAppVersionFromProxyServer(proxyPort: number): Promise<string> {
|
||||
const response = await requestPromise({
|
||||
method: "GET",
|
||||
uri: `http://localhost:${proxyPort}/version`,
|
||||
resolveWithFullResponse: true
|
||||
uri: `http://127.0.0.1:${proxyPort}/version`,
|
||||
resolveWithFullResponse: true,
|
||||
proxy: undefined,
|
||||
});
|
||||
|
||||
return JSON.parse(response.body).version;
|
||||
|
||||
@ -69,7 +69,7 @@ export const apiKubePrefix = "/api-kube" as string; // k8s cluster apis
|
||||
|
||||
// Links
|
||||
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as string;
|
||||
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI" as string;
|
||||
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/zt-wcl8jq3k-68R5Wcmk1o95MLBE5igUDQ" as string;
|
||||
export const supportUrl = "https://docs.k8slens.dev/latest/support/" as string;
|
||||
|
||||
export const appSemVer = new SemVer(packageInfo.version);
|
||||
|
||||
@ -77,7 +77,7 @@ export class LensRendererExtension extends LensExtension {
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a filtering function for the catalog catogries. This will be removed if the extension is disabled.
|
||||
* Add a filtering function for the catalog categories. This will be removed if the extension is disabled.
|
||||
* @param fn The function which should return a truthy value for those categories which should be kept.
|
||||
* @returns A function to clean up the filter
|
||||
*/
|
||||
|
||||
@ -16,9 +16,9 @@
|
||||
"name": "OpenLens Authors"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"@types/react-select": "*",
|
||||
"@material-ui/core": "*",
|
||||
"@types/node": "14.17.14",
|
||||
"@types/react-select": "3.1.2",
|
||||
"@material-ui/core": "4.12.3",
|
||||
"conf": "^7.0.1",
|
||||
"typed-emitter": "^1.3.1"
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ import { Singleton } from "../../common/utils";
|
||||
import { LensExtension } from "../lens-extension";
|
||||
|
||||
export class BaseRegistry<T, I = T> extends Singleton {
|
||||
private items = observable.map<T, I>([], {deep: false});
|
||||
private items = observable.map<T, I>([], { deep: false });
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
@ -25,9 +25,9 @@ import { BaseRegistry } from "./base-registry";
|
||||
import type { LensExtension } from "../lens-extension";
|
||||
import type { CatalogEntity } from "../../common/catalog";
|
||||
|
||||
export type CommandContext = {
|
||||
export interface CommandContext {
|
||||
entity?: CatalogEntity;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CommandRegistration {
|
||||
id: string;
|
||||
|
||||
@ -22,7 +22,8 @@
|
||||
|
||||
import type { CatalogCategory, CatalogEntity } from "../../common/catalog";
|
||||
import { catalogEntityRegistry as registry } from "../../renderer/api/catalog-entity-registry";
|
||||
|
||||
import type { CatalogEntityOnBeforeRun } from "../../renderer/api/catalog-entity-registry";
|
||||
import type { Disposer } from "../../common/utils";
|
||||
export { catalogCategoryRegistry as catalogCategories } from "../../common/catalog/catalog-category-registry";
|
||||
|
||||
export class CatalogEntityRegistry {
|
||||
@ -48,6 +49,18 @@ export class CatalogEntityRegistry {
|
||||
getItemsForCategory<T extends CatalogEntity>(category: CatalogCategory): T[] {
|
||||
return registry.getItemsForCategory(category);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a onBeforeRun hook to a catalog entities. If `onBeforeRun` was previously
|
||||
* added then it will not be added again.
|
||||
* @param onBeforeRun The function to be called with a `CatalogRunEvent`
|
||||
* event target will be the catalog entity. onBeforeRun hook can call event.preventDefault()
|
||||
* to stop run sequence
|
||||
* @returns A function to remove that hook
|
||||
*/
|
||||
addOnBeforeRun(onBeforeRun: CatalogEntityOnBeforeRun): Disposer {
|
||||
return registry.addOnBeforeRun(onBeforeRun);
|
||||
}
|
||||
}
|
||||
|
||||
export const catalogEntities = new CatalogEntityRegistry();
|
||||
|
||||
@ -21,10 +21,17 @@
|
||||
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
import configurePackages from "./common/configure-packages";
|
||||
import { configure } from "mobx";
|
||||
|
||||
// setup default configuration for external npm-packages
|
||||
configurePackages();
|
||||
|
||||
configure({
|
||||
// Needed because we want to use jest.spyOn()
|
||||
// ref https://github.com/mobxjs/mobx/issues/2784
|
||||
safeDescriptors: false,
|
||||
});
|
||||
|
||||
// rewire global.fetch to call 'fetchMock'
|
||||
fetchMock.enableMocks();
|
||||
|
||||
|
||||
56
src/main/__test__/lens-proxy.test.ts
Normal file
56
src/main/__test__/lens-proxy.test.ts
Normal file
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { isLongRunningRequest } from "../lens-proxy";
|
||||
|
||||
describe("isLongRunningRequest", () => {
|
||||
it("returns true on watches", () => {
|
||||
["watch=true", "watch=1", "watch"].forEach((param) => {
|
||||
expect(
|
||||
isLongRunningRequest(`/api/v1/namespaces/default/pods?${param}`)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false on disabled watches", () => {
|
||||
["watch=false", "watch=0", ""].forEach((param) => {
|
||||
expect(
|
||||
isLongRunningRequest(`/api/v1/namespaces/default/pods?${param}`)
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns true on follows", () => {
|
||||
["follow=true", "follow=1", "follow"].forEach((param) => {
|
||||
expect(
|
||||
isLongRunningRequest(`/api/v1/namespaces/default/pods/foo/log?${param}`)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false on disabled follows", () => {
|
||||
["follow=false", "follow=0", ""].forEach((param) => {
|
||||
expect(
|
||||
isLongRunningRequest(`/api/v1/namespaces/default/pods/foo/log?${param}`)
|
||||
).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -131,11 +131,11 @@ export class ClusterManager extends Singleton {
|
||||
entity.spec.metrics.prometheus = prometheus;
|
||||
}
|
||||
|
||||
// Only set the icon if the preference is set. If the preference is not set
|
||||
// then let the source determine if a cluster has an icon.
|
||||
if (cluster.preferences.icon) {
|
||||
entity.spec.icon ??= {};
|
||||
entity.spec.icon.src = cluster.preferences.icon;
|
||||
} else {
|
||||
entity.spec.icon = null;
|
||||
}
|
||||
|
||||
catalogEntityRegistry.items.splice(index, 1, entity);
|
||||
@ -160,6 +160,10 @@ export class ClusterManager extends Singleton {
|
||||
return "connecting";
|
||||
}
|
||||
|
||||
if (entity?.status?.phase) {
|
||||
return entity.status.phase;
|
||||
}
|
||||
|
||||
return "disconnected";
|
||||
})();
|
||||
|
||||
|
||||
@ -117,10 +117,10 @@ export class ContextHandler {
|
||||
return `http://127.0.0.1:${this.kubeAuthProxy.port}${path}`;
|
||||
}
|
||||
|
||||
async getApiTarget(isWatchRequest = false): Promise<httpProxy.ServerOptions> {
|
||||
const timeout = isWatchRequest ? 4 * 60 * 60 * 1000 : 30000; // 4 hours for watch request, 30 seconds for the rest
|
||||
async getApiTarget(isLongRunningRequest = false): Promise<httpProxy.ServerOptions> {
|
||||
const timeout = isLongRunningRequest ? 4 * 60 * 60_000 : 30_000; // 4 hours for long running request, 30 seconds for the rest
|
||||
|
||||
if (isWatchRequest) {
|
||||
if (isLongRunningRequest) {
|
||||
return this.newApiTarget(timeout);
|
||||
}
|
||||
|
||||
|
||||
@ -31,6 +31,7 @@ import { Singleton } from "../common/utils";
|
||||
import type { Cluster } from "./cluster";
|
||||
import type { ProxyApiRequestArgs } from "./proxy-functions";
|
||||
import { appEventBus } from "../common/event-bus";
|
||||
import { getBoolean } from "./utils/parse-query";
|
||||
|
||||
type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | null;
|
||||
|
||||
@ -40,6 +41,15 @@ export interface LensProxyFunctions {
|
||||
kubeApiRequest: (args: ProxyApiRequestArgs) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const watchParam = "watch";
|
||||
const followParam = "follow";
|
||||
|
||||
export function isLongRunningRequest(reqUrl: string) {
|
||||
const url = new URL(reqUrl, "http://localhost");
|
||||
|
||||
return getBoolean(url.searchParams, watchParam) || getBoolean(url.searchParams, followParam);
|
||||
}
|
||||
|
||||
export class LensProxy extends Singleton {
|
||||
protected origin: string;
|
||||
protected proxyServer: http.Server;
|
||||
@ -174,9 +184,8 @@ export class LensProxy extends Singleton {
|
||||
if (req.url.startsWith(apiKubePrefix)) {
|
||||
delete req.headers.authorization;
|
||||
req.url = req.url.replace(apiKubePrefix, "");
|
||||
const isWatchRequest = req.url.includes("watch=");
|
||||
|
||||
return contextHandler.getApiTarget(isWatchRequest);
|
||||
return contextHandler.getApiTarget(isLongRunningRequest(req.url));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
129
src/main/menu.ts
129
src/main/menu.ts
@ -19,19 +19,23 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { app, BrowserWindow, dialog, IpcMainEvent, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron";
|
||||
import { app, BrowserWindow, dialog, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron";
|
||||
import { autorun } from "mobx";
|
||||
import type { WindowManager } from "./window-manager";
|
||||
import { appName, isMac, isWindows, isTestEnv, docsUrl, supportUrl, productName } from "../common/vars";
|
||||
import { appName, isMac, isWindows, docsUrl, supportUrl, productName } from "../common/vars";
|
||||
import { MenuRegistry } from "../extensions/registries/menu-registry";
|
||||
import logger from "./logger";
|
||||
import { exitApp } from "./exit-app";
|
||||
import { broadcastMessage, ipcMainOn } from "../common/ipc";
|
||||
import { broadcastMessage } from "../common/ipc";
|
||||
import * as packageJson from "../../package.json";
|
||||
import { preferencesURL, extensionsURL, addClusterURL, catalogURL, welcomeURL } from "../common/routes";
|
||||
|
||||
export type MenuTopId = "mac" | "file" | "edit" | "view" | "help";
|
||||
|
||||
interface MenuItemsOpts extends MenuItemConstructorOptions {
|
||||
submenu?: MenuItemConstructorOptions[];
|
||||
}
|
||||
|
||||
export function initMenu(windowManager: WindowManager) {
|
||||
return autorun(() => buildMenu(windowManager), {
|
||||
delay: 100
|
||||
@ -68,11 +72,13 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
await windowManager.navigate(url);
|
||||
}
|
||||
|
||||
const macAppMenu: MenuItemConstructorOptions = {
|
||||
const macAppMenu: MenuItemsOpts = {
|
||||
label: app.getName(),
|
||||
id: "root",
|
||||
submenu: [
|
||||
{
|
||||
label: `About ${productName}`,
|
||||
id: "about",
|
||||
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
|
||||
showAbout(browserWindow);
|
||||
}
|
||||
@ -81,6 +87,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{
|
||||
label: "Preferences",
|
||||
accelerator: "CmdOrCtrl+,",
|
||||
id: "preferences",
|
||||
click() {
|
||||
navigate(preferencesURL());
|
||||
},
|
||||
@ -88,6 +95,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{
|
||||
label: "Extensions",
|
||||
accelerator: "CmdOrCtrl+Shift+E",
|
||||
id: "extensions",
|
||||
click() {
|
||||
navigate(extensionsURL());
|
||||
}
|
||||
@ -102,18 +110,21 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{
|
||||
label: "Quit",
|
||||
accelerator: "Cmd+Q",
|
||||
id: "quit",
|
||||
click() {
|
||||
exitApp();
|
||||
}
|
||||
}
|
||||
],
|
||||
};
|
||||
const fileMenu: MenuItemConstructorOptions = {
|
||||
const fileMenu: MenuItemsOpts = {
|
||||
label: "File",
|
||||
id: "file",
|
||||
submenu: [
|
||||
{
|
||||
label: "Add Cluster",
|
||||
accelerator: "CmdOrCtrl+Shift+A",
|
||||
id: "add-cluster",
|
||||
click() {
|
||||
navigate(addClusterURL());
|
||||
}
|
||||
@ -122,6 +133,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Preferences",
|
||||
id: "preferences",
|
||||
accelerator: "Ctrl+,",
|
||||
click() {
|
||||
navigate(preferencesURL());
|
||||
@ -135,16 +147,22 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
}
|
||||
}
|
||||
]),
|
||||
|
||||
{ type: "separator" },
|
||||
{
|
||||
role: "close",
|
||||
label: "Close Window"
|
||||
},
|
||||
|
||||
...(isMac ? [
|
||||
{
|
||||
role: "close",
|
||||
label: "Close Window",
|
||||
accelerator: "Shift+Cmd+W"
|
||||
}
|
||||
] as MenuItemConstructorOptions[] : []),
|
||||
|
||||
...ignoreOnMac([
|
||||
{ type: "separator" },
|
||||
{
|
||||
label: "Exit",
|
||||
accelerator: "Alt+F4",
|
||||
id: "quit",
|
||||
click() {
|
||||
exitApp();
|
||||
}
|
||||
@ -152,8 +170,9 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
])
|
||||
],
|
||||
};
|
||||
const editMenu: MenuItemConstructorOptions = {
|
||||
const editMenu: MenuItemsOpts = {
|
||||
label: "Edit",
|
||||
id: "edit",
|
||||
submenu: [
|
||||
{ role: "undo" },
|
||||
{ role: "redo" },
|
||||
@ -166,12 +185,14 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{ role: "selectAll" },
|
||||
]
|
||||
};
|
||||
const viewMenu: MenuItemConstructorOptions = {
|
||||
const viewMenu: MenuItemsOpts = {
|
||||
label: "View",
|
||||
id: "view",
|
||||
submenu: [
|
||||
{
|
||||
label: "Catalog",
|
||||
accelerator: "Shift+CmdOrCtrl+C",
|
||||
id: "catalog",
|
||||
click() {
|
||||
navigate(catalogURL());
|
||||
}
|
||||
@ -179,6 +200,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{
|
||||
label: "Command Palette...",
|
||||
accelerator: "Shift+CmdOrCtrl+P",
|
||||
id: "command-palette",
|
||||
click() {
|
||||
broadcastMessage("command-palette:open");
|
||||
}
|
||||
@ -187,6 +209,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{
|
||||
label: "Back",
|
||||
accelerator: "CmdOrCtrl+[",
|
||||
id: "go-back",
|
||||
click() {
|
||||
webContents.getAllWebContents().filter(wc => wc.getType() === "window").forEach(wc => wc.goBack());
|
||||
}
|
||||
@ -194,6 +217,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{
|
||||
label: "Forward",
|
||||
accelerator: "CmdOrCtrl+]",
|
||||
id: "go-forward",
|
||||
click() {
|
||||
webContents.getAllWebContents().filter(wc => wc.getType() === "window").forEach(wc => wc.goForward());
|
||||
}
|
||||
@ -201,6 +225,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{
|
||||
label: "Reload",
|
||||
accelerator: "CmdOrCtrl+R",
|
||||
id: "reload",
|
||||
click() {
|
||||
windowManager.reload();
|
||||
}
|
||||
@ -214,23 +239,27 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{ role: "togglefullscreen" }
|
||||
]
|
||||
};
|
||||
const helpMenu: MenuItemConstructorOptions = {
|
||||
const helpMenu: MenuItemsOpts = {
|
||||
role: "help",
|
||||
id: "help",
|
||||
submenu: [
|
||||
{
|
||||
label: "Welcome",
|
||||
id: "welcome",
|
||||
click() {
|
||||
navigate(welcomeURL());
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Documentation",
|
||||
id: "documentation",
|
||||
click: async () => {
|
||||
shell.openExternal(docsUrl);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Support",
|
||||
id: "support",
|
||||
click: async () => {
|
||||
shell.openExternal(supportUrl);
|
||||
},
|
||||
@ -238,6 +267,7 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
...ignoreOnMac([
|
||||
{
|
||||
label: `About ${productName}`,
|
||||
id: "about",
|
||||
click(menuItem: MenuItem, browserWindow: BrowserWindow) {
|
||||
showAbout(browserWindow);
|
||||
}
|
||||
@ -246,69 +276,28 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
]
|
||||
};
|
||||
// Prepare menu items order
|
||||
const appMenu: Record<MenuTopId, MenuItemConstructorOptions> = {
|
||||
mac: macAppMenu,
|
||||
file: fileMenu,
|
||||
edit: editMenu,
|
||||
view: viewMenu,
|
||||
help: helpMenu,
|
||||
};
|
||||
const appMenu = new Map([
|
||||
["mac", macAppMenu],
|
||||
["file", fileMenu],
|
||||
["edit", editMenu],
|
||||
["view", viewMenu],
|
||||
["help", helpMenu],
|
||||
]);
|
||||
|
||||
// Modify menu from extensions-api
|
||||
MenuRegistry.getInstance().getItems().forEach(({ parentId, ...menuItem }) => {
|
||||
try {
|
||||
const topMenu = appMenu[parentId as MenuTopId].submenu as MenuItemConstructorOptions[];
|
||||
for (const { parentId, ...menuItem } of MenuRegistry.getInstance().getItems()) {
|
||||
if (!appMenu.has(parentId)) {
|
||||
logger.error(`[MENU]: cannot register menu item for parentId=${parentId}, parent item doesn't exist`, { menuItem });
|
||||
|
||||
topMenu.push(menuItem);
|
||||
} catch (err) {
|
||||
logger.error(`[MENU]: can't register menu item, parentId=${parentId}`, { menuItem });
|
||||
continue;
|
||||
}
|
||||
});
|
||||
|
||||
appMenu.get(parentId).submenu.push(menuItem);
|
||||
}
|
||||
|
||||
if (!isMac) {
|
||||
delete appMenu.mac;
|
||||
appMenu.delete("mac");
|
||||
}
|
||||
|
||||
const menu = Menu.buildFromTemplate(Object.values(appMenu));
|
||||
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
if (isTestEnv) {
|
||||
// this is a workaround for the test environment (spectron) not being able to directly access
|
||||
// the application menus (https://github.com/electron-userland/spectron/issues/21)
|
||||
ipcMainOn("test-menu-item-click", (event: IpcMainEvent, ...names: string[]) => {
|
||||
let menu: Menu = Menu.getApplicationMenu();
|
||||
const parentLabels: string[] = [];
|
||||
let menuItem: MenuItem;
|
||||
|
||||
for (const name of names) {
|
||||
parentLabels.push(name);
|
||||
menuItem = menu?.items?.find(item => item.label === name);
|
||||
|
||||
if (!menuItem) {
|
||||
break;
|
||||
}
|
||||
menu = menuItem.submenu;
|
||||
}
|
||||
|
||||
const menuPath: string = parentLabels.join(" -> ");
|
||||
|
||||
if (!menuItem) {
|
||||
logger.info(`[MENU:test-menu-item-click] Cannot find menu item ${menuPath}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const { enabled, visible, click } = menuItem;
|
||||
|
||||
if (enabled === false || visible === false || typeof click !== "function") {
|
||||
logger.info(`[MENU:test-menu-item-click] Menu item ${menuPath} not clickable`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(`[MENU:test-menu-item-click] Menu item ${menuPath} click!`);
|
||||
menuItem.click();
|
||||
});
|
||||
}
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate([...appMenu.values()]));
|
||||
}
|
||||
|
||||
@ -38,9 +38,9 @@ export class PrometheusOperator extends PrometheusProvider {
|
||||
case "cluster":
|
||||
switch (queryName) {
|
||||
case "memoryUsage":
|
||||
return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes))`.replace(/_bytes/g, `_bytes * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}`);
|
||||
return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes))`.replace(/_bytes/g, `_bytes{node=~"${opts.nodes}"}`);
|
||||
case "workloadMemoryUsage":
|
||||
return `sum(container_memory_working_set_bytes{container!="POD",container!="",instance=~"${opts.nodes}"}) by (component)`;
|
||||
return `sum(container_memory_working_set_bytes{container!="", instance=~"${opts.nodes}"}) by (component)`;
|
||||
case "memoryRequests":
|
||||
return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="memory"})`;
|
||||
case "memoryLimits":
|
||||
@ -50,7 +50,7 @@ export class PrometheusOperator extends PrometheusProvider {
|
||||
case "memoryAllocatableCapacity":
|
||||
return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="memory"})`;
|
||||
case "cpuUsage":
|
||||
return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])* on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`;
|
||||
return `sum(rate(node_cpu_seconds_total{node=~"${opts.nodes}", mode=~"user|system"}[${this.rateAccuracy}]))`;
|
||||
case "cpuRequests":
|
||||
return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"})`;
|
||||
case "cpuLimits":
|
||||
@ -66,61 +66,61 @@ export class PrometheusOperator extends PrometheusProvider {
|
||||
case "podAllocatableCapacity":
|
||||
return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="pods"})`;
|
||||
case "fsSize":
|
||||
return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`;
|
||||
return `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`;
|
||||
case "fsUsage":
|
||||
return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"} - node_filesystem_avail_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`;
|
||||
return `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"} - node_filesystem_avail_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`;
|
||||
}
|
||||
break;
|
||||
case "nodes":
|
||||
switch (queryName) {
|
||||
case "memoryUsage":
|
||||
return `sum((node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) * on (pod,namespace) group_left(node) kube_pod_info) by (node)`;
|
||||
return `sum (node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) by (node)`;
|
||||
case "workloadMemoryUsage":
|
||||
return `sum(container_memory_working_set_bytes{container!="POD",container!=""}) by (node)`;
|
||||
return `sum(container_memory_working_set_bytes{container!=""}) by (node)`;
|
||||
case "memoryCapacity":
|
||||
return `sum(kube_node_status_capacity{resource="memory"}) by (node)`;
|
||||
case "memoryAllocatableCapacity":
|
||||
return `sum(kube_node_status_allocatable{resource="memory"}) by (node)`;
|
||||
case "cpuUsage":
|
||||
return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}]) * on (pod,namespace) group_left(node) kube_pod_info) by (node)`;
|
||||
return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])) by(node)`;
|
||||
case "cpuCapacity":
|
||||
return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`;
|
||||
case "cpuAllocatableCapacity":
|
||||
return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`;
|
||||
case "fsSize":
|
||||
return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info) by (node)`;
|
||||
return `sum(node_filesystem_size_bytes{mountpoint="/"}) by (node)`;
|
||||
case "fsUsage":
|
||||
return `sum((node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) * on (pod,namespace) group_left(node) kube_pod_info) by (node)`;
|
||||
return `sum(node_filesystem_size_bytes{mountpoint="/"} - node_filesystem_avail_bytes{mountpoint="/"}) by (node)`;
|
||||
}
|
||||
break;
|
||||
case "pods":
|
||||
switch (queryName) {
|
||||
case "cpuUsage":
|
||||
return `sum(rate(container_cpu_usage_seconds_total{container!="POD",container!="",image!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`;
|
||||
return `sum(rate(container_cpu_usage_seconds_total{container!="", image!="", pod=~"${opts.pods}", namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`;
|
||||
case "cpuRequests":
|
||||
return `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}",resource="cpu",namespace="${opts.namespace}"}) by (${opts.selector})`;
|
||||
return `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}", resource="cpu", namespace="${opts.namespace}"}) by (${opts.selector})`;
|
||||
case "cpuLimits":
|
||||
return `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}",resource="cpu",namespace="${opts.namespace}"}) by (${opts.selector})`;
|
||||
return `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}", resource="cpu", namespace="${opts.namespace}"}) by (${opts.selector})`;
|
||||
case "memoryUsage":
|
||||
return `sum(container_memory_working_set_bytes{container!="POD",container!="",image!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`;
|
||||
return `sum(container_memory_working_set_bytes{container!="", image!="", pod=~"${opts.pods}", namespace="${opts.namespace}"}) by (${opts.selector})`;
|
||||
case "memoryRequests":
|
||||
return `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}",resource="memory",namespace="${opts.namespace}"}) by (${opts.selector})`;
|
||||
return `sum(kube_pod_container_resource_requests{pod=~"${opts.pods}", resource="memory", namespace="${opts.namespace}"}) by (${opts.selector})`;
|
||||
case "memoryLimits":
|
||||
return `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}",resource="memory",namespace="${opts.namespace}"}) by (${opts.selector})`;
|
||||
return `sum(kube_pod_container_resource_limits{pod=~"${opts.pods}", resource="memory", namespace="${opts.namespace}"}) by (${opts.selector})`;
|
||||
case "fsUsage":
|
||||
return `sum(container_fs_usage_bytes{container!="POD",container!="",pod=~"${opts.pods}",namespace="${opts.namespace}"}) by (${opts.selector})`;
|
||||
return `sum(container_fs_usage_bytes{container!="", pod=~"${opts.pods}", namespace="${opts.namespace}"}) by (${opts.selector})`;
|
||||
case "networkReceive":
|
||||
return `sum(rate(container_network_receive_bytes_total{pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`;
|
||||
return `sum(rate(container_network_receive_bytes_total{pod=~"${opts.pods}", namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`;
|
||||
case "networkTransmit":
|
||||
return `sum(rate(container_network_transmit_bytes_total{pod=~"${opts.pods}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`;
|
||||
return `sum(rate(container_network_transmit_bytes_total{pod=~"${opts.pods}", namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (${opts.selector})`;
|
||||
}
|
||||
break;
|
||||
case "pvc":
|
||||
switch (queryName) {
|
||||
case "diskUsage":
|
||||
return `sum(kubelet_volume_stats_used_bytes{persistentvolumeclaim="${opts.pvc}",namespace="${opts.namespace}"}) by (persistentvolumeclaim, namespace)`;
|
||||
return `sum(kubelet_volume_stats_used_bytes{persistentvolumeclaim="${opts.pvc}", namespace="${opts.namespace}"}) by (persistentvolumeclaim, namespace)`;
|
||||
case "diskCapacity":
|
||||
return `sum(kubelet_volume_stats_capacity_bytes{persistentvolumeclaim="${opts.pvc}",namespace="${opts.namespace}"}) by (persistentvolumeclaim, namespace)`;
|
||||
return `sum(kubelet_volume_stats_capacity_bytes{persistentvolumeclaim="${opts.pvc}", namespace="${opts.namespace}"}) by (persistentvolumeclaim, namespace)`;
|
||||
}
|
||||
break;
|
||||
case "ingress":
|
||||
@ -130,9 +130,9 @@ export class PrometheusOperator extends PrometheusProvider {
|
||||
case "bytesSentFailure":
|
||||
return this.bytesSent(opts.ingress, opts.namespace, "^5\\\\d*");
|
||||
case "requestDurationSeconds":
|
||||
return `sum(rate(nginx_ingress_controller_request_duration_seconds_sum{ingress="${opts.ingress}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (ingress, namespace)`;
|
||||
return `sum(rate(nginx_ingress_controller_request_duration_seconds_sum{ingress="${opts.ingress}", namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (ingress, namespace)`;
|
||||
case "responseDurationSeconds":
|
||||
return `sum(rate(nginx_ingress_controller_response_duration_seconds_sum{ingress="${opts.ingress}",namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (ingress, namespace)`;
|
||||
return `sum(rate(nginx_ingress_controller_response_duration_seconds_sum{ingress="${opts.ingress}", namespace="${opts.namespace}"}[${this.rateAccuracy}])) by (ingress, namespace)`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@ -126,7 +126,7 @@ describe("protocol router tests", () => {
|
||||
}
|
||||
|
||||
await delay(50);
|
||||
expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerInternal, "lens://app/", "matched");
|
||||
expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerInternal, "lens://app", "matched");
|
||||
expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerExtension, "lens://extension/@mirantis/minikube", "matched");
|
||||
});
|
||||
|
||||
|
||||
@ -22,55 +22,96 @@
|
||||
import type { Cluster } from "./cluster";
|
||||
import type { KubernetesObject } from "@kubernetes/client-node";
|
||||
import { exec } from "child_process";
|
||||
import fs from "fs";
|
||||
import fs from "fs-extra";
|
||||
import * as yaml from "js-yaml";
|
||||
import path from "path";
|
||||
import * as tempy from "tempy";
|
||||
import logger from "./logger";
|
||||
import { appEventBus } from "../common/event-bus";
|
||||
import { cloneJsonObject } from "../common/utils";
|
||||
import type { Patch } from "rfc6902";
|
||||
import { promiseExecFile } from "./promise-exec";
|
||||
|
||||
export class ResourceApplier {
|
||||
constructor(protected cluster: Cluster) {
|
||||
constructor(protected cluster: Cluster) {}
|
||||
|
||||
/**
|
||||
* Patch a kube resource's manifest, throwing any error that occurs.
|
||||
* @param name The name of the kube resource
|
||||
* @param kind The kind of the kube resource
|
||||
* @param patch The list of JSON operations
|
||||
* @param ns The optional namespace of the kube resource
|
||||
*/
|
||||
async patch(name: string, kind: string, patch: Patch, ns?: string): Promise<string> {
|
||||
appEventBus.emit({ name: "resource", action: "patch" });
|
||||
|
||||
const kubectl = await this.cluster.ensureKubectl();
|
||||
const kubectlPath = await kubectl.getPath();
|
||||
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
|
||||
const args = [
|
||||
"--kubeconfig", proxyKubeconfigPath,
|
||||
"patch",
|
||||
kind,
|
||||
name,
|
||||
];
|
||||
|
||||
if (ns) {
|
||||
args.push("--namespace", ns);
|
||||
}
|
||||
|
||||
args.push(
|
||||
"--type", "json",
|
||||
"--patch", JSON.stringify(patch),
|
||||
"-o", "json"
|
||||
);
|
||||
|
||||
try {
|
||||
const { stdout } = await promiseExecFile(kubectlPath, args);
|
||||
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
throw error.stderr ?? error;
|
||||
}
|
||||
}
|
||||
|
||||
async apply(resource: KubernetesObject | any): Promise<string> {
|
||||
resource = this.sanitizeObject(resource);
|
||||
appEventBus.emit({ name: "resource", action: "apply" });
|
||||
|
||||
return await this.kubectlApply(yaml.safeDump(resource));
|
||||
return this.kubectlApply(yaml.safeDump(resource));
|
||||
}
|
||||
|
||||
protected async kubectlApply(content: string): Promise<string> {
|
||||
const kubectl = await this.cluster.ensureKubectl();
|
||||
const kubectlPath = await kubectl.getPath();
|
||||
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
|
||||
const fileName = tempy.file({ name: "resource.yaml" });
|
||||
const args = [
|
||||
"apply",
|
||||
"--kubeconfig", proxyKubeconfigPath,
|
||||
"-o", "json",
|
||||
"-f", fileName,
|
||||
];
|
||||
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const fileName = tempy.file({ name: "resource.yaml" });
|
||||
logger.debug(`shooting manifests with ${kubectlPath}`, { args });
|
||||
|
||||
fs.writeFileSync(fileName, content);
|
||||
const cmd = `"${kubectlPath}" apply --kubeconfig "${proxyKubeconfigPath}" -o json -f "${fileName}"`;
|
||||
const execEnv = { ...process.env };
|
||||
const httpsProxy = this.cluster.preferences?.httpsProxy;
|
||||
|
||||
logger.debug(`shooting manifests with: ${cmd}`);
|
||||
const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env);
|
||||
const httpsProxy = this.cluster.preferences?.httpsProxy;
|
||||
if (httpsProxy) {
|
||||
execEnv.HTTPS_PROXY = httpsProxy;
|
||||
}
|
||||
|
||||
if (httpsProxy) {
|
||||
execEnv["HTTPS_PROXY"] = httpsProxy;
|
||||
}
|
||||
exec(cmd, { env: execEnv },
|
||||
(error, stdout, stderr) => {
|
||||
if (stderr != "") {
|
||||
fs.unlinkSync(fileName);
|
||||
reject(stderr);
|
||||
try {
|
||||
await fs.writeFile(fileName, content);
|
||||
const { stdout } = await promiseExecFile(kubectlPath, args);
|
||||
|
||||
return;
|
||||
}
|
||||
fs.unlinkSync(fileName);
|
||||
resolve(JSON.parse(stdout));
|
||||
});
|
||||
});
|
||||
return stdout;
|
||||
} catch (error) {
|
||||
throw error?.stderr ?? error;
|
||||
} finally {
|
||||
await fs.unlink(fileName);
|
||||
}
|
||||
}
|
||||
|
||||
public async kubectlApplyAll(resources: string[], extraArgs = ["-o", "json"]): Promise<string> {
|
||||
|
||||
@ -179,8 +179,11 @@ export class Router {
|
||||
this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, MetricsRoute.routeMetrics);
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/metrics/providers` }, MetricsRoute.routeMetricsProviders);
|
||||
|
||||
// Port-forward API
|
||||
this.router.add({ method: "post", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, PortForwardRoute.routePortForward);
|
||||
// Port-forward API (the container port and local forwarding port are obtained from the query parameters)
|
||||
this.router.add({ method: "post", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routePortForward);
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForward);
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/pods/port-forwards` }, PortForwardRoute.routeAllPortForwards);
|
||||
this.router.add({ method: "delete", path: `${apiPrefix}/pods/port-forward/{namespace}/{resourceType}/{resourceName}` }, PortForwardRoute.routeCurrentPortForwardStop);
|
||||
|
||||
// Helm API
|
||||
this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, HelmApiRoute.listCharts);
|
||||
@ -198,5 +201,6 @@ export class Router {
|
||||
|
||||
// Resource Applier API
|
||||
this.router.add({ method: "post", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.applyResource);
|
||||
this.router.add({ method: "patch", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.patchResource);
|
||||
}
|
||||
}
|
||||
|
||||
@ -23,7 +23,7 @@ import type { LensApiRequest } from "../router";
|
||||
import { helmService } from "../helm/helm-service";
|
||||
import logger from "../logger";
|
||||
import { respondJson, respondText } from "../utils/http-responses";
|
||||
import { getBoolean } from "./utils/parse-query";
|
||||
import { getBoolean } from "../utils/parse-query";
|
||||
|
||||
export class HelmApiRoute {
|
||||
static async listCharts(request: LensApiRequest) {
|
||||
|
||||
@ -22,7 +22,6 @@
|
||||
import type { LensApiRequest } from "../router";
|
||||
import { spawn, ChildProcessWithoutNullStreams } from "child_process";
|
||||
import { Kubectl } from "../kubectl";
|
||||
import { shell } from "electron";
|
||||
import * as tcpPortUsed from "tcp-port-used";
|
||||
import logger from "../logger";
|
||||
import { getPortFrom } from "../utils/get-port";
|
||||
@ -33,7 +32,8 @@ interface PortForwardArgs {
|
||||
kind: string;
|
||||
namespace: string;
|
||||
name: string;
|
||||
port: string;
|
||||
port: number;
|
||||
forwardPort: number;
|
||||
}
|
||||
|
||||
const internalPortRegex = /^forwarding from (?<address>.+) ->/i;
|
||||
@ -56,8 +56,8 @@ class PortForward {
|
||||
public kind: string;
|
||||
public namespace: string;
|
||||
public name: string;
|
||||
public port: string;
|
||||
public internalPort?: number;
|
||||
public port: number;
|
||||
public forwardPort: number;
|
||||
|
||||
constructor(public kubeConfig: string, args: PortForwardArgs) {
|
||||
this.clusterId = args.clusterId;
|
||||
@ -65,16 +65,17 @@ class PortForward {
|
||||
this.namespace = args.namespace;
|
||||
this.name = args.name;
|
||||
this.port = args.port;
|
||||
this.forwardPort = args.forwardPort;
|
||||
}
|
||||
|
||||
public async start() {
|
||||
const kubectlBin = await Kubectl.bundled().getPath();
|
||||
const kubectlBin = await Kubectl.bundled().getPath(true);
|
||||
const args = [
|
||||
"--kubeconfig", this.kubeConfig,
|
||||
"port-forward",
|
||||
"-n", this.namespace,
|
||||
`${this.kind}/${this.name}`,
|
||||
`:${this.port}`
|
||||
`${this.forwardPort ?? ""}:${this.port}`
|
||||
];
|
||||
|
||||
this.process = spawn(kubectlBin, args, {
|
||||
@ -89,12 +90,15 @@ class PortForward {
|
||||
}
|
||||
});
|
||||
|
||||
this.internalPort = await getPortFrom(this.process.stdout, {
|
||||
const internalPort = await getPortFrom(this.process.stdout, {
|
||||
lineRegex: internalPortRegex,
|
||||
});
|
||||
|
||||
try {
|
||||
await tcpPortUsed.waitUntilUsed(this.internalPort, 500, 15000);
|
||||
await tcpPortUsed.waitUntilUsed(internalPort, 500, 15000);
|
||||
|
||||
// make sure this.forwardPort is set to the actual port used (if it was 0 then an available port is found by 'kubectl port-forward')
|
||||
this.forwardPort = internalPort;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
@ -104,58 +108,113 @@ class PortForward {
|
||||
}
|
||||
}
|
||||
|
||||
public open() {
|
||||
shell.openExternal(`http://localhost:${this.internalPort}`)
|
||||
.catch(error => logger.error(`[PORT-FORWARD]: failed to open external shell: ${error}`, {
|
||||
clusterId: this.clusterId,
|
||||
port: this.port,
|
||||
kind: this.kind,
|
||||
namespace: this.namespace,
|
||||
name: this.name,
|
||||
}));
|
||||
public async stop() {
|
||||
this.process.kill();
|
||||
}
|
||||
}
|
||||
|
||||
export class PortForwardRoute {
|
||||
static async routePortForward(request: LensApiRequest) {
|
||||
const { params, response, cluster} = request;
|
||||
const { namespace, port, resourceType, resourceName } = params;
|
||||
let portForward = PortForward.getPortforward({
|
||||
clusterId: cluster.id, kind: resourceType, name: resourceName,
|
||||
namespace, port
|
||||
});
|
||||
const { params, query, response, cluster } = request;
|
||||
const { namespace, resourceType, resourceName } = params;
|
||||
const port = Number(query.get("port"));
|
||||
const forwardPort = Number(query.get("forwardPort"));
|
||||
|
||||
if (!portForward) {
|
||||
logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`);
|
||||
portForward = new PortForward(await cluster.getProxyKubeconfigPath(), {
|
||||
clusterId: cluster.id,
|
||||
kind: resourceType,
|
||||
namespace,
|
||||
name: resourceName,
|
||||
port,
|
||||
try {
|
||||
let portForward = PortForward.getPortforward({
|
||||
clusterId: cluster.id, kind: resourceType, name: resourceName,
|
||||
namespace, port, forwardPort,
|
||||
});
|
||||
|
||||
try {
|
||||
let thePort = 0;
|
||||
|
||||
if (forwardPort > 0 && forwardPort < 65536) {
|
||||
thePort = forwardPort;
|
||||
}
|
||||
|
||||
if (!portForward) {
|
||||
logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`);
|
||||
portForward = new PortForward(await cluster.getProxyKubeconfigPath(), {
|
||||
clusterId: cluster.id,
|
||||
kind: resourceType,
|
||||
namespace,
|
||||
name: resourceName,
|
||||
port,
|
||||
forwardPort: thePort,
|
||||
});
|
||||
|
||||
const started = await portForward.start();
|
||||
|
||||
if (!started) {
|
||||
logger.warn("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { namespace, port, resourceType, resourceName });
|
||||
logger.error("[PORT-FORWARD-ROUTE]: failed to start a port-forward", { namespace, port, resourceType, resourceName });
|
||||
|
||||
return respondJson(response, {
|
||||
message: "Failed to open port-forward"
|
||||
message: `Failed to forward port ${port} to ${thePort ? forwardPort : "random port"}`
|
||||
}, 400);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, { namespace, port, resourceType, resourceName });
|
||||
|
||||
return respondJson(response, {
|
||||
message: error?.toString() || "Failed to open port-forward",
|
||||
}, 400);
|
||||
}
|
||||
|
||||
respondJson(response, { port: portForward.forwardPort });
|
||||
} catch (error) {
|
||||
logger.error(`[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, { namespace, port, resourceType, resourceName });
|
||||
|
||||
return respondJson(response, {
|
||||
message: `Failed to forward port ${port}`
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
|
||||
portForward.open();
|
||||
static async routeCurrentPortForward(request: LensApiRequest) {
|
||||
const { params, query, response, cluster } = request;
|
||||
const { namespace, resourceType, resourceName } = params;
|
||||
const port = Number(query.get("port"));
|
||||
const forwardPort = Number(query.get("forwardPort"));
|
||||
|
||||
respondJson(response, {});
|
||||
const portForward = PortForward.getPortforward({
|
||||
clusterId: cluster.id, kind: resourceType, name: resourceName,
|
||||
namespace, port, forwardPort
|
||||
});
|
||||
|
||||
respondJson(response, { port: portForward?.forwardPort ?? null });
|
||||
}
|
||||
|
||||
static async routeAllPortForwards(request: LensApiRequest) {
|
||||
const { response } = request;
|
||||
|
||||
const portForwards: PortForwardArgs[] = PortForward.portForwards.map(f => (
|
||||
{
|
||||
clusterId: f.clusterId,
|
||||
kind: f.kind,
|
||||
namespace: f.namespace,
|
||||
name: f.name,
|
||||
port: f.port,
|
||||
forwardPort: f.forwardPort,
|
||||
})
|
||||
);
|
||||
|
||||
respondJson(response, { portForwards });
|
||||
}
|
||||
|
||||
static async routeCurrentPortForwardStop(request: LensApiRequest) {
|
||||
const { params, query, response, cluster } = request;
|
||||
const { namespace, resourceType, resourceName } = params;
|
||||
const port = Number(query.get("port"));
|
||||
const forwardPort = Number(query.get("forwardPort"));
|
||||
|
||||
const portForward = PortForward.getPortforward({
|
||||
clusterId: cluster.id, kind: resourceType, name: resourceName,
|
||||
namespace, port, forwardPort,
|
||||
});
|
||||
|
||||
try {
|
||||
await portForward.stop();
|
||||
respondJson(response, { status: true });
|
||||
} catch (error) {
|
||||
logger.error("[PORT-FORWARD-ROUTE]: error stopping a port-forward", { namespace, port, forwardPort, resourceType, resourceName });
|
||||
|
||||
return respondJson(response, {
|
||||
message: `error stopping a forward port ${port}`
|
||||
}, 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,7 +30,19 @@ export class ResourceApplierApiRoute {
|
||||
try {
|
||||
const resource = await new ResourceApplier(cluster).apply(payload);
|
||||
|
||||
respondJson(response, [resource], 200);
|
||||
respondJson(response, resource, 200);
|
||||
} catch (error) {
|
||||
respondText(response, error, 422);
|
||||
}
|
||||
}
|
||||
|
||||
static async patchResource(request: LensApiRequest) {
|
||||
const { response, cluster, payload } = request;
|
||||
|
||||
try {
|
||||
const resource = await new ResourceApplier(cluster).patch(payload.name, payload.kind, payload.patch, payload.ns);
|
||||
|
||||
respondJson(response, resource, 200);
|
||||
} catch (error) {
|
||||
respondText(response, error, 422);
|
||||
}
|
||||
|
||||
@ -21,14 +21,37 @@
|
||||
|
||||
import type http from "http";
|
||||
|
||||
export function respondJson(res: http.ServerResponse, content: any, status = 200) {
|
||||
respond(res, JSON.stringify(content), "application/json", status);
|
||||
/**
|
||||
* Respond to a HTTP request with a body of JSON data
|
||||
* @param res The HTTP response to write data to
|
||||
* @param content The data or its JSON stringified version of it
|
||||
* @param status [200] The status code to respond with
|
||||
*/
|
||||
export function respondJson(res: http.ServerResponse, content: Object | string, status = 200) {
|
||||
const normalizedContent = typeof content === "object"
|
||||
? JSON.stringify(content)
|
||||
: content;
|
||||
|
||||
respond(res, normalizedContent, "application/json", status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to a HTTP request with a body of plain text data
|
||||
* @param res The HTTP response to write data to
|
||||
* @param content The string data to respond with
|
||||
* @param status [200] The status code to respond with
|
||||
*/
|
||||
export function respondText(res: http.ServerResponse, content: string, status = 200) {
|
||||
respond(res, content, "text/plain", status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Respond to a HTTP request with a body of plain text data
|
||||
* @param res The HTTP response to write data to
|
||||
* @param content The string data to respond with
|
||||
* @param contentType The HTTP Content-Type header value
|
||||
* @param status [200] The status code to respond with
|
||||
*/
|
||||
export function respond(res: http.ServerResponse, content: string, contentType: string, status = 200) {
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.statusCode = status;
|
||||
|
||||
@ -109,10 +109,6 @@ export class WindowManager extends Singleton {
|
||||
app.dock?.hide(); // hide icon in dock (mac-os)
|
||||
})
|
||||
.webContents
|
||||
.on("new-window", (event, url) => {
|
||||
event.preventDefault();
|
||||
shell.openExternal(url);
|
||||
})
|
||||
.on("dom-ready", () => {
|
||||
appEventBus.emit({ name: "app", action: "dom-ready" });
|
||||
})
|
||||
@ -150,6 +146,10 @@ export class WindowManager extends Singleton {
|
||||
|
||||
// Always disable Node.js integration for all webviews
|
||||
webPreferences.nodeIntegration = false;
|
||||
}).setWindowOpenHandler((details) => {
|
||||
shell.openExternal(details.url);
|
||||
|
||||
return { action: "deny" };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -19,12 +19,12 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { app } from "electron";
|
||||
import fse from "fs-extra";
|
||||
import path from "path";
|
||||
import { getPath } from "../../common/utils/getPath";
|
||||
|
||||
export function fileNameMigration() {
|
||||
const userDataPath = getPath("userData");
|
||||
const userDataPath = app.getPath("userData");
|
||||
const configJsonPath = path.join(userDataPath, "config.json");
|
||||
const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json");
|
||||
|
||||
|
||||
@ -27,8 +27,12 @@ import type { Cluster } from "../../main/cluster";
|
||||
import { ClusterStore } from "../../common/cluster-store";
|
||||
import { Disposer, iter } from "../utils";
|
||||
import { once } from "lodash";
|
||||
import logger from "../../common/logger";
|
||||
import { catalogEntityRunContext } from "./catalog-entity";
|
||||
import { CatalogRunEvent } from "../../common/catalog/catalog-run-event";
|
||||
|
||||
export type EntityFilter = (entity: CatalogEntity) => any;
|
||||
export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise<void>;
|
||||
|
||||
export class CatalogEntityRegistry {
|
||||
@observable protected activeEntityId: string | undefined = undefined;
|
||||
@ -36,6 +40,9 @@ export class CatalogEntityRegistry {
|
||||
protected filters = observable.set<EntityFilter>([], {
|
||||
deep: false,
|
||||
});
|
||||
protected onBeforeRunHooks = observable.set<CatalogEntityOnBeforeRun>([], {
|
||||
deep: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Buffer for keeping entities that don't yet have CatalogCategory synced
|
||||
@ -169,6 +176,60 @@ export class CatalogEntityRegistry {
|
||||
|
||||
return once(() => void this.filters.delete(fn));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a onBeforeRun hook. If `onBeforeRun` was previously added then it will not be added again
|
||||
* @param onBeforeRun The function that should return a boolean if the onRun of catalog entity should be triggered.
|
||||
* @returns A function to remove that hook
|
||||
*/
|
||||
addOnBeforeRun(onBeforeRun: CatalogEntityOnBeforeRun): Disposer {
|
||||
logger.debug(`[CATALOG-ENTITY-REGISTRY]: adding onBeforeRun hook`);
|
||||
|
||||
this.onBeforeRunHooks.add(onBeforeRun);
|
||||
|
||||
return once(() => void this.onBeforeRunHooks.delete(onBeforeRun));
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs all the registered `onBeforeRun` hooks, short circuiting on the first event that's preventDefaulted
|
||||
* @param entity The entity to run the hooks on
|
||||
* @returns Whether the entities `onRun` method should be executed
|
||||
*/
|
||||
async onBeforeRun(entity: CatalogEntity): Promise<boolean> {
|
||||
logger.debug(`[CATALOG-ENTITY-REGISTRY]: run onBeforeRun on ${entity.getId()}`);
|
||||
|
||||
const runEvent = new CatalogRunEvent({ target: entity });
|
||||
|
||||
for (const onBeforeRun of this.onBeforeRunHooks) {
|
||||
try {
|
||||
await onBeforeRun(runEvent);
|
||||
} catch (error) {
|
||||
logger.warn(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onBeforeRun threw an error`, error);
|
||||
}
|
||||
|
||||
if (runEvent.defaultPrevented) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the onBeforeRun check and, if successful, then proceed to call `entity`'s onRun method
|
||||
* @param entity The instance to invoke the hooks and then execute the onRun
|
||||
*/
|
||||
onRun(entity: CatalogEntity): void {
|
||||
this.onBeforeRun(entity)
|
||||
.then(doOnRun => {
|
||||
if (doOnRun) {
|
||||
return entity.onRun?.(catalogEntityRunContext);
|
||||
} else {
|
||||
logger.debug(`onBeforeRun for ${entity.getId()} returned false`);
|
||||
}
|
||||
})
|
||||
.catch(error => logger.error(`[CATALOG-ENTITY-REGISTRY]: entity ${entity.getId()} onRun threw an error`, error));
|
||||
}
|
||||
}
|
||||
|
||||
export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
|
||||
|
||||
@ -29,7 +29,7 @@ import * as ReactRouterDom from "react-router-dom";
|
||||
import * as LensExtensionsCommonApi from "../extensions/common-api";
|
||||
import * as LensExtensionsRendererApi from "../extensions/renderer-api";
|
||||
import { monaco } from "react-monaco-editor";
|
||||
import { render, unmountComponentAtNode } from "react-dom";
|
||||
import { render } from "react-dom";
|
||||
import { delay } from "../common/utils";
|
||||
import { isMac, isDevelopment } from "../common/vars";
|
||||
import { ClusterStore } from "../common/cluster-store";
|
||||
@ -66,7 +66,7 @@ async function attachChromeDebugger() {
|
||||
}
|
||||
|
||||
type AppComponent = React.ComponentType & {
|
||||
init?(): Promise<void>;
|
||||
init?(rootElem: HTMLElement): Promise<void>;
|
||||
};
|
||||
|
||||
export async function bootstrap(App: AppComponent) {
|
||||
@ -125,16 +125,8 @@ export async function bootstrap(App: AppComponent) {
|
||||
|
||||
// init app's dependencies if any
|
||||
if (App.init) {
|
||||
await App.init();
|
||||
await App.init(rootElem);
|
||||
}
|
||||
window.addEventListener("message", (ev: MessageEvent) => {
|
||||
if (ev.data === "teardown") {
|
||||
UserStore.getInstance(false)?.unregisterIpcListener();
|
||||
ClusterStore.getInstance(false)?.unregisterIpcListener();
|
||||
unmountComponentAtNode(rootElem);
|
||||
window.location.href = "about:blank";
|
||||
}
|
||||
});
|
||||
render(<>
|
||||
{isMac && <div id="draggable-top" />}
|
||||
{DefaultProps(App)}
|
||||
|
||||
@ -59,7 +59,7 @@ export class ReleaseDetails extends Component<Props> {
|
||||
@observable details: IReleaseDetails;
|
||||
@observable values = "";
|
||||
@observable valuesLoading = false;
|
||||
@observable showOnlyUserSuppliedValues = false;
|
||||
@observable showOnlyUserSuppliedValues = true;
|
||||
@observable saving = false;
|
||||
@observable releaseSecret: Secret;
|
||||
|
||||
@ -165,7 +165,7 @@ export class ReleaseDetails extends Component<Props> {
|
||||
onChange={text => this.values = text}
|
||||
theme={ThemeStore.getInstance().activeTheme.monacoTheme}
|
||||
className={cssNames("MonacoEditor", {loading: valuesLoading})}
|
||||
options={{readOnly: valuesLoading || this.showOnlyUserSuppliedValues, ...UserStore.getInstance().getEditorOptions()}}
|
||||
options={{readOnly: valuesLoading, ...UserStore.getInstance().getEditorOptions()}}
|
||||
>
|
||||
{valuesLoading && <Spinner center />}
|
||||
</MonacoEditor>
|
||||
|
||||
@ -23,13 +23,12 @@ import "./catalog-entity-details.scss";
|
||||
import React, { Component } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { Drawer, DrawerItem } from "../drawer";
|
||||
import { catalogEntityRunContext } from "../../api/catalog-entity";
|
||||
import type { CatalogCategory, CatalogEntity } from "../../../common/catalog";
|
||||
import { Icon } from "../icon";
|
||||
import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu";
|
||||
import { CatalogEntityDetailRegistry } from "../../../extensions/registries";
|
||||
import { HotbarIcon } from "../hotbar/hotbar-icon";
|
||||
import type { CatalogEntityItem } from "./catalog-entity.store";
|
||||
import type { CatalogEntityItem } from "./catalog-entity-item";
|
||||
import { isDevelopment } from "../../../common/vars";
|
||||
|
||||
interface Props<T extends CatalogEntity> {
|
||||
@ -68,8 +67,10 @@ export class CatalogEntityDetails<T extends CatalogEntity> extends Component<Pro
|
||||
material={item.entity.spec.icon?.material}
|
||||
background={item.entity.spec.icon?.background}
|
||||
disabled={!item?.enabled}
|
||||
onClick={() => item.onRun(catalogEntityRunContext)}
|
||||
size={128} />
|
||||
onClick={() => item.onRun()}
|
||||
size={128}
|
||||
data-testid="detail-panel-hot-bar-icon"
|
||||
/>
|
||||
{item?.enabled && (
|
||||
<div className="IconHint">
|
||||
Click to open
|
||||
|
||||
@ -30,7 +30,7 @@ import { MenuItem } from "../menu";
|
||||
import { ConfirmDialog } from "../confirm-dialog";
|
||||
import { HotbarStore } from "../../../common/hotbar-store";
|
||||
import { Icon } from "../icon";
|
||||
import type { CatalogEntityItem } from "./catalog-entity.store";
|
||||
import type { CatalogEntityItem } from "./catalog-entity-item";
|
||||
|
||||
export interface CatalogEntityDrawerMenuProps<T extends CatalogEntity> extends MenuActionsProps {
|
||||
item: CatalogEntityItem<T> | null | undefined;
|
||||
|
||||
114
src/renderer/components/+catalog/catalog-entity-item.tsx
Normal file
114
src/renderer/components/+catalog/catalog-entity-item.tsx
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
import styles from "./catalog.module.css";
|
||||
import React from "react";
|
||||
import { action, computed } from "mobx";
|
||||
import type { CatalogEntity } from "../../api/catalog-entity";
|
||||
import type { ItemObject } from "../../../common/item.store";
|
||||
import { Badge } from "../badge";
|
||||
import { navigation } from "../../navigation";
|
||||
import { searchUrlParam } from "../input";
|
||||
import { makeCss } from "../../../common/utils/makeCss";
|
||||
import { KubeObject } from "../../../common/k8s-api/kube-object";
|
||||
import type { CatalogEntityRegistry } from "../../api/catalog-entity-registry";
|
||||
|
||||
const css = makeCss(styles);
|
||||
|
||||
export class CatalogEntityItem<T extends CatalogEntity> implements ItemObject {
|
||||
constructor(public entity: T, private registry: CatalogEntityRegistry) {}
|
||||
|
||||
get kind() {
|
||||
return this.entity.kind;
|
||||
}
|
||||
|
||||
get apiVersion() {
|
||||
return this.entity.apiVersion;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.entity.metadata.name;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.entity.metadata.name;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.entity.metadata.uid;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
@computed get phase() {
|
||||
return this.entity.status.phase;
|
||||
}
|
||||
|
||||
get enabled() {
|
||||
return this.entity.status.enabled ?? true;
|
||||
}
|
||||
|
||||
get labels() {
|
||||
return KubeObject.stringifyLabels(this.entity.metadata.labels);
|
||||
}
|
||||
|
||||
getLabelBadges(onClick?: React.MouseEventHandler<any>) {
|
||||
return this.labels
|
||||
.map(label => (
|
||||
<Badge
|
||||
className={css.badge}
|
||||
key={label}
|
||||
label={label}
|
||||
title={label}
|
||||
onClick={(event) => {
|
||||
navigation.searchParams.set(searchUrlParam.name, label);
|
||||
onClick?.(event);
|
||||
event.stopPropagation();
|
||||
}}
|
||||
expandable={false}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
get source() {
|
||||
return this.entity.metadata.source || "unknown";
|
||||
}
|
||||
|
||||
get searchFields() {
|
||||
return [
|
||||
this.name,
|
||||
this.id,
|
||||
this.phase,
|
||||
`source=${this.source}`,
|
||||
...this.labels,
|
||||
];
|
||||
}
|
||||
|
||||
onRun() {
|
||||
this.registry.onRun(this.entity);
|
||||
}
|
||||
|
||||
@action
|
||||
async onContextMenuOpen(ctx: any) {
|
||||
return this.entity.onContextMenuOpen(ctx);
|
||||
}
|
||||
}
|
||||
@ -19,106 +19,16 @@
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import styles from "./catalog.module.css";
|
||||
|
||||
import React from "react";
|
||||
import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from "mobx";
|
||||
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
|
||||
import type { CatalogEntity, CatalogEntityActionContext } from "../../api/catalog-entity";
|
||||
import { ItemObject, ItemStore } from "../../../common/item.store";
|
||||
import { computed, makeObservable, observable, reaction } from "mobx";
|
||||
import { catalogEntityRegistry, CatalogEntityRegistry } from "../../api/catalog-entity-registry";
|
||||
import type { CatalogEntity } from "../../api/catalog-entity";
|
||||
import { ItemStore } from "../../../common/item.store";
|
||||
import { CatalogCategory, catalogCategoryRegistry } from "../../../common/catalog";
|
||||
import { autoBind } from "../../../common/utils";
|
||||
import { Badge } from "../badge";
|
||||
import { navigation } from "../../navigation";
|
||||
import { searchUrlParam } from "../input";
|
||||
import { makeCss } from "../../../common/utils/makeCss";
|
||||
import { KubeObject } from "../../../common/k8s-api/kube-object";
|
||||
|
||||
const css = makeCss(styles);
|
||||
|
||||
export class CatalogEntityItem<T extends CatalogEntity> implements ItemObject {
|
||||
constructor(public entity: T) {}
|
||||
|
||||
get kind() {
|
||||
return this.entity.kind;
|
||||
}
|
||||
|
||||
get apiVersion() {
|
||||
return this.entity.apiVersion;
|
||||
}
|
||||
|
||||
get name() {
|
||||
return this.entity.metadata.name;
|
||||
}
|
||||
|
||||
getName() {
|
||||
return this.entity.metadata.name;
|
||||
}
|
||||
|
||||
get id() {
|
||||
return this.entity.metadata.uid;
|
||||
}
|
||||
|
||||
getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
@computed get phase() {
|
||||
return this.entity.status.phase;
|
||||
}
|
||||
|
||||
get enabled() {
|
||||
return this.entity.status.enabled ?? true;
|
||||
}
|
||||
|
||||
get labels() {
|
||||
return KubeObject.stringifyLabels(this.entity.metadata.labels);
|
||||
}
|
||||
|
||||
getLabelBadges(onClick?: React.MouseEventHandler<any>) {
|
||||
return this.labels
|
||||
.map(label => (
|
||||
<Badge
|
||||
className={css.badge}
|
||||
key={label}
|
||||
label={label}
|
||||
title={label}
|
||||
onClick={(event) => {
|
||||
navigation.searchParams.set(searchUrlParam.name, label);
|
||||
onClick?.(event);
|
||||
event.stopPropagation();
|
||||
}}
|
||||
expandable={false}
|
||||
/>
|
||||
));
|
||||
}
|
||||
|
||||
get source() {
|
||||
return this.entity.metadata.source || "unknown";
|
||||
}
|
||||
|
||||
get searchFields() {
|
||||
return [
|
||||
this.name,
|
||||
this.id,
|
||||
this.phase,
|
||||
`source=${this.source}`,
|
||||
...this.labels,
|
||||
];
|
||||
}
|
||||
|
||||
onRun(ctx: CatalogEntityActionContext) {
|
||||
this.entity.onRun(ctx);
|
||||
}
|
||||
|
||||
@action
|
||||
async onContextMenuOpen(ctx: any) {
|
||||
return this.entity.onContextMenuOpen(ctx);
|
||||
}
|
||||
}
|
||||
import { autoBind, disposer } from "../../../common/utils";
|
||||
import { CatalogEntityItem } from "./catalog-entity-item";
|
||||
|
||||
export class CatalogEntityStore extends ItemStore<CatalogEntityItem<CatalogEntity>> {
|
||||
constructor() {
|
||||
constructor(private registry: CatalogEntityRegistry = catalogEntityRegistry) {
|
||||
super();
|
||||
makeObservable(this);
|
||||
autoBind(this);
|
||||
@ -129,10 +39,10 @@ export class CatalogEntityStore extends ItemStore<CatalogEntityItem<CatalogEntit
|
||||
|
||||
@computed get entities() {
|
||||
if (!this.activeCategory) {
|
||||
return catalogEntityRegistry.filteredItems.map(entity => new CatalogEntityItem(entity));
|
||||
return this.registry.filteredItems.map(entity => new CatalogEntityItem(entity, this.registry));
|
||||
}
|
||||
|
||||
return catalogEntityRegistry.getItemsForCategory(this.activeCategory, { filtered: true }).map(entity => new CatalogEntityItem(entity));
|
||||
return this.registry.getItemsForCategory(this.activeCategory, { filtered: true }).map(entity => new CatalogEntityItem(entity, this.registry));
|
||||
}
|
||||
|
||||
@computed get selectedItem() {
|
||||
@ -140,12 +50,10 @@ export class CatalogEntityStore extends ItemStore<CatalogEntityItem<CatalogEntit
|
||||
}
|
||||
|
||||
watch() {
|
||||
const disposers: IReactionDisposer[] = [
|
||||
return disposer(
|
||||
reaction(() => this.entities, () => this.loadAll()),
|
||||
reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100})
|
||||
];
|
||||
|
||||
return () => disposers.forEach((dispose) => dispose());
|
||||
reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100}),
|
||||
);
|
||||
}
|
||||
|
||||
loadAll() {
|
||||
|
||||
@ -32,4 +32,4 @@
|
||||
.catalog {
|
||||
@apply p-5 font-bold text-2xl;
|
||||
color: var(--textColorAccent);
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,11 +41,15 @@ function getCategories() {
|
||||
}
|
||||
|
||||
function getCategoryIcon(category: CatalogCategory) {
|
||||
if (!category.metadata?.icon) return null;
|
||||
const { icon } = category.metadata ?? {};
|
||||
|
||||
return category.metadata.icon.includes("<svg")
|
||||
? <Icon small svg={category.metadata.icon}/>
|
||||
: <Icon small material={category.metadata.icon}/>;
|
||||
if (typeof icon === "string") {
|
||||
return icon.includes("<svg")
|
||||
? <Icon small svg={icon}/>
|
||||
: <Icon small material={icon}/>;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function Item(props: TreeItemProps) {
|
||||
|
||||
366
src/renderer/components/+catalog/catalog.test.tsx
Normal file
366
src/renderer/components/+catalog/catalog.test.tsx
Normal file
@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Catalog } from "./catalog";
|
||||
import { createMemoryHistory } from "history";
|
||||
import { mockWindow } from "../../../../__mocks__/windowMock";
|
||||
import { kubernetesClusterCategory } from "../../../common/catalog-entities/kubernetes-cluster";
|
||||
import { catalogCategoryRegistry, CatalogCategoryRegistry } from "../../../common/catalog";
|
||||
import { CatalogEntityRegistry } from "../../../renderer/api/catalog-entity-registry";
|
||||
import { CatalogEntityDetailRegistry } from "../../../extensions/registries";
|
||||
import { CatalogEntityItem } from "./catalog-entity-item";
|
||||
import { CatalogEntityStore } from "./catalog-entity.store";
|
||||
|
||||
mockWindow();
|
||||
|
||||
// avoid TypeError: Cannot read property 'getPath' of undefined
|
||||
jest.mock("@electron/remote", () => {
|
||||
return {
|
||||
app: {
|
||||
getPath: () => {
|
||||
// avoid TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string. Received undefined
|
||||
return "";
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("<Catalog />", () => {
|
||||
const history = createMemoryHistory();
|
||||
const mockLocation = {
|
||||
pathname: "",
|
||||
search: "",
|
||||
state: "",
|
||||
hash: "",
|
||||
};
|
||||
const mockMatch = {
|
||||
params: {
|
||||
// will be used to match activeCategory
|
||||
// need to be the same as property values in kubernetesClusterCategory
|
||||
group: "entity.k8slens.dev",
|
||||
kind: "KubernetesCluster",
|
||||
},
|
||||
isExact: true,
|
||||
path: "",
|
||||
url: "",
|
||||
};
|
||||
|
||||
const catalogEntityUid = "a_catalogEntity_uid";
|
||||
const catalogEntity = {
|
||||
enabled: true,
|
||||
apiVersion: "api",
|
||||
kind: "kind",
|
||||
metadata: {
|
||||
uid: catalogEntityUid,
|
||||
name: "a catalog entity",
|
||||
labels: {
|
||||
test: "label",
|
||||
},
|
||||
},
|
||||
status: {
|
||||
phase: "",
|
||||
},
|
||||
spec: {},
|
||||
};
|
||||
const catalogEntityItemMethods = {
|
||||
getId: () => catalogEntity.metadata.uid,
|
||||
getName: () => catalogEntity.metadata.name,
|
||||
onContextMenuOpen: () => {},
|
||||
onSettingsOpen: () => {},
|
||||
onRun: () => {},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
CatalogEntityDetailRegistry.createInstance();
|
||||
// mock the return of getting CatalogCategoryRegistry.filteredItems
|
||||
jest
|
||||
.spyOn(catalogCategoryRegistry, "filteredItems", "get")
|
||||
.mockImplementation(() => {
|
||||
return [kubernetesClusterCategory];
|
||||
});
|
||||
|
||||
// we don't care what this.renderList renders in this test case.
|
||||
jest.spyOn(Catalog.prototype, "renderList").mockImplementation(() => {
|
||||
return <span>empty renderList</span>;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
CatalogEntityDetailRegistry.resetInstance();
|
||||
jest.clearAllMocks();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("can use catalogEntityRegistry.addOnBeforeRun to add hooks for catalog entities", (done) => {
|
||||
const catalogCategoryRegistry = new CatalogCategoryRegistry();
|
||||
const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
|
||||
const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry);
|
||||
const onRun = jest.fn();
|
||||
const catalogEntityItem = new CatalogEntityItem({
|
||||
...catalogEntity,
|
||||
...catalogEntityItemMethods,
|
||||
onRun,
|
||||
}, catalogEntityRegistry);
|
||||
|
||||
// mock as if there is a selected item > the detail panel opens
|
||||
jest
|
||||
.spyOn(catalogEntityStore, "selectedItem", "get")
|
||||
.mockImplementation(() => catalogEntityItem);
|
||||
|
||||
catalogEntityRegistry.addOnBeforeRun(
|
||||
(event) => {
|
||||
expect(event.target).toMatchInlineSnapshot(`
|
||||
Object {
|
||||
"apiVersion": "api",
|
||||
"enabled": true,
|
||||
"getId": [Function],
|
||||
"getName": [Function],
|
||||
"kind": "kind",
|
||||
"metadata": Object {
|
||||
"labels": Object {
|
||||
"test": "label",
|
||||
},
|
||||
"name": "a catalog entity",
|
||||
"uid": "a_catalogEntity_uid",
|
||||
},
|
||||
"onContextMenuOpen": [Function],
|
||||
"onRun": [MockFunction],
|
||||
"onSettingsOpen": [Function],
|
||||
"spec": Object {},
|
||||
"status": Object {
|
||||
"phase": "",
|
||||
},
|
||||
}
|
||||
`);
|
||||
|
||||
setTimeout(() => {
|
||||
expect(onRun).toHaveBeenCalled();
|
||||
done();
|
||||
}, 500);
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Catalog
|
||||
history={history}
|
||||
location={mockLocation}
|
||||
match={mockMatch}
|
||||
catalogEntityStore={catalogEntityStore}
|
||||
/>
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
|
||||
});
|
||||
|
||||
it("onBeforeRun prevents event => onRun wont be triggered", (done) => {
|
||||
const catalogCategoryRegistry = new CatalogCategoryRegistry();
|
||||
const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
|
||||
const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry);
|
||||
const onRun = jest.fn();
|
||||
const catalogEntityItem = new CatalogEntityItem({
|
||||
...catalogEntity,
|
||||
...catalogEntityItemMethods,
|
||||
onRun,
|
||||
}, catalogEntityRegistry);
|
||||
|
||||
// mock as if there is a selected item > the detail panel opens
|
||||
jest
|
||||
.spyOn(catalogEntityStore, "selectedItem", "get")
|
||||
.mockImplementation(() => catalogEntityItem);
|
||||
|
||||
catalogEntityRegistry.addOnBeforeRun(
|
||||
(e) => {
|
||||
setTimeout(() => {
|
||||
expect(onRun).not.toHaveBeenCalled();
|
||||
done();
|
||||
}, 500);
|
||||
e.preventDefault();
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Catalog
|
||||
history={history}
|
||||
location={mockLocation}
|
||||
match={mockMatch}
|
||||
catalogEntityStore={catalogEntityStore}
|
||||
/>
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
|
||||
});
|
||||
|
||||
it("addOnBeforeRun throw an exception => onRun will be triggered", (done) => {
|
||||
const catalogCategoryRegistry = new CatalogCategoryRegistry();
|
||||
const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
|
||||
const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry);
|
||||
const onRun = jest.fn();
|
||||
const catalogEntityItem = new CatalogEntityItem({
|
||||
...catalogEntity,
|
||||
...catalogEntityItemMethods,
|
||||
onRun,
|
||||
}, catalogEntityRegistry);
|
||||
|
||||
// mock as if there is a selected item > the detail panel opens
|
||||
jest
|
||||
.spyOn(catalogEntityStore, "selectedItem", "get")
|
||||
.mockImplementation(() => catalogEntityItem);
|
||||
|
||||
catalogEntityRegistry.addOnBeforeRun(
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
expect(onRun).toHaveBeenCalled();
|
||||
done();
|
||||
}, 500);
|
||||
|
||||
throw new Error("error!");
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Catalog
|
||||
history={history}
|
||||
location={mockLocation}
|
||||
match={mockMatch}
|
||||
catalogEntityStore={catalogEntityStore}
|
||||
/>
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
|
||||
});
|
||||
|
||||
it("addOnRunHook return a promise and does not prevent run event => onRun()", (done) => {
|
||||
const catalogCategoryRegistry = new CatalogCategoryRegistry();
|
||||
const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
|
||||
const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry);
|
||||
const onRun = jest.fn(() => done());
|
||||
const catalogEntityItem = new CatalogEntityItem({
|
||||
...catalogEntity,
|
||||
...catalogEntityItemMethods,
|
||||
onRun,
|
||||
}, catalogEntityRegistry);
|
||||
|
||||
// mock as if there is a selected item > the detail panel opens
|
||||
jest
|
||||
.spyOn(catalogEntityStore, "selectedItem", "get")
|
||||
.mockImplementation(() => catalogEntityItem);
|
||||
|
||||
catalogEntityRegistry.addOnBeforeRun(
|
||||
async () => {
|
||||
// no op
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Catalog
|
||||
history={history}
|
||||
location={mockLocation}
|
||||
match={mockMatch}
|
||||
catalogEntityStore={catalogEntityStore}
|
||||
/>
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
|
||||
});
|
||||
|
||||
it("addOnRunHook return a promise and prevents event wont be triggered", (done) => {
|
||||
const catalogCategoryRegistry = new CatalogCategoryRegistry();
|
||||
const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
|
||||
const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry);
|
||||
const onRun = jest.fn();
|
||||
const catalogEntityItem = new CatalogEntityItem({
|
||||
...catalogEntity,
|
||||
...catalogEntityItemMethods,
|
||||
onRun,
|
||||
}, catalogEntityRegistry);
|
||||
|
||||
// mock as if there is a selected item > the detail panel opens
|
||||
jest
|
||||
.spyOn(catalogEntityStore, "selectedItem", "get")
|
||||
.mockImplementation(() => catalogEntityItem);
|
||||
|
||||
catalogEntityRegistry.addOnBeforeRun(
|
||||
async (e) => {
|
||||
expect(onRun).not.toBeCalled();
|
||||
|
||||
setTimeout(() => {
|
||||
expect(onRun).not.toBeCalled();
|
||||
done();
|
||||
}, 500);
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Catalog
|
||||
history={history}
|
||||
location={mockLocation}
|
||||
match={mockMatch}
|
||||
catalogEntityStore={catalogEntityStore}
|
||||
/>
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
|
||||
});
|
||||
|
||||
it("addOnRunHook return a promise and reject => onRun will be triggered", (done) => {
|
||||
const catalogCategoryRegistry = new CatalogCategoryRegistry();
|
||||
const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);
|
||||
const catalogEntityStore = new CatalogEntityStore(catalogEntityRegistry);
|
||||
const onRun = jest.fn();
|
||||
const catalogEntityItem = new CatalogEntityItem({
|
||||
...catalogEntity,
|
||||
...catalogEntityItemMethods,
|
||||
onRun,
|
||||
}, catalogEntityRegistry);
|
||||
|
||||
// mock as if there is a selected item > the detail panel opens
|
||||
jest
|
||||
.spyOn(catalogEntityStore, "selectedItem", "get")
|
||||
.mockImplementation(() => catalogEntityItem);
|
||||
|
||||
catalogEntityRegistry.addOnBeforeRun(
|
||||
async () => {
|
||||
setTimeout(() => {
|
||||
expect(onRun).toHaveBeenCalled();
|
||||
done();
|
||||
}, 500);
|
||||
|
||||
throw new Error("rejection!");
|
||||
}
|
||||
);
|
||||
|
||||
render(
|
||||
<Catalog
|
||||
history={history}
|
||||
location={mockLocation}
|
||||
match={mockMatch}
|
||||
catalogEntityStore={catalogEntityStore}
|
||||
/>
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByTestId("detail-panel-hot-bar-icon"));
|
||||
});
|
||||
});
|
||||
@ -25,7 +25,8 @@ import React from "react";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { ItemListLayout } from "../item-object-list";
|
||||
import { action, makeObservable, observable, reaction, runInAction, when } from "mobx";
|
||||
import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store";
|
||||
import { CatalogEntityStore } from "./catalog-entity.store";
|
||||
import type { CatalogEntityItem } from "./catalog-entity-item";
|
||||
import { navigate } from "../../navigation";
|
||||
import { MenuItem, MenuActions } from "../menu";
|
||||
import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
|
||||
@ -39,12 +40,12 @@ import { MainLayout } from "../layout/main-layout";
|
||||
import { createAppStorage, cssNames } from "../../utils";
|
||||
import { makeCss } from "../../../common/utils/makeCss";
|
||||
import { CatalogEntityDetails } from "./catalog-entity-details";
|
||||
import { catalogURL, CatalogViewRouteParam } from "../../../common/routes";
|
||||
import { browseCatalogTab, catalogURL, CatalogViewRouteParam } from "../../../common/routes";
|
||||
import { CatalogMenu } from "./catalog-menu";
|
||||
import { HotbarIcon } from "../hotbar/hotbar-icon";
|
||||
import { RenderDelay } from "../render-delay/render-delay";
|
||||
|
||||
export const previousActiveTab = createAppStorage("catalog-previous-active-tab", "");
|
||||
export const previousActiveTab = createAppStorage("catalog-previous-active-tab", browseCatalogTab);
|
||||
|
||||
enum sortBy {
|
||||
name = "name",
|
||||
@ -55,7 +56,10 @@ enum sortBy {
|
||||
|
||||
const css = makeCss(styles);
|
||||
|
||||
interface Props extends RouteComponentProps<CatalogViewRouteParam> {}
|
||||
interface Props extends RouteComponentProps<CatalogViewRouteParam> {
|
||||
catalogEntityStore?: CatalogEntityStore;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class Catalog extends React.Component<Props> {
|
||||
@observable private catalogEntityStore?: CatalogEntityStore;
|
||||
@ -65,8 +69,11 @@ export class Catalog extends React.Component<Props> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
this.catalogEntityStore = new CatalogEntityStore();
|
||||
this.catalogEntityStore = props.catalogEntityStore;
|
||||
}
|
||||
static defaultProps = {
|
||||
catalogEntityStore: new CatalogEntityStore(),
|
||||
};
|
||||
|
||||
get routeActiveTab(): string {
|
||||
const { group, kind } = this.props.match.params ?? {};
|
||||
@ -75,7 +82,7 @@ export class Catalog extends React.Component<Props> {
|
||||
return `${group}/${kind}`;
|
||||
}
|
||||
|
||||
return "";
|
||||
return browseCatalogTab;
|
||||
}
|
||||
|
||||
async componentDidMount() {
|
||||
@ -89,7 +96,7 @@ export class Catalog extends React.Component<Props> {
|
||||
previousActiveTab.set(this.routeActiveTab);
|
||||
|
||||
try {
|
||||
await when(() => (routeTab === "" || !!catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab)), { timeout: 5_000 }); // we need to wait because extensions might take a while to load
|
||||
await when(() => (routeTab === browseCatalogTab || !!catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab)), { timeout: 5_000 }); // we need to wait because extensions might take a while to load
|
||||
const item = catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab);
|
||||
|
||||
runInAction(() => {
|
||||
@ -117,15 +124,16 @@ export class Catalog extends React.Component<Props> {
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
addToHotbar(item: CatalogEntityItem<CatalogEntity>): void {
|
||||
HotbarStore.getInstance().addToHotbar(item.entity);
|
||||
}
|
||||
|
||||
onDetails = (item: CatalogEntityItem<CatalogEntity>) => {
|
||||
if (this.catalogEntityStore.selectedItemId === item.getId()) {
|
||||
if (this.catalogEntityStore.selectedItemId) {
|
||||
this.catalogEntityStore.selectedItemId = null;
|
||||
} else {
|
||||
this.catalogEntityStore.selectedItemId = item.getId();
|
||||
item.onRun();
|
||||
}
|
||||
};
|
||||
|
||||
@ -157,7 +165,7 @@ export class Catalog extends React.Component<Props> {
|
||||
if (activeCategory) {
|
||||
navigate(catalogURL({ params: {group: activeCategory.spec.group, kind: activeCategory.spec.names.kind }}));
|
||||
} else {
|
||||
navigate(catalogURL());
|
||||
navigate(catalogURL({ params: { group: browseCatalogTab }}));
|
||||
}
|
||||
};
|
||||
|
||||
@ -176,6 +184,9 @@ export class Catalog extends React.Component<Props> {
|
||||
|
||||
return (
|
||||
<MenuActions onOpen={onOpen}>
|
||||
<MenuItem key="open-details" onClick={() => this.catalogEntityStore.selectedItemId = item.getId()}>
|
||||
View Details
|
||||
</MenuItem>
|
||||
{
|
||||
this.contextMenu.menuItems.map((menuItem, index) => (
|
||||
<MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}>
|
||||
|
||||
@ -72,6 +72,8 @@ export class ConfigMapDetails extends React.Component<Props> {
|
||||
<>ConfigMap <b>{configMap.getName()}</b> successfully updated.</>
|
||||
</p>
|
||||
);
|
||||
} catch (error) {
|
||||
Notifications.error(`Failed to save config map: ${error}`);
|
||||
} finally {
|
||||
this.isSaving = false;
|
||||
}
|
||||
|
||||
@ -26,6 +26,7 @@ import { observer } from "mobx-react";
|
||||
import type { KubeObject } from "../../../common/k8s-api/kube-object";
|
||||
import { DrawerItem, DrawerTitle } from "../drawer";
|
||||
import { cssNames } from "../../utils";
|
||||
import { LocaleDate } from "../locale-date";
|
||||
import { eventStore } from "./event.store";
|
||||
|
||||
export interface KubeEventDetailsProps {
|
||||
@ -74,7 +75,7 @@ export class KubeEventDetails extends React.Component<KubeEventDetailsProps> {
|
||||
{involvedObject.fieldPath}
|
||||
</DrawerItem>
|
||||
<DrawerItem name="Last seen">
|
||||
{lastTimestamp}
|
||||
<LocaleDate date={lastTimestamp} />
|
||||
</DrawerItem>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -18,4 +18,5 @@
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
export * from "./parse-query";
|
||||
|
||||
export * from "./port-forwards";
|
||||
@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { boundMethod, cssNames } from "../../utils";
|
||||
import { openPortForward, PortForwardItem, removePortForward } from "../../port-forward";
|
||||
import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
|
||||
import { MenuItem } from "../menu";
|
||||
import { Icon } from "../icon";
|
||||
import { PortForwardDialog } from "../../port-forward";
|
||||
|
||||
interface Props extends MenuActionsProps {
|
||||
portForward: PortForwardItem;
|
||||
hideDetails?(): void;
|
||||
}
|
||||
|
||||
export class PortForwardMenu extends React.Component<Props> {
|
||||
@boundMethod
|
||||
remove() {
|
||||
return removePortForward(this.props.portForward);
|
||||
}
|
||||
|
||||
renderContent() {
|
||||
const { portForward, toolbar } = this.props;
|
||||
|
||||
if (!portForward) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem onClick={() => openPortForward(this.props.portForward)}>
|
||||
<Icon material="open_in_browser" interactive={toolbar} tooltip="Open in browser" />
|
||||
<span className="title">Open</span>
|
||||
</MenuItem>
|
||||
<MenuItem onClick={() => PortForwardDialog.open(portForward)}>
|
||||
<Icon material="edit" tooltip="Change port" interactive={toolbar} />
|
||||
<span className="title">Edit</span>
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, ...menuProps } = this.props;
|
||||
|
||||
return (
|
||||
<MenuActions
|
||||
{...menuProps}
|
||||
className={cssNames("PortForwardMenu", className)}
|
||||
removeAction={this.remove}
|
||||
>
|
||||
{this.renderContent()}
|
||||
</MenuActions>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
.PortForwards {
|
||||
.TableCell {
|
||||
&.warning {
|
||||
@include table-cell-warning;
|
||||
}
|
||||
}
|
||||
}
|
||||
103
src/renderer/components/+network-port-forwards/port-forwards.tsx
Normal file
103
src/renderer/components/+network-port-forwards/port-forwards.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import "./port-forwards.scss";
|
||||
|
||||
import React from "react";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { ItemListLayout } from "../item-object-list/item-list-layout";
|
||||
import { PortForwardItem, portForwardStore } from "../../port-forward";
|
||||
import { PortForwardMenu } from "./port-forward-menu";
|
||||
|
||||
enum columnId {
|
||||
name = "name",
|
||||
namespace = "namespace",
|
||||
kind = "kind",
|
||||
port = "port",
|
||||
forwardPort = "forwardPort",
|
||||
}
|
||||
|
||||
@observer
|
||||
export class PortForwards extends React.Component {
|
||||
|
||||
componentDidMount() {
|
||||
disposeOnUnmount(this, [
|
||||
portForwardStore.watch(),
|
||||
]);
|
||||
}
|
||||
|
||||
renderRemoveDialogMessage(selectedItems: PortForwardItem[]) {
|
||||
const forwardPorts = selectedItems.map(item => item.getForwardPort()).join(", ");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>Stop forwarding from <b>{forwardPorts}</b>?</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<ItemListLayout
|
||||
isConfigurable
|
||||
tableId="port_forwards"
|
||||
className="PortForwards" store={portForwardStore}
|
||||
sortingCallbacks={{
|
||||
[columnId.name]: item => item.getName(),
|
||||
[columnId.namespace]: item => item.getNs(),
|
||||
[columnId.kind]: item => item.getKind(),
|
||||
[columnId.port]: item => item.getPort(),
|
||||
[columnId.forwardPort]: item => item.getForwardPort(),
|
||||
}}
|
||||
searchFilters={[
|
||||
item => item.getSearchFields(),
|
||||
]}
|
||||
renderHeaderTitle="Port Forwarding"
|
||||
renderTableHeader={[
|
||||
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
|
||||
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },
|
||||
{ title: "Kind", className: "kind", sortBy: columnId.kind, id: columnId.kind },
|
||||
{ title: "Pod Port", className: "port", sortBy: columnId.port, id: columnId.port },
|
||||
{ title: "Local Port", className: "forwardPort", sortBy: columnId.forwardPort, id: columnId.forwardPort },
|
||||
]}
|
||||
renderTableContents={item => [
|
||||
item.getName(),
|
||||
item.getNs(),
|
||||
item.getKind(),
|
||||
item.getPort(),
|
||||
item.getForwardPort(),
|
||||
]}
|
||||
renderItemMenu={pf => (
|
||||
<PortForwardMenu
|
||||
portForward={pf}
|
||||
removeConfirmationMessage={this.renderRemoveDialogMessage([pf])}
|
||||
/>
|
||||
)}
|
||||
customizeRemoveDialog={selectedItems => ({
|
||||
message: this.renderRemoveDialogMessage(selectedItems)
|
||||
})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -32,6 +32,7 @@ import { ServicePortComponent } from "./service-port-component";
|
||||
import { endpointStore } from "../+network-endpoints/endpoints.store";
|
||||
import { ServiceDetailsEndpoint } from "./service-details-endpoint";
|
||||
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
|
||||
import { portForwardStore } from "../../port-forward";
|
||||
|
||||
interface Props extends KubeObjectDetailsProps<Service> {
|
||||
}
|
||||
@ -46,6 +47,7 @@ export class ServiceDetails extends React.Component<Props> {
|
||||
preload: true,
|
||||
namespaces: [service.getNs()],
|
||||
}),
|
||||
portForwardStore.watch(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -33,11 +33,13 @@
|
||||
cursor: pointer;
|
||||
color: $primary;
|
||||
text-decoration: underline;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.Spinner {
|
||||
--spinner-size: #{$unit * 2};
|
||||
margin-left: $margin;
|
||||
position: absolute;
|
||||
.portInput {
|
||||
display: inline-block !important;
|
||||
width: 70px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,12 +22,15 @@
|
||||
import "./service-port-component.scss";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import type { Service, ServicePort } from "../../../common/k8s-api/endpoints";
|
||||
import { apiBase } from "../../api";
|
||||
import { observable, makeObservable } from "mobx";
|
||||
import { observable, makeObservable, reaction } from "mobx";
|
||||
import { cssNames } from "../../utils";
|
||||
import { Notifications } from "../notifications";
|
||||
import { Button } from "../button";
|
||||
import { addPortForward, getPortForward, openPortForward, PortForwardDialog, portForwardStore, removePortForward } from "../../port-forward";
|
||||
import type { ForwardedPort } from "../../port-forward";
|
||||
import logger from "../../../common/logger";
|
||||
import { Spinner } from "../spinner";
|
||||
|
||||
interface Props {
|
||||
@ -38,20 +41,86 @@ interface Props {
|
||||
@observer
|
||||
export class ServicePortComponent extends React.Component<Props> {
|
||||
@observable waiting = false;
|
||||
@observable forwardPort = 0;
|
||||
@observable isPortForwarded = false;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
this.init();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
disposeOnUnmount(this, [
|
||||
reaction(() => [ portForwardStore.portForwards, this.props.service ], () => this.init()),
|
||||
]);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.checkExistingPortForwarding().catch(error => {
|
||||
logger.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
async checkExistingPortForwarding() {
|
||||
const { service, port } = this.props;
|
||||
const portForward: ForwardedPort = {
|
||||
kind: "service",
|
||||
name: service.getName(),
|
||||
namespace: service.getNs(),
|
||||
port: port.port,
|
||||
forwardPort: this.forwardPort,
|
||||
};
|
||||
const activePort = await getPortForward(portForward) ?? 0;
|
||||
|
||||
this.forwardPort = activePort;
|
||||
this.isPortForwarded = activePort ? true : false;
|
||||
}
|
||||
|
||||
async portForward() {
|
||||
const { service, port } = this.props;
|
||||
const portForward: ForwardedPort = {
|
||||
kind: "service",
|
||||
name: service.getName(),
|
||||
namespace: service.getNs(),
|
||||
port: port.port,
|
||||
forwardPort: this.forwardPort,
|
||||
};
|
||||
|
||||
this.waiting = true;
|
||||
this.isPortForwarded = false;
|
||||
|
||||
try {
|
||||
this.forwardPort = await addPortForward(portForward);
|
||||
|
||||
if (this.forwardPort) {
|
||||
portForward.forwardPort = this.forwardPort;
|
||||
openPortForward(portForward);
|
||||
this.isPortForwarded = true;
|
||||
}
|
||||
} catch (error) {
|
||||
Notifications.error(error);
|
||||
} finally {
|
||||
this.waiting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async stopPortForward() {
|
||||
const { service, port } = this.props;
|
||||
const portForward: ForwardedPort = {
|
||||
kind: "service",
|
||||
name: service.getName(),
|
||||
namespace: service.getNs(),
|
||||
port: port.port,
|
||||
forwardPort: this.forwardPort,
|
||||
};
|
||||
|
||||
this.waiting = true;
|
||||
|
||||
try {
|
||||
await apiBase.post(`/pods/${service.getNs()}/service/${service.getName()}/port-forward/${port.port}`, {});
|
||||
} catch(error) {
|
||||
await removePortForward(portForward);
|
||||
this.isPortForwarded = false;
|
||||
} catch (error) {
|
||||
Notifications.error(error);
|
||||
} finally {
|
||||
this.waiting = false;
|
||||
@ -59,16 +128,33 @@ export class ServicePortComponent extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { port } = this.props;
|
||||
const { port, service } = this.props;
|
||||
|
||||
const portForwardAction = async () => {
|
||||
if (this.isPortForwarded) {
|
||||
await this.stopPortForward();
|
||||
} else {
|
||||
const portForward: ForwardedPort = {
|
||||
kind: "service",
|
||||
name: service.getName(),
|
||||
namespace: service.getNs(),
|
||||
port: port.port,
|
||||
forwardPort: this.forwardPort,
|
||||
};
|
||||
|
||||
PortForwardDialog.open(portForward, { openInBrowser: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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()}
|
||||
{this.waiting && (
|
||||
<Spinner />
|
||||
)}
|
||||
</span>
|
||||
<Button onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button>
|
||||
{this.waiting && (
|
||||
<Spinner />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,6 +28,7 @@ import { Services } from "../+network-services";
|
||||
import { Endpoints } from "../+network-endpoints";
|
||||
import { Ingresses } from "../+network-ingresses";
|
||||
import { NetworkPolicies } from "../+network-policies";
|
||||
import { PortForwards } from "../+network-port-forwards";
|
||||
import { isAllowedResource } from "../../../common/utils/allowed-resource";
|
||||
import * as routes from "../../../common/routes";
|
||||
|
||||
@ -72,6 +73,13 @@ export class Network extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
tabs.push({
|
||||
title: "Port Forwarding",
|
||||
component: PortForwards,
|
||||
url: routes.portForwardsURL(),
|
||||
routePath: routes.portForwardsRoute.path.toString(),
|
||||
});
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
|
||||
@ -236,8 +236,8 @@ export class Nodes extends React.Component<Props> {
|
||||
this.renderDiskUsage(node),
|
||||
<>
|
||||
<span id={tooltipId}>{node.getTaints().length}</span>
|
||||
<Tooltip targetId={tooltipId} style={{ whiteSpace: "pre-line" }}>
|
||||
{node.getTaints().map(({ key, effect }) => `${key}: ${effect}`).join("\n")}
|
||||
<Tooltip targetId={tooltipId} tooltipOnParentHover={true} style={{ whiteSpace: "pre-line" }}>
|
||||
{node.getTaints().map(({ key, value, effect }) => `${key}=${value}:${effect}`).join("\n")}
|
||||
</Tooltip>
|
||||
</>,
|
||||
node.getRoleLabels(),
|
||||
|
||||
@ -72,6 +72,20 @@ export const Application = observer(() => {
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section id="terminalSelection">
|
||||
<SubTitle title="Terminal copy & paste" />
|
||||
<FormSwitch
|
||||
label="Copy on select and paste on right-click"
|
||||
control={
|
||||
<Switcher
|
||||
checked={UserStore.getInstance().terminalCopyOnSelect}
|
||||
onChange={v => UserStore.getInstance().terminalCopyOnSelect = v.target.checked}
|
||||
name="terminalCopyOnSelect"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<hr/>
|
||||
|
||||
<section id="other">
|
||||
|
||||
@ -75,6 +75,7 @@ export class DeploymentReplicaSets extends React.Component<Props> {
|
||||
sortable={this.sortingCallbacks}
|
||||
sortByDefault={{ sortBy: sortBy.pods, orderBy: "desc" }}
|
||||
sortSyncWithUrl={false}
|
||||
tableId="deployment_replica_sets_view"
|
||||
className="box grow"
|
||||
>
|
||||
<TableHead>
|
||||
|
||||
@ -112,6 +112,7 @@ const dummyDeployment: Deployment = {
|
||||
toPlainObject: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
};
|
||||
|
||||
describe("<DeploymentScaleDialog />", () => {
|
||||
|
||||
@ -34,11 +34,13 @@
|
||||
color: $primary;
|
||||
text-decoration: underline;
|
||||
position: relative;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.Spinner {
|
||||
--spinner-size: #{$unit * 2};
|
||||
margin-left: $margin;
|
||||
position: absolute;
|
||||
.portInput {
|
||||
display: inline-block !important;
|
||||
width: 70px;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,12 +22,15 @@
|
||||
import "./pod-container-port.scss";
|
||||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import type { Pod } from "../../../common/k8s-api/endpoints";
|
||||
import { apiBase } from "../../api";
|
||||
import { observable, makeObservable } from "mobx";
|
||||
import { observable, makeObservable, reaction } from "mobx";
|
||||
import { cssNames } from "../../utils";
|
||||
import { Notifications } from "../notifications";
|
||||
import { Button } from "../button";
|
||||
import { addPortForward, getPortForward, openPortForward, PortForwardDialog, portForwardStore, removePortForward } from "../../port-forward";
|
||||
import type { ForwardedPort } from "../../port-forward";
|
||||
import logger from "../../../common/logger";
|
||||
import { Spinner } from "../spinner";
|
||||
|
||||
interface Props {
|
||||
@ -42,20 +45,86 @@ interface Props {
|
||||
@observer
|
||||
export class PodContainerPort extends React.Component<Props> {
|
||||
@observable waiting = false;
|
||||
@observable forwardPort = 0;
|
||||
@observable isPortForwarded = false;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
makeObservable(this);
|
||||
this.init();
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
disposeOnUnmount(this, [
|
||||
reaction(() => [ portForwardStore.portForwards, this.props.pod ], () => this.init()),
|
||||
]);
|
||||
}
|
||||
|
||||
init() {
|
||||
this.checkExistingPortForwarding().catch(error => {
|
||||
logger.error(error);
|
||||
});
|
||||
}
|
||||
|
||||
async checkExistingPortForwarding() {
|
||||
const { pod, port } = this.props;
|
||||
const portForward: ForwardedPort = {
|
||||
kind: "pod",
|
||||
name: pod.getName(),
|
||||
namespace: pod.getNs(),
|
||||
port: port.containerPort,
|
||||
forwardPort: this.forwardPort,
|
||||
};
|
||||
const activePort = await getPortForward(portForward) ?? 0;
|
||||
|
||||
this.forwardPort = activePort;
|
||||
this.isPortForwarded = activePort ? true : false;
|
||||
}
|
||||
|
||||
async portForward() {
|
||||
const { pod, port } = this.props;
|
||||
const portForward: ForwardedPort = {
|
||||
kind: "pod",
|
||||
name: pod.getName(),
|
||||
namespace: pod.getNs(),
|
||||
port: port.containerPort,
|
||||
forwardPort: this.forwardPort,
|
||||
};
|
||||
|
||||
this.waiting = true;
|
||||
this.isPortForwarded = false;
|
||||
|
||||
try {
|
||||
this.forwardPort = await addPortForward(portForward);
|
||||
|
||||
if (this.forwardPort) {
|
||||
portForward.forwardPort = this.forwardPort;
|
||||
openPortForward(portForward);
|
||||
this.isPortForwarded = true;
|
||||
}
|
||||
} catch (error) {
|
||||
Notifications.error(error);
|
||||
} finally {
|
||||
this.waiting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async stopPortForward() {
|
||||
const { pod, port } = this.props;
|
||||
const portForward: ForwardedPort = {
|
||||
kind: "pod",
|
||||
name: pod.getName(),
|
||||
namespace: pod.getNs(),
|
||||
port: port.containerPort,
|
||||
forwardPort: this.forwardPort,
|
||||
};
|
||||
|
||||
this.waiting = true;
|
||||
|
||||
try {
|
||||
await apiBase.post(`/pods/${pod.getNs()}/pod/${pod.getName()}/port-forward/${port.containerPort}`, {});
|
||||
} catch(error) {
|
||||
await removePortForward(portForward);
|
||||
this.isPortForwarded = false;
|
||||
} catch (error) {
|
||||
Notifications.error(error);
|
||||
} finally {
|
||||
this.waiting = false;
|
||||
@ -63,18 +132,35 @@ export class PodContainerPort extends React.Component<Props> {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { port } = this.props;
|
||||
const { pod, port } = this.props;
|
||||
const { name, containerPort, protocol } = port;
|
||||
const text = `${name ? `${name}: ` : ""}${containerPort}/${protocol}`;
|
||||
|
||||
const portForwardAction = async () => {
|
||||
if (this.isPortForwarded) {
|
||||
await this.stopPortForward();
|
||||
} else {
|
||||
const portForward: ForwardedPort = {
|
||||
kind: "pod",
|
||||
name: pod.getName(),
|
||||
namespace: pod.getNs(),
|
||||
port: port.containerPort,
|
||||
forwardPort: this.forwardPort,
|
||||
};
|
||||
|
||||
PortForwardDialog.open(portForward, { openInBrowser: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cssNames("PodContainerPort", { waiting: this.waiting })}>
|
||||
<span title="Open in a browser" onClick={() => this.portForward() }>
|
||||
<span title="Open in a browser" onClick={() => this.portForward()}>
|
||||
{text}
|
||||
{this.waiting && (
|
||||
<Spinner />
|
||||
)}
|
||||
</span>
|
||||
<Button onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button>
|
||||
{this.waiting && (
|
||||
<Spinner />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -35,6 +35,8 @@ import { ContainerCharts } from "./container-charts";
|
||||
import { LocaleDate } from "../locale-date";
|
||||
import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
|
||||
import { ClusterMetricsResourceType } from "../../../common/cluster-types";
|
||||
import { portForwardStore } from "../../port-forward/port-forward.store";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
|
||||
interface Props {
|
||||
pod: Pod;
|
||||
@ -42,8 +44,15 @@ interface Props {
|
||||
metrics?: { [key: string]: IMetrics };
|
||||
}
|
||||
|
||||
@observer
|
||||
export class PodDetailsContainer extends React.Component<Props> {
|
||||
|
||||
componentDidMount() {
|
||||
disposeOnUnmount(this, [
|
||||
portForwardStore.watch(),
|
||||
]);
|
||||
}
|
||||
|
||||
renderStatus(state: string, status: IPodContainerStatus) {
|
||||
const ready = status ? status.ready : "";
|
||||
|
||||
|
||||
@ -107,6 +107,7 @@ const dummyReplicaSet: ReplicaSet = {
|
||||
toPlainObject: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
};
|
||||
|
||||
describe("<ReplicaSetScaleDialog />", () => {
|
||||
|
||||
@ -117,6 +117,7 @@ const dummyStatefulSet: StatefulSet = {
|
||||
toPlainObject: jest.fn(),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
};
|
||||
|
||||
describe("<StatefulSetScaleDialog />", () => {
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
import { computed } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import React from "react";
|
||||
import type { CatalogEntity } from "../../api/catalog-entity";
|
||||
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
|
||||
import { CommandOverlay } from "../command-palette";
|
||||
import { Select } from "../select";
|
||||
|
||||
@observer
|
||||
export class ActivateEntityCommand extends React.Component {
|
||||
@computed get options() {
|
||||
return catalogEntityRegistry.items.map(entity => ({
|
||||
label: `${entity.kind}: ${entity.getName()}`,
|
||||
value: entity,
|
||||
}));
|
||||
}
|
||||
|
||||
onSelect(entity: CatalogEntity): void {
|
||||
catalogEntityRegistry.onRun(entity);
|
||||
CommandOverlay.close();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Select
|
||||
menuPortalTarget={null}
|
||||
onChange={(v) => this.onSelect(v.value)}
|
||||
components={{ DropdownIndicator: null, IndicatorSeparator: null }}
|
||||
menuIsOpen={true}
|
||||
options={this.options}
|
||||
autoFocus={true}
|
||||
escapeClearsValue={false}
|
||||
placeholder="Activate entity ..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/renderer/components/activate-entity-command/index.ts
Normal file
22
src/renderer/components/activate-entity-command/index.ts
Normal file
@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Copyright (c) 2021 OpenLens Authors
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
export * from "./activate-entity-command";
|
||||
@ -73,6 +73,8 @@ import { getHostedClusterId } from "../utils";
|
||||
import { ClusterStore } from "../../common/cluster-store";
|
||||
import type { ClusterId } from "../../common/cluster-types";
|
||||
import { watchHistoryState } from "../remote-helpers/history-updater";
|
||||
import { unmountComponentAtNode } from "react-dom";
|
||||
import { PortForwardDialog } from "../port-forward";
|
||||
|
||||
@observer
|
||||
export class App extends React.Component {
|
||||
@ -83,7 +85,7 @@ export class App extends React.Component {
|
||||
makeObservable(this);
|
||||
}
|
||||
|
||||
static async init() {
|
||||
static async init(rootElem: HTMLElement) {
|
||||
catalogEntityRegistry.init();
|
||||
const frameId = webFrame.routingId;
|
||||
|
||||
@ -112,6 +114,20 @@ export class App extends React.Component {
|
||||
window.addEventListener("online", () => {
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
window.addEventListener("message", (ev: MessageEvent) => {
|
||||
if (ev.data === "teardown") {
|
||||
unmountComponentAtNode(rootElem);
|
||||
window.location.href = "about:blank";
|
||||
}
|
||||
});
|
||||
|
||||
window.onbeforeunload = () => {
|
||||
logger.info(`[APP]: Unload dashboard, clusterId=${App.clusterId}, frameId=${frameId}`);
|
||||
|
||||
unmountComponentAtNode(rootElem);
|
||||
};
|
||||
|
||||
whatInput.ask(); // Start to monitor user input device
|
||||
|
||||
// Setup hosted cluster context
|
||||
@ -216,6 +232,7 @@ export class App extends React.Component {
|
||||
<StatefulSetScaleDialog/>
|
||||
<ReplicaSetScaleDialog/>
|
||||
<CronJobTriggerDialog/>
|
||||
<PortForwardDialog/>
|
||||
<CommandContainer clusterId={App.clusterId}/>
|
||||
</ErrorBoundary>
|
||||
</Router>
|
||||
|
||||
@ -25,7 +25,7 @@ import React from "react";
|
||||
import { Redirect, Route, Switch } from "react-router";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { BottomBar } from "./bottom-bar";
|
||||
import { Catalog } from "../+catalog";
|
||||
import { Catalog, previousActiveTab } from "../+catalog";
|
||||
import { Preferences } from "../+preferences";
|
||||
import { AddCluster } from "../+add-cluster";
|
||||
import { ClusterView } from "./cluster-view";
|
||||
@ -40,6 +40,7 @@ import { reaction } from "mobx";
|
||||
import { navigation } from "../../navigation";
|
||||
import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync";
|
||||
import { TopBar } from "../layout/topbar";
|
||||
import { catalogURL, getPreviousTabUrl } from "../../../common/routes";
|
||||
|
||||
@observer
|
||||
export class ClusterManager extends React.Component {
|
||||
@ -56,6 +57,7 @@ export class ClusterManager extends React.Component {
|
||||
<main>
|
||||
<div id="lens-views"/>
|
||||
<Switch>
|
||||
<Redirect exact from={catalogURL()} to={getPreviousTabUrl(previousActiveTab.get())}/>
|
||||
<Route component={Welcome} {...routes.welcomeRoute} />
|
||||
<Route component={Catalog} {...routes.catalogRoute} />
|
||||
<Route component={Preferences} {...routes.preferencesRoute} />
|
||||
|
||||
@ -33,7 +33,6 @@ import { clusterActivateHandler } from "../../../common/cluster-ipc";
|
||||
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
|
||||
import { navigate } from "../../navigation";
|
||||
import { catalogURL, ClusterViewRouteParams } from "../../../common/routes";
|
||||
import { previousActiveTab } from "../+catalog";
|
||||
|
||||
interface Props extends RouteComponentProps<ClusterViewRouteParams> {
|
||||
}
|
||||
@ -85,7 +84,7 @@ export class ClusterView extends React.Component<Props> {
|
||||
const disconnected = values[1];
|
||||
|
||||
if (hasLoadedView(this.clusterId) && disconnected) {
|
||||
navigate(`${catalogURL()}/${previousActiveTab.get()}`); // redirect to catalog when active cluster get disconnected/not available
|
||||
navigate(catalogURL()); // redirect to catalog when active cluster get disconnected/not available
|
||||
}
|
||||
}),
|
||||
]);
|
||||
|
||||
@ -25,9 +25,11 @@ import { Pod } from "../../../../common/k8s-api/endpoints";
|
||||
import { ThemeStore } from "../../../theme.store";
|
||||
import { dockStore } from "../dock.store";
|
||||
import { logTabStore } from "../log-tab.store";
|
||||
import { TerminalStore } from "../terminal.store";
|
||||
import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock";
|
||||
import fse from "fs-extra";
|
||||
import { mockWindow } from "../../../../../__mocks__/windowMock";
|
||||
|
||||
mockWindow();
|
||||
|
||||
jest.mock("react-monaco-editor", () => null);
|
||||
|
||||
@ -41,12 +43,10 @@ podsStore.items.push(new Pod(dockerPod));
|
||||
podsStore.items.push(new Pod(deploymentPod1));
|
||||
podsStore.items.push(new Pod(deploymentPod2));
|
||||
|
||||
// FIXME: broken since mobx@6 migration
|
||||
describe.skip("log tab store", () => {
|
||||
describe("log tab store", () => {
|
||||
beforeEach(() => {
|
||||
UserStore.createInstance();
|
||||
ThemeStore.createInstance();
|
||||
TerminalStore.createInstance();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@ -54,7 +54,6 @@ describe.skip("log tab store", () => {
|
||||
dockStore.reset();
|
||||
UserStore.resetInstance();
|
||||
ThemeStore.resetInstance();
|
||||
TerminalStore.resetInstance();
|
||||
fse.remove("tmp");
|
||||
});
|
||||
|
||||
@ -135,11 +134,12 @@ describe.skip("log tab store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("closes tab if no pods left in store", () => {
|
||||
// FIXME: this is failed when it's not .only == depends on something above
|
||||
it.only("closes tab if no pods left in store", async () => {
|
||||
const selectedPod = new Pod(deploymentPod1);
|
||||
const selectedContainer = selectedPod.getInitContainers()[0];
|
||||
|
||||
logTabStore.createPodTab({
|
||||
const id = logTabStore.createPodTab({
|
||||
selectedPod,
|
||||
selectedContainer
|
||||
});
|
||||
@ -147,6 +147,7 @@ describe.skip("log tab store", () => {
|
||||
podsStore.items.clear();
|
||||
|
||||
expect(logTabStore.getData(dockStore.selectedTabId)).toBeUndefined();
|
||||
expect(dockStore.getTabById(dockStore.selectedTabId)).toBeUndefined();
|
||||
expect(logTabStore.getData(id)).toBeUndefined();
|
||||
expect(dockStore.getTabById(id)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@ -33,10 +33,10 @@ import { createResourceStore } from "./create-resource.store";
|
||||
import type { DockTab } from "./dock.store";
|
||||
import { EditorPanel } from "./editor-panel";
|
||||
import { InfoPanel } from "./info-panel";
|
||||
import { resourceApplierApi } from "../../../common/k8s-api/endpoints/resource-applier.api";
|
||||
import type { JsonApiErrorParsed } from "../../../common/k8s-api/json-api";
|
||||
import * as resourceApplierApi from "../../../common/k8s-api/endpoints/resource-applier.api";
|
||||
import { Notifications } from "../notifications";
|
||||
import { monacoModelsManager } from "./monaco-model-manager";
|
||||
import logger from "../../../common/logger";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@ -95,7 +95,7 @@ export class CreateResource extends React.Component<Props> {
|
||||
});
|
||||
};
|
||||
|
||||
create = async () => {
|
||||
create = async (): Promise<undefined> => {
|
||||
if (this.error || !this.data.trim()) {
|
||||
// do not save when field is empty or there is an error
|
||||
return null;
|
||||
@ -103,31 +103,31 @@ export class CreateResource extends React.Component<Props> {
|
||||
|
||||
// skip empty documents if "---" pasted at the beginning or end
|
||||
const resources = jsYaml.safeLoadAll(this.data).filter(Boolean);
|
||||
const createdResources: string[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
await Promise.all(
|
||||
resources.map(data => {
|
||||
return resourceApplierApi.update(data)
|
||||
.then(item => createdResources.push(item.metadata.name))
|
||||
.catch((err: JsonApiErrorParsed) => errors.push(err.toString()));
|
||||
})
|
||||
);
|
||||
|
||||
if (errors.length) {
|
||||
errors.forEach(error => Notifications.error(error));
|
||||
if (!createdResources.length) throw errors[0];
|
||||
if (resources.length === 0) {
|
||||
return void logger.info("Nothing to create");
|
||||
}
|
||||
const successMessage = (
|
||||
<p>
|
||||
{createdResources.length === 1 ? "Resource" : "Resources"}{" "}
|
||||
<b>{createdResources.join(", ")}</b> successfully created
|
||||
</p>
|
||||
);
|
||||
|
||||
Notifications.ok(successMessage);
|
||||
const createdResources: string[] = [];
|
||||
|
||||
return successMessage;
|
||||
for (const result of await Promise.allSettled(resources.map(resourceApplierApi.update))) {
|
||||
if (result.status === "fulfilled") {
|
||||
createdResources.push(result.value.metadata.name);
|
||||
} else {
|
||||
Notifications.error(result.reason.toString());
|
||||
}
|
||||
}
|
||||
|
||||
if (createdResources.length > 0) {
|
||||
Notifications.ok((
|
||||
<p>
|
||||
{createdResources.length === 1 ? "Resource" : "Resources"}{" "}
|
||||
<b>{createdResources.join(", ")}</b> successfully created
|
||||
</p>
|
||||
));
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
renderControls(){
|
||||
|
||||
@ -29,6 +29,7 @@ import { Tab, TabProps } from "../tabs";
|
||||
import { Icon } from "../icon";
|
||||
import { Menu, MenuItem } from "../menu";
|
||||
import { observable, makeObservable } from "mobx";
|
||||
import { isMac } from "../../../common/vars";
|
||||
|
||||
export interface DockTabProps extends TabProps<DockTabModel> {
|
||||
moreActions?: React.ReactNode;
|
||||
@ -94,7 +95,7 @@ export class DockTab extends React.Component<DockTabProps> {
|
||||
{!pinned && (
|
||||
<Icon
|
||||
small material="close"
|
||||
tooltip="Close (Ctrl+Shift+W)"
|
||||
tooltip={`Close ${isMac ? "⌘+W" : "Ctrl+W"}`}
|
||||
onClick={prevDefault(this.close)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -27,6 +27,16 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&:not(:focus-within) .DockTab.active {
|
||||
&::after {
|
||||
color: var(--halfGray);
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
color: var(--line-color-active);
|
||||
}
|
||||
}
|
||||
|
||||
&.isOpen {
|
||||
&.fullSize {
|
||||
position: fixed;
|
||||
|
||||
@ -160,7 +160,7 @@ export class DockStore implements DockStorageState {
|
||||
window.addEventListener("resize", throttle(this.adjustHeight, 250));
|
||||
// create monaco models
|
||||
this.whenReady.then(() => {this.tabs.forEach(tab => {
|
||||
if (this.usesMonacoEditor(tab)) {
|
||||
if (this.usesMonacoEditor(tab)) {
|
||||
monacoModelsManager.addModel(tab.id);
|
||||
}
|
||||
});});
|
||||
@ -274,7 +274,7 @@ export class DockStore implements DockStorageState {
|
||||
title
|
||||
};
|
||||
|
||||
// add monaco model
|
||||
// add monaco model
|
||||
if (this.usesMonacoEditor(tab)) {
|
||||
monacoModelsManager.addModel(id);
|
||||
}
|
||||
@ -287,14 +287,14 @@ export class DockStore implements DockStorageState {
|
||||
}
|
||||
|
||||
@action
|
||||
async closeTab(tabId: TabId) {
|
||||
closeTab(tabId: TabId) {
|
||||
const tab = this.getTabById(tabId);
|
||||
|
||||
if (!tab || tab.pinned) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove monaco model
|
||||
// remove monaco model
|
||||
if (this.usesMonacoEditor(tab)) {
|
||||
monacoModelsManager.removeModel(tabId);
|
||||
}
|
||||
@ -305,12 +305,6 @@ export class DockStore implements DockStorageState {
|
||||
if (this.tabs.length) {
|
||||
const newTab = this.tabs.slice(-1)[0]; // last
|
||||
|
||||
if (newTab?.kind === TabKind.TERMINAL) {
|
||||
// close the dock when selected sibling inactive terminal tab
|
||||
const { TerminalStore } = await import("./terminal.store");
|
||||
|
||||
if (!TerminalStore.getInstance(false)?.isConnected(newTab.id)) this.close();
|
||||
}
|
||||
this.selectTab(newTab.id);
|
||||
} else {
|
||||
this.selectedTabId = null;
|
||||
|
||||
@ -46,19 +46,31 @@ interface Props {
|
||||
|
||||
@observer
|
||||
export class Dock extends React.Component<Props> {
|
||||
onKeydown = (evt: React.KeyboardEvent<HTMLElement>) => {
|
||||
const { close, closeTab, selectedTab } = dockStore;
|
||||
private element = React.createRef<HTMLDivElement>();
|
||||
|
||||
if (!selectedTab) return;
|
||||
const { code, ctrlKey, shiftKey } = evt.nativeEvent;
|
||||
componentDidMount() {
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
document.removeEventListener("keydown", this.onKeyDown);
|
||||
}
|
||||
|
||||
onKeyDown = (evt: KeyboardEvent) => {
|
||||
const { close, selectedTab, closeTab } = dockStore;
|
||||
const { code, ctrlKey, metaKey, shiftKey } = evt;
|
||||
// Determine if user working inside <Dock/> or using any other areas in app
|
||||
const dockIsFocused = this.element?.current.contains(document.activeElement);
|
||||
|
||||
if (!selectedTab || !dockIsFocused) return;
|
||||
|
||||
if (shiftKey && code === "Escape") {
|
||||
close();
|
||||
}
|
||||
|
||||
if (ctrlKey && code === "KeyW") {
|
||||
if (selectedTab.pinned) close();
|
||||
else closeTab(selectedTab.id);
|
||||
if ((ctrlKey && code === "KeyW") || (metaKey && code === "KeyW")) {
|
||||
closeTab(selectedTab.id);
|
||||
this.element?.current.focus(); // Avoid loosing focus when closing tab
|
||||
}
|
||||
};
|
||||
|
||||
@ -67,6 +79,7 @@ export class Dock extends React.Component<Props> {
|
||||
|
||||
open();
|
||||
selectTab(tab.id);
|
||||
this.element?.current.focus();
|
||||
};
|
||||
|
||||
renderTab(tab: DockTab) {
|
||||
@ -105,7 +118,7 @@ export class Dock extends React.Component<Props> {
|
||||
return (
|
||||
<div
|
||||
className={cssNames("Dock", className, { isOpen, fullSize })}
|
||||
onKeyDown={this.onKeydown}
|
||||
ref={this.element}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<ResizingAnchor
|
||||
|
||||
@ -31,6 +31,7 @@ import {monacoModelsManager} from "./monaco-model-manager";
|
||||
export interface EditingResource {
|
||||
resource: string; // resource path, e.g. /api/v1/namespaces/default
|
||||
draft?: string; // edited draft in yaml
|
||||
firstDraft?: string;
|
||||
}
|
||||
|
||||
export class EditResourceStore extends DockTabStore<EditingResource> {
|
||||
@ -106,6 +107,10 @@ export class EditResourceStore extends DockTabStore<EditingResource> {
|
||||
return dockStore.getTabById(tabId);
|
||||
}
|
||||
|
||||
clearInitialDraft(tabId: TabId): void {
|
||||
delete this.getData(tabId)?.firstDraft;
|
||||
}
|
||||
|
||||
reset() {
|
||||
super.reset();
|
||||
Array.from(this.watchers).forEach(([tabId, dispose]) => {
|
||||
|
||||
@ -24,7 +24,7 @@ import "./edit-resource.scss";
|
||||
import React from "react";
|
||||
import { action, computed, makeObservable, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import jsYaml from "js-yaml";
|
||||
import yaml from "js-yaml";
|
||||
import type { DockTab } from "./dock.store";
|
||||
import { cssNames } from "../../utils";
|
||||
import { editResourceStore } from "./edit-resource.store";
|
||||
@ -33,6 +33,7 @@ import { Badge } from "../badge";
|
||||
import { EditorPanel } from "./editor-panel";
|
||||
import { Spinner } from "../spinner";
|
||||
import type { KubeObject } from "../../../common/k8s-api/kube-object";
|
||||
import { createPatch } from "rfc6902";
|
||||
|
||||
interface Props {
|
||||
className?: string;
|
||||
@ -71,16 +72,17 @@ export class EditResource extends React.Component<Props> {
|
||||
return draft;
|
||||
}
|
||||
|
||||
return jsYaml.safeDump(this.resource.toPlainObject()); // dump resource first time
|
||||
return yaml.safeDump(this.resource.toPlainObject()); // dump resource first time
|
||||
}
|
||||
|
||||
@action
|
||||
saveDraft(draft: string | object) {
|
||||
if (typeof draft === "object") {
|
||||
draft = draft ? jsYaml.safeDump(draft) : undefined;
|
||||
draft = draft ? yaml.safeDump(draft) : undefined;
|
||||
}
|
||||
|
||||
editResourceStore.setData(this.tabId, {
|
||||
firstDraft: draft, // this must be before the next line
|
||||
...editResourceStore.getData(this.tabId),
|
||||
draft,
|
||||
});
|
||||
@ -95,16 +97,18 @@ export class EditResource extends React.Component<Props> {
|
||||
if (this.error) {
|
||||
return null;
|
||||
}
|
||||
const store = editResourceStore.getStore(this.tabId);
|
||||
const updatedResource: KubeObject = await store.update(this.resource, jsYaml.safeLoad(this.draft));
|
||||
|
||||
this.saveDraft(updatedResource.toPlainObject()); // update with new resourceVersion to avoid further errors on save
|
||||
const resourceType = updatedResource.kind;
|
||||
const resourceName = updatedResource.getName();
|
||||
const store = editResourceStore.getStore(this.tabId);
|
||||
const currentVersion = yaml.safeLoad(this.draft);
|
||||
const firstVersion = yaml.safeLoad(editResourceStore.getData(this.tabId).firstDraft ?? this.draft);
|
||||
const patches = createPatch(firstVersion, currentVersion);
|
||||
const updatedResource = await store.patch(this.resource, patches);
|
||||
|
||||
editResourceStore.clearInitialDraft(this.tabId);
|
||||
|
||||
return (
|
||||
<p>
|
||||
{resourceType} <b>{resourceName}</b> updated.
|
||||
{updatedResource.kind} <b>{updatedResource.getName()}</b> updated.
|
||||
</p>
|
||||
);
|
||||
};
|
||||
@ -126,9 +130,9 @@ export class EditResource extends React.Component<Props> {
|
||||
submittingMessage="Applying.."
|
||||
controls={(
|
||||
<div className="resource-info flex gaps align-center">
|
||||
<span>Kind:</span> <Badge label={resource.kind}/>
|
||||
<span>Kind:</span><Badge label={resource.kind}/>
|
||||
<span>Name:</span><Badge label={resource.getName()}/>
|
||||
<span>Namespace:</span> <Badge label={resource.getNs() || "global"}/>
|
||||
<span>Namespace:</span><Badge label={resource.getNs() || "global"}/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -91,10 +91,7 @@ export class EditorPanel extends React.Component<Props> {
|
||||
|
||||
onChange = (value: string) => {
|
||||
this.validate(value);
|
||||
|
||||
if (this.props.onChange) {
|
||||
this.props.onChange(value, this.yamlError);
|
||||
}
|
||||
this.props.onChange?.(value, this.yamlError);
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@ -25,6 +25,7 @@ import { podsStore } from "../+workloads-pods/pods.store";
|
||||
|
||||
import { IPodContainer, Pod } from "../../../common/k8s-api/endpoints";
|
||||
import type { WorkloadKubeObject } from "../../../common/k8s-api/workload-kube-object";
|
||||
import logger from "../../../common/logger";
|
||||
import { DockTabStore } from "./dock-tab.store";
|
||||
import { dockStore, DockTabCreateSpecific, TabKind } from "./dock.store";
|
||||
|
||||
@ -51,17 +52,15 @@ export class LogTabStore extends DockTabStore<LogTabData> {
|
||||
storageKey: "pod_logs"
|
||||
});
|
||||
|
||||
reaction(() => podsStore.items.length, () => {
|
||||
this.updateTabsData();
|
||||
});
|
||||
reaction(() => podsStore.items.length, () => this.updateTabsData());
|
||||
}
|
||||
|
||||
createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): void {
|
||||
createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): string {
|
||||
const podOwner = selectedPod.getOwnerRefs()[0];
|
||||
const pods = podsStore.getPodsByOwnerId(podOwner?.uid);
|
||||
const title = `Pod ${selectedPod.getName()}`;
|
||||
|
||||
this.createLogsTab(title, {
|
||||
return this.createLogsTab(title, {
|
||||
pods: pods.length ? pods : [selectedPod],
|
||||
selectedPod,
|
||||
selectedContainer
|
||||
@ -95,9 +94,9 @@ export class LogTabStore extends DockTabStore<LogTabData> {
|
||||
...tabParams,
|
||||
kind: TabKind.POD_LOGS,
|
||||
}, false);
|
||||
}
|
||||
}
|
||||
|
||||
private createLogsTab(title: string, data: LogTabData) {
|
||||
private createLogsTab(title: string, data: LogTabData): string {
|
||||
const id = uniqueId("log-tab-");
|
||||
|
||||
this.createDockTab({ id, title });
|
||||
@ -106,38 +105,45 @@ export class LogTabStore extends DockTabStore<LogTabData> {
|
||||
showTimestamps: false,
|
||||
previous: false
|
||||
});
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
private async updateTabsData() {
|
||||
const promises: Promise<void>[] = [];
|
||||
|
||||
private updateTabsData() {
|
||||
for (const [tabId, tabData] of this.data) {
|
||||
const pod = new Pod(tabData.selectedPod);
|
||||
const pods = podsStore.getPodsByOwnerId(pod.getOwnerRefs()[0]?.uid);
|
||||
const isSelectedPodInList = pods.find(item => item.getId() == pod.getId());
|
||||
const selectedPod = isSelectedPodInList ? pod : pods[0];
|
||||
const selectedContainer = isSelectedPodInList ? tabData.selectedContainer : pod.getAllContainers()[0];
|
||||
try {
|
||||
if (!tabData.selectedPod) {
|
||||
tabData.selectedPod = tabData.pods[0];
|
||||
}
|
||||
|
||||
if (pods.length) {
|
||||
this.setData(tabId, {
|
||||
...tabData,
|
||||
selectedPod,
|
||||
selectedContainer,
|
||||
pods
|
||||
});
|
||||
|
||||
this.renameTab(tabId);
|
||||
} else {
|
||||
promises.push(this.closeTab(tabId));
|
||||
const pod = new Pod(tabData.selectedPod);
|
||||
const pods = podsStore.getPodsByOwnerId(pod.getOwnerRefs()[0]?.uid);
|
||||
const isSelectedPodInList = pods.find(item => item.getId() == pod.getId());
|
||||
const selectedPod = isSelectedPodInList ? pod : pods[0];
|
||||
const selectedContainer = isSelectedPodInList ? tabData.selectedContainer : pod.getAllContainers()[0];
|
||||
|
||||
if (pods.length > 0) {
|
||||
this.setData(tabId, {
|
||||
...tabData,
|
||||
selectedPod,
|
||||
selectedContainer,
|
||||
pods
|
||||
});
|
||||
|
||||
this.renameTab(tabId);
|
||||
} else {
|
||||
this.closeTab(tabId);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[LOG-TAB-STORE]: failed to set data for tabId=${tabId} deleting`, error,);
|
||||
this.data.delete(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
private async closeTab(tabId: string) {
|
||||
private closeTab(tabId: string) {
|
||||
this.clearData(tabId);
|
||||
await dockStore.closeTab(tabId);
|
||||
dockStore.closeTab(tabId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -29,6 +29,8 @@ import { ThemeStore } from "../../theme.store";
|
||||
import { boundMethod } from "../../utils";
|
||||
import { isMac } from "../../../common/vars";
|
||||
import { camelCase } from "lodash";
|
||||
import { UserStore } from "../../../common/user-store";
|
||||
import { clipboard } from "electron";
|
||||
|
||||
export class Terminal {
|
||||
static spawningPool: HTMLElement;
|
||||
@ -115,11 +117,13 @@ export class Terminal {
|
||||
this.xterm.open(Terminal.spawningPool);
|
||||
this.xterm.registerLinkMatcher(/https?:\/\/[^\s]+/i, this.onClickLink);
|
||||
this.xterm.attachCustomKeyEventHandler(this.keyHandler);
|
||||
this.xterm.onSelectionChange(this.onSelectionChange);
|
||||
|
||||
// bind events
|
||||
const onDataHandler = this.xterm.onData(this.onData);
|
||||
|
||||
this.viewport.addEventListener("scroll", this.onScroll);
|
||||
this.elem.addEventListener("contextmenu", this.onContextMenu);
|
||||
this.api.onReady.addListener(this.onClear, { once: true }); // clear status logs (connecting..)
|
||||
this.api.onData.addListener(this.onApiData);
|
||||
window.addEventListener("resize", this.onResize);
|
||||
@ -133,6 +137,7 @@ export class Terminal {
|
||||
() => this.fitAddon.dispose(),
|
||||
() => this.api.removeAllListeners(),
|
||||
() => window.removeEventListener("resize", this.onResize),
|
||||
() => this.elem.removeEventListener("contextmenu", this.onContextMenu),
|
||||
);
|
||||
}
|
||||
|
||||
@ -198,8 +203,26 @@ export class Terminal {
|
||||
window.open(link, "_blank");
|
||||
};
|
||||
|
||||
onContextMenu = () => {
|
||||
const { terminalCopyOnSelect } = UserStore.getInstance();
|
||||
const textFromClipboard = clipboard.readText();
|
||||
|
||||
if (terminalCopyOnSelect) {
|
||||
this.xterm.paste(textFromClipboard);
|
||||
}
|
||||
};
|
||||
|
||||
onSelectionChange = () => {
|
||||
const { terminalCopyOnSelect } = UserStore.getInstance();
|
||||
const selection = this.xterm.getSelection().trim();
|
||||
|
||||
if (terminalCopyOnSelect && selection !== "") {
|
||||
clipboard.writeText(selection);
|
||||
}
|
||||
};
|
||||
|
||||
keyHandler = (evt: KeyboardEvent): boolean => {
|
||||
const { code, ctrlKey, type, metaKey } = evt;
|
||||
const { code, ctrlKey, metaKey } = evt;
|
||||
|
||||
// Handle custom hotkey bindings
|
||||
if (ctrlKey) {
|
||||
@ -225,11 +248,6 @@ export class Terminal {
|
||||
}
|
||||
}
|
||||
|
||||
// Pass the event above in DOM for <Dock/> to handle common actions
|
||||
if (!evt.defaultPrevented) {
|
||||
this.elem.dispatchEvent(new KeyboardEvent(type, evt));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ import { HotbarEntityIcon } from "./hotbar-entity-icon";
|
||||
import { cssNames, IClassName } from "../../utils";
|
||||
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
|
||||
import { HotbarStore } from "../../../common/hotbar-store";
|
||||
import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity";
|
||||
import type { CatalogEntity } from "../../api/catalog-entity";
|
||||
import { DragDropContext, Draggable, Droppable, DropResult } from "react-beautiful-dnd";
|
||||
import { HotbarSelector } from "./hotbar-selector";
|
||||
import { HotbarCell } from "./hotbar-cell";
|
||||
@ -124,7 +124,7 @@ export class HotbarMenu extends React.Component<Props> {
|
||||
key={index}
|
||||
index={index}
|
||||
entity={entity}
|
||||
onClick={() => entity.onRun(catalogEntityRunContext)}
|
||||
onClick={() => catalogEntityRegistry.onRun(entity)}
|
||||
className={cssNames({ isDragging: snapshot.isDragging })}
|
||||
remove={this.removeItem}
|
||||
add={this.addItem}
|
||||
|
||||
@ -114,14 +114,14 @@ export class Icon extends React.PureComponent<IconProps> {
|
||||
};
|
||||
|
||||
// render as inline svg-icon
|
||||
if (svg) {
|
||||
if (typeof svg === "string") {
|
||||
const svgIconText = svg.includes("<svg") ? svg : require(`!!raw-loader!./${svg}.svg`).default;
|
||||
|
||||
iconContent = <span className="icon" dangerouslySetInnerHTML={{ __html: svgIconText }}/>;
|
||||
}
|
||||
|
||||
// render as material-icon
|
||||
if (material) {
|
||||
if (typeof material === "string") {
|
||||
iconContent = <span className="icon" data-icon-name={material}>{material}</span>;
|
||||
}
|
||||
|
||||
|
||||
@ -44,15 +44,11 @@ export class KubeObjectMenu<T extends KubeObject> extends React.Component<KubeOb
|
||||
}
|
||||
|
||||
get isEditable() {
|
||||
const { editable } = this.props;
|
||||
|
||||
return editable !== undefined ? editable : !!(this.store && this.store.update);
|
||||
return this.props.editable ?? Boolean(this.store?.patch);
|
||||
}
|
||||
|
||||
get isRemovable() {
|
||||
const { removable } = this.props;
|
||||
|
||||
return removable !== undefined ? removable : !!(this.store && this.store.remove);
|
||||
return this.props.removable ?? Boolean(this.store?.remove);
|
||||
}
|
||||
|
||||
@boundMethod
|
||||
|
||||
@ -153,7 +153,7 @@ export function openServiceAccountKubeConfig(account: ServiceAccount) {
|
||||
const namespace = account.getNs();
|
||||
|
||||
KubeConfigDialog.open({
|
||||
title: "{accountName} kubeconfig",
|
||||
title: `${accountName} kubeconfig`,
|
||||
loader: () => apiBase.get(`/kubeconfig/service-account/${namespace}/${accountName}`)
|
||||
});
|
||||
}
|
||||
|
||||
@ -30,7 +30,6 @@ import { ipcRendererOn } from "../../../common/ipc";
|
||||
import { watchHistoryState } from "../../remote-helpers/history-updater";
|
||||
import { isActiveRoute, navigate } from "../../navigation";
|
||||
import { catalogRoute, catalogURL } from "../../../common/routes";
|
||||
import { previousActiveTab } from "../+catalog";
|
||||
|
||||
interface Props extends React.HTMLAttributes<any> {
|
||||
}
|
||||
@ -72,7 +71,7 @@ export const TopBar = observer(({ children, ...rest }: Props) => {
|
||||
};
|
||||
|
||||
const goHome = () => {
|
||||
navigate(`${catalogURL()}/${previousActiveTab.get()}`);
|
||||
navigate(catalogURL());
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user