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

Merge branch 'master' into pages-url-params

# Conflicts:
#	src/renderer/components/app.tsx
This commit is contained in:
Roman 2020-12-08 15:24:23 +02:00
commit a9d0af1656
39 changed files with 1452 additions and 259 deletions

9
.github/dependabot.yml vendored Normal file
View File

@ -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"

View File

@ -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:

View File

@ -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: |

View File

@ -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)
Worlds 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 youll 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 @@ Worlds 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 792 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 305 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@ -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 <Component.Icon {...props} material="security" tooltip="Certificates"/>
}
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: () => <CertificatePage extension={this} />,
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<Certificate> {
}
export const certificatesApi = new CertificatesApi({
objectConstructor: Certificate
});
```
`CertificateStore` defines storage for `Certificate` objects
```typescript
export class CertificatesStore extends K8sApi.KubeObjectStore<Certificate> {
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 (
<Component.TabLayout>
<Component.KubeObjectListLayout
className="Certicates" store={certificatesStore}
sortingCallbacks={{
[sortBy.name]: (certificate: Certificate) => 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
]}
/>
</Component.TabLayout>
)
}
}
```
### 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) => <CertificateDetails {...props} />
}
}]
}
```
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<Certificate>{
}
export class CertificateDetails extends React.Component<CertificateDetailsProps> {
render() {
const { object: certificate } = this.props;
if (!certificate) return null;
return (
<div className="Certificate">
<Component.DrawerItem name="Created">
{certificate.getAge(true, false)} ago ({certificate.metadata.creationTimestamp })
</Component.DrawerItem>
<Component.DrawerItem name="DNS Names">
{certificate.spec.dnsNames.join(",")}
</Component.DrawerItem>
<Component.DrawerItem name="Secret">
{certificate.spec.secretName}
</Component.DrawerItem>
<Component.DrawerItem name="Status" className="status" labelsOnly>
{certificate.status.conditions.map((condition, index) => {
const { type, reason, message, status } = condition;
const kind = type || reason;
if (!kind) return null;
return (
<Component.Badge
key={kind + index} label={kind}
className={"success "+kind.toLowerCase()}
tooltip={message}
/>
);
})}
</Component.DrawerItem>
</div>
)
}
}
```
## 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.

View File

@ -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<any>`, 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<any>`, 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: () => <ExemplePage extension={this}/>,
}
@ -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<any>`, 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<any>`, 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<void>;
@ -306,13 +374,20 @@ The `title` and `components.Description` fields provide content that appears on
abstract updateStatus(cluster: Cluster): Promise<ClusterFeatureStatus>;
```
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<ExamplePreferenceProps> {
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) => <CustomMenuItem {...props} />
MenuItem: (props: Component.KubeObjectMenuProps<K8sApi.Namespace>) => <NamespaceMenuItem {...props} />
}
}
];
@ -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<K8sApi.Namespace>) {
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 (
<Component.MenuItem onClick={getPods}>
<Component.Icon material="speaker_group" interactive={toolbar} title="Get pods in terminal"/>
<span className="title">Get Pods</span>
</Component.MenuItem>
);
}
```
`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) => <CustomKindDetails {...props} />
Details: (props: Component.KubeObjectDetailsProps<K8sApi.Namespace>) => <NamespaceDetailsItem {...props} />
}
}
];
}
```
```
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<Component.KubeObjectDetailsProps<K8sApi.Namespace>> {
@observable private pods: K8sApi.Pod[];
async componentDidMount() {
this.pods = await K8sApi.podsApi.list({namespace: this.props.object.getName()});
}
render() {
return (
<div>
<Component.DrawerTitle title="Pods" />
<PodsDetailsList pods={this.pods}/>
</div>
)
}
}
```
Since `NamespaceDetailsItem` extends `React.Component<Component.KubeObjectDetailsProps<K8sApi.Namespace>>` 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 `<Component.DrawerItem>` 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<Props> {
getTableRow(index: number) {
const {pods} = this.props;
return (
<Component.TableRow key={index} nowrap>
<Component.TableCell className="podName">{pods[index].getName()}</Component.TableCell>
<Component.TableCell className="podAge">{pods[index].getAge()}</Component.TableCell>
<Component.TableCell className="podStatus">{pods[index].getStatus()}</Component.TableCell>
</Component.TableRow>
)
}
render() {
const {pods} = this.props
if (!pods?.length) {
return null;
}
return (
<div >
<Component.Table>
<Component.TableHead>
<Component.TableCell className="podName">Name</Component.TableCell>
<Component.TableCell className="podAge">Age</Component.TableCell>
<Component.TableCell className="podStatus">Status</Component.TableCell>
</Component.TableHead>
{
pods.map((pod, index) => this.getTableRow(index))
}
</Component.Table>
</div>
)
}
}
```
`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.

View File

@ -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
## 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<ExamplePreferencesModel> {
@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<ExamplePreferencesStore>();
```
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<ExamplePreferencesStore>()`, 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: () => <ExamplePreferenceInput preference={examplePreferencesStore}/>,
Hint: () => <ExamplePreferenceHint/>
}
}
];
}
```
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<ExamplePreferenceProps> {
render() {
const { preference } = this.props;
return (
<Component.Checkbox
label="I understand appPreferences"
value={preference.enabled}
onChange={v => { preference.enabled = v; }}
/>
);
}
}
export class ExamplePreferenceHint extends React.Component {
render() {
return (
<span>This is an example of an appPreference for extensions.</span>
);
}
}
```
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`.

