mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Merge branch 'master' into fix/logs-scroll-to-bottom-appearence
Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>
This commit is contained in:
commit
159e14f432
@ -30,10 +30,9 @@ jobs:
|
||||
displayName: Install Node.js
|
||||
- task: Cache@2
|
||||
inputs:
|
||||
key: yarn | $(Agent.OS) | yarn.lock
|
||||
key: 'yarn | "$(Agent.OS)"" | yarn.lock'
|
||||
restoreKeys: |
|
||||
yarn | "$(Agent.OS)"
|
||||
yarn
|
||||
path: $(YARN_CACHE_FOLDER)
|
||||
displayName: Cache Yarn packages
|
||||
- script: make node_modules
|
||||
@ -70,10 +69,9 @@ jobs:
|
||||
displayName: Install Node.js
|
||||
- task: Cache@2
|
||||
inputs:
|
||||
key: yarn | $(Agent.OS) | yarn.lock
|
||||
key: 'yarn | "$(Agent.OS)" | yarn.lock'
|
||||
restoreKeys: |
|
||||
yarn | "$(Agent.OS)"
|
||||
yarn
|
||||
path: $(YARN_CACHE_FOLDER)
|
||||
displayName: Cache Yarn packages
|
||||
- script: make node_modules
|
||||
@ -116,10 +114,9 @@ jobs:
|
||||
displayName: Install Node.js
|
||||
- task: Cache@2
|
||||
inputs:
|
||||
key: yarn | $(Agent.OS) | yarn.lock
|
||||
key: 'yarn | "$(Agent.OS)" | yarn.lock'
|
||||
restoreKeys: |
|
||||
yarn | "$(Agent.OS)"
|
||||
yarn
|
||||
path: $(YARN_CACHE_FOLDER)
|
||||
displayName: Cache Yarn packages
|
||||
- script: make node_modules
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
module.exports = {
|
||||
ignorePatterns: ["src/extensions/npm/extensions/dist/**/*"],
|
||||
ignorePatterns: [
|
||||
"**/node_modules/**/*",
|
||||
"**/dist/**/*",
|
||||
],
|
||||
overrides: [
|
||||
{
|
||||
files: [
|
||||
@ -23,6 +26,7 @@ module.exports = {
|
||||
}],
|
||||
"no-unused-vars": "off",
|
||||
"semi": ["error", "always"],
|
||||
"object-shorthand": "error",
|
||||
}
|
||||
},
|
||||
{
|
||||
@ -55,6 +59,7 @@ module.exports = {
|
||||
}],
|
||||
"semi": "off",
|
||||
"@typescript-eslint/semi": ["error"],
|
||||
"object-shorthand": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -87,6 +92,7 @@ module.exports = {
|
||||
}],
|
||||
"semi": "off",
|
||||
"@typescript-eslint/semi": ["error"],
|
||||
"object-shorthand": "error",
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
@ -101,7 +101,6 @@ export default class ExampleRendererExtension extends LensRendererExtension {
|
||||
globalPages = [
|
||||
{
|
||||
id: "example",
|
||||
routePath: "/example",
|
||||
components: {
|
||||
Page: ExamplePage,
|
||||
}
|
||||
@ -156,7 +155,7 @@ import { ExampleIcon, ExamplePage } from "./src/page"
|
||||
export default class ExampleExtension extends LensRendererExtension {
|
||||
clusterPages = [
|
||||
{
|
||||
routePath: "/extension-example", // optional
|
||||
id: "extension-example", // optional
|
||||
exact: true, // optional
|
||||
components: {
|
||||
Page: () => <ExamplePage extension={this}/>,
|
||||
|
||||
@ -85,7 +85,7 @@ import React from "react"
|
||||
export default class ExampleExtension extends LensRendererExtension {
|
||||
clusterPages = [
|
||||
{
|
||||
routePath: "/extension-example",
|
||||
id: "extension-example",
|
||||
components: {
|
||||
Page: () => <ExamplePage extension={this}/>,
|
||||
}
|
||||
@ -94,4 +94,4 @@ export default class ExampleExtension extends LensRendererExtension {
|
||||
}
|
||||
```
|
||||
|
||||
The Hello World sample extension uses the `Cluster Page` capability, which is just one of the Lens extension API's capabilities. The [Common Capabilities](../capabilities/common-capabilities.md) page will help you home in on the right capabilities to use with your own extensions.
|
||||
The Hello World sample extension uses the `Cluster Page` capability, which is just one of the Lens extension API's capabilities. The [Common Capabilities](../capabilities/common-capabilities.md) page will help you home in on the right capabilities to use with your own extensions.
|
||||
|
||||
@ -1,5 +1,11 @@
|
||||
# Your First Extension
|
||||
|
||||
We recommend to always use [Yeoman generator for Lens Extension](https://github.com/lensapp/generator-lens-ext) to start new extension project. [Read the generator guide here](../guides/generator.md).
|
||||
|
||||
If you want to setup the project manually, please continue reading.
|
||||
|
||||
## First Extension
|
||||
|
||||
In this topic, you'll learn the basics of building extensions by creating an extension that adds a "Hello World" page to a cluster menu.
|
||||
|
||||
## Install the Extension
|
||||
|
||||
65
docs/extensions/guides/generator.md
Normal file
65
docs/extensions/guides/generator.md
Normal file
@ -0,0 +1,65 @@
|
||||
# New Extension Project with Generator
|
||||
|
||||
The [Lens Extension Generator](https://github.com/lensapp/generator-lens-ext) scaffolds a project ready for development. Install Yeoman and Lens Extension Generator with:
|
||||
|
||||
```bash
|
||||
npm install -g yo generator-lens-ext
|
||||
```
|
||||
|
||||
Run the generator and fill out a few fields for a TypeScript project:
|
||||
|
||||
```bash
|
||||
yo lens-ext
|
||||
# ? What type of extension do you want to create? New Extension (TypeScript)
|
||||
# ? What's the name of your extension? my-first-lens-ext
|
||||
# ? What's the description of your extension? My hello world extension
|
||||
# ? What's your extension's publisher name? @my-org/my-first-lens-ext
|
||||
# ? Initialize a git repository? Yes
|
||||
# ? Install dependencies after initialization? Yes
|
||||
# ? Which package manager to use? yarn
|
||||
# ? symlink created extension folder to ~/.k8slens/extensions (mac/linux) or :User
|
||||
s\<user>\.k8slens\extensions (windows)? Yes
|
||||
```
|
||||
|
||||
Start webpack, which watches the `my-first-lens-ext` folder.
|
||||
|
||||
```bash
|
||||
cd my-first-lens-ext
|
||||
npm start # start the webpack server in watch mode
|
||||
```
|
||||
|
||||
Then, open Lens, you should see a Hello World item in the menu:
|
||||
|
||||

|
||||
|
||||
## Developing the Extension
|
||||
|
||||
Try to change `my-first-lens-ext/renderer.tsx` to "Hello Lens!":
|
||||
|
||||
```tsx
|
||||
clusterPageMenus = [
|
||||
{
|
||||
target: { pageId: "hello" },
|
||||
title: "Hello Lens",
|
||||
components: {
|
||||
Icon: ExampleIcon,
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Then, Reload Lens by CMD+R (Mac) / Ctrl+R (Linux/Windows), you should see the menu item text changes:
|
||||
|
||||

|
||||
|
||||
## Debugging the Extension
|
||||
|
||||
[Testing](../testing-and-publishing/testing.md)
|
||||
|
||||
## Next steps
|
||||
|
||||
You can take a closer look at [Common Capabilities](../capabilities/common-capabilities.md) of extension, how to [style](../capabilities/styling.md) the extension. Or the [Extension Anatomy](anatomy.md).
|
||||
|
||||
You are welcome to raise an [issue](https://github.com/lensapp/generator-lens-ext/issues) for Lens Extension Generator, if you find problems, or have feature requests.
|
||||
|
||||
The source code of the generator is hosted at [Github](https://github.com/lensapp/generator-lens-ext)
|
||||
BIN
docs/extensions/guides/images/hello-lens.png
Normal file
BIN
docs/extensions/guides/images/hello-lens.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/extensions/guides/images/hello-world.png
Normal file
BIN
docs/extensions/guides/images/hello-world.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
@ -20,7 +20,7 @@ export default class ExampleExtensionMain extends LensMainExtension {
|
||||
}
|
||||
```
|
||||
|
||||
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 overriding `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. Note that to see standard output from the main process there must be a console connected to it. This is typically achieved by starting Lens from the command prompt.
|
||||
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. Note that to see standard output from the main process there must be a console connected to it. This is typically achieved by starting Lens from the command prompt.
|
||||
|
||||
The following example is a little more interesting in that it accesses some Lens state data and periodically logs the name of the currently active cluster in Lens.
|
||||
|
||||
|
||||
@ -1 +1,425 @@
|
||||
# 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.
|
||||
|
||||
## `LensRendererExtension` Class
|
||||
|
||||
To create a renderer extension simply extend the `LensRendererExtension` class:
|
||||
|
||||
``` typescript
|
||||
import { LensRendererExtension } from "@k8slens/extensions";
|
||||
|
||||
export default class ExampleExtensionMain extends LensRendererExtension {
|
||||
onActivate() {
|
||||
console.log('custom renderer process extension code started');
|
||||
}
|
||||
|
||||
onDeactivate() {
|
||||
console.log('custom renderer process extension de-activated');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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).
|
||||
|
||||
The following example adds a cluster page definition to a `LensRendererExtension` subclass:
|
||||
|
||||
``` typescript
|
||||
import { LensRendererExtension } from "@k8slens/extensions";
|
||||
import { ExampleIcon, ExamplePage } from "./page"
|
||||
import React from "react"
|
||||
|
||||
export default class ExampleExtension extends LensRendererExtension {
|
||||
clusterPages = [
|
||||
{
|
||||
id: "hello",
|
||||
components: {
|
||||
Page: () => <ExamplePage extension={this}/>,
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
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`:
|
||||
|
||||
``` typescript
|
||||
import { LensRendererExtension } from "@k8slens/extensions";
|
||||
import React from "react"
|
||||
|
||||
export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<p>Hello world!</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
``` typescript
|
||||
import { LensRendererExtension } from "@k8slens/extensions";
|
||||
import { ExampleIcon, ExamplePage } from "./page"
|
||||
import React from "react"
|
||||
|
||||
export default class ExampleExtension extends LensRendererExtension {
|
||||
clusterPages = [
|
||||
{
|
||||
id: "hello",
|
||||
components: {
|
||||
Page: () => <ExamplePage extension={this}/>,
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
clusterPageMenus = [
|
||||
{
|
||||
target: { pageId: "hello" },
|
||||
title: "Hello World",
|
||||
components: {
|
||||
Icon: ExampleIcon,
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
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";
|
||||
import React from "react"
|
||||
|
||||
export function ExampleIcon(props: Component.IconProps) {
|
||||
return <Component.Icon {...props} material="pages" tooltip={"Hi!"}/>
|
||||
}
|
||||
|
||||
export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<p>Hello world!</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
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";
|
||||
import { ExampleIcon, ExamplePage } from "./page"
|
||||
import React from "react"
|
||||
|
||||
export default class ExampleExtension extends LensRendererExtension {
|
||||
clusterPages = [
|
||||
{
|
||||
id: "hello",
|
||||
components: {
|
||||
Page: () => <ExamplePage extension={this}/>,
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "bonjour",
|
||||
components: {
|
||||
Page: () => <ExemplePage extension={this}/>,
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
clusterPageMenus = [
|
||||
{
|
||||
id: "example",
|
||||
title: "Greetings",
|
||||
components: {
|
||||
Icon: ExampleIcon,
|
||||
}
|
||||
},
|
||||
{
|
||||
parentId: "example",
|
||||
target: { pageId: "hello" },
|
||||
title: "Hello World",
|
||||
components: {
|
||||
Icon: ExampleIcon,
|
||||
}
|
||||
},
|
||||
{
|
||||
parentId: "example",
|
||||
target: { pageId: "bonjour" },
|
||||
title: "Bonjour le monde",
|
||||
components: {
|
||||
Icon: ExempleIcon,
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
The following example defines a `LensRendererExtension` subclass with a single global page definition:
|
||||
|
||||
``` typescript
|
||||
import { LensRendererExtension } from '@k8slens/extensions';
|
||||
import { HelpPage } from './page';
|
||||
import React from 'react';
|
||||
|
||||
export default class HelpExtension extends LensRendererExtension {
|
||||
globalPages = [
|
||||
{
|
||||
id: "help",
|
||||
components: {
|
||||
Page: () => <HelpPage extension={this}/>,
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
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`:
|
||||
|
||||
``` typescript
|
||||
import { LensRendererExtension } from "@k8slens/extensions";
|
||||
import React from "react"
|
||||
|
||||
export class HelpPage extends React.Component<{ extension: LensRendererExtension }> {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<p>Help yourself</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
### `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:
|
||||
|
||||
``` typescript
|
||||
import { LensRendererExtension } from "@k8slens/extensions";
|
||||
import { HelpIcon, HelpPage } from "./page"
|
||||
import React from "react"
|
||||
|
||||
export default class HelpExtension extends LensRendererExtension {
|
||||
clusterPages = [
|
||||
{
|
||||
id: "help",
|
||||
components: {
|
||||
Page: () => <HelpPage extension={this}/>,
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
globalPageMenus = [
|
||||
{
|
||||
target: { pageId: "help" },
|
||||
title: "Help",
|
||||
components: {
|
||||
Icon: HelpIcon,
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
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";
|
||||
import React from "react"
|
||||
|
||||
export function HelpIcon(props: Component.IconProps) {
|
||||
return <Component.Icon {...props} material="help"/>
|
||||
}
|
||||
|
||||
export class HelpPage extends React.Component<{ extension: LensRendererExtension }> {
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
<p>Help</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`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.
|
||||
|
||||
|
||||
|
||||
|
||||
*********************************************************************
|
||||
WIP below!
|
||||
*********************************************************************
|
||||
|
||||
|
||||
|
||||
### `clusterFeatures`
|
||||
|
||||
Cluster features are Kubernetes resources that can applied and managed to the active cluster. They can be installed/uninstalled from the [cluster settings page]().
|
||||
The following example shows how to add a cluster feature:
|
||||
|
||||
``` typescript
|
||||
import { LensRendererExtension } from "@k8slens/extensions"
|
||||
import { MetricsFeature } from "./src/metrics-feature"
|
||||
import React from "react"
|
||||
|
||||
export default class ClusterMetricsFeatureExtension extends LensRendererExtension {
|
||||
clusterFeatures = [
|
||||
{
|
||||
title: "Metrics Stack",
|
||||
components: {
|
||||
Description: () => {
|
||||
return (
|
||||
<span>
|
||||
Enable timeseries data visualization (Prometheus stack) for your cluster.
|
||||
Install this only if you don't have existing Prometheus stack installed.
|
||||
You can see preview of manifests <a href="https://github.com/lensapp/lens/tree/master/extensions/lens-metrics/resources" target="_blank">here</a>.
|
||||
</span>
|
||||
)
|
||||
}
|
||||
},
|
||||
feature: new MetricsFeature()
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
The `title` and `components.Description` fields appear on the cluster settings page. The cluster feature must extend the abstract class `ClusterFeature.Feature`, and specifically implement the following methods:
|
||||
|
||||
``` typescript
|
||||
abstract install(cluster: Cluster): Promise<void>;
|
||||
abstract upgrade(cluster: Cluster): Promise<void>;
|
||||
abstract uninstall(cluster: Cluster): Promise<void>;
|
||||
abstract updateStatus(cluster: Cluster): Promise<ClusterFeatureStatus>;
|
||||
```
|
||||
|
||||
### `appPreferences`
|
||||
|
||||
The Preferences page is essentially a global page. Extensions can add custom preferences to the Preferences page, thus providing a single location for users to configure global, for Lens and extensions alike.
|
||||
|
||||
``` typescript
|
||||
import React from "react"
|
||||
import { LensRendererExtension } from "@k8slens/extensions"
|
||||
import { myCustomPreferencesStore } from "./src/my-custom-preferences-store"
|
||||
import { MyCustomPreferenceHint, MyCustomPreferenceInput } from "./src/my-custom-preference"
|
||||
|
||||
|
||||
export default class ExampleRendererExtension extends LensRendererExtension {
|
||||
appPreferences = [
|
||||
{
|
||||
title: "My Custom Preference",
|
||||
components: {
|
||||
Hint: () => <MyCustomPreferenceHint/>,
|
||||
Input: () => <MyCustomPreferenceInput store={myCustomPreferencesStore}/>
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### `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.
|
||||
|
||||
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 upon a mouse click:
|
||||
|
||||
``` typescript
|
||||
import { LensRendererExtension, Navigation } from '@k8slens/extensions';
|
||||
import { MyStatusBarIcon, MyPage } from './page';
|
||||
import React from 'react';
|
||||
|
||||
export default class ExtensionRenderer extends LensRendererExtension {
|
||||
globalPages = [
|
||||
{
|
||||
path: "/my-extension-path",
|
||||
hideInMenu: true,
|
||||
components: {
|
||||
Page: () => <MyPage extension={this} />,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
statusBarItems = [
|
||||
{
|
||||
item: (
|
||||
<div
|
||||
className="flex align-center gaps hover-highlight"
|
||||
onClick={() => Navigation.navigate(this.globalPages[0].path)}
|
||||
>
|
||||
<MyStatusBarIcon />
|
||||
<span>My Status Bar Item</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
### `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.
|
||||
|
||||
``` typescript
|
||||
import React from "react"
|
||||
import { LensRendererExtension } from "@k8slens/extensions";
|
||||
import { CustomMenuItem, CustomMenuItemProps } from "./src/custom-menu-item"
|
||||
|
||||
export default class ExampleExtension extends LensRendererExtension {
|
||||
kubeObjectMenuItems = [
|
||||
{
|
||||
kind: "Node",
|
||||
apiVersions: ["v1"],
|
||||
components: {
|
||||
MenuItem: (props: CustomMenuItemProps) => <CustomMenuItem {...props} />
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### `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.
|
||||
|
||||
``` typescript
|
||||
import React from "react"
|
||||
import { LensRendererExtension } from "@k8slens/extensions";
|
||||
import { CustomKindDetails, CustomKindDetailsProps } from "./src/custom-kind-details"
|
||||
|
||||
export default class ExampleExtension extends LensRendererExtension {
|
||||
kubeObjectMenuItems = [
|
||||
{
|
||||
kind: "CustomKind",
|
||||
apiVersions: ["custom.acme.org/v1"],
|
||||
components: {
|
||||
Details: (props: CustomKindDetailsProps) => <CustomKindDetails {...props} />
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
```
|
||||
@ -1,13 +1,16 @@
|
||||
# Welcome to Lens support
|
||||
Here you will find different ways of getting support for Lens.
|
||||
# Support
|
||||
|
||||
## Community Slack Channel
|
||||
We have an active and growing community! Ask a question, see what's being discussed, get insights to up and coming features, help others, join the conversation on our community slack <a href="https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI" target="_blank">here</a>
|
||||
Here you will find different ways of getting support for Lens IDE.
|
||||
|
||||
## Open Source Github Repository
|
||||
Search feature requests, submit an idea, review existing issues, or open a new one at our Github repository <a href="https://github.com/lensapp/lens/issues" target="_blank">here</a>
|
||||
## Community Support
|
||||
|
||||
## Enterprise Support
|
||||
If you are interested in paid support options designed for enterprises to cover Lens usage at scale please see the following links:
|
||||
|
||||
- <a href="https://www.mirantis.com/support/enterprise-support-services" target="_blank">Mirantis</a>
|
||||
* [Community Slack](https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI) - Request for support and help from the Lens community via Slack.
|
||||
* [Github Issues](https://github.com/lensapp/lens/issues) - Submit your issues and feature requests to Lens IDE via Github.
|
||||
|
||||
## Commercial Support & Services
|
||||
|
||||
If you are interested in paid support options, professional services or training, please see the offerings from the following vendors:
|
||||
|
||||
* [Mirantis](https://www.mirantis.com/software/lens/) offers commercial support for officially released versions of Lens IDE on MacOS, Windows and Linux operating systems. In addition, Mirantis offers professional services to create proprietary / custom Lens IDE extensions and custom `msi` packaging to meet enterprise IT policies for software distribution and configuration. Training is also available.
|
||||
|
||||
If you'd like to get your business listed in here, please contact us via email [info@k8slens.dev](mailto:info@k8slens.dev)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "extension-example",
|
||||
"name": "example-extension",
|
||||
"version": "1.0.0",
|
||||
"description": "Example extension",
|
||||
"main": "dist/main.js",
|
||||
|
||||
@ -11,7 +11,7 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens
|
||||
deactivate = () => {
|
||||
const { extension } = this.props;
|
||||
extension.disable();
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const doodleStyle = {
|
||||
|
||||
@ -6,13 +6,12 @@ export default class ExampleExtension extends LensRendererExtension {
|
||||
clusterPages = [
|
||||
{
|
||||
id: "example",
|
||||
routePath: "/extension-example",
|
||||
title: "Example Extension",
|
||||
components: {
|
||||
Page: () => <ExamplePage extension={this}/>,
|
||||
}
|
||||
}
|
||||
]
|
||||
];
|
||||
|
||||
clusterPageMenus = [
|
||||
{
|
||||
@ -22,5 +21,5 @@ export default class ExampleExtension extends LensRendererExtension {
|
||||
Icon: ExampleIcon,
|
||||
}
|
||||
}
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@ -38,5 +38,5 @@ export default class EventResourceStatusRendererExtension extends LensRendererEx
|
||||
apiVersions: ["batch/v1"],
|
||||
resolve: (cronJob: K8sApi.CronJob) => resolveStatusForCronJobs(cronJob)
|
||||
},
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@ -6,8 +6,8 @@ export default class LicenseLensMainExtension extends LensMainExtension {
|
||||
parentId: "help",
|
||||
label: "License",
|
||||
async click() {
|
||||
Util.openExternal("https://k8slens.dev/licenses/eula.md");
|
||||
Util.openExternal("https://k8slens.dev/licenses/eula");
|
||||
}
|
||||
}
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@ -10,14 +10,14 @@ export default class ClusterMetricsFeatureExtension extends LensRendererExtensio
|
||||
Description: () => {
|
||||
return (
|
||||
<span>
|
||||
Enable timeseries data visualization (Prometheus stack) for your cluster.
|
||||
Install this only if you don't have existing Prometheus stack installed.
|
||||
You can see preview of manifests <a href="https://github.com/lensapp/lens/tree/master/extensions/lens-metrics/resources" target="_blank">here</a>.
|
||||
Enable timeseries data visualization (Prometheus stack) for your cluster.
|
||||
Install this only if you don't have existing Prometheus stack installed.
|
||||
You can see preview of manifests <a href="https://github.com/lensapp/lens/tree/master/extensions/lens-metrics/resources" target="_blank">here</a>.
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
feature: new MetricsFeature()
|
||||
}
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@ -25,8 +25,8 @@ export interface MetricsConfiguration {
|
||||
}
|
||||
|
||||
export class MetricsFeature extends ClusterFeature.Feature {
|
||||
name = "metrics"
|
||||
latestVersion = "v2.17.2-lens1"
|
||||
name = "metrics";
|
||||
latestVersion = "v2.17.2-lens1";
|
||||
|
||||
config: MetricsConfiguration = {
|
||||
persistence: {
|
||||
|
||||
@ -11,5 +11,5 @@ export default class NodeMenuRendererExtension extends LensRendererExtension {
|
||||
MenuItem: (props: NodeMenuProps) => <NodeMenu {...props} />
|
||||
}
|
||||
}
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@ -19,5 +19,5 @@ export default class PodMenuRendererExtension extends LensRendererExtension {
|
||||
MenuItem: (props: PodLogsMenuProps) => <PodLogsMenu {...props} />
|
||||
}
|
||||
}
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ import { toJS } from "mobx";
|
||||
|
||||
export type TelemetryPreferencesModel = {
|
||||
enabled: boolean;
|
||||
}
|
||||
};
|
||||
|
||||
export class TelemetryPreferencesStore extends Store.ExtensionStore<TelemetryPreferencesModel> {
|
||||
enabled = true;
|
||||
|
||||
@ -7,22 +7,22 @@ import { reaction, IReactionDisposer } from "mobx";
|
||||
import { comparer } from "mobx";
|
||||
|
||||
export class Tracker extends Util.Singleton {
|
||||
static readonly GA_ID = "UA-159377374-1"
|
||||
static readonly SEGMENT_KEY = "YENwswyhlOgz8P7EFKUtIZ2MfON7Yxqb"
|
||||
protected eventHandlers: Array<(ev: EventBus.AppEvent ) => void> = []
|
||||
protected started = false
|
||||
protected visitor: ua.Visitor
|
||||
protected analytics: Analytics
|
||||
static readonly GA_ID = "UA-159377374-1";
|
||||
static readonly SEGMENT_KEY = "YENwswyhlOgz8P7EFKUtIZ2MfON7Yxqb";
|
||||
protected eventHandlers: Array<(ev: EventBus.AppEvent ) => void> = [];
|
||||
protected started = false;
|
||||
protected visitor: ua.Visitor;
|
||||
protected analytics: Analytics;
|
||||
protected machineId: string = null;
|
||||
protected ip: string = null;
|
||||
protected appVersion: string;
|
||||
protected locale: string;
|
||||
protected userAgent: string;
|
||||
protected anonymousId: string;
|
||||
protected os: string
|
||||
protected disposers: IReactionDisposer[]
|
||||
protected os: string;
|
||||
protected disposers: IReactionDisposer[];
|
||||
|
||||
protected reportInterval: NodeJS.Timeout
|
||||
protected reportInterval: NodeJS.Timeout;
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
@ -63,7 +63,7 @@ export class Tracker extends Util.Singleton {
|
||||
const newExtensions = currentExtensions.filter(x => !previousExtensions.includes(x));
|
||||
newExtensions.forEach(ext => {
|
||||
this.event("extension", "enable", { extension: ext });
|
||||
})
|
||||
});
|
||||
previousExtensions = currentExtensions;
|
||||
}, { equals: comparer.structural }));
|
||||
}
|
||||
|
||||
@ -16,8 +16,8 @@ jest.setTimeout(60000);
|
||||
// FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below)
|
||||
describe("Lens integration tests", () => {
|
||||
const TEST_NAMESPACE = "integration-tests";
|
||||
|
||||
const BACKSPACE = "\uE003";
|
||||
|
||||
let app: Application;
|
||||
|
||||
const appStart = async () => {
|
||||
@ -37,23 +37,33 @@ describe("Lens integration tests", () => {
|
||||
|
||||
const minikubeReady = (): boolean => {
|
||||
// determine if minikube is running
|
||||
let status = spawnSync("minikube status", { shell: true });
|
||||
if (status.status !== 0) {
|
||||
console.warn("minikube not running");
|
||||
return false;
|
||||
{
|
||||
const { status } = spawnSync("minikube status", { shell: true });
|
||||
if (status !== 0) {
|
||||
console.warn("minikube not running");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove TEST_NAMESPACE if it already exists
|
||||
status = spawnSync(`minikube kubectl -- get namespace ${TEST_NAMESPACE}`, { shell: true });
|
||||
if (status.status === 0) {
|
||||
console.warn(`Removing existing ${TEST_NAMESPACE} namespace`);
|
||||
status = spawnSync(`minikube kubectl -- delete namespace ${TEST_NAMESPACE}`, { shell: true });
|
||||
if (status.status !== 0) {
|
||||
console.warn(`Error removing ${TEST_NAMESPACE} namespace: ${status.stderr.toString()}`);
|
||||
return false;
|
||||
{
|
||||
const { status } = spawnSync(`minikube kubectl -- get namespace ${TEST_NAMESPACE}`, { shell: true });
|
||||
if (status === 0) {
|
||||
console.warn(`Removing existing ${TEST_NAMESPACE} namespace`);
|
||||
|
||||
const { status, stdout, stderr } = spawnSync(
|
||||
`minikube kubectl -- delete namespace ${TEST_NAMESPACE}`,
|
||||
{ shell: true },
|
||||
);
|
||||
if (status !== 0) {
|
||||
console.warn(`Error removing ${TEST_NAMESPACE} namespace: ${stderr.toString()}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(stdout.toString());
|
||||
}
|
||||
console.log(status.stdout.toString());
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
const ready = minikubeReady();
|
||||
@ -62,8 +72,8 @@ describe("Lens integration tests", () => {
|
||||
beforeAll(appStart, 20000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (app && app.isRunning()) {
|
||||
return util.tearDown(app);
|
||||
if (app?.isRunning()) {
|
||||
await util.tearDown(app);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -8,9 +8,8 @@ const AppPaths: Partial<Record<NodeJS.Platform, string>> = {
|
||||
|
||||
export function setup(): Application {
|
||||
return new Application({
|
||||
// path to electron app
|
||||
path: AppPaths[process.platform], // path to electron app
|
||||
args: [],
|
||||
path: AppPaths[process.platform],
|
||||
startTimeout: 30000,
|
||||
waitTimeout: 60000,
|
||||
env: {
|
||||
@ -19,9 +18,10 @@ export function setup(): Application {
|
||||
});
|
||||
}
|
||||
|
||||
type AsyncPidGetter = () => Promise<number>;
|
||||
|
||||
export async function tearDown(app: Application) {
|
||||
const mpid: any = app.mainProcess.pid;
|
||||
const pid = await mpid();
|
||||
const pid = await (app.mainProcess.pid as any as AsyncPidGetter)();
|
||||
await app.stop();
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
|
||||
@ -32,6 +32,7 @@ nav:
|
||||
- Overview: extensions/guides/README.md
|
||||
- Main Extension: extensions/guides/main-extension.md
|
||||
- Renderer Extension: extensions/guides/renderer-extension.md
|
||||
- Generator: extensions/guides/generator.md
|
||||
- Testing and Publishing:
|
||||
- Testing Extensions: extensions/testing-and-publishing/testing.md
|
||||
- Publishing Extensions: extensions/testing-and-publishing/publishing.md
|
||||
|
||||
13
package.json
13
package.json
@ -37,7 +37,7 @@
|
||||
"download:kubectl": "yarn run ts-node build/download_kubectl.ts",
|
||||
"download:helm": "yarn run ts-node build/download_helm.ts",
|
||||
"build:tray-icons": "yarn run ts-node build/build_tray_icon.ts",
|
||||
"lint": "yarn run eslint $@ --ext js,ts,tsx --max-warnings=0 src/ integration/ __mocks__/ build/",
|
||||
"lint": "yarn run eslint $@ --ext js,ts,tsx --max-warnings=0 src/ integration/ __mocks__/ build/ extensions/",
|
||||
"lint:fix": "yarn run lint --fix",
|
||||
"mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest",
|
||||
"typedocs-extensions-api": "yarn run typedoc --ignoreCompilerErrors --readme docs/extensions/typedoc-readme.md.tpl --name @k8slens/extensions --out docs/extensions/api --mode library --excludePrivate --hideBreadcrumbs --includes src/ src/extensions/extension-api.ts"
|
||||
@ -47,7 +47,7 @@
|
||||
"bundledHelmVersion": "3.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0 <13.0"
|
||||
"node": ">=12 <13"
|
||||
},
|
||||
"lingui": {
|
||||
"locales": [
|
||||
@ -214,12 +214,15 @@
|
||||
"@types/node": "^12.12.45",
|
||||
"@types/proper-lockfile": "^4.1.1",
|
||||
"@types/react-beautiful-dnd": "^13.0.0",
|
||||
"@types/tar": "^4.0.3",
|
||||
"@types/tar": "^4.0.4",
|
||||
"array-move": "^3.0.0",
|
||||
"await-lock": "^2.1.0",
|
||||
"chalk": "^4.1.0",
|
||||
"chokidar": "^3.4.3",
|
||||
"command-exists": "1.2.9",
|
||||
"conf": "^7.0.1",
|
||||
"crypto-js": "^4.0.0",
|
||||
"electron-devtools-installer": "^3.1.1",
|
||||
"electron-updater": "^4.3.1",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"file-type": "^14.7.1",
|
||||
@ -248,7 +251,7 @@
|
||||
"serializr": "^2.0.3",
|
||||
"shell-env": "^3.0.0",
|
||||
"spdy": "^4.0.2",
|
||||
"tar": "^6.0.2",
|
||||
"tar": "^6.0.5",
|
||||
"tcp-port-used": "^1.0.1",
|
||||
"tempy": "^0.5.0",
|
||||
"uuid": "^8.1.0",
|
||||
@ -279,6 +282,7 @@
|
||||
"@types/color": "^3.0.1",
|
||||
"@types/crypto-js": "^3.1.47",
|
||||
"@types/dompurify": "^2.0.2",
|
||||
"@types/electron-devtools-installer": "^2.2.0",
|
||||
"@types/electron-window-state": "^2.0.34",
|
||||
"@types/fs-extra": "^9.0.1",
|
||||
"@types/hapi": "^18.0.3",
|
||||
@ -310,7 +314,6 @@
|
||||
"@types/sharp": "^0.26.0",
|
||||
"@types/shelljs": "^0.8.8",
|
||||
"@types/spdy": "^3.4.4",
|
||||
"@types/tar": "^4.0.3",
|
||||
"@types/tcp-port-used": "^1.0.0",
|
||||
"@types/tempy": "^0.3.0",
|
||||
"@types/terser-webpack-plugin": "^3.0.0",
|
||||
|
||||
@ -84,7 +84,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
|
||||
super({
|
||||
configName: "lens-cluster-store",
|
||||
accessPropertiesByDotNotation: false, // To make dots safe in cluster context names
|
||||
migrations: migrations,
|
||||
migrations,
|
||||
});
|
||||
|
||||
this.pushStateToViewsAutomatically();
|
||||
|
||||
@ -36,7 +36,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
|
||||
private constructor() {
|
||||
super({
|
||||
// configName: "lens-user-store", // todo: migrate from default "config.json"
|
||||
migrations: migrations,
|
||||
migrations,
|
||||
});
|
||||
|
||||
this.handleOnLoad();
|
||||
|
||||
35
src/common/utils/downloadFile.ts
Normal file
35
src/common/utils/downloadFile.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import request from "request";
|
||||
|
||||
export interface DownloadFileOptions {
|
||||
url: string;
|
||||
gzip?: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadFileTicket {
|
||||
url: string;
|
||||
promise: Promise<Buffer>;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function downloadFile({ url, gzip = true }: DownloadFileOptions): DownloadFileTicket {
|
||||
const fileChunks: Buffer[] = [];
|
||||
const req = request(url, { gzip });
|
||||
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
fileChunks.push(chunk);
|
||||
});
|
||||
req.once("error", err => {
|
||||
reject({ url, err });
|
||||
});
|
||||
req.once("complete", () => {
|
||||
resolve(Buffer.concat(fileChunks));
|
||||
});
|
||||
});
|
||||
return {
|
||||
url,
|
||||
promise,
|
||||
cancel() {
|
||||
req.abort();
|
||||
}
|
||||
};
|
||||
}
|
||||
5
src/common/utils/escapeRegExp.ts
Normal file
5
src/common/utils/escapeRegExp.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// Helper to sanitize / escape special chars for passing to RegExp-constructor
|
||||
|
||||
export function escapeRegExp(str: string) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
// Common utils (main OR renderer)
|
||||
|
||||
export const noop: any = () => { /* empty */ };
|
||||
|
||||
export * from "./app-version";
|
||||
export * from "./autobind";
|
||||
export * from "./base64";
|
||||
@ -12,3 +14,7 @@ export * from "./splitArray";
|
||||
export * from "./saveToAppFiles";
|
||||
export * from "./singleton";
|
||||
export * from "./openExternal";
|
||||
export * from "./rectify-array";
|
||||
export * from "./downloadFile";
|
||||
export * from "./escapeRegExp";
|
||||
export * from "./tar";
|
||||
|
||||
8
src/common/utils/rectify-array.ts
Normal file
8
src/common/utils/rectify-array.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* rectify condences the single item or array of T type, to an array.
|
||||
* @param items either one item or an array of items
|
||||
* @returns a list of items
|
||||
*/
|
||||
export function recitfy<T>(items: T | T[]): T[] {
|
||||
return Array.isArray(items) ? items : [items];
|
||||
}
|
||||
55
src/common/utils/tar.ts
Normal file
55
src/common/utils/tar.ts
Normal file
@ -0,0 +1,55 @@
|
||||
// Helper for working with tarball files (.tar, .tgz)
|
||||
// Docs: https://github.com/npm/node-tar
|
||||
import tar, { ExtractOptions, FileStat } from "tar";
|
||||
import path from "path";
|
||||
|
||||
export interface ReadFileFromTarOpts {
|
||||
tarPath: string;
|
||||
filePath: string;
|
||||
parseJson?: boolean;
|
||||
}
|
||||
|
||||
export function readFileFromTar<R = Buffer>({ tarPath, filePath, parseJson }: ReadFileFromTarOpts): Promise<R> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const fileChunks: Buffer[] = [];
|
||||
|
||||
await tar.list({
|
||||
file: tarPath,
|
||||
filter: path => path === filePath,
|
||||
onentry(entry: FileStat) {
|
||||
entry.on("data", chunk => {
|
||||
fileChunks.push(chunk);
|
||||
});
|
||||
entry.once("error", err => {
|
||||
reject(new Error(`reading file has failed ${entry.path}: ${err}`));
|
||||
});
|
||||
entry.once("end", () => {
|
||||
const data = Buffer.concat(fileChunks);
|
||||
const result = parseJson ? JSON.parse(data.toString("utf8")) : data;
|
||||
resolve(result);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!fileChunks.length) {
|
||||
reject(new Error("Not found"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function listTarEntries(filePath: string): Promise<string[]> {
|
||||
const entries: string[] = [];
|
||||
await tar.list({
|
||||
file: filePath,
|
||||
onentry: (entry: FileStat) => entries.push(entry.path as any as string),
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function extractTar(filePath: string, opts: ExtractOptions & { sync?: boolean } = {}) {
|
||||
return tar.extract({
|
||||
file: filePath,
|
||||
cwd: path.dirname(filePath),
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
@ -6,8 +6,8 @@ import { defineGlobal } from "./utils/defineGlobal";
|
||||
export const isMac = process.platform === "darwin";
|
||||
export const isWindows = process.platform === "win32";
|
||||
export const isLinux = process.platform === "linux";
|
||||
export const isDebugging = process.env.DEBUG === "true";
|
||||
export const isSnap = !!process.env["SNAP"];
|
||||
export const isDebugging = ["true", "1", "yes", "y", "on"].includes((process.env.DEBUG ?? "").toLowerCase());
|
||||
export const isSnap = !!process.env.SNAP;
|
||||
export const isProduction = process.env.NODE_ENV === "production";
|
||||
export const isTestEnv = !!process.env.JEST_WORKER_ID;
|
||||
export const isDevelopment = !isTestEnv && !isProduction;
|
||||
@ -41,3 +41,5 @@ export const apiKubePrefix = "/api-kube"; // k8s cluster apis
|
||||
// Links
|
||||
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues";
|
||||
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI";
|
||||
export const docsUrl = "https://docs.k8slens.dev/";
|
||||
export const supportUrl = "https://docs.k8slens.dev/latest/support/";
|
||||
|
||||
330
src/extensions/extension-discovery.ts
Normal file
330
src/extensions/extension-discovery.ts
Normal file
@ -0,0 +1,330 @@
|
||||
import chokidar from "chokidar";
|
||||
import { EventEmitter } from "events";
|
||||
import fs from "fs-extra";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import { getBundledExtensions } from "../common/utils/app-version";
|
||||
import logger from "../main/logger";
|
||||
import { extensionInstaller, PackageJson } from "./extension-installer";
|
||||
import { extensionsStore } from "./extensions-store";
|
||||
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
|
||||
|
||||
export interface InstalledExtension {
|
||||
readonly manifest: LensExtensionManifest;
|
||||
readonly manifestPath: string;
|
||||
readonly isBundled: boolean; // defined in project root's package.json
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
const logModule = "[EXTENSION-DISCOVERY]";
|
||||
export const manifestFilename = "package.json";
|
||||
|
||||
/**
|
||||
* Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link)
|
||||
* @param lstat the stats to compare
|
||||
*/
|
||||
const isDirectoryLike = (lstat: fs.Stats) => lstat.isDirectory() || lstat.isSymbolicLink();
|
||||
|
||||
/**
|
||||
* Discovers installed bundled and local extensions from the filesystem.
|
||||
* Also watches for added and removed local extensions by watching the directory.
|
||||
* Uses ExtensionInstaller to install dependencies for all of the extensions.
|
||||
* This is also done when a new extension is copied to the local extensions directory.
|
||||
* .init() must be called to start the directory watching.
|
||||
* The class emits events for added and removed extensions:
|
||||
* - "add": When extension is added. The event is of type InstalledExtension
|
||||
* - "remove": When extension is removed. The event is of type LensExtensionId
|
||||
*/
|
||||
export class ExtensionDiscovery {
|
||||
protected bundledFolderPath: string;
|
||||
|
||||
private loadStarted = false;
|
||||
|
||||
// This promise is resolved when .load() is finished.
|
||||
// This allows operations to be added after .load() success.
|
||||
private loaded: Promise<void>;
|
||||
|
||||
// These are called to either resolve or reject this.loaded promise
|
||||
private resolveLoaded: () => void;
|
||||
private rejectLoaded: (error: any) => void;
|
||||
|
||||
public events: EventEmitter;
|
||||
|
||||
constructor() {
|
||||
this.loaded = new Promise((resolve, reject) => {
|
||||
this.resolveLoaded = resolve;
|
||||
this.rejectLoaded = reject;
|
||||
});
|
||||
|
||||
this.events = new EventEmitter();
|
||||
}
|
||||
|
||||
// Each extension is added as a single dependency to this object, which is written as package.json.
|
||||
// Each dependency key is the name of the dependency, and
|
||||
// each dependency value is the non-symlinked path to the dependency (folder).
|
||||
protected packagesJson: PackageJson = {
|
||||
dependencies: {}
|
||||
};
|
||||
|
||||
get localFolderPath(): string {
|
||||
return path.join(os.homedir(), ".k8slens", "extensions");
|
||||
}
|
||||
|
||||
get packageJsonPath() {
|
||||
return path.join(extensionInstaller.extensionPackagesRoot, manifestFilename);
|
||||
}
|
||||
|
||||
get inTreeTargetPath() {
|
||||
return path.join(extensionInstaller.extensionPackagesRoot, "extensions");
|
||||
}
|
||||
|
||||
get inTreeFolderPath(): string {
|
||||
return path.resolve(__static, "../extensions");
|
||||
}
|
||||
|
||||
get nodeModulesPath(): string {
|
||||
return path.join(extensionInstaller.extensionPackagesRoot, "node_modules");
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the class and setups the file watcher for added/removed local extensions.
|
||||
*/
|
||||
init() {
|
||||
this.watchExtensions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches for added/removed local extensions.
|
||||
* Dependencies are installed automatically after an extension folder is copied.
|
||||
*/
|
||||
async watchExtensions() {
|
||||
logger.info(`${logModule} watching extension add/remove in ${this.localFolderPath}`);
|
||||
|
||||
// Wait until .load() has been called and has been resolved
|
||||
await this.loaded;
|
||||
|
||||
// chokidar works better than fs.watch
|
||||
chokidar.watch(this.localFolderPath, {
|
||||
// Dont watch recursively into subdirectories
|
||||
depth: 0,
|
||||
// Try to wait until the file has been completely copied.
|
||||
// The OS might emit an event for added file even it's not completely written to the filesysten.
|
||||
awaitWriteFinish: {
|
||||
// Wait 300ms until the file size doesn't change to consider the file written.
|
||||
// For a small file like package.json this should be plenty of time.
|
||||
stabilityThreshold: 300
|
||||
}
|
||||
})
|
||||
// Extension add is detected by watching "<extensionDir>package.json" add
|
||||
.on("add", this.handleWatchFileAdd)
|
||||
// Extension remove is detected by watching <extensionDir>" unlink
|
||||
.on("unlinkDir", this.handleWatchUnlinkDir);
|
||||
}
|
||||
|
||||
handleWatchFileAdd = async (filePath: string) => {
|
||||
if (path.basename(filePath) === manifestFilename) {
|
||||
try {
|
||||
const absPath = path.dirname(filePath);
|
||||
|
||||
// this.loadExtensionFromPath updates this.packagesJson
|
||||
const extension = await this.loadExtensionFromPath(absPath);
|
||||
|
||||
if (extension) {
|
||||
// Install dependencies for the new extension
|
||||
await this.installPackages();
|
||||
|
||||
logger.info(`${logModule} Added extension ${extension.manifest.name}`);
|
||||
this.events.emit("add", extension);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
handleWatchUnlinkDir = async (filePath: string) => {
|
||||
// filePath is the non-symlinked path to the extension folder
|
||||
// this.packagesJson.dependencies value is the non-symlinked path to the extension folder
|
||||
// LensExtensionId in extension-loader is the symlinked path to the extension folder manifest file
|
||||
|
||||
// Check that the removed path is directly under this.localFolderPath
|
||||
// Note that the watcher can create unlink events for subdirectories of the extension
|
||||
const extensionFolderName = path.basename(filePath);
|
||||
|
||||
if (path.relative(this.localFolderPath, filePath) === extensionFolderName) {
|
||||
const extensionName: string | undefined = Object
|
||||
.entries(this.packagesJson.dependencies)
|
||||
.find(([_name, extensionFolder]) => filePath === extensionFolder)?.[0];
|
||||
|
||||
if (extensionName !== undefined) {
|
||||
delete this.packagesJson.dependencies[extensionName];
|
||||
|
||||
// Reinstall dependencies to remove the extension from package.json
|
||||
await this.installPackages();
|
||||
|
||||
// The path to the manifest file is the lens extension id
|
||||
// Note that we need to use the symlinked path
|
||||
const lensExtensionId = path.join(this.nodeModulesPath, extensionName, "package.json");
|
||||
|
||||
logger.info(`${logModule} removed extension ${extensionName}`);
|
||||
this.events.emit("remove", lensExtensionId as LensExtensionId);
|
||||
} else {
|
||||
logger.warn(`${logModule} extension ${extensionFolderName} not found, can't remove`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||
if (this.loadStarted) {
|
||||
// The class is simplified by only supporting .load() to be called once
|
||||
throw new Error("ExtensionDiscovery.load() can be only be called once");
|
||||
}
|
||||
|
||||
this.loadStarted = true;
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
protected async getByManifest(manifestPath: string, { isBundled = false }: {
|
||||
isBundled?: boolean;
|
||||
} = {}): Promise<InstalledExtension | null> {
|
||||
let manifestJson: LensExtensionManifest;
|
||||
let isEnabled: boolean;
|
||||
|
||||
try {
|
||||
// check manifest file for existence
|
||||
fs.accessSync(manifestPath, fs.constants.F_OK);
|
||||
|
||||
manifestJson = __non_webpack_require__(manifestPath);
|
||||
const installedManifestPath = path.join(this.nodeModulesPath, manifestJson.name, "package.json");
|
||||
this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath);
|
||||
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
|
||||
|
||||
return {
|
||||
manifestPath: installedManifestPath,
|
||||
manifest: manifestJson,
|
||||
isBundled,
|
||||
isEnabled
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`${logModule}: can't install extension at ${manifestPath}: ${error}`, { manifestJson });
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async loadExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||
const bundledExtensions = await this.loadBundledExtensions();
|
||||
const localExtensions = await this.loadFromFolder(this.localFolderPath);
|
||||
await this.installPackages();
|
||||
const extensions = bundledExtensions.concat(localExtensions);
|
||||
|
||||
return new Map(extensions.map(ext => [ext.manifestPath, ext]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write package.json to file system and install dependencies.
|
||||
*/
|
||||
installPackages() {
|
||||
return extensionInstaller.installPackages(this.packageJsonPath, this.packagesJson);
|
||||
}
|
||||
|
||||
async loadBundledExtensions() {
|
||||
const extensions: InstalledExtension[] = [];
|
||||
const folderPath = this.bundledFolderPath;
|
||||
const bundledExtensions = getBundledExtensions();
|
||||
const paths = await fs.readdir(folderPath);
|
||||
|
||||
for (const fileName of paths) {
|
||||
if (!bundledExtensions.includes(fileName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const absPath = path.resolve(folderPath, fileName);
|
||||
const extension = await this.loadExtensionFromPath(absPath, { isBundled: true });
|
||||
|
||||
if (extension) {
|
||||
extensions.push(extension);
|
||||
}
|
||||
}
|
||||
logger.debug(`${logModule}: ${extensions.length} extensions loaded`, { folderPath, extensions });
|
||||
|
||||
return extensions;
|
||||
}
|
||||
|
||||
async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> {
|
||||
const bundledExtensions = getBundledExtensions();
|
||||
const extensions: InstalledExtension[] = [];
|
||||
const paths = await fs.readdir(folderPath);
|
||||
|
||||
for (const fileName of paths) {
|
||||
// do not allow to override bundled extensions
|
||||
if (bundledExtensions.includes(fileName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const absPath = path.resolve(folderPath, fileName);
|
||||
|
||||
if (!fs.existsSync(absPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const lstat = await fs.lstat(absPath);
|
||||
|
||||
// skip non-directories
|
||||
if (!isDirectoryLike(lstat)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const extension = await this.loadExtensionFromPath(absPath);
|
||||
if (extension) {
|
||||
extensions.push(extension);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`${logModule}: ${extensions.length} extensions loaded`, { folderPath, extensions });
|
||||
return extensions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads extension from absolute path, updates this.packagesJson to include it and returns the extension.
|
||||
*/
|
||||
async loadExtensionFromPath(absPath: string, { isBundled = false }: {
|
||||
isBundled?: boolean;
|
||||
} = {}): Promise<InstalledExtension | null> {
|
||||
const manifestPath = path.resolve(absPath, manifestFilename);
|
||||
|
||||
return this.getByManifest(manifestPath, { isBundled });
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionDiscovery = new ExtensionDiscovery();
|
||||
69
src/extensions/extension-installer.ts
Normal file
69
src/extensions/extension-installer.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import AwaitLock from 'await-lock';
|
||||
import child_process from "child_process";
|
||||
import fs from "fs-extra";
|
||||
import path from "path";
|
||||
import logger from "../main/logger";
|
||||
import { extensionPackagesRoot } from "./extension-loader";
|
||||
|
||||
const logModule = "[EXTENSION-INSTALLER]";
|
||||
|
||||
type Dependencies = {
|
||||
[name: string]: string;
|
||||
};
|
||||
|
||||
// Type for the package.json file that is written by ExtensionInstaller
|
||||
export type PackageJson = {
|
||||
dependencies: Dependencies;
|
||||
};
|
||||
|
||||
/**
|
||||
* Installs dependencies for extensions
|
||||
*/
|
||||
export class ExtensionInstaller {
|
||||
private installLock = new AwaitLock();
|
||||
|
||||
get extensionPackagesRoot() {
|
||||
return extensionPackagesRoot();
|
||||
}
|
||||
|
||||
get npmPath() {
|
||||
return __non_webpack_require__.resolve('npm/bin/npm-cli');
|
||||
}
|
||||
|
||||
installDependencies(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
logger.info(`${logModule} installing dependencies at ${extensionPackagesRoot()}`);
|
||||
const child = child_process.fork(this.npmPath, ["install", "--silent", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"], {
|
||||
cwd: extensionPackagesRoot(),
|
||||
silent: true
|
||||
});
|
||||
child.on("close", () => {
|
||||
resolve();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write package.json to the file system and execute npm install for it.
|
||||
*/
|
||||
async installPackages(packageJsonPath: string, packagesJson: PackageJson): Promise<void> {
|
||||
// Mutual exclusion to install packages in sequence
|
||||
await this.installLock.acquireAsync();
|
||||
|
||||
try {
|
||||
// Write the package.json which will be installed in .installDependencies()
|
||||
await fs.writeFile(path.join(packageJsonPath), JSON.stringify(packagesJson, null, 2), {
|
||||
mode: 0o600
|
||||
});
|
||||
|
||||
await this.installDependencies();
|
||||
} finally {
|
||||
this.installLock.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionInstaller = new ExtensionInstaller();
|
||||
@ -1,20 +1,26 @@
|
||||
import { app, ipcRenderer, remote } from "electron";
|
||||
import { action, computed, observable, reaction, toJS, when } from "mobx";
|
||||
import path from "path";
|
||||
import { getHostedCluster } from "../common/cluster-store";
|
||||
import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc";
|
||||
import logger from "../main/logger";
|
||||
import type { InstalledExtension } from "./extension-discovery";
|
||||
import { extensionsStore } from "./extensions-store";
|
||||
import type { LensExtension, LensExtensionConstructor, LensExtensionId } from "./lens-extension";
|
||||
import type { LensMainExtension } from "./lens-main-extension";
|
||||
import type { LensRendererExtension } from "./lens-renderer-extension";
|
||||
import type { InstalledExtension } from "./extension-manager";
|
||||
import path from "path";
|
||||
import { broadcastMessage, handleRequest, requestMain, subscribeToBroadcast } from "../common/ipc";
|
||||
import { action, computed, observable, reaction, toJS, when } from "mobx";
|
||||
import logger from "../main/logger";
|
||||
import { app, ipcRenderer, remote } from "electron";
|
||||
import * as registries from "./registries";
|
||||
import { extensionsStore } from "./extensions-store";
|
||||
|
||||
// lazy load so that we get correct userData
|
||||
export function extensionPackagesRoot() {
|
||||
return path.join((app || remote.app).getPath("userData"));
|
||||
}
|
||||
|
||||
const logModule = "[EXTENSIONS-LOADER]";
|
||||
|
||||
/**
|
||||
* Loads installed extensions to the Lens application
|
||||
*/
|
||||
export class ExtensionLoader {
|
||||
protected extensions = observable.map<LensExtensionId, InstalledExtension>();
|
||||
protected instances = observable.map<LensExtensionId, LensExtension>();
|
||||
@ -47,6 +53,17 @@ export class ExtensionLoader {
|
||||
this.extensions.replace(extensions);
|
||||
}
|
||||
|
||||
addExtension(extension: InstalledExtension) {
|
||||
this.extensions.set(extension.manifestPath as LensExtensionId, extension);
|
||||
}
|
||||
|
||||
removeExtension(lensExtensionId: LensExtensionId) {
|
||||
// TODO: Remove the extension properly (from menus etc.)
|
||||
if (!this.extensions.delete(lensExtensionId)) {
|
||||
throw new Error(`Can't remove extension ${lensExtensionId}, doesn't exist.`);
|
||||
}
|
||||
}
|
||||
|
||||
protected async initMain() {
|
||||
this.isLoaded = true;
|
||||
this.loadOnMain();
|
||||
@ -77,15 +94,15 @@ export class ExtensionLoader {
|
||||
}
|
||||
|
||||
loadOnMain() {
|
||||
logger.info('[EXTENSIONS-LOADER]: load on main');
|
||||
this.autoInitExtensions((ext: LensMainExtension) => [
|
||||
logger.info(`${logModule}: load on main`);
|
||||
this.autoInitExtensions(async (ext: LensMainExtension) => [
|
||||
registries.menuRegistry.add(ext.appMenus)
|
||||
]);
|
||||
}
|
||||
|
||||
loadOnClusterManagerRenderer() {
|
||||
logger.info('[EXTENSIONS-LOADER]: load on main renderer (cluster manager)');
|
||||
this.autoInitExtensions((ext: LensRendererExtension) => [
|
||||
logger.info(`${logModule}: load on main renderer (cluster manager)`);
|
||||
this.autoInitExtensions(async (ext: LensRendererExtension) => [
|
||||
registries.globalPageRegistry.add(ext.globalPages, ext),
|
||||
registries.globalPageMenuRegistry.add(ext.globalPageMenus, ext),
|
||||
registries.appPreferenceRegistry.add(ext.appPreferences),
|
||||
@ -95,37 +112,48 @@ export class ExtensionLoader {
|
||||
}
|
||||
|
||||
loadOnClusterRenderer() {
|
||||
logger.info('[EXTENSIONS-LOADER]: load on cluster renderer (dashboard)');
|
||||
this.autoInitExtensions((ext: LensRendererExtension) => [
|
||||
registries.clusterPageRegistry.add(ext.clusterPages, ext),
|
||||
registries.clusterPageMenuRegistry.add(ext.clusterPageMenus, ext),
|
||||
registries.kubeObjectMenuRegistry.add(ext.kubeObjectMenuItems),
|
||||
registries.kubeObjectDetailRegistry.add(ext.kubeObjectDetailItems),
|
||||
registries.kubeObjectStatusRegistry.add(ext.kubeObjectStatusTexts)
|
||||
]);
|
||||
logger.info(`${logModule}: load on cluster renderer (dashboard)`);
|
||||
const cluster = getHostedCluster();
|
||||
this.autoInitExtensions(async (ext: LensRendererExtension) => {
|
||||
if (await ext.isEnabledForCluster(cluster) === false) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
registries.clusterPageRegistry.add(ext.clusterPages, ext),
|
||||
registries.clusterPageMenuRegistry.add(ext.clusterPageMenus, ext),
|
||||
registries.kubeObjectMenuRegistry.add(ext.kubeObjectMenuItems),
|
||||
registries.kubeObjectDetailRegistry.add(ext.kubeObjectDetailItems),
|
||||
registries.kubeObjectStatusRegistry.add(ext.kubeObjectStatusTexts)
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
protected autoInitExtensions(register: (ext: LensExtension) => Function[]) {
|
||||
protected autoInitExtensions(register: (ext: LensExtension) => Promise<Function[]>) {
|
||||
return reaction(() => this.toJSON(), installedExtensions => {
|
||||
for (const [extId, ext] of installedExtensions) {
|
||||
let instance = this.instances.get(extId);
|
||||
if (ext.isEnabled && !instance) {
|
||||
const alreadyInit = this.instances.has(extId);
|
||||
|
||||
if (ext.isEnabled && !alreadyInit) {
|
||||
try {
|
||||
const LensExtensionClass: LensExtensionConstructor = this.requireExtension(ext);
|
||||
if (!LensExtensionClass) continue;
|
||||
instance = new LensExtensionClass(ext);
|
||||
const LensExtensionClass = this.requireExtension(ext);
|
||||
if (!LensExtensionClass) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const instance = new LensExtensionClass(ext);
|
||||
instance.whenEnabled(() => register(instance));
|
||||
instance.enable();
|
||||
this.instances.set(extId, instance);
|
||||
} catch (err) {
|
||||
logger.error(`[EXTENSION-LOADER]: activation extension error`, { ext, err });
|
||||
logger.error(`${logModule}: activation extension error`, { ext, err });
|
||||
}
|
||||
} else if (!ext.isEnabled && instance) {
|
||||
} else if (!ext.isEnabled && alreadyInit) {
|
||||
try {
|
||||
const instance = this.instances.get(extId);
|
||||
instance.disable();
|
||||
this.instances.delete(extId);
|
||||
} catch (err) {
|
||||
logger.error(`[EXTENSION-LOADER]: deactivation extension error`, { ext, err });
|
||||
logger.error(`${logModule}: deactivation extension error`, { ext, err });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -134,7 +162,7 @@ export class ExtensionLoader {
|
||||
});
|
||||
}
|
||||
|
||||
protected requireExtension(extension: InstalledExtension) {
|
||||
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor {
|
||||
let extEntrypoint = "";
|
||||
try {
|
||||
if (ipcRenderer && extension.manifest.renderer) {
|
||||
@ -146,7 +174,7 @@ export class ExtensionLoader {
|
||||
return __non_webpack_require__(extEntrypoint).default;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[EXTENSION-LOADER]: can't load extension main at ${extEntrypoint}: ${err}`, { extension });
|
||||
console.error(`${logModule}: can't load extension main at ${extEntrypoint}: ${err}`, { extension });
|
||||
console.trace(err);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,172 +0,0 @@
|
||||
import type { LensExtensionId, LensExtensionManifest } from "./lens-extension";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import fs from "fs-extra";
|
||||
import child_process from "child_process";
|
||||
import logger from "../main/logger";
|
||||
import { extensionPackagesRoot } from "./extension-loader";
|
||||
import { getBundledExtensions } from "../common/utils/app-version";
|
||||
|
||||
export interface InstalledExtension {
|
||||
readonly manifest: LensExtensionManifest;
|
||||
readonly manifestPath: string;
|
||||
readonly isBundled: boolean; // defined in project root's package.json
|
||||
isEnabled: boolean;
|
||||
}
|
||||
|
||||
type Dependencies = {
|
||||
[name: string]: string;
|
||||
};
|
||||
|
||||
type PackageJson = {
|
||||
dependencies: Dependencies;
|
||||
};
|
||||
|
||||
export class ExtensionManager {
|
||||
|
||||
protected bundledFolderPath: string;
|
||||
|
||||
protected packagesJson: PackageJson = {
|
||||
dependencies: {}
|
||||
};
|
||||
|
||||
get extensionPackagesRoot() {
|
||||
return extensionPackagesRoot();
|
||||
}
|
||||
|
||||
get inTreeTargetPath() {
|
||||
return path.join(this.extensionPackagesRoot, "extensions");
|
||||
}
|
||||
|
||||
get inTreeFolderPath(): string {
|
||||
return path.resolve(__static, "../extensions");
|
||||
}
|
||||
|
||||
get nodeModulesPath(): string {
|
||||
return path.join(this.extensionPackagesRoot, "node_modules");
|
||||
}
|
||||
|
||||
get localFolderPath(): string {
|
||||
return path.join(os.homedir(), ".k8slens", "extensions");
|
||||
}
|
||||
|
||||
get npmPath() {
|
||||
return __non_webpack_require__.resolve('npm/bin/npm-cli');
|
||||
}
|
||||
|
||||
get packageJsonPath() {
|
||||
return path.join(this.extensionPackagesRoot, "package.json");
|
||||
}
|
||||
|
||||
async load(): Promise<Map<LensExtensionId, InstalledExtension>> {
|
||||
logger.info("[EXTENSION-MANAGER] loading extensions from " + this.extensionPackagesRoot);
|
||||
if (fs.existsSync(path.join(this.extensionPackagesRoot, "package-lock.json"))) {
|
||||
await fs.remove(path.join(this.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);
|
||||
return await this.loadExtensions();
|
||||
}
|
||||
|
||||
protected async getByManifest(manifestPath: string, { isBundled = false } = {}): Promise<InstalledExtension> {
|
||||
let manifestJson: LensExtensionManifest;
|
||||
try {
|
||||
fs.accessSync(manifestPath, fs.constants.F_OK); // check manifest file for existence
|
||||
manifestJson = __non_webpack_require__(manifestPath);
|
||||
this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath);
|
||||
|
||||
logger.info("[EXTENSION-MANAGER] installed extension " + manifestJson.name);
|
||||
return {
|
||||
manifestPath: path.join(this.nodeModulesPath, manifestJson.name, "package.json"),
|
||||
manifest: manifestJson,
|
||||
isBundled: isBundled,
|
||||
isEnabled: isBundled,
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(`[EXTENSION-MANAGER]: can't install extension at ${manifestPath}: ${err}`, { manifestJson });
|
||||
}
|
||||
}
|
||||
|
||||
protected installPackages(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = child_process.fork(this.npmPath, ["install", "--silent", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"], {
|
||||
cwd: extensionPackagesRoot(),
|
||||
silent: true
|
||||
});
|
||||
child.on("close", () => {
|
||||
resolve();
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async loadExtensions() {
|
||||
const bundledExtensions = await this.loadBundledExtensions();
|
||||
const localExtensions = await this.loadFromFolder(this.localFolderPath);
|
||||
await fs.writeFile(path.join(this.packageJsonPath), JSON.stringify(this.packagesJson, null, 2), { mode: 0o600 });
|
||||
await this.installPackages();
|
||||
const extensions = bundledExtensions.concat(localExtensions);
|
||||
return new Map(extensions.map(ext => [ext.manifestPath, ext]));
|
||||
}
|
||||
|
||||
async loadBundledExtensions() {
|
||||
const extensions: InstalledExtension[] = [];
|
||||
const folderPath = this.bundledFolderPath;
|
||||
const bundledExtensions = getBundledExtensions();
|
||||
const paths = await fs.readdir(folderPath);
|
||||
for (const fileName of paths) {
|
||||
if (!bundledExtensions.includes(fileName)) {
|
||||
continue;
|
||||
}
|
||||
const absPath = path.resolve(folderPath, fileName);
|
||||
const manifestPath = path.resolve(absPath, "package.json");
|
||||
const ext = await this.getByManifest(manifestPath, { isBundled: true }).catch(() => null);
|
||||
if (ext) {
|
||||
extensions.push(ext);
|
||||
}
|
||||
}
|
||||
logger.debug(`[EXTENSION-MANAGER]: ${extensions.length} extensions loaded`, { folderPath, extensions });
|
||||
return extensions;
|
||||
}
|
||||
|
||||
async loadFromFolder(folderPath: string): Promise<InstalledExtension[]> {
|
||||
const bundledExtensions = getBundledExtensions();
|
||||
const extensions: InstalledExtension[] = [];
|
||||
const paths = await fs.readdir(folderPath);
|
||||
for (const fileName of paths) {
|
||||
if (bundledExtensions.includes(fileName)) { // do no allow to override bundled extensions
|
||||
continue;
|
||||
}
|
||||
const absPath = path.resolve(folderPath, fileName);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
continue;
|
||||
}
|
||||
const lstat = await fs.lstat(absPath);
|
||||
if (!lstat.isDirectory() && !lstat.isSymbolicLink()) { // skip non-directories
|
||||
continue;
|
||||
}
|
||||
const manifestPath = path.resolve(absPath, "package.json");
|
||||
const ext = await this.getByManifest(manifestPath).catch(() => null);
|
||||
if (ext) {
|
||||
extensions.push(ext);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(`[EXTENSION-MANAGER]: ${extensions.length} extensions loaded`, { folderPath, extensions });
|
||||
return extensions;
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionManager = new ExtensionManager();
|
||||
@ -47,11 +47,6 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
|
||||
await extensionLoader.whenLoaded;
|
||||
await this.whenLoaded;
|
||||
|
||||
// activate user-extensions when state is ready
|
||||
extensionLoader.userExtensions.forEach((ext, extId) => {
|
||||
ext.isEnabled = this.isEnabled(extId);
|
||||
});
|
||||
|
||||
// apply state on changes from store
|
||||
reaction(() => this.state.toJS(), extensionsState => {
|
||||
extensionsState.forEach((state, extId) => {
|
||||
@ -70,7 +65,7 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
|
||||
|
||||
isEnabled(extId: LensExtensionId) {
|
||||
const state = this.state.get(extId);
|
||||
return !state /* enabled by default */ || state.enabled;
|
||||
return state && state.enabled; // by default false
|
||||
}
|
||||
|
||||
@action
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { InstalledExtension } from "./extension-manager";
|
||||
import type { InstalledExtension } from "./extension-discovery";
|
||||
import { action, observable, reaction } from "mobx";
|
||||
import { filesystemProvisionerStore } from "../main/extension-filesystem";
|
||||
import logger from "../main/logger";
|
||||
|
||||
export type LensExtensionId = string; // path to manifest (package.json)
|
||||
@ -11,6 +12,7 @@ export interface LensExtensionManifest {
|
||||
description?: string;
|
||||
main?: string; // path to %ext/dist/main.js
|
||||
renderer?: string; // path to %ext/dist/renderer.js
|
||||
lens?: object; // fixme: add more required fields for validation
|
||||
}
|
||||
|
||||
export class LensExtension {
|
||||
@ -27,6 +29,7 @@ export class LensExtension {
|
||||
}
|
||||
|
||||
get id(): LensExtensionId {
|
||||
// This is the symlinked path under node_modules
|
||||
return this.manifestPath;
|
||||
}
|
||||
|
||||
@ -38,6 +41,17 @@ export class LensExtension {
|
||||
return this.manifest.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* getExtensionFileFolder returns the path to an already created folder. This
|
||||
* folder is for the sole use of this extension.
|
||||
*
|
||||
* Note: there is no security done on this folder, only obfiscation of the
|
||||
* folder name.
|
||||
*/
|
||||
async getExtensionFileFolder(): Promise<string> {
|
||||
return filesystemProvisionerStore.requestDirectory(this.id);
|
||||
}
|
||||
|
||||
get description() {
|
||||
return this.manifest.description;
|
||||
}
|
||||
@ -66,15 +80,16 @@ export class LensExtension {
|
||||
}
|
||||
}
|
||||
|
||||
async whenEnabled(handlers: () => Function[]) {
|
||||
async whenEnabled(handlers: () => Promise<Function[]>) {
|
||||
const disposers: Function[] = [];
|
||||
const unregisterHandlers = () => {
|
||||
disposers.forEach(unregister => unregister());
|
||||
disposers.length = 0;
|
||||
};
|
||||
const cancelReaction = reaction(() => this.isEnabled, isEnabled => {
|
||||
const cancelReaction = reaction(() => this.isEnabled, async (isEnabled) => {
|
||||
if (isEnabled) {
|
||||
disposers.push(...handlers());
|
||||
const handlerDisposers = await handlers();
|
||||
disposers.push(...handlerDisposers);
|
||||
} else {
|
||||
unregisterHandlers();
|
||||
}
|
||||
@ -95,3 +110,7 @@ export class LensExtension {
|
||||
// mock
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeExtensionName(name: string) {
|
||||
return name.replace("@", "").replace("/", "--");
|
||||
}
|
||||
|
||||
@ -5,13 +5,13 @@ import { WindowManager } from "../main/window-manager";
|
||||
import { getExtensionPageUrl } from "./registries/page-registry";
|
||||
|
||||
export class LensMainExtension extends LensExtension {
|
||||
@observable.shallow appMenus: MenuRegistration[] = [];
|
||||
appMenus: MenuRegistration[] = [];
|
||||
|
||||
async navigate<P extends object>(pageId?: string, params?: P, frameId?: number) {
|
||||
const windowManager = WindowManager.getInstance<WindowManager>();
|
||||
const pageUrl = getExtensionPageUrl({
|
||||
extensionId: this.name,
|
||||
pageId: pageId,
|
||||
pageId,
|
||||
params: params ?? {}, // compile to url with params
|
||||
});
|
||||
await windowManager.navigate(pageUrl, frameId);
|
||||
|
||||
@ -1,27 +1,36 @@
|
||||
import type { AppPreferenceRegistration, ClusterFeatureRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, } from "./registries";
|
||||
import type { Cluster } from "../main/cluster";
|
||||
import { observable } from "mobx";
|
||||
import { LensExtension } from "./lens-extension";
|
||||
import { getExtensionPageUrl } from "./registries/page-registry";
|
||||
|
||||
|
||||
export class LensRendererExtension extends LensExtension {
|
||||
@observable.shallow globalPages: PageRegistration[] = [];
|
||||
@observable.shallow clusterPages: PageRegistration[] = [];
|
||||
@observable.shallow globalPageMenus: PageMenuRegistration[] = [];
|
||||
@observable.shallow clusterPageMenus: PageMenuRegistration[] = [];
|
||||
@observable.shallow kubeObjectStatusTexts: KubeObjectStatusRegistration[] = [];
|
||||
@observable.shallow appPreferences: AppPreferenceRegistration[] = [];
|
||||
@observable.shallow clusterFeatures: ClusterFeatureRegistration[] = [];
|
||||
@observable.shallow statusBarItems: StatusBarRegistration[] = [];
|
||||
@observable.shallow kubeObjectDetailItems: KubeObjectDetailRegistration[] = [];
|
||||
@observable.shallow kubeObjectMenuItems: KubeObjectMenuRegistration[] = [];
|
||||
globalPages: PageRegistration[] = [];
|
||||
clusterPages: PageRegistration[] = [];
|
||||
globalPageMenus: PageMenuRegistration[] = [];
|
||||
clusterPageMenus: PageMenuRegistration[] = [];
|
||||
kubeObjectStatusTexts: KubeObjectStatusRegistration[] = [];
|
||||
appPreferences: AppPreferenceRegistration[] = [];
|
||||
clusterFeatures: ClusterFeatureRegistration[] = [];
|
||||
statusBarItems: StatusBarRegistration[] = [];
|
||||
kubeObjectDetailItems: KubeObjectDetailRegistration[] = [];
|
||||
kubeObjectMenuItems: KubeObjectMenuRegistration[] = [];
|
||||
|
||||
async navigate<P extends object>(pageId?: string, params?: P) {
|
||||
const { navigate } = await import("../renderer/navigation");
|
||||
const pageUrl = getExtensionPageUrl({
|
||||
extensionId: this.name,
|
||||
pageId: pageId,
|
||||
pageId,
|
||||
params: params ?? {}, // compile to url with params
|
||||
});
|
||||
navigate(pageUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines if extension is enabled for a given cluster. Defaults to `true`.
|
||||
*/
|
||||
async isEnabledForCluster(cluster: Cluster): Promise<Boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,8 +25,8 @@ describe("getPageUrl", () => {
|
||||
expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "/test" })).toBe("/extension/foo-bar/test");
|
||||
});
|
||||
|
||||
it("removes @", () => {
|
||||
expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo-bar");
|
||||
it("removes @ and replace `/` to `--`", () => {
|
||||
expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo--bar");
|
||||
});
|
||||
|
||||
it("adds / prefix", () => {
|
||||
|
||||
@ -1,20 +1,21 @@
|
||||
// Base class for extensions-api registries
|
||||
import { action, observable } from "mobx";
|
||||
import { LensExtension } from "../lens-extension";
|
||||
import { recitfy } from "../../common/utils";
|
||||
|
||||
export class BaseRegistry<T = object, I extends T = T> {
|
||||
export class BaseRegistry<T> {
|
||||
private items = observable<T>([], { deep: false });
|
||||
|
||||
getItems(): I[] {
|
||||
return this.items.toJS() as I[];
|
||||
getItems(): T[] {
|
||||
return this.items.toJS();
|
||||
}
|
||||
|
||||
add(items: T | T[], ext?: LensExtension): () => void; // allow method overloading with required "ext"
|
||||
@action
|
||||
add(items: T | T[]) {
|
||||
const normalizedItems = (Array.isArray(items) ? items : [items]);
|
||||
this.items.push(...normalizedItems);
|
||||
return () => this.remove(...normalizedItems);
|
||||
const itemArray = recitfy(items);
|
||||
this.items.push(...itemArray);
|
||||
return () => this.remove(...itemArray);
|
||||
}
|
||||
|
||||
@action
|
||||
|
||||
@ -5,8 +5,9 @@ import path from "path";
|
||||
import { action } from "mobx";
|
||||
import { compile } from "path-to-regexp";
|
||||
import { BaseRegistry } from "./base-registry";
|
||||
import { LensExtension } from "../lens-extension";
|
||||
import { LensExtension, sanitizeExtensionName } from "../lens-extension";
|
||||
import logger from "../../main/logger";
|
||||
import { recitfy } from "../../common/utils";
|
||||
|
||||
export interface PageRegistration {
|
||||
/**
|
||||
@ -15,11 +16,6 @@ export interface PageRegistration {
|
||||
* When not provided, first registered page without "id" would be used for page-menus without target.pageId for same extension
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Alias to page ID which assume to be used as path with possible :param placeholders
|
||||
* @deprecated
|
||||
*/
|
||||
routePath?: string;
|
||||
/**
|
||||
* Strict route matching to provided page-id, read also: https://reactrouter.com/web/api/NavLink/exact-bool
|
||||
* In case when more than one page registered at same extension "pageId" is required to identify different pages,
|
||||
@ -44,10 +40,6 @@ export interface PageComponents {
|
||||
Page: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
export function sanitizeExtensionName(name: string) {
|
||||
return name.replace("@", "").replace("/", "-");
|
||||
}
|
||||
|
||||
export function getExtensionPageUrl<P extends object>({ extensionId, pageId = "", params }: PageMenuTarget<P>): string {
|
||||
const extensionBaseUrl = compile(`/extension/:name`)({
|
||||
name: sanitizeExtensionName(extensionId), // compile only with extension-id first and define base path
|
||||
@ -59,15 +51,16 @@ export function getExtensionPageUrl<P extends object>({ extensionId, pageId = ""
|
||||
return extPageRoutePath;
|
||||
}
|
||||
|
||||
export class PageRegistry extends BaseRegistry<PageRegistration, RegisteredPage> {
|
||||
export class PageRegistry extends BaseRegistry<RegisteredPage> {
|
||||
@action
|
||||
add(items: PageRegistration[], ext: LensExtension) {
|
||||
add(items: PageRegistration | PageRegistration[], ext: LensExtension) {
|
||||
const itemArray = recitfy(items);
|
||||
let registeredPages: RegisteredPage[] = [];
|
||||
try {
|
||||
registeredPages = items.map(page => ({
|
||||
registeredPages = itemArray.map(page => ({
|
||||
...page,
|
||||
extensionId: ext.name,
|
||||
routePath: getExtensionPageUrl({ extensionId: ext.name, pageId: page.id ?? page.routePath }),
|
||||
routePath: getExtensionPageUrl({ extensionId: ext.name, pageId: page.id }),
|
||||
}));
|
||||
} catch (err) {
|
||||
logger.error(`[EXTENSION]: page-registration failed`, {
|
||||
|
||||
@ -13,7 +13,7 @@ export class ClusterIdDetector extends BaseClusterDetector {
|
||||
id = this.cluster.apiUrl;
|
||||
}
|
||||
const value = createHash("sha256").update(id).digest("hex");
|
||||
return { value: value, accuracy: 100 };
|
||||
return { value, accuracy: 100 };
|
||||
}
|
||||
|
||||
protected async getDefaultNamespaceId() {
|
||||
|
||||
@ -91,7 +91,7 @@ export class ContextHandler {
|
||||
return {
|
||||
target: proxyUrl,
|
||||
changeOrigin: true,
|
||||
timeout: timeout,
|
||||
timeout,
|
||||
headers: {
|
||||
"Host": this.clusterUrl.hostname,
|
||||
},
|
||||
|
||||
11
src/main/developer-tools.ts
Normal file
11
src/main/developer-tools.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Installs Electron developer tools in the development build.
|
||||
* The dependency is not bundled to the production build.
|
||||
*/
|
||||
export const installDeveloperTools = async () => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
const { default: devToolsInstaller, REACT_DEVELOPER_TOOLS } = await import('electron-devtools-installer');
|
||||
|
||||
return devToolsInstaller([REACT_DEVELOPER_TOOLS]);
|
||||
}
|
||||
};
|
||||
57
src/main/extension-filesystem.ts
Normal file
57
src/main/extension-filesystem.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { SHA256 } from "crypto-js";
|
||||
import { app } from "electron";
|
||||
import fse from "fs-extra";
|
||||
import { action, observable, toJS } from "mobx";
|
||||
import path from "path";
|
||||
import { BaseStore } from "../common/base-store";
|
||||
import { LensExtensionId } from "../extensions/lens-extension";
|
||||
|
||||
interface FSProvisionModel {
|
||||
extensions: Record<string, string>; // extension names to paths
|
||||
}
|
||||
|
||||
export class FilesystemProvisionerStore extends BaseStore<FSProvisionModel> {
|
||||
@observable registeredExtensions = observable.map<LensExtensionId, string>();
|
||||
|
||||
private constructor() {
|
||||
super({
|
||||
configName: "lens-filesystem-provisioner-store",
|
||||
accessPropertiesByDotNotation: false, // To make dots safe in cluster context names
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* This function retrieves the saved path to the folder which the extension
|
||||
* can saves files to. If the folder is not present then it is created.
|
||||
* @param extensionName the name of the extension requesting the path
|
||||
* @returns path to the folder that the extension can safely write files to.
|
||||
*/
|
||||
async requestDirectory(extensionName: string): Promise<string> {
|
||||
if (!this.registeredExtensions.has(extensionName)) {
|
||||
const salt = randomBytes(32).toString("hex");
|
||||
const hashedName = SHA256(`${extensionName}/${salt}`).toString();
|
||||
const dirPath = path.resolve(app.getPath("userData"), "extension_data", hashedName);
|
||||
this.registeredExtensions.set(extensionName, dirPath);
|
||||
}
|
||||
|
||||
const dirPath = this.registeredExtensions.get(extensionName);
|
||||
await fse.ensureDir(dirPath);
|
||||
return dirPath;
|
||||
}
|
||||
|
||||
@action
|
||||
protected fromStore({ extensions }: FSProvisionModel = { extensions: {} }): void {
|
||||
this.registeredExtensions.merge(extensions);
|
||||
}
|
||||
|
||||
toJSON(): FSProvisionModel {
|
||||
return toJS({
|
||||
extensions: this.registeredExtensions.toJSON(),
|
||||
}, {
|
||||
recurseEverything: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const filesystemProvisionerStore = FilesystemProvisionerStore.getInstance<FilesystemProvisionerStore>();
|
||||
@ -8,7 +8,7 @@ export class HelmCli extends LensBinary {
|
||||
public constructor(baseDir: string, version: string) {
|
||||
const opts: LensBinaryOpts = {
|
||||
version,
|
||||
baseDir: baseDir,
|
||||
baseDir,
|
||||
originalBinaryName: "helm",
|
||||
newBinaryName: "helm3"
|
||||
};
|
||||
|
||||
@ -40,7 +40,7 @@ export class HelmReleaseManager {
|
||||
log: stdout,
|
||||
release: {
|
||||
name: releaseName,
|
||||
namespace: namespace
|
||||
namespace
|
||||
}
|
||||
};
|
||||
} finally {
|
||||
|
||||
@ -21,8 +21,11 @@ import { userStore } from "../common/user-store";
|
||||
import { workspaceStore } from "../common/workspace-store";
|
||||
import { appEventBus } from "../common/event-bus";
|
||||
import { extensionLoader } from "../extensions/extension-loader";
|
||||
import { extensionManager } from "../extensions/extension-manager";
|
||||
import { extensionsStore } from "../extensions/extensions-store";
|
||||
import { InstalledExtension, extensionDiscovery } from "../extensions/extension-discovery";
|
||||
import type { LensExtensionId } from "../extensions/lens-extension";
|
||||
import { installDeveloperTools } from "./developer-tools";
|
||||
import { filesystemProvisionerStore } from "./extension-filesystem";
|
||||
|
||||
const workingDir = path.join(app.getPath("appData"), appName);
|
||||
let proxyPort: number;
|
||||
@ -49,12 +52,15 @@ app.on("ready", async () => {
|
||||
|
||||
registerFileProtocol("static", __static);
|
||||
|
||||
await installDeveloperTools();
|
||||
|
||||
// preload
|
||||
await Promise.all([
|
||||
userStore.load(),
|
||||
clusterStore.load(),
|
||||
workspaceStore.load(),
|
||||
extensionsStore.load(),
|
||||
filesystemProvisionerStore.load(),
|
||||
]);
|
||||
|
||||
// find free port
|
||||
@ -79,8 +85,22 @@ app.on("ready", async () => {
|
||||
}
|
||||
|
||||
extensionLoader.init();
|
||||
|
||||
extensionDiscovery.init();
|
||||
windowManager = WindowManager.getInstance<WindowManager>(proxyPort);
|
||||
extensionLoader.initExtensions(await extensionManager.load()); // call after windowManager to see splash earlier
|
||||
|
||||
// call after windowManager to see splash earlier
|
||||
const extensions = await extensionDiscovery.load();
|
||||
|
||||
// Subscribe to extensions that are copied or deleted to/from the extensions folder
|
||||
extensionDiscovery.events.on("add", (extension: InstalledExtension) => {
|
||||
extensionLoader.addExtension(extension);
|
||||
});
|
||||
extensionDiscovery.events.on("remove", (lensExtensionId: LensExtensionId) => {
|
||||
extensionLoader.removeExtension(lensExtensionId);
|
||||
});
|
||||
|
||||
extensionLoader.initExtensions(extensions);
|
||||
|
||||
setTimeout(() => {
|
||||
appEventBus.emit({ name: "service", action: "start" });
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, IpcMainEvent, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron";
|
||||
import { autorun } from "mobx";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { appName, isMac, isWindows, isTestEnv } from "../common/vars";
|
||||
import { appName, isMac, isWindows, isTestEnv, docsUrl, supportUrl } from "../common/vars";
|
||||
import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route";
|
||||
import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
|
||||
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
|
||||
@ -24,6 +24,7 @@ export function showAbout(browserWindow: BrowserWindow) {
|
||||
`${appName}: ${app.getVersion()}`,
|
||||
`Electron: ${process.versions.electron}`,
|
||||
`Chrome: ${process.versions.chrome}`,
|
||||
`Node: ${process.versions.node}`,
|
||||
`Copyright 2020 Mirantis, Inc.`,
|
||||
];
|
||||
dialog.showMessageBoxSync(browserWindow, {
|
||||
@ -215,13 +216,13 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{
|
||||
label: "Documentation",
|
||||
click: async () => {
|
||||
shell.openExternal('https://docs.k8slens.dev/');
|
||||
shell.openExternal(docsUrl);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Support",
|
||||
click: async () => {
|
||||
shell.openExternal('https://docs.k8slens.dev/latest/support/');
|
||||
shell.openExternal(supportUrl);
|
||||
},
|
||||
},
|
||||
...ignoreOnMac([
|
||||
|
||||
@ -67,15 +67,15 @@ export class Router {
|
||||
output: "data",
|
||||
});
|
||||
return {
|
||||
cluster: cluster,
|
||||
cluster,
|
||||
path: url.pathname,
|
||||
raw: {
|
||||
req: req,
|
||||
req,
|
||||
},
|
||||
response: res,
|
||||
query: url.searchParams,
|
||||
payload: payload,
|
||||
params: params
|
||||
payload,
|
||||
params
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -78,16 +78,16 @@ class PortForwardRoute extends LensApi {
|
||||
|
||||
let portForward = PortForward.getPortforward({
|
||||
clusterId: cluster.id, kind: resourceType, name: resourceName,
|
||||
namespace: namespace, port: port
|
||||
namespace, port
|
||||
});
|
||||
if (!portForward) {
|
||||
logger.info(`Creating a new port-forward ${namespace}/${resourceType}/${resourceName}:${port}`);
|
||||
portForward = new PortForward({
|
||||
clusterId: cluster.id,
|
||||
kind: resourceType,
|
||||
namespace: namespace,
|
||||
namespace,
|
||||
name: resourceName,
|
||||
port: port,
|
||||
port,
|
||||
kubeConfig: cluster.getProxyKubeconfigPath()
|
||||
});
|
||||
const started = await portForward.start();
|
||||
|
||||
@ -47,7 +47,7 @@ export class ShellSession extends EventEmitter {
|
||||
this.shellProcess = pty.spawn(shell, args, {
|
||||
cols: 80,
|
||||
cwd: this.cwd() || env.HOME,
|
||||
env: env,
|
||||
env,
|
||||
name: "xterm-256color",
|
||||
rows: 30,
|
||||
});
|
||||
|
||||
@ -124,7 +124,7 @@ export class WindowManager extends Singleton {
|
||||
await this.ensureMainWindow();
|
||||
this.sendToView({
|
||||
channel: "renderer:navigate",
|
||||
frameId: frameId,
|
||||
frameId,
|
||||
data: [url],
|
||||
});
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ export default migration({
|
||||
user["auth-provider"].config = authConfig;
|
||||
kubeConfig.users = [{
|
||||
name: userObj.name,
|
||||
user: user
|
||||
user
|
||||
}];
|
||||
cluster.kubeConfig = yaml.safeDump(kubeConfig);
|
||||
store.set(clusterKey, cluster);
|
||||
|
||||
@ -8,7 +8,7 @@ export class ClusterApi extends KubeApi<Cluster> {
|
||||
|
||||
async getMetrics(nodeNames: string[], params?: IMetricsReqParams): Promise<IClusterMetrics> {
|
||||
const nodes = nodeNames.join("|");
|
||||
const opts = { category: "cluster", nodes: nodes };
|
||||
const opts = { category: "cluster", nodes };
|
||||
|
||||
return metricsApi.getMetrics({
|
||||
memoryUsage: opts,
|
||||
|
||||
@ -20,7 +20,7 @@ export class DeploymentApi extends KubeApi<Deployment> {
|
||||
data: {
|
||||
metadata: params,
|
||||
spec: {
|
||||
replicas: replicas
|
||||
replicas
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -119,7 +119,7 @@ export const helmReleasesApi = {
|
||||
const path = endpoint({ name, namespace }) + "/rollback";
|
||||
return apiBase.put(path, {
|
||||
data: {
|
||||
revision: revision
|
||||
revision
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -84,8 +84,8 @@ export class JsonApi<D = JsonApiData, P extends JsonApiParams = JsonApiParams> {
|
||||
}
|
||||
const infoLog: JsonApiLog = {
|
||||
method: reqInit.method.toUpperCase(),
|
||||
reqUrl: reqUrl,
|
||||
reqInit: reqInit,
|
||||
reqUrl,
|
||||
reqInit,
|
||||
};
|
||||
return cancelableFetch(reqUrl, reqInit).then(res => {
|
||||
return this.parseResponse<D>(res, infoLog);
|
||||
|
||||
@ -73,7 +73,7 @@ export function forCluster<T extends KubeObject>(cluster: IKubeApiCluster, kubeC
|
||||
});
|
||||
return new KubeApi({
|
||||
objectConstructor: kubeClass,
|
||||
request: request
|
||||
request
|
||||
});
|
||||
}
|
||||
|
||||
@ -228,7 +228,7 @@ export class KubeApi<T extends KubeObject = any> {
|
||||
apiVersion: this.apiVersionWithGroup,
|
||||
resource: this.apiResource,
|
||||
namespace: this.isNamespaced ? namespace : undefined,
|
||||
name: name,
|
||||
name,
|
||||
});
|
||||
return resourcePath + (query ? `?` + stringify(this.normalizeQuery(query)) : "");
|
||||
}
|
||||
@ -256,7 +256,7 @@ export class KubeApi<T extends KubeObject = any> {
|
||||
this.setResourceVersion("", metadata.resourceVersion);
|
||||
return items.map(item => new KubeObjectConstructor({
|
||||
kind: this.kind,
|
||||
apiVersion: apiVersion,
|
||||
apiVersion,
|
||||
...item,
|
||||
}));
|
||||
}
|
||||
|
||||
@ -15,6 +15,7 @@ import { i18nStore } from "./i18n";
|
||||
import { themeStore } from "./theme.store";
|
||||
import { extensionsStore } from "../extensions/extensions-store";
|
||||
import { extensionLoader } from "../extensions/extension-loader";
|
||||
import { filesystemProvisionerStore } from "../main/extension-filesystem";
|
||||
|
||||
type AppComponent = React.ComponentType & {
|
||||
init?(): Promise<void>;
|
||||
@ -39,6 +40,7 @@ export async function bootstrap(App: AppComponent) {
|
||||
workspaceStore.load(),
|
||||
clusterStore.load(),
|
||||
extensionsStore.load(),
|
||||
filesystemProvisionerStore.load(),
|
||||
i18nStore.init(),
|
||||
themeStore.init(),
|
||||
]);
|
||||
|
||||
@ -1,12 +1,4 @@
|
||||
.AddCluster {
|
||||
.droppable {
|
||||
box-shadow: 0 0 0 5px inset $primary;
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: -$padding;
|
||||
color: $textColorSecondary;
|
||||
|
||||
@ -8,7 +8,7 @@ import { KubeConfig } from "@kubernetes/client-node";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { Select, SelectOption } from "../select";
|
||||
import { Input } from "../input";
|
||||
import { DropFileInput, Input } from "../input";
|
||||
import { AceEditor } from "../ace-editor";
|
||||
import { Button } from "../button";
|
||||
import { Icon } from "../icon";
|
||||
@ -24,6 +24,7 @@ import { cssNames } from "../../utils";
|
||||
import { Notifications } from "../notifications";
|
||||
import { Tab, Tabs } from "../tabs";
|
||||
import { ExecValidationNotFoundError } from "../../../common/custom-errors";
|
||||
import { appEventBus } from "../../../common/event-bus";
|
||||
|
||||
enum KubeConfigSourceTab {
|
||||
FILE = "file",
|
||||
@ -43,11 +44,11 @@ export class AddCluster extends React.Component {
|
||||
@observable proxyServer = "";
|
||||
@observable isWaiting = false;
|
||||
@observable showSettings = false;
|
||||
@observable dropAreaActive = false;
|
||||
|
||||
componentDidMount() {
|
||||
clusterStore.setActive(null);
|
||||
this.setKubeConfig(userStore.kubeConfigPath);
|
||||
appEventBus.emit({ name: "cluster-add", action: "start" });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@ -119,6 +120,11 @@ export class AddCluster extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
onDropKubeConfig = (files: File[]) => {
|
||||
this.sourceTab = KubeConfigSourceTab.FILE;
|
||||
this.setKubeConfig(files[0].path);
|
||||
};
|
||||
|
||||
@action
|
||||
addClusters = () => {
|
||||
let newClusters: ClusterModel[] = [];
|
||||
@ -129,7 +135,7 @@ export class AddCluster extends React.Component {
|
||||
}
|
||||
this.error = "";
|
||||
this.isWaiting = true;
|
||||
|
||||
appEventBus.emit({ name: "cluster-add", action: "click" });
|
||||
newClusters = this.selectedContexts.filter(context => {
|
||||
try {
|
||||
const kubeConfig = this.kubeContexts.get(context);
|
||||
@ -137,7 +143,7 @@ export class AddCluster extends React.Component {
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.error = String(err.message);
|
||||
if (err instanceof ExecValidationNotFoundError ) {
|
||||
if (err instanceof ExecValidationNotFoundError) {
|
||||
Notifications.error(<Trans>Error while adding cluster(s): {this.error}</Trans>);
|
||||
return false;
|
||||
} else {
|
||||
@ -152,7 +158,7 @@ export class AddCluster extends React.Component {
|
||||
: ClusterStore.embedCustomKubeConfig(clusterId, kubeConfig); // save in app-files folder
|
||||
return {
|
||||
id: clusterId,
|
||||
kubeConfigPath: kubeConfigPath,
|
||||
kubeConfigPath,
|
||||
workspace: workspaceStore.currentWorkspaceId,
|
||||
contextName: kubeConfig.currentContext,
|
||||
preferences: {
|
||||
@ -228,7 +234,7 @@ export class AddCluster extends React.Component {
|
||||
<Tab
|
||||
value={KubeConfigSourceTab.FILE}
|
||||
label={<Trans>Select kubeconfig file</Trans>}
|
||||
active={this.sourceTab == KubeConfigSourceTab.FILE} />
|
||||
active={this.sourceTab == KubeConfigSourceTab.FILE}/>
|
||||
<Tab
|
||||
value={KubeConfigSourceTab.TEXT}
|
||||
label={<Trans>Paste as text</Trans>}
|
||||
@ -342,71 +348,55 @@ export class AddCluster extends React.Component {
|
||||
return (
|
||||
<div className={cssNames("kube-context flex gaps align-center", context)}>
|
||||
<span>{context}</span>
|
||||
{isNew && <Icon small material="fiber_new" />}
|
||||
{isSelected && <Icon small material="check" className="box right" />}
|
||||
{isNew && <Icon small material="fiber_new"/>}
|
||||
{isSelected && <Icon small material="check" className="box right"/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const addDisabled = this.selectedContexts.length === 0;
|
||||
|
||||
return (
|
||||
<WizardLayout
|
||||
className="AddCluster"
|
||||
infoPanel={this.renderInfo()}
|
||||
contentClass={{ droppable: this.dropAreaActive }}
|
||||
contentProps={{
|
||||
onDragEnter: event => this.dropAreaActive = true,
|
||||
onDragLeave: event => this.dropAreaActive = false,
|
||||
onDragOver: event => {
|
||||
event.preventDefault(); // enable onDrop()-callback
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
},
|
||||
onDrop: event => {
|
||||
this.sourceTab = KubeConfigSourceTab.FILE;
|
||||
this.dropAreaActive = false;
|
||||
this.setKubeConfig(event.dataTransfer.files[0].path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h2><Trans>Add Cluster</Trans></h2>
|
||||
{this.renderKubeConfigSource()}
|
||||
{this.renderContextSelector()}
|
||||
<div className="cluster-settings">
|
||||
<a href="#" onClick={() => this.showSettings = !this.showSettings}>
|
||||
<Trans>Proxy settings</Trans>
|
||||
</a>
|
||||
</div>
|
||||
{this.showSettings && (
|
||||
<div className="proxy-settings">
|
||||
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
|
||||
<Input
|
||||
autoFocus
|
||||
value={this.proxyServer}
|
||||
onChange={value => this.proxyServer = value}
|
||||
theme="round-black"
|
||||
/>
|
||||
<small className="hint">
|
||||
{'A HTTP proxy server URL (format: http://<address>:<port>).'}
|
||||
</small>
|
||||
<DropFileInput onDropFiles={this.onDropKubeConfig}>
|
||||
<WizardLayout className="AddCluster" infoPanel={this.renderInfo()}>
|
||||
<h2><Trans>Add Cluster</Trans></h2>
|
||||
{this.renderKubeConfigSource()}
|
||||
{this.renderContextSelector()}
|
||||
<div className="cluster-settings">
|
||||
<a href="#" onClick={() => this.showSettings = !this.showSettings}>
|
||||
<Trans>Proxy settings</Trans>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{this.error && (
|
||||
<div className="error">{this.error}</div>
|
||||
)}
|
||||
<div className="actions-panel">
|
||||
<Button
|
||||
primary
|
||||
disabled={addDisabled}
|
||||
label={<Trans>Add cluster(s)</Trans>}
|
||||
onClick={this.addClusters}
|
||||
waiting={this.isWaiting}
|
||||
tooltip={addDisabled ? _i18n._("Select at least one cluster to add.") : undefined}
|
||||
tooltipOverrideDisabled
|
||||
/>
|
||||
</div>
|
||||
</WizardLayout>
|
||||
{this.showSettings && (
|
||||
<div className="proxy-settings">
|
||||
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
|
||||
<Input
|
||||
autoFocus
|
||||
value={this.proxyServer}
|
||||
onChange={value => this.proxyServer = value}
|
||||
theme="round-black"
|
||||
/>
|
||||
<small className="hint">
|
||||
{'A HTTP proxy server URL (format: http://<address>:<port>).'}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{this.error && (
|
||||
<div className="error">{this.error}</div>
|
||||
)}
|
||||
<div className="actions-panel">
|
||||
<Button
|
||||
primary
|
||||
disabled={addDisabled}
|
||||
label={<Trans>Add cluster(s)</Trans>}
|
||||
onClick={this.addClusters}
|
||||
waiting={this.isWaiting}
|
||||
tooltip={addDisabled ? _i18n._("Select at least one cluster to add.") : undefined}
|
||||
tooltipOverrideDisabled
|
||||
/>
|
||||
</div>
|
||||
</WizardLayout>
|
||||
</DropFileInput>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ export class HelmChartStore extends ItemStore<HelmChart> {
|
||||
const loadVersions = (repo: string) => {
|
||||
return helmChartsApi.get(repo, chartName).then(({ versions }) => {
|
||||
return versions.map(chart => ({
|
||||
repo: repo,
|
||||
repo,
|
||||
version: chart.getVersion()
|
||||
}));
|
||||
});
|
||||
|
||||
@ -32,7 +32,6 @@
|
||||
border: 1px solid var(--drawerSubtitleBackground);
|
||||
border-radius: $radius;
|
||||
overflow: auto;
|
||||
@include custom-scrollbar();
|
||||
|
||||
.TableHead {
|
||||
border-bottom: none;
|
||||
|
||||
@ -27,7 +27,7 @@ export const ClusterMetrics = observer(() => {
|
||||
id: metricType + metricNodeRole,
|
||||
label: metricType.toUpperCase() + " usage",
|
||||
borderColor: colors[metricType],
|
||||
data: data
|
||||
data
|
||||
}];
|
||||
const cpuOptions: ChartOptions = {
|
||||
scales: {
|
||||
|
||||
@ -92,11 +92,11 @@ export class AddSecretDialog extends React.Component<Props> {
|
||||
const { name, namespace, type } = this;
|
||||
const { data = [], labels = [], annotations = [] } = this.secret[type];
|
||||
const secret: Partial<Secret> = {
|
||||
type: type,
|
||||
type,
|
||||
data: this.getDataFromFields(data, val => val ? base64.encode(val) : ""),
|
||||
metadata: {
|
||||
name: name,
|
||||
namespace: namespace,
|
||||
name,
|
||||
namespace,
|
||||
annotations: this.getDataFromFields(annotations),
|
||||
labels: this.getDataFromFields(labels),
|
||||
} as IKubeObjectMetadata
|
||||
|
||||
@ -1,22 +1,52 @@
|
||||
.Extensions {
|
||||
$spacing: $padding * 2;
|
||||
--width: 100%;
|
||||
--max-width: auto;
|
||||
|
||||
.extension-list {
|
||||
.extensions-list {
|
||||
.extension {
|
||||
--flex-gap: $padding / 3;
|
||||
padding: $padding $padding * 2;
|
||||
padding: $padding $spacing;
|
||||
background: $colorVague;
|
||||
border-radius: $radius;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: $padding * 2;
|
||||
margin-top: $spacing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.extensions-info {
|
||||
--flex-gap: #{$spacing};
|
||||
|
||||
> .flex.gaps {
|
||||
--flex-gap: #{$padding};
|
||||
}
|
||||
}
|
||||
|
||||
.extensions-path {
|
||||
word-break: break-all;
|
||||
|
||||
&:hover code {
|
||||
color: $textColorSecondary;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.Clipboard {
|
||||
display: inline;
|
||||
vertical-align: baseline;
|
||||
font-size: $font-size-small;
|
||||
|
||||
&:hover {
|
||||
color: $textColorSecondary;
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
--spacing: #{$padding};
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.WizardLayout {
|
||||
@ -27,15 +57,28 @@
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
margin-top: $margin / 2;
|
||||
margin-bottom: $margin * 2;
|
||||
max-width: none;
|
||||
.InstallingExtensionNotification {
|
||||
.remove-folder-warning {
|
||||
font-size: $font-size-small;
|
||||
font-style: italic;
|
||||
opacity: .8;
|
||||
cursor: pointer;
|
||||
|
||||
> label {
|
||||
padding: $padding $padding * 2;
|
||||
border-radius: $radius;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Button {
|
||||
background-color: unset;
|
||||
border: 1px solid currentColor;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import "./extensions.scss";
|
||||
import { shell } from "electron";
|
||||
import { remote, shell } from "electron";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import fse from "fs-extra";
|
||||
import React from "react";
|
||||
import { computed, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
@ -7,15 +10,38 @@ import { t, Trans } from "@lingui/macro";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { Button } from "../button";
|
||||
import { WizardLayout } from "../layout/wizard-layout";
|
||||
import { Input } from "../input";
|
||||
import { DropFileInput, Input, InputValidators, SearchInput } from "../input";
|
||||
import { Icon } from "../icon";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { PageLayout } from "../layout/page-layout";
|
||||
import logger from "../../../main/logger";
|
||||
import { extensionLoader } from "../../../extensions/extension-loader";
|
||||
import { extensionManager } from "../../../extensions/extension-manager";
|
||||
import { extensionDiscovery, manifestFilename } from "../../../extensions/extension-discovery";
|
||||
import { LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
|
||||
import { Notifications } from "../notifications";
|
||||
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
|
||||
import { docsUrl } from "../../../common/vars";
|
||||
|
||||
interface InstallRequest {
|
||||
fileName: string;
|
||||
filePath?: string;
|
||||
data?: Buffer;
|
||||
}
|
||||
|
||||
interface InstallRequestPreloaded extends InstallRequest {
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
interface InstallRequestValidated extends InstallRequestPreloaded {
|
||||
manifest: LensExtensionManifest;
|
||||
tempFile: string; // temp system path to packed extension for unpacking
|
||||
}
|
||||
|
||||
@observer
|
||||
export class Extensions extends React.Component {
|
||||
private supportedFormats = [".tar", ".tgz"];
|
||||
@observable search = "";
|
||||
@observable downloadUrl = "";
|
||||
|
||||
@computed get extensions() {
|
||||
const searchText = this.search.toLowerCase();
|
||||
@ -29,30 +55,247 @@ export class Extensions extends React.Component {
|
||||
}
|
||||
|
||||
get extensionsPath() {
|
||||
return extensionManager.localFolderPath;
|
||||
return extensionDiscovery.localFolderPath;
|
||||
}
|
||||
|
||||
getExtensionPackageTemp(fileName = "") {
|
||||
return path.join(os.tmpdir(), "lens-extensions", fileName);
|
||||
}
|
||||
|
||||
getExtensionDestFolder(name: string) {
|
||||
return path.join(this.extensionsPath, sanitizeExtensionName(name));
|
||||
}
|
||||
|
||||
installFromSelectFileDialog = async () => {
|
||||
const { dialog, BrowserWindow, app } = remote;
|
||||
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(", ")}), `),
|
||||
buttonLabel: _i18n._(t`Use configuration`),
|
||||
filters: [
|
||||
{ name: "tarball", extensions: this.supportedFormats }
|
||||
]
|
||||
});
|
||||
if (!canceled && filePaths.length) {
|
||||
this.requestInstall(
|
||||
filePaths.map(filePath => ({
|
||||
fileName: path.basename(filePath),
|
||||
filePath,
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
addExtensions = () => {
|
||||
const { downloadUrl } = this;
|
||||
if (downloadUrl && InputValidators.isUrl.validate(downloadUrl)) {
|
||||
this.installFromUrl(downloadUrl);
|
||||
} else {
|
||||
this.installFromSelectFileDialog();
|
||||
}
|
||||
};
|
||||
|
||||
installFromUrl = async (url: string) => {
|
||||
try {
|
||||
const { promise: filePromise } = downloadFile({ url });
|
||||
this.requestInstall([{
|
||||
fileName: path.basename(url),
|
||||
data: await filePromise,
|
||||
}]);
|
||||
} catch (err) {
|
||||
Notifications.error(
|
||||
<p>Installation via URL has failed: <b>{String(err)}</b></p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
installOnDrop = (files: File[]) => {
|
||||
logger.info('Install from D&D');
|
||||
return this.requestInstall(
|
||||
files.map(file => ({
|
||||
fileName: path.basename(file.path),
|
||||
filePath: file.path,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) {
|
||||
const preloadedRequests = requests.filter(req => req.data);
|
||||
await Promise.all(
|
||||
requests
|
||||
.filter(req => !req.data && req.filePath)
|
||||
.map(req => {
|
||||
return fse.readFile(req.filePath).then(data => {
|
||||
req.data = data;
|
||||
preloadedRequests.push(req);
|
||||
}).catch(err => {
|
||||
if (showError) {
|
||||
Notifications.error(`Error while reading "${req.filePath}": ${String(err)}`);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
return preloadedRequests as InstallRequestPreloaded[];
|
||||
}
|
||||
|
||||
async validatePackage(filePath: string): Promise<LensExtensionManifest> {
|
||||
const tarFiles = await listTarEntries(filePath);
|
||||
|
||||
// tarball from npm contains single root folder "package/*"
|
||||
const rootFolder = tarFiles[0].split("/")[0];
|
||||
const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder));
|
||||
const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename;
|
||||
|
||||
if (!tarFiles.includes(manifestLocation)) {
|
||||
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
|
||||
}
|
||||
const manifest = await readFileFromTar<LensExtensionManifest>({
|
||||
tarPath: filePath,
|
||||
filePath: manifestLocation,
|
||||
parseJson: true,
|
||||
});
|
||||
if (!manifest.lens && !manifest.renderer) {
|
||||
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) {
|
||||
const validatedRequests: InstallRequestValidated[] = [];
|
||||
|
||||
// copy files to temp
|
||||
await fse.ensureDir(this.getExtensionPackageTemp());
|
||||
requests.forEach(req => {
|
||||
const tempFile = this.getExtensionPackageTemp(req.fileName);
|
||||
fse.writeFileSync(tempFile, req.data);
|
||||
});
|
||||
|
||||
// validate packages
|
||||
await Promise.all(
|
||||
requests.map(async req => {
|
||||
const tempFile = this.getExtensionPackageTemp(req.fileName);
|
||||
try {
|
||||
const manifest = await this.validatePackage(tempFile);
|
||||
validatedRequests.push({
|
||||
...req,
|
||||
manifest,
|
||||
tempFile,
|
||||
});
|
||||
} catch (err) {
|
||||
fse.unlink(tempFile).catch(() => null); // remove invalid temp package
|
||||
if (showErrors) {
|
||||
Notifications.error(
|
||||
<div className="flex column gaps">
|
||||
<p>Installing <em>{req.fileName}</em> has failed, skipping.</p>
|
||||
<p>Reason: <em>{String(err)}</em></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
return validatedRequests;
|
||||
}
|
||||
|
||||
async requestInstall(requests: InstallRequest[]) {
|
||||
const preloadedRequests = await this.preloadExtensions(requests);
|
||||
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
|
||||
|
||||
validatedRequests.forEach(install => {
|
||||
const { name, version, description } = install.manifest;
|
||||
const extensionFolder = this.getExtensionDestFolder(name);
|
||||
const folderExists = fse.existsSync(extensionFolder);
|
||||
if (!folderExists) {
|
||||
// auto-install extension if not yet exists
|
||||
this.unpackExtension(install);
|
||||
} else {
|
||||
// otherwise confirmation required (re-install / update)
|
||||
const removeNotification = Notifications.info(
|
||||
<div className="InstallingExtensionNotification flex gaps align-center">
|
||||
<div className="flex column gaps">
|
||||
<p>Install extension <b>{name}@{version}</b>?</p>
|
||||
<p>Description: <em>{description}</em></p>
|
||||
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
|
||||
<b>Warning:</b> <code>{extensionFolder}</code> will be removed before installation.
|
||||
</div>
|
||||
</div>
|
||||
<Button autoFocus label="Install" onClick={() => {
|
||||
removeNotification();
|
||||
this.unpackExtension(install);
|
||||
}}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) {
|
||||
const extName = `${name}@${version}`;
|
||||
logger.info(`Unpacking extension ${extName}`, { fileName, tempFile });
|
||||
const unpackingTempFolder = path.join(path.dirname(tempFile), path.basename(tempFile) + "-unpacked");
|
||||
const extensionFolder = this.getExtensionDestFolder(name);
|
||||
try {
|
||||
// extract to temp folder first
|
||||
await fse.remove(unpackingTempFolder).catch(Function);
|
||||
await fse.ensureDir(unpackingTempFolder);
|
||||
await extractTar(tempFile, { cwd: unpackingTempFolder });
|
||||
|
||||
// move contents to extensions folder
|
||||
const unpackedFiles = await fse.readdir(unpackingTempFolder);
|
||||
let unpackedRootFolder = unpackingTempFolder;
|
||||
if (unpackedFiles.length === 1) {
|
||||
// check if %extension.tgz was packed with single top folder,
|
||||
// e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball
|
||||
unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]);
|
||||
}
|
||||
await fse.ensureDir(extensionFolder);
|
||||
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
|
||||
Notifications.ok(
|
||||
<p>Extension <b>{extName}</b> successfully installed!</p>
|
||||
);
|
||||
} catch (err) {
|
||||
Notifications.error(
|
||||
<p>Installing extension <b>{extName}</b> has failed: <em>{err}</em></p>
|
||||
);
|
||||
} finally {
|
||||
// clean up
|
||||
fse.remove(unpackingTempFolder).catch(Function);
|
||||
fse.unlink(tempFile).catch(Function);
|
||||
}
|
||||
}
|
||||
|
||||
renderInfo() {
|
||||
return (
|
||||
<div className="flex column gaps">
|
||||
<h2>Lens Extension API</h2>
|
||||
<div className="extensions-info flex column gaps">
|
||||
<h2>Lens Extensions</h2>
|
||||
<div>
|
||||
The Extensions API in Lens allows users to customize and enhance the Lens experience by creating their own menus or page content that is extended from the existing pages. Many of the core
|
||||
features of Lens are built as extensions and use the same Extension API.
|
||||
The features that Lens includes out-of-the-box are just the start.
|
||||
Lens extensions let you add new features to your installation to support your workflow.
|
||||
Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself.
|
||||
Check out documentation to <a href={`${docsUrl}/latest/extensions/usage/`} target="_blank">learn more</a>.
|
||||
</div>
|
||||
<div>
|
||||
Extensions loaded from:
|
||||
<div className="extensions-path flex inline">
|
||||
<code>{this.extensionsPath}</code>
|
||||
<Icon
|
||||
material="folder"
|
||||
tooltip="Open folder"
|
||||
onClick={() => shell.openPath(this.extensionsPath)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
Check out documentation to <a href="https://docs.k8slens.dev/" target="_blank">learn more</a>
|
||||
<div className="install-extension flex column gaps">
|
||||
<SubTitle title="Install extension:"/>
|
||||
<Input
|
||||
showErrorsAsTooltip={true}
|
||||
className="box grow"
|
||||
theme="round-black"
|
||||
iconLeft="link"
|
||||
placeholder={`URL to an extension package (${this.supportedFormats.join(", ")})`}
|
||||
validators={InputValidators.isUrl}
|
||||
value={this.downloadUrl}
|
||||
onChange={v => this.downloadUrl = v}
|
||||
onSubmit={this.addExtensions}
|
||||
/>
|
||||
<Button
|
||||
primary
|
||||
label="Install"
|
||||
onClick={this.addExtensions}
|
||||
/>
|
||||
<p className="hint">
|
||||
<Trans><b>Pro-Tip</b>: you can drag & drop extension's tarball here to request installation</Trans>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -95,20 +338,19 @@ export class Extensions extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<PageLayout showOnTop className="Extensions" header={<h2>Extensions</h2>}>
|
||||
<WizardLayout infoPanel={this.renderInfo()}>
|
||||
<Input
|
||||
autoFocus
|
||||
theme="round-black"
|
||||
className="SearchInput"
|
||||
placeholder={_i18n._(t`Search extensions`)}
|
||||
value={this.search}
|
||||
onChange={(value) => this.search = value}
|
||||
/>
|
||||
<div className="extension-list">
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</WizardLayout>
|
||||
<DropFileInput onDropFiles={this.installOnDrop}>
|
||||
<WizardLayout infoPanel={this.renderInfo()}>
|
||||
<SearchInput
|
||||
placeholder={_i18n._(t`Search installed extensions`)}
|
||||
value={this.search}
|
||||
onChange={(value) => this.search = value}
|
||||
/>
|
||||
<div className="extensions-list">
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</WizardLayout>
|
||||
</DropFileInput>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@ export class NamespaceStore extends KubeObjectStore<Namespace> {
|
||||
kind: "Namespace",
|
||||
apiVersion: "v1",
|
||||
metadata: {
|
||||
name: name,
|
||||
name,
|
||||
uid: "",
|
||||
resourceVersion: "",
|
||||
selfLink: `/api/v1/namespaces/${name}`
|
||||
|
||||
@ -6,7 +6,6 @@
|
||||
|
||||
.Badge {
|
||||
display: flex;
|
||||
margin: 0;
|
||||
margin-bottom: 1px;
|
||||
padding: $padding $spacing;
|
||||
}
|
||||
|
||||
@ -137,7 +137,7 @@ export class Preferences extends React.Component {
|
||||
formatOptionLabel={this.formatHelmOptionLabel}
|
||||
controlShouldRenderValue={false}
|
||||
/>
|
||||
<div className="repos flex gaps column">
|
||||
<div className="repos flex column">
|
||||
{Array.from(this.helmAddedRepos).map(([name, repo]) => {
|
||||
const tooltipId = `message-${name}`;
|
||||
return (
|
||||
|
||||
@ -137,7 +137,7 @@ export class AddRoleBindingDialog extends React.Component<Props> {
|
||||
else {
|
||||
const name = useRoleForBindingName ? selectedRole.getName() : bindingName;
|
||||
roleBinding = await roleBindingsStore.create({ name, namespace }, {
|
||||
subjects: subjects,
|
||||
subjects,
|
||||
roleRef: {
|
||||
name: selectedRole.getName(),
|
||||
kind: selectedRole.kind,
|
||||
|
||||
@ -88,7 +88,7 @@ export class ServiceAccountsDetails extends React.Component<Props> {
|
||||
apiVersion: "v1",
|
||||
kind: "Secret",
|
||||
metadata: {
|
||||
name: name,
|
||||
name,
|
||||
uid: null,
|
||||
selfLink: null,
|
||||
resourceVersion: null
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
}
|
||||
|
||||
> .content {
|
||||
@include custom-scrollbar;
|
||||
overflow: auto;
|
||||
margin-top: $spacing;
|
||||
padding: $spacing * 2;
|
||||
|
||||
|
||||
@ -7,10 +7,6 @@
|
||||
|
||||
.theme-light & {
|
||||
border: 1px solid gainsboro;
|
||||
|
||||
.ace_scrollbar {
|
||||
@include custom-scrollbar(dark);
|
||||
}
|
||||
}
|
||||
|
||||
> .editor {
|
||||
@ -51,8 +47,4 @@
|
||||
.ace_comment {
|
||||
color: #808080;
|
||||
}
|
||||
|
||||
.ace_scrollbar {
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
@ -1,15 +1,6 @@
|
||||
@import "~flex.box";
|
||||
@import "fonts";
|
||||
|
||||
*, *:before, *:after {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
:root {
|
||||
--unit: 8px;
|
||||
--padding: var(--unit);
|
||||
@ -27,6 +18,33 @@
|
||||
--drag-region-height: 22px
|
||||
}
|
||||
|
||||
*, *:before, *:after {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
outline: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
height: 15px; // Align sizes visually
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollBarColor);
|
||||
background-clip: padding-box;
|
||||
border: 4px solid transparent;
|
||||
border-right-width: 5px;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: $primary;
|
||||
color: white;
|
||||
|
||||
@ -59,7 +59,7 @@ export class App extends React.Component {
|
||||
name: "cluster",
|
||||
action: "open",
|
||||
params: {
|
||||
clusterId: clusterId
|
||||
clusterId
|
||||
}
|
||||
});
|
||||
window.addEventListener("online", () => {
|
||||
|
||||
@ -30,7 +30,7 @@ export class Checkbox extends React.PureComponent<CheckboxProps> {
|
||||
render() {
|
||||
const { label, inline, className, value, theme, children, ...inputProps } = this.props;
|
||||
const componentClass = cssNames('Checkbox flex', className, {
|
||||
inline: inline,
|
||||
inline,
|
||||
checked: value,
|
||||
disabled: this.props.disabled,
|
||||
["theme-" + theme]: theme,
|
||||
|
||||
3
src/renderer/components/clipboard/clipboard.scss
Normal file
3
src/renderer/components/clipboard/clipboard.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.Clipboard {
|
||||
cursor: pointer;
|
||||
}
|
||||
62
src/renderer/components/clipboard/clipboard.tsx
Normal file
62
src/renderer/components/clipboard/clipboard.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import "./clipboard.scss";
|
||||
import React from "react";
|
||||
import { findDOMNode } from "react-dom";
|
||||
import { autobind } from "../../../common/utils";
|
||||
import { Notifications } from "../notifications";
|
||||
import { copyToClipboard } from "../../utils/copyToClipboard";
|
||||
import logger from "../../../main/logger";
|
||||
import { cssNames } from "../../utils";
|
||||
|
||||
export interface CopyToClipboardProps {
|
||||
resetSelection?: boolean;
|
||||
showNotification?: boolean;
|
||||
cssSelectorLimit?: string; // allows to copy partial content with css-selector in children-element context
|
||||
getNotificationMessage?(copiedText: string): React.ReactNode;
|
||||
}
|
||||
|
||||
export const defaultProps: Partial<CopyToClipboardProps> = {
|
||||
getNotificationMessage(copiedText: string) {
|
||||
return <p>Copied to clipboard: <em>{copiedText}</em></p>;
|
||||
}
|
||||
};
|
||||
|
||||
export class Clipboard extends React.Component<CopyToClipboardProps> {
|
||||
static displayName = "Clipboard";
|
||||
static defaultProps = defaultProps as object;
|
||||
|
||||
get rootElem(): HTMLElement {
|
||||
return findDOMNode(this) as HTMLElement;
|
||||
}
|
||||
|
||||
get rootReactElem(): React.ReactElement<React.HTMLProps<any>> {
|
||||
return React.Children.only(this.props.children) as React.ReactElement;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
onClick(evt: React.MouseEvent) {
|
||||
if (this.rootReactElem.props.onClick) {
|
||||
this.rootReactElem.props.onClick(evt); // pass event to children-root-element if any
|
||||
}
|
||||
const { showNotification, resetSelection, getNotificationMessage, cssSelectorLimit } = this.props;
|
||||
const contentElem = this.rootElem.querySelector<any>(cssSelectorLimit) || this.rootElem;
|
||||
if (contentElem) {
|
||||
const { copiedText, copied } = copyToClipboard(contentElem, { resetSelection });
|
||||
if (copied && showNotification) {
|
||||
Notifications.ok(getNotificationMessage(copiedText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
try {
|
||||
const rootElem = this.rootReactElem;
|
||||
return React.cloneElement(rootElem, {
|
||||
className: cssNames(Clipboard.displayName, rootElem.props.className),
|
||||
onClick: this.onClick,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Invalid usage components/CopyToClick usage. Children must contain root html element.`, { err: String(err) });
|
||||
return this.rootReactElem;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/renderer/components/clipboard/index.ts
Normal file
1
src/renderer/components/clipboard/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./clipboard";
|
||||
@ -1,7 +1,5 @@
|
||||
|
||||
.Dialog {
|
||||
@include custom-scrollbar;
|
||||
|
||||
position: fixed;
|
||||
overflow: auto;
|
||||
left: 0;
|
||||
@ -11,6 +9,7 @@
|
||||
padding: $unit * 5;
|
||||
z-index: $zIndex-dialog;
|
||||
overscroll-behavior: none; // prevent swiping with touch-pad on MacOSX
|
||||
overflow: auto;
|
||||
|
||||
&.modal {
|
||||
background: transparentize(#222, .5);
|
||||
|
||||
@ -55,7 +55,7 @@ export class EditResource extends React.Component<Props> {
|
||||
}
|
||||
editResourceStore.setData(this.tabId, {
|
||||
...this.tabData,
|
||||
draft: draft,
|
||||
draft,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -7,7 +7,7 @@ import { Select, SelectOption } from "../select";
|
||||
import { Badge } from "../badge";
|
||||
import { Icon } from "../icon";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { cssNames, downloadFile } from "../../utils";
|
||||
import { cssNames, saveFileDialog } from "../../utils";
|
||||
import { Pod } from "../../api/endpoints";
|
||||
import { PodLogSearch, PodLogSearchProps } from "./pod-log-search";
|
||||
|
||||
@ -39,7 +39,7 @@ export const PodLogControls = observer((props: Props) => {
|
||||
|
||||
const downloadLogs = () => {
|
||||
const fileName = selectedContainer ? selectedContainer.name : pod.getName();
|
||||
downloadFile(fileName + ".log", logs.join("\n"), "text/plain");
|
||||
saveFileDialog(fileName + ".log", logs.join("\n"), "text/plain");
|
||||
};
|
||||
|
||||
const onContainerChange = (option: SelectOption) => {
|
||||
|
||||
@ -5,17 +5,7 @@
|
||||
margin-left: $padding * 2;
|
||||
margin-top: $padding * 2;
|
||||
|
||||
.theme-light & {
|
||||
.xterm-viewport {
|
||||
@include custom-scrollbar(dark);
|
||||
}
|
||||
}
|
||||
|
||||
> .xterm {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-viewport {
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
}
|
||||
@ -10,12 +10,6 @@
|
||||
box-shadow: 0 0 $unit * 2 $boxShadow;
|
||||
z-index: $zIndex-drawer;
|
||||
|
||||
.theme-light & {
|
||||
.drawer-content {
|
||||
@include custom-scrollbar(dark);
|
||||
}
|
||||
}
|
||||
|
||||
&.left {
|
||||
left: 0;
|
||||
}
|
||||
@ -71,11 +65,8 @@
|
||||
}
|
||||
|
||||
.drawer-content {
|
||||
@include custom-scrollbar;
|
||||
|
||||
> *:not(.Spinner) {
|
||||
padding: var(--spacing);
|
||||
}
|
||||
overflow: auto;
|
||||
padding: var(--spacing);
|
||||
|
||||
.Table .TableHead {
|
||||
background-color: $contentColor;
|
||||
|
||||
@ -32,7 +32,7 @@ export class Icon extends React.PureComponent<IconProps> {
|
||||
|
||||
get isInteractive() {
|
||||
const { interactive, onClick, href, link } = this.props;
|
||||
return interactive || !!(onClick || href || link);
|
||||
return interactive ?? !!(onClick || href || link);
|
||||
}
|
||||
|
||||
@autobind()
|
||||
|
||||
9
src/renderer/components/input/drop-file-input.scss
Normal file
9
src/renderer/components/input/drop-file-input.scss
Normal file
@ -0,0 +1,9 @@
|
||||
.DropFileInput {
|
||||
&.droppable {
|
||||
box-shadow: 0 0 0 5px $primary; // fixme: might not work sometimes
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
79
src/renderer/components/input/drop-file-input.tsx
Normal file
79
src/renderer/components/input/drop-file-input.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import "./drop-file-input.scss";
|
||||
import React from "react";
|
||||
import { autobind, cssNames, IClassName } from "../../utils";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import logger from "../../../main/logger";
|
||||
|
||||
export interface DropFileInputProps extends React.DOMAttributes<any> {
|
||||
className?: IClassName;
|
||||
disabled?: boolean;
|
||||
onDropFiles(files: File[], meta: DropFileMeta): void;
|
||||
}
|
||||
|
||||
export interface DropFileMeta<T extends HTMLElement = any> {
|
||||
evt: React.DragEvent<T>;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class DropFileInput<T extends HTMLElement = any> extends React.Component<DropFileInputProps> {
|
||||
@observable dropAreaActive = false;
|
||||
|
||||
@autobind()
|
||||
onDragEnter() {
|
||||
this.dropAreaActive = true;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
onDragLeave() {
|
||||
this.dropAreaActive = false;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
onDragOver(evt: React.DragEvent<T>) {
|
||||
if (this.props.onDragOver) {
|
||||
this.props.onDragOver(evt);
|
||||
}
|
||||
evt.preventDefault(); // enable onDrop()-callback
|
||||
evt.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
|
||||
@autobind()
|
||||
onDrop(evt: React.DragEvent<T>) {
|
||||
if (this.props.onDrop) {
|
||||
this.props.onDrop(evt);
|
||||
}
|
||||
this.dropAreaActive = false;
|
||||
const files = Array.from(evt.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
this.props.onDropFiles(files, { evt });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onDragEnter, onDragLeave, onDragOver, onDrop } = this;
|
||||
const { disabled, className } = this.props;
|
||||
try {
|
||||
const contentElem = React.Children.only(this.props.children) as React.ReactElement<React.HTMLProps<HTMLElement>>;
|
||||
if (disabled) {
|
||||
return contentElem;
|
||||
}
|
||||
const isValidContentElem = React.isValidElement(contentElem);
|
||||
if (isValidContentElem) {
|
||||
const contentElemProps: React.HTMLProps<HTMLElement> = {
|
||||
className: cssNames("DropFileInput", className, {
|
||||
droppable: this.dropAreaActive,
|
||||
}),
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
};
|
||||
return React.cloneElement(contentElem, contentElemProps);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`Error: <DropFileInput/> must contain only single child element`);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -38,7 +38,7 @@ export class FileInput extends React.Component<Props> {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
resolve({
|
||||
file: file,
|
||||
file,
|
||||
data: reader.result,
|
||||
error: reader.error ? String(reader.error) : null,
|
||||
});
|
||||
|
||||
@ -2,3 +2,4 @@ export * from './input';
|
||||
export * from './search-input';
|
||||
export * from './search-input-url';
|
||||
export * from './file-input';
|
||||
export * from './drop-file-input';
|
||||
|
||||
@ -89,6 +89,10 @@
|
||||
|
||||
&.theme {
|
||||
&.round-black {
|
||||
&.invalid label {
|
||||
border-color: $colorSoftError !important;
|
||||
}
|
||||
|
||||
label {
|
||||
background: $mainBackground;
|
||||
border: 1px solid $borderFaintColor;
|
||||
@ -107,3 +111,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Tooltip.InputTooltipError {
|
||||
--bgc: #{$colorError};
|
||||
--border: none;
|
||||
--color: white;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import "./input.scss";
|
||||
|
||||
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||
import { autobind, cssNames, debouncePromise } from "../../utils";
|
||||
import { autobind, cssNames, debouncePromise, getRandId } from "../../utils";
|
||||
import { Icon } from "../icon";
|
||||
import * as Validators from "./input_validators";
|
||||
import { InputValidator } from "./input_validators";
|
||||
@ -9,6 +9,7 @@ import isString from "lodash/isString";
|
||||
import isFunction from "lodash/isFunction";
|
||||
import isBoolean from "lodash/isBoolean";
|
||||
import uniqueId from "lodash/uniqueId";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
const { conditionalValidators, ...InputValidators } = Validators;
|
||||
export { InputValidators, InputValidator };
|
||||
@ -25,6 +26,7 @@ export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSub
|
||||
maxRows?: number; // when multiLine={true} define max rows size
|
||||
dirty?: boolean; // show validation errors even if the field wasn't touched yet
|
||||
showValidationLine?: boolean; // show animated validation line for async validators
|
||||
showErrorsAsTooltip?: boolean; // show validation errors as a tooltip :hover (instead of block below)
|
||||
iconLeft?: string | React.ReactNode; // material-icon name in case of string-type
|
||||
iconRight?: string | React.ReactNode;
|
||||
contentRight?: string | React.ReactNode; // Any component of string goes after iconRight
|
||||
@ -149,7 +151,7 @@ export class Input extends React.Component<InputProps, State> {
|
||||
this.setState({
|
||||
validating: false,
|
||||
valid: !errors.length,
|
||||
errors: errors,
|
||||
errors,
|
||||
});
|
||||
}
|
||||
|
||||
@ -265,7 +267,7 @@ export class Input extends React.Component<InputProps, State> {
|
||||
|
||||
render() {
|
||||
const {
|
||||
multiLine, showValidationLine, validators, theme, maxRows, children,
|
||||
multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip,
|
||||
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight,
|
||||
...inputProps
|
||||
} = this.props;
|
||||
@ -273,11 +275,11 @@ export class Input extends React.Component<InputProps, State> {
|
||||
|
||||
const className = cssNames("Input", this.props.className, {
|
||||
[`theme ${theme}`]: theme,
|
||||
focused: focused,
|
||||
disabled: disabled,
|
||||
focused,
|
||||
disabled,
|
||||
invalid: !valid,
|
||||
dirty: dirty,
|
||||
validating: validating,
|
||||
dirty,
|
||||
validating,
|
||||
validatingLine: validating && showValidationLine,
|
||||
});
|
||||
|
||||
@ -292,21 +294,31 @@ export class Input extends React.Component<InputProps, State> {
|
||||
ref: this.bindRef,
|
||||
spellCheck: "false",
|
||||
});
|
||||
|
||||
const tooltipId = showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined;
|
||||
const showErrors = errors.length > 0 && !valid && dirty;
|
||||
const errorsInfo = (
|
||||
<div className="errors box grow">
|
||||
{errors.map((error, i) => <p key={i}>{error}</p>)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="input-area flex gaps align-center">
|
||||
<div id={tooltipId} className={className}>
|
||||
<label className="input-area flex gaps align-center" id="">
|
||||
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
||||
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
|
||||
{isString(iconRight) ? <Icon material={iconRight} /> : iconRight}
|
||||
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
|
||||
{contentRight}
|
||||
</label>
|
||||
<div className="input-info flex gaps">
|
||||
{!valid && dirty && (
|
||||
<div className="errors box grow">
|
||||
{errors.map((error, i) => <p key={i}>{error}</p>)}
|
||||
{showErrorsAsTooltip && showErrors && (
|
||||
<Tooltip targetId={tooltipId} className="InputTooltipError">
|
||||
<div className="flex gaps align-center">
|
||||
<Icon material="error_outline"/>
|
||||
{errorsInfo}
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="input-info flex gaps">
|
||||
{!showErrorsAsTooltip && showErrors && errorsInfo}
|
||||
{this.showMaxLenIndicator && (
|
||||
<div className="maxLengthIndicator box right">
|
||||
{this.getValue().length} / {maxLength}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
.Input.SearchInput {
|
||||
--compact-focus-width: 190px;
|
||||
--spacing: 6px 6px 6px 10px;
|
||||
|
||||
max-width: 900px;
|
||||
min-width: 220px;
|
||||
@ -10,7 +11,7 @@
|
||||
border: none;
|
||||
border-radius: $radius;
|
||||
box-shadow: 0 0 0 1px $halfGray;
|
||||
padding: 6px 6px 6px 10px;
|
||||
padding: var(--spacing);
|
||||
|
||||
.Icon {
|
||||
height: $margin * 2;
|
||||
|
||||
@ -7,7 +7,7 @@ import jsYaml from "js-yaml";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { AceEditor } from "../ace-editor";
|
||||
import { ServiceAccount } from "../../api/endpoints";
|
||||
import { copyToClipboard, cssNames, downloadFile } from "../../utils";
|
||||
import { copyToClipboard, cssNames, saveFileDialog } from "../../utils";
|
||||
import { Button } from "../button";
|
||||
import { Dialog, DialogProps } from "../dialog";
|
||||
import { Icon } from "../icon";
|
||||
@ -67,7 +67,7 @@ export class KubeConfigDialog extends React.Component<Props> {
|
||||
};
|
||||
|
||||
download = () => {
|
||||
downloadFile("config", this.config, "text/yaml");
|
||||
saveFileDialog("config", this.config, "text/yaml");
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@ -33,7 +33,7 @@
|
||||
}
|
||||
|
||||
> .content-wrapper {
|
||||
@include custom-scrollbar-themed;
|
||||
overflow: auto;
|
||||
padding: $spacing * 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user