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

Merge branch 'master' into show-cluster-on-workspace-switch

This commit is contained in:
Alex Andreev 2020-11-19 12:48:21 +03:00
commit 7ea7ee7a85
15 changed files with 187 additions and 29 deletions

View File

@ -0,0 +1,28 @@
# Extension Guides
The basics of the Lens Extension API are covered in [Your First Extension](../get-started/your-first-extension.md). In this section detailed code guides and samples are used to explain how to use specific Lens Extension APIs.
Each guide or sample will include:
- Clearly commented source code.
- Instructions for running the sample extension.
- Image of the sample extension's appearance and usage.
- Listing of Extension API being used.
- Explanation of Extension API concepts.
## Guides
| Guide | APIs |
| ----- | ----- |
| [Main process extension](main-extension.md) | LensMainExtension |
| [Renderer process extension](renderer-extension.md) | LensRendererExtension |
| [Stores](stores.md) | |
| [Components](components.md) | |
| [KubeObjectListLayout](kube-object-list-layout.md) | |
## Samples
| Sample | APIs |
| ----- | ----- |
[helloworld](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 |

View File

@ -0,0 +1,76 @@
# Main Extension
The main extension api is the interface to Lens' main process (Lens runs in main and renderer processes). It allows you to access, configure, and customize Lens data, add custom application menu items, and generally run custom code in Lens' main process.
## `LensMainExtension` Class
To create a main extension simply extend the `LensMainExtension` class:
``` typescript
import { LensMainExtension } from "@k8slens/extensions";
export default class ExampleExtensionMain extends LensMainExtension {
onActivate() {
console.log('custom main process extension code started');
}
onDeactivate() {
console.log('custom main process extension de-activated');
}
}
```
There are two methods that you can implement to facilitate running your custom code. `onActivate()` is called when your extension has been successfully enabled. By overriding `onActivate()` you can initiate your custom code. `onDeactivate()` is called when the extension is disabled (typically from the [Lens Extensions Page]()) and when implemented gives you a chance to clean up after your extension, if necessary. The example above simply logs messages when the extension is enabled and disabled. Note that to see standard output from the main process there must be a console connected to it. This is typically achieved by starting Lens from the command prompt.
The following example is a little more interesting in that it accesses some Lens state data and periodically logs the name of the currently active cluster in Lens.
``` typescript
import { LensMainExtension, Store } from "@k8slens/extensions";
const clusterStore = Store.clusterStore
export default class ActiveClusterExtensionMain extends LensMainExtension {
timer: NodeJS.Timeout
onActivate() {
console.log("Cluster logger activated");
this.timer = setInterval(() => {
if (!clusterStore.active) {
console.log("No active cluster");
return;
}
console.log("active cluster is", clusterStore.active.contextName)
}, 5000)
}
onDeactivate() {
clearInterval(this.timer)
console.log("Cluster logger deactivated");
}
}
```
See the [Stores](../stores) guide for more details on accessing Lens state data.
### `appMenus`
The only UI feature customizable in the main extension api is the application menu. Custom menu items can be inserted and linked to custom functionality, such as navigating to a specific page. The following example demonstrates adding a menu item to the Help menu.
``` typescript
import { LensMainExtension } from "@k8slens/extensions";
export default class SamplePageMainExtension extends LensMainExtension {
appMenus = [
{
parentId: "help",
label: "Sample",
click() {
console.log("Sample clicked");
}
}
]
}
```
`appMenus` is an array of objects satisfying the `MenuRegistration` interface. `MenuRegistration` extends React's `MenuItemConstructorOptions` interface. `parentId` is the id of the menu to put this menu item under (todo: is this case sensitive and how do we know what the available ids are?), `label` is the text to show on the menu item, and `click()` is called when the menu item is selected. In this example we simply log a message, but typically you would navigate to a specific page or perform some operation. Pages are associated with the [`LensRendererExtension`](renderer-extension.md) class and can be defined when you extend it.

View File

@ -30,6 +30,7 @@ nav:
- Color Reference: extensions/capabilities/color-reference.md
- Extension Guides:
- Overview: extensions/guides/README.md
- Main Extension: extensions/guides/main-extension.md
- Renderer Extension: extensions/guides/renderer-extension.md
- Testing and Publishing:
- Testing Extensions: extensions/testing-and-publishing/testing.md

View File

@ -58,6 +58,7 @@ describe("kube auth proxy tests", () => {
let port: number
let mockedCP: MockProxy<ChildProcess>
let listeners: Record<string, (...args: any[]) => void>
let proxy: KubeAuthProxy
beforeEach(async () => {
port = await getFreePort()
@ -85,43 +86,41 @@ describe("kube auth proxy tests", () => {
return mockedCP
})
mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve())
const cluster = new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" })
jest.spyOn(cluster, "apiUrl", "get").mockReturnValue("https://fake.k8s.internal")
proxy = new KubeAuthProxy(cluster, port, {})
})
it("should call spawn and broadcast errors", async () => {
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {})
await kap.run()
await proxy.run()
listeners["error"]({ message: "foobarbat" })
expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "foobarbat", error: true }] })
})
it("should call spawn and broadcast exit", async () => {
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {})
await kap.run()
await proxy.run()
listeners["exit"](0)
expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "proxy exited with code: 0", error: false }] })
})
it("should call spawn and broadcast errors from stderr", async () => {
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {})
await kap.run()
await proxy.run()
listeners["stderr/data"]("an error")
expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "an error", error: true }] })
})
it("should call spawn and broadcast stdout serving info", async () => {
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {})
await kap.run()
await proxy.run()
listeners["stdout/data"]("Starting to serve on")
expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "Authentication proxy started\n" }] })
})
it("should call spawn and broadcast stdout other info", async () => {
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {})
await kap.run()
await proxy.run()
listeners["stdout/data"]("some info")
expect(mockBroadcastIpc).toBeCalledWith({ channel: "kube-auth:foobar", args: [{ data: "some info" }] })

View File

@ -6,9 +6,11 @@ import { clusterStore, getClusterIdFromHost } from "../common/cluster-store"
import { Cluster } from "./cluster"
import logger from "./logger";
import { apiKubePrefix } from "../common/vars";
import { Singleton } from "../common/utils";
export class ClusterManager {
export class ClusterManager extends Singleton {
constructor(public readonly port: number) {
super()
// auto-init clusters
autorun(() => {
clusterStore.enabledClustersList.forEach(cluster => {

View File

@ -86,6 +86,10 @@ export class Cluster implements ClusterModel, ClusterState {
return this.accessible && !this.disconnected;
}
@computed get name() {
return this.preferences.clusterName || this.contextName
}
get version(): string {
return String(this.metadata?.version) || ""
}

18
src/main/exit-app.ts Normal file
View File

@ -0,0 +1,18 @@
import { app } from "electron";
import { WindowManager } from "./window-manager";
import { appEventBus } from "../common/event-bus";
import { ClusterManager } from "./cluster-manager";
import logger from "./logger";
export function exitApp() {
const windowManager = WindowManager.getInstance<WindowManager>()
const clusterManager = ClusterManager.getInstance<ClusterManager>()
appEventBus.emit({ name: "service", action: "close" })
windowManager.hide();
clusterManager.stop();
logger.info('SERVICE:QUIT');
setTimeout(() => {
app.exit()
}, 1000)
}

View File

@ -67,7 +67,7 @@ app.on("ready", async () => {
}
// create cluster manager
clusterManager = new ClusterManager(proxyPort);
clusterManager = ClusterManager.getInstance<ClusterManager>(proxyPort);
// run proxy
try {
@ -82,7 +82,7 @@ app.on("ready", async () => {
extensionLoader.init(await extensionManager.load()); // call after windowManager to see splash earlier
setTimeout(() => {
appEventBus.emit({ name: "app", action: "start" })
appEventBus.emit({ name: "service", action: "start" })
}, 1000)
});
@ -96,6 +96,7 @@ app.on("activate", (event, hasVisibleWindows) => {
// Quit app on Cmd+Q (MacOS)
app.on("will-quit", (event) => {
logger.info('APP:QUIT');
appEventBus.emit({name: "app", action: "close"})
event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.)
clusterManager?.stop(); // close cluster connections
return; // skip exit to make tray work, to quit go to app's global menu or tray's menu

View File

@ -4,6 +4,7 @@ import { broadcastIpc } from "../common/ipc";
import type { Cluster } from "./cluster"
import { Kubectl } from "./kubectl"
import logger from "./logger"
import * as url from "url"
export interface KubeAuthProxyLog {
data: string;
@ -26,17 +27,22 @@ export class KubeAuthProxy {
this.kubectl = Kubectl.bundled()
}
get acceptHosts() {
return url.parse(this.cluster.apiUrl).hostname;
}
public async run(): Promise<void> {
if (this.proxyProcess) {
return;
}
const proxyBin = await this.kubectl.getPath()
const args = [
"proxy",
"-p", `${this.port}`,
"--kubeconfig", `${this.cluster.kubeConfigPath}`,
"--context", `${this.cluster.contextName}`,
"--accept-hosts", ".*",
"--accept-hosts", this.acceptHosts,
"--reject-paths", "^[^/]"
]
if (process.env.DEBUG_PROXY === "true") {

View File

@ -131,7 +131,11 @@ export class LensProxy {
}
}
}
res.writeHead(500).end("Oops, something went wrong.")
try {
res.writeHead(500).end("Oops, something went wrong.")
} catch (e) {
logger.error(`[LENS-PROXY]: Failed to write headers: `, e)
}
})
return proxy;

View File

@ -9,6 +9,7 @@ import { clusterSettingsURL } from "../renderer/components/+cluster-settings/clu
import { extensionsURL } from "../renderer/components/+extensions/extensions.route";
import { menuRegistry } from "../extensions/registries/menu-registry";
import logger from "./logger";
import { exitApp } from "./exit-app";
export type MenuTopId = "mac" | "file" | "edit" | "view" | "help"
@ -89,7 +90,7 @@ export function buildMenu(windowManager: WindowManager) {
label: 'Quit',
accelerator: 'Cmd+Q',
click() {
app.exit(); // force quit since might be blocked within app.on("will-quit")
exitApp()
}
}
]
@ -133,12 +134,23 @@ export function buildMenu(windowManager: WindowManager) {
click() {
navigate(extensionsURL())
}
},
{ type: 'separator' },
{ role: 'quit' }
}
]),
{ type: 'separator' },
{ role: 'close' } // close current window
{
role: 'close',
label: "Close Window"
},
...ignoreOnMac([
{ type: 'separator' },
{
label: 'Exit',
accelerator: 'Alt+F4',
click() {
exitApp()
}
}
])
]
};
@ -259,7 +271,7 @@ export function buildMenu(windowManager: WindowManager) {
}
menu = menuItem.submenu;
}
const menuPath: string = parentLabels.join(" -> ")
if (!menuItem) {
logger.info(`[MENU:test-menu-item-click] Cannot find menu item ${menuPath}`);

View File

@ -1,6 +1,6 @@
import path from "path"
import packageInfo from "../../package.json"
import { app, dialog, Menu, NativeImage, nativeTheme, Tray } from "electron"
import { dialog, Menu, NativeImage, nativeTheme, Tray } from "electron"
import { autorun } from "mobx";
import { showAbout } from "./menu";
import { AppUpdater } from "./app-updater";
@ -11,6 +11,7 @@ import { preferencesURL } from "../renderer/components/+preferences/preferences.
import { clusterViewURL } from "../renderer/components/cluster-manager/cluster-view.route";
import logger from "./logger";
import { isDevelopment } from "../common/vars";
import { exitApp } from "./exit-app";
// note: instance of Tray should be saved somewhere, otherwise it disappears
export let tray: Tray;
@ -88,7 +89,7 @@ export function createTrayMenu(windowManager: WindowManager): Menu {
label: workspace.name,
toolTip: workspace.description,
submenu: clusters.map(cluster => {
const { id: clusterId, preferences: { clusterName: label }, online, workspace } = cluster;
const { id: clusterId, name: label, online, workspace } = cluster;
return {
label: `${online ? '✓' : '\x20'.repeat(3)/*offset*/}${label}`,
toolTip: clusterId,
@ -118,7 +119,7 @@ export function createTrayMenu(windowManager: WindowManager): Menu {
{
label: 'Quit App',
click() {
app.exit();
exitApp()
}
}
]);

View File

@ -85,6 +85,7 @@ export class WindowManager extends Singleton {
await this.mainWindow.loadURL(this.mainUrl);
this.mainWindow.show();
this.splashWindow?.close();
appEventBus.emit({ name: "app", action: "start" })
} catch (err) {
dialog.showErrorBox("ERROR!", err.toString())
}
@ -156,6 +157,11 @@ export class WindowManager extends Singleton {
this.splashWindow.show();
}
hide() {
if (!this.mainWindow?.isDestroyed()) this.mainWindow.hide();
if (!this.splashWindow.isDestroyed()) this.splashWindow.hide();
}
destroy() {
this.mainWindow.destroy();
this.splashWindow.destroy();

View File

@ -34,8 +34,8 @@ export class ClusterIcon extends React.Component<Props> {
cluster, showErrors, showTooltip, errorClass, options, interactive, isActive,
children, ...elemProps
} = this.props;
const { isAdmin, eventCount, preferences, id: clusterId } = cluster;
const { clusterName, icon } = preferences;
const { isAdmin, name, eventCount, preferences, id: clusterId } = cluster;
const { icon } = preferences;
const clusterIconId = `cluster-icon-${clusterId}`;
const className = cssNames("ClusterIcon flex inline", this.props.className, {
interactive: interactive !== undefined ? interactive : !!this.props.onClick,
@ -44,9 +44,9 @@ export class ClusterIcon extends React.Component<Props> {
return (
<div {...elemProps} className={className} id={showTooltip ? clusterIconId : null}>
{showTooltip && (
<Tooltip targetId={clusterIconId}>{clusterName}</Tooltip>
<Tooltip targetId={clusterIconId}>{name}</Tooltip>
)}
{icon && <img src={icon} alt={clusterName}/>}
{icon && <img src={icon} alt={name}/>}
{!icon && <Hashicon value={clusterId} options={options}/>}
{showErrors && isAdmin && eventCount > 0 && (
<Badge

View File

@ -65,7 +65,7 @@ export class MainLayout extends React.Component<MainLayoutProps> {
return (
<div className={cssNames("MainLayout", className)} style={this.getSidebarSize() as any}>
<header className={cssNames("flex gaps align-center", headerClass)}>
<span className="cluster">{cluster.preferences.clusterName || cluster.contextName}</span>
<span className="cluster">{cluster.name}</span>
</header>
<aside className={cssNames("flex column", { pinned: this.isPinned, accessible: this.isAccessible })}>