View File

@ -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 <major|minor|patch>` 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 <extension-name> 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.

View File

@ -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`

View File

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

View File

@ -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",

View File

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

View File

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

View File

@ -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<void>;
// 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();

View File

@ -17,6 +17,7 @@
"email": "info@k8slens.dev"
},
"devDependencies": {
"@types/node": "^14.14.6"
"@types/node": "^14.14.6",
"conf": "^7.0.1"
}
}

View File

@ -9,6 +9,8 @@ export type { ClusterModel, ClusterId } from "../../common/cluster-store";
/**
* Store for all added clusters
*
* @beta
*/
export class ClusterStore extends Singleton {

View File

@ -7,6 +7,8 @@ export type { WorkspaceId, WorkspaceModel } from "../../common/workspace-store";
/**
* Stores all workspaces
*
* @beta
*/
export class WorkspaceStore extends Singleton {
/**

View File

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

View File

@ -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<boolean> {
const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api);
@ -347,6 +537,9 @@ export class Cluster implements ClusterModel, ClusterState {
}
}
/**
* @internal
*/
async isClusterAdmin(): Promise<boolean> {
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);

View File

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

View File

@ -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<void>;
@ -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([

View File

@ -12,6 +12,7 @@ import { apiManager } from "../../api/api-manager";
export class EventStore extends KubeObjectStore<KubeEvent> {
api = eventApi;
limit = 1000;
saveLimit = 50000;
protected bindWatchEventsUpdater() {
return super.bindWatchEventsUpdater(5000);

View File

@ -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(<><Extensions /><ConfirmDialog/></>);
@ -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(<><Extensions /><ConfirmDialog/></>);
@ -107,4 +113,17 @@ describe("Extensions", () => {
expect(Notifications.error).not.toHaveBeenCalled();
});
});
it("displays spinner while extensions are loading", () => {
extensionDiscovery.isLoaded = false;
const { container } = render(<Extensions />);
expect(container.querySelector(".Spinner")).toBeInTheDocument();
extensionDiscovery.isLoaded = true;
waitFor(() =>
expect(container.querySelector(".Spinner")).not.toBeInTheDocument()
);
});
});

View File

@ -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<string, ExtensionState>();
}

View File

@ -37,6 +37,11 @@
font-weight: bold;
}
}
> .spinner-wrapper {
display: flex;
justify-content: center;
}
}
.SearchInput {

View File

@ -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: <Trans>Invalid URL or absolute path</Trans>,
validate(value: string) {
return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value);
}
};
@observable
extensionState = observable.map<string, ExtensionState>();
get extensionStateStore() {
return ExtensionStateStore.getInstance<ExtensionStateStore>();
}
@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(
<p>Extension <b>{displayName}</b> successfully uninstalled!</p>
);
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(
<p>Extension <b>{displayName}</b> successfully installed!</p>
);
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 (
<div key={id} className="extension flex gaps align-center">
@ -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 {
<Button
primary
label="Install"
disabled={this.isInstalling || !this.installPathValidator.validate(installPath)}
disabled={this.isInstalling || !Extensions.installPathValidator.validate(installPath)}
waiting={this.isInstalling}
onClick={this.installFromUrlOrPath}
/>
@ -540,7 +542,7 @@ export class Extensions extends React.Component {
value={this.search}
onChange={(value) => this.search = value}
/>
{this.renderExtensions()}
{extensionDiscovery.isLoaded ? this.renderExtensions() : <div className="spinner-wrapper"><Spinner/></div>}
</div>
</PageLayout>
</DropFileInput>

View File

@ -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 = () => <page.components.Page/>;
return <Route key={`extension-tab-layout-route-${index}`} path={page.url} component={pageComponent}/>;
return <Route key={`extension-tab-layout-route-${index}`} path={page.url} component={page.components.Page}/>;
}
}
});

View File

@ -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<typeof broadcastMessage>;
const cluster: Cluster = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId
});
describe("<MainLayoutHeader />", () => {
it("renders w/o errors", () => {
const { container } = render(<MainLayoutHeader cluster={cluster} />);
expect(container).toBeInstanceOf(HTMLElement);
});
it("renders gear icon", () => {
const { container } = render(<MainLayoutHeader cluster={cluster} />);
const icon = container.querySelector(".Icon .icon");
expect(icon).toBeInstanceOf(HTMLElement);
expect(icon).toHaveTextContent("settings");
});
it("navigates to cluster settings", () => {
const { container } = render(<MainLayoutHeader cluster={cluster} />);
const icon = container.querySelector(".Icon");
fireEvent.click(icon);
expect(mockBroadcastIpc).toBeCalledWith("renderer:navigate", "/cluster/foo/settings");
});
it("renders cluster name", async () => {
const { getByText } = render(<MainLayoutHeader cluster={cluster} />);
expect(await getByText("minikube")).toBeTruthy();
});
});

