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

Merge branch 'master' into close-dock-tabs-with-cmd-w

This commit is contained in:
Alex Andreev 2021-10-11 06:15:07 +03:00
commit 2822f82f8d
121 changed files with 3231 additions and 1542 deletions

View File

@ -15,7 +15,7 @@ trigger: none
jobs: jobs:
- job: Linux - job: Linux
pool: pool:
vmImage: ubuntu-16.04 vmImage: ubuntu-18.04
strategy: strategy:
matrix: matrix:
kube_1.16: kube_1.16:
@ -46,7 +46,7 @@ jobs:
curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
sudo install minikube-linux-amd64 /usr/local/bin/minikube sudo install minikube-linux-amd64 /usr/local/bin/minikube
sudo minikube start --driver=none --kubernetes-version $(kubernetes_version) sudo minikube start --driver=none --kubernetes-version $(kubernetes_version)
# Although the kube and minikube config files are in placed $HOME they are owned by root sudo mv /root/.kube /root/.minikube $HOME
sudo chown -R $USER $HOME/.kube $HOME/.minikube sudo chown -R $USER $HOME/.kube $HOME/.minikube
displayName: Install integration test dependencies displayName: Install integration test dependencies
- script: make node_modules - script: make node_modules

View File

@ -109,7 +109,7 @@ jobs:
- job: Linux - job: Linux
pool: pool:
vmImage: ubuntu-16.04 vmImage: ubuntu-18.04
strategy: strategy:
matrix: matrix:
node_14.x: node_14.x:

View File

@ -2,7 +2,7 @@ name: Test
on: on:
pull_request: pull_request:
branches: branches:
- "*" - "**"
push: push:
branches: branches:
- master - master
@ -12,7 +12,7 @@ jobs:
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
strategy: strategy:
matrix: matrix:
os: [ubuntu-16.04, macos-11, windows-2019] os: [ubuntu-18.04, macos-11, windows-2019]
node-version: [14.x] node-version: [14.x]
steps: steps:
- name: Checkout Release from lens - name: Checkout Release from lens
@ -67,15 +67,9 @@ jobs:
- run: make test-extensions - run: make test-extensions
name: Run In-tree Extension tests name: Run In-tree Extension tests
- run: | - name: Install integration test dependencies
sudo apt-get update id: minikube
sudo apt-get install libgconf-2-4 conntrack -y uses: medyagh/setup-minikube@5a9a7104d7322fa40424de8855c84685e89cefd7
curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
sudo install minikube-linux-amd64 /usr/local/bin/minikube
sudo minikube start --driver=none
# Although the kube and minikube config files are in placed $HOME they are owned by root
sudo chown -R $USER $HOME/.kube $HOME/.minikube
name: Install integration test dependencies
if: runner.os == 'Linux' if: runner.os == 'Linux'
- run: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration - run: xvfb-run --auto-servernum --server-args='-screen 0, 1600x900x24' make integration

View File

@ -1,3 +1,3 @@
disturl "https://atom.io/download/electron" disturl "https://atom.io/download/electron"
target "12.0.15" target "13.5.1"
runtime "electron" runtime "electron"

View File

@ -2,7 +2,9 @@
Lens releases are built by CICD automatically on git tags. The typical release process flow is the following: 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` - `major`
- `minor` - `minor`
- `patch` - `patch`
@ -17,15 +19,15 @@ Lens releases are built by CICD automatically on git tags. The typical release p
- `rc` - `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 ...` 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. 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 runs the azure jobs to build the binaries and put them on S3. 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: 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 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 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) - `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. 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 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` `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) - 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 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) - 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)

View File

@ -8,9 +8,9 @@
"version": "file:../../src/extensions/npm/extensions", "version": "file:../../src/extensions/npm/extensions",
"dev": true, "dev": true,
"requires": { "requires": {
"@material-ui/core": "*", "@material-ui/core": "4.12.3",
"@types/node": "*", "@types/node": "14.17.14",
"@types/react-select": "*", "@types/react-select": "3.1.2",
"conf": "^7.0.1", "conf": "^7.0.1",
"typed-emitter": "^1.3.1" "typed-emitter": "^1.3.1"
}, },

View File

