From 5f6abe34967c1d3affd3c96a734e22cd6af55c73 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 16 Jun 2021 14:21:23 -0400 Subject: [PATCH 01/70] Update `extensions/**/package-lock.json` to v0.0.1 (#3076) Signed-off-by: Sebastian Malton --- extensions/kube-object-event-status/package-lock.json | 2 +- extensions/metrics-cluster-feature/package-lock.json | 2 +- extensions/node-menu/package-lock.json | 2 +- extensions/pod-menu/package-lock.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/extensions/kube-object-event-status/package-lock.json b/extensions/kube-object-event-status/package-lock.json index e1234ebd73..f3b0fe6669 100644 --- a/extensions/kube-object-event-status/package-lock.json +++ b/extensions/kube-object-event-status/package-lock.json @@ -1,6 +1,6 @@ { "name": "kube-object-event-status", - "version": "0.1.0", + "version": "0.0.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/extensions/metrics-cluster-feature/package-lock.json b/extensions/metrics-cluster-feature/package-lock.json index f6a43b3f72..15feb302b8 100644 --- a/extensions/metrics-cluster-feature/package-lock.json +++ b/extensions/metrics-cluster-feature/package-lock.json @@ -1,6 +1,6 @@ { "name": "lens-metrics-cluster-feature", - "version": "0.1.0", + "version": "0.0.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/extensions/node-menu/package-lock.json b/extensions/node-menu/package-lock.json index 245b548fdc..3ca2677ed4 100644 --- a/extensions/node-menu/package-lock.json +++ b/extensions/node-menu/package-lock.json @@ -1,6 +1,6 @@ { "name": "lens-node-menu", - "version": "0.1.0", + "version": "0.0.1", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/extensions/pod-menu/package-lock.json b/extensions/pod-menu/package-lock.json index e0cc8c93c0..70154d1055 100644 --- a/extensions/pod-menu/package-lock.json +++ b/extensions/pod-menu/package-lock.json @@ -1,6 +1,6 @@ { "name": "lens-pod-menu", - "version": "0.1.0", + "version": "0.0.1", "lockfileVersion": 1, "requires": true, "dependencies": { From dc2d7a08d36eb8d15ccd8257e2e94b2922041f52 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Thu, 17 Jun 2021 07:45:46 +0300 Subject: [PATCH 02/70] Check cluster connected status from cluster.status.phase property (#3078) Signed-off-by: Lauri Nevala --- .../metrics-cluster-feature/src/metrics-settings.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/extensions/metrics-cluster-feature/src/metrics-settings.tsx b/extensions/metrics-cluster-feature/src/metrics-settings.tsx index b5a78eda1a..5399cd638a 100644 --- a/extensions/metrics-cluster-feature/src/metrics-settings.tsx +++ b/extensions/metrics-cluster-feature/src/metrics-settings.tsx @@ -81,7 +81,7 @@ export class MetricsSettings extends React.Component { @computed get isTogglable() { if (this.inProgress) return false; - if (!this.props.cluster.status.active) return false; + if (this.props.cluster.status.phase !== "connected") return false; if (this.canUpgrade) return false; if (!this.isActiveMetricsProvider) return false; @@ -205,7 +205,7 @@ export class MetricsSettings extends React.Component { render() { return ( <> - { !this.props.cluster.status.active && ( + { this.props.cluster.status.phase !== "connected" && (

Lens Metrics settings requires established connection to the cluster. @@ -225,7 +225,7 @@ export class MetricsSettings extends React.Component { control={ this.togglePrometheus(v.target.checked)} name="prometheus" /> @@ -243,7 +243,7 @@ export class MetricsSettings extends React.Component { control={ this.toggleKubeStateMetrics(v.target.checked)} name="node-exporter" /> @@ -262,7 +262,7 @@ export class MetricsSettings extends React.Component { control={ this.toggleNodeExporter(v.target.checked)} name="node-exporter" /> From c3c944cd3020fea243d132aeb02fafc36efd8b1d Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 17 Jun 2021 05:59:27 -0400 Subject: [PATCH 03/70] Send LensMainExtension.nagivate calls to renderer before navigating (#3082) Signed-off-by: Sebastian Malton --- src/common/cluster-frames.ts | 4 +- src/common/utils/iter.ts | 16 ++++++++ src/extensions/common-api/registrations.ts | 2 +- src/extensions/extension-loader.ts | 4 ++ src/extensions/lens-main-extension.ts | 12 +----- src/extensions/lens-renderer-extension.ts | 37 ++++++++----------- .../registries/page-menu-registry.ts | 19 ++++------ src/main/window-manager.ts | 24 ++++++++++-- src/renderer/bootstrap.tsx | 1 + src/renderer/initializers/index.ts | 1 + src/renderer/initializers/ipc.ts | 30 +++++++++++++++ src/renderer/initializers/registries.ts | 1 - 12 files changed, 100 insertions(+), 51 deletions(-) create mode 100644 src/renderer/initializers/ipc.ts diff --git a/src/common/cluster-frames.ts b/src/common/cluster-frames.ts index 794a09d827..88dc2d3163 100644 --- a/src/common/cluster-frames.ts +++ b/src/common/cluster-frames.ts @@ -21,9 +21,9 @@ import { observable } from "mobx"; -export type ClusterFrameInfo = { +export interface ClusterFrameInfo { frameId: number; processId: number -}; +} export const clusterFrameMap = observable.map(); diff --git a/src/common/utils/iter.ts b/src/common/utils/iter.ts index 277c58213e..59fce41829 100644 --- a/src/common/utils/iter.ts +++ b/src/common/utils/iter.ts @@ -140,3 +140,19 @@ export function* filterMapStrict(src: Iterable, fn: (from: T) => U | nu } } } + +/** + * Iterate through `src` until `match` returns a truthy value + * @param src A type that can be iterated over + * @param match A function that should return a truthy value for the item that you want to find + * @returns The first entry that `match` returns a truthy value for, or `undefined` + */ +export function find(src: Iterable, match: (i: T) => any): T | undefined { + for (const from of src) { + if (match(from)) { + return from; + } + } + + return void 0; +} diff --git a/src/extensions/common-api/registrations.ts b/src/extensions/common-api/registrations.ts index dadde70ceb..7b7688e85f 100644 --- a/src/extensions/common-api/registrations.ts +++ b/src/extensions/common-api/registrations.ts @@ -24,6 +24,6 @@ export type { KubeObjectDetailRegistration, KubeObjectDetailComponents } from ". export type { KubeObjectMenuRegistration, KubeObjectMenuComponents } from "../registries/kube-object-menu-registry"; export type { KubeObjectStatusRegistration } from "../registries/kube-object-status-registry"; export type { PageRegistration, RegisteredPage, PageParams, PageComponentProps, PageComponents, PageTarget } from "../registries/page-registry"; -export type { PageMenuRegistration, ClusterPageMenuRegistration, PageMenuComponents } from "../registries/page-menu-registry"; +export type { ClusterPageMenuRegistration, ClusterPageMenuComponents } 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"; diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index 4073ad1e79..d3349b3c16 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -355,6 +355,10 @@ export class ExtensionLoader extends Singleton { return this.extensions.get(extId); } + getInstanceById(extId: LensExtensionId): E { + return this.instances.get(extId) as E; + } + toJSON(): Map { return toJS(this.extensions); } diff --git a/src/extensions/lens-main-extension.ts b/src/extensions/lens-main-extension.ts index 023a8b92f9..cbdba9e07c 100644 --- a/src/extensions/lens-main-extension.ts +++ b/src/extensions/lens-main-extension.ts @@ -21,7 +21,6 @@ import { LensExtension } from "./lens-extension"; import { WindowManager } from "../main/window-manager"; -import { getExtensionPageUrl } from "./registries/page-registry"; import { catalogEntityRegistry } from "../main/catalog"; import type { CatalogEntity } from "../common/catalog"; import type { IObservableArray } from "mobx"; @@ -30,15 +29,8 @@ import type { MenuRegistration } from "./registries"; export class LensMainExtension extends LensExtension { appMenus: MenuRegistration[] = []; - async navigate

(pageId?: string, params?: P, frameId?: number) { - const windowManager = WindowManager.getInstance(); - const pageUrl = getExtensionPageUrl({ - extensionId: this.name, - pageId, - params: params ?? {}, // compile to url with params - }); - - await windowManager.navigate(pageUrl, frameId); + async navigate(pageId?: string, params?: Record, frameId?: number) { + return WindowManager.getInstance().navigateExtension(this.id, pageId, params, frameId); } addCatalogSource(id: string, source: IObservableArray) { diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 7119b0f7ac..a3026d99a5 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -19,33 +19,26 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { - AppPreferenceRegistration, CatalogEntityDetailRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, - KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, WelcomeMenuRegistration, WorkloadsOverviewDetailRegistration, -} from "./registries"; +import type * as registries from "./registries"; import type { Cluster } from "../main/cluster"; import { LensExtension } from "./lens-extension"; import { getExtensionPageUrl } from "./registries/page-registry"; -import type { CommandRegistration } from "./registries/command-registry"; -import type { EntitySettingRegistration } from "./registries/entity-setting-registry"; -import type { TopBarRegistration } from "./registries/topbar-registry"; export class LensRendererExtension extends LensExtension { - globalPages: PageRegistration[] = []; - clusterPages: PageRegistration[] = []; - globalPageMenus: PageMenuRegistration[] = []; - clusterPageMenus: ClusterPageMenuRegistration[] = []; - kubeObjectStatusTexts: KubeObjectStatusRegistration[] = []; - appPreferences: AppPreferenceRegistration[] = []; - entitySettings: EntitySettingRegistration[] = []; - statusBarItems: StatusBarRegistration[] = []; - kubeObjectDetailItems: KubeObjectDetailRegistration[] = []; - kubeObjectMenuItems: KubeObjectMenuRegistration[] = []; - kubeWorkloadsOverviewItems: WorkloadsOverviewDetailRegistration[] = []; - commands: CommandRegistration[] = []; - welcomeMenus: WelcomeMenuRegistration[] = []; - catalogEntityDetailItems: CatalogEntityDetailRegistration[] = []; - topBarItems: TopBarRegistration[] = []; + globalPages: registries.PageRegistration[] = []; + clusterPages: registries.PageRegistration[] = []; + clusterPageMenus: registries.ClusterPageMenuRegistration[] = []; + kubeObjectStatusTexts: registries.KubeObjectStatusRegistration[] = []; + appPreferences: registries.AppPreferenceRegistration[] = []; + entitySettings: registries.EntitySettingRegistration[] = []; + statusBarItems: registries.StatusBarRegistration[] = []; + kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = []; + kubeObjectMenuItems: registries.KubeObjectMenuRegistration[] = []; + kubeWorkloadsOverviewItems: registries.WorkloadsOverviewDetailRegistration[] = []; + commands: registries.CommandRegistration[] = []; + welcomeMenus: registries.WelcomeMenuRegistration[] = []; + catalogEntityDetailItems: registries.CatalogEntityDetailRegistration[] = []; + topBarItems: registries.TopBarRegistration[] = []; async navigate

(pageId?: string, params?: P) { const { navigate } = await import("../renderer/navigation"); diff --git a/src/extensions/registries/page-menu-registry.ts b/src/extensions/registries/page-menu-registry.ts index 73997fb461..17e5f10491 100644 --- a/src/extensions/registries/page-menu-registry.ts +++ b/src/extensions/registries/page-menu-registry.ts @@ -26,23 +26,20 @@ import type { PageTarget, RegisteredPage } from "./page-registry"; import type { LensExtension } from "../lens-extension"; import { BaseRegistry } from "./base-registry"; -export interface PageMenuRegistration { - target?: PageTarget; - title: React.ReactNode; - components: PageMenuComponents; -} - -export interface ClusterPageMenuRegistration extends PageMenuRegistration { +export interface ClusterPageMenuRegistration { id?: string; parentId?: string; + target?: PageTarget; + title: React.ReactNode; + components: ClusterPageMenuComponents; } -export interface PageMenuComponents { +export interface ClusterPageMenuComponents { Icon: React.ComponentType; } -export class PageMenuRegistry extends BaseRegistry { - add(items: T[], ext: LensExtension) { +export class ClusterPageMenuRegistry extends BaseRegistry { + add(items: ClusterPageMenuRegistration[], ext: LensExtension) { const normalizedItems = items.map(menuItem => { menuItem.target = { extensionId: ext.name, @@ -54,9 +51,7 @@ export class PageMenuRegistry extends BaseRegist return super.add(normalizedItems); } -} -export class ClusterPageMenuRegistry extends PageMenuRegistry { getRootItems() { return this.getItems().filter((item) => !item.parentId); } diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index b30d26d519..9a56aaafb8 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -27,7 +27,7 @@ import { appEventBus } from "../common/event-bus"; import { ipcMainOn } from "../common/ipc"; import { initMenu } from "./menu"; import { initTray } from "./tray"; -import { delay, Singleton } from "../common/utils"; +import { delay, iter, Singleton } from "../common/utils"; import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import logger from "./logger"; @@ -38,6 +38,12 @@ function isHideable(window: BrowserWindow | null): boolean { return Boolean(window && !window.isDestroyed()); } +export interface SendToViewArgs { + channel: string; + frameInfo?: ClusterFrameInfo; + data?: any[]; +} + export class WindowManager extends Singleton { protected mainWindow: BrowserWindow; protected splashWindow: BrowserWindow; @@ -175,7 +181,7 @@ export class WindowManager extends Singleton { return this.mainWindow; } - sendToView({ channel, frameInfo, data = [] }: { channel: string, frameInfo?: ClusterFrameInfo, data?: any[] }) { + private sendToView({ channel, frameInfo, data = [] }: SendToViewArgs) { if (frameInfo) { this.mainWindow.webContents.sendToFrame([frameInfo.processId, frameInfo.frameId], channel, ...data); } else { @@ -183,10 +189,22 @@ export class WindowManager extends Singleton { } } + async navigateExtension(extId: string, pageId?: string, params?: Record, frameId?: number) { + await this.ensureMainWindow(); + + const frameInfo = iter.find(clusterFrameMap.values(), frameInfo => frameInfo.frameId === frameId); + + this.sendToView({ + channel: "extension:navigate", + frameInfo, + data: [extId, pageId, params], + }); + } + async navigate(url: string, frameId?: number) { await this.ensureMainWindow(); - const frameInfo = Array.from(clusterFrameMap.values()).find((frameInfo) => frameInfo.frameId === frameId); + const frameInfo = iter.find(clusterFrameMap.values(), frameInfo => frameInfo.frameId === frameId); const channel = frameInfo ? IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER : IpcRendererNavigationEvents.NAVIGATE_IN_APP; diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 8c7fe38d4c..3824caa011 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -77,6 +77,7 @@ export async function bootstrap(App: AppComponent) { initializers.intiKubeObjectDetailRegistry(); initializers.initWelcomeMenuRegistry(); initializers.initWorkloadsOverviewDetailRegistry(); + initializers.initIpcRendererListeners(); ExtensionLoader.createInstance().init(); ExtensionDiscovery.createInstance().init(); diff --git a/src/renderer/initializers/index.ts b/src/renderer/initializers/index.ts index a5cbb49257..0bb4eb611e 100644 --- a/src/renderer/initializers/index.ts +++ b/src/renderer/initializers/index.ts @@ -26,3 +26,4 @@ export * from "./kube-object-menu-registry"; export * from "./registries"; export * from "./welcome-menu-registry"; export * from "./workloads-overview-detail-registry"; +export * from "./ipc"; diff --git a/src/renderer/initializers/ipc.ts b/src/renderer/initializers/ipc.ts new file mode 100644 index 0000000000..fedb007d9b --- /dev/null +++ b/src/renderer/initializers/ipc.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { ipcRendererOn } from "../../common/ipc"; +import { ExtensionLoader } from "../../extensions/extension-loader"; +import type { LensRendererExtension } from "../../extensions/lens-renderer-extension"; + +export function initIpcRendererListeners() { + ipcRendererOn("extension:navigate", (event, extId: string, pageId ?: string, params?: Record) => { + ExtensionLoader.getInstance().getInstanceById(extId).navigate(pageId, params); + }); +} diff --git a/src/renderer/initializers/registries.ts b/src/renderer/initializers/registries.ts index 08594d41c5..1d455a0bcc 100644 --- a/src/renderer/initializers/registries.ts +++ b/src/renderer/initializers/registries.ts @@ -32,7 +32,6 @@ export function initRegistries() { registries.KubeObjectDetailRegistry.createInstance(); registries.KubeObjectMenuRegistry.createInstance(); registries.KubeObjectStatusRegistry.createInstance(); - registries.PageMenuRegistry.createInstance(); registries.StatusBarRegistry.createInstance(); registries.WelcomeMenuRegistry.createInstance(); registries.WorkloadsOverviewDetailRegistry.createInstance(); From e64f90332e720630072ed94e0f74a925bd546020 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Thu, 17 Jun 2021 13:22:52 +0300 Subject: [PATCH 04/70] Add React.Fragment with key property to entity settings navigation (#3087) Signed-off-by: Lauri Nevala --- .../components/+entity-settings/entity-settings.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/+entity-settings/entity-settings.tsx b/src/renderer/components/+entity-settings/entity-settings.tsx index d37fdef953..16c79412cf 100644 --- a/src/renderer/components/+entity-settings/entity-settings.tsx +++ b/src/renderer/components/+entity-settings/entity-settings.tsx @@ -85,8 +85,8 @@ export class EntitySettings extends React.Component { <>

{this.entity.metadata.name}

- { groups.map((group) => ( - <> + { groups.map((group, groupIndex) => ( +
{group[0]}
{ group[1].map((setting, index) => ( { data-testid={`${setting.id}-tab`} /> ))} - +
))}
From 7988f068cb8eb762cf5eaaacb590ff32e1ad3965 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 17 Jun 2021 06:25:24 -0400 Subject: [PATCH 05/70] release v5.0.0-beta.9 (#3063) * Revert displayName being first key Signed-off-by: Sebastian Malton * release v5.0.0-beta.9 Signed-off-by: Sebastian Malton --- .azure-pipelines.yml | 88 ++++++++++++++++++++++---------------------- package.json | 2 +- 2 files changed, 45 insertions(+), 45 deletions(-) diff --git a/.azure-pipelines.yml b/.azure-pipelines.yml index 73fe5f9ee3..afec611177 100644 --- a/.azure-pipelines.yml +++ b/.azure-pipelines.yml @@ -19,27 +19,26 @@ jobs: node_12.x: node_version: 12.x steps: - - displayName: Set the tag name as an environment variable - powershell: | + - powershell: | $CI_BUILD_TAG = git describe --tags Write-Output ("##vso[task.setvariable variable=CI_BUILD_TAG;]$CI_BUILD_TAG") condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" + displayName: Set the tag name as an environment variable - - displayName: Install Node.js - task: NodeTool@0 + - task: NodeTool@0 inputs: versionSpec: $(node_version) + displayName: Install Node.js - - displayName: Cache Yarn packages - task: Cache@2 + - task: Cache@2 inputs: key: 'yarn | "$(Agent.OS)"" | yarn.lock' restoreKeys: | yarn | "$(Agent.OS)" path: $(YARN_CACHE_FOLDER) + displayName: Cache Yarn packages - - displayName: Customize config - bash: | + - bash: | set -e git clone "https://${GH_TOKEN}@github.com/lensapp/lens-ide.git" .lens-ide-overlay rm -rf .lens-ide-overlay/.git @@ -47,15 +46,15 @@ jobs: jq -s '.[0] * .[1]' package.json package.ide.json > package.custom.json && mv package.custom.json package.json env: GH_TOKEN: $(LENS_IDE_GH_TOKEN) + displayName: Customize config - - displayName: Install dependencies - script: make node_modules + - script: make node_modules + displayName: Install dependencies - - displayName: Generate npm package - script: make build-npm + - script: make build-npm + displayName: Generate npm package - - displayName: Build - script: make build + - script: make build condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" env: WIN_CSC_LINK: $(WIN_CSC_LINK) @@ -63,6 +62,7 @@ jobs: AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID) AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY) BUILD_NUMBER: $(Build.BuildNumber) + displayName: Build - job: macOS pool: @@ -72,25 +72,24 @@ jobs: node_12.x: node_version: 12.x steps: - - displayName: Set the tag name as an environment variable - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" + - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" + displayName: Set the tag name as an environment variable - - displayName: Install Node.js - task: NodeTool@0 + - task: NodeTool@0 inputs: versionSpec: $(node_version) + displayName: Install Node.js - - displayName: Cache Yarn packages - task: Cache@2 + - task: Cache@2 inputs: key: 'yarn | "$(Agent.OS)" | yarn.lock' restoreKeys: | yarn | "$(Agent.OS)" path: $(YARN_CACHE_FOLDER) + displayName: Cache Yarn packages - - displayName: Customize config - bash: | + - bash: | set -e git clone "https://${GH_TOKEN}@github.com/lensapp/lens-ide.git" .lens-ide-overlay rm -rf .lens-ide-overlay/.git @@ -98,15 +97,15 @@ jobs: jq -s '.[0] * .[1]' package.json package.ide.json > package.custom.json && mv package.custom.json package.json env: GH_TOKEN: $(LENS_IDE_GH_TOKEN) + displayName: Customize config - - displayName: Install dependencies - script: make node_modules + - script: make node_modules + displayName: Install dependencies - - displayName: Generate npm package - script: make build-npm + - script: make build-npm + displayName: Generate npm package - - displayName: Build - script: make build + - script: make build condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" env: APPLEID: $(APPLEID) @@ -116,6 +115,7 @@ jobs: AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID) AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY) BUILD_NUMBER: $(Build.BuildNumber) + displayName: Build - job: Linux pool: @@ -125,25 +125,24 @@ jobs: node_12.x: node_version: 12.x steps: - - displayName: Set the tag name as an environment variable - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" + - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" + displayName: Set the tag name as an environment variable - - displayName: Install Node.js - task: NodeTool@0 + - task: NodeTool@0 inputs: versionSpec: $(node_version) + displayName: Install Node.js - - displayName: Cache Yarn packages - task: Cache@2 + - task: Cache@2 inputs: key: 'yarn | "$(Agent.OS)" | yarn.lock' restoreKeys: | yarn | "$(Agent.OS)" path: $(YARN_CACHE_FOLDER) + displayName: Cache Yarn packages - - displayName: Customize config - bash: | + - bash: | set -e git clone "https://${GH_TOKEN}@github.com/lensapp/lens-ide.git" .lens-ide-overlay rm -rf .lens-ide-overlay/.git @@ -151,15 +150,15 @@ jobs: jq -s '.[0] * .[1]' package.json package.ide.json > package.custom.json && mv package.custom.json package.json env: GH_TOKEN: $(LENS_IDE_GH_TOKEN) + displayName: Customize config - - displayName: Install dependencies - script: make node_modules + - script: make node_modules + displayName: Install dependencies - - displayName: Generate npm package - script: make build-npm + - script: make build-npm + displayName: Generate npm package - - displayName: Setup snapcraft - bash: | + - bash: | sudo chown root:root / sudo apt-get update && sudo apt-get install -y snapd sudo snap install snapcraft --classic @@ -168,11 +167,12 @@ jobs: condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" env: SNAP_LOGIN: $(SNAP_LOGIN) + displayName: Setup snapcraft - - displayName: Build - script: make build + - script: make build condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" env: AWS_ACCESS_KEY_ID: $(AWS_ACCESS_KEY_ID) AWS_SECRET_ACCESS_KEY: $(AWS_SECRET_ACCESS_KEY) BUILD_NUMBER: $(Build.BuildNumber) + displayName: Build diff --git a/package.json b/package.json index a9e013bddd..3d62c8558f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", "homepage": "https://github.com/lensapp/lens", - "version": "5.0.0-beta.8", + "version": "5.0.0-beta.9", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", From 54eaaefe623b7c37fe6f4a923aecb7fd1830466a Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Thu, 17 Jun 2021 14:09:48 +0300 Subject: [PATCH 06/70] Do not make watch requests without resourceVersion (#3089) * Do not make watch requests without resourceVersion Signed-off-by: Lauri Nevala * Check resource version from given namespace Signed-off-by: Lauri Nevala --- src/renderer/kube-object.store.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/renderer/kube-object.store.ts b/src/renderer/kube-object.store.ts index 0c9f8688a2..62dd1cb117 100644 --- a/src/renderer/kube-object.store.ts +++ b/src/renderer/kube-object.store.ts @@ -280,7 +280,7 @@ export abstract class KubeObjectStore extends ItemSt async update(item: T, data: Partial): Promise { const newItem = await item.update(data); - + ensureObjectSelfLink(this.api, newItem); const index = this.items.findIndex(item => item.getId() === newItem.getId()); @@ -332,6 +332,10 @@ export abstract class KubeObjectStore extends ItemSt } private watchNamespace(namespace: string, abortController: AbortController) { + if (!this.api.getResourceVersion(namespace)) { + return; + } + let timedRetry: NodeJS.Timeout; const watch = () => this.api.watch({ namespace, From 3206e7d7fdd4dbc8f5d4a273dfe3c36202ba97d3 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Thu, 17 Jun 2021 14:59:31 +0300 Subject: [PATCH 07/70] Catch exceptions when trying to load namespaces from api (#3084) Signed-off-by: Lauri Nevala --- src/renderer/components/+namespaces/namespace.store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/components/+namespaces/namespace.store.ts b/src/renderer/components/+namespaces/namespace.store.ts index f865787d76..c292f0f9fa 100644 --- a/src/renderer/components/+namespaces/namespace.store.ts +++ b/src/renderer/components/+namespaces/namespace.store.ts @@ -112,7 +112,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).catch(() => []); namespaces = namespaces.filter(namespace => allowedNamespaces.includes(namespace.getName())); From 2ab92ec48857be30fc91a615589ca96916ebf2d5 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Thu, 17 Jun 2021 15:03:01 +0300 Subject: [PATCH 08/70] Add z-index prop to siblings of
(#3085) Signed-off-by: Alex Andreev --- src/renderer/components/cluster-manager/cluster-manager.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/renderer/components/cluster-manager/cluster-manager.scss b/src/renderer/components/cluster-manager/cluster-manager.scss index da31c2a07b..dddbb1a5e5 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.scss +++ b/src/renderer/components/cluster-manager/cluster-manager.scss @@ -35,6 +35,10 @@ position: relative; display: flex; flex-direction: column; + + > * { + z-index: 1; + } } .HotbarMenu { From 239a8b5b8769564acea0d5f765729162936f2733 Mon Sep 17 00:00:00 2001 From: Lauri Nevala Date: Thu, 17 Jun 2021 15:43:59 +0300 Subject: [PATCH 09/70] Set correct namespace for the subject when creating new role binding (#3083) Signed-off-by: Lauri Nevala --- .../components/+user-management/+role-bindings/dialog.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/renderer/components/+user-management/+role-bindings/dialog.tsx b/src/renderer/components/+user-management/+role-bindings/dialog.tsx index 0bed732381..c163a01b83 100644 --- a/src/renderer/components/+user-management/+role-bindings/dialog.tsx +++ b/src/renderer/components/+user-management/+role-bindings/dialog.tsx @@ -91,17 +91,15 @@ export class RoleBindingDialog extends React.Component { const serviceAccounts = Array.from(this.selectedAccounts, sa => ({ name: sa.getName(), kind: "ServiceAccount" as const, - namespace: this.bindingNamespace, + namespace: sa.getNs(), })); const users = Array.from(this.selectedUsers, user => ({ name: user, - kind: "User" as const, - namespace: this.bindingNamespace, + kind: "User" as const })); const groups = Array.from(this.selectedGroups, group => ({ name: group, - kind: "Group" as const, - namespace: this.bindingNamespace, + kind: "Group" as const })); return [ From e47d26e1ce7f0ec3d9d1024706e8c272f880ad58 Mon Sep 17 00:00:00 2001 From: steve richards Date: Thu, 17 Jun 2021 13:53:25 +0100 Subject: [PATCH 10/70] Use new config structure for versioning feature (#3088) Signed-off-by: Steve Richards --- mkdocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mkdocs.yml b/mkdocs.yml index 123506a585..51ad079eaf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -83,7 +83,7 @@ extra: link: https://k8slens.dev/ name: Lens Website version: - method: mike + provider: mike analytics: provider: google property: UA-159377374-2 From eb45f45a7ab06a0c3a4b8c30e219ad7241abd53c Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Thu, 17 Jun 2021 12:19:22 -0400 Subject: [PATCH 11/70] Add context menu entry for deleting local clusters (#2923) --- src/common/__tests__/cluster-store.test.ts | 51 ++++------ .../catalog-entities/kubernetes-cluster.ts | 49 +++++----- src/common/catalog/catalog-entity.ts | 5 + src/common/cluster-ipc.ts | 1 + src/common/cluster-store.ts | 79 +++------------- src/common/hotbar-store.ts | 21 ++++- src/common/utils/index.ts | 5 +- src/main/__test__/cluster.test.ts | 4 +- src/main/catalog-pusher.ts | 2 +- .../__test__/kubeconfig-sync.test.ts | 4 + src/main/catalog-sources/kubeconfig-sync.ts | 5 +- src/main/cluster-manager.ts | 28 ++++-- src/main/cluster.ts | 19 ++-- src/main/helm/helm-release-manager.ts | 5 +- src/main/index.ts | 9 +- src/main/initializers/ipc.ts | 29 +++++- src/main/kubectl.ts | 18 ++-- src/main/promise-exec.ts | 3 +- src/main/proxy/lens-proxy.ts | 8 +- src/main/resource-applier.ts | 14 +-- src/migrations/cluster-store/3.6.0-beta.1.ts | 93 +++++++++---------- .../+catalog/catalog-entity-details.tsx | 70 ++++++-------- .../+catalog/catalog-entity-drawer-menu.tsx | 15 +-- .../+catalog/catalog-entity.store.ts | 19 +++- .../components/+catalog/catalog.module.css | 2 +- src/renderer/components/+catalog/catalog.tsx | 72 +++++++------- .../components/hotbar/hotbar-entity-icon.tsx | 2 +- .../components/hotbar/hotbar-icon.tsx | 21 ++++- src/renderer/ipc/index.tsx | 6 +- .../ipc/invalid-kubeconfig-handler.tsx | 66 ------------- 30 files changed, 348 insertions(+), 377 deletions(-) delete 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 3b87f53670..92839bcb30 100644 --- a/src/common/__tests__/cluster-store.test.ts +++ b/src/common/__tests__/cluster-store.test.ts @@ -125,33 +125,28 @@ describe("empty config", () => { expect(storedCluster.preferences.terminalCWD).toBe("/tmp"); expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5"); }); - - it("removes cluster from store", async () => { - await ClusterStore.getInstance().removeById("foo"); - expect(ClusterStore.getInstance().getById("foo")).toBeNull(); - }); }); describe("with prod and dev clusters added", () => { beforeEach(() => { - ClusterStore.getInstance().addClusters( - new Cluster({ - id: "prod", - contextName: "foo", - preferences: { - clusterName: "prod" - }, - kubeConfigPath: embed("prod", kubeconfig) - }), - new Cluster({ - id: "dev", - contextName: "foo2", - preferences: { - clusterName: "dev" - }, - kubeConfigPath: embed("dev", kubeconfig) - }) - ); + const store = ClusterStore.getInstance(); + + store.addCluster({ + id: "prod", + contextName: "foo", + preferences: { + clusterName: "prod" + }, + kubeConfigPath: embed("prod", kubeconfig) + }); + store.addCluster({ + id: "dev", + contextName: "foo2", + preferences: { + clusterName: "dev" + }, + kubeConfigPath: embed("dev", kubeconfig) + }); }); it("check if store can contain multiple clusters", () => { @@ -222,16 +217,6 @@ describe("config with existing clusters", () => { expect(storedCluster.preferences.terminalCWD).toBe("/foo"); }); - it("allows to delete a cluster", () => { - ClusterStore.getInstance().removeById("cluster2"); - const storedCluster = ClusterStore.getInstance().getById("cluster1"); - - expect(storedCluster).toBeTruthy(); - const storedCluster2 = ClusterStore.getInstance().getById("cluster2"); - - expect(storedCluster2).toBeNull(); - }); - it("allows getting all of the clusters", async () => { const storedClusters = ClusterStore.getInstance().clustersList; diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 82582d2bc5..c86ad91034 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -21,13 +21,13 @@ import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { CatalogEntity, CatalogEntityActionContext, CatalogEntityAddMenuContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; -import { clusterActivateHandler, clusterDisconnectHandler } from "../cluster-ipc"; +import { clusterActivateHandler, clusterDeleteHandler, clusterDisconnectHandler } from "../cluster-ipc"; import { ClusterStore } from "../cluster-store"; import { requestMain } from "../ipc"; -import { productName } from "../vars"; import { CatalogCategory, CatalogCategorySpec } from "../catalog"; import { addClusterURL } from "../routes"; import { app } from "electron"; +import { HotbarStore } from "../hotbar-store"; export type KubernetesClusterPrometheusMetrics = { address?: { @@ -50,7 +50,7 @@ export type KubernetesClusterSpec = { }; export interface KubernetesClusterStatus extends CatalogEntityStatus { - phase: "connected" | "disconnected"; + phase: "connected" | "disconnected" | "deleting"; } export class KubernetesCluster extends CatalogEntity { @@ -103,39 +103,38 @@ export class KubernetesCluster extends CatalogEntity context.navigate(`/entity/${this.metadata.uid}/settings`) - }); - } - - if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) { - context.menuItems.push({ - title: "Delete", - icon: "delete", - onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid), - confirm: { - message: `Remove Kubernetes Cluster "${this.metadata.name} from ${productName}?` - } - }); + context.menuItems.push( + { + title: "Settings", + icon: "edit", + onClick: () => context.navigate(`/entity/${this.metadata.uid}/settings`) + }, + { + title: "Delete", + icon: "delete", + onClick: () => { + HotbarStore.getInstance().removeAllHotbarItems(this.getId()); + requestMain(clusterDeleteHandler, this.metadata.uid); + }, + confirm: { + // TODO: change this to be a

tag with better formatting once this code can accept it. + message: `Delete the "${this.metadata.name}" context from "${this.metadata.labels.file}"?` + } + }, + ); } if (this.status.phase == "connected") { context.menuItems.push({ title: "Disconnect", icon: "link_off", - onClick: async () => { - requestMain(clusterDisconnectHandler, this.metadata.uid); - } + onClick: () => requestMain(clusterDisconnectHandler, this.metadata.uid) }); } else { context.menuItems.push({ title: "Connect", icon: "link", - onClick: async () => { - context.navigate(`/cluster/${this.metadata.uid}`); - } + onClick: () => context.navigate(`/cluster/${this.metadata.uid}`) }); } diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index 9acc2a47c9..8b8d04f6cb 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -86,6 +86,11 @@ export interface CatalogEntityMetadata { export interface CatalogEntityStatus { phase: string; reason?: string; + + /** + * @default true + */ + enabled?: boolean; message?: string; active?: boolean; } diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 13725b37dc..9191d93048 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -24,5 +24,6 @@ export const clusterSetFrameIdHandler = "cluster:set-frame-id"; export const clusterVisibilityHandler = "cluster:visibility"; export const clusterRefreshHandler = "cluster:refresh"; export const clusterDisconnectHandler = "cluster:disconnect"; +export const clusterDeleteHandler = "cluster:delete"; export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all"; export const clusterKubectlDeleteAllHandler = "cluster:kubectl-delete-all"; diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index d2fcd616ed..01ff9b3fa1 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -20,8 +20,7 @@ */ import path from "path"; -import { app, ipcMain, ipcRenderer, remote, webFrame } from "electron"; -import { unlink } from "fs-extra"; +import { app, ipcMain, ipcRenderer, webFrame } from "electron"; import { action, comparer, computed, makeObservable, observable, reaction } from "mobx"; import { BaseStore } from "./base-store"; import { Cluster, ClusterState } from "../main/cluster"; @@ -30,7 +29,7 @@ import * as uuid from "uuid"; import logger from "../main/logger"; import { appEventBus } from "./event-bus"; import { ipcMainHandle, ipcMainOn, ipcRendererOn, requestMain } from "./ipc"; -import { disposer, noop, toJS } from "./utils"; +import { disposer, toJS } from "./utils"; export interface ClusterIconUpload { clusterId: string; @@ -83,9 +82,6 @@ export interface ClusterModel { /** List of accessible namespaces */ accessibleNamespaces?: string[]; - - /** @deprecated */ - kubeConfig?: string; // yaml } export interface ClusterPreferences extends ClusterPrometheusPreferences { @@ -113,7 +109,7 @@ export class ClusterStore extends BaseStore { private static StateChannel = "cluster:state"; static get storedKubeConfigFolder(): string { - return path.resolve((app || remote.app).getPath("userData"), "kubeconfigs"); + return path.resolve(app.getPath("userData"), "kubeconfigs"); } static getCustomKubeConfigPath(clusterId: ClusterId = uuid.v4()): string { @@ -123,7 +119,6 @@ export class ClusterStore extends BaseStore { @observable clusters = observable.map(); @observable removedClusters = observable.map(); - private static stateRequestChannel = "cluster:states"; protected disposer = disposer(); constructor() { @@ -142,35 +137,22 @@ export class ClusterStore extends BaseStore { } async load() { + const initialStates = "cluster:states"; + await super.load(); - type clusterStateSync = { - id: string; - state: ClusterState; - }; if (ipcRenderer) { logger.info("[CLUSTER-STORE] requesting initial state sync"); - const clusterStates: clusterStateSync[] = await requestMain(ClusterStore.stateRequestChannel); - clusterStates.forEach((clusterState) => { - const cluster = this.getById(clusterState.id); - - if (cluster) { - cluster.setState(clusterState.state); - } - }); + for (const { id, state } of await requestMain(initialStates)) { + this.getById(id)?.setState(state); + } } else if (ipcMain) { - ipcMainHandle(ClusterStore.stateRequestChannel, (): clusterStateSync[] => { - const clusterStates: clusterStateSync[] = []; - - this.clustersList.forEach((cluster) => { - clusterStates.push({ - state: cluster.getState(), - id: cluster.id - }); - }); - - return clusterStates; + ipcMainHandle(initialStates, () => { + return this.clustersList.map(cluster => ({ + id: cluster.id, + state: cluster.getState(), + })); }); } } @@ -178,9 +160,7 @@ export class ClusterStore extends BaseStore { protected pushStateToViewsAutomatically() { if (ipcMain) { this.disposer.push( - reaction(() => this.connectedClustersList, () => { - this.pushState(); - }), + reaction(() => this.connectedClustersList, () => this.pushState()), ); } } @@ -229,18 +209,6 @@ export class ClusterStore extends BaseStore { return this.clusters.get(id) ?? null; } - @action - addClusters(...models: ClusterModel[]): Cluster[] { - const clusters: Cluster[] = []; - - models.forEach(model => { - clusters.push(this.addCluster(model)); - }); - - return clusters; - } - - @action addCluster(clusterOrModel: ClusterModel | Cluster): Cluster { appEventBus.emit({ name: "cluster", action: "add" }); @@ -253,25 +221,6 @@ export class ClusterStore extends BaseStore { return cluster; } - async removeCluster(model: ClusterModel) { - await this.removeById(model.id); - } - - @action - async removeById(clusterId: ClusterId) { - appEventBus.emit({ name: "cluster", action: "remove" }); - const cluster = this.getById(clusterId); - - if (cluster) { - this.clusters.delete(clusterId); - - // remove only custom kubeconfigs (pasted as text) - if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) { - await unlink(cluster.kubeConfigPath).catch(noop); - } - } - } - @action protected fromStore({ clusters = [] }: ClusterStoreModel = {}) { const currentClusters = new Map(this.clusters); diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index 2d91ec0cc5..6d15ace11e 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -181,7 +181,7 @@ export class HotbarStore extends BaseStore { } @action - removeFromHotbar(uid: string) { + removeFromHotbar(uid: string): void { const hotbar = this.getActive(); const index = hotbar.items.findIndex((i) => i?.entity.uid === uid); @@ -192,6 +192,25 @@ export class HotbarStore extends BaseStore { hotbar.items[index] = null; } + /** + * Remvove all hotbar items that reference the `uid`. + * @param uid The `EntityId` that each hotbar item refers to + * @returns A function that will (in an action) undo the removing of the hotbar items. This function will not complete if the hotbar has changed. + */ + @action + removeAllHotbarItems(uid: string) { + const undoItems: [Hotbar, number, HotbarItem][] = []; + + for (const hotbar of this.hotbars) { + const index = hotbar.items.findIndex((i) => i?.entity.uid === uid); + + if (index >= 0) { + undoItems.push([hotbar, index, hotbar.items[index]]); + hotbar.items[index] = null; + } + } + } + findClosestEmptyIndex(from: number, direction = 1) { let index = from; diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 34bd65755b..d652933341 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -19,8 +19,9 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// Common utils (main OR renderer) - +/** + * A function that does nothing + */ export function noop(...args: T): void { return void args; } diff --git a/src/main/__test__/cluster.test.ts b/src/main/__test__/cluster.test.ts index 9210739195..8c50518c85 100644 --- a/src/main/__test__/cluster.test.ts +++ b/src/main/__test__/cluster.test.ts @@ -120,8 +120,8 @@ describe("create clusters", () => { protected bindEvents() { return; } - protected async ensureKubectl() { - return Promise.resolve(true); + async ensureKubectl() { + return Promise.resolve(null); } }({ id: "foo", diff --git a/src/main/catalog-pusher.ts b/src/main/catalog-pusher.ts index 5c9a41b702..b662c36f69 100644 --- a/src/main/catalog-pusher.ts +++ b/src/main/catalog-pusher.ts @@ -30,7 +30,7 @@ import type { CatalogEntity } from "../common/catalog"; const broadcaster = debounce((items: CatalogEntity[]) => { broadcastMessage("catalog:items", items); -}, 1_000, { trailing: true }); +}, 1_000, { leading: true, trailing: true }); export function pushCatalogToRenderer(catalog: CatalogEntityRegistry) { return reaction(() => toJS(catalog.items), (items) => { diff --git a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts index 7fcd3c1db6..ac0eb3b33b 100644 --- a/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts +++ b/src/main/catalog-sources/__test__/kubeconfig-sync.test.ts @@ -27,6 +27,7 @@ import { computeDiff, configToModels } from "../kubeconfig-sync"; import mockFs from "mock-fs"; import fs from "fs"; import { ClusterStore } from "../../../common/cluster-store"; +import { ClusterManager } from "../../cluster-manager"; jest.mock("electron", () => ({ app: { @@ -38,10 +39,13 @@ describe("kubeconfig-sync.source tests", () => { beforeEach(() => { mockFs(); ClusterStore.createInstance(); + ClusterManager.createInstance(); }); afterEach(() => { mockFs.restore(); + ClusterStore.resetInstance(); + ClusterManager.resetInstance(); }); describe("configsToModels", () => { diff --git a/src/main/catalog-sources/kubeconfig-sync.ts b/src/main/catalog-sources/kubeconfig-sync.ts index af6d7e150d..6a6f4120f9 100644 --- a/src/main/catalog-sources/kubeconfig-sync.ts +++ b/src/main/catalog-sources/kubeconfig-sync.ts @@ -31,7 +31,7 @@ import logger from "../logger"; import type { KubeConfig } from "@kubernetes/client-node"; import { loadConfigFromString, splitConfig } from "../../common/kube-helpers"; import { Cluster } from "../cluster"; -import { catalogEntityFromCluster } from "../cluster-manager"; +import { catalogEntityFromCluster, ClusterManager } from "../cluster-manager"; import { UserStore } from "../../common/user-store"; import { ClusterStore, UpdateClusterModel } from "../../common/cluster-store"; import { createHash } from "crypto"; @@ -170,6 +170,9 @@ export function computeDiff(contents: string, source: RootSource, filePath: stri // remove and disconnect clusters that were removed from the config if (!model) { + // remove from the deleting set, so that if a new context of the same name is added, it isn't marked as deleting + ClusterManager.getInstance().deleting.delete(value[0].id); + value[0].disconnect(); source.delete(contextName); logger.debug(`${logPrefix} Removed old cluster from sync`, { filePath, contextName }); diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 4075c81e36..d42b2a6002 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -21,8 +21,8 @@ import "../common/cluster-ipc"; import type http from "http"; -import { action, autorun, makeObservable, reaction, toJS } from "mobx"; -import { ClusterStore, getClusterIdFromHost } from "../common/cluster-store"; +import { action, autorun, makeObservable, observable, observe, reaction, toJS } from "mobx"; +import { ClusterId, ClusterStore, getClusterIdFromHost } from "../common/cluster-store"; import type { Cluster } from "./cluster"; import logger from "./logger"; import { apiKubePrefix } from "../common/vars"; @@ -30,17 +30,18 @@ import { Singleton } from "../common/utils"; import { catalogEntityRegistry } from "./catalog"; import { KubernetesCluster, KubernetesClusterPrometheusMetrics } from "../common/catalog-entities/kubernetes-cluster"; import { ipcMainOn } from "../common/ipc"; +import { once } from "lodash"; export class ClusterManager extends Singleton { private store = ClusterStore.getInstance(); + deleting = observable.set(); constructor() { super(); makeObservable(this); - this.bindEvents(); } - private bindEvents() { + init = once(() => { // reacting to every cluster's state change and total amount of items reaction( () => this.store.clustersList.map(c => c.getState()), @@ -59,6 +60,12 @@ export class ClusterManager extends Singleton { this.syncClustersFromCatalog(entities); }); + observe(this.deleting, change => { + if (change.type === "add") { + this.updateEntityStatus(catalogEntityRegistry.getById(change.newValue)); + } + }); + // auto-stop removed clusters autorun(() => { const removedClusters = Array.from(this.store.removedClusters.values()); @@ -76,7 +83,7 @@ export class ClusterManager extends Singleton { ipcMainOn("network:offline", this.onNetworkOffline); ipcMainOn("network:online", this.onNetworkOnline); - } + }); @action protected updateCatalog(clusters: Cluster[]) { @@ -115,8 +122,15 @@ export class ClusterManager extends Singleton { catalogEntityRegistry.items.splice(index, 1, entity); } - protected updateEntityStatus(entity: KubernetesCluster, cluster: Cluster) { - entity.status.phase = cluster.accessible ? "connected" : "disconnected"; + @action + protected updateEntityStatus(entity: KubernetesCluster, cluster?: Cluster) { + if (this.deleting.has(entity.getId())) { + entity.status.phase = "deleting"; + entity.status.enabled = false; + } else { + entity.status.phase = cluster?.accessible ? "connected" : "disconnected"; + entity.status.enabled = true; + } } @action syncClustersFromCatalog(entities: KubernetesCluster[]) { diff --git a/src/main/cluster.ts b/src/main/cluster.ts index df0da0a552..56c07a26b0 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -88,12 +88,7 @@ export interface ClusterState { export class Cluster implements ClusterModel, ClusterState { /** Unique id for a cluster */ public readonly id: ClusterId; - /** - * Kubectl - * - * @internal - */ - public kubeCtl: Kubectl; + private kubeCtl: Kubectl; /** * Context handler * @@ -363,7 +358,7 @@ export class Cluster implements ClusterModel, ClusterState { if (this.accessible) { await this.refreshAccessibility(); - this.ensureKubectl(); + this.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard } this.activated = true; @@ -373,10 +368,12 @@ export class Cluster implements ClusterModel, ClusterState { /** * @internal */ - protected async ensureKubectl() { - this.kubeCtl = new Kubectl(this.version); + async ensureKubectl() { + this.kubeCtl ??= new Kubectl(this.version); - return this.kubeCtl.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard + await this.kubeCtl.ensureKubectl(); + + return this.kubeCtl; } /** @@ -650,7 +647,7 @@ export class Cluster implements ClusterModel, ClusterState { const api = (await this.getProxyKubeconfig()).makeApiClient(CoreV1Api); try { - const { body: { items }} = await api.listNamespace(); + const { body: { items } } = await api.listNamespace(); const namespaces = items.map(ns => ns.metadata.name); this.getAllowedNamespacesErrorCount = 0; // reset on success diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index 9ec1237c07..b8a18a1021 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -169,9 +169,10 @@ export async function rollback(name: string, namespace: string, revision: number async function getResources(name: string, namespace: string, cluster: Cluster) { try { const helm = await helmCli.binaryPath(); - const kubectl = await cluster.kubeCtl.getPath(); + const kubectl = await cluster.ensureKubectl(); + const kubectlPath = await kubectl.getPath(); const pathToKubeconfig = await cluster.getProxyKubeconfigPath(); - const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`); + const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectlPath}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`); return JSON.parse(stdout).items; } catch { diff --git a/src/main/index.ts b/src/main/index.ts index e04746e1e2..aba3d73ce1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -146,9 +146,12 @@ app.on("ready", async () => { filesystemStore.load(), ]); - const lensProxy = LensProxy.createInstance(handleWsUpgrade); - - ClusterManager.createInstance(); + const lensProxy = LensProxy.createInstance( + handleWsUpgrade, + req => ClusterManager.getInstance().getClusterForRequest(req), + ); + + ClusterManager.createInstance().init(); KubeconfigSyncManager.createInstance(); try { diff --git a/src/main/initializers/ipc.ts b/src/main/initializers/ipc.ts index e3bb4f5ff7..95c4571a41 100644 --- a/src/main/initializers/ipc.ts +++ b/src/main/initializers/ipc.ts @@ -22,11 +22,15 @@ import type { IpcMainInvokeEvent } from "electron"; import type { KubernetesCluster } from "../../common/catalog-entities"; import { clusterFrameMap } from "../../common/cluster-frames"; -import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler } from "../../common/cluster-ipc"; +import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler } from "../../common/cluster-ipc"; import { ClusterId, ClusterStore } from "../../common/cluster-store"; import { appEventBus } from "../../common/event-bus"; import { ipcMainHandle } from "../../common/ipc"; import { catalogEntityRegistry } from "../catalog"; +import { ClusterManager } from "../cluster-manager"; +import { bundledKubectlPath } from "../kubectl"; +import logger from "../logger"; +import { promiseExecFile } from "../promise-exec"; import { ResourceApplier } from "../resource-applier"; export function initIpcMainHandlers() { @@ -73,6 +77,29 @@ export function initIpcMainHandlers() { } }); + ipcMainHandle(clusterDeleteHandler, async (event, clusterId: ClusterId) => { + appEventBus.emit({ name: "cluster", action: "remove" }); + const cluster = ClusterStore.getInstance().getById(clusterId); + + if (!cluster) { + return; + } + + ClusterManager.getInstance().deleting.add(clusterId); + cluster.disconnect(); + clusterFrameMap.delete(cluster.id); + const kubectlPath = bundledKubectlPath(); + const args = ["config", "delete-context", cluster.contextName, "--kubeconfig", cluster.kubeConfigPath]; + + try { + await promiseExecFile(kubectlPath, args); + } catch ({ stderr }) { + logger.error(`[CLUSTER-REMOVE]: failed to remove cluster: ${stderr}`, { clusterId, context: cluster.contextName }); + + throw `Failed to remove cluster: ${stderr}`; + } + }); + ipcMainHandle(clusterKubectlApplyAllHandler, async (event, clusterId: ClusterId, resources: string[], extraArgs: string[]) => { appEventBus.emit({ name: "cluster", action: "kubectl-apply-all" }); const cluster = ClusterStore.getInstance().getById(clusterId); diff --git a/src/main/kubectl.ts b/src/main/kubectl.ts index 1a00d2919d..01b38c67fe 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl.ts @@ -31,6 +31,7 @@ import { UserStore } from "../common/user-store"; import { customRequest } from "../common/request"; import { getBundledKubectlVersion } from "../common/utils/app-version"; import { isDevelopment, isWindows, isTestEnv } from "../common/vars"; +import { SemVer } from "semver"; const bundledVersion = getBundledKubectlVersion(); const kubectlMap: Map = new Map([ @@ -92,14 +93,19 @@ export class Kubectl { // Returns the single bundled Kubectl instance public static bundled() { - if (!Kubectl.bundledInstance) Kubectl.bundledInstance = new Kubectl(Kubectl.bundledKubectlVersion); - - return Kubectl.bundledInstance; + return Kubectl.bundledInstance ??= new Kubectl(Kubectl.bundledKubectlVersion); } constructor(clusterVersion: string) { - const versionParts = /^v?(\d+\.\d+)(.*)/.exec(clusterVersion); - const minorVersion = versionParts[1]; + let version: SemVer; + + try { + version = new SemVer(clusterVersion, { includePrerelease: false }); + } catch { + version = new SemVer(Kubectl.bundledKubectlVersion); + } + + const minorVersion = `${version.major}.${version.minor}`; /* minorVersion is the first two digits of kube server version if the version map includes that, use that version, if not, fallback to the exact x.y.z of kube version */ @@ -107,7 +113,7 @@ export class Kubectl { this.kubectlVersion = kubectlMap.get(minorVersion); logger.debug(`Set kubectl version ${this.kubectlVersion} for cluster version ${clusterVersion} using version map`); } else { - this.kubectlVersion = versionParts[1] + versionParts[2]; + this.kubectlVersion = version.format(); logger.debug(`Set kubectl version ${this.kubectlVersion} for cluster version ${clusterVersion} using fallback`); } diff --git a/src/main/promise-exec.ts b/src/main/promise-exec.ts index 8c55272c60..8b9d5b72cc 100644 --- a/src/main/promise-exec.ts +++ b/src/main/promise-exec.ts @@ -20,6 +20,7 @@ */ import * as util from "util"; -import { exec } from "child_process"; +import { exec, execFile } from "child_process"; export const promiseExec = util.promisify(exec); +export const promiseExecFile = util.promisify(execFile); diff --git a/src/main/proxy/lens-proxy.ts b/src/main/proxy/lens-proxy.ts index 199d48398b..00ef5fbb48 100644 --- a/src/main/proxy/lens-proxy.ts +++ b/src/main/proxy/lens-proxy.ts @@ -29,7 +29,7 @@ import { Router } from "../router"; import type { ContextHandler } from "../context-handler"; import logger from "../logger"; import { Singleton } from "../../common/utils"; -import { ClusterManager } from "../cluster-manager"; +import type { Cluster } from "../cluster"; type WSUpgradeHandler = (req: http.IncomingMessage, socket: net.Socket, head: Buffer) => void; @@ -42,7 +42,7 @@ export class LensProxy extends Singleton { public port: number; - constructor(handleWsUpgrade: WSUpgradeHandler) { + constructor(handleWsUpgrade: WSUpgradeHandler, protected getClusterForRequest: (req: http.IncomingMessage) => Cluster | undefined) { super(); const proxy = this.createProxy(); @@ -104,7 +104,7 @@ export class LensProxy extends Singleton { } protected async handleProxyUpgrade(proxy: httpProxy, req: http.IncomingMessage, socket: net.Socket, head: Buffer) { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); + const cluster = this.getClusterForRequest(req); if (cluster) { const proxyUrl = await cluster.contextHandler.resolveAuthProxyUrl() + req.url.replace(apiKubePrefix, ""); @@ -220,7 +220,7 @@ export class LensProxy extends Singleton { } protected async handleRequest(proxy: httpProxy, req: http.IncomingMessage, res: http.ServerResponse) { - const cluster = ClusterManager.getInstance().getClusterForRequest(req); + const cluster = this.getClusterForRequest(req); if (cluster) { const proxyTarget = await this.getProxyTarget(req, cluster.contextHandler); diff --git a/src/main/resource-applier.ts b/src/main/resource-applier.ts index 5dd02cc617..f4772c5275 100644 --- a/src/main/resource-applier.ts +++ b/src/main/resource-applier.ts @@ -36,15 +36,15 @@ export class ResourceApplier { async apply(resource: KubernetesObject | any): Promise { resource = this.sanitizeObject(resource); - appEventBus.emit({name: "resource", action: "apply"}); + appEventBus.emit({ name: "resource", action: "apply" }); return await this.kubectlApply(yaml.safeDump(resource)); } protected async kubectlApply(content: string): Promise { - const { kubeCtl } = this.cluster; - const kubectlPath = await kubeCtl.getPath(); - const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); + const kubectl = await this.cluster.ensureKubectl(); + const kubectlPath = await kubectl.getPath(); + const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); return new Promise((resolve, reject) => { const fileName = tempy.file({ name: "resource.yaml" }); @@ -82,9 +82,9 @@ export class ResourceApplier { } protected async kubectlCmdAll(subCmd: string, resources: string[], args: string[] = []): Promise { - const { kubeCtl } = this.cluster; - const kubectlPath = await kubeCtl.getPath(); - const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); + const kubectl = await this.cluster.ensureKubectl(); + const kubectlPath = await kubectl.getPath(); + const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath(); return new Promise((resolve, reject) => { const tmpDir = tempy.directory(); diff --git a/src/migrations/cluster-store/3.6.0-beta.1.ts b/src/migrations/cluster-store/3.6.0-beta.1.ts index 5339d5232b..516e025eb6 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -23,69 +23,68 @@ // convert file path cluster icons to their base64 encoded versions import path from "path"; -import { app, remote } from "electron"; +import { app } from "electron"; import { migration } from "../migration-wrapper"; import fse from "fs-extra"; import { ClusterModel, ClusterStore } from "../../common/cluster-store"; import { loadConfigFromFileSync } from "../../common/kube-helpers"; +interface Pre360ClusterModel extends ClusterModel { + kubeConfig: string; +} + export default migration({ version: "3.6.0-beta.1", run(store, printLog) { - const userDataPath = (app || remote.app).getPath("userData"); - const kubeConfigBase = ClusterStore.getCustomKubeConfigPath(""); - const storedClusters: ClusterModel[] = store.get("clusters") || []; + const userDataPath = app.getPath("userData"); + const storedClusters: Pre360ClusterModel[] = store.get("clusters") ?? []; + const migratedClusters: ClusterModel[] = []; - if (!storedClusters.length) return; - fse.ensureDirSync(kubeConfigBase); + fse.ensureDirSync(ClusterStore.storedKubeConfigFolder); printLog("Number of clusters to migrate: ", storedClusters.length); - const migratedClusters = storedClusters - .map(cluster => { - /** - * migrate kubeconfig - */ - try { - const absPath = ClusterStore.getCustomKubeConfigPath(cluster.id); - fse.ensureDirSync(path.dirname(absPath)); - fse.writeFileSync(absPath, cluster.kubeConfig, { encoding: "utf-8", mode: 0o600 }); - // take the embedded kubeconfig and dump it into a file - cluster.kubeConfigPath = absPath; - cluster.contextName = loadConfigFromFileSync(cluster.kubeConfigPath).config.getCurrentContext(); - delete cluster.kubeConfig; + for (const clusterModel of storedClusters) { + /** + * migrate kubeconfig + */ + try { + const absPath = ClusterStore.getCustomKubeConfigPath(clusterModel.id); - } catch (error) { - printLog(`Failed to migrate Kubeconfig for cluster "${cluster.id}", removing cluster...`, error); + // take the embedded kubeconfig and dump it into a file + fse.writeFileSync(absPath, clusterModel.kubeConfig, { encoding: "utf-8", mode: 0o600 }); - return undefined; + clusterModel.kubeConfigPath = absPath; + clusterModel.contextName = loadConfigFromFileSync(clusterModel.kubeConfigPath).config.getCurrentContext(); + delete clusterModel.kubeConfig; + + } catch (error) { + printLog(`Failed to migrate Kubeconfig for cluster "${clusterModel.id}", removing clusterModel...`, error); + + continue; + } + + /** + * migrate cluster icon + */ + try { + if (clusterModel.preferences?.icon) { + printLog(`migrating ${clusterModel.preferences.icon} for ${clusterModel.preferences.clusterName}`); + const iconPath = clusterModel.preferences.icon.replace("store://", ""); + const fileData = fse.readFileSync(path.join(userDataPath, iconPath)); + + clusterModel.preferences.icon = `data:;base64,${fileData.toString("base64")}`; + } else { + delete clusterModel.preferences?.icon; } + } catch (error) { + printLog(`Failed to migrate cluster icon for cluster "${clusterModel.id}"`, error); + delete clusterModel.preferences.icon; + } - /** - * migrate cluster icon - */ - try { - if (cluster.preferences?.icon) { - printLog(`migrating ${cluster.preferences.icon} for ${cluster.preferences.clusterName}`); - const iconPath = cluster.preferences.icon.replace("store://", ""); - const fileData = fse.readFileSync(path.join(userDataPath, iconPath)); - - cluster.preferences.icon = `data:;base64,${fileData.toString("base64")}`; - } else { - delete cluster.preferences?.icon; - } - } catch (error) { - printLog(`Failed to migrate cluster icon for cluster "${cluster.id}"`, error); - delete cluster.preferences.icon; - } - - return cluster; - }) - .filter(c => c); - - // "overwrite" the cluster configs - if (migratedClusters.length > 0) { - store.set("clusters", migratedClusters); + migratedClusters.push(clusterModel); } + + store.set("clusters", migratedClusters); } }); diff --git a/src/renderer/components/+catalog/catalog-entity-details.tsx b/src/renderer/components/+catalog/catalog-entity-details.tsx index 639d7c4843..c36a227695 100644 --- a/src/renderer/components/+catalog/catalog-entity-details.tsx +++ b/src/renderer/components/+catalog/catalog-entity-details.tsx @@ -26,28 +26,18 @@ import { Drawer, DrawerItem, DrawerItemLabels } from "../drawer"; import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; import type { CatalogCategory } from "../../../common/catalog"; import { Icon } from "../icon"; -import { KubeObject } from "../../api/kube-object"; import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu"; import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; import { HotbarIcon } from "../hotbar/hotbar-icon"; +import type { CatalogEntityItem } from "./catalog-entity.store"; -interface Props { - entity: CatalogEntity; +interface Props { + item: CatalogEntityItem | null | undefined; hideDetails(): void; } @observer -export class CatalogEntityDetails extends Component { - private abortController?: AbortController; - - constructor(props: Props) { - super(props); - } - - componentWillUnmount() { - this.abortController?.abort(); - } - +export class CatalogEntityDetails extends Component> { categoryIcon(category: CatalogCategory) { if (category.metadata.icon.includes("; @@ -56,16 +46,10 @@ export class CatalogEntityDetails extends Component { } } - openEntity() { - this.props.entity.onRun(catalogEntityRunContext); - } - - renderContent() { - const { entity } = this.props; - const labels = KubeObject.stringifyLabels(entity.metadata.labels); - const detailItems = CatalogEntityDetailRegistry.getInstance().getItemsForKind(entity.kind, entity.apiVersion); - const details = detailItems.map((item, index) => { - return ; + renderContent(item: CatalogEntityItem) { + const detailItems = CatalogEntityDetailRegistry.getInstance().getItemsForKind(item.kind, item.apiVersion); + const details = detailItems.map(({ components }, index) => { + return ; }); const showDetails = detailItems.find((item) => item.priority > 999) === undefined; @@ -76,29 +60,35 @@ export class CatalogEntityDetails extends Component {

this.openEntity()} + uid={item.id} + title={item.name} + source={item.source} + icon={item.entity.spec.iconData} + disabled={!item?.enabled} + onClick={() => item.onRun(catalogEntityRunContext)} size={128} /> -
- Click to open -
+ {item?.enabled && ( +
+ Click to open +
+ )}
- {entity.metadata.name} + {item.name} - {entity.kind} + {item.kind} - {entity.metadata.source} + {item.source} + + + {item.phase}
@@ -111,8 +101,8 @@ export class CatalogEntityDetails extends Component { } render() { - const { entity, hideDetails } = this.props; - const title = `${entity.kind}: ${entity.metadata.name}`; + const { item, hideDetails } = this.props; + const title = `${item.kind}: ${item.name}`; return ( { usePortal={true} open={true} title={title} - toolbar={} + toolbar={} onClose={hideDetails} > - {this.renderContent()} + {item && this.renderContent(item)} ); } diff --git a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx index d4f75d7434..545eebb933 100644 --- a/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx +++ b/src/renderer/components/+catalog/catalog-entity-drawer-menu.tsx @@ -30,9 +30,10 @@ import { MenuItem } from "../menu"; import { ConfirmDialog } from "../confirm-dialog"; import { HotbarStore } from "../../../common/hotbar-store"; import { Icon } from "../icon"; +import type { CatalogEntityItem } from "./catalog-entity.store"; export interface CatalogEntityDrawerMenuProps extends MenuActionsProps { - entity: T | null | undefined; + item: CatalogEntityItem | null | undefined; } @observer @@ -47,9 +48,9 @@ export class CatalogEntityDrawerMenu extends React.Comp componentDidMount() { this.contextMenu = { menuItems: [], - navigate: (url: string) => navigate(url) + navigate: (url: string) => navigate(url), }; - this.props.entity?.onContextMenuOpen(this.contextMenu); + this.props.item?.onContextMenuOpen(this.contextMenu); } onMenuItemClick(menuItem: CatalogEntityContextMenu) { @@ -107,19 +108,19 @@ export class CatalogEntityDrawerMenu extends React.Comp } render() { - if (!this.contextMenu) { + const { className, item: entity, ...menuProps } = this.props; + + if (!this.contextMenu || !entity.enabled) { return null; } - const { className, entity, ...menuProps } = this.props; - return ( - {this.getMenuItems(entity)} + {this.getMenuItems(entity.entity)} ); } diff --git a/src/renderer/components/+catalog/catalog-entity.store.ts b/src/renderer/components/+catalog/catalog-entity.store.ts index 7c63bcb0f0..c9e0e9bf11 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.ts +++ b/src/renderer/components/+catalog/catalog-entity.store.ts @@ -25,13 +25,17 @@ import type { CatalogEntity, CatalogEntityActionContext } from "../../api/catalo import { ItemObject, ItemStore } from "../../item.store"; import { CatalogCategory, catalogCategoryRegistry } from "../../../common/catalog"; import { autoBind } from "../../../common/utils"; -export class CatalogEntityItem implements ItemObject { - constructor(public entity: CatalogEntity) {} +export class CatalogEntityItem implements ItemObject { + constructor(public entity: T) {} get kind() { return this.entity.kind; } + get apiVersion() { + return this.entity.apiVersion; + } + get name() { return this.entity.metadata.name; } @@ -52,6 +56,10 @@ export class CatalogEntityItem implements ItemObject { return this.entity.status.phase; } + get enabled() { + return this.entity.status.enabled ?? true; + } + get labels() { const labels: string[] = []; @@ -87,7 +95,7 @@ export class CatalogEntityItem implements ItemObject { } } -export class CatalogEntityStore extends ItemStore { +export class CatalogEntityStore extends ItemStore> { constructor() { super(); makeObservable(this); @@ -95,6 +103,7 @@ export class CatalogEntityStore extends ItemStore { } @observable activeCategory?: CatalogCategory; + @observable selectedItemId?: string; @computed get entities() { if (!this.activeCategory) { @@ -104,6 +113,10 @@ export class CatalogEntityStore extends ItemStore { return catalogEntityRegistry.getItemsForCategory(this.activeCategory).map(entity => new CatalogEntityItem(entity)); } + @computed get selectedItem() { + return this.entities.find(e => e.getId() === this.selectedItemId); + } + watch() { const disposers: IReactionDisposer[] = [ reaction(() => this.entities, () => this.loadAll()), diff --git a/src/renderer/components/+catalog/catalog.module.css b/src/renderer/components/+catalog/catalog.module.css index cc1e7ce411..cfc6aa569a 100644 --- a/src/renderer/components/+catalog/catalog.module.css +++ b/src/renderer/components/+catalog/catalog.module.css @@ -44,7 +44,7 @@ color: var(--colorSuccess); } -.disconnected { +.disconnected, .deleting { color: var(--halfGray); } diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 067dab9009..13a827f398 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -32,7 +32,7 @@ import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from " import { Badge } from "../badge"; import { HotbarStore } from "../../../common/hotbar-store"; import { ConfirmDialog } from "../confirm-dialog"; -import { catalogCategoryRegistry } from "../../../common/catalog"; +import { catalogCategoryRegistry, CatalogEntity } from "../../../common/catalog"; import { CatalogAddButton } from "./catalog-add-button"; import type { RouteComponentProps } from "react-router"; import { Notifications } from "../notifications"; @@ -59,7 +59,6 @@ export class Catalog extends React.Component { @observable private catalogEntityStore?: CatalogEntityStore; @observable private contextMenu: CatalogEntityContextMenuContext; @observable activeTab?: string; - @observable selectedItem?: CatalogEntityItem; constructor(props: Props) { super(props); @@ -79,7 +78,7 @@ export class Catalog extends React.Component { async componentDidMount() { this.contextMenu = { menuItems: observable.array([]), - navigate: (url: string) => navigate(url) + navigate: (url: string) => navigate(url), }; this.catalogEntityStore = new CatalogEntityStore(); disposeOnUnmount(this, [ @@ -103,13 +102,13 @@ export class Catalog extends React.Component { ]); } - addToHotbar(item: CatalogEntityItem): void { + addToHotbar(item: CatalogEntityItem): void { HotbarStore.getInstance().addToHotbar(item.entity); } - onDetails(item: CatalogEntityItem) { - this.selectedItem = item; - } + onDetails = (item: CatalogEntityItem) => { + this.catalogEntityStore.selectedItemId = item.getId(); + }; onMenuItemClick(menuItem: CatalogEntityContextMenu) { if (menuItem.confirm) { @@ -144,7 +143,7 @@ export class Catalog extends React.Component { return ; } - renderItemMenu = (item: CatalogEntityItem) => { + renderItemMenu = (item: CatalogEntityItem) => { const onOpen = () => { this.contextMenu.menuItems = []; @@ -167,7 +166,7 @@ export class Catalog extends React.Component { ); }; - renderIcon(item: CatalogEntityItem) { + renderIcon(item: CatalogEntityItem) { return ( { store={this.catalogEntityStore} tableId="catalog-items" sortingCallbacks={{ - [sortBy.name]: (item: CatalogEntityItem) => item.name, - [sortBy.source]: (item: CatalogEntityItem) => item.source, - [sortBy.status]: (item: CatalogEntityItem) => item.phase, + [sortBy.name]: (item: CatalogEntityItem) => item.name, + [sortBy.source]: (item: CatalogEntityItem) => item.source, + [sortBy.status]: (item: CatalogEntityItem) => item.phase, }} searchFilters={[ - (entity: CatalogEntityItem) => entity.searchFields, + (entity: CatalogEntityItem) => entity.searchFields, ]} renderTableHeader={[ { title: "", className: css.iconCell }, @@ -202,14 +201,17 @@ export class Catalog extends React.Component { { title: "Labels", className: css.labelsCell }, { title: "Status", className: css.statusCell, sortBy: sortBy.status }, ]} - renderTableContents={(item: CatalogEntityItem) => [ + customizeTableRowProps={(item: CatalogEntityItem) => ({ + disabled: !item.enabled, + })} + renderTableContents={(item: CatalogEntityItem) => [ this.renderIcon(item), item.name, item.source, item.labels.map((label) => ), { title: item.phase, className: cssNames(css[item.phase]) } ]} - onDetails={(item: CatalogEntityItem) => this.onDetails(item) } + onDetails={this.onDetails} renderItemMenu={this.renderItemMenu} /> ); @@ -224,13 +226,13 @@ export class Catalog extends React.Component { store={this.catalogEntityStore} tableId="catalog-items" sortingCallbacks={{ - [sortBy.name]: (item: CatalogEntityItem) => item.name, - [sortBy.kind]: (item: CatalogEntityItem) => item.kind, - [sortBy.source]: (item: CatalogEntityItem) => item.source, - [sortBy.status]: (item: CatalogEntityItem) => item.phase, + [sortBy.name]: (item: CatalogEntityItem) => item.name, + [sortBy.kind]: (item: CatalogEntityItem) => item.kind, + [sortBy.source]: (item: CatalogEntityItem) => item.source, + [sortBy.status]: (item: CatalogEntityItem) => item.phase, }} searchFilters={[ - (entity: CatalogEntityItem) => entity.searchFields, + (entity: CatalogEntityItem) => entity.searchFields, ]} renderTableHeader={[ { title: "", className: css.iconCell }, @@ -240,7 +242,10 @@ export class Catalog extends React.Component { { title: "Labels", className: css.labelsCell }, { title: "Status", className: css.statusCell, sortBy: sortBy.status }, ]} - renderTableContents={(item: CatalogEntityItem) => [ + customizeTableRowProps={(item: CatalogEntityItem) => ({ + disabled: !item.enabled, + })} + renderTableContents={(item: CatalogEntityItem) => [ this.renderIcon(item), item.name, item.kind, @@ -248,8 +253,8 @@ export class Catalog extends React.Component { item.labels.map((label) => ), { title: item.phase, className: cssNames(css[item.phase]) } ]} - detailsItem={this.selectedItem} - onDetails={(item: CatalogEntityItem) => this.onDetails(item) } + detailsItem={this.catalogEntityStore.selectedItem} + onDetails={this.onDetails} renderItemMenu={this.renderItemMenu} /> ); @@ -265,15 +270,18 @@ export class Catalog extends React.Component {
{ this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList() }
- { !this.selectedItem && ( - - )} - { this.selectedItem && ( - this.selectedItem = null} - /> - )} + { + this.catalogEntityStore.selectedItem + ? ( + this.catalogEntityStore.selectedItemId = null} + /> + ) + : ( + + ) + } ); } diff --git a/src/renderer/components/hotbar/hotbar-entity-icon.tsx b/src/renderer/components/hotbar/hotbar-entity-icon.tsx index 5708379a7a..bde9347d39 100644 --- a/src/renderer/components/hotbar/hotbar-entity-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-entity-icon.tsx @@ -54,7 +54,7 @@ export class HotbarEntityIcon extends React.Component { componentDidMount() { this.contextMenu = { menuItems: [], - navigate: (url: string) => navigate(url) + navigate: (url: string) => navigate(url), }; } diff --git a/src/renderer/components/hotbar/hotbar-icon.tsx b/src/renderer/components/hotbar/hotbar-icon.tsx index 0ee8f2ddc8..7d3008231e 100644 --- a/src/renderer/components/hotbar/hotbar-icon.tsx +++ b/src/renderer/components/hotbar/hotbar-icon.tsx @@ -62,7 +62,7 @@ function onMenuItemClick(menuItem: CatalogEntityContextMenu) { } export const HotbarIcon = observer(({menuItems = [], size = 40, ...props}: HotbarIconProps) => { - const { uid, title, icon, active, className, source, disabled, onMenuOpen, children, ...rest } = props; + const { uid, title, icon, active, className, source, disabled, onMenuOpen, onClick, children, ...rest } = props; const id = `hotbarIcon-${uid}`; const [menuOpen, setMenuOpen] = useState(false); @@ -77,7 +77,13 @@ export const HotbarIcon = observer(({menuItems = [], size = 40, ...props}: Hotba src={icon} className={active ? "active" : "default"} width={size} - height={size} />; + height={size} + onClick={(event) => { + if (!disabled) { + onClick?.(event); + } + }} + />; } else { return { + if (!disabled) { + onClick?.(event); + } + }} />; } }; @@ -106,8 +117,10 @@ export const HotbarIcon = observer(({menuItems = [], size = 40, ...props}: Hotba toggleEvent="contextmenu" position={{right: true, bottom: true }} // FIXME: position does not work open={() => { - onMenuOpen?.(); - toggleMenu(); + if (!disabled) { + onMenuOpen?.(); + toggleMenu(); + } }} close={() => toggleMenu()}> { menuItems.map((menuItem) => { diff --git a/src/renderer/ipc/index.tsx b/src/renderer/ipc/index.tsx index 4b61205bd7..51a50f1b01 100644 --- a/src/renderer/ipc/index.tsx +++ b/src/renderer/ipc/index.tsx @@ -25,7 +25,6 @@ import { areArgsUpdateAvailableFromMain, UpdateAvailableChannel, onCorrect, Upda import { Notifications, notificationsStore } from "../components/notifications"; import { Button } from "../components/button"; import { isMac } from "../../common/vars"; -import { invalidKubeconfigHandler } from "./invalid-kubeconfig-handler"; import { ClusterStore } from "../../common/cluster-store"; import { navigate } from "../navigation"; import { entitySettingsURL } from "../../common/routes"; @@ -87,7 +86,7 @@ function ListNamespacesForbiddenHandler(event: IpcRendererEvent, ...[clusterId]: if (!wasDisplayed || (now - lastDisplayedAt) > intervalBetweenNotifications) { listNamespacesForbiddenHandlerDisplayedAt.set(clusterId, now); - } else { + } else { // don't bother the user too often return; } @@ -100,7 +99,7 @@ function ListNamespacesForbiddenHandler(event: IpcRendererEvent, ...[clusterId]: Add Accessible Namespaces

Cluster {ClusterStore.getInstance().getById(clusterId).name} does not have permissions to list namespaces. Please add the namespaces you have access to.

-
- - ), - { - id: notificationId, - timeout: 0 - } - ); -} From 74624b1fdf5914840dd28320dadcee4f9e76929d Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Thu, 17 Jun 2021 22:53:56 +0300 Subject: [PATCH 12/70] Fix catalog list icon font size (#3093) Signed-off-by: Jari Kolehmainen --- src/renderer/components/+catalog/catalog.module.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/components/+catalog/catalog.module.css b/src/renderer/components/+catalog/catalog.module.css index cfc6aa569a..38628cf878 100644 --- a/src/renderer/components/+catalog/catalog.module.css +++ b/src/renderer/components/+catalog/catalog.module.css @@ -23,10 +23,10 @@ max-width: 40px; display: flex; align-items: center; +} - .MuiAvatar-root { - font-size: var(--unit); - } +.iconCell > div * { + font-size: var(--unit); } .nameCell { From 7ba93c9c7089e5be10b7d0c690d863bfa8949955 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 18 Jun 2021 11:23:01 +0300 Subject: [PATCH 13/70] Implement missing weblink add/remove (#3092) * implement weblink add/remove Signed-off-by: Jari Kolehmainen * cleanup Signed-off-by: Jari Kolehmainen * refactor Signed-off-by: Jari Kolehmainen * css fix Signed-off-by: Jari Kolehmainen * fix tests Signed-off-by: Jari Kolehmainen --- src/common/catalog-entities/web-link.ts | 40 +++++-- src/common/weblink-store.ts | 90 ++++++++++++++++ src/main/catalog-sources/index.ts | 1 + src/main/catalog-sources/weblinks.ts | 102 ++++++++++++++++++ .../__tests__/catalog-entity-registry.test.ts | 4 +- src/main/index.ts | 28 +---- src/main/initializers/index.ts | 2 + src/main/initializers/stores.ts | 48 +++++++++ src/main/initializers/weblinks.ts | 22 ++++ src/renderer/bootstrap.tsx | 24 +---- .../components/+catalog/catalog.module.css | 4 +- .../catalog-entities/weblink-add-command.tsx | 102 ++++++++++++++++++ src/renderer/components/input/input.tsx | 2 +- src/renderer/initializers/catalog.tsx | 33 ++++++ src/renderer/initializers/index.ts | 2 + src/renderer/initializers/stores.ts | 50 +++++++++ 16 files changed, 496 insertions(+), 58 deletions(-) create mode 100644 src/common/weblink-store.ts create mode 100644 src/main/catalog-sources/weblinks.ts create mode 100644 src/main/initializers/stores.ts create mode 100644 src/main/initializers/weblinks.ts create mode 100644 src/renderer/components/catalog-entities/weblink-add-command.tsx create mode 100644 src/renderer/initializers/catalog.tsx create mode 100644 src/renderer/initializers/stores.ts diff --git a/src/common/catalog-entities/web-link.ts b/src/common/catalog-entities/web-link.ts index 7e0f024421..e18b3ac931 100644 --- a/src/common/catalog-entities/web-link.ts +++ b/src/common/catalog-entities/web-link.ts @@ -19,11 +19,13 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { CatalogCategory, CatalogEntity, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; +import { CatalogCategory, CatalogEntity, CatalogEntityAddMenuContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; +import { productName } from "../vars"; +import { WeblinkStore } from "../weblink-store"; export interface WebLinkStatus extends CatalogEntityStatus { - phase: "valid" | "invalid"; + phase: "available" | "unavailable"; } export type WebLinkSpec = { @@ -42,12 +44,17 @@ export class WebLink extends CatalogEntity WeblinkStore.getInstance().removeById(this.metadata.uid), + confirm: { + message: `Remove Web Link "${this.metadata.name}" from ${productName}?` + } + }); + } } } @@ -56,7 +63,7 @@ export class WebLinkCategory extends CatalogCategory { public readonly kind = "CatalogCategory"; public metadata = { name: "Web Links", - icon: "link" + icon: "public" }; public spec = { group: "entity.k8slens.dev", @@ -70,6 +77,21 @@ export class WebLinkCategory extends CatalogCategory { kind: "WebLink" } }; + public static onAdd?: () => void; + + constructor() { + super(); + + this.on("catalogAddMenu", (ctx: CatalogEntityAddMenuContext) => { + ctx.menuItems.push({ + icon: "public", + title: "Add web link", + onClick: () => { + WebLinkCategory.onAdd(); + } + }); + }); + } } catalogCategoryRegistry.add(new WebLinkCategory()); diff --git a/src/common/weblink-store.ts b/src/common/weblink-store.ts new file mode 100644 index 0000000000..0f66725545 --- /dev/null +++ b/src/common/weblink-store.ts @@ -0,0 +1,90 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { action, comparer, observable, makeObservable } from "mobx"; +import { BaseStore } from "./base-store"; +import migrations from "../migrations/hotbar-store"; +import * as uuid from "uuid"; +import { toJS } from "./utils"; + +export interface WeblinkData { + id: string; + name: string; + url: string; +} + +export interface WeblinkCreateOptions { + id?: string; + name: string; + url: string; +} + + +export interface WeblinkStoreModel { + weblinks: WeblinkData[]; +} + +export class WeblinkStore extends BaseStore { + @observable weblinks: WeblinkData[] = []; + + constructor() { + super({ + configName: "lens-weblink-store", + accessPropertiesByDotNotation: false, // To make dots safe in cluster context names + syncOptions: { + equals: comparer.structural, + }, + migrations, + }); + makeObservable(this); + } + + @action protected async fromStore(data: Partial = {}) { + this.weblinks = data.weblinks || []; + } + + add(data: WeblinkCreateOptions) { + const { + id = uuid.v4(), + name, + url + } = data; + + const weblink = { id, name, url }; + + this.weblinks.push(weblink as WeblinkData); + + return weblink as WeblinkData; + } + + @action + removeById(id: string) { + this.weblinks = this.weblinks.filter((w) => w.id !== id); + } + + toJSON(): WeblinkStoreModel { + const model: WeblinkStoreModel = { + weblinks: this.weblinks + }; + + return toJS(model); + } +} diff --git a/src/main/catalog-sources/index.ts b/src/main/catalog-sources/index.ts index 3f1a76790f..81f0182ca7 100644 --- a/src/main/catalog-sources/index.ts +++ b/src/main/catalog-sources/index.ts @@ -19,4 +19,5 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +export { initializeWeblinks } from "./weblinks"; export { KubeconfigSyncManager } from "./kubeconfig-sync"; diff --git a/src/main/catalog-sources/weblinks.ts b/src/main/catalog-sources/weblinks.ts new file mode 100644 index 0000000000..b6b1ec5a39 --- /dev/null +++ b/src/main/catalog-sources/weblinks.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { observable, reaction } from "mobx"; +import { WeblinkStore } from "../../common/weblink-store"; +import { WebLink } from "../../common/catalog-entities"; +import { catalogEntityRegistry } from "../catalog"; +import got from "got"; +import logger from "../logger"; +import { docsUrl, slackUrl } from "../../common/vars"; + +const defaultLinks = [ + { title: "Lens Website", url: "https://k8slens.dev" }, + { title: "Lens Documentation", url: docsUrl }, + { title: "Lens Community Slack", url: slackUrl }, + { title: "Kubernetes Documentation", url: "https://kubernetes.io/docs/home/" }, + { title: "Lens on Twitter", url: "https://twitter.com/k8slens" }, + { title: "Lens Official Blog", url: "https://medium.com/k8slens" } +].map((link) => ( + new WebLink({ + metadata: { + uid: link.url, + name: link.title, + source: "app", + labels: {} + }, + spec: { + url: link.url + }, + status: { + phase: "available", + active: true + } + }) +)); + +async function validateLink(link: WebLink) { + try { + const response = await got.get(link.spec.url, { + throwHttpErrors: false + }); + + if (response.statusCode >= 200 && response.statusCode < 500) { + link.status.phase = "available"; + } else { + link.status.phase = "unavailable"; + } + } catch(error) { + link.status.phase = "unavailable"; + } +} + + +export function initializeWeblinks() { + const weblinkStore = WeblinkStore.getInstance(); + const weblinks = observable.array(defaultLinks); + + reaction(() => weblinkStore.weblinks, (links) => { + weblinks.replace(links.map((data) => new WebLink({ + metadata: { + uid: data.id, + name: data.name, + source: "local", + labels: {} + }, + spec: { + url: data.url + }, + status: { + phase: "available", + active: true + } + }))); + weblinks.push(...defaultLinks); + + for (const link of weblinks) { + validateLink(link).catch((error) => { + logger.error(`failed to validate link ${link.spec.url}: %s`, error); + }); + } + }, {fireImmediately: true}); + + catalogEntityRegistry.addObservableSource("weblinks", weblinks); +} diff --git a/src/main/catalog/__tests__/catalog-entity-registry.test.ts b/src/main/catalog/__tests__/catalog-entity-registry.test.ts index fff3faa3d9..ee8450ae26 100644 --- a/src/main/catalog/__tests__/catalog-entity-registry.test.ts +++ b/src/main/catalog/__tests__/catalog-entity-registry.test.ts @@ -58,7 +58,7 @@ describe("CatalogEntityRegistry", () => { url: "https://k8slens.dev" }, status: { - phase: "valid" + phase: "available" } }); const invalidEntity = new InvalidEntity({ @@ -72,7 +72,7 @@ describe("CatalogEntityRegistry", () => { url: "https://k8slens.dev" }, status: { - phase: "valid" + phase: "available" } }); diff --git a/src/main/index.ts b/src/main/index.ts index aba3d73ce1..dc36de80dc 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -35,14 +35,10 @@ import { shellSync } from "./shell-sync"; import { mangleProxyEnv } from "./proxy-env"; import { registerFileProtocol } from "../common/register-protocol"; import logger from "./logger"; -import { ClusterStore } from "../common/cluster-store"; -import { UserStore } from "../common/user-store"; import { appEventBus } from "../common/event-bus"; import { ExtensionLoader } from "../extensions/extension-loader"; -import { ExtensionsStore } from "../extensions/extensions-store"; import { InstalledExtension, ExtensionDiscovery } from "../extensions/extension-discovery"; import type { LensExtensionId } from "../extensions/lens-extension"; -import { FilesystemProvisionerStore } from "./extension-filesystem"; import { installDeveloperTools } from "./developer-tools"; import { LensProtocolRouterMain } from "./protocol-handler"; import { disposer, getAppVersion, getAppVersionFromProxyServer } from "../common/utils"; @@ -51,7 +47,6 @@ import { startUpdateChecking } from "./app-updater"; import { IpcRendererNavigationEvents } from "../renderer/navigation/events"; import { pushCatalogToRenderer } from "./catalog-pusher"; import { catalogEntityRegistry } from "./catalog"; -import { HotbarStore } from "../common/hotbar-store"; import { HelmRepoManager } from "./helm/helm-repo-manager"; import { KubeconfigSyncManager } from "./catalog-sources"; import { handleWsUpgrade } from "./proxy/ws-upgrade"; @@ -128,24 +123,11 @@ app.on("ready", async () => { PrometheusProviderRegistry.createInstance(); initializers.initPrometheusProviderRegistry(); - const userStore = UserStore.createInstance(); - const clusterStore = ClusterStore.createInstance(); - const hotbarStore = HotbarStore.createInstance(); - const extensionsStore = ExtensionsStore.createInstance(); - const filesystemStore = FilesystemProvisionerStore.createInstance(); + await initializers.initializeStores(); + initializers.initializeWeblinks(); HelmRepoManager.createInstance(); // create the instance - logger.info("💾 Loading stores"); - // preload - await Promise.all([ - userStore.load(), - clusterStore.load(), - hotbarStore.load(), - extensionsStore.load(), - filesystemStore.load(), - ]); - const lensProxy = LensProxy.createInstance( handleWsUpgrade, req => ClusterManager.getInstance().getClusterForRequest(req), @@ -257,13 +239,13 @@ app.on("will-quit", (event) => { // This is called when the close button of the main window is clicked const lprm = LensProtocolRouterMain.getInstance(false); - + logger.info("APP:QUIT"); appEventBus.emit({ name: "app", action: "close" }); ClusterManager.getInstance(false)?.stop(); // close cluster connections KubeconfigSyncManager.getInstance(false)?.stopSync(); cleanup(); - + if (lprm) { // This is set to false here so that LPRM can wait to send future lens:// // requests until after it loads again @@ -272,7 +254,7 @@ app.on("will-quit", (event) => { if (blockQuit) { // Quit app on Cmd+Q (MacOS) - + event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.) return; // skip exit to make tray work, to quit go to app's global menu or tray's menu diff --git a/src/main/initializers/index.ts b/src/main/initializers/index.ts index 526b61d4ed..37ff00bd77 100644 --- a/src/main/initializers/index.ts +++ b/src/main/initializers/index.ts @@ -22,3 +22,5 @@ export * from "./registries"; export * from "./metrics-providers"; export * from "./ipc"; +export * from "./weblinks"; +export * from "./stores"; diff --git a/src/main/initializers/stores.ts b/src/main/initializers/stores.ts new file mode 100644 index 0000000000..4ea6322457 --- /dev/null +++ b/src/main/initializers/stores.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { HotbarStore } from "../../common/hotbar-store"; +import { ClusterStore } from "../../common/cluster-store"; +import { UserStore } from "../../common/user-store"; +import { ExtensionsStore } from "../../extensions/extensions-store"; +import { FilesystemProvisionerStore } from "../extension-filesystem"; +import { WeblinkStore } from "../../common/weblink-store"; +import logger from "../logger"; + +export async function initializeStores() { + const userStore = UserStore.createInstance(); + const clusterStore = ClusterStore.createInstance(); + const hotbarStore = HotbarStore.createInstance(); + const extensionsStore = ExtensionsStore.createInstance(); + const filesystemStore = FilesystemProvisionerStore.createInstance(); + const weblinkStore = WeblinkStore.createInstance(); + + logger.info("💾 Loading stores"); + // preload + await Promise.all([ + userStore.load(), + clusterStore.load(), + hotbarStore.load(), + extensionsStore.load(), + filesystemStore.load(), + weblinkStore.load() + ]); +} diff --git a/src/main/initializers/weblinks.ts b/src/main/initializers/weblinks.ts new file mode 100644 index 0000000000..2bd65d0894 --- /dev/null +++ b/src/main/initializers/weblinks.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export { initializeWeblinks } from "../catalog-sources"; diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index 3824caa011..df0bd00feb 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -31,16 +31,12 @@ import * as LensExtensionsRendererApi from "../extensions/renderer-api"; import { render, unmountComponentAtNode } from "react-dom"; import { delay } from "../common/utils"; import { isMac, isDevelopment } from "../common/vars"; -import { HotbarStore } from "../common/hotbar-store"; import { ClusterStore } from "../common/cluster-store"; import { UserStore } from "../common/user-store"; import { ExtensionDiscovery } from "../extensions/extension-discovery"; import { ExtensionLoader } from "../extensions/extension-loader"; -import { ExtensionsStore } from "../extensions/extensions-store"; -import { FilesystemProvisionerStore } from "../main/extension-filesystem"; import { App } from "./components/app"; import { LensApp } from "./lens-app"; -import { ThemeStore } from "./theme.store"; import { HelmRepoManager } from "../main/helm/helm-repo-manager"; import { ExtensionInstallationStateStore } from "./components/+extensions/extension-install.store"; import { DefaultProps } from "./mui-base-theme"; @@ -77,33 +73,19 @@ export async function bootstrap(App: AppComponent) { initializers.intiKubeObjectDetailRegistry(); initializers.initWelcomeMenuRegistry(); initializers.initWorkloadsOverviewDetailRegistry(); + initializers.initCatalog(); initializers.initIpcRendererListeners(); ExtensionLoader.createInstance().init(); ExtensionDiscovery.createInstance().init(); - const userStore = UserStore.createInstance(); - const clusterStore = ClusterStore.createInstance(); - const extensionsStore = ExtensionsStore.createInstance(); - const filesystemStore = FilesystemProvisionerStore.createInstance(); - const themeStore = ThemeStore.createInstance(); - const hotbarStore = HotbarStore.createInstance(); + await initializers.initStores(); ExtensionInstallationStateStore.bindIpcListeners(); HelmRepoManager.createInstance(); // initialize the manager - // preload common stores - await Promise.all([ - userStore.load(), - hotbarStore.load(), - clusterStore.load(), - extensionsStore.load(), - filesystemStore.load(), - themeStore.init(), - ]); - // Register additional store listeners - clusterStore.registerIpcListener(); + ClusterStore.getInstance().registerIpcListener(); // init app's dependencies if any if (App.init) { diff --git a/src/renderer/components/+catalog/catalog.module.css b/src/renderer/components/+catalog/catalog.module.css index 38628cf878..b651814507 100644 --- a/src/renderer/components/+catalog/catalog.module.css +++ b/src/renderer/components/+catalog/catalog.module.css @@ -40,11 +40,11 @@ max-width: 100px; } -.connected { +.connected, .available { color: var(--colorSuccess); } -.disconnected, .deleting { +.disconnected, .deleting, .unavailable { color: var(--halfGray); } diff --git a/src/renderer/components/catalog-entities/weblink-add-command.tsx b/src/renderer/components/catalog-entities/weblink-add-command.tsx new file mode 100644 index 0000000000..04310ac46d --- /dev/null +++ b/src/renderer/components/catalog-entities/weblink-add-command.tsx @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import React from "react"; +import { observer } from "mobx-react"; +import { CommandOverlay } from "../command-palette"; +import { Input } from "../input"; +import { isUrl } from "../input/input_validators"; +import { WeblinkStore } from "../../../common/weblink-store"; +import { computed, makeObservable, observable } from "mobx"; + +@observer +export class WeblinkAddCommand extends React.Component { + @observable url = ""; + @observable nameHidden = true; + @observable dirty = false; + + constructor(props: {}) { + super(props); + + makeObservable(this); + } + + onChangeUrl(url: string) { + this.dirty = true; + this.url = url; + } + + onSubmitUrl(url: string) { + this.dirty = true; + this.url = url; + this.nameHidden = false; + } + + onSubmit(name: string) { + WeblinkStore.getInstance().add({ + name: name || this.url, + url: this.url + }); + + CommandOverlay.close(); + } + + @computed get showValidation() { + return this.url?.length > 0; + } + + render() { + return ( + <> + this.onChangeUrl(v)} + onSubmit={(v) => this.onSubmitUrl(v)} + showValidationLine={true} /> + { this.nameHidden && ( + + Please provide a web link URL (Press "Enter" to continue or "Escape" to cancel) + + )} + { !this.nameHidden && ( + <> + this.onSubmit(v)} + dirty={true}/> + + Please provide a name for the web link (Press "Enter" to confirm or "Escape" to cancel) + + + )} + + ); + } +} diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index 5de07cb856..fa1692e988 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -261,7 +261,7 @@ export class Input extends React.Component { switch (evt.key) { case "Enter": - if (this.props.onSubmit && !modified && !evt.repeat && this.isValid) { + if (this.props.onSubmit && !modified && !evt.repeat && this.isValid()) { this.props.onSubmit(this.getValue(), evt); if (this.isUncontrolled) { diff --git a/src/renderer/initializers/catalog.tsx b/src/renderer/initializers/catalog.tsx new file mode 100644 index 0000000000..b9c56bc0c6 --- /dev/null +++ b/src/renderer/initializers/catalog.tsx @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import React from "react"; +import { WebLinkCategory } from "../../common/catalog-entities"; +import { WeblinkAddCommand } from "../components/catalog-entities/weblink-add-command"; +import { CommandOverlay } from "../components/command-palette"; + +function initWebLinks() { + WebLinkCategory.onAdd = () => CommandOverlay.open(); +} + +export function initCatalog() { + initWebLinks(); +} diff --git a/src/renderer/initializers/index.ts b/src/renderer/initializers/index.ts index 0bb4eb611e..ed2d8e8575 100644 --- a/src/renderer/initializers/index.ts +++ b/src/renderer/initializers/index.ts @@ -26,4 +26,6 @@ export * from "./kube-object-menu-registry"; export * from "./registries"; export * from "./welcome-menu-registry"; export * from "./workloads-overview-detail-registry"; +export * from "./catalog"; +export * from "./stores"; export * from "./ipc"; diff --git a/src/renderer/initializers/stores.ts b/src/renderer/initializers/stores.ts new file mode 100644 index 0000000000..37bef7530a --- /dev/null +++ b/src/renderer/initializers/stores.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { HotbarStore } from "../../common/hotbar-store"; +import { ClusterStore } from "../../common/cluster-store"; +import { UserStore } from "../../common/user-store"; +import { ExtensionsStore } from "../../extensions/extensions-store"; +import { FilesystemProvisionerStore } from "../../main/extension-filesystem"; + +import { ThemeStore } from "../theme.store"; +import { WeblinkStore } from "../../common/weblink-store"; + +export async function initStores() { + const userStore = UserStore.createInstance(); + const clusterStore = ClusterStore.createInstance(); + const extensionsStore = ExtensionsStore.createInstance(); + const filesystemStore = FilesystemProvisionerStore.createInstance(); + const themeStore = ThemeStore.createInstance(); + const hotbarStore = HotbarStore.createInstance(); + const weblinkStore = WeblinkStore.createInstance(); + + // preload common stores + await Promise.all([ + userStore.load(), + hotbarStore.load(), + clusterStore.load(), + extensionsStore.load(), + filesystemStore.load(), + themeStore.init(), + weblinkStore.load() + ]); +} From b256475044f7fef4fab18a2840842ea1112baad7 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 18 Jun 2021 11:23:22 +0300 Subject: [PATCH 14/70] allow to search catalog by source (#3097) Signed-off-by: Jari Kolehmainen --- src/renderer/components/+catalog/catalog-entity.store.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/components/+catalog/catalog-entity.store.ts b/src/renderer/components/+catalog/catalog-entity.store.ts index c9e0e9bf11..2b93c241cc 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.ts +++ b/src/renderer/components/+catalog/catalog-entity.store.ts @@ -81,6 +81,7 @@ export class CatalogEntityItem implements ItemObject { this.name, this.id, this.phase, + `source=${this.source}`, ...this.labels.map((value, key) => `${key}=${value}`) ]; } From df2aaa4c85086435fb14023b5a2a1ce3ab42c60c Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 18 Jun 2021 12:03:59 +0300 Subject: [PATCH 15/70] allow to hide/show catalog list columns (#3098) Signed-off-by: Jari Kolehmainen --- src/renderer/components/+catalog/catalog.tsx | 32 +++++++++++--------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 13a827f398..777b4c2e7e 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -181,11 +181,13 @@ export class Catalog extends React.Component { renderSingleCategoryList() { return ( ) => item.name, [sortBy.source]: (item: CatalogEntityItem) => item.source, @@ -195,11 +197,11 @@ export class Catalog extends React.Component { (entity: CatalogEntityItem) => entity.searchFields, ]} renderTableHeader={[ - { title: "", className: css.iconCell }, - { title: "Name", className: css.nameCell, sortBy: sortBy.name }, - { title: "Source", className: css.sourceCell, sortBy: sortBy.source }, - { title: "Labels", className: css.labelsCell }, - { title: "Status", className: css.statusCell, sortBy: sortBy.status }, + { title: "", className: css.iconCell, id: "icon" }, + { title: "Name", className: css.nameCell, sortBy: sortBy.name, id: "name" }, + { title: "Source", className: css.sourceCell, sortBy: sortBy.source, id: "source" }, + { title: "Labels", className: css.labelsCell, id: "labels" }, + { title: "Status", className: css.statusCell, sortBy: sortBy.status, id: "status" }, ]} customizeTableRowProps={(item: CatalogEntityItem) => ({ disabled: !item.enabled, @@ -220,8 +222,10 @@ export class Catalog extends React.Component { renderAllCategoriesList() { return ( { (entity: CatalogEntityItem) => entity.searchFields, ]} renderTableHeader={[ - { title: "", className: css.iconCell }, - { title: "Name", className: css.nameCell, sortBy: sortBy.name }, - { title: "Kind", className: css.kindCell, sortBy: sortBy.kind }, - { title: "Source", className: css.sourceCell, sortBy: sortBy.source }, - { title: "Labels", className: css.labelsCell }, - { title: "Status", className: css.statusCell, sortBy: sortBy.status }, + { title: "", className: css.iconCell, id: "icon" }, + { title: "Name", className: css.nameCell, sortBy: sortBy.name, id: "name" }, + { title: "Kind", className: css.kindCell, sortBy: sortBy.kind, id: "kind" }, + { title: "Source", className: css.sourceCell, sortBy: sortBy.source, id: "source" }, + { title: "Labels", className: css.labelsCell, id: "labels" }, + { title: "Status", className: css.statusCell, sortBy: sortBy.status, id: "status" }, ]} customizeTableRowProps={(item: CatalogEntityItem) => ({ disabled: !item.enabled, From e9a89f396f4480b1f5ff3480fd351f6ec379c479 Mon Sep 17 00:00:00 2001 From: Mario Sarcher Date: Fri, 18 Jun 2021 11:58:30 +0200 Subject: [PATCH 16/70] Rename Kubernetes Clusters in Catalog Menu to "Clusters" (#3099) Signed-off-by: Mario Sarcher --- src/common/catalog-entities/kubernetes-cluster.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index c86ad91034..1f28f3453f 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -148,7 +148,7 @@ export class KubernetesClusterCategory extends CatalogCategory { public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; public readonly kind = "CatalogCategory"; public metadata = { - name: "Kubernetes Clusters", + name: "Clusters", icon: require(`!!raw-loader!./icons/kubernetes.svg`).default, // eslint-disable-line }; public spec: CatalogCategorySpec = { From d812e28639703eb41907e53e767e300e114bc4c5 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Fri, 18 Jun 2021 14:09:04 +0300 Subject: [PATCH 17/70] update catalog url params on category change (#3102) Signed-off-by: Jari Kolehmainen --- src/renderer/components/+catalog/catalog.tsx | 28 +++++++++----------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index 777b4c2e7e..f904fe9d9c 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -40,7 +40,7 @@ import { MainLayout } from "../layout/main-layout"; import { cssNames } from "../../utils"; import { makeCss } from "../../../common/utils/makeCss"; import { CatalogEntityDetails } from "./catalog-entity-details"; -import type { CatalogViewRouteParam } from "../../../common/routes"; +import { catalogURL, CatalogViewRouteParam } from "../../../common/routes"; import { CatalogMenu } from "./catalog-menu"; import { HotbarIcon } from "../hotbar/hotbar-icon"; @@ -83,22 +83,17 @@ export class Catalog extends React.Component { this.catalogEntityStore = new CatalogEntityStore(); disposeOnUnmount(this, [ this.catalogEntityStore.watch(), - when(() => catalogCategoryRegistry.items.length > 0, () => { - const item = catalogCategoryRegistry.items.find(i => i.getId() === this.routeActiveTab); + reaction(() => this.routeActiveTab, async (routeTab) => { + await when(() => catalogCategoryRegistry.items.length > 0); + const item = catalogCategoryRegistry.items.find(i => i.getId() === routeTab); - if (item || this.routeActiveTab === undefined) { - this.activeTab = this.routeActiveTab; + if (item || routeTab === undefined) { + this.activeTab = routeTab; this.catalogEntityStore.activeCategory = item; } else { - Notifications.error(

Unknown category: {this.routeActiveTab}

); + Notifications.error(

Unknown category: {routeTab}

); } - }), - reaction(() => catalogCategoryRegistry.items, (items) => { - if (!this.activeTab && items.length > 0) { - this.activeTab = items[0].getId(); - this.catalogEntityStore.activeCategory = items[0]; - } - }), + }, {fireImmediately: true}), ]); } @@ -135,8 +130,11 @@ export class Catalog extends React.Component { onTabChange = (tabId: string | null) => { const activeCategory = this.categories.find(category => category.getId() === tabId); - this.catalogEntityStore.activeCategory = activeCategory; - this.activeTab = activeCategory?.getId(); + if (activeCategory) { + navigate(catalogURL({ params: {group: activeCategory.spec.group, kind: activeCategory.spec.names.kind }})); + } else { + navigate(catalogURL()); + } }; renderNavigation() { From 7f51e3addd2e7e5d0d03afcc4a7e84bee1045399 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 18 Jun 2021 08:44:28 -0400 Subject: [PATCH 18/70] Make CatalogEntity labels clickable (#3075) - Transition workspace's to be labels - Fix migrations to support multiple migrations for the same version Signed-off-by: Sebastian Malton --- package.json | 2 +- src/common/cluster-store.ts | 5 ++ src/main/cluster-manager.ts | 2 + src/main/cluster.ts | 10 +++ src/migrations/cluster-store/2.0.0-beta.2.ts | 12 ++-- src/migrations/cluster-store/2.4.1.ts | 9 +-- src/migrations/cluster-store/2.6.0-beta.2.ts | 6 +- src/migrations/cluster-store/2.6.0-beta.3.ts | 10 +-- src/migrations/cluster-store/2.7.0-beta.0.ts | 6 +- src/migrations/cluster-store/2.7.0-beta.1.ts | 6 +- src/migrations/cluster-store/3.6.0-beta.1.ts | 16 ++--- src/migrations/cluster-store/5.0.0-beta.10.ts | 69 +++++++++++++++++++ src/migrations/cluster-store/index.ts | 24 ++++--- src/migrations/cluster-store/snap.ts | 12 ++-- .../{migration-wrapper.ts => helpers.ts} | 41 +++++++---- src/migrations/hotbar-store/5.0.0-alpha.0.ts | 10 +-- src/migrations/hotbar-store/5.0.0-alpha.2.ts | 6 +- src/migrations/hotbar-store/5.0.0-beta.5.ts | 6 +- src/migrations/hotbar-store/index.ts | 12 ++-- src/migrations/user-store/2.1.0-beta.4.ts | 6 +- src/migrations/user-store/5.0.0-alpha.3.ts | 6 +- src/migrations/user-store/index.ts | 10 +-- .../+catalog/catalog-entity-details.tsx | 13 ++-- ...tity.store.ts => catalog-entity.store.tsx} | 38 +++++++--- .../components/+catalog/catalog.module.css | 4 ++ src/renderer/components/+catalog/catalog.tsx | 21 +++--- src/renderer/components/badge/badge.scss | 6 +- src/renderer/components/badge/badge.tsx | 3 +- .../item-object-list/page-filters.store.ts | 15 +++- src/renderer/themes/lens-light.json | 2 +- 30 files changed, 268 insertions(+), 120 deletions(-) create mode 100644 src/migrations/cluster-store/5.0.0-beta.10.ts rename src/migrations/{migration-wrapper.ts => helpers.ts} (59%) rename src/renderer/components/+catalog/{catalog-entity.store.ts => catalog-entity.store.tsx} (81%) diff --git a/package.json b/package.json index 3d62c8558f..297e37e16b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", "homepage": "https://github.com/lensapp/lens", - "version": "5.0.0-beta.9", + "version": "5.0.0-beta.10", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 01ff9b3fa1..1a39f201c0 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -80,6 +80,11 @@ export interface ClusterModel { /** Metadata */ metadata?: ClusterMetadata; + /** + * Labels for the catalog entity + */ + labels?: Record; + /** List of accessible namespaces */ accessibleNamespaces?: string[]; } diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index d42b2a6002..5f2ef9899d 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -102,6 +102,8 @@ export class ClusterManager extends Singleton { const entity = catalogEntityRegistry.items[index] as KubernetesCluster; this.updateEntityStatus(entity, cluster); + + entity.metadata.labels = Object.assign({}, cluster.labels, entity.metadata.labels); if (cluster.preferences?.clusterName) { entity.metadata.name = cluster.preferences.clusterName; diff --git a/src/main/cluster.ts b/src/main/cluster.ts index 56c07a26b0..6675bd0151 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -207,6 +207,11 @@ export class Cluster implements ClusterModel, ClusterState { */ @observable accessibleNamespaces: string[] = []; + /** + * Labels for the catalog entity + */ + @observable labels: Record = {}; + /** * Is cluster available * @@ -304,6 +309,10 @@ export class Cluster implements ClusterModel, ClusterState { if (model.accessibleNamespaces) { this.accessibleNamespaces = model.accessibleNamespaces; } + + if (model.labels) { + this.labels = model.labels; + } } /** @@ -583,6 +592,7 @@ export class Cluster implements ClusterModel, ClusterState { preferences: this.preferences, metadata: this.metadata, accessibleNamespaces: this.accessibleNamespaces, + labels: this.labels, }; return toJS(model); diff --git a/src/migrations/cluster-store/2.0.0-beta.2.ts b/src/migrations/cluster-store/2.0.0-beta.2.ts index 0d54588194..db2fd0bff0 100644 --- a/src/migrations/cluster-store/2.0.0-beta.2.ts +++ b/src/migrations/cluster-store/2.0.0-beta.2.ts @@ -19,12 +19,14 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -/* Early store format had the kubeconfig directly under context name, this moves - it under the kubeConfig key */ +import type { MigrationDeclaration } from "../helpers"; -import { migration } from "../migration-wrapper"; +/** + * Early store format had the kubeconfig directly under context name, this moves + * it under the kubeConfig key + */ -export default migration({ +export default { version: "2.0.0-beta.2", run(store) { for (const value of store) { @@ -35,4 +37,4 @@ export default migration({ store.set(contextName, { kubeConfig: value[1] }); } } -}); +} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/2.4.1.ts b/src/migrations/cluster-store/2.4.1.ts index 59d4ef21dc..192f7f3dc8 100644 --- a/src/migrations/cluster-store/2.4.1.ts +++ b/src/migrations/cluster-store/2.4.1.ts @@ -19,10 +19,11 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -// Cleans up a store that had the state related data stored -import { migration } from "../migration-wrapper"; +import type { MigrationDeclaration } from "../helpers"; -export default migration({ +// Cleans up a store that had the state related data stored + +export default { version: "2.4.1", run(store) { for (const value of store) { @@ -34,4 +35,4 @@ export default migration({ store.set(contextName, { kubeConfig: cluster.kubeConfig, icon: cluster.icon || null, preferences: cluster.preferences || {} }); } } -}); +} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/2.6.0-beta.2.ts b/src/migrations/cluster-store/2.6.0-beta.2.ts index ffe303a28d..a1d7df0c1b 100644 --- a/src/migrations/cluster-store/2.6.0-beta.2.ts +++ b/src/migrations/cluster-store/2.6.0-beta.2.ts @@ -20,9 +20,9 @@ */ // Move cluster icon from root to preferences -import { migration } from "../migration-wrapper"; +import type { MigrationDeclaration } from "../helpers"; -export default migration({ +export default { version: "2.6.0-beta.2", run(store) { for (const value of store) { @@ -40,4 +40,4 @@ export default migration({ store.set(clusterKey, { contextName: clusterKey, kubeConfig: value[1].kubeConfig, preferences: value[1].preferences }); } } -}); +} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/2.6.0-beta.3.ts b/src/migrations/cluster-store/2.6.0-beta.3.ts index 9ec844fe65..75662582e5 100644 --- a/src/migrations/cluster-store/2.6.0-beta.3.ts +++ b/src/migrations/cluster-store/2.6.0-beta.3.ts @@ -19,12 +19,12 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { migration } from "../migration-wrapper"; import yaml from "js-yaml"; +import { MigrationDeclaration, migrationLog } from "../helpers"; -export default migration({ +export default { version: "2.6.0-beta.3", - run(store, log) { + run(store) { for (const value of store) { const clusterKey = value[0]; @@ -50,7 +50,7 @@ export default migration({ if (authConfig.expiry) { authConfig.expiry = `${authConfig.expiry}`; } - log(authConfig); + migrationLog(authConfig); user["auth-provider"].config = authConfig; kubeConfig.users = [{ name: userObj.name, @@ -62,4 +62,4 @@ export default migration({ } } } -}); +} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/2.7.0-beta.0.ts b/src/migrations/cluster-store/2.7.0-beta.0.ts index a23d3be991..150cb3f8ce 100644 --- a/src/migrations/cluster-store/2.7.0-beta.0.ts +++ b/src/migrations/cluster-store/2.7.0-beta.0.ts @@ -20,9 +20,9 @@ */ // Add existing clusters to "default" workspace -import { migration } from "../migration-wrapper"; +import type { MigrationDeclaration } from "../helpers"; -export default migration({ +export default { version: "2.7.0-beta.0", run(store) { for (const value of store) { @@ -35,4 +35,4 @@ export default migration({ store.set(clusterKey, cluster); } } -}); +} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/2.7.0-beta.1.ts b/src/migrations/cluster-store/2.7.0-beta.1.ts index 06439063c6..b7f9c8b0a0 100644 --- a/src/migrations/cluster-store/2.7.0-beta.1.ts +++ b/src/migrations/cluster-store/2.7.0-beta.1.ts @@ -20,10 +20,10 @@ */ // Add id for clusters and store them to array -import { migration } from "../migration-wrapper"; import { v4 as uuid } from "uuid"; +import type { MigrationDeclaration } from "../helpers"; -export default migration({ +export default { version: "2.7.0-beta.1", run(store) { const clusters: any[] = []; @@ -48,4 +48,4 @@ export default migration({ store.set("clusters", clusters); } } -}); +} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/3.6.0-beta.1.ts b/src/migrations/cluster-store/3.6.0-beta.1.ts index 516e025eb6..2599005332 100644 --- a/src/migrations/cluster-store/3.6.0-beta.1.ts +++ b/src/migrations/cluster-store/3.6.0-beta.1.ts @@ -24,25 +24,25 @@ import path from "path"; import { app } from "electron"; -import { migration } from "../migration-wrapper"; import fse from "fs-extra"; import { ClusterModel, ClusterStore } from "../../common/cluster-store"; import { loadConfigFromFileSync } from "../../common/kube-helpers"; +import { MigrationDeclaration, migrationLog } from "../helpers"; interface Pre360ClusterModel extends ClusterModel { kubeConfig: string; } -export default migration({ +export default { version: "3.6.0-beta.1", - run(store, printLog) { + run(store) { const userDataPath = app.getPath("userData"); const storedClusters: Pre360ClusterModel[] = store.get("clusters") ?? []; const migratedClusters: ClusterModel[] = []; fse.ensureDirSync(ClusterStore.storedKubeConfigFolder); - printLog("Number of clusters to migrate: ", storedClusters.length); + migrationLog("Number of clusters to migrate: ", storedClusters.length); for (const clusterModel of storedClusters) { /** @@ -59,7 +59,7 @@ export default migration({ delete clusterModel.kubeConfig; } catch (error) { - printLog(`Failed to migrate Kubeconfig for cluster "${clusterModel.id}", removing clusterModel...`, error); + migrationLog(`Failed to migrate Kubeconfig for cluster "${clusterModel.id}", removing clusterModel...`, error); continue; } @@ -69,7 +69,7 @@ export default migration({ */ try { if (clusterModel.preferences?.icon) { - printLog(`migrating ${clusterModel.preferences.icon} for ${clusterModel.preferences.clusterName}`); + migrationLog(`migrating ${clusterModel.preferences.icon} for ${clusterModel.preferences.clusterName}`); const iconPath = clusterModel.preferences.icon.replace("store://", ""); const fileData = fse.readFileSync(path.join(userDataPath, iconPath)); @@ -78,7 +78,7 @@ export default migration({ delete clusterModel.preferences?.icon; } } catch (error) { - printLog(`Failed to migrate cluster icon for cluster "${clusterModel.id}"`, error); + migrationLog(`Failed to migrate cluster icon for cluster "${clusterModel.id}"`, error); delete clusterModel.preferences.icon; } @@ -87,4 +87,4 @@ export default migration({ store.set("clusters", migratedClusters); } -}); +} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/5.0.0-beta.10.ts b/src/migrations/cluster-store/5.0.0-beta.10.ts new file mode 100644 index 0000000000..daea58279b --- /dev/null +++ b/src/migrations/cluster-store/5.0.0-beta.10.ts @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import path from "path"; +import { app } from "electron"; +import fse from "fs-extra"; +import type { ClusterModel } from "../../common/cluster-store"; +import { MigrationDeclaration, migrationLog } from "../helpers"; + +interface Pre500WorkspaceStoreModel { + workspaces: { + id: string; + name: string; + }[]; +} + +export default { + version: "5.0.0-beta.10", + run(store) { + const userDataPath = app.getPath("userData"); + + try { + const workspaceData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json")); + const workspaces = new Map(); // mapping from WorkspaceId to name + + for (const { id, name } of workspaceData.workspaces) { + workspaces.set(id, name); + } + + migrationLog("workspaces", JSON.stringify([...workspaces.entries()])); + + const clusters: ClusterModel[] = store.get("clusters"); + + for (const cluster of clusters) { + if (cluster.workspace && workspaces.has(cluster.workspace)) { + cluster.labels ??= {}; + cluster.labels.workspace = workspaces.get(cluster.workspace); + } + } + + store.set("clusters", clusters); + } catch (error) { + migrationLog("error", error.path); + + if (!(error.code === "ENOENT" && error.path.endsWith("lens-workspace-store.json"))) { + // ignore lens-workspace-store.json being missing + throw error; + } + } + }, +} as MigrationDeclaration; diff --git a/src/migrations/cluster-store/index.ts b/src/migrations/cluster-store/index.ts index b80911bbfe..9bf870825c 100644 --- a/src/migrations/cluster-store/index.ts +++ b/src/migrations/cluster-store/index.ts @@ -21,6 +21,8 @@ // Cluster store migrations +import { joinMigrations } from "../helpers"; + import version200Beta2 from "./2.0.0-beta.2"; import version241 from "./2.4.1"; import version260Beta2 from "./2.6.0-beta.2"; @@ -28,15 +30,17 @@ import version260Beta3 from "./2.6.0-beta.3"; import version270Beta0 from "./2.7.0-beta.0"; import version270Beta1 from "./2.7.0-beta.1"; import version360Beta1 from "./3.6.0-beta.1"; +import version500Beta10 from "./5.0.0-beta.10"; import snap from "./snap"; -export default { - ...version200Beta2, - ...version241, - ...version260Beta2, - ...version260Beta3, - ...version270Beta0, - ...version270Beta1, - ...version360Beta1, - ...snap -}; +export default joinMigrations( + version200Beta2, + version241, + version260Beta2, + version260Beta3, + version270Beta0, + version270Beta1, + version360Beta1, + version500Beta10, + snap, +); diff --git a/src/migrations/cluster-store/snap.ts b/src/migrations/cluster-store/snap.ts index 3dcf998f1c..c1c5eb2dc5 100644 --- a/src/migrations/cluster-store/snap.ts +++ b/src/migrations/cluster-store/snap.ts @@ -21,22 +21,22 @@ // Fix embedded kubeconfig paths under snap config -import { migration } from "../migration-wrapper"; import type { ClusterModel } from "../../common/cluster-store"; import { getAppVersion } from "../../common/utils/app-version"; import fs from "fs"; +import { MigrationDeclaration, migrationLog } from "../helpers"; -export default migration({ +export default { version: getAppVersion(), // Run always after upgrade - run(store, printLog) { + run(store) { if (!process.env["SNAP"]) return; - printLog("Migrating embedded kubeconfig paths"); + migrationLog("Migrating embedded kubeconfig paths"); const storedClusters: ClusterModel[] = store.get("clusters") || []; if (!storedClusters.length) return; - printLog("Number of clusters to migrate: ", storedClusters.length); + migrationLog("Number of clusters to migrate: ", storedClusters.length); const migratedClusters = storedClusters .map(cluster => { /** @@ -54,4 +54,4 @@ export default migration({ store.set("clusters", migratedClusters); } -}); +} as MigrationDeclaration; diff --git a/src/migrations/migration-wrapper.ts b/src/migrations/helpers.ts similarity index 59% rename from src/migrations/migration-wrapper.ts rename to src/migrations/helpers.ts index 26b17e0450..623d46a63e 100644 --- a/src/migrations/migration-wrapper.ts +++ b/src/migrations/helpers.ts @@ -19,24 +19,37 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type Config from "conf"; +import type Conf from "conf"; +import type { Migrations } from "conf/dist/source/types"; +import { ExtendedMap, iter } from "../common/utils"; import { isTestEnv } from "../common/vars"; -export interface MigrationOpts { - version: string; - run(storeConfig: Config, log: (...args: any[]) => void): void; +export function migrationLog(...args: any[]) { + if (!isTestEnv) { + console.log(...args); + } } -function infoLog(...args: any[]) { - if (isTestEnv) return; - console.log(...args); +export interface MigrationDeclaration { + version: string, + run(store: Conf): void; } -export function migration({ version, run }: MigrationOpts) { - return { - [version]: (storeConfig: Config) => { - infoLog(`STORE MIGRATION (${storeConfig.path}): ${version}`,); - run(storeConfig, infoLog); - } - }; +export function joinMigrations(...declarations: MigrationDeclaration[]): Migrations { + const migrations = new ExtendedMap) => void)[]>(); + + for (const decl of declarations) { + migrations.getOrInsert(decl.version, () => []).push(decl.run); + } + + return Object.fromEntries( + iter.map( + migrations, + ([v, fns]) => [v, (store: Conf) => { + for (const fn of fns) { + fn(store); + } + }] + ) + ); } diff --git a/src/migrations/hotbar-store/5.0.0-alpha.0.ts b/src/migrations/hotbar-store/5.0.0-alpha.0.ts index 461d6e1886..7cd8011414 100644 --- a/src/migrations/hotbar-store/5.0.0-alpha.0.ts +++ b/src/migrations/hotbar-store/5.0.0-alpha.0.ts @@ -22,10 +22,10 @@ // Cleans up a store that had the state related data stored import type { Hotbar } from "../../common/hotbar-store"; import { ClusterStore } from "../../common/cluster-store"; -import { migration } from "../migration-wrapper"; -import { v4 as uuid } from "uuid"; +import * as uuid from "uuid"; +import type { MigrationDeclaration } from "../helpers"; -export default migration({ +export default { version: "5.0.0-alpha.0", run(store) { const hotbars: Hotbar[] = []; @@ -38,7 +38,7 @@ export default migration({ let hotbar = hotbars.find((h) => h.name === name); if (!hotbar) { - hotbar = { id: uuid(), name, items: [] }; + hotbar = { id: uuid.v4(), name, items: [] }; hotbars.push(hotbar); } @@ -50,4 +50,4 @@ export default migration({ store.set("hotbars", hotbars); } -}); +} as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/5.0.0-alpha.2.ts b/src/migrations/hotbar-store/5.0.0-alpha.2.ts index 83696d01b0..e049a6d731 100644 --- a/src/migrations/hotbar-store/5.0.0-alpha.2.ts +++ b/src/migrations/hotbar-store/5.0.0-alpha.2.ts @@ -21,10 +21,10 @@ // Cleans up a store that had the state related data stored import type { Hotbar } from "../../common/hotbar-store"; -import { migration } from "../migration-wrapper"; import * as uuid from "uuid"; +import type { MigrationDeclaration } from "../helpers"; -export default migration({ +export default { version: "5.0.0-alpha.2", run(store) { const hotbars = (store.get("hotbars") || []) as Hotbar[]; @@ -34,4 +34,4 @@ export default migration({ ...hotbar }))); } -}); +} as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/5.0.0-beta.5.ts b/src/migrations/hotbar-store/5.0.0-beta.5.ts index 33e39f2a13..1cc8bc2840 100644 --- a/src/migrations/hotbar-store/5.0.0-beta.5.ts +++ b/src/migrations/hotbar-store/5.0.0-beta.5.ts @@ -20,10 +20,10 @@ */ import type { Hotbar } from "../../common/hotbar-store"; -import { migration } from "../migration-wrapper"; import { catalogEntityRegistry } from "../../renderer/api/catalog-entity-registry"; +import type { MigrationDeclaration } from "../helpers"; -export default migration({ +export default { version: "5.0.0-beta.5", run(store) { const hotbars: Hotbar[] = store.get("hotbars"); @@ -48,4 +48,4 @@ export default migration({ store.set("hotbars", hotbars); } -}); +} as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/index.ts b/src/migrations/hotbar-store/index.ts index fabedee4b7..899429cf2a 100644 --- a/src/migrations/hotbar-store/index.ts +++ b/src/migrations/hotbar-store/index.ts @@ -21,12 +21,14 @@ // Hotbar store migrations +import { joinMigrations } from "../helpers"; + import version500alpha0 from "./5.0.0-alpha.0"; import version500alpha2 from "./5.0.0-alpha.2"; import version500beta5 from "./5.0.0-beta.5"; -export default { - ...version500alpha0, - ...version500alpha2, - ...version500beta5, -}; +export default joinMigrations( + version500alpha0, + version500alpha2, + version500beta5, +); diff --git a/src/migrations/user-store/2.1.0-beta.4.ts b/src/migrations/user-store/2.1.0-beta.4.ts index eace0dd367..12aa3d1cf0 100644 --- a/src/migrations/user-store/2.1.0-beta.4.ts +++ b/src/migrations/user-store/2.1.0-beta.4.ts @@ -20,11 +20,11 @@ */ // Add / reset "lastSeenAppVersion" -import { migration } from "../migration-wrapper"; +import type { MigrationDeclaration } from "../helpers"; -export default migration({ +export default { version: "2.1.0-beta.4", run(store) { store.set("lastSeenAppVersion", "0.0.0"); } -}); +} as MigrationDeclaration; diff --git a/src/migrations/user-store/5.0.0-alpha.3.ts b/src/migrations/user-store/5.0.0-alpha.3.ts index 57692c0bc0..3f70240164 100644 --- a/src/migrations/user-store/5.0.0-alpha.3.ts +++ b/src/migrations/user-store/5.0.0-alpha.3.ts @@ -20,9 +20,9 @@ */ // Switch representation of hiddenTableColumns in store -import { migration } from "../migration-wrapper"; +import type { MigrationDeclaration } from "../helpers"; -export default migration({ +export default { version: "5.0.0-alpha.3", run(store) { const preferences = store.get("preferences"); @@ -36,4 +36,4 @@ export default migration({ store.set("preferences", preferences); } -}); +} as MigrationDeclaration; diff --git a/src/migrations/user-store/index.ts b/src/migrations/user-store/index.ts index cf345b1bfb..c08ef2c5c5 100644 --- a/src/migrations/user-store/index.ts +++ b/src/migrations/user-store/index.ts @@ -21,6 +21,8 @@ // User store migrations +import { joinMigrations } from "../helpers"; + import version210Beta4 from "./2.1.0-beta.4"; import version500Alpha3 from "./5.0.0-alpha.3"; import { fileNameMigration } from "./file-name-migration"; @@ -29,7 +31,7 @@ export { fileNameMigration }; -export default { - ...version210Beta4, - ...version500Alpha3, -}; +export default joinMigrations( + version210Beta4, + version500Alpha3, +); diff --git a/src/renderer/components/+catalog/catalog-entity-details.tsx b/src/renderer/components/+catalog/catalog-entity-details.tsx index c36a227695..b0df7371a6 100644 --- a/src/renderer/components/+catalog/catalog-entity-details.tsx +++ b/src/renderer/components/+catalog/catalog-entity-details.tsx @@ -22,9 +22,9 @@ import "./catalog-entity-details.scss"; import React, { Component } from "react"; import { observer } from "mobx-react"; -import { Drawer, DrawerItem, DrawerItemLabels } from "../drawer"; -import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity"; -import type { CatalogCategory } from "../../../common/catalog"; +import { Drawer, DrawerItem } from "../drawer"; +import { catalogEntityRunContext } from "../../api/catalog-entity"; +import type { CatalogCategory, CatalogEntity } from "../../../common/catalog"; import { Icon } from "../icon"; import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu"; import { CatalogEntityDetailRegistry } from "../../../extensions/registries"; @@ -86,10 +86,9 @@ export class CatalogEntityDetails extends Component {item.phase} - + + {...item.getLabelBadges(this.props.hideDetails)} + )} diff --git a/src/renderer/components/+catalog/catalog-entity.store.ts b/src/renderer/components/+catalog/catalog-entity.store.tsx similarity index 81% rename from src/renderer/components/+catalog/catalog-entity.store.ts rename to src/renderer/components/+catalog/catalog-entity.store.tsx index 2b93c241cc..fea9e5e6ba 100644 --- a/src/renderer/components/+catalog/catalog-entity.store.ts +++ b/src/renderer/components/+catalog/catalog-entity.store.tsx @@ -19,12 +19,23 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +import styles from "./catalog.module.css"; + +import React from "react"; import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from "mobx"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import type { CatalogEntity, CatalogEntityActionContext } from "../../api/catalog-entity"; import { ItemObject, ItemStore } from "../../item.store"; import { CatalogCategory, catalogCategoryRegistry } from "../../../common/catalog"; import { autoBind } from "../../../common/utils"; +import { Badge } from "../badge"; +import { navigation } from "../../navigation"; +import { searchUrlParam } from "../input"; +import { makeCss } from "../../../common/utils/makeCss"; +import { KubeObject } from "../../api/kube-object"; + +const css = makeCss(styles); + export class CatalogEntityItem implements ItemObject { constructor(public entity: T) {} @@ -61,15 +72,24 @@ export class CatalogEntityItem implements ItemObject { } get labels() { - const labels: string[] = []; + return KubeObject.stringifyLabels(this.entity.metadata.labels); + } - Object.keys(this.entity.metadata.labels).forEach((key) => { - const value = this.entity.metadata.labels[key]; - - labels.push(`${key}=${value}`); - }); - - return labels; + getLabelBadges(onClick?: React.MouseEventHandler) { + return this.labels + .map(label => ( + { + navigation.searchParams.set(searchUrlParam.name, label); + onClick?.(event); + event.stopPropagation(); + }} + /> + )); } get source() { @@ -82,7 +102,7 @@ export class CatalogEntityItem implements ItemObject { this.id, this.phase, `source=${this.source}`, - ...this.labels.map((value, key) => `${key}=${value}`) + ...this.labels, ]; } diff --git a/src/renderer/components/+catalog/catalog.module.css b/src/renderer/components/+catalog/catalog.module.css index b651814507..4c529a0934 100644 --- a/src/renderer/components/+catalog/catalog.module.css +++ b/src/renderer/components/+catalog/catalog.module.css @@ -63,6 +63,10 @@ max-width: unset; } +.badge:hover { + color: var(--textColorAccent); +} + .badge:not(:first-child) { margin-left: 0.5em; } diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index f904fe9d9c..a55b2ae0af 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -29,7 +29,6 @@ import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store"; import { navigate } from "../../navigation"; import { MenuItem, MenuActions } from "../menu"; import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity"; -import { Badge } from "../badge"; import { HotbarStore } from "../../../common/hotbar-store"; import { ConfirmDialog } from "../confirm-dialog"; import { catalogCategoryRegistry, CatalogEntity } from "../../../common/catalog"; @@ -208,7 +207,7 @@ export class Catalog extends React.Component { this.renderIcon(item), item.name, item.source, - item.labels.map((label) => ), + item.getLabelBadges(), { title: item.phase, className: cssNames(css[item.phase]) } ]} onDetails={this.onDetails} @@ -252,7 +251,7 @@ export class Catalog extends React.Component { item.name, item.kind, item.source, - item.labels.map((label) => ), + item.getLabelBadges(), { title: item.phase, className: cssNames(css[item.phase]) } ]} detailsItem={this.catalogEntityStore.selectedItem} @@ -274,15 +273,13 @@ export class Catalog extends React.Component { { this.catalogEntityStore.selectedItem - ? ( - this.catalogEntityStore.selectedItemId = null} - /> - ) - : ( - - ) + ? this.catalogEntityStore.selectedItemId = null} + /> + : } ); diff --git a/src/renderer/components/badge/badge.scss b/src/renderer/components/badge/badge.scss index 4e7f9702f6..23f8e99ff0 100644 --- a/src/renderer/components/badge/badge.scss +++ b/src/renderer/components/badge/badge.scss @@ -36,4 +36,8 @@ &.small { font-size: $font-size-small; } -} \ No newline at end of file + + &.clickable { + cursor: pointer; + } +} diff --git a/src/renderer/components/badge/badge.tsx b/src/renderer/components/badge/badge.tsx index 57d1e8917a..14959b04d1 100644 --- a/src/renderer/components/badge/badge.tsx +++ b/src/renderer/components/badge/badge.tsx @@ -35,9 +35,10 @@ export interface BadgeProps extends React.HTMLAttributes, TooltipDecoratorP export class Badge extends React.Component { render() { const { className, label, small, flat, children, ...elemProps } = this.props; + const clickable = Boolean(this.props.onClick); return <> - + {label} {children} diff --git a/src/renderer/components/item-object-list/page-filters.store.ts b/src/renderer/components/item-object-list/page-filters.store.ts index f2d36a0da8..a6d840dc93 100644 --- a/src/renderer/components/item-object-list/page-filters.store.ts +++ b/src/renderer/components/item-object-list/page-filters.store.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { computed, observable, reaction, makeObservable } from "mobx"; +import { computed, observable, reaction, makeObservable, action } from "mobx"; import { autoBind } from "../../utils"; import { searchUrlParam } from "../input/search-input-url"; @@ -68,6 +68,7 @@ export class PageFiltersStore { return () => disposers.forEach(dispose => dispose()); } + @action addFilter(filter: Filter, begin = false) { if (begin) this.filters.unshift(filter); else { @@ -75,12 +76,17 @@ export class PageFiltersStore { } } + @action removeFilter(filter: Filter) { if (!this.filters.remove(filter)) { const filterCopy = this.filters.find(f => f.type === filter.type && f.value === filter.value); if (filterCopy) this.filters.remove(filterCopy); } + + if (filter.type === FilterType.SEARCH) { + searchUrlParam.clear(); + } } getByType(type: FilterType, value?: any): Filter { @@ -99,19 +105,26 @@ export class PageFiltersStore { return !this.isDisabled.get(type); } + @action disable(type: FilterType | FilterType[]) { [type].flat().forEach(type => this.isDisabled.set(type, true)); return () => this.enable(type); } + @action enable(type: FilterType | FilterType[]) { [type].flat().forEach(type => this.isDisabled.delete(type)); return () => this.disable(type); } + @action reset() { + if (this.isEnabled(FilterType.SEARCH)) { + searchUrlParam.clear(); + } + this.filters.length = 0; this.isDisabled.clear(); } diff --git a/src/renderer/themes/lens-light.json b/src/renderer/themes/lens-light.json index 5084b63c52..8893fe27ac 100644 --- a/src/renderer/themes/lens-light.json +++ b/src/renderer/themes/lens-light.json @@ -12,7 +12,7 @@ "textColorPrimary": "#555555", "textColorSecondary": "#51575d", "textColorTertiary": "#555555", - "textColorAccent": "#333333", + "textColorAccent": "#222222", "textColorDimmed": "#5557598c", "borderColor": "#c9cfd3", "borderFaintColor": "#dfdfdf", From 8a0cb2602b75bbab70d1396d367937c9188d16a8 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 18 Jun 2021 08:44:40 -0400 Subject: [PATCH 19/70] ClusterStore.storedKubeConfigFolder should be usabled everywhere (#3105) Signed-off-by: Sebastian Malton --- src/common/cluster-store.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/common/cluster-store.ts b/src/common/cluster-store.ts index 1a39f201c0..26f0e24370 100644 --- a/src/common/cluster-store.ts +++ b/src/common/cluster-store.ts @@ -20,7 +20,7 @@ */ import path from "path"; -import { app, ipcMain, ipcRenderer, webFrame } from "electron"; +import { app, ipcMain, ipcRenderer, remote, webFrame } from "electron"; import { action, comparer, computed, makeObservable, observable, reaction } from "mobx"; import { BaseStore } from "./base-store"; import { Cluster, ClusterState } from "../main/cluster"; @@ -114,7 +114,7 @@ export class ClusterStore extends BaseStore { private static StateChannel = "cluster:state"; static get storedKubeConfigFolder(): string { - return path.resolve(app.getPath("userData"), "kubeconfigs"); + return path.resolve((app ?? remote.app).getPath("userData"), "kubeconfigs"); } static getCustomKubeConfigPath(clusterId: ClusterId = uuid.v4()): string { From 4ff5a722a79f8e16569321ba61642f05263d0ffd Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 18 Jun 2021 10:19:13 -0400 Subject: [PATCH 20/70] lens:// routing shouldn't ignore intree extensions (#3091) Signed-off-by: Sebastian Malton --- src/common/protocol-handler/router.ts | 2 +- src/extensions/extension-loader.ts | 48 +++++++++++++-------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index 7967d0bda3..50f971c356 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -179,7 +179,7 @@ export abstract class LensProtocolRouter extends Singleton { const { [EXTENSION_PUBLISHER_MATCH]: publisher, [EXTENSION_NAME_MATCH]: partialName } = match.params; const name = [publisher, partialName].filter(Boolean).join("/"); - const extension = ExtensionLoader.getInstance().userExtensionsByName.get(name); + const extension = ExtensionLoader.getInstance().getInstanceByName(name); if (!extension) { logger.info(`${LensProtocolRouter.LoggingPrefix}: Extension ${name} matched, but not installed`); diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index d3349b3c16..68e54828dc 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -22,7 +22,7 @@ import { app, ipcRenderer, remote } from "electron"; import { EventEmitter } from "events"; import { isEqual } from "lodash"; -import { action, computed, makeObservable, observable, reaction, when } from "mobx"; +import { action, computed, makeObservable, observable, observe, reaction, when } from "mobx"; import path from "path"; import { getHostedCluster } from "../common/cluster-store"; import { broadcastMessage, ipcMainOn, ipcRendererOn, requestMain, ipcMainHandle } from "../common/ipc"; @@ -48,6 +48,11 @@ export class ExtensionLoader extends Singleton { protected extensions = observable.map(); protected instances = observable.map(); + /** + * This is updated by the `observe` in the constructor. DO NOT write directly to it + */ + protected instancesByName = observable.map(); + // IPC channel to broadcast changes to extensions from main protected static readonly extensionsMainChannel = "extensions:main"; @@ -65,8 +70,23 @@ export class ExtensionLoader extends Singleton { constructor() { super(); - makeObservable(this); + observe(this.instances, change => { + switch (change.type) { + case "add": + if (this.instancesByName.has(change.newValue.name)) { + throw new TypeError("Extension names must be unique"); + } + + this.instancesByName.set(change.newValue.name, change.newValue); + break; + case "delete": + this.instancesByName.delete(change.oldValue.name); + break; + case "update": + throw new Error("Extension instances shouldn't be updated"); + } + }); } @computed get userExtensions(): Map { @@ -81,28 +101,8 @@ export class ExtensionLoader extends Singleton { return extensions; } - @computed get userExtensionsByName(): Map { - const extensions = new Map(); - - for (const [, val] of this.instances.toJSON()) { - 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; + getInstanceByName(name: string): LensExtension | undefined { + return this.instancesByName.get(name); } // Transform userExtensions to a state object for storing into ExtensionsStore From 49935a73069ad669b45c50c16b1b7d65ed73ff68 Mon Sep 17 00:00:00 2001 From: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com> Date: Fri, 18 Jun 2021 11:34:16 -0400 Subject: [PATCH 21/70] Add migration details for extension api docs (#3074) * updating extension api docs for 5.0 Signed-off-by: Jim Ehrismann * beefed up migration details Signed-off-by: Jim Ehrismann * address review comments Signed-off-by: Jim Ehrismann * updated cluster features docs (#3094) Signed-off-by: Jim Ehrismann * revert extension package-lock.json files again Signed-off-by: Jim Ehrismann --- docs/README.md | 8 + .../capabilities/common-capabilities.md | 30 -- docs/extensions/extension-migration.md | 24 ++ docs/extensions/guides/README.md | 5 +- docs/extensions/guides/catalog.md | 5 + .../guides/images/clusterfeature.png | Bin 0 -> 133771 bytes docs/extensions/guides/main-extension.md | 31 +- docs/extensions/guides/renderer-extension.md | 288 +++--------------- docs/extensions/guides/resource-stack.md | 238 +++++++++++++++ mkdocs.yml | 2 + 10 files changed, 343 insertions(+), 288 deletions(-) create mode 100644 docs/extensions/extension-migration.md create mode 100644 docs/extensions/guides/catalog.md create mode 100644 docs/extensions/guides/images/clusterfeature.png create mode 100644 docs/extensions/guides/resource-stack.md diff --git a/docs/README.md b/docs/README.md index f98403eb75..f9ca9e6779 100644 --- a/docs/README.md +++ b/docs/README.md @@ -35,6 +35,14 @@ Just like Lens itself, the extension API updates on a monthly cadence, rolling o Keep up with Lens and the Lens Extension API by reviewing the [release notes](https://github.com/lensapp/lens/releases). +## Important changes since Lens v4 + +Lens has undergone major design improvements in v5, which have resulted in several large changes to the extension API. +Workspaces are gone, and the catalog is introduced for containing clusters, as well as other items, including custom entities. +Lens has migrated from using mobx 5 to mobx 6 for internal state management, and this may have ramifications for extension implementations. +Although the API retains many components from v4, given these changes, extensions written for Lens v4 are not compatible with the Lens v5 extension API. +See the [Lens v4 to v5 extension migration notes](extensions/extension-migration.md) on getting old extensions working in Lens v5. + ## Looking for Help If you have questions for extension development, try asking on the [Lens Dev Slack](http://k8slens.slack.com/). It's a public chatroom for Lens developers, where Lens team members chime in from time to time. diff --git a/docs/extensions/capabilities/common-capabilities.md b/docs/extensions/capabilities/common-capabilities.md index a399a7bc03..4993e94181 100644 --- a/docs/extensions/capabilities/common-capabilities.md +++ b/docs/extensions/capabilities/common-capabilities.md @@ -189,36 +189,6 @@ export default class ExampleExtension extends Renderer.LensExtension { ``` -### Cluster Features - -This extension can register installable features for a cluster. -These features are visible in the "Cluster Settings" page. - -```typescript -import React from "react" -import { Renderer } from "@k8slens/extensions" -import { MyCustomFeature } from "./src/my-custom-feature" - -export default class ExampleExtension extends Renderer.LensExtension { - clusterFeatures = [ - { - title: "My Custom Feature", - components: { - Description: () => { - return ( - - Just an example. - - ) - } - }, - feature: new MyCustomFeature() - } - ] -} - -``` - ### Top Bar Items This extension can register custom components to a top bar area. diff --git a/docs/extensions/extension-migration.md b/docs/extensions/extension-migration.md new file mode 100644 index 0000000000..93ccd0d923 --- /dev/null +++ b/docs/extensions/extension-migration.md @@ -0,0 +1,24 @@ +# Lens v4 to v5 Extension Migration Notes + +* Lens v5 inspects the version of the extension to ensure it is compatible. +The `package.json` for your extension must have an `"engines"` field specifying the lens version that your extension is targeted for, e.g: +``` + "engines": { + "lens": "^5.0.0-beta.7" + }, +``` +Note that Lens v5 supports all the range semantics that [semver](https://www.npmjs.com/package/semver) provides. +* Types and components have been reorganized, many have been grouped by process (`Main` and `Renderer`) plus those not specific to a process (`Common`). +For example the `LensMainExtension` class is now referred to by `Main.LensExtension`. +See the [API Reference](api/README.md) for the new organization. +* The `globalPageMenus` field of the Renderer extension class (now `Renderer.LensExtension`) is removed. +Global pages can still be made accessible via the application menus and the status bar, as well as from the newly added Welcome menu. +* The `clusterFeatures` field of the Renderer extension class (now `Renderer.LensExtension`) is removed. +Cluster features can still be implemented but Lens no longer dictates how a feature's lifecycle (install/upgrade/uninstall) is managed. +`Renderer.K8sApi.ResourceStack` provides the functionality to input and apply kubernetes resources to a cluster. +It is up to the extension developer to manage the lifecycle. +It could be applied automatically to a cluster by the extension or the end-user could be expected to install it, etc. from the cluster **Settings** page. +* Lens v5 now relies on mobx 6 for state management. Extensions that use mobx will need to be modified to work with mobx 6. +See [Migrating from Mobx 4/5](https://mobx.js.org/migrating-from-4-or-5.html) for specific details. + +For an example of an existing extension that is compatible with Lens v5 see the [Lens Resource Map Extension](https://github.com/nevalla/lens-resource-map-extension) diff --git a/docs/extensions/guides/README.md b/docs/extensions/guides/README.md index c1c2521f5a..434aec7444 100644 --- a/docs/extensions/guides/README.md +++ b/docs/extensions/guides/README.md @@ -17,8 +17,9 @@ Each guide or code sample includes the following: | Guide | APIs | | ----- | ----- | | [Generate new extension project](generator.md) || -| [Main process extension](main-extension.md) | LensMainExtension | -| [Renderer process extension](renderer-extension.md) | LensRendererExtension | +| [Main process extension](main-extension.md) | Main.LensExtension | +| [Renderer process extension](renderer-extension.md) | Renderer.LensExtension | +| [Resource stack (cluster feature)](resource-stack.md) | | | [Stores](stores.md) | | | [Components](components.md) | | | [KubeObjectListLayout](kube-object-list-layout.md) | | diff --git a/docs/extensions/guides/catalog.md b/docs/extensions/guides/catalog.md new file mode 100644 index 0000000000..5425382638 --- /dev/null +++ b/docs/extensions/guides/catalog.md @@ -0,0 +1,5 @@ +# Catalog (WIP) + +## CatalogCategoryRegistry + +## CatalogEntityRegistry \ No newline at end of file diff --git a/docs/extensions/guides/images/clusterfeature.png b/docs/extensions/guides/images/clusterfeature.png new file mode 100644 index 0000000000000000000000000000000000000000..ada8beba6f5375bc2cb82617db61abfdcbf528eb GIT binary patch literal 133771 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 literal 0 HcmV?d00001 diff --git a/docs/extensions/guides/main-extension.md b/docs/extensions/guides/main-extension.md index 32d24acdef..8a501ca533 100644 --- a/docs/extensions/guides/main-extension.md +++ b/docs/extensions/guides/main-extension.md @@ -2,13 +2,14 @@ The Main Extension API is the interface to Lens's main process. Lens runs in both main and renderer processes. -The Main Extension API allows you to access, configure, and customize Lens data, add custom application menu items, and run custom code in Lens's main process. +The Main Extension API allows you to access, configure, and customize Lens data, add custom application menu items and [protocol handlers](protocol-handlers.md), and run custom code in Lens's main process. +It also provides convenient methods for navigating to built-in Lens pages and extension pages, as well as adding and removing sources of catalog entities. -## `LensMainExtension` Class +## `Main.LensExtension` Class ### `onActivate()` and `onDeactivate()` Methods -To create a main extension simply extend the `LensMainExtension` class: +To create a main extension simply extend the `Main.LensExtension` class: ```typescript import { Main } from "@k8slens/extensions"; @@ -75,3 +76,27 @@ Valid values include: `"file"`, `"edit"`, `"view"`, and `"help"`. In this example, we simply log a message. However, you would typically have this navigate to a specific page or perform another operation. Note that pages are associated with the [`Renderer.LensExtension`](renderer-extension.md) class and can be defined in the process of extending it. + +The following example demonstrates how an application menu can be used to navigate to such a page: + +``` typescript +import { Main } from "@k8slens/extensions"; + +export default class SamplePageMainExtension extends Main.LensExtension { + appMenus = [ + { + parentId: "help", + label: "Sample", + click: () => this.navigate("myGlobalPage") + } + ] +} +``` + +When the menu item is clicked the `navigate()` method looks for and displays a global page with id `"myGlobalPage"`. +This page would be defined in your extension's `Renderer.LensExtension` implmentation (See [`Renderer.LensExtension`](renderer-extension.md)). + +### `addCatalogSource()` and `removeCatalogSource()` Methods + +The `Main.LensExtension` class also provides the `addCatalogSource()` and `removeCatalogSource()` methods, for managing custom catalog items (or entities). +See the [`Catalog`](catalog.md) documentation for full details about the catalog. \ No newline at end of file diff --git a/docs/extensions/guides/renderer-extension.md b/docs/extensions/guides/renderer-extension.md index c96391fde0..110cf3331f 100644 --- a/docs/extensions/guides/renderer-extension.md +++ b/docs/extensions/guides/renderer-extension.md @@ -1,27 +1,40 @@ -# Renderer Extension +# Renderer Extension (WIP) The Renderer Extension API is the interface to Lens's renderer process. Lens runs in both the main and renderer processes. -The Renderer Extension API allows you to access, configure, and customize Lens data, add custom Lens UI elements, and run custom code in Lens's renderer process. +The Renderer Extension API allows you to access, configure, and customize Lens data, add custom Lens UI elements, protocol handlers, and command palette commands, as well as run custom code in Lens's renderer process. The custom Lens UI elements that you can add include: * [Cluster pages](#clusterpages) * [Cluster page menus](#clusterpagemenus) * [Global pages](#globalpages) -* [Global page menus](#globalpagemenus) +* [Welcome menus](#welcomemenus) * [App preferences](#apppreferences) +* [Top bar items](#topbaritems) * [Status bar items](#statusbaritems) * [KubeObject menu items](#kubeobjectmenuitems) * [KubeObject detail items](#kubeobjectdetailitems) +* [KubeObject status texts](#kubeobjectstatustexts) +* [Kube workloads overview items](#kubeworkloadsoverviewitems) + +as well as catalog-related UI elements: + +* [Entity settings](#entitysettings) +* [Catalog entity detail items](#catalogentitydetailitems) All UI elements are based on React components. -## `LensRendererExtension` Class +Finally, you can also add commands and protocol handlers: + +* [Command palette commands](#commandpalettecommands) +* [protocol handlers](protocol-handlers.md) + +## `Renderer.LensExtension` Class ### `onActivate()` and `onDeactivate()` Methods -To create a renderer extension, extend the `LensRendererExtension` class: +To create a renderer extension, extend the `Renderer.LensExtension` class: ```typescript import { Renderer } from "@k8slens/extensions"; @@ -319,254 +332,7 @@ Global pages can be made available in the following ways: * To add global pages as an interactive element in the blue status bar along the bottom of the Lens UI, see [`statusBarItems`](#statusbaritems). * To add global pages to the left side menu, see [`globalPageMenus`](#globalpagemenus). -### `globalPageMenus` - -`globalPageMenus` allows you to add global page menu items to the left nav. - -By expanding on the above example, you can add a global page menu item to the `HelpExtension` definition: - -```typescript -import { Renderer } from "@k8slens/extensions"; -import { HelpIcon, HelpPage } from "./page" -import React from "react" - -export default class HelpExtension extends Renderer.LensExtension { - globalPages = [ - { - id: "help", - components: { - Page: () => , - } - } - ]; - - globalPageMenus = [ - { - target: { pageId: "help" }, - title: "Help", - components: { - Icon: HelpIcon, - } - }, - ]; -} -``` - -`globalPageMenus` is an array of objects that satisfy the `PageMenuRegistration` interface. -This element defines how the global page menu item will appear and what it will do when you click it. -The properties of the `globalPageMenus` array objects are defined as follows: - -* `target` links to the relevant global page using `pageId`. -* `pageId` takes the value of the relevant global page's `id` property. -* `title` sets the name of the global page menu item that will display as a tooltip in the left nav. -* `components` is used to set an icon that appears in the left nav. - -The above example creates a "Help" icon menu item. -When users click the icon, the Lens UI will display the contents of `ExamplePage`. - -This example requires the definition of another React-based component, `HelpIcon`. -Update `page.tsx` from the example above with the `HelpIcon` definition, as follows: - -```typescript -import { Renderer } from "@k8slens/extensions"; -import React from "react" - -type IconProps = Renderer.Component.IconProps; - -const { - Component: { Icon }, -} = Renderer; - -export function HelpIcon(props: IconProps) { - return -} - -export class HelpPage extends React.Component<{ extension: Renderer.LensExtension }> { - render() { - return ( -
-

Help

-
- ) - } -} -``` - -Lens includes various built-in components available for extension developers to use. -One of these is the `Renderer.Component.Icon`, introduced in `HelpIcon`, which you can use to access any of the [icons](https://material.io/resources/icons/) available at [Material Design](https://material.io). -The property that `Renderer.Component.Icon` uses is defined as follows: - -* `material` takes the name of the icon you want to use. - -This is what the example will look like, including how the menu item will appear in the left nav: - -![globalPageMenus](images/globalpagemenus.png) - -### `clusterFeatures` - -Cluster features are Kubernetes resources that can be applied to and managed within the active cluster. -They can be installed and uninstalled by the Lens user from the cluster **Settings** page. - -!!! info - To access the cluster **Settings** page, right-click the relevant cluster in the left side menu and click **Settings**. - -The following example shows how to add a cluster feature as part of a `LensRendererExtension`: - -```typescript -import { Renderer } from "@k8slens/extensions" -import { ExampleFeature } from "./src/example-feature" -import React from "react" - -export default class ExampleFeatureExtension extends Renderer.LensExtension { - clusterFeatures = [ - { - title: "Example Feature", - components: { - Description: () => { - return ( - - Enable an example feature. - - ) - } - }, - feature: new ExampleFeature() - } - ]; -} -``` - -The properties of the `clusterFeatures` array objects are defined as follows: - -* `title` and `components.Description` provide content that appears on the cluster settings page, in the **Features** section. -* `feature` specifies an instance which extends the abstract class `ClusterFeature.Feature`, and specifically implements the following methods: - -```typescript - abstract install(cluster: Cluster): Promise; - abstract upgrade(cluster: Cluster): Promise; - abstract uninstall(cluster: Cluster): Promise; - abstract updateStatus(cluster: Cluster): Promise; -``` - -The four methods listed above are defined as follows: - -* The `install()` method installs Kubernetes resources using the `applyResources()` method, or by directly accessing the [Kubernetes API](../api/README.md). -This method is typically called when a user indicates that they want to install the feature (i.e., by clicking **Install** for the feature in the cluster settings page). - -* The `upgrade()` method upgrades the Kubernetes resources already installed, if they are relevant to the feature. -This method is typically called when a user indicates that they want to upgrade the feature (i.e., by clicking **Upgrade** for the feature in the cluster settings page). - -* The `uninstall()` method uninstalls Kubernetes resources using the [Kubernetes API](../api/README.md). -This method is typically called when a user indicates that they want to uninstall the feature (i.e., by clicking **Uninstall** for the feature in the cluster settings page). - -* The `updateStatus()` method provides the current status information in the `status` field of the `ClusterFeature.Feature` parent class. -Lens periodically calls this method to determine details about the feature's current status. -The implementation of this method should uninstall Kubernetes resources using the Kubernetes api (`K8sApi`) -Consider using the following properties with `updateStatus()`: - - * `status.currentVersion` and `status.latestVersion` may be displayed by Lens in the feature's description. - - * `status.installed` should be set to `true` if the feature is installed, and `false` otherwise. - - * `status.canUpgrade` is set according to a rule meant to determine whether the feature can be upgraded. - This rule can involve `status.currentVersion` and `status.latestVersion`, if desired. - -The following shows a very simple implementation of a `ClusterFeature`: - -```typescript -import { Renderer, Common } from "@k8slens/extensions"; -import * as path from "path"; - -const { - K8sApi: { - ResourceStack, - forCluster, - StorageClass, - Namespace, - } -} = Renderer; - -type ResourceStack = Renderer.K8sApi.ResourceStack; -type Pod = Renderer.K8sApi.Pod; -type KubernetesCluster = Common.Catalog.KubernetesCluster; - -export interface MetricsStatus { - installed: boolean; - canUpgrade: boolean; -} - -export class ExampleFeature { - protected stack: ResourceStack; - - constructor(protected cluster: KubernetesCluster) { - this.stack = new ResourceStack(cluster, this.name); - } - - install(): Promise { - return this.stack.kubectlApplyFolder(path.join(__dirname, "../resources/")); - } - - upgrade(): Promise { - return this.install(config); - } - - async getStatus(): Promise { - const status: MetricsStatus = { installed: false, canUpgrade: false}; - - try { - const pod = forCluster(cluster, Pod); - const examplePod = await pod.get({name: "example-pod", namespace: "default"}); - - if (examplePod?.kind) { - status.installed = true; - status.currentVersion = examplePod.spec.containers[0].image.split(":")[1]; - status.canUpgrade = true; // a real implementation would perform a check here that is relevant to the specific feature - } else { - status.installed = false; - status.canUpgrade = false; - } - } catch(e) { - if (e?.error?.code === 404) { - status.installed = false; - status.canUpgrade = false; - } - } - - return status; - } - - async uninstall(): Promise { - return this.stack.kubectlDeleteFolder(this.resourceFolder); - } -} -``` - -This example implements the `install()` method by invoking the helper `applyResources()` method. -`applyResources()` tries to apply all resources read from all files found in the folder path provided. -In this case the folder path is the `../resources` subfolder relative to the current source code's folder. -The file `../resources/example-pod.yml` could contain: - -``` yaml -apiVersion: v1 -kind: Pod -metadata: - name: example-pod -spec: - containers: - - name: example-pod - image: nginx -``` - -The example above implements the four methods as follows: - -* It implements `upgrade()` by invoking the `install()` method. -Depending on the feature to be supported by an extension, upgrading may require additional and/or different steps. - -* It implements `uninstall()` by utilizing the [Kubernetes API](../api/README.md) which Lens provides to delete the `example-pod` applied by the `install()` method. - -* It implements `updateStatus()` by using the [Kubernetes API](../api/README.md) which Lens provides to determine whether the `example-pod` is installed, what version is associated with it, and whether it can be upgraded. -The implementation determines what the status is for a specific cluster feature. - +### `welcomeMenus` ### `appPreferences` The Lens **Preferences** page is a built-in global page. @@ -677,6 +443,8 @@ Note that you can manage an extension's state data using an `ExtensionStore` obj To simplify this guide, the example above defines a `preference` field in the `ExampleRendererExtension` class definition to hold the extension's state. However, we recommend that you manage your extension's state data using [`ExtensionStore`](../stores#extensionstore). +### `topBarItems` + ### `statusBarItems` The status bar is the blue strip along the bottom of the Lens UI. @@ -998,3 +766,17 @@ Construct the table using the `Renderer.Component.Table` and related elements. For each pod the name, age, and status are obtained using the `Renderer.K8sApi.Pod` methods. The table is constructed using the `Renderer.Component.Table` and related elements. See [Component documentation](https://docs.k8slens.dev/latest/extensions/api/modules/_renderer_api_components_/) for further details. + +### `kubeObjectStatusTexts` + +### `kubeWorkloadsOverviewItems` + +### `entitySettings` + +### `catalogEntityDetailItems` + +### `commandPaletteCommands` + +### `protocolHandlers` + +See the [Protocol Handlers Guide](protocol-handlers.md) diff --git a/docs/extensions/guides/resource-stack.md b/docs/extensions/guides/resource-stack.md new file mode 100644 index 0000000000..8d36bb6495 --- /dev/null +++ b/docs/extensions/guides/resource-stack.md @@ -0,0 +1,238 @@ +# Resource Stack (Cluster Feature) + +A cluster feature is a set of Kubernetes resources that can be applied to and managed within the active cluster. +The `Renderer.K8sApi.ResourceStack` class provides the functionality to input and apply kubernetes resources to a cluster. +It is up to the extension developer to manage the life cycle of the resource stack. +It could be applied automatically to a cluster by the extension, or the end-user could be required to install it. + +The code examples in this section show how to create a resource stack, and define a cluster feature that is configurable from the cluster **Settings** page. + +!!! info + To access the cluster **Settings** page, right-click the relevant cluster in the left side menu and click **Settings**. + +The resource stack in this example consists of a single kubernetes resource: + +```yaml +apiVersion: v1 +kind: Pod +metadata: + name: example-pod +spec: + containers: + - name: example-pod + image: nginx +``` + +It is simply a pod named `example-pod`, running nginx. Assume this content is in the file `../resources/example-pod.yml`. + +The following code sample shows how to use the `Renderer.K8sApi.ResourceStack` to manage installing and uninstalling this resource stack: + +```typescript +import { Renderer, Common } from "@k8slens/extensions"; +import * as path from "path"; + +const { + K8sApi: { + ResourceStack, + forCluster, + Pod, + } +} = Renderer; + +type ResourceStack = Renderer.K8sApi.ResourceStack; +type Pod = Renderer.K8sApi.Pod; +type KubernetesCluster = Common.Catalog.KubernetesCluster; + +export class ExampleClusterFeature { + protected stack: ResourceStack; + + constructor(protected cluster: KubernetesCluster) { + this.stack = new ResourceStack(cluster, "example-resource-stack"); + } + + get resourceFolder() { + return path.join(__dirname, "../resources/"); + } + + install(): Promise { + console.log("installing example-pod"); + return this.stack.kubectlApplyFolder(this.resourceFolder); + } + + async isInstalled(): Promise { + try { + const podApi = forCluster(this.cluster, Pod); + const examplePod = await podApi.get({name: "example-pod", namespace: "default"}); + + if (examplePod?.kind) { + console.log("found example-pod"); + return true; + } + } catch(e) { + console.log("Error getting example-pod:", e); + } + console.log("didn't find example-pod"); + + return false; + } + + async uninstall(): Promise { + console.log("uninstalling example-pod"); + return this.stack.kubectlDeleteFolder(this.resourceFolder); + } +} +``` + +The `ExampleClusterFeature` class constructor takes a `Common.Catalog.KubernetesCluster` argument. +This is the cluster that the resource stack will be applied to, and the constructor instantiates a `Renderer.K8sApi.ResourceStack` as such. +`ExampleClusterFeature` implements an `install()` method which simply invokes the `kubectlApplyFolder()` method of the `Renderer.K8sApi.ResourceStack` class. +`kubectlApplyFolder()` applies to the cluster all kubernetes resources found in the folder passed to it, in this case `../resources`. +Similarly, `ExampleClusterFeature` implements an `uninstall()` method which simply invokes the `kubectlDeleteFolder()` method of the `Renderer.K8sApi.ResourceStack` class. +`kubectlDeleteFolder()` tries to delete from the cluster all kubernetes resources found in the folder passed to it, again in this case `../resources`. + +`ExampleClusterFeature` also implements an `isInstalled()` method, which demonstrates how you can utiliize the kubernetes api to inspect the resource stack status. +`isInstalled()` simply tries to find a pod named `example-pod`, as a way to determine if the pod is already installed. +This method can be useful in creating a context-sensitive UI for installing/uninstalling the feature, as demonstrated in the next sample code. + +To allow the end-user to control the life cycle of this cluster feature the following code sample shows how to implement a user interface based on React and custom Lens UI components: + +```typescript + import React from "react"; + import { Common, Renderer } from "@k8slens/extensions"; + import { observer } from "mobx-react"; + import { computed, observable, makeObservable } from "mobx"; + import { ExampleClusterFeature } from "./example-cluster-feature"; + + const { + Component: { + SubTitle, Button, + } + } = Renderer; + + interface Props { + cluster: Common.Catalog.KubernetesCluster; + } + + @observer + export class ExampleClusterFeatureSettings extends React.Component { + constructor(props: Props) { + super(props); + makeObservable(this); + } + + @observable installed = false; + @observable inProgress = false; + + feature: ExampleClusterFeature; + + async componentDidMount() { + this.feature = new ExampleClusterFeature(this.props.cluster); + + await this.updateFeatureState(); + } + + async updateFeatureState() { + this.installed = await this.feature.isInstalled(); + } + + async save() { + this.inProgress = true; + + try { + if (this.installed) { + await this.feature.uninstall(); + } else { + await this.feature.install(); + } + } finally { + this.inProgress = false; + + await this.updateFeatureState(); + } + } + + @computed get buttonLabel() { + if (this.inProgress && this.installed) return "Uninstalling ..."; + if (this.inProgress) return "Applying ..."; + + if (this.installed) { + return "Uninstall"; + } + + return "Apply"; + } + + render() { + return ( + <> +
+ +
+ + ); + } +} +``` + +The `ExampleClusterFeatureSettings` class extends `React.Component` and simply renders a subtitle and a button. +`ExampleClusterFeatureSettings` takes the cluster as a prop and when the React component has mounted the `ExampleClusterFeature` is instantiated using this cluster (in `componentDidMount()`). +The rest of the logic concerns the button appearance and action, based on the `ExampleClusterFeatureSettings` fields `installed` and `inProgress`. +The `installed` value is of course determined using the aforementioned `ExampleClusterFeature` method `isInstalled()`. +The `inProgress` value is true while waiting for the feature to be installed (or uninstalled). + +Note that the button is a `Renderer.Component.Button` element and this example takes advantage of its `waiting` prop to show a "waiting" animation while the install (or uninstall) is in progress. +Using elements from `Renderer.Component` is encouraged, to take advantage of their built-in properties, and to ensure a common look and feel. + +Also note that [MobX 6](https://mobx.js.org/README.html) is used for state management, ensuring that the UI is rerendered when state has changed. +The `ExampleClusterFeatureSettings` class is marked as an `@observer`, and its constructor must call `makeObservable()`. +As well, the `installed` and `inProgress` fields are marked as `@observable`, ensuring that the button gets rerendered any time these fields change. + +Finally, `ExampleClusterFeatureSettings` needs to be connected to the extension, and would typically appear on the cluster **Settings** page via a `Renderer.LensExtension.entitySettings` entry. +The `ExampleExtension` would look like this: + +```typescript +import { Common, Renderer } from "@k8slens/extensions"; +import { ExampleClusterFeatureSettings } from "./src/example-cluster-feature-settings" +import React from "react" + +export default class ExampleExtension extends Renderer.LensExtension { + entitySettings = [ + { + apiVersions: ["entity.k8slens.dev/v1alpha1"], + kind: "KubernetesCluster", + title: "Example Cluster Feature", + priority: 5, + components: { + View: ({ entity = null }: { entity: Common.Catalog.KubernetesCluster}) => { + return ( + + ); + } + } + } + ]; + +} +``` + +An entity setting is added to the `entitySettings` array field of the `Renderer.LensExtension` class. +Because Lens's catalog can contain different kinds of entities, the kind must be identified. +For more details about the catalog see the [Catalog Guide](catalog.md). +Clusters are a built-in kind, so the `apiVersions` and `kind` fields should be set as above. +The `title` is shown as a navigation item on the cluster **Settings** page and the `components.View` is displayed when the navigation item is clicked on. +The `components.View` definition above shows how the `ExampleClusterFeatureSettings` element is included, and how its `cluster` prop is set. +`priority` determines the order of the entity settings, the higher the number the higher in the navigation panel the setting is placed. The default value is 50. + +The final result looks like this: + +![Cluster Feature Settings](images/clusterfeature.png) + +`ExampleClusterFeature` and `ExampleClusterFeatureSettings` demonstrate a cluster feature for a simple resource stack. +In practice a resource stack can include many resources, and require more sophisticated life cycle management (upgrades, partial installations, etc.) +Using `Renderer.K8sApi.ResourceStack` and `entitySettings` it is possible to implement solutions for more complex cluster features. +The **Lens Metrics** setting (on the cluster **Settings** page) is a good example of an advanced solution. \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 51ad079eaf..4ed763e6d3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,8 @@ nav: - Generator: extensions/guides/generator.md - Main Extension: extensions/guides/main-extension.md - Renderer Extension: extensions/guides/renderer-extension.md + - Catalog: extensions/guides/catalog.md + - Resource Stack: extensions/guides/resource-stack.md - Stores: extensions/guides/stores.md - Working with MobX: extensions/guides/working-with-mobx.md - Protocol Handlers: extensions/guides/protocol-handlers.md From 9b04e611a4926c3b6b6f4fdd2cc38cb42d8b7d28 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 18 Jun 2021 12:05:44 -0400 Subject: [PATCH 22/70] Transition workspaces to hotbars (#3079) --- package.json | 2 +- src/migrations/hotbar-store/5.0.0-beta.11.ts | 85 ++++++++++++++++++++ src/migrations/hotbar-store/index.ts | 2 + 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 src/migrations/hotbar-store/5.0.0-beta.11.ts diff --git a/package.json b/package.json index 297e37e16b..ba4c5d4254 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "productName": "OpenLens", "description": "OpenLens - Open Source IDE for Kubernetes", "homepage": "https://github.com/lensapp/lens", - "version": "5.0.0-beta.10", + "version": "5.0.0-beta.11", "main": "static/build/main.js", "copyright": "© 2021 OpenLens Authors", "license": "MIT", diff --git a/src/migrations/hotbar-store/5.0.0-beta.11.ts b/src/migrations/hotbar-store/5.0.0-beta.11.ts new file mode 100644 index 0000000000..9ad4feb0bf --- /dev/null +++ b/src/migrations/hotbar-store/5.0.0-beta.11.ts @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { app } from "electron"; +import fse from "fs-extra"; +import path from "path"; +import * as uuid from "uuid"; +import type { ClusterStoreModel } from "../../common/cluster-store"; +import { defaultHotbarCells, Hotbar } from "../../common/hotbar-store"; +import type { MigrationDeclaration } from "../helpers"; + +interface Pre500WorkspaceStoreModel { + workspaces: { + id: string; + name: string; + }[]; +} + +export default { + version: "5.0.0-beta.11", + run(store) { + const hotbars: Hotbar[] = store.get("hotbars"); + const userDataPath = app.getPath("userData"); + + try { + const workspaceStoreData: Pre500WorkspaceStoreModel = fse.readJsonSync(path.join(userDataPath, "lens-workspace-store.json")); + const { clusters }: ClusterStoreModel = fse.readJSONSync(path.join(userDataPath, "lens-cluster-store.json")); + const workspaceHotbars = new Map(); // mapping from WorkspaceId to HotBar + + for (const { id, name } of workspaceStoreData.workspaces) { + workspaceHotbars.set(id, { + id: uuid.v4(), // don't use the old IDs as they aren't necessarily UUIDs + items: [], + name, + }); + } + + for (const cluster of clusters) { + const workspaceHotbar = workspaceHotbars.get(cluster.workspace); + + if (workspaceHotbar?.items.length < defaultHotbarCells) { + workspaceHotbar.items.push({ + entity: { + uid: cluster.id, + name: cluster.preferences.clusterName || cluster.contextName, + } + }); + } + } + + for (const hotbar of workspaceHotbars.values()) { + while (hotbar.items.length < defaultHotbarCells) { + hotbar.items.push(null); + } + + hotbars.push(hotbar); + } + + store.set("hotbars", hotbars); + } catch (error) { + if (!(error.code === "ENOENT" && error.path.endsWith("lens-workspace-store.json"))) { + // ignore lens-workspace-store.json being missing + throw error; + } + } + } +} as MigrationDeclaration; diff --git a/src/migrations/hotbar-store/index.ts b/src/migrations/hotbar-store/index.ts index 899429cf2a..a18a27bcad 100644 --- a/src/migrations/hotbar-store/index.ts +++ b/src/migrations/hotbar-store/index.ts @@ -26,9 +26,11 @@ import { joinMigrations } from "../helpers"; import version500alpha0 from "./5.0.0-alpha.0"; import version500alpha2 from "./5.0.0-alpha.2"; import version500beta5 from "./5.0.0-beta.5"; +import version500beta11 from "./5.0.0-beta.11"; export default joinMigrations( version500alpha0, version500alpha2, version500beta5, + version500beta11, ); From 008e5167155af790a586d7f413a64b680cb80e9e Mon Sep 17 00:00:00 2001 From: mcpappas <39843989+mcpappas@users.noreply.github.com> Date: Fri, 18 Jun 2021 14:53:29 -0400 Subject: [PATCH 23/70] Use allocatable instead of capacity for node resources. (#2098) --- src/main/prometheus/lens.ts | 14 ++++++ src/main/prometheus/operator.ts | 14 ++++++ src/main/prometheus/stacklight.ts | 14 ++++++ src/renderer/api/endpoints/cluster.api.ts | 4 ++ src/renderer/api/endpoints/nodes.api.ts | 4 ++ .../+cluster/cluster-pie-charts.tsx | 50 ++++++++++++------- .../components/+nodes/node-charts.tsx | 26 +++++++++- src/renderer/components/+nodes/nodes.tsx | 2 +- 8 files changed, 108 insertions(+), 20 deletions(-) diff --git a/src/main/prometheus/lens.ts b/src/main/prometheus/lens.ts index 87e2367636..63341fe30e 100644 --- a/src/main/prometheus/lens.ts +++ b/src/main/prometheus/lens.ts @@ -54,12 +54,16 @@ export class PrometheusLens extends PrometheusProvider { switch (queryName) { case "memoryUsage": return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) by (kubernetes_name)`.replace(/_bytes/g, `_bytes{kubernetes_node=~"${opts.nodes}"}`); + case "workloadMemoryUsage": + return `sum(container_memory_working_set_bytes{container!="POD",container!="",instance=~"${opts.nodes}"}) by (component)`; case "memoryRequests": return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="memory"}) by (component)`; case "memoryLimits": return `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="memory"}) by (component)`; case "memoryCapacity": return `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="memory"}) by (component)`; + case "memoryAllocatableCapacity": + return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="memory"}) by (component)`; case "cpuUsage": return `sum(rate(node_cpu_seconds_total{kubernetes_node=~"${opts.nodes}", mode=~"user|system"}[${this.rateAccuracy}]))`; case "cpuRequests": @@ -68,10 +72,14 @@ export class PrometheusLens extends PrometheusProvider { return `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"}) by (component)`; case "cpuCapacity": return `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"}) by (component)`; + case "cpuAllocatableCapacity": + return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="cpu"}) by (component)`; case "podUsage": return `sum({__name__=~"kubelet_running_pod_count|kubelet_running_pods", instance=~"${opts.nodes}"})`; case "podCapacity": return `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"}) by (component)`; + case "podAllocatableCapacity": + return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="pods"}) by (component)`; case "fsSize": return `sum(node_filesystem_size_bytes{kubernetes_node=~"${opts.nodes}", mountpoint="/"}) by (kubernetes_node)`; case "fsUsage": @@ -82,12 +90,18 @@ export class PrometheusLens extends PrometheusProvider { switch (queryName) { case "memoryUsage": return `sum (node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) by (kubernetes_node)`; + case "workloadMemoryUsage": + return `sum(container_memory_working_set_bytes{container!="POD",container!=""}) by (instance)`; case "memoryCapacity": return `sum(kube_node_status_capacity{resource="memory"}) by (node)`; + case "memoryAllocatableCapacity": + return `sum(kube_node_status_allocatable{resource="memory"}) by (node)`; case "cpuUsage": return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])) by(kubernetes_node)`; case "cpuCapacity": return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; + case "cpuAllocatableCapacity": + return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; case "fsSize": return `sum(node_filesystem_size_bytes{mountpoint="/"}) by (kubernetes_node)`; case "fsUsage": diff --git a/src/main/prometheus/operator.ts b/src/main/prometheus/operator.ts index 2ee3b50cf9..58d41210b5 100644 --- a/src/main/prometheus/operator.ts +++ b/src/main/prometheus/operator.ts @@ -39,12 +39,16 @@ export class PrometheusOperator extends PrometheusProvider { switch (queryName) { case "memoryUsage": return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes))`.replace(/_bytes/g, `_bytes * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"}`); + case "workloadMemoryUsage": + return `sum(container_memory_working_set_bytes{container!="POD",container!="",instance=~"${opts.nodes}"}) by (component)`; case "memoryRequests": return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="memory"})`; case "memoryLimits": return `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="memory"})`; case "memoryCapacity": return `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="memory"})`; + case "memoryAllocatableCapacity": + return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="memory"})`; case "cpuUsage": return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])* on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`; case "cpuRequests": @@ -53,10 +57,14 @@ export class PrometheusOperator extends PrometheusProvider { return `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"})`; case "cpuCapacity": return `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"})`; + case "cpuAllocatableCapacity": + return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="cpu"})`; case "podUsage": return `sum({__name__=~"kubelet_running_pod_count|kubelet_running_pods", node=~"${opts.nodes}"})`; case "podCapacity": return `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"})`; + case "podAllocatableCapacity": + return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="pods"})`; case "fsSize": return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info{node=~"${opts.nodes}"})`; case "fsUsage": @@ -67,12 +75,18 @@ export class PrometheusOperator extends PrometheusProvider { switch (queryName) { case "memoryUsage": return `sum((node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) * on (pod,namespace) group_left(node) kube_pod_info) by (node)`; + case "workloadMemoryUsage": + return `sum(container_memory_working_set_bytes{container!="POD",container!=""}) by (node)`; case "memoryCapacity": return `sum(kube_node_status_capacity{resource="memory"}) by (node)`; + case "memoryAllocatableCapacity": + return `sum(kube_node_status_allocatable{resource="memory"}) by (node)`; case "cpuUsage": return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}]) * on (pod,namespace) group_left(node) kube_pod_info) by (node)`; case "cpuCapacity": return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; + case "cpuAllocatableCapacity": + return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; case "fsSize": return `sum(node_filesystem_size_bytes{mountpoint="/"} * on (pod,namespace) group_left(node) kube_pod_info) by (node)`; case "fsUsage": diff --git a/src/main/prometheus/stacklight.ts b/src/main/prometheus/stacklight.ts index 2e14508b22..7960e666a8 100644 --- a/src/main/prometheus/stacklight.ts +++ b/src/main/prometheus/stacklight.ts @@ -54,12 +54,16 @@ export class PrometheusStacklight extends PrometheusProvider { switch (queryName) { case "memoryUsage": return `sum(node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) by (kubernetes_name)`.replace(/_bytes/g, `_bytes{node=~"${opts.nodes}"}`); + case "workloadMemoryUsage": + return `sum(container_memory_working_set_bytes{container!="POD",container!="",instance=~"${opts.nodes}"}) by (component)`; case "memoryRequests": return `sum(kube_pod_container_resource_requests{node=~"${opts.nodes}", resource="memory"}) by (component)`; case "memoryLimits": return `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="memory"}) by (component)`; case "memoryCapacity": return `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="memory"}) by (component)`; + case "memoryAllocatableCapacity": + return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="memory"}) by (component)`; case "cpuUsage": return `sum(rate(node_cpu_seconds_total{node=~"${opts.nodes}", mode=~"user|system"}[${this.rateAccuracy}]))`; case "cpuRequests": @@ -68,10 +72,14 @@ export class PrometheusStacklight extends PrometheusProvider { return `sum(kube_pod_container_resource_limits{node=~"${opts.nodes}", resource="cpu"}) by (component)`; case "cpuCapacity": return `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="cpu"}) by (component)`; + case "cpuAllocatableCapacity": + return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="cpu"}) by (component)`; case "podUsage": return `sum({__name__=~"kubelet_running_pod_count|kubelet_running_pods", instance=~"${opts.nodes}"})`; case "podCapacity": return `sum(kube_node_status_capacity{node=~"${opts.nodes}", resource="pods"}) by (component)`; + case "podAllocatableCapacity": + return `sum(kube_node_status_allocatable{node=~"${opts.nodes}", resource="pods"}) by (component)`; case "fsSize": return `sum(node_filesystem_size_bytes{node=~"${opts.nodes}", mountpoint="/"}) by (node)`; case "fsUsage": @@ -82,12 +90,18 @@ export class PrometheusStacklight extends PrometheusProvider { switch (queryName) { case "memoryUsage": return `sum (node_memory_MemTotal_bytes - (node_memory_MemFree_bytes + node_memory_Buffers_bytes + node_memory_Cached_bytes)) by (node)`; + case "workloadMemoryUsage": + return `sum(container_memory_working_set_bytes{container!="POD",container!=""}) by (instance)`; case "memoryCapacity": return `sum(kube_node_status_capacity{resource="memory"}) by (node)`; + case "memoryAllocatableCapacity": + return `sum(kube_node_status_allocatable{resource="memory"}) by (node)`; case "cpuUsage": return `sum(rate(node_cpu_seconds_total{mode=~"user|system"}[${this.rateAccuracy}])) by(node)`; case "cpuCapacity": return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; + case "cpuAllocatableCapacity": + return `sum(kube_node_status_allocatable{resource="cpu"}) by (node)`; case "fsSize": return `sum(node_filesystem_size_bytes{mountpoint="/"}) by (node)`; case "fsUsage": diff --git a/src/renderer/api/endpoints/cluster.api.ts b/src/renderer/api/endpoints/cluster.api.ts index 5c3f05ce91..75f15b5a5b 100644 --- a/src/renderer/api/endpoints/cluster.api.ts +++ b/src/renderer/api/endpoints/cluster.api.ts @@ -33,15 +33,19 @@ export class ClusterApi extends KubeApi { return metricsApi.getMetrics({ memoryUsage: opts, + workloadMemoryUsage: opts, memoryRequests: opts, memoryLimits: opts, memoryCapacity: opts, + memoryAllocatableCapacity: opts, cpuUsage: opts, cpuRequests: opts, cpuLimits: opts, cpuCapacity: opts, + cpuAllocatableCapacity: opts, podUsage: opts, podCapacity: opts, + podAllocatableCapacity: opts, fsSize: opts, fsUsage: opts }, params); diff --git a/src/renderer/api/endpoints/nodes.api.ts b/src/renderer/api/endpoints/nodes.api.ts index 40dc2aeadb..17a7571209 100644 --- a/src/renderer/api/endpoints/nodes.api.ts +++ b/src/renderer/api/endpoints/nodes.api.ts @@ -31,7 +31,9 @@ export class NodesApi extends KubeApi { return metricsApi.getMetrics({ memoryUsage: opts, + workloadMemoryUsage: opts, memoryCapacity: opts, + memoryAllocatableCapacity: opts, cpuUsage: opts, cpuCapacity: opts, fsSize: opts, @@ -43,7 +45,9 @@ export class NodesApi extends KubeApi { export interface INodeMetrics { [metric: string]: T; memoryUsage: T; + workloadMemoryUsage: T; memoryCapacity: T; + memoryAllocatableCapacity: T; cpuUsage: T; cpuCapacity: T; fsUsage: T; diff --git a/src/renderer/components/+cluster/cluster-pie-charts.tsx b/src/renderer/components/+cluster/cluster-pie-charts.tsx index 2dc4b25971..609f41d1ad 100644 --- a/src/renderer/components/+cluster/cluster-pie-charts.tsx +++ b/src/renderer/components/+cluster/cluster-pie-charts.tsx @@ -45,20 +45,20 @@ export const ClusterPieCharts = observer(() => { const renderCharts = () => { const data = getMetricLastPoints(clusterOverviewStore.metrics); - const { memoryUsage, memoryRequests, memoryCapacity, memoryLimits } = data; - const { cpuUsage, cpuRequests, cpuCapacity, cpuLimits } = data; - const { podUsage, podCapacity } = data; - const cpuLimitsOverload = cpuLimits > cpuCapacity; - const memoryLimitsOverload = memoryLimits > memoryCapacity; + const { memoryUsage, memoryRequests, memoryAllocatableCapacity, memoryCapacity, memoryLimits } = data; + const { cpuUsage, cpuRequests, cpuAllocatableCapacity, cpuCapacity, cpuLimits } = data; + const { podUsage, podAllocatableCapacity, podCapacity } = data; + const cpuLimitsOverload = cpuLimits > cpuAllocatableCapacity; + const memoryLimitsOverload = memoryLimits > memoryAllocatableCapacity; const defaultColor = ThemeStore.getInstance().activeTheme.colors.pieChartDefaultColor; - - if (!memoryCapacity || !cpuCapacity || !podCapacity) return null; + + if (!memoryCapacity || !cpuCapacity || !podCapacity || !memoryAllocatableCapacity || !cpuAllocatableCapacity || !podAllocatableCapacity) return null; const cpuData: ChartData = { datasets: [ { data: [ cpuUsage, - cpuUsage ? cpuCapacity - cpuUsage : 1, + cpuUsage ? cpuAllocatableCapacity - cpuUsage : 1, ], backgroundColor: [ "#c93dce", @@ -70,7 +70,7 @@ export const ClusterPieCharts = observer(() => { { data: [ cpuRequests, - cpuRequests ? cpuCapacity - cpuRequests : 1, + cpuRequests ? cpuAllocatableCapacity - cpuRequests : 1, ], backgroundColor: [ "#4caf50", @@ -82,7 +82,7 @@ export const ClusterPieCharts = observer(() => { { data: [ cpuLimits, - cpuLimitsOverload ? 0 : cpuCapacity - cpuLimits, + cpuLimitsOverload ? 0 : cpuAllocatableCapacity - cpuLimits, ], backgroundColor: [ "#3d90ce", @@ -96,6 +96,7 @@ export const ClusterPieCharts = observer(() => { `Usage: ${cpuUsage ? cpuUsage.toFixed(2) : "N/A"}`, `Requests: ${cpuRequests ? cpuRequests.toFixed(2) : "N/A"}`, `Limits: ${cpuLimits ? cpuLimits.toFixed(2) : "N/A"}`, + `Allocatable Capacity: ${cpuAllocatableCapacity || "N/A"}`, `Capacity: ${cpuCapacity || "N/A"}` ] }; @@ -104,7 +105,7 @@ export const ClusterPieCharts = observer(() => { { data: [ memoryUsage, - memoryUsage ? memoryCapacity - memoryUsage : 1, + memoryUsage ? memoryAllocatableCapacity - memoryUsage : 1, ], backgroundColor: [ "#c93dce", @@ -116,7 +117,7 @@ export const ClusterPieCharts = observer(() => { { data: [ memoryRequests, - memoryRequests ? memoryCapacity - memoryRequests : 1, + memoryRequests ? memoryAllocatableCapacity - memoryRequests : 1, ], backgroundColor: [ "#4caf50", @@ -128,7 +129,7 @@ export const ClusterPieCharts = observer(() => { { data: [ memoryLimits, - memoryLimitsOverload ? 0 : memoryCapacity - memoryLimits, + memoryLimitsOverload ? 0 : memoryAllocatableCapacity - memoryLimits, ], backgroundColor: [ "#3d90ce", @@ -142,7 +143,8 @@ export const ClusterPieCharts = observer(() => { `Usage: ${bytesToUnits(memoryUsage)}`, `Requests: ${bytesToUnits(memoryRequests)}`, `Limits: ${bytesToUnits(memoryLimits)}`, - `Capacity: ${bytesToUnits(memoryCapacity)}`, + `Allocatable Capacity: ${bytesToUnits(memoryAllocatableCapacity)}`, + `Capacity: ${bytesToUnits(memoryCapacity)}` ] }; const podsData: ChartData = { @@ -150,7 +152,7 @@ export const ClusterPieCharts = observer(() => { { data: [ podUsage, - podUsage ? podCapacity - podUsage : 1, + podUsage ? podAllocatableCapacity - podUsage : 1, ], backgroundColor: [ "#4caf50", @@ -162,7 +164,7 @@ export const ClusterPieCharts = observer(() => { ], labels: [ `Usage: ${podUsage || 0}`, - `Capacity: ${podCapacity}`, + `Capacity: ${podAllocatableCapacity}`, ] }; @@ -172,7 +174,13 @@ export const ClusterPieCharts = observer(() => { {cpuLimitsOverload && renderLimitWarning()} @@ -180,7 +188,13 @@ export const ClusterPieCharts = observer(() => { {memoryLimitsOverload && renderLimitWarning()} diff --git a/src/renderer/components/+nodes/node-charts.tsx b/src/renderer/components/+nodes/node-charts.tsx index 664da5a19b..60d6e08842 100644 --- a/src/renderer/components/+nodes/node-charts.tsx +++ b/src/renderer/components/+nodes/node-charts.tsx @@ -47,11 +47,14 @@ export const NodeCharts = observer(() => { const { memoryUsage, + workloadMemoryUsage, memoryRequests, memoryCapacity, + memoryAllocatableCapacity, cpuUsage, cpuRequests, cpuCapacity, + cpuAllocatableCapacity, podUsage, podCapacity, fsSize, @@ -75,6 +78,13 @@ export const NodeCharts = observer(() => { borderColor: "#30b24d", data: cpuRequests.map(([x, y]) => ({ x, y })) }, + { + id: `${id}-cpuAllocatableCapacity`, + label: `Allocatable Capacity`, + tooltip: `CPU allocatable capacity`, + borderColor: "#032b4d", + data: cpuAllocatableCapacity.map(([x, y]) => ({ x, y })) + }, { id: `${id}-cpuCapacity`, label: `Capacity`, @@ -92,6 +102,13 @@ export const NodeCharts = observer(() => { borderColor: "#c93dce", data: memoryUsage.map(([x, y]) => ({ x, y })) }, + { + id: `${id}-workloadMemoryUsage`, + label: `Workload Memory Usage`, + tooltip: `Workload memory usage`, + borderColor: "#9cd3ce", + data: workloadMemoryUsage.map(([x, y]) => ({ x, y })) + }, { id: "memoryRequests", label: `Requests`, @@ -99,6 +116,13 @@ export const NodeCharts = observer(() => { borderColor: "#30b24d", data: memoryRequests.map(([x, y]) => ({ x, y })) }, + { + id: `${id}-memoryAllocatableCapacity`, + label: `Allocatable Capacity`, + tooltip: `Memory allocatable capacity`, + borderColor: "#032b4d", + data: memoryAllocatableCapacity.map(([x, y]) => ({ x, y })) + }, { id: `${id}-memoryCapacity`, label: `Capacity`, @@ -148,7 +172,7 @@ export const NodeCharts = observer(() => { yAxes: [{ ticks: { callback: value => value - } + } }] }, tooltips: { diff --git a/src/renderer/components/+nodes/nodes.tsx b/src/renderer/components/+nodes/nodes.tsx index 2b774f3618..abd389d6e7 100644 --- a/src/renderer/components/+nodes/nodes.tsx +++ b/src/renderer/components/+nodes/nodes.tsx @@ -97,7 +97,7 @@ export class Nodes extends React.Component { } renderMemoryUsage(node: Node) { - const metrics = nodesStore.getLastMetricValues(node, ["memoryUsage", "memoryCapacity"]); + const metrics = nodesStore.getLastMetricValues(node, ["workloadMemoryUsage", "memoryAllocatableCapacity"]); if (!metrics || !metrics[1]) return ; const usage = metrics[0]; From 631013d9b7ed45d43aaf9af221dde77eea1a74a0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Jun 2021 14:57:35 -0400 Subject: [PATCH 24/70] Bump proper-lockfile from 4.1.1 to 4.1.2 (#3047) Bumps [proper-lockfile](https://github.com/moxystudio/node-proper-lockfile) from 4.1.1 to 4.1.2. - [Release notes](https://github.com/moxystudio/node-proper-lockfile/releases) - [Changelog](https://github.com/moxystudio/node-proper-lockfile/blob/master/CHANGELOG.md) - [Commits](https://github.com/moxystudio/node-proper-lockfile/compare/v4.1.1...v4.1.2) --- updated-dependencies: - dependency-name: proper-lockfile dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/package.json b/package.json index ba4c5d4254..6e9b803cea 100644 --- a/package.json +++ b/package.json @@ -222,7 +222,7 @@ "openid-client": "^3.15.2", "p-limit": "^3.1.0", "path-to-regexp": "^6.1.0", - "proper-lockfile": "^4.1.1", + "proper-lockfile": "^4.1.2", "react": "^17.0.1", "react-dom": "^17.0.1", "react-router": "^5.2.0", diff --git a/yarn.lock b/yarn.lock index 100f25bacf..55a26fbecb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6788,12 +6788,7 @@ got@^9.6.0: to-readable-stream "^1.0.0" url-parse-lax "^3.0.0" -graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.2.4: - version "4.2.4" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" - integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== - -graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2: +graceful-fs@^4.1.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.2, graceful-fs@^4.2.4: version "4.2.6" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee" integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ== @@ -11640,12 +11635,12 @@ proper-lockfile@^1.2.0: graceful-fs "^4.1.2" retry "^0.10.0" -proper-lockfile@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.1.tgz#284cf9db9e30a90e647afad69deb7cb06881262c" - integrity sha512-1w6rxXodisVpn7QYvLk706mzprPTAPCYAqxMvctmPN3ekuRk/kuGkGc82pangZiAt4R3lwSuUzheTTn0/Yb7Zg== +proper-lockfile@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/proper-lockfile/-/proper-lockfile-4.1.2.tgz#c8b9de2af6b2f1601067f98e01ac66baa223141f" + integrity sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA== dependencies: - graceful-fs "^4.1.11" + graceful-fs "^4.2.4" retry "^0.12.0" signal-exit "^3.0.2" From f6862fc72da8898a84f8dd1f9d373a9d75f62841 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 18 Jun 2021 14:57:52 -0400 Subject: [PATCH 25/70] Bump typeface-roboto from 0.0.75 to 1.1.13 (#3003) Bumps [typeface-roboto](https://github.com/KyleAMathews/typefaces) from 0.0.75 to 1.1.13. - [Release notes](https://github.com/KyleAMathews/typefaces/releases) - [Commits](https://github.com/KyleAMathews/typefaces/compare/v0.0.75...v1.1.13) --- updated-dependencies: - dependency-name: typeface-roboto dependency-type: direct:development update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 6e9b803cea..7a71c0dd8e 100644 --- a/package.json +++ b/package.json @@ -367,7 +367,7 @@ "typed-emitter": "^1.3.1", "typedoc": "0.21.0-beta.2", "typedoc-plugin-markdown": "^3.9.0", - "typeface-roboto": "^0.0.75", + "typeface-roboto": "^1.1.13", "typescript": "^4.3.2", "typescript-plugin-css-modules": "^3.2.0", "url-loader": "^4.1.0", diff --git a/yarn.lock b/yarn.lock index 55a26fbecb..e2ad61cc84 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14359,10 +14359,10 @@ typedoc@0.21.0-beta.2: shiki "^0.9.3" typedoc-default-themes "^0.12.10" -typeface-roboto@^0.0.75: - version "0.0.75" - resolved "https://registry.yarnpkg.com/typeface-roboto/-/typeface-roboto-0.0.75.tgz#98d5ba35ec234bbc7172374c8297277099cc712b" - integrity sha512-VrR/IiH00Z1tFP4vDGfwZ1esNqTiDMchBEXYY9kilT6wRGgFoCAlgkEUMHb1E3mB0FsfZhv756IF0+R+SFPfdg== +typeface-roboto@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/typeface-roboto/-/typeface-roboto-1.1.13.tgz#9c4517cb91e311706c74823e857b4bac9a764ae5" + integrity sha512-YXvbd3a1QTREoD+FJoEkl0VQNJoEjewR2H11IjVv4bp6ahuIcw0yyw/3udC4vJkHw3T3cUh85FTg8eWef3pSaw== typescript-plugin-css-modules@^3.2.0: version "3.2.0" From fd474fcfa32c0ccabd06eea18832c85beec34124 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 18 Jun 2021 15:08:46 -0400 Subject: [PATCH 26/70] Depedabot config should be in .github/ (#3113) Signed-off-by: Sebastian Malton --- .dependabot/config.yml | 21 --------------------- .github/dependabot.yml | 20 ++++++++++++++++---- 2 files changed, 16 insertions(+), 25 deletions(-) delete mode 100644 .dependabot/config.yml diff --git a/.dependabot/config.yml b/.dependabot/config.yml deleted file mode 100644 index 10842da386..0000000000 --- a/.dependabot/config.yml +++ /dev/null @@ -1,21 +0,0 @@ -# See https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates -# for config options - -version: 2 -updates: - - package-ecosystem: "npm" - directory: "/" - schedule: - interval: "daily" - open-pull-requests-limit: 4 - reviewers: - - "lensapp/lens-maintainers" - labels: - - "dependencies" - versioning-strategy: - lockfile-only: false - increase: true - ignore: - - dependency-name: "*" - update-types: - - version-update:semver-major diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9770f52991..10842da386 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,9 +1,21 @@ -# Please see the documentation for all configuration options: -# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates +# See https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates +# for config options version: 2 updates: - - package-ecosystem: "npm" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "npm" + directory: "/" schedule: interval: "daily" + open-pull-requests-limit: 4 + reviewers: + - "lensapp/lens-maintainers" + labels: + - "dependencies" + versioning-strategy: + lockfile-only: false + increase: true + ignore: + - dependency-name: "*" + update-types: + - version-update:semver-major From e30381f1ee6ce527a0e406139a6ce383fed40d6e Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 18 Jun 2021 15:10:45 -0400 Subject: [PATCH 27/70] Fix dependabot.yml Signed-off-by: Sebastian Malton --- .github/dependabot.yml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 10842da386..f0c6017c78 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,18 +3,16 @@ version: 2 updates: - - package-ecosystem: "npm" - directory: "/" + - package-ecosystem: npm + directory: / schedule: - interval: "daily" + interval: daily open-pull-requests-limit: 4 reviewers: - - "lensapp/lens-maintainers" + - lensapp/lens-maintainers labels: - - "dependencies" - versioning-strategy: - lockfile-only: false - increase: true + - dependencies + versioning-strategy: increase ignore: - dependency-name: "*" update-types: From adf1251bbd9cef1078ecd318c8d27ccf9c239599 Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Fri, 18 Jun 2021 15:12:12 -0400 Subject: [PATCH 28/70] Fix dependabot.yml reviewers Signed-off-by: Sebastian Malton --- .github/dependabot.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f0c6017c78..822722ed6c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,7 +9,7 @@ updates: interval: daily open-pull-requests-limit: 4 reviewers: - - lensapp/lens-maintainers + - lensapp/lens-ide labels: - dependencies versioning-strategy: increase From 48e278c71b58f8207a36f5fe206ffac3fa4e0cb9 Mon Sep 17 00:00:00 2001 From: Jari Kolehmainen Date: Mon, 21 Jun 2021 10:50:40 +0300 Subject: [PATCH 29/70] Rename PageLayout to SettingLayout (#3123) * rename PageLayout to SettingLayout Signed-off-by: Jari Kolehmainen * expose to extensions Signed-off-by: Jari Kolehmainen * fix Signed-off-by: Jari Kolehmainen * fix Signed-off-by: Jari Kolehmainen --- src/extensions/renderer-api/components.ts | 1 + .../components/+add-cluster/add-cluster.tsx | 6 +- .../+entity-settings/entity-settings.tsx | 7 +- .../components/+extensions/extensions.tsx | 8 +- .../components/+preferences/preferences.tsx | 7 +- .../components/layout/page-layout.tsx | 99 ++------------- .../{page-layout.scss => setting-layout.scss} | 4 +- .../components/layout/setting-layout.tsx | 116 ++++++++++++++++++ 8 files changed, 138 insertions(+), 110 deletions(-) rename src/renderer/components/layout/{page-layout.scss => setting-layout.scss} (99%) create mode 100644 src/renderer/components/layout/setting-layout.tsx diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 49b3ef933c..96eaeb6db4 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -20,6 +20,7 @@ */ // layouts +export * from "../../renderer/components/layout/setting-layout"; export * from "../../renderer/components/layout/page-layout"; export * from "../../renderer/components/layout/wizard-layout"; export * from "../../renderer/components/layout/tab-layout"; diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index b04c9a2989..164106b517 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -38,8 +38,8 @@ import { navigate } from "../../navigation"; import { iter } from "../../utils"; import { AceEditor } from "../ace-editor"; import { Button } from "../button"; -import { PageLayout } from "../layout/page-layout"; import { Notifications } from "../notifications"; +import { SettingLayout } from "../layout/setting-layout"; interface Option { config: KubeConfig; @@ -108,7 +108,7 @@ export class AddCluster extends React.Component { render() { return ( - +

Add Clusters from Kubeconfig

Clusters added here are not merged into the ~/.kube/config file. @@ -144,7 +144,7 @@ export class AddCluster extends React.Component { tooltipOverrideDisabled /> - + ); } } diff --git a/src/renderer/components/+entity-settings/entity-settings.tsx b/src/renderer/components/+entity-settings/entity-settings.tsx index 16c79412cf..de38e6df2a 100644 --- a/src/renderer/components/+entity-settings/entity-settings.tsx +++ b/src/renderer/components/+entity-settings/entity-settings.tsx @@ -25,7 +25,6 @@ import React from "react"; import { observable, makeObservable } from "mobx"; import type { RouteComponentProps } from "react-router"; import { observer } from "mobx-react"; -import { PageLayout } from "../layout/page-layout"; import { navigation } from "../../navigation"; import { Tabs, Tab } from "../tabs"; import type { CatalogEntity } from "../../api/catalog-entity"; @@ -33,6 +32,7 @@ import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { EntitySettingRegistry } from "../../../extensions/registries"; import type { EntitySettingsRouteParams } from "../../../common/routes"; import { groupBy } from "lodash"; +import { SettingLayout } from "../layout/setting-layout"; interface Props extends RouteComponentProps { } @@ -120,10 +120,9 @@ export class EntitySettings extends React.Component { const activeSetting = this.menuItems.find((setting) => setting.id === this.activeTab); return ( -

@@ -132,7 +131,7 @@ export class EntitySettings extends React.Component {
- + ); } } diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 831ec6c1cd..6a2524da31 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -39,12 +39,12 @@ import logger from "../../../main/logger"; import { Button } from "../button"; import { ConfirmDialog } from "../confirm-dialog"; import { DropFileInput, InputValidators } from "../input"; -import { PageLayout } from "../layout/page-layout"; import { Notifications } from "../notifications"; import { ExtensionInstallationState, ExtensionInstallationStateStore } from "./extension-install.store"; import { Install } from "./install"; import { InstalledExtensions } from "./installed-extensions"; import { Notice } from "./notice"; +import { SettingLayout } from "../layout/setting-layout"; function getMessageFromError(error: any): string { if (!error || typeof error !== "object") { @@ -317,7 +317,7 @@ export async function attemptInstallByInfo({ name, version, requireConfirmation version = json["dist-tags"][version]; } else { Notifications.error(

The {name} extension does not have a version or tag {version}.

); - + return disposer(); } } @@ -510,7 +510,7 @@ export class Extensions extends React.Component { return ( - +

Extensions

@@ -533,7 +533,7 @@ export class Extensions extends React.Component { uninstall={confirmUninstallExtension} />
-
+
); } diff --git a/src/renderer/components/+preferences/preferences.tsx b/src/renderer/components/+preferences/preferences.tsx index 0a85c6369d..9bf82f9e4f 100644 --- a/src/renderer/components/+preferences/preferences.tsx +++ b/src/renderer/components/+preferences/preferences.tsx @@ -31,7 +31,6 @@ import { AppPreferenceRegistry, RegisteredAppPreference } from "../../../extensi import { UserStore } from "../../../common/user-store"; import { ThemeStore } from "../../theme.store"; import { Input } from "../input"; -import { PageLayout } from "../layout/page-layout"; import { SubTitle } from "../layout/sub-title"; import { Select, SelectOption } from "../select"; import { HelmCharts } from "./helm-charts"; @@ -40,6 +39,7 @@ import { navigation } from "../../navigation"; import { Tab, Tabs } from "../tabs"; import { FormSwitch, Switcher } from "../switch"; import { KubeconfigSyncs } from "./kubeconfig-syncs"; +import { SettingLayout } from "../layout/setting-layout"; enum Pages { Application = "application", @@ -136,8 +136,7 @@ export class Preferences extends React.Component { ); return ( - !e.showInPreferencesTab).map(this.renderExtension)} )} - + ); } } diff --git a/src/renderer/components/layout/page-layout.tsx b/src/renderer/components/layout/page-layout.tsx index 6978cdc218..a393603885 100644 --- a/src/renderer/components/layout/page-layout.tsx +++ b/src/renderer/components/layout/page-layout.tsx @@ -19,96 +19,11 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./page-layout.scss"; +import { SettingLayout } from "./setting-layout"; -import React from "react"; -import { observer } from "mobx-react"; -import { boundMethod, cssNames, IClassName } from "../../utils"; -import { navigation } from "../../navigation"; -import { Icon } from "../icon"; - -export interface PageLayoutProps extends React.DOMAttributes { - className?: IClassName; - contentClass?: IClassName; - provideBackButtonNavigation?: boolean; - contentGaps?: boolean; - showOnTop?: boolean; // covers whole app view - navigation?: React.ReactNode; - back?: (evt: React.MouseEvent | KeyboardEvent) => void; -} - -const defaultProps: Partial = { - provideBackButtonNavigation: true, - contentGaps: true, -}; - -@observer -export class PageLayout extends React.Component { - static defaultProps = defaultProps as object; - - @boundMethod - back(evt?: React.MouseEvent | KeyboardEvent) { - if (this.props.back) { - this.props.back(evt); - } else { - navigation.goBack(); - } - } - - async componentDidMount() { - window.addEventListener("keydown", this.onEscapeKey); - } - - componentWillUnmount() { - window.removeEventListener("keydown", this.onEscapeKey); - } - - onEscapeKey = (evt: KeyboardEvent) => { - if (!this.props.provideBackButtonNavigation) { - return; - } - - if (evt.code === "Escape") { - evt.stopPropagation(); - this.back(evt); - } - }; - - render() { - const { - contentClass, provideBackButtonNavigation, - contentGaps, showOnTop, navigation, children, ...elemProps - } = this.props; - const className = cssNames("PageLayout", { showOnTop, showNavigation: navigation }, this.props.className); - - return ( -
- { navigation && ( - - )} -
-
- {children} -
-
- { this.props.provideBackButtonNavigation && ( -
-
- -
- - -
- )} -
-
-
- ); - } -} +/** + * PageLayout is deprecated. See MainLayout & SettingLayout + * + * @deprecated + */ +export class PageLayout extends SettingLayout {} diff --git a/src/renderer/components/layout/page-layout.scss b/src/renderer/components/layout/setting-layout.scss similarity index 99% rename from src/renderer/components/layout/page-layout.scss rename to src/renderer/components/layout/setting-layout.scss index 8b2c30494e..198e7c9cb6 100644 --- a/src/renderer/components/layout/page-layout.scss +++ b/src/renderer/components/layout/setting-layout.scss @@ -19,12 +19,10 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -.PageLayout { +.SettingLayout { --width: 75%; --nav-width: 180px; --nav-column-width: 30vw; - - position: relative; width: 100%; height: 100%; display: grid !important; diff --git a/src/renderer/components/layout/setting-layout.tsx b/src/renderer/components/layout/setting-layout.tsx new file mode 100644 index 0000000000..45912f262c --- /dev/null +++ b/src/renderer/components/layout/setting-layout.tsx @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import "./setting-layout.scss"; + +import React from "react"; +import { observer } from "mobx-react"; +import { boundMethod, cssNames, IClassName } from "../../utils"; +import { navigation } from "../../navigation"; +import { Icon } from "../icon"; + +export interface SettingLayoutProps extends React.DOMAttributes { + className?: IClassName; + contentClass?: IClassName; + provideBackButtonNavigation?: boolean; + contentGaps?: boolean; + navigation?: React.ReactNode; + back?: (evt: React.MouseEvent | KeyboardEvent) => void; +} + +const defaultProps: Partial = { + provideBackButtonNavigation: true, + contentGaps: true, +}; + +/** + * Layout for settings like pages with navigation + */ +@observer +export class SettingLayout extends React.Component { + static defaultProps = defaultProps as object; + + @boundMethod + back(evt?: React.MouseEvent | KeyboardEvent) { + if (this.props.back) { + this.props.back(evt); + } else { + navigation.goBack(); + } + } + + async componentDidMount() { + window.addEventListener("keydown", this.onEscapeKey); + } + + componentWillUnmount() { + window.removeEventListener("keydown", this.onEscapeKey); + } + + onEscapeKey = (evt: KeyboardEvent) => { + if (!this.props.provideBackButtonNavigation) { + return; + } + + if (evt.code === "Escape") { + evt.stopPropagation(); + this.back(evt); + } + }; + + render() { + const { + contentClass, provideBackButtonNavigation, + contentGaps, navigation, children, ...elemProps + } = this.props; + const className = cssNames("SettingLayout", "showOnTop", { showNavigation: navigation }, this.props.className); + + return ( +
+ { navigation && ( + + )} +
+
+ {children} +
+
+ { this.props.provideBackButtonNavigation && ( +
+
+ +
+ + +
+ )} +
+
+
+ ); + } +} From d9ceb8fa082395336463ba9981cd85735176f444 Mon Sep 17 00:00:00 2001 From: Alex Andreev Date: Mon, 21 Jun 2021 10:59:54 +0300 Subject: [PATCH 30/70] General catalog category (#3106) * Adding General Entities and General Category Signed-off-by: Alex Andreev * Usign material icons for general entities Signed-off-by: Alex Andreev * Setting background for hotbar icon explicitly Signed-off-by: Alex Andreev * Adding Catalog initially to first hotbar Signed-off-by: Alex Andreev * Tuning hotbar store tests Signed-off-by: Alex Andreev * Changing type from GeneralEntity to General Signed-off-by: Alex Andreev * Search for catalog hotbar item in tests Signed-off-by: Alex Andreev * Removing Catalog link from bottom bar Signed-off-by: Alex Andreev * Waiting for hotbar catalog entity Signed-off-by: Alex Andreev * Opening cluster list by data-testId Signed-off-by: Alex Andreev * Replacing types on interfaces Signed-off-by: Alex Andreev * Fixing integration tests Signed-off-by: Alex Andreev * Adding general entities throught initializers Signed-off-by: Alex Andreev * Removing icon refs from CatalogEntitySpec Signed-off-by: Alex Andreev --- integration/__tests__/cluster-pages.tests.ts | 1 - integration/helpers/minikube.ts | 1 + integration/helpers/utils.ts | 4 +- src/common/__tests__/hotbar-store.test.ts | 30 +++++--- src/common/catalog-entities/general.ts | 76 +++++++++++++++++++ src/common/catalog-entities/index.ts | 1 + .../catalog-entities/kubernetes-cluster.ts | 13 ++-- src/common/catalog/catalog-entity.ts | 1 + src/common/hotbar-store.ts | 13 +++- .../registries/welcome-menu-registry.ts | 1 + src/main/catalog-sources/general.ts | 72 ++++++++++++++++++ src/main/catalog-sources/index.ts | 1 + src/main/cluster-manager.ts | 5 +- src/main/index.ts | 3 +- src/main/initializers/general-entities.ts | 22 ++++++ src/main/initializers/index.ts | 1 + .../+catalog/catalog-entity-details.tsx | 4 +- src/renderer/components/+catalog/catalog.tsx | 7 +- src/renderer/components/+welcome/welcome.tsx | 2 +- src/renderer/components/avatar/avatar.tsx | 7 +- .../cluster-manager/bottom-bar.scss | 12 --- .../components/cluster-manager/bottom-bar.tsx | 7 -- .../components/cluster-icon-settings.tsx | 2 +- .../components/hotbar/hotbar-entity-icon.tsx | 4 +- .../components/hotbar/hotbar-icon.scss | 8 +- .../components/hotbar/hotbar-icon.tsx | 33 ++++---- .../initializers/welcome-menu-registry.ts | 3 +- 27 files changed, 261 insertions(+), 73 deletions(-) create mode 100644 src/common/catalog-entities/general.ts create mode 100644 src/main/catalog-sources/general.ts create mode 100644 src/main/initializers/general-entities.ts diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 24ad38dcfc..9fa04500b8 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -45,7 +45,6 @@ describe("Lens cluster pages", () => { utils.describeIf(ready)("test common pages", () => { let clusterAdded = false; const addCluster = async () => { - await app.client.waitUntilTextExists("div", "Catalog"); await waitForMinikubeDashboard(app); await app.client.click('a[href="/nodes"]'); await app.client.waitUntilTextExists("div.TableCell", "Ready"); diff --git a/integration/helpers/minikube.ts b/integration/helpers/minikube.ts index bbe411047b..8698bd6be2 100644 --- a/integration/helpers/minikube.ts +++ b/integration/helpers/minikube.ts @@ -65,6 +65,7 @@ export async function waitForMinikubeDashboard(app: Application) { await app.client.waitUntilTextExists("div.TableCell", "minikube"); await app.client.click("div.TableRow"); await app.client.waitUntilTextExists("div.drawer-title-text", "KubernetesCluster: minikube"); + await app.client.waitForExist("div.EntityIcon div.HotbarIcon div div.MuiAvatar-root"); await app.client.click("div.EntityIcon div.HotbarIcon div div.MuiAvatar-root"); await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); await app.client.waitForExist(`iframe[name="minikube"]`); diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index 9ce33b21c3..b509b3d342 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -99,8 +99,8 @@ export async function appStart() { } export async function showCatalog(app: Application) { - await app.client.waitUntilTextExists("[data-test-id=catalog-link]", "Catalog"); - await app.client.click("[data-test-id=catalog-link]"); + await app.client.waitForExist("#hotbarIcon-catalog-entity .Icon"); + await app.client.click("#hotbarIcon-catalog-entity .Icon"); } type AsyncPidGetter = () => Promise; diff --git a/src/common/__tests__/hotbar-store.test.ts b/src/common/__tests__/hotbar-store.test.ts index 51002fee23..9f54e8731d 100644 --- a/src/common/__tests__/hotbar-store.test.ts +++ b/src/common/__tests__/hotbar-store.test.ts @@ -156,6 +156,13 @@ describe("HotbarStore", () => { expect(hotbarStore.getActive().items.length).toEqual(12); }); + it("initially adds catalog entity as first item", () => { + const hotbarStore = HotbarStore.createInstance(); + + hotbarStore.load(); + expect(hotbarStore.getActive().items[0].entity.name).toEqual("Catalog"); + }); + it("adds items", () => { const hotbarStore = HotbarStore.createInstance(); @@ -163,7 +170,7 @@ describe("HotbarStore", () => { hotbarStore.addToHotbar(testCluster); const items = hotbarStore.getActive().items.filter(Boolean); - expect(items.length).toEqual(1); + expect(items.length).toEqual(2); }); it("removes items", () => { @@ -172,6 +179,7 @@ describe("HotbarStore", () => { hotbarStore.load(); hotbarStore.addToHotbar(testCluster); hotbarStore.removeFromHotbar("test"); + hotbarStore.removeFromHotbar("catalog-entity"); const items = hotbarStore.getActive().items.filter(Boolean); expect(items.length).toEqual(0); @@ -185,7 +193,7 @@ describe("HotbarStore", () => { hotbarStore.removeFromHotbar("invalid uid"); const items = hotbarStore.getActive().items.filter(Boolean); - expect(items.length).toEqual(1); + expect(items.length).toEqual(2); }); it("moves item to empty cell", () => { @@ -196,12 +204,12 @@ describe("HotbarStore", () => { hotbarStore.addToHotbar(minikubeCluster); hotbarStore.addToHotbar(awsCluster); - expect(hotbarStore.getActive().items[5]).toBeNull(); + expect(hotbarStore.getActive().items[6]).toBeNull(); hotbarStore.restackItems(1, 5); expect(hotbarStore.getActive().items[5]).toBeTruthy(); - expect(hotbarStore.getActive().items[5].entity.uid).toEqual("minikube"); + expect(hotbarStore.getActive().items[5].entity.uid).toEqual("test"); }); it("moves items down", () => { @@ -212,12 +220,12 @@ describe("HotbarStore", () => { hotbarStore.addToHotbar(minikubeCluster); hotbarStore.addToHotbar(awsCluster); - // aws -> test - hotbarStore.restackItems(2, 0); + // aws -> catalog + hotbarStore.restackItems(3, 0); const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); - expect(items.slice(0, 4)).toEqual(["aws", "test", "minikube", null]); + expect(items.slice(0, 4)).toEqual(["aws", "catalog-entity", "test", "minikube"]); }); it("moves items up", () => { @@ -229,11 +237,11 @@ describe("HotbarStore", () => { hotbarStore.addToHotbar(awsCluster); // test -> aws - hotbarStore.restackItems(0, 2); + hotbarStore.restackItems(1, 3); const items = hotbarStore.getActive().items.map(item => item?.entity.uid || null); - expect(items.slice(0, 4)).toEqual(["minikube", "aws", "test", null]); + expect(items.slice(0, 4)).toEqual(["catalog-entity", "minikube", "aws", "test"]); }); it("does nothing when item moved to same cell", () => { @@ -241,9 +249,9 @@ describe("HotbarStore", () => { hotbarStore.load(); hotbarStore.addToHotbar(testCluster); - hotbarStore.restackItems(0, 0); + hotbarStore.restackItems(1, 1); - expect(hotbarStore.getActive().items[0].entity.uid).toEqual("test"); + expect(hotbarStore.getActive().items[1].entity.uid).toEqual("test"); }); it("new items takes first empty cell", () => { diff --git a/src/common/catalog-entities/general.ts b/src/common/catalog-entities/general.ts new file mode 100644 index 0000000000..5d13eeabee --- /dev/null +++ b/src/common/catalog-entities/general.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { navigate } from "../../renderer/navigation"; +import { CatalogCategory, CatalogEntity, CatalogEntityMetadata, CatalogEntitySpec, CatalogEntityStatus } from "../catalog"; +import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; + +interface GeneralEntitySpec extends CatalogEntitySpec { + path: string; + icon?: { + material?: string; + background?: string; + }; +} + +export class GeneralEntity extends CatalogEntity { + public readonly apiVersion = "entity.k8slens.dev/v1alpha1"; + public readonly kind = "General"; + + async onRun() { + navigate(this.spec.path); + } + + public onSettingsOpen(): void { + return; + } + + public onDetailsOpen(): void { + return; + } + + public onContextMenuOpen(): void { + return; + } +} + +export class GeneralCategory extends CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + public metadata = { + name: "General", + icon: "settings" + }; + public spec = { + group: "entity.k8slens.dev", + versions: [ + { + name: "v1alpha1", + entityClass: GeneralEntity + } + ], + names: { + kind: "General" + } + }; +} + +catalogCategoryRegistry.add(new GeneralCategory()); diff --git a/src/common/catalog-entities/index.ts b/src/common/catalog-entities/index.ts index 621383c6ec..ee8a41b552 100644 --- a/src/common/catalog-entities/index.ts +++ b/src/common/catalog-entities/index.ts @@ -19,5 +19,6 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ +export * from "./general"; export * from "./kubernetes-cluster"; export * from "./web-link"; diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 1f28f3453f..322675b23a 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -27,9 +27,10 @@ import { requestMain } from "../ipc"; import { CatalogCategory, CatalogCategorySpec } from "../catalog"; import { addClusterURL } from "../routes"; import { app } from "electron"; +import type { CatalogEntitySpec } from "../catalog/catalog-entity"; import { HotbarStore } from "../hotbar-store"; -export type KubernetesClusterPrometheusMetrics = { +export interface KubernetesClusterPrometheusMetrics { address?: { namespace: string; service: string; @@ -37,17 +38,19 @@ export type KubernetesClusterPrometheusMetrics = { prefix: string; }; type?: string; -}; + icon?: { + src?: string; + }; +} -export type KubernetesClusterSpec = { +export interface KubernetesClusterSpec extends CatalogEntitySpec { kubeconfigPath: string; kubeconfigContext: string; - iconData?: string; metrics?: { source: string; prometheus?: KubernetesClusterPrometheusMetrics; } -}; +} export interface KubernetesClusterStatus extends CatalogEntityStatus { phase: "connected" | "disconnected" | "deleting"; diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index 8b8d04f6cb..bc3e360a7b 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -149,6 +149,7 @@ export interface CatalogEntityAddMenuContext { export type CatalogEntitySpec = Record; + export interface CatalogEntityData< Metadata extends CatalogEntityMetadata = CatalogEntityMetadata, Status extends CatalogEntityStatus = CatalogEntityStatus, diff --git a/src/common/hotbar-store.ts b/src/common/hotbar-store.ts index 6d15ace11e..ab41583b0a 100644 --- a/src/common/hotbar-store.ts +++ b/src/common/hotbar-store.ts @@ -26,6 +26,7 @@ import * as uuid from "uuid"; import isNull from "lodash/isNull"; import { toJS } from "./utils"; import { CatalogEntity } from "./catalog"; +import { catalogEntity } from "../main/catalog-sources/general"; export interface HotbarItem { entity: { @@ -91,6 +92,16 @@ export class HotbarStore extends BaseStore { return this.hotbarIndex(this.activeHotbarId); } + get defaultHotbarInitialItems() { + const { metadata: { uid, name, source } } = catalogEntity; + const initialItem = { entity: { uid, name, source }}; + + return [ + initialItem, + ...Array.from(Array(defaultHotbarCells - 1).fill(null)) + ]; + } + get initialItems() { return [...Array.from(Array(defaultHotbarCells).fill(null))]; } @@ -100,7 +111,7 @@ export class HotbarStore extends BaseStore { this.hotbars = [{ id: uuid.v4(), name: "Default", - items: this.initialItems, + items: this.defaultHotbarInitialItems, }]; } else { this.hotbars = data.hotbars; diff --git a/src/extensions/registries/welcome-menu-registry.ts b/src/extensions/registries/welcome-menu-registry.ts index 7092028459..681de6c8f3 100644 --- a/src/extensions/registries/welcome-menu-registry.ts +++ b/src/extensions/registries/welcome-menu-registry.ts @@ -25,6 +25,7 @@ export interface WelcomeMenuRegistration { title: string | (() => string); icon: string; click: () => void | Promise; + testId?: string; } export class WelcomeMenuRegistry extends BaseRegistry {} diff --git a/src/main/catalog-sources/general.ts b/src/main/catalog-sources/general.ts new file mode 100644 index 0000000000..9f64f46b6d --- /dev/null +++ b/src/main/catalog-sources/general.ts @@ -0,0 +1,72 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { observable } from "mobx"; +import { GeneralEntity } from "../../common/catalog-entities/general"; +import { catalogURL, preferencesURL } from "../../common/routes"; +import { catalogEntityRegistry } from "../catalog"; + +export const catalogEntity = new GeneralEntity({ + metadata: { + uid: "catalog-entity", + name: "Catalog", + source: "app", + labels: {} + }, + spec: { + path: catalogURL(), + icon: { + material: "view_list", + background: "#3d90ce" + } + }, + status: { + phase: "active", + } +}); + +const preferencesEntity = new GeneralEntity({ + metadata: { + uid: "preferences-entity", + name: "Preferences", + source: "app", + labels: {} + }, + spec: { + path: preferencesURL(), + icon: { + material: "settings", + background: "#3d90ce" + } + }, + status: { + phase: "active", + } +}); + +const generalEntities = observable([ + catalogEntity, + preferencesEntity +]); + +export function initializeGeneralEntities() { + catalogEntityRegistry.addObservableSource("lens:general", generalEntities); +} diff --git a/src/main/catalog-sources/index.ts b/src/main/catalog-sources/index.ts index 81f0182ca7..17f383185b 100644 --- a/src/main/catalog-sources/index.ts +++ b/src/main/catalog-sources/index.ts @@ -21,3 +21,4 @@ export { initializeWeblinks } from "./weblinks"; export { KubeconfigSyncManager } from "./kubeconfig-sync"; +export { initializeGeneralEntities } from "./general"; diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index 5f2ef9899d..66c9bda601 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -119,7 +119,7 @@ export class ClusterManager extends Singleton { entity.spec.metrics.prometheus = prometheus; } - entity.spec.iconData = cluster.preferences.icon; + entity.spec.icon.src = cluster.preferences.icon; catalogEntityRegistry.items.splice(index, 1, entity); } @@ -220,7 +220,8 @@ export function catalogEntityFromCluster(cluster: Cluster) { }, spec: { kubeconfigPath: cluster.kubeConfigPath, - kubeconfigContext: cluster.contextName + kubeconfigContext: cluster.contextName, + icon: {} }, status: { phase: cluster.disconnected ? "disconnected" : "connected", diff --git a/src/main/index.ts b/src/main/index.ts index dc36de80dc..d9013cf1ba 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -132,7 +132,7 @@ app.on("ready", async () => { handleWsUpgrade, req => ClusterManager.getInstance().getClusterForRequest(req), ); - + ClusterManager.createInstance().init(); KubeconfigSyncManager.createInstance(); @@ -182,6 +182,7 @@ app.on("ready", async () => { ipcMainOn(IpcRendererNavigationEvents.LOADED, () => { cleanup.push(pushCatalogToRenderer(catalogEntityRegistry)); KubeconfigSyncManager.getInstance().startSync(); + initializers.initializeGeneralEntities(); startUpdateChecking(); LensProtocolRouterMain.getInstance().rendererLoaded = true; }); diff --git a/src/main/initializers/general-entities.ts b/src/main/initializers/general-entities.ts new file mode 100644 index 0000000000..b2e39d8a39 --- /dev/null +++ b/src/main/initializers/general-entities.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export { initializeGeneralEntities } from "../catalog-sources"; diff --git a/src/main/initializers/index.ts b/src/main/initializers/index.ts index 37ff00bd77..6cd9330bac 100644 --- a/src/main/initializers/index.ts +++ b/src/main/initializers/index.ts @@ -24,3 +24,4 @@ export * from "./metrics-providers"; export * from "./ipc"; export * from "./weblinks"; export * from "./stores"; +export * from "./general-entities"; diff --git a/src/renderer/components/+catalog/catalog-entity-details.tsx b/src/renderer/components/+catalog/catalog-entity-details.tsx index b0df7371a6..1a6f0ab03a 100644 --- a/src/renderer/components/+catalog/catalog-entity-details.tsx +++ b/src/renderer/components/+catalog/catalog-entity-details.tsx @@ -63,7 +63,9 @@ export class CatalogEntityDetails extends Component item.onRun(catalogEntityRunContext)} size={128} /> diff --git a/src/renderer/components/+catalog/catalog.tsx b/src/renderer/components/+catalog/catalog.tsx index a55b2ae0af..747adc97c7 100644 --- a/src/renderer/components/+catalog/catalog.tsx +++ b/src/renderer/components/+catalog/catalog.tsx @@ -169,9 +169,12 @@ export class Catalog extends React.Component { uid={item.getId()} title={item.getName()} source={item.source} - icon={item.entity.spec.iconData} + src={item.entity.spec.icon?.src} + material={item.entity.spec.icon?.material} + background={item.entity.spec.icon?.background} onClick={() => this.onDetails(item)} - size={24} /> + size={24} + /> ); } diff --git a/src/renderer/components/+welcome/welcome.tsx b/src/renderer/components/+welcome/welcome.tsx index 703520fa17..d6f24b076b 100644 --- a/src/renderer/components/+welcome/welcome.tsx +++ b/src/renderer/components/+welcome/welcome.tsx @@ -44,7 +44,7 @@ export class Welcome extends React.Component {