View File

@ -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 (
<header className={cssNames("flex gaps align-center justify-space-between", className)}>
<span className="cluster">{cluster.name}</span>
<Icon
material="settings"
tooltip={<Trans>Open cluster settings</Trans>}
interactive
onClick={() => {
broadcastMessage("renderer:navigate", clusterSettingsURL({
params: {
clusterId: cluster.id
}
}));
}}
/>
</header>
);
}

View File

@ -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 {

View File

@ -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<MainLayoutProps> {
return (
<div className={cssNames("MainLayout", className)} style={this.getSidebarSize() as any}>
<header className={cssNames("flex gaps align-center", headerClass)}>
<span className="cluster">{cluster.name}</span>
</header>
<MainLayoutHeader className={headerClass} cluster={cluster} />
<aside className={cssNames("flex column", { pinned: this.isPinned, accessible: this.isAccessible })}>
<Sidebar className="box grow" isPinned={this.isPinned} toggle={this.toggleSidebar} />

View File

@ -279,7 +279,9 @@ class SidebarNavItem extends React.Component<SidebarNavItemProps> {
public context: SidebarContextValue;
get itemId() {
return this.props.url;
const url = new URL(this.props.url, `${window.location.protocol}//${window.location.host}`);
return url.pathname; // pathname without get params
}
@computed get isExpanded() {

View File

@ -11,7 +11,8 @@ import { getHostedCluster } from "../common/cluster-store";
@autobind()
export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemStore<T> {
abstract api: KubeApi<T>;
public limit: number;
public readonly limit?: number;
public readonly bufferSize: number = 50000;
constructor() {
super();
@ -19,6 +20,16 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
kubeWatchApi.addListener(this, this.onWatchApiEvent);
}
get query(): IKubeApiQueryParams {
const { limit } = this;
if (!limit) {
return {};
}
return { limit };
}
getStatuses?(items: T[]): Record<string, number>;
getAllByNs(namespace: string | string[], strict = false): T[] {
@ -62,10 +73,7 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
protected async loadItems(allowedNamespaces?: string[]): Promise<T[]> {
if (!this.api.isNamespaced || !allowedNamespaces) {
const { limit } = this;
const query: IKubeApiQueryParams = limit ? { limit } : {};
return this.api.list({}, query);
return this.api.list({}, this.query);
} else {
return Promise
.all(allowedNamespaces.map(namespace => this.api.list({ namespace })))
@ -179,9 +187,9 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
return;
}
// create latest non-observable copy of items to apply updates in one action (==single render)
let items = this.items.toJS();
const items = this.items.toJS();
this.eventsBuffer.clear().forEach(({ type, object }) => {
for (const {type, object} of this.eventsBuffer.clear()) {
const { uid, selfLink } = object.metadata;
const index = items.findIndex(item => item.getId() === uid);
const item = items[index];
@ -204,14 +212,9 @@ export abstract class KubeObjectStore<T extends KubeObject = any> extends ItemSt
}
break;
}
});
// slice to max allowed items
if (this.limit && items.length > this.limit) {
items = items.slice(-this.limit);
}
// update items
this.items.replace(this.sortItems(items));
this.items.replace(this.sortItems(items.slice(-this.bufferSize)));
}
}

View File

@ -2,7 +2,7 @@
Here you can find description of changes we've built into each release. While we try our best to make each upgrade automatic and as smooth as possible, there may be some cases where you might need to do something to ensure the application works smoothly. So please read through the release highlights!
## 4.0.0-rc.2 (current version)
## 4.0.0-rc.3 (current version)
- Extension API
- Improved pod logs
@ -26,6 +26,8 @@ Here you can find description of changes we've built into each release. While we
- Update EULA url
- Change add-cluster to single column layout
- Replace cluster warning event polling with watches
- Detect more Kubernetes distributions
- Performance fix when cluster has lots of namespaces
- Fix pod usage metrics on Kubernetes >=1.19
- Fix proxy upgrade socket timeouts
- Fix UI staleness after network issues
@ -33,6 +35,11 @@ Here you can find description of changes we've built into each release. While we
- Fix kube-auth-proxy to accept only target cluster hostname
- Fix link to metrics stack resources
## 3.6.9
- Use Alpine 3.12 for node shell sessions
- Fix errors on app quit
- Fix kube-auth-proxy to accept only target cluster hostname
## 3.6.8
- Fix cluster connection issue when opening cluster settings for disconnected clusters
- Fetch available Helm repositories from Artifact HUB