diff --git a/build/set_build_version.ts b/build/set_build_version.ts index df48a1da13..13d46d4ff3 100644 --- a/build/set_build_version.ts +++ b/build/set_build_version.ts @@ -22,6 +22,7 @@ import * as fs from "fs"; import * as path from "path"; import appInfo from "../package.json"; import semver from "semver"; +import fastGlob from "fast-glob"; const packagePath = path.join(__dirname, "../package.json"); const versionInfo = semver.parse(appInfo.version); @@ -41,3 +42,14 @@ if (versionInfo.prerelease) { fs.writeFileSync(packagePath, `${JSON.stringify(appInfo, null, 2)}\n`); + +const extensionManifests = fastGlob.sync(["extensions/*/package.json"]); + +for (const manifestPath of extensionManifests) { + const packagePath = path.join(__dirname, "..", manifestPath); + + import(packagePath).then((packageInfo) => { + packageInfo.default.version = `${versionInfo.raw}.${Date.now()}`; + fs.writeFileSync(packagePath, `${JSON.stringify(packageInfo.default, null, 2)}\n`); + }); +} diff --git a/docs/extensions/capabilities/common-capabilities.md b/docs/extensions/capabilities/common-capabilities.md index 676603ce21..ffb066f72f 100644 --- a/docs/extensions/capabilities/common-capabilities.md +++ b/docs/extensions/capabilities/common-capabilities.md @@ -14,9 +14,9 @@ In order to see logs from this extension, you need to start Lens from the comman This extension can register a custom callback that is executed when an extension is activated (started). ``` javascript -import { LensMainExtension } from "@k8slens/extensions" +import { Main } from "@k8slens/extensions" -export default class ExampleMainExtension extends LensMainExtension { +export default class ExampleMainExtension extends Main.LensExtension { async onActivate() { console.log("hello world") } @@ -28,9 +28,9 @@ export default class ExampleMainExtension extends LensMainExtension { This extension can register a custom callback that is executed when an extension is deactivated (stopped). ``` javascript -import { LensMainExtension } from "@k8slens/extensions" +import { Main } from "@k8slens/extensions" -export default class ExampleMainExtension extends LensMainExtension { +export default class ExampleMainExtension extends Main.LensExtension { async onDeactivate() { console.log("bye bye") } @@ -44,15 +44,15 @@ This extension can register custom app menus that will be displayed on OS native Example: ```typescript -import { LensMainExtension, windowManager } from "@k8slens/extensions" +import { Main } from "@k8slens/extensions" -export default class ExampleMainExtension extends LensMainExtension { +export default class ExampleMainExtension extends Main.LensExtension { appMenus = [ { parentId: "help", label: "Example item", click() { - windowManager.navigate("https://k8slens.dev"); + Main.Navigation.navigate("https://k8slens.dev"); } } ] @@ -69,9 +69,9 @@ In order to see logs from this extension you need to check them via **View** > * This extension can register a custom callback that is executed when an extension is activated (started). ``` javascript -import { LensRendererExtension } from "@k8slens/extensions" +import { Renderer } from "@k8slens/extensions" -export default class ExampleExtension extends LensRendererExtension { +export default class ExampleExtension extends Renderer.LensExtension { async onActivate() { console.log("hello world") } @@ -83,9 +83,9 @@ export default class ExampleExtension extends LensRendererExtension { This extension can register a custom callback that is executed when an extension is deactivated (stopped). ``` javascript -import { LensRendererExtension } from "@k8slens/extensions" +import { Renderer } from "@k8slens/extensions" -export default class ExampleMainExtension extends LensRendererExtension { +export default class ExampleMainExtension extends Renderer.LensExtension { async onDeactivate() { console.log("bye bye") } @@ -99,10 +99,16 @@ The global page is a full-screen page that hides all other content from a window ```typescript import React from "react" -import { Component, LensRendererExtension } from "@k8slens/extensions" +import { Renderer } from "@k8slens/extensions" import { ExamplePage } from "./src/example-page" -export default class ExampleRendererExtension extends LensRendererExtension { +const { + Component: { + Icon, + } +} = Renderer; + +export default class ExampleRendererExtension extends Renderer.LensExtension { globalPages = [ { id: "example", @@ -117,7 +123,7 @@ export default class ExampleRendererExtension extends LensRendererExtension { title: "Example page", // used in icon's tooltip target: { pageId: "example" } components: { - Icon: () => , + Icon: () => , } } ] @@ -131,12 +137,12 @@ It is responsible for storing a state for custom preferences. ```typescript import React from "react" -import { LensRendererExtension } from "@k8slens/extensions" +import { Renderer } from "@k8slens/extensions" import { myCustomPreferencesStore } from "./src/my-custom-preferences-store" import { MyCustomPreferenceHint, MyCustomPreferenceInput } from "./src/my-custom-preference" -export default class ExampleRendererExtension extends LensRendererExtension { +export default class ExampleRendererExtension extends Renderer.LensExtension { appPreferences = [ { title: "My Custom Preference", @@ -156,10 +162,10 @@ These pages are visible in a cluster menu when a cluster is opened. ```typescript import React from "react" -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { ExampleIcon, ExamplePage } from "./src/page" -export default class ExampleExtension extends LensRendererExtension { +export default class ExampleExtension extends Renderer.LensExtension { clusterPages = [ { id: "extension-example", // optional @@ -190,10 +196,10 @@ These features are visible in the "Cluster Settings" page. ```typescript import React from "react" -import { LensRendererExtension } from "@k8slens/extensions" +import { Renderer } from "@k8slens/extensions" import { MyCustomFeature } from "./src/my-custom-feature" -export default class ExampleExtension extends LensRendererExtension { +export default class ExampleExtension extends Renderer.LensExtension { clusterFeatures = [ { title: "My Custom Feature", @@ -219,15 +225,21 @@ This extension can register custom icons and text to a status bar area. ```typescript import React from "react"; -import { Component, LensRendererExtension, Navigation } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; -export default class ExampleExtension extends LensRendererExtension { +const { + Component: { + Icon, + } +} = Renderer; + +export default class ExampleExtension extends Renderer.LensExtension { statusBarItems = [ { components: { Item: (
this.navigate("/example-page")} > - +
) } @@ -243,10 +255,10 @@ This extension can register custom menu items (actions) for specified Kubernetes ```typescript import React from "react" -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { CustomMenuItem, CustomMenuItemProps } from "./src/custom-menu-item" -export default class ExampleExtension extends LensRendererExtension { +export default class ExampleExtension extends Renderer.LensExtension { kubeObjectMenuItems = [ { kind: "Node", @@ -266,10 +278,10 @@ This extension can register custom details (content) for specified Kubernetes ki ```typescript import React from "react" -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { CustomKindDetails, CustomKindDetailsProps } from "./src/custom-kind-details" -export default class ExampleExtension extends LensRendererExtension { +export default class ExampleExtension extends Renderer.LensExtension { kubeObjectDetailItems = [ { kind: "CustomKind", diff --git a/docs/extensions/capabilities/styling.md b/docs/extensions/capabilities/styling.md index 62dbddde1a..152a2e3937 100644 --- a/docs/extensions/capabilities/styling.md +++ b/docs/extensions/capabilities/styling.md @@ -114,14 +114,14 @@ There is a way of detect active theme and its changes in JS. [MobX observer func ```js import React from "react" import { observer } from "mobx-react" -import { App, Component, Theme } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; @observer export class SupportPage extends React.Component { render() { return (
-

Active theme is {Theme.getActiveTheme().name}

+

Active theme is {Renderer.Theme.getActiveTheme().name}

); } diff --git a/docs/extensions/get-started/anatomy.md b/docs/extensions/get-started/anatomy.md index f445e421bf..87b3042189 100644 --- a/docs/extensions/get-started/anatomy.md +++ b/docs/extensions/get-started/anatomy.md @@ -96,11 +96,11 @@ It also registers the `MenuItem` component that displays the `ExampleIcon` React These React components are defined in the additional `./src/page.tsx` file. ``` typescript -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { ExampleIcon, ExamplePage } from "./page" import React from "react" -export default class ExampleExtension extends LensRendererExtension { +export default class ExampleExtension extends Renderer.LensExtension { clusterPages = [ { id: "extension-example", diff --git a/docs/extensions/guides/README.md b/docs/extensions/guides/README.md index 012794a39e..c1c2521f5a 100644 --- a/docs/extensions/guides/README.md +++ b/docs/extensions/guides/README.md @@ -30,9 +30,8 @@ Each guide or code sample includes the following: | Sample | APIs | | ----- | ----- | -[hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | -[minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension
Store.ClusterStore
Store.workspaceStore | -[styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | -[styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | -[styling-sass-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) | LensMainExtension
LensRendererExtension
Component.Icon
Component.IconProps | -[custom-resource-page](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) | LensRendererExtension
K8sApi.KubeApi
K8sApi.KubeObjectStore
Component.KubeObjectListLayout
Component.KubeObjectDetailsProps
Component.IconProps | +[hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension
LensRendererExtension
Renderer.Component.Icon
Renderer.Component.IconProps | +[styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension
LensRendererExtension
Renderer.Component.Icon
Renderer.Component.IconProps | +[styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension
LensRendererExtension
Renderer.Component.Icon
Renderer.Component.IconProps | +[styling-sass-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) | LensMainExtension
LensRendererExtension
Renderer.Component.Icon
Renderer.Component.IconProps | +[custom-resource-page](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) | LensRendererExtension
Renderer.K8sApi.KubeApi
Renderer.K8sApi.KubeObjectStore
Renderer.Component.KubeObjectListLayout
Renderer.Component.KubeObjectDetailsProps
Renderer.Component.IconProps | diff --git a/docs/extensions/guides/ipc.md b/docs/extensions/guides/ipc.md index e95cb25883..df4684ac5d 100644 --- a/docs/extensions/guides/ipc.md +++ b/docs/extensions/guides/ipc.md @@ -43,10 +43,10 @@ To register either a handler or a listener, you should do something like the fol `main.ts`: ```typescript -import { LensMainExtension } from "@k8slens/extensions"; +import { Main } from "@k8slens/extensions"; import { IpcMain } from "./helpers/main"; -export class ExampleExtensionMain extends LensMainExtension { +export class ExampleExtensionMain extends Main.LensExtension { onActivate() { IpcMain.createInstance(this); } @@ -60,10 +60,10 @@ Lens will automatically clean up that store and all the handlers on deactivation `helpers/main.ts`: ```typescript -import { Ipc, Types } from "@k8slens/extensions"; +import { Main } from "@k8slens/extensions"; -export class IpcMain extends Ipc.Main { - constructor(extension: LensMainExtension) { +export class IpcMain extends Main.Ipc { + constructor(extension: Main.LensExtension) { super(extension); this.listen("initialize", onInitialize); @@ -82,10 +82,10 @@ You should be able to just call `IpcMain.getInstance()` anywhere it is needed in `renderer.ts`: ```typescript -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { IpcRenderer } from "./helpers/renderer"; -export class ExampleExtensionRenderer extends LensRendererExtension { +export class ExampleExtensionRenderer extends Renderer.LensExtension { onActivate() { const ipc = IpcRenderer.createInstance(this); @@ -100,9 +100,9 @@ It is also needed to create an instance to broadcast messages too. `helpers/renderer.ts`: ```typescript -import { Ipc } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; -export class IpcRenderer extends Ipc.Renderer {} +export class IpcRenderer extends Renderer.Ipc {} ``` It is necessary to create child classes of these `abstract class`'s in your extension before you can use them. @@ -116,7 +116,7 @@ There is no behind the scenes transfer of these functions. To register a "handler" call `IpcMain.getInstance().handle(...)`. The cleanup of these handlers is handled by Lens itself. -The `listen()` methods on `Ipc.Main` and `Ipc.Renderer` return a `Disposer`, or more specifically, a `() => void`. +The `listen()` methods on `Main.Ipc` and `Renderer.Ipc` return a `Disposer`, or more specifically, a `() => void`. This can be optionally called to remove the listener early. Calling either `IpcRenderer.getInstance().broadcast(...)` or `IpcMain.getInstance().broadcast(...)` sends an event to all `renderer` frames and to `main`. diff --git a/docs/extensions/guides/kube-object-list-layout.md b/docs/extensions/guides/kube-object-list-layout.md index 99f6796c91..f5ee72e902 100644 --- a/docs/extensions/guides/kube-object-list-layout.md +++ b/docs/extensions/guides/kube-object-list-layout.md @@ -18,7 +18,7 @@ First thing we need to do with our extension is to register new menu item in the We will do this in our extension class `CrdSampleExtension` that is derived `LensRendererExtension` class: ```typescript -export default class CrdSampleExtension extends LensRendererExtension { +export default class CrdSampleExtension extends Renderer.LensExtension { } ``` @@ -27,11 +27,21 @@ This object will register a menu item with "Certificates" text. It will also use `CertificateIcon` component to render an icon and navigate to cluster page that is having `certificates` page id. ```typescript -export function CertificateIcon(props: Component.IconProps) { - return +import { Renderer } from "@k8slens/extensions"; + +type IconProps = Renderer.Component.IconProps; + +const { + Component: { + Icon, + }, +} = Renderer; + +export function CertificateIcon(props: IconProps) { + return } -export default class CrdSampleExtension extends LensRendererExtension { +export default class CrdSampleExtension extends Renderer.LensExtension { clusterPageMenus = [ { @@ -48,7 +58,7 @@ export default class CrdSampleExtension extends LensRendererExtension { Then we need to register `PageRegistration` object with `certificates` id and define `CertificatePage` component to render certificates. ```typescript -export default class CrdSampleExtension extends LensRendererExtension { +export default class CrdSampleExtension extends Renderer.LensExtension { ... clusterPages = [{ @@ -65,18 +75,29 @@ export default class CrdSampleExtension extends LensRendererExtension { In the previous step we defined `CertificatePage` component to render certificates. In this step we will actually implement that. -`CertificatePage` is a React component that will render `Component.KubeObjectListLayout` component to list `Certificate` CRD objects. +`CertificatePage` is a React component that will render `Renderer.Component.KubeObjectListLayout` component to list `Certificate` CRD objects. ### Get CRD objects In order to list CRD objects, we need first fetch those from Kubernetes API. Lens Extensions API provides easy mechanism to do this. -We just need to define `Certificate` class derived from `K8sApi.KubeObject`, `CertificatesApi`derived from `K8sApi.KubeApi` and `CertificatesStore` derived from `K8sApi.KubeObjectStore`. +We just need to define `Certificate` class derived from `Renderer.K8sApi.KubeObject`, `CertificatesApi`derived from `Renderer.K8sApi.KubeApi` and `CertificatesStore` derived from `Renderer.K8sApi.KubeObjectStore`. `Certificate` class defines properties found in the CRD object: ```typescript -export class Certificate extends K8sApi.KubeObject { +import { Renderer } from "@k8slens/extensions"; + +const { + K8sApi: { + KubeObject, + KubeObjectStore, + KubeApi, + apiManager, + }, +} = Renderer; + +export class Certificate extends KubeObject { static kind = "Certificate" static namespaced = true static apiBase = "/apis/cert-manager.io/v1alpha2/certificates" @@ -121,8 +142,8 @@ export class Certificate extends K8sApi.KubeObject { With `CertificatesApi` class we are able to manage `Certificate` objects in Kubernetes API: ```typescript -export class CertificatesApi extends K8sApi.KubeApi { -} +export class CertificatesApi extends KubeApi {} + export const certificatesApi = new CertificatesApi({ objectConstructor: Certificate }); @@ -131,7 +152,7 @@ export const certificatesApi = new CertificatesApi({ `CertificateStore` defines storage for `Certificate` objects ```typescript -export class CertificatesStore extends K8sApi.KubeObjectStore { +export class CertificatesStore extends KubeObjectStore { api = certificatesApi } @@ -141,7 +162,7 @@ export const certificatesStore = new CertificatesStore(); And, finally, we register this store to Lens's API manager. ```typescript -K8sApi.apiManager.registerStore(certificatesStore); +apiManager.registerStore(certificatesStore); ``` @@ -153,23 +174,32 @@ Then we need to fetch those and render them in the UI. First we define `CertificatePage` class that extends `React.Component`. ```typescript -import { Component, LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import React from "react"; import { certificatesStore } from "../certificate-store"; import { Certificate } from "../certificate" -export class CertificatePage extends React.Component<{ extension: LensRendererExtension }> { +export class CertificatePage extends React.Component<{ extension: Renderer.LensExtension }> { } ``` Next we will implement `render` method that will display certificates in a list. -To do that, we just need to add `Component.KubeObjectListLayout` component inside `Component.TabLayout` component in render method. -To define which objects the list is showing, we need to pass `certificateStore` object to `Component.KubeObjectListLayout` in `store` property. -`Component.KubeObjectListLayout` will fetch automatically items from the given store when component is mounted. +To do that, we just need to add `Renderer.Component.KubeObjectListLayout` component inside `Renderer.Component.TabLayout` component in render method. +To define which objects the list is showing, we need to pass `certificateStore` object to `Renderer.Component.KubeObjectListLayout` in `store` property. +`Renderer.Component.KubeObjectListLayout` will fetch automatically items from the given store when component is mounted. Also, we can define needed sorting callbacks and search filters for the list: ```typescript +import { Renderer } from "@k8slens/extensions"; + +const { + Component: { + TabLayout, + KubeObjectListLayout, + }, +} = Renderer; + enum sortBy { name = "name", namespace = "namespace", @@ -181,8 +211,8 @@ export class CertificatePage extends React.Component<{ extension: LensRendererEx render() { return ( - - + certificate.getName(), @@ -204,7 +234,7 @@ export class CertificatePage extends React.Component<{ extension: LensRendererEx certificate.spec.issuerRef.name ]} /> - + ) } } @@ -219,7 +249,7 @@ First, we need to register our custom component to render details for the specif We will do this again in `CrdSampleExtension` class: ```typescript -export default class CrdSampleExtension extends LensRendererExtension { +export default class CrdSampleExtension extends Renderer.LensExtension { //... kubeObjectDetailItems = [{ @@ -235,14 +265,22 @@ export default class CrdSampleExtension extends LensRendererExtension { Here we defined that `CertificateDetails` component will render the resource details. So, next we need to implement that component. Lens will inject `Certificate` object into our component so we just need to render some information out of it. -We can use `Component.DrawerItem` component from Lens Extensions API to give the same look and feel as Lens is using elsewhere: +We can use `Renderer.Component.DrawerItem` component from Lens Extensions API to give the same look and feel as Lens is using elsewhere: ```typescript -import { Component, K8sApi } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import React from "react"; import { Certificate } from "../certificate"; -export interface CertificateDetailsProps extends Component.KubeObjectDetailsProps{ +const { + Component: { + KubeObjectDetailsProps, + DrawerItem, + Badge, + } +} = Renderer; + +export interface CertificateDetailsProps extends KubeObjectDetailsProps{ } export class CertificateDetails extends React.Component { @@ -252,29 +290,29 @@ export class CertificateDetails extends React.Component if (!certificate) return null; return (
- + {certificate.getAge(true, false)} ago ({certificate.metadata.creationTimestamp }) - - + + {certificate.spec.dnsNames.join(",")} - - + + {certificate.spec.secretName} - - + + {certificate.status.conditions.map((condition, index) => { const { type, reason, message, status } = condition; const kind = type || reason; if (!kind) return null; return ( - ); })} - +
) } diff --git a/docs/extensions/guides/main-extension.md b/docs/extensions/guides/main-extension.md index f1212c0d37..32d24acdef 100644 --- a/docs/extensions/guides/main-extension.md +++ b/docs/extensions/guides/main-extension.md @@ -11,9 +11,9 @@ The Main Extension API allows you to access, configure, and customize Lens data, To create a main extension simply extend the `LensMainExtension` class: ```typescript -import { LensMainExtension } from "@k8slens/extensions"; +import { Main } from "@k8slens/extensions"; -export default class ExampleExtensionMain extends LensMainExtension { +export default class ExampleExtensionMain extends Main.LensExtension { onActivate() { console.log('custom main process extension code started'); } @@ -39,34 +39,6 @@ The example above logs messages when the extension is enabled and disabled. To see standard output from the main process there must be a console connected to it. Achieve this by starting Lens from the command prompt. -The following example is a little more interesting. -It accesses some Lens state data, and it periodically logs the name of the cluster that is currently active in Lens. - -```typescript -import { LensMainExtension, Store } from "@k8slens/extensions"; - -export default class ActiveClusterExtensionMain extends LensMainExtension { - - timer: NodeJS.Timeout - - onActivate() { - console.log("Cluster logger activated"); - this.timer = setInterval(() => { - if (!Store.ClusterStore.getInstance().active) { - console.log("No active cluster"); - return; - } - console.log("active cluster is", Store.ClusterStore.getInstance().active.contextName) - }, 5000) - } - - onDeactivate() { - clearInterval(this.timer) - console.log("Cluster logger deactivated"); - } -} -``` - For more details on accessing Lens state data, please see the [Stores](../stores) guide. ### `appMenus` @@ -76,9 +48,9 @@ Note that this is the only UI feature that the Main Extension API allows you to The following example demonstrates adding an item to the **Help** menu. ``` typescript -import { LensMainExtension } from "@k8slens/extensions"; +import { Main } from "@k8slens/extensions"; -export default class SamplePageMainExtension extends LensMainExtension { +export default class SamplePageMainExtension extends Main.LensExtension { appMenus = [ { parentId: "help", @@ -102,4 +74,4 @@ Valid values include: `"file"`, `"edit"`, `"view"`, and `"help"`. * `click()` is called when the menu item is selected. 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 [`LensRendererExtension`](renderer-extension.md) class and can be defined in the process of extending it. +Note that pages are associated with the [`Renderer.LensExtension`](renderer-extension.md) class and can be defined in the process of extending it. diff --git a/docs/extensions/guides/protocol-handlers.md b/docs/extensions/guides/protocol-handlers.md index 8e13c8436a..f3335cba56 100644 --- a/docs/extensions/guides/protocol-handlers.md +++ b/docs/extensions/guides/protocol-handlers.md @@ -18,13 +18,13 @@ In other words, which handler is selected in either process is independent from Example of registering a handler: ```typescript -import { LensMainExtension, Interface } from "@k8slens/extensions"; +import { Main, Common } from "@k8slens/extensions"; -function rootHandler(params: Iterface.ProtocolRouteParams) { +function rootHandler(params: Common.Types.ProtocolRouteParams) { console.log("routed to ExampleExtension", params); } -export default class ExampleExtensionMain extends LensMainExtension { +export default class ExampleExtensionMain extends Main.LensExtension { protocolHandlers = [ pathSchema: "/", handler: rootHandler, diff --git a/docs/extensions/guides/renderer-extension.md b/docs/extensions/guides/renderer-extension.md index b03607bd42..c96391fde0 100644 --- a/docs/extensions/guides/renderer-extension.md +++ b/docs/extensions/guides/renderer-extension.md @@ -24,9 +24,9 @@ All UI elements are based on React components. To create a renderer extension, extend the `LensRendererExtension` class: ```typescript -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; -export default class ExampleExtensionMain extends LensRendererExtension { +export default class ExampleExtensionMain extends Renderer.LensExtension { onActivate() { console.log('custom renderer process extension code started'); } @@ -61,11 +61,11 @@ Use your extension to access Kubernetes resources in the active cluster with [`C Add a cluster page definition to a `LensRendererExtension` subclass with the following example: ```typescript -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { ExampleIcon, ExamplePage } from "./page" import React from "react" -export default class ExampleExtension extends LensRendererExtension { +export default class ExampleExtension extends Renderer.LensExtension { clusterPages = [ { id: "hello", @@ -88,7 +88,7 @@ It offers flexibility in defining the appearance and behavior of your page. `ExamplePage` in the example above can be defined in `page.tsx`: ```typescript -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import React from "react" export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> { @@ -116,11 +116,11 @@ Use `clusterPageMenus`, covered in the next section, to add cluster pages to the By expanding on the above example, you can add a cluster page menu item to the `ExampleExtension` definition: ```typescript -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { ExampleIcon, ExamplePage } from "./page" import React from "react" -export default class ExampleExtension extends LensRendererExtension { +export default class ExampleExtension extends Renderer.LensExtension { clusterPages = [ { id: "hello", @@ -157,14 +157,20 @@ When users click **Hello World**, the cluster dashboard will show the contents o This example requires the definition of another React-based component, `ExampleIcon`, which has been added to `page.tsx`, as follows: ```typescript -import { LensRendererExtension, Component } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import React from "react" -export function ExampleIcon(props: Component.IconProps) { - return +type IconProps = Renderer.Component.IconProps; + +const { + Component: { Icon }, +} = Renderer; + +export function ExampleIcon(props: IconProps) { + return } -export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> { +export class ExamplePage extends React.Component<{ extension: Renderer.LensExtension }> { render() { return (
@@ -176,8 +182,8 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens ``` Lens includes various built-in components available for extension developers to use. -One of these is the `Component.Icon`, introduced in `ExampleIcon`, which you can use to access any of the [icons](https://material.io/resources/icons/) available at [Material Design](https://material.io). -The properties that `Component.Icon` uses are defined as follows: +One of these is the `Renderer.Component.Icon`, introduced in `ExampleIcon`, which you can use to access any of the [icons](https://material.io/resources/icons/) available at [Material Design](https://material.io). +The properties that `Renderer.Component.Icon` uses are defined as follows: * `material` takes the name of the icon you want to use. * `tooltip` sets the text you want to appear when a user hovers over the icon. @@ -187,11 +193,11 @@ The following example groups two sub menu items under one parent menu item: ```typescript -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { ExampleIcon, ExamplePage } from "./page" import React from "react" -export default class ExampleExtension extends LensRendererExtension { +export default class ExampleExtension extends Renderer.LensExtension { clusterPages = [ { id: "hello", @@ -261,11 +267,11 @@ Unlike cluster pages, users can trigger global pages even when there is no activ The following example defines a `LensRendererExtension` subclass with a single global page definition: ```typescript -import { LensRendererExtension } from '@k8slens/extensions'; +import { Renderer } from '@k8slens/extensions'; import { HelpPage } from './page'; import React from 'react'; -export default class HelpExtension extends LensRendererExtension { +export default class HelpExtension extends Renderer.LensExtension { globalPages = [ { id: "help", @@ -288,7 +294,7 @@ It offers flexibility in defining the appearance and behavior of your page. `HelpPage` in the example above can be defined in `page.tsx`: ```typescript -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import React from "react" export class HelpPage extends React.Component<{ extension: LensRendererExtension }> { @@ -320,11 +326,11 @@ Global pages can be made available in the following ways: By expanding on the above example, you can add a global page menu item to the `HelpExtension` definition: ```typescript -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { HelpIcon, HelpPage } from "./page" import React from "react" -export default class HelpExtension extends LensRendererExtension { +export default class HelpExtension extends Renderer.LensExtension { globalPages = [ { id: "help", @@ -362,14 +368,20 @@ 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 { LensRendererExtension, Component } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import React from "react" -export function HelpIcon(props: Component.IconProps) { - return +type IconProps = Renderer.Component.IconProps; + +const { + Component: { Icon }, +} = Renderer; + +export function HelpIcon(props: IconProps) { + return } -export class HelpPage extends React.Component<{ extension: LensRendererExtension }> { +export class HelpPage extends React.Component<{ extension: Renderer.LensExtension }> { render() { return (
@@ -381,8 +393,8 @@ export class HelpPage extends React.Component<{ extension: LensRendererExtension ``` Lens includes various built-in components available for extension developers to use. -One of these is the `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 `Component.Icon` uses is defined as follows: +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. @@ -401,11 +413,11 @@ They can be installed and uninstalled by the Lens user from the cluster **Settin The following example shows how to add a cluster feature as part of a `LensRendererExtension`: ```typescript -import { LensRendererExtension } from "@k8slens/extensions" +import { Renderer } from "@k8slens/extensions" import { ExampleFeature } from "./src/example-feature" import React from "react" -export default class ExampleFeatureExtension extends LensRendererExtension { +export default class ExampleFeatureExtension extends Renderer.LensExtension { clusterFeatures = [ { title: "Example Feature", @@ -462,45 +474,69 @@ Consider using the following properties with `updateStatus()`: The following shows a very simple implementation of a `ClusterFeature`: ```typescript -import { ClusterFeature, Store, K8sApi } from "@k8slens/extensions"; +import { Renderer, Common } from "@k8slens/extensions"; import * as path from "path"; -export class ExampleFeature extends ClusterFeature.Feature { +const { + K8sApi: { + ResourceStack, + forCluster, + StorageClass, + Namespace, + } +} = Renderer; - async install(cluster: Store.Cluster): Promise { +type ResourceStack = Renderer.K8sApi.ResourceStack; +type Pod = Renderer.K8sApi.Pod; +type KubernetesCluster = Common.Catalog.KubernetesCluster; - super.applyResources(cluster, path.join(__dirname, "../resources/")); +export interface MetricsStatus { + installed: boolean; + canUpgrade: boolean; +} + +export class ExampleFeature { + protected stack: ResourceStack; + + constructor(protected cluster: KubernetesCluster) { + this.stack = new ResourceStack(cluster, this.name); } - async upgrade(cluster: Store.Cluster): Promise { - return this.install(cluster); + install(): Promise { + return this.stack.kubectlApplyFolder(path.join(__dirname, "../resources/")); } - async updateStatus(cluster: Store.Cluster): Promise { + upgrade(): Promise { + return this.install(config); + } + + async getStatus(): Promise { + const status: MetricsStatus = { installed: false, canUpgrade: false}; + try { - const pod = K8sApi.forCluster(cluster, K8sApi.Pod); + const pod = forCluster(cluster, Pod); const examplePod = await pod.get({name: "example-pod", namespace: "default"}); + if (examplePod?.kind) { - this.status.installed = true; - this.status.currentVersion = examplePod.spec.containers[0].image.split(":")[1]; - this.status.canUpgrade = true; // a real implementation would perform a check here that is relevant to the specific feature + 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 { - this.status.installed = false; - this.status.canUpgrade = false; + status.installed = false; + status.canUpgrade = false; } } catch(e) { if (e?.error?.code === 404) { - this.status.installed = false; - this.status.canUpgrade = false; + status.installed = false; + status.canUpgrade = false; } } - return this.status; + return status; } - async uninstall(cluster: Store.Cluster): Promise { - const podApi = K8sApi.forCluster(cluster, K8sApi.Pod); - await podApi.delete({name: "example-pod", namespace: "default"}); + async uninstall(): Promise { + return this.stack.kubectlDeleteFolder(this.resourceFolder); } } ``` @@ -539,12 +575,12 @@ You can use Lens extensions to add custom preferences to the Preferences page, p The following example demonstrates adding a custom preference: ```typescript -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference"; import { observable } from "mobx"; import React from "react"; -export default class ExampleRendererExtension extends LensRendererExtension { +export default class ExampleRendererExtension extends Renderer.LensExtension { @observable preference = { enabled: false }; @@ -578,10 +614,16 @@ This is how `ExampleRendererExtension` handles the state of the preference input In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreferenceProps` are defined in `./src/example-preference.tsx` as follows: ```typescript -import { Component } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { observer } from "mobx-react"; import React from "react"; +const { + Component: { + Checkbox, + }, +} = Renderer; + export class ExamplePreferenceProps { preference: { enabled: boolean; @@ -594,7 +636,7 @@ export class ExamplePreferenceInput extends React.Component { preference.enabled = v; }} @@ -612,7 +654,7 @@ export class ExamplePreferenceHint extends React.Component { } ``` -`ExamplePreferenceInput` implements a simple checkbox using Lens's `Component.Checkbox` using the following properties: +`ExamplePreferenceInput` implements a simple checkbox using Lens's `Renderer.Component.Checkbox` using the following properties: * `label` sets the text that displays next to the checkbox. * `value` is initially set to `preference.enabled`. @@ -645,11 +687,11 @@ The following example adds a `statusBarItems` definition and a `globalPages` def It configures the status bar item to navigate to the global page upon activation (normally a mouse click): ```typescript -import { LensRendererExtension } from '@k8slens/extensions'; +import { Renderer } from '@k8slens/extensions'; import { HelpIcon, HelpPage } from "./page" import React from 'react'; -export default class HelpExtension extends LensRendererExtension { +export default class HelpExtension extends Renderer.LensExtension { globalPages = [ { id: "help", @@ -703,16 +745,19 @@ The following example shows how to add a `kubeObjectMenuItems` for namespace res ```typescript import React from "react" -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { NamespaceMenuItem } from "./src/namespace-menu-item" -export default class ExampleExtension extends LensRendererExtension { +type KubeObjectMenuProps = Renderer.Component.KubeObjectMenuProps; +type Namespace = Renderer.K8sApi.Namespace; + +export default class ExampleExtension extends Renderer.LensExtension { kubeObjectMenuItems = [ { kind: "Namespace", apiVersions: ["v1"], components: { - MenuItem: (props: Component.KubeObjectMenuProps) => + MenuItem: (props: KubeObjectMenuProps) => } } ]; @@ -734,16 +779,28 @@ In this example a `NamespaceMenuItem` object is returned. ```typescript import React from "react"; -import { Component, K8sApi, Navigation} from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; -export function NamespaceMenuItem(props: Component.KubeObjectMenuProps) { +const { + Component: { + terminalStore, + MenuItem, + Icon, + }, + Navigation, +} = Renderer; + +type KubeObjectMenuProps = Renderer.Component.KubeObjectMenuProps; +type Namespace = Renderer.K8sApi.Namespace; + +export function NamespaceMenuItem(props: KubeObjectMenuProps) { const { object: namespace, toolbar } = props; if (!namespace) return null; const namespaceName = namespace.getName(); const sendToTerminal = (command: string) => { - Component.terminalStore.sendCommand(command, { + terminalStore.sendCommand(command, { enter: true, newTab: true, }); @@ -755,21 +812,21 @@ export function NamespaceMenuItem(props: Component.KubeObjectMenuProps - - Get Pods - + + + Get Pods + ); } ``` -`NamespaceMenuItem` returns a `Component.MenuItem` which defines the menu item's appearance and its behavior when activated via the `onClick` property. +`NamespaceMenuItem` returns a `Renderer.Component.MenuItem` which defines the menu item's appearance and its behavior when activated via the `onClick` property. In the example, `getPods()` opens a terminal tab and runs `kubectl` to get a list of pods running in the current namespace. The name of the namespace is retrieved from `props` passed into `NamespaceMenuItem()`. -`namespace` is the `props.object`, which is of type `K8sApi.Namespace`. -`K8sApi.Namespace` is the API for accessing namespaces. +`namespace` is the `props.object`, which is of type `Renderer.K8sApi.Namespace`. +`Renderer.K8sApi.Namespace` is the API for accessing namespaces. The current namespace in this example is simply given by `namespace.getName()`. Thus, `kubeObjectMenuItems` afford convenient access to the specific resource selected by the user. @@ -783,18 +840,22 @@ These custom details appear on the details page for a specific resource, such as The following example shows how to use `kubeObjectDetailItems` to add a tabulated list of pods to the Namespace resource details page: ```typescript -import React from "react" -import { LensRendererExtension } from "@k8slens/extensions"; -import { NamespaceDetailsItem } from "./src/namespace-details-item" +import React from "react"; +import { Renderer } from "@k8slens/extensions"; +import { NamespaceDetailsItem } from "./src/namespace-details-item"; -export default class ExampleExtension extends LensRendererExtension { +type KubeObjectMenuProps = Renderer.Component.KubeObjectMenuProps; +type KubeObjectDetailsProps = Renderer.Component.KubeObjectDetailsProps; +type Namespace = Renderer.K8sApi.Namespace; + +export default class ExampleExtension extends Renderer.LensExtension { kubeObjectDetailItems = [ { kind: "Namespace", apiVersions: ["v1"], priority: 10, components: { - Details: (props: Component.KubeObjectDetailsProps) => + Details: (props: KubeObjectDetailsProps) => } } ]; @@ -814,25 +875,39 @@ In this example a `NamespaceDetailsItem` object is returned. `NamespaceDetailsItem` is defined in `./src/namespace-details-item.tsx`: ``` typescript -import { Component, K8sApi } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { PodsDetailsList } from "./pods-details-list"; import React from "react"; import { observable } from "mobx"; import { observer } from "mobx-react"; -@observer -export class NamespaceDetailsItem extends React.Component> { +const { + K8sApi: { + podsApi, + }, + Component: { + DrawerTitle, + }, +} = Renderer; - @observable private pods: K8sApi.Pod[]; +type KubeObjectMenuProps = Renderer.Component.KubeObjectMenuProps; +type Namespace = Renderer.K8sApi.Namespace; +type Pod = Renderer.K8sApi.Pod; + +@observer +export class NamespaceDetailsItem extends React.Component> { + @observable private pods: Pod[]; async componentDidMount() { - this.pods = await K8sApi.podsApi.list({namespace: this.props.object.getName()}); + const namespace = this.props.object.getName(); + + this.pods = await podsApi.list({ namespace }); } render() { return (
- +
) @@ -840,14 +915,14 @@ export class NamespaceDetailsItem extends React.Component>`, it can access the current namespace object (type `K8sApi.Namespace`) through `this.props.object`. +Since `NamespaceDetailsItem` extends `React.Component>`, it can access the current namespace object (type `Namespace`) through `this.props.object`. You can query this object for many details about the current namespace. -In the example above, `componentDidMount()` gets the namespace's name using the `K8sApi.Namespace` `getName()` method. +In the example above, `componentDidMount()` gets the namespace's name using the `Namespace` `getName()` method. Use the namespace's name to limit the list of pods only to those in the relevant namespace. -To get this list of pods, this example uses the Kubernetes pods API `K8sApi.podsApi.list()` method. -The `K8sApi.podsApi` is automatically configured for the active cluster. +To get this list of pods, this example uses the Kubernetes pods API `podsApi.list()` method. +The `podsApi` is automatically configured for the active cluster. -Note that `K8sApi.podsApi.list()` is an asynchronous method. +Note that `podsApi.list()` is an asynchronous method. Getting the pods list should occur prior to rendering the `NamespaceDetailsItem`. It is a common technique in React development to await async calls in `componentDidMount()`. However, `componentDidMount()` is called right after the first call to `render()`. @@ -856,51 +931,59 @@ Like in the [`appPreferences` guide](#apppreferences), [`mobx`](https://mobx.js. This is done simply by marking the `pods` field as an `observable` and the `NamespaceDetailsItem` class itself as an `observer`. Finally, the `NamespaceDetailsItem` renders using the `render()` method. -Details are placed in drawers, and using `Component.DrawerTitle` provides a separator from details above this one. -Multiple details in a drawer can be placed in `` elements for further separation, if desired. +Details are placed in drawers, and using `Renderer.Component.DrawerTitle` provides a separator from details above this one. +Multiple details in a drawer can be placed in `` elements for further separation, if desired. The rest of this example's details are defined in `PodsDetailsList`, found in `./pods-details-list.tsx`: ``` typescript import React from "react"; -import { Component, K8sApi } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; + +const { + Component: { + TableHead, + TableRow, + TableCell, + Table, + }, +} = Renderer; + +type Pod = Renderer.K8sApi.Pod; interface Props { - pods: K8sApi.Pod[]; + pods?: Pod[]; } export class PodsDetailsList extends React.Component { - - getTableRow(index: number) { - const {pods} = this.props; - return ( - - {pods[index].getName()} - {pods[index].getAge()} - {pods[index].getStatus()} - - ) - } + getTableRow = (pod: Pod) => { + return ( + + {pods[index].getName()} + {pods[index].getAge()} + {pods[index].getStatus()} + + ) + }; render() { - const {pods} = this.props - if (!pods?.length) { - return null; - } + const { pods } = this.props + + if (!pods?.length) { + return null; + } - return ( -
- - - Name - Age - Status - - { - pods.map((pod, index) => this.getTableRow(index)) - } - -
- ) + return ( +
+ + + Name + Age + Status + + { pods.map(this.getTableRow) } +
+
+ ); } } ``` @@ -909,9 +992,9 @@ export class PodsDetailsList extends React.Component { ![DetailsWithPods](images/kubeobjectdetailitemwithpods.png) -Obtain the name, age, and status for each pod using the `K8sApi.Pod` methods. -Construct the table using the `Component.Table` and related elements. +Obtain the name, age, and status for each pod using the `Renderer.K8sApi.Pod` methods. +Construct the table using the `Renderer.Component.Table` and related elements. -For each pod the name, age, and status are obtained using the `K8sApi.Pod` methods. -The table is constructed using the `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. diff --git a/docs/extensions/guides/stores.md b/docs/extensions/guides/stores.md index 2eaa589198..3a990f58b7 100644 --- a/docs/extensions/guides/stores.md +++ b/docs/extensions/guides/stores.md @@ -24,14 +24,14 @@ This is so that your data is kept up to date and not persisted longer than expec The following example code creates a store for the `appPreferences` guide example: ``` typescript -import { Store } from "@k8slens/extensions"; +import { Common } from "@k8slens/extensions"; import { observable, makeObservable } from "mobx"; export type ExamplePreferencesModel = { enabled: boolean; }; -export class ExamplePreferencesStore extends Store.ExtensionStore { +export class ExamplePreferencesStore extends Common.Store.ExtensionStore { @observable enabled = false; @@ -87,10 +87,10 @@ The following example code, modified from the [`appPreferences`](../renderer-ext This can be done in `./main.ts`: ``` typescript -import { LensMainExtension } from "@k8slens/extensions"; +import { Main } from "@k8slens/extensions"; import { ExamplePreferencesStore } from "./src/example-preference-store"; -export default class ExampleMainExtension extends LensMainExtension { +export default class ExampleMainExtension extends Main.LensExtension { async onActivate() { await ExamplePreferencesStore.getInstanceOrCreate().loadExtension(this); } @@ -102,12 +102,12 @@ Similarly, `ExamplePreferencesStore` must load in the renderer process where the This can be done in `./renderer.ts`: ``` typescript -import { LensRendererExtension } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference"; import { ExamplePreferencesStore } from "./src/example-preference-store"; import React from "react"; -export default class ExampleRendererExtension extends LensRendererExtension { +export default class ExampleRendererExtension extends Renderer.LensExtension { async onActivate() { await ExamplePreferencesStore.getInstanceOrCreate().loadExtension(this); @@ -130,17 +130,23 @@ Again, `ExamplePreferencesStore.getInstanceOrCreate().loadExtension(this)` is ca `ExamplePreferenceInput` is defined in `./src/example-preference.tsx`: ``` typescript -import { Component } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; import { observer } from "mobx-react"; import React from "react"; import { ExamplePreferencesStore } from "./example-preference-store"; +const { + Component: { + Checkbox, + }, +} = Renderer; + @observer export class ExamplePreferenceInput extends React.Component { render() { return ( - { ExamplePreferencesStore.getInstace().enabled = v; }} diff --git a/docs/extensions/testing-and-publishing/testing.md b/docs/extensions/testing-and-publishing/testing.md index af178efeb7..cd51cbe3b0 100644 --- a/docs/extensions/testing-and-publishing/testing.md +++ b/docs/extensions/testing-and-publishing/testing.md @@ -14,7 +14,13 @@ My component `GlobalPageMenuIcon` ```typescript import React from "react" -import { Component: { Icon } } from "@k8slens/extensions"; +import { Renderer } from "@k8slens/extensions"; + +const { + Component: { + Icon, + }, +} = Renderer; const GlobalPageMenuIcon = ({ navigate }: { navigate?: () => void }): JSX.Element => ( { + this.on("catalogAddMenu", (ctx: CatalogEntityAddMenuContext) => { ctx.menuItems.push({ icon: "text_snippet", title: "Add from kubeconfig", diff --git a/src/common/catalog/catalog-entity.ts b/src/common/catalog/catalog-entity.ts index f30a392464..afc15d4c64 100644 --- a/src/common/catalog/catalog-entity.ts +++ b/src/common/catalog/catalog-entity.ts @@ -19,7 +19,8 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { EventEmitter } from "events"; +import EventEmitter from "events"; +import type TypedEmitter from "typed-emitter"; import { observable, makeObservable } from "mobx"; type ExtractEntityMetadataType = Entity extends CatalogEntity ? Metadata : never; @@ -47,7 +48,13 @@ export interface CatalogCategorySpec { }; } -export abstract class CatalogCategory extends EventEmitter { +export interface CatalogCategoryEvents { + load: () => void; + catalogAddMenu: (context: CatalogEntityAddMenuContext) => void; + contextMenuOpen: (entity: CatalogEntity, context: CatalogEntityContextMenuContext) => void; +} + +export abstract class CatalogCategory extends (EventEmitter as new () => TypedEmitter) { abstract readonly apiVersion: string; abstract readonly kind: string; abstract metadata: { diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 718151c3ed..b1cced3edf 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -25,9 +25,12 @@ import { appEventBus } from "./event-bus"; import { ResourceApplier } from "../main/resource-applier"; import { ipcMain, IpcMainInvokeEvent } from "electron"; import { clusterFrameMap } from "./cluster-frames"; +import { catalogEntityRegistry } from "../main/catalog"; +import type { KubernetesCluster } from "./catalog-entities"; export const clusterActivateHandler = "cluster:activate"; export const clusterSetFrameIdHandler = "cluster:set-frame-id"; +export const clusterVisibilityHandler = "cluster:visibility"; export const clusterRefreshHandler = "cluster:refresh"; export const clusterDisconnectHandler = "cluster:disconnect"; export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all"; @@ -49,6 +52,18 @@ if (ipcMain) { } }); + handleRequest(clusterVisibilityHandler, (event: IpcMainInvokeEvent, clusterId: ClusterId, visible: boolean) => { + const entity = catalogEntityRegistry.getById(clusterId); + + for (const kubeEntity of catalogEntityRegistry.getItemsForApiKind(entity.apiVersion, entity.kind)) { + kubeEntity.status.active = false; + } + + if (entity) { + entity.status.active = visible; + } + }); + handleRequest(clusterRefreshHandler, (event, clusterId: ClusterId) => { return ClusterStore.getInstance() .getById(clusterId) diff --git a/src/common/protocol-handler/router.ts b/src/common/protocol-handler/router.ts index 59101b5aa7..ee0d4b3799 100644 --- a/src/common/protocol-handler/router.ts +++ b/src/common/protocol-handler/router.ts @@ -21,7 +21,7 @@ import { match, matchPath } from "react-router"; import { countBy } from "lodash"; -import { Singleton } from "../utils"; +import { iter, Singleton } from "../utils"; import { pathToRegexp } from "path-to-regexp"; import logger from "../../main/logger"; import type Url from "url-parse"; @@ -35,7 +35,8 @@ import type { RouteHandler, RouteParams } from "../../extensions/registries/prot export const ProtocolHandlerIpcPrefix = "protocol-handler"; export const ProtocolHandlerInternal = `${ProtocolHandlerIpcPrefix}:internal`; -export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`; +export const ProtocolHandlerExtension = `${ProtocolHandlerIpcPrefix}:extension`; +export const ProtocolHandlerInvalid = `${ProtocolHandlerIpcPrefix}:invalid`; /** * These two names are long and cumbersome by design so as to decrease the chances @@ -47,6 +48,34 @@ export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`; export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH"; export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH"; +/** + * Returned from routing attempts + */ +export enum RouteAttempt { + /** + * A handler was found in the set of registered routes + */ + MATCHED = "matched", + /** + * A handler was not found within the set of registered routes + */ + MISSING = "missing", + /** + * The extension that was matched in the route was not activated + */ + MISSING_EXTENSION = "no-extension", +} + +export function foldAttemptResults(mainAttempt: RouteAttempt, rendererAttempt: RouteAttempt): RouteAttempt { + switch (mainAttempt) { + case RouteAttempt.MATCHED: + return RouteAttempt.MATCHED; + case RouteAttempt.MISSING: + case RouteAttempt.MISSING_EXTENSION: + return rendererAttempt; + } +} + export abstract class LensProtocolRouter extends Singleton { // Map between path schemas and the handlers protected internalRoutes = new Map(); @@ -56,11 +85,12 @@ export abstract class LensProtocolRouter extends Singleton { static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; /** - * + * Attempts to route the given URL to all internal routes that have been registered * @param url the parsed URL that initiated the `lens://` protocol + * @returns true if a route has been found */ - protected _routeToInternal(url: Url): void { - this._route(Array.from(this.internalRoutes.entries()), url); + protected _routeToInternal(url: Url): RouteAttempt { + return this._route(this.internalRoutes.entries(), url); } /** @@ -69,7 +99,7 @@ export abstract class LensProtocolRouter extends Singleton { * @param routes the array of path schemas, handler pairs to match against * @param url the url (in its current state) */ - protected _findMatchingRoute(routes: [string, RouteHandler][], url: Url): null | [match>, RouteHandler] { + protected _findMatchingRoute(routes: Iterable<[string, RouteHandler]>, url: Url): null | [match>, RouteHandler] { const matches: [match>, RouteHandler][] = []; for (const [schema, handler] of routes) { @@ -96,7 +126,7 @@ export abstract class LensProtocolRouter extends Singleton { * @param routes the array of (path schemas, handler) pairs to match against * @param url the url (in its current state) */ - protected _route(routes: [string, RouteHandler][], url: Url, extensionName?: string): void { + protected _route(routes: Iterable<[string, RouteHandler]>, url: Url, extensionName?: string): RouteAttempt { const route = this._findMatchingRoute(routes, url); if (!route) { @@ -106,7 +136,9 @@ export abstract class LensProtocolRouter extends Singleton { data.extensionName = extensionName; } - return void logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data); + logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data); + + return RouteAttempt.MISSING; } const [match, handler] = route; @@ -121,6 +153,8 @@ export abstract class LensProtocolRouter extends Singleton { } handler(params); + + return RouteAttempt.MATCHED; } /** @@ -174,23 +208,22 @@ export abstract class LensProtocolRouter extends Singleton { * Note: this function modifies its argument, do not reuse * @param url the protocol request URI that was "open"-ed */ - protected async _routeToExtension(url: Url): Promise { + protected async _routeToExtension(url: Url): Promise { const extension = await this._findMatchingExtensionByName(url); if (typeof extension === "string") { // failed to find an extension, it returned its name - return; + return RouteAttempt.MISSING_EXTENSION; } // remove the extension name from the path name so we don't need to match on it anymore url.set("pathname", url.pathname.slice(extension.name.length + 1)); - const handlers = extension - .protocolHandlers - .map<[string, RouteHandler]>(({ pathSchema, handler }) => [pathSchema, handler]); try { - this._route(handlers, url, extension.name); + const handlers = iter.map(extension.protocolHandlers, ({ pathSchema, handler }) => [pathSchema, handler] as [string, RouteHandler]); + + return this._route(handlers, url, extension.name); } catch (error) { if (error instanceof RoutingError) { error.extensionName = extension.name; diff --git a/src/common/utils/buildUrl.ts b/src/common/utils/buildUrl.ts index d373aa634d..8ad88945df 100644 --- a/src/common/utils/buildUrl.ts +++ b/src/common/utils/buildUrl.ts @@ -41,3 +41,11 @@ export function buildURL

(path: str return parts.filter(Boolean).join(""); }; } + +export function buildURLPositional

(path: string | any) { + const builder = buildURL(path); + + return function(params?: P, query?: Q, fragment?: string): string { + return builder({ params, query, fragment }); + }; +} diff --git a/src/common/vars.ts b/src/common/vars.ts index 94ded7028e..6a55683d48 100644 --- a/src/common/vars.ts +++ b/src/common/vars.ts @@ -37,7 +37,7 @@ export const isPublishConfigured = Object.keys(packageInfo.build).includes("publ export const productName = packageInfo.productName; export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`; -export const publicPath = "/build/"; +export const publicPath = "/build/" as string; // Webpack build paths export const contextDir = process.cwd(); @@ -60,13 +60,13 @@ defineGlobal("__static", { }); // Apis -export const apiPrefix = "/api"; // local router apis -export const apiKubePrefix = "/api-kube"; // k8s cluster apis +export const apiPrefix = "/api" as string; // local router apis +export const apiKubePrefix = "/api-kube" as string; // k8s cluster apis // Links -export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues"; -export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI"; -export const supportUrl = "https://docs.k8slens.dev/latest/support/"; +export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as string; +export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI" as string; +export const supportUrl = "https://docs.k8slens.dev/latest/support/" as string; // This explicitly ignores the prerelease info on the package version const { major, minor, patch } = new SemVer(packageInfo.version); diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 8a0929d016..829e8a617f 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -34,6 +34,7 @@ import { extensionInstaller, PackageJson } from "./extension-installer"; import { ExtensionsStore } from "./extensions-store"; import { ExtensionLoader } from "./extension-loader"; import type { LensExtensionId, LensExtensionManifest } from "./lens-extension"; +import { isProduction } from "../common/vars"; export interface InstalledExtension { id: LensExtensionId; @@ -353,7 +354,7 @@ export class ExtensionDiscovery extends Singleton { const isEnabled = isBundled || ExtensionsStore.getInstance().isEnabled(installedManifestPath); const extensionDir = path.dirname(manifestPath); const npmPackage = path.join(extensionDir, `${manifest.name}-${manifest.version}.tgz`); - const absolutePath = (await fse.pathExists(npmPackage)) ? npmPackage : extensionDir; + const absolutePath = (isProduction && await fse.pathExists(npmPackage)) ? npmPackage : extensionDir; return { id: installedManifestPath, diff --git a/src/extensions/extension-loader.ts b/src/extensions/extension-loader.ts index dc96b892f2..8651455380 100644 --- a/src/extensions/extension-loader.ts +++ b/src/extensions/extension-loader.ts @@ -279,6 +279,7 @@ export class ExtensionLoader extends Singleton { registries.kubeObjectMenuRegistry.add(extension.kubeObjectMenuItems), registries.kubeObjectDetailRegistry.add(extension.kubeObjectDetailItems), registries.kubeObjectStatusRegistry.add(extension.kubeObjectStatusTexts), + registries.workloadsOverviewDetailRegistry.add(extension.kubeWorkloadsOverviewItems), registries.commandRegistry.add(extension.commands), ]; diff --git a/src/extensions/lens-renderer-extension.ts b/src/extensions/lens-renderer-extension.ts index 520ffae05b..e9105dbcd9 100644 --- a/src/extensions/lens-renderer-extension.ts +++ b/src/extensions/lens-renderer-extension.ts @@ -21,7 +21,7 @@ import type { AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, - KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, WelcomeMenuRegistration, + KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, WelcomeMenuRegistration, WorkloadsOverviewDetailRegistration, } from "./registries"; import type { Cluster } from "../main/cluster"; import { LensExtension } from "./lens-extension"; @@ -40,6 +40,7 @@ export class LensRendererExtension extends LensExtension { statusBarItems: StatusBarRegistration[] = []; kubeObjectDetailItems: KubeObjectDetailRegistration[] = []; kubeObjectMenuItems: KubeObjectMenuRegistration[] = []; + kubeWorkloadsOverviewItems: WorkloadsOverviewDetailRegistration[] = []; commands: CommandRegistration[] = []; welcomeMenus: WelcomeMenuRegistration[] = []; diff --git a/src/extensions/main-api/index.ts b/src/extensions/main-api/index.ts index 72102ebc24..a596e8169e 100644 --- a/src/extensions/main-api/index.ts +++ b/src/extensions/main-api/index.ts @@ -20,11 +20,13 @@ */ import * as Catalog from "./catalog"; +import * as Navigation from "./navigation"; import { IpcMain as Ipc } from "../ipc/ipc-main"; import { LensMainExtension as LensExtension } from "../lens-main-extension"; export { Catalog, + Navigation, Ipc, LensExtension, }; diff --git a/src/extensions/main-api/navigation.ts b/src/extensions/main-api/navigation.ts new file mode 100644 index 0000000000..95ae718b33 --- /dev/null +++ b/src/extensions/main-api/navigation.ts @@ -0,0 +1,26 @@ +/** + * 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 { WindowManager } from "../../main/window-manager"; + +export function navigate(url: string) { + return WindowManager.getInstance().navigate(url); +} diff --git a/src/extensions/registries/index.ts b/src/extensions/registries/index.ts index 7056206daf..e3a6490d61 100644 --- a/src/extensions/registries/index.ts +++ b/src/extensions/registries/index.ts @@ -33,3 +33,4 @@ export * from "./command-registry"; export * from "./entity-setting-registry"; export * from "./welcome-menu-registry"; export * from "./protocol-handler-registry"; +export * from "./workloads-overview-detail-registry"; diff --git a/src/extensions/registries/workloads-overview-detail-registry.ts b/src/extensions/registries/workloads-overview-detail-registry.ts new file mode 100644 index 0000000000..8ad2a10e19 --- /dev/null +++ b/src/extensions/registries/workloads-overview-detail-registry.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import type React from "react"; +import { BaseRegistry } from "./base-registry"; + +export interface WorkloadsOverviewDetailComponents { + Details: React.ComponentType; +} + +export interface WorkloadsOverviewDetailRegistration { + components: WorkloadsOverviewDetailComponents; + priority?: number; +} + +export class WorkloadsOverviewDetailRegistry extends BaseRegistry { + getItems() { + const items = super.getItems(); + + return items.sort((a, b) => (b.priority ?? 50) - (a.priority ?? 50)); + } +} + +export const workloadsOverviewDetailRegistry = new WorkloadsOverviewDetailRegistry(); diff --git a/src/extensions/renderer-api/components.ts b/src/extensions/renderer-api/components.ts index 110b40bbb2..49b3ef933c 100644 --- a/src/extensions/renderer-api/components.ts +++ b/src/extensions/renderer-api/components.ts @@ -53,6 +53,7 @@ export * from "../../renderer/components/stepper"; export * from "../../renderer/components/wizard"; export * from "../../renderer/components/+workloads-pods/pod-details-list"; export * from "../../renderer/components/+namespaces/namespace-select"; +export * from "../../renderer/components/+namespaces/namespace-select-filter"; export * from "../../renderer/components/layout/sub-title"; export * from "../../renderer/components/input/search-input"; export * from "../../renderer/components/chart/bar-chart"; diff --git a/src/main/catalog-pusher.ts b/src/main/catalog-pusher.ts index e97dc07038..5c9a41b702 100644 --- a/src/main/catalog-pusher.ts +++ b/src/main/catalog-pusher.ts @@ -24,10 +24,17 @@ import { broadcastMessage } from "../common/ipc"; import type { CatalogEntityRegistry } from "./catalog"; import "../common/catalog-entities/kubernetes-cluster"; import { toJS } from "../common/utils"; +import { debounce } from "lodash"; +import type { CatalogEntity } from "../common/catalog"; + + +const broadcaster = debounce((items: CatalogEntity[]) => { + broadcastMessage("catalog:items", items); +}, 1_000, { trailing: true }); export function pushCatalogToRenderer(catalog: CatalogEntityRegistry) { return reaction(() => toJS(catalog.items), (items) => { - broadcastMessage("catalog:items", items); + broadcaster(items); }, { fireImmediately: true, }); diff --git a/src/main/catalog/catalog-entity-registry.ts b/src/main/catalog/catalog-entity-registry.ts index 55162970a2..4cecf4f49b 100644 --- a/src/main/catalog/catalog-entity-registry.ts +++ b/src/main/catalog/catalog-entity-registry.ts @@ -48,6 +48,15 @@ export class CatalogEntityRegistry { return allItems.filter((entity) => this.categoryRegistry.getCategoryForEntity(entity) !== undefined); } + getById(id: string): T | undefined { + const item = this.items.find((entity) => entity.metadata.uid === id); + + if (item) return item as T; + + + return undefined; + } + getItemsForApiKind(apiVersion: string, kind: string): T[] { const items = this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind); diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index b0eb2e7471..2daefb689a 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -79,8 +79,7 @@ export class ClusterManager extends Singleton { if (index !== -1) { const entity = catalogEntityRegistry.items[index] as KubernetesCluster; - entity.status.phase = cluster.disconnected ? "disconnected" : "connected"; - entity.status.active = !cluster.disconnected; + this.updateEntityStatus(entity, cluster); if (cluster.preferences?.clusterName) { entity.metadata.name = cluster.preferences.clusterName; @@ -101,6 +100,10 @@ export class ClusterManager extends Singleton { } } + protected updateEntityStatus(entity: KubernetesCluster, cluster: Cluster) { + entity.status.phase = cluster.accessible ? "connected" : "disconnected"; + } + @action syncClustersFromCatalog(entities: KubernetesCluster[]) { for (const entity of entities) { const cluster = this.store.getById(entity.metadata.uid); @@ -118,10 +121,7 @@ export class ClusterManager extends Singleton { cluster.kubeConfigPath = entity.spec.kubeconfigPath; cluster.contextName = entity.spec.kubeconfigContext; - entity.status = { - phase: cluster.disconnected ? "disconnected" : "connected", - active: !cluster.disconnected - }; + this.updateEntityStatus(entity, cluster); } } } diff --git a/src/main/helm/helm-release-manager.ts b/src/main/helm/helm-release-manager.ts index 852c649ede..9ec1237c07 100644 --- a/src/main/helm/helm-release-manager.ts +++ b/src/main/helm/helm-release-manager.ts @@ -22,7 +22,7 @@ import * as tempy from "tempy"; import fse from "fs-extra"; import * as yaml from "js-yaml"; -import { promiseExec} from "../promise-exec"; +import { promiseExec } from "../promise-exec"; import { helmCli } from "./helm-cli"; import type { Cluster } from "../cluster"; import { toCamelCase } from "../../common/utils/camelCase"; @@ -39,7 +39,7 @@ export async function listReleases(pathToKubeconfig: string, namespace?: string) return output; } output.forEach((release: any, index: number) => { - output[index] = toCamelCase(release); + output[index] = toCamelCase(release); }); return output; @@ -49,9 +49,9 @@ export async function listReleases(pathToKubeconfig: string, namespace?: string) } -export async function installChart(chart: string, values: any, name: string | undefined, namespace: string, version: string, pathToKubeconfig: string){ +export async function installChart(chart: string, values: any, name: string | undefined, namespace: string, version: string, pathToKubeconfig: string) { const helm = await helmCli.binaryPath(); - const fileName = tempy.file({name: "values.yaml"}); + const fileName = tempy.file({ name: "values.yaml" }); await fse.writeFile(fileName, yaml.safeDump(values)); @@ -81,7 +81,7 @@ export async function installChart(chart: string, values: any, name: string | un export async function upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster) { const helm = await helmCli.binaryPath(); - const fileName = tempy.file({name: "values.yaml"}); + const fileName = tempy.file({ name: "values.yaml" }); await fse.writeFile(fileName, yaml.safeDump(values)); @@ -119,7 +119,7 @@ export async function getRelease(name: string, namespace: string, cluster: Clust export async function deleteRelease(name: string, namespace: string, pathToKubeconfig: string) { try { const helm = await helmCli.binaryPath(); - const { stdout } = await promiseExec(`"${helm}" delete ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`); + const { stdout } = await promiseExec(`"${helm}" delete ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`); return stdout; } catch ({ stderr }) { @@ -127,10 +127,16 @@ export async function deleteRelease(name: string, namespace: string, pathToKubec } } -export async function getValues(name: string, namespace: string, all: boolean, pathToKubeconfig: string) { +interface GetValuesOptions { + namespace: string; + all?: boolean; + pathToKubeconfig: string; +} + +export async function getValues(name: string, { namespace, all = false, pathToKubeconfig }: GetValuesOptions) { try { const helm = await helmCli.binaryPath(); - const { stdout, } = await promiseExec(`"${helm}" get values ${name} ${all ? "--all": ""} --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`); + const { stdout } = await promiseExec(`"${helm}" get values ${name} ${all ? "--all" : ""} --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`); return stdout; } catch ({ stderr }) { @@ -167,8 +173,8 @@ async function getResources(name: string, namespace: string, cluster: Cluster) { 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`); - return stdout; + return JSON.parse(stdout).items; } catch { - return { stdout: JSON.stringify({ items: [] }) }; + return []; } } diff --git a/src/main/helm/helm-service.ts b/src/main/helm/helm-service.ts index 73e93e5ac1..01262b6ef8 100644 --- a/src/main/helm/helm-service.ts +++ b/src/main/helm/helm-service.ts @@ -27,6 +27,12 @@ import { HelmChartManager } from "./helm-chart-manager"; import type { HelmChartList, RepoHelmChartList } from "../../renderer/api/endpoints/helm-charts.api"; import { deleteRelease, getHistory, getRelease, getValues, installChart, listReleases, rollback, upgradeRelease } from "./helm-release-manager"; +interface GetReleaseValuesArgs { + cluster: Cluster; + namespace: string; + all: boolean; +} + class HelmService { public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) { const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); @@ -86,12 +92,12 @@ class HelmService { return getRelease(releaseName, namespace, cluster); } - public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string, all: boolean) { - const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); + public async getReleaseValues(releaseName: string, { cluster, namespace, all }: GetReleaseValuesArgs) { + const pathToKubeconfig = await cluster.getProxyKubeconfigPath(); logger.debug("Fetch release values"); - return getValues(releaseName, namespace, all, proxyKubeconfig); + return getValues(releaseName, { namespace, all, pathToKubeconfig }); } public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) { diff --git a/src/main/index.ts b/src/main/index.ts index 858c903881..0396f840a1 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -93,8 +93,11 @@ if (!app.requestSingleInstanceLock()) { for (const arg of process.argv) { if (arg.toLowerCase().startsWith("lens://")) { - lprm.route(arg) - .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg })); + try { + lprm.route(arg); + } catch (error) { + logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg }); + } } } } @@ -104,8 +107,11 @@ app.on("second-instance", (event, argv) => { for (const arg of argv) { if (arg.toLowerCase().startsWith("lens://")) { - lprm.route(arg) - .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg })); + try { + lprm.route(arg); + } catch (error) { + logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg }); + } } } @@ -252,9 +258,10 @@ autoUpdater.on("before-quit-for-update", () => blockQuit = false); app.on("will-quit", (event) => { // Quit app on Cmd+Q (MacOS) logger.info("APP:QUIT"); - appEventBus.emit({name: "app", action: "close"}); + appEventBus.emit({ name: "app", action: "close" }); ClusterManager.getInstance(false)?.stop(); // close cluster connections KubeconfigSyncManager.getInstance(false)?.stopSync(); + LensProtocolRouterMain.getInstance(false)?.cleanup(); cleanup(); if (blockQuit) { @@ -268,10 +275,11 @@ app.on("open-url", (event, rawUrl) => { // lens:// protocol handler event.preventDefault(); - LensProtocolRouterMain - .getInstance() - .route(rawUrl) - .catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl })); + try { + LensProtocolRouterMain.getInstance().route(rawUrl); + } catch (error) { + logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl }); + } }); /** diff --git a/src/main/protocol-handler/__test__/router.test.ts b/src/main/protocol-handler/__test__/router.test.ts index 5e6d6612c4..597be0c124 100644 --- a/src/main/protocol-handler/__test__/router.test.ts +++ b/src/main/protocol-handler/__test__/router.test.ts @@ -23,7 +23,7 @@ import * as uuid from "uuid"; import { broadcastMessage } from "../../../common/ipc"; import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler"; -import { noop } from "../../../common/utils"; +import { delay, noop } from "../../../common/utils"; import { LensExtension } from "../../../extensions/main-api"; import { ExtensionLoader } from "../../../extensions/extension-loader"; import { ExtensionsStore } from "../../../extensions/extensions-store"; @@ -56,27 +56,27 @@ describe("protocol router tests", () => { LensProtocolRouterMain.resetInstance(); }); - it("should throw on non-lens URLS", async () => { + it("should throw on non-lens URLS", () => { try { const lpr = LensProtocolRouterMain.getInstance(); - expect(await lpr.route("https://google.ca")).toBeUndefined(); + expect(lpr.route("https://google.ca")).toBeUndefined(); } catch (error) { expect(error).toBeInstanceOf(Error); } }); - it("should throw when host not internal or extension", async () => { + it("should throw when host not internal or extension", () => { try { const lpr = LensProtocolRouterMain.getInstance(); - expect(await lpr.route("lens://foobar")).toBeUndefined(); + expect(lpr.route("lens://foobar")).toBeUndefined(); } catch (error) { expect(error).toBeInstanceOf(Error); } }); - it.only("should not throw when has valid host", async () => { + it("should not throw when has valid host", async () => { const extId = uuid.v4(); const ext = new LensExtension({ id: extId, @@ -102,38 +102,39 @@ describe("protocol router tests", () => { lpr.addInternalHandler("/", noop); try { - expect(await lpr.route("lens://app")).toBeUndefined(); + expect(lpr.route("lens://app")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } try { - expect(await lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined(); + expect(lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } - expect(broadcastMessage).toHaveBeenNthCalledWith(1, ProtocolHandlerInternal, "lens://app/"); - expect(broadcastMessage).toHaveBeenNthCalledWith(2, ProtocolHandlerExtension, "lens://extension/@mirantis/minikube"); + await delay(50); + expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerInternal, "lens://app/", "matched"); + expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerExtension, "lens://extension/@mirantis/minikube", "matched"); }); - it("should call handler if matches", async () => { + it("should call handler if matches", () => { const lpr = LensProtocolRouterMain.getInstance(); let called = false; lpr.addInternalHandler("/page", () => { called = true; }); try { - expect(await lpr.route("lens://app/page")).toBeUndefined(); + expect(lpr.route("lens://app/page")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } expect(called).toBe(true); - expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page"); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page", "matched"); }); - it("should call most exact handler", async () => { + it("should call most exact handler", () => { const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; @@ -141,13 +142,13 @@ describe("protocol router tests", () => { lpr.addInternalHandler("/page/:id", params => { called = params.pathname.id; }); try { - expect(await lpr.route("lens://app/page/foo")).toBeUndefined(); + expect(lpr.route("lens://app/page/foo")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } expect(called).toBe("foo"); - expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo"); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo", "matched"); }); it("should call most exact handler for an extension", async () => { @@ -180,13 +181,14 @@ describe("protocol router tests", () => { (ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); try { - expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); + expect(lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } + await delay(50); expect(called).toBe("foob"); - expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/@foobar/icecream/page/foob"); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/@foobar/icecream/page/foob", "matched"); }); it("should work with non-org extensions", async () => { @@ -245,13 +247,15 @@ describe("protocol router tests", () => { (ExtensionsStore.getInstance() as any).state.set("icecream", { enabled: true, name: "icecream" }); try { - expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined(); + expect(lpr.route("lens://extension/icecream/page")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } + await delay(50); + expect(called).toBe(1); - expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/icecream/page"); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/icecream/page", "matched"); }); it("should throw if urlSchema is invalid", () => { @@ -260,7 +264,7 @@ describe("protocol router tests", () => { expect(() => lpr.addInternalHandler("/:@", noop)).toThrowError(); }); - it("should call most exact handler with 3 found handlers", async () => { + it("should call most exact handler with 3 found handlers", () => { const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; @@ -270,16 +274,16 @@ describe("protocol router tests", () => { lpr.addInternalHandler("/page/bar", () => { called = 4; }); try { - expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); + expect(lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } expect(called).toBe(3); - expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat"); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat", "matched"); }); - it("should call most exact handler with 2 found handlers", async () => { + it("should call most exact handler with 2 found handlers", () => { const lpr = LensProtocolRouterMain.getInstance(); let called: any = 0; @@ -288,12 +292,12 @@ describe("protocol router tests", () => { lpr.addInternalHandler("/page/bar", () => { called = 4; }); try { - expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); + expect(lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); } catch (error) { expect(throwIfDefined(error)).not.toThrow(); } expect(called).toBe(1); - expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat"); + expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat", "matched"); }); }); diff --git a/src/main/protocol-handler/router.ts b/src/main/protocol-handler/router.ts index 087ab3fd88..25c7bbefba 100644 --- a/src/main/protocol-handler/router.ts +++ b/src/main/protocol-handler/router.ts @@ -25,6 +25,8 @@ import Url from "url-parse"; import type { LensExtension } from "../../extensions/lens-extension"; import { broadcastMessage } from "../../common/ipc"; import { observable, when, makeObservable } from "mobx"; +import { ProtocolHandlerInvalid, RouteAttempt } from "../../common/protocol-handler"; +import { disposer } from "../../common/utils"; export interface FallbackHandler { (name: string): Promise; @@ -36,19 +38,25 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { @observable rendererLoaded = false; @observable extensionsLoaded = false; + protected disposers = disposer(); + constructor() { super(); makeObservable(this); } + public cleanup() { + this.disposers(); + } + /** * Find the most specific registered handler, if it exists, and invoke it. * * This will send an IPC message to the renderer router to do the same * in the renderer. */ - public async route(rawUrl: string): Promise { + public route(rawUrl: string) { try { const url = new Url(rawUrl, true); @@ -60,16 +68,18 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { switch (url.host) { case "app": - return this._routeToInternal(url); + this._routeToInternal(url); + break; case "extension": - await when(() => this.extensionsLoaded); - - return this._routeToExtension(url); + this.disposers.push(when(() => this.extensionsLoaded, () => this._routeToExtension(url))); + break; default: throw new proto.RoutingError(proto.RoutingErrorType.INVALID_HOST, url); } } catch (error) { + broadcastMessage(ProtocolHandlerInvalid, error.toString(), rawUrl); + if (error instanceof proto.RoutingError) { logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url }); } else { @@ -102,17 +112,16 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { return ""; } - protected async _routeToInternal(url: Url): Promise { + protected _routeToInternal(url: Url): RouteAttempt { const rawUrl = url.toString(); // for sending to renderer + const attempt = super._routeToInternal(url); - super._routeToInternal(url); + this.disposers.push(when(() => this.rendererLoaded, () => broadcastMessage(proto.ProtocolHandlerInternal, rawUrl, attempt))); - await when(() => this.rendererLoaded); - - return broadcastMessage(proto.ProtocolHandlerInternal, rawUrl); + return attempt; } - protected async _routeToExtension(url: Url): Promise { + protected async _routeToExtension(url: Url): Promise { const rawUrl = url.toString(); // for sending to renderer /** @@ -122,10 +131,11 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter { * Note: this needs to clone the url because _routeToExtension modifies its * argument. */ - await super._routeToExtension(new Url(url.toString(), true)); - await when(() => this.rendererLoaded); + const attempt = await super._routeToExtension(new Url(url.toString(), true)); - return broadcastMessage(proto.ProtocolHandlerExtension, rawUrl); + this.disposers.push(when(() => this.rendererLoaded, () => broadcastMessage(proto.ProtocolHandlerExtension, rawUrl, attempt))); + + return attempt; } /** diff --git a/src/main/routes/helm-route.ts b/src/main/routes/helm-route.ts index 7a4033d4f0..5e104f6a11 100644 --- a/src/main/routes/helm-route.ts +++ b/src/main/routes/helm-route.ts @@ -21,8 +21,9 @@ import type { LensApiRequest } from "../router"; import { helmService } from "../helm/helm-service"; -import { respondJson, respondText } from "../utils/http-responses"; import logger from "../logger"; +import { respondJson, respondText } from "../utils/http-responses"; +import { getBoolean } from "./utils/parse-query"; export class HelmApiRoute { static async listCharts(request: LensApiRequest) { @@ -122,10 +123,11 @@ export class HelmApiRoute { } static async getReleaseValues(request: LensApiRequest) { - const { cluster, params, response, query } = request; + const { cluster, params: { namespace, release }, response, query } = request; + const all = getBoolean(query, "all"); try { - const result = await helmService.getReleaseValues(cluster, params.release, params.namespace, query.has("all")); + const result = await helmService.getReleaseValues(release, { cluster, namespace, all }); respondText(response, result); } catch (error) { diff --git a/src/main/routes/utils/index.ts b/src/main/routes/utils/index.ts new file mode 100644 index 0000000000..4a7de80225 --- /dev/null +++ b/src/main/routes/utils/index.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +export * from "./parse-query"; diff --git a/src/main/routes/utils/parse-query.ts b/src/main/routes/utils/parse-query.ts new file mode 100644 index 0000000000..b426f40c28 --- /dev/null +++ b/src/main/routes/utils/parse-query.ts @@ -0,0 +1,34 @@ +/** + * 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 function getBoolean(query: URLSearchParams, key: string): boolean { + const value = query.get(key); + + switch (value?.toLowerCase()) { + case "false": + case "f": + case "0": + case null: + case undefined: + return false; + default: + return true; + } +} diff --git a/src/renderer/api/__tests__/catalog-entity-registry.test.ts b/src/renderer/api/__tests__/catalog-entity-registry.test.ts index 8a26aabc00..4c3bcb48c3 100644 --- a/src/renderer/api/__tests__/catalog-entity-registry.test.ts +++ b/src/renderer/api/__tests__/catalog-entity-registry.test.ts @@ -22,14 +22,36 @@ import { CatalogEntityRegistry } from "../catalog-entity-registry"; import "../../../common/catalog-entities"; import { catalogCategoryRegistry } from "../../../common/catalog/catalog-category-registry"; -import type { CatalogEntityData, CatalogEntityKindData } from "../catalog-entity"; +import { CatalogCategory, CatalogEntityData, CatalogEntityKindData } from "../catalog-entity"; +import { WebLink } from "../../../common/catalog-entities"; class TestCatalogEntityRegistry extends CatalogEntityRegistry { replaceItems(items: Array) { - this.rawItems.replace(items); + this.updateItems(items); } } +class FooBarCategory extends CatalogCategory { + public readonly apiVersion = "catalog.k8slens.dev/v1alpha1"; + public readonly kind = "CatalogCategory"; + public metadata = { + name: "FooBars", + icon: "broken" + }; + public spec = { + group: "entity.k8slens.dev", + versions: [ + { + name: "v1alpha1", + entityClass: WebLink + } + ], + names: { + kind: "FooBar" + } + }; +} + describe("CatalogEntityRegistry", () => { describe("updateItems", () => { it("adds new catalog item", () => { @@ -99,6 +121,32 @@ describe("CatalogEntityRegistry", () => { expect(catalog.items[0].status.phase).toEqual("connected"); }); + it("updates activeEntity", () => { + const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); + const items = [{ + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "KubernetesCluster", + metadata: { + uid: "123", + name: "foobar", + source: "test", + labels: {} + }, + status: { + phase: "disconnected" + }, + spec: {} + }]; + + catalog.replaceItems(items); + catalog.activeEntity = catalog.items[0]; + expect(catalog.activeEntity.status.phase).toEqual("disconnected"); + + items[0].status.phase = "connected"; + catalog.replaceItems(items); + expect(catalog.activeEntity.status.phase).toEqual("connected"); + }); + it("removes deleted items", () => { const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); const items = [ @@ -175,8 +223,31 @@ describe("CatalogEntityRegistry", () => { ]; catalog.replaceItems(items); - expect(catalog.items.length).toBe(1); }); }); + + it("does return items after matching category is added", () => { + const catalog = new TestCatalogEntityRegistry(catalogCategoryRegistry); + const items = [ + { + apiVersion: "entity.k8slens.dev/v1alpha1", + kind: "FooBar", + metadata: { + uid: "456", + name: "barbaz", + source: "test", + labels: {} + }, + status: { + phase: "disconnected" + }, + spec: {} + } + ]; + + catalog.replaceItems(items); + catalogCategoryRegistry.add(new FooBarCategory()); + expect(catalog.items.length).toBe(1); + }); }); diff --git a/src/renderer/api/catalog-entity-registry.ts b/src/renderer/api/catalog-entity-registry.ts index e2f83058b8..0a30db31a2 100644 --- a/src/renderer/api/catalog-entity-registry.ts +++ b/src/renderer/api/catalog-entity-registry.ts @@ -19,17 +19,21 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { computed, makeObservable, observable } from "mobx"; +import { computed, observable, makeObservable, action } from "mobx"; import { subscribeToBroadcast } from "../../common/ipc"; import { CatalogCategory, catalogCategoryRegistry, CatalogCategoryRegistry, CatalogEntity, CatalogEntityData, CatalogEntityKindData } from "../../common/catalog"; import "../../common/catalog-entities"; -import { iter } from "../utils"; import type { Cluster } from "../../main/cluster"; import { ClusterStore } from "../../common/cluster-store"; export class CatalogEntityRegistry { - protected rawItems = observable.array(); - @observable.ref activeEntity?: CatalogEntity; + @observable.ref activeEntity: CatalogEntity; + protected _entities = observable.map([], { deep: true }); + + /** + * Buffer for keeping entities that don't yet have CatalogCategory synced + */ + protected rawEntities: (CatalogEntityData & CatalogEntityKindData)[] = []; constructor(private categoryRegistry: CatalogCategoryRegistry) { makeObservable(this); @@ -37,20 +41,68 @@ export class CatalogEntityRegistry { init() { subscribeToBroadcast("catalog:items", (ev, items: (CatalogEntityData & CatalogEntityKindData)[]) => { - this.rawItems.replace(items); + this.updateItems(items); }); } + @action updateItems(items: (CatalogEntityData & CatalogEntityKindData)[]) { + this.rawEntities.length = 0; + + const newIds = new Set(items.map((item) => item.metadata.uid)); + + for (const uid of this._entities.keys()) { + if (!newIds.has(uid)) { + this._entities.delete(uid); + } + } + + for (const item of items) { + this.updateItem(item); + } + } + + @action protected updateItem(item: (CatalogEntityData & CatalogEntityKindData)) { + const existing = this._entities.get(item.metadata.uid); + + if (!existing) { + const entity = this.categoryRegistry.getEntityForData(item); + + if (entity) { + this._entities.set(entity.metadata.uid, entity); + } else { + this.rawEntities.push(item); + } + } else { + existing.metadata = item.metadata; + existing.spec = item.spec; + existing.status = item.status; + } + } + + protected processRawEntities() { + const items = [...this.rawEntities]; + + this.rawEntities.length = 0; + + for (const item of items) { + this.updateItem(item); + } + } + @computed get items() { - return Array.from(iter.filterMap(this.rawItems, rawItem => this.categoryRegistry.getEntityForData(rawItem))); + this.processRawEntities(); + + return Array.from(this._entities.values()); } @computed get entities(): Map { - return new Map(this.items.map(item => [item.metadata.uid, item])); + this.processRawEntities(); + + return this._entities; } - getById(id: string) { - return this.entities.get(id); + getById(id: string) { + return this.entities.get(id) as T; } getItemsForApiKind(apiVersion: string, kind: string): T[] { diff --git a/src/renderer/api/endpoints/helm-releases.api.ts b/src/renderer/api/endpoints/helm-releases.api.ts index 0e62027dbe..9a4a05875c 100644 --- a/src/renderer/api/endpoints/helm-releases.api.ts +++ b/src/renderer/api/endpoints/helm-releases.api.ts @@ -20,7 +20,6 @@ */ import jsYaml from "js-yaml"; -import { compile } from "path-to-regexp"; import { autoBind, formatDuration } from "../../utils"; import capitalize from "lodash/capitalize"; import { apiBase } from "../index"; @@ -28,6 +27,8 @@ import { helmChartStore } from "../../components/+apps-helm-charts/helm-chart.st import type { ItemObject } from "../../item.store"; import { KubeObject } from "../kube-object"; import type { JsonApiData } from "../json-api"; +import { buildURLPositional } from "../../../common/utils/buildUrl"; +import type { KubeJsonApiData } from "../kube-json-api"; interface IReleasePayload { name: string; @@ -46,7 +47,7 @@ interface IReleasePayload { } interface IReleaseRawDetails extends IReleasePayload { - resources: string; + resources: KubeJsonApiData[]; } export interface IReleaseDetails extends IReleasePayload { @@ -83,12 +84,16 @@ export interface IReleaseRevision { description: string; } -const endpoint = compile(`/v2/releases/:namespace?/:name?`) as ( - params?: { - namespace?: string; - name?: string; - } -) => string; +type EndpointParams = {} + | { namespace: string } + | { namespace: string, name: string } + | { namespace: string, name: string, route: string }; + +interface EndpointQuery { + all?: boolean; +} + +const endpoint = buildURLPositional("/v2/releases/:namespace?/:name?/:route?"); export async function listReleases(namespace?: string): Promise { const releases = await apiBase.get(endpoint({ namespace })); @@ -98,10 +103,8 @@ export async function listReleases(namespace?: string): Promise { export async function getRelease(name: string, namespace: string): Promise { const path = endpoint({ name, namespace }); - - const details = await apiBase.get(path); - const items: KubeObject[] = JSON.parse(details.resources).items; - const resources = items.map(item => KubeObject.create(item)); + const { resources: rawResources, ...details } = await apiBase.get(path); + const resources = rawResources.map(KubeObject.create); return { ...details, @@ -134,25 +137,25 @@ export async function deleteRelease(name: string, namespace: string): Promise { - const path = `${endpoint({ name, namespace })}/values${all? "?all": ""}`; + const route = "values"; + const path = endpoint({ name, namespace, route }, { all }); return apiBase.get(path); } export async function getReleaseHistory(name: string, namespace: string): Promise { - const path = `${endpoint({ name, namespace })}/history`; + const route = "history"; + const path = endpoint({ name, namespace, route }); return apiBase.get(path); } export async function rollbackRelease(name: string, namespace: string, revision: number): Promise { - const path = `${endpoint({ name, namespace })}/rollback`; + const route = "rollback"; + const path = endpoint({ name, namespace, route }); + const data = { revision }; - return apiBase.put(path, { - data: { - revision - } - }); + return apiBase.put(path, { data }); } export interface HelmRelease { @@ -190,7 +193,7 @@ export class HelmRelease implements ItemObject { getChart(withVersion = false) { let chart = this.chart; - if(!withVersion && this.getVersion() != "" ) { + if (!withVersion && this.getVersion() != "") { const search = new RegExp(`-${this.getVersion()}`); chart = chart.replace(search, ""); @@ -210,12 +213,7 @@ export class HelmRelease implements ItemObject { getVersion() { const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/); - if (versions) { - return versions[0]; - } - else { - return ""; - } + return versions?.[0] ?? ""; } getUpdated(humanize = true, compact = true) { diff --git a/src/renderer/api/kube-object.ts b/src/renderer/api/kube-object.ts index 7b5b51cffc..b72beb9383 100644 --- a/src/renderer/api/kube-object.ts +++ b/src/renderer/api/kube-object.ts @@ -97,7 +97,7 @@ export class KubeObject(object: unknown, verifyItem:(val: unknown) => val is T): object is KubeJsonApiDataList { + static isJsonApiDataList(object: unknown, verifyItem: (val: unknown) => val is T): object is KubeJsonApiDataList { return ( isObject(object) && hasTypedProperty(object, "kind", isString) diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.tsx b/src/renderer/components/+apps-helm-charts/helm-charts.tsx index cce8090818..d8cb43a98d 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-charts.tsx @@ -81,7 +81,6 @@ export class HelmCharts extends Component { tableId="helm_charts" className="HelmCharts" store={helmChartStore} - isClusterScoped={true} isSelectable={false} sortingCallbacks={{ [columnId.name]: (chart: HelmChart) => chart.getName(), diff --git a/src/renderer/components/+apps-releases/release-details.tsx b/src/renderer/components/+apps-releases/release-details.tsx index 2e7eca5e1a..d7b6b76279 100644 --- a/src/renderer/components/+apps-releases/release-details.tsx +++ b/src/renderer/components/+apps-releases/release-details.tsx @@ -58,32 +58,35 @@ export class ReleaseDetails extends Component { @observable details: IReleaseDetails; @observable values = ""; @observable valuesLoading = false; - @observable userSuppliedOnly = false; + @observable showOnlyUserSuppliedValues = false; @observable saving = false; @observable releaseSecret: Secret; - @disposeOnUnmount - releaseSelector = reaction(() => this.props.release, release => { - if (!release) return; - this.loadDetails(); - this.loadValues(); - this.releaseSecret = null; + componentDidMount() { + disposeOnUnmount(this, [ + reaction(() => this.props.release, release => { + if (!release) return; + this.loadDetails(); + this.loadValues(); + this.releaseSecret = null; + }), + reaction(() => secretsStore.getItems(), () => { + if (!this.props.release) return; + const { getReleaseSecret } = releaseStore; + const { release } = this.props; + const secret = getReleaseSecret(release); + + if (this.releaseSecret) { + if (isEqual(this.releaseSecret.getLabels(), secret.getLabels())) return; + this.loadDetails(); + } + this.releaseSecret = secret; + }), + reaction(() => this.showOnlyUserSuppliedValues, () => { + this.loadValues(); + }), + ]); } - ); - - @disposeOnUnmount - secretWatcher = reaction(() => secretsStore.getItems(), () => { - if (!this.props.release) return; - const { getReleaseSecret } = releaseStore; - const { release } = this.props; - const secret = getReleaseSecret(release); - - if (this.releaseSecret) { - if (isEqual(this.releaseSecret.getLabels(), secret.getLabels())) return; - this.loadDetails(); - } - this.releaseSecret = secret; - }); constructor(props: Props) { super(props); @@ -100,10 +103,15 @@ export class ReleaseDetails extends Component { async loadValues() { const { release } = this.props; - this.values = ""; - this.valuesLoading = true; - this.values = (await getReleaseValues(release.getName(), release.getNs(), !this.userSuppliedOnly)) ?? ""; - this.valuesLoading = false; + try { + this.valuesLoading = true; + this.values = (await getReleaseValues(release.getName(), release.getNs(), !this.showOnlyUserSuppliedValues)) ?? ""; + } catch (error) { + Notifications.error(`Failed to load values for ${release.getName()}: ${error}`); + this.values = ""; + } finally { + this.valuesLoading = false; + } } updateValues = async () => { @@ -146,21 +154,19 @@ export class ReleaseDetails extends Component {

{ - this.userSuppliedOnly = values; - this.loadValues(); - }} + value={this.showOnlyUserSuppliedValues} + onChange={value => this.showOnlyUserSuppliedValues = value} disabled={valuesLoading} /> - {valuesLoading - ? - : this.values = values} - /> - } + this.values = text} + className={cssNames({ loading: valuesLoading })} + readOnly={valuesLoading || this.showOnlyUserSuppliedValues} + > + {valuesLoading && } +