@ -662,10 +662,11 @@
"version": "file:../../src/extensions/npm/extensions", "version": "file:../../src/extensions/npm/extensions",
"dev": true, "dev": true,
"requires": { "requires": {
"@material-ui/core": "*", "@material-ui/core": "4.12.3",
"@types/node": "*", "@types/node": "14.17.14",
"@types/react-select": "*", "@types/react-select": "3.1.2",
"conf": "^7.0.1" "conf": "^7.0.1",
"typed-emitter": "^1.3.1"
}, },
"dependencies": { "dependencies": {
"@babel/runtime": { "@babel/runtime": {
@ -1329,6 +1330,12 @@
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
"dev": true "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": { "uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@ -1742,9 +1749,9 @@
} }
}, },
"ansi-regex": { "ansi-regex": {
"version": "5.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true "dev": true
}, },
"ansi-styles": { "ansi-styles": {
@ -6645,9 +6652,9 @@
} }
}, },
"tmpl": { "tmpl": {
"version": "1.0.4", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
"integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
"dev": true "dev": true
}, },
"to-arraybuffer": { "to-arraybuffer": {

View File

@ -628,9 +628,9 @@
"version": "file:../../src/extensions/npm/extensions", "version": "file:../../src/extensions/npm/extensions",
"dev": true, "dev": true,
"requires": { "requires": {
"@material-ui/core": "*", "@material-ui/core": "4.12.3",
"@types/node": "*", "@types/node": "14.17.14",
"@types/react-select": "*", "@types/react-select": "3.1.2",
"conf": "^7.0.1", "conf": "^7.0.1",
"typed-emitter": "^1.3.1" "typed-emitter": "^1.3.1"
}, },
@ -1707,9 +1707,9 @@
} }
}, },
"ansi-regex": { "ansi-regex": {
"version": "5.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true "dev": true
}, },
"ansi-styles": { "ansi-styles": {
@ -6608,9 +6608,9 @@
} }
}, },
"tmpl": { "tmpl": {
"version": "1.0.4", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
"integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
"dev": true "dev": true
}, },
"to-arraybuffer": { "to-arraybuffer": {

View File

@ -628,9 +628,9 @@
"version": "file:../../src/extensions/npm/extensions", "version": "file:../../src/extensions/npm/extensions",
"dev": true, "dev": true,
"requires": { "requires": {
"@material-ui/core": "*", "@material-ui/core": "4.12.3",
"@types/node": "*", "@types/node": "14.17.14",
"@types/react-select": "*", "@types/react-select": "3.1.2",
"conf": "^7.0.1", "conf": "^7.0.1",
"typed-emitter": "^1.3.1" "typed-emitter": "^1.3.1"
}, },
@ -1660,9 +1660,9 @@
} }
}, },
"ansi-regex": { "ansi-regex": {
"version": "5.0.0", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.0.tgz", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-bY6fj56OUQ0hU1KjFNDQuJFezqKdrAyFdIevADiqrWHwSlbmBNMHp5ak2f40Pm8JTFyM2mqxkG6ngkHO11f/lg==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true "dev": true
}, },
"ansi-styles": { "ansi-styles": {
@ -6561,9 +6561,9 @@
} }
}, },
"tmpl": { "tmpl": {
"version": "1.0.4", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.4.tgz", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz",
"integrity": "sha1-I2QN17QtAEM5ERQIIOXPRA5SHdE=", "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==",
"dev": true "dev": true
}, },
"to-arraybuffer": { "to-arraybuffer": {

View File

@ -25,16 +25,24 @@
TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube
cluster and vice versa. cluster and vice versa.
*/ */
import type { Page } from "playwright"; import type { ElectronApplication, Page } from "playwright";
import * as utils from "../helpers/utils"; import * as utils from "../helpers/utils";
describe("preferences page tests", () => { describe("preferences page tests", () => {
let window: Page, cleanup: () => Promise<void>; let window: Page, cleanup: () => Promise<void>;
beforeEach(async () => { beforeEach(async () => {
({ window, cleanup } = await utils.start()); let app: ElectronApplication;
({ window, cleanup, app } = await utils.start());
await utils.clickWelcomeButton(window); 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); }, 10*60*1000);
afterEach(async () => { afterEach(async () => {

View File

@ -19,14 +19,14 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * 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"; import * as utils from "../helpers/utils";
describe("Lens command palette", () => { describe("Lens command palette", () => {
let window: Page, cleanup: () => Promise<void>; let window: Page, cleanup: () => Promise<void>, app: ElectronApplication;
beforeEach(async () => { beforeEach(async () => {
({ window, cleanup } = await utils.start()); ({ window, cleanup, app } = await utils.start());
await utils.clickWelcomeButton(window); await utils.clickWelcomeButton(window);
}, 10*60*1000); }, 10*60*1000);
@ -35,8 +35,13 @@ describe("Lens command palette", () => {
}, 10*60*1000); }, 10*60*1000);
describe("menu", () => { describe("menu", () => {
it("opens command dialog from keyboard shortcut", async () => { it("opens command dialog from menu", async () => {
await window.keyboard.press("Meta+Shift+p"); await app.evaluate(async ({ app }) => {
await app.applicationMenu
.getMenuItemById("view")
.submenu.getMenuItemById("command-palette")
.click();
});
await window.waitForSelector(".Select__option >> text=Hotbar: Switch"); await window.waitForSelector(".Select__option >> text=Hotbar: Switch");
}, 10*60*1000); }, 10*60*1000);
}); });

View File

@ -104,10 +104,7 @@ function minikubeEntityId() {
* From the catalog, click the minikube entity and wait for it to connect, returning its frame * From the catalog, click the minikube entity and wait for it to connect, returning its frame
*/ */
export async function lauchMinikubeClusterFromCatalog(window: Page): Promise<Frame> { export async function lauchMinikubeClusterFromCatalog(window: Page): Promise<Frame> {
await window.waitForSelector("div.TableCell");
await window.click("div.TableCell >> text='minikube'"); 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()}`); const minikubeFrame = await window.waitForSelector(`#cluster-frame-${minikubeEntityId()}`);

View File

@ -3,7 +3,7 @@
"productName": "OpenLens", "productName": "OpenLens",
"description": "OpenLens - Open Source IDE for Kubernetes", "description": "OpenLens - Open Source IDE for Kubernetes",
"homepage": "https://github.com/lensapp/lens", "homepage": "https://github.com/lensapp/lens",
"version": "5.2.0", "version": "5.3.0-alpha.0",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2021 OpenLens Authors", "copyright": "© 2021 OpenLens Authors",
"license": "MIT", "license": "MIT",
@ -185,7 +185,6 @@
"@kubernetes/client-node": "^0.15.1", "@kubernetes/client-node": "^0.15.1",
"@sentry/electron": "^2.5.0", "@sentry/electron": "^2.5.0",
"@sentry/integrations": "^6.10.0", "@sentry/integrations": "^6.10.0",
"@types/which": "^2.0.1",
"abort-controller": "^3.0.0", "abort-controller": "^3.0.0",
"array-move": "^3.0.1", "array-move": "^3.0.1",
"auto-bind": "^4.0.0", "auto-bind": "^4.0.0",
@ -194,14 +193,16 @@
"byline": "^5.0.0", "byline": "^5.0.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"chokidar": "^3.4.3", "chokidar": "^3.4.3",
"command-exists": "1.2.9",
"conf": "^7.0.1", "conf": "^7.0.1",
"crypto-js": "^4.1.1", "crypto-js": "^4.1.1",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-updater": "^4.4.3", "electron-updater": "^4.6.0",
"electron-window-state": "^5.0.3", "electron-window-state": "^5.0.3",
"filehound": "^1.17.4", "filehound": "^1.17.4",
"fs-extra": "^9.0.1", "fs-extra": "^9.0.1",
"glob-to-regexp": "^0.4.1", "glob-to-regexp": "^0.4.1",
"got": "^11.8.2",
"grapheme-splitter": "^1.0.4", "grapheme-splitter": "^1.0.4",
"handlebars": "^4.7.7", "handlebars": "^4.7.7",
"http-proxy": "^1.18.1", "http-proxy": "^1.18.1",
@ -209,7 +210,7 @@
"joi": "^17.4.2", "joi": "^17.4.2",
"js-yaml": "^3.14.0", "js-yaml": "^3.14.0",
"jsdom": "^16.7.0", "jsdom": "^16.7.0",
"jsonpath": "^1.0.2", "jsonpath": "^1.1.1",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"mac-ca": "^1.0.6", "mac-ca": "^1.0.6",
"marked": "^2.0.3", "marked": "^2.0.3",
@ -223,10 +224,9 @@
"monaco-editor": "^0.26.1", "monaco-editor": "^0.26.1",
"node-fetch": "^2.6.1", "node-fetch": "^2.6.1",
"node-pty": "^0.10.1", "node-pty": "^0.10.1",
"npm": "^6.14.8", "npm": "^6.14.15",
"openid-client": "^3.15.2",
"p-limit": "^3.1.0", "p-limit": "^3.1.0",
"path-to-regexp": "^6.1.0", "path-to-regexp": "^6.2.0",
"proper-lockfile": "^4.1.2", "proper-lockfile": "^4.1.2",
"react": "^17.0.2", "react": "^17.0.2",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
@ -237,17 +237,17 @@
"readable-stream": "^3.6.0", "readable-stream": "^3.6.0",
"request": "^2.88.2", "request": "^2.88.2",
"request-promise-native": "^1.0.9", "request-promise-native": "^1.0.9",
"rfc6902": "^4.0.2",
"semver": "^7.3.2", "semver": "^7.3.2",
"serializr": "^2.0.5", "serializr": "^2.0.5",
"shell-env": "^3.0.1", "shell-env": "^3.0.1",
"spdy": "^4.0.2", "spdy": "^4.0.2",
"tar": "^6.1.10", "tar": "^6.1.10",
"tcp-port-used": "^1.0.1", "tcp-port-used": "^1.0.2",
"tempy": "^0.5.0", "tempy": "1.0.1",
"url-parse": "^1.5.1", "url-parse": "^1.5.3",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"which": "^2.0.2", "win-ca": "^3.4.5",
"win-ca": "^3.2.0",
"winston": "^3.3.3", "winston": "^3.3.3",
"winston-console-format": "^1.0.8", "winston-console-format": "^1.0.8",
"winston-transport-browserconsole": "^1.0.5", "winston-transport-browserconsole": "^1.0.5",
@ -275,9 +275,9 @@
"@types/glob-to-regexp": "^0.4.1", "@types/glob-to-regexp": "^0.4.1",
"@types/hapi": "^18.0.6", "@types/hapi": "^18.0.6",
"@types/hoist-non-react-statics": "^3.3.1", "@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/http-proxy": "^1.17.7",
"@types/jest": "^26.0.22", "@types/jest": "^26.0.24",
"@types/js-yaml": "^3.12.4", "@types/js-yaml": "^3.12.4",
"@types/jsdom": "^16.2.4", "@types/jsdom": "^16.2.4",
"@types/jsonpath": "^0.2.0", "@types/jsonpath": "^0.2.0",
@ -291,19 +291,19 @@
"@types/node-fetch": "^2.5.12", "@types/node-fetch": "^2.5.12",
"@types/npm": "^2.0.32", "@types/npm": "^2.0.32",
"@types/progress-bar-webpack-plugin": "^2.1.2", "@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/randomcolor": "^0.5.6",
"@types/react": "^17.0.0", "@types/react": "^17.0.0",
"@types/react-beautiful-dnd": "^13.1.1", "@types/react-beautiful-dnd": "^13.1.1",
"@types/react-dom": "^17.0.9", "@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-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-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5", "@types/react-window": "^1.8.5",
"@types/readable-stream": "^2.3.9", "@types/readable-stream": "^2.3.9",
"@types/request": "^2.48.7", "@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/semver": "^7.2.0",
"@types/sharp": "^0.28.3", "@types/sharp": "^0.28.3",
"@types/spdy": "^3.4.4", "@types/spdy": "^3.4.4",
@ -314,26 +314,26 @@
"@types/url-parse": "^1.4.3", "@types/url-parse": "^1.4.3",
"@types/uuid": "^8.3.1", "@types/uuid": "^8.3.1",
"@types/webdriverio": "^4.13.0", "@types/webdriverio": "^4.13.0",
"@types/webpack": "^4.41.17", "@types/webpack": "^4.41.31",
"@types/webpack-dev-server": "^3.11.1", "@types/webpack-dev-server": "^3.11.6",
"@types/webpack-env": "^1.15.2", "@types/webpack-env": "^1.16.2",
"@types/webpack-node-externals": "^1.7.1", "@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", "@typescript-eslint/parser": "^4.29.1",
"ansi_up": "^5.0.1", "ansi_up": "^5.0.1",
"chart.js": "^2.9.4", "chart.js": "^2.9.4",
"circular-dependency-plugin": "^5.2.2", "circular-dependency-plugin": "^5.2.2",
"color": "^3.1.2", "color": "^3.1.2",
"concurrently": "^5.2.0", "concurrently": "^5.2.0",
"css-loader": "^5.2.6", "css-loader": "^5.2.7",
"deepdash": "^5.3.5", "deepdash": "^5.3.9",
"dompurify": "^2.3.1", "dompurify": "^2.3.1",
"electron": "^12.0.17", "electron": "^13.5.1",
"electron-builder": "^22.11.11", "electron-builder": "^22.11.11",
"electron-notarize": "^0.3.0", "electron-notarize": "^0.3.0",
"esbuild": "^0.12.24", "esbuild": "^0.12.24",
"esbuild-loader": "^2.13.1", "esbuild-loader": "^2.15.1",
"eslint": "^7.7.0", "eslint": "^7.32.0",
"eslint-plugin-header": "^3.1.1", "eslint-plugin-header": "^3.1.1",
"eslint-plugin-react": "^7.24.0", "eslint-plugin-react": "^7.24.0",
"eslint-plugin-react-hooks": "^4.2.0", "eslint-plugin-react-hooks": "^4.2.0",
@ -350,7 +350,7 @@
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"jest-mock-extended": "^1.0.16", "jest-mock-extended": "^1.0.16",
"make-plural": "^6.2.2", "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-gyp": "7.1.2",
"node-loader": "^1.0.3", "node-loader": "^1.0.3",
"nodemon": "^2.0.12", "nodemon": "^2.0.12",
@ -358,6 +358,7 @@
"postcss": "^8.3.6", "postcss": "^8.3.6",
"postcss-loader": "4.3.0", "postcss-loader": "4.3.0",
"postinstall-postinstall": "^2.1.0", "postinstall-postinstall": "^2.1.0",
"prettier": "^2.4.1",
"progress-bar-webpack-plugin": "^2.1.0", "progress-bar-webpack-plugin": "^2.1.0",
"randomcolor": "^0.6.2", "randomcolor": "^0.6.2",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
@ -368,20 +369,20 @@
"react-select-event": "^5.1.0", "react-select-event": "^5.1.0",
"react-table": "^7.7.0", "react-table": "^7.7.0",
"react-window": "^1.8.5", "react-window": "^1.8.5",
"sass": "^1.39.0", "sass": "^1.41.1",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"sharp": "^0.29.0", "sharp": "^0.29.1",
"style-loader": "^2.0.0", "style-loader": "^2.0.0",
"tailwindcss": "^2.2.4", "tailwindcss": "^2.2.4",
"ts-jest": "26.5.6", "ts-jest": "26.5.6",
"ts-loader": "^7.0.5", "ts-loader": "^7.0.5",
"ts-node": "^10.1.0", "ts-node": "^10.2.1",
"type-fest": "^1.0.2", "type-fest": "^1.0.2",
"typed-emitter": "^1.3.1", "typed-emitter": "^1.3.1",
"typedoc": "0.21.0-beta.2", "typedoc": "0.22.5",
"typedoc-plugin-markdown": "^3.9.0", "typedoc-plugin-markdown": "^3.9.0",
"typeface-roboto": "^1.1.13", "typeface-roboto": "^1.1.13",
"typescript": "^4.3.2", "typescript": "^4.4.3",
"typescript-plugin-css-modules": "^3.4.0", "typescript-plugin-css-modules": "^3.4.0",
"url-loader": "^4.1.1", "url-loader": "^4.1.1",
"webpack": "^4.46.0", "webpack": "^4.46.0",

View File

@ -46,6 +46,10 @@ export abstract class BaseStore<T> extends Singleton {
protected constructor(protected params: BaseStoreParams<T>) { protected constructor(protected params: BaseStoreParams<T>) {
super(); super();
makeObservable(this); makeObservable(this);
if (ipcRenderer) {
params.migrations = undefined; // don't run migrations on renderer
}
} }
/** /**

View File

@ -57,6 +57,10 @@ export class WebLink extends CatalogEntity<CatalogEntityMetadata, WebLinkStatus,
} }
}); });
} }
catalogCategoryRegistry
.getCategoryForEntity<WebLinkCategory>(this)
?.emit("contextMenuOpen", this, context);
} }
} }

View 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;
}
}

View File

@ -19,18 +19,15 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import path from "path";
export class ExecValidationNotFoundError extends Error { export class ExecValidationNotFoundError extends Error {
constructor(execPath: string) { constructor(execPath: string, isAbsolute: boolean) {
super(`User Exec command "${execPath}" not found on host.`);
let message = `User Exec command "${execPath}" not found on host.`; let message = `User Exec command "${execPath}" not found on host.`;
if (!path.isAbsolute(execPath)) { if (!isAbsolute) {
message += ` Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig`; message += ` Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig`;
} }
this.message = message;
super(message);
this.name = this.constructor.name; this.name = this.constructor.name;
Error.captureStackTrace(this, this.constructor); Error.captureStackTrace(this, this.constructor);
} }

View File

@ -31,11 +31,13 @@ export class PersistentVolumeClaimsApi extends KubeApi<PersistentVolumeClaim> {
} }
export function getMetricsForPvc(pvc: PersistentVolumeClaim): Promise<IPvcMetrics> { export function getMetricsForPvc(pvc: PersistentVolumeClaim): Promise<IPvcMetrics> {
const opts = { category: "pvc", pvc: pvc.getName(), namespace: pvc.getNs() };
return metricsApi.getMetrics({ return metricsApi.getMetrics({
diskUsage: { category: "pvc", pvc: pvc.getName() }, diskUsage: opts,
diskCapacity: { category: "pvc", pvc: pvc.getName() } diskCapacity: opts
}, { }, {
namespace: pvc.getNs() namespace: opts.namespace
}); });
} }

View File

@ -22,19 +22,27 @@
import jsYaml from "js-yaml"; import jsYaml from "js-yaml";
import type { KubeJsonApiData } from "../kube-json-api"; import type { KubeJsonApiData } from "../kube-json-api";
import { apiBase } from "../index"; import { apiBase } from "../index";
import type { Patch } from "rfc6902";
export const resourceApplierApi = { export const annotations = [
annotations: [
"kubectl.kubernetes.io/last-applied-configuration" "kubectl.kubernetes.io/last-applied-configuration"
], ];
async update(resource: object | string): Promise<KubeJsonApiData | null> { export async function update(resource: object | string): Promise<KubeJsonApiData> {
if (typeof resource === "string") { if (typeof resource === "string") {
resource = jsYaml.safeLoad(resource); resource = jsYaml.safeLoad(resource);
} }
const [data = null] = await apiBase.post<KubeJsonApiData[]>("/stack", { data: resource }); return apiBase.post<KubeJsonApiData>("/stack", { data: resource });
}
return data; export async function patch(name: string, kind: string, ns: string, patch: Patch): Promise<KubeJsonApiData> {
} return apiBase.patch<KubeJsonApiData>("/stack", {
}; data: {
name,
kind,
ns,
patch,
},
});
}

View File

@ -32,6 +32,7 @@ import { parseKubeApi } from "./kube-api-parse";
import type { KubeJsonApiData } from "./kube-json-api"; import type { KubeJsonApiData } from "./kube-json-api";
import type { RequestInit } from "node-fetch"; import type { RequestInit } from "node-fetch";
import AbortController from "abort-controller"; import AbortController from "abort-controller";
import type { Patch } from "rfc6902";
export interface KubeObjectStoreLoadingParams<K extends KubeObject> { export interface KubeObjectStoreLoadingParams<K extends KubeObject> {
namespaces: string[]; namespaces: string[];
@ -159,7 +160,7 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
this.loadedNamespaces = namespaces; this.loadedNamespaces = namespaces;
return Promise // load resources per namespace return Promise // load resources per namespace
.all(namespaces.map(namespace => api.list({ namespace, reqInit }))) .all(namespaces.map(namespace => api.list({ namespace, reqInit }, this.query)))
.then(items => items.flat().filter(Boolean)); .then(items => items.flat().filter(Boolean));
} }
} }
@ -279,19 +280,29 @@ export abstract class KubeObjectStore<T extends KubeObject> extends ItemStore<T>
return newItem; return newItem;
} }
async update(item: T, data: Partial<T>): Promise<T> { private postUpdate(rawItem: KubeJsonApiData): T {
const rawItem = await item.update(data);
const newItem = new this.api.objectConstructor(rawItem); const newItem = new this.api.objectConstructor(rawItem);
const index = this.items.findIndex(item => item.getId() === newItem.getId());
ensureObjectSelfLink(this.api, newItem); ensureObjectSelfLink(this.api, newItem);
const index = this.items.findIndex(item => item.getId() === newItem.getId()); if (index < 0) {
this.items.push(newItem);
this.items.splice(index, 1, newItem); } else {
this.items[index] = newItem;
}
return 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) { async remove(item: T) {
await item.delete(); await item.delete();
this.items.remove(item); this.items.remove(item);

View File

@ -27,9 +27,9 @@ import { autoBind, formatDuration } from "../utils";
import type { ItemObject } from "../item.store"; import type { ItemObject } from "../item.store";
import { apiKube } from "./index"; import { apiKube } from "./index";
import type { JsonApiParams } from "./json-api"; 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 { 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) & { export type KubeObjectConstructor<K extends KubeObject> = (new (data: KubeJsonApiData | any) => K) & {
kind?: string; kind?: string;
@ -98,6 +98,12 @@ export interface KubeObjectStatus {
export type KubeMetaField = keyof KubeObjectMetadata; 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 { export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata, Status = any, Spec = any> implements ItemObject {
static readonly kind: string; static readonly kind: string;
static readonly namespaced: boolean; static readonly namespaced: boolean;
@ -191,18 +197,32 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
return Object.entries(labels).map(([name, value]) => `${name}=${value}`); return Object.entries(labels).map(([name, value]) => `${name}=${value}`);
} }
protected static readonly nonEditableFields = [ /**
"apiVersion", * These must be RFC6902 compliant paths
"kind", */
"metadata.name", private static readonly nonEditiablePathPrefixes = [
"metadata.selfLink", "/metadata/managedFields",
"metadata.resourceVersion", "/status",
"metadata.uid",
"managedFields",
"status",
]; ];
private static readonly nonEditablePaths = new Set([
"/apiVersion",
"/kind",
"/metadata/name",
"/metadata/selfLink",
"/metadata/resourceVersion",
"/metadata/uid",
...KubeObject.nonEditiablePathPrefixes,
]);
constructor(data: KubeJsonApiData) { 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); Object.assign(this, data);
autoBind(this); autoBind(this);
} }
@ -264,7 +284,7 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
} }
getOwnerRefs() { getOwnerRefs() {
const refs = this.metadata?.ownerReferences || []; const refs = this.metadata.ownerReferences || [];
const namespace = this.getNs(); const namespace = this.getNs();
return refs.map(ownerRef => ({ ...ownerRef, namespace })); return refs.map(ownerRef => ({ ...ownerRef, namespace }));
@ -286,14 +306,31 @@ export class KubeObject<Metadata extends KubeObjectMetadata = KubeObjectMetadata
return JSON.parse(JSON.stringify(this)); return JSON.parse(JSON.stringify(this));
} }
// use unified resource-applier api for updating all k8s objects async patch(patch: Patch): Promise<KubeJsonApiData | null> {
async update(data: Partial<this>): Promise<KubeJsonApiData | null> { for (const op of patch) {
for (const field of KubeObject.nonEditableFields) { if (KubeObject.nonEditablePaths.has(op.path)) {
if (!_.isEqual(_.get(this, field), _.get(data, field))) { throw new Error(`Failed to update ${this.kind}: JSON pointer ${op.path} has been modified`);
throw new Error(`Failed to update Kube Object: ${field} 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({ return resourceApplierApi.update({
...this.toPlainObject(), ...this.toPlainObject(),
...data, ...data,

View File

@ -25,11 +25,11 @@ import path from "path";
import os from "os"; import os from "os";
import yaml from "js-yaml"; import yaml from "js-yaml";
import logger from "../main/logger"; import logger from "../main/logger";
import commandExists from "command-exists";
import { ExecValidationNotFoundError } from "./custom-errors"; import { ExecValidationNotFoundError } from "./custom-errors";
import { Cluster, Context, newClusters, newContexts, newUsers, User } from "@kubernetes/client-node/dist/config_types"; import { Cluster, Context, newClusters, newContexts, newUsers, User } from "@kubernetes/client-node/dist/config_types";
import { resolvePath } from "./utils"; import { resolvePath } from "./utils";
import Joi from "joi"; import Joi from "joi";
import which from "which";
export type KubeConfigValidationOpts = { export type KubeConfigValidationOpts = {
validateCluster?: boolean; validateCluster?: boolean;
@ -295,17 +295,13 @@ export function validateKubeConfig(config: KubeConfig, contextName: string, vali
// Validate exec command if present // Validate exec command if present
if (validateExec && user?.exec) { if (validateExec && user?.exec) {
try { const execCommand = user.exec["command"];
which.sync(user.exec.command); // check if the command is absolute or not
const isAbsolute = path.isAbsolute(execCommand);
// If this doesn't throw an error it also means that it has found the executable. // validate the exec struct in the user object, start with the command field
} catch (error) { if (!commandExists.sync(execCommand)) {
switch (error?.code) { return new ExecValidationNotFoundError(execCommand, isAbsolute);
case "ENOENT":
return new ExecValidationNotFoundError(user.exec.command);
default:
return error;
}
} }
} }

View File

@ -59,11 +59,8 @@ if (ipcMain) {
), ),
}) })
); );
} else {
transports.push(new BrowserConsole());
}
if (!isTestEnv) { if (!isTestEnv) {
transports.push( transports.push(
new winston.transports.File({ new winston.transports.File({
handleExceptions: false, handleExceptions: false,
@ -75,6 +72,9 @@ if (!isTestEnv) {
tailable: true, tailable: true,
}) })
); );
}
} else {
transports.push(new BrowserConsole());
} }
export default winston.createLogger({ export default winston.createLogger({

View File

@ -30,4 +30,17 @@ export const catalogRoute: RouteProps = {
path: "/catalog/:group?/:kind?" 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 catalogURL = buildURL<CatalogViewRouteParam>(catalogRoute.path);
export const browseCatalogTab = "browse";

View File

@ -40,6 +40,7 @@ export * from "./network-policies";
export * from "./network"; export * from "./network";
export * from "./nodes"; export * from "./nodes";
export * from "./pod-disruption-budgets"; export * from "./pod-disruption-budgets";
export * from "./port-forwards";
export * from "./preferences"; export * from "./preferences";
export * from "./releases"; export * from "./releases";
export * from "./resource-quotas"; export * from "./resource-quotas";

View File

@ -25,6 +25,7 @@ import { endpointRoute } from "./endpoints";
import { ingressRoute } from "./ingresses"; import { ingressRoute } from "./ingresses";
import { networkPoliciesRoute } from "./network-policies"; import { networkPoliciesRoute } from "./network-policies";
import { servicesRoute, servicesURL } from "./services"; import { servicesRoute, servicesURL } from "./services";
import { portForwardsRoute } from "./port-forwards";
export const networkRoute: RouteProps = { export const networkRoute: RouteProps = {
path: [ path: [
@ -32,6 +33,7 @@ export const networkRoute: RouteProps = {
endpointRoute, endpointRoute,
ingressRoute, ingressRoute,
networkPoliciesRoute, networkPoliciesRoute,
portForwardsRoute,
].map(route => route.path.toString()) ].map(route => route.path.toString())
}; };

View File

@ -19,26 +19,14 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
.EntitySettings { import type { RouteProps } from "react-router";
$spacing: $padding * 3; import { buildURL } from "../utils/buildUrl";
export const portForwardsRoute: RouteProps = {
path: "/port-forwards"
};
// TODO: move sub-component styles to separate files export interface PortForwardsRouteParams {
.admin-note {
font-size: small;
opacity: 0.5;
margin-left: $margin;
}
.button-area {
margin-top: $margin * 2;
}
.file-loader {
margin-top: $margin * 2;
}
.Input, .Select {
margin-top: $padding;
}
} }
export const portForwardsURL = buildURL<PortForwardsRouteParams>(portForwardsRoute.path);

View File

@ -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>>> = { const hiddenTableColumns: PreferenceDescription<[string, string[]][], Map<string, ObservableToggleSet<string>>> = {
fromStore(val) { fromStore(val) {
return new Map( return new Map(
@ -274,4 +287,5 @@ export const DESCRIPTORS = {
hiddenTableColumns, hiddenTableColumns,
syncKubeconfigEntries, syncKubeconfigEntries,
editorConfiguration, editorConfiguration,
terminalCopyOnSelect,
}; };

View File

@ -19,7 +19,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * 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 semver from "semver";
import { action, computed, observable, reaction, makeObservable } from "mobx"; import { action, computed, observable, reaction, makeObservable } from "mobx";
import { BaseStore } from "../base-store"; import { BaseStore } from "../base-store";
@ -48,7 +48,11 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
}); });
makeObservable(this); makeObservable(this);
if (ipcMain) {
fileNameMigration(); fileNameMigration();
}
this.load(); this.load();
} }
@ -70,6 +74,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
@observable shell?: string; @observable shell?: string;
@observable downloadBinariesPath?: string; @observable downloadBinariesPath?: string;
@observable kubectlBinariesPath?: string; @observable kubectlBinariesPath?: string;
@observable terminalCopyOnSelect: boolean;
/** /**
* Download kubectl binaries matching cluster version * 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.hiddenTableColumns.replace(DESCRIPTORS.hiddenTableColumns.fromStore(preferences?.hiddenTableColumns));
this.syncKubeconfigEntries.replace(DESCRIPTORS.syncKubeconfigEntries.fromStore(preferences?.syncKubeconfigEntries)); this.syncKubeconfigEntries.replace(DESCRIPTORS.syncKubeconfigEntries.fromStore(preferences?.syncKubeconfigEntries));
this.editorConfiguration = DESCRIPTORS.editorConfiguration.fromStore(preferences?.editorConfiguration); this.editorConfiguration = DESCRIPTORS.editorConfiguration.fromStore(preferences?.editorConfiguration);
this.terminalCopyOnSelect = DESCRIPTORS.terminalCopyOnSelect.fromStore(preferences?.terminalCopyOnSelect);
} }
toJSON(): UserStoreModel { toJSON(): UserStoreModel {
@ -233,6 +239,7 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
hiddenTableColumns: DESCRIPTORS.hiddenTableColumns.toStore(this.hiddenTableColumns), hiddenTableColumns: DESCRIPTORS.hiddenTableColumns.toStore(this.hiddenTableColumns),
syncKubeconfigEntries: DESCRIPTORS.syncKubeconfigEntries.toStore(this.syncKubeconfigEntries), syncKubeconfigEntries: DESCRIPTORS.syncKubeconfigEntries.toStore(this.syncKubeconfigEntries),
editorConfiguration: DESCRIPTORS.editorConfiguration.toStore(this.editorConfiguration), editorConfiguration: DESCRIPTORS.editorConfiguration.toStore(this.editorConfiguration),
terminalCopyOnSelect: DESCRIPTORS.terminalCopyOnSelect.toStore(this.terminalCopyOnSelect),
}, },
}; };

View File

@ -33,8 +33,9 @@ export function getBundledKubectlVersion(): string {
export async function getAppVersionFromProxyServer(proxyPort: number): Promise<string> { export async function getAppVersionFromProxyServer(proxyPort: number): Promise<string> {
const response = await requestPromise({ const response = await requestPromise({
method: "GET", method: "GET",
uri: `http://localhost:${proxyPort}/version`, uri: `http://127.0.0.1:${proxyPort}/version`,
resolveWithFullResponse: true resolveWithFullResponse: true,
proxy: undefined,
}); });
return JSON.parse(response.body).version; return JSON.parse(response.body).version;

View File

@ -69,7 +69,7 @@ export const apiKubePrefix = "/api-kube" as string; // k8s cluster apis
// Links // Links
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as string; 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 supportUrl = "https://docs.k8slens.dev/latest/support/" as string;
export const appSemVer = new SemVer(packageInfo.version); export const appSemVer = new SemVer(packageInfo.version);

View File

@ -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. * @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 * @returns A function to clean up the filter
*/ */

View File

@ -16,9 +16,9 @@
"name": "OpenLens Authors" "name": "OpenLens Authors"
}, },
"dependencies": { "dependencies": {
"@types/node": "*", "@types/node": "14.17.14",
"@types/react-select": "*", "@types/react-select": "3.1.2",
"@material-ui/core": "*", "@material-ui/core": "4.12.3",
"conf": "^7.0.1", "conf": "^7.0.1",
"typed-emitter": "^1.3.1" "typed-emitter": "^1.3.1"
} }

View File

@ -25,7 +25,7 @@ import { Singleton } from "../../common/utils";
import { LensExtension } from "../lens-extension"; import { LensExtension } from "../lens-extension";
export class BaseRegistry<T, I = T> extends Singleton { 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() { constructor() {
super(); super();

View File

@ -25,9 +25,9 @@ import { BaseRegistry } from "./base-registry";
import type { LensExtension } from "../lens-extension"; import type { LensExtension } from "../lens-extension";
import type { CatalogEntity } from "../../common/catalog"; import type { CatalogEntity } from "../../common/catalog";
export type CommandContext = { export interface CommandContext {
entity?: CatalogEntity; entity?: CatalogEntity;
}; }
export interface CommandRegistration { export interface CommandRegistration {
id: string; id: string;

View File

@ -22,7 +22,8 @@
import type { CatalogCategory, CatalogEntity } from "../../common/catalog"; import type { CatalogCategory, CatalogEntity } from "../../common/catalog";
import { catalogEntityRegistry as registry } from "../../renderer/api/catalog-entity-registry"; 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 { catalogCategoryRegistry as catalogCategories } from "../../common/catalog/catalog-category-registry";
export class CatalogEntityRegistry { export class CatalogEntityRegistry {
@ -48,6 +49,18 @@ export class CatalogEntityRegistry {
getItemsForCategory<T extends CatalogEntity>(category: CatalogCategory): T[] { getItemsForCategory<T extends CatalogEntity>(category: CatalogCategory): T[] {
return registry.getItemsForCategory(category); 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(); export const catalogEntities = new CatalogEntityRegistry();

View File

@ -21,10 +21,17 @@
import fetchMock from "jest-fetch-mock"; import fetchMock from "jest-fetch-mock";
import configurePackages from "./common/configure-packages"; import configurePackages from "./common/configure-packages";
import { configure } from "mobx";
// setup default configuration for external npm-packages // setup default configuration for external npm-packages
configurePackages(); 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' // rewire global.fetch to call 'fetchMock'
fetchMock.enableMocks(); fetchMock.enableMocks();

View 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();
});
});
});

View File

@ -131,11 +131,11 @@ export class ClusterManager extends Singleton {
entity.spec.metrics.prometheus = prometheus; 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) { if (cluster.preferences.icon) {
entity.spec.icon ??= {}; entity.spec.icon ??= {};
entity.spec.icon.src = cluster.preferences.icon; entity.spec.icon.src = cluster.preferences.icon;
} else {
entity.spec.icon = null;
} }
catalogEntityRegistry.items.splice(index, 1, entity); catalogEntityRegistry.items.splice(index, 1, entity);

View File

@ -117,10 +117,10 @@ export class ContextHandler {
return `http://127.0.0.1:${this.kubeAuthProxy.port}${path}`; return `http://127.0.0.1:${this.kubeAuthProxy.port}${path}`;
} }
async getApiTarget(isWatchRequest = false): Promise<httpProxy.ServerOptions> { async getApiTarget(isLongRunningRequest = false): Promise<httpProxy.ServerOptions> {
const timeout = isWatchRequest ? 4 * 60 * 60 * 1000 : 30000; // 4 hours for watch request, 30 seconds for the rest 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); return this.newApiTarget(timeout);
} }

View File

@ -31,6 +31,7 @@ import { Singleton } from "../common/utils";
import type { Cluster } from "./cluster"; import type { Cluster } from "./cluster";
import type { ProxyApiRequestArgs } from "./proxy-functions"; import type { ProxyApiRequestArgs } from "./proxy-functions";
import { appEventBus } from "../common/event-bus"; import { appEventBus } from "../common/event-bus";
import { getBoolean } from "./utils/parse-query";
type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | null; type GetClusterForRequest = (req: http.IncomingMessage) => Cluster | null;
@ -40,6 +41,15 @@ export interface LensProxyFunctions {
kubeApiRequest: (args: ProxyApiRequestArgs) => void | Promise<void>; 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 { export class LensProxy extends Singleton {
protected origin: string; protected origin: string;
protected proxyServer: http.Server; protected proxyServer: http.Server;
@ -174,9 +184,8 @@ export class LensProxy extends Singleton {
if (req.url.startsWith(apiKubePrefix)) { if (req.url.startsWith(apiKubePrefix)) {
delete req.headers.authorization; delete req.headers.authorization;
req.url = req.url.replace(apiKubePrefix, ""); req.url = req.url.replace(apiKubePrefix, "");
const isWatchRequest = req.url.includes("watch=");
return contextHandler.getApiTarget(isWatchRequest); return contextHandler.getApiTarget(isLongRunningRequest(req.url));
} }
} }

View File

@ -19,19 +19,23 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * 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 { autorun } from "mobx";
import type { WindowManager } from "./window-manager"; 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 { MenuRegistry } from "../extensions/registries/menu-registry";
import logger from "./logger"; import logger from "./logger";
import { exitApp } from "./exit-app"; import { exitApp } from "./exit-app";
import { broadcastMessage, ipcMainOn } from "../common/ipc"; import { broadcastMessage } from "../common/ipc";
import * as packageJson from "../../package.json"; import * as packageJson from "../../package.json";
import { preferencesURL, extensionsURL, addClusterURL, catalogURL, welcomeURL } from "../common/routes"; import { preferencesURL, extensionsURL, addClusterURL, catalogURL, welcomeURL } from "../common/routes";
export type MenuTopId = "mac" | "file" | "edit" | "view" | "help"; export type MenuTopId = "mac" | "file" | "edit" | "view" | "help";
interface MenuItemsOpts extends MenuItemConstructorOptions {
submenu?: MenuItemConstructorOptions[];
}
export function initMenu(windowManager: WindowManager) { export function initMenu(windowManager: WindowManager) {
return autorun(() => buildMenu(windowManager), { return autorun(() => buildMenu(windowManager), {
delay: 100 delay: 100
@ -68,11 +72,13 @@ export function buildMenu(windowManager: WindowManager) {
await windowManager.navigate(url); await windowManager.navigate(url);
} }
const macAppMenu: MenuItemConstructorOptions = { const macAppMenu: MenuItemsOpts = {
label: app.getName(), label: app.getName(),
id: "root",
submenu: [ submenu: [
{ {
label: `About ${productName}`, label: `About ${productName}`,
id: "about",
click(menuItem: MenuItem, browserWindow: BrowserWindow) { click(menuItem: MenuItem, browserWindow: BrowserWindow) {
showAbout(browserWindow); showAbout(browserWindow);
} }
@ -81,6 +87,7 @@ export function buildMenu(windowManager: WindowManager) {
{ {
label: "Preferences", label: "Preferences",
accelerator: "CmdOrCtrl+,", accelerator: "CmdOrCtrl+,",
id: "preferences",
click() { click() {
navigate(preferencesURL()); navigate(preferencesURL());
}, },
@ -88,6 +95,7 @@ export function buildMenu(windowManager: WindowManager) {
{ {
label: "Extensions", label: "Extensions",
accelerator: "CmdOrCtrl+Shift+E", accelerator: "CmdOrCtrl+Shift+E",
id: "extensions",
click() { click() {
navigate(extensionsURL()); navigate(extensionsURL());
} }
@ -102,18 +110,21 @@ export function buildMenu(windowManager: WindowManager) {
{ {
label: "Quit", label: "Quit",
accelerator: "Cmd+Q", accelerator: "Cmd+Q",
id: "quit",
click() { click() {
exitApp(); exitApp();
} }
} }
], ],
}; };
const fileMenu: MenuItemConstructorOptions = { const fileMenu: MenuItemsOpts = {
label: "File", label: "File",
id: "file",
submenu: [ submenu: [
{ {
label: "Add Cluster", label: "Add Cluster",
accelerator: "CmdOrCtrl+Shift+A", accelerator: "CmdOrCtrl+Shift+A",
id: "add-cluster",
click() { click() {
navigate(addClusterURL()); navigate(addClusterURL());
} }
@ -122,6 +133,7 @@ export function buildMenu(windowManager: WindowManager) {
{ type: "separator" }, { type: "separator" },
{ {
label: "Preferences", label: "Preferences",
id: "preferences",
accelerator: "Ctrl+,", accelerator: "Ctrl+,",
click() { click() {
navigate(preferencesURL()); navigate(preferencesURL());
@ -150,6 +162,7 @@ export function buildMenu(windowManager: WindowManager) {
{ {
label: "Exit", label: "Exit",
accelerator: "Alt+F4", accelerator: "Alt+F4",
id: "quit",
click() { click() {
exitApp(); exitApp();
} }
@ -157,8 +170,9 @@ export function buildMenu(windowManager: WindowManager) {
]) ])
], ],
}; };
const editMenu: MenuItemConstructorOptions = { const editMenu: MenuItemsOpts = {
label: "Edit", label: "Edit",
id: "edit",
submenu: [ submenu: [
{ role: "undo" }, { role: "undo" },
{ role: "redo" }, { role: "redo" },
@ -171,12 +185,14 @@ export function buildMenu(windowManager: WindowManager) {
{ role: "selectAll" }, { role: "selectAll" },
] ]
}; };
const viewMenu: MenuItemConstructorOptions = { const viewMenu: MenuItemsOpts = {
label: "View", label: "View",
id: "view",
submenu: [ submenu: [
{ {
label: "Catalog", label: "Catalog",
accelerator: "Shift+CmdOrCtrl+C", accelerator: "Shift+CmdOrCtrl+C",
id: "catalog",
click() { click() {
navigate(catalogURL()); navigate(catalogURL());
} }
@ -184,6 +200,7 @@ export function buildMenu(windowManager: WindowManager) {
{ {
label: "Command Palette...", label: "Command Palette...",
accelerator: "Shift+CmdOrCtrl+P", accelerator: "Shift+CmdOrCtrl+P",
id: "command-palette",
click() { click() {
broadcastMessage("command-palette:open"); broadcastMessage("command-palette:open");
} }
@ -192,6 +209,7 @@ export function buildMenu(windowManager: WindowManager) {
{ {
label: "Back", label: "Back",
accelerator: "CmdOrCtrl+[", accelerator: "CmdOrCtrl+[",
id: "go-back",
click() { click() {
webContents.getAllWebContents().filter(wc => wc.getType() === "window").forEach(wc => wc.goBack()); webContents.getAllWebContents().filter(wc => wc.getType() === "window").forEach(wc => wc.goBack());
} }
@ -199,6 +217,7 @@ export function buildMenu(windowManager: WindowManager) {
{ {
label: "Forward", label: "Forward",
accelerator: "CmdOrCtrl+]", accelerator: "CmdOrCtrl+]",
id: "go-forward",
click() { click() {
webContents.getAllWebContents().filter(wc => wc.getType() === "window").forEach(wc => wc.goForward()); webContents.getAllWebContents().filter(wc => wc.getType() === "window").forEach(wc => wc.goForward());
} }
@ -206,6 +225,7 @@ export function buildMenu(windowManager: WindowManager) {
{ {
label: "Reload", label: "Reload",
accelerator: "CmdOrCtrl+R", accelerator: "CmdOrCtrl+R",
id: "reload",
click() { click() {
windowManager.reload(); windowManager.reload();
} }
@ -219,23 +239,27 @@ export function buildMenu(windowManager: WindowManager) {
{ role: "togglefullscreen" } { role: "togglefullscreen" }
] ]
}; };
const helpMenu: MenuItemConstructorOptions = { const helpMenu: MenuItemsOpts = {
role: "help", role: "help",
id: "help",
submenu: [ submenu: [
{ {
label: "Welcome", label: "Welcome",
id: "welcome",
click() { click() {
navigate(welcomeURL()); navigate(welcomeURL());
}, },
}, },
{ {
label: "Documentation", label: "Documentation",
id: "documentation",
click: async () => { click: async () => {
shell.openExternal(docsUrl); shell.openExternal(docsUrl);
}, },
}, },
{ {
label: "Support", label: "Support",
id: "support",
click: async () => { click: async () => {
shell.openExternal(supportUrl); shell.openExternal(supportUrl);
}, },
@ -243,6 +267,7 @@ export function buildMenu(windowManager: WindowManager) {
...ignoreOnMac([ ...ignoreOnMac([
{ {
label: `About ${productName}`, label: `About ${productName}`,
id: "about",
click(menuItem: MenuItem, browserWindow: BrowserWindow) { click(menuItem: MenuItem, browserWindow: BrowserWindow) {
showAbout(browserWindow); showAbout(browserWindow);
} }
@ -251,69 +276,28 @@ export function buildMenu(windowManager: WindowManager) {
] ]
}; };
// Prepare menu items order // Prepare menu items order
const appMenu: Record<MenuTopId, MenuItemConstructorOptions> = { const appMenu = new Map([
mac: macAppMenu, ["mac", macAppMenu],
file: fileMenu, ["file", fileMenu],
edit: editMenu, ["edit", editMenu],
view: viewMenu, ["view", viewMenu],
help: helpMenu, ["help", helpMenu],
}; ]);
// Modify menu from extensions-api // Modify menu from extensions-api
MenuRegistry.getInstance().getItems().forEach(({ parentId, ...menuItem }) => { for (const { parentId, ...menuItem } of MenuRegistry.getInstance().getItems()) {
try { if (!appMenu.has(parentId)) {
const topMenu = appMenu[parentId as MenuTopId].submenu as MenuItemConstructorOptions[]; logger.error(`[MENU]: cannot register menu item for parentId=${parentId}, parent item doesn't exist`, { menuItem });
topMenu.push(menuItem); continue;
} catch (err) { }
logger.error(`[MENU]: can't register menu item, parentId=${parentId}`, { menuItem });
appMenu.get(parentId).submenu.push(menuItem);
} }
});
if (!isMac) { if (!isMac) {
delete appMenu.mac; appMenu.delete("mac");
} }
const menu = Menu.buildFromTemplate(Object.values(appMenu)); Menu.setApplicationMenu(Menu.buildFromTemplate([...appMenu.values()]));
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();
});
}
} }

View File

@ -38,9 +38,9 @@ export class PrometheusOperator extends PrometheusProvider {
case "cluster": case "cluster":
switch (queryName) { switch (queryName) {
case "memoryUsage": 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": 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": case "memoryRequests":
return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="memory"})`; return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="memory"})`;
case "memoryLimits": case "memoryLimits":
@ -50,7 +50,7 @@ export class PrometheusOperator extends PrometheusProvider {
case "memoryAllocatableCapacity": case "memoryAllocatableCapacity":
return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="memory"})`; return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="memory"})`;
case "cpuUsage": 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": case "cpuRequests":
return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"})`; return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="cpu"})`;
case "cpuLimits": case "cpuLimits":
@ -66,61 +66,61 @@ export class PrometheusOperator extends PrometheusProvider {
case "podAllocatableCapacity": case "podAllocatableCapacity":
return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="pods"})`; return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="pods"})`;
case "fsSize": 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": 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; break;
case "nodes": case "nodes":
switch (queryName) { switch (queryName) {
case "memoryUsage": 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": 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": case "memoryCapacity":
return `sum(kube_node_status_capacity{resource="memory"}) by (node)`; return `sum(kube_node_status_capacity{resource="memory"}) by (node)`;
case "memoryAllocatableCapacity": case "memoryAllocatableCapacity":
return `sum(kube_node_status_allocatable{resource="memory"}) by (node)`; return `sum(kube_node_status_allocatable{resource="memory"}) by (node)`;
case "cpuUsage": 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": case "cpuCapacity":
return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`;
case "cpuAllocatableCapacity": case "cpuAllocatableCapacity":
return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`;
case "fsSize": 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": 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; break;
case "pods": case "pods":
switch (queryName) { switch (queryName) {
case "cpuUsage": 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": 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": 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": 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": 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": 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": 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": 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": 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; break;
case "pvc": case "pvc":
switch (queryName) { switch (queryName) {
case "diskUsage": 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": 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; break;
case "ingress": case "ingress":
@ -130,9 +130,9 @@ export class PrometheusOperator extends PrometheusProvider {
case "bytesSentFailure": case "bytesSentFailure":
return this.bytesSent(opts.ingress, opts.namespace, "^5\\\\d*"); return this.bytesSent(opts.ingress, opts.namespace, "^5\\\\d*");
case "requestDurationSeconds": 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": 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; break;
} }

View File

@ -126,7 +126,7 @@ describe("protocol router tests", () => {
} }
await delay(50); 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"); expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerExtension, "lens://extension/@mirantis/minikube", "matched");
}); });

View File

@ -22,55 +22,96 @@
import type { Cluster } from "./cluster"; import type { Cluster } from "./cluster";
import type { KubernetesObject } from "@kubernetes/client-node"; import type { KubernetesObject } from "@kubernetes/client-node";
import { exec } from "child_process"; import { exec } from "child_process";
import fs from "fs"; import fs from "fs-extra";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import path from "path"; import path from "path";
import * as tempy from "tempy"; import * as tempy from "tempy";
import logger from "./logger"; import logger from "./logger";
import { appEventBus } from "../common/event-bus"; import { appEventBus } from "../common/event-bus";
import { cloneJsonObject } from "../common/utils"; import { cloneJsonObject } from "../common/utils";
import type { Patch } from "rfc6902";
import { promiseExecFile } from "./promise-exec";
export class ResourceApplier { 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> { async apply(resource: KubernetesObject | any): Promise<string> {
resource = this.sanitizeObject(resource); resource = this.sanitizeObject(resource);
appEventBus.emit({ name: "resource", action: "apply" }); 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> { protected async kubectlApply(content: string): Promise<string> {
const kubectl = await this.cluster.ensureKubectl(); const kubectl = await this.cluster.ensureKubectl();
const kubectlPath = await kubectl.getPath(); const kubectlPath = await kubectl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
return new Promise<string>((resolve, reject) => {
const fileName = tempy.file({ name: "resource.yaml" }); const fileName = tempy.file({ name: "resource.yaml" });
const args = [
"apply",
"--kubeconfig", proxyKubeconfigPath,
"-o", "json",
"-f", fileName,
];
fs.writeFileSync(fileName, content); logger.debug(`shooting manifests with ${kubectlPath}`, { args });
const cmd = `"${kubectlPath}" apply --kubeconfig "${proxyKubeconfigPath}" -o json -f "${fileName}"`;
logger.debug(`shooting manifests with: ${cmd}`); const execEnv = { ...process.env };
const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env);
const httpsProxy = this.cluster.preferences?.httpsProxy; const httpsProxy = this.cluster.preferences?.httpsProxy;
if (httpsProxy) { if (httpsProxy) {
execEnv["HTTPS_PROXY"] = httpsProxy; execEnv.HTTPS_PROXY = httpsProxy;
} }
exec(cmd, { env: execEnv },
(error, stdout, stderr) => {
if (stderr != "") {
fs.unlinkSync(fileName);
reject(stderr);
return; try {
await fs.writeFile(fileName, content);
const { stdout } = await promiseExecFile(kubectlPath, args);
return stdout;
} catch (error) {
throw error?.stderr ?? error;
} finally {
await fs.unlink(fileName);
} }
fs.unlinkSync(fileName);
resolve(JSON.parse(stdout));
});
});
} }
public async kubectlApplyAll(resources: string[], extraArgs = ["-o", "json"]): Promise<string> { public async kubectlApplyAll(resources: string[], extraArgs = ["-o", "json"]): Promise<string> {

View File

@ -179,8 +179,11 @@ export class Router {
this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, MetricsRoute.routeMetrics); this.router.add({ method: "post", path: `${apiPrefix}/metrics` }, MetricsRoute.routeMetrics);
this.router.add({ method: "get", path: `${apiPrefix}/metrics/providers` }, MetricsRoute.routeMetricsProviders); this.router.add({ method: "get", path: `${apiPrefix}/metrics/providers` }, MetricsRoute.routeMetricsProviders);
// Port-forward API // Port-forward API (the container port and local forwarding port are obtained from the query parameters)
this.router.add({ method: "post", path: `${apiPrefix}/pods/{namespace}/{resourceType}/{resourceName}/port-forward/{port}` }, PortForwardRoute.routePortForward); 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 // Helm API
this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, HelmApiRoute.listCharts); this.router.add({ method: "get", path: `${apiPrefix}/v2/charts` }, HelmApiRoute.listCharts);
@ -198,5 +201,6 @@ export class Router {
// Resource Applier API // Resource Applier API
this.router.add({ method: "post", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.applyResource); this.router.add({ method: "post", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.applyResource);
this.router.add({ method: "patch", path: `${apiPrefix}/stack` }, ResourceApplierApiRoute.patchResource);
} }
} }

View File

@ -23,7 +23,7 @@ import type { LensApiRequest } from "../router";
import { helmService } from "../helm/helm-service"; import { helmService } from "../helm/helm-service";
import logger from "../logger"; import logger from "../logger";
import { respondJson, respondText } from "../utils/http-responses"; import { respondJson, respondText } from "../utils/http-responses";
import { getBoolean } from "./utils/parse-query"; import { getBoolean } from "../utils/parse-query";
export class HelmApiRoute { export class HelmApiRoute {
static async listCharts(request: LensApiRequest) { static async listCharts(request: LensApiRequest) {

View File

@ -22,7 +22,6 @@
import type { LensApiRequest } from "../router"; import type { LensApiRequest } from "../router";
import { spawn, ChildProcessWithoutNullStreams } from "child_process"; import { spawn, ChildProcessWithoutNullStreams } from "child_process";
import { Kubectl } from "../kubectl"; import { Kubectl } from "../kubectl";
import { shell } from "electron";
import * as tcpPortUsed from "tcp-port-used"; import * as tcpPortUsed from "tcp-port-used";
import logger from "../logger"; import logger from "../logger";
import { getPortFrom } from "../utils/get-port"; import { getPortFrom } from "../utils/get-port";
@ -33,7 +32,8 @@ interface PortForwardArgs {
kind: string; kind: string;
namespace: string; namespace: string;
name: string; name: string;
port: string; port: number;
forwardPort: number;
} }
const internalPortRegex = /^forwarding from (?<address>.+) ->/i; const internalPortRegex = /^forwarding from (?<address>.+) ->/i;
@ -56,8 +56,8 @@ class PortForward {
public kind: string; public kind: string;
public namespace: string; public namespace: string;
public name: string; public name: string;
public port: string; public port: number;
public internalPort?: number; public forwardPort: number;
constructor(public kubeConfig: string, args: PortForwardArgs) { constructor(public kubeConfig: string, args: PortForwardArgs) {
this.clusterId = args.clusterId; this.clusterId = args.clusterId;
@ -65,16 +65,17 @@ class PortForward {
this.namespace = args.namespace; this.namespace = args.namespace;
this.name = args.name; this.name = args.name;
this.port = args.port; this.port = args.port;
this.forwardPort = args.forwardPort;
} }
public async start() { public async start() {
const kubectlBin = await Kubectl.bundled().getPath(); const kubectlBin = await Kubectl.bundled().getPath(true);
const args = [ const args = [
"--kubeconfig", this.kubeConfig, "--kubeconfig", this.kubeConfig,
"port-forward", "port-forward",
"-n", this.namespace, "-n", this.namespace,
`${this.kind}/${this.name}`, `${this.kind}/${this.name}`,
`:${this.port}` `${this.forwardPort ?? ""}:${this.port}`
]; ];
this.process = spawn(kubectlBin, args, { 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, lineRegex: internalPortRegex,
}); });
try { 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; return true;
} catch (error) { } catch (error) {
@ -104,27 +108,30 @@ class PortForward {
} }
} }
public open() { public async stop() {
shell.openExternal(`http://localhost:${this.internalPort}`) this.process.kill();
.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,
}));
} }
} }
export class PortForwardRoute { export class PortForwardRoute {
static async routePortForward(request: LensApiRequest) { static async routePortForward(request: LensApiRequest) {
const { params, response, cluster} = request; const { params, query, response, cluster } = request;
const { namespace, port, resourceType, resourceName } = params; const { namespace, resourceType, resourceName } = params;
const port = Number(query.get("port"));
const forwardPort = Number(query.get("forwardPort"));
try {
let portForward = PortForward.getPortforward({ let portForward = PortForward.getPortforward({
clusterId: cluster.id, kind: resourceType, name: resourceName, clusterId: cluster.id, kind: resourceType, name: resourceName,
namespace, port namespace, port, forwardPort,
}); });
let thePort = 0;
if (forwardPort > 0 && forwardPort < 65536) {
thePort = forwardPort;
}
if (!portForward) { if (!portForward) {
logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`); logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`);
portForward = new PortForward(await cluster.getProxyKubeconfigPath(), { portForward = new PortForward(await cluster.getProxyKubeconfigPath(), {
@ -133,29 +140,81 @@ export class PortForwardRoute {
namespace, namespace,
name: resourceName, name: resourceName,
port, port,
forwardPort: thePort,
}); });
try {
const started = await portForward.start(); const started = await portForward.start();
if (!started) { 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, { return respondJson(response, {
message: "Failed to open port-forward" message: `Failed to forward port ${port} to ${thePort ? forwardPort : "random port"}`
}, 400); }, 400);
} }
}
respondJson(response, { port: portForward.forwardPort });
} catch (error) { } catch (error) {
logger.warn(`[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, { namespace, port, resourceType, resourceName }); logger.error(`[PORT-FORWARD-ROUTE]: failed to open a port-forward: ${error}`, { namespace, port, resourceType, resourceName });
return respondJson(response, { return respondJson(response, {
message: error?.toString() || "Failed to open port-forward", message: `Failed to forward port ${port}`
}, 400); }, 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);
}
} }
} }

View File

@ -30,7 +30,19 @@ export class ResourceApplierApiRoute {
try { try {
const resource = await new ResourceApplier(cluster).apply(payload); 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) { } catch (error) {
respondText(response, error, 422); respondText(response, error, 422);
} }

View File

@ -21,14 +21,37 @@
import type http from "http"; 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) { export function respondText(res: http.ServerResponse, content: string, status = 200) {
respond(res, content, "text/plain", status); 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) { export function respond(res: http.ServerResponse, content: string, contentType: string, status = 200) {
res.setHeader("Content-Type", contentType); res.setHeader("Content-Type", contentType);
res.statusCode = status; res.statusCode = status;

View File

@ -31,7 +31,6 @@ import { IpcRendererNavigationEvents } from "../renderer/navigation/events";
import logger from "./logger"; import logger from "./logger";
import { productName } from "../common/vars"; import { productName } from "../common/vars";
import { LensProxy } from "./lens-proxy"; import { LensProxy } from "./lens-proxy";
import * as path from "path";
function isHideable(window: BrowserWindow | null): boolean { function isHideable(window: BrowserWindow | null): boolean {
return Boolean(window && !window.isDestroyed()); return Boolean(window && !window.isDestroyed());
@ -85,7 +84,6 @@ export class WindowManager extends Singleton {
titleBarStyle: "hiddenInset", titleBarStyle: "hiddenInset",
backgroundColor: "#1e2124", backgroundColor: "#1e2124",
webPreferences: { webPreferences: {
preload: path.join(__static, "build", "preload.js"),
nodeIntegration: true, nodeIntegration: true,
nodeIntegrationInSubFrames: true, nodeIntegrationInSubFrames: true,
enableRemoteModule: true, enableRemoteModule: true,
@ -111,10 +109,6 @@ export class WindowManager extends Singleton {
app.dock?.hide(); // hide icon in dock (mac-os) app.dock?.hide(); // hide icon in dock (mac-os)
}) })
.webContents .webContents
.on("new-window", (event, url) => {
event.preventDefault();
shell.openExternal(url);
})
.on("dom-ready", () => { .on("dom-ready", () => {
appEventBus.emit({ name: "app", action: "dom-ready" }); appEventBus.emit({ name: "app", action: "dom-ready" });
}) })
@ -152,6 +146,10 @@ export class WindowManager extends Singleton {
// Always disable Node.js integration for all webviews // Always disable Node.js integration for all webviews
webPreferences.nodeIntegration = false; webPreferences.nodeIntegration = false;
}).setWindowOpenHandler((details) => {
shell.openExternal(details.url);
return { action: "deny" };
}); });
} }

View File

@ -19,12 +19,12 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import { app } from "electron";
import fse from "fs-extra"; import fse from "fs-extra";
import path from "path"; import path from "path";
import { getPath } from "../../common/utils/getPath";
export function fileNameMigration() { export function fileNameMigration() {
const userDataPath = getPath("userData"); const userDataPath = app.getPath("userData");
const configJsonPath = path.join(userDataPath, "config.json"); const configJsonPath = path.join(userDataPath, "config.json");
const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json"); const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json");

View File

@ -27,15 +27,22 @@ import type { Cluster } from "../../main/cluster";
import { ClusterStore } from "../../common/cluster-store"; import { ClusterStore } from "../../common/cluster-store";
import { Disposer, iter } from "../utils"; import { Disposer, iter } from "../utils";
import { once } from "lodash"; 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 EntityFilter = (entity: CatalogEntity) => any;
export type CatalogEntityOnBeforeRun = (event: CatalogRunEvent) => void | Promise<void>;
export class CatalogEntityRegistry { export class CatalogEntityRegistry {
@observable.ref activeEntity: CatalogEntity; @observable protected activeEntityId: string | undefined = undefined;
protected _entities = observable.map<string, CatalogEntity>([], { deep: true }); protected _entities = observable.map<string, CatalogEntity>([], { deep: true });
protected filters = observable.set<EntityFilter>([], { protected filters = observable.set<EntityFilter>([], {
deep: false, deep: false,
}); });
protected onBeforeRunHooks = observable.set<CatalogEntityOnBeforeRun>([], {
deep: false,
});
/** /**
* Buffer for keeping entities that don't yet have CatalogCategory synced * Buffer for keeping entities that don't yet have CatalogCategory synced
@ -46,6 +53,22 @@ export class CatalogEntityRegistry {
makeObservable(this); makeObservable(this);
} }
get activeEntity(): CatalogEntity | null {
return this._entities.get(this.activeEntityId) || null;
}
set activeEntity(raw: CatalogEntity | string | null) {
if (raw) {
const id = typeof raw === "string"
? raw
: raw.getId();
this.activeEntityId = id;
} else {
this.activeEntityId = undefined;
}
}
init() { init() {
ipcRendererOn("catalog:items", (event, items: (CatalogEntityData & CatalogEntityKindData)[]) => { ipcRendererOn("catalog:items", (event, items: (CatalogEntityData & CatalogEntityKindData)[]) => {
this.updateItems(items); this.updateItems(items);
@ -153,6 +176,60 @@ export class CatalogEntityRegistry {
return once(() => void this.filters.delete(fn)); 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); export const catalogEntityRegistry = new CatalogEntityRegistry(catalogCategoryRegistry);

View File

@ -29,7 +29,7 @@ import * as ReactRouterDom from "react-router-dom";
import * as LensExtensionsCommonApi from "../extensions/common-api"; import * as LensExtensionsCommonApi from "../extensions/common-api";
import * as LensExtensionsRendererApi from "../extensions/renderer-api"; import * as LensExtensionsRendererApi from "../extensions/renderer-api";
import { monaco } from "react-monaco-editor"; import { monaco } from "react-monaco-editor";
import { render, unmountComponentAtNode } from "react-dom"; import { render } from "react-dom";
import { delay } from "../common/utils"; import { delay } from "../common/utils";
import { isMac, isDevelopment } from "../common/vars"; import { isMac, isDevelopment } from "../common/vars";
import { ClusterStore } from "../common/cluster-store"; import { ClusterStore } from "../common/cluster-store";
@ -66,7 +66,7 @@ async function attachChromeDebugger() {
} }
type AppComponent = React.ComponentType & { type AppComponent = React.ComponentType & {
init?(): Promise<void>; init?(rootElem: HTMLElement): Promise<void>;
}; };
export async function bootstrap(App: AppComponent) { export async function bootstrap(App: AppComponent) {
@ -125,16 +125,8 @@ export async function bootstrap(App: AppComponent) {
// init app's dependencies if any // init app's dependencies if any
if (App.init) { 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(<> render(<>
{isMac && <div id="draggable-top" />} {isMac && <div id="draggable-top" />}
{DefaultProps(App)} {DefaultProps(App)}

View File

@ -50,11 +50,4 @@
display: block; display: block;
padding-top: 6px; padding-top: 6px;
} }
.actions-panel {
.Spinner {
vertical-align: middle;
margin-left: $spacing;
}
}
} }

View File

@ -24,7 +24,7 @@ import "./add-cluster.scss";
import type { KubeConfig } from "@kubernetes/client-node"; import type { KubeConfig } from "@kubernetes/client-node";
import fse from "fs-extra"; import fse from "fs-extra";
import { debounce } from "lodash"; import { debounce } from "lodash";
import { action, computed, observable, makeObservable, runInAction } from "mobx"; import { action, computed, observable, makeObservable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import path from "path"; import path from "path";
import React from "react"; import React from "react";
@ -41,7 +41,6 @@ import { SettingLayout } from "../layout/setting-layout";
import MonacoEditor from "react-monaco-editor"; import MonacoEditor from "react-monaco-editor";
import { ThemeStore } from "../../theme.store"; import { ThemeStore } from "../../theme.store";
import { UserStore } from "../../../common/user-store"; import { UserStore } from "../../../common/user-store";
import { Spinner } from "../spinner";
interface Option { interface Option {
config: KubeConfig; config: KubeConfig;
@ -63,8 +62,7 @@ export class AddCluster extends React.Component {
@observable kubeContexts = observable.map<string, Option>(); @observable kubeContexts = observable.map<string, Option>();
@observable customConfig = ""; @observable customConfig = "";
@observable isWaiting = false; @observable isWaiting = false;
@observable isCheckingInput = false; @observable errors: string[] = [];
@observable errorText: string;
constructor(props: {}) { constructor(props: {}) {
super(props); super(props);
@ -77,39 +75,25 @@ export class AddCluster extends React.Component {
@computed get allErrors(): string[] { @computed get allErrors(): string[] {
return [ return [
this.errorText, ...this.errors,
...iter.map(this.kubeContexts.values(), ({ error }) => error) ...iter.map(this.kubeContexts.values(), ({ error }) => error)
].filter(Boolean); ].filter(Boolean);
} }
_refreshContexts = debounce(() => { @action
runInAction(() => { refreshContexts = debounce(() => {
try { const { config, error } = loadConfigFromString(this.customConfig.trim() || "{}");
const text = this.customConfig.trim();
if (!text) {
return this.kubeContexts.clear();
}
const { config, error } = loadConfigFromString(text);
this.kubeContexts.replace(getContexts(config)); this.kubeContexts.replace(getContexts(config));
this.errorText = error?.toString();
} catch (error) {
this.kubeContexts.clear();
this.errorText = error?.toString() || "An error occured";
} finally {
this.isCheckingInput = false;
}
});
}, 500);
refreshContexts = () => { if (error) {
// Clear the kubeContexts immediately this.errors.push(error.toString());
this.isCheckingInput = true; }
this.kubeContexts.clear();
this._refreshContexts(); if (config.contexts.length === 0) {
}; this.errors.push('No contexts defined, either missing the "contexts" field, or it is empty.');
}
}, 500);
@action @action
addClusters = async () => { addClusters = async () => {
@ -147,7 +131,7 @@ export class AddCluster extends React.Component {
value={this.customConfig} value={this.customConfig}
onChange={value => { onChange={value => {
this.customConfig = value; this.customConfig = value;
this.errorText = ""; this.errors.length = 0;
this.refreshContexts(); this.refreshContexts();
}} }}
/> />
@ -168,7 +152,6 @@ export class AddCluster extends React.Component {
tooltip={this.kubeContexts.size === 0 || "Paste in at least one cluster to add."} tooltip={this.kubeContexts.size === 0 || "Paste in at least one cluster to add."}
tooltipOverrideDisabled tooltipOverrideDisabled
/> />
{this.isCheckingInput && <Spinner />}
</div> </div>
</SettingLayout> </SettingLayout>
); );

View File

@ -59,7 +59,7 @@ export class ReleaseDetails extends Component<Props> {
@observable details: IReleaseDetails; @observable details: IReleaseDetails;
@observable values = ""; @observable values = "";
@observable valuesLoading = false; @observable valuesLoading = false;
@observable showOnlyUserSuppliedValues = false; @observable showOnlyUserSuppliedValues = true;
@observable saving = false; @observable saving = false;
@observable releaseSecret: Secret; @observable releaseSecret: Secret;
@ -165,7 +165,7 @@ export class ReleaseDetails extends Component<Props> {
onChange={text => this.values = text} onChange={text => this.values = text}
theme={ThemeStore.getInstance().activeTheme.monacoTheme} theme={ThemeStore.getInstance().activeTheme.monacoTheme}
className={cssNames("MonacoEditor", {loading: valuesLoading})} className={cssNames("MonacoEditor", {loading: valuesLoading})}
options={{readOnly: valuesLoading || this.showOnlyUserSuppliedValues, ...UserStore.getInstance().getEditorOptions()}} options={{readOnly: valuesLoading, ...UserStore.getInstance().getEditorOptions()}}
> >
{valuesLoading && <Spinner center />} {valuesLoading && <Spinner center />}
</MonacoEditor> </MonacoEditor>

View File

@ -23,13 +23,12 @@ import "./catalog-entity-details.scss";
import React, { Component } from "react"; import React, { Component } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Drawer, DrawerItem } from "../drawer"; import { Drawer, DrawerItem } from "../drawer";
import { catalogEntityRunContext } from "../../api/catalog-entity";
import type { CatalogCategory, CatalogEntity } from "../../../common/catalog"; import type { CatalogCategory, CatalogEntity } from "../../../common/catalog";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu"; import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu";
import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; import { CatalogEntityDetailRegistry } from "../../../extensions/registries";
import { HotbarIcon } from "../hotbar/hotbar-icon"; 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"; import { isDevelopment } from "../../../common/vars";
interface Props<T extends CatalogEntity> { interface Props<T extends CatalogEntity> {
@ -68,8 +67,10 @@ export class CatalogEntityDetails<T extends CatalogEntity> extends Component<Pro
material={item.entity.spec.icon?.material} material={item.entity.spec.icon?.material}
background={item.entity.spec.icon?.background} background={item.entity.spec.icon?.background}
disabled={!item?.enabled} disabled={!item?.enabled}
onClick={() => item.onRun(catalogEntityRunContext)} onClick={() => item.onRun()}
size={128} /> size={128}
data-testid="detail-panel-hot-bar-icon"
/>
{item?.enabled && ( {item?.enabled && (
<div className="IconHint"> <div className="IconHint">
Click to open Click to open

View File

@ -30,7 +30,7 @@ import { MenuItem } from "../menu";
import { ConfirmDialog } from "../confirm-dialog"; import { ConfirmDialog } from "../confirm-dialog";
import { HotbarStore } from "../../../common/hotbar-store"; import { HotbarStore } from "../../../common/hotbar-store";
import { Icon } from "../icon"; 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 { export interface CatalogEntityDrawerMenuProps<T extends CatalogEntity> extends MenuActionsProps {
item: CatalogEntityItem<T> | null | undefined; item: CatalogEntityItem<T> | null | undefined;

View 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);
}
}

View File

@ -19,106 +19,16 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import styles from "./catalog.module.css"; import { computed, makeObservable, observable, reaction } from "mobx";
import { catalogEntityRegistry, CatalogEntityRegistry } from "../../api/catalog-entity-registry";
import React from "react"; import type { CatalogEntity } from "../../api/catalog-entity";
import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from "mobx"; import { ItemStore } from "../../../common/item.store";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import type { CatalogEntity, CatalogEntityActionContext } from "../../api/catalog-entity";
import { ItemObject, ItemStore } from "../../../common/item.store";
import { CatalogCategory, catalogCategoryRegistry } from "../../../common/catalog"; import { CatalogCategory, catalogCategoryRegistry } from "../../../common/catalog";
import { autoBind } from "../../../common/utils"; import { autoBind, disposer } from "../../../common/utils";
import { Badge } from "../badge"; import { CatalogEntityItem } from "./catalog-entity-item";
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);
}
}
export class CatalogEntityStore extends ItemStore<CatalogEntityItem<CatalogEntity>> { export class CatalogEntityStore extends ItemStore<CatalogEntityItem<CatalogEntity>> {
constructor() { constructor(private registry: CatalogEntityRegistry = catalogEntityRegistry) {
super(); super();
makeObservable(this); makeObservable(this);
autoBind(this); autoBind(this);
@ -129,10 +39,10 @@ export class CatalogEntityStore extends ItemStore<CatalogEntityItem<CatalogEntit
@computed get entities() { @computed get entities() {
if (!this.activeCategory) { 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() { @computed get selectedItem() {
@ -140,12 +50,10 @@ export class CatalogEntityStore extends ItemStore<CatalogEntityItem<CatalogEntit
} }
watch() { watch() {
const disposers: IReactionDisposer[] = [ return disposer(
reaction(() => this.entities, () => this.loadAll()), reaction(() => this.entities, () => this.loadAll()),
reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100}) reaction(() => this.activeCategory, () => this.loadAll(), { delay: 100}),
]; );
return () => disposers.forEach((dispose) => dispose());
} }
loadAll() { loadAll() {

View File

@ -41,11 +41,15 @@ function getCategories() {
} }
function getCategoryIcon(category: CatalogCategory) { function getCategoryIcon(category: CatalogCategory) {
if (!category.metadata?.icon) return null; const { icon } = category.metadata ?? {};
return category.metadata.icon.includes("<svg") if (typeof icon === "string") {
? <Icon small svg={category.metadata.icon}/> return icon.includes("<svg")
: <Icon small material={category.metadata.icon}/>; ? <Icon small svg={icon}/>
: <Icon small material={icon}/>;
}
return null;
} }
function Item(props: TreeItemProps) { function Item(props: TreeItemProps) {

View 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"));
});
});

View File

@ -25,7 +25,8 @@ import React from "react";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { ItemListLayout } from "../item-object-list"; import { ItemListLayout } from "../item-object-list";
import { action, makeObservable, observable, reaction, runInAction, when } from "mobx"; 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 { navigate } from "../../navigation";
import { MenuItem, MenuActions } from "../menu"; import { MenuItem, MenuActions } from "../menu";
import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
@ -39,12 +40,12 @@ import { MainLayout } from "../layout/main-layout";
import { createAppStorage, cssNames } from "../../utils"; import { createAppStorage, cssNames } from "../../utils";
import { makeCss } from "../../../common/utils/makeCss"; import { makeCss } from "../../../common/utils/makeCss";
import { CatalogEntityDetails } from "./catalog-entity-details"; 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 { CatalogMenu } from "./catalog-menu";
import { HotbarIcon } from "../hotbar/hotbar-icon"; import { HotbarIcon } from "../hotbar/hotbar-icon";
import { RenderDelay } from "../render-delay/render-delay"; 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 { enum sortBy {
name = "name", name = "name",
@ -55,7 +56,10 @@ enum sortBy {
const css = makeCss(styles); const css = makeCss(styles);
interface Props extends RouteComponentProps<CatalogViewRouteParam> {} interface Props extends RouteComponentProps<CatalogViewRouteParam> {
catalogEntityStore?: CatalogEntityStore;
}
@observer @observer
export class Catalog extends React.Component<Props> { export class Catalog extends React.Component<Props> {
@observable private catalogEntityStore?: CatalogEntityStore; @observable private catalogEntityStore?: CatalogEntityStore;
@ -65,8 +69,11 @@ export class Catalog extends React.Component<Props> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
makeObservable(this); makeObservable(this);
this.catalogEntityStore = new CatalogEntityStore(); this.catalogEntityStore = props.catalogEntityStore;
} }
static defaultProps = {
catalogEntityStore: new CatalogEntityStore(),
};
get routeActiveTab(): string { get routeActiveTab(): string {
const { group, kind } = this.props.match.params ?? {}; const { group, kind } = this.props.match.params ?? {};
@ -75,7 +82,7 @@ export class Catalog extends React.Component<Props> {
return `${group}/${kind}`; return `${group}/${kind}`;
} }
return ""; return browseCatalogTab;
} }
async componentDidMount() { async componentDidMount() {
@ -89,7 +96,7 @@ export class Catalog extends React.Component<Props> {
previousActiveTab.set(this.routeActiveTab); previousActiveTab.set(this.routeActiveTab);
try { 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); const item = catalogCategoryRegistry.filteredItems.find(i => i.getId() === routeTab);
runInAction(() => { runInAction(() => {
@ -117,15 +124,16 @@ export class Catalog extends React.Component<Props> {
} }
})); }));
} }
addToHotbar(item: CatalogEntityItem<CatalogEntity>): void { addToHotbar(item: CatalogEntityItem<CatalogEntity>): void {
HotbarStore.getInstance().addToHotbar(item.entity); HotbarStore.getInstance().addToHotbar(item.entity);
} }
onDetails = (item: CatalogEntityItem<CatalogEntity>) => { onDetails = (item: CatalogEntityItem<CatalogEntity>) => {
if (this.catalogEntityStore.selectedItemId === item.getId()) { if (this.catalogEntityStore.selectedItemId) {
this.catalogEntityStore.selectedItemId = null; this.catalogEntityStore.selectedItemId = null;
} else { } else {
this.catalogEntityStore.selectedItemId = item.getId(); item.onRun();
} }
}; };
@ -157,7 +165,7 @@ export class Catalog extends React.Component<Props> {
if (activeCategory) { if (activeCategory) {
navigate(catalogURL({ params: {group: activeCategory.spec.group, kind: activeCategory.spec.names.kind }})); navigate(catalogURL({ params: {group: activeCategory.spec.group, kind: activeCategory.spec.names.kind }}));
} else { } else {
navigate(catalogURL()); navigate(catalogURL({ params: { group: browseCatalogTab }}));
} }
}; };
@ -176,6 +184,9 @@ export class Catalog extends React.Component<Props> {
return ( return (
<MenuActions onOpen={onOpen}> <MenuActions onOpen={onOpen}>
<MenuItem key="open-details" onClick={() => this.catalogEntityStore.selectedItemId = item.getId()}>
View Details
</MenuItem>
{ {
this.contextMenu.menuItems.map((menuItem, index) => ( this.contextMenu.menuItems.map((menuItem, index) => (
<MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}> <MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}>

View File

@ -72,6 +72,8 @@ export class ConfigMapDetails extends React.Component<Props> {
<>ConfigMap <b>{configMap.getName()}</b> successfully updated.</> <>ConfigMap <b>{configMap.getName()}</b> successfully updated.</>
</p> </p>
); );
} catch (error) {
Notifications.error(`Failed to save config map: ${error}`);
} finally { } finally {
this.isSaving = false; this.isSaving = false;
} }

View File

@ -0,0 +1,30 @@
/**
* 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.
*/
.entityName {
@apply font-bold overflow-hidden;
word-break: break-word;
color: var(--textColorAccent);
display: -webkit-box;
/* Simulate text-overflow:ellipsis styles but for multiple text lines */
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
}

View File

@ -19,7 +19,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./entity-settings.scss"; import styles from "./entity-settings.module.css";
import React from "react"; import React from "react";
import { observable, makeObservable } from "mobx"; import { observable, makeObservable } from "mobx";
@ -98,7 +98,9 @@ export class EntitySettings extends React.Component<Props> {
source={this.entity.metadata.source} source={this.entity.metadata.source}
src={this.entity.spec.icon?.src} src={this.entity.spec.icon?.src}
/> />
<h2>{this.entity.metadata.name}</h2> <div className={styles.entityName}>
{this.entity.metadata.name}
</div>
</div> </div>
<Tabs className="flex column" scrollable={false} onChange={this.onTabChange} value={this.activeTab}> <Tabs className="flex column" scrollable={false} onChange={this.onTabChange} value={this.activeTab}>
{ groups.map((group, groupIndex) => ( { groups.map((group, groupIndex) => (
@ -138,7 +140,6 @@ export class EntitySettings extends React.Component<Props> {
return ( return (
<SettingLayout <SettingLayout
className="CatalogEntitySettings"
navigation={this.renderNavigation()} navigation={this.renderNavigation()}
contentGaps={false} contentGaps={false}
> >

View File

@ -26,6 +26,7 @@ import { observer } from "mobx-react";
import type { KubeObject } from "../../../common/k8s-api/kube-object"; import type { KubeObject } from "../../../common/k8s-api/kube-object";
import { DrawerItem, DrawerTitle } from "../drawer"; import { DrawerItem, DrawerTitle } from "../drawer";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { LocaleDate } from "../locale-date";
import { eventStore } from "./event.store"; import { eventStore } from "./event.store";
export interface KubeEventDetailsProps { export interface KubeEventDetailsProps {
@ -74,7 +75,7 @@ export class KubeEventDetails extends React.Component<KubeEventDetailsProps> {
{involvedObject.fieldPath} {involvedObject.fieldPath}
</DrawerItem> </DrawerItem>
<DrawerItem name="Last seen"> <DrawerItem name="Last seen">
{lastTimestamp} <LocaleDate date={lastTimestamp} />
</DrawerItem> </DrawerItem>
</div> </div>
); );

View File

@ -18,4 +18,5 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * 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. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
export * from "./parse-query";
export * from "./port-forwards";

View File

@ -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>
);
}
}

View File

@ -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;
}
}
}

View 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)
})}
/>
</>
);
}
}

View File

@ -32,6 +32,7 @@ import { ServicePortComponent } from "./service-port-component";
import { endpointStore } from "../+network-endpoints/endpoints.store"; import { endpointStore } from "../+network-endpoints/endpoints.store";
import { ServiceDetailsEndpoint } from "./service-details-endpoint"; import { ServiceDetailsEndpoint } from "./service-details-endpoint";
import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api";
import { portForwardStore } from "../../port-forward";
interface Props extends KubeObjectDetailsProps<Service> { interface Props extends KubeObjectDetailsProps<Service> {
} }
@ -46,6 +47,7 @@ export class ServiceDetails extends React.Component<Props> {
preload: true, preload: true,
namespaces: [service.getNs()], namespaces: [service.getNs()],
}), }),
portForwardStore.watch(),
]); ]);
} }

View File

@ -33,11 +33,13 @@
cursor: pointer; cursor: pointer;
color: $primary; color: $primary;
text-decoration: underline; text-decoration: underline;
padding-right: 1em;
} }
.Spinner { .portInput {
--spinner-size: #{$unit * 2}; display: inline-block !important;
margin-left: $margin; width: 70px;
position: absolute; margin-left: 10px;
margin-right: 10px;
} }
} }

View File

@ -22,12 +22,15 @@
import "./service-port-component.scss"; import "./service-port-component.scss";
import React from "react"; 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 type { Service, ServicePort } from "../../../common/k8s-api/endpoints";
import { apiBase } from "../../api"; import { observable, makeObservable, reaction } from "mobx";
import { observable, makeObservable } from "mobx";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Notifications } from "../notifications"; 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"; import { Spinner } from "../spinner";
interface Props { interface Props {
@ -38,20 +41,86 @@ interface Props {
@observer @observer
export class ServicePortComponent extends React.Component<Props> { export class ServicePortComponent extends React.Component<Props> {
@observable waiting = false; @observable waiting = false;
@observable forwardPort = 0;
@observable isPortForwarded = false;
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
makeObservable(this); 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() { async portForward() {
const { service, port } = this.props; 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; this.waiting = true;
try { try {
await apiBase.post(`/pods/${service.getNs()}/service/${service.getName()}/port-forward/${port.port}`, {}); await removePortForward(portForward);
} catch(error) { this.isPortForwarded = false;
} catch (error) {
Notifications.error(error); Notifications.error(error);
} finally { } finally {
this.waiting = false; this.waiting = false;
@ -59,16 +128,33 @@ export class ServicePortComponent extends React.Component<Props> {
} }
render() { 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 ( return (
<div className={cssNames("ServicePortComponent", { waiting: this.waiting })}> <div className={cssNames("ServicePortComponent", { waiting: this.waiting })}>
<span title="Open in a browser" onClick={() => this.portForward() }> <span title="Open in a browser" onClick={() => this.portForward()}>
{port.toString()} {port.toString()}
</span>
<Button onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button>
{this.waiting && ( {this.waiting && (
<Spinner /> <Spinner />
)} )}
</span>
</div> </div>
); );
} }

View File

@ -28,6 +28,7 @@ import { Services } from "../+network-services";
import { Endpoints } from "../+network-endpoints"; import { Endpoints } from "../+network-endpoints";
import { Ingresses } from "../+network-ingresses"; import { Ingresses } from "../+network-ingresses";
import { NetworkPolicies } from "../+network-policies"; import { NetworkPolicies } from "../+network-policies";
import { PortForwards } from "../+network-port-forwards";
import { isAllowedResource } from "../../../common/utils/allowed-resource"; import { isAllowedResource } from "../../../common/utils/allowed-resource";
import * as routes from "../../../common/routes"; 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; return tabs;
} }

View File

@ -236,8 +236,8 @@ export class Nodes extends React.Component<Props> {
this.renderDiskUsage(node), this.renderDiskUsage(node),
<> <>
<span id={tooltipId}>{node.getTaints().length}</span> <span id={tooltipId}>{node.getTaints().length}</span>
<Tooltip targetId={tooltipId} style={{ whiteSpace: "pre-line" }}> <Tooltip targetId={tooltipId} tooltipOnParentHover={true} style={{ whiteSpace: "pre-line" }}>
{node.getTaints().map(({ key, effect }) => `${key}: ${effect}`).join("\n")} {node.getTaints().map(({ key, value, effect }) => `${key}=${value}:${effect}`).join("\n")}
</Tooltip> </Tooltip>
</>, </>,
node.getRoleLabels(), node.getRoleLabels(),

View File

@ -72,6 +72,20 @@ export const Application = observer(() => {
/> />
</section> </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/> <hr/>
<section id="other"> <section id="other">

View File

@ -75,6 +75,7 @@ export class DeploymentReplicaSets extends React.Component<Props> {
sortable={this.sortingCallbacks} sortable={this.sortingCallbacks}
sortByDefault={{ sortBy: sortBy.pods, orderBy: "desc" }} sortByDefault={{ sortBy: sortBy.pods, orderBy: "desc" }}
sortSyncWithUrl={false} sortSyncWithUrl={false}
tableId="deployment_replica_sets_view"
className="box grow" className="box grow"
> >
<TableHead> <TableHead>

View File

@ -112,6 +112,7 @@ const dummyDeployment: Deployment = {
toPlainObject: jest.fn(), toPlainObject: jest.fn(),
update: jest.fn(), update: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
patch: jest.fn(),
}; };
describe("<DeploymentScaleDialog />", () => { describe("<DeploymentScaleDialog />", () => {

View File

@ -34,11 +34,13 @@
color: $primary; color: $primary;
text-decoration: underline; text-decoration: underline;
position: relative; position: relative;
padding-right: 1em;
} }
.Spinner { .portInput {
--spinner-size: #{$unit * 2}; display: inline-block !important;
margin-left: $margin; width: 70px;
position: absolute; margin-left: 10px;
margin-right: 10px;
} }
} }

View File

@ -22,12 +22,15 @@
import "./pod-container-port.scss"; import "./pod-container-port.scss";
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import type { Pod } from "../../../common/k8s-api/endpoints"; import type { Pod } from "../../../common/k8s-api/endpoints";
import { apiBase } from "../../api"; import { observable, makeObservable, reaction } from "mobx";
import { observable, makeObservable } from "mobx";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Notifications } from "../notifications"; 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"; import { Spinner } from "../spinner";
interface Props { interface Props {
@ -42,20 +45,86 @@ interface Props {
@observer @observer
export class PodContainerPort extends React.Component<Props> { export class PodContainerPort extends React.Component<Props> {
@observable waiting = false; @observable waiting = false;
@observable forwardPort = 0;
@observable isPortForwarded = false;
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
makeObservable(this); 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() { async portForward() {
const { pod, port } = this.props; 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; this.waiting = true;
try { try {
await apiBase.post(`/pods/${pod.getNs()}/pod/${pod.getName()}/port-forward/${port.containerPort}`, {}); await removePortForward(portForward);
} catch(error) { this.isPortForwarded = false;
} catch (error) {
Notifications.error(error); Notifications.error(error);
} finally { } finally {
this.waiting = false; this.waiting = false;
@ -63,18 +132,35 @@ export class PodContainerPort extends React.Component<Props> {
} }
render() { render() {
const { port } = this.props; const { pod, port } = this.props;
const { name, containerPort, protocol } = port; const { name, containerPort, protocol } = port;
const text = `${name ? `${name}: ` : ""}${containerPort}/${protocol}`; 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 ( return (
<div className={cssNames("PodContainerPort", { waiting: this.waiting })}> <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} {text}
</span>
<Button onClick={() => portForwardAction()}> {this.isPortForwarded ? "Stop" : "Forward..."} </Button>
{this.waiting && ( {this.waiting && (
<Spinner /> <Spinner />
)} )}
</span>
</div> </div>
); );
} }

View File

@ -35,6 +35,8 @@ import { ContainerCharts } from "./container-charts";
import { LocaleDate } from "../locale-date"; import { LocaleDate } from "../locale-date";
import { getActiveClusterEntity } from "../../api/catalog-entity-registry"; import { getActiveClusterEntity } from "../../api/catalog-entity-registry";
import { ClusterMetricsResourceType } from "../../../common/cluster-types"; import { ClusterMetricsResourceType } from "../../../common/cluster-types";
import { portForwardStore } from "../../port-forward/port-forward.store";
import { disposeOnUnmount, observer } from "mobx-react";
interface Props { interface Props {
pod: Pod; pod: Pod;
@ -42,8 +44,15 @@ interface Props {
metrics?: { [key: string]: IMetrics }; metrics?: { [key: string]: IMetrics };
} }
@observer
export class PodDetailsContainer extends React.Component<Props> { export class PodDetailsContainer extends React.Component<Props> {
componentDidMount() {
disposeOnUnmount(this, [
portForwardStore.watch(),
]);
}
renderStatus(state: string, status: IPodContainerStatus) { renderStatus(state: string, status: IPodContainerStatus) {
const ready = status ? status.ready : ""; const ready = status ? status.ready : "";

View File

@ -107,6 +107,7 @@ const dummyReplicaSet: ReplicaSet = {
toPlainObject: jest.fn(), toPlainObject: jest.fn(),
update: jest.fn(), update: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
patch: jest.fn(),
}; };
describe("<ReplicaSetScaleDialog />", () => { describe("<ReplicaSetScaleDialog />", () => {

View File

@ -117,6 +117,7 @@ const dummyStatefulSet: StatefulSet = {
toPlainObject: jest.fn(), toPlainObject: jest.fn(),
update: jest.fn(), update: jest.fn(),
delete: jest.fn(), delete: jest.fn(),
patch: jest.fn(),
}; };
describe("<StatefulSetScaleDialog />", () => { describe("<StatefulSetScaleDialog />", () => {

View File

@ -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 ..."
/>
);
}
}

View 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";

View File

@ -19,7 +19,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import React from "react"; import React from "react";
import { observable, makeObservable, reaction } from "mobx"; import { observable, makeObservable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { Redirect, Route, Router, Switch } from "react-router"; import { Redirect, Route, Router, Switch } from "react-router";
import { history } from "../navigation"; import { history } from "../navigation";
@ -73,6 +73,8 @@ import { getHostedClusterId } from "../utils";
import { ClusterStore } from "../../common/cluster-store"; import { ClusterStore } from "../../common/cluster-store";
import type { ClusterId } from "../../common/cluster-types"; import type { ClusterId } from "../../common/cluster-types";
import { watchHistoryState } from "../remote-helpers/history-updater"; import { watchHistoryState } from "../remote-helpers/history-updater";
import { unmountComponentAtNode } from "react-dom";
import { PortForwardDialog } from "../port-forward";
@observer @observer
export class App extends React.Component { export class App extends React.Component {
@ -83,7 +85,7 @@ export class App extends React.Component {
makeObservable(this); makeObservable(this);
} }
static async init() { static async init(rootElem: HTMLElement) {
catalogEntityRegistry.init(); catalogEntityRegistry.init();
const frameId = webFrame.routingId; const frameId = webFrame.routingId;
@ -97,13 +99,7 @@ export class App extends React.Component {
await cluster.whenReady; // cluster.activate() is done at this point await cluster.whenReady; // cluster.activate() is done at this point
const activeEntityDisposer = reaction(() => catalogEntityRegistry.getById(App.clusterId), (entity) => { catalogEntityRegistry.activeEntity = App.clusterId;
if (!entity) {
return;
}
catalogEntityRegistry.activeEntity = entity;
activeEntityDisposer();
}, {fireImmediately: true});
ExtensionLoader.getInstance().loadOnClusterRenderer(); ExtensionLoader.getInstance().loadOnClusterRenderer();
setTimeout(() => { setTimeout(() => {
@ -118,6 +114,20 @@ export class App extends React.Component {
window.addEventListener("online", () => { window.addEventListener("online", () => {
window.location.reload(); 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 whatInput.ask(); // Start to monitor user input device
// Setup hosted cluster context // Setup hosted cluster context
@ -222,6 +232,7 @@ export class App extends React.Component {
<StatefulSetScaleDialog/> <StatefulSetScaleDialog/>
<ReplicaSetScaleDialog/> <ReplicaSetScaleDialog/>
<CronJobTriggerDialog/> <CronJobTriggerDialog/>
<PortForwardDialog/>
<CommandContainer clusterId={App.clusterId}/> <CommandContainer clusterId={App.clusterId}/>
</ErrorBoundary> </ErrorBoundary>
</Router> </Router>

View File

@ -25,7 +25,7 @@ import React from "react";
import { Redirect, Route, Switch } from "react-router"; import { Redirect, Route, Switch } from "react-router";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { BottomBar } from "./bottom-bar"; import { BottomBar } from "./bottom-bar";
import { Catalog } from "../+catalog"; import { Catalog, previousActiveTab } from "../+catalog";
import { Preferences } from "../+preferences"; import { Preferences } from "../+preferences";
import { AddCluster } from "../+add-cluster"; import { AddCluster } from "../+add-cluster";
import { ClusterView } from "./cluster-view"; import { ClusterView } from "./cluster-view";
@ -40,6 +40,7 @@ import { reaction } from "mobx";
import { navigation } from "../../navigation"; import { navigation } from "../../navigation";
import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync"; import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync";
import { TopBar } from "../layout/topbar"; import { TopBar } from "../layout/topbar";
import { catalogURL, getPreviousTabUrl } from "../../../common/routes";
@observer @observer
export class ClusterManager extends React.Component { export class ClusterManager extends React.Component {
@ -56,6 +57,7 @@ export class ClusterManager extends React.Component {
<main> <main>
<div id="lens-views"/> <div id="lens-views"/>
<Switch> <Switch>
<Redirect exact from={catalogURL()} to={getPreviousTabUrl(previousActiveTab.get())}/>
<Route component={Welcome} {...routes.welcomeRoute} /> <Route component={Welcome} {...routes.welcomeRoute} />
<Route component={Catalog} {...routes.catalogRoute} /> <Route component={Catalog} {...routes.catalogRoute} />
<Route component={Preferences} {...routes.preferencesRoute} /> <Route component={Preferences} {...routes.preferencesRoute} />

View File

@ -33,7 +33,6 @@ import { clusterActivateHandler } from "../../../common/cluster-ipc";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
import { catalogURL, ClusterViewRouteParams } from "../../../common/routes"; import { catalogURL, ClusterViewRouteParams } from "../../../common/routes";
import { previousActiveTab } from "../+catalog";
interface Props extends RouteComponentProps<ClusterViewRouteParams> { interface Props extends RouteComponentProps<ClusterViewRouteParams> {
} }
@ -85,7 +84,7 @@ export class ClusterView extends React.Component<Props> {
const disconnected = values[1]; const disconnected = values[1];
if (hasLoadedView(this.clusterId) && disconnected) { 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
} }
}), }),
]); ]);

View File

@ -25,9 +25,11 @@ import { Pod } from "../../../../common/k8s-api/endpoints";
import { ThemeStore } from "../../../theme.store"; import { ThemeStore } from "../../../theme.store";
import { dockStore } from "../dock.store"; import { dockStore } from "../dock.store";
import { logTabStore } from "../log-tab.store"; import { logTabStore } from "../log-tab.store";
import { TerminalStore } from "../terminal.store";
import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock"; import { deploymentPod1, deploymentPod2, deploymentPod3, dockerPod } from "./pod.mock";
import fse from "fs-extra"; import fse from "fs-extra";
import { mockWindow } from "../../../../../__mocks__/windowMock";
mockWindow();
jest.mock("react-monaco-editor", () => null); jest.mock("react-monaco-editor", () => null);
@ -45,7 +47,6 @@ describe("log tab store", () => {
beforeEach(() => { beforeEach(() => {
UserStore.createInstance(); UserStore.createInstance();
ThemeStore.createInstance(); ThemeStore.createInstance();
TerminalStore.createInstance();
}); });
afterEach(() => { afterEach(() => {
@ -53,7 +54,6 @@ describe("log tab store", () => {
dockStore.reset(); dockStore.reset();
UserStore.resetInstance(); UserStore.resetInstance();
ThemeStore.resetInstance(); ThemeStore.resetInstance();
TerminalStore.resetInstance();
fse.remove("tmp"); fse.remove("tmp");
}); });
@ -135,11 +135,11 @@ describe("log tab store", () => {
}); });
// FIXME: this is failed when it's not .only == depends on something above // FIXME: this is failed when it's not .only == depends on something above
it.only("closes tab if no pods left in store", () => { it.only("closes tab if no pods left in store", async () => {
const selectedPod = new Pod(deploymentPod1); const selectedPod = new Pod(deploymentPod1);
const selectedContainer = selectedPod.getInitContainers()[0]; const selectedContainer = selectedPod.getInitContainers()[0];
logTabStore.createPodTab({ const id = logTabStore.createPodTab({
selectedPod, selectedPod,
selectedContainer selectedContainer
}); });
@ -147,6 +147,7 @@ describe("log tab store", () => {
podsStore.items.clear(); podsStore.items.clear();
expect(logTabStore.getData(dockStore.selectedTabId)).toBeUndefined(); expect(logTabStore.getData(dockStore.selectedTabId)).toBeUndefined();
expect(dockStore.getTabById(dockStore.selectedTabId)).toBeUndefined(); expect(logTabStore.getData(id)).toBeUndefined();
expect(dockStore.getTabById(id)).toBeUndefined();
}); });
}); });

View File

@ -33,10 +33,10 @@ import { createResourceStore } from "./create-resource.store";
import type { DockTab } from "./dock.store"; import type { DockTab } from "./dock.store";
import { EditorPanel } from "./editor-panel"; import { EditorPanel } from "./editor-panel";
import { InfoPanel } from "./info-panel"; import { InfoPanel } from "./info-panel";
import { resourceApplierApi } from "../../../common/k8s-api/endpoints/resource-applier.api"; import * as resourceApplierApi from "../../../common/k8s-api/endpoints/resource-applier.api";
import type { JsonApiErrorParsed } from "../../../common/k8s-api/json-api";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { monacoModelsManager } from "./monaco-model-manager"; import { monacoModelsManager } from "./monaco-model-manager";
import logger from "../../../common/logger";
interface Props { interface Props {
className?: string; 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()) { if (this.error || !this.data.trim()) {
// do not save when field is empty or there is an error // do not save when field is empty or there is an error
return null; return null;
@ -103,31 +103,31 @@ export class CreateResource extends React.Component<Props> {
// skip empty documents if "---" pasted at the beginning or end // skip empty documents if "---" pasted at the beginning or end
const resources = jsYaml.safeLoadAll(this.data).filter(Boolean); const resources = jsYaml.safeLoadAll(this.data).filter(Boolean);
const createdResources: string[] = [];
const errors: string[] = [];
await Promise.all( if (resources.length === 0) {
resources.map(data => { return void logger.info("Nothing to create");
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];
} }
const successMessage = (
const createdResources: string[] = [];
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> <p>
{createdResources.length === 1 ? "Resource" : "Resources"}{" "} {createdResources.length === 1 ? "Resource" : "Resources"}{" "}
<b>{createdResources.join(", ")}</b> successfully created <b>{createdResources.join(", ")}</b> successfully created
</p> </p>
); ));
}
Notifications.ok(successMessage); return undefined;
return successMessage;
}; };
renderControls(){ renderControls(){

View File

@ -287,7 +287,7 @@ export class DockStore implements DockStorageState {
} }
@action @action
async closeTab(tabId: TabId) { closeTab(tabId: TabId) {
const tab = this.getTabById(tabId); const tab = this.getTabById(tabId);
if (!tab || tab.pinned) { if (!tab || tab.pinned) {
@ -305,12 +305,6 @@ export class DockStore implements DockStorageState {
if (this.tabs.length) { if (this.tabs.length) {
const newTab = this.tabs.slice(-1)[0]; // last 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); this.selectTab(newTab.id);
} else { } else {
this.selectedTabId = null; this.selectedTabId = null;

View File

@ -31,6 +31,7 @@ import {monacoModelsManager} from "./monaco-model-manager";
export interface EditingResource { export interface EditingResource {
resource: string; // resource path, e.g. /api/v1/namespaces/default resource: string; // resource path, e.g. /api/v1/namespaces/default
draft?: string; // edited draft in yaml draft?: string; // edited draft in yaml
firstDraft?: string;
} }
export class EditResourceStore extends DockTabStore<EditingResource> { export class EditResourceStore extends DockTabStore<EditingResource> {
@ -106,6 +107,10 @@ export class EditResourceStore extends DockTabStore<EditingResource> {
return dockStore.getTabById(tabId); return dockStore.getTabById(tabId);
} }
clearInitialDraft(tabId: TabId): void {
delete this.getData(tabId)?.firstDraft;
}
reset() { reset() {
super.reset(); super.reset();
Array.from(this.watchers).forEach(([tabId, dispose]) => { Array.from(this.watchers).forEach(([tabId, dispose]) => {

View File

@ -24,7 +24,7 @@ import "./edit-resource.scss";
import React from "react"; import React from "react";
import { action, computed, makeObservable, observable } from "mobx"; import { action, computed, makeObservable, observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import jsYaml from "js-yaml"; import yaml from "js-yaml";
import type { DockTab } from "./dock.store"; import type { DockTab } from "./dock.store";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { editResourceStore } from "./edit-resource.store"; import { editResourceStore } from "./edit-resource.store";
@ -33,6 +33,7 @@ import { Badge } from "../badge";
import { EditorPanel } from "./editor-panel"; import { EditorPanel } from "./editor-panel";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import type { KubeObject } from "../../../common/k8s-api/kube-object"; import type { KubeObject } from "../../../common/k8s-api/kube-object";
import { createPatch } from "rfc6902";
interface Props { interface Props {
className?: string; className?: string;
@ -71,16 +72,17 @@ export class EditResource extends React.Component<Props> {
return draft; return draft;
} }
return jsYaml.safeDump(this.resource.toPlainObject()); // dump resource first time return yaml.safeDump(this.resource.toPlainObject()); // dump resource first time
} }
@action @action
saveDraft(draft: string | object) { saveDraft(draft: string | object) {
if (typeof draft === "object") { if (typeof draft === "object") {
draft = draft ? jsYaml.safeDump(draft) : undefined; draft = draft ? yaml.safeDump(draft) : undefined;
} }
editResourceStore.setData(this.tabId, { editResourceStore.setData(this.tabId, {
firstDraft: draft, // this must be before the next line
...editResourceStore.getData(this.tabId), ...editResourceStore.getData(this.tabId),
draft, draft,
}); });
@ -95,16 +97,18 @@ export class EditResource extends React.Component<Props> {
if (this.error) { if (this.error) {
return null; 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 store = editResourceStore.getStore(this.tabId);
const resourceType = updatedResource.kind; const currentVersion = yaml.safeLoad(this.draft);
const resourceName = updatedResource.getName(); 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 ( return (
<p> <p>
{resourceType} <b>{resourceName}</b> updated. {updatedResource.kind} <b>{updatedResource.getName()}</b> updated.
</p> </p>
); );
}; };
@ -126,9 +130,9 @@ export class EditResource extends React.Component<Props> {
submittingMessage="Applying.." submittingMessage="Applying.."
controls={( controls={(
<div className="resource-info flex gaps align-center"> <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>Name:</span><Badge label={resource.getName()}/>
<span>Namespace:</span> <Badge label={resource.getNs() || "global"}/> <span>Namespace:</span><Badge label={resource.getNs() || "global"}/>
</div> </div>
)} )}
/> />

View File

@ -91,10 +91,7 @@ export class EditorPanel extends React.Component<Props> {
onChange = (value: string) => { onChange = (value: string) => {
this.validate(value); this.validate(value);
this.props.onChange?.(value, this.yamlError);
if (this.props.onChange) {
this.props.onChange(value, this.yamlError);
}
}; };
render() { render() {

View File

@ -25,6 +25,7 @@ import { podsStore } from "../+workloads-pods/pods.store";
import { IPodContainer, Pod } from "../../../common/k8s-api/endpoints"; import { IPodContainer, Pod } from "../../../common/k8s-api/endpoints";
import type { WorkloadKubeObject } from "../../../common/k8s-api/workload-kube-object"; import type { WorkloadKubeObject } from "../../../common/k8s-api/workload-kube-object";
import logger from "../../../common/logger";
import { DockTabStore } from "./dock-tab.store"; import { DockTabStore } from "./dock-tab.store";
import { dockStore, DockTabCreateSpecific, TabKind } from "./dock.store"; import { dockStore, DockTabCreateSpecific, TabKind } from "./dock.store";
@ -51,17 +52,15 @@ export class LogTabStore extends DockTabStore<LogTabData> {
storageKey: "pod_logs" storageKey: "pod_logs"
}); });
reaction(() => podsStore.items.length, () => { reaction(() => podsStore.items.length, () => this.updateTabsData());
this.updateTabsData();
});
} }
createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): void { createPodTab({ selectedPod, selectedContainer }: PodLogsTabData): string {
const podOwner = selectedPod.getOwnerRefs()[0]; const podOwner = selectedPod.getOwnerRefs()[0];
const pods = podsStore.getPodsByOwnerId(podOwner?.uid); const pods = podsStore.getPodsByOwnerId(podOwner?.uid);
const title = `Pod ${selectedPod.getName()}`; const title = `Pod ${selectedPod.getName()}`;
this.createLogsTab(title, { return this.createLogsTab(title, {
pods: pods.length ? pods : [selectedPod], pods: pods.length ? pods : [selectedPod],
selectedPod, selectedPod,
selectedContainer selectedContainer
@ -97,7 +96,7 @@ export class LogTabStore extends DockTabStore<LogTabData> {
}, false); }, false);
} }
private createLogsTab(title: string, data: LogTabData) { private createLogsTab(title: string, data: LogTabData): string {
const id = uniqueId("log-tab-"); const id = uniqueId("log-tab-");
this.createDockTab({ id, title }); this.createDockTab({ id, title });
@ -106,19 +105,24 @@ export class LogTabStore extends DockTabStore<LogTabData> {
showTimestamps: false, showTimestamps: false,
previous: false previous: false
}); });
return id;
} }
private async updateTabsData() { private updateTabsData() {
const promises: Promise<void>[] = [];
for (const [tabId, tabData] of this.data) { for (const [tabId, tabData] of this.data) {
try {
if (!tabData.selectedPod) {
tabData.selectedPod = tabData.pods[0];
}
const pod = new Pod(tabData.selectedPod); const pod = new Pod(tabData.selectedPod);
const pods = podsStore.getPodsByOwnerId(pod.getOwnerRefs()[0]?.uid); const pods = podsStore.getPodsByOwnerId(pod.getOwnerRefs()[0]?.uid);
const isSelectedPodInList = pods.find(item => item.getId() == pod.getId()); const isSelectedPodInList = pods.find(item => item.getId() == pod.getId());
const selectedPod = isSelectedPodInList ? pod : pods[0]; const selectedPod = isSelectedPodInList ? pod : pods[0];
const selectedContainer = isSelectedPodInList ? tabData.selectedContainer : pod.getAllContainers()[0]; const selectedContainer = isSelectedPodInList ? tabData.selectedContainer : pod.getAllContainers()[0];
if (pods.length) { if (pods.length > 0) {
this.setData(tabId, { this.setData(tabId, {
...tabData, ...tabData,
selectedPod, selectedPod,
@ -128,16 +132,18 @@ export class LogTabStore extends DockTabStore<LogTabData> {
this.renameTab(tabId); this.renameTab(tabId);
} else { } else {
promises.push(this.closeTab(tabId)); 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 closeTab(tabId: string) {
}
private async closeTab(tabId: string) {
this.clearData(tabId); this.clearData(tabId);
await dockStore.closeTab(tabId); dockStore.closeTab(tabId);
} }
} }

View File

@ -29,6 +29,8 @@ import { ThemeStore } from "../../theme.store";
import { boundMethod } from "../../utils"; import { boundMethod } from "../../utils";
import { isMac } from "../../../common/vars"; import { isMac } from "../../../common/vars";
import { camelCase } from "lodash"; import { camelCase } from "lodash";
import { UserStore } from "../../../common/user-store";
import { clipboard } from "electron";
export class Terminal { export class Terminal {
static spawningPool: HTMLElement; static spawningPool: HTMLElement;
@ -115,11 +117,13 @@ export class Terminal {
this.xterm.open(Terminal.spawningPool); this.xterm.open(Terminal.spawningPool);
this.xterm.registerLinkMatcher(/https?:\/\/[^\s]+/i, this.onClickLink); this.xterm.registerLinkMatcher(/https?:\/\/[^\s]+/i, this.onClickLink);
this.xterm.attachCustomKeyEventHandler(this.keyHandler); this.xterm.attachCustomKeyEventHandler(this.keyHandler);
this.xterm.onSelectionChange(this.onSelectionChange);
// bind events // bind events
const onDataHandler = this.xterm.onData(this.onData); const onDataHandler = this.xterm.onData(this.onData);
this.viewport.addEventListener("scroll", this.onScroll); 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.onReady.addListener(this.onClear, { once: true }); // clear status logs (connecting..)
this.api.onData.addListener(this.onApiData); this.api.onData.addListener(this.onApiData);
window.addEventListener("resize", this.onResize); window.addEventListener("resize", this.onResize);
@ -133,6 +137,7 @@ export class Terminal {
() => this.fitAddon.dispose(), () => this.fitAddon.dispose(),
() => this.api.removeAllListeners(), () => this.api.removeAllListeners(),
() => window.removeEventListener("resize", this.onResize), () => window.removeEventListener("resize", this.onResize),
() => this.elem.removeEventListener("contextmenu", this.onContextMenu),
); );
} }
@ -198,6 +203,24 @@ export class Terminal {
window.open(link, "_blank"); 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 => { keyHandler = (evt: KeyboardEvent): boolean => {
const { code, ctrlKey, metaKey } = evt; const { code, ctrlKey, metaKey } = evt;

View File

@ -31,12 +31,20 @@
} }
.HotbarIcon { .HotbarIcon {
--iconActiveShadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px var(--textColorAccent);
--iconHoverShadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px #ffffff50;
border-radius: 6px; border-radius: 6px;
user-select: none; user-select: none;
cursor: pointer; cursor: pointer;
transition: none; transition: none;
text-shadow: 0 0 4px #0000008f; text-shadow: 0 0 4px #0000008f;
position: relative; position: relative;
z-index: 0; // allows to catch state of :active pseudo-selector
&:active .MuiAvatar-root {
box-shadow: var(--iconActiveShadow) !important;
}
div.MuiAvatar-colorDefault { div.MuiAvatar-colorDefault {
font-weight:500; font-weight:500;
@ -64,20 +72,17 @@
box-shadow: none; box-shadow: none;
} }
div.MuiAvatar-root { .MuiAvatar-root {
width: var(--size); width: var(--size);
height: var(--size); height: var(--size);
border-radius: 6px;
&.active { &.active {
box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px var(--textColorAccent); box-shadow: var(--iconActiveShadow);
} }
&.interactive { &.interactive:not(.active):hover {
&:hover { box-shadow: var(--iconHoverShadow);
&:not(.active) {
box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px #ffffff50;
}
}
} }
} }
@ -117,20 +122,6 @@
} }
} }
img {
border-radius: 6px;
&.active {
box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px var(--textColorAccent);
}
&:hover {
&:not(.active) {
box-shadow: 0 0 0px 3px var(--clusterMenuBackground), 0 0 0px 6px #ffffff50;
}
}
}
.materialIcon { .materialIcon {
margin-left: 1px; margin-left: 1px;
margin-top: 1px; margin-top: 1px;

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