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:
release:
types:
- published
jobs:
publish:
name: Publish NPM
name: Publish NPM Package Release
runs-on: ubuntu-latest
strategy:
matrix:

View File

@ -110,7 +110,8 @@ build-extension-types: node_modules src/extensions/npm/extensions/dist
.PHONY: publish-npm
publish-npm: node_modules build-npm
./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
docs:

View File

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

View File

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

View File

@ -114,14 +114,14 @@ There is a way of detect active theme and its changes in JS. [MobX observer func
```js
import React from "react"
import { observer } from "mobx-react"
import { App, Component, Theme } from "@k8slens/extensions";
import { Renderer } from "@k8slens/extensions";
@observer
export class SupportPage extends React.Component {
render() {
return (
<div className="SupportPage">
<h1>Active theme is {Theme.getActiveTheme().name}</h1>
<h1>Active theme is {Renderer.Theme.getActiveTheme().name}</h1>
</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.
``` typescript
import { LensRendererExtension } from "@k8slens/extensions";
import { Renderer } from "@k8slens/extensions";
import { ExampleIcon, ExamplePage } from "./page"
import React from "react"
export default class ExampleExtension extends LensRendererExtension {
export default class ExampleExtension extends Renderer.LensExtension {
clusterPages = [
{
id: "extension-example",

View File

@ -30,9 +30,8 @@ Each guide or code sample includes the following:
| Sample | APIs |
| ----- | ----- |
[hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-sample) | LensMainExtension <br> LensRendererExtension <br> Component.Icon <br> 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> Component.Icon <br> 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> Component.Icon <br> 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 |
[hello-world](https://github.com/lensapp/lens-extension-samples/tree/master/helloworld-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> 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> 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> Renderer.Component.Icon <br> Renderer.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 |

View File

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

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:
```typescript
export default class CrdSampleExtension extends LensRendererExtension {
export default class CrdSampleExtension extends Renderer.LensExtension {
}
```
@ -27,11 +27,21 @@ This object will register a menu item with "Certificates" text.
It will also use `CertificateIcon` component to render an icon and navigate to cluster page that is having `certificates` page id.
```typescript
export function CertificateIcon(props: Component.IconProps) {
return <Component.Icon {...props} material="security" tooltip="Certificates"/>
import { Renderer } from "@k8slens/extensions";
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 = [
{
@ -48,7 +58,7 @@ export default class CrdSampleExtension extends LensRendererExtension {
Then we need to register `PageRegistration` object with `certificates` id and define `CertificatePage` component to render certificates.
```typescript
export default class CrdSampleExtension extends LensRendererExtension {
export default class CrdSampleExtension extends Renderer.LensExtension {
...
clusterPages = [{
@ -65,18 +75,29 @@ export default class CrdSampleExtension extends LensRendererExtension {
In the previous step we defined `CertificatePage` component to render certificates.
In this step we will actually implement that.
`CertificatePage` is a React component that will render `Component.KubeObjectListLayout` component to list `Certificate` CRD objects.
`CertificatePage` is a React component that will render `Renderer.Component.KubeObjectListLayout` component to list `Certificate` CRD objects.
### Get CRD objects
In order to list CRD objects, we need first fetch those from Kubernetes API.
Lens Extensions API provides easy mechanism to do this.
We just need to define `Certificate` class derived from `K8sApi.KubeObject`, `CertificatesApi`derived from `K8sApi.KubeApi` and `CertificatesStore` derived from `K8sApi.KubeObjectStore`.
We just need to define `Certificate` class derived from `Renderer.K8sApi.KubeObject`, `CertificatesApi`derived from `Renderer.K8sApi.KubeApi` and `CertificatesStore` derived from `Renderer.K8sApi.KubeObjectStore`.
`Certificate` class defines properties found in the CRD object:
```typescript
export class Certificate extends K8sApi.KubeObject {
import { Renderer } from "@k8slens/extensions";
const {
K8sApi: {
KubeObject,
KubeObjectStore,
KubeApi,
apiManager,
},
} = Renderer;
export class Certificate extends KubeObject {
static kind = "Certificate"
static namespaced = true
static apiBase = "/apis/cert-manager.io/v1alpha2/certificates"
@ -121,8 +142,8 @@ export class Certificate extends K8sApi.KubeObject {
With `CertificatesApi` class we are able to manage `Certificate` objects in Kubernetes API:
```typescript
export class CertificatesApi extends K8sApi.KubeApi<Certificate> {
}
export class CertificatesApi extends KubeApi<Certificate> {}
export const certificatesApi = new CertificatesApi({
objectConstructor: Certificate
});
@ -131,7 +152,7 @@ export const certificatesApi = new CertificatesApi({
`CertificateStore` defines storage for `Certificate` objects
```typescript
export class CertificatesStore extends K8sApi.KubeObjectStore<Certificate> {
export class CertificatesStore extends KubeObjectStore<Certificate> {
api = certificatesApi
}
@ -141,7 +162,7 @@ export const certificatesStore = new CertificatesStore();
And, finally, we register this store to Lens's API manager.
```typescript
K8sApi.apiManager.registerStore(certificatesStore);
apiManager.registerStore(certificatesStore);
```
@ -153,23 +174,32 @@ Then we need to fetch those and render them in the UI.
First we define `CertificatePage` class that extends `React.Component`.
```typescript
import { Component, LensRendererExtension } from "@k8slens/extensions";
import { Renderer } from "@k8slens/extensions";
import React from "react";
import { certificatesStore } from "../certificate-store";
import { Certificate } from "../certificate"
export class CertificatePage extends React.Component<{ extension: LensRendererExtension }> {
export class CertificatePage extends React.Component<{ extension: Renderer.LensExtension }> {
}
```
Next we will implement `render` method that will display certificates in a list.
To do that, we just need to add `Component.KubeObjectListLayout` component inside `Component.TabLayout` component in render method.
To define which objects the list is showing, we need to pass `certificateStore` object to `Component.KubeObjectListLayout` in `store` property.
`Component.KubeObjectListLayout` will fetch automatically items from the given store when component is mounted.
To do that, we just need to add `Renderer.Component.KubeObjectListLayout` component inside `Renderer.Component.TabLayout` component in render method.
To define which objects the list is showing, we need to pass `certificateStore` object to `Renderer.Component.KubeObjectListLayout` in `store` property.
`Renderer.Component.KubeObjectListLayout` will fetch automatically items from the given store when component is mounted.
Also, we can define needed sorting callbacks and search filters for the list:
```typescript
import { Renderer } from "@k8slens/extensions";
const {
Component: {
TabLayout,
KubeObjectListLayout,
},
} = Renderer;
enum sortBy {
name = "name",
namespace = "namespace",
@ -181,8 +211,8 @@ export class CertificatePage extends React.Component<{ extension: LensRendererEx
render() {
return (
<Component.TabLayout>
<Component.KubeObjectListLayout
<TabLayout>
<KubeObjectListLayout
className="Certicates" store={certificatesStore}
sortingCallbacks={{
[sortBy.name]: (certificate: Certificate) => certificate.getName(),
@ -204,7 +234,7 @@ export class CertificatePage extends React.Component<{ extension: LensRendererEx
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:
```typescript
export default class CrdSampleExtension extends LensRendererExtension {
export default class CrdSampleExtension extends Renderer.LensExtension {
//...
kubeObjectDetailItems = [{
@ -235,14 +265,22 @@ export default class CrdSampleExtension extends LensRendererExtension {
Here we defined that `CertificateDetails` component will render the resource details.
So, next we need to implement that component.
Lens will inject `Certificate` object into our component so we just need to render some information out of it.
We can use `Component.DrawerItem` component from Lens Extensions API to give the same look and feel as Lens is using elsewhere:
We can use `Renderer.Component.DrawerItem` component from Lens Extensions API to give the same look and feel as Lens is using elsewhere:
```typescript
import { Component, K8sApi } from "@k8slens/extensions";
import { Renderer } from "@k8slens/extensions";
import React from "react";
import { Certificate } from "../certificate";
export interface CertificateDetailsProps extends Component.KubeObjectDetailsProps<Certificate>{
const {
Component: {
KubeObjectDetailsProps,
DrawerItem,
Badge,
}
} = Renderer;
export interface CertificateDetailsProps extends KubeObjectDetailsProps<Certificate>{
}
export class CertificateDetails extends React.Component<CertificateDetailsProps> {
@ -252,29 +290,29 @@ export class CertificateDetails extends React.Component<CertificateDetailsProps>
if (!certificate) return null;
return (
<div className="Certificate">
<Component.DrawerItem name="Created">
<DrawerItem name="Created">
{certificate.getAge(true, false)} ago ({certificate.metadata.creationTimestamp })
</Component.DrawerItem>
<Component.DrawerItem name="DNS Names">
</DrawerItem>
<DrawerItem name="DNS Names">
{certificate.spec.dnsNames.join(",")}
</Component.DrawerItem>
<Component.DrawerItem name="Secret">
</DrawerItem>
<DrawerItem name="Secret">
{certificate.spec.secretName}
</Component.DrawerItem>
<Component.DrawerItem name="Status" className="status" labelsOnly>
</DrawerItem>
<DrawerItem name="Status" className="status" labelsOnly>
{certificate.status.conditions.map((condition, index) => {
const { type, reason, message, status } = condition;
const kind = type || reason;
if (!kind) return null;
return (
<Component.Badge
<Badge
key={kind + index} label={kind}
className={"success "+kind.toLowerCase()}
tooltip={message}
/>
);
})}
</Component.DrawerItem>
</DrawerItem>
</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:
```typescript
import { LensMainExtension } from "@k8slens/extensions";
import { Main } from "@k8slens/extensions";
export default class ExampleExtensionMain extends LensMainExtension {
export default class ExampleExtensionMain extends Main.LensExtension {
onActivate() {
console.log('custom main process extension code started');
}
@ -39,34 +39,6 @@ The example above logs messages when the extension is enabled and disabled.
To see standard output from the main process there must be a console connected to it.
Achieve this by starting Lens from the command prompt.
The following example is a little more interesting.
It accesses some Lens state data, and it periodically logs the name of the cluster that is currently active in Lens.
```typescript
import { LensMainExtension, Store } from "@k8slens/extensions";
export default class ActiveClusterExtensionMain extends LensMainExtension {
timer: NodeJS.Timeout
onActivate() {
console.log("Cluster logger activated");
this.timer = setInterval(() => {
if (!Store.ClusterStore.getInstance().active) {
console.log("No active cluster");
return;
}
console.log("active cluster is", Store.ClusterStore.getInstance().active.contextName)
}, 5000)
}
onDeactivate() {
clearInterval(this.timer)
console.log("Cluster logger deactivated");
}
}
```
For more details on accessing Lens state data, please see the [Stores](../stores) guide.
### `appMenus`
@ -76,9 +48,9 @@ Note that this is the only UI feature that the Main Extension API allows you to
The following example demonstrates adding an item to the **Help** menu.
``` typescript
import { LensMainExtension } from "@k8slens/extensions";
import { Main } from "@k8slens/extensions";
export default class SamplePageMainExtension extends LensMainExtension {
export default class SamplePageMainExtension extends Main.LensExtension {
appMenus = [
{
parentId: "help",
@ -102,4 +74,4 @@ Valid values include: `"file"`, `"edit"`, `"view"`, and `"help"`.
* `click()` is called when the menu item is selected.
In this example, we simply log a message.
However, you would typically have this navigate to a specific page or perform another operation.
Note that pages are associated with the [`LensRendererExtension`](renderer-extension.md) class and can be defined in the process of extending it.
Note that pages are associated with the [`Renderer.LensExtension`](renderer-extension.md) class and can be defined in the process of extending it.

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -64,6 +64,8 @@ export async function waitForMinikubeDashboard(app: Application) {
await app.client.setValue(".Input.SearchInput input", "minikube");
await app.client.waitUntilTextExists("div.TableCell", "minikube");
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.waitForExist(`iframe[name="minikube"]`);
await app.client.frame("minikube");

View File

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

View File

@ -104,6 +104,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
context.menuItems = [
{
title: "Settings",
icon: "edit",
onlyVisibleForSource: "local",
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)) {
context.menuItems.push({
title: "Delete",
icon: "delete",
onlyVisibleForSource: "local",
onClick: async () => ClusterStore.getInstance().removeById(this.metadata.uid),
confirm: {
@ -123,6 +125,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
if (this.status.phase == "connected") {
context.menuItems.push({
title: "Disconnect",
icon: "link_off",
onClick: async () => {
requestMain(clusterDisconnectHandler, this.metadata.uid);
}
@ -130,6 +133,7 @@ export class KubernetesCluster extends CatalogEntity<CatalogEntityMetadata, Kube
} else {
context.menuItems.push({
title: "Connect",
icon: "link",
onClick: async () => {
context.navigate(`/cluster/${this.metadata.uid}`);
}
@ -147,7 +151,7 @@ export class KubernetesClusterCategory extends CatalogCategory {
public readonly kind = "CatalogCategory";
public metadata = {
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 = {
group: "entity.k8slens.dev",

View File

@ -96,9 +96,25 @@ export interface CatalogEntityActionContext {
}
export interface CatalogEntityContextMenu {
/**
* Menu title
*/
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>;
/**
* Confirm click with a message
*/
confirm?: {
message: string;
}
@ -175,7 +191,6 @@ export abstract class CatalogEntity<
}
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 onSettingsOpen(context: CatalogEntitySettingsContext): void | Promise<void>;
}

View File

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

View File

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

View File

@ -41,3 +41,11 @@ export function buildURL<P extends object = {}, Q extends object = {}>(path: str
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 appName = `${packageInfo.productName}${isDevelopment ? "Dev" : ""}`;
export const publicPath = "/build/";
export const publicPath = "/build/" as string;
// Webpack build paths
export const contextDir = process.cwd();
@ -60,13 +60,13 @@ defineGlobal("__static", {
});
// Apis
export const apiPrefix = "/api"; // local router apis
export const apiKubePrefix = "/api-kube"; // k8s cluster apis
export const apiPrefix = "/api" as string; // local router apis
export const apiKubePrefix = "/api-kube" as string; // k8s cluster apis
// Links
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues";
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI";
export const supportUrl = "https://docs.k8slens.dev/latest/support/";
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues" as string;
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI" as string;
export const supportUrl = "https://docs.k8slens.dev/latest/support/" as string;
// This explicitly ignores the prerelease info on the package version
export const appSemVer = new SemVer(packageInfo.version);

View File

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

View File

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

View File

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

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 "./welcome-menu-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/+workloads-pods/pod-details-list";
export * from "../../renderer/components/+namespaces/namespace-select";
export * from "../../renderer/components/+namespaces/namespace-select-filter";
export * from "../../renderer/components/layout/sub-title";
export * from "../../renderer/components/input/search-input";
export * from "../../renderer/components/chart/bar-chart";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,4 @@
* 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 default {
Trans: ({ children }: { children: React.ReactNode }) => children,
t: (message: string) => message
};
export * from "./parse-query";

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

View File

@ -97,7 +97,7 @@ export class KubeObject<Metadata extends IKubeObjectMetadata = IKubeObjectMetada
status?: Status;
spec?: Spec;
static create(data: any) {
static create(data: KubeJsonApiData) {
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 (
isObject(object)
&& hasTypedProperty(object, "kind", isString)

View File

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

View File

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

View File

@ -66,10 +66,8 @@ export class ReleaseRollbackDialog extends React.Component<Props> {
onOpen = async () => {
this.isLoading = true;
const currentRevision = this.release.getRevision();
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
this.revisions.replace(releases);
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 { HelmReleaseMenu } from "./release-menu";
import { secretsStore } from "../+config-secrets/secrets.store";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
enum columnId {
name = "name",
@ -117,6 +118,15 @@ export class HelmReleases extends Component<Props> {
(release: HelmRelease) => release.getVersion(),
]}
renderHeaderTitle="Releases"
customizeHeader={({ filters, ...headerPlaceholders }) => ({
filters: (
<>
{filters}
<NamespaceSelectFilter />
</>
),
...headerPlaceholders,
})}
renderTableHeader={[
{ title: "Name", className: "name", sortBy: columnId.name, id: columnId.name },
{ 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 {
constructor(public entity: CatalogEntity) {}
get kind() {
return this.entity.kind;
}
get 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;
}
.TableCell.kind {
max-width: 150px;
}
.TableCell.source {
max-width: 100px;
}

View File

@ -19,7 +19,8 @@
* 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 { disposeOnUnmount, observer } from "mobx-react";
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 { navigate } from "../../navigation";
import { kebabCase } from "lodash";
import { PageLayout } from "../layout/page-layout";
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 { HotbarStore } from "../../../common/hotbar-store";
import { ConfirmDialog } from "../confirm-dialog";
@ -40,9 +40,17 @@ import type { RouteComponentProps } from "react-router";
import type { ICatalogViewRouteParam } from "./catalog.route";
import { Notifications } from "../notifications";
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 {
name = "name",
kind = "kind",
source = "source",
status = "status"
}
@ -54,6 +62,7 @@ export class Catalog extends React.Component<Props> {
@observable private catalogEntityStore?: CatalogEntityStore;
@observable private contextMenu: CatalogEntityContextMenuContext;
@observable activeTab?: string;
@observable selectedItem?: CatalogEntityItem;
constructor(props: Props) {
super(props);
@ -102,7 +111,7 @@ export class Catalog extends React.Component<Props> {
}
onDetails(item: CatalogEntityItem) {
item.onRun(catalogEntityRunContext);
this.selectedItem = item;
}
onMenuItemClick(menuItem: CatalogEntityContextMenu) {
@ -136,14 +145,14 @@ export class Catalog extends React.Component<Props> {
renderNavigation() {
return (
<Tabs className="flex column" scrollable={false} onChange={this.onTabChange} value={this.activeTab}>
<div className="sidebarHeader">Catalog</div>
<div className="sidebarTabs">
<Tabs className={cssNames(styles.tabs, "flex column")} scrollable={false} onChange={this.onTabChange} value={this.activeTab}>
<div>
<Tab
value={undefined}
key="*"
label="Browse"
data-testid="*-tab"
className={cssNames(styles.tab, { [styles.activeTab]: this.activeTab == null })}
/>
{
this.categories.map(category => (
@ -152,6 +161,7 @@ export class Catalog extends React.Component<Props> {
key={category.getId()}
label={category.metadata.name}
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) {
const category = catalogCategoryRegistry.getCategoryForEntity(item.entity);
if (!category) {
return null;
}
return (
<Avatar
title={item.name}
colorHash={`${item.name}-${item.source}`}
width={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 (
<PageLayout
className="CatalogPage"
navigation={this.renderNavigation()}
provideBackButtonNavigation={false}
contentGaps={false}>
<ItemListLayout
renderHeaderTitle={this.catalogEntityStore.activeCategory?.metadata.name ?? "Browse All"}
isClusterScoped
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}
/>
<CatalogAddButton category={this.catalogEntityStore.activeCategory} />
</PageLayout>
<>
<TopBar label="Catalog">
<div>
<MaterialTooltip title="Close Catalog" placement="left">
<Icon style={{ cursor: "default" }} material="close" onClick={() => navigate(welcomeURL())}/>
</MaterialTooltip>
</div>
</TopBar>
<MainLayout sidebar={this.renderNavigation()}>
<div className="p-6 h-full">
{ this.catalogEntityStore.activeCategory ? this.renderSingleCategoryList() : this.renderAllCategoriesList() }
</div>
{ !this.selectedItem && (
<CatalogAddButton category={this.catalogEntityStore.activeCategory} />
)}
{ this.selectedItem && (
<CatalogEntityDetails
entity={this.selectedItem.entity}
hideDetails={() => this.selectedItem = null}
/>
)}
</MainLayout>
</>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,6 +38,7 @@ import { Events } from "../+events";
import { isAllowedResource } from "../../../common/rbac";
import { kubeWatchApi } from "../../api/kube-watch-api";
import { clusterContext } from "../context";
import { workloadsOverviewDetailRegistry } from "../../../extensions/registries";
interface Props extends RouteComponentProps<IWorkloadsOverviewRouteParams> {
}
@ -57,11 +58,34 @@ export class WorkloadsOverview extends React.Component<Props> {
}
render() {
const items = workloadsOverviewDetailRegistry.getItems().map((item, index) => {
return (
<item.components.Details key={`workload-overview-${index}`}/>
);
});
return (
<div className="WorkloadsOverview flex column gaps">
<OverviewStatuses/>
{isAllowedResource("events") && <Events compact hideFilters className="box grow"/>}
{items}
</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;
}
&.loading {
pointer-events: none;
&:after {
content: "";
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
background: transparentize(white, .85);
}
}
> .editor {
position: absolute;
width: inherit;
@ -76,4 +89,4 @@
.ace_comment {
color: var(--dockEditorComment);
}
}
}

View File

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

View File

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

View File

@ -71,6 +71,8 @@ import { CommandContainer } from "./command-palette/command-container";
import { KubeObjectStore } from "../kube-object.store";
import { clusterContext } from "./context";
import { namespaceStore } from "./+namespaces/namespace.store";
import { Sidebar } from "./layout/sidebar";
import { Dock } from "./dock";
@observer
export class App extends React.Component {
@ -176,7 +178,7 @@ export class App extends React.Component {
return (
<Router history={history}>
<ErrorBoundary>
<MainLayout>
<MainLayout sidebar={<Sidebar/>} footer={<Dock/>}>
<Switch>
<Route component={ClusterOverview} {...clusterRoute}/>
<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 metaData = Object.values<{ total: number }>(dataset["_meta"])[0];
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 }) => {

View File

@ -32,6 +32,7 @@
grid-area: main;
position: relative;
display: flex;
flex-direction: column;
}
.HotbarMenu {
@ -45,7 +46,7 @@
#lens-views {
position: absolute;
left: 0;
top: 0;
top: var(--main-layout-header); // Move below the TopBar
right: 0;
bottom: 0;
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 { clusterActivateHandler } from "../../../common/cluster-ipc";
import { catalogEntityRegistry } from "../../api/catalog-entity-registry";
import { getMatchedClusterId, navigate } from "../../navigation";
import { navigate } from "../../navigation";
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
export class ClusterView extends React.Component {
export class ClusterView extends React.Component<Props> {
private store = ClusterStore.getInstance();
constructor(props: {}) {
constructor(props: Props) {
super(props);
makeObservable(this);
}
get clusterId() {
return getMatchedClusterId();
@computed get clusterId() {
return this.props.match.params.clusterId;
}
@computed get cluster(): Cluster | undefined {
@ -60,6 +66,10 @@ export class ClusterView extends React.Component {
this.bindEvents();
}
componentWillUnmount() {
refreshViews();
}
bindEvents() {
disposeOnUnmount(this, [
reaction(() => this.clusterId, async (clusterId) => {
@ -71,14 +81,12 @@ export class ClusterView extends React.Component {
fireImmediately: true,
}),
reaction(() => this.isReady, (ready) => {
if (ready) {
refreshViews(this.clusterId); // show cluster's view (iframe)
} else if (hasLoadedView(this.clusterId)) {
reaction(() => [this.cluster?.ready, this.cluster?.disconnected], (values) => {
const disconnected = values[1];
if (hasLoadedView(this.clusterId) && disconnected) {
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;
if (cluster && !isReady) {
return <ClusterStatus clusterId={clusterId} className="box center"/>;
return <ClusterStatus key={clusterId} clusterId={clusterId} className="box center"/>;
}
return null;
@ -95,7 +103,8 @@ export class ClusterView extends React.Component {
render() {
return (
<div className="ClusterView flex align-center">
<div className="ClusterView flex column align-center">
{this.cluster && <ClusterTopbar cluster={this.cluster}/>}
{this.renderStatus()}
</div>
);

View File

@ -22,6 +22,8 @@
import { observable, when } from "mobx";
import { ClusterId, ClusterStore, getClusterFrameUrl } from "../../../common/cluster-store";
import logger from "../../../main/logger";
import { requestMain } from "../../../common/ipc";
import { clusterVisibilityHandler } from "../../../common/cluster-ipc";
export interface LensView {
isLoaded?: boolean
@ -56,8 +58,13 @@ export async function initView(clusterId: ClusterId) {
parentElem.appendChild(iframe);
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) {
@ -88,5 +95,9 @@ export function refreshViews(visibleClusterId?: string) {
const isVisible = isCurrent && isLoaded && isReady;
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
get logs() {
const showTimestamps = logTabStore.getData(this.props.id).showTimestamps;
const showTimestamps = logTabStore.getData(this.props.id)?.showTimestamps;
if (!showTimestamps) {
return logStore.logsWithoutTimestamps;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,7 +24,7 @@ import groupBy from "lodash/groupBy";
import React, { ReactNode } from "react";
import { computed, makeObservable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react";
import { observer } from "mobx-react";
import { ConfirmDialog, ConfirmDialogParams } from "../confirm-dialog";
import { Table, TableCell, TableCellProps, TableHead, TableProps, TableRow, TableRowProps, TableSortCallback } from "../table";
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 { PageFiltersList } from "./page-filters-list";
import { PageFiltersSelect } from "./page-filters-select";
import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter";
import { ThemeStore } from "../../theme.store";
import { MenuActions } from "../menu/menu-actions";
import { MenuItem } from "../menu";
import { Checkbox } from "../checkbox";
import { UserStore } from "../../../common/user-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
@ -63,7 +64,6 @@ export interface ItemListLayoutProps<T extends ItemObject = ItemObject> {
store: ItemStore<T>;
dependentStores?: ItemStore[];
preloadStores?: boolean;
isClusterScoped?: boolean;
hideFilters?: boolean;
searchFilters?: SearchFilter<T>[];
/** @deprecated */
@ -137,7 +137,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
}
async componentDidMount() {
const { isClusterScoped, isConfigurable, tableId, preloadStores } = this.props;
const { isConfigurable, tableId, preloadStores } = this.props;
if (isConfigurable && !tableId) {
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) {
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 stores = Array.from(new Set([store, ...dependentStores]));
// load context namespaces by default (see also: `<NamespaceSelectFilter/>`)
stores.forEach(store => store.loadAll(namespaceStore.contextNamespaces));
}
@ -224,7 +217,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
}
@computed get items() {
const {filters, filterCallbacks } = this;
const { filters, filterCallbacks } = this;
const filterGroups = groupBy<Filter>(filters, ({ type }) => type);
const filterItems: ItemsFilter[] = [];
@ -330,7 +323,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
return null;
}
return <PageFiltersList filters={filters}/>;
return <PageFiltersList filters={filters} />;
}
renderNoItems() {
@ -355,7 +348,7 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
);
}
return <NoItems/>;
return <NoItems />;
}
renderItems() {
@ -397,20 +390,26 @@ export class ItemListLayout extends React.Component<ItemListLayoutProps> {
}
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 placeholders: IHeaderPlaceholders = {
title: <h5 className="title">{title}</h5>,
info: this.renderInfo(),
filters: <>
{!isClusterScoped && <NamespaceSelectFilter/>}
<PageFiltersSelect allowEmpty disableFilters={{
[FilterType.NAMESPACE]: true, // namespace-select used instead
}}/>
</>,
search: <SearchInputUrl/>,
filters: (
<>
{showNamespaceSelectFilter && <NamespaceSelectFilter />}
<PageFiltersSelect allowEmpty disableFilters={{
[FilterType.NAMESPACE]: true, // namespace-select used instead
}} />
</>
),
search: <SearchInputUrl />,
};
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.
*/
import "./main-layout.scss";
import styles from "./main-layout.module.css";
import React from "react";
import { observer } from "mobx-react";
import { getHostedCluster } from "../../../common/cluster-store";
import { cssNames } from "../../utils";
import { Dock } from "../dock";
import { ErrorBoundary } from "../error-boundary";
import { ResizeDirection, ResizeGrowthDirection, ResizeSide, ResizingAnchor } from "../resizing-anchor";
import { MainLayoutHeader } from "./main-layout-header";
import { Sidebar } from "./sidebar";
import { sidebarStorage } from "./sidebar-storage";
export interface MainLayoutProps {
className?: any;
interface Props {
sidebar: React.ReactNode;
className?: string;
footer?: React.ReactNode;
headerClass?: string;
footerClass?: string;
}
@observer
export class MainLayout extends React.Component<MainLayoutProps> {
onSidebarCompactModeChange = () => {
sidebarStorage.merge(draft => {
draft.compact = !draft.compact;
});
};
export class MainLayout extends React.Component<Props> {
onSidebarResize = (width: number) => {
sidebarStorage.merge({ width });
};
render() {
const cluster = getHostedCluster();
const { onSidebarCompactModeChange, onSidebarResize } = this;
const { className, headerClass, footer, footerClass, children } = this.props;
const { compact, width: sidebarWidth } = sidebarStorage.get();
const { onSidebarResize } = this;
const { className, footer, children, sidebar } = this.props;
const { width: sidebarWidth } = sidebarStorage.get();
const style = { "--sidebar-width": `${sidebarWidth}px` } as React.CSSProperties;
if (!cluster) {
return null; // fix: skip render when removing active (visible) cluster
}
return (
<div className={cssNames("MainLayout", className)} style={style}>
<MainLayoutHeader className={headerClass} cluster={cluster}/>
<aside className={cssNames("flex column", { compact })}>
<Sidebar className="box grow" compact={compact} toggle={onSidebarCompactModeChange}/>
<div className={cssNames(styles.mainLayout, className)} style={style}>
<div className={styles.sidebar}>
{sidebar}
<ResizingAnchor
direction={ResizeDirection.HORIZONTAL}
placement={ResizeSide.TRAILING}
growthDirection={ResizeGrowthDirection.LEFT_TO_RIGHT}
getCurrentExtent={() => sidebarWidth}
onDrag={onSidebarResize}
onDoubleClick={onSidebarCompactModeChange}
disabled={compact}
minExtent={120}
maxExtent={400}
/>
</aside>
</div>
<main>
<div className={styles.contents}>
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</div>
<footer className={footerClass}>{footer ?? <Dock/>}</footer>
<div className={styles.footer}>{footer}</div>
</div>
);
}

View File

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

View File

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

View File

@ -23,49 +23,9 @@
$iconSize: 24px;
$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 {
padding: $padding / 1.5 0;
width: var(--sidebar-width);
padding-bottom: calc(var(--padding) * 3);
overflow: auto;
.Icon {

View File

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

View File

@ -21,18 +21,19 @@
.TabLayout {
display: contents;
display: flex;
flex-direction: column;
height: 100%;
> .Tabs {
grid-area: tabs;
background: $layoutTabsBackground;
min-height: 32px;
}
main {
$spacing: $margin * 2;
grid-area: main;
flex-grow: 1;
overflow-y: scroll; // always reserve space for scrollbar (17px)
overflow-x: auto;
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.
*/
import styles from "./topbar.module.css";
import React from "react";
import { observer } from "mobx-react";
import type { Cluster } from "../../../main/cluster";
import { cssNames } from "../../utils";
interface Props {
cluster: Cluster
className?: string
interface Props extends React.HTMLAttributes<any> {
label: React.ReactNode;
}
export const MainLayoutHeader = observer(({ cluster, className }: Props) => {
export const TopBar = observer(({ label, children, ...rest }: Props) => {
return (
<header className={cssNames("flex gaps align-center justify-space-between", className)}>
<span className="cluster">{cluster.name}</span>
</header>
<div className={styles.topBar} {...rest}>
<div className={styles.title}>{label}</div>
<div className={styles.controls}>{children}</div>
</div>
);
});

View File

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

View File

@ -31,6 +31,23 @@ body.resizing {
position: absolute;
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 {
display: none;
}
@ -56,6 +73,17 @@ body.resizing {
cursor: col-resize;
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 {
left: -$dimension / 2;
}

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