From 92dfd80889eb1800d354f310b0ffa555ceac96f3 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 24 Feb 2021 09:05:14 -0500 Subject: [PATCH 01/26] StatusBarRegistration's component field must be optional for backwards compatability (#2211) Signed-off-by: Sebastian Malton --- src/extensions/registries/status-bar-registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/extensions/registries/status-bar-registry.ts b/src/extensions/registries/status-bar-registry.ts index 698c5f5f24..e0454fe77e 100644 --- a/src/extensions/registries/status-bar-registry.ts +++ b/src/extensions/registries/status-bar-registry.ts @@ -8,7 +8,7 @@ interface StatusBarComponents { } interface StatusBarRegistrationV2 { - components: StatusBarComponents; + components?: StatusBarComponents; // has to be optional for backwards compatability } export interface StatusBarRegistration extends StatusBarRegistrationV2 { From f18d8618cd1a8081203c62bf901c1dbe626713a1 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Wed, 24 Feb 2021 16:10:47 +0200 Subject: [PATCH 02/26] Fix loading all namespaces for users with limited cluster access (#2217) Signed-off-by: Lauri Nevala --- src/renderer/api/kube-api.ts | 1 + src/renderer/components/+namespaces/namespace.store.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/api/kube-api.ts b/src/renderer/api/kube-api.ts index a880cc2406..448cd9da8f 100644 --- a/src/renderer/api/kube-api.ts +++ b/src/renderer/api/kube-api.ts @@ -272,6 +272,7 @@ export class KubeApi { } protected parseResponse(data: KubeJsonApiData | KubeJsonApiData[] | KubeJsonApiDataList, namespace?: string): any { + if (!data) return; const KubeObjectConstructor = this.objectConstructor; if (KubeObject.isJsonApiData(data)) { diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index ad271d1302..9995fbb7e5 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -120,7 +120,7 @@ export class NamespaceStore extends KubeObjectStore { protected async loadItems(params: KubeObjectStoreLoadingParams) { const { allowedNamespaces } = this; - let namespaces = await super.loadItems(params); + let namespaces = (await super.loadItems(params)) || []; namespaces = namespaces.filter(namespace => allowedNamespaces.includes(namespace.getName())); From 38c3734d5ccd5f874648481fe6e56f0646af829a Mon Sep 17 00:00:00 2001 From: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com> Date: Thu, 25 Feb 2021 09:09:39 -0500 Subject: [PATCH 03/26] the select all checkbox should not select disabled items (#2151) * the select all checkbox should not select disabled items Signed-off-by: Jim Ehrismann * remove improper bullet-proofing Signed-off-by: Jim Ehrismann * added default for customizeTableRowProps Signed-off-by: Jim Ehrismann --- .../item-object-list/item-list-layout.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index 895c275fbb..adec4bef27 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -89,7 +89,8 @@ const defaultProps: Partial = { filterItems: [], hasDetailsView: true, onDetails: noop, - virtual: true + virtual: true, + customizeTableRowProps: () => ({} as TableRowProps), }; interface ItemListLayoutUserSettings { @@ -241,7 +242,7 @@ export class ItemListLayout extends React.Component { sortItem={item} selected={detailsItem && detailsItem.getId() === itemId} onClick={hasDetailsView ? prevDefault(() => onDetails(item)) : undefined} - {...(customizeTableRowProps ? customizeTableRowProps(item) : {})} + {...customizeTableRowProps(item)} > {isSelectable && ( { } renderTableHeader() { - const { renderTableHeader, isSelectable, isConfigurable, store } = this.props; + const { customizeTableRowProps, renderTableHeader, isSelectable, isConfigurable, store } = this.props; if (!renderTableHeader) { return; } + const enabledItems = this.items.filter(item => !customizeTableRowProps(item).disabled); + return ( {isSelectable && ( store.toggleSelectionAll(this.items))} + isChecked={store.isSelectedAll(enabledItems)} + onClick={prevDefault(() => store.toggleSelectionAll(enabledItems))} /> )} {renderTableHeader.map((cellProps, index) => { From 1470103fd486a269ad9c4fcccd3373275dba83b9 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 25 Feb 2021 09:32:40 -0500 Subject: [PATCH 04/26] Add lens:// protocol handling with a routing mechanism (#1949) - Add lens:// protocol handling with a routing mechanism - document the methods in an extension guide - remove handlers when an extension is deactivated or removed - make sure that the found extension when routing a request is currently enabled (as a backup) - added documentation about the above behaviour to the guide - tweaked the naming convention so that it is clearer that the router uses extension names as not IDs (which currently are folder paths) - Convert the extension API to use an array for registering handlers - switch design to execute both main and renderer handlers simultaneously, without any overlap checking - change open to be a dev dep - improve docs, export types for extensions, skip integration tests - switch to event emitting renderer being ready - Add logging and fix renderer:loaded send to main Signed-off-by: Sebastian Malton --- Makefile | 15 +- docs/extensions/guides/README.md | 3 +- .../extensions/guides/images/routing-diag.png | Bin 0 -> 25838 bytes docs/extensions/guides/protocol-handlers.md | 83 ++++++ docs/getting-started/README.md | 23 +- package.json | 17 +- scripts/test.sh | 1 + src/common/ipc/ipc.ts | 2 +- src/common/protocol-handler/error.ts | 36 +++ src/common/protocol-handler/index.ts | 2 + src/common/protocol-handler/router.ts | 218 +++++++++++++++ src/common/utils/delay.ts | 17 +- src/common/utils/index.ts | 2 +- src/common/utils/type-narrowing.ts | 13 + src/extensions/extension-loader.ts | 27 +- src/extensions/extensions-store.ts | 2 +- src/extensions/interfaces/registrations.ts | 1 + src/extensions/lens-extension.ts | 3 + src/extensions/lens-renderer-extension.ts | 3 +- .../registries/protocol-handler-registry.ts | 44 +++ src/main/app-updater.ts | 5 +- src/main/index.ts | 69 ++++- .../protocol-handler/__test__/router.test.ts | 259 ++++++++++++++++++ src/main/protocol-handler/index.ts | 1 + src/main/protocol-handler/router.ts | 112 ++++++++ src/main/window-manager.ts | 6 +- src/renderer/lens-app.tsx | 4 + src/renderer/navigation/index.ts | 2 + src/renderer/navigation/protocol-handlers.ts | 10 + src/renderer/protocol-handler/index.ts | 1 + src/renderer/protocol-handler/router.ts | 40 +++ src/renderer/utils/index.ts | 2 - yarn.lock | 27 +- 33 files changed, 1010 insertions(+), 40 deletions(-) create mode 100644 docs/extensions/guides/images/routing-diag.png create mode 100644 docs/extensions/guides/protocol-handlers.md create mode 100755 scripts/test.sh create mode 100644 src/common/protocol-handler/error.ts create mode 100644 src/common/protocol-handler/index.ts create mode 100644 src/common/protocol-handler/router.ts create mode 100644 src/common/utils/type-narrowing.ts create mode 100644 src/extensions/registries/protocol-handler-registry.ts create mode 100644 src/main/protocol-handler/__test__/router.test.ts create mode 100644 src/main/protocol-handler/index.ts create mode 100644 src/main/protocol-handler/router.ts create mode 100644 src/renderer/navigation/protocol-handlers.ts create mode 100644 src/renderer/protocol-handler/index.ts create mode 100644 src/renderer/protocol-handler/router.ts diff --git a/Makefile b/Makefile index 000682e039..ea7c84d018 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ else DETECTED_OS := $(shell uname) endif -binaries/client: +binaries/client: node_modules yarn download-bins node_modules: yarn.lock @@ -37,17 +37,24 @@ test: binaries/client yarn test .PHONY: integration-linux -integration-linux: build-extension-types build-extensions +integration-linux: binaries/client build-extension-types build-extensions +# ifdef XDF_CONFIG_HOME +# rm -rf ${XDG_CONFIG_HOME}/.config/Lens +# else +# rm -rf ${HOME}/.config/Lens +# endif yarn build:linux yarn integration .PHONY: integration-mac -integration-mac: build-extension-types build-extensions +integration-mac: binaries/client build-extension-types build-extensions + # rm ${HOME}/Library/Application\ Support/Lens yarn build:mac yarn integration .PHONY: integration-win -integration-win: build-extension-types build-extensions +integration-win: binaries/client build-extension-types build-extensions + # rm %APPDATA%/Lens yarn build:win yarn integration diff --git a/docs/extensions/guides/README.md b/docs/extensions/guides/README.md index 8db209dc82..d288f0cd64 100644 --- a/docs/extensions/guides/README.md +++ b/docs/extensions/guides/README.md @@ -21,12 +21,13 @@ Each guide or code sample includes the following: | [Components](components.md) | | | [KubeObjectListLayout](kube-object-list-layout.md) | | | [Working with mobx](working-with-mobx.md) | | +| [Protocol Handlers](protocol-handlers.md) | | ## Samples | Sample | APIs | | ----- | ----- | -[helloworld](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | +[hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | [minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension
Store.clusterStore
Store.workspaceStore | [styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | [styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | diff --git a/docs/extensions/guides/images/routing-diag.png b/docs/extensions/guides/images/routing-diag.png new file mode 100644 index 0000000000000000000000000000000000000000..9185ce94d8bfa105e27df0b647f3c43382fb7aed GIT binary patch literal 25838 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

d#<2>jMen+c&Kq#EHyv-PUGDM_etgI zIR0w98Nm354>!uGH?OsXXOb|F!ia}7+f#0kcIx6AORFoIO3yU@YSg|G|L{ z_qIcs1P$yeW>SI@M>InBd=9;+pr|S2XA`n<5Fv1&YSTrqT^IitrR*R38VSqEX_$c% z1p9h>k0R6{ehWcV#SkHZHd%D_po2eZpJ+rk@<7SAV?r`DERoY^I6z&U3E};!vojT6 zKzJ)%uxwjxFnSRGInqt>oD2+!@5ZVCW^x) zU{L3N4a7-%ww&HmK4+S<0JkjSj^ABc3I;=z3c}18+f{vzN3AJi|46tOrTLIPDF zf*%CHxMTnHA|aW%xw%2222-`1xD+}X1R4p*N4Ko*iLV{TU6Md5Jq#YTC5C!HK*E_w znwXf_eW>&jOjYk-C{8Y(ho2gzAt^jW*}`wH-C|-qa2%5e#$tp!;#c1aP#Dku8q*#D z2>?EEva?s95XWGyfAO4zpvl1qA-eN3Y7_Wn7dUcYG)Y6X^TVKLn5h(ta*0Zt?NA

r@7sC59Q|<%IMu9(H!#{z$E@BpDqG5-#4l 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 literal 0 HcmV?d00001 diff --git a/docs/extensions/guides/protocol-handlers.md b/docs/extensions/guides/protocol-handlers.md new file mode 100644 index 0000000000..8e13c8436a --- /dev/null +++ b/docs/extensions/guides/protocol-handlers.md @@ -0,0 +1,83 @@ +# Lens Protocol Handlers + +Lens has a file association with the `lens://` protocol. +This means that Lens can be opened by external programs by providing a link that has `lens` as its protocol. +Lens provides a routing mechanism that extensions can use to register custom handlers. + +## Registering A Protocol Handler + +The field `protocolHandlers` exists both on [`LensMainExtension`](extensions/api/classes/lensmainextension/#protocolhandlers) and on [`LensRendererExtension`](extensions/api/classes/lensrendererextension/#protocolhandlers). +This field will be iterated through every time a `lens://` request gets sent to the application. +The `pathSchema` argument must comply with the [path-to-regexp](https://www.npmjs.com/package/path-to-regexp) package's `compileToRegex` function. + +Once you have registered a handler it will be called when a user opens a link on their computer. +Handlers will be run in both `main` and `renderer` in parallel with no synchronization between the two processes. +Furthermore, both `main` and `renderer` are routed separately. +In other words, which handler is selected in either process is independent from the list of possible handlers in the other. + +Example of registering a handler: + +```typescript +import { LensMainExtension, Interface } from "@k8slens/extensions"; + +function rootHandler(params: Iterface.ProtocolRouteParams) { + console.log("routed to ExampleExtension", params); +} + +export default class ExampleExtensionMain extends LensMainExtension { + protocolHandlers = [ + pathSchema: "/", + handler: rootHandler, + ] +} +``` + +For testing the routing of URIs the `open` (on macOS) or `xdg-open` (on most linux) CLI utilities can be used. +For the above handler, the following URI would be always routed to it: + +``` +open lens://extension/example-extension/ +``` + +## Deregistering A Protocol Handler + +All that is needed to deregister a handler is to remove it from the array of handlers. + +## Routing Algorithm + +The routing mechanism for extensions is quite straight forward. +For example consider an extension `example-extension` which is published by the `@mirantis` org. +If it were to register a handler with `"/display/:type"` as its corresponding link then we would match the following URI like this: + +![Lens Protocol Link Resolution](images/routing-diag.png) + +Once matched, the handler would be called with the following argument (note both `"search"` and `"pathname"` will always be defined): + +```json +{ + "search": { + "text": "Hello" + }, + "pathname": { + "type": "notification" + } +} +``` + +As the diagram above shows, the search (or query) params are not considered as part of the handler resolution. +If the URI had instead been `lens://extension/@mirantis/example-extension/display/notification/green` then a third (and optional) field will have the rest of the path. +The `tail` field would be filled with `"/green"`. +If multiple `pathSchema`'s match a given URI then the most specific handler will be called. + +For example consider the following `pathSchema`'s: + +1. `"/"` +1. `"/display"` +1. `"/display/:type"` +1. `"/show/:id"` + +The URI sub-path `"/display"` would be routed to #2 since it is an exact match. +On the other hand, the subpath `"/display/notification"` would be routed to #3. + +The URI is routed to the most specific matching `pathSchema`. +This way the `"/"` (root) `pathSchema` acts as a sort of catch all or default route if no other route matches. diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index bfb990378d..ed6537bd76 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -28,6 +28,28 @@ Review the [System Requirements](../supporting/requirements.md) to check if your See the [Download Lens](https://github.com/lensapp/lens/releases) page for a complete list of available installation options. +After installing Lens manually (not using a package manager file such as `.deb` or `.rpm`) the following will need to be done to allow protocol handling. +This assumes that your linux distribution uses `xdg-open` and the `xdg-*` suite of programs for determining which application can handle custom URIs. + +1. Create a file called `lens.desktop` in either `~/.local/share/applications/` or `/usr/share/applications` (if you have permissions and are installing Lens for all users). +1. That file should have the following contents, with `` being the absolute path to where you have installed the unpacked `Lens` executable: + ``` + [Desktop Entry] + Name=Lens + Exec= %U + Terminal=false + Type=Application + Icon=lens + StartupWMClass=Lens + Comment=Lens - The Kubernetes IDE + MimeType=x-scheme-handler/lens; + Categories=Network; + ``` +1. Then run the following command: + ``` + xdg-settings set default-url-scheme-handler lens lens.desktop + ``` +1. If that succeeds (exits with code `0`) then your Lens install should be set up to handle `lens://` URIs. ### Snap @@ -52,4 +74,3 @@ To stay current with the Lens features, you can review the [release notes](https - [Add clusters](../clusters/adding-clusters.md) - [Watch introductory videos](./introductory-videos.md) - diff --git a/package.json b/package.json index e575e48520..3639366298 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "build:linux": "yarn run compile && electron-builder --linux --dir -c.productName=Lens", "build:mac": "yarn run compile && electron-builder --mac --dir -c.productName=Lens", "build:win": "yarn run compile && electron-builder --win --dir -c.productName=Lens", - "test": "jest --env=jsdom src $@", + "test": "scripts/test.sh", "integration": "jest --runInBand integration", "dist": "yarn run compile && electron-builder --publish onTag", "dist:win": "yarn run compile && electron-builder --publish onTag --x64 --ia32", @@ -170,7 +170,14 @@ "repo": "lens", "owner": "lensapp" } - ] + ], + "protocols": { + "name": "Lens Protocol Handler", + "schemes": [ + "lens" + ], + "role": "Viewer" + } }, "lens": { "extensions": [ @@ -187,6 +194,7 @@ "@hapi/call": "^8.0.0", "@hapi/subtext": "^7.0.3", "@kubernetes/client-node": "^0.12.0", + "abort-controller": "^3.0.0", "array-move": "^3.0.0", "await-lock": "^2.1.0", "byline": "^5.0.0", @@ -213,6 +221,7 @@ "mobx-observable-history": "^1.0.3", "mobx-react": "^6.2.2", "mock-fs": "^4.12.0", + "moment": "^2.26.0", "node-pty": "^0.9.0", "npm": "^6.14.8", "openid-client": "^3.15.2", @@ -232,6 +241,7 @@ "tar": "^6.0.5", "tcp-port-used": "^1.0.1", "tempy": "^0.5.0", + "url-parse": "^1.4.7", "uuid": "^8.3.2", "win-ca": "^3.2.0", "winston": "^3.2.1", @@ -289,6 +299,7 @@ "@types/tempy": "^0.3.0", "@types/terser-webpack-plugin": "^3.0.0", "@types/universal-analytics": "^0.4.4", + "@types/url-parse": "^1.4.3", "@types/uuid": "^8.3.0", "@types/webdriverio": "^4.13.0", "@types/webpack": "^4.41.17", @@ -325,10 +336,10 @@ "jest-mock-extended": "^1.0.10", "make-plural": "^6.2.2", "mini-css-extract-plugin": "^0.9.0", - "moment": "^2.26.0", "node-loader": "^0.6.0", "node-sass": "^4.14.1", "nodemon": "^2.0.4", + "open": "^7.3.1", "patch-package": "^6.2.2", "postinstall-postinstall": "^2.1.0", "prettier": "^2.2.0", diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000000..19c1f71c47 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1 @@ +jest --env=jsdom ${1:-src} diff --git a/src/common/ipc/ipc.ts b/src/common/ipc/ipc.ts index 48b0b89153..b104b31f4a 100644 --- a/src/common/ipc/ipc.ts +++ b/src/common/ipc/ipc.ts @@ -47,7 +47,7 @@ export async function broadcastMessage(channel: string, ...args: any[]) { view.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...args); } } catch (error) { - logger.error("[IPC]: failed to send IPC message", { error }); + logger.error("[IPC]: failed to send IPC message", { error: String(error) }); } } } diff --git a/src/common/protocol-handler/error.ts b/src/common/protocol-handler/error.ts new file mode 100644 index 0000000000..ebe7adccd7 --- /dev/null +++ b/src/common/protocol-handler/error.ts @@ -0,0 +1,36 @@ +import Url from "url-parse"; + +export enum RoutingErrorType { + INVALID_PROTOCOL = "invalid-protocol", + INVALID_HOST = "invalid-host", + INVALID_PATHNAME = "invalid-pathname", + NO_HANDLER = "no-handler", + NO_EXTENSION_ID = "no-ext-id", + MISSING_EXTENSION = "missing-ext", +} + +export class RoutingError extends Error { + /** + * Will be set if the routing error originated in an extension route table + */ + public extensionName?: string; + + constructor(public type: RoutingErrorType, public url: Url) { + super("routing error"); + } + + toString(): string { + switch (this.type) { + case RoutingErrorType.INVALID_HOST: + return "invalid host"; + case RoutingErrorType.INVALID_PROTOCOL: + return "invalid protocol"; + case RoutingErrorType.INVALID_PATHNAME: + return "invalid pathname"; + case RoutingErrorType.NO_EXTENSION_ID: + return "no extension ID"; + case RoutingErrorType.MISSING_EXTENSION: + return "extension not found"; + } + } +} diff --git a/src/common/protocol-handler/index.ts b/src/common/protocol-handler/index.ts new file mode 100644 index 0000000000..887f549507 --- /dev/null +++ b/src/common/protocol-handler/index.ts @@ -0,0 +1,2 @@ +export * from "./error"; +export * from "./router"; diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts new file mode 100644 index 0000000000..b18eb84368 --- /dev/null +++ b/src/common/protocol-handler/router.ts @@ -0,0 +1,218 @@ +import { match, matchPath } from "react-router"; +import { countBy } from "lodash"; +import { Singleton } from "../utils"; +import { pathToRegexp } from "path-to-regexp"; +import logger from "../../main/logger"; +import Url from "url-parse"; +import { RoutingError, RoutingErrorType } from "./error"; +import { extensionsStore } from "../../extensions/extensions-store"; +import { extensionLoader } from "../../extensions/extension-loader"; +import { LensExtension } from "../../extensions/lens-extension"; +import { RouteHandler, RouteParams } from "../../extensions/registries/protocol-handler-registry"; + +// IPC channel for protocol actions. Main broadcasts the open-url events to this channel. +export const ProtocolHandlerIpcPrefix = "protocol-handler"; + +export const ProtocolHandlerInternal = `${ProtocolHandlerIpcPrefix}:internal`; +export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`; + +/** + * These two names are long and cumbersome by design so as to decrease the chances + * of an extension using the same names. + * + * Though under the current (2021/01/18) implementation, these are never matched + * against in the final matching so their names are less of a concern. + */ +const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH"; +const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH"; + +export abstract class LensProtocolRouter extends Singleton { + // Map between path schemas and the handlers + protected internalRoutes = new Map(); + + public static readonly LoggingPrefix = "[PROTOCOL ROUTER]"; + + protected static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; + + /** + * + * @param url the parsed URL that initiated the `lens://` protocol + */ + protected _routeToInternal(url: Url): void { + this._route(Array.from(this.internalRoutes.entries()), url); + } + + /** + * match against all matched URIs, returning either the first exact match or + * the most specific match if none are exact. + * @param routes the array of path schemas, handler pairs to match against + * @param url the url (in its current state) + */ + protected _findMatchingRoute(routes: [string, RouteHandler][], url: Url): null | [match>, RouteHandler] { + const matches: [match>, RouteHandler][] = []; + + for (const [schema, handler] of routes) { + const match = matchPath(url.pathname, { path: schema }); + + if (!match) { + continue; + } + + // prefer an exact match + if (match.isExact) { + return [match, handler]; + } + + matches.push([match, handler]); + } + + // if no exact match pick the one that is the most specific + return matches.sort(([a], [b]) => compareMatches(a, b))[0] ?? null; + } + + /** + * find the most specific matching handler and call it + * @param routes the array of (path schemas, handler) paris to match against + * @param url the url (in its current state) + */ + protected _route(routes: [string, RouteHandler][], url: Url, extensionName?: string): void { + const route = this._findMatchingRoute(routes, url); + + if (!route) { + const data: Record = { url: url.toString() }; + + if (extensionName) { + data.extensionName = extensionName; + } + + return void logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data); + } + + const [match, handler] = route; + + const params: RouteParams = { + pathname: match.params, + search: url.query, + }; + + if (!match.isExact) { + params.tail = url.pathname.slice(match.url.length); + } + + handler(params); + } + + /** + * Tries to find the matching LensExtension instance + * + * Note: this needs to be async so that `main`'s overloaded version can also be async + * @param url the protocol request URI that was "open"-ed + * @returns either the found name or the instance of `LensExtension` + */ + protected async _findMatchingExtensionByName(url: Url): Promise { + interface ExtensionUrlMatch { + [EXTENSION_PUBLISHER_MATCH]: string; + [EXTENSION_NAME_MATCH]: string; + } + + const match = matchPath(url.pathname, LensProtocolRouter.ExtensionUrlSchema); + + if (!match) { + throw new RoutingError(RoutingErrorType.NO_EXTENSION_ID, url); + } + + const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params; + const name = [publisher, partialName].filter(Boolean).join("/"); + + const extension = extensionLoader.userExtensionsByName.get(name); + + if (!extension) { + logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed`); + + return name; + } + + if (!extensionsStore.isEnabled(extension.id)) { + logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not enabled`); + + return name; + } + + logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched`); + + return extension; + } + + /** + * Find a matching extension by the first one or two path segments of `url` and then try to `_route` + * its correspondingly registered handlers. + * + * If no handlers are found or the extension is not enabled then `_missingHandlers` is called before + * checking if more handlers have been added. + * + * Note: this function modifies its argument, do not reuse + * @param url the protocol request URI that was "open"-ed + */ + protected async _routeToExtension(url: Url): Promise { + const extension = await this._findMatchingExtensionByName(url); + + if (typeof extension === "string") { + // failed to find an extension, it returned its name + return; + } + + // remove the extension name from the path name so we don't need to match on it anymore + url.set("pathname", url.pathname.slice(extension.name.length + 1)); + + const handlers = extension + .protocolHandlers + .map<[string, RouteHandler]>(({ pathSchema, handler }) => [pathSchema, handler]); + + try { + this._route(handlers, url, extension.name); + } catch (error) { + if (error instanceof RoutingError) { + error.extensionName = extension.name; + } + + throw error; + } + } + + /** + * Add a handler under the `lens://app` tree of routing. + * @param pathSchema the URI path schema to match against for this handler + * @param handler a function that will be called if a protocol path matches + */ + public addInternalHandler(urlSchema: string, handler: RouteHandler): void { + pathToRegexp(urlSchema); // verify now that the schema is valid + logger.info(`${LensProtocolRouter.LoggingPrefix}: internal registering ${urlSchema}`); + this.internalRoutes.set(urlSchema, handler); + } + + /** + * Remove an internal protocol handler. + * @param pathSchema the path schema that the handler was registered under + */ + public removeInternalHandler(urlSchema: string): void { + this.internalRoutes.delete(urlSchema); + } +} + +/** + * a comparison function for `array.sort(...)`. Sort order should be most path + * parts to least path parts. + * @param a the left side to compare + * @param b the right side to compare + */ +function compareMatches(a: match, b: match): number { + if (a.path === "/") { + return 1; + } + + if (b.path === "/") { + return -1; + } + + return countBy(b.path)["/"] - countBy(a.path)["/"]; +} diff --git a/src/common/utils/delay.ts b/src/common/utils/delay.ts index 7d0686d29b..f19839538a 100644 --- a/src/common/utils/delay.ts +++ b/src/common/utils/delay.ts @@ -1,8 +1,19 @@ +import { AbortController } from "abort-controller"; + /** * Return a promise that will be resolved after at least `timeout` ms have - * passed + * passed. If `failFast` is provided then the promise is also resolved if it has + * been aborted. * @param timeout The number of milliseconds before resolving + * @param failFast An abort controller instance to cause the delay to short-circuit */ -export function delay(timeout = 1000): Promise { - return new Promise(resolve => setTimeout(resolve, timeout)); +export function delay(timeout = 1000, failFast?: AbortController): Promise { + return new Promise(resolve => { + const timeoutId = setTimeout(resolve, timeout); + + failFast?.signal.addEventListener("abort", () => { + clearTimeout(timeoutId); + resolve(); + }); + }); } diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 2b8147fad9..6f26bab2da 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -18,4 +18,4 @@ export * from "./openExternal"; export * from "./downloadFile"; export * from "./escapeRegExp"; export * from "./tar"; -export * from "./delay"; +export * from "./type-narrowing"; diff --git a/src/common/utils/type-narrowing.ts b/src/common/utils/type-narrowing.ts new file mode 100644 index 0000000000..6a239c43ee --- /dev/null +++ b/src/common/utils/type-narrowing.ts @@ -0,0 +1,13 @@ +/** + * Narrows `val` to include the property `key` (if true is returned) + * @param val The object to be tested + * @param key The key to test if it is present on the object + */ +export function hasOwnProperty(val: V, key: K): val is (V & { [key in K]: unknown }) { + // this call syntax is for when `val` was created by `Object.create(null)` + return Object.prototype.hasOwnProperty.call(val, key); +} + +export function hasOwnProperties(val: V, ...keys: K[]): val is (V & { [key in K]: unknown}) { + return keys.every(key => hasOwnProperty(val, key)); +} diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 98697d252c..b4aedaf274 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -14,7 +14,7 @@ import type { LensRendererExtension } from "./lens-renderer-extension"; import * as registries from "./registries"; import fs from "fs"; -// lazy load so that we get correct userData + export function extensionPackagesRoot() { return path.join((app || remote.app).getPath("userData")); } @@ -52,6 +52,30 @@ export class ExtensionLoader { return extensions; } + @computed get userExtensionsByName(): Map { + const extensions = new Map(); + + for (const [, val] of this.instances.toJS()) { + if (val.isBundled) { + continue; + } + + extensions.set(val.manifest.name, val); + } + + return extensions; + } + + getExtensionByName(name: string): LensExtension | null { + for (const [, val] of this.instances) { + if (val.name === name) { + return val; + } + } + + return null; + } + // Transform userExtensions to a state object for storing into ExtensionsStore @computed get storeState() { return Object.fromEntries( @@ -102,7 +126,6 @@ export class ExtensionLoader { } catch (error) { logger.error(`${logModule}: deactivation extension error`, { lensExtensionId, error }); } - } removeExtension(lensExtensionId: LensExtensionId) { diff --git a/src/extensions/extensions-store.ts b/src/extensions/extensions-store.ts index 0885bbb730..8e88d22f38 100644 --- a/src/extensions/extensions-store.ts +++ b/src/extensions/extensions-store.ts @@ -27,7 +27,7 @@ export class ExtensionsStore extends BaseStore { protected state = observable.map(); - isEnabled(extId: LensExtensionId) { + isEnabled(extId: LensExtensionId): boolean { const state = this.state.get(extId); // By default false, so that copied extensions are disabled by default. diff --git a/src/extensions/interfaces/registrations.ts b/src/extensions/interfaces/registrations.ts index ff51d9a824..10a55d1b78 100644 --- a/src/extensions/interfaces/registrations.ts +++ b/src/extensions/interfaces/registrations.ts @@ -6,3 +6,4 @@ export type { KubeObjectStatusRegistration } from "../registries/kube-object-sta export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry"; export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry"; export type { StatusBarRegistration } from "../registries/status-bar-registry"; +export type { ProtocolHandlerRegistration, RouteParams as ProtocolRouteParams, RouteHandler as ProtocolRouteHandler } from "../registries/protocol-handler-registry"; diff --git a/src/extensions/lens-extension.ts b/src/extensions/lens-extension.ts index aaa6f60ac5..a00e289e17 100644 --- a/src/extensions/lens-extension.ts +++ b/src/extensions/lens-extension.ts @@ -2,6 +2,7 @@ import type { InstalledExtension } from "./extension-discovery"; import { action, observable, reaction } from "mobx"; import { filesystemProvisionerStore } from "../main/extension-filesystem"; import logger from "../main/logger"; +import { ProtocolHandlerRegistration } from "./registries/protocol-handler-registry"; export type LensExtensionId = string; // path to manifest (package.json) export type LensExtensionConstructor = new (...args: ConstructorParameters) => LensExtension; @@ -21,6 +22,8 @@ export class LensExtension { readonly manifestPath: string; readonly isBundled: boolean; + protocolHandlers: ProtocolHandlerRegistration[] = []; + @observable private isEnabled = false; constructor({ id, manifest, manifestPath, isBundled }: InstalledExtension) { diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 8b9b132114..982830d8af 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -31,8 +31,7 @@ export class LensRendererExtension extends LensExtension { /** * Defines if extension is enabled for a given cluster. Defaults to `true`. */ - // eslint-disable-next-line unused-imports/no-unused-vars-ts async isEnabledForCluster(cluster: Cluster): Promise { - return true; + return (void cluster) || true; } } diff --git a/src/extensions/registries/protocol-handler-registry.ts b/src/extensions/registries/protocol-handler-registry.ts new file mode 100644 index 0000000000..dd637818a3 --- /dev/null +++ b/src/extensions/registries/protocol-handler-registry.ts @@ -0,0 +1,44 @@ +/** + * ProtocolHandlerRegistration is the data required for an extension to register + * a handler to a specific path or dynamic path. + */ +export interface ProtocolHandlerRegistration { + pathSchema: string; + handler: RouteHandler; +} + +/** + * The collection of the dynamic parts of a URI which initiated a `lens://` + * protocol request + */ +export interface RouteParams { + /** + * the parts of the URI query string + */ + search: Record; + + /** + * the matching parts of the path. The dynamic parts of the URI path. + */ + pathname: Record; + + /** + * if the most specific path schema that is matched does not cover the whole + * of the URI's path. Then this field will be set to the remaining path + * segments. + * + * Example: + * + * If the path schema `/landing/:type` is the matched schema for the URI + * `/landing/soft/easy` then this field will be set to `"/easy"`. + */ + tail?: string; +} + +/** + * RouteHandler represents the function signature of the handler function for + * `lens://` protocol routing. + */ +export interface RouteHandler { + (params: RouteParams): void; +} diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index ed2bf4250b..9893abfa81 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -4,6 +4,7 @@ import { isDevelopment, isTestEnv } from "../common/vars"; import { delay } from "../common/utils"; import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc"; import { ipcMain } from "electron"; +import { once } from "lodash"; let installVersion: null | string = null; @@ -28,7 +29,7 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd * starts the automatic update checking * @param interval milliseconds between interval to check on, defaults to 24h */ -export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void { +export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24): void { if (isDevelopment || isTestEnv) { return; } @@ -83,7 +84,7 @@ export function startUpdateChecking(interval = 1000 * 60 * 60 * 24): void { } helper(); -} +}); export async function checkForUpdates(): Promise { try { diff --git a/src/main/index.ts b/src/main/index.ts index c98595fa35..ddee53bfea 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -4,7 +4,7 @@ import "../common/system-ca"; import "../common/prometheus-providers"; import * as Mobx from "mobx"; import * as LensExtensions from "../extensions/core-api"; -import { app, autoUpdater, dialog, powerMonitor } from "electron"; +import { app, autoUpdater, ipcMain, dialog, powerMonitor } from "electron"; import { appName } from "../common/vars"; import path from "path"; import { LensProxy } from "./lens-proxy"; @@ -25,6 +25,7 @@ import { InstalledExtension, extensionDiscovery } from "../extensions/extension- import type { LensExtensionId } from "../extensions/lens-extension"; import { installDeveloperTools } from "./developer-tools"; import { filesystemProvisionerStore } from "./extension-filesystem"; +import { LensProtocolRouterMain } from "./protocol-handler"; import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; import { bindBroadcastHandlers } from "../common/ipc"; import { startUpdateChecking } from "./app-updater"; @@ -37,30 +38,54 @@ let windowManager: WindowManager; app.setName(appName); +logger.info("📟 Setting as Lens as protocol client for lens://"); + +if (app.setAsDefaultProtocolClient("lens")) { + logger.info("📟 succeeded ✅"); +} else { + logger.info("📟 failed ❗"); +} + if (!process.env.CICD) { app.setPath("userData", workingDir); } +if (process.env.LENS_DISABLE_GPU) { + app.disableHardwareAcceleration(); +} + mangleProxyEnv(); if (app.commandLine.getSwitchValue("proxy-server") !== "") { process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server"); } -const instanceLock = app.requestSingleInstanceLock(); - -if (!instanceLock) { +if (!app.requestSingleInstanceLock()) { app.exit(); +} else { + const lprm = LensProtocolRouterMain.getInstance(); + + for (const arg of process.argv) { + if (arg.toLowerCase().startsWith("lens://")) { + lprm.route(arg) + .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg })); + } + } } -app.on("second-instance", () => { +app.on("second-instance", (event, argv) => { + const lprm = LensProtocolRouterMain.getInstance(); + + for (const arg of argv) { + if (arg.toLowerCase().startsWith("lens://")) { + lprm.route(arg) + .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg })); + } + } + windowManager?.ensureMainWindow(); }); -if (process.env.LENS_DISABLE_GPU) { - app.disableHardwareAcceleration(); -} - app.on("ready", async () => { logger.info(`🚀 Starting Lens from "${workingDir}"`); logger.info("🐚 Syncing shell environment"); @@ -128,7 +153,19 @@ app.on("ready", async () => { logger.info("🖥️ Starting WindowManager"); windowManager = WindowManager.getInstance(proxyPort); - windowManager.whenLoaded.then(() => startUpdateChecking()); + + ipcMain.on("renderer:loaded", () => { + startUpdateChecking(); + LensProtocolRouterMain + .getInstance() + .rendererLoaded = true; + }); + + extensionLoader.whenLoaded.then(() => { + LensProtocolRouterMain + .getInstance() + .extensionsLoaded = true; + }); logger.info("🧩 Initializing extensions"); @@ -174,8 +211,8 @@ let blockQuit = true; autoUpdater.on("before-quit-for-update", () => blockQuit = false); -// Quit app on Cmd+Q (MacOS) app.on("will-quit", (event) => { + // Quit app on Cmd+Q (MacOS) logger.info("APP:QUIT"); appEventBus.emit({name: "app", action: "close"}); @@ -188,6 +225,16 @@ app.on("will-quit", (event) => { } }); +app.on("open-url", (event, rawUrl) => { + // lens:// protocol handler + event.preventDefault(); + + LensProtocolRouterMain + .getInstance() + .route(rawUrl) + .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl })); +}); + // Extensions-api runtime exports export const LensExtensionsApi = { ...LensExtensions, diff --git a/src/main/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts new file mode 100644 index 0000000000..6b3f668079 --- /dev/null +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -0,0 +1,259 @@ +import { LensProtocolRouterMain } from "../router"; +import { noop } from "../../../common/utils"; +import { extensionsStore } from "../../../extensions/extensions-store"; +import { extensionLoader } from "../../../extensions/extension-loader"; +import * as uuid from "uuid"; +import { LensMainExtension } from "../../../extensions/core-api"; +import { broadcastMessage } from "../../../common/ipc"; +import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler"; + +jest.mock("../../../common/ipc"); + +function throwIfDefined(val: any): void { + if (val != null) { + throw val; + } +} + +describe("protocol router tests", () => { + let lpr: LensProtocolRouterMain; + + beforeEach(() => { + jest.clearAllMocks(); + (extensionsStore as any).state.clear(); + (extensionLoader as any).instances.clear(); + LensProtocolRouterMain.resetInstance(); + lpr = LensProtocolRouterMain.getInstance(); + lpr.extensionsLoaded = true; + lpr.rendererLoaded = true; + }); + + it("should throw on non-lens URLS", async () => { + try { + expect(await lpr.route("https://google.ca")).toBeUndefined(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); + + it("should throw when host not internal or extension", async () => { + try { + expect(await lpr.route("lens://foobar")).toBeUndefined(); + } catch (error) { + expect(error).toBeInstanceOf(Error); + } + }); + + it("should not throw when has valid host", async () => { + const extId = uuid.v4(); + const ext = new LensMainExtension({ + id: extId, + manifestPath: "/foo/bar", + manifest: { + name: "@mirantis/minikube", + version: "0.1.1", + }, + isBundled: false, + isEnabled: true, + absolutePath: "/foo/bar", + }); + + ext.protocolHandlers.push({ + pathSchema: "/", + handler: noop, + }); + + (extensionLoader as any).instances.set(extId, ext); + (extensionsStore as any).state.set(extId, { enabled: true, name: "@mirantis/minikube" }); + + lpr.addInternalHandler("/", noop); + + try { + expect(await lpr.route("lens://app")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + + try { + expect(await lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(broadcastMessage).toHaveBeenNthCalledWith(1, ProtocolHandlerInternal, "lens://app"); + expect(broadcastMessage).toHaveBeenNthCalledWith(2, ProtocolHandlerExtension, "lens://extension/@mirantis/minikube"); + }); + + it("should call handler if matches", async () => { + let called = false; + + lpr.addInternalHandler("/page", () => { called = true; }); + + try { + expect(await lpr.route("lens://app/page")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe(true); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page"); + }); + + it("should call most exact handler", async () => { + let called: any = 0; + + lpr.addInternalHandler("/page", () => { called = 1; }); + lpr.addInternalHandler("/page/:id", params => { called = params.pathname.id; }); + + try { + expect(await lpr.route("lens://app/page/foo")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe("foo"); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo"); + }); + + it("should call most exact handler for an extension", async () => { + let called: any = 0; + + const extId = uuid.v4(); + const ext = new LensMainExtension({ + id: extId, + manifestPath: "/foo/bar", + manifest: { + name: "@foobar/icecream", + version: "0.1.1", + }, + isBundled: false, + isEnabled: true, + absolutePath: "/foo/bar", + }); + + ext.protocolHandlers + .push({ + pathSchema: "/page", + handler: () => { called = 1; }, + }, { + pathSchema: "/page/:id", + handler: params => { called = params.pathname.id; }, + }); + + (extensionLoader as any).instances.set(extId, ext); + (extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); + + try { + expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe("foob"); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/@foobar/icecream/page/foob"); + }); + + it("should work with non-org extensions", async () => { + let called: any = 0; + + { + const extId = uuid.v4(); + const ext = new LensMainExtension({ + id: extId, + manifestPath: "/foo/bar", + manifest: { + name: "@foobar/icecream", + version: "0.1.1", + }, + isBundled: false, + isEnabled: true, + absolutePath: "/foo/bar", + }); + + ext.protocolHandlers + .push({ + pathSchema: "/page/:id", + handler: params => { called = params.pathname.id; }, + }); + + (extensionLoader as any).instances.set(extId, ext); + (extensionsStore as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); + } + + { + const extId = uuid.v4(); + const ext = new LensMainExtension({ + id: extId, + manifestPath: "/foo/bar", + manifest: { + name: "icecream", + version: "0.1.1", + }, + isBundled: false, + isEnabled: true, + absolutePath: "/foo/bar", + }); + + ext.protocolHandlers + .push({ + pathSchema: "/page", + handler: () => { called = 1; }, + }); + + (extensionLoader as any).instances.set(extId, ext); + (extensionsStore as any).state.set(extId, { enabled: true, name: "icecream" }); + } + + (extensionsStore as any).state.set("@foobar/icecream", { enabled: true, name: "@foobar/icecream" }); + (extensionsStore as any).state.set("icecream", { enabled: true, name: "icecream" }); + + try { + expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe(1); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/icecream/page"); + }); + + it("should throw if urlSchema is invalid", () => { + expect(() => lpr.addInternalHandler("/:@", noop)).toThrowError(); + }); + + it("should call most exact handler with 3 found handlers", async () => { + let called: any = 0; + + lpr.addInternalHandler("/", () => { called = 2; }); + lpr.addInternalHandler("/page", () => { called = 1; }); + lpr.addInternalHandler("/page/foo", () => { called = 3; }); + lpr.addInternalHandler("/page/bar", () => { called = 4; }); + + try { + expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe(3); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat"); + }); + + it("should call most exact handler with 2 found handlers", async () => { + let called: any = 0; + + lpr.addInternalHandler("/", () => { called = 2; }); + lpr.addInternalHandler("/page", () => { called = 1; }); + lpr.addInternalHandler("/page/bar", () => { called = 4; }); + + try { + expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); + } catch (error) { + expect(throwIfDefined(error)).not.toThrow(); + } + + expect(called).toBe(1); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat"); + }); +}); diff --git a/src/main/protocol-handler/index.ts b/src/main/protocol-handler/index.ts new file mode 100644 index 0000000000..9ca8201129 --- /dev/null +++ b/src/main/protocol-handler/index.ts @@ -0,0 +1 @@ +export * from "./router"; diff --git a/src/main/protocol-handler/router.ts b/src/main/protocol-handler/router.ts new file mode 100644 index 0000000000..20962372b2 --- /dev/null +++ b/src/main/protocol-handler/router.ts @@ -0,0 +1,112 @@ +import logger from "../logger"; +import * as proto from "../../common/protocol-handler"; +import Url from "url-parse"; +import { LensExtension } from "../../extensions/lens-extension"; +import { broadcastMessage } from "../../common/ipc"; +import { observable, when } from "mobx"; + +export interface FallbackHandler { + (name: string): Promise; +} + +export class LensProtocolRouterMain extends proto.LensProtocolRouter { + private missingExtensionHandlers: FallbackHandler[] = []; + + @observable rendererLoaded = false; + @observable extensionsLoaded = false; + + /** + * Find the most specific registered handler, if it exists, and invoke it. + * + * This will send an IPC message to the renderer router to do the same + * in the renderer. + */ + public async route(rawUrl: string): Promise { + try { + const url = new Url(rawUrl, true); + + if (url.protocol.toLowerCase() !== "lens:") { + throw new proto.RoutingError(proto.RoutingErrorType.INVALID_PROTOCOL, url); + } + + logger.info(`${proto.LensProtocolRouter.LoggingPrefix}: routing ${url.toString()}`); + + switch (url.host) { + case "app": + return this._routeToInternal(url); + case "extension": + await when(() => this.extensionsLoaded); + + return this._routeToExtension(url); + default: + throw new proto.RoutingError(proto.RoutingErrorType.INVALID_HOST, url); + } + + } catch (error) { + if (error instanceof proto.RoutingError) { + logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url }); + } else { + logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { rawUrl }); + } + } + } + + protected async _executeMissingExtensionHandlers(extensionName: string): Promise { + for (const handler of this.missingExtensionHandlers) { + if (await handler(extensionName)) { + return true; + } + } + + return false; + } + + protected async _findMatchingExtensionByName(url: Url): Promise { + const firstAttempt = await super._findMatchingExtensionByName(url); + + if (typeof firstAttempt !== "string") { + return firstAttempt; + } + + if (await this._executeMissingExtensionHandlers(firstAttempt)) { + return super._findMatchingExtensionByName(url); + } + + return ""; + } + + protected async _routeToInternal(url: Url): Promise { + const rawUrl = url.toString(); // for sending to renderer + + super._routeToInternal(url); + + await when(() => this.rendererLoaded); + + return broadcastMessage(proto.ProtocolHandlerInternal, rawUrl); + } + + protected async _routeToExtension(url: Url): Promise { + const rawUrl = url.toString(); // for sending to renderer + + /** + * This needs to be done first, so that the missing extension handlers can + * be called before notifying the renderer. + * + * Note: this needs to clone the url because _routeToExtension modifies its + * argument. + */ + await super._routeToExtension(new Url(url.toString(), true)); + await when(() => this.rendererLoaded); + + return broadcastMessage(proto.ProtocolHandlerExtension, rawUrl); + } + + /** + * Add a function to the list which will be sequentially called if an extension + * is not found while routing to the extensions + * @param handler A function that tries to find an extension + */ + public addMissingExtensionHandler(handler: FallbackHandler): void { + this.missingExtensionHandlers.push(handler); + } +} diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index c092e186cb..691aa7c66b 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -1,5 +1,5 @@ import type { ClusterId } from "../common/cluster-store"; -import { observable, when } from "mobx"; +import { observable } from "mobx"; import { app, BrowserWindow, dialog, shell, webContents } from "electron"; import windowStateKeeper from "electron-window-state"; import { appEventBus } from "../common/event-bus"; @@ -16,9 +16,6 @@ export class WindowManager extends Singleton { protected windowState: windowStateKeeper.State; protected disposers: Record = {}; - @observable mainViewInitiallyLoaded = false; - whenLoaded = when(() => this.mainViewInitiallyLoaded); - @observable activeClusterId: ClusterId; constructor(protected proxyPort: number) { @@ -104,7 +101,6 @@ export class WindowManager extends Singleton { setTimeout(() => { appEventBus.emit({ name: "app", action: "start" }); }, 1000); - this.mainViewInitiallyLoaded = true; } catch (err) { dialog.showErrorBox("ERROR!", err.toString()); } diff --git a/src/renderer/lens-app.tsx b/src/renderer/lens-app.tsx index 963bd43e4e..c6f55872a9 100644 --- a/src/renderer/lens-app.tsx +++ b/src/renderer/lens-app.tsx @@ -12,12 +12,15 @@ import { ConfirmDialog } from "./components/confirm-dialog"; import { extensionLoader } from "../extensions/extension-loader"; import { broadcastMessage } from "../common/ipc"; import { CommandContainer } from "./components/command-palette/command-container"; +import { LensProtocolRouterRenderer } from "./protocol-handler/router"; import { registerIpcHandlers } from "./ipc"; +import { ipcRenderer } from "electron"; @observer export class LensApp extends React.Component { static async init() { extensionLoader.loadOnClusterManagerRenderer(); + LensProtocolRouterRenderer.getInstance().init(); window.addEventListener("offline", () => { broadcastMessage("network:offline"); }); @@ -26,6 +29,7 @@ export class LensApp extends React.Component { }); registerIpcHandlers(); + ipcRenderer.send("renderer:loaded"); } render() { diff --git a/src/renderer/navigation/index.ts b/src/renderer/navigation/index.ts index 94930fc994..adf2577f4e 100644 --- a/src/renderer/navigation/index.ts +++ b/src/renderer/navigation/index.ts @@ -1,8 +1,10 @@ // Navigation (renderer) import { bindEvents } from "./events"; +import { bindProtocolHandlers } from "./protocol-handlers"; export * from "./history"; export * from "./helpers"; bindEvents(); +bindProtocolHandlers(); diff --git a/src/renderer/navigation/protocol-handlers.ts b/src/renderer/navigation/protocol-handlers.ts new file mode 100644 index 0000000000..423cc70fd0 --- /dev/null +++ b/src/renderer/navigation/protocol-handlers.ts @@ -0,0 +1,10 @@ +import { LensProtocolRouterRenderer } from "../protocol-handler/router"; +import { navigate } from "./helpers"; + +export function bindProtocolHandlers() { + const lprr = LensProtocolRouterRenderer.getInstance(); + + lprr.addInternalHandler("/preferences", () => { + navigate("/preferences"); + }); +} diff --git a/src/renderer/protocol-handler/index.ts b/src/renderer/protocol-handler/index.ts new file mode 100644 index 0000000000..d18015da88 --- /dev/null +++ b/src/renderer/protocol-handler/index.ts @@ -0,0 +1 @@ +export * from "./router.ts"; diff --git a/src/renderer/protocol-handler/router.ts b/src/renderer/protocol-handler/router.ts new file mode 100644 index 0000000000..d1dc0ceafd --- /dev/null +++ b/src/renderer/protocol-handler/router.ts @@ -0,0 +1,40 @@ +import { ipcRenderer } from "electron"; +import * as proto from "../../common/protocol-handler"; +import logger from "../../main/logger"; +import Url from "url-parse"; +import { autobind } from "../utils"; + +export class LensProtocolRouterRenderer extends proto.LensProtocolRouter { + /** + * This function is needed to be called early on in the renderers lifetime. + */ + public init(): void { + ipcRenderer + .on(proto.ProtocolHandlerInternal, this.ipcInternalHandler) + .on(proto.ProtocolHandlerExtension, this.ipcExtensionHandler); + } + + @autobind() + private ipcInternalHandler(event: Electron.IpcRendererEvent, ...args: any[]): void { + if (args.length !== 1) { + return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: unexpected number of args`, { args }); + } + + const [rawUrl] = args; + const url = new Url(rawUrl, true); + + this._routeToInternal(url); + } + + @autobind() + private ipcExtensionHandler(event: Electron.IpcRendererEvent, ...args: any[]): void { + if (args.length !== 1) { + return void logger.warn(`${proto.LensProtocolRouter.LoggingPrefix}: unexpected number of args`, { args }); + } + + const [rawUrl] = args; + const url = new Url(rawUrl, true); + + this._routeToExtension(url); + } +} diff --git a/src/renderer/utils/index.ts b/src/renderer/utils/index.ts index 517fd8f359..e546f94154 100755 --- a/src/renderer/utils/index.ts +++ b/src/renderer/utils/index.ts @@ -1,7 +1,5 @@ // Common usage utils & helpers -export const isElectron = !!navigator.userAgent.match(/Electron/); - export * from "../../common/utils"; export * from "./cssVar"; diff --git a/yarn.lock b/yarn.lock index 2cc97b2383..bc2e606b71 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1771,6 +1771,11 @@ resolved "https://registry.yarnpkg.com/@types/universal-analytics/-/universal-analytics-0.4.4.tgz#496a52b92b599a0112bec7c12414062de6ea8449" integrity sha512-9g3F0SGxVr4UDd6y07bWtFnkpSSX1Ake7U7AGHgSFrwM6pF53/fV85bfxT2JLWS/3sjLCcyzoYzQlCxpkVo7wA== +"@types/url-parse@^1.4.3": + version "1.4.3" + resolved "https://registry.yarnpkg.com/@types/url-parse/-/url-parse-1.4.3.tgz#fba49d90f834951cb000a674efee3d6f20968329" + integrity sha512-4kHAkbV/OfW2kb5BLVUuUMoumB3CP8rHqlw48aHvFy5tf9ER0AfOonBlX29l/DD68G70DmyhRlSYfQPSYpC5Vw== + "@types/uuid@^8.3.0": version "8.3.0" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" @@ -2137,6 +2142,13 @@ abbrev@1, abbrev@~1.1.1: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8" integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7: version "1.3.7" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" @@ -5336,6 +5348,11 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^4.0.0: version "4.0.4" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.4.tgz#b5463ace635a083d018bdc7c917b4c5f10a85384" @@ -10092,6 +10109,14 @@ onetime@^5.1.0: dependencies: mimic-fn "^2.1.0" +open@^7.3.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/open/-/open-7.3.1.tgz#111119cb919ca1acd988f49685c4fdd0f4755356" + integrity sha512-f2wt9DCBKKjlFbjzGb8MOAW8LH8F0mrs1zc7KTjAJ9PZNQbfenzWbNP1VZJvw6ICMG9r14Ah6yfwPn7T7i646A== + dependencies: + is-docker "^2.0.0" + is-wsl "^2.1.1" + opener@^1.5.1: version "1.5.2" resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" @@ -13754,7 +13779,7 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" -url-parse@^1.4.3: +url-parse@^1.4.3, url-parse@^1.4.7: version "1.4.7" resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.4.7.tgz#a8a83535e8c00a316e403a5db4ac1b9b853ae278" integrity sha512-d3uaVyzDB9tQoSXFvuSUNFibTd9zxd2bkVrDRvF5TmvWWQwqE4lgYJ5m+x1DbecWkw+LK4RNl2CU1hHuOKPVlg== From 3acbdcfd6049227a8ef8f7ddbd5bc084eb3922d9 Mon Sep 17 00:00:00 2001 From: pashevskii <53330707+pashevskii@users.noreply.github.com> Date: Thu, 25 Feb 2021 19:09:07 +0400 Subject: [PATCH 05/26] Extract chart version ignoring numbers in chart name (#2226) Signed-off-by: Pavel Ashevskiy Co-authored-by: Pavel Ashevskiy --- src/renderer/api/endpoints/helm-releases.api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/api/endpoints/helm-releases.api.ts b/src/renderer/api/endpoints/helm-releases.api.ts index fb3eae1a79..9a5a6dadd0 100644 --- a/src/renderer/api/endpoints/helm-releases.api.ts +++ b/src/renderer/api/endpoints/helm-releases.api.ts @@ -187,7 +187,7 @@ export class HelmRelease implements ItemObject { } getVersion() { - const versions = this.chart.match(/(v?\d+)[^-].*$/); + const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/); if (versions) { return versions[0]; From 090a3c2bf5172c71dd5c4094c8c9a524b2345f16 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 26 Feb 2021 07:34:54 +0200 Subject: [PATCH 06/26] Flush response headers always when proxy gets a response (#2229) * flush response header always when proxy gets a response Signed-off-by: Jari Kolehmainen * force flush only when watch param exists Signed-off-by: Jari Kolehmainen --- src/main/lens-proxy.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/lens-proxy.ts b/src/main/lens-proxy.ts index 177e4d11d2..7e1aa98b7e 100644 --- a/src/main/lens-proxy.ts +++ b/src/main/lens-proxy.ts @@ -120,12 +120,18 @@ export class LensProxy { protected createProxy(): httpProxy { const proxy = httpProxy.createProxyServer(); - proxy.on("proxyRes", (proxyRes, req) => { + proxy.on("proxyRes", (proxyRes, req, res) => { const retryCounterId = this.getRequestId(req); if (this.retryCounters.has(retryCounterId)) { this.retryCounters.delete(retryCounterId); } + + if (!res.headersSent && req.url) { + const url = new URL(req.url, "http://localhost"); + + if (url.searchParams.has("watch")) res.flushHeaders(); + } }); proxy.on("error", (error, req, res, target) => { From 5a9bf6c55abc8c4fc2e5f79c95e68951bfc93722 Mon Sep 17 00:00:00 2001 From: Christoph Meier Date: Fri, 26 Feb 2021 07:39:54 +0100 Subject: [PATCH 07/26] Fix(rbac): pdp should have policy group (#2132) For rbac the `PodDisruptionBudget` should use the `policy` group. Signed-off-by: Christoph MEIER --- src/common/rbac.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/rbac.ts b/src/common/rbac.ts index de242b114a..7d02e3be51 100644 --- a/src/common/rbac.ts +++ b/src/common/rbac.ts @@ -31,7 +31,7 @@ export const apiResources: KubeApiResource[] = [ { kind: "PersistentVolume", apiName: "persistentvolumes" }, { kind: "PersistentVolumeClaim", apiName: "persistentvolumeclaims" }, { kind: "Pod", apiName: "pods" }, - { kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets" }, + { kind: "PodDisruptionBudget", apiName: "poddisruptionbudgets", group: "policy" }, { kind: "PodSecurityPolicy", apiName: "podsecuritypolicies" }, { kind: "ResourceQuota", apiName: "resourcequotas" }, { kind: "ReplicaSet", apiName: "replicasets", group: "apps" }, From a2ce429f221e4c3cb6b66e47e582306729a3f1c9 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Fri, 26 Feb 2021 14:56:51 +0200 Subject: [PATCH 08/26] Pass Lens wslenvs to terminal session on Windows (#2198) Signed-off-by: Lauri Nevala --- src/main/shell-session.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/main/shell-session.ts b/src/main/shell-session.ts index 9e5af371f7..10a2f9ed47 100644 --- a/src/main/shell-session.ts +++ b/src/main/shell-session.ts @@ -110,6 +110,14 @@ export class ShellSession extends EventEmitter { env["SystemRoot"] = process.env.SystemRoot; env["PTYSHELL"] = process.env.SHELL || "powershell.exe"; env["PATH"] = pathStr; + env["LENS_SESSION"] = "true"; + const lensWslEnv = "KUBECONFIG/up:LENS_SESSION/u"; + + if (process.env.WSLENV != undefined) { + env["WSLENV"] = `${process.env["WSLENV"]}:${lensWslEnv}`; + } else { + env["WSLENV"] = lensWslEnv; + } } else if(typeof(process.env.SHELL) != "undefined") { env["PTYSHELL"] = process.env.SHELL; env["PATH"] = pathStr; From b3176a6fc4707a4678fca1f04a37f01448d8a0df Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 26 Feb 2021 09:33:47 -0500 Subject: [PATCH 09/26] fix protocol log line grammer (#2234) Signed-off-by: Sebastian Malton --- src/main/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/index.ts b/src/main/index.ts index ddee53bfea..9a0b11b626 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -38,7 +38,7 @@ let windowManager: WindowManager; app.setName(appName); -logger.info("📟 Setting as Lens as protocol client for lens://"); +logger.info("📟 Setting Lens as protocol client for lens://"); if (app.setAsDefaultProtocolClient("lens")) { logger.info("📟 succeeded ✅"); From e837e6f1db197fba56036f9bf7af40cbdbbbda27 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Mon, 1 Mar 2021 12:35:00 +0300 Subject: [PATCH 10/26] Removing unused chart files (#2238) Signed-off-by: Alex Andreev --- .../chart/background-block.plugin.ts | 42 ----------------- .../components/chart/useRealTimeMetrics.ts | 45 ------------------- 2 files changed, 87 deletions(-) delete mode 100644 src/renderer/components/chart/background-block.plugin.ts delete mode 100644 src/renderer/components/chart/useRealTimeMetrics.ts diff --git a/src/renderer/components/chart/background-block.plugin.ts b/src/renderer/components/chart/background-block.plugin.ts deleted file mode 100644 index ff4816c4dd..0000000000 --- a/src/renderer/components/chart/background-block.plugin.ts +++ /dev/null @@ -1,42 +0,0 @@ -import ChartJS from "chart.js"; -import get from "lodash/get"; - -const defaultOptions = { - interval: 61, - coverBars: 3, - borderColor: "#44474A", - backgroundColor: "#00000033" -}; - -export const BackgroundBlock = { - options: {}, - - getOptions(chart: ChartJS) { - return get(chart, "options.plugins.BackgroundBlock"); - }, - - afterInit(chart: ChartJS) { - this.options = { - ...defaultOptions, - ...this.getOptions(chart) - }; - }, - - beforeDraw(chart: ChartJS) { - if (!chart.chartArea) return; - const { interval, coverBars, borderColor, backgroundColor } = this.options; - const { ctx, chartArea } = chart; - const { left, right, top, bottom } = chartArea; - const blockWidth = (right - left) / interval * coverBars; - - ctx.save(); - ctx.fillStyle = backgroundColor; - ctx.strokeStyle = borderColor; - ctx.fillRect(right - blockWidth, top, blockWidth, bottom - top); - ctx.beginPath(); - ctx.moveTo(right - blockWidth + 1.5, top); - ctx.lineTo(right - blockWidth + 1.5, bottom); - ctx.stroke(); - ctx.restore(); - } -}; diff --git a/src/renderer/components/chart/useRealTimeMetrics.ts b/src/renderer/components/chart/useRealTimeMetrics.ts deleted file mode 100644 index b01629e8e9..0000000000 --- a/src/renderer/components/chart/useRealTimeMetrics.ts +++ /dev/null @@ -1,45 +0,0 @@ -import moment from "moment"; -import { useState, useEffect } from "react"; -import { useInterval } from "../../hooks"; - -type IMetricValues = [number, string][]; -type IChartData = { x: number; y: string }[]; - -const defaultParams = { - fetchInterval: 15, - updateInterval: 5 -}; - -export function useRealTimeMetrics(metrics: IMetricValues, chartData: IChartData, params = defaultParams) { - const [index, setIndex] = useState(0); - const { fetchInterval, updateInterval } = params; - const rangeMetrics = metrics.slice(-updateInterval); - const steps = fetchInterval / updateInterval; - const data = [...chartData]; - - useEffect(() => { - setIndex(0); - }, [metrics]); - - useInterval(() => { - if (index < steps + 1) { - setIndex(index + steps - 1); - } - }, updateInterval * 1000); - - if (data.length && metrics.length) { - const lastTime = data[data.length - 1].x; - const values = []; - - for (let i = 0; i < 3; i++) { - values[i] = moment.unix(lastTime).add(i + 1, "m").unix(); - } - data.push( - { x: values[0], y: "0" }, - { x: values[1], y: parseFloat(rangeMetrics[index][1]).toFixed(3) }, - { x: values[2], y: "0" } - ); - } - - return data; -} From 4f74b9aabec453e090396c83875ef8bc87ab82d3 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Mon, 1 Mar 2021 17:30:22 +0200 Subject: [PATCH 11/26] Ignore clusters with invalid kubeconfig (#1956) * Ignore clusters with invalid kubeconfig Signed-off-by: Lauri Nevala * Improve error message Signed-off-by: Lauri Nevala * Mark cluster as dead if kubeconfig loading fails Signed-off-by: Lauri Nevala * Fix tests Signed-off-by: Lauri Nevala * Validate cluster object in kubeconfig when constructing cluster Signed-off-by: Lauri Nevala * Add unit tests for validateKubeConfig Signed-off-by: Lauri Nevala * Refactor validateKubeconfig unit tests Signed-off-by: Lauri Nevala * Extract ValidationOpts type Signed-off-by: Lauri Nevala * Add default value to validationOpts param Signed-off-by: Lauri Nevala * Change isDead to property Signed-off-by: Lauri Nevala * Fix lint issues Signed-off-by: Lauri Nevala * Add missing new line Signed-off-by: Lauri Nevala * Update validateKubeConfig in-code documentation Signed-off-by: Lauri Nevala * Remove isDead property Signed-off-by: Lauri Nevala * Display warning notification if invalid kubeconfig detected (#2233) * Display warning notification if invalid kubeconfig detected Signed-off-by: Lauri Nevala --- src/common/__tests__/cluster-store.test.ts | 113 ++++++++++++++++-- src/common/__tests__/kube-helpers.test.ts | 101 ++++++++++++++++ src/common/cluster-store.ts | 4 +- src/common/ipc/index.ts | 1 + src/common/ipc/invalid-kubeconfig/index.ts | 3 + src/common/kube-helpers.ts | 43 +++++-- src/main/cluster.ts | 15 ++- .../components/+add-cluster/add-cluster.tsx | 2 +- .../components/dock/create-resource.tsx | 2 +- .../notifications/notifications.tsx | 5 +- src/renderer/ipc/index.tsx | 2 + .../ipc/invalid-kubeconfig-handler.tsx | 46 +++++++ 12 files changed, 307 insertions(+), 30 deletions(-) create mode 100644 src/common/__tests__/kube-helpers.test.ts create mode 100644 src/common/ipc/invalid-kubeconfig/index.ts create mode 100644 src/renderer/ipc/invalid-kubeconfig-handler.tsx diff --git a/src/common/__tests__/cluster-store.test.ts b/src/common/__tests__/cluster-store.test.ts index 6ee34388a2..d1d2f76603 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -6,6 +6,29 @@ import { ClusterStore, getClusterIdFromHost } from "../cluster-store"; import { workspaceStore } from "../workspace-store"; const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png"); +const kubeconfig = ` +apiVersion: v1 +clusters: +- cluster: + server: https://localhost + name: test +contexts: +- context: + cluster: test + user: test + name: foo +- context: + cluster: test + user: test + name: foo2 +current-context: test +kind: Config +preferences: {} +users: +- name: test + user: + token: kubeconfig-user-q4lm4:xxxyyyy +`; jest.mock("electron", () => { return { @@ -47,13 +70,13 @@ describe("empty config", () => { clusterStore.addCluster( new Cluster({ id: "foo", - contextName: "minikube", + contextName: "foo", preferences: { terminalCWD: "/tmp", icon: "data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5", clusterName: "minikube" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", "fancy foo config"), + kubeConfigPath: ClusterStore.embedCustomKubeConfig("foo", kubeconfig), workspace: workspaceStore.currentWorkspaceId }) ); @@ -91,20 +114,20 @@ describe("empty config", () => { clusterStore.addClusters( new Cluster({ id: "prod", - contextName: "prod", + contextName: "foo", preferences: { clusterName: "prod" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", "fancy config"), + kubeConfigPath: ClusterStore.embedCustomKubeConfig("prod", kubeconfig), workspace: "workstation" }), new Cluster({ id: "dev", - contextName: "dev", + contextName: "foo2", preferences: { clusterName: "dev" }, - kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", "fancy config"), + kubeConfigPath: ClusterStore.embedCustomKubeConfig("dev", kubeconfig), workspace: "workstation" }) ); @@ -177,20 +200,20 @@ describe("config with existing clusters", () => { clusters: [ { id: "cluster1", - kubeConfig: "foo", + kubeConfigPath: kubeconfig, contextName: "foo", preferences: { terminalCWD: "/foo" }, workspace: "default" }, { id: "cluster2", - kubeConfig: "foo2", + kubeConfigPath: kubeconfig, contextName: "foo2", preferences: { terminalCWD: "/foo2" } }, { id: "cluster3", - kubeConfig: "foo", + kubeConfigPath: kubeconfig, contextName: "foo", preferences: { terminalCWD: "/foo" }, workspace: "foo", @@ -247,6 +270,78 @@ describe("config with existing clusters", () => { }); }); +describe("config with invalid cluster kubeconfig", () => { + beforeEach(() => { + const invalidKubeconfig = ` +apiVersion: v1 +clusters: +- cluster: + server: https://localhost + name: test2 +contexts: +- context: + cluster: test + user: test + name: test +current-context: test +kind: Config +preferences: {} +users: +- name: test + user: + token: kubeconfig-user-q4lm4:xxxyyyy +`; + + ClusterStore.resetInstance(); + const mockOpts = { + "tmp": { + "lens-cluster-store.json": JSON.stringify({ + __internal__: { + migrations: { + version: "99.99.99" + } + }, + clusters: [ + { + id: "cluster1", + kubeConfigPath: invalidKubeconfig, + contextName: "test", + preferences: { terminalCWD: "/foo" }, + workspace: "foo", + }, + { + id: "cluster2", + kubeConfigPath: kubeconfig, + contextName: "foo", + preferences: { terminalCWD: "/foo" }, + workspace: "default" + }, + + ] + }) + } + }; + + mockFs(mockOpts); + clusterStore = ClusterStore.getInstance(); + + return clusterStore.load(); + }); + + afterEach(() => { + mockFs.restore(); + }); + + it("does not enable clusters with invalid kubeconfig", () => { + const storedClusters = clusterStore.clustersList; + + expect(storedClusters.length).toBe(2); + expect(storedClusters[0].enabled).toBeFalsy; + expect(storedClusters[1].id).toBe("cluster2"); + expect(storedClusters[1].enabled).toBeTruthy; + }); +}); + describe("pre 2.0 config with an existing cluster", () => { beforeEach(() => { ClusterStore.resetInstance(); diff --git a/src/common/__tests__/kube-helpers.test.ts b/src/common/__tests__/kube-helpers.test.ts new file mode 100644 index 0000000000..a782772d34 --- /dev/null +++ b/src/common/__tests__/kube-helpers.test.ts @@ -0,0 +1,101 @@ +import { KubeConfig } from "@kubernetes/client-node"; +import { validateKubeConfig } from "../kube-helpers"; + +const kubeconfig = ` +apiVersion: v1 +clusters: +- cluster: + server: https://localhost + name: test +contexts: +- context: + cluster: test + user: test + name: valid +- context: + cluster: test2 + user: test + name: invalidCluster +- context: + cluster: test + user: test2 + name: invalidUser +- context: + cluster: test + user: invalidExec + name: invalidExec +current-context: test +kind: Config +preferences: {} +users: +- name: test + user: + exec: + command: echo +- name: invalidExec + user: + exec: + command: foo +`; + +const kc = new KubeConfig(); + +describe("validateKubeconfig", () => { + beforeAll(() => { + kc.loadFromString(kubeconfig); + }); + describe("with default validation options", () => { + describe("with valid kubeconfig", () => { + it("does not raise exceptions", () => { + expect(() => { validateKubeConfig(kc, "valid");}).not.toThrow(); + }); + }); + describe("with invalid context object", () => { + it("it raises exception", () => { + expect(() => { validateKubeConfig(kc, "invalid");}).toThrow("No valid context object provided in kubeconfig for context 'invalid'"); + }); + }); + + describe("with invalid cluster object", () => { + it("it raises exception", () => { + expect(() => { validateKubeConfig(kc, "invalidCluster");}).toThrow("No valid cluster object provided in kubeconfig for context 'invalidCluster'"); + }); + }); + + describe("with invalid user object", () => { + it("it raises exception", () => { + expect(() => { validateKubeConfig(kc, "invalidUser");}).toThrow("No valid user object provided in kubeconfig for context 'invalidUser'"); + }); + }); + + describe("with invalid exec command", () => { + it("it raises exception", () => { + expect(() => { validateKubeConfig(kc, "invalidExec");}).toThrow("User Exec command \"foo\" not found on host. Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig"); + }); + }); + }); + + describe("with validateCluster as false", () => { + describe("with invalid cluster object", () => { + it("does not raise exception", () => { + expect(() => { validateKubeConfig(kc, "invalidCluster", { validateCluster: false });}).not.toThrow(); + }); + }); + }); + + describe("with validateUser as false", () => { + describe("with invalid user object", () => { + it("does not raise excpetions", () => { + expect(() => { validateKubeConfig(kc, "invalidUser", { validateUser: false });}).not.toThrow(); + }); + }); + }); + + describe("with validateExec as false", () => { + describe("with invalid exec object", () => { + it("does not raise excpetions", () => { + expect(() => { validateKubeConfig(kc, "invalidExec", { validateExec: false });}).not.toThrow(); + }); + }); + }); +}); diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 4000684d16..6bf932f0f4 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -323,7 +323,7 @@ export class ClusterStore extends BaseStore { } else { cluster = new Cluster(clusterModel); - if (!cluster.isManaged) { + if (!cluster.isManaged && cluster.apiUrl) { cluster.enabled = true; } } @@ -337,7 +337,7 @@ export class ClusterStore extends BaseStore { } }); - this.activeCluster = newClusters.has(activeCluster) ? activeCluster : null; + this.activeCluster = newClusters.get(activeCluster)?.enabled ? activeCluster : null; this.clusters.replace(newClusters); this.removedClusters.replace(removedClusters); } diff --git a/src/common/ipc/index.ts b/src/common/ipc/index.ts index a34890472e..c5e864dc75 100644 --- a/src/common/ipc/index.ts +++ b/src/common/ipc/index.ts @@ -1,3 +1,4 @@ export * from "./ipc"; +export * from "./invalid-kubeconfig"; export * from "./update-available"; export * from "./type-enforced-ipc"; diff --git a/src/common/ipc/invalid-kubeconfig/index.ts b/src/common/ipc/invalid-kubeconfig/index.ts new file mode 100644 index 0000000000..9e8e7921d7 --- /dev/null +++ b/src/common/ipc/invalid-kubeconfig/index.ts @@ -0,0 +1,3 @@ +export const InvalidKubeconfigChannel = "invalid-kubeconfig"; + +export type InvalidKubeConfigArgs = [clusterId: string]; diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index 02a9faef92..c2a2a8df93 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -7,6 +7,12 @@ import logger from "../main/logger"; import commandExists from "command-exists"; import { ExecValidationNotFoundError } from "./custom-errors"; +export type KubeConfigValidationOpts = { + validateCluster?: boolean; + validateUser?: boolean; + validateExec?: boolean; +}; + export const kubeConfigDefaultPath = path.join(os.homedir(), ".kube", "config"); function resolveTilde(filePath: string) { @@ -151,27 +157,42 @@ export function getNodeWarningConditions(node: V1Node) { } /** - * Validates kubeconfig supplied in the add clusters screen. At present this will just validate - * the User struct, specifically the command passed to the exec substructure. - */ -export function validateKubeConfig (config: KubeConfig) { + * Checks if `config` has valid `Context`, `User`, `Cluster`, and `exec` fields (if present when required) + */ +export function validateKubeConfig (config: KubeConfig, contextName: string, validationOpts: KubeConfigValidationOpts = {}) { // we only receive a single context, cluster & user object here so lets validate them as this // will be called when we add a new cluster to Lens - logger.debug(`validateKubeConfig: validating kubeconfig - ${JSON.stringify(config)}`); + + const { validateUser = true, validateCluster = true, validateExec = true } = validationOpts; + + const contextObject = config.getContextObject(contextName); + + // Validate the Context Object + if (!contextObject) { + throw new Error(`No valid context object provided in kubeconfig for context '${contextName}'`); + } + + // Validate the Cluster Object + if (validateCluster && !config.getCluster(contextObject.cluster)) { + throw new Error(`No valid cluster object provided in kubeconfig for context '${contextName}'`); + } + + const user = config.getUser(contextObject.user); // Validate the User Object - const user = config.getCurrentUser(); - - if (user.exec) { + if (validateUser && !user) { + throw new Error(`No valid user object provided in kubeconfig for context '${contextName}'`); + } + + // Validate exec command if present + if (validateExec && user?.exec) { const execCommand = user.exec["command"]; // check if the command is absolute or not const isAbsolute = path.isAbsolute(execCommand); // validate the exec struct in the user object, start with the command field - logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`); - if (!commandExists.sync(execCommand)) { - logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${config.currentContext} not found`); + logger.debug(`validateKubeConfig: exec command ${String(execCommand)} in kubeconfig ${contextName} not found`); throw new ExecValidationNotFoundError(execCommand, isAbsolute); } } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 13c74a285e..198d24c2f9 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -4,12 +4,12 @@ import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api"; import type { WorkspaceId } from "../common/workspace-store"; import { action, comparer, computed, observable, reaction, toJS, when } from "mobx"; import { apiKubePrefix } from "../common/vars"; -import { broadcastMessage } from "../common/ipc"; +import { broadcastMessage, InvalidKubeconfigChannel } from "../common/ipc"; import { ContextHandler } from "./context-handler"; import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node"; import { Kubectl } from "./kubectl"; import { KubeconfigManager } from "./kubeconfig-manager"; -import { loadConfig } from "../common/kube-helpers"; +import { loadConfig, validateKubeConfig } from "../common/kube-helpers"; import request, { RequestPromiseOptions } from "request-promise-native"; import { apiResources, KubeApiResource } from "../common/rbac"; import logger from "./logger"; @@ -177,6 +177,7 @@ export class Cluster implements ClusterModel, ClusterState { * @observable */ @observable isAdmin = false; + /** * Global watch-api accessibility , e.g. "/api/v1/services?watch=1" * @@ -256,10 +257,16 @@ export class Cluster implements ClusterModel, ClusterState { constructor(model: ClusterModel) { this.updateModel(model); - const kubeconfig = this.getKubeconfig(); - if (kubeconfig.getContextObject(this.contextName)) { + try { + const kubeconfig = this.getKubeconfig(); + + validateKubeConfig(kubeconfig, this.contextName, { validateCluster: true, validateUser: false, validateExec: false}); this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; + } catch(err) { + logger.error(err); + logger.error(`[CLUSTER] Failed to load kubeconfig for the cluster '${this.name || this.contextName}' (context: ${this.contextName}, kubeconfig: ${this.kubeConfigPath}).`); + broadcastMessage(InvalidKubeconfigChannel, model.id); } } diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index ae4a3e6ace..38d03482e8 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -147,7 +147,7 @@ export class AddCluster extends React.Component { try { const kubeConfig = this.kubeContexts.get(context); - validateKubeConfig(kubeConfig); + validateKubeConfig(kubeConfig, context); return true; } catch (err) { diff --git a/src/renderer/components/dock/create-resource.tsx b/src/renderer/components/dock/create-resource.tsx index 8ee859d2cf..01e6002309 100644 --- a/src/renderer/components/dock/create-resource.tsx +++ b/src/renderer/components/dock/create-resource.tsx @@ -52,7 +52,7 @@ export class CreateResource extends React.Component { ); if (errors.length) { - errors.forEach(Notifications.error); + errors.forEach(error => Notifications.error(error)); if (!createdResources.length) throw errors[0]; } const successMessage = ( diff --git a/src/renderer/components/notifications/notifications.tsx b/src/renderer/components/notifications/notifications.tsx index 0c1ac692cf..206102b1a3 100644 --- a/src/renderer/components/notifications/notifications.tsx +++ b/src/renderer/components/notifications/notifications.tsx @@ -21,11 +21,12 @@ export class Notifications extends React.Component { }); } - static error(message: NotificationMessage) { + static error(message: NotificationMessage, customOpts: Partial = {}) { notificationsStore.add({ message, timeout: 10000, - status: NotificationStatus.ERROR + status: NotificationStatus.ERROR, + ...customOpts }); } diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index b9644f7404..544cefbf78 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -5,6 +5,7 @@ import { Notifications, notificationsStore } from "../components/notifications"; import { Button } from "../components/button"; import { isMac } from "../../common/vars"; import * as uuid from "uuid"; +import { invalidKubeconfigHandler } from "./invalid-kubeconfig-handler"; function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void { notificationsStore.remove(notificationId); @@ -58,4 +59,5 @@ export function registerIpcHandlers() { listener: UpdateAvailableHandler, verifier: areArgsUpdateAvailableFromMain, }); + onCorrect(invalidKubeconfigHandler); } diff --git a/src/renderer/ipc/invalid-kubeconfig-handler.tsx b/src/renderer/ipc/invalid-kubeconfig-handler.tsx new file mode 100644 index 0000000000..cadf7e4e3f --- /dev/null +++ b/src/renderer/ipc/invalid-kubeconfig-handler.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { ipcRenderer, IpcRendererEvent, shell } from "electron"; +import { clusterStore } from "../../common/cluster-store"; +import { InvalidKubeConfigArgs, InvalidKubeconfigChannel } from "../../common/ipc/invalid-kubeconfig"; +import { Notifications, notificationsStore } from "../components/notifications"; +import { Button } from "../components/button"; + +export const invalidKubeconfigHandler = { + source: ipcRenderer, + channel: InvalidKubeconfigChannel, + listener: InvalidKubeconfigListener, + verifier: (args: [unknown]): args is InvalidKubeConfigArgs => { + return args.length === 1 && typeof args[0] === "string" && !!clusterStore.getById(args[0]); + }, +}; + +function InvalidKubeconfigListener(event: IpcRendererEvent, ...[clusterId]: InvalidKubeConfigArgs): void { + const notificationId = `invalid-kubeconfig:${clusterId}`; + const cluster = clusterStore.getById(clusterId); + const contextName = cluster.name !== cluster.contextName ? `(context: ${cluster.contextName})` : ""; + + Notifications.error( + ( +

+ Cluster with Invalid Kubeconfig Detected! +

Cluster {cluster.name} has invalid kubeconfig {contextName} and cannot be displayed. + Please fix the { e.preventDefault(); shell.showItemInFolder(cluster.kubeConfigPath); }}>kubeconfig manually and restart Lens + or remove the cluster.

+

Do you want to remove the cluster now?

+
+
+
+ ), + { + id: notificationId, + timeout: 0 + } + ); +} + + From 4931f681e48564ba05e9ea14e53d92720ee71ab7 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Tue, 2 Mar 2021 08:07:16 -0500 Subject: [PATCH 12/26] log JSON api calls when in DEBUG mode (#2263) Signed-off-by: Sebastian Malton --- src/renderer/api/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/renderer/api/index.ts b/src/renderer/api/index.ts index 1a82ce45f8..7c2b55d0e1 100644 --- a/src/renderer/api/index.ts +++ b/src/renderer/api/index.ts @@ -1,11 +1,11 @@ import { JsonApi, JsonApiErrorParsed } from "./json-api"; import { KubeJsonApi } from "./kube-json-api"; import { Notifications } from "../components/notifications"; -import { apiKubePrefix, apiPrefix, isDevelopment } from "../../common/vars"; +import { apiKubePrefix, apiPrefix, isDebugging, isDevelopment } from "../../common/vars"; export const apiBase = new JsonApi({ apiBase: apiPrefix, - debug: isDevelopment, + debug: isDevelopment || isDebugging, }); export const apiKube = new KubeJsonApi({ apiBase: apiKubePrefix, From 25a7403f3c05ecf6083c00c97da9d8b671c9d4e6 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Wed, 3 Mar 2021 15:40:19 +0200 Subject: [PATCH 13/26] Render only secret name on pod details without access to secrets (#2244) * Render only secret name on pod details without access to secrets Signed-off-by: Lauri Nevala * Preserving layout when amount of secrets passed Signed-off-by: Alex Andreev * Refactor secrets to observable map Signed-off-by: Lauri Nevala Co-authored-by: Alex Andreev --- .../+workloads-pods/pod-details-secrets.scss | 4 +-- .../+workloads-pods/pod-details-secrets.tsx | 32 ++++++++++++++----- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/+workloads-pods/pod-details-secrets.scss b/src/renderer/components/+workloads-pods/pod-details-secrets.scss index d1ee08c51b..f20aced60d 100644 --- a/src/renderer/components/+workloads-pods/pod-details-secrets.scss +++ b/src/renderer/components/+workloads-pods/pod-details-secrets.scss @@ -1,7 +1,7 @@ .PodDetailsSecrets { - a { + > * { display: block; - margin-bottom: $margin; + margin-bottom: var(--margin); &:last-child { margin-bottom: 0; diff --git a/src/renderer/components/+workloads-pods/pod-details-secrets.tsx b/src/renderer/components/+workloads-pods/pod-details-secrets.tsx index af1515c1b4..b2c7cd14c3 100644 --- a/src/renderer/components/+workloads-pods/pod-details-secrets.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-secrets.tsx @@ -13,33 +13,49 @@ interface Props { @observer export class PodDetailsSecrets extends Component { - @observable secrets: Secret[] = []; + @observable secrets: Map = observable.map(); @disposeOnUnmount secretsLoader = autorun(async () => { const { pod } = this.props; - this.secrets = await Promise.all( + const secrets = await Promise.all( pod.getSecrets().map(secretName => secretsApi.get({ name: secretName, namespace: pod.getNs(), })) ); + + secrets.forEach(secret => secret && this.secrets.set(secret.getName(), secret)); }); render() { + const { pod } = this.props; + return (
{ - this.secrets.map(secret => { - return ( - - {secret.getName()} - - ); + pod.getSecrets().map(secretName => { + const secret = this.secrets.get(secretName); + + if (secret) { + return this.renderSecretLink(secret); + } else { + return ( + {secretName} + ); + } }) }
); } + + protected renderSecretLink(secret: Secret) { + return ( + + {secret.getName()} + + ); + } } From bcaef7938669610ccbda6bbe973e70fd134e244d Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 3 Mar 2021 14:34:35 -0500 Subject: [PATCH 14/26] Cleanup add namespace dialog (#2261) - Remove mixing of async and promises - Remove unnecessary instance close method - Use condition call syntax Signed-off-by: Sebastian Malton --- .../+namespaces/add-namespace-dialog.tsx | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/renderer/components/+namespaces/add-namespace-dialog.tsx b/src/renderer/components/+namespaces/add-namespace-dialog.tsx index a3b30235fa..1ad2efae59 100644 --- a/src/renderer/components/+namespaces/add-namespace-dialog.tsx +++ b/src/renderer/components/+namespaces/add-namespace-dialog.tsx @@ -33,20 +33,18 @@ export class AddNamespaceDialog extends React.Component { this.namespace = ""; }; - close = () => { - AddNamespaceDialog.close(); - }; - addNamespace = async () => { const { namespace } = this; const { onSuccess, onError } = this.props; try { - await namespaceStore.create({ name: namespace }).then(onSuccess); - this.close(); + const created = await namespaceStore.create({ name: namespace }); + + onSuccess?.(created); + AddNamespaceDialog.close(); } catch (err) { Notifications.error(err); - onError && onError(err); + onError?.(err); } }; @@ -61,9 +59,9 @@ export class AddNamespaceDialog extends React.Component { className="AddNamespaceDialog" isOpen={AddNamespaceDialog.isOpen} onOpen={this.reset} - close={this.close} + close={AddNamespaceDialog.close} > - + Date: Thu, 4 Mar 2021 15:12:28 +0300 Subject: [PATCH 15/26] Fix: preventing to render on cluster refresh (#2253) * Removing @observer decorator from Signed-off-by: Alex Andreev * Add observer wrapper to Signed-off-by: Alex Andreev * Fix eslint claim Signed-off-by: Alex Andreev * Moving extension route renderers to components Signed-off-by: Alex Andreev * Clean up Signed-off-by: Alex Andreev * Removing external observables out from App render() Signed-off-by: Alex Andreev * Fetching hosted cluster inside Command Palette Signed-off-by: Alex Andreev * Setting route lists explicitly To avoid using observable data within tabRoutes arrays Signed-off-by: Alex Andreev * Review fixes Signed-off-by: Alex Andreev --- .../components/+config/config.route.ts | 19 +++++-- .../components/+network/network.route.ts | 15 ++++-- .../+pod-security-policies/index.ts | 1 - .../pod-security-policies.route.ts | 8 --- .../components/+storage/storage.route.ts | 13 +++-- .../+user-management/user-management.route.ts | 20 ++++--- .../+user-management/user-management.tsx | 4 +- .../components/+workloads/workloads.route.ts | 20 ++++--- src/renderer/components/app.tsx | 54 ++----------------- .../command-palette/command-container.tsx | 7 ++- .../components/layout/main-layout-header.tsx | 5 +- 11 files changed, 70 insertions(+), 96 deletions(-) delete mode 100644 src/renderer/components/+pod-security-policies/pod-security-policies.route.ts diff --git a/src/renderer/components/+config/config.route.ts b/src/renderer/components/+config/config.route.ts index 8ea637c505..d269455566 100644 --- a/src/renderer/components/+config/config.route.ts +++ b/src/renderer/components/+config/config.route.ts @@ -1,12 +1,21 @@ import { RouteProps } from "react-router"; -import { Config } from "./config"; import { IURLParams } from "../../../common/utils/buildUrl"; -import { configMapsURL } from "../+config-maps/config-maps.route"; +import { configMapsRoute, configMapsURL } from "../+config-maps/config-maps.route"; +import { hpaRoute } from "../+config-autoscalers"; +import { limitRangesRoute } from "../+config-limit-ranges"; +import { pdbRoute } from "../+config-pod-disruption-budgets"; +import { resourceQuotaRoute } from "../+config-resource-quotas"; +import { secretsRoute } from "../+config-secrets"; export const configRoute: RouteProps = { - get path() { - return Config.tabRoutes.map(({ routePath }) => routePath).flat(); - } + path: [ + configMapsRoute, + secretsRoute, + resourceQuotaRoute, + limitRangesRoute, + hpaRoute, + pdbRoute + ].map(route => route.path.toString()) }; export const configURL = (params?: IURLParams) => configMapsURL(params); diff --git a/src/renderer/components/+network/network.route.ts b/src/renderer/components/+network/network.route.ts index de36bfc77a..2e5d5ffcb5 100644 --- a/src/renderer/components/+network/network.route.ts +++ b/src/renderer/components/+network/network.route.ts @@ -1,12 +1,17 @@ import { RouteProps } from "react-router"; -import { Network } from "./network"; -import { servicesURL } from "../+network-services"; +import { endpointRoute } from "../+network-endpoints"; +import { ingressRoute } from "../+network-ingresses"; +import { networkPoliciesRoute } from "../+network-policies"; +import { servicesRoute, servicesURL } from "../+network-services"; import { IURLParams } from "../../../common/utils/buildUrl"; export const networkRoute: RouteProps = { - get path() { - return Network.tabRoutes.map(({ routePath }) => routePath).flat(); - } + path: [ + servicesRoute, + endpointRoute, + ingressRoute, + networkPoliciesRoute + ].map(route => route.path.toString()) }; export const networkURL = (params?: IURLParams) => servicesURL(params); diff --git a/src/renderer/components/+pod-security-policies/index.ts b/src/renderer/components/+pod-security-policies/index.ts index c9379d3381..223affa147 100644 --- a/src/renderer/components/+pod-security-policies/index.ts +++ b/src/renderer/components/+pod-security-policies/index.ts @@ -1,3 +1,2 @@ -export * from "./pod-security-policies.route"; export * from "./pod-security-policies"; export * from "./pod-security-policy-details"; diff --git a/src/renderer/components/+pod-security-policies/pod-security-policies.route.ts b/src/renderer/components/+pod-security-policies/pod-security-policies.route.ts deleted file mode 100644 index 8bee44985e..0000000000 --- a/src/renderer/components/+pod-security-policies/pod-security-policies.route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { RouteProps } from "react-router"; -import { buildURL } from "../../../common/utils/buildUrl"; - -export const podSecurityPoliciesRoute: RouteProps = { - path: "/pod-security-policies" -}; - -export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path); diff --git a/src/renderer/components/+storage/storage.route.ts b/src/renderer/components/+storage/storage.route.ts index 6ad17a4fdf..174eaea080 100644 --- a/src/renderer/components/+storage/storage.route.ts +++ b/src/renderer/components/+storage/storage.route.ts @@ -1,12 +1,15 @@ import { RouteProps } from "react-router"; -import { volumeClaimsURL } from "../+storage-volume-claims"; -import { Storage } from "./storage"; +import { storageClassesRoute } from "../+storage-classes"; +import { volumeClaimsRoute, volumeClaimsURL } from "../+storage-volume-claims"; +import { volumesRoute } from "../+storage-volumes"; import { IURLParams } from "../../../common/utils/buildUrl"; export const storageRoute: RouteProps = { - get path() { - return Storage.tabRoutes.map(({ routePath }) => routePath).flat(); - } + path: [ + volumeClaimsRoute, + volumesRoute, + storageClassesRoute + ].map(route => route.path.toString()) }; export const storageURL = (params?: IURLParams) => volumeClaimsURL(params); diff --git a/src/renderer/components/+user-management/user-management.route.ts b/src/renderer/components/+user-management/user-management.route.ts index 9dc17cbbd8..3acebb7899 100644 --- a/src/renderer/components/+user-management/user-management.route.ts +++ b/src/renderer/components/+user-management/user-management.route.ts @@ -1,12 +1,5 @@ import type { RouteProps } from "react-router"; import { buildURL, IURLParams } from "../../../common/utils/buildUrl"; -import { UserManagement } from "./user-management"; - -export const usersManagementRoute: RouteProps = { - get path() { - return UserManagement.tabRoutes.map(({ routePath }) => routePath).flat(); - } -}; // Routes export const serviceAccountsRoute: RouteProps = { @@ -18,6 +11,18 @@ export const rolesRoute: RouteProps = { export const roleBindingsRoute: RouteProps = { path: "/role-bindings" }; +export const podSecurityPoliciesRoute: RouteProps = { + path: "/pod-security-policies" +}; + +export const usersManagementRoute: RouteProps = { + path: [ + serviceAccountsRoute, + roleBindingsRoute, + rolesRoute, + podSecurityPoliciesRoute + ].map(route => route.path.toString()) +}; // Route params export interface IServiceAccountsRouteParams { @@ -34,3 +39,4 @@ export const usersManagementURL = (params?: IURLParams) => serviceAccountsURL(pa export const serviceAccountsURL = buildURL(serviceAccountsRoute.path); export const roleBindingsURL = buildURL(roleBindingsRoute.path); export const rolesURL = buildURL(rolesRoute.path); +export const podSecurityPoliciesURL = buildURL(podSecurityPoliciesRoute.path); diff --git a/src/renderer/components/+user-management/user-management.tsx b/src/renderer/components/+user-management/user-management.tsx index e851d50424..480808d6a1 100644 --- a/src/renderer/components/+user-management/user-management.tsx +++ b/src/renderer/components/+user-management/user-management.tsx @@ -5,9 +5,9 @@ import { TabLayout, TabLayoutRoute } from "../layout/tab-layout"; import { Roles } from "../+user-management-roles"; import { RoleBindings } from "../+user-management-roles-bindings"; import { ServiceAccounts } from "../+user-management-service-accounts"; -import { roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route"; +import { podSecurityPoliciesRoute, podSecurityPoliciesURL, roleBindingsRoute, roleBindingsURL, rolesRoute, rolesURL, serviceAccountsRoute, serviceAccountsURL } from "./user-management.route"; import { namespaceUrlParam } from "../+namespaces/namespace.store"; -import { PodSecurityPolicies, podSecurityPoliciesRoute, podSecurityPoliciesURL } from "../+pod-security-policies"; +import { PodSecurityPolicies } from "../+pod-security-policies"; import { isAllowedResource } from "../../../common/rbac"; @observer diff --git a/src/renderer/components/+workloads/workloads.route.ts b/src/renderer/components/+workloads/workloads.route.ts index 44c43c5ef9..14a0bbb07d 100644 --- a/src/renderer/components/+workloads/workloads.route.ts +++ b/src/renderer/components/+workloads/workloads.route.ts @@ -1,13 +1,6 @@ import type { RouteProps } from "react-router"; import { buildURL, IURLParams } from "../../../common/utils/buildUrl"; import { KubeResource } from "../../../common/rbac"; -import { Workloads } from "./workloads"; - -export const workloadsRoute: RouteProps = { - get path() { - return Workloads.tabRoutes.map(({ routePath }) => routePath).flat(); - } -}; // Routes export const overviewRoute: RouteProps = { @@ -35,6 +28,19 @@ export const cronJobsRoute: RouteProps = { path: "/cronjobs" }; +export const workloadsRoute: RouteProps = { + path: [ + overviewRoute, + podsRoute, + deploymentsRoute, + daemonSetsRoute, + statefulSetsRoute, + replicaSetsRoute, + jobsRoute, + cronJobsRoute + ].map(route => route.path.toString()) +}; + // Route params export interface IWorkloadsOverviewRouteParams { } diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index 8b7f8a527c..f5ad684595 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -91,27 +91,15 @@ export class App extends React.Component { reaction(() => this.warningsTotal, (count: number) => { broadcastMessage(`cluster-warning-event-count:${getHostedCluster().id}`, count); }), - - reaction(() => clusterPageMenuRegistry.getRootItems(), (rootItems) => { - this.generateExtensionTabLayoutRoutes(rootItems); - }, { - fireImmediately: true - }) ]); } + @observable startUrl = isAllowedResource(["events", "nodes", "pods"]) ? clusterURL() : workloadsURL(); + @computed get warningsTotal(): number { return nodesStore.getWarningsCount() + eventStore.getWarningsCount(); } - get startURL() { - if (isAllowedResource(["events", "nodes", "pods"])) { - return clusterURL(); - } - - return workloadsURL(); - } - getTabLayoutRoutes(menuItem: ClusterPageMenuRegistration) { const routes: TabLayoutRoute[] = []; @@ -152,38 +140,6 @@ export class App extends React.Component { }); } - @observable extensionRoutes: Map = new Map(); - - generateExtensionTabLayoutRoutes(rootItems: ClusterPageMenuRegistration[]) { - rootItems.forEach((menu, index) => { - let route = this.extensionRoutes.get(menu); - - if (!route) { - const tabRoutes = this.getTabLayoutRoutes(menu); - - if (tabRoutes.length > 0) { - const pageComponent = () => ; - - route = tab.routePath)}/>; - this.extensionRoutes.set(menu, route); - } else { - const page = clusterPageRegistry.getByPageTarget(menu.target); - - if (page) { - route = ; - this.extensionRoutes.set(menu, route); - } - } - } - }); - - for (const menu of this.extensionRoutes.keys()) { - if (!rootItems.includes(menu)) { - this.extensionRoutes.delete(menu); - } - } - } - renderExtensionRoutes() { return clusterPageRegistry.getItems().map((page, index) => { const menu = clusterPageMenuRegistry.getByPage(page); @@ -195,8 +151,6 @@ export class App extends React.Component { } render() { - const cluster = getHostedCluster(); - return ( @@ -215,7 +169,7 @@ export class App extends React.Component { {this.renderExtensionTabLayoutRoutes()} {this.renderExtensionRoutes()} - + @@ -228,7 +182,7 @@ export class App extends React.Component { - + ); diff --git a/src/renderer/components/command-palette/command-container.tsx b/src/renderer/components/command-palette/command-container.tsx index 34ab71ee67..c6950bc5b9 100644 --- a/src/renderer/components/command-palette/command-container.tsx +++ b/src/renderer/components/command-palette/command-container.tsx @@ -10,7 +10,6 @@ import { CommandDialog } from "./command-dialog"; import { CommandRegistration, commandRegistry } from "../../../extensions/registries/command-registry"; import { clusterStore } from "../../../common/cluster-store"; import { workspaceStore } from "../../../common/workspace-store"; -import { Cluster } from "../../../main/cluster"; export type CommandDialogEvent = { component: React.ReactElement @@ -29,7 +28,7 @@ export class CommandOverlay { } @observer -export class CommandContainer extends React.Component<{cluster?: Cluster}> { +export class CommandContainer extends React.Component<{ clusterId?: string }> { @observable.ref commandComponent: React.ReactElement; private escHandler(event: KeyboardEvent) { @@ -56,8 +55,8 @@ export class CommandContainer extends React.Component<{cluster?: Cluster}> { } componentDidMount() { - if (this.props.cluster) { - subscribeToBroadcast(`command-palette:run-action:${this.props.cluster.id}`, (event, commandId: string) => { + if (this.props.clusterId) { + subscribeToBroadcast(`command-palette:run-action:${this.props.clusterId}`, (event, commandId: string) => { const command = this.findCommandById(commandId); if (command) { diff --git a/src/renderer/components/layout/main-layout-header.tsx b/src/renderer/components/layout/main-layout-header.tsx index 6753f8d262..570c04a1f9 100644 --- a/src/renderer/components/layout/main-layout-header.tsx +++ b/src/renderer/components/layout/main-layout-header.tsx @@ -1,3 +1,4 @@ +import { observer } from "mobx-react"; import React from "react"; import { clusterSettingsURL } from "../+cluster-settings"; @@ -11,7 +12,7 @@ interface Props { className?: string } -export function MainLayoutHeader({ cluster, className }: Props) { +export const MainLayoutHeader = observer(({ cluster, className }: Props) => { return (
{cluster.name} @@ -29,4 +30,4 @@ export function MainLayoutHeader({ cluster, className }: Props) { />
); -} +}); From 35e60654875cae6623af665ea133f1855fbf30ad Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 4 Mar 2021 08:38:07 -0500 Subject: [PATCH 16/26] Broadcast update available only after downloading update (#2232) * broadcast update available only after downloading update * remove unnecessary downloads, force silent and restart modes if user chooses to install update immediately * add app.exit() for installing (fixes bug on Windows) Signed-off-by: Sebastian Malton --- src/main/app-updater.ts | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/src/main/app-updater.ts b/src/main/app-updater.ts index 9893abfa81..8f31df8b57 100644 --- a/src/main/app-updater.ts +++ b/src/main/app-updater.ts @@ -3,8 +3,8 @@ import logger from "./logger"; import { isDevelopment, isTestEnv } from "../common/vars"; import { delay } from "../common/utils"; import { areArgsUpdateAvailableToBackchannel, AutoUpdateLogPrefix, broadcastMessage, onceCorrect, UpdateAvailableChannel, UpdateAvailableToBackchannel } from "../common/ipc"; -import { ipcMain } from "electron"; import { once } from "lodash"; +import { app, ipcMain } from "electron"; let installVersion: null | string = null; @@ -12,13 +12,11 @@ function handleAutoUpdateBackChannel(event: Electron.IpcMainEvent, ...[arg]: Upd if (arg.doUpdate) { if (arg.now) { logger.info(`${AutoUpdateLogPrefix}: User chose to update now`); - autoUpdater.on("update-downloaded", () => autoUpdater.quitAndInstall()); - autoUpdater.downloadUpdate().catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download or install update`, { error })); + autoUpdater.quitAndInstall(true, true); + app.exit(); // this is needed for the installer not to fail on windows. } else { logger.info(`${AutoUpdateLogPrefix}: User chose to update on quit`); autoUpdater.autoInstallOnAppQuit = true; - autoUpdater.downloadUpdate() - .catch(error => logger.error(`${AutoUpdateLogPrefix}: Failed to download update`, { error })); } } else { logger.info(`${AutoUpdateLogPrefix}: User chose not to update`); @@ -39,10 +37,10 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24 autoUpdater.autoInstallOnAppQuit = false; autoUpdater - .on("update-available", (args: UpdateInfo) => { + .on("update-available", (info: UpdateInfo) => { if (autoUpdater.autoInstallOnAppQuit) { // a previous auto-update loop was completed with YES+LATER, check if same version - if (installVersion === args.version) { + if (installVersion === info.version) { // same version, don't broadcast return; } @@ -54,10 +52,14 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24 * didn't ask for. */ autoUpdater.autoInstallOnAppQuit = false; - installVersion = args.version; + installVersion = info.version; + autoUpdater.downloadUpdate() + .catch(error => logger.error(`${AutoUpdateLogPrefix}: failed to download update`, { error: String(error) })); + }) + .on("update-downloaded", (info: UpdateInfo) => { try { - const backchannel = `auto-update:${args.version}`; + const backchannel = `auto-update:${info.version}`; ipcMain.removeAllListeners(backchannel); // only one handler should be present @@ -68,8 +70,8 @@ export const startUpdateChecking = once(function (interval = 1000 * 60 * 60 * 24 listener: handleAutoUpdateBackChannel, verifier: areArgsUpdateAvailableToBackchannel, }); - logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: args.version }); - broadcastMessage(UpdateAvailableChannel, backchannel, args); + logger.info(`${AutoUpdateLogPrefix}: broadcasting update available`, { backchannel, version: info.version }); + broadcastMessage(UpdateAvailableChannel, backchannel, info); } catch (error) { logger.error(`${AutoUpdateLogPrefix}: broadcasting failed`, { error }); installVersion = undefined; From d9daa94c61f6d5d3c0cced49255db2d806b694c6 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 5 Mar 2021 12:26:36 -0500 Subject: [PATCH 17/26] Release 4.2.0-alpha.1 (#2285) --- package.json | 2 +- static/RELEASE_NOTES.md | 44 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 3639366298..faaedb77cf 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "4.2.0-alpha.0", + "version": "4.2.0-alpha.1", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", diff --git a/static/RELEASE_NOTES.md b/static/RELEASE_NOTES.md index 8c43fcda18..a8d802316c 100644 --- a/static/RELEASE_NOTES.md +++ b/static/RELEASE_NOTES.md @@ -2,7 +2,49 @@ Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights! -## 4.1.0 (current version) +## 4.2.0-alpha.1 (current version) + +- Add lens:// protocol handling with a routing mechanism +- Notify about update after it has been downloaded +- Add persistent volumes info to storage class submenu +- Fix: proper sorting resources by age column +- Fix: events sorting with compact=true is broken + +## 4.1.4 + +- Ignore clusters with invalid kubeconfig +- Render only secret name on pod details without access to secrets +- Pass Lens wslenvs to terminal session on Windows +- Prevent top-level re-rendering on cluster refresh +- Extract chart version ignoring numbers in chart name +- The select all checkbox should not select disabled items +- Fix: Pdb should have policy group +- Fix: kubectl rollout not exiting properly on Lens terminal + +## 4.1.3 + +- Don't reset selected namespaces to defaults in case of "All namespaces" on page reload +- Fix loading all namespaces for users with limited cluster access +- Display environment variables coming from secret in pod details +- Fix deprecated helm chart filtering +- Fix RoleBindings Namespace and Bindings field not displaying the correct data +- Fix RoleBindingDetails not rendering the name of the role binding +- Fix auto update on quit with newer version + +## 4.1.2 + +**Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more. + +- Fix an issue where a cluster gets stuck on "Connecting ..." phase +- Fix an issue with auto-update + +## 4.1.1 + +- Fix an issue where users with rights to a single namespace were seeing an empty dashboard +- Windows: use SHELL for terminal if set +- Keep highlighted table row during navigation in the details panel + +## 4.1.0 **Upgrade note:** Where have all my pods gone? Namespaced Kubernetes resources are now initially shown only for the "default" namespace. Use the namespaces selector to add more. From e69d008d59a93cb72e8fa1eb1e4d72d85e8715a2 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 5 Mar 2021 12:26:58 -0500 Subject: [PATCH 18/26] Update copyright year (#2286) Signed-off-by: Sebastian Malton --- LICENSE | 8 ++++---- mkdocs.yml | 2 +- package.json | 2 +- src/extensions/npm/extensions/package.json | 2 +- src/main/menu.ts | 3 ++- 5 files changed, 9 insertions(+), 8 deletions(-) diff --git a/LICENSE b/LICENSE index b7a6d74205..37da2d2e07 100644 --- a/LICENSE +++ b/LICENSE @@ -1,12 +1,12 @@ -Copyright (c) 2020 Mirantis, Inc. +Copyright (c) 2021 Mirantis, Inc. Portions of this software are licensed as follows: -* All content residing under the "docs/" directory of this repository, if that +* All content residing under the "docs/" directory of this repository, if that directory exists, is licensed under "Creative Commons: CC BY-SA 4.0 license". -* All third party components incorporated into the Lens Software are licensed +* All third party components incorporated into the Lens Software are licensed under the original license provided by the owner of the applicable component. -* Content outside of the above mentioned directories or restrictions above is +* Content outside of the above mentioned directories or restrictions above is available under the "MIT" license as defined below. Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/mkdocs.yml b/mkdocs.yml index dd72a677ea..337e0c4b11 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -5,7 +5,7 @@ site_url: https://docs.k8slens.dev docs_dir: docs/ repo_name: GitHub repo_url: https://github.com/lensapp/lens -copyright: Copyright © 2020 Mirantis Inc. - All rights reserved. +copyright: Copyright © 2021 Mirantis Inc. - All rights reserved. edit_uri: "" nav: - Overview: README.md diff --git a/package.json b/package.json index faaedb77cf..661bd6d0b9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Lens - The Kubernetes IDE", "version": "4.2.0-alpha.1", "main": "static/build/main.js", - "copyright": "© 2020, Mirantis, Inc.", + "copyright": "© 2021, Mirantis, Inc.", "license": "MIT", "author": { "name": "Mirantis, Inc.", diff --git a/src/extensions/npm/extensions/package.json b/src/extensions/npm/extensions/package.json index 5b2b4475ae..77fcc25a11 100644 --- a/src/extensions/npm/extensions/package.json +++ b/src/extensions/npm/extensions/package.json @@ -3,7 +3,7 @@ "productName": "Lens extensions", "description": "Lens - The Kubernetes IDE: extensions", "version": "0.0.0", - "copyright": "© 2020, Mirantis, Inc.", + "copyright": "© 2021, Mirantis, Inc.", "license": "MIT", "main": "dist/src/extensions/extension-api.js", "types": "dist/src/extensions/extension-api.d.ts", diff --git a/src/main/menu.ts b/src/main/menu.ts index 57c6ccab5e..142c776878 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -11,6 +11,7 @@ import { menuRegistry } from "../extensions/registries/menu-registry"; import logger from "./logger"; import { exitApp } from "./exit-app"; import { broadcastMessage } from "../common/ipc"; +import * as packageJson from "../../package.json"; export type MenuTopId = "mac" | "file" | "edit" | "view" | "help"; @@ -26,7 +27,7 @@ export function showAbout(browserWindow: BrowserWindow) { `Electron: ${process.versions.electron}`, `Chrome: ${process.versions.chrome}`, `Node: ${process.versions.node}`, - `Copyright 2020 Mirantis, Inc.`, + packageJson.copyright, ]; dialog.showMessageBoxSync(browserWindow, { From a5621b71af4a7a907f5b128ec8efade9419d2307 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 8 Mar 2021 08:12:27 -0500 Subject: [PATCH 19/26] Add more information to a helm chart's ID (#2288) Signed-off-by: Sebastian Malton --- src/renderer/api/endpoints/helm-charts.api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/api/endpoints/helm-charts.api.ts b/src/renderer/api/endpoints/helm-charts.api.ts index 02b5b0dbee..093adf9aef 100644 --- a/src/renderer/api/endpoints/helm-charts.api.ts +++ b/src/renderer/api/endpoints/helm-charts.api.ts @@ -83,7 +83,7 @@ export class HelmChart { tillerVersion?: string; getId() { - return `${this.apiVersion}/${this.name}@${this.getAppVersion()}`; + return `${this.repo}:${this.apiVersion}/${this.name}@${this.getAppVersion()}+${this.digest}`; } getName() { From f287b8a3d0dc2a5d60ebe4171c84b3b5419afc36 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Mon, 8 Mar 2021 08:15:04 -0500 Subject: [PATCH 20/26] Add workflow to notify on new PR conflicts (#2287) Signed-off-by: Sebastian Malton --- .github/workflows/maintenance.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/maintenance.yml diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml new file mode 100644 index 0000000000..dfa16b99c7 --- /dev/null +++ b/.github/workflows/maintenance.yml @@ -0,0 +1,22 @@ +name: "Maintenance" +on: + # So that PRs touching the same files as the push are updated + push: + # So that the `dirtyLabel` is removed if conflicts are resolve + # We recommend `pull_request_target` so that github secrets are available. + # In `pull_request` we wouldn't be able to change labels of fork PRs + pull_request_target: + types: [synchronize] + +jobs: + main: + runs-on: ubuntu-latest + steps: + - name: check if prs are dirty + uses: eps1lon/actions-label-merge-conflict@releases/2.x + with: + dirtyLabel: "PR: needs rebase" + removeOnDirtyLabel: "PR: ready to ship" + repoToken: "${{ secrets.GITHUB_TOKEN }}" + commentOnDirty: "This pull request has conflicts, please resolve those before we can evaluate the pull request." + commentOnClean: "Conflicts have been resolved. A maintainer will review the pull request shortly." From 1b0f56f417462538052036ea386c79e15e4453e1 Mon Sep 17 00:00:00 2001 From: Alex Date: Tue, 9 Mar 2021 17:57:55 +0200 Subject: [PATCH 21/26] VSCode launch configurations for debugging (#2281) * Add configuration for debugging integration tests Signed-off-by: Alex Culliere * Add launch configuration for debugging main process Signed-off-by: Alex Culliere * Continue polishing debug configurations Signed-off-by: Alex Culliere * Remove unnecessary dependency Signed-off-by: Alex Culliere * Add debug configuration for unit tests + cleanup vscode tasks Signed-off-by: Alex Culliere * Update src/renderer/bootstrap.tsx Add `await` keyword to debugger attachment Co-authored-by: chh <1474479+chenhunghan@users.noreply.github.com> Signed-off-by: Alex Culliere * Update src/renderer/bootstrap.tsx Co-authored-by: chh <1474479+chenhunghan@users.noreply.github.com> Signed-off-by: Alex Culliere * Use existing variable to wait for chrome debugger attachment Signed-off-by: Alex Culliere * Update src/renderer/bootstrap.tsx Use available helper function instead of raw promise Co-authored-by: Sebastian Malton Signed-off-by: Alex Culliere * Import delay utility Signed-off-by: Alex Culliere * Move async function to async context (attaching debugger) Signed-off-by: Alex Culliere Co-authored-by: Alex Culliere Co-authored-by: chh <1474479+chenhunghan@users.noreply.github.com> Co-authored-by: Sebastian Malton --- .gitignore | 1 - .vscode/launch.json | 57 ++++++++++++++++++++++++++++++++++++++ .vscode/tasks.json | 18 ++++++++++++ package.json | 3 +- src/renderer/bootstrap.tsx | 15 +++++++++- 5 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 .vscode/tasks.json diff --git a/.gitignore b/.gitignore index 88d48ab5ee..d018f3b251 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,3 @@ types/extension-renderer-api.d.ts extensions/*/dist docs/extensions/api site/ -.vscode/ diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000000..7d7cef13d8 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,57 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Main Process", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "protocol": "inspector", + "preLaunchTask": "compile-dev", + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron", + "windows": { + "runtimeExecutable": "${workspaceFolder}/node_modules/.bin/electron.cmd" + }, + "runtimeArgs": [ + "--remote-debugging-port=9223", + "--inspect", + "." + ], + "outputCapture": "std" + }, + { + "name": "Renderer Process", + "type": "pwa-chrome", + "request": "attach", + "port": 9223, + "webRoot": "${workspaceFolder}", + "timeout": 30000 + }, + { + "name": "Integration Tests", + "type": "node", + "request": "launch", + "console": "integratedTerminal", + "runtimeArgs": [ + "${workspaceFolder}/node_modules/.bin/jest", + "--runInBand", + "integration" + ], + }, + { + "name": "Unit Tests", + "type": "node", + "request": "launch", + "internalConsoleOptions": "openOnSessionStart", + "program": "${workspaceFolder}/node_modules/jest/bin/jest.js", + "args": [ + "--env=jsdom", + "-i", + "src" + ] + } + ], +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000000..03a7b306f7 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=733558 + // for the documentation about the tasks.json format + "version": "2.0.0", + "tasks": [ + { + "type": "shell", + "group": "build", + "command": "yarn", + "args": [ + "debug-build" + ], + "problemMatcher": [], + "label": "compile-dev", + "detail": "Compiles main and extension types" + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json index 661bd6d0b9..be3e2896bf 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "scripts": { "dev": "concurrently -k \"yarn run dev-run -C\" yarn:dev:*", "dev-build": "concurrently yarn:compile:*", - "dev-run": "nodemon --watch static/build/main.js --exec \"electron --inspect .\"", + "debug-build": "concurrently yarn:compile:main yarn:compile:extension-types", + "dev-run": "nodemon --watch static/build/main.js --exec \"electron --remote-debugging-port=9223 --inspect .\"", "dev:main": "yarn run compile:main --watch", "dev:renderer": "yarn run webpack-dev-server --config webpack.renderer.ts", "dev:extension-types": "yarn run compile:extension-types --watch --progress", diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 4d46011442..0d412e257c 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -8,7 +8,8 @@ import * as ReactRouterDom from "react-router-dom"; import { render, unmountComponentAtNode } from "react-dom"; import { clusterStore } from "../common/cluster-store"; import { userStore } from "../common/user-store"; -import { isMac } from "../common/vars"; +import { delay } from "../common/utils"; +import { isMac, isDevelopment } from "../common/vars"; import { workspaceStore } from "../common/workspace-store"; import * as LensExtensions from "../extensions/extension-api"; import { extensionDiscovery } from "../extensions/extension-discovery"; @@ -19,6 +20,17 @@ import { App } from "./components/app"; import { LensApp } from "./lens-app"; import { themeStore } from "./theme.store"; +/** + * If this is a development buid, wait a second to attach + * Chrome Debugger to renderer process + * https://stackoverflow.com/questions/52844870/debugging-electron-renderer-process-with-vscode + */ +async function attachChromeDebugger() { + if (isDevelopment) { + await delay(1000); + } +} + type AppComponent = React.ComponentType & { init?(): Promise; }; @@ -35,6 +47,7 @@ export { export async function bootstrap(App: AppComponent) { const rootElem = document.getElementById("app"); + await attachChromeDebugger(); rootElem.classList.toggle("is-mac", isMac); extensionLoader.init(); From 20709d63e9a3ce43bc718457652d2a5614a145ea Mon Sep 17 00:00:00 2001 From: Violetta <38247153+vshakirova@users.noreply.github.com> Date: Wed, 10 Mar 2021 12:31:24 +0400 Subject: [PATCH 22/26] Add image sha256 in pod inspector (#2252) Signed-off-by: vshakirova --- .../components/+workloads-pods/pod-details-container.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/+workloads-pods/pod-details-container.tsx b/src/renderer/components/+workloads-pods/pod-details-container.tsx index b1596c60f5..7d409a2d2d 100644 --- a/src/renderer/components/+workloads-pods/pod-details-container.tsx +++ b/src/renderer/components/+workloads-pods/pod-details-container.tsx @@ -53,6 +53,7 @@ export class PodDetailsContainer extends React.Component { const state = status ? Object.keys(status.state)[0] : ""; const lastState = status ? Object.keys(status.lastState)[0] : ""; const ready = status ? status.ready : ""; + const imageId = status? status.imageID : ""; const liveness = pod.getLivenessProbe(container); const readiness = pod.getReadinessProbe(container); const startup = pod.getStartupProbe(container); @@ -84,7 +85,7 @@ export class PodDetailsContainer extends React.Component { } - {image} + {imagePullPolicy && imagePullPolicy !== "IfNotPresent" && From 805268a9d13b224761cf28ddbc75372b724fba27 Mon Sep 17 00:00:00 2001 From: pashevskii <53330707+pashevskii@users.noreply.github.com> Date: Wed, 10 Mar 2021 12:47:26 +0400 Subject: [PATCH 23/26] Apply custom filters after selecting source (#2241) Signed-off-by: Pavel Ashevskiy Co-authored-by: Pavel Ashevskiy --- .../item-object-list/item-list-layout.tsx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index adec4bef27..15c833d8aa 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -197,17 +197,12 @@ export class ItemListLayout extends React.Component { return filters.reduce((items, filter) => filter(items), items); } - @computed get allItems() { - const { filterItems, store } = this.props; - - return this.applyFilters(filterItems, store.items); - } - @computed get items() { - const { allItems, filters, filterCallbacks } = this; - const filterItems: ItemsFilter[] = []; + const {filters, filterCallbacks } = this; const filterGroups = groupBy(filters, ({ type }) => type); + const filterItems: ItemsFilter[] = []; + Object.entries(filterGroups).forEach(([type, filtersGroup]) => { const filterCallback = filterCallbacks[type]; @@ -216,9 +211,9 @@ export class ItemListLayout extends React.Component { } }); - const items = this.props.items ?? allItems; + const items = this.props.items ?? this.props.store.items; - return this.applyFilters(filterItems, items); + return this.applyFilters(filterItems.concat(this.props.filterItems), items); } @autobind() From b3ce845d6252ace27d2fd912036163bdff2ad62b Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 10 Mar 2021 09:13:27 -0500 Subject: [PATCH 24/26] Fix font-size on (#2273) Signed-off-by: Sebastian Malton --- src/renderer/components/app.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/app.scss b/src/renderer/components/app.scss index 037b088efe..6144b1f265 100755 --- a/src/renderer/components/app.scss +++ b/src/renderer/components/app.scss @@ -151,7 +151,7 @@ code { vertical-align: middle; border-radius: $radius; font-family: $font-monospace; - font-size: calc($font-size * .9); + font-size: calc(var(--font-size) * .9); color: #b4b5b4; &.block { From 2e8f94b3ebcfcedb1a10d3fad1cce8d8c25fdada Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 10 Mar 2021 09:30:48 -0500 Subject: [PATCH 25/26] Fix update available notification able to show twice (#2295) Signed-off-by: Sebastian Malton --- src/renderer/ipc/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index 544cefbf78..5f5e04d9d2 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -4,7 +4,6 @@ import { areArgsUpdateAvailableFromMain, UpdateAvailableChannel, onCorrect, Upda import { Notifications, notificationsStore } from "../components/notifications"; import { Button } from "../components/button"; import { isMac } from "../../common/vars"; -import * as uuid from "uuid"; import { invalidKubeconfigHandler } from "./invalid-kubeconfig-handler"; function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void { @@ -31,7 +30,7 @@ function RenderYesButtons(props: { backchannel: string, notificationId: string } } function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, updateInfo]: UpdateAvailableFromMain): void { - const notificationId = uuid.v4(); + const notificationId = `update-available:${updateInfo.version}`; Notifications.info( ( From 713ec8c69ddab13438f53666145d8b23633f9e74 Mon Sep 17 00:00:00 2001 From: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com> Date: Thu, 11 Mar 2021 02:56:12 -0500 Subject: [PATCH 26/26] Basic workspace overview (#2047) * basic workspace overview Signed-off-by: Jim Ehrismann * css tweaks for landing page as a PageLayout Signed-off-by: Jim Ehrismann * address review comments Signed-off-by: Jim Ehrismann * more review comment addressing, added overview to workspace command palette Signed-off-by: Jim Ehrismann * added back the landing page startup hint Signed-off-by: Jim Ehrismann * refactoring as per review comments Signed-off-by: Jim Ehrismann * added original landing page back only for default workspace with no clusters Signed-off-by: Jim Ehrismann * Workspace overview layout tweaks (#2302) * tweaks workspace overview layout Signed-off-by: Jari Kolehmainen * cluster settings on top Signed-off-by: Jari Kolehmainen * header logo for add cluster page Signed-off-by: Jari Kolehmainen * tweak landing page Signed-off-by: Jari Kolehmainen * combine left menu icons Signed-off-by: Jari Kolehmainen * always show bottom status bar Signed-off-by: Jari Kolehmainen * tweak Signed-off-by: Jari Kolehmainen * integration test fixes Signed-off-by: Jari Kolehmainen * change cluster menu Signed-off-by: Jari Kolehmainen * first attempt to fix integration test Signed-off-by: Jim Ehrismann * lint Signed-off-by: Jim Ehrismann * get selectors right for integration test Signed-off-by: Jim Ehrismann Co-authored-by: Jim Ehrismann Co-authored-by: Jim Ehrismann * address review comments, and rebased to master Signed-off-by: Jim Ehrismann Co-authored-by: Jari Kolehmainen Co-authored-by: Jim Ehrismann --- integration/__tests__/cluster-pages.tests.ts | 3 +- integration/helpers/minikube.ts | 2 +- integration/helpers/utils.ts | 12 ++- src/main/cluster.ts | 2 +- .../components/+add-cluster/add-cluster.tsx | 2 +- .../+cluster-settings/cluster-settings.tsx | 2 +- .../+landing-page/landing-page.scss | 65 +++------------- .../components/+landing-page/landing-page.tsx | 57 +++++++------- .../+landing-page/workspace-cluster-menu.tsx | 74 ++++++++++++++++++ .../+landing-page/workspace-cluster.store.ts | 72 ++++++++++++++++++ .../+landing-page/workspace-overview.scss | 32 ++++++++ .../+landing-page/workspace-overview.tsx | 75 +++++++++++++++++++ .../components/+workspaces/workspaces.tsx | 10 +++ .../cluster-manager/clusters-menu.scss | 30 ++------ .../cluster-manager/clusters-menu.tsx | 40 +++++----- .../item-object-list/item-list-layout.tsx | 3 +- .../components/layout/page-layout.scss | 5 +- 17 files changed, 357 insertions(+), 129 deletions(-) create mode 100644 src/renderer/components/+landing-page/workspace-cluster-menu.tsx create mode 100644 src/renderer/components/+landing-page/workspace-cluster.store.ts create mode 100644 src/renderer/components/+landing-page/workspace-overview.scss create mode 100644 src/renderer/components/+landing-page/workspace-overview.tsx diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index e73774f86a..1662f33f4a 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -25,6 +25,7 @@ describe("Lens cluster pages", () => { let clusterAdded = false; const addCluster = async () => { await utils.clickWhatsNew(app); + await utils.clickWelcomeNotification(app); await addMinikubeCluster(app); await waitForMinikubeDashboard(app); await app.client.click('a[href="/nodes"]'); @@ -345,7 +346,7 @@ describe("Lens cluster pages", () => { } }); - it(`shows a logs for a pod`, async () => { + it(`shows a log for a pod`, async () => { expect(clusterAdded).toBe(true); // Go to Pods page await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text"); diff --git a/integration/helpers/minikube.ts b/integration/helpers/minikube.ts index 67ef0145d5..e2ca0e0f23 100644 --- a/integration/helpers/minikube.ts +++ b/integration/helpers/minikube.ts @@ -39,7 +39,7 @@ export function minikubeReady(testNamespace: string): boolean { } export async function addMinikubeCluster(app: Application) { - await app.client.click("div.add-cluster"); + await app.client.click("button.add-button"); await app.client.waitUntilTextExists("div", "Select kubeconfig file"); await app.client.click("div.Select__control"); // show the context drop-down list await app.client.waitUntilTextExists("div", "minikube"); diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index f96c9124e2..1db9af1c4d 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -47,7 +47,17 @@ export async function appStart() { export async function clickWhatsNew(app: Application) { await app.client.waitUntilTextExists("h1", "What's new?"); await app.client.click("button.primary"); - await app.client.waitUntilTextExists("h1", "Welcome"); + await app.client.waitUntilTextExists("h2", "default"); +} + +export async function clickWelcomeNotification(app: Application) { + const itemsText = await app.client.$("div.info-panel").getText(); + + if (itemsText === "0 item") { + // welcome notification should be present, dismiss it + await app.client.waitUntilTextExists("div.message", "Welcome!"); + await app.client.click("i.Icon.close"); + } } type AsyncPidGetter = () => Promise; diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 198d24c2f9..88ae49d123 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -252,7 +252,7 @@ export class Cluster implements ClusterModel, ClusterState { * Kubernetes version */ get version(): string { - return String(this.metadata?.version) || ""; + return String(this.metadata?.version || ""); } constructor(model: ClusterModel) { diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index 38d03482e8..dc5cdf4d4c 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -352,7 +352,7 @@ export class AddCluster extends React.Component { return ( - Add Clusters

}> +

Add Clusters

} showOnTop={true}>

Add Clusters from Kubeconfig

{this.renderInfo()} {this.renderKubeConfigSource()} diff --git a/src/renderer/components/+cluster-settings/cluster-settings.tsx b/src/renderer/components/+cluster-settings/cluster-settings.tsx index 0cde390c47..4ec8e1ccdd 100644 --- a/src/renderer/components/+cluster-settings/cluster-settings.tsx +++ b/src/renderer/components/+cluster-settings/cluster-settings.tsx @@ -59,7 +59,7 @@ export class ClusterSettings extends React.Component { ); return ( - + diff --git a/src/renderer/components/+landing-page/landing-page.scss b/src/renderer/components/+landing-page/landing-page.scss index 4874b37c72..ea1eb664ef 100644 --- a/src/renderer/components/+landing-page/landing-page.scss +++ b/src/renderer/components/+landing-page/landing-page.scss @@ -1,60 +1,15 @@ -.LandingPage { - width: 100%; - height: 100%; +.PageLayout.LandingOverview { + --width: 100%; + --height: 100%; text-align: center; - z-index: 0; - &::after { - content: ""; - background: url(../../components/icon/crane.svg) no-repeat; - background-position: 0 35%; - background-size: 85%; - background-clip: content-box; - opacity: .75; - top: 0; - left: 0; - bottom: 0; - right: 0; - position: absolute; - z-index: -1; - .theme-light & { - opacity: 0.2; + + .content-wrapper { + + .content { + margin: unset; + max-width: unset; } } - - .startup-hint { - $bgc: $mainBackground; - $arrowSize: 10px; - - position: absolute; - left: 0; - top: 25px; - margin: $padding; - padding: $padding * 2; - width: 320px; - background: $bgc; - color: $textColorAccent; - filter: drop-shadow(0 0px 2px #ffffff33); - - &:before { - content: ""; - position: absolute; - width: 0; - height: 0; - border-top: $arrowSize solid transparent; - border-bottom: $arrowSize solid transparent; - border-right: $arrowSize solid $bgc; - right: 100%; - } - - .theme-light & { - filter: drop-shadow(0 0px 2px #777); - background: white; - - &:before { - border-right-color: white; - } - } - } -} \ No newline at end of file +} diff --git a/src/renderer/components/+landing-page/landing-page.tsx b/src/renderer/components/+landing-page/landing-page.tsx index ea0b24bb87..15e22b2c9f 100644 --- a/src/renderer/components/+landing-page/landing-page.tsx +++ b/src/renderer/components/+landing-page/landing-page.tsx @@ -1,40 +1,47 @@ import "./landing-page.scss"; import React from "react"; -import { observable } from "mobx"; +import { computed, observable } from "mobx"; import { observer } from "mobx-react"; import { clusterStore } from "../../../common/cluster-store"; -import { workspaceStore } from "../../../common/workspace-store"; +import { Workspace, workspaceStore } from "../../../common/workspace-store"; +import { WorkspaceOverview } from "./workspace-overview"; +import { PageLayout } from "../layout/page-layout"; +import { Notifications } from "../notifications"; +import { Icon } from "../icon"; @observer export class LandingPage extends React.Component { @observable showHint = true; + get workspace(): Workspace { + return workspaceStore.currentWorkspace; + } + + @computed + get clusters() { + return clusterStore.getByWorkspaceId(this.workspace.id); + } + + componentDidMount() { + const noClustersInScope = !this.clusters.length; + const showStartupHint = this.showHint; + + if (showStartupHint && noClustersInScope) { + Notifications.info(<>Welcome!

Get started by associating one or more clusters to Lens

, { + timeout: 30_000, + id: "landing-welcome" + }); + } + } + render() { - const clusters = clusterStore.getByWorkspaceId(workspaceStore.currentWorkspaceId); - const noClustersInScope = !clusters.length; - const showStartupHint = this.showHint && noClustersInScope; + const showBackButton = this.clusters.length > 0; + const header = <>

{this.workspace.name}

; return ( -
- {showStartupHint && ( -
this.showHint = false}> -

This is the quick launch menu.

-

- Associate clusters and choose the ones you want to access via quick launch menu by clicking the + button. -

-
- )} - {noClustersInScope && ( -
-

- Welcome! -

-

- Get started by associating one or more clusters to Lens. -

-
- )} -
+ + + ); } } diff --git a/src/renderer/components/+landing-page/workspace-cluster-menu.tsx b/src/renderer/components/+landing-page/workspace-cluster-menu.tsx new file mode 100644 index 0000000000..8c2935c3e1 --- /dev/null +++ b/src/renderer/components/+landing-page/workspace-cluster-menu.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { ClusterItem, WorkspaceClusterStore } from "./workspace-cluster.store"; +import { autobind, cssNames } from "../../utils"; +import { MenuActions, MenuActionsProps } from "../menu/menu-actions"; +import { MenuItem } from "../menu"; +import { Icon } from "../icon"; +import { Workspace } from "../../../common/workspace-store"; +import { clusterSettingsURL } from "../+cluster-settings"; +import { navigate } from "../../navigation"; + +interface Props extends MenuActionsProps { + clusterItem: ClusterItem; + workspace: Workspace; + workspaceClusterStore: WorkspaceClusterStore; +} + +export class WorkspaceClusterMenu extends React.Component { + + @autobind() + remove() { + const { clusterItem, workspaceClusterStore } = this.props; + + return workspaceClusterStore.remove(clusterItem); + } + + @autobind() + gotoSettings() { + const { clusterItem } = this.props; + + navigate(clusterSettingsURL({ + params: { + clusterId: clusterItem.id + } + })); + } + + @autobind() + renderRemoveMessage() { + const { clusterItem, workspace } = this.props; + + return ( +

Remove cluster {clusterItem.name} from workspace {workspace.name}?

+ ); + } + + + renderContent() { + const { toolbar } = this.props; + + return ( + <> + + + Settings + + + ); + } + + render() { + const { clusterItem: { cluster: { isManaged } }, className, ...menuProps } = this.props; + + return ( + + {this.renderContent()} + + ); + } +} diff --git a/src/renderer/components/+landing-page/workspace-cluster.store.ts b/src/renderer/components/+landing-page/workspace-cluster.store.ts new file mode 100644 index 0000000000..24834927b3 --- /dev/null +++ b/src/renderer/components/+landing-page/workspace-cluster.store.ts @@ -0,0 +1,72 @@ +import { WorkspaceId } from "../../../common/workspace-store"; +import { Cluster } from "../../../main/cluster"; +import { clusterStore } from "../../../common/cluster-store"; +import { ItemObject, ItemStore } from "../../item.store"; +import { autobind } from "../../utils"; + +export class ClusterItem implements ItemObject { + constructor(public cluster: Cluster) {} + + get name() { + return this.cluster.name; + } + + get distribution() { + return this.cluster.metadata?.distribution?.toString() ?? "unknown"; + } + + get version() { + return this.cluster.version; + } + + get connectionStatus() { + return this.cluster.online ? "connected" : "disconnected"; + } + + getName() { + return this.name; + } + + get id() { + return this.cluster.id; + } + + get clusterId() { + return this.cluster.id; + } + + getId() { + return this.id; + } +} + +/** an ItemStore of the clusters belonging to a given workspace */ +@autobind() +export class WorkspaceClusterStore extends ItemStore { + + workspaceId: WorkspaceId; + + constructor(workspaceId: WorkspaceId) { + super(); + this.workspaceId = workspaceId; + } + + loadAll() { + return this.loadItems( + () => ( + clusterStore + .getByWorkspaceId(this.workspaceId) + .filter(cluster => cluster.enabled) + .map(cluster => new ClusterItem(cluster)) + ) + ); + } + + async remove(clusterItem: ClusterItem) { + const { cluster: { isManaged, id: clusterId }} = clusterItem; + + if (!isManaged) { + return super.removeItem(clusterItem, () => clusterStore.removeById(clusterId)); + } + } +} diff --git a/src/renderer/components/+landing-page/workspace-overview.scss b/src/renderer/components/+landing-page/workspace-overview.scss new file mode 100644 index 0000000000..fa156bf74f --- /dev/null +++ b/src/renderer/components/+landing-page/workspace-overview.scss @@ -0,0 +1,32 @@ +.WorkspaceOverview { + max-height: 50%; + .Table { + padding-bottom: 60px; + } + .TableCell { + display: flex; + align-items: left; + + &.cluster-icon { + align-items: center; + flex-grow: 0.2; + padding: 0; + } + + &.connected { + color: var(--colorSuccess); + } + } + + .TableCell.status { + flex: 0.1; + } + + .TableCell.distribution { + flex: 0.2; + } + + .TableCell.version { + flex: 0.2; + } +} diff --git a/src/renderer/components/+landing-page/workspace-overview.tsx b/src/renderer/components/+landing-page/workspace-overview.tsx new file mode 100644 index 0000000000..c095abb31b --- /dev/null +++ b/src/renderer/components/+landing-page/workspace-overview.tsx @@ -0,0 +1,75 @@ +import "./workspace-overview.scss"; + +import React, { Component } from "react"; +import { Workspace } from "../../../common/workspace-store"; +import { observer } from "mobx-react"; +import { ItemListLayout } from "../item-object-list/item-list-layout"; +import { ClusterItem, WorkspaceClusterStore } from "./workspace-cluster.store"; +import { navigate } from "../../navigation"; +import { clusterViewURL } from "../cluster-manager/cluster-view.route"; +import { WorkspaceClusterMenu } from "./workspace-cluster-menu"; +import { kebabCase } from "lodash"; +import { addClusterURL } from "../+add-cluster"; + +interface Props { + workspace: Workspace; +} + +enum sortBy { + name = "name", + distribution = "distribution", + version = "version", + online = "online" +} + +@observer +export class WorkspaceOverview extends Component { + + showCluster = ({ clusterId }: ClusterItem) => { + navigate(clusterViewURL({ params: { clusterId } })); + }; + + render() { + const { workspace } = this.props; + const workspaceClusterStore = new WorkspaceClusterStore(workspace.id); + + workspaceClusterStore.loadAll(); + + return ( + Clusters} + isClusterScoped + isSearchable={false} + isSelectable={false} + className="WorkspaceOverview" + store={workspaceClusterStore} + sortingCallbacks={{ + [sortBy.name]: (item: ClusterItem) => item.name, + [sortBy.distribution]: (item: ClusterItem) => item.distribution, + [sortBy.version]: (item: ClusterItem) => item.version, + [sortBy.online]: (item: ClusterItem) => item.connectionStatus, + }} + renderTableHeader={[ + { title: "Name", className: "name", sortBy: sortBy.name }, + { title: "Distribution", className: "distribution", sortBy: sortBy.distribution }, + { title: "Version", className: "version", sortBy: sortBy.version }, + { title: "Status", className: "status", sortBy: sortBy.online }, + ]} + renderTableContents={(item: ClusterItem) => [ + item.name, + item.distribution, + item.version, + { title: item.connectionStatus, className: kebabCase(item.connectionStatus) } + ]} + onDetails={this.showCluster} + addRemoveButtons={{ + addTooltip: "Add Cluster", + onAdd: () => navigate(addClusterURL()), + }} + renderItemMenu={(clusterItem: ClusterItem) => ( + + )} + /> + ); + } +} diff --git a/src/renderer/components/+workspaces/workspaces.tsx b/src/renderer/components/+workspaces/workspaces.tsx index 7e3b9647f2..3bea020ef3 100644 --- a/src/renderer/components/+workspaces/workspaces.tsx +++ b/src/renderer/components/+workspaces/workspaces.tsx @@ -14,6 +14,7 @@ import { clusterViewURL } from "../cluster-manager/cluster-view.route"; @observer export class ChooseWorkspace extends React.Component { + private static overviewActionId = "__overview__"; private static addActionId = "__add__"; private static removeActionId = "__remove__"; private static editActionId = "__edit__"; @@ -23,6 +24,8 @@ export class ChooseWorkspace extends React.Component { return { value: workspace.id, label: workspace.name }; }); + options.push({ value: ChooseWorkspace.overviewActionId, label: "Show current workspace overview ..." }); + options.push({ value: ChooseWorkspace.addActionId, label: "Add workspace ..." }); if (options.length > 1) { @@ -37,6 +40,13 @@ export class ChooseWorkspace extends React.Component { } onChange(id: string) { + if (id === ChooseWorkspace.overviewActionId) { + navigate(landingURL()); // overview of active workspace. TODO: change name from landing + CommandOverlay.close(); + + return; + } + if (id === ChooseWorkspace.addActionId) { CommandOverlay.open(); diff --git a/src/renderer/components/cluster-manager/clusters-menu.scss b/src/renderer/components/cluster-manager/clusters-menu.scss index b6a1e66d1a..b24daa4522 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.scss +++ b/src/renderer/components/cluster-manager/clusters-menu.scss @@ -27,14 +27,16 @@ } } - > .add-cluster { + > .WorkspaceMenu { position: relative; + margin-bottom: $margin; .Icon { + margin-bottom: $margin * 1.5; border-radius: $radius; padding: $padding / 3; - color: $addClusterIconColor; - background: #ffffff66; + color: #ffffff66; + background: unset; cursor: pointer; &.active { @@ -43,28 +45,10 @@ &:hover { box-shadow: none; - background: #ffffff; + color: #ffffff; + background-color: unset; } } - - .Badge { - $boxSize: 17px; - - position: absolute; - bottom: 0px; - transform: translateX(-50%) translateY(50%); - font-size: $font-size-small; - line-height: $boxSize; - min-width: $boxSize; - min-height: $boxSize; - text-align: center; - color: white; - background: $colorSuccess; - font-weight: normal; - border-radius: $radius; - padding: 0; - pointer-events: none; - } } > .extensions { diff --git a/src/renderer/components/cluster-manager/clusters-menu.tsx b/src/renderer/components/cluster-manager/clusters-menu.tsx index d963438136..dd36e529e1 100644 --- a/src/renderer/components/cluster-manager/clusters-menu.tsx +++ b/src/renderer/components/cluster-manager/clusters-menu.tsx @@ -6,26 +6,24 @@ import { requestMain } from "../../../common/ipc"; import type { Cluster } from "../../../main/cluster"; import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd"; import { observer } from "mobx-react"; -import { userStore } from "../../../common/user-store"; import { ClusterId, clusterStore } from "../../../common/cluster-store"; import { workspaceStore } from "../../../common/workspace-store"; import { ClusterIcon } from "../cluster-icon"; import { Icon } from "../icon"; import { autobind, cssNames, IClassName } from "../../utils"; -import { Badge } from "../badge"; import { isActiveRoute, navigate } from "../../navigation"; import { addClusterURL } from "../+add-cluster"; import { clusterSettingsURL } from "../+cluster-settings"; import { landingURL } from "../+landing-page"; -import { Tooltip } from "../tooltip"; import { ConfirmDialog } from "../confirm-dialog"; import { clusterViewURL } from "./cluster-view.route"; import { getExtensionPageUrl, globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries"; import { clusterDisconnectHandler } from "../../../common/cluster-ipc"; import { commandRegistry } from "../../../extensions/registries/command-registry"; import { CommandOverlay } from "../command-palette/command-container"; -import { computed } from "mobx"; +import { computed, observable } from "mobx"; import { Select } from "../select"; +import { Menu, MenuItem } from "../menu"; interface Props { className?: IClassName; @@ -33,14 +31,12 @@ interface Props { @observer export class ClustersMenu extends React.Component { + @observable workspaceMenuVisible = false; + showCluster = (clusterId: ClusterId) => { navigate(clusterViewURL({ params: { clusterId } })); }; - addCluster = () => { - navigate(addClusterURL()); - }; - showContextMenu = (cluster: Cluster) => { const { Menu, MenuItem } = remote; const menu = new Menu(); @@ -111,7 +107,6 @@ export class ClustersMenu extends React.Component { render() { const { className } = this.props; - const { newContexts } = userStore; const workspace = workspaceStore.getById(workspaceStore.currentWorkspaceId); const clusters = clusterStore.getByWorkspaceId(workspace.id).filter(cluster => cluster.enabled); const activeClusterId = clusterStore.activeCluster; @@ -149,14 +144,25 @@ export class ClustersMenu extends React.Component { -
- - Add Cluster - - - {newContexts.size > 0 && ( - - )} + +
+ + this.workspaceMenuVisible = true} + close={() => this.workspaceMenuVisible = false} + toggleEvent="click" + > + navigate(addClusterURL())} data-test-id="add-cluster-menu-item"> + Add Cluster + + navigate(landingURL())} data-test-id="workspace-overview-menu-item"> + Workspace Overview + +
{globalPageMenuRegistry.getItems().map(({ title, target, components: { Icon } }) => { diff --git a/src/renderer/components/item-object-list/item-list-layout.tsx b/src/renderer/components/item-object-list/item-list-layout.tsx index 15c833d8aa..bb4e384c27 100644 --- a/src/renderer/components/item-object-list/item-list-layout.tsx +++ b/src/renderer/components/item-object-list/item-list-layout.tsx @@ -318,6 +318,7 @@ export class ItemListLayout extends React.Component { } renderHeaderContent(placeholders: IHeaderPlaceholders): ReactNode { + const { isSearchable, searchFilters } = this.props; const { title, filters, search, info } = placeholders; return ( @@ -327,7 +328,7 @@ export class ItemListLayout extends React.Component { {this.isReady && info}
{filters} - {search} + {isSearchable && searchFilters && search} ); } diff --git a/src/renderer/components/layout/page-layout.scss b/src/renderer/components/layout/page-layout.scss index c975ea3305..d7fd0a8544 100644 --- a/src/renderer/components/layout/page-layout.scss +++ b/src/renderer/components/layout/page-layout.scss @@ -17,7 +17,8 @@ left: 0; top: 0; right: 0; - bottom: 0; + bottom: 24px; + height: unset; background-color: $mainBackground; // adds extra space for traffic-light top buttons (mac only) @@ -73,4 +74,4 @@ box-shadow: 0 0 0 1px $borderFaintColor; } } -} \ No newline at end of file +}