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

Merge branch 'master' into extensions/validate-lens-engine-version

This commit is contained in:
Jari Kolehmainen 2021-06-07 13:13:40 +03:00
commit ff9dd1a8c6
108 changed files with 1950 additions and 976 deletions

View File

@ -0,0 +1,37 @@
name: Publish NPM Package `master`
on:
pull_request:
branches:
- master
types:
- closed
jobs:
publish:
name: Publish NPM Package `master`
runs-on: ubuntu-latest
if: |
${{ github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'area/extension') }}
strategy:
matrix:
node-version: [12.x]
steps:
- name: Checkout Release
uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Using Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- name: Generate NPM package
run: |
make build-npm
- name: publish new release
run: |
make publish-npm
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NPM_RELEASE_TAG: master

View File

@ -1,11 +1,11 @@
name: Publish NPM name: Publish NPM Package Release
on: on:
release: release:
types: types:
- published - published
jobs: jobs:
publish: publish:
name: Publish NPM name: Publish NPM Package Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:

View File

@ -110,7 +110,8 @@ build-extension-types: node_modules src/extensions/npm/extensions/dist
.PHONY: publish-npm .PHONY: publish-npm
publish-npm: node_modules build-npm publish-npm: node_modules build-npm
./node_modules/.bin/npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" ./node_modules/.bin/npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}"
cd src/extensions/npm/extensions && npm publish --access=public cd src/extensions/npm/extensions && npm publish --access=public --tag=${NPM_RELEASE_TAG:-latest}
git restore src/extensions/npm/extensions/package.json
.PHONY: docs .PHONY: docs
docs: docs:

View File

@ -22,6 +22,7 @@ import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import appInfo from "../package.json"; import appInfo from "../package.json";
import semver from "semver"; import semver from "semver";
import fastGlob from "fast-glob";
const packagePath = path.join(__dirname, "../package.json"); const packagePath = path.join(__dirname, "../package.json");
const versionInfo = semver.parse(appInfo.version); const versionInfo = semver.parse(appInfo.version);
@ -41,3 +42,14 @@ if (versionInfo.prerelease) {
fs.writeFileSync(packagePath, `${JSON.stringify(appInfo, null, 2)}\n`); fs.writeFileSync(packagePath, `${JSON.stringify(appInfo, null, 2)}\n`);
const extensionManifests = fastGlob.sync(["extensions/*/package.json"]);
for (const manifestPath of extensionManifests) {
const packagePath = path.join(__dirname, "..", manifestPath);
import(packagePath).then((packageInfo) => {
packageInfo.default.version = `${versionInfo.raw}.${Date.now()}`;
fs.writeFileSync(packagePath, `${JSON.stringify(packageInfo.default, null, 2)}\n`);
});
}

View File

@ -22,8 +22,20 @@ import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import packageInfo from "../src/extensions/npm/extensions/package.json"; import packageInfo from "../src/extensions/npm/extensions/package.json";
import appInfo from "../package.json"; import appInfo from "../package.json";
import { SemVer } from "semver";
import { execSync } from "child_process";
const packagePath = path.join(__dirname, "../src/extensions/npm/extensions/package.json"); const { NPM_RELEASE_TAG = "latest" } = process.env;
const version = new SemVer(appInfo.version);
packageInfo.version = appInfo.version; if (NPM_RELEASE_TAG !== "latest") {
fs.writeFileSync(packagePath, `${JSON.stringify(packageInfo, null, 2)}\n`); const gitRef = execSync("git rev-parse --short HEAD", {
encoding: "utf-8",
});
version.inc("prerelease", `git.${gitRef.trim()}`);
}
packageInfo.version = version.format();
fs.writeFileSync(path.join(__dirname, "../src/extensions/npm/extensions/package.json"), `${JSON.stringify(packageInfo, null, 2)}\n`);

View File

@ -14,9 +14,9 @@ In order to see logs from this extension, you need to start Lens from the comman
This extension can register a custom callback that is executed when an extension is activated (started). This extension can register a custom callback that is executed when an extension is activated (started).
``` javascript ``` javascript
import { LensMainExtension } from "@k8slens/extensions" import { Main } from "@k8slens/extensions"
export default class ExampleMainExtension extends LensMainExtension { export default class ExampleMainExtension extends Main.LensExtension {
async onActivate() { async onActivate() {
console.log("hello world") console.log("hello world")
} }
@ -28,9 +28,9 @@ export default class ExampleMainExtension extends LensMainExtension {
This extension can register a custom callback that is executed when an extension is deactivated (stopped). This extension can register a custom callback that is executed when an extension is deactivated (stopped).
``` javascript ``` javascript
import { LensMainExtension } from "@k8slens/extensions" import { Main } from "@k8slens/extensions"
export default class ExampleMainExtension extends LensMainExtension { export default class ExampleMainExtension extends Main.LensExtension {
async onDeactivate() { async onDeactivate() {
console.log("bye bye") console.log("bye bye")
} }
@ -44,15 +44,15 @@ This extension can register custom app menus that will be displayed on OS native
Example: Example:
```typescript ```typescript
import { LensMainExtension, windowManager } from "@k8slens/extensions" import { Main } from "@k8slens/extensions"
export default class ExampleMainExtension extends LensMainExtension { export default class ExampleMainExtension extends Main.LensExtension {
appMenus = [ appMenus = [
{ {
parentId: "help", parentId: "help",
label: "Example item", label: "Example item",
click() { click() {
windowManager.navigate("https://k8slens.dev"); Main.Navigation.navigate("https://k8slens.dev");
} }
} }
] ]
@ -69,9 +69,9 @@ In order to see logs from this extension you need to check them via **View** > *
This extension can register a custom callback that is executed when an extension is activated (started). This extension can register a custom callback that is executed when an extension is activated (started).
``` javascript ``` javascript
import { LensRendererExtension } from "@k8slens/extensions" import { Renderer } from "@k8slens/extensions"
export default class ExampleExtension extends LensRendererExtension { export default class ExampleExtension extends Renderer.LensExtension {
async onActivate() { async onActivate() {
console.log("hello world") console.log("hello world")
} }
@ -83,9 +83,9 @@ export default class ExampleExtension extends LensRendererExtension {
This extension can register a custom callback that is executed when an extension is deactivated (stopped). This extension can register a custom callback that is executed when an extension is deactivated (stopped).
``` javascript ``` javascript
import { LensRendererExtension } from "@k8slens/extensions" import { Renderer } from "@k8slens/extensions"
export default class ExampleMainExtension extends LensRendererExtension { export default class ExampleMainExtension extends Renderer.LensExtension {
async onDeactivate() { async onDeactivate() {
console.log("bye bye") console.log("bye bye")
} }
@ -99,10 +99,16 @@ The global page is a full-screen page that hides all other content from a window
```typescript ```typescript
import React from "react" import React from "react"
import { Component, LensRendererExtension } from "@k8slens/extensions" import { Renderer } from "@k8slens/extensions"
import { ExamplePage } from "./src/example-page" import { ExamplePage } from "./src/example-page"
export default class ExampleRendererExtension extends LensRendererExtension { const {
Component: {
Icon,
}
} = Renderer;
export default class ExampleRendererExtension extends Renderer.LensExtension {
globalPages = [ globalPages = [
{ {
id: "example", id: "example",
@ -117,7 +123,7 @@ export default class ExampleRendererExtension extends LensRendererExtension {
title: "Example page", // used in icon's tooltip title: "Example page", // used in icon's tooltip
target: { pageId: "example" } target: { pageId: "example" }
components: { components: {
Icon: () => <Component.Icon material="arrow"/>, Icon: () => <Icon material="arrow"/>,
} }
} }
] ]
@ -131,12 +137,12 @@ It is responsible for storing a state for custom preferences.
```typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions" import { Renderer } from "@k8slens/extensions"
import { myCustomPreferencesStore } from "./src/my-custom-preferences-store" import { myCustomPreferencesStore } from "./src/my-custom-preferences-store"
import { MyCustomPreferenceHint, MyCustomPreferenceInput } from "./src/my-custom-preference" import { MyCustomPreferenceHint, MyCustomPreferenceInput } from "./src/my-custom-preference"
export default class ExampleRendererExtension extends LensRendererExtension { export default class ExampleRendererExtension extends Renderer.LensExtension {
appPreferences = [ appPreferences = [
{ {
title: "My Custom Preference", title: "My Custom Preference",
@ -156,10 +162,10 @@ These pages are visible in a cluster menu when a cluster is opened.
```typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { ExampleIcon, ExamplePage } from "./src/page" import { ExampleIcon, ExamplePage } from "./src/page"
export default class ExampleExtension extends LensRendererExtension { export default class ExampleExtension extends Renderer.LensExtension {
clusterPages = [ clusterPages = [
{ {
id: "extension-example", // optional id: "extension-example", // optional
@ -190,10 +196,10 @@ These features are visible in the "Cluster Settings" page.
```typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions" import { Renderer } from "@k8slens/extensions"
import { MyCustomFeature } from "./src/my-custom-feature" import { MyCustomFeature } from "./src/my-custom-feature"
export default class ExampleExtension extends LensRendererExtension { export default class ExampleExtension extends Renderer.LensExtension {
clusterFeatures = [ clusterFeatures = [
{ {
title: "My Custom Feature", title: "My Custom Feature",
@ -219,15 +225,21 @@ This extension can register custom icons and text to a status bar area.
```typescript ```typescript
import React from "react"; import React from "react";
import { Component, LensRendererExtension, Navigation } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
export default class ExampleExtension extends LensRendererExtension { const {
Component: {
Icon,
}
} = Renderer;
export default class ExampleExtension extends Renderer.LensExtension {
statusBarItems = [ statusBarItems = [
{ {
components: { components: {
Item: ( Item: (
<div className="flex align-center gaps hover-highlight" onClick={() => this.navigate("/example-page")} > <div className="flex align-center gaps hover-highlight" onClick={() => this.navigate("/example-page")} >
<Component.Icon material="favorite" /> <Icon material="favorite" />
</div> </div>
) )
} }
@ -243,10 +255,10 @@ This extension can register custom menu items (actions) for specified Kubernetes
```typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { CustomMenuItem, CustomMenuItemProps } from "./src/custom-menu-item" import { CustomMenuItem, CustomMenuItemProps } from "./src/custom-menu-item"
export default class ExampleExtension extends LensRendererExtension { export default class ExampleExtension extends Renderer.LensExtension {
kubeObjectMenuItems = [ kubeObjectMenuItems = [
{ {
kind: "Node", kind: "Node",
@ -266,10 +278,10 @@ This extension can register custom details (content) for specified Kubernetes ki
```typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { CustomKindDetails, CustomKindDetailsProps } from "./src/custom-kind-details" import { CustomKindDetails, CustomKindDetailsProps } from "./src/custom-kind-details"
export default class ExampleExtension extends LensRendererExtension { export default class ExampleExtension extends Renderer.LensExtension {
kubeObjectDetailItems = [ kubeObjectDetailItems = [
{ {
kind: "CustomKind", kind: "CustomKind",

View File

@ -114,14 +114,14 @@ There is a way of detect active theme and its changes in JS. [MobX observer func
```js ```js
import React from "react" import React from "react"
import { observer } from "mobx-react" import { observer } from "mobx-react"
import { App, Component, Theme } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
@observer @observer
export class SupportPage extends React.Component { export class SupportPage extends React.Component {
render() { render() {
return ( return (
<div className="SupportPage"> <div className="SupportPage">
<h1>Active theme is {Theme.getActiveTheme().name}</h1> <h1>Active theme is {Renderer.Theme.getActiveTheme().name}</h1>
</div> </div>
); );
} }

View File

@ -96,11 +96,11 @@ It also registers the `MenuItem` component that displays the `ExampleIcon` React
These React components are defined in the additional `./src/page.tsx` file. These React components are defined in the additional `./src/page.tsx` file.
``` typescript ``` typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { ExampleIcon, ExamplePage } from "./page" import { ExampleIcon, ExamplePage } from "./page"
import React from "react" import React from "react"
export default class ExampleExtension extends LensRendererExtension { export default class ExampleExtension extends Renderer.LensExtension {
clusterPages = [ clusterPages = [
{ {
id: "extension-example", id: "extension-example",

View File

@ -30,9 +30,8 @@ Each guide or code sample includes the following:
| Sample | APIs | | Sample | APIs |
| ----- | ----- | | ----- | ----- |
[hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps |
[minikube](https://github.com/lensapp/lens-extension-samples/tree/master/minikube-sample) | LensMainExtension <br> Store.ClusterStore <br> Store.workspaceStore | [styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps |
[styling-css-modules-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-css-modules-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps |
[styling-emotion-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-emotion-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [styling-sass-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) | LensMainExtension <br> LensRendererExtension <br> Renderer.Component.Icon <br> Renderer.Component.IconProps |
[styling-sass-sample](https://github.com/lensapp/lens-extension-samples/tree/master/styling-sass-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> Component.IconProps | [custom-resource-page](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) | LensRendererExtension <br> Renderer.K8sApi.KubeApi <br> Renderer.K8sApi.KubeObjectStore <br> Renderer.Component.KubeObjectListLayout <br> Renderer.Component.KubeObjectDetailsProps <br> Renderer.Component.IconProps |
[custom-resource-page](https://github.com/lensapp/lens-extension-samples/tree/master/custom-resource-page) | LensRendererExtension <br> K8sApi.KubeApi <br> K8sApi.KubeObjectStore <br> Component.KubeObjectListLayout <br> Component.KubeObjectDetailsProps <br> Component.IconProps |

View File

@ -43,10 +43,10 @@ To register either a handler or a listener, you should do something like the fol
`main.ts`: `main.ts`:
```typescript ```typescript
import { LensMainExtension } from "@k8slens/extensions"; import { Main } from "@k8slens/extensions";
import { IpcMain } from "./helpers/main"; import { IpcMain } from "./helpers/main";
export class ExampleExtensionMain extends LensMainExtension { export class ExampleExtensionMain extends Main.LensExtension {
onActivate() { onActivate() {
IpcMain.createInstance(this); IpcMain.createInstance(this);
} }
@ -60,10 +60,10 @@ Lens will automatically clean up that store and all the handlers on deactivation
`helpers/main.ts`: `helpers/main.ts`:
```typescript ```typescript
import { Ipc, Types } from "@k8slens/extensions"; import { Main } from "@k8slens/extensions";
export class IpcMain extends Ipc.Main { export class IpcMain extends Main.Ipc {
constructor(extension: LensMainExtension) { constructor(extension: Main.LensExtension) {
super(extension); super(extension);
this.listen("initialize", onInitialize); this.listen("initialize", onInitialize);
@ -82,10 +82,10 @@ You should be able to just call `IpcMain.getInstance()` anywhere it is needed in
`renderer.ts`: `renderer.ts`:
```typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { IpcRenderer } from "./helpers/renderer"; import { IpcRenderer } from "./helpers/renderer";
export class ExampleExtensionRenderer extends LensRendererExtension { export class ExampleExtensionRenderer extends Renderer.LensExtension {
onActivate() { onActivate() {
const ipc = IpcRenderer.createInstance(this); const ipc = IpcRenderer.createInstance(this);
@ -100,9 +100,9 @@ It is also needed to create an instance to broadcast messages too.
`helpers/renderer.ts`: `helpers/renderer.ts`:
```typescript ```typescript
import { Ipc } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
export class IpcRenderer extends Ipc.Renderer {} export class IpcRenderer extends Renderer.Ipc {}
``` ```
It is necessary to create child classes of these `abstract class`'s in your extension before you can use them. It is necessary to create child classes of these `abstract class`'s in your extension before you can use them.
@ -116,7 +116,7 @@ There is no behind the scenes transfer of these functions.
To register a "handler" call `IpcMain.getInstance().handle(...)`. To register a "handler" call `IpcMain.getInstance().handle(...)`.
The cleanup of these handlers is handled by Lens itself. The cleanup of these handlers is handled by Lens itself.
The `listen()` methods on `Ipc.Main` and `Ipc.Renderer` return a `Disposer`, or more specifically, a `() => void`. The `listen()` methods on `Main.Ipc` and `Renderer.Ipc` return a `Disposer`, or more specifically, a `() => void`.
This can be optionally called to remove the listener early. This can be optionally called to remove the listener early.
Calling either `IpcRenderer.getInstance().broadcast(...)` or `IpcMain.getInstance().broadcast(...)` sends an event to all `renderer` frames and to `main`. Calling either `IpcRenderer.getInstance().broadcast(...)` or `IpcMain.getInstance().broadcast(...)` sends an event to all `renderer` frames and to `main`.

View File

@ -18,7 +18,7 @@ First thing we need to do with our extension is to register new menu item in the
We will do this in our extension class `CrdSampleExtension` that is derived `LensRendererExtension` class: We will do this in our extension class `CrdSampleExtension` that is derived `LensRendererExtension` class:
```typescript ```typescript
export default class CrdSampleExtension extends LensRendererExtension { export default class CrdSampleExtension extends Renderer.LensExtension {
} }
``` ```
@ -27,11 +27,21 @@ This object will register a menu item with "Certificates" text.
It will also use `CertificateIcon` component to render an icon and navigate to cluster page that is having `certificates` page id. It will also use `CertificateIcon` component to render an icon and navigate to cluster page that is having `certificates` page id.
```typescript ```typescript
export function CertificateIcon(props: Component.IconProps) { import { Renderer } from "@k8slens/extensions";
return <Component.Icon {...props} material="security" tooltip="Certificates"/>
type IconProps = Renderer.Component.IconProps;
const {
Component: {
Icon,
},
} = Renderer;
export function CertificateIcon(props: IconProps) {
return <Icon {...props} material="security" tooltip="Certificates"/>
} }
export default class CrdSampleExtension extends LensRendererExtension { export default class CrdSampleExtension extends Renderer.LensExtension {
clusterPageMenus = [ clusterPageMenus = [
{ {
@ -48,7 +58,7 @@ export default class CrdSampleExtension extends LensRendererExtension {
Then we need to register `PageRegistration` object with `certificates` id and define `CertificatePage` component to render certificates. Then we need to register `PageRegistration` object with `certificates` id and define `CertificatePage` component to render certificates.
```typescript ```typescript
export default class CrdSampleExtension extends LensRendererExtension { export default class CrdSampleExtension extends Renderer.LensExtension {
... ...
clusterPages = [{ clusterPages = [{
@ -65,18 +75,29 @@ export default class CrdSampleExtension extends LensRendererExtension {
In the previous step we defined `CertificatePage` component to render certificates. In the previous step we defined `CertificatePage` component to render certificates.
In this step we will actually implement that. In this step we will actually implement that.
`CertificatePage` is a React component that will render `Component.KubeObjectListLayout` component to list `Certificate` CRD objects. `CertificatePage` is a React component that will render `Renderer.Component.KubeObjectListLayout` component to list `Certificate` CRD objects.
### Get CRD objects ### Get CRD objects
In order to list CRD objects, we need first fetch those from Kubernetes API. In order to list CRD objects, we need first fetch those from Kubernetes API.
Lens Extensions API provides easy mechanism to do this. Lens Extensions API provides easy mechanism to do this.
We just need to define `Certificate` class derived from `K8sApi.KubeObject`, `CertificatesApi`derived from `K8sApi.KubeApi` and `CertificatesStore` derived from `K8sApi.KubeObjectStore`. We just need to define `Certificate` class derived from `Renderer.K8sApi.KubeObject`, `CertificatesApi`derived from `Renderer.K8sApi.KubeApi` and `CertificatesStore` derived from `Renderer.K8sApi.KubeObjectStore`.
`Certificate` class defines properties found in the CRD object: `Certificate` class defines properties found in the CRD object:
```typescript ```typescript
export class Certificate extends K8sApi.KubeObject { import { Renderer } from "@k8slens/extensions";
const {
K8sApi: {
KubeObject,
KubeObjectStore,
KubeApi,
apiManager,
},
} = Renderer;
export class Certificate extends KubeObject {
static kind = "Certificate" static kind = "Certificate"
static namespaced = true static namespaced = true
static apiBase = "/apis/cert-manager.io/v1alpha2/certificates" static apiBase = "/apis/cert-manager.io/v1alpha2/certificates"
@ -121,8 +142,8 @@ export class Certificate extends K8sApi.KubeObject {
With `CertificatesApi` class we are able to manage `Certificate` objects in Kubernetes API: With `CertificatesApi` class we are able to manage `Certificate` objects in Kubernetes API:
```typescript ```typescript
export class CertificatesApi extends K8sApi.KubeApi<Certificate> { export class CertificatesApi extends KubeApi<Certificate> {}
}
export const certificatesApi = new CertificatesApi({ export const certificatesApi = new CertificatesApi({
objectConstructor: Certificate objectConstructor: Certificate
}); });
@ -131,7 +152,7 @@ export const certificatesApi = new CertificatesApi({
`CertificateStore` defines storage for `Certificate` objects `CertificateStore` defines storage for `Certificate` objects
```typescript ```typescript
export class CertificatesStore extends K8sApi.KubeObjectStore<Certificate> { export class CertificatesStore extends KubeObjectStore<Certificate> {
api = certificatesApi api = certificatesApi
} }
@ -141,7 +162,7 @@ export const certificatesStore = new CertificatesStore();
And, finally, we register this store to Lens's API manager. And, finally, we register this store to Lens's API manager.
```typescript ```typescript
K8sApi.apiManager.registerStore(certificatesStore); apiManager.registerStore(certificatesStore);
``` ```
@ -153,23 +174,32 @@ Then we need to fetch those and render them in the UI.
First we define `CertificatePage` class that extends `React.Component`. First we define `CertificatePage` class that extends `React.Component`.
```typescript ```typescript
import { Component, LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import React from "react"; import React from "react";
import { certificatesStore } from "../certificate-store"; import { certificatesStore } from "../certificate-store";
import { Certificate } from "../certificate" import { Certificate } from "../certificate"
export class CertificatePage extends React.Component<{ extension: LensRendererExtension }> { export class CertificatePage extends React.Component<{ extension: Renderer.LensExtension }> {
} }
``` ```
Next we will implement `render` method that will display certificates in a list. Next we will implement `render` method that will display certificates in a list.
To do that, we just need to add `Component.KubeObjectListLayout` component inside `Component.TabLayout` component in render method. To do that, we just need to add `Renderer.Component.KubeObjectListLayout` component inside `Renderer.Component.TabLayout` component in render method.
To define which objects the list is showing, we need to pass `certificateStore` object to `Component.KubeObjectListLayout` in `store` property. To define which objects the list is showing, we need to pass `certificateStore` object to `Renderer.Component.KubeObjectListLayout` in `store` property.
`Component.KubeObjectListLayout` will fetch automatically items from the given store when component is mounted. `Renderer.Component.KubeObjectListLayout` will fetch automatically items from the given store when component is mounted.
Also, we can define needed sorting callbacks and search filters for the list: Also, we can define needed sorting callbacks and search filters for the list:
```typescript ```typescript
import { Renderer } from "@k8slens/extensions";
const {
Component: {
TabLayout,
KubeObjectListLayout,
},
} = Renderer;
enum sortBy { enum sortBy {
name = "name", name = "name",
namespace = "namespace", namespace = "namespace",
@ -181,8 +211,8 @@ export class CertificatePage extends React.Component<{ extension: LensRendererEx
render() { render() {
return ( return (
<Component.TabLayout> <TabLayout>
<Component.KubeObjectListLayout <KubeObjectListLayout
className="Certicates" store={certificatesStore} className="Certicates" store={certificatesStore}
sortingCallbacks={{ sortingCallbacks={{
[sortBy.name]: (certificate: Certificate) => certificate.getName(), [sortBy.name]: (certificate: Certificate) => certificate.getName(),
@ -204,7 +234,7 @@ export class CertificatePage extends React.Component<{ extension: LensRendererEx
certificate.spec.issuerRef.name certificate.spec.issuerRef.name
]} ]}
/> />
</Component.TabLayout> </TabLayout>
) )
} }
} }
@ -219,7 +249,7 @@ First, we need to register our custom component to render details for the specif
We will do this again in `CrdSampleExtension` class: We will do this again in `CrdSampleExtension` class:
```typescript ```typescript
export default class CrdSampleExtension extends LensRendererExtension { export default class CrdSampleExtension extends Renderer.LensExtension {
//... //...
kubeObjectDetailItems = [{ kubeObjectDetailItems = [{
@ -235,14 +265,22 @@ export default class CrdSampleExtension extends LensRendererExtension {
Here we defined that `CertificateDetails` component will render the resource details. Here we defined that `CertificateDetails` component will render the resource details.
So, next we need to implement that component. So, next we need to implement that component.
Lens will inject `Certificate` object into our component so we just need to render some information out of it. Lens will inject `Certificate` object into our component so we just need to render some information out of it.
We can use `Component.DrawerItem` component from Lens Extensions API to give the same look and feel as Lens is using elsewhere: We can use `Renderer.Component.DrawerItem` component from Lens Extensions API to give the same look and feel as Lens is using elsewhere:
```typescript ```typescript
import { Component, K8sApi } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import React from "react"; import React from "react";
import { Certificate } from "../certificate"; import { Certificate } from "../certificate";
export interface CertificateDetailsProps extends Component.KubeObjectDetailsProps<Certificate>{ const {
Component: {
KubeObjectDetailsProps,
DrawerItem,
Badge,
}
} = Renderer;
export interface CertificateDetailsProps extends KubeObjectDetailsProps<Certificate>{
} }
export class CertificateDetails extends React.Component<CertificateDetailsProps> { export class CertificateDetails extends React.Component<CertificateDetailsProps> {
@ -252,29 +290,29 @@ export class CertificateDetails extends React.Component<CertificateDetailsProps>
if (!certificate) return null; if (!certificate) return null;
return ( return (
<div className="Certificate"> <div className="Certificate">
<Component.DrawerItem name="Created"> <DrawerItem name="Created">
{certificate.getAge(true, false)} ago ({certificate.metadata.creationTimestamp }) {certificate.getAge(true, false)} ago ({certificate.metadata.creationTimestamp })
</Component.DrawerItem> </DrawerItem>
<Component.DrawerItem name="DNS Names"> <DrawerItem name="DNS Names">
{certificate.spec.dnsNames.join(",")} {certificate.spec.dnsNames.join(",")}
</Component.DrawerItem> </DrawerItem>
<Component.DrawerItem name="Secret"> <DrawerItem name="Secret">
{certificate.spec.secretName} {certificate.spec.secretName}
</Component.DrawerItem> </DrawerItem>
<Component.DrawerItem name="Status" className="status" labelsOnly> <DrawerItem name="Status" className="status" labelsOnly>
{certificate.status.conditions.map((condition, index) => { {certificate.status.conditions.map((condition, index) => {
const { type, reason, message, status } = condition; const { type, reason, message, status } = condition;
const kind = type || reason; const kind = type || reason;
if (!kind) return null; if (!kind) return null;
return ( return (
<Component.Badge <Badge
key={kind + index} label={kind} key={kind + index} label={kind}
className={"success "+kind.toLowerCase()} className={"success "+kind.toLowerCase()}
tooltip={message} tooltip={message}
/> />
); );
})} })}
</Component.DrawerItem> </DrawerItem>
</div> </div>
) )
} }

View File

@ -11,9 +11,9 @@ The Main Extension API allows you to access, configure, and customize Lens data,
To create a main extension simply extend the `LensMainExtension` class: To create a main extension simply extend the `LensMainExtension` class:
```typescript ```typescript
import { LensMainExtension } from "@k8slens/extensions"; import { Main } from "@k8slens/extensions";
export default class ExampleExtensionMain extends LensMainExtension { export default class ExampleExtensionMain extends Main.LensExtension {
onActivate() { onActivate() {
console.log('custom main process extension code started'); console.log('custom main process extension code started');
} }
@ -39,34 +39,6 @@ The example above logs messages when the extension is enabled and disabled.
To see standard output from the main process there must be a console connected to it. To see standard output from the main process there must be a console connected to it.
Achieve this by starting Lens from the command prompt. Achieve this by starting Lens from the command prompt.
The following example is a little more interesting.
It accesses some Lens state data, and it periodically logs the name of the cluster that is currently active in Lens.
```typescript
import { LensMainExtension, Store } from "@k8slens/extensions";
export default class ActiveClusterExtensionMain extends LensMainExtension {
timer: NodeJS.Timeout
onActivate() {
console.log("Cluster logger activated");
this.timer = setInterval(() => {
if (!Store.ClusterStore.getInstance().active) {
console.log("No active cluster");
return;
}
console.log("active cluster is", Store.ClusterStore.getInstance().active.contextName)
}, 5000)
}
onDeactivate() {
clearInterval(this.timer)
console.log("Cluster logger deactivated");
}
}
```
For more details on accessing Lens state data, please see the [Stores](../stores) guide. For more details on accessing Lens state data, please see the [Stores](../stores) guide.
### `appMenus` ### `appMenus`
@ -76,9 +48,9 @@ Note that this is the only UI feature that the Main Extension API allows you to
The following example demonstrates adding an item to the **Help** menu. The following example demonstrates adding an item to the **Help** menu.
``` typescript ``` typescript
import { LensMainExtension } from "@k8slens/extensions"; import { Main } from "@k8slens/extensions";
export default class SamplePageMainExtension extends LensMainExtension { export default class SamplePageMainExtension extends Main.LensExtension {
appMenus = [ appMenus = [
{ {
parentId: "help", parentId: "help",
@ -102,4 +74,4 @@ Valid values include: `"file"`, `"edit"`, `"view"`, and `"help"`.
* `click()` is called when the menu item is selected. * `click()` is called when the menu item is selected.
In this example, we simply log a message. In this example, we simply log a message.
However, you would typically have this navigate to a specific page or perform another operation. However, you would typically have this navigate to a specific page or perform another operation.
Note that pages are associated with the [`LensRendererExtension`](renderer-extension.md) class and can be defined in the process of extending it. Note that pages are associated with the [`Renderer.LensExtension`](renderer-extension.md) class and can be defined in the process of extending it.

View File

@ -18,13 +18,13 @@ In other words, which handler is selected in either process is independent from
Example of registering a handler: Example of registering a handler:
```typescript ```typescript
import { LensMainExtension, Interface } from "@k8slens/extensions"; import { Main, Common } from "@k8slens/extensions";
function rootHandler(params: Iterface.ProtocolRouteParams) { function rootHandler(params: Common.Types.ProtocolRouteParams) {
console.log("routed to ExampleExtension", params); console.log("routed to ExampleExtension", params);
} }
export default class ExampleExtensionMain extends LensMainExtension { export default class ExampleExtensionMain extends Main.LensExtension {
protocolHandlers = [ protocolHandlers = [
pathSchema: "/", pathSchema: "/",
handler: rootHandler, handler: rootHandler,

View File

@ -24,9 +24,9 @@ All UI elements are based on React components.
To create a renderer extension, extend the `LensRendererExtension` class: To create a renderer extension, extend the `LensRendererExtension` class:
```typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
export default class ExampleExtensionMain extends LensRendererExtension { export default class ExampleExtensionMain extends Renderer.LensExtension {
onActivate() { onActivate() {
console.log('custom renderer process extension code started'); console.log('custom renderer process extension code started');
} }
@ -61,11 +61,11 @@ Use your extension to access Kubernetes resources in the active cluster with [`C
Add a cluster page definition to a `LensRendererExtension` subclass with the following example: Add a cluster page definition to a `LensRendererExtension` subclass with the following example:
```typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { ExampleIcon, ExamplePage } from "./page" import { ExampleIcon, ExamplePage } from "./page"
import React from "react" import React from "react"
export default class ExampleExtension extends LensRendererExtension { export default class ExampleExtension extends Renderer.LensExtension {
clusterPages = [ clusterPages = [
{ {
id: "hello", id: "hello",
@ -88,7 +88,7 @@ It offers flexibility in defining the appearance and behavior of your page.
`ExamplePage` in the example above can be defined in `page.tsx`: `ExamplePage` in the example above can be defined in `page.tsx`:
```typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import React from "react" import React from "react"
export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> { export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> {
@ -116,11 +116,11 @@ Use `clusterPageMenus`, covered in the next section, to add cluster pages to the
By expanding on the above example, you can add a cluster page menu item to the `ExampleExtension` definition: By expanding on the above example, you can add a cluster page menu item to the `ExampleExtension` definition:
```typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { ExampleIcon, ExamplePage } from "./page" import { ExampleIcon, ExamplePage } from "./page"
import React from "react" import React from "react"
export default class ExampleExtension extends LensRendererExtension { export default class ExampleExtension extends Renderer.LensExtension {
clusterPages = [ clusterPages = [
{ {
id: "hello", id: "hello",
@ -157,14 +157,20 @@ When users click **Hello World**, the cluster dashboard will show the contents o
This example requires the definition of another React-based component, `ExampleIcon`, which has been added to `page.tsx`, as follows: This example requires the definition of another React-based component, `ExampleIcon`, which has been added to `page.tsx`, as follows:
```typescript ```typescript
import { LensRendererExtension, Component } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import React from "react" import React from "react"
export function ExampleIcon(props: Component.IconProps) { type IconProps = Renderer.Component.IconProps;
return <Component.Icon {...props} material="pages" tooltip={"Hi!"}/>
const {
Component: { Icon },
} = Renderer;
export function ExampleIcon(props: IconProps) {
return <Icon {...props} material="pages" tooltip={"Hi!"}/>
} }
export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> { export class ExamplePage extends React.Component<{ extension: Renderer.LensExtension }> {
render() { render() {
return ( return (
<div> <div>
@ -176,8 +182,8 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens
``` ```
Lens includes various built-in components available for extension developers to use. Lens includes various built-in components available for extension developers to use.
One of these is the `Component.Icon`, introduced in `ExampleIcon`, which you can use to access any of the [icons](https://material.io/resources/icons/) available at [Material Design](https://material.io). One of these is the `Renderer.Component.Icon`, introduced in `ExampleIcon`, which you can use to access any of the [icons](https://material.io/resources/icons/) available at [Material Design](https://material.io).
The properties that `Component.Icon` uses are defined as follows: The properties that `Renderer.Component.Icon` uses are defined as follows:
* `material` takes the name of the icon you want to use. * `material` takes the name of the icon you want to use.
* `tooltip` sets the text you want to appear when a user hovers over the icon. * `tooltip` sets the text you want to appear when a user hovers over the icon.
@ -187,11 +193,11 @@ The following example groups two sub menu items under one parent menu item:
```typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { ExampleIcon, ExamplePage } from "./page" import { ExampleIcon, ExamplePage } from "./page"
import React from "react" import React from "react"
export default class ExampleExtension extends LensRendererExtension { export default class ExampleExtension extends Renderer.LensExtension {
clusterPages = [ clusterPages = [
{ {
id: "hello", id: "hello",
@ -261,11 +267,11 @@ Unlike cluster pages, users can trigger global pages even when there is no activ
The following example defines a `LensRendererExtension` subclass with a single global page definition: The following example defines a `LensRendererExtension` subclass with a single global page definition:
```typescript ```typescript
import { LensRendererExtension } from '@k8slens/extensions'; import { Renderer } from '@k8slens/extensions';
import { HelpPage } from './page'; import { HelpPage } from './page';
import React from 'react'; import React from 'react';
export default class HelpExtension extends LensRendererExtension { export default class HelpExtension extends Renderer.LensExtension {
globalPages = [ globalPages = [
{ {
id: "help", id: "help",
@ -288,7 +294,7 @@ It offers flexibility in defining the appearance and behavior of your page.
`HelpPage` in the example above can be defined in `page.tsx`: `HelpPage` in the example above can be defined in `page.tsx`:
```typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import React from "react" import React from "react"
export class HelpPage extends React.Component<{ extension: LensRendererExtension }> { export class HelpPage extends React.Component<{ extension: LensRendererExtension }> {
@ -320,11 +326,11 @@ Global pages can be made available in the following ways:
By expanding on the above example, you can add a global page menu item to the `HelpExtension` definition: By expanding on the above example, you can add a global page menu item to the `HelpExtension` definition:
```typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { HelpIcon, HelpPage } from "./page" import { HelpIcon, HelpPage } from "./page"
import React from "react" import React from "react"
export default class HelpExtension extends LensRendererExtension { export default class HelpExtension extends Renderer.LensExtension {
globalPages = [ globalPages = [
{ {
id: "help", id: "help",
@ -362,14 +368,20 @@ This example requires the definition of another React-based component, `HelpIcon
Update `page.tsx` from the example above with the `HelpIcon` definition, as follows: Update `page.tsx` from the example above with the `HelpIcon` definition, as follows:
```typescript ```typescript
import { LensRendererExtension, Component } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import React from "react" import React from "react"
export function HelpIcon(props: Component.IconProps) { type IconProps = Renderer.Component.IconProps;
return <Component.Icon {...props} material="help"/>
const {
Component: { Icon },
} = Renderer;
export function HelpIcon(props: IconProps) {
return <Icon {...props} material="help"/>
} }
export class HelpPage extends React.Component<{ extension: LensRendererExtension }> { export class HelpPage extends React.Component<{ extension: Renderer.LensExtension }> {
render() { render() {
return ( return (
<div> <div>
@ -381,8 +393,8 @@ export class HelpPage extends React.Component<{ extension: LensRendererExtension
``` ```
Lens includes various built-in components available for extension developers to use. Lens includes various built-in components available for extension developers to use.
One of these is the `Component.Icon`, introduced in `HelpIcon`, which you can use to access any of the [icons](https://material.io/resources/icons/) available at [Material Design](https://material.io). One of these is the `Renderer.Component.Icon`, introduced in `HelpIcon`, which you can use to access any of the [icons](https://material.io/resources/icons/) available at [Material Design](https://material.io).
The property that `Component.Icon` uses is defined as follows: The property that `Renderer.Component.Icon` uses is defined as follows:
* `material` takes the name of the icon you want to use. * `material` takes the name of the icon you want to use.
@ -401,11 +413,11 @@ They can be installed and uninstalled by the Lens user from the cluster **Settin
The following example shows how to add a cluster feature as part of a `LensRendererExtension`: The following example shows how to add a cluster feature as part of a `LensRendererExtension`:
```typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions" import { Renderer } from "@k8slens/extensions"
import { ExampleFeature } from "./src/example-feature" import { ExampleFeature } from "./src/example-feature"
import React from "react" import React from "react"
export default class ExampleFeatureExtension extends LensRendererExtension { export default class ExampleFeatureExtension extends Renderer.LensExtension {
clusterFeatures = [ clusterFeatures = [
{ {
title: "Example Feature", title: "Example Feature",
@ -462,45 +474,69 @@ Consider using the following properties with `updateStatus()`:
The following shows a very simple implementation of a `ClusterFeature`: The following shows a very simple implementation of a `ClusterFeature`:
```typescript ```typescript
import { ClusterFeature, Store, K8sApi } from "@k8slens/extensions"; import { Renderer, Common } from "@k8slens/extensions";
import * as path from "path"; import * as path from "path";
export class ExampleFeature extends ClusterFeature.Feature { const {
K8sApi: {
ResourceStack,
forCluster,
StorageClass,
Namespace,
}
} = Renderer;
async install(cluster: Store.Cluster): Promise<void> { type ResourceStack = Renderer.K8sApi.ResourceStack;
type Pod = Renderer.K8sApi.Pod;
type KubernetesCluster = Common.Catalog.KubernetesCluster;
super.applyResources(cluster, path.join(__dirname, "../resources/")); export interface MetricsStatus {
installed: boolean;
canUpgrade: boolean;
}
export class ExampleFeature {
protected stack: ResourceStack;
constructor(protected cluster: KubernetesCluster) {
this.stack = new ResourceStack(cluster, this.name);
} }
async upgrade(cluster: Store.Cluster): Promise<void> { install(): Promise<string> {
return this.install(cluster); return this.stack.kubectlApplyFolder(path.join(__dirname, "../resources/"));
} }
async updateStatus(cluster: Store.Cluster): Promise<ClusterFeature.FeatureStatus> { upgrade(): Promise<string> {
return this.install(config);
}
async getStatus(): Promise<MetricsStatus> {
const status: MetricsStatus = { installed: false, canUpgrade: false};
try { try {
const pod = K8sApi.forCluster(cluster, K8sApi.Pod); const pod = forCluster(cluster, Pod);
const examplePod = await pod.get({name: "example-pod", namespace: "default"}); const examplePod = await pod.get({name: "example-pod", namespace: "default"});
if (examplePod?.kind) { if (examplePod?.kind) {
this.status.installed = true; status.installed = true;
this.status.currentVersion = examplePod.spec.containers[0].image.split(":")[1]; status.currentVersion = examplePod.spec.containers[0].image.split(":")[1];
this.status.canUpgrade = true; // a real implementation would perform a check here that is relevant to the specific feature status.canUpgrade = true; // a real implementation would perform a check here that is relevant to the specific feature
} else { } else {
this.status.installed = false; status.installed = false;
this.status.canUpgrade = false; status.canUpgrade = false;
} }
} catch(e) { } catch(e) {
if (e?.error?.code === 404) { if (e?.error?.code === 404) {
this.status.installed = false; status.installed = false;
this.status.canUpgrade = false; status.canUpgrade = false;
} }
} }
return this.status; return status;
} }
async uninstall(cluster: Store.Cluster): Promise<void> { async uninstall(): Promise<string> {
const podApi = K8sApi.forCluster(cluster, K8sApi.Pod); return this.stack.kubectlDeleteFolder(this.resourceFolder);
await podApi.delete({name: "example-pod", namespace: "default"});
} }
} }
``` ```
@ -539,12 +575,12 @@ You can use Lens extensions to add custom preferences to the Preferences page, p
The following example demonstrates adding a custom preference: The following example demonstrates adding a custom preference:
```typescript ```typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference"; import { ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference";
import { observable } from "mobx"; import { observable } from "mobx";
import React from "react"; import React from "react";
export default class ExampleRendererExtension extends LensRendererExtension { export default class ExampleRendererExtension extends Renderer.LensExtension {
@observable preference = { enabled: false }; @observable preference = { enabled: false };
@ -578,10 +614,16 @@ This is how `ExampleRendererExtension` handles the state of the preference input
In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreferenceProps` are defined in `./src/example-preference.tsx` as follows: In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreferenceProps` are defined in `./src/example-preference.tsx` as follows:
```typescript ```typescript
import { Component } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import React from "react"; import React from "react";
const {
Component: {
Checkbox,
},
} = Renderer;
export class ExamplePreferenceProps { export class ExamplePreferenceProps {
preference: { preference: {
enabled: boolean; enabled: boolean;
@ -594,7 +636,7 @@ export class ExamplePreferenceInput extends React.Component<ExamplePreferencePro
render() { render() {
const { preference } = this.props; const { preference } = this.props;
return ( return (
<Component.Checkbox <Checkbox
label="I understand appPreferences" label="I understand appPreferences"
value={preference.enabled} value={preference.enabled}
onChange={v => { preference.enabled = v; }} onChange={v => { preference.enabled = v; }}
@ -612,7 +654,7 @@ export class ExamplePreferenceHint extends React.Component {
} }
``` ```
`ExamplePreferenceInput` implements a simple checkbox using Lens's `Component.Checkbox` using the following properties: `ExamplePreferenceInput` implements a simple checkbox using Lens's `Renderer.Component.Checkbox` using the following properties:
* `label` sets the text that displays next to the checkbox. * `label` sets the text that displays next to the checkbox.
* `value` is initially set to `preference.enabled`. * `value` is initially set to `preference.enabled`.
@ -645,11 +687,11 @@ The following example adds a `statusBarItems` definition and a `globalPages` def
It configures the status bar item to navigate to the global page upon activation (normally a mouse click): It configures the status bar item to navigate to the global page upon activation (normally a mouse click):
```typescript ```typescript
import { LensRendererExtension } from '@k8slens/extensions'; import { Renderer } from '@k8slens/extensions';
import { HelpIcon, HelpPage } from "./page" import { HelpIcon, HelpPage } from "./page"
import React from 'react'; import React from 'react';
export default class HelpExtension extends LensRendererExtension { export default class HelpExtension extends Renderer.LensExtension {
globalPages = [ globalPages = [
{ {
id: "help", id: "help",
@ -703,16 +745,19 @@ The following example shows how to add a `kubeObjectMenuItems` for namespace res
```typescript ```typescript
import React from "react" import React from "react"
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { NamespaceMenuItem } from "./src/namespace-menu-item" import { NamespaceMenuItem } from "./src/namespace-menu-item"
export default class ExampleExtension extends LensRendererExtension { type KubeObjectMenuProps = Renderer.Component.KubeObjectMenuProps;
type Namespace = Renderer.K8sApi.Namespace;
export default class ExampleExtension extends Renderer.LensExtension {
kubeObjectMenuItems = [ kubeObjectMenuItems = [
{ {
kind: "Namespace", kind: "Namespace",
apiVersions: ["v1"], apiVersions: ["v1"],
components: { components: {
MenuItem: (props: Component.KubeObjectMenuProps<K8sApi.Namespace>) => <NamespaceMenuItem {...props} /> MenuItem: (props: KubeObjectMenuProps<Namespace>) => <NamespaceMenuItem {...props} />
} }
} }
]; ];
@ -734,16 +779,28 @@ In this example a `NamespaceMenuItem` object is returned.
```typescript ```typescript
import React from "react"; import React from "react";
import { Component, K8sApi, Navigation} from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
export function NamespaceMenuItem(props: Component.KubeObjectMenuProps<K8sApi.Namespace>) { const {
Component: {
terminalStore,
MenuItem,
Icon,
},
Navigation,
} = Renderer;
type KubeObjectMenuProps = Renderer.Component.KubeObjectMenuProps;
type Namespace = Renderer.K8sApi.Namespace;
export function NamespaceMenuItem(props: KubeObjectMenuProps<Namespace>) {
const { object: namespace, toolbar } = props; const { object: namespace, toolbar } = props;
if (!namespace) return null; if (!namespace) return null;
const namespaceName = namespace.getName(); const namespaceName = namespace.getName();
const sendToTerminal = (command: string) => { const sendToTerminal = (command: string) => {
Component.terminalStore.sendCommand(command, { terminalStore.sendCommand(command, {
enter: true, enter: true,
newTab: true, newTab: true,
}); });
@ -755,21 +812,21 @@ export function NamespaceMenuItem(props: Component.KubeObjectMenuProps<K8sApi.Na
}; };
return ( return (
<Component.MenuItem onClick={getPods}> <MenuItem onClick={getPods}>
<Component.Icon material="speaker_group" interactive={toolbar} title="Get pods in terminal"/> <Icon material="speaker_group" interactive={toolbar} title="Get pods in terminal"/>
<span className="title">Get Pods</span> <span className="title">Get Pods</span>
</Component.MenuItem> </MenuItem>
); );
} }
``` ```
`NamespaceMenuItem` returns a `Component.MenuItem` which defines the menu item's appearance and its behavior when activated via the `onClick` property. `NamespaceMenuItem` returns a `Renderer.Component.MenuItem` which defines the menu item's appearance and its behavior when activated via the `onClick` property.
In the example, `getPods()` opens a terminal tab and runs `kubectl` to get a list of pods running in the current namespace. In the example, `getPods()` opens a terminal tab and runs `kubectl` to get a list of pods running in the current namespace.
The name of the namespace is retrieved from `props` passed into `NamespaceMenuItem()`. The name of the namespace is retrieved from `props` passed into `NamespaceMenuItem()`.
`namespace` is the `props.object`, which is of type `K8sApi.Namespace`. `namespace` is the `props.object`, which is of type `Renderer.K8sApi.Namespace`.
`K8sApi.Namespace` is the API for accessing namespaces. `Renderer.K8sApi.Namespace` is the API for accessing namespaces.
The current namespace in this example is simply given by `namespace.getName()`. The current namespace in this example is simply given by `namespace.getName()`.
Thus, `kubeObjectMenuItems` afford convenient access to the specific resource selected by the user. Thus, `kubeObjectMenuItems` afford convenient access to the specific resource selected by the user.
@ -783,18 +840,22 @@ These custom details appear on the details page for a specific resource, such as
The following example shows how to use `kubeObjectDetailItems` to add a tabulated list of pods to the Namespace resource details page: The following example shows how to use `kubeObjectDetailItems` to add a tabulated list of pods to the Namespace resource details page:
```typescript ```typescript
import React from "react" import React from "react";
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { NamespaceDetailsItem } from "./src/namespace-details-item" import { NamespaceDetailsItem } from "./src/namespace-details-item";
export default class ExampleExtension extends LensRendererExtension { type KubeObjectMenuProps = Renderer.Component.KubeObjectMenuProps;
type KubeObjectDetailsProps = Renderer.Component.KubeObjectDetailsProps;
type Namespace = Renderer.K8sApi.Namespace;
export default class ExampleExtension extends Renderer.LensExtension {
kubeObjectDetailItems = [ kubeObjectDetailItems = [
{ {
kind: "Namespace", kind: "Namespace",
apiVersions: ["v1"], apiVersions: ["v1"],
priority: 10, priority: 10,
components: { components: {
Details: (props: Component.KubeObjectDetailsProps<K8sApi.Namespace>) => <NamespaceDetailsItem {...props} /> Details: (props: KubeObjectDetailsProps<Namespace>) => <NamespaceDetailsItem {...props} />
} }
} }
]; ];
@ -814,25 +875,39 @@ In this example a `NamespaceDetailsItem` object is returned.
`NamespaceDetailsItem` is defined in `./src/namespace-details-item.tsx`: `NamespaceDetailsItem` is defined in `./src/namespace-details-item.tsx`:
``` typescript ``` typescript
import { Component, K8sApi } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { PodsDetailsList } from "./pods-details-list"; import { PodsDetailsList } from "./pods-details-list";
import React from "react"; import React from "react";
import { observable } from "mobx"; import { observable } from "mobx";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
@observer const {
export class NamespaceDetailsItem extends React.Component<Component.KubeObjectDetailsProps<K8sApi.Namespace>> { K8sApi: {
podsApi,
},
Component: {
DrawerTitle,
},
} = Renderer;
@observable private pods: K8sApi.Pod[]; type KubeObjectMenuProps = Renderer.Component.KubeObjectMenuProps;
type Namespace = Renderer.K8sApi.Namespace;
type Pod = Renderer.K8sApi.Pod;
@observer
export class NamespaceDetailsItem extends React.Component<KubeObjectDetailsProps<Namespace>> {
@observable private pods: Pod[];
async componentDidMount() { async componentDidMount() {
this.pods = await K8sApi.podsApi.list({namespace: this.props.object.getName()}); const namespace = this.props.object.getName();
this.pods = await podsApi.list({ namespace });
} }
render() { render() {
return ( return (
<div> <div>
<Component.DrawerTitle title="Pods" /> <DrawerTitle title="Pods" />
<PodsDetailsList pods={this.pods}/> <PodsDetailsList pods={this.pods}/>
</div> </div>
) )
@ -840,14 +915,14 @@ export class NamespaceDetailsItem extends React.Component<Component.KubeObjectDe
} }
``` ```
Since `NamespaceDetailsItem` extends `React.Component<Component.KubeObjectDetailsProps<K8sApi.Namespace>>`, it can access the current namespace object (type `K8sApi.Namespace`) through `this.props.object`. Since `NamespaceDetailsItem` extends `React.Component<KubeObjectDetailsProps<Namespace>>`, it can access the current namespace object (type `Namespace`) through `this.props.object`.
You can query this object for many details about the current namespace. You can query this object for many details about the current namespace.
In the example above, `componentDidMount()` gets the namespace's name using the `K8sApi.Namespace` `getName()` method. In the example above, `componentDidMount()` gets the namespace's name using the `Namespace` `getName()` method.
Use the namespace's name to limit the list of pods only to those in the relevant namespace. Use the namespace's name to limit the list of pods only to those in the relevant namespace.
To get this list of pods, this example uses the Kubernetes pods API `K8sApi.podsApi.list()` method. To get this list of pods, this example uses the Kubernetes pods API `podsApi.list()` method.
The `K8sApi.podsApi` is automatically configured for the active cluster. The `podsApi` is automatically configured for the active cluster.
Note that `K8sApi.podsApi.list()` is an asynchronous method. Note that `podsApi.list()` is an asynchronous method.
Getting the pods list should occur prior to rendering the `NamespaceDetailsItem`. Getting the pods list should occur prior to rendering the `NamespaceDetailsItem`.
It is a common technique in React development to await async calls in `componentDidMount()`. It is a common technique in React development to await async calls in `componentDidMount()`.
However, `componentDidMount()` is called right after the first call to `render()`. However, `componentDidMount()` is called right after the first call to `render()`.
@ -856,51 +931,59 @@ Like in the [`appPreferences` guide](#apppreferences), [`mobx`](https://mobx.js.
This is done simply by marking the `pods` field as an `observable` and the `NamespaceDetailsItem` class itself as an `observer`. This is done simply by marking the `pods` field as an `observable` and the `NamespaceDetailsItem` class itself as an `observer`.
Finally, the `NamespaceDetailsItem` renders using the `render()` method. Finally, the `NamespaceDetailsItem` renders using the `render()` method.
Details are placed in drawers, and using `Component.DrawerTitle` provides a separator from details above this one. Details are placed in drawers, and using `Renderer.Component.DrawerTitle` provides a separator from details above this one.
Multiple details in a drawer can be placed in `<Component.DrawerItem>` elements for further separation, if desired. Multiple details in a drawer can be placed in `<Renderer.Component.DrawerItem>` elements for further separation, if desired.
The rest of this example's details are defined in `PodsDetailsList`, found in `./pods-details-list.tsx`: The rest of this example's details are defined in `PodsDetailsList`, found in `./pods-details-list.tsx`:
``` typescript ``` typescript
import React from "react"; import React from "react";
import { Component, K8sApi } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
const {
Component: {
TableHead,
TableRow,
TableCell,
Table,
},
} = Renderer;
type Pod = Renderer.K8sApi.Pod;
interface Props { interface Props {
pods: K8sApi.Pod[]; pods?: Pod[];
} }
export class PodsDetailsList extends React.Component<Props> { export class PodsDetailsList extends React.Component<Props> {
getTableRow = (pod: Pod) => {
getTableRow(index: number) { return (
const {pods} = this.props; <TableRow key={index} nowrap>
return ( <TableCell className="podName">{pods[index].getName()}</TableCell>
<Component.TableRow key={index} nowrap> <TableCell className="podAge">{pods[index].getAge()}</TableCell>
<Component.TableCell className="podName">{pods[index].getName()}</Component.TableCell> <TableCell className="podStatus">{pods[index].getStatus()}</TableCell>
<Component.TableCell className="podAge">{pods[index].getAge()}</Component.TableCell> </TableRow>
<Component.TableCell className="podStatus">{pods[index].getStatus()}</Component.TableCell> )
</Component.TableRow> };
)
}
render() { render() {
const {pods} = this.props const { pods } = this.props
if (!pods?.length) {
return null; if (!pods?.length) {
} return null;
}
return ( return (
<div > <div>
<Component.Table> <Table>
<Component.TableHead> <TableHead>
<Component.TableCell className="podName">Name</Component.TableCell> <TableCell className="podName">Name</TableCell>
<Component.TableCell className="podAge">Age</Component.TableCell> <TableCell className="podAge">Age</TableCell>
<Component.TableCell className="podStatus">Status</Component.TableCell> <TableCell className="podStatus">Status</TableCell>
</Component.TableHead> </TableHead>
{ { pods.map(this.getTableRow) }
pods.map((pod, index) => this.getTableRow(index)) </Table>
} </div>
</Component.Table> );
</div>
)
} }
} }
``` ```
@ -909,9 +992,9 @@ export class PodsDetailsList extends React.Component<Props> {
![DetailsWithPods](images/kubeobjectdetailitemwithpods.png) ![DetailsWithPods](images/kubeobjectdetailitemwithpods.png)
Obtain the name, age, and status for each pod using the `K8sApi.Pod` methods. Obtain the name, age, and status for each pod using the `Renderer.K8sApi.Pod` methods.
Construct the table using the `Component.Table` and related elements. Construct the table using the `Renderer.Component.Table` and related elements.
For each pod the name, age, and status are obtained using the `K8sApi.Pod` methods. For each pod the name, age, and status are obtained using the `Renderer.K8sApi.Pod` methods.
The table is constructed using the `Component.Table` and related elements. The table is constructed using the `Renderer.Component.Table` and related elements.
See [Component documentation](https://docs.k8slens.dev/latest/extensions/api/modules/_renderer_api_components_/) for further details. See [Component documentation](https://docs.k8slens.dev/latest/extensions/api/modules/_renderer_api_components_/) for further details.

View File

@ -24,14 +24,14 @@ This is so that your data is kept up to date and not persisted longer than expec
The following example code creates a store for the `appPreferences` guide example: The following example code creates a store for the `appPreferences` guide example:
``` typescript ``` typescript
import { Store } from "@k8slens/extensions"; import { Common } from "@k8slens/extensions";
import { observable, makeObservable } from "mobx"; import { observable, makeObservable } from "mobx";
export type ExamplePreferencesModel = { export type ExamplePreferencesModel = {
enabled: boolean; enabled: boolean;
}; };
export class ExamplePreferencesStore extends Store.ExtensionStore<ExamplePreferencesModel> { export class ExamplePreferencesStore extends Common.Store.ExtensionStore<ExamplePreferencesModel> {
@observable enabled = false; @observable enabled = false;
@ -87,10 +87,10 @@ The following example code, modified from the [`appPreferences`](../renderer-ext
This can be done in `./main.ts`: This can be done in `./main.ts`:
``` typescript ``` typescript
import { LensMainExtension } from "@k8slens/extensions"; import { Main } from "@k8slens/extensions";
import { ExamplePreferencesStore } from "./src/example-preference-store"; import { ExamplePreferencesStore } from "./src/example-preference-store";
export default class ExampleMainExtension extends LensMainExtension { export default class ExampleMainExtension extends Main.LensExtension {
async onActivate() { async onActivate() {
await ExamplePreferencesStore.getInstanceOrCreate().loadExtension(this); await ExamplePreferencesStore.getInstanceOrCreate().loadExtension(this);
} }
@ -102,12 +102,12 @@ Similarly, `ExamplePreferencesStore` must load in the renderer process where the
This can be done in `./renderer.ts`: This can be done in `./renderer.ts`:
``` typescript ``` typescript
import { LensRendererExtension } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference"; import { ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference";
import { ExamplePreferencesStore } from "./src/example-preference-store"; import { ExamplePreferencesStore } from "./src/example-preference-store";
import React from "react"; import React from "react";
export default class ExampleRendererExtension extends LensRendererExtension { export default class ExampleRendererExtension extends Renderer.LensExtension {
async onActivate() { async onActivate() {
await ExamplePreferencesStore.getInstanceOrCreate().loadExtension(this); await ExamplePreferencesStore.getInstanceOrCreate().loadExtension(this);
@ -130,17 +130,23 @@ Again, `ExamplePreferencesStore.getInstanceOrCreate().loadExtension(this)` is ca
`ExamplePreferenceInput` is defined in `./src/example-preference.tsx`: `ExamplePreferenceInput` is defined in `./src/example-preference.tsx`:
``` typescript ``` typescript
import { Component } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import React from "react"; import React from "react";
import { ExamplePreferencesStore } from "./example-preference-store"; import { ExamplePreferencesStore } from "./example-preference-store";
const {
Component: {
Checkbox,
},
} = Renderer;
@observer @observer
export class ExamplePreferenceInput extends React.Component { export class ExamplePreferenceInput extends React.Component {
render() { render() {
return ( return (
<Component.Checkbox <Checkbox
label="I understand appPreferences" label="I understand appPreferences"
value={ExamplePreferencesStore.getInstace().enabled} value={ExamplePreferencesStore.getInstace().enabled}
onChange={v => { ExamplePreferencesStore.getInstace().enabled = v; }} onChange={v => { ExamplePreferencesStore.getInstace().enabled = v; }}

View File

@ -14,7 +14,13 @@ My component `GlobalPageMenuIcon`
```typescript ```typescript
import React from "react" import React from "react"
import { Component: { Icon } } from "@k8slens/extensions"; import { Renderer } from "@k8slens/extensions";
const {
Component: {
Icon,
},
} = Renderer;
const GlobalPageMenuIcon = ({ navigate }: { navigate?: () => void }): JSX.Element => ( const GlobalPageMenuIcon = ({ navigate }: { navigate?: () => void }): JSX.Element => (
<Icon <Icon

View File

@ -1,6 +1,6 @@
{ {
"name": "kube-object-event-status", "name": "kube-object-event-status",
"version": "0.1.0", "version": "0.0.1",
"description": "Adds kube object status from events", "description": "Adds kube object status from events",
"renderer": "dist/renderer.js", "renderer": "dist/renderer.js",
"lens": { "lens": {

View File

@ -1,6 +1,6 @@
{ {
"name": "lens-metrics-cluster-feature", "name": "lens-metrics-cluster-feature",
"version": "0.1.0", "version": "0.0.1",
"description": "Lens metrics cluster feature", "description": "Lens metrics cluster feature",
"renderer": "dist/renderer.js", "renderer": "dist/renderer.js",
"lens": { "lens": {

View File

@ -1,6 +1,6 @@
{ {
"name": "lens-node-menu", "name": "lens-node-menu",
"version": "0.1.0", "version": "0.0.1",
"description": "Lens node menu", "description": "Lens node menu",
"renderer": "dist/renderer.js", "renderer": "dist/renderer.js",
"lens": { "lens": {

View File

@ -1,6 +1,6 @@
{ {
"name": "lens-pod-menu", "name": "lens-pod-menu",
"version": "0.1.0", "version": "0.0.1",
"description": "Lens pod menu", "description": "Lens pod menu",
"renderer": "dist/renderer.js", "renderer": "dist/renderer.js",
"lens": { "lens": {

View File

@ -64,6 +64,8 @@ export async function waitForMinikubeDashboard(app: Application) {
await app.client.setValue(".Input.SearchInput input", "minikube"); await app.client.setValue(".Input.SearchInput input", "minikube");
await app.client.waitUntilTextExists("div.TableCell", "minikube"); await app.client.waitUntilTextExists("div.TableCell", "minikube");
await app.client.click("div.TableRow"); await app.client.click("div.TableRow");
await app.client.waitUntilTextExists("div.drawer-title-text", "KubernetesCluster: minikube");
await app.client.click("div.EntityIcon div.HotbarIcon div div.MuiAvatar-root");
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`); await app.client.waitForExist(`iframe[name="minikube"]`);
await app.client.frame("minikube"); await app.client.frame("minikube");

View File

@ -2,6 +2,7 @@
"name": "open-lens", "name": "open-lens",
"productName": "OpenLens", "productName": "OpenLens",
"description": "OpenLens - Open Source IDE for Kubernetes", "description": "OpenLens - Open Source IDE for Kubernetes",
"homepage": "https://github.com/lensapp/lens",
"version": "5.0.0-beta.6", "version": "5.0.0-beta.6",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2021 OpenLens Authors", "copyright": "© 2021 OpenLens Authors",
@ -178,7 +179,7 @@
} }
}, },
"dependencies": { "dependencies": {
"@hapi/call": "^8.0.0", "@hapi/call": "^8.0.1",
"@hapi/subtext": "^7.0.3", "@hapi/subtext": "^7.0.3",
"@kubernetes/client-node": "^0.12.0", "@kubernetes/client-node": "^0.12.0",
"abort-controller": "^3.0.0", "abort-controller": "^3.0.0",
@ -267,7 +268,7 @@
"@types/jsdom": "^16.2.4", "@types/jsdom": "^16.2.4",
"@types/jsonpath": "^0.2.0", "@types/jsonpath": "^0.2.0",
"@types/lodash": "^4.14.155", "@types/lodash": "^4.14.155",
"@types/marked": "^0.7.4", "@types/marked": "^2.0.3",
"@types/md5-file": "^4.0.2", "@types/md5-file": "^4.0.2",
"@types/mini-css-extract-plugin": "^0.9.1", "@types/mini-css-extract-plugin": "^0.9.1",
"@types/mock-fs": "^4.10.0", "@types/mock-fs": "^4.10.0",
@ -281,7 +282,7 @@
"@types/react-beautiful-dnd": "^13.0.0", "@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "^17.0.0", "@types/react-dom": "^17.0.0",
"@types/react-router-dom": "^5.1.6", "@types/react-router-dom": "^5.1.6",
"@types/react-select": "^3.0.13", "@types/react-select": "3.1.2",
"@types/react-table": "^7.7.0", "@types/react-table": "^7.7.0",
"@types/react-window": "^1.8.2", "@types/react-window": "^1.8.2",
"@types/readable-stream": "^2.3.9", "@types/readable-stream": "^2.3.9",
@ -333,7 +334,7 @@
"jest-fetch-mock": "^3.0.3", "jest-fetch-mock": "^3.0.3",
"jest-mock-extended": "^1.0.10", "jest-mock-extended": "^1.0.10",
"make-plural": "^6.2.2", "make-plural": "^6.2.2",
"mini-css-extract-plugin": "^0.9.0", "mini-css-extract-plugin": "^1.6.0",
"node-loader": "^0.6.0", "node-loader": "^0.6.0",
"node-sass": "^4.14.1", "node-sass": "^4.14.1",
"nodemon": "^2.0.4", "nodemon": "^2.0.4",
@ -348,14 +349,14 @@
"react-beautiful-dnd": "^13.1.0", "react-beautiful-dnd": "^13.1.0",
"react-refresh": "^0.9.0", "react-refresh": "^0.9.0",
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"react-select": "^3.1.0", "react-select": "3.1.1",
"react-select-event": "^5.1.0", "react-select-event": "^5.1.0",
"react-table": "^7.7.0", "react-table": "^7.7.0",
"react-window": "^1.8.5", "react-window": "^1.8.5",
"sass-loader": "^8.0.2", "sass-loader": "^8.0.2",
"sharp": "^0.26.1", "sharp": "^0.26.1",
"spectron": "11.0.0", "spectron": "11.0.0",
"style-loader": "^1.2.1", "style-loader": "^2.0.0",
"tailwindcss": "^2.1.2", "tailwindcss": "^2.1.2",
"ts-jest": "26.3.0", "ts-jest": "26.3.0",
"ts-loader": "^7.0.5", "ts-loader": "^7.0.5",

View File

@ -104,6 +104,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
context.menuItems = [ context.menuItems = [
{ {
title: "Settings", title: "Settings",
icon: "edit",
onlyVisibleForSource: "local", onlyVisibleForSource: "local",
onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`) onClick: async () => context.navigate(`/entity/${this.metadata.uid}/settings`)
}, },
@ -112,6 +113,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) { if (this.metadata.labels["file"]?.startsWith(ClusterStore.storedKubeConfigFolder)) {
context.menuItems.push({ context.menuItems.push({
title: "Delete", title: "Delete",
icon: "delete",
onlyVisibleForSource: "local", onlyVisibleForSource: "local",
onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid), onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid),
confirm: { confirm: {
@ -123,6 +125,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
if (this.status.phase == "connected") { if (this.status.phase == "connected") {
context.menuItems.push({ context.menuItems.push({
title: "Disconnect", title: "Disconnect",
icon: "link_off",
onClick: async () => { onClick: async () => {
requestMain(clusterDisconnectHandler, this.metadata.uid); requestMain(clusterDisconnectHandler, this.metadata.uid);
} }
@ -130,6 +133,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
} else { } else {
context.menuItems.push({ context.menuItems.push({
title: "Connect", title: "Connect",
icon: "link",
onClick: async () => { onClick: async () => {
context.navigate(`/cluster/${this.metadata.uid}`); context.navigate(`/cluster/${this.metadata.uid}`);
} }
@ -147,7 +151,7 @@ export class KubernetesClusterCategory extends CatalogCategory {
public readonly kind = "CatalogCategory"; public readonly kind = "CatalogCategory";
public metadata = { public metadata = {
name: "Kubernetes Clusters", name: "Kubernetes Clusters",
icon: require(`!!raw-loader!./icons/kubernetes.svg`).default // eslint-disable-line icon: require(`!!raw-loader!./icons/kubernetes.svg`).default, // eslint-disable-line
}; };
public spec: CatalogCategorySpec = { public spec: CatalogCategorySpec = {
group: "entity.k8slens.dev", group: "entity.k8slens.dev",

View File

@ -96,9 +96,25 @@ export interface CatalogEntityActionContext {
} }
export interface CatalogEntityContextMenu { export interface CatalogEntityContextMenu {
/**
* Menu title
*/
title: string; title: string;
onlyVisibleForSource?: string; // show only if empty or if matches with entity source /**
* Menu icon
*/
icon?: string;
/**
* Show only if empty or if value matches with entity.metadata.source
*/
onlyVisibleForSource?: string;
/**
* OnClick handler
*/
onClick: () => void | Promise<void>; onClick: () => void | Promise<void>;
/**
* Confirm click with a message
*/
confirm?: { confirm?: {
message: string; message: string;
} }
@ -175,7 +191,6 @@ export abstract class CatalogEntity<
} }
public abstract onRun?(context: CatalogEntityActionContext): void | Promise<void>; public abstract onRun?(context: CatalogEntityActionContext): void | Promise<void>;
public abstract onDetailsOpen(context: CatalogEntityActionContext): void | Promise<void>;
public abstract onContextMenuOpen(context: CatalogEntityContextMenuContext): void | Promise<void>; public abstract onContextMenuOpen(context: CatalogEntityContextMenuContext): void | Promise<void>;
public abstract onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise<void>; public abstract onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise<void>;
} }

View File

@ -25,9 +25,12 @@ import { appEventBus } from "./event-bus";
import { ResourceApplier } from "../main/resource-applier"; import { ResourceApplier } from "../main/resource-applier";
import { ipcMain, IpcMainInvokeEvent } from "electron"; import { ipcMain, IpcMainInvokeEvent } from "electron";
import { clusterFrameMap } from "./cluster-frames"; import { clusterFrameMap } from "./cluster-frames";
import { catalogEntityRegistry } from "../main/catalog";
import type { KubernetesCluster } from "./catalog-entities";
export const clusterActivateHandler = "cluster:activate"; export const clusterActivateHandler = "cluster:activate";
export const clusterSetFrameIdHandler = "cluster:set-frame-id"; export const clusterSetFrameIdHandler = "cluster:set-frame-id";
export const clusterVisibilityHandler = "cluster:visibility";
export const clusterRefreshHandler = "cluster:refresh"; export const clusterRefreshHandler = "cluster:refresh";
export const clusterDisconnectHandler = "cluster:disconnect"; export const clusterDisconnectHandler = "cluster:disconnect";
export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all"; export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all";
@ -49,6 +52,18 @@ if (ipcMain) {
} }
}); });
handleRequest(clusterVisibilityHandler, (event: IpcMainInvokeEvent, clusterId: ClusterId, visible: boolean) => {
const entity = catalogEntityRegistry.getById<KubernetesCluster>(clusterId);
for (const kubeEntity of catalogEntityRegistry.getItemsForApiKind(entity.apiVersion, entity.kind)) {
kubeEntity.status.active = false;
}
if (entity) {
entity.status.active = visible;
}
});
handleRequest(clusterRefreshHandler, (event, clusterId: ClusterId) => { handleRequest(clusterRefreshHandler, (event, clusterId: ClusterId) => {
return ClusterStore.getInstance() return ClusterStore.getInstance()
.getById(clusterId) .getById(clusterId)

View File

@ -21,7 +21,7 @@
import { match, matchPath } from "react-router"; import { match, matchPath } from "react-router";
import { countBy } from "lodash"; import { countBy } from "lodash";
import { Singleton } from "../utils"; import { iter, Singleton } from "../utils";
import { pathToRegexp } from "path-to-regexp"; import { pathToRegexp } from "path-to-regexp";
import logger from "../../main/logger"; import logger from "../../main/logger";
import type Url from "url-parse"; import type Url from "url-parse";
@ -35,7 +35,8 @@ import type { RouteHandler, RouteParams } from "../../extensions/registries/prot
export const ProtocolHandlerIpcPrefix = "protocol-handler"; export const ProtocolHandlerIpcPrefix = "protocol-handler";
export const ProtocolHandlerInternal = `${ProtocolHandlerIpcPrefix}:internal`; export const ProtocolHandlerInternal = `${ProtocolHandlerIpcPrefix}:internal`;
export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`; export const ProtocolHandlerExtension = `${ProtocolHandlerIpcPrefix}:extension`;
export const ProtocolHandlerInvalid = `${ProtocolHandlerIpcPrefix}:invalid`;
/** /**
* These two names are long and cumbersome by design so as to decrease the chances * These two names are long and cumbersome by design so as to decrease the chances
@ -47,6 +48,34 @@ export const ProtocolHandlerExtension= `${ProtocolHandlerIpcPrefix}:extension`;
export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH"; export const EXTENSION_PUBLISHER_MATCH = "LENS_INTERNAL_EXTENSION_PUBLISHER_MATCH";
export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH"; export const EXTENSION_NAME_MATCH = "LENS_INTERNAL_EXTENSION_NAME_MATCH";
/**
* Returned from routing attempts
*/
export enum RouteAttempt {
/**
* A handler was found in the set of registered routes
*/
MATCHED = "matched",
/**
* A handler was not found within the set of registered routes
*/
MISSING = "missing",
/**
* The extension that was matched in the route was not activated
*/
MISSING_EXTENSION = "no-extension",
}
export function foldAttemptResults(mainAttempt: RouteAttempt, rendererAttempt: RouteAttempt): RouteAttempt {
switch (mainAttempt) {
case RouteAttempt.MATCHED:
return RouteAttempt.MATCHED;
case RouteAttempt.MISSING:
case RouteAttempt.MISSING_EXTENSION:
return rendererAttempt;
}
}
export abstract class LensProtocolRouter extends Singleton { export abstract class LensProtocolRouter extends Singleton {
// Map between path schemas and the handlers // Map between path schemas and the handlers
protected internalRoutes = new Map<string, RouteHandler>(); protected internalRoutes = new Map<string, RouteHandler>();
@ -56,11 +85,12 @@ export abstract class LensProtocolRouter extends Singleton {
static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`; static readonly ExtensionUrlSchema = `/:${EXTENSION_PUBLISHER_MATCH}(\@[A-Za-z0-9_]+)?/:${EXTENSION_NAME_MATCH}`;
/** /**
* * Attempts to route the given URL to all internal routes that have been registered
* @param url the parsed URL that initiated the `lens://` protocol * @param url the parsed URL that initiated the `lens://` protocol
* @returns true if a route has been found
*/ */
protected _routeToInternal(url: Url): void { protected _routeToInternal(url: Url): RouteAttempt {
this._route(Array.from(this.internalRoutes.entries()), url); return this._route(this.internalRoutes.entries(), url);
} }
/** /**
@ -69,7 +99,7 @@ export abstract class LensProtocolRouter extends Singleton {
* @param routes the array of path schemas, handler pairs to match against * @param routes the array of path schemas, handler pairs to match against
* @param url the url (in its current state) * @param url the url (in its current state)
*/ */
protected _findMatchingRoute(routes: [string, RouteHandler][], url: Url): null | [match<Record<string, string>>, RouteHandler] { protected _findMatchingRoute(routes: Iterable<[string, RouteHandler]>, url: Url): null | [match<Record<string, string>>, RouteHandler] {
const matches: [match<Record<string, string>>, RouteHandler][] = []; const matches: [match<Record<string, string>>, RouteHandler][] = [];
for (const [schema, handler] of routes) { for (const [schema, handler] of routes) {
@ -96,7 +126,7 @@ export abstract class LensProtocolRouter extends Singleton {
* @param routes the array of (path schemas, handler) pairs to match against * @param routes the array of (path schemas, handler) pairs to match against
* @param url the url (in its current state) * @param url the url (in its current state)
*/ */
protected _route(routes: [string, RouteHandler][], url: Url, extensionName?: string): void { protected _route(routes: Iterable<[string, RouteHandler]>, url: Url, extensionName?: string): RouteAttempt {
const route = this._findMatchingRoute(routes, url); const route = this._findMatchingRoute(routes, url);
if (!route) { if (!route) {
@ -106,7 +136,9 @@ export abstract class LensProtocolRouter extends Singleton {
data.extensionName = extensionName; data.extensionName = extensionName;
} }
return void logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data); logger.info(`${LensProtocolRouter.LoggingPrefix}: No handler found`, data);
return RouteAttempt.MISSING;
} }
const [match, handler] = route; const [match, handler] = route;
@ -121,6 +153,8 @@ export abstract class LensProtocolRouter extends Singleton {
} }
handler(params); handler(params);
return RouteAttempt.MATCHED;
} }
/** /**
@ -174,23 +208,22 @@ export abstract class LensProtocolRouter extends Singleton {
* Note: this function modifies its argument, do not reuse * Note: this function modifies its argument, do not reuse
* @param url the protocol request URI that was "open"-ed * @param url the protocol request URI that was "open"-ed
*/ */
protected async _routeToExtension(url: Url): Promise<void> { protected async _routeToExtension(url: Url): Promise<RouteAttempt> {
const extension = await this._findMatchingExtensionByName(url); const extension = await this._findMatchingExtensionByName(url);
if (typeof extension === "string") { if (typeof extension === "string") {
// failed to find an extension, it returned its name // failed to find an extension, it returned its name
return; return RouteAttempt.MISSING_EXTENSION;
} }
// remove the extension name from the path name so we don't need to match on it anymore // remove the extension name from the path name so we don't need to match on it anymore
url.set("pathname", url.pathname.slice(extension.name.length + 1)); url.set("pathname", url.pathname.slice(extension.name.length + 1));
const handlers = extension
.protocolHandlers
.map<[string, RouteHandler]>(({ pathSchema, handler }) => [pathSchema, handler]);
try { try {
this._route(handlers, url, extension.name); const handlers = iter.map(extension.protocolHandlers, ({ pathSchema, handler }) => [pathSchema, handler] as [string, RouteHandler]);
return this._route(handlers, url, extension.name);
} catch (error) { } catch (error) {
if (error instanceof RoutingError) { if (error instanceof RoutingError) {
error.extensionName = extension.name; error.extensionName = extension.name;

View File

@ -41,3 +41,11 @@ export function buildURL<P extends object = {}, Q extends object = {}>(path: str
return parts.filter(Boolean).join(""); return parts.filter(Boolean).join("");
}; };
} }
export function buildURLPositional<P extends object = {}, Q extends object = {}>(path: string | any) {
const builder = buildURL(path);
return function(params?: P, query?: Q, fragment?: string): string {
return builder({ params, query, fragment });
};
}

View File

@ -37,7 +37,7 @@ export const isPublishConfigured = Object.keys(packageInfo.build).includes("publ
export const productName = packageInfo.productName; export const productName = packageInfo.productName;
export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`; export const appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`;
export const publicPath = "/build/"; export const publicPath = "/build/" as string;
// Webpack build paths // Webpack build paths
export const contextDir = process.cwd(); export const contextDir = process.cwd();
@ -60,13 +60,13 @@ defineGlobal("__static", {
}); });
// Apis // Apis
export const apiPrefix = "/api"; // local router apis export const apiPrefix = "/api" as string; // local router apis
export const apiKubePrefix = "/api-kube"; // k8s cluster apis export const apiKubePrefix = "/api-kube" as string; // k8s cluster apis
// Links // Links
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues"; export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as string;
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI"; export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI" as string;
export const supportUrl = "https://docs.k8slens.dev/latest/support/"; export const supportUrl = "https://docs.k8slens.dev/latest/support/" as string;
// This explicitly ignores the prerelease info on the package version // This explicitly ignores the prerelease info on the package version
export const appSemVer = new SemVer(packageInfo.version); export const appSemVer = new SemVer(packageInfo.version);

View File

@ -250,6 +250,7 @@ export class ExtensionLoader extends Singleton {
registries.statusBarRegistry.add(extension.statusBarItems), registries.statusBarRegistry.add(extension.statusBarItems),
registries.commandRegistry.add(extension.commands), registries.commandRegistry.add(extension.commands),
registries.welcomeMenuRegistry.add(extension.welcomeMenus), registries.welcomeMenuRegistry.add(extension.welcomeMenus),
registries.catalogEntityDetailRegistry.add(extension.catalogEntityDetailItems),
]; ];
this.events.on("remove", (removedExtension: LensRendererExtension) => { this.events.on("remove", (removedExtension: LensRendererExtension) => {
@ -279,6 +280,7 @@ export class ExtensionLoader extends Singleton {
registries.kubeObjectMenuRegistry.add(extension.kubeObjectMenuItems), registries.kubeObjectMenuRegistry.add(extension.kubeObjectMenuItems),
registries.kubeObjectDetailRegistry.add(extension.kubeObjectDetailItems), registries.kubeObjectDetailRegistry.add(extension.kubeObjectDetailItems),
registries.kubeObjectStatusRegistry.add(extension.kubeObjectStatusTexts), registries.kubeObjectStatusRegistry.add(extension.kubeObjectStatusTexts),
registries.workloadsOverviewDetailRegistry.add(extension.kubeWorkloadsOverviewItems),
registries.commandRegistry.add(extension.commands), registries.commandRegistry.add(extension.commands),
]; ];

View File

@ -20,8 +20,8 @@
*/ */
import type { import type {
AppPreferenceRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration, AppPreferenceRegistration, CatalogEntityDetailRegistration, ClusterPageMenuRegistration, KubeObjectDetailRegistration, KubeObjectMenuRegistration,
KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, WelcomeMenuRegistration, KubeObjectStatusRegistration, PageMenuRegistration, PageRegistration, StatusBarRegistration, WelcomeMenuRegistration, WorkloadsOverviewDetailRegistration,
} from "./registries"; } from "./registries";
import type { Cluster } from "../main/cluster"; import type { Cluster } from "../main/cluster";
import { LensExtension } from "./lens-extension"; import { LensExtension } from "./lens-extension";
@ -40,8 +40,10 @@ export class LensRendererExtension extends LensExtension {
statusBarItems: StatusBarRegistration[] = []; statusBarItems: StatusBarRegistration[] = [];
kubeObjectDetailItems: KubeObjectDetailRegistration[] = []; kubeObjectDetailItems: KubeObjectDetailRegistration[] = [];
kubeObjectMenuItems: KubeObjectMenuRegistration[] = []; kubeObjectMenuItems: KubeObjectMenuRegistration[] = [];
kubeWorkloadsOverviewItems: WorkloadsOverviewDetailRegistration[] = [];
commands: CommandRegistration[] = []; commands: CommandRegistration[] = [];
welcomeMenus: WelcomeMenuRegistration[] = []; welcomeMenus: WelcomeMenuRegistration[] = [];
catalogEntityDetailItems: CatalogEntityDetailRegistration[] = [];
async navigate<P extends object>(pageId?: string, params?: P) { async navigate<P extends object>(pageId?: string, params?: P) {
const { navigate } = await import("../renderer/navigation"); const { navigate } = await import("../renderer/navigation");

View File

@ -20,11 +20,13 @@
*/ */
import * as Catalog from "./catalog"; import * as Catalog from "./catalog";
import * as Navigation from "./navigation";
import { IpcMain as Ipc } from "../ipc/ipc-main"; import { IpcMain as Ipc } from "../ipc/ipc-main";
import { LensMainExtension as LensExtension } from "../lens-main-extension"; import { LensMainExtension as LensExtension } from "../lens-main-extension";
export { export {
Catalog, Catalog,
Navigation,
Ipc, Ipc,
LensExtension, LensExtension,
}; };

View File

@ -0,0 +1,26 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { WindowManager } from "../../main/window-manager";
export function navigate(url: string) {
return WindowManager.getInstance().navigate(url);
}

View File

@ -0,0 +1,46 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type React from "react";
import { BaseRegistry } from "./base-registry";
export interface CatalogEntityDetailComponents {
Details: React.ComponentType<any>;
}
export interface CatalogEntityDetailRegistration {
kind: string;
apiVersions: string[];
components: CatalogEntityDetailComponents;
priority?: number;
}
export class CatalogEntityDetailRegistry extends BaseRegistry<CatalogEntityDetailRegistration> {
getItemsForKind(kind: string, apiVersion: string) {
const items = this.getItems().filter((item) => {
return item.kind === kind && item.apiVersions.includes(apiVersion);
});
return items.sort((a, b) => (b.priority ?? 50) - (a.priority ?? 50));
}
}
export const catalogEntityDetailRegistry = new CatalogEntityDetailRegistry();

View File

@ -33,3 +33,5 @@ export * from "./command-registry";
export * from "./entity-setting-registry"; export * from "./entity-setting-registry";
export * from "./welcome-menu-registry"; export * from "./welcome-menu-registry";
export * from "./protocol-handler-registry"; export * from "./protocol-handler-registry";
export * from "./catalog-entity-detail-registry";
export * from "./workloads-overview-detail-registry";

View File

@ -0,0 +1,42 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import type React from "react";
import { BaseRegistry } from "./base-registry";
export interface WorkloadsOverviewDetailComponents {
Details: React.ComponentType<any>;
}
export interface WorkloadsOverviewDetailRegistration {
components: WorkloadsOverviewDetailComponents;
priority?: number;
}
export class WorkloadsOverviewDetailRegistry extends BaseRegistry<WorkloadsOverviewDetailRegistration> {
getItems() {
const items = super.getItems();
return items.sort((a, b) => (b.priority ?? 50) - (a.priority ?? 50));
}
}
export const workloadsOverviewDetailRegistry = new WorkloadsOverviewDetailRegistry();

View File

@ -53,6 +53,7 @@ export * from "../../renderer/components/stepper";
export * from "../../renderer/components/wizard"; export * from "../../renderer/components/wizard";
export * from "../../renderer/components/+workloads-pods/pod-details-list"; export * from "../../renderer/components/+workloads-pods/pod-details-list";
export * from "../../renderer/components/+namespaces/namespace-select"; export * from "../../renderer/components/+namespaces/namespace-select";
export * from "../../renderer/components/+namespaces/namespace-select-filter";
export * from "../../renderer/components/layout/sub-title"; export * from "../../renderer/components/layout/sub-title";
export * from "../../renderer/components/input/search-input"; export * from "../../renderer/components/input/search-input";
export * from "../../renderer/components/chart/bar-chart"; export * from "../../renderer/components/chart/bar-chart";

View File

@ -48,6 +48,15 @@ export class CatalogEntityRegistry {
return allItems.filter((entity) => this.categoryRegistry.getCategoryForEntity(entity) !== undefined); return allItems.filter((entity) => this.categoryRegistry.getCategoryForEntity(entity) !== undefined);
} }
getById<T extends CatalogEntity>(id: string): T | undefined {
const item = this.items.find((entity) => entity.metadata.uid === id);
if (item) return item as T;
return undefined;
}
getItemsForApiKind<T extends CatalogEntity>(apiVersion: string, kind: string): T[] { getItemsForApiKind<T extends CatalogEntity>(apiVersion: string, kind: string): T[] {
const items = this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind); const items = this.items.filter((item) => item.apiVersion === apiVersion && item.kind === kind);

View File

@ -79,8 +79,7 @@ export class ClusterManager extends Singleton {
if (index !== -1) { if (index !== -1) {
const entity = catalogEntityRegistry.items[index] as KubernetesCluster; const entity = catalogEntityRegistry.items[index] as KubernetesCluster;
entity.status.phase = cluster.disconnected ? "disconnected" : "connected"; this.updateEntityStatus(entity, cluster);
entity.status.active = !cluster.disconnected;
if (cluster.preferences?.clusterName) { if (cluster.preferences?.clusterName) {
entity.metadata.name = cluster.preferences.clusterName; entity.metadata.name = cluster.preferences.clusterName;
@ -101,6 +100,10 @@ export class ClusterManager extends Singleton {
} }
} }
protected updateEntityStatus(entity: KubernetesCluster, cluster: Cluster) {
entity.status.phase = cluster.accessible ? "connected" : "disconnected";
}
@action syncClustersFromCatalog(entities: KubernetesCluster[]) { @action syncClustersFromCatalog(entities: KubernetesCluster[]) {
for (const entity of entities) { for (const entity of entities) {
const cluster = this.store.getById(entity.metadata.uid); const cluster = this.store.getById(entity.metadata.uid);
@ -118,10 +121,7 @@ export class ClusterManager extends Singleton {
cluster.kubeConfigPath = entity.spec.kubeconfigPath; cluster.kubeConfigPath = entity.spec.kubeconfigPath;
cluster.contextName = entity.spec.kubeconfigContext; cluster.contextName = entity.spec.kubeconfigContext;
entity.status = { this.updateEntityStatus(entity, cluster);
phase: cluster.disconnected ? "disconnected" : "connected",
active: !cluster.disconnected
};
} }
} }
} }

View File

@ -22,7 +22,7 @@
import * as tempy from "tempy"; import * as tempy from "tempy";
import fse from "fs-extra"; import fse from "fs-extra";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import { promiseExec} from "../promise-exec"; import { promiseExec } from "../promise-exec";
import { helmCli } from "./helm-cli"; import { helmCli } from "./helm-cli";
import type { Cluster } from "../cluster"; import type { Cluster } from "../cluster";
import { toCamelCase } from "../../common/utils/camelCase"; import { toCamelCase } from "../../common/utils/camelCase";
@ -39,7 +39,7 @@ export async function listReleases(pathToKubeconfig: string, namespace?: string)
return output; return output;
} }
output.forEach((release: any, index: number) => { output.forEach((release: any, index: number) => {
output[index] = toCamelCase(release); output[index] = toCamelCase(release);
}); });
return output; return output;
@ -49,9 +49,9 @@ export async function listReleases(pathToKubeconfig: string, namespace?: string)
} }
export async function installChart(chart: string, values: any, name: string | undefined, namespace: string, version: string, pathToKubeconfig: string){ export async function installChart(chart: string, values: any, name: string | undefined, namespace: string, version: string, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
const fileName = tempy.file({name: "values.yaml"}); const fileName = tempy.file({ name: "values.yaml" });
await fse.writeFile(fileName, yaml.safeDump(values)); await fse.writeFile(fileName, yaml.safeDump(values));
@ -81,7 +81,7 @@ export async function installChart(chart: string, values: any, name: string | un
export async function upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster) { export async function upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster) {
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
const fileName = tempy.file({name: "values.yaml"}); const fileName = tempy.file({ name: "values.yaml" });
await fse.writeFile(fileName, yaml.safeDump(values)); await fse.writeFile(fileName, yaml.safeDump(values));
@ -119,7 +119,7 @@ export async function getRelease(name: string, namespace: string, cluster: Clust
export async function deleteRelease(name: string, namespace: string, pathToKubeconfig: string) { export async function deleteRelease(name: string, namespace: string, pathToKubeconfig: string) {
try { try {
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
const { stdout } = await promiseExec(`"${helm}" delete ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`); const { stdout } = await promiseExec(`"${helm}" delete ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`);
return stdout; return stdout;
} catch ({ stderr }) { } catch ({ stderr }) {
@ -127,10 +127,16 @@ export async function deleteRelease(name: string, namespace: string, pathToKubec
} }
} }
export async function getValues(name: string, namespace: string, all: boolean, pathToKubeconfig: string) { interface GetValuesOptions {
namespace: string;
all?: boolean;
pathToKubeconfig: string;
}
export async function getValues(name: string, { namespace, all = false, pathToKubeconfig }: GetValuesOptions) {
try { try {
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
const { stdout, } = await promiseExec(`"${helm}" get values ${name} ${all ? "--all": ""} --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`); const { stdout } = await promiseExec(`"${helm}" get values ${name} ${all ? "--all" : ""} --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`);
return stdout; return stdout;
} catch ({ stderr }) { } catch ({ stderr }) {
@ -167,8 +173,8 @@ async function getResources(name: string, namespace: string, cluster: Cluster) {
const pathToKubeconfig = await cluster.getProxyKubeconfigPath(); const pathToKubeconfig = await cluster.getProxyKubeconfigPath();
const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`); const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`);
return stdout; return JSON.parse(stdout).items;
} catch { } catch {
return { stdout: JSON.stringify({ items: [] }) }; return [];
} }
} }

View File

@ -27,6 +27,12 @@ import { HelmChartManager } from "./helm-chart-manager";
import type { HelmChartList, RepoHelmChartList } from "../../renderer/api/endpoints/helm-charts.api"; import type { HelmChartList, RepoHelmChartList } from "../../renderer/api/endpoints/helm-charts.api";
import { deleteRelease, getHistory, getRelease, getValues, installChart, listReleases, rollback, upgradeRelease } from "./helm-release-manager"; import { deleteRelease, getHistory, getRelease, getValues, installChart, listReleases, rollback, upgradeRelease } from "./helm-release-manager";
interface GetReleaseValuesArgs {
cluster: Cluster;
namespace: string;
all: boolean;
}
class HelmService { class HelmService {
public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) { public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
@ -86,12 +92,12 @@ class HelmService {
return getRelease(releaseName, namespace, cluster); return getRelease(releaseName, namespace, cluster);
} }
public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string, all: boolean) { public async getReleaseValues(releaseName: string, { cluster, namespace, all }: GetReleaseValuesArgs) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath(); const pathToKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Fetch release values"); logger.debug("Fetch release values");
return getValues(releaseName, namespace, all, proxyKubeconfig); return getValues(releaseName, { namespace, all, pathToKubeconfig });
} }
public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) { public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) {

View File

@ -93,8 +93,11 @@ if (!app.requestSingleInstanceLock()) {
for (const arg of process.argv) { for (const arg of process.argv) {
if (arg.toLowerCase().startsWith("lens://")) { if (arg.toLowerCase().startsWith("lens://")) {
lprm.route(arg) try {
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg })); lprm.route(arg);
} catch (error) {
logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg });
}
} }
} }
} }
@ -104,8 +107,11 @@ app.on("second-instance", (event, argv) => {
for (const arg of argv) { for (const arg of argv) {
if (arg.toLowerCase().startsWith("lens://")) { if (arg.toLowerCase().startsWith("lens://")) {
lprm.route(arg) try {
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg })); lprm.route(arg);
} catch (error) {
logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl: arg });
}
} }
} }
@ -252,9 +258,10 @@ autoUpdater.on("before-quit-for-update", () => blockQuit = false);
app.on("will-quit", (event) => { app.on("will-quit", (event) => {
// Quit app on Cmd+Q (MacOS) // Quit app on Cmd+Q (MacOS)
logger.info("APP:QUIT"); logger.info("APP:QUIT");
appEventBus.emit({name: "app", action: "close"}); appEventBus.emit({ name: "app", action: "close" });
ClusterManager.getInstance(false)?.stop(); // close cluster connections ClusterManager.getInstance(false)?.stop(); // close cluster connections
KubeconfigSyncManager.getInstance(false)?.stopSync(); KubeconfigSyncManager.getInstance(false)?.stopSync();
LensProtocolRouterMain.getInstance(false)?.cleanup();
cleanup(); cleanup();
if (blockQuit) { if (blockQuit) {
@ -268,10 +275,11 @@ app.on("open-url", (event, rawUrl) => {
// lens:// protocol handler // lens:// protocol handler
event.preventDefault(); event.preventDefault();
LensProtocolRouterMain try {
.getInstance() LensProtocolRouterMain.getInstance().route(rawUrl);
.route(rawUrl) } catch (error) {
.catch(error => logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl })); logger.error(`${LensProtocolRouterMain.LoggingPrefix}: an error occured`, { error, rawUrl });
}
}); });
/** /**

View File

@ -23,7 +23,7 @@ import * as uuid from "uuid";
import { broadcastMessage } from "../../../common/ipc"; import { broadcastMessage } from "../../../common/ipc";
import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler"; import { ProtocolHandlerExtension, ProtocolHandlerInternal } from "../../../common/protocol-handler";
import { noop } from "../../../common/utils"; import { delay, noop } from "../../../common/utils";
import { LensExtension } from "../../../extensions/main-api"; import { LensExtension } from "../../../extensions/main-api";
import { ExtensionLoader } from "../../../extensions/extension-loader"; import { ExtensionLoader } from "../../../extensions/extension-loader";
import { ExtensionsStore } from "../../../extensions/extensions-store"; import { ExtensionsStore } from "../../../extensions/extensions-store";
@ -56,27 +56,27 @@ describe("protocol router tests", () => {
LensProtocolRouterMain.resetInstance(); LensProtocolRouterMain.resetInstance();
}); });
it("should throw on non-lens URLS", async () => { it("should throw on non-lens URLS", () => {
try { try {
const lpr = LensProtocolRouterMain.getInstance(); const lpr = LensProtocolRouterMain.getInstance();
expect(await lpr.route("https://google.ca")).toBeUndefined(); expect(lpr.route("https://google.ca")).toBeUndefined();
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(Error); expect(error).toBeInstanceOf(Error);
} }
}); });
it("should throw when host not internal or extension", async () => { it("should throw when host not internal or extension", () => {
try { try {
const lpr = LensProtocolRouterMain.getInstance(); const lpr = LensProtocolRouterMain.getInstance();
expect(await lpr.route("lens://foobar")).toBeUndefined(); expect(lpr.route("lens://foobar")).toBeUndefined();
} catch (error) { } catch (error) {
expect(error).toBeInstanceOf(Error); expect(error).toBeInstanceOf(Error);
} }
}); });
it.only("should not throw when has valid host", async () => { it("should not throw when has valid host", async () => {
const extId = uuid.v4(); const extId = uuid.v4();
const ext = new LensExtension({ const ext = new LensExtension({
id: extId, id: extId,
@ -103,38 +103,39 @@ describe("protocol router tests", () => {
lpr.addInternalHandler("/", noop); lpr.addInternalHandler("/", noop);
try { try {
expect(await lpr.route("lens://app")).toBeUndefined(); expect(lpr.route("lens://app")).toBeUndefined();
} catch (error) { } catch (error) {
expect(throwIfDefined(error)).not.toThrow(); expect(throwIfDefined(error)).not.toThrow();
} }
try { try {
expect(await lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined(); expect(lpr.route("lens://extension/@mirantis/minikube")).toBeUndefined();
} catch (error) { } catch (error) {
expect(throwIfDefined(error)).not.toThrow(); expect(throwIfDefined(error)).not.toThrow();
} }
expect(broadcastMessage).toHaveBeenNthCalledWith(1, ProtocolHandlerInternal, "lens://app/"); await delay(50);
expect(broadcastMessage).toHaveBeenNthCalledWith(2, ProtocolHandlerExtension, "lens://extension/@mirantis/minikube"); expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerInternal, "lens://app/", "matched");
expect(broadcastMessage).toHaveBeenCalledWith(ProtocolHandlerExtension, "lens://extension/@mirantis/minikube", "matched");
}); });
it("should call handler if matches", async () => { it("should call handler if matches", () => {
const lpr = LensProtocolRouterMain.getInstance(); const lpr = LensProtocolRouterMain.getInstance();
let called = false; let called = false;
lpr.addInternalHandler("/page", () => { called = true; }); lpr.addInternalHandler("/page", () => { called = true; });
try { try {
expect(await lpr.route("lens://app/page")).toBeUndefined(); expect(lpr.route("lens://app/page")).toBeUndefined();
} catch (error) { } catch (error) {
expect(throwIfDefined(error)).not.toThrow(); expect(throwIfDefined(error)).not.toThrow();
} }
expect(called).toBe(true); expect(called).toBe(true);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page"); expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page", "matched");
}); });
it("should call most exact handler", async () => { it("should call most exact handler", () => {
const lpr = LensProtocolRouterMain.getInstance(); const lpr = LensProtocolRouterMain.getInstance();
let called: any = 0; let called: any = 0;
@ -142,13 +143,13 @@ describe("protocol router tests", () => {
lpr.addInternalHandler("/page/:id", params => { called = params.pathname.id; }); lpr.addInternalHandler("/page/:id", params => { called = params.pathname.id; });
try { try {
expect(await lpr.route("lens://app/page/foo")).toBeUndefined(); expect(lpr.route("lens://app/page/foo")).toBeUndefined();
} catch (error) { } catch (error) {
expect(throwIfDefined(error)).not.toThrow(); expect(throwIfDefined(error)).not.toThrow();
} }
expect(called).toBe("foo"); expect(called).toBe("foo");
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo"); expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo", "matched");
}); });
it("should call most exact handler for an extension", async () => { it("should call most exact handler for an extension", async () => {
@ -182,13 +183,14 @@ describe("protocol router tests", () => {
(ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "@foobar/icecream" }); (ExtensionsStore.getInstance() as any).state.set(extId, { enabled: true, name: "@foobar/icecream" });
try { try {
expect(await lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined(); expect(lpr.route("lens://extension/@foobar/icecream/page/foob")).toBeUndefined();
} catch (error) { } catch (error) {
expect(throwIfDefined(error)).not.toThrow(); expect(throwIfDefined(error)).not.toThrow();
} }
await delay(50);
expect(called).toBe("foob"); expect(called).toBe("foob");
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/@foobar/icecream/page/foob"); expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/@foobar/icecream/page/foob", "matched");
}); });
it("should work with non-org extensions", async () => { it("should work with non-org extensions", async () => {
@ -249,13 +251,15 @@ describe("protocol router tests", () => {
(ExtensionsStore.getInstance() as any).state.set("icecream", { enabled: true, name: "icecream" }); (ExtensionsStore.getInstance() as any).state.set("icecream", { enabled: true, name: "icecream" });
try { try {
expect(await lpr.route("lens://extension/icecream/page")).toBeUndefined(); expect(lpr.route("lens://extension/icecream/page")).toBeUndefined();
} catch (error) { } catch (error) {
expect(throwIfDefined(error)).not.toThrow(); expect(throwIfDefined(error)).not.toThrow();
} }
await delay(50);
expect(called).toBe(1); expect(called).toBe(1);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/icecream/page"); expect(broadcastMessage).toBeCalledWith(ProtocolHandlerExtension, "lens://extension/icecream/page", "matched");
}); });
it("should throw if urlSchema is invalid", () => { it("should throw if urlSchema is invalid", () => {
@ -264,7 +268,7 @@ describe("protocol router tests", () => {
expect(() => lpr.addInternalHandler("/:@", noop)).toThrowError(); expect(() => lpr.addInternalHandler("/:@", noop)).toThrowError();
}); });
it("should call most exact handler with 3 found handlers", async () => { it("should call most exact handler with 3 found handlers", () => {
const lpr = LensProtocolRouterMain.getInstance(); const lpr = LensProtocolRouterMain.getInstance();
let called: any = 0; let called: any = 0;
@ -274,16 +278,16 @@ describe("protocol router tests", () => {
lpr.addInternalHandler("/page/bar", () => { called = 4; }); lpr.addInternalHandler("/page/bar", () => { called = 4; });
try { try {
expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); expect(lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined();
} catch (error) { } catch (error) {
expect(throwIfDefined(error)).not.toThrow(); expect(throwIfDefined(error)).not.toThrow();
} }
expect(called).toBe(3); expect(called).toBe(3);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat"); expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat", "matched");
}); });
it("should call most exact handler with 2 found handlers", async () => { it("should call most exact handler with 2 found handlers", () => {
const lpr = LensProtocolRouterMain.getInstance(); const lpr = LensProtocolRouterMain.getInstance();
let called: any = 0; let called: any = 0;
@ -292,12 +296,12 @@ describe("protocol router tests", () => {
lpr.addInternalHandler("/page/bar", () => { called = 4; }); lpr.addInternalHandler("/page/bar", () => { called = 4; });
try { try {
expect(await lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined(); expect(lpr.route("lens://app/page/foo/bar/bat")).toBeUndefined();
} catch (error) { } catch (error) {
expect(throwIfDefined(error)).not.toThrow(); expect(throwIfDefined(error)).not.toThrow();
} }
expect(called).toBe(1); expect(called).toBe(1);
expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat"); expect(broadcastMessage).toBeCalledWith(ProtocolHandlerInternal, "lens://app/page/foo/bar/bat", "matched");
}); });
}); });

View File

@ -25,6 +25,8 @@ import Url from "url-parse";
import type { LensExtension } from "../../extensions/lens-extension"; import type { LensExtension } from "../../extensions/lens-extension";
import { broadcastMessage } from "../../common/ipc"; import { broadcastMessage } from "../../common/ipc";
import { observable, when, makeObservable } from "mobx"; import { observable, when, makeObservable } from "mobx";
import { ProtocolHandlerInvalid, RouteAttempt } from "../../common/protocol-handler";
import { disposer } from "../../common/utils";
export interface FallbackHandler { export interface FallbackHandler {
(name: string): Promise<boolean>; (name: string): Promise<boolean>;
@ -36,19 +38,25 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter {
@observable rendererLoaded = false; @observable rendererLoaded = false;
@observable extensionsLoaded = false; @observable extensionsLoaded = false;
protected disposers = disposer();
constructor() { constructor() {
super(); super();
makeObservable(this); makeObservable(this);
} }
public cleanup() {
this.disposers();
}
/** /**
* Find the most specific registered handler, if it exists, and invoke it. * Find the most specific registered handler, if it exists, and invoke it.
* *
* This will send an IPC message to the renderer router to do the same * This will send an IPC message to the renderer router to do the same
* in the renderer. * in the renderer.
*/ */
public async route(rawUrl: string): Promise<void> { public route(rawUrl: string) {
try { try {
const url = new Url(rawUrl, true); const url = new Url(rawUrl, true);
@ -60,16 +68,18 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter {
switch (url.host) { switch (url.host) {
case "app": case "app":
return this._routeToInternal(url); this._routeToInternal(url);
break;
case "extension": case "extension":
await when(() => this.extensionsLoaded); this.disposers.push(when(() => this.extensionsLoaded, () => this._routeToExtension(url)));
break;
return this._routeToExtension(url);
default: default:
throw new proto.RoutingError(proto.RoutingErrorType.INVALID_HOST, url); throw new proto.RoutingError(proto.RoutingErrorType.INVALID_HOST, url);
} }
} catch (error) { } catch (error) {
broadcastMessage(ProtocolHandlerInvalid, error.toString(), rawUrl);
if (error instanceof proto.RoutingError) { if (error instanceof proto.RoutingError) {
logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url }); logger.error(`${proto.LensProtocolRouter.LoggingPrefix}: ${error}`, { url: error.url });
} else { } else {
@ -102,17 +112,16 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter {
return ""; return "";
} }
protected async _routeToInternal(url: Url): Promise<void> { protected _routeToInternal(url: Url): RouteAttempt {
const rawUrl = url.toString(); // for sending to renderer const rawUrl = url.toString(); // for sending to renderer
const attempt = super._routeToInternal(url);
super._routeToInternal(url); this.disposers.push(when(() => this.rendererLoaded, () => broadcastMessage(proto.ProtocolHandlerInternal, rawUrl, attempt)));
await when(() => this.rendererLoaded); return attempt;
return broadcastMessage(proto.ProtocolHandlerInternal, rawUrl);
} }
protected async _routeToExtension(url: Url): Promise<void> { protected async _routeToExtension(url: Url): Promise<RouteAttempt> {
const rawUrl = url.toString(); // for sending to renderer const rawUrl = url.toString(); // for sending to renderer
/** /**
@ -122,10 +131,11 @@ export class LensProtocolRouterMain extends proto.LensProtocolRouter {
* Note: this needs to clone the url because _routeToExtension modifies its * Note: this needs to clone the url because _routeToExtension modifies its
* argument. * argument.
*/ */
await super._routeToExtension(new Url(url.toString(), true)); const attempt = await super._routeToExtension(new Url(url.toString(), true));
await when(() => this.rendererLoaded);
return broadcastMessage(proto.ProtocolHandlerExtension, rawUrl); this.disposers.push(when(() => this.rendererLoaded, () => broadcastMessage(proto.ProtocolHandlerExtension, rawUrl, attempt)));
return attempt;
} }
/** /**

View File

@ -21,8 +21,9 @@
import type { LensApiRequest } from "../router"; import type { LensApiRequest } from "../router";
import { helmService } from "../helm/helm-service"; import { helmService } from "../helm/helm-service";
import { respondJson, respondText } from "../utils/http-responses";
import logger from "../logger"; import logger from "../logger";
import { respondJson, respondText } from "../utils/http-responses";
import { getBoolean } from "./utils/parse-query";
export class HelmApiRoute { export class HelmApiRoute {
static async listCharts(request: LensApiRequest) { static async listCharts(request: LensApiRequest) {
@ -122,10 +123,11 @@ export class HelmApiRoute {
} }
static async getReleaseValues(request: LensApiRequest) { static async getReleaseValues(request: LensApiRequest) {
const { cluster, params, response, query } = request; const { cluster, params: { namespace, release }, response, query } = request;
const all = getBoolean(query, "all");
try { try {
const result = await helmService.getReleaseValues(cluster, params.release, params.namespace, query.has("all")); const result = await helmService.getReleaseValues(release, { cluster, namespace, all });
respondText(response, result); respondText(response, result);
} catch (error) { } catch (error) {

View File

@ -18,7 +18,4 @@
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
export default { export * from "./parse-query";
Trans: ({ children }: { children: React.ReactNode }) => children,
t: (message: string) => message
};

View File

@ -0,0 +1,34 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
export function getBoolean(query: URLSearchParams, key: string): boolean {
const value = query.get(key);
switch (value?.toLowerCase()) {
case "false":
case "f":
case "0":
case null:
case undefined:
return false;
default:
return true;
}
}

View File

@ -20,7 +20,6 @@
*/ */
import jsYaml from "js-yaml"; import jsYaml from "js-yaml";
import { compile } from "path-to-regexp";
import { autoBind, formatDuration } from "../../utils"; import { autoBind, formatDuration } from "../../utils";
import capitalize from "lodash/capitalize"; import capitalize from "lodash/capitalize";
import { apiBase } from "../index"; import { apiBase } from "../index";
@ -28,6 +27,8 @@ import { helmChartStore } from "../../components/+apps-helm-charts/helm-chart.st
import type { ItemObject } from "../../item.store"; import type { ItemObject } from "../../item.store";
import { KubeObject } from "../kube-object"; import { KubeObject } from "../kube-object";
import type { JsonApiData } from "../json-api"; import type { JsonApiData } from "../json-api";
import { buildURLPositional } from "../../../common/utils/buildUrl";
import type { KubeJsonApiData } from "../kube-json-api";
interface IReleasePayload { interface IReleasePayload {
name: string; name: string;
@ -46,7 +47,7 @@ interface IReleasePayload {
} }
interface IReleaseRawDetails extends IReleasePayload { interface IReleaseRawDetails extends IReleasePayload {
resources: string; resources: KubeJsonApiData[];
} }
export interface IReleaseDetails extends IReleasePayload { export interface IReleaseDetails extends IReleasePayload {
@ -83,12 +84,16 @@ export interface IReleaseRevision {
description: string; description: string;
} }
const endpoint = compile(`/v2/releases/:namespace?/:name?`) as ( type EndpointParams = {}
params?: { | { namespace: string }
namespace?: string; | { namespace: string, name: string }
name?: string; | { namespace: string, name: string, route: string };
}
) => string; interface EndpointQuery {
all?: boolean;
}
const endpoint = buildURLPositional<EndpointParams, EndpointQuery>("/v2/releases/:namespace?/:name?/:route?");
export async function listReleases(namespace?: string): Promise<HelmRelease[]> { export async function listReleases(namespace?: string): Promise<HelmRelease[]> {
const releases = await apiBase.get<HelmRelease[]>(endpoint({ namespace })); const releases = await apiBase.get<HelmRelease[]>(endpoint({ namespace }));
@ -98,10 +103,8 @@ export async function listReleases(namespace?: string): Promise<HelmRelease[]> {
export async function getRelease(name: string, namespace: string): Promise<IReleaseDetails> { export async function getRelease(name: string, namespace: string): Promise<IReleaseDetails> {
const path = endpoint({ name, namespace }); const path = endpoint({ name, namespace });
const { resources: rawResources, ...details } = await apiBase.get<IReleaseRawDetails>(path);
const details = await apiBase.get<IReleaseRawDetails>(path); const resources = rawResources.map(KubeObject.create);
const items: KubeObject[] = JSON.parse(details.resources).items;
const resources = items.map(item => KubeObject.create(item));
return { return {
...details, ...details,
@ -134,25 +137,25 @@ export async function deleteRelease(name: string, namespace: string): Promise<Js
} }
export async function getReleaseValues(name: string, namespace: string, all?: boolean): Promise<string> { export async function getReleaseValues(name: string, namespace: string, all?: boolean): Promise<string> {
const path = `${endpoint({ name, namespace })}/values${all? "?all": ""}`; const route = "values";
const path = endpoint({ name, namespace, route }, { all });
return apiBase.get<string>(path); return apiBase.get<string>(path);
} }
export async function getReleaseHistory(name: string, namespace: string): Promise<IReleaseRevision[]> { export async function getReleaseHistory(name: string, namespace: string): Promise<IReleaseRevision[]> {
const path = `${endpoint({ name, namespace })}/history`; const route = "history";
const path = endpoint({ name, namespace, route });
return apiBase.get(path); return apiBase.get(path);
} }
export async function rollbackRelease(name: string, namespace: string, revision: number): Promise<JsonApiData> { export async function rollbackRelease(name: string, namespace: string, revision: number): Promise<JsonApiData> {
const path = `${endpoint({ name, namespace })}/rollback`; const route = "rollback";
const path = endpoint({ name, namespace, route });
const data = { revision };
return apiBase.put(path, { return apiBase.put(path, { data });
data: {
revision
}
});
} }
export interface HelmRelease { export interface HelmRelease {
@ -190,7 +193,7 @@ export class HelmRelease implements ItemObject {
getChart(withVersion = false) { getChart(withVersion = false) {
let chart = this.chart; let chart = this.chart;
if(!withVersion && this.getVersion() != "" ) { if (!withVersion && this.getVersion() != "") {
const search = new RegExp(`-${this.getVersion()}`); const search = new RegExp(`-${this.getVersion()}`);
chart = chart.replace(search, ""); chart = chart.replace(search, "");
@ -210,12 +213,7 @@ export class HelmRelease implements ItemObject {
getVersion() { getVersion() {
const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/); const versions = this.chart.match(/(?<=-)(v?\d+)[^-].*$/);
if (versions) { return versions?.[0] ?? "";
return versions[0];
}
else {
return "";
}
} }
getUpdated(humanize = true, compact = true) { getUpdated(humanize = true, compact = true) {

View File

@ -97,7 +97,7 @@ export class KubeObject<Metadata extends IKubeObjectMetadata = IKubeObjectMetada
status?: Status; status?: Status;
spec?: Spec; spec?: Spec;
static create(data: any) { static create(data: KubeJsonApiData) {
return new KubeObject(data); return new KubeObject(data);
} }
@ -163,7 +163,7 @@ export class KubeObject<Metadata extends IKubeObjectMetadata = IKubeObjectMetada
); );
} }
static isJsonApiDataList<T>(object: unknown, verifyItem:(val: unknown) => val is T): object is KubeJsonApiDataList<T> { static isJsonApiDataList<T>(object: unknown, verifyItem: (val: unknown) => val is T): object is KubeJsonApiDataList<T> {
return ( return (
isObject(object) isObject(object)
&& hasTypedProperty(object, "kind", isString) && hasTypedProperty(object, "kind", isString)

View File

@ -81,7 +81,6 @@ export class HelmCharts extends Component<Props> {
tableId="helm_charts" tableId="helm_charts"
className="HelmCharts" className="HelmCharts"
store={helmChartStore} store={helmChartStore}
isClusterScoped={true}
isSelectable={false} isSelectable={false}
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (chart: HelmChart) => chart.getName(), [columnId.name]: (chart: HelmChart) => chart.getName(),

View File

@ -58,32 +58,35 @@ export class ReleaseDetails extends Component<Props> {
@observable details: IReleaseDetails; @observable details: IReleaseDetails;
@observable values = ""; @observable values = "";
@observable valuesLoading = false; @observable valuesLoading = false;
@observable userSuppliedOnly = false; @observable showOnlyUserSuppliedValues = false;
@observable saving = false; @observable saving = false;
@observable releaseSecret: Secret; @observable releaseSecret: Secret;
@disposeOnUnmount componentDidMount() {
releaseSelector = reaction(() => this.props.release, release => { disposeOnUnmount(this, [
if (!release) return; reaction(() => this.props.release, release => {
this.loadDetails(); if (!release) return;
this.loadValues(); this.loadDetails();
this.releaseSecret = null; this.loadValues();
this.releaseSecret = null;
}),
reaction(() => secretsStore.getItems(), () => {
if (!this.props.release) return;
const { getReleaseSecret } = releaseStore;
const { release } = this.props;
const secret = getReleaseSecret(release);
if (this.releaseSecret) {
if (isEqual(this.releaseSecret.getLabels(), secret.getLabels())) return;
this.loadDetails();
}
this.releaseSecret = secret;
}),
reaction(() => this.showOnlyUserSuppliedValues, () => {
this.loadValues();
}),
]);
} }
);
@disposeOnUnmount
secretWatcher = reaction(() => secretsStore.getItems(), () => {
if (!this.props.release) return;
const { getReleaseSecret } = releaseStore;
const { release } = this.props;
const secret = getReleaseSecret(release);
if (this.releaseSecret) {
if (isEqual(this.releaseSecret.getLabels(), secret.getLabels())) return;
this.loadDetails();
}
this.releaseSecret = secret;
});
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
@ -100,10 +103,15 @@ export class ReleaseDetails extends Component<Props> {
async loadValues() { async loadValues() {
const { release } = this.props; const { release } = this.props;
this.values = ""; try {
this.valuesLoading = true; this.valuesLoading = true;
this.values = (await getReleaseValues(release.getName(), release.getNs(), !this.userSuppliedOnly)) ?? ""; this.values = (await getReleaseValues(release.getName(), release.getNs(), !this.showOnlyUserSuppliedValues)) ?? "";
this.valuesLoading = false; } catch (error) {
Notifications.error(`Failed to load values for ${release.getName()}: ${error}`);
this.values = "";
} finally {
this.valuesLoading = false;
}
} }
updateValues = async () => { updateValues = async () => {
@ -146,21 +154,19 @@ export class ReleaseDetails extends Component<Props> {
<div className="flex column gaps"> <div className="flex column gaps">
<Checkbox <Checkbox
label="User-supplied values only" label="User-supplied values only"
value={this.userSuppliedOnly} value={this.showOnlyUserSuppliedValues}
onChange={values => { onChange={value => this.showOnlyUserSuppliedValues = value}
this.userSuppliedOnly = values;
this.loadValues();
}}
disabled={valuesLoading} disabled={valuesLoading}
/> />
{valuesLoading <AceEditor
? <Spinner /> mode="yaml"
: <AceEditor value={values}
mode="yaml" onChange={text => this.values = text}
value={values} className={cssNames({ loading: valuesLoading })}
onChange={values => this.values = values} readOnly={valuesLoading || this.showOnlyUserSuppliedValues}
/> >
} {valuesLoading && <Spinner center />}
</AceEditor>
<Button <Button
primary primary
label="Save" label="Save"

View File

@ -66,10 +66,8 @@ export class ReleaseRollbackDialog extends React.Component<Props> {
onOpen = async () => { onOpen = async () => {
this.isLoading = true; this.isLoading = true;
const currentRevision = this.release.getRevision();
let releases = await getReleaseHistory(this.release.getName(), this.release.getNs()); let releases = await getReleaseHistory(this.release.getName(), this.release.getNs());
releases = releases.filter(item => item.revision !== currentRevision); // remove current
releases = orderBy(releases, "revision", "desc"); // sort releases = orderBy(releases, "revision", "desc"); // sort
this.revisions.replace(releases); this.revisions.replace(releases);
this.revision = this.revisions[0]; this.revision = this.revisions[0];

View File

@ -34,6 +34,7 @@ import { navigation } from "../../navigation";
import { ItemListLayout } from "../item-object-list/item-list-layout"; import { ItemListLayout } from "../item-object-list/item-list-layout";
import { HelmReleaseMenu } from "./release-menu"; import { HelmReleaseMenu } from "./release-menu";
import { secretsStore } from "../+config-secrets/secrets.store"; import { secretsStore } from "../+config-secrets/secrets.store";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
enum columnId { enum columnId {
name = "name", name = "name",
@ -117,6 +118,15 @@ export class HelmReleases extends Component<Props> {
(release: HelmRelease) => release.getVersion(), (release: HelmRelease) => release.getVersion(),
]} ]}
renderHeaderTitle="Releases" renderHeaderTitle="Releases"
customizeHeader={({ filters, ...headerPlaceholders }) => ({
filters: (
<>
{filters}
<NamespaceSelectFilter />
</>
),
...headerPlaceholders,
})}
renderTableHeader={[ renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name }, { title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace }, { title: "Namespace", className: "namespace", sortBy: columnId.namespace, id: columnId.namespace },

View File

@ -0,0 +1,43 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.CatalogEntityDetails {
.EntityMetadata {
margin-right: $margin;
}
.EntityIcon.box.top.left {
margin-right: $margin * 2;
.IconHint {
text-align: center;
font-size: var(--font-size-small);
text-transform: uppercase;
margin-top: $margin;
cursor: default;
user-select: none;
opacity: 0.5;
}
div * {
font-size: 1.5em;
}
}
}

View File

@ -0,0 +1,129 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import "./catalog-entity-details.scss";
import React, { Component } from "react";
import { observer } from "mobx-react";
import { Drawer, DrawerItem, DrawerItemLabels } from "../drawer";
import { CatalogEntity, catalogEntityRunContext } from "../../api/catalog-entity";
import type { CatalogCategory } from "../../../common/catalog";
import { Icon } from "../icon";
import { KubeObject } from "../../api/kube-object";
import { CatalogEntityDrawerMenu } from "./catalog-entity-drawer-menu";
import { catalogEntityDetailRegistry } from "../../../extensions/registries";
import { HotbarIcon } from "../hotbar/hotbar-icon";
interface Props {
entity: CatalogEntity;
hideDetails(): void;
}
@observer
export class CatalogEntityDetails extends Component<Props> {
private abortController?: AbortController;
constructor(props: Props) {
super(props);
}
componentWillUnmount() {
this.abortController?.abort();
}
categoryIcon(category: CatalogCategory) {
if (category.metadata.icon.includes("<svg")) {
return <Icon svg={category.metadata.icon} smallest />;
} else {
return <Icon material={category.metadata.icon} smallest />;
}
}
openEntity() {
this.props.entity.onRun(catalogEntityRunContext);
}
renderContent() {
const { entity } = this.props;
const labels = KubeObject.stringifyLabels(entity.metadata.labels);
const detailItems = catalogEntityDetailRegistry.getItemsForKind(entity.kind, entity.apiVersion);
const details = detailItems.map((item, index) => {
return <item.components.Details entity={entity} key={index}/>;
});
const showDetails = detailItems.find((item) => item.priority > 999) === undefined;
return (
<>
{showDetails && (
<div className="flex CatalogEntityDetails">
<div className="EntityIcon box top left">
<HotbarIcon
uid={entity.metadata.uid}
title={entity.metadata.name}
source={entity.metadata.source}
onClick={() => this.openEntity()}
size={128} />
<div className="IconHint">
Click to open
</div>
</div>
<div className="box grow EntityMetadata">
<DrawerItem name="Name">
{entity.metadata.name}
</DrawerItem>
<DrawerItem name="Kind">
{entity.kind}
</DrawerItem>
<DrawerItem name="Source">
{entity.metadata.source}
</DrawerItem>
<DrawerItemLabels
name="Labels"
labels={labels}
/>
</div>
</div>
)}
<div className="box grow">
{details}
</div>
</>
);
}
render() {
const { entity, hideDetails } = this.props;
const title = `${entity.kind}: ${entity.metadata.name}`;
return (
<Drawer
className="CatalogEntityDetails"
usePortal={true}
open={true}
title={title}
toolbar={<CatalogEntityDrawerMenu entity={entity} key={entity.getId()} />}
onClose={hideDetails}
>
{this.renderContent()}
</Drawer>
);
}
}

View File

@ -0,0 +1,127 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import { cssNames } from "../../utils";
import { MenuActions, MenuActionsProps } from "../menu/menu-actions";
import type { CatalogEntity, CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
import { observer } from "mobx-react";
import { makeObservable, observable } from "mobx";
import { navigate } from "../../navigation";
import { MenuItem } from "../menu";
import { ConfirmDialog } from "../confirm-dialog";
import { HotbarStore } from "../../../common/hotbar-store";
import { Icon } from "../icon";
export interface CatalogEntityDrawerMenuProps<T extends CatalogEntity> extends MenuActionsProps {
entity: T | null | undefined;
}
@observer
export class CatalogEntityDrawerMenu<T extends CatalogEntity> extends React.Component<CatalogEntityDrawerMenuProps<T>> {
@observable private contextMenu: CatalogEntityContextMenuContext;
constructor(props: CatalogEntityDrawerMenuProps<T>) {
super(props);
makeObservable(this);
}
componentDidMount() {
this.contextMenu = {
menuItems: [],
navigate: (url: string) => navigate(url)
};
this.props.entity?.onContextMenuOpen(this.contextMenu);
}
onMenuItemClick(menuItem: CatalogEntityContextMenu) {
if (menuItem.confirm) {
ConfirmDialog.open({
okButtonProps: {
primary: false,
accent: true,
},
ok: () => {
menuItem.onClick();
},
message: menuItem.confirm.message
});
} else {
menuItem.onClick();
}
}
addToHotbar(entity: CatalogEntity): void {
HotbarStore.getInstance().addToHotbar(entity);
}
getMenuItems(entity: T): React.ReactChild[] {
if (!entity) {
return [];
}
const menuItems = this.contextMenu.menuItems.filter((menuItem) => {
return menuItem.icon && !menuItem.onlyVisibleForSource || menuItem.onlyVisibleForSource === entity.metadata.source;
});
const items = menuItems.map((menuItem, index) => {
const props = menuItem.icon.includes("<svg") ? { svg: menuItem.icon } : { material: menuItem.icon };
return (
<MenuItem key={index} onClick={() => this.onMenuItemClick(menuItem)}>
<Icon
title={menuItem.title}
{...props}
/>
</MenuItem>
);
});
items.unshift(
<MenuItem key="add-to-hotbar" onClick={() => this.addToHotbar(entity) }>
<Icon material="playlist_add" small title="Add to Hotbar" />
</MenuItem>
);
items.reverse();
return items;
}
render() {
if (!this.contextMenu) {
return null;
}
const { className, entity, ...menuProps } = this.props;
return (
<MenuActions
className={cssNames("CatalogEntityDrawerMenu", className)}
toolbar
{...menuProps}
>
{this.getMenuItems(entity)}
</MenuActions>
);
}
}

View File

@ -28,6 +28,10 @@ import { autoBind } from "../../../common/utils";
export class CatalogEntityItem implements ItemObject { export class CatalogEntityItem implements ItemObject {
constructor(public entity: CatalogEntity) {} constructor(public entity: CatalogEntity) {}
get kind() {
return this.entity.kind;
}
get name() { get name() {
return this.entity.metadata.name; return this.entity.metadata.name;
} }

View File

@ -0,0 +1,91 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.iconCell {
max-width: 40px;
display: flex;
align-items: center;
}
.nameCell {
}
.sourceCell {
max-width: 100px;
}
.statusCell {
max-width: 100px;
}
.connected {
color: var(--colorSuccess);
}
.disconnected {
color: var(--halfGray);
}
.labelsCell {
overflow-x: scroll;
text-overflow: unset;
}
.labelsCell::-webkit-scrollbar {
display: none;
}
.badge {
overflow: unset;
text-overflow: unset;
max-width: unset;
}
.badge:not(:first-child) {
margin-left: 0.5em;
}
.catalogIcon {
font-size: 10px;
-webkit-font-smoothing: auto;
}
.tabs {
@apply flex flex-grow flex-col;
}
.tab {
@apply px-8 py-4;
}
.tab:hover {
background-color: var(--sidebarItemHoverBackground);
--color-active: var(--textColorTertiary);
}
.tab::after {
display: none;
}
.activeTab, .activeTab:hover {
background-color: var(--blue);
--color-active: white;
}

View File

@ -75,6 +75,10 @@
align-items: center; align-items: center;
} }
.TableCell.kind {
max-width: 150px;
}
.TableCell.source { .TableCell.source {
max-width: 100px; max-width: 100px;
} }

View File

@ -19,7 +19,8 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./catalog.scss"; import styles from "./catalog.module.css";
import React from "react"; import React from "react";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { ItemListLayout } from "../item-object-list"; import { ItemListLayout } from "../item-object-list";
@ -27,9 +28,8 @@ import { action, makeObservable, observable, reaction, when } from "mobx";
import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store"; import { CatalogEntityItem, CatalogEntityStore } from "./catalog-entity.store";
import { navigate } from "../../navigation"; import { navigate } from "../../navigation";
import { kebabCase } from "lodash"; import { kebabCase } from "lodash";
import { PageLayout } from "../layout/page-layout";
import { MenuItem, MenuActions } from "../menu"; import { MenuItem, MenuActions } from "../menu";
import { CatalogEntityContextMenu, CatalogEntityContextMenuContext, catalogEntityRunContext } from "../../api/catalog-entity"; import type { CatalogEntityContextMenu, CatalogEntityContextMenuContext } from "../../api/catalog-entity";
import { Badge } from "../badge"; import { Badge } from "../badge";
import { HotbarStore } from "../../../common/hotbar-store"; import { HotbarStore } from "../../../common/hotbar-store";
import { ConfirmDialog } from "../confirm-dialog"; import { ConfirmDialog } from "../confirm-dialog";
@ -40,9 +40,17 @@ import type { RouteComponentProps } from "react-router";
import type { ICatalogViewRouteParam } from "./catalog.route"; import type { ICatalogViewRouteParam } from "./catalog.route";
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { Avatar } from "../avatar/avatar"; import { Avatar } from "../avatar/avatar";
import { MainLayout } from "../layout/main-layout";
import { cssNames } from "../../utils";
import { TopBar } from "../layout/topbar";
import { welcomeURL } from "../+welcome";
import { Icon } from "../icon";
import { MaterialTooltip } from "../material-tooltip/material-tooltip";
import { CatalogEntityDetails } from "./catalog-entity-details";
enum sortBy { enum sortBy {
name = "name", name = "name",
kind = "kind",
source = "source", source = "source",
status = "status" status = "status"
} }
@ -54,6 +62,7 @@ export class Catalog extends React.Component<Props> {
@observable private catalogEntityStore?: CatalogEntityStore; @observable private catalogEntityStore?: CatalogEntityStore;
@observable private contextMenu: CatalogEntityContextMenuContext; @observable private contextMenu: CatalogEntityContextMenuContext;
@observable activeTab?: string; @observable activeTab?: string;
@observable selectedItem?: CatalogEntityItem;
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
@ -102,7 +111,7 @@ export class Catalog extends React.Component<Props> {
} }
onDetails(item: CatalogEntityItem) { onDetails(item: CatalogEntityItem) {
item.onRun(catalogEntityRunContext); this.selectedItem = item;
} }
onMenuItemClick(menuItem: CatalogEntityContextMenu) { onMenuItemClick(menuItem: CatalogEntityContextMenu) {
@ -136,14 +145,14 @@ export class Catalog extends React.Component<Props> {
renderNavigation() { renderNavigation() {
return ( return (
<Tabs className="flex column" scrollable={false} onChange={this.onTabChange} value={this.activeTab}> <Tabs className={cssNames(styles.tabs, "flex column")} scrollable={false} onChange={this.onTabChange} value={this.activeTab}>
<div className="sidebarHeader">Catalog</div> <div>
<div className="sidebarTabs">
<Tab <Tab
value={undefined} value={undefined}
key="*" key="*"
label="Browse" label="Browse"
data-testid="*-tab" data-testid="*-tab"
className={cssNames(styles.tab, { [styles.activeTab]: this.activeTab == null })}
/> />
{ {
this.categories.map(category => ( this.categories.map(category => (
@ -152,6 +161,7 @@ export class Catalog extends React.Component<Props> {
key={category.getId()} key={category.getId()}
label={category.metadata.name} label={category.metadata.name}
data-testid={`${category.getId()}-tab`} data-testid={`${category.getId()}-tab`}
className={cssNames(styles.tab, { [styles.activeTab]: this.activeTab == category.getId() })}
/> />
)) ))
} }
@ -180,19 +190,91 @@ export class Catalog extends React.Component<Props> {
}; };
renderIcon(item: CatalogEntityItem) { renderIcon(item: CatalogEntityItem) {
const category = catalogCategoryRegistry.getCategoryForEntity(item.entity);
if (!category) {
return null;
}
return ( return (
<Avatar <Avatar
title={item.name} title={item.name}
colorHash={`${item.name}-${item.source}`} colorHash={`${item.name}-${item.source}`}
width={24} width={24}
height={24} height={24}
className="catalogIcon" className={styles.catalogIcon}
/>
);
}
renderSingleCategoryList() {
return (
<ItemListLayout
renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name ?? "Browse All"}
isSearchable={true}
isSelectable={false}
className="CatalogItemList"
store={this.catalogEntityStore}
tableId="catalog-items"
sortingCallbacks={{
[sortBy.name]: (item: CatalogEntityItem) => item.name,
[sortBy.source]: (item: CatalogEntityItem) => item.source,
[sortBy.status]: (item: CatalogEntityItem) => item.phase,
}}
searchFilters={[
(entity: CatalogEntityItem) => entity.searchFields,
]}
renderTableHeader={[
{ title: "", className: "icon" },
{ title: "Name", className: "name", sortBy: sortBy.name },
{ title: "Source", className: "source", sortBy: sortBy.source },
{ title: "Labels", className: "labels" },
{ title: "Status", className: "status", sortBy: sortBy.status },
]}
renderTableContents={(item: CatalogEntityItem) => [
this.renderIcon(item),
item.name,
item.source,
item.labels.map((label) => <Badge key={label} label={label} title={label} />),
{ title: item.phase, className: kebabCase(item.phase) }
]}
onDetails={(item: CatalogEntityItem) => this.onDetails(item) }
renderItemMenu={this.renderItemMenu}
/>
);
}
renderAllCategoriesList() {
return (
<ItemListLayout
renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name ?? "Browse All"}
isSearchable={true}
isSelectable={false}
className="CatalogItemList"
store={this.catalogEntityStore}
tableId="catalog-items"
sortingCallbacks={{
[sortBy.name]: (item: CatalogEntityItem) => item.name,
[sortBy.kind]: (item: CatalogEntityItem) => item.kind,
[sortBy.source]: (item: CatalogEntityItem) => item.source,
[sortBy.status]: (item: CatalogEntityItem) => item.phase,
}}
searchFilters={[
(entity: CatalogEntityItem) => entity.searchFields,
]}
renderTableHeader={[
{ title: "", className: "icon" },
{ title: "Name", className: "name", sortBy: sortBy.name },
{ title: "Kind", className: "kind", sortBy: sortBy.kind },
{ title: "Source", className: "source", sortBy: sortBy.source },
{ title: "Labels", className: "labels" },
{ title: "Status", className: "status", sortBy: sortBy.status },
]}
renderTableContents={(item: CatalogEntityItem) => [
this.renderIcon(item),
item.name,
item.kind,
item.source,
item.labels.map((label) => <Badge key={label} label={label} title={label} />),
{ title: item.phase, className: kebabCase(item.phase) }
]}
detailsItem={this.selectedItem}
onDetails={(item: CatalogEntityItem) => this.onDetails(item) }
renderItemMenu={this.renderItemMenu}
/> />
); );
} }
@ -203,46 +285,29 @@ export class Catalog extends React.Component<Props> {
} }
return ( return (
<PageLayout <>
className="CatalogPage" <TopBar label="Catalog">
navigation={this.renderNavigation()} <div>
provideBackButtonNavigation={false} <MaterialTooltip title="Close Catalog" placement="left">
contentGaps={false}> <Icon style={{ cursor: "default" }} material="close" onClick={() => navigate(welcomeURL())}/>
<ItemListLayout </MaterialTooltip>
renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name ?? "Browse All"} </div>
isClusterScoped </TopBar>
isSearchable={true} <MainLayout sidebar={this.renderNavigation()}>
isSelectable={false} <div className="p-6 h-full">
className="CatalogItemList" { this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList() }
store={this.catalogEntityStore} </div>
tableId="catalog-items" { !this.selectedItem && (
sortingCallbacks={{ <CatalogAddButton category={this.catalogEntityStore.activeCategory} />
[sortBy.name]: (item: CatalogEntityItem) => item.name, )}
[sortBy.source]: (item: CatalogEntityItem) => item.source, { this.selectedItem && (
[sortBy.status]: (item: CatalogEntityItem) => item.phase, <CatalogEntityDetails
}} entity={this.selectedItem.entity}
searchFilters={[ hideDetails={() => this.selectedItem = null}
(entity: CatalogEntityItem) => entity.searchFields, />
]} )}
renderTableHeader={[ </MainLayout>
{ title: "", className: "icon" }, </>
{ title: "Name", className: "name", sortBy: sortBy.name },
{ title: "Source", className: "source", sortBy: sortBy.source },
{ title: "Labels", className: "labels" },
{ title: "Status", className: "status", sortBy: sortBy.status },
]}
renderTableContents={(item: CatalogEntityItem) => [
this.renderIcon(item),
item.name,
item.source,
item.labels.map((label) => <Badge key={label} label={label} title={label} />),
{ title: item.phase, className: kebabCase(item.phase) }
]}
onDetails={(item: CatalogEntityItem) => this.onDetails(item) }
renderItemMenu={this.renderItemMenu}
/>
<CatalogAddButton category={this.catalogEntityStore.activeCategory} />
</PageLayout>
); );
} }
} }

View File

@ -64,7 +64,8 @@ export const ClusterPieCharts = observer(() => {
"#c93dce", "#c93dce",
defaultColor, defaultColor,
], ],
id: "cpuUsage" id: "cpuUsage",
label: "Usage"
}, },
{ {
data: [ data: [
@ -75,7 +76,8 @@ export const ClusterPieCharts = observer(() => {
"#4caf50", "#4caf50",
defaultColor, defaultColor,
], ],
id: "cpuRequests" id: "cpuRequests",
label: "Requests"
}, },
{ {
data: [ data: [
@ -86,7 +88,8 @@ export const ClusterPieCharts = observer(() => {
"#3d90ce", "#3d90ce",
defaultColor, defaultColor,
], ],
id: "cpuLimits" id: "cpuLimits",
label: "Limits"
}, },
], ],
labels: [ labels: [
@ -107,7 +110,8 @@ export const ClusterPieCharts = observer(() => {
"#c93dce", "#c93dce",
defaultColor, defaultColor,
], ],
id: "memoryUsage" id: "memoryUsage",
label: "Usage"
}, },
{ {
data: [ data: [
@ -118,7 +122,8 @@ export const ClusterPieCharts = observer(() => {
"#4caf50", "#4caf50",
defaultColor, defaultColor,
], ],
id: "memoryRequests" id: "memoryRequests",
label: "Requests"
}, },
{ {
data: [ data: [
@ -129,7 +134,8 @@ export const ClusterPieCharts = observer(() => {
"#3d90ce", "#3d90ce",
defaultColor, defaultColor,
], ],
id: "memoryLimits" id: "memoryLimits",
label: "Limits"
}, },
], ],
labels: [ labels: [
@ -150,7 +156,8 @@ export const ClusterPieCharts = observer(() => {
"#4caf50", "#4caf50",
defaultColor, defaultColor,
], ],
id: "podUsage" id: "podUsage",
label: "Usage"
}, },
], ],
labels: [ labels: [

View File

@ -145,7 +145,7 @@ export class CRDDetails extends React.Component<Props> {
<> <>
<DrawerTitle title="Validation"/> <DrawerTitle title="Validation"/>
<AceEditor <AceEditor
mode="json" mode="yaml"
className="validation" className="validation"
value={validation} value={validation}
readOnly readOnly

View File

@ -90,7 +90,6 @@ export class CrdList extends React.Component {
isConfigurable isConfigurable
tableId="crd" tableId="crd"
className="CrdList" className="CrdList"
isClusterScoped={true}
store={crdStore} store={crdStore}
items={items} items={items}
sortingCallbacks={sortingCallbacks} sortingCallbacks={sortingCallbacks}

View File

@ -95,7 +95,6 @@ export class CrdResources extends React.Component<Props> {
isConfigurable isConfigurable
tableId="crd_resources" tableId="crd_resources"
className="CrdResources" className="CrdResources"
isClusterScoped={!isNamespaced}
store={store} store={store}
sortingCallbacks={sortingCallbacks} sortingCallbacks={sortingCallbacks}
searchFilters={[ searchFilters={[

View File

@ -33,7 +33,7 @@ import { namespaceStore } from "./namespace.store";
import type { SelectOption, SelectProps } from "../select"; import type { SelectOption, SelectProps } from "../select";
const Placeholder = observer((props: PlaceholderProps<any>) => { const Placeholder = observer((props: PlaceholderProps<any, boolean>) => {
const getPlaceholder = (): React.ReactNode => { const getPlaceholder = (): React.ReactNode => {
const namespaces = namespaceStore.contextNamespaces; const namespaces = namespaceStore.contextNamespaces;

View File

@ -47,7 +47,6 @@ export class Namespaces extends React.Component<Props> {
return ( return (
<TabLayout> <TabLayout>
<KubeObjectListLayout <KubeObjectListLayout
isClusterScoped
isConfigurable isConfigurable
tableId="namespaces" tableId="namespaces"
className="Namespaces" store={namespaceStore} className="Namespaces" store={namespaceStore}

View File

@ -166,7 +166,7 @@ export class Nodes extends React.Component<Props> {
isConfigurable isConfigurable
tableId="nodes" tableId="nodes"
className="Nodes" className="Nodes"
store={nodesStore} isClusterScoped store={nodesStore}
isReady={nodesStore.isLoaded} isReady={nodesStore.isLoaded}
dependentStores={[podsStore]} dependentStores={[podsStore]}
isSelectable={false} isSelectable={false}

View File

@ -43,7 +43,6 @@ export class PodSecurityPolicies extends React.Component {
isConfigurable isConfigurable
tableId="access_pod_security_policies" tableId="access_pod_security_policies"
className="PodSecurityPolicies" className="PodSecurityPolicies"
isClusterScoped={true}
store={podSecurityPoliciesStore} store={podSecurityPoliciesStore}
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (item: PodSecurityPolicy) => item.getName(), [columnId.name]: (item: PodSecurityPolicy) => item.getName(),

View File

@ -49,7 +49,7 @@ export class StorageClasses extends React.Component<Props> {
isConfigurable isConfigurable
tableId="storage_classes" tableId="storage_classes"
className="StorageClasses" className="StorageClasses"
store={storageClassStore} isClusterScoped store={storageClassStore}
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (item: StorageClass) => item.getName(), [columnId.name]: (item: StorageClass) => item.getName(),
[columnId.age]: (item: StorageClass) => item.getTimeDiffFromNow(), [columnId.age]: (item: StorageClass) => item.getTimeDiffFromNow(),

View File

@ -52,7 +52,7 @@ export class PersistentVolumes extends React.Component<Props> {
isConfigurable isConfigurable
tableId="storage_volumes" tableId="storage_volumes"
className="PersistentVolumes" className="PersistentVolumes"
store={volumesStore} isClusterScoped store={volumesStore}
sortingCallbacks={{ sortingCallbacks={{
[columnId.name]: (item: PersistentVolume) => item.getName(), [columnId.name]: (item: PersistentVolume) => item.getName(),
[columnId.storageClass]: (item: PersistentVolume) => item.getStorageClass(), [columnId.storageClass]: (item: PersistentVolume) => item.getStorageClass(),

View File

@ -22,6 +22,7 @@
.Welcome { .Welcome {
text-align: center; text-align: center;
width: 100%; width: 100%;
height: 100%;
z-index: 1; z-index: 1;
.box { .box {

View File

@ -38,6 +38,7 @@ import { Events } from "../+events";
import { isAllowedResource } from "../../../common/rbac"; import { isAllowedResource } from "../../../common/rbac";
import { kubeWatchApi } from "../../api/kube-watch-api"; import { kubeWatchApi } from "../../api/kube-watch-api";
import { clusterContext } from "../context"; import { clusterContext } from "../context";
import { workloadsOverviewDetailRegistry } from "../../../extensions/registries";
interface Props extends RouteComponentProps<IWorkloadsOverviewRouteParams> { interface Props extends RouteComponentProps<IWorkloadsOverviewRouteParams> {
} }
@ -57,11 +58,34 @@ export class WorkloadsOverview extends React.Component<Props> {
} }
render() { render() {
const items = workloadsOverviewDetailRegistry.getItems().map((item, index) => {
return (
<item.components.Details key={`workload-overview-${index}`}/>
);
});
return ( return (
<div className="WorkloadsOverview flex column gaps"> <div className="WorkloadsOverview flex column gaps">
<OverviewStatuses/> {items}
{isAllowedResource("events") && <Events compact hideFilters className="box grow"/>}
</div> </div>
); );
} }
} }
workloadsOverviewDetailRegistry.add([
{
components: {
Details: (props: any) => <OverviewStatuses {...props} />,
}
},
{
priority: 5,
components: {
Details: () => {
return (
isAllowedResource("events") && <Events compact hideFilters className="box grow"/>
);
}
}
}
]);

View File

@ -30,6 +30,19 @@
border: 1px solid gainsboro; border: 1px solid gainsboro;
} }
&.loading {
pointer-events: none;
&:after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: transparentize(white, .85);
}
}
> .editor { > .editor {
position: absolute; position: absolute;
width: inherit; width: inherit;
@ -76,4 +89,4 @@
.ace_comment { .ace_comment {
color: var(--dockEditorComment); color: var(--dockEditorComment);
} }
} }

View File

@ -169,11 +169,12 @@ export class AceEditor extends React.Component<Props, State> {
} }
render() { render() {
const { className, hidden } = this.props; const { className, hidden, children } = this.props;
return ( return (
<div className={cssNames("AceEditor", className, { hidden })}> <div className={cssNames("AceEditor", className, { hidden })}>
<div className="editor" ref={e => this.elem = e}/> <div className="editor" ref={e => this.elem = e}/>
{children}
</div> </div>
); );
} }

View File

@ -39,7 +39,7 @@
--font-weight-normal: 400; --font-weight-normal: 400;
--font-weight-bold: 500; --font-weight-bold: 500;
--main-layout-header: 40px; --main-layout-header: 40px;
--drag-region-height: 22px --drag-region-height: 22px;
} }
*, *:before, *:after { *, *:before, *:after {

View File

@ -71,6 +71,8 @@ import { CommandContainer } from "./command-palette/command-container";
import { KubeObjectStore } from "../kube-object.store"; import { KubeObjectStore } from "../kube-object.store";
import { clusterContext } from "./context"; import { clusterContext } from "./context";
import { namespaceStore } from "./+namespaces/namespace.store"; import { namespaceStore } from "./+namespaces/namespace.store";
import { Sidebar } from "./layout/sidebar";
import { Dock } from "./dock";
@observer @observer
export class App extends React.Component { export class App extends React.Component {
@ -176,7 +178,7 @@ export class App extends React.Component {
return ( return (
<Router history={history}> <Router history={history}>
<ErrorBoundary> <ErrorBoundary>
<MainLayout> <MainLayout sidebar={<Sidebar/>} footer={<Dock/>}>
<Switch> <Switch>
<Route component={ClusterOverview} {...clusterRoute}/> <Route component={ClusterOverview} {...clusterRoute}/>
<Route component={Nodes} {...nodesRoute}/> <Route component={Nodes} {...nodesRoute}/>

View File

@ -46,10 +46,11 @@ export class PieChart extends React.Component<Props> {
const dataset: any = data["datasets"][tooltipItem.datasetIndex]; const dataset: any = data["datasets"][tooltipItem.datasetIndex];
const metaData = Object.values<{ total: number }>(dataset["_meta"])[0]; const metaData = Object.values<{ total: number }>(dataset["_meta"])[0];
const percent = Math.round((dataset["data"][tooltipItem["index"]] / metaData.total) * 100); const percent = Math.round((dataset["data"][tooltipItem["index"]] / metaData.total) * 100);
const label = dataset["label"];
if (isNaN(percent)) return "N/A"; if (isNaN(percent)) return `${label}: N/A`;
return `${percent}%`; return `${label}: ${percent}%`;
}, },
}, },
filter: ({ datasetIndex, index }, { datasets }) => { filter: ({ datasetIndex, index }, { datasets }) => {

View File

@ -32,6 +32,7 @@
grid-area: main; grid-area: main;
position: relative; position: relative;
display: flex; display: flex;
flex-direction: column;
} }
.HotbarMenu { .HotbarMenu {
@ -45,7 +46,7 @@
#lens-views { #lens-views {
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: var(--main-layout-header); // Move below the TopBar
right: 0; right: 0;
bottom: 0; bottom: 0;
display: flex; display: flex;

View File

@ -0,0 +1,44 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React from "react";
import { catalogURL } from "../+catalog";
import type { Cluster } from "../../../main/cluster";
import { navigate } from "../../navigation";
import { Icon } from "../icon";
import { TopBar } from "../layout/topbar";
import { MaterialTooltip } from "../material-tooltip/material-tooltip";
interface Props {
cluster: Cluster
}
export function ClusterTopbar({ cluster }: Props) {
return (
<TopBar label={cluster.name}>
<div>
<MaterialTooltip title="Back to Catalog" placement="left">
<Icon style={{ cursor: "default" }} material="close" onClick={() => navigate(catalogURL())}/>
</MaterialTooltip>
</div>
</TopBar>
);
}

View File

@ -30,20 +30,26 @@ import { ClusterStore } from "../../../common/cluster-store";
import { requestMain } from "../../../common/ipc"; import { requestMain } from "../../../common/ipc";
import { clusterActivateHandler } from "../../../common/cluster-ipc"; import { clusterActivateHandler } from "../../../common/cluster-ipc";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { getMatchedClusterId, navigate } from "../../navigation"; import { navigate } from "../../navigation";
import { catalogURL } from "../+catalog/catalog.route"; import { catalogURL } from "../+catalog/catalog.route";
import { ClusterTopbar } from "./cluster-topbar";
import type { RouteComponentProps } from "react-router-dom";
import type { IClusterViewRouteParams } from "./cluster-view.route";
interface Props extends RouteComponentProps<IClusterViewRouteParams> {
}
@observer @observer
export class ClusterView extends React.Component { export class ClusterView extends React.Component<Props> {
private store = ClusterStore.getInstance(); private store = ClusterStore.getInstance();
constructor(props: {}) { constructor(props: Props) {
super(props); super(props);
makeObservable(this); makeObservable(this);
} }
get clusterId() { @computed get clusterId() {
return getMatchedClusterId(); return this.props.match.params.clusterId;
} }
@computed get cluster(): Cluster | undefined { @computed get cluster(): Cluster | undefined {
@ -60,6 +66,10 @@ export class ClusterView extends React.Component {
this.bindEvents(); this.bindEvents();
} }
componentWillUnmount() {
refreshViews();
}
bindEvents() { bindEvents() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
reaction(() => this.clusterId, async (clusterId) => { reaction(() => this.clusterId, async (clusterId) => {
@ -71,14 +81,12 @@ export class ClusterView extends React.Component {
fireImmediately: true, fireImmediately: true,
}), }),
reaction(() => this.isReady, (ready) => { reaction(() => [this.cluster?.ready, this.cluster?.disconnected], (values) => {
if (ready) { const disconnected = values[1];
refreshViews(this.clusterId); // show cluster's view (iframe)
} else if (hasLoadedView(this.clusterId)) { if (hasLoadedView(this.clusterId) && disconnected) {
navigate(catalogURL()); // redirect to catalog when active cluster get disconnected/not available navigate(catalogURL()); // redirect to catalog when active cluster get disconnected/not available
} }
}, {
fireImmediately: true,
}), }),
]); ]);
} }
@ -87,7 +95,7 @@ export class ClusterView extends React.Component {
const { clusterId, cluster, isReady } = this; const { clusterId, cluster, isReady } = this;
if (cluster && !isReady) { if (cluster && !isReady) {
return <ClusterStatus clusterId={clusterId} className="box center"/>; return <ClusterStatus key={clusterId} clusterId={clusterId} className="box center"/>;
} }
return null; return null;
@ -95,7 +103,8 @@ export class ClusterView extends React.Component {
render() { render() {
return ( return (
<div className="ClusterView flex align-center"> <div className="ClusterView flex column align-center">
{this.cluster && <ClusterTopbar cluster={this.cluster}/>}
{this.renderStatus()} {this.renderStatus()}
</div> </div>
); );

View File

@ -22,6 +22,8 @@
import { observable, when } from "mobx"; import { observable, when } from "mobx";
import { ClusterId, ClusterStore, getClusterFrameUrl } from "../../../common/cluster-store"; import { ClusterId, ClusterStore, getClusterFrameUrl } from "../../../common/cluster-store";
import logger from "../../../main/logger"; import logger from "../../../main/logger";
import { requestMain } from "../../../common/ipc";
import { clusterVisibilityHandler } from "../../../common/cluster-ipc";
export interface LensView { export interface LensView {
isLoaded?: boolean isLoaded?: boolean
@ -56,8 +58,13 @@ export async function initView(clusterId: ClusterId) {
parentElem.appendChild(iframe); parentElem.appendChild(iframe);
logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`); logger.info(`[LENS-VIEW]: waiting cluster to be ready, clusterId=${clusterId}`);
await cluster.whenReady;
await autoCleanOnRemove(clusterId, iframe); try {
await when(() => cluster.ready, { timeout: 5_000 }); // we cannot wait forever because cleanup would be blocked for broken cluster connections
logger.info(`[LENS-VIEW]: cluster is ready, clusterId=${clusterId}`);
} finally {
await autoCleanOnRemove(clusterId, iframe);
}
} }
export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrameElement) { export async function autoCleanOnRemove(clusterId: ClusterId, iframe: HTMLIFrameElement) {
@ -88,5 +95,9 @@ export function refreshViews(visibleClusterId?: string) {
const isVisible = isCurrent && isLoaded && isReady; const isVisible = isCurrent && isLoaded && isReady;
view.style.display = isVisible ? "flex" : "none"; view.style.display = isVisible ? "flex" : "none";
requestMain(clusterVisibilityHandler, clusterId, isVisible).catch(() => {
logger.error(`[LENS-VIEW]: failed to set cluster visibility, clusterId=${clusterId}`);
});
}); });
} }

View File

@ -106,7 +106,7 @@ export class LogList extends React.Component<Props> {
*/ */
@computed @computed
get logs() { get logs() {
const showTimestamps = logTabStore.getData(this.props.id).showTimestamps; const showTimestamps = logTabStore.getData(this.props.id)?.showTimestamps;
if (!showTimestamps) { if (!showTimestamps) {
return logStore.logsWithoutTimestamps; return logStore.logsWithoutTimestamps;

View File

@ -39,6 +39,7 @@ interface Props extends DOMAttributes<HTMLElement> {
errorClass?: IClassName; errorClass?: IClassName;
add: (item: CatalogEntity, index: number) => void; add: (item: CatalogEntity, index: number) => void;
remove: (uid: string) => void; remove: (uid: string) => void;
size?: number;
} }
@observer @observer

View File

@ -31,7 +31,7 @@ import { MaterialTooltip } from "../material-tooltip/material-tooltip";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { Avatar } from "../avatar/avatar"; import { Avatar } from "../avatar/avatar";
interface Props extends DOMAttributes<HTMLElement> { export interface HotbarIconProps extends DOMAttributes<HTMLElement> {
uid: string; uid: string;
title: string; title: string;
source: string; source: string;
@ -40,6 +40,7 @@ interface Props extends DOMAttributes<HTMLElement> {
active?: boolean; active?: boolean;
menuItems?: CatalogEntityContextMenu[]; menuItems?: CatalogEntityContextMenu[];
disabled?: boolean; disabled?: boolean;
size?: number;
} }
function onMenuItemClick(menuItem: CatalogEntityContextMenu) { function onMenuItemClick(menuItem: CatalogEntityContextMenu) {
@ -59,7 +60,7 @@ function onMenuItemClick(menuItem: CatalogEntityContextMenu) {
} }
} }
export const HotbarIcon = observer(({menuItems = [], ...props}: Props) => { export const HotbarIcon = observer(({menuItems = [], size = 40, ...props}: HotbarIconProps) => {
const { uid, title, active, className, source, disabled, onMenuOpen, children, ...rest } = props; const { uid, title, active, className, source, disabled, onMenuOpen, children, ...rest } = props;
const id = `hotbarIcon-${uid}`; const id = `hotbarIcon-${uid}`;
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
@ -77,8 +78,8 @@ export const HotbarIcon = observer(({menuItems = [], ...props}: Props) => {
title={title} title={title}
colorHash={`${title}-${source}`} colorHash={`${title}-${source}`}
className={active ? "active" : "default"} className={active ? "active" : "default"}
width={40} width={size}
height={40} height={size}
/> />
{children} {children}
</div> </div>

View File

@ -157,6 +157,7 @@ export class HotbarMenu extends React.Component<Props> {
className={cssNames({ isDragging: snapshot.isDragging })} className={cssNames({ isDragging: snapshot.isDragging })}
remove={this.removeItem} remove={this.removeItem}
add={this.addItem} add={this.addItem}
size={40}
/> />
) : ( ) : (
<HotbarIcon <HotbarIcon
@ -165,6 +166,7 @@ export class HotbarMenu extends React.Component<Props> {
source={item.entity.source} source={item.entity.source}
menuItems={disabledMenuItems} menuItems={disabledMenuItems}
disabled disabled
size={40}
/> />
)} )}
</div> </div>

View File

@ -135,7 +135,7 @@
&.interactive { &.interactive {
cursor: pointer; cursor: pointer;
transition: 250ms color, 250ms opacity, 150ms background-color, 150ms box-shadow; transition: 250ms color, 250ms opacity, 150ms background-color, 150ms box-shadow;
border-radius: 50%; border-radius: var(--border-radius);
&.focusable:focus:not(:hover) { &.focusable:focus:not(:hover) {
box-shadow: 0 0 0 2px var(--focus-color); box-shadow: 0 0 0 2px var(--focus-color);

View File

@ -28,7 +28,7 @@
padding: var(--flex-gap); padding: var(--flex-gap);
.title { .title {
color: $textColorPrimary; color: var(--textColorTertiary);
} }
.info-panel { .info-panel {

View File

@ -24,7 +24,7 @@ import groupBy from "lodash/groupBy";
import React, { ReactNode } from "react"; import React, { ReactNode } from "react";
import { computed, makeObservable } from "mobx"; import { computed, makeObservable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { observer } from "mobx-react";
import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog"; import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog";
import { Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps, TableSortCallback } from "../table"; import { Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps, TableSortCallback } from "../table";
import { boundMethod, createStorage, cssNames, IClassName, isReactNode, noop, ObservableToggleSet, prevDefault, stopPropagation } from "../../utils"; import { boundMethod, createStorage, cssNames, IClassName, isReactNode, noop, ObservableToggleSet, prevDefault, stopPropagation } from "../../utils";
@ -36,13 +36,14 @@ import { SearchInputUrl } from "../input";
import { Filter, FilterType, pageFilters } from "./page-filters.store"; import { Filter, FilterType, pageFilters } from "./page-filters.store";
import { PageFiltersList } from "./page-filters-list"; import { PageFiltersList } from "./page-filters-list";
import { PageFiltersSelect } from "./page-filters-select"; import { PageFiltersSelect } from "./page-filters-select";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
import { ThemeStore } from "../../theme.store"; import { ThemeStore } from "../../theme.store";
import { MenuActions } from "../menu/menu-actions"; import { MenuActions } from "../menu/menu-actions";
import { MenuItem } from "../menu"; import { MenuItem } from "../menu";
import { Checkbox } from "../checkbox"; import { Checkbox } from "../checkbox";
import { UserStore } from "../../../common/user-store"; import { UserStore } from "../../../common/user-store";
import { namespaceStore } from "../+namespaces/namespace.store"; import { namespaceStore } from "../+namespaces/namespace.store";
import { KubeObjectStore } from "../../kube-object.store";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
// todo: refactor, split to small re-usable components // todo: refactor, split to small re-usable components
@ -63,7 +64,6 @@ export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
store: ItemStore<T>; store: ItemStore<T>;
dependentStores?: ItemStore[]; dependentStores?: ItemStore[];
preloadStores?: boolean; preloadStores?: boolean;
isClusterScoped?: boolean;
hideFilters?: boolean; hideFilters?: boolean;
searchFilters?: SearchFilter<T>[]; searchFilters?: SearchFilter<T>[];
/** @deprecated */ /** @deprecated */
@ -137,7 +137,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
} }
async componentDidMount() { async componentDidMount() {
const { isClusterScoped, isConfigurable, tableId, preloadStores } = this.props; const { isConfigurable, tableId, preloadStores } = this.props;
if (isConfigurable && !tableId) { if (isConfigurable && !tableId) {
throw new Error("[ItemListLayout]: configurable list require props.tableId to be specified"); throw new Error("[ItemListLayout]: configurable list require props.tableId to be specified");
@ -149,12 +149,6 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
if (preloadStores) { if (preloadStores) {
this.loadStores(); this.loadStores();
if (!isClusterScoped) {
disposeOnUnmount(this, [
namespaceStore.onContextChange(() => this.loadStores())
]);
}
} }
} }
@ -162,7 +156,6 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
const { store, dependentStores } = this.props; const { store, dependentStores } = this.props;
const stores = Array.from(new Set([store, ...dependentStores])); const stores = Array.from(new Set([store, ...dependentStores]));
// load context namespaces by default (see also: `<NamespaceSelectFilter/>`)
stores.forEach(store => store.loadAll(namespaceStore.contextNamespaces)); stores.forEach(store => store.loadAll(namespaceStore.contextNamespaces));
} }
@ -224,7 +217,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
} }
@computed get items() { @computed get items() {
const {filters, filterCallbacks } = this; const { filters, filterCallbacks } = this;
const filterGroups = groupBy<Filter>(filters, ({ type }) => type); const filterGroups = groupBy<Filter>(filters, ({ type }) => type);
const filterItems: ItemsFilter[] = []; const filterItems: ItemsFilter[] = [];
@ -330,7 +323,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
return null; return null;
} }
return <PageFiltersList filters={filters}/>; return <PageFiltersList filters={filters} />;
} }
renderNoItems() { renderNoItems() {
@ -355,7 +348,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
); );
} }
return <NoItems/>; return <NoItems />;
} }
renderItems() { renderItems() {
@ -397,20 +390,26 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
} }
renderHeader() { renderHeader() {
const { showHeader, customizeHeader, renderHeaderTitle, headerClassName, isClusterScoped } = this.props; const { showHeader, customizeHeader, renderHeaderTitle, headerClassName } = this.props;
if (!showHeader) return null; if (!showHeader) {
return null;
}
const showNamespaceSelectFilter = this.props.store instanceof KubeObjectStore && this.props.store.api.isNamespaced;
const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle; const title = typeof renderHeaderTitle === "function" ? renderHeaderTitle(this) : renderHeaderTitle;
const placeholders: IHeaderPlaceholders = { const placeholders: IHeaderPlaceholders = {
title: <h5 className="title">{title}</h5>, title: <h5 className="title">{title}</h5>,
info: this.renderInfo(), info: this.renderInfo(),
filters: <> filters: (
{!isClusterScoped && <NamespaceSelectFilter/>} <>
<PageFiltersSelect allowEmpty disableFilters={{ {showNamespaceSelectFilter && <NamespaceSelectFilter />}
[FilterType.NAMESPACE]: true, // namespace-select used instead <PageFiltersSelect allowEmpty disableFilters={{
}}/> [FilterType.NAMESPACE]: true, // namespace-select used instead
</>, }} />
search: <SearchInputUrl/>, </>
),
search: <SearchInputUrl />,
}; };
let header = this.renderHeaderContent(placeholders); let header = this.renderHeaderContent(placeholders);

View File

@ -1,88 +0,0 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
jest.mock("../../../../common/ipc");
import React from "react";
import { render } from "@testing-library/react";
import "@testing-library/jest-dom/extend-expect";
import { MainLayoutHeader } from "../main-layout-header";
import { Cluster } from "../../../../main/cluster";
import { ClusterStore } from "../../../../common/cluster-store";
import mockFs from "mock-fs";
describe("<MainLayoutHeader />", () => {
let cluster: Cluster;
beforeEach(() => {
const mockOpts = {
"minikube-config.yml": JSON.stringify({
apiVersion: "v1",
clusters: [{
name: "minikube",
cluster: {
server: "https://192.168.64.3:8443",
},
}],
contexts: [{
context: {
cluster: "minikube",
user: "minikube",
},
name: "minikube",
}],
users: [{
name: "minikube",
}],
kind: "Config",
preferences: {},
})
};
mockFs(mockOpts);
ClusterStore.createInstance();
cluster = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
});
});
afterEach(() => {
ClusterStore.resetInstance();
mockFs.restore();
});
it("renders w/o errors", () => {
const { container } = render(<MainLayoutHeader cluster={cluster} />);
expect(container).toBeInstanceOf(HTMLElement);
});
it("renders cluster name", () => {
const { getByText } = render(<MainLayoutHeader cluster={cluster} />);
expect(getByText("minikube")).toBeInTheDocument();
});
});

View File

@ -0,0 +1,50 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.mainLayout {
display: grid;
grid-template-areas:
"sidebar contents"
"sidebar footer";
grid-template-rows: [contents] 1fr [footer] auto;
grid-template-columns: [sidebar] var(--sidebar-width) [contents] 1fr;
width: 100%;
z-index: 1;
height: calc(100% - var(--main-layout-header));
}
.sidebar {
grid-area: sidebar;
display: flex;
position: relative;
background: var(--sidebarBackground);
}
.contents {
grid-area: contents;
overflow: auto;
}
.footer {
position: relative;
grid-area: footer;
min-width: 0; /* restrict size when overflow content (e.g. <Dock> tabs scrolling) */
}

View File

@ -1,76 +0,0 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.MainLayout {
display: grid;
grid-template-areas:
"aside header"
"aside tabs"
"aside main"
"aside footer";
grid-template-rows: [header] var(--main-layout-header) [tabs] min-content [main] 1fr [footer] auto;
grid-template-columns: [sidebar] minmax(var(--main-layout-header), min-content) [main] 1fr;
height: 100%;
> header {
grid-area: header;
background: $layoutBackground;
padding: $padding $padding * 2;
}
> aside {
grid-area: aside;
position: relative;
background: $sidebarBackground;
white-space: nowrap;
transition: width 150ms cubic-bezier(0.4, 0, 0.2, 1);
width: var(--sidebar-width);
&.compact {
position: absolute;
width: var(--main-layout-header);
height: 100%;
overflow: hidden;
&:hover {
width: var(--sidebar-width);
transition-delay: 750ms;
box-shadow: 3px 3px 16px rgba(0, 0, 0, 0.35);
z-index: $zIndex-sidebar-hover;
}
}
}
> main {
display: contents;
> * {
grid-area: main;
overflow: auto;
}
}
footer {
position: relative;
grid-area: footer;
min-width: 0; // restrict size when overflow content (e.g. <Dock> tabs scrolling)
}
}

View File

@ -19,73 +19,53 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import "./main-layout.scss"; import styles from "./main-layout.module.css";
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { getHostedCluster } from "../../../common/cluster-store";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Dock } from "../dock";
import { ErrorBoundary } from "../error-boundary"; import { ErrorBoundary } from "../error-boundary";
import { ResizeDirection, ResizeGrowthDirection, ResizeSide, ResizingAnchor } from "../resizing-anchor"; import { ResizeDirection, ResizeGrowthDirection, ResizeSide, ResizingAnchor } from "../resizing-anchor";
import { MainLayoutHeader } from "./main-layout-header";
import { Sidebar } from "./sidebar";
import { sidebarStorage } from "./sidebar-storage"; import { sidebarStorage } from "./sidebar-storage";
export interface MainLayoutProps { interface Props {
className?: any; sidebar: React.ReactNode;
className?: string;
footer?: React.ReactNode; footer?: React.ReactNode;
headerClass?: string;
footerClass?: string;
} }
@observer @observer
export class MainLayout extends React.Component<MainLayoutProps> { export class MainLayout extends React.Component<Props> {
onSidebarCompactModeChange = () => {
sidebarStorage.merge(draft => {
draft.compact = !draft.compact;
});
};
onSidebarResize = (width: number) => { onSidebarResize = (width: number) => {
sidebarStorage.merge({ width }); sidebarStorage.merge({ width });
}; };
render() { render() {
const cluster = getHostedCluster(); const { onSidebarResize } = this;
const { onSidebarCompactModeChange, onSidebarResize } = this; const { className, footer, children, sidebar } = this.props;
const { className, headerClass, footer, footerClass, children } = this.props; const { width: sidebarWidth } = sidebarStorage.get();
const { compact, width: sidebarWidth } = sidebarStorage.get();
const style = { "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties; const style = { "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties;
if (!cluster) {
return null; // fix: skip render when removing active (visible) cluster
}
return ( return (
<div className={cssNames("MainLayout", className)} style={style}> <div className={cssNames(styles.mainLayout, className)} style={style}>
<MainLayoutHeader className={headerClass} cluster={cluster}/> <div className={styles.sidebar}>
{sidebar}
<aside className={cssNames("flex column", { compact })}>
<Sidebar className="box grow" compact={compact} toggle={onSidebarCompactModeChange}/>
<ResizingAnchor <ResizingAnchor
direction={ResizeDirection.HORIZONTAL} direction={ResizeDirection.HORIZONTAL}
placement={ResizeSide.TRAILING} placement={ResizeSide.TRAILING}
growthDirection={ResizeGrowthDirection.LEFT_TO_RIGHT} growthDirection={ResizeGrowthDirection.LEFT_TO_RIGHT}
getCurrentExtent={() => sidebarWidth} getCurrentExtent={() => sidebarWidth}
onDrag={onSidebarResize} onDrag={onSidebarResize}
onDoubleClick={onSidebarCompactModeChange}
disabled={compact}
minExtent={120} minExtent={120}
maxExtent={400} maxExtent={400}
/> />
</aside> </div>
<main> <div className={styles.contents}>
<ErrorBoundary>{children}</ErrorBoundary> <ErrorBoundary>{children}</ErrorBoundary>
</main> </div>
<footer className={footerClass}>{footer ?? <Dock/>}</footer> <div className={styles.footer}>{footer}</div>
</div> </div>
); );
} }

View File

@ -62,10 +62,6 @@ export class SidebarItem extends React.Component<SidebarItemProps> {
return this.props.id; return this.props.id;
} }
@computed get compact(): boolean {
return Boolean(sidebarStorage.get().compact);
}
@computed get expanded(): boolean { @computed get expanded(): boolean {
return Boolean(sidebarStorage.get().expanded[this.id]); return Boolean(sidebarStorage.get().expanded[this.id]);
} }
@ -78,8 +74,6 @@ export class SidebarItem extends React.Component<SidebarItemProps> {
} }
@computed get isExpandable(): boolean { @computed get isExpandable(): boolean {
if (this.compact) return false; // not available in compact-mode currently
return Boolean(this.props.children); return Boolean(this.props.children);
} }
@ -108,10 +102,8 @@ export class SidebarItem extends React.Component<SidebarItemProps> {
if (isHidden) return null; if (isHidden) return null;
const { isActive, id, compact, expanded, isExpandable, toggleExpand } = this; const { isActive, id, expanded, isExpandable, toggleExpand } = this;
const classNames = cssNames(SidebarItem.displayName, className, { const classNames = cssNames(SidebarItem.displayName, className);
compact,
});
return ( return (
<div className={classNames} data-test-id={id}> <div className={classNames} data-test-id={id}>

View File

@ -23,14 +23,12 @@ import { createStorage } from "../../utils";
export interface SidebarStorageState { export interface SidebarStorageState {
width: number; width: number;
compact: boolean;
expanded: { expanded: {
[itemId: string]: boolean; [itemId: string]: boolean;
} }
} }
export const sidebarStorage = createStorage<SidebarStorageState>("sidebar", { export const sidebarStorage = createStorage<SidebarStorageState>("sidebar", {
width: 200, // sidebar size in non-compact mode width: 200,
compact: false, // compact-mode (icons only)
expanded: {}, expanded: {},
}); });

View File

@ -23,49 +23,9 @@
$iconSize: 24px; $iconSize: 24px;
$itemSpacing: floor($unit / 2.6) floor($unit / 1.6); $itemSpacing: floor($unit / 2.6) floor($unit / 1.6);
&.compact {
.sidebar-nav {
@include hidden-scrollbar; // fix: scrollbar overlaps icons
}
}
.header {
background: $sidebarLogoBackground;
padding: $padding / 2;
height: var(--main-layout-header);
a {
font-size: 18.5px;
text-decoration: none;
}
div.logo-text {
position: absolute;
left: 42px;
top: 11px;
}
.logo-icon {
width: 28px;
height: 28px;
margin-left: 2px;
margin-top: 2px;
margin-right: 10px;
svg {
--size: 28px;
padding: 2px;
}
}
.pin-icon {
margin: auto;
margin-right: $padding / 2;
}
}
.sidebar-nav { .sidebar-nav {
padding: $padding / 1.5 0; width: var(--sidebar-width);
padding-bottom: calc(var(--padding) * 3);
overflow: auto; overflow: auto;
.Icon { .Icon {

View File

@ -24,7 +24,6 @@ import type { TabLayoutRoute } from "./tab-layout";
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { NavLink } from "react-router-dom";
import { cssNames } from "../../utils"; import { cssNames } from "../../utils";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { workloadsRoute, workloadsURL } from "../+workloads/workloads.route"; import { workloadsRoute, workloadsURL } from "../+workloads/workloads.route";
@ -52,8 +51,6 @@ import { SidebarItem } from "./sidebar-item";
interface Props { interface Props {
className?: string; className?: string;
compact?: boolean; // compact-mode view: show only icons and expand on :hover
toggle(): void; // compact-mode updater
} }
@observer @observer
@ -173,24 +170,11 @@ export class Sidebar extends React.Component<Props> {
} }
render() { render() {
const { toggle, compact, className } = this.props; const { className } = this.props;
return ( return (
<div className={cssNames(Sidebar.displayName, "flex column", { compact }, className)}> <div className={cssNames(Sidebar.displayName, "flex column", className)}>
<div className="header flex align-center"> <div className={cssNames("sidebar-nav flex column box grow-fixed")}>
<NavLink exact to="/" className="box grow">
<Icon svg="logo-lens" className="logo-icon"/>
<div className="logo-text">Lens</div>
</NavLink>
<Icon
focusable={false}
className="pin-icon"
tooltip="Compact view"
material={compact ? "keyboard_arrow_right" : "keyboard_arrow_left"}
onClick={toggle}
/>
</div>
<div className={cssNames("sidebar-nav flex column box grow-fixed", { compact })}>
<SidebarItem <SidebarItem
id="cluster" id="cluster"
text="Cluster" text="Cluster"

View File

@ -21,18 +21,19 @@
.TabLayout { .TabLayout {
display: contents; display: flex;
flex-direction: column;
height: 100%;
> .Tabs { > .Tabs {
grid-area: tabs;
background: $layoutTabsBackground; background: $layoutTabsBackground;
min-height: 32px;
} }
main { main {
$spacing: $margin * 2; $spacing: $margin * 2;
grid-area: main; flex-grow: 1;
overflow-y: scroll; // always reserve space for scrollbar (17px) overflow-y: scroll; // always reserve space for scrollbar (17px)
overflow-x: auto; overflow-x: auto;
margin: $spacing; margin: $spacing;

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
.topBar {
display: grid;
grid-template-columns: [title] 1fr [controls] auto;
grid-template-rows: var(--main-layout-header);
grid-template-areas: "title controls";
background-color: var(--layoutBackground);
z-index: 1;
width: 100%;
}
.title {
@apply font-bold px-6;
color: var(--textColorAccent);
align-items: center;
display: flex;
}
.controls {
align-self: flex-end;
padding-right: 1.5rem;
align-items: center;
display: flex;
height: 100%;
}

View File

@ -19,20 +19,19 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import styles from "./topbar.module.css";
import React from "react"; import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { Cluster } from "../../../main/cluster";
import { cssNames } from "../../utils";
interface Props { interface Props extends React.HTMLAttributes<any> {
cluster: Cluster label: React.ReactNode;
className?: string
} }
export const MainLayoutHeader = observer(({ cluster, className }: Props) => { export const TopBar = observer(({ label, children, ...rest }: Props) => {
return ( return (
<header className={cssNames("flex gaps align-center justify-space-between", className)}> <div className={styles.topBar} {...rest}>
<span className="cluster">{cluster.name}</span> <div className={styles.title}>{label}</div>
</header> <div className={styles.controls}>{children}</div>
</div>
); );
}); });

View File

@ -37,7 +37,7 @@ export class Notifications extends React.Component {
static ok(message: NotificationMessage) { static ok(message: NotificationMessage) {
notificationsStore.add({ notificationsStore.add({
message, message,
timeout: 2500, timeout: 2_500,
status: NotificationStatus.OK status: NotificationStatus.OK
}); });
} }
@ -45,12 +45,19 @@ export class Notifications extends React.Component {
static error(message: NotificationMessage, customOpts: Partial<Notification> = {}) { static error(message: NotificationMessage, customOpts: Partial<Notification> = {}) {
notificationsStore.add({ notificationsStore.add({
message, message,
timeout: 10000, timeout: 10_000,
status: NotificationStatus.ERROR, status: NotificationStatus.ERROR,
...customOpts ...customOpts
}); });
} }
static shortInfo(message: NotificationMessage, customOpts: Partial<Notification> = {}) {
this.info(message, {
timeout: 5_000,
...customOpts
});
}
static info(message: NotificationMessage, customOpts: Partial<Notification> = {}) { static info(message: NotificationMessage, customOpts: Partial<Notification> = {}) {
return notificationsStore.add({ return notificationsStore.add({
status: NotificationStatus.INFO, status: NotificationStatus.INFO,

View File

@ -31,6 +31,23 @@ body.resizing {
position: absolute; position: absolute;
z-index: 10; z-index: 10;
&::after {
content: " ";
display: block;
width: 3px;
height: 100%;
margin-left: 50%;
background: transparent;
transition: all 0.2s 0s;
}
&:hover {
&::after {
background: var(--blue);
transition: all 0.2s 0.5s;
}
}
&.disabled { &.disabled {
display: none; display: none;
} }
@ -56,6 +73,17 @@ body.resizing {
cursor: col-resize; cursor: col-resize;
width: $dimension; width: $dimension;
// Expand hoverable area while resizing to keep highlighting resizer.
// Otherwise, cursor can move far away dropping hover indicator.
.resizing & {
$expandedWidth: 200px;
width: $expandedWidth;
&.trailing {
right: -$expandedWidth / 2;
}
}
&.leading { &.leading {
left: -$dimension / 2; left: -$dimension / 2;
} }

Some files were not shown because too many files have changed in this diff Show More