diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..9770f52991 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,9 @@ +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "npm" # See documentation for possible values + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/.github/workflows/check-docs.yml b/.github/workflows/check-docs.yml index 69b3b7e6d5..eafc6befc0 100644 --- a/.github/workflows/check-docs.yml +++ b/.github/workflows/check-docs.yml @@ -1,37 +1,20 @@ -name: Publish docs via GitHub Pages +name: Check Documentation on: - pull_request jobs: build: name: Check Docs runs-on: ubuntu-latest - if: github.event.label.name != 'area/documentation' + if: ${{ contains(github.event.pull_request.labels.*.name, 'area/documentation') }} strategy: matrix: node-version: [12.x] steps: - - name: Set up Python 3.7 - uses: actions/setup-python@v2 - with: - python-version: '3.x' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install git+https://${{ secrets.GH_TOKEN }}@github.com/lensapp/mkdocs-material-insiders.git - pip install mike - - - name: Checkout Release from lens uses: actions/checkout@v2 with: fetch-depth: 0 - - name: git config - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - - name: Using Node.js ${{ matrix.node-version }} uses: actions/setup-node@v1 with: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b26d80a856..0c6340d6a2 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,12 +7,44 @@ on: types: - published jobs: + verify-docs: + name: Verify docs + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [12.x] + steps: + - name: Set up Python 3.7 + uses: actions/setup-python@v2 + with: + python-version: '3.x' + + - name: Checkout Release from lens + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + - name: Using Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + + - name: Generate Extensions API Reference using typedocs + run: | + yarn install + yarn typedocs-extensions-api + + - name: Verify that the markdown is valid + run: | + yarn run verify-docs + build: name: Deploy docs runs-on: ubuntu-latest strategy: matrix: node-version: [12.x] + needs: verify-docs steps: - name: Set up Python 3.7 uses: actions/setup-python@v2 @@ -25,7 +57,6 @@ jobs: pip install git+https://${{ secrets.GH_TOKEN }}@github.com/lensapp/mkdocs-material-insiders.git pip install mike - - name: Checkout Release from lens uses: actions/checkout@v2 with: @@ -46,11 +77,6 @@ jobs: yarn install yarn typedocs-extensions-api - - name: Verify that the markdown is valid - run: | - yarn run verify-docs - rm -fr site - - name: mkdocs deploy master if: contains(github.ref, 'refs/heads/master') run: | diff --git a/README.md b/README.md index 7d86bf6c52..f5fa4ca42a 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,9 @@ [![Releases](https://img.shields.io/github/downloads/lensapp/lens/total.svg)](https://github.com/lensapp/lens/releases?label=Downloads) [![Chat on Slack](https://img.shields.io/badge/chat-on%20slack-blue.svg?logo=slack&longCache=true&style=flat)](https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI) -World’s most popular Kubernetes IDE provides a simplified, consistent entry point for developers, testers, integrators, and DevOps, to ship code faster at scale. Lens is the only IDE you’ll ever need to take control of your Kubernetes clusters. It is a standalone application for MacOS, Windows and Linux operating systems. Lens is an open source project and free! +Lens provides the full situational awareness for everything that runs in Kubernetes. It's lowering the barrier of entry for people just getting started and radically improving productivity for people with more experience. + +The Lens open source project is backed by a number of Kubernetes and cloud native ecosystem pioneers. It's a standalone application for MacOS, Windows and Linux operating systems. Lens is 100% open source and free of charge for any purpose. [![Screenshot](.github/screenshot.png)](https://youtu.be/04v2ODsmtIs) @@ -21,7 +23,9 @@ World’s most popular Kubernetes IDE provides a simplified, consistent entry po * Performance optimized to handle massive clusters (tested with a cluster running 25k pods) * RBAC security is preserved, as Lens uses the standard Kubernetes API * Lens Extensions are used to add custom visualizations and functionality to accelerate development workflows for all the technologies and services that integrate with Kubernetes +* Port forwarding * Helm package deployment: Browse and deploy Helm charts with one click-Install +* Extensions via Lens Extensions API ## Installation diff --git a/docs/extensions/guides/images/certificates-crd-list.png b/docs/extensions/guides/images/certificates-crd-list.png new file mode 100644 index 0000000000..19c9558f71 Binary files /dev/null and b/docs/extensions/guides/images/certificates-crd-list.png differ diff --git a/docs/extensions/guides/images/kubeobjectdetailitem.png b/docs/extensions/guides/images/kubeobjectdetailitem.png new file mode 100644 index 0000000000..e2d68f0c3b Binary files /dev/null and b/docs/extensions/guides/images/kubeobjectdetailitem.png differ diff --git a/docs/extensions/guides/images/kubeobjectdetailitemwithpods.png b/docs/extensions/guides/images/kubeobjectdetailitemwithpods.png new file mode 100644 index 0000000000..9a91f230f3 Binary files /dev/null and b/docs/extensions/guides/images/kubeobjectdetailitemwithpods.png differ diff --git a/docs/extensions/guides/images/kubeobjectmenuitem.png b/docs/extensions/guides/images/kubeobjectmenuitem.png new file mode 100644 index 0000000000..f9f91675de Binary files /dev/null and b/docs/extensions/guides/images/kubeobjectmenuitem.png differ diff --git a/docs/extensions/guides/images/kubeobjectmenuitemdetail.png b/docs/extensions/guides/images/kubeobjectmenuitemdetail.png new file mode 100644 index 0000000000..ab5f9ac0f0 Binary files /dev/null and b/docs/extensions/guides/images/kubeobjectmenuitemdetail.png differ diff --git a/docs/extensions/guides/kube-object-list-layout.md b/docs/extensions/guides/kube-object-list-layout.md index cc7b84e256..64f6093530 100644 --- a/docs/extensions/guides/kube-object-list-layout.md +++ b/docs/extensions/guides/kube-object-list-layout.md @@ -1,3 +1,268 @@ ---- -WIP ---- +# KubeObjectListLayout Sample + +In this guide we will learn how to list Kubernetes CRD objects on the cluster dashboard. You can see the complete source code for this guide [here](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page). + + +![](./images/certificates-crd-list.png) + +Next, we will go the implementation through in steps. To achieve our goal, we need to: + +1. [Register ClustePage and ClusterPageMenu objects](#register-objects-for-clustepages-and-clusterpagemenus) +2. [List Certificate Objects on the Cluster Page](#list-certificate-objects-on-the-cluster-page) +3. [Customize Details Panel](#customize-details-panel) + +## Register `clusterPage` and `clusterPageMenu` Objects + +First thing we need to do with our extension is to register new menu item in the cluster menu and create a cluster page that is opened when clicking the menu item. We will do this in our extension class `CrdSampleExtension` that is derived `LensRendererExtension` class: + +```typescript +export default class CrdSampleExtension extends LensRendererExtension { +} +``` + +To register menu item in the cluster menu we need to register `PageMenuRegistration` object. 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 +} + +export default class CrdSampleExtension extends LensRendererExtension { + + clusterPageMenus = [ + { + target: { pageId: "certificates" }, + title: "Certificates", + components: { + Icon: CertificateIcon, + } + }, + ] +} +``` + +Then we need to register `PageRegistration` object with `certificates` id and define `CertificatePage` component to render certificates. + +```typescript +export default class CrdSampleExtension extends LensRendererExtension { + ... + + clusterPages = [{ + id: "certificates", + components: { + Page: () => , + MenuIcon: CertificateIcon, + } + }] +} +``` + +## List Certificate Objects on the Cluster Page + +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. + +### 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`. + +`Certificate` class defines properties found in the CRD object: + +```typescript +export class Certificate extends K8sApi.KubeObject { + static kind = "Certificate" + static namespaced = true + static apiBase = "/apis/cert-manager.io/v1alpha2/certificates" + + kind: string + apiVersion: string + metadata: { + name: string; + namespace: string; + selfLink: string; + uid: string; + resourceVersion: string; + creationTimestamp: string; + labels: { + [key: string]: string; + }; + annotations: { + [key: string]: string; + }; + } + spec: { + dnsNames: string[]; + issuerRef: { + group: string; + kind: string; + name: string; + } + secretName: string + } + status: { + conditions: { + lastTransitionTime: string; + message: string; + reason: string; + status: string; + type?: string; + }[]; + } +} +``` + +With `CertificatesApi` class we are able to manage `Certificate` objects in Kubernetes API: + +```typescript +export class CertificatesApi extends K8sApi.KubeApi { +} +export const certificatesApi = new CertificatesApi({ + objectConstructor: Certificate +}); +``` + +`CertificateStore` defines storage for `Certificate` objects + +```typescript +export class CertificatesStore extends K8sApi.KubeObjectStore { + api = certificatesApi +} + +export const certificatesStore = new CertificatesStore(); +``` + +And, finally, we register this store to Lens's API manager. + +```typescript +K8sApi.apiManager.registerStore(certificatesStore); +``` + + +### Create CertificatePage component + +Now we have created mechanism to manage `Certificate` objects in Kubernetes API. 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 React from "react"; +import { certificatesStore } from "../certificate-store"; +import { Certificate } from "../certificate" + +export class CertificatePage extends React.Component<{ extension: LensRendererExtension }> { + +} +``` + +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 automacially items from the given store when component is mounted. Also, we can define needed sorting callbacks and search filters for the list: + +```typescript +enum sortBy { + name = "name", + namespace = "namespace", + issuer = "issuer" +} + +export class CertificatePage extends React.Component<{ extension: LensRendererExtension }> { + // ... + + render() { + return ( + + certificate.getName(), + [sortBy.namespace]: (certificate: Certificate) => certificate.metadata.namespace, + [sortBy.issuer]: (certificate: Certificate) => certificate.spec.issuerRef.name + }} + searchFilters={[ + (certificate: Certificate) => certificate.getSearchFields() + ]} + renderHeaderTitle="Certificates" + renderTableHeader={[ + { title: "Name", className: "name", sortBy: sortBy.name }, + { title: "Namespace", className: "namespace", sortBy: sortBy.namespace }, + { title: "Issuer", className: "issuer", sortBy: sortBy.namespace }, + ]} + renderTableContents={(certificate: Certificate) => [ + certificate.getName(), + certificate.metadata.namespace, + certificate.spec.issuerRef.name + ]} + /> + + ) + } +} +``` + +### Customize Details panel + +We have learned now, how to list CRD objects in a list view. Next, we will learn how to customize details panel that will be opened when the object is clicked in the list. + +First, we need to register our custom component to render details for the specific Kubernetes custom resource, in our case `Certificate`. We will do this again in `CrdSampleExtension` class: + +```typescript +export default class CrdSampleExtension extends LensRendererExtension { + //... + + kubeObjectDetailItems = [{ + kind: Certificate.kind, + apiVersions: ["cert-manager.io/v1alpha2"], + components: { + Details: (props: CertificateDetailsProps) => + } + }] +} +``` + +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: + +```typescript +import { Component, K8sApi } from "@k8slens/extensions"; +import React from "react"; +import { Certificate } from "../certificate"; + +export interface CertificateDetailsProps extends Component.KubeObjectDetailsProps{ +} + +export class CertificateDetails extends React.Component { + + render() { + const { object: certificate } = this.props; + 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 ( + + ); + })} + +
+ ) + } +} +``` + +## Summary + +Like we can see above, it's very easy to add custom pages and fetch Kubernetes resources by using Extensions API. Please see the [complete source code](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) to test it out. \ No newline at end of file diff --git a/docs/extensions/guides/renderer-extension.md b/docs/extensions/guides/renderer-extension.md index 7a1393de16..f64cb5fd49 100644 --- a/docs/extensions/guides/renderer-extension.md +++ b/docs/extensions/guides/renderer-extension.md @@ -1,6 +1,9 @@ # Renderer Extension -The renderer extension api is the interface to Lens' renderer process (Lens runs in main and renderer processes). It allows you to access, configure, and customize Lens data, add custom Lens UI elements, and generally run custom code in Lens' renderer process. The custom Lens UI elements that can be added include global pages, cluster pages, cluster page menus, cluster features, app preferences, status bar items, KubeObject menu items, and KubeObject details items. These UI elements are based on React components. +The renderer extension api is the interface to Lens's renderer process (Lens runs in main and renderer processes). +It allows you to access, configure, and customize Lens data, add custom Lens UI elements, and generally run custom code in Lens's renderer process. +The custom Lens UI elements that can be added include global pages, cluster pages, cluster page menus, cluster features, app preferences, status bar items, KubeObject menu items, and KubeObject details items. +These UI elements are based on React components. ## `LensRendererExtension` Class @@ -20,11 +23,19 @@ export default class ExampleExtensionMain extends LensRendererExtension { } ``` -There are two methods that you can implement to facilitate running your custom code. `onActivate()` is called when your extension has been successfully enabled. By implementing `onActivate()` you can initiate your custom code. `onDeactivate()` is called when the extension is disabled (typically from the [Lens Extensions Page]()) and when implemented gives you a chance to clean up after your extension, if necessary. The example above simply logs messages when the extension is enabled and disabled. +There are two methods that you can implement to facilitate running your custom code. +`onActivate()` is called when your extension has been successfully enabled. +By implementing `onActivate()` you can initiate your custom code. +`onDeactivate()` is called when the extension is disabled (typically from the [Lens Extensions Page]()) and when implemented gives you a chance to clean up after your extension, if necessary. +The example above simply logs messages when the extension is enabled and disabled. ### `clusterPages` -Cluster pages appear as part of the cluster dashboard. They are accessible from the side bar, and are shown in the menu list after *Custom Resources*. It is conventional to use a cluster page to show information or provide functionality pertaining to the active cluster, along with custom data and functionality your extension may have. However, it is not limited to the active cluster. Also, your extension can gain access to the Kubernetes resources in the active cluster in a straightforward manner using the [`clusterStore`](../stores#clusterstore). +Cluster pages appear as part of the cluster dashboard. +They are accessible from the side bar, and are shown in the menu list after *Custom Resources*. +It is conventional to use a cluster page to show information or provide functionality pertaining to the active cluster, along with custom data and functionality your extension may have. +However, it is not limited to the active cluster. +Also, your extension can gain access to the Kubernetes resources in the active cluster in a straightforward manner using the [`clusterStore`](../stores#clusterstore). The following example adds a cluster page definition to a `LensRendererExtension` subclass: @@ -45,7 +56,12 @@ export default class ExampleExtension extends LensRendererExtension { } ``` -Cluster pages are objects matching the `PageRegistration` interface. The `id` field identiifies the page, and at its simplest is just a string identifier, as shown in the example above. The 'id' field can also convey route path details, such as variable parameters provided to a page ([See example below]()). The `components` field matches the `PageComponents` interface for wich there is one field, `Page`. `Page` is of type ` React.ComponentType`, which gives you great flexibility in defining the appearance and behaviour of your page. For the example above `ExamplePage` can be defined in `page.tsx`: +Cluster pages are objects matching the `PageRegistration` interface. +The `id` field identifies the page, and at its simplest is just a string identifier, as shown in the example above. +The 'id' field can also convey route path details, such as variable parameters provided to a page ([See example below]()). +The `components` field matches the `PageComponents` interface for wich there is one field, `Page`. +`Page` is of type ` React.ComponentType`, which gives you great flexibility in defining the appearance and behaviour of your page. +For the example above `ExamplePage` can be defined in `page.tsx`: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -62,11 +78,14 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens } ``` -Note that the `ExamplePage` class defines a property named `extension`. This allows the `ExampleExtension` object to be passed in React-style in the cluster page definition, so that `ExamplePage` can access any `ExampleExtension` subclass data. +Note that the `ExamplePage` class defines a property named `extension`. +This allows the `ExampleExtension` object to be passed in React-style in the cluster page definition, so that `ExamplePage` can access any `ExampleExtension` subclass data. ### `clusterPageMenus` -The above example code shows how to create a cluster page but not how to make it available to the Lens user. Cluster pages are typically made available through a menu item in the cluster dashboard sidebar. Expanding on the above example a cluster page menu is added to the `ExampleExtension` definition: +The above example code shows how to create a cluster page but not how to make it available to the Lens user. +Cluster pages are typically made available through a menu item in the cluster dashboard sidebar. +Expanding on the above example a cluster page menu is added to the `ExampleExtension` definition: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -95,7 +114,14 @@ export default class ExampleExtension extends LensRendererExtension { } ``` -Cluster page menus are objects matching the `ClusterPageMenuRegistration` interface. They define the appearance of the cluster page menu item in the cluster dashboard sidebar and the behaviour when the cluster page menu item is activated (typically by a mouse click). The example above uses the `target` field to set the behaviour as a link to the cluster page with `id` of `"hello"`. This is done by setting `target`'s `pageId` field to `"hello"`. The cluster page menu item's appearance is defined by setting the `title` field to the text that is to be displayed in the cluster dashboard sidebar. The `components` field is used to set an icon that appears to the left of the `title` text in the sidebar. Thus when the `"Hello World"` menu item is activated the cluster dashboard will show the contents of `ExamplePage`. This example requires the definition of another React-based component, `ExampleIcon`, which has been added to `page.tsx`: +Cluster page menus are objects matching the `ClusterPageMenuRegistration` interface. +They define the appearance of the cluster page menu item in the cluster dashboard sidebar and the behaviour when the cluster page menu item is activated (typically by a mouse click). +The example above uses the `target` field to set the behaviour as a link to the cluster page with `id` of `"hello"`. +This is done by setting `target`'s `pageId` field to `"hello"`. +The cluster page menu item's appearance is defined by setting the `title` field to the text that is to be displayed in the cluster dashboard sidebar. +The `components` field is used to set an icon that appears to the left of the `title` text in the sidebar. +Thus when the `"Hello World"` menu item is activated the cluster dashboard will show the contents of `ExamplePage`. +This example requires the definition of another React-based component, `ExampleIcon`, which has been added to `page.tsx`: ``` typescript import { LensRendererExtension, Component } from "@k8slens/extensions"; @@ -116,9 +142,14 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens } ``` -`ExampleIcon` introduces one of Lens' built-in components available to extension developers, the `Component.Icon`. Built in are the [Material Design](https://material.io) [icons](https://material.io/resources/icons/). One can be selected by name via the `material` field. `ExampleIcon` also sets a tooltip, shown when the Lens user hovers over the icon with a mouse, by setting the `tooltip` field. +`ExampleIcon` introduces one of Lens's built-in components available to extension developers, the `Component.Icon`. +Built in are the [Material Design](https://material.io) [icons](https://material.io/resources/icons/). +One can be selected by name via the `material` field. +`ExampleIcon` also sets a tooltip, shown when the Lens user hovers over the icon with a mouse, by setting the `tooltip` field. -A cluster page menu can also be used to define a foldout submenu in the cluster dashboard sidebar. This enables the grouping of cluster pages. The following example shows how to specify a submenu having two menu items: +A cluster page menu can also be used to define a foldout submenu in the cluster dashboard sidebar. +This enables the grouping of cluster pages. +The following example shows how to specify a submenu having two menu items: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -134,7 +165,7 @@ export default class ExampleExtension extends LensRendererExtension { } }, { - id: "bonjour", + id: "bonjour", components: { Page: () => , } @@ -169,11 +200,24 @@ export default class ExampleExtension extends LensRendererExtension { } ``` -The above defines two cluster pages and three cluster page menu objects. The cluster page definitons are straightforward. The first cluster page menu object defines the parent of a foldout submenu. Setting the `id` field in a cluster page menu definition implies that it is defining a foldout submenu. Also note that the `target` field is not specified (it is ignored if the `id` field is specified). This cluster page menu object specifies the `title` and `components` fields, which are used in displaying the menu item in the cluster dashboard sidebar. Initially the submenu is hidden. Activating this menu item toggles on and off the appearance of the submenu below it. The remaining two cluster page menu objects define the contents of the submenu. A cluster page menu object is defined to be a submenu item by setting the `parentId` field to the id of the parent of a foldout submenu, `"example"` in this case +The above defines two cluster pages and three cluster page menu objects. +The cluster page definitons are straightforward. +The first cluster page menu object defines the parent of a foldout submenu. +Setting the `id` field in a cluster page menu definition implies that it is defining a foldout submenu. +Also note that the `target` field is not specified (it is ignored if the `id` field is specified). +This cluster page menu object specifies the `title` and `components` fields, which are used in displaying the menu item in the cluster dashboard sidebar. +Initially the submenu is hidden. +Activating this menu item toggles on and off the appearance of the submenu below it. +The remaining two cluster page menu objects define the contents of the submenu. +A cluster page menu object is defined to be a submenu item by setting the `parentId` field to the id of the parent of a foldout submenu, `"example"` in this case ### `globalPages` -Global pages appear independently of the cluster dashboard and they fill the Lens UI space. A global page is typically triggered from the cluster menu using a [global page menu](#globalpagemenus). They can also be triggered by a [custom app menu selection](../main-extension#appmenus) from a Main Extension or a [custom status bar item](#statusbaritems). Global pages can appear even when there is no active cluster, unlike cluster pages. It is conventional to use a global page to show information and provide functionality relevant across clusters, along with custom data and functionality that your extension may have. +Global pages appear independently of the cluster dashboard and they fill the Lens UI space. +A global page is typically triggered from the cluster menu using a [global page menu](#globalpagemenus). +They can also be triggered by a [custom app menu selection](../main-extension#appmenus) from a Main Extension or a [custom status bar item](#statusbaritems). +Global pages can appear even when there is no active cluster, unlike cluster pages. +It is conventional to use a global page to show information and provide functionality relevant across clusters, along with custom data and functionality that your extension may have. The following example defines a `LensRendererExtension` subclass with a single global page definition: @@ -194,7 +238,12 @@ export default class HelpExtension extends LensRendererExtension { } ``` -Global pages are objects matching the `PageRegistration` interface. The `id` field identiifies the page, and at its simplest is just a string identifier, as shown in the example above. The 'id' field can also convey route path details, such as variable parameters provided to a page ([See example below]()). The `components` field matches the `PageComponents` interface for which there is one field, `Page`. `Page` is of type ` React.ComponentType`, which gives you great flexibility in defining the appearance and behaviour of your page. For the example above `HelpPage` can be defined in `page.tsx`: +Global pages are objects matching the `PageRegistration` interface. +The `id` field identifies the page, and at its simplest is just a string identifier, as shown in the example above. +The 'id' field can also convey route path details, such as variable parameters provided to a page ([See example below]()). +The `components` field matches the `PageComponents` interface for which there is one field, `Page`. +`Page` is of type ` React.ComponentType`, which gives you great flexibility in defining the appearance and behaviour of your page. +For the example above `HelpPage` can be defined in `page.tsx`: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -211,13 +260,20 @@ export class HelpPage extends React.Component<{ extension: LensRendererExtension } ``` -Note that the `HelpPage` class defines a property named `extension`. This allows the `HelpExtension` object to be passed in React-style in the global page definition, so that `HelpPage` can access any `HelpExtension` subclass data. +Note that the `HelpPage` class defines a property named `extension`. +This allows the `HelpExtension` object to be passed in React-style in the global page definition, so that `HelpPage` can access any `HelpExtension` subclass data. -This example code shows how to create a global page but not how to make it available to the Lens user. Global pages are typically made available through a number of ways. Menu items can be added to the Lens app menu system and set to open a global page when activated (See [`appMenus` in the Main Extension guide](../main-extension#appmenus)). Interactive elements can be placed on the status bar (the blue strip along the bottom of the Lens UI) and can be configured to link to a global page when activated (See [`statusBarItems`](#statusbaritems)). As well, global pages can be made accessible from the cluster menu, which is the vertical strip along the left side of the Lens UI showing the available cluster icons, and the Add Cluster icon. Global page menu icons that are defined using [`globalPageMenus`](#globalpagemenus) appear below the Add Cluster icon. +This example code shows how to create a global page but not how to make it available to the Lens user. +Global pages are typically made available through a number of ways. +Menu items can be added to the Lens app menu system and set to open a global page when activated (See [`appMenus` in the Main Extension guide](../main-extension#appmenus)). +Interactive elements can be placed on the status bar (the blue strip along the bottom of the Lens UI) and can be configured to link to a global page when activated (See [`statusBarItems`](#statusbaritems)). +As well, global pages can be made accessible from the cluster menu, which is the vertical strip along the left side of the Lens UI showing the available cluster icons, and the Add Cluster icon. +Global page menu icons that are defined using [`globalPageMenus`](#globalpagemenus) appear below the Add Cluster icon. ### `globalPageMenus` -Global page menus connect a global page to the cluster menu, which is the vertical strip along the left side of the Lens UI showing the available cluster icons, and the Add Cluster icon. Expanding on the example from [`globalPages`](#globalPages) a global page menu is added to the `HelpExtension` definition: +Global page menus connect a global page to the cluster menu, which is the vertical strip along the left side of the Lens UI showing the available cluster icons, and the Add Cluster icon. +Expanding on the example from [`globalPages`](#globalPages) a global page menu is added to the `HelpExtension` definition: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; @@ -246,7 +302,14 @@ export default class HelpExtension extends LensRendererExtension { } ``` -Global page menus are objects matching the `PageMenuRegistration` interface. They define the appearance of the global page menu item in the cluster menu and the behaviour when the global page menu item is activated (typically by a mouse click). The example above uses the `target` field to set the behaviour as a link to the global page with `id` of `"help"`. This is done by setting `target`'s `pageId` field to `"help"`. The global page menu item's appearance is defined by setting the `title` field to the text that is to be displayed as a tooltip in the cluster menu. The `components` field is used to set an icon that appears in the cluster menu. Thus when the `"Help"` icon is activated the contents of `ExamplePage` will be shown. This example requires the definition of another React-based component, `HelpIcon`, which has been added to `page.tsx`: +Global page menus are objects matching the `PageMenuRegistration` interface. +They define the appearance of the global page menu item in the cluster menu and the behaviour when the global page menu item is activated (typically by a mouse click). +The example above uses the `target` field to set the behaviour as a link to the global page with `id` of `"help"`. +This is done by setting `target`'s `pageId` field to `"help"`. +The global page menu item's appearance is defined by setting the `title` field to the text that is to be displayed as a tooltip in the cluster menu. +The `components` field is used to set an icon that appears in the cluster menu. +Thus when the `"Help"` icon is activated the contents of `ExamplePage` will be shown. +This example requires the definition of another React-based component, `HelpIcon`, which has been added to `page.tsx`: ``` typescript import { LensRendererExtension, Component } from "@k8slens/extensions"; @@ -267,11 +330,15 @@ export class HelpPage extends React.Component<{ extension: LensRendererExtension } ``` -`HelpIcon` introduces one of Lens' built-in components available to extension developers, the `Component.Icon`. Built in are the [Material Design](https://material.io) [icons](https://material.io/resources/icons/). One can be selected by name via the `material` field. +`HelpIcon` introduces one of Lens's built-in components available to extension developers, the `Component.Icon`. +Built in are the [Material Design](https://material.io) [icons](https://material.io/resources/icons/). +One can be selected by name via the `material` field. ### `clusterFeatures` -Cluster features are Kubernetes resources that can be applied to and managed within the active cluster. They can be installed/uninstalled by the Lens user from the [cluster settings page](). +Cluster features are Kubernetes resources that can be applied to and managed within the active cluster. +They can be installed/uninstalled by the Lens user from the [cluster settings page](). + The following example shows how to add a cluster feature as part of a `LensRendererExtension`: ``` typescript @@ -297,7 +364,8 @@ export default class ExampleFeatureExtension extends LensRendererExtension { ]; } ``` -The `title` and `components.Description` fields provide content that appears on the cluster settings page, in the **Features** section. The `feature` field must specify an instance which extends the abstract class `ClusterFeature.Feature`, and specifically implement the following methods: +The `title` and `components.Description` fields provide content that appears on the cluster settings page, in the **Features** section. +The `feature` field must specify an instance which extends the abstract class `ClusterFeature.Feature`, and specifically implement the following methods: ``` typescript abstract install(cluster: Cluster): Promise; @@ -306,13 +374,20 @@ The `title` and `components.Description` fields provide content that appears on abstract updateStatus(cluster: Cluster): Promise; ``` -The `install()` method is typically called by Lens when a user has indicated that this feature is to be installed (i.e. clicked **Install** for the feature on the cluster settings page). The implementation of this method should install kubernetes resources using the `applyResources()` method, or by directly accessing the kubernetes api ([`K8sApi`](tbd)). +The `install()` method is typically called by Lens when a user has indicated that this feature is to be installed (i.e. clicked **Install** for the feature on the cluster settings page). +The implementation of this method should install kubernetes resources using the `applyResources()` method, or by directly accessing the kubernetes api ([`K8sApi`](tbd)). -The `upgrade()` method is typically called by Lens when a user has indicated that this feature is to be upgraded (i.e. clicked **Upgrade** for the feature on the cluster settings page). The implementation of this method should upgrade the kubernetes resources already installed, if relevant to the feature. +The `upgrade()` method is typically called by Lens when a user has indicated that this feature is to be upgraded (i.e. clicked **Upgrade** for the feature on the cluster settings page). +The implementation of this method should upgrade the kubernetes resources already installed, if relevant to the feature. -The `uninstall()` method is typically called by Lens when a user has indicated that this feature is to be uninstalled (i.e. clicked **Uninstall** for the feature on the cluster settings page). The implementation of this method should uninstall kubernetes resources using the kubernetes api (`K8sApi`) +The `uninstall()` method is typically called by Lens when a user has indicated that this feature is to be uninstalled (i.e. clicked **Uninstall** for the feature on the cluster settings page). +The implementation of this method should uninstall kubernetes resources using the kubernetes api (`K8sApi`) -The `updateStatus()` method is called periodically by Lens to determine details about the feature's current status. The implementation of this method should provide the current status information in the `status` field of the `ClusterFeature.Feature` parent class. The `status.currentVersion` and `status.latestVersion` fields may be displayed by Lens in describing the feature. The `status.installed` field should be set to true if the feature is currently installed, otherwise false. Also, Lens relies on the `status.canUpgrade` field to determine if the feature can be upgraded (i.e a new version could be available) so the implementation should set the `status.canUpgrade` field according to specific rules for the feature, if relevant. +The `updateStatus()` method is called periodically by Lens to determine details about the feature's current status. +The implementation of this method should provide the current status information in the `status` field of the `ClusterFeature.Feature` parent class. +The `status.currentVersion` and `status.latestVersion` fields may be displayed by Lens in describing the feature. +The `status.installed` field should be set to true if the feature is currently installed, otherwise false. +Also, Lens relies on the `status.canUpgrade` field to determine if the feature can be upgraded (i.e a new version could be available) so the implementation should set the `status.canUpgrade` field according to specific rules for the feature, if relevant. The following shows a very simple implementation of a `ClusterFeature`: @@ -338,7 +413,7 @@ export class ExampleFeature extends ClusterFeature.Feature { 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 + this.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; @@ -360,7 +435,10 @@ export class ExampleFeature extends ClusterFeature.Feature { } ``` -This example implements the `install()` method by simply invoking the helper `applyResources()` method. `applyResources()` tries to apply all resources read from all files found in the folder path provided. In this case this folder path is the `../resources` subfolder relative to current source code's folder. The file `../resources/example-pod.yml` could contain: +This example implements the `install()` method by simply invoking the helper `applyResources()` method. +`applyResources()` tries to apply all resources read from all files found in the folder path provided. +In this case this folder path is the `../resources` subfolder relative to current source code's folder. +The file `../resources/example-pod.yml` could contain: ``` yaml apiVersion: v1 @@ -373,25 +451,29 @@ spec: image: nginx ``` -The `upgrade()` method in the example above is implemented by simply invoking the `install()` method. Depending on the feature to be supported by an extension, upgrading may require additional and/or different steps. +The `upgrade()` method in the example above is implemented by simply invoking the `install()` method. +Depending on the feature to be supported by an extension, upgrading may require additional and/or different steps. The `uninstall()` method is implemented in the example above by utilizing the [`K8sApi`](tbd) provided by Lens to simply delete the `example-pod` pod applied by the `install()` method. -The `updateStatus()` method is implemented above by using the [`K8sApi`](tbd) as well, this time to get information from the `example-pod` pod, in particular to determine if it is installed, what version is associated with it, and if it can be upgraded. How the status is updated for a specific cluster feature is up to the implementation. +The `updateStatus()` method is implemented above by using the [`K8sApi`](tbd) as well, this time to get information from the `example-pod` pod, in particular to determine if it is installed, what version is associated with it, and if it can be upgraded. +How the status is updated for a specific cluster feature is up to the implementation. ### `appPreferences` -The Preferences page is a built-in global page. Extensions can add custom preferences to the Preferences page, thus providing a single location for users to configure global options, for Lens and extensions alike. The following example demonstrates adding a custom preference: +The Preferences page is a built-in global page. +Extensions can add custom preferences to the Preferences page, thus providing a single location for users to configure global options, for Lens and extensions alike. +The following example demonstrates adding a custom preference: ``` typescript import { LensRendererExtension } from "@k8slens/extensions"; -import { ExamplePreference, ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference"; +import { ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference"; import { observable } from "mobx"; import React from "react"; export default class ExampleRendererExtension extends LensRendererExtension { - @observable preference: ExamplePreference = { enabled: false }; + @observable preference = { enabled: false }; appPreferences = [ { @@ -405,19 +487,27 @@ export default class ExampleRendererExtension extends LensRendererExtension { } ``` -App preferences are objects matching the `AppPreferenceRegistration` interface. The `title` field specifies the text to show as the heading on the Preferences page. The `components` field specifies two `React.Component` objects defining the interface for the preference. `Input` should specify an interactive input element for your preference and `Hint` should provide descriptive information for the preference, which is shown below the `Input` element. `ExamplePreferenceInput` expects its React props set to an `ExamplePreference` instance, which is how `ExampleRendererExtension` handles the state of the preference input. `ExampleRendererExtension` has the field `preference`, which is provided to `ExamplePreferenceInput` when it is created. In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreference` are defined in `./src/example-preference.tsx`: +App preferences are objects matching the `AppPreferenceRegistration` interface. +The `title` field specifies the text to show as the heading on the Preferences page. +The `components` field specifies two `React.Component` objects defining the interface for the preference. +`Input` should specify an interactive input element for your preference and `Hint` should provide descriptive information for the preference, which is shown below the `Input` element. +`ExamplePreferenceInput` expects its React props set to an `ExamplePreferenceProps` instance, which is how `ExampleRendererExtension` handles the state of the preference input. +`ExampleRendererExtension` has the field `preference`, which is provided to `ExamplePreferenceInput` when it is created. +In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreferenceProps` are defined in `./src/example-preference.tsx`: ``` typescript import { Component } from "@k8slens/extensions"; import { observer } from "mobx-react"; import React from "react"; -export type ExamplePreference = { - enabled: boolean; +export class ExamplePreferenceProps { + preference: { + enabled: boolean; + } } @observer -export class ExamplePreferenceInput extends React.Component<{preference: ExamplePreference}, {}> { +export class ExamplePreferenceInput extends React.Component { render() { const { preference } = this.props; @@ -440,25 +530,28 @@ export class ExamplePreferenceHint extends React.Component { } ``` -`ExamplePreferenceInput` implements a simple checkbox (using Lens' `Component.Checkbox`). It provides `label` as the text to display next to the checkbox and an `onChange` function, which reacts to the checkbox state change. The checkbox's `value` is initially set to `preference.enabled`. `ExamplePreferenceInput` is defined with React props of `ExamplePreference` type, which has a single field, `enabled`. This is used to indicate the state of the preference, and is bound to the checkbox state in `onChange`. `ExamplePreferenceHint` is a simple text span. Note that the input and the hint could comprise of more sophisticated elements, according to the needs of the extension. - -Note that the above example introduces decorators `observable` and `observer` from the [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) packages. `mobx` simplifies state management and without it this example would not visually update the checkbox properly when the user activates it. [Lens uses `mobx` extensively for state management](../working-with-mobx) of its own UI elements and it is recommended that extensions rely on it too. Alternatively, React's state management can be used instead, though `mobx` is typically simpler to use. - -Also note that an extension's state data can be managed using an `ExtensionStore` object, which conveniently handles persistence and synchronization. The example above defined an `ExamplePreference` type to hold the extension's state to simplify the code for this guide, but it is recommended to manage your extension's state data using [`ExtensionStore`](../stores#extensionstore) - - - -********************************************************************* -WIP below! -********************************************************************* +`ExamplePreferenceInput` implements a simple checkbox (using Lens's `Component.Checkbox`). +It provides `label` as the text to display next to the checkbox and an `onChange` function, which reacts to the checkbox state change. +The checkbox's `value` is initially set to `preference.enabled`. +`ExamplePreferenceInput` is defined with React props of `ExamplePreferenceProps` type, which is an object with a single field, `enabled`. +This is used to indicate the state of the preference, and is bound to the checkbox state in `onChange`. +`ExamplePreferenceHint` is a simple text span. +Note that the input and the hint could comprise of more sophisticated elements, according to the needs of the extension. +Note that the above example introduces decorators `observable` and `observer` from the [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) packages. +`mobx` simplifies state management and without it this example would not visually update the checkbox properly when the user activates it. +[Lens uses `mobx` extensively for state management](../working-with-mobx) of its own UI elements and it is recommended that extensions rely on it too. +Alternatively, React's state management can be used, though `mobx` is typically simpler to use. +Also note that an extension's state data can be managed using an `ExtensionStore` object, which conveniently handles persistence and synchronization. +The example above defined a `preference` field in the `ExampleRendererExtension` class definition to hold the extension's state primarily to simplify the code for this guide, but it is recommended to manage your extension's state data using [`ExtensionStore`](../stores#extensionstore) ### `statusBarItems` -The Status bar is the blue strip along the bottom of the Lens UI. Status bar items are `React.ReactNode` types, which can be used to convey status information, or act as a link to a global page, or even an external page. +The Status bar is the blue strip along the bottom of the Lens UI. +Status bar items are `React.ReactNode` types, which can be used to convey status information, or act as a link to a global page, or even an external page. -The following example adds a status bar item definition, as well as a global page definition, to a `LensRendererExtension` subclass, and configures the status bar item to navigate to the global page upon a mouse click: +The following example adds a status bar item definition, as well as a global page definition, to a `LensRendererExtension` subclass, and configures the status bar item to navigate to the global page upon activation (normally a mouse click): ``` typescript import { LensRendererExtension } from '@k8slens/extensions'; @@ -491,22 +584,36 @@ export default class HelpExtension extends LensRendererExtension { } ``` +The `item` field of a status bar item specifies the `React.Component` to be shown on the status bar. +By default items are added starting from the right side of the status bar. +Typically, `item` would specify an icon and/or a short string of text, considering the limited space on the status bar. +In the example above the `HelpIcon` from the [`globalPageMenus` guide](#globalpagemenus) is reused. +Also, the `item` provides a link to the global page by setting the `onClick` property to a function that calls the `LensRendererExtension` `navigate()` method. +`navigate()` takes as a parameter the id of the global page, which is shown when `navigate()` is called. + ### `kubeObjectMenuItems` -An extension can add custom menu items (including actions) for specified Kubernetes resource kinds/apiVersions. These menu items appear under the `...` for each listed resource, and on the title bar of the details page for a specific resource. +An extension can add custom menu items (including actions) for specific Kubernetes resource kinds/apiVersions. +These menu items appear under the `...` for each listed resource in the cluster dashboard, and on the title bar of the details page for a specific resource: + +![List](images/kubeobjectmenuitem.png) + +![Details](images/kubeobjectmenuitemdetail.png) + +The following example shows how to add a menu for Namespace resources, and associate an action with it: ``` typescript import React from "react" import { LensRendererExtension } from "@k8slens/extensions"; -import { CustomMenuItem, CustomMenuItemProps } from "./src/custom-menu-item" +import { NamespaceMenuItem } from "./src/namespace-menu-item" export default class ExampleExtension extends LensRendererExtension { kubeObjectMenuItems = [ { - kind: "Node", + kind: "Namespace", apiVersions: ["v1"], components: { - MenuItem: (props: CustomMenuItemProps) => + MenuItem: (props: Component.KubeObjectMenuProps) => } } ]; @@ -514,24 +621,185 @@ export default class ExampleExtension extends LensRendererExtension { ``` +Kube object menu items are objects matching the `KubeObjectMenuRegistration` interface. +The `kind` field specifies the kubernetes resource type to apply this menu item to, and the `apiVersion` field specifies the kubernetes api to use in relation to this resource type. +This example adds a menu item for namespaces in the cluster dashboard. +The `components` field defines the menu item's appearance and behaviour. +The `MenuItem` field provides a function that returns a `React.Component` given a set of menu item properties. +In this example a `NamespaceMenuItem` object is returned. +`NamespaceMenuItem` is defined in `./src/namespace-menu-item.tsx`: + +```typescript +import React from "react"; +import { Component, K8sApi, Navigation} from "@k8slens/extensions"; + +export function NamespaceMenuItem(props: Component.KubeObjectMenuProps) { + const { object: namespace, toolbar } = props; + if (!namespace) return null; + + const namespaceName = namespace.getName(); + + const sendToTerminal = (command: string) => { + Component.terminalStore.sendCommand(command, { + enter: true, + newTab: true, + }); + Navigation.hideDetails(); + }; + + const getPods = () => { + sendToTerminal(`kubectl get pods -n ${namespaceName}`); + }; + + return ( + + + Get Pods + + ); +} + +``` + +`NamespaceMenuItem` returns a `Component.MenuItem` defining the menu item's appearance (icon and text) and behaviour when activated via the `onClick` property. +`getPods()` shows how to open a terminal tab and run a command, specifically it runs `kubectl` to get a list of pods running in the current namespace. +See [`Component.terminalStore.sendCommand`](api-docs?) for more details on running terminal commands. +The name of the namespace is retrieved from `props` passed into `NamespaceMenuItem()`. +`namespace` is the `props.object`, which is of type `K8sApi.Namespace`. +This is the api for accessing namespaces, and the current namespace in this example is simply given by `namespace.getName()`. +Thus kube object menu items are afforded convenient access to the specific resource selected by the user. + ### `kubeObjectDetailItems` -An extension can add custom details (content) for specified Kubernetes resource kinds/apiVersions. These custom details appear on the details page for a specific resource. +An extension can add custom details (content) for specified Kubernetes resource kinds/apiVersions. +These custom details appear on the details page for a specific resource, such as a Namespace: + +![Details](images/kubeobjectdetailitem.png) + +The following example shows how to add a tabulated list of pods to the Namespace resource details page: ``` typescript import React from "react" import { LensRendererExtension } from "@k8slens/extensions"; -import { CustomKindDetails, CustomKindDetailsProps } from "./src/custom-kind-details" +import { NamespaceDetailsItem } from "./src/namespace-details-item" export default class ExampleExtension extends LensRendererExtension { - kubeObjectMenuItems = [ + kubeObjectDetailItems = [ { - kind: "CustomKind", - apiVersions: ["custom.acme.org/v1"], + kind: "Namespace", + apiVersions: ["v1"], + priority: 10, components: { - Details: (props: CustomKindDetailsProps) => + Details: (props: Component.KubeObjectDetailsProps) => } } ]; } -``` \ No newline at end of file +``` + +Kube object detail items are objects matching the `KubeObjectDetailRegistration` interface. +The `kind` field specifies the kubernetes resource type to apply this detail item to, and the `apiVersion` field specifies the kubernetes api to use in relation to this resource type. +This example adds a detail item for namespaces in the cluster dashboard. +The `components` field defines the detail item's appearance and behaviour. +The `Details` field provides a function that returns a `React.Component` given a set of detail item properties. +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 { 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> { + + @observable private pods: K8sApi.Pod[]; + + async componentDidMount() { + this.pods = await K8sApi.podsApi.list({namespace: this.props.object.getName()}); + } + + render() { + return ( +
+ + +
+ ) + } +} +``` + +Since `NamespaceDetailsItem` extends `React.Component>` it can access the current namespace object (type `K8sApi.Namespace`) through `this.props.object`. +This object can be queried for many details about the current namespace. +In this example the namespace's name is obtained in `componentDidMount()` using the `K8sApi.Namespace` `getName()` method. +The namespace's name is needed to limit the list of pods to only those in this namespace. +To get the list of pods this example uses the kubernetes pods api, specifically the `K8sApi.podsApi.list()` method. +The `K8sApi.podsApi` is automatically configured for the currently active cluster. + +Note that `K8sApi.podsApi.list()` is an asynchronous method, and ideally getting the pods list should be done before 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()`. +In order to effect a subsequent `render()` call React must be made aware of a state change. +Like in the [`appPreferences` guide](#apppreferences), [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) are used to ensure `NamespaceDetailsItem` renders when the pods list is updated. +This is done simply by marking the `pods` field as an `observable` and the `NamespaceDetailsItem` class itself as an `observer`. + +Finally, the `NamespaceDetailsItem` is rendered 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. +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"; + +interface Props { + pods: K8sApi.Pod[]; +} + +export class PodsDetailsList extends React.Component { + + getTableRow(index: number) { + const {pods} = this.props; + return ( + + {pods[index].getName()} + {pods[index].getAge()} + {pods[index].getStatus()} + + ) + } + + render() { + const {pods} = this.props + if (!pods?.length) { + return null; + } + + return ( +
+ + + Name + Age + Status + + { + pods.map((pod, index) => this.getTableRow(index)) + } + +
+ ) + } +} +``` + +`PodsDetailsList` produces a simple table showing a list of the pods found in this namespace: + +![DetailsWithPods](images/kubeobjectdetailitemwithpods.png) + + 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. +See [`Component` documentation](url?) for further details. \ No newline at end of file diff --git a/docs/extensions/guides/stores.md b/docs/extensions/guides/stores.md index 13982179e0..981a7cda3e 100644 --- a/docs/extensions/guides/stores.md +++ b/docs/extensions/guides/stores.md @@ -1,11 +1,155 @@ ---- -WIP ---- - # Stores -## ClusterStore +Stores are components that persist and synchronize state data. Lens utilizes a number of stores for maintaining a variety of state information. +A few of these are exposed by the extensions api for use by the extension developer. -## WorkspaceStore +- The `ClusterStore` manages cluster state data such as cluster details, and which cluster is active. +- The `WorkspaceStore` similarly manages workspace state data, such as workspace name, and which clusters belong to a given workspace. +- The `ExtensionStore` is a store for managing custom extension state data. -## ExtensionStore \ No newline at end of file +## ExtensionStore + +Extension developers can create their own store for managing state data by extending the `ExtensionStore` class. +This guide shows how to create a store for the [`appPreferences` guide example](../renderer-extension#apppreferences), which demonstrates how to add a custom preference to the Preferences page. +The preference is a simple boolean that indicates whether something is enabled or not. +The problem with that example is that the enabled state is not stored anywhere, and reverts to the default the next time Lens is started. + +The following example code creates a store for the `appPreferences` guide example: + +``` typescript +import { Store } from "@k8slens/extensions"; +import { observable, toJS } from "mobx"; + +export type ExamplePreferencesModel = { + enabled: boolean; +}; + +export class ExamplePreferencesStore extends Store.ExtensionStore { + + @observable enabled = false; + + private constructor() { + super({ + configName: "example-preferences-store", + defaults: { + enabled: false + } + }); + } + + protected fromStore({ enabled }: ExamplePreferencesModel): void { + this.enabled = enabled; + } + + toJSON(): ExamplePreferencesModel { + return toJS({ + enabled: this.enabled + }, { + recurseEverything: true + }); + } +} + +export const examplePreferencesStore = ExamplePreferencesStore.getInstance(); +``` + +First the extension's data model is defined using a simple type, `ExamplePreferencesModel`, which has a single field, `enabled`, representing the preference's state. +`ExamplePreferencesStore` extends `Store.ExtensionStore`, based on the `ExamplePreferencesModel`. +The field `enabled` is added to the `ExamplePreferencesStore` class to hold the "live" or current state of the preference. +Note the use of the `observer` decorator on the `enabled` field. +As for the [`appPreferences` guide example](../renderer-extension#apppreferences), [`mobx`](https://mobx.js.org/README.html) is used for the UI state management, ensuring the checkbox updates when activated by the user. + +Then the constructor and two abstract methods are implemented. +In the constructor, the name of the store (`"example-preferences-store"`), and the default (initial) value for the preference state (`enabled: false`) are specified. +The `fromStore()` method is called by Lens internals when the store is loaded, and gives the extension the opportunity to retrieve the stored state data values based on the defined data model. +Here, the `enabled` field of the `ExamplePreferencesStore` is set to the value from the store whenever `fromStore()` is invoked. +The `toJSON()` method is complementary to `fromStore()`, and is called when the store is being saved. +`toJSON()` must provide a JSON serializable object, facilitating its storage in JSON format. +The `toJS()` function from [`mobx`](https://mobx.js.org/README.html) is convenient for this purpose, and is used here. + +Finally, `examplePreferencesStore` is created by calling `ExamplePreferencesStore.getInstance()`, and exported for use by other parts of the extension. +Note that `examplePreferencesStore` is a singleton, calling this function again will not create a new store. + +The following example code, modified from the [`appPreferences` guide example](../renderer-extension#apppreferences) demonstrates how to use the extension store. +`examplePreferencesStore` must be loaded in the main process, where loaded stores are automatically saved when exiting Lens. This can be done in `./main.ts`: + +``` typescript +import { LensMainExtension } from "@k8slens/extensions"; +import { examplePreferencesStore } from "./src/example-preference-store"; + +export default class ExampleMainExtension extends LensMainExtension { + async onActivate() { + await examplePreferencesStore.loadExtension(this); + } +} +``` + +Here, `examplePreferencesStore` is loaded with `examplePreferencesStore.loadExtension(this)`, which is conveniently called from the `onActivate()` method of `ExampleMainExtension`. +Similarly, `examplePreferencesStore` must be loaded in the renderer process where the `appPreferences` are handled. This can be done in `./renderer.ts`: + +``` typescript +import { LensRendererExtension } 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 { + + async onActivate() { + await examplePreferencesStore.loadExtension(this); + } + + appPreferences = [ + { + title: "Example Preferences", + components: { + Input: () => , + Hint: () => + } + } + ]; +} +``` + +Again, `examplePreferencesStore.loadExtension(this)` is called to load `examplePreferencesStore`, this time from the `onActivate()` method of `ExampleRendererExtension`. +Also, there is no longer the need for the `preference` field in the `ExampleRendererExtension` class, as the props for `ExamplePreferenceInput` is now `examplePreferencesStore`. +`ExamplePreferenceInput` is defined in `./src/example-preference.tsx`: + +``` typescript +import { Component } from "@k8slens/extensions"; +import { observer } from "mobx-react"; +import React from "react"; +import { ExamplePreferencesStore } from "./example-preference-store"; + +export class ExamplePreferenceProps { + preference: ExamplePreferencesStore; +} + +@observer +export class ExamplePreferenceInput extends React.Component { + + render() { + const { preference } = this.props; + + return ( + { preference.enabled = v; }} + /> + ); + } +} + +export class ExamplePreferenceHint extends React.Component { + render() { + return ( + This is an example of an appPreference for extensions. + ); + } +} +``` + +The only change here is that `ExamplePreferenceProps` defines its `preference` field as an `ExamplePreferencesStore` type. +Everything else works as before except now the `enabled` state persists across Lens restarts because it is managed by the +`examplePreferencesStore`. \ No newline at end of file diff --git a/docs/extensions/testing-and-publishing/publishing.md b/docs/extensions/testing-and-publishing/publishing.md index e69de29bb2..d8e7b8efad 100644 --- a/docs/extensions/testing-and-publishing/publishing.md +++ b/docs/extensions/testing-and-publishing/publishing.md @@ -0,0 +1,46 @@ +# Publishing Extensions + +To be able to easily share extensions with users they need to be published somewhere. +Lens currently only supports installing extensions from NPM tarballs. +All hosted extensions must, therefore, be retrievable in a NPM tarball. + +## Places To Host Your Extension + +We recommend to host your extension somewhere on the web so that it is easy for people to search for and download it. +We recommend either hosting it as an NPM package on https://www.npmjs.com or through GitHub releases. +We recommend against using GitHub packages (https://github.com/features/packages) as it requires a GitHub token to access the package. + +### Publishing via NPM + +This is the easiest method of publishing as NPM comes built in with mechanism to get a link to download the package as a tarball. +Once you have set up an account with NPM (https://www.npmjs.com/signup) and logged in with their CLI (`npm login`) you will be ready to publish. + +* Run `npm version ` to bump the version of your extension by the appropriate amount. +* Run `npm publish` to publish your extension to NPM +* Run `git push && git push --tags` to push the commit that NPM creates to your git remote. + +It is probably a good idea to put into your README.md the following instructions for your users to get the tarball download link. + +```bash +npm view dist.tarball +``` + +This will output the link that they will need to give to Lens to install your extension. + +### Publish via GitHub Releases + +Another method of publishing your extensions is to do so with the releases mechanism built into GitHub. +We recommend reading [GitHub's Releases Documentation](https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/managing-releases-in-a-repository) for how to actually do the steps of a release. +The following will be a quick walk through on how to make the tarball which will be the released file. + +### Making a NPM Tarball of Your Extension + +While this is necessary for hosting on GitHub releases, this is also the means for creating a tarball if you plan on hosting on a different file hosting platform. + +Say you have your project folder at `~/my-extension/` and you want to create an NPM package we need to do the following within your git repo: + +``` +npm pack +``` + +This will create a NPM tarball that can be hosted on Github Releases or any other publicly available file hosting service. diff --git a/docs/faq/README.md b/docs/faq/README.md index a0990367ef..472ba6596b 100644 --- a/docs/faq/README.md +++ b/docs/faq/README.md @@ -1 +1,46 @@ -TBD +# FAQ + +### What operating systems does Lens support? + +Lens supports MacOS, Windows and Linux operating systems. For Linux there are Snap and AppImage versions. For MacOS there are DMG and Homebrew options. + +### Lens application is not opening, what might be wrong? + +When Lens is started, it will start HTTP proxy server on the background and requires that operating system allows to start listening to some free port. You can see the port allocated for Lens from application logs. Lens expects also that `localhost` DNS points to `127.0.0.1` address. + +### Why can't I add any clusters? + +When adding new clusters, a valid Kubeconfig file is required. Please check that all contexts present in Kubeconfig file are valid. + +### Why Cluster dashboard is not opening? + +To see Cluster dashboard properly, Kubernetes cluster must be reachable either directly from your computer or via HTTP proxy. You can configure HTTP proxy in Cluster Settigns. Also, provided credentials in Kubeconfig must be valid. If Kubeconfig uses `exec` command, the binary must be available in global PATH or absolute path must be used. Lens application can't see PATH modifications made by any shell init scripts. There might be also some issues on the Snap version if the exec binary is installed also from Snap and requires additional symlinking, please see [#699](https://github.com/lensapp/lens/issues/699). + +### Why I don't see anything on Cluster dashboard? + +Users will see on Cluster dashboard only those resources that they are allowed to see (RBAC). Lens requires that user has access at least to one namespace. Lens tries first fetch namespaces from Kubernetes API. If user is not allowed to list namespaces, allowed namespaces can be configured in Cluster settings or in Kubeconfig. + +### Why I don't see any metrics or some of the metrics are not working? + +In order to display cluster metrics, Lens requires that Prometheus is running in the cluster. You can install Prometheus in Cluster settings if needed. + +Lens tries to detect Prometheus installation automatically. If it fails to detect the installation properly, you can configure Prometheus service address in Cluster settings. If some of the metrics are not displayed correctly, you can see queries that Lens is using [here](https://github.com/lensapp/lens/tree/master/src/main/prometheus) and adapt your prometheus configuration to support those queries. Please refer [Prometheus documentation](https://prometheus.io/docs/prometheus/latest/configuration/configuration/) or your Prometheus installer documentation how to do this. + +### Kubectl is not working in Lens terminal, what should I do? + +Lens tries to download correct Kubectl version for the cluster and use that in Lens terminal. Some operating systems (namely Windows) might have restrictions set that prevent downloading and executing binaries from the default location that Lens is using. You can change the directory where Lens downloads the binaries in App Preferences. It's also possible to change the Download mirror to use Azure if default Google is not reachable from your network. If downloading Kubectl is not option for you, you can define path to pre-installed Kubectl on your machine and Lens will use that binary instead. + +### How can I configure Helm repositories? + +Lens comes with bundled Helm 3 binary and Lens will add by default `bitnami` repository if no other repositories are configured. You can add more repositories from Artifact HUB in App preferences. At this moment it is not possible to add private repositories. Those and other public repositories can be added manually via command line. + +### Where can I find application logs? + +Lens will store application logs to following locations depending on your operating system: +- MacOS: ~/Library/Logs/Lens/ +- Windows: %USERPROFILE%\AppData\Roaming\Lens\logs\ +- Linux: ~/.config/Lens/logs/ + +### How can I see more verbose logs? + +You can start Lens application on debug mode from the command line to see more verbose logs. To start application on debug mode, please provide `DEBUG=true` environment variable and before starting the application, for example: `DEBUG=TRUE /Applications/Lens.app/Contents/MacOS/Lens` \ No newline at end of file diff --git a/mkdocs.yml b/mkdocs.yml index 3e95eae065..ad3e19572e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -30,9 +30,10 @@ nav: - Color Reference: extensions/capabilities/color-reference.md - Extension Guides: - Overview: extensions/guides/README.md + - Generator: extensions/guides/generator.md - Main Extension: extensions/guides/main-extension.md - Renderer Extension: extensions/guides/renderer-extension.md - - Generator: extensions/guides/generator.md + - Stores: extensions/guides/stores.md - Working with mobx: extensions/guides/working-with-mobx.md - Testing and Publishing: - Testing Extensions: extensions/testing-and-publishing/testing.md diff --git a/package.json b/package.json index 5042ff2be9..e8dda65f7a 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "kontena-lens", "productName": "Lens", "description": "Lens - The Kubernetes IDE", - "version": "4.0.0-rc.2", + "version": "4.0.0-rc.3", "main": "static/build/main.js", "copyright": "© 2020, Mirantis, Inc.", "license": "MIT", diff --git a/src/common/workspace-store.ts b/src/common/workspace-store.ts index e1fa113ca3..7688516af2 100644 --- a/src/common/workspace-store.ts +++ b/src/common/workspace-store.ts @@ -26,6 +26,11 @@ export interface WorkspaceState { enabled: boolean; } +/** + * Workspace + * + * @beta + */ export class Workspace implements WorkspaceModel, WorkspaceState { /** * Unique id for workspace @@ -78,23 +83,39 @@ export class Workspace implements WorkspaceModel, WorkspaceState { } } + /** + * Is workspace managed by an extension + */ get isManaged(): boolean { return !!this.ownerRef; } + /** + * Get workspace state + * + */ getState(): WorkspaceState { return toJS({ enabled: this.enabled }); } + /** + * Push state + * + * @interal + * @param state workspace state + */ pushState(state = this.getState()) { logger.silly("[WORKSPACE] pushing state", {...state, id: this.id}); broadcastMessage("workspace:state", this.id, toJS(state)); } - @action - setState(state: WorkspaceState) { + /** + * + * @param state workspace state + */ + @action setState(state: WorkspaceState) { Object.assign(this, state); } diff --git a/src/extensions/core-api/stores.ts b/src/extensions/core-api/stores.ts index aa14623287..706c336fdf 100644 --- a/src/extensions/core-api/stores.ts +++ b/src/extensions/core-api/stores.ts @@ -1,8 +1,8 @@ export { ExtensionStore } from "../extension-store"; -export { clusterStore, Cluster } from "../stores/cluster-store"; +export { clusterStore, Cluster, ClusterStore } from "../stores/cluster-store"; export type { ClusterModel, ClusterId } from "../stores/cluster-store"; -export { workspaceStore, Workspace } from "../stores/workspace-store"; +export { workspaceStore, Workspace, WorkspaceStore } from "../stores/workspace-store"; export type { WorkspaceId, WorkspaceModel } from "../stores/workspace-store"; diff --git a/src/extensions/extension-discovery.ts b/src/extensions/extension-discovery.ts index 105b8e2041..ab52027f15 100644 --- a/src/extensions/extension-discovery.ts +++ b/src/extensions/extension-discovery.ts @@ -1,8 +1,11 @@ import chokidar from "chokidar"; +import { ipcRenderer } from "electron"; import { EventEmitter } from "events"; import fs from "fs-extra"; +import { observable, reaction, toJS, when } from "mobx"; import os from "os"; import path from "path"; +import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc"; import { getBundledExtensions } from "../common/utils/app-version"; import logger from "../main/logger"; import { extensionInstaller, PackageJson } from "./extension-installer"; @@ -28,6 +31,10 @@ const logModule = "[EXTENSION-DISCOVERY]"; export const manifestFilename = "package.json"; +interface ExtensionDiscoveryChannelMessage { + isLoaded: boolean; +} + /** * Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link) * @param lstat the stats to compare @@ -49,22 +56,16 @@ export class ExtensionDiscovery { private loadStarted = false; - // This promise is resolved when .load() is finished. - // This allows operations to be added after .load() success. - private loaded: Promise; + // True if extensions have been loaded from the disk after app startup + @observable isLoaded = false; + whenLoaded = when(() => this.isLoaded); - // These are called to either resolve or reject this.loaded promise - private resolveLoaded: () => void; - private rejectLoaded: (error: any) => void; + // IPC channel to broadcast changes to extension-discovery from main + protected static readonly extensionDiscoveryChannel = "extension-discovery:main"; public events: EventEmitter; constructor() { - this.loaded = new Promise((resolve, reject) => { - this.resolveLoaded = resolve; - this.rejectLoaded = reject; - }); - this.events = new EventEmitter(); } @@ -98,8 +99,32 @@ export class ExtensionDiscovery { /** * Initializes the class and setups the file watcher for added/removed local extensions. */ - init() { + async init() { + if (ipcRenderer) { + await this.initRenderer(); + } else { + await this.initMain(); + } + } + + async initRenderer() { + const onMessage = ({ isLoaded }: ExtensionDiscoveryChannelMessage) => { + this.isLoaded = isLoaded; + }; + + requestMain(ExtensionDiscovery.extensionDiscoveryChannel).then(onMessage); + subscribeToBroadcast(ExtensionDiscovery.extensionDiscoveryChannel, (_event, message: ExtensionDiscoveryChannelMessage) => { + onMessage(message); + }); + } + + async initMain() { this.watchExtensions(); + handleRequest(ExtensionDiscovery.extensionDiscoveryChannel, () => this.toJSON()); + + reaction(() => this.toJSON(), () => { + this.broadcast(); + }); } /** @@ -110,7 +135,7 @@ export class ExtensionDiscovery { logger.info(`${logModule} watching extension add/remove in ${this.localFolderPath}`); // Wait until .load() has been called and has been resolved - await this.loaded; + await this.whenLoaded; // chokidar works better than fs.watch chokidar.watch(this.localFolderPath, { @@ -208,36 +233,31 @@ export class ExtensionDiscovery { this.loadStarted = true; - try { - logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`); + logger.info(`${logModule} loading extensions from ${extensionInstaller.extensionPackagesRoot}`); - if (fs.existsSync(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"))) { - await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json")); - } - - try { - await fs.access(this.inTreeFolderPath, fs.constants.W_OK); - this.bundledFolderPath = this.inTreeFolderPath; - } catch { - // we need to copy in-tree extensions so that we can symlink them properly on "npm install" - await fs.remove(this.inTreeTargetPath); - await fs.ensureDir(this.inTreeTargetPath); - await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath); - this.bundledFolderPath = this.inTreeTargetPath; - } - - await fs.ensureDir(this.nodeModulesPath); - await fs.ensureDir(this.localFolderPath); - - const extensions = await this.loadExtensions(); - - // resolve the loaded promise - this.resolveLoaded(); - - return extensions; - } catch (error) { - this.rejectLoaded(error); + if (fs.existsSync(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json"))) { + await fs.remove(path.join(extensionInstaller.extensionPackagesRoot, "package-lock.json")); } + + try { + await fs.access(this.inTreeFolderPath, fs.constants.W_OK); + this.bundledFolderPath = this.inTreeFolderPath; + } catch { + // we need to copy in-tree extensions so that we can symlink them properly on "npm install" + await fs.remove(this.inTreeTargetPath); + await fs.ensureDir(this.inTreeTargetPath); + await fs.copy(this.inTreeFolderPath, this.inTreeTargetPath); + this.bundledFolderPath = this.inTreeTargetPath; + } + + await fs.ensureDir(this.nodeModulesPath); + await fs.ensureDir(this.localFolderPath); + + const extensions = await this.loadExtensions(); + + this.isLoaded = true; + + return extensions; } protected async getByManifest(manifestPath: string, { isBundled = false }: { @@ -356,6 +376,18 @@ export class ExtensionDiscovery { return this.getByManifest(manifestPath, { isBundled }); } + + toJSON(): ExtensionDiscoveryChannelMessage { + return toJS({ + isLoaded: this.isLoaded + }, { + recurseEverything: true + }); + } + + broadcast() { + broadcastMessage(ExtensionDiscovery.extensionDiscoveryChannel, this.toJSON()); + } } export const extensionDiscovery = new ExtensionDiscovery(); diff --git a/src/extensions/npm/extensions/package.json b/src/extensions/npm/extensions/package.json index 1fc67414bf..a4c3f13d6e 100644 --- a/src/extensions/npm/extensions/package.json +++ b/src/extensions/npm/extensions/package.json @@ -17,6 +17,7 @@ "email": "info@k8slens.dev" }, "devDependencies": { - "@types/node": "^14.14.6" + "@types/node": "^14.14.6", + "conf": "^7.0.1" } } diff --git a/src/extensions/stores/cluster-store.ts b/src/extensions/stores/cluster-store.ts index 988302543e..b2aba41c06 100644 --- a/src/extensions/stores/cluster-store.ts +++ b/src/extensions/stores/cluster-store.ts @@ -9,6 +9,8 @@ export type { ClusterModel, ClusterId } from "../../common/cluster-store"; /** * Store for all added clusters + * + * @beta */ export class ClusterStore extends Singleton { diff --git a/src/extensions/stores/workspace-store.ts b/src/extensions/stores/workspace-store.ts index dd5f8c9ebd..2ff4a830fd 100644 --- a/src/extensions/stores/workspace-store.ts +++ b/src/extensions/stores/workspace-store.ts @@ -7,6 +7,8 @@ export type { WorkspaceId, WorkspaceModel } from "../../common/workspace-store"; /** * Stores all workspaces + * + * @beta */ export class WorkspaceStore extends Singleton { /** diff --git a/src/main/cluster-detectors/distribution-detector.ts b/src/main/cluster-detectors/distribution-detector.ts index b496f2ce00..be0cadb1bd 100644 --- a/src/main/cluster-detectors/distribution-detector.ts +++ b/src/main/cluster-detectors/distribution-detector.ts @@ -60,6 +60,10 @@ export class DistributionDetector extends BaseClusterDetector { return { value: "custom", accuracy: 10}; } + if (await this.isOpenshift()) { + return { value: "openshift", accuracy: 90}; + } + return { value: "unknown", accuracy: 10}; } @@ -122,4 +126,14 @@ export class DistributionDetector extends BaseClusterDetector { protected isK3s() { return this.version.includes("+k3s"); } + + protected async isOpenshift() { + try { + const response = await this.k8sRequest(""); + + return response.paths?.includes("/apis/project.openshift.io"); + } catch (e) { + return false; + } + } } diff --git a/src/main/cluster.ts b/src/main/cluster.ts index a130691e8e..79e28fa9c4 100644 --- a/src/main/cluster.ts +++ b/src/main/cluster.ts @@ -49,10 +49,31 @@ export interface ClusterState { allowedResources: string[] } +/** + * Cluster + * + * @beta + */ export class Cluster implements ClusterModel, ClusterState { + /** Unique id for a cluster */ public id: ClusterId; + /** + * Kubectl + * + * @internal + */ public kubeCtl: Kubectl; + /** + * Context handler + * + * @internal + */ public contextHandler: ContextHandler; + /** + * Owner reference + * + * If extension sets this it needs to also mark cluster as enabled on activate (or when added to a store) + */ public ownerRef: string; protected kubeconfigManager: KubeconfigManager; protected eventDisposers: Function[] = []; @@ -61,34 +82,147 @@ export class Cluster implements ClusterModel, ClusterState { whenInitialized = when(() => this.initialized); whenReady = when(() => this.ready); + /** + * Is cluster object initialized + * + * @observable + */ @observable initialized = false; + /** + * Kubeconfig context name + * + * @observable + */ @observable contextName: string; + /** + * Workspace id + * + * @observable + */ @observable workspace: WorkspaceId; + /** + * Path to kubeconfig + * + * @observable + */ @observable kubeConfigPath: string; + /** + * Kubernetes API server URL + * + * @observable + */ @observable apiUrl: string; // cluster server url + /** + * Internal authentication proxy URL + * + * @observable + * @internal + */ @observable kubeProxyUrl: string; // lens-proxy to kube-api url + /** + * Is cluster instance enabled (disabled clusters are currently hidden) + * + * @observable + */ @observable enabled = false; // only enabled clusters are visible to users + /** + * Is cluster online + * + * @observable + */ @observable online = false; // describes if we can detect that cluster is online + /** + * Can user access cluster resources + * + * @observable + */ @observable accessible = false; // if user is able to access cluster resources + /** + * Is cluster instance in usable state + * + * @observable + */ @observable ready = false; // cluster is in usable state + /** + * Is cluster currently reconnecting + * + * @observable + */ @observable reconnecting = false; - @observable disconnected = true; // false if user has selected to connect + /** + * Is cluster disconnected. False if user has selected to connect. + * + * @observable + */ + @observable disconnected = true; + /** + * Connection failure reason + * + * @observable + */ @observable failureReason: string; + /** + * Does user have admin like access + * + * @observable + */ @observable isAdmin = false; + /** + * Preferences + * + * @observable + */ @observable preferences: ClusterPreferences = {}; + /** + * Metadata + * + * @observable + */ @observable metadata: ClusterMetadata = {}; + /** + * List of allowed namespaces + * + * @observable + */ @observable allowedNamespaces: string[] = []; + /** + * List of allowed resources + * + * @observable + * @internal + */ @observable allowedResources: string[] = []; + /** + * List of accessible namespaces + * + * @observable + */ @observable accessibleNamespaces: string[] = []; + /** + * Is cluster available + * + * @computed + */ @computed get available() { return this.accessible && !this.disconnected; } + /** + * Cluster name + * + * @computed + */ @computed get name() { return this.preferences.clusterName || this.contextName; } + /** + * Prometheus preferences + * + * @computed + * @internal + */ @computed get prometheusPreferences(): ClusterPrometheusPreferences { const { prometheus, prometheusProvider } = this.preferences; @@ -97,6 +231,9 @@ export class Cluster implements ClusterModel, ClusterState { }); } + /** + * Kubernetes version + */ get version(): string { return String(this.metadata?.version) || ""; } @@ -110,17 +247,29 @@ export class Cluster implements ClusterModel, ClusterState { } } + /** + * Is cluster managed by an extension + */ get isManaged(): boolean { return !!this.ownerRef; } - @action - updateModel(model: ClusterModel) { + /** + * Update cluster data model + * + * @param model + */ + @action updateModel(model: ClusterModel) { Object.assign(this, model); } - @action - async init(port: number) { + /** + * Initialize a cluster (can be done only in main process) + * + * @param port port where internal auth proxy is listening + * @internal + */ + @action async init(port: number) { try { this.contextHandler = new ContextHandler(this); this.kubeconfigManager = await KubeconfigManager.create(this, this.contextHandler, port); @@ -139,6 +288,9 @@ export class Cluster implements ClusterModel, ClusterState { } } + /** + * @internal + */ protected bindEvents() { logger.info(`[CLUSTER]: bind events`, this.getMeta()); const refreshTimer = setInterval(() => !this.disconnected && this.refresh(), 30000); // every 30s @@ -156,14 +308,20 @@ export class Cluster implements ClusterModel, ClusterState { } } + /** + * internal + */ protected unbindEvents() { logger.info(`[CLUSTER]: unbind events`, this.getMeta()); this.eventDisposers.forEach(dispose => dispose()); this.eventDisposers.length = 0; } - @action - async activate(force = false) { + /** + * @param force force activation + * @internal + */ + @action async activate(force = false) { if (this.activated && !force) { return this.pushState(); } @@ -190,22 +348,29 @@ export class Cluster implements ClusterModel, ClusterState { return this.pushState(); } + /** + * @internal + */ protected async ensureKubectl() { this.kubeCtl = new Kubectl(this.version); return this.kubeCtl.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard } - @action - async reconnect() { + /** + * @internal + */ + @action async reconnect() { logger.info(`[CLUSTER]: reconnect`, this.getMeta()); this.contextHandler?.stopServer(); await this.contextHandler?.ensureServer(); this.disconnected = false; } - @action - disconnect() { + /** + * @internal + */ + @action disconnect() { logger.info(`[CLUSTER]: disconnect`, this.getMeta()); this.unbindEvents(); this.contextHandler?.stopServer(); @@ -217,8 +382,11 @@ export class Cluster implements ClusterModel, ClusterState { this.pushState(); } - @action - async refresh(opts: ClusterRefreshOptions = {}) { + /** + * @internal + * @param opts refresh options + */ + @action async refresh(opts: ClusterRefreshOptions = {}) { logger.info(`[CLUSTER]: refresh`, this.getMeta()); await this.whenInitialized; await this.refreshConnectionStatus(); @@ -235,8 +403,10 @@ export class Cluster implements ClusterModel, ClusterState { this.pushState(); } - @action - async refreshMetadata() { + /** + * @internal + */ + @action async refreshMetadata() { logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); const metadata = await detectorRegistry.detectForCluster(this); const existingMetadata = this.metadata; @@ -244,16 +414,20 @@ export class Cluster implements ClusterModel, ClusterState { this.metadata = Object.assign(existingMetadata, metadata); } - @action - async refreshConnectionStatus() { + /** + * @internal + */ + @action async refreshConnectionStatus() { const connectionStatus = await this.getConnectionStatus(); this.online = connectionStatus > ClusterStatus.Offline; this.accessible = connectionStatus == ClusterStatus.AccessGranted; } - @action - async refreshAllowedResources() { + /** + * @internal + */ + @action async refreshAllowedResources() { this.allowedNamespaces = await this.getAllowedNamespaces(); this.allowedResources = await this.getAllowedResources(); } @@ -262,10 +436,16 @@ export class Cluster implements ClusterModel, ClusterState { return loadConfig(this.kubeConfigPath); } + /** + * @internal + */ getProxyKubeconfig(): KubeConfig { return loadConfig(this.getProxyKubeconfigPath()); } + /** + * @internal + */ getProxyKubeconfigPath(): string { return this.kubeconfigManager.getPath(); } @@ -279,6 +459,12 @@ export class Cluster implements ClusterModel, ClusterState { return request(this.kubeProxyUrl + path, options); } + /** + * + * @param prometheusPath path to prometheus service + * @param queryParams query parameters + * @internal + */ getMetrics(prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) { const prometheusPrefix = this.preferences.prometheus?.prefix || ""; const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`; @@ -329,6 +515,10 @@ export class Cluster implements ClusterModel, ClusterState { } } + /** + * @internal + * @param resourceAttributes resource attributes + */ async canI(resourceAttributes: V1ResourceAttributes): Promise { const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api); @@ -347,6 +537,9 @@ export class Cluster implements ClusterModel, ClusterState { } } + /** + * @internal + */ async isClusterAdmin(): Promise { return this.canI({ namespace: "kube-system", @@ -372,7 +565,9 @@ export class Cluster implements ClusterModel, ClusterState { }); } - // serializable cluster-state used for sync btw main <-> renderer + /** + * Serializable cluster-state used for sync btw main <-> renderer + */ getState(): ClusterState { const state: ClusterState = { initialized: this.initialized, @@ -393,11 +588,18 @@ export class Cluster implements ClusterModel, ClusterState { }); } - @action - setState(state: ClusterState) { + /** + * @internal + * @param state cluster state + */ + @action setState(state: ClusterState) { Object.assign(this, state); } + /** + * @internal + * @param state cluster state + */ pushState(state = this.getState()) { logger.silly(`[CLUSTER]: push-state`, state); broadcastMessage("cluster:state", this.id, state); diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index 7013966f08..b1af7d69ac 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -88,7 +88,9 @@ export class WindowManager extends Singleton { await this.mainWindow.loadURL(this.mainUrl); this.mainWindow.show(); this.splashWindow?.close(); - appEventBus.emit({ name: "app", action: "start" }); + setTimeout(() => { + appEventBus.emit({ name: "app", action: "start" }); + }, 1000); } catch (err) { dialog.showErrorBox("ERROR!", err.toString()); } diff --git a/src/renderer/bootstrap.tsx b/src/renderer/bootstrap.tsx index f2369df0fd..f15625a881 100644 --- a/src/renderer/bootstrap.tsx +++ b/src/renderer/bootstrap.tsx @@ -3,19 +3,20 @@ import "./components/app.scss"; import React from "react"; import * as Mobx from "mobx"; import * as MobxReact from "mobx-react"; -import * as LensExtensions from "../extensions/extension-api"; -import { App } from "./components/app"; -import { LensApp } from "./lens-app"; import { render, unmountComponentAtNode } from "react-dom"; -import { isMac } from "../common/vars"; -import { userStore } from "../common/user-store"; -import { workspaceStore } from "../common/workspace-store"; import { clusterStore } from "../common/cluster-store"; -import { i18nStore } from "./i18n"; -import { themeStore } from "./theme.store"; -import { extensionsStore } from "../extensions/extensions-store"; +import { userStore } from "../common/user-store"; +import { isMac } from "../common/vars"; +import { workspaceStore } from "../common/workspace-store"; +import * as LensExtensions from "../extensions/extension-api"; +import { extensionDiscovery } from "../extensions/extension-discovery"; import { extensionLoader } from "../extensions/extension-loader"; +import { extensionsStore } from "../extensions/extensions-store"; import { filesystemProvisionerStore } from "../main/extension-filesystem"; +import { App } from "./components/app"; +import { i18nStore } from "./i18n"; +import { LensApp } from "./lens-app"; +import { themeStore } from "./theme.store"; type AppComponent = React.ComponentType & { init?(): Promise; @@ -34,6 +35,7 @@ export async function bootstrap(App: AppComponent) { rootElem.classList.toggle("is-mac", isMac); extensionLoader.init(); + extensionDiscovery.init(); // preload common stores await Promise.all([ diff --git a/src/renderer/components/+events/event.store.ts b/src/renderer/components/+events/event.store.ts index 39d4d5df83..3651ce1549 100644 --- a/src/renderer/components/+events/event.store.ts +++ b/src/renderer/components/+events/event.store.ts @@ -12,6 +12,7 @@ import { apiManager } from "../../api/api-manager"; export class EventStore extends KubeObjectStore { api = eventApi; limit = 1000; + saveLimit = 50000; protected bindWatchEventsUpdater() { return super.bindWatchEventsUpdater(5000); diff --git a/src/renderer/components/+extensions/__tests__/extensions.test.tsx b/src/renderer/components/+extensions/__tests__/extensions.test.tsx index 57c7738e16..cb4db0fece 100644 --- a/src/renderer/components/+extensions/__tests__/extensions.test.tsx +++ b/src/renderer/components/+extensions/__tests__/extensions.test.tsx @@ -5,6 +5,7 @@ import React from "react"; import { extensionDiscovery } from "../../../../extensions/extension-discovery"; import { ConfirmDialog } from "../../confirm-dialog"; import { Notifications } from "../../notifications"; +import { ExtensionStateStore } from "../extension-install.store"; import { Extensions } from "../extensions"; jest.mock("fs-extra"); @@ -21,7 +22,8 @@ jest.mock("../../../../extensions/extension-discovery", () => ({ ...jest.requireActual("../../../../extensions/extension-discovery"), extensionDiscovery: { localFolderPath: "/fake/path", - uninstallExtension: jest.fn(() => Promise.resolve()) + uninstallExtension: jest.fn(() => Promise.resolve()), + isLoaded: true } })); @@ -51,6 +53,10 @@ jest.mock("../../notifications", () => ({ })); describe("Extensions", () => { + beforeEach(() => { + ExtensionStateStore.resetInstance(); + }); + it("disables uninstall and disable buttons while uninstalling", async () => { render(<>); @@ -61,14 +67,14 @@ describe("Extensions", () => { // Approve confirm dialog fireEvent.click(screen.getByText("Yes")); - + expect(extensionDiscovery.uninstallExtension).toHaveBeenCalledWith("/absolute/path"); expect(screen.getByText("Disable").closest("button")).toBeDisabled(); expect(screen.getByText("Uninstall").closest("button")).toBeDisabled(); }); it("displays error notification on uninstall error", () => { - (extensionDiscovery.uninstallExtension as any).mockImplementationOnce(() => + (extensionDiscovery.uninstallExtension as any).mockImplementationOnce(() => Promise.reject() ); render(<>); @@ -107,4 +113,17 @@ describe("Extensions", () => { expect(Notifications.error).not.toHaveBeenCalled(); }); }); + + it("displays spinner while extensions are loading", () => { + extensionDiscovery.isLoaded = false; + const { container } = render(); + + expect(container.querySelector(".Spinner")).toBeInTheDocument(); + + extensionDiscovery.isLoaded = true; + + waitFor(() => + expect(container.querySelector(".Spinner")).not.toBeInTheDocument() + ); + }); }); diff --git a/src/renderer/components/+extensions/extension-install.store.ts b/src/renderer/components/+extensions/extension-install.store.ts new file mode 100644 index 0000000000..c4a8ed6690 --- /dev/null +++ b/src/renderer/components/+extensions/extension-install.store.ts @@ -0,0 +1,13 @@ +import { observable } from "mobx"; +import { autobind, Singleton } from "../../utils"; + +interface ExtensionState { + displayName: string; + // Possible states the extension can be + state: "installing" | "uninstalling"; +} + +@autobind() +export class ExtensionStateStore extends Singleton { + extensionState = observable.map(); +} diff --git a/src/renderer/components/+extensions/extensions.scss b/src/renderer/components/+extensions/extensions.scss index 6060fee61e..7352080a45 100644 --- a/src/renderer/components/+extensions/extensions.scss +++ b/src/renderer/components/+extensions/extensions.scss @@ -37,6 +37,11 @@ font-weight: bold; } } + + > .spinner-wrapper { + display: flex; + justify-content: center; + } } .SearchInput { diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 4b81a342dc..6a94b49480 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -21,7 +21,9 @@ import { DropFileInput, Input, InputValidator, InputValidators, SearchInput } fr import { PageLayout } from "../layout/page-layout"; import { SubTitle } from "../layout/sub-title"; import { Notifications } from "../notifications"; +import { Spinner } from "../spinner/spinner"; import { TooltipPosition } from "../tooltip"; +import { ExtensionStateStore } from "./extension-install.store"; import "./extensions.scss"; interface InstallRequest { @@ -39,25 +41,20 @@ interface InstallRequestValidated extends InstallRequestPreloaded { tempFile: string; // temp system path to packed extension for unpacking } -interface ExtensionState { - displayName: string; - // Possible states the extension can be - state: "installing" | "uninstalling"; -} - @observer export class Extensions extends React.Component { - private supportedFormats = ["tar", "tgz"]; + private static supportedFormats = ["tar", "tgz"]; - private installPathValidator: InputValidator = { + private static installPathValidator: InputValidator = { message: Invalid URL or absolute path, validate(value: string) { return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value); } }; - @observable - extensionState = observable.map(); + get extensionStateStore() { + return ExtensionStateStore.getInstance(); + } @observable search = ""; @observable installPath = ""; @@ -69,18 +66,24 @@ export class Extensions extends React.Component { * Extensions that were removed from extensions but are still in "uninstalling" state */ @computed get removedUninstalling() { - return Array.from(this.extensionState.entries()).filter(([id, extension]) => - extension.state === "uninstalling" && !this.extensions.find(extension => extension.id === id) - ).map(([id, extension]) => ({ ...extension, id })); + return Array.from(this.extensionStateStore.extensionState.entries()) + .filter(([id, extension]) => + extension.state === "uninstalling" + && !this.extensions.find(extension => extension.id === id) + ) + .map(([id, extension]) => ({ ...extension, id })); } /** * Extensions that were added to extensions but are still in "installing" state */ @computed get addedInstalling() { - return Array.from(this.extensionState.entries()).filter(([id, extension]) => - extension.state === "installing" && this.extensions.find(extension => extension.id === id) - ).map(([id, extension]) => ({ ...extension, id })); + return Array.from(this.extensionStateStore.extensionState.entries()) + .filter(([id, extension]) => + extension.state === "installing" + && this.extensions.find(extension => extension.id === id) + ) + .map(([id, extension]) => ({ ...extension, id })); } componentDidMount() { @@ -90,7 +93,7 @@ export class Extensions extends React.Component { Notifications.ok(

Extension {displayName} successfully uninstalled!

); - this.extensionState.delete(id); + this.extensionStateStore.extensionState.delete(id); }); this.addedInstalling.forEach(({ id, displayName }) => { @@ -103,7 +106,7 @@ export class Extensions extends React.Component { Notifications.ok(

Extension {displayName} successfully installed!

); - this.extensionState.delete(id); + this.extensionStateStore.extensionState.delete(id); this.installPath = ""; // Enable installed extensions by default. @@ -116,14 +119,11 @@ export class Extensions extends React.Component { @computed get extensions() { const searchText = this.search.toLowerCase(); - return Array.from(extensionLoader.userExtensions.values()).filter(ext => { - const { name, description } = ext.manifest; - - return [ - name.toLowerCase().includes(searchText), - description?.toLowerCase().includes(searchText), - ].some(value => value); - }); + return Array.from(extensionLoader.userExtensions.values()) + .filter(({ manifest: { name, description }}) => ( + name.toLowerCase().includes(searchText) + || description?.toLowerCase().includes(searchText) + )); } get extensionsPath() { @@ -143,10 +143,10 @@ export class Extensions extends React.Component { const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), { defaultPath: app.getPath("downloads"), properties: ["openFile", "multiSelections"], - message: _i18n._(t`Select extensions to install (formats: ${this.supportedFormats.join(", ")}), `), + message: _i18n._(t`Select extensions to install (formats: ${Extensions.supportedFormats.join(", ")}), `), buttonLabel: _i18n._(t`Use configuration`), filters: [ - { name: "tarball", extensions: this.supportedFormats } + { name: "tarball", extensions: Extensions.supportedFormats } ] }); @@ -346,7 +346,9 @@ export class Extensions extends React.Component { const displayName = extensionDisplayName(name, version); const extensionId = path.join(extensionDiscovery.nodeModulesPath, name, "package.json"); - this.extensionState.set(extensionId, { + logger.info(`Unpacking extension ${displayName}`, { fileName, tempFile }); + + this.extensionStateStore.extensionState.set(extensionId, { state: "installing", displayName }); @@ -381,8 +383,8 @@ export class Extensions extends React.Component { ); // Remove install state on install failure - if (this.extensionState.get(extensionId)?.state === "installing") { - this.extensionState.delete(extensionId); + if (this.extensionStateStore.extensionState.get(extensionId)?.state === "installing") { + this.extensionStateStore.extensionState.delete(extensionId); } } finally { // clean up @@ -406,7 +408,7 @@ export class Extensions extends React.Component { const displayName = extensionDisplayName(extension.manifest.name, extension.manifest.version); try { - this.extensionState.set(extension.id, { + this.extensionStateStore.extensionState.set(extension.id, { state: "uninstalling", displayName }); @@ -418,8 +420,8 @@ export class Extensions extends React.Component { ); // Remove uninstall state on uninstall failure - if (this.extensionState.get(extension.id)?.state === "uninstalling") { - this.extensionState.delete(extension.id); + if (this.extensionStateStore.extensionState.get(extension.id)?.state === "uninstalling") { + this.extensionStateStore.extensionState.delete(extension.id); } } } @@ -445,7 +447,7 @@ export class Extensions extends React.Component { return extensions.map(extension => { const { id, isEnabled, manifest } = extension; const { name, description } = manifest; - const isUninstalling = this.extensionState.get(id)?.state === "uninstalling"; + const isUninstalling = this.extensionStateStore.extensionState.get(id)?.state === "uninstalling"; return (
@@ -481,7 +483,7 @@ export class Extensions extends React.Component { * True if at least one extension is in installing state */ @computed get isInstalling() { - return this.startingInstall || [...this.extensionState.values()].some(extension => extension.state === "installing"); + return [...this.extensionStateStore.extensionState.values()].some(extension => extension.state === "installing"); } render() { @@ -504,9 +506,9 @@ export class Extensions extends React.Component { className="box grow" theme="round-black" disabled={this.isInstalling} - placeholder={`Path or URL to an extension package (${this.supportedFormats.join(", ")})`} + placeholder={`Path or URL to an extension package (${Extensions.supportedFormats.join(", ")})`} showErrorsAsTooltip={{ preferredPositions: TooltipPosition.BOTTOM }} - validators={installPath ? this.installPathValidator : undefined} + validators={installPath ? Extensions.installPathValidator : undefined} value={installPath} onChange={value => this.installPath = value} onSubmit={this.installFromUrlOrPath} @@ -524,7 +526,7 @@ export class Extensions extends React.Component {
diff --git a/src/renderer/components/app.tsx b/src/renderer/components/app.tsx index f6c266f966..3051336633 100755 --- a/src/renderer/components/app.tsx +++ b/src/renderer/components/app.tsx @@ -61,12 +61,14 @@ export class App extends React.Component { await requestMain(clusterSetFrameIdHandler, clusterId, frameId); await getHostedCluster().whenReady; // cluster.activate() is done at this point extensionLoader.loadOnClusterRenderer(); - appEventBus.emit({ - name: "cluster", - action: "open", - params: { - clusterId - } + setTimeout(() => { + appEventBus.emit({ + name: "cluster", + action: "open", + params: { + clusterId + } + }); }); window.addEventListener("online", () => { window.location.reload(); @@ -154,9 +156,7 @@ export class App extends React.Component { const page = clusterPageRegistry.getByPageTarget(menu.target); if (page) { - const pageComponent = () => ; - - return ; + return ; } } }); diff --git a/src/renderer/components/layout/__test__/main-layout-header.test.tsx b/src/renderer/components/layout/__test__/main-layout-header.test.tsx new file mode 100644 index 0000000000..91fcc5dc7f --- /dev/null +++ b/src/renderer/components/layout/__test__/main-layout-header.test.tsx @@ -0,0 +1,49 @@ +jest.mock("../../../../common/ipc"); + +import React from "react"; +import { fireEvent, render } from "@testing-library/react"; +import "@testing-library/jest-dom/extend-expect"; + +import { MainLayoutHeader } from "../main-layout-header"; +import { Cluster } from "../../../../main/cluster"; +import { workspaceStore } from "../../../../common/workspace-store"; +import { broadcastMessage } from "../../../../common/ipc"; + +const mockBroadcastIpc = broadcastMessage as jest.MockedFunction; + +const cluster: Cluster = new Cluster({ + id: "foo", + contextName: "minikube", + kubeConfigPath: "minikube-config.yml", + workspace: workspaceStore.currentWorkspaceId +}); + +describe("", () => { + it("renders w/o errors", () => { + const { container } = render(); + + expect(container).toBeInstanceOf(HTMLElement); + }); + + it("renders gear icon", () => { + const { container } = render(); + const icon = container.querySelector(".Icon .icon"); + + expect(icon).toBeInstanceOf(HTMLElement); + expect(icon).toHaveTextContent("settings"); + }); + + it("navigates to cluster settings", () => { + const { container } = render(); + const icon = container.querySelector(".Icon"); + + fireEvent.click(icon); + expect(mockBroadcastIpc).toBeCalledWith("renderer:navigate", "/cluster/foo/settings"); + }); + + it("renders cluster name", async () => { + const { getByText } = render(); + + expect(await getByText("minikube")).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/renderer/components/layout/main-layout-header.tsx b/src/renderer/components/layout/main-layout-header.tsx new file mode 100644 index 0000000000..d48d52634f --- /dev/null +++ b/src/renderer/components/layout/main-layout-header.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { Trans } from "@lingui/macro"; + +import { clusterSettingsURL } from "../+cluster-settings"; +import { broadcastMessage } from "../../../common/ipc"; +import { Cluster } from "../../../main/cluster"; +import { cssNames } from "../../utils"; +import { Icon } from "../icon"; + +interface Props { + cluster: Cluster + className?: string +} + +export function MainLayoutHeader({ cluster, className }: Props) { + return ( +
+ {cluster.name} + Open cluster settings} + interactive + onClick={() => { + broadcastMessage("renderer:navigate", clusterSettingsURL({ + params: { + clusterId: cluster.id + } + })); + }} + /> +
+ ); +} \ No newline at end of file diff --git a/src/renderer/components/layout/main-layout.scss b/src/renderer/components/layout/main-layout.scss index d423b0388f..92f1173b7a 100755 --- a/src/renderer/components/layout/main-layout.scss +++ b/src/renderer/components/layout/main-layout.scss @@ -14,15 +14,6 @@ grid-area: header; background: $layoutBackground; padding: $padding $padding * 2; - - span + .lens-version { - margin-left: $margin; - } - - .lens-version { - font-size: x-small; - text-transform: uppercase; - } } > aside { diff --git a/src/renderer/components/layout/main-layout.tsx b/src/renderer/components/layout/main-layout.tsx index d02d7077aa..7be6148e3e 100755 --- a/src/renderer/components/layout/main-layout.tsx +++ b/src/renderer/components/layout/main-layout.tsx @@ -3,12 +3,13 @@ import "./main-layout.scss"; import React from "react"; import { observable, reaction } from "mobx"; import { disposeOnUnmount, observer } from "mobx-react"; -import { autobind, createStorage, cssNames } from "../../utils"; -import { Sidebar } from "./sidebar"; -import { ErrorBoundary } from "../error-boundary"; -import { Dock } from "../dock"; import { getHostedCluster } from "../../../common/cluster-store"; +import { autobind, createStorage, cssNames } from "../../utils"; +import { Dock } from "../dock"; +import { ErrorBoundary } from "../error-boundary"; import { ResizeDirection, ResizeGrowthDirection, ResizeSide, ResizingAnchor } from "../resizing-anchor"; +import { MainLayoutHeader } from "./main-layout-header"; +import { Sidebar } from "./sidebar"; export interface MainLayoutProps { className?: any; @@ -66,9 +67,7 @@ export class MainLayout extends React.Component { return (
-
- {cluster.name} -
+