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

Merge branch 'master' into tag-exposed-extension-stores-as-beta

This commit is contained in:
Jari Kolehmainen 2020-12-03 15:36:12 +02:00 committed by GitHub
commit bb6a7dcf7a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
425 changed files with 2738 additions and 378 deletions

View File

@ -41,10 +41,12 @@ jobs:
displayName: Generate npm package displayName: Generate npm package
- script: make -j2 build-extensions - script: make -j2 build-extensions
displayName: Build bundled extensions displayName: Build bundled extensions
- script: make integration-win - script: make test
displayName: Run integration tests displayName: Run tests
- script: make test-extensions - script: make test-extensions
displayName: Run In-tree Extension tests displayName: Run In-tree Extension tests
- script: make integration-win
displayName: Run integration tests
- script: make build - script: make build
condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))" condition: "and(succeeded(), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))"
displayName: Build displayName: Build

View File

@ -4,6 +4,7 @@ module.exports = {
ignorePatterns: [ ignorePatterns: [
"**/node_modules/**/*", "**/node_modules/**/*",
"**/dist/**/*", "**/dist/**/*",
"**/static/**/*",
], ],
settings: { settings: {
react: { react: {
@ -49,6 +50,15 @@ module.exports = {
"object-shorthand": "error", "object-shorthand": "error",
"prefer-template": "error", "prefer-template": "error",
"template-curly-spacing": "error", "template-curly-spacing": "error",
"padding-line-between-statements": [
"error",
{ "blankLine": "always", "prev": "*", "next": "return" },
{ "blankLine": "always", "prev": "*", "next": "block-like" },
{ "blankLine": "always", "prev": "*", "next": "function" },
{ "blankLine": "always", "prev": "*", "next": "class" },
{ "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" },
{ "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]},
]
} }
}, },
{ {
@ -94,6 +104,15 @@ module.exports = {
"object-shorthand": "error", "object-shorthand": "error",
"prefer-template": "error", "prefer-template": "error",
"template-curly-spacing": "error", "template-curly-spacing": "error",
"padding-line-between-statements": [
"error",
{ "blankLine": "always", "prev": "*", "next": "return" },
{ "blankLine": "always", "prev": "*", "next": "block-like" },
{ "blankLine": "always", "prev": "*", "next": "function" },
{ "blankLine": "always", "prev": "*", "next": "class" },
{ "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" },
{ "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]},
]
}, },
}, },
{ {
@ -146,6 +165,15 @@ module.exports = {
"object-shorthand": "error", "object-shorthand": "error",
"prefer-template": "error", "prefer-template": "error",
"template-curly-spacing": "error", "template-curly-spacing": "error",
"padding-line-between-statements": [
"error",
{ "blankLine": "always", "prev": "*", "next": "return" },
{ "blankLine": "always", "prev": "*", "next": "block-like" },
{ "blankLine": "always", "prev": "*", "next": "function" },
{ "blankLine": "always", "prev": "*", "next": "class" },
{ "blankLine": "always", "prev": ["const", "let", "var"], "next": "*" },
{ "blankLine": "any", "prev": ["const", "let", "var"], "next": ["const", "let", "var"]},
]
}, },
} }
] ]

View File

@ -17,14 +17,15 @@ export async function generateTrayIcon(
outputFilename += shouldUseDarkColors ? "_dark" : ""; outputFilename += shouldUseDarkColors ? "_dark" : "";
dpiSuffix = dpiSuffix !== "1x" ? `@${dpiSuffix}` : ""; dpiSuffix = dpiSuffix !== "1x" ? `@${dpiSuffix}` : "";
const pngIconDestPath = path.resolve(outputFolder, `${outputFilename}${dpiSuffix}.png`); const pngIconDestPath = path.resolve(outputFolder, `${outputFilename}${dpiSuffix}.png`);
try { try {
// Modify .SVG colors // Modify .SVG colors
const trayIconColor = shouldUseDarkColors ? "white" : "black"; const trayIconColor = shouldUseDarkColors ? "white" : "black";
const svgDom = await jsdom.JSDOM.fromFile(svgIconPath); const svgDom = await jsdom.JSDOM.fromFile(svgIconPath);
const svgRoot = svgDom.window.document.body.getElementsByTagName("svg")[0]; const svgRoot = svgDom.window.document.body.getElementsByTagName("svg")[0];
svgRoot.innerHTML += `<style>* {fill: ${trayIconColor} !important;}</style>`; svgRoot.innerHTML += `<style>* {fill: ${trayIconColor} !important;}</style>`;
const svgIconBuffer = Buffer.from(svgRoot.outerHTML); const svgIconBuffer = Buffer.from(svgRoot.outerHTML);
// Resize and convert to .PNG // Resize and convert to .PNG
const pngIconBuffer: Buffer = await sharp(svgIconBuffer) const pngIconBuffer: Buffer = await sharp(svgIconBuffer)
.resize({ width: pixelSize, height: pixelSize }) .resize({ width: pixelSize, height: pixelSize })
@ -45,6 +46,7 @@ const iconSizes: Record<string, number> = {
"2x": 32, "2x": 32,
"3x": 48, "3x": 48,
}; };
Object.entries(iconSizes).forEach(([dpiSuffix, pixelSize]) => { Object.entries(iconSizes).forEach(([dpiSuffix, pixelSize]) => {
generateTrayIcon({ dpiSuffix, pixelSize, shouldUseDarkColors: false }); generateTrayIcon({ dpiSuffix, pixelSize, shouldUseDarkColors: false });
generateTrayIcon({ dpiSuffix, pixelSize, shouldUseDarkColors: true }); generateTrayIcon({ dpiSuffix, pixelSize, shouldUseDarkColors: true });

View File

@ -15,6 +15,7 @@ class KubectlDownloader {
constructor(clusterVersion: string, platform: string, arch: string, target: string) { constructor(clusterVersion: string, platform: string, arch: string, target: string) {
this.kubectlVersion = clusterVersion; this.kubectlVersion = clusterVersion;
const binaryName = platform === "windows" ? "kubectl.exe" : "kubectl"; const binaryName = platform === "windows" ? "kubectl.exe" : "kubectl";
this.url = `https://storage.googleapis.com/kubernetes-release/release/v${this.kubectlVersion}/bin/${platform}/${arch}/${binaryName}`; this.url = `https://storage.googleapis.com/kubernetes-release/release/v${this.kubectlVersion}/bin/${platform}/${arch}/${binaryName}`;
this.dirname = path.dirname(target); this.dirname = path.dirname(target);
this.path = target; this.path = target;
@ -30,16 +31,20 @@ class KubectlDownloader {
if (response.headers["etag"]) { if (response.headers["etag"]) {
return response.headers["etag"].replace(/"/g, ""); return response.headers["etag"].replace(/"/g, "");
} }
return ""; return "";
} }
public async checkBinary() { public async checkBinary() {
const exists = await pathExists(this.path); const exists = await pathExists(this.path);
if (exists) { if (exists) {
const hash = md5File.sync(this.path); const hash = md5File.sync(this.path);
const etag = await this.urlEtag(); const etag = await this.urlEtag();
if(hash == etag) { if(hash == etag) {
console.log("Kubectl md5sum matches the remote etag"); console.log("Kubectl md5sum matches the remote etag");
return true; return true;
} }
@ -52,13 +57,16 @@ class KubectlDownloader {
public async downloadKubectl() { public async downloadKubectl() {
const exists = await this.checkBinary(); const exists = await this.checkBinary();
if(exists) { if(exists) {
console.log("Already exists and is valid"); console.log("Already exists and is valid");
return; return;
} }
await ensureDir(path.dirname(this.path), 0o755); await ensureDir(path.dirname(this.path), 0o755);
const file = fs.createWriteStream(this.path); const file = fs.createWriteStream(this.path);
console.log(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`); console.log(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`);
const requestOpts: request.UriOptions & request.CoreOptions = { const requestOpts: request.UriOptions & request.CoreOptions = {
uri: this.url, uri: this.url,
@ -78,6 +86,7 @@ class KubectlDownloader {
fs.unlink(this.path, () => {}); fs.unlink(this.path, () => {});
throw(error); throw(error);
}); });
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
file.on("close", () => { file.on("close", () => {
console.log("kubectl binary download closed"); console.log("kubectl binary download closed");
@ -103,6 +112,7 @@ const downloads = [
downloads.forEach((dlOpts) => { downloads.forEach((dlOpts) => {
console.log(dlOpts); console.log(dlOpts);
const downloader = new KubectlDownloader(downloadVersion, dlOpts.platform, dlOpts.arch, dlOpts.target); const downloader = new KubectlDownloader(downloadVersion, dlOpts.platform, dlOpts.arch, dlOpts.target);
console.log(`Downloading: ${JSON.stringify(dlOpts)}`); console.log(`Downloading: ${JSON.stringify(dlOpts)}`);
downloader.downloadKubectl().then(() => downloader.checkBinary().then(() => console.log("Download complete"))); downloader.downloadKubectl().then(() => downloader.checkBinary().then(() => console.log("Download complete")));
}); });

View File

@ -2,9 +2,11 @@ const { notarize } = require("electron-notarize");
exports.default = async function notarizing(context) { exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context; const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== "darwin") { if (electronPlatformName !== "darwin") {
return; return;
} }
if (!process.env.APPLEID || !process.env.APPLEIDPASS) { if (!process.env.APPLEID || !process.env.APPLEIDPASS) {
return; return;
} }

View File

Before

Width:  |  Height:  |  Size: 478 B

After

Width:  |  Height:  |  Size: 478 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 973 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -225,7 +225,7 @@ import { HelpIcon, HelpPage } from "./page"
import React from "react" import React from "react"
export default class HelpExtension extends LensRendererExtension { export default class HelpExtension extends LensRendererExtension {
clusterPages = [ globalPages = [
{ {
id: "help", id: "help",
components: { components: {
@ -379,6 +379,74 @@ The `uninstall()` method is implemented in the example above by utilizing the [`
The `updateStatus()` method is implemented above by using the [`K8sApi`](tbd) as well, this time to get information from the `example-pod` pod, in particular to determine if it is installed, what version is associated with it, and if it can be upgraded. How the status is updated for a specific cluster feature is up to the implementation. The `updateStatus()` method is implemented above by using the [`K8sApi`](tbd) as well, this time to get information from the `example-pod` pod, in particular to determine if it is installed, what version is associated with it, and if it can be upgraded. How the status is updated for a specific cluster feature is up to the implementation.
### `appPreferences`
The Preferences page is a built-in global page. Extensions can add custom preferences to the Preferences page, thus providing a single location for users to configure global options, for Lens and extensions alike. The following example demonstrates adding a custom preference:
``` typescript
import { LensRendererExtension } from "@k8slens/extensions";
import { ExamplePreference, ExamplePreferenceHint, ExamplePreferenceInput } from "./src/example-preference";
import { observable } from "mobx";
import React from "react";
export default class ExampleRendererExtension extends LensRendererExtension {
@observable preference: ExamplePreference = { enabled: false };
appPreferences = [
{
title: "Example Preferences",
components: {
Input: () => <ExamplePreferenceInput preference={this.preference}/>,
Hint: () => <ExamplePreferenceHint/>
}
}
];
}
```
App preferences are objects matching the `AppPreferenceRegistration` interface. The `title` field specifies the text to show as the heading on the Preferences page. The `components` field specifies two `React.Component` objects defining the interface for the preference. `Input` should specify an interactive input element for your preference and `Hint` should provide descriptive information for the preference, which is shown below the `Input` element. `ExamplePreferenceInput` expects its React props set to an `ExamplePreference` instance, which is how `ExampleRendererExtension` handles the state of the preference input. `ExampleRendererExtension` has the field `preference`, which is provided to `ExamplePreferenceInput` when it is created. In this example `ExamplePreferenceInput`, `ExamplePreferenceHint`, and `ExamplePreference` are defined in `./src/example-preference.tsx`:
``` typescript
import { Component } from "@k8slens/extensions";
import { observer } from "mobx-react";
import React from "react";
export type ExamplePreference = {
enabled: boolean;
}
@observer
export class ExamplePreferenceInput extends React.Component<{preference: ExamplePreference}, {}> {
render() {
const { preference } = this.props;
return (
<Component.Checkbox
label="I understand appPreferences"
value={preference.enabled}
onChange={v => { preference.enabled = v; }}
/>
);
}
}
export class ExamplePreferenceHint extends React.Component {
render() {
return (
<span>This is an example of an appPreference for extensions.</span>
);
}
}
```
`ExamplePreferenceInput` implements a simple checkbox (using Lens' `Component.Checkbox`). It provides `label` as the text to display next to the checkbox and an `onChange` function, which reacts to the checkbox state change. The checkbox's `value` is initially set to `preference.enabled`. `ExamplePreferenceInput` is defined with React props of `ExamplePreference` type, which has a single field, `enabled`. This is used to indicate the state of the preference, and is bound to the checkbox state in `onChange`. `ExamplePreferenceHint` is a simple text span. Note that the input and the hint could comprise of more sophisticated elements, according to the needs of the extension.
Note that the above example introduces decorators `observable` and `observer` from the [`mobx`](https://mobx.js.org/README.html) and [`mobx-react`](https://github.com/mobxjs/mobx-react#mobx-react) packages. `mobx` simplifies state management and without it this example would not visually update the checkbox properly when the user activates it. [Lens uses `mobx` extensively for state management](../working-with-mobx) of its own UI elements and it is recommended that extensions rely on it too. Alternatively, React's state management can be used instead, though `mobx` is typically simpler to use.
Also note that an extension's state data can be managed using an `ExtensionStore` object, which conveniently handles persistence and synchronization. The example above defined an `ExamplePreference` type to hold the extension's state to simplify the code for this guide, but it is recommended to manage your extension's state data using [`ExtensionStore`](../stores#extensionstore)
********************************************************************* *********************************************************************
WIP below! WIP below!
@ -386,62 +454,36 @@ WIP below!
### `appPreferences`
The Preferences page is essentially a global page. Extensions can add custom preferences to the Preferences page, thus providing a single location for users to configure global, for Lens and extensions alike.
``` typescript
import React from "react"
import { LensRendererExtension } from "@k8slens/extensions"
import { myCustomPreferencesStore } from "./src/my-custom-preferences-store"
import { MyCustomPreferenceHint, MyCustomPreferenceInput } from "./src/my-custom-preference"
export default class ExampleRendererExtension extends LensRendererExtension {
appPreferences = [
{
title: "My Custom Preference",
components: {
Hint: () => <MyCustomPreferenceHint/>,
Input: () => <MyCustomPreferenceInput store={myCustomPreferencesStore}/>
}
}
];
}
```
### `statusBarItems` ### `statusBarItems`
The Status bar is the blue strip along the bottom of the Lens UI. Status bar items are `React.ReactNode` types, which can be used to convey status information, or act as a link to a global page. The Status bar is the blue strip along the bottom of the Lens UI. Status bar items are `React.ReactNode` types, which can be used to convey status information, or act as a link to a global page, or even an external page.
The following example adds a status bar item definition, as well as a global page definition, to a `LensRendererExtension` subclass, and configures the status bar item to navigate to the global upon a mouse click: The following example adds a status bar item definition, as well as a global page definition, to a `LensRendererExtension` subclass, and configures the status bar item to navigate to the global page upon a mouse click:
``` typescript ``` typescript
import { LensRendererExtension, Navigation } from '@k8slens/extensions'; import { LensRendererExtension } from '@k8slens/extensions';
import { MyStatusBarIcon, MyPage } from './page'; import { HelpIcon, HelpPage } from "./page"
import React from 'react'; import React from 'react';
export default class ExtensionRenderer extends LensRendererExtension { export default class HelpExtension extends LensRendererExtension {
globalPages = [ globalPages = [
{ {
path: "/my-extension-path", id: "help",
hideInMenu: true,
components: { components: {
Page: () => <MyPage extension={this} />, Page: () => <HelpPage extension={this}/>,
}, }
}, }
]; ];
statusBarItems = [ statusBarItems = [
{ {
item: ( item: (
<div <div
className="flex align-center gaps hover-highlight" className="flex align-center gaps"
onClick={() => Navigation.navigate(this.globalPages[0].path)} onClick={() => this.navigate("help")}
> >
<MyStatusBarIcon /> <HelpIcon />
<span>My Status Bar Item</span> My Status Bar Item
</div> </div>
), ),
}, },

View File

@ -1,3 +1,11 @@
--- ---
WIP WIP
--- ---
# Stores
## ClusterStore
## WorkspaceStore
## ExtensionStore

View File

@ -3,21 +3,24 @@
The features that Lens includes out-of-the-box are just the start. The features that Lens includes out-of-the-box are just the start.
Lens extensions let you add new features to your installation to support your workflow. Lens extensions let you add new features to your installation to support your workflow.
Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself. Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself.
The start using Lens Extensions go to **File** (or **Lens** on macOS) > **Extensions** in the application menu.
This is the `Extensions` management page where all the management of the extensions you want to use is done.
![Extensions](images/extensions.png) ![Extensions](images/extensions.png)
## Installing an Extension ## Installing an Extension
You can install a dowloaded extension .tgz package by going to **File** > **Extensions** (**Lens** > **Extensions** on Mac). Alternatively you can point an URL to .tgz file. An installed extension is enabled automatically. There are three ways to install extensions.
If you have the extension as a `.tgz` file then dragging and dropping it in the extension management page will install it for you.
If it is hosted on the web, you can paste the URL and click `Install` and Lens will download and install it.
The third way is to move the extension into your `~/.k8slens/extensions` (or `C:\Users\<user>\.k8slens\extensions`) folder and Lens will automatically detect it and install the extension.
## Enabling an Extension ## Enabling or Disabling an Extension
Go to **File** > **Extensions** (**Lens** > **Extensions** on Mac) and click "Enable" button. Go to the extension management page and click either the `Enable` or `Disable` buttons.
Extensions will be enabled by default when you first install them.
## Disabling an Extension A disabled extension is not loaded by Lens and is not run.
Go to **File** > **Extensions** (**Lens** > **Extensions** on Mac) and click "Disable" button.
## Uninstalling an Extension ## Uninstalling an Extension
Go to **File** > **Extensions** (**Lens** > **Extensions** on Mac) and click "Uninstall" button. If, for whatever reason, you wish to remove the installation of an extension simple click the `Uninstall` button. This will remove all the files that Lens would need to run the extension.

View File

@ -1,5 +1,5 @@
{ {
"name": "extension-example", "name": "example-extension",
"version": "1.0.0", "version": "1.0.0",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,

View File

@ -10,6 +10,7 @@ export function ExampleIcon(props: Component.IconProps) {
export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> { export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> {
deactivate = () => { deactivate = () => {
const { extension } = this.props; const { extension } = this.props;
extension.disable(); extension.disable();
}; };
@ -17,6 +18,7 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens
const doodleStyle = { const doodleStyle = {
width: "200px" width: "200px"
}; };
return ( return (
<div className="flex column gaps align-flex-start"> <div className="flex column gaps align-flex-start">
<div style={doodleStyle}><CoffeeDoodle accent="#3d90ce" /></div> <div style={doodleStyle}><CoffeeDoodle accent="#3d90ce" /></div>

View File

@ -4,10 +4,12 @@ export function resolveStatus(object: K8sApi.KubeObject): K8sApi.KubeObjectStatu
const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi); const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi);
const events = (eventStore as K8sApi.EventStore).getEventsByObject(object); const events = (eventStore as K8sApi.EventStore).getEventsByObject(object);
const warnings = events.filter(evt => evt.isWarning()); const warnings = events.filter(evt => evt.isWarning());
if (!events.length || !warnings.length) { if (!events.length || !warnings.length) {
return null; return null;
} }
const event = [...warnings, ...events][0]; // get latest event const event = [...warnings, ...events][0]; // get latest event
return { return {
level: K8sApi.KubeObjectStatusLevel.WARNING, level: K8sApi.KubeObjectStatusLevel.WARNING,
text: `${event.message}`, text: `${event.message}`,
@ -22,10 +24,12 @@ export function resolveStatusForPods(pod: K8sApi.Pod): K8sApi.KubeObjectStatus {
const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi); const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi);
const events = (eventStore as K8sApi.EventStore).getEventsByObject(pod); const events = (eventStore as K8sApi.EventStore).getEventsByObject(pod);
const warnings = events.filter(evt => evt.isWarning()); const warnings = events.filter(evt => evt.isWarning());
if (!events.length || !warnings.length) { if (!events.length || !warnings.length) {
return null; return null;
} }
const event = [...warnings, ...events][0]; // get latest event const event = [...warnings, ...events][0]; // get latest event
return { return {
level: K8sApi.KubeObjectStatusLevel.WARNING, level: K8sApi.KubeObjectStatusLevel.WARNING,
text: `${event.message}`, text: `${event.message}`,
@ -37,13 +41,16 @@ export function resolveStatusForCronJobs(cronJob: K8sApi.CronJob): K8sApi.KubeOb
const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi); const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi);
let events = (eventStore as K8sApi.EventStore).getEventsByObject(cronJob); let events = (eventStore as K8sApi.EventStore).getEventsByObject(cronJob);
const warnings = events.filter(evt => evt.isWarning()); const warnings = events.filter(evt => evt.isWarning());
if (cronJob.isNeverRun()) { if (cronJob.isNeverRun()) {
events = events.filter(event => event.reason != "FailedNeedsStart"); events = events.filter(event => event.reason != "FailedNeedsStart");
} }
if (!events.length || !warnings.length) { if (!events.length || !warnings.length) {
return null; return null;
} }
const event = [...warnings, ...events][0]; // get latest event const event = [...warnings, ...events][0]; // get latest event
return { return {
level: K8sApi.KubeObjectStatusLevel.WARNING, level: K8sApi.KubeObjectStatusLevel.WARNING,
text: `${event.message}`, text: `${event.message}`,

View File

@ -53,6 +53,7 @@ export class MetricsFeature extends ClusterFeature.Feature {
// Check if there are storageclasses // Check if there are storageclasses
const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass); const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass);
const scs = await storageClassApi.list(); const scs = await storageClassApi.list();
this.templateContext.persistence.enabled = scs.some(sc => ( this.templateContext.persistence.enabled = scs.some(sc => (
sc.metadata?.annotations?.["storageclass.kubernetes.io/is-default-class"] === "true" || sc.metadata?.annotations?.["storageclass.kubernetes.io/is-default-class"] === "true" ||
sc.metadata?.annotations?.["storageclass.beta.kubernetes.io/is-default-class"] === "true" sc.metadata?.annotations?.["storageclass.beta.kubernetes.io/is-default-class"] === "true"
@ -69,6 +70,7 @@ export class MetricsFeature extends ClusterFeature.Feature {
try { try {
const statefulSet = K8sApi.forCluster(cluster, K8sApi.StatefulSet); const statefulSet = K8sApi.forCluster(cluster, K8sApi.StatefulSet);
const prometheus = await statefulSet.get({name: "prometheus", namespace: "lens-metrics"}); const prometheus = await statefulSet.get({name: "prometheus", namespace: "lens-metrics"});
if (prometheus?.kind) { if (prometheus?.kind) {
this.status.installed = true; this.status.installed = true;
this.status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1]; this.status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1];

View File

@ -6,6 +6,7 @@ export interface NodeMenuProps extends Component.KubeObjectMenuProps<K8sApi.Node
export function NodeMenu(props: NodeMenuProps) { export function NodeMenu(props: NodeMenuProps) {
const { object: node, toolbar } = props; const { object: node, toolbar } = props;
if (!node) return null; if (!node) return null;
const nodeName = node.getName(); const nodeName = node.getName();
@ -35,6 +36,7 @@ export function NodeMenu(props: NodeMenuProps) {
const drain = () => { const drain = () => {
const command = `kubectl drain ${nodeName} --delete-local-data --ignore-daemonsets --force`; const command = `kubectl drain ${nodeName} --delete-local-data --ignore-daemonsets --force`;
Component.ConfirmDialog.open({ Component.ConfirmDialog.open({
ok: () => sendToTerminal(command), ok: () => sendToTerminal(command),
labelOk: `Drain Node`, labelOk: `Drain Node`,

View File

@ -8,6 +8,7 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
showLogs(container: K8sApi.IPodContainer) { showLogs(container: K8sApi.IPodContainer) {
Navigation.hideDetails(); Navigation.hideDetails();
const pod = this.props.object; const pod = this.props.object;
Component.createPodLogsTab({ Component.createPodLogsTab({
pod, pod,
containers: pod.getContainers(), containers: pod.getContainers(),
@ -22,7 +23,9 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
const { object: pod, toolbar } = this.props; const { object: pod, toolbar } = this.props;
const containers = pod.getAllContainers(); const containers = pod.getAllContainers();
const statuses = pod.getContainerStatuses(); const statuses = pod.getContainerStatuses();
if (!containers.length) return null; if (!containers.length) return null;
return ( return (
<Component.MenuItem onClick={Util.prevDefault(() => this.showLogs(containers[0]))}> <Component.MenuItem onClick={Util.prevDefault(() => this.showLogs(containers[0]))}>
<Component.Icon material="subject" title="Logs" interactive={toolbar}/> <Component.Icon material="subject" title="Logs" interactive={toolbar}/>
@ -40,6 +43,7 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
className={Util.cssNames(Object.keys(status.state)[0], { ready: status.ready })} className={Util.cssNames(Object.keys(status.state)[0], { ready: status.ready })}
/> />
) : null; ) : null;
return ( return (
<Component.MenuItem key={name} onClick={Util.prevDefault(() => this.showLogs(container))} className="flex align-center"> <Component.MenuItem key={name} onClick={Util.prevDefault(() => this.showLogs(container))} className="flex align-center">
{brick} {brick}

View File

@ -12,9 +12,11 @@ export class PodShellMenu extends React.Component<PodShellMenuProps> {
const { object: pod } = this.props; const { object: pod } = this.props;
const containerParam = container ? `-c ${container}` : ""; const containerParam = container ? `-c ${container}` : "";
let command = `kubectl exec -i -t -n ${pod.getNs()} ${pod.getName()} ${containerParam} "--"`; let command = `kubectl exec -i -t -n ${pod.getNs()} ${pod.getName()} ${containerParam} "--"`;
if (window.navigator.platform !== "Win32") { if (window.navigator.platform !== "Win32") {
command = `exec ${command}`; command = `exec ${command}`;
} }
if (pod.getSelectedNodeOs() === "windows") { if (pod.getSelectedNodeOs() === "windows") {
command = `${command} powershell`; command = `${command} powershell`;
} else { } else {
@ -34,7 +36,9 @@ export class PodShellMenu extends React.Component<PodShellMenuProps> {
render() { render() {
const { object, toolbar } = this.props; const { object, toolbar } = this.props;
const containers = object.getRunningContainers(); const containers = object.getRunningContainers();
if (!containers.length) return null; if (!containers.length) return null;
return ( return (
<Component.MenuItem onClick={Util.prevDefault(() => this.execShell(containers[0].name))}> <Component.MenuItem onClick={Util.prevDefault(() => this.execShell(containers[0].name))}>
<Component.Icon svg="ssh" interactive={toolbar} title="Pod shell"/> <Component.Icon svg="ssh" interactive={toolbar} title="Pod shell"/>
@ -46,6 +50,7 @@ export class PodShellMenu extends React.Component<PodShellMenuProps> {
{ {
containers.map(container => { containers.map(container => {
const { name } = container; const { name } = container;
return ( return (
<Component.MenuItem key={name} onClick={Util.prevDefault(() => this.execShell(name))} className="flex align-center"> <Component.MenuItem key={name} onClick={Util.prevDefault(() => this.execShell(name))} className="flex align-center">
<Component.StatusBrick/> <Component.StatusBrick/>

View File

@ -7,6 +7,7 @@ import { TelemetryPreferencesStore } from "./telemetry-preferences-store";
export class TelemetryPreferenceInput extends React.Component<{telemetry: TelemetryPreferencesStore}, {}> { export class TelemetryPreferenceInput extends React.Component<{telemetry: TelemetryPreferencesStore}, {}> {
render() { render() {
const { telemetry } = this.props; const { telemetry } = this.props;
return ( return (
<Component.Checkbox <Component.Checkbox
label="Allow telemetry & usage tracking" label="Allow telemetry & usage tracking"

View File

@ -29,6 +29,7 @@ export class Tracker extends Util.Singleton {
this.anonymousId = machineIdSync(); this.anonymousId = machineIdSync();
this.os = this.resolveOS(); this.os = this.resolveOS();
this.userAgent = `Lens ${App.version} (${this.os})`; this.userAgent = `Lens ${App.version} (${this.os})`;
try { try {
this.visitor = ua(Tracker.GA_ID, this.anonymousId, { strictCidFormat: false }); this.visitor = ua(Tracker.GA_ID, this.anonymousId, { strictCidFormat: false });
} catch (error) { } catch (error) {
@ -49,18 +50,22 @@ export class Tracker extends Util.Singleton {
const handler = (ev: EventBus.AppEvent) => { const handler = (ev: EventBus.AppEvent) => {
this.event(ev.name, ev.action, ev.params); this.event(ev.name, ev.action, ev.params);
}; };
this.eventHandlers.push(handler); this.eventHandlers.push(handler);
EventBus.appEventBus.addListener(handler); EventBus.appEventBus.addListener(handler);
} }
watchExtensions() { watchExtensions() {
let previousExtensions = App.getEnabledExtensions(); let previousExtensions = App.getEnabledExtensions();
this.disposers.push(reaction(() => App.getEnabledExtensions(), (currentExtensions) => { this.disposers.push(reaction(() => App.getEnabledExtensions(), (currentExtensions) => {
const removedExtensions = previousExtensions.filter(x => !currentExtensions.includes(x)); const removedExtensions = previousExtensions.filter(x => !currentExtensions.includes(x));
removedExtensions.forEach(ext => { removedExtensions.forEach(ext => {
this.event("extension", "disable", { extension: ext }); this.event("extension", "disable", { extension: ext });
}); });
const newExtensions = currentExtensions.filter(x => !previousExtensions.includes(x)); const newExtensions = currentExtensions.filter(x => !previousExtensions.includes(x));
newExtensions.forEach(ext => { newExtensions.forEach(ext => {
this.event("extension", "enable", { extension: ext }); this.event("extension", "enable", { extension: ext });
}); });
@ -82,6 +87,7 @@ export class Tracker extends Util.Singleton {
for (const handler of this.eventHandlers) { for (const handler of this.eventHandlers) {
EventBus.appEventBus.removeListener(handler); EventBus.appEventBus.removeListener(handler);
} }
if (this.reportInterval) { if (this.reportInterval) {
clearInterval(this.reportInterval); clearInterval(this.reportInterval);
} }
@ -125,12 +131,14 @@ export class Tracker extends Util.Singleton {
protected resolveOS() { protected resolveOS() {
let os = ""; let os = "";
if (App.isMac) { if (App.isMac) {
os = "MacOS"; os = "MacOS";
} else if(App.isWindows) { } else if(App.isWindows) {
os = "Windows"; os = "Windows";
} else if (App.isLinux) { } else if (App.isLinux) {
os = "Linux"; os = "Linux";
if (App.isSnap) { if (App.isSnap) {
os += "; Snap"; os += "; Snap";
} else { } else {
@ -139,12 +147,14 @@ export class Tracker extends Util.Singleton {
} else { } else {
os = "Unknown"; os = "Unknown";
} }
return os; return os;
} }
protected async event(eventCategory: string, eventAction: string, otherParams = {}) { protected async event(eventCategory: string, eventAction: string, otherParams = {}) {
try { try {
const allowed = await this.isTelemetryAllowed(); const allowed = await this.isTelemetryAllowed();
if (!allowed) { if (!allowed) {
return; return;
} }

View File

@ -14,9 +14,7 @@ jest.setTimeout(60000);
describe("Lens integration tests", () => { describe("Lens integration tests", () => {
const TEST_NAMESPACE = "integration-tests"; const TEST_NAMESPACE = "integration-tests";
const BACKSPACE = "\uE003"; const BACKSPACE = "\uE003";
let app: Application; let app: Application;
const appStart = async () => { const appStart = async () => {
app = util.setup(); app = util.setup();
await app.start(); await app.start();
@ -25,19 +23,19 @@ describe("Lens integration tests", () => {
await app.client.windowByIndex(0); await app.client.windowByIndex(0);
await app.client.waitUntilWindowLoaded(); await app.client.waitUntilWindowLoaded();
}; };
const clickWhatsNew = async (app: Application) => { const clickWhatsNew = async (app: Application) => {
await app.client.waitUntilTextExists("h1", "What's new?"); await app.client.waitUntilTextExists("h1", "What's new?");
await app.client.click("button.primary"); await app.client.click("button.primary");
await app.client.waitUntilTextExists("h1", "Welcome"); await app.client.waitUntilTextExists("h1", "Welcome");
}; };
const minikubeReady = (): boolean => { const minikubeReady = (): boolean => {
// determine if minikube is running // determine if minikube is running
{ {
const { status } = spawnSync("minikube status", { shell: true }); const { status } = spawnSync("minikube status", { shell: true });
if (status !== 0) { if (status !== 0) {
console.warn("minikube not running"); console.warn("minikube not running");
return false; return false;
} }
} }
@ -45,6 +43,7 @@ describe("Lens integration tests", () => {
// Remove TEST_NAMESPACE if it already exists // Remove TEST_NAMESPACE if it already exists
{ {
const { status } = spawnSync(`minikube kubectl -- get namespace ${TEST_NAMESPACE}`, { shell: true }); const { status } = spawnSync(`minikube kubectl -- get namespace ${TEST_NAMESPACE}`, { shell: true });
if (status === 0) { if (status === 0) {
console.warn(`Removing existing ${TEST_NAMESPACE} namespace`); console.warn(`Removing existing ${TEST_NAMESPACE} namespace`);
@ -52,8 +51,10 @@ describe("Lens integration tests", () => {
`minikube kubectl -- delete namespace ${TEST_NAMESPACE}`, `minikube kubectl -- delete namespace ${TEST_NAMESPACE}`,
{ shell: true }, { shell: true },
); );
if (status !== 0) { if (status !== 0) {
console.warn(`Error removing ${TEST_NAMESPACE} namespace: ${stderr.toString()}`); console.warn(`Error removing ${TEST_NAMESPACE} namespace: ${stderr.toString()}`);
return false; return false;
} }
@ -86,6 +87,7 @@ describe("Lens integration tests", () => {
describe("preferences page", () => { describe("preferences page", () => {
it('shows "preferences"', async () => { it('shows "preferences"', async () => {
const appName: string = process.platform === "darwin" ? "Lens" : "File"; const appName: string = process.platform === "darwin" ? "Lens" : "File";
await app.electron.ipcRenderer.send("test-menu-item-click", appName, "Preferences"); await app.electron.ipcRenderer.send("test-menu-item-click", appName, "Preferences");
await app.client.waitUntilTextExists("h2", "Preferences"); await app.client.waitUntilTextExists("h2", "Preferences");
}); });
@ -153,13 +155,13 @@ describe("Lens integration tests", () => {
await app.client.waitUntilTextExists("div", "Select kubeconfig file"); await app.client.waitUntilTextExists("div", "Select kubeconfig file");
await app.client.click("div.Select__control"); // show the context drop-down list await app.client.click("div.Select__control"); // show the context drop-down list
await app.client.waitUntilTextExists("div", "minikube"); await app.client.waitUntilTextExists("div", "minikube");
if (!await app.client.$("button.primary").isEnabled()) { if (!await app.client.$("button.primary").isEnabled()) {
await app.client.click("div.minikube"); // select minikube context await app.client.click("div.minikube"); // select minikube context
} // else the only context, which must be 'minikube', is automatically selected } // else the only context, which must be 'minikube', is automatically selected
await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button) await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button)
await app.client.click("button.primary"); // add minikube cluster await app.client.click("button.primary"); // add minikube cluster
}; };
const waitForMinikubeDashboard = async (app: Application) => { const waitForMinikubeDashboard = async (app: Application) => {
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started"); await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`); await app.client.waitForExist(`iframe[name="minikube"]`);
@ -169,7 +171,6 @@ describe("Lens integration tests", () => {
util.describeIf(ready)("cluster tests", () => { util.describeIf(ready)("cluster tests", () => {
let clusterAdded = false; let clusterAdded = false;
const addCluster = async () => { const addCluster = async () => {
await clickWhatsNew(app); await clickWhatsNew(app);
await addMinikubeCluster(app); await addMinikubeCluster(app);
@ -443,6 +444,7 @@ describe("Lens integration tests", () => {
expectedText: "Custom Resources" expectedText: "Custom Resources"
}] }]
}]; }];
tests.forEach(({ drawer = "", drawerId = "", pages }) => { tests.forEach(({ drawer = "", drawerId = "", pages }) => {
if (drawer !== "") { if (drawer !== "") {
it(`shows ${drawer} drawer`, async () => { it(`shows ${drawer} drawer`, async () => {
@ -458,6 +460,7 @@ describe("Lens integration tests", () => {
await app.client.waitUntilTextExists(expectedSelector, expectedText); await app.client.waitUntilTextExists(expectedSelector, expectedText);
}); });
}); });
if (drawer !== "") { if (drawer !== "") {
// hide the drawer // hide the drawer
it(`hides ${drawer} drawer`, async () => { it(`hides ${drawer} drawer`, async () => {

View File

@ -30,7 +30,9 @@ type AsyncPidGetter = () => Promise<number>;
export async function tearDown(app: Application) { export async function tearDown(app: Application) {
const pid = await (app.mainProcess.pid as any as AsyncPidGetter)(); const pid = await (app.mainProcess.pid as any as AsyncPidGetter)();
await app.stop(); await app.stop();
try { try {
process.kill(pid, "SIGKILL"); process.kill(pid, "SIGKILL");
} catch (e) { } catch (e) {

View File

@ -1594,6 +1594,7 @@ msgstr "Names"
#: src/renderer/components/dock/upgrade-chart.tsx:98 #: src/renderer/components/dock/upgrade-chart.tsx:98
#: src/renderer/components/item-object-list/page-filters-select.tsx:57 #: src/renderer/components/item-object-list/page-filters-select.tsx:57
#: src/renderer/components/kube-object/kube-object-meta.tsx:23 #: src/renderer/components/kube-object/kube-object-meta.tsx:23
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:144
msgid "Namespace" msgid "Namespace"
msgstr "Namespace" msgstr "Namespace"
@ -2003,6 +2004,10 @@ msgstr "Read-only Root Filesystem"
msgid "Readiness" msgid "Readiness"
msgstr "Readiness" msgstr "Readiness"
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:145
msgid "Ready"
msgstr "Ready"
#: src/renderer/components/+events/event-details.tsx:32 #: src/renderer/components/+events/event-details.tsx:32
#: src/renderer/components/+workloads-pods/pod-details-container.tsx:25 #: src/renderer/components/+workloads-pods/pod-details-container.tsx:25
msgid "Reason" msgid "Reason"

View File

@ -1585,6 +1585,7 @@ msgstr ""
#: src/renderer/components/dock/upgrade-chart.tsx:98 #: src/renderer/components/dock/upgrade-chart.tsx:98
#: src/renderer/components/item-object-list/page-filters-select.tsx:57 #: src/renderer/components/item-object-list/page-filters-select.tsx:57
#: src/renderer/components/kube-object/kube-object-meta.tsx:23 #: src/renderer/components/kube-object/kube-object-meta.tsx:23
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:144
msgid "Namespace" msgid "Namespace"
msgstr "" msgstr ""
@ -1986,6 +1987,10 @@ msgstr ""
msgid "Readiness" msgid "Readiness"
msgstr "" msgstr ""
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:145
msgid "Ready"
msgstr ""
#: src/renderer/components/+events/event-details.tsx:32 #: src/renderer/components/+events/event-details.tsx:32
#: src/renderer/components/+workloads-pods/pod-details-container.tsx:25 #: src/renderer/components/+workloads-pods/pod-details-container.tsx:25
msgid "Reason" msgid "Reason"

View File

@ -1595,6 +1595,7 @@ msgstr ""
#: src/renderer/components/dock/upgrade-chart.tsx:98 #: src/renderer/components/dock/upgrade-chart.tsx:98
#: src/renderer/components/item-object-list/page-filters-select.tsx:57 #: src/renderer/components/item-object-list/page-filters-select.tsx:57
#: src/renderer/components/kube-object/kube-object-meta.tsx:23 #: src/renderer/components/kube-object/kube-object-meta.tsx:23
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:144
msgid "Namespace" msgid "Namespace"
msgstr "Namespace" msgstr "Namespace"
@ -2004,6 +2005,10 @@ msgstr ""
msgid "Readiness" msgid "Readiness"
msgstr "Готовность" msgstr "Готовность"
#: src/renderer/components/+workloads-pods/pod-details-list.tsx:145
msgid "Ready"
msgstr "Готовы"
#: src/renderer/components/+events/event-details.tsx:32 #: src/renderer/components/+events/event-details.tsx:32
#: src/renderer/components/+workloads-pods/pod-details-container.tsx:25 #: src/renderer/components/+workloads-pods/pod-details-container.tsx:25
msgid "Reason" msgid "Reason"

View File

@ -2,7 +2,7 @@
"name": "kontena-lens", "name": "kontena-lens",
"productName": "Lens", "productName": "Lens",
"description": "Lens - The Kubernetes IDE", "description": "Lens - The Kubernetes IDE",
"version": "4.0.0-rc.1", "version": "4.0.0-rc.2",
"main": "static/build/main.js", "main": "static/build/main.js",
"copyright": "© 2020, Mirantis, Inc.", "copyright": "© 2020, Mirantis, Inc.",
"license": "MIT", "license": "MIT",
@ -37,7 +37,7 @@
"download:kubectl": "yarn run ts-node build/download_kubectl.ts", "download:kubectl": "yarn run ts-node build/download_kubectl.ts",
"download:helm": "yarn run ts-node build/download_helm.ts", "download:helm": "yarn run ts-node build/download_helm.ts",
"build:tray-icons": "yarn run ts-node build/build_tray_icon.ts", "build:tray-icons": "yarn run ts-node build/build_tray_icon.ts",
"lint": "yarn run eslint $@ --ext js,ts,tsx --max-warnings=0 src/ integration/ __mocks__/ build/ extensions/", "lint": "yarn run eslint $@ --ext js,ts,tsx --max-warnings=0 .",
"lint:fix": "yarn run lint --fix", "lint:fix": "yarn run lint --fix",
"mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest", "mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest",
"verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict", "verify-docs": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -v ${PWD}:/docs mkdocs-serve-local:latest build --strict",

View File

@ -7,6 +7,20 @@ import { workspaceStore } from "../workspace-store";
const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png"); const testDataIcon = fs.readFileSync("test-data/cluster-store-migration-icon.png");
jest.mock("electron", () => {
return {
app: {
getVersion: () => "99.99.99",
getPath: () => "tmp",
getLocale: () => "en"
},
ipcMain: {
handle: jest.fn(),
on: jest.fn()
}
};
});
let clusterStore: ClusterStore; let clusterStore: ClusterStore;
describe("empty config", () => { describe("empty config", () => {
@ -17,8 +31,10 @@ describe("empty config", () => {
"lens-cluster-store.json": JSON.stringify({}) "lens-cluster-store.json": JSON.stringify({})
} }
}; };
mockFs(mockOpts); mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>(); clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load(); return clusterStore.load();
}); });
@ -45,13 +61,16 @@ describe("empty config", () => {
it("adds new cluster to store", async () => { it("adds new cluster to store", async () => {
const storedCluster = clusterStore.getById("foo"); const storedCluster = clusterStore.getById("foo");
expect(storedCluster.id).toBe("foo"); expect(storedCluster.id).toBe("foo");
expect(storedCluster.preferences.terminalCWD).toBe("/tmp"); expect(storedCluster.preferences.terminalCWD).toBe("/tmp");
expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5"); expect(storedCluster.preferences.icon).toBe("data:image/jpeg;base64, iVBORw0KGgoAAAANSUhEUgAAA1wAAAKoCAYAAABjkf5");
expect(storedCluster.enabled).toBe(true);
}); });
it("adds cluster to default workspace", () => { it("adds cluster to default workspace", () => {
const storedCluster = clusterStore.getById("foo"); const storedCluster = clusterStore.getById("foo");
expect(storedCluster.workspace).toBe("default"); expect(storedCluster.workspace).toBe("default");
}); });
@ -99,6 +118,7 @@ describe("empty config", () => {
it("gets clusters by workspaces", () => { it("gets clusters by workspaces", () => {
const wsClusters = clusterStore.getByWorkspaceId("workstation"); const wsClusters = clusterStore.getByWorkspaceId("workstation");
const defaultClusters = clusterStore.getByWorkspaceId("default"); const defaultClusters = clusterStore.getByWorkspaceId("default");
expect(defaultClusters.length).toBe(0); expect(defaultClusters.length).toBe(0);
expect(wsClusters.length).toBe(2); expect(wsClusters.length).toBe(2);
expect(wsClusters[0].id).toBe("prod"); expect(wsClusters[0].id).toBe("prod");
@ -107,6 +127,7 @@ describe("empty config", () => {
it("check if cluster's kubeconfig file saved", () => { it("check if cluster's kubeconfig file saved", () => {
const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig"); const file = ClusterStore.embedCustomKubeConfig("boo", "kubeconfig");
expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig"); expect(fs.readFileSync(file, "utf8")).toBe("kubeconfig");
}); });
@ -114,6 +135,7 @@ describe("empty config", () => {
clusterStore.swapIconOrders("workstation", 1, 1); clusterStore.swapIconOrders("workstation", 1, 1);
const clusters = clusterStore.getByWorkspaceId("workstation"); const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("prod"); expect(clusters[0].id).toBe("prod");
expect(clusters[0].preferences.iconOrder).toBe(0); expect(clusters[0].preferences.iconOrder).toBe(0);
expect(clusters[1].id).toBe("dev"); expect(clusters[1].id).toBe("dev");
@ -124,6 +146,7 @@ describe("empty config", () => {
clusterStore.swapIconOrders("workstation", 0, 1); clusterStore.swapIconOrders("workstation", 0, 1);
const clusters = clusterStore.getByWorkspaceId("workstation"); const clusters = clusterStore.getByWorkspaceId("workstation");
expect(clusters[0].id).toBe("dev"); expect(clusters[0].id).toBe("dev");
expect(clusters[0].preferences.iconOrder).toBe(0); expect(clusters[0].preferences.iconOrder).toBe(0);
expect(clusters[1].id).toBe("prod"); expect(clusters[1].id).toBe("prod");
@ -170,14 +193,17 @@ describe("config with existing clusters", () => {
kubeConfig: "foo", kubeConfig: "foo",
contextName: "foo", contextName: "foo",
preferences: { terminalCWD: "/foo" }, preferences: { terminalCWD: "/foo" },
workspace: "foo" workspace: "foo",
ownerRef: "foo"
}, },
] ]
}) })
} }
}; };
mockFs(mockOpts); mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>(); clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load(); return clusterStore.load();
}); });
@ -187,6 +213,7 @@ describe("config with existing clusters", () => {
it("allows to retrieve a cluster", () => { it("allows to retrieve a cluster", () => {
const storedCluster = clusterStore.getById("cluster1"); const storedCluster = clusterStore.getById("cluster1");
expect(storedCluster.id).toBe("cluster1"); expect(storedCluster.id).toBe("cluster1");
expect(storedCluster.preferences.terminalCWD).toBe("/foo"); expect(storedCluster.preferences.terminalCWD).toBe("/foo");
}); });
@ -194,13 +221,16 @@ describe("config with existing clusters", () => {
it("allows to delete a cluster", () => { it("allows to delete a cluster", () => {
clusterStore.removeById("cluster2"); clusterStore.removeById("cluster2");
const storedCluster = clusterStore.getById("cluster1"); const storedCluster = clusterStore.getById("cluster1");
expect(storedCluster).toBeTruthy(); expect(storedCluster).toBeTruthy();
const storedCluster2 = clusterStore.getById("cluster2"); const storedCluster2 = clusterStore.getById("cluster2");
expect(storedCluster2).toBeUndefined(); expect(storedCluster2).toBeUndefined();
}); });
it("allows getting all of the clusters", async () => { it("allows getting all of the clusters", async () => {
const storedClusters = clusterStore.clustersList; const storedClusters = clusterStore.clustersList;
expect(storedClusters.length).toBe(3); expect(storedClusters.length).toBe(3);
expect(storedClusters[0].id).toBe("cluster1"); expect(storedClusters[0].id).toBe("cluster1");
expect(storedClusters[0].preferences.terminalCWD).toBe("/foo"); expect(storedClusters[0].preferences.terminalCWD).toBe("/foo");
@ -208,6 +238,13 @@ describe("config with existing clusters", () => {
expect(storedClusters[1].preferences.terminalCWD).toBe("/foo2"); expect(storedClusters[1].preferences.terminalCWD).toBe("/foo2");
expect(storedClusters[2].id).toBe("cluster3"); expect(storedClusters[2].id).toBe("cluster3");
}); });
it("marks owned cluster disabled by default", () => {
const storedClusters = clusterStore.clustersList;
expect(storedClusters[0].enabled).toBe(true);
expect(storedClusters[2].enabled).toBe(false);
});
}); });
describe("pre 2.0 config with an existing cluster", () => { describe("pre 2.0 config with an existing cluster", () => {
@ -225,8 +262,10 @@ describe("pre 2.0 config with an existing cluster", () => {
}) })
} }
}; };
mockFs(mockOpts); mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>(); clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load(); return clusterStore.load();
}); });
@ -236,6 +275,7 @@ describe("pre 2.0 config with an existing cluster", () => {
it("migrates to modern format with kubeconfig in a file", async () => { it("migrates to modern format with kubeconfig in a file", async () => {
const config = clusterStore.clustersList[0].kubeConfigPath; const config = clusterStore.clustersList[0].kubeConfigPath;
expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content"); expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content");
}); });
}); });
@ -257,8 +297,10 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () =>
}) })
} }
}; };
mockFs(mockOpts); mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>(); clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load(); return clusterStore.load();
}); });
@ -270,6 +312,7 @@ describe("pre 2.6.0 config with a cluster that has arrays in auth config", () =>
const file = clusterStore.clustersList[0].kubeConfigPath; const file = clusterStore.clustersList[0].kubeConfigPath;
const config = fs.readFileSync(file, "utf8"); const config = fs.readFileSync(file, "utf8");
const kc = yaml.safeLoad(config); const kc = yaml.safeLoad(config);
expect(kc.users[0].user["auth-provider"].config["access-token"]).toBe("should be string"); expect(kc.users[0].user["auth-provider"].config["access-token"]).toBe("should be string");
expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe("should be string"); expect(kc.users[0].user["auth-provider"].config["expiry"]).toBe("should be string");
}); });
@ -297,8 +340,10 @@ describe("pre 2.6.0 config with a cluster icon", () => {
"icon_path": testDataIcon, "icon_path": testDataIcon,
} }
}; };
mockFs(mockOpts); mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>(); clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load(); return clusterStore.load();
}); });
@ -308,6 +353,7 @@ describe("pre 2.6.0 config with a cluster icon", () => {
it("moves the icon into preferences", async () => { it("moves the icon into preferences", async () => {
const storedClusterData = clusterStore.clustersList[0]; const storedClusterData = clusterStore.clustersList[0];
expect(storedClusterData.hasOwnProperty("icon")).toBe(false); expect(storedClusterData.hasOwnProperty("icon")).toBe(false);
expect(storedClusterData.preferences.hasOwnProperty("icon")).toBe(true); expect(storedClusterData.preferences.hasOwnProperty("icon")).toBe(true);
expect(storedClusterData.preferences.icon.startsWith("data:;base64,")).toBe(true); expect(storedClusterData.preferences.icon.startsWith("data:;base64,")).toBe(true);
@ -334,8 +380,10 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
}) })
} }
}; };
mockFs(mockOpts); mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>(); clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load(); return clusterStore.load();
}); });
@ -345,6 +393,7 @@ describe("for a pre 2.7.0-beta.0 config without a workspace", () => {
it("adds cluster to default workspace", async () => { it("adds cluster to default workspace", async () => {
const storedClusterData = clusterStore.clustersList[0]; const storedClusterData = clusterStore.clustersList[0];
expect(storedClusterData.workspace).toBe("default"); expect(storedClusterData.workspace).toBe("default");
}); });
}); });
@ -374,8 +423,10 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
"icon_path": testDataIcon, "icon_path": testDataIcon,
} }
}; };
mockFs(mockOpts); mockFs(mockOpts);
clusterStore = ClusterStore.getInstance<ClusterStore>(); clusterStore = ClusterStore.getInstance<ClusterStore>();
return clusterStore.load(); return clusterStore.load();
}); });
@ -385,11 +436,13 @@ describe("pre 3.6.0-beta.1 config with an existing cluster", () => {
it("migrates to modern format with kubeconfig in a file", async () => { it("migrates to modern format with kubeconfig in a file", async () => {
const config = clusterStore.clustersList[0].kubeConfigPath; const config = clusterStore.clustersList[0].kubeConfigPath;
expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content"); expect(fs.readFileSync(config, "utf8")).toBe("kubeconfig content");
}); });
it("migrates to modern format with icon not in file", async () => { it("migrates to modern format with icon not in file", async () => {
const { icon } = clusterStore.clustersList[0].preferences; const { icon } = clusterStore.clustersList[0].preferences;
expect(icon.startsWith("data:;base64,")).toBe(true); expect(icon.startsWith("data:;base64,")).toBe(true);
}); });
}); });

View File

@ -4,6 +4,7 @@ describe("event bus tests", () => {
describe("emit", () => { describe("emit", () => {
it("emits an event", () => { it("emits an event", () => {
let event: AppEvent = null; let event: AppEvent = null;
appEventBus.addListener((data) => { appEventBus.addListener((data) => {
event = data; event = data;
}); });

View File

@ -5,7 +5,6 @@
import { SearchStore } from "../search-store"; import { SearchStore } from "../search-store";
let searchStore: SearchStore = null; let searchStore: SearchStore = null;
const logs = [ const logs = [
"1:M 30 Oct 2020 16:17:41.553 # Connection with replica 172.17.0.12:6379 lost", "1:M 30 Oct 2020 16:17:41.553 # Connection with replica 172.17.0.12:6379 lost",
"1:M 30 Oct 2020 16:17:41.623 * Replica 172.17.0.12:6379 asks for synchronization", "1:M 30 Oct 2020 16:17:41.623 * Replica 172.17.0.12:6379 asks for synchronization",
@ -64,6 +63,7 @@ describe("search store tests", () => {
it("escapes string for using in regex", () => { it("escapes string for using in regex", () => {
const regex = searchStore.escapeRegex("some.interesting-query\\#?()[]"); const regex = searchStore.escapeRegex("some.interesting-query\\#?()[]");
expect(regex).toBe("some\\.interesting\\-query\\\\\\#\\?\\(\\)\\[\\]"); expect(regex).toBe("some\\.interesting\\-query\\\\\\#\\?\\(\\)\\[\\]");
}); });

View File

@ -59,6 +59,7 @@ describe("user store tests", () => {
it("correctly resets theme to default value", async () => { it("correctly resets theme to default value", async () => {
const us = UserStore.getInstance<UserStore>(); const us = UserStore.getInstance<UserStore>();
us.isLoaded = true; us.isLoaded = true;
us.preferences.colorTheme = "some other theme"; us.preferences.colorTheme = "some other theme";

View File

@ -6,6 +6,10 @@ jest.mock("electron", () => {
getVersion: () => "99.99.99", getVersion: () => "99.99.99",
getPath: () => "tmp", getPath: () => "tmp",
getLocale: () => "en" getLocale: () => "en"
},
ipcMain: {
handle: jest.fn(),
on: jest.fn()
} }
}; };
}); });
@ -40,7 +44,6 @@ describe("workspace store tests", () => {
it("can update workspace description", () => { it("can update workspace description", () => {
const ws = WorkspaceStore.getInstance<WorkspaceStore>(); const ws = WorkspaceStore.getInstance<WorkspaceStore>();
const workspace = ws.addWorkspace(new Workspace({ const workspace = ws.addWorkspace(new Workspace({
id: "foobar", id: "foobar",
name: "foobar", name: "foobar",
@ -60,7 +63,10 @@ describe("workspace store tests", () => {
name: "foobar", name: "foobar",
})); }));
expect(ws.getById("123").name).toBe("foobar"); const workspace = ws.getById("123");
expect(workspace.name).toBe("foobar");
expect(workspace.enabled).toBe(true);
}); });
it("cannot set a non-existent workspace to be active", () => { it("cannot set a non-existent workspace to be active", () => {

View File

@ -55,6 +55,7 @@ export abstract class BaseStore<T = any> extends Singleton {
if (this.params.autoLoad) { if (this.params.autoLoad) {
await this.load(); await this.load();
} }
if (this.params.syncEnabled) { if (this.params.syncEnabled) {
await this.whenLoaded; await this.whenLoaded;
this.enableSync(); this.enableSync();
@ -63,6 +64,7 @@ export abstract class BaseStore<T = any> extends Singleton {
async load() { async load() {
const { autoLoad, syncEnabled, ...confOptions } = this.params; const { autoLoad, syncEnabled, ...confOptions } = this.params;
this.storeConfig = new Config({ this.storeConfig = new Config({
...confOptions, ...confOptions,
projectName: "lens", projectName: "lens",
@ -90,19 +92,23 @@ export abstract class BaseStore<T = any> extends Singleton {
this.syncDisposers.push( this.syncDisposers.push(
reaction(() => this.toJSON(), model => this.onModelChange(model), this.params.syncOptions), reaction(() => this.toJSON(), model => this.onModelChange(model), this.params.syncOptions),
); );
if (ipcMain) { if (ipcMain) {
const callback = (event: IpcMainEvent, model: T) => { const callback = (event: IpcMainEvent, model: T) => {
logger.silly(`[STORE]: SYNC ${this.name} from renderer`, { model }); logger.silly(`[STORE]: SYNC ${this.name} from renderer`, { model });
this.onSync(model); this.onSync(model);
}; };
subscribeToBroadcast(this.syncMainChannel, callback); subscribeToBroadcast(this.syncMainChannel, callback);
this.syncDisposers.push(() => unsubscribeFromBroadcast(this.syncMainChannel, callback)); this.syncDisposers.push(() => unsubscribeFromBroadcast(this.syncMainChannel, callback));
} }
if (ipcRenderer) { if (ipcRenderer) {
const callback = (event: IpcRendererEvent, model: T) => { const callback = (event: IpcRendererEvent, model: T) => {
logger.silly(`[STORE]: SYNC ${this.name} from main`, { model }); logger.silly(`[STORE]: SYNC ${this.name} from main`, { model });
this.onSyncFromMain(model); this.onSyncFromMain(model);
}; };
subscribeToBroadcast(this.syncRendererChannel, callback); subscribeToBroadcast(this.syncRendererChannel, callback);
this.syncDisposers.push(() => unsubscribeFromBroadcast(this.syncRendererChannel, callback)); this.syncDisposers.push(() => unsubscribeFromBroadcast(this.syncRendererChannel, callback));
} }
@ -127,6 +133,7 @@ export abstract class BaseStore<T = any> extends Singleton {
protected applyWithoutSync(callback: () => void) { protected applyWithoutSync(callback: () => void) {
this.disableSync(); this.disableSync();
runInAction(callback); runInAction(callback);
if (this.params.syncEnabled) { if (this.params.syncEnabled) {
this.enableSync(); this.enableSync();
} }

View File

@ -15,6 +15,7 @@ export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all";
if (ipcMain) { if (ipcMain) {
handleRequest(clusterActivateHandler, (event, clusterId: ClusterId, force = false) => { handleRequest(clusterActivateHandler, (event, clusterId: ClusterId, force = false) => {
const cluster = clusterStore.getById(clusterId); const cluster = clusterStore.getById(clusterId);
if (cluster) { if (cluster) {
return cluster.activate(force); return cluster.activate(force);
} }
@ -22,20 +23,24 @@ if (ipcMain) {
handleRequest(clusterSetFrameIdHandler, (event, clusterId: ClusterId, frameId: number) => { handleRequest(clusterSetFrameIdHandler, (event, clusterId: ClusterId, frameId: number) => {
const cluster = clusterStore.getById(clusterId); const cluster = clusterStore.getById(clusterId);
if (cluster) { if (cluster) {
clusterFrameMap.set(cluster.id, frameId); clusterFrameMap.set(cluster.id, frameId);
return cluster.pushState(); return cluster.pushState();
} }
}); });
handleRequest(clusterRefreshHandler, (event, clusterId: ClusterId) => { handleRequest(clusterRefreshHandler, (event, clusterId: ClusterId) => {
const cluster = clusterStore.getById(clusterId); const cluster = clusterStore.getById(clusterId);
if (cluster) return cluster.refresh({ refreshMetadata: true }); if (cluster) return cluster.refresh({ refreshMetadata: true });
}); });
handleRequest(clusterDisconnectHandler, (event, clusterId: ClusterId) => { handleRequest(clusterDisconnectHandler, (event, clusterId: ClusterId) => {
appEventBus.emit({name: "cluster", action: "stop"}); appEventBus.emit({name: "cluster", action: "stop"});
const cluster = clusterStore.getById(clusterId); const cluster = clusterStore.getById(clusterId);
if (cluster) { if (cluster) {
cluster.disconnect(); cluster.disconnect();
clusterFrameMap.delete(cluster.id); clusterFrameMap.delete(cluster.id);
@ -45,8 +50,10 @@ if (ipcMain) {
handleRequest(clusterKubectlApplyAllHandler, (event, clusterId: ClusterId, resources: string[]) => { handleRequest(clusterKubectlApplyAllHandler, (event, clusterId: ClusterId, resources: string[]) => {
appEventBus.emit({name: "cluster", action: "kubectl-apply-all"}); appEventBus.emit({name: "cluster", action: "kubectl-apply-all"});
const cluster = clusterStore.getById(clusterId); const cluster = clusterStore.getById(clusterId);
if (cluster) { if (cluster) {
const applier = new ResourceApplier(cluster); const applier = new ResourceApplier(cluster);
applier.kubectlApplyAll(resources); applier.kubectlApplyAll(resources);
} else { } else {
throw `${clusterId} is not a valid cluster id`; throw `${clusterId} is not a valid cluster id`;

View File

@ -11,7 +11,7 @@ import { appEventBus } from "./event-bus";
import { dumpConfigYaml } from "./kube-helpers"; import { dumpConfigYaml } from "./kube-helpers";
import { saveToAppFiles } from "./utils/saveToAppFiles"; import { saveToAppFiles } from "./utils/saveToAppFiles";
import { KubeConfig } from "@kubernetes/client-node"; import { KubeConfig } from "@kubernetes/client-node";
import { subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc"; import { handleRequest, requestMain, subscribeToBroadcast, unsubscribeAllFromBroadcast } from "./ipc";
import _ from "lodash"; import _ from "lodash";
import move from "array-move"; import move from "array-move";
import type { WorkspaceId } from "./workspace-store"; import type { WorkspaceId } from "./workspace-store";
@ -40,13 +40,30 @@ export interface ClusterStoreModel {
export type ClusterId = string; export type ClusterId = string;
export interface ClusterModel { export interface ClusterModel {
/** Unique id for a cluster */
id: ClusterId; id: ClusterId;
/** Path to cluster kubeconfig */
kubeConfigPath: string; kubeConfigPath: string;
/** Workspace id */
workspace?: WorkspaceId; workspace?: WorkspaceId;
/** User context in kubeconfig */
contextName?: string; contextName?: string;
/** Preferences */
preferences?: ClusterPreferences; preferences?: ClusterPreferences;
/** Metadata */
metadata?: ClusterMetadata; metadata?: ClusterMetadata;
/**
* If extension sets ownerRef it has to explicitly mark a cluster as enabled during onActive (or when cluster is saved)
*/
ownerRef?: string; ownerRef?: string;
/** List of accessible namespaces */
accessibleNamespaces?: string[]; accessibleNamespaces?: string[];
/** @deprecated */ /** @deprecated */
@ -81,7 +98,9 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string { static embedCustomKubeConfig(clusterId: ClusterId, kubeConfig: KubeConfig | string): string {
const filePath = ClusterStore.getCustomKubeConfigPath(clusterId); const filePath = ClusterStore.getCustomKubeConfigPath(clusterId);
const fileContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig); const fileContents = typeof kubeConfig == "string" ? kubeConfig : dumpConfigYaml(kubeConfig);
saveToAppFiles(filePath, fileContents, { mode: 0o600 }); saveToAppFiles(filePath, fileContents, { mode: 0o600 });
return filePath; return filePath;
} }
@ -89,6 +108,8 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@observable removedClusters = observable.map<ClusterId, Cluster>(); @observable removedClusters = observable.map<ClusterId, Cluster>();
@observable clusters = observable.map<ClusterId, Cluster>(); @observable clusters = observable.map<ClusterId, Cluster>();
private static stateRequestChannel = "cluster:states";
private constructor() { private constructor() {
super({ super({
configName: "lens-cluster-store", configName: "lens-cluster-store",
@ -102,8 +123,45 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
this.pushStateToViewsAutomatically(); this.pushStateToViewsAutomatically();
} }
async load() {
await super.load();
type clusterStateSync = {
id: string;
state: ClusterState;
};
if (ipcRenderer) {
logger.info("[CLUSTER-STORE] requesting initial state sync");
const clusterStates: clusterStateSync[] = await requestMain(ClusterStore.stateRequestChannel);
clusterStates.forEach((clusterState) => {
const cluster = this.getById(clusterState.id);
if (cluster) {
cluster.setState(clusterState.state);
}
});
} else {
handleRequest(ClusterStore.stateRequestChannel, (): clusterStateSync[] => {
const states: clusterStateSync[] = [];
this.clustersList.forEach((cluster) => {
states.push({
state: cluster.getState(),
id: cluster.id
});
});
return states;
});
}
}
protected pushStateToViewsAutomatically() { protected pushStateToViewsAutomatically() {
if (!ipcRenderer) { if (!ipcRenderer) {
reaction(() => this.enabledClustersList, () => {
this.pushState();
});
reaction(() => this.connectedClustersList, () => { reaction(() => this.connectedClustersList, () => {
this.pushState(); this.pushState();
}); });
@ -156,6 +214,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@action @action
setActive(id: ClusterId) { setActive(id: ClusterId) {
const clusterId = this.clusters.has(id) ? id : null; const clusterId = this.clusters.has(id) ? id : null;
this.activeCluster = clusterId; this.activeCluster = clusterId;
workspaceStore.setLastActiveClusterId(clusterId); workspaceStore.setLastActiveClusterId(clusterId);
} }
@ -163,11 +222,13 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
@action @action
swapIconOrders(workspace: WorkspaceId, from: number, to: number) { swapIconOrders(workspace: WorkspaceId, from: number, to: number) {
const clusters = this.getByWorkspaceId(workspace); const clusters = this.getByWorkspaceId(workspace);
if (from < 0 || to < 0 || from >= clusters.length || to >= clusters.length || isNaN(from) || isNaN(to)) { if (from < 0 || to < 0 || from >= clusters.length || to >= clusters.length || isNaN(from) || isNaN(to)) {
throw new Error(`invalid from<->to arguments`); throw new Error(`invalid from<->to arguments`);
} }
move.mutate(clusters, from, to); move.mutate(clusters, from, to);
for (const i in clusters) { for (const i in clusters) {
// This resets the iconOrder to the current display order // This resets the iconOrder to the current display order
clusters[i].preferences.iconOrder = +i; clusters[i].preferences.iconOrder = +i;
@ -185,12 +246,14 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
getByWorkspaceId(workspaceId: string): Cluster[] { getByWorkspaceId(workspaceId: string): Cluster[] {
const clusters = Array.from(this.clusters.values()) const clusters = Array.from(this.clusters.values())
.filter(cluster => cluster.workspace === workspaceId); .filter(cluster => cluster.workspace === workspaceId);
return _.sortBy(clusters, cluster => cluster.preferences.iconOrder); return _.sortBy(clusters, cluster => cluster.preferences.iconOrder);
} }
@action @action
addClusters(...models: ClusterModel[]): Cluster[] { addClusters(...models: ClusterModel[]): Cluster[] {
const clusters: Cluster[] = []; const clusters: Cluster[] = [];
models.forEach(model => { models.forEach(model => {
clusters.push(this.addCluster(model)); clusters.push(this.addCluster(model));
}); });
@ -202,10 +265,16 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
addCluster(model: ClusterModel | Cluster): Cluster { addCluster(model: ClusterModel | Cluster): Cluster {
appEventBus.emit({ name: "cluster", action: "add" }); appEventBus.emit({ name: "cluster", action: "add" });
let cluster = model as Cluster; let cluster = model as Cluster;
if (!(model instanceof Cluster)) { if (!(model instanceof Cluster)) {
cluster = new Cluster(model); cluster = new Cluster(model);
} }
if (!cluster.isManaged) {
cluster.enabled = true;
}
this.clusters.set(model.id, cluster); this.clusters.set(model.id, cluster);
return cluster; return cluster;
} }
@ -217,11 +286,14 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
async removeById(clusterId: ClusterId) { async removeById(clusterId: ClusterId) {
appEventBus.emit({ name: "cluster", action: "remove" }); appEventBus.emit({ name: "cluster", action: "remove" });
const cluster = this.getById(clusterId); const cluster = this.getById(clusterId);
if (cluster) { if (cluster) {
this.clusters.delete(clusterId); this.clusters.delete(clusterId);
if (this.activeCluster === clusterId) { if (this.activeCluster === clusterId) {
this.setActive(null); this.setActive(null);
} }
// remove only custom kubeconfigs (pasted as text) // remove only custom kubeconfigs (pasted as text)
if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) { if (cluster.kubeConfigPath == ClusterStore.getCustomKubeConfigPath(clusterId)) {
unlink(cluster.kubeConfigPath).catch(() => null); unlink(cluster.kubeConfigPath).catch(() => null);
@ -245,10 +317,12 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
// update new clusters // update new clusters
for (const clusterModel of clusters) { for (const clusterModel of clusters) {
let cluster = currentClusters.get(clusterModel.id); let cluster = currentClusters.get(clusterModel.id);
if (cluster) { if (cluster) {
cluster.updateModel(clusterModel); cluster.updateModel(clusterModel);
} else { } else {
cluster = new Cluster(clusterModel); cluster = new Cluster(clusterModel);
if (!cluster.isManaged) { if (!cluster.isManaged) {
cluster.enabled = true; cluster.enabled = true;
} }
@ -282,6 +356,7 @@ export const clusterStore = ClusterStore.getInstance<ClusterStore>();
export function getClusterIdFromHost(hostname: string): ClusterId { export function getClusterIdFromHost(hostname: string): ClusterId {
const subDomains = hostname.split(":")[0].split("."); const subDomains = hostname.split(":")[0].split(".");
return subDomains.slice(-2)[0]; // e.g host == "%clusterId.localhost:45345" return subDomains.slice(-2)[0]; // e.g host == "%clusterId.localhost:45345"
} }

View File

@ -2,6 +2,7 @@ export class ExecValidationNotFoundError extends Error {
constructor(execPath: string, isAbsolute: boolean) { constructor(execPath: string, isAbsolute: boolean) {
super(`User Exec command "${execPath}" not found on host.`); super(`User Exec command "${execPath}" not found on host.`);
let message = `User Exec command "${execPath}" not found on host.`; let message = `User Exec command "${execPath}" not found on host.`;
if (!isAbsolute) { if (!isAbsolute) {
message += ` Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig`; message += ` Please ensure binary is found in PATH or use absolute path to binary in Kubeconfig`;
} }

View File

@ -13,6 +13,7 @@ export class EventEmitter<D extends [...any[]]> {
addListener(callback: Callback<D>, options: Options = {}) { addListener(callback: Callback<D>, options: Options = {}) {
if (options.prepend) { if (options.prepend) {
const listeners = [...this.listeners]; const listeners = [...this.listeners];
listeners.unshift([callback, options]); listeners.unshift([callback, options]);
this.listeners = new Map(listeners); this.listeners = new Map(listeners);
} }
@ -33,7 +34,9 @@ export class EventEmitter<D extends [...any[]]> {
[...this.listeners].every(([callback, options]) => { [...this.listeners].every(([callback, options]) => {
if (options.once) this.removeListener(callback); if (options.once) this.removeListener(callback);
const result = callback(...data); const result = callback(...data);
if (result === false) return; // break cycle if (result === false) return; // break cycle
return true; return true;
}); });
} }

View File

@ -16,18 +16,22 @@ export async function requestMain(channel: string, ...args: any[]) {
async function getSubFrames(): Promise<number[]> { async function getSubFrames(): Promise<number[]> {
const subFrames: number[] = []; const subFrames: number[] = [];
clusterFrameMap.forEach(frameId => { clusterFrameMap.forEach(frameId => {
subFrames.push(frameId); subFrames.push(frameId);
}); });
return subFrames; return subFrames;
} }
export function broadcastMessage(channel: string, ...args: any[]) { export function broadcastMessage(channel: string, ...args: any[]) {
const views = (webContents || remote?.webContents)?.getAllWebContents(); const views = (webContents || remote?.webContents)?.getAllWebContents();
if (!views) return; if (!views) return;
views.forEach(webContent => { views.forEach(webContent => {
const type = webContent.getType(); const type = webContent.getType();
logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args }); logger.silly(`[IPC]: broadcasting "${channel}" to ${type}=${webContent.id}`, { args });
webContent.send(channel, ...args); webContent.send(channel, ...args);
getSubFrames().then((frames) => { getSubFrames().then((frames) => {
@ -36,6 +40,7 @@ export function broadcastMessage(channel: string, ...args: any[]) {
}); });
}).catch((e) => e); }).catch((e) => e);
}); });
if (ipcRenderer) { if (ipcRenderer) {
ipcRenderer.send(channel, ...args); ipcRenderer.send(channel, ...args);
} else { } else {

View File

@ -13,6 +13,7 @@ function resolveTilde(filePath: string) {
if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) { if (filePath[0] === "~" && (filePath[1] === "/" || filePath.length === 1)) {
return filePath.replace("~", os.homedir()); return filePath.replace("~", os.homedir());
} }
return filePath; return filePath;
} }
@ -40,12 +41,15 @@ export function validateConfig(config: KubeConfig | string): KubeConfig {
config = loadConfig(config); config = loadConfig(config);
} }
logger.debug(`validating kube config: ${JSON.stringify(config)}`); logger.debug(`validating kube config: ${JSON.stringify(config)}`);
if (!config.users || config.users.length == 0) { if (!config.users || config.users.length == 0) {
throw new Error("No users provided in config"); throw new Error("No users provided in config");
} }
if (!config.clusters || config.clusters.length == 0) { if (!config.clusters || config.clusters.length == 0) {
throw new Error("No clusters provided in config"); throw new Error("No clusters provided in config");
} }
if (!config.contexts || config.contexts.length == 0) { if (!config.contexts || config.contexts.length == 0) {
throw new Error("No contexts provided in config"); throw new Error("No contexts provided in config");
} }
@ -58,11 +62,13 @@ export function validateConfig(config: KubeConfig | string): KubeConfig {
*/ */
export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] { export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] {
const configs: KubeConfig[] = []; const configs: KubeConfig[] = [];
if (!kubeConfig.contexts) { if (!kubeConfig.contexts) {
return configs; return configs;
} }
kubeConfig.contexts.forEach(ctx => { kubeConfig.contexts.forEach(ctx => {
const kc = new KubeConfig(); const kc = new KubeConfig();
kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n); kc.clusters = [kubeConfig.getCluster(ctx.cluster)].filter(n => n);
kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n); kc.users = [kubeConfig.getUser(ctx.user)].filter(n => n);
kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n); kc.contexts = [kubeConfig.getContextObject(ctx.name)].filter(n => n);
@ -70,6 +76,7 @@ export function splitConfig(kubeConfig: KubeConfig): KubeConfig[] {
configs.push(kc); configs.push(kc);
}); });
return configs; return configs;
} }
@ -154,10 +161,12 @@ export function validateKubeConfig (config: KubeConfig) {
// Validate the User Object // Validate the User Object
const user = config.getCurrentUser(); const user = config.getCurrentUser();
if (user.exec) { if (user.exec) {
const execCommand = user.exec["command"]; const execCommand = user.exec["command"];
// check if the command is absolute or not // check if the command is absolute or not
const isAbsolute = path.isAbsolute(execCommand); const isAbsolute = path.isAbsolute(execCommand);
// validate the exec struct in the user object, start with the command field // validate the exec struct in the user object, start with the command field
logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`); logger.debug(`validateKubeConfig: validating user exec command - ${JSON.stringify(execCommand)}`);

View File

@ -6,6 +6,7 @@ import { PrometheusProviderRegistry } from "../main/prometheus/provider-registry
[PrometheusLens, PrometheusHelm, PrometheusOperator, PrometheusStacklight].forEach(providerClass => { [PrometheusLens, PrometheusHelm, PrometheusOperator, PrometheusStacklight].forEach(providerClass => {
const provider = new providerClass(); const provider = new providerClass();
PrometheusProviderRegistry.registerProvider(provider.id, provider); PrometheusProviderRegistry.registerProvider(provider.id, provider);
}); });

View File

@ -42,10 +42,12 @@ export function isAllowedResource(resources: KubeResource | KubeResource[]) {
resources = [resources]; resources = [resources];
} }
const { allowedResources = [] } = getHostedCluster() || {}; const { allowedResources = [] } = getHostedCluster() || {};
for (const resource of resources) { for (const resource of resources) {
if (!allowedResources.includes(resource)) { if (!allowedResources.includes(resource)) {
return false; return false;
} }
} }
return true; return true;
} }

View File

@ -7,6 +7,7 @@ export function registerFileProtocol(name: string, basePath: string) {
protocol.registerFileProtocol(name, (request, callback) => { protocol.registerFileProtocol(name, (request, callback) => {
const filePath = request.url.replace(`${name}://`, ""); const filePath = request.url.replace(`${name}://`, "");
const absPath = path.resolve(basePath, filePath); const absPath = path.resolve(basePath, filePath);
callback({ path: absPath }); callback({ path: absPath });
}); });
} }

View File

@ -7,6 +7,7 @@ import { userStore } from "./user-store";
function getDefaultRequestOpts(): Partial<request.Options> { function getDefaultRequestOpts(): Partial<request.Options> {
const { httpsProxy, allowUntrustedCAs } = userStore.preferences; const { httpsProxy, allowUntrustedCAs } = userStore.preferences;
return { return {
proxy: httpsProxy || undefined, proxy: httpsProxy || undefined,
rejectUnauthorized: !allowUntrustedCAs, rejectUnauthorized: !allowUntrustedCAs,

View File

@ -14,8 +14,10 @@ export class SearchStore {
@action @action
onSearch(text: string[], query = this.searchQuery) { onSearch(text: string[], query = this.searchQuery) {
this.searchQuery = query; this.searchQuery = query;
if (!query) { if (!query) {
this.reset(); this.reset();
return; return;
} }
this.occurrences = this.findOccurences(text, query); this.occurrences = this.findOccurences(text, query);
@ -36,11 +38,14 @@ export class SearchStore {
findOccurences(text: string[], query: string) { findOccurences(text: string[], query: string) {
if (!text) return []; if (!text) return [];
const occurences: number[] = []; const occurences: number[] = [];
text.forEach((line, index) => { text.forEach((line, index) => {
const regex = new RegExp(this.escapeRegex(query), "gi"); const regex = new RegExp(this.escapeRegex(query), "gi");
const matches = [...line.matchAll(regex)]; const matches = [...line.matchAll(regex)];
matches.forEach(() => occurences.push(index)); matches.forEach(() => occurences.push(index));
}); });
return occurences; return occurences;
} }
@ -51,9 +56,11 @@ export class SearchStore {
*/ */
getNextOverlay(loopOver = false) { getNextOverlay(loopOver = false) {
const next = this.activeOverlayIndex + 1; const next = this.activeOverlayIndex + 1;
if (next > this.occurrences.length - 1) { if (next > this.occurrences.length - 1) {
return loopOver ? 0 : this.activeOverlayIndex; return loopOver ? 0 : this.activeOverlayIndex;
} }
return next; return next;
} }
@ -64,9 +71,11 @@ export class SearchStore {
*/ */
getPrevOverlay(loopOver = false) { getPrevOverlay(loopOver = false) {
const prev = this.activeOverlayIndex - 1; const prev = this.activeOverlayIndex - 1;
if (prev < 0) { if (prev < 0) {
return loopOver ? this.occurrences.length - 1 : this.activeOverlayIndex; return loopOver ? this.occurrences.length - 1 : this.activeOverlayIndex;
} }
return prev; return prev;
} }
@ -104,6 +113,7 @@ export class SearchStore {
@autobind() @autobind()
isActiveOverlay(line: number, occurence: number) { isActiveOverlay(line: number, occurence: number) {
const firstLineIndex = this.occurrences.findIndex(item => item === line); const firstLineIndex = this.occurrences.findIndex(item => item === line);
return firstLineIndex + occurence === this.activeOverlayIndex; return firstLineIndex + occurence === this.activeOverlayIndex;
} }

View File

@ -6,9 +6,11 @@ import logger from "../main/logger";
if (isMac) { if (isMac) {
for (const crt of macca.all()) { for (const crt of macca.all()) {
const attributes = crt.issuer?.attributes?.map((a: any) => `${a.name}=${a.value}`); const attributes = crt.issuer?.attributes?.map((a: any) => `${a.name}=${a.value}`);
logger.debug(`Using host CA: ${attributes.join(",")}`); logger.debug(`Using host CA: ${attributes.join(",")}`);
} }
} }
if (isWindows) { if (isWindows) {
winca.inject("+"); // see: https://github.com/ukoloff/win-ca#caveats winca.inject("+"); // see: https://github.com/ukoloff/win-ca#caveats
} }

View File

@ -102,6 +102,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
protected refreshNewContexts = async () => { protected refreshNewContexts = async () => {
try { try {
const kubeConfig = await readFile(this.kubeConfigPath, "utf8"); const kubeConfig = await readFile(this.kubeConfigPath, "utf8");
if (kubeConfig) { if (kubeConfig) {
this.newContexts.clear(); this.newContexts.clear();
loadConfig(kubeConfig).getContexts() loadConfig(kubeConfig).getContexts()
@ -118,6 +119,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
@action @action
markNewContextsAsSeen() { markNewContextsAsSeen() {
const { seenContexts, newContexts } = this; const { seenContexts, newContexts } = this;
this.seenContexts.replace([...seenContexts, ...newContexts]); this.seenContexts.replace([...seenContexts, ...newContexts]);
this.newContexts.clear(); this.newContexts.clear();
} }
@ -133,9 +135,11 @@ export class UserStore extends BaseStore<UserStoreModel> {
@action @action
protected async fromStore(data: Partial<UserStoreModel> = {}) { protected async fromStore(data: Partial<UserStoreModel> = {}) {
const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data; const { lastSeenAppVersion, seenContexts = [], preferences, kubeConfigPath } = data;
if (lastSeenAppVersion) { if (lastSeenAppVersion) {
this.lastSeenAppVersion = lastSeenAppVersion; this.lastSeenAppVersion = lastSeenAppVersion;
} }
if (kubeConfigPath) { if (kubeConfigPath) {
this.kubeConfigPath = kubeConfigPath; this.kubeConfigPath = kubeConfigPath;
} }
@ -150,6 +154,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
seenContexts: Array.from(this.seenContexts), seenContexts: Array.from(this.seenContexts),
preferences: this.preferences, preferences: this.preferences,
}; };
return toJS(model, { return toJS(model, {
recurseEverything: true, recurseEverything: true,
}); });

View File

@ -12,7 +12,6 @@ export function autobind() {
function bindClass<T extends Constructor>(constructor: T) { function bindClass<T extends Constructor>(constructor: T) {
const proto = constructor.prototype; const proto = constructor.prototype;
const descriptors = Object.getOwnPropertyDescriptors(proto); const descriptors = Object.getOwnPropertyDescriptors(proto);
const skipMethod = (methodName: string) => { const skipMethod = (methodName: string) => {
return methodName === "constructor" return methodName === "constructor"
|| typeof descriptors[methodName].value !== "function"; || typeof descriptors[methodName].value !== "function";
@ -21,6 +20,7 @@ function bindClass<T extends Constructor>(constructor: T) {
Object.keys(descriptors).forEach(prop => { Object.keys(descriptors).forEach(prop => {
if (skipMethod(prop)) return; if (skipMethod(prop)) return;
const boundDescriptor = bindMethod(proto, prop, descriptors[prop]); const boundDescriptor = bindMethod(proto, prop, descriptors[prop]);
Object.defineProperty(proto, prop, boundDescriptor); Object.defineProperty(proto, prop, boundDescriptor);
}); });
} }
@ -38,6 +38,7 @@ function bindMethod(target: object, prop?: string, descriptor?: PropertyDescript
get() { get() {
if (this === target) return func; // direct access from prototype if (this === target) return func; // direct access from prototype
if (!boundFunc.has(this)) boundFunc.set(this, func.bind(this)); if (!boundFunc.has(this)) boundFunc.set(this, func.bind(this));
return boundFunc.get(this); return boundFunc.get(this);
} }
}); });

View File

@ -7,8 +7,10 @@ export interface IURLParams<P extends object = {}, Q extends object = {}> {
export function buildURL<P extends object = {}, Q extends object = {}>(path: string | any) { export function buildURL<P extends object = {}, Q extends object = {}>(path: string | any) {
const pathBuilder = compile(String(path)); const pathBuilder = compile(String(path));
return function ({ params, query }: IURLParams<P, Q> = {}) { return function ({ params, query }: IURLParams<P, Q> = {}) {
const queryParams = query ? new URLSearchParams(Object.entries(query)).toString() : ""; const queryParams = query ? new URLSearchParams(Object.entries(query)).toString() : "";
return pathBuilder(params) + (queryParams ? `?${queryParams}` : ""); return pathBuilder(params) + (queryParams ? `?${queryParams}` : "");
}; };
} }

View File

@ -8,7 +8,9 @@ export function toCamelCase(obj: Record<string, any>): any {
else if (isPlainObject(obj)) { else if (isPlainObject(obj)) {
return Object.keys(obj).reduce((result, key) => { return Object.keys(obj).reduce((result, key) => {
const value = obj[key]; const value = obj[key];
result[camelCase(key)] = typeof value === "object" ? toCamelCase(value) : value; result[camelCase(key)] = typeof value === "object" ? toCamelCase(value) : value;
return result; return result;
}, {} as any); }, {} as any);
} }

View File

@ -2,6 +2,7 @@
export function debouncePromise<T, F extends any[]>(func: (...args: F) => T | Promise<T>, timeout = 0): (...args: F) => Promise<T> { export function debouncePromise<T, F extends any[]>(func: (...args: F) => T | Promise<T>, timeout = 0): (...args: F) => Promise<T> {
let timer: NodeJS.Timeout; let timer: NodeJS.Timeout;
return (...params: any[]) => new Promise(resolve => { return (...params: any[]) => new Promise(resolve => {
clearTimeout(timer); clearTimeout(timer);
timer = global.setTimeout(() => resolve(func.apply(this, params)), timeout); timer = global.setTimeout(() => resolve(func.apply(this, params)), timeout);

View File

@ -26,6 +26,7 @@ export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions)
resolve(Buffer.concat(fileChunks)); resolve(Buffer.concat(fileChunks));
}); });
}); });
return { return {
url, url,
promise, promise,

View File

@ -2,5 +2,6 @@
export function getRandId({ prefix = "", suffix = "", sep = "_" } = {}) { export function getRandId({ prefix = "", suffix = "", sep = "_" } = {}) {
const randId = () => Math.random().toString(16).substr(2); const randId = () => Math.random().toString(16).substr(2);
return [prefix, randId(), suffix].filter(s => s).join(sep); return [prefix, randId(), suffix].filter(s => s).join(sep);
} }

View File

@ -6,7 +6,9 @@ import { WriteFileOptions } from "fs";
export function saveToAppFiles(filePath: string, contents: any, options?: WriteFileOptions): string { export function saveToAppFiles(filePath: string, contents: any, options?: WriteFileOptions): string {
const absPath = path.resolve((app || remote.app).getPath("userData"), filePath); const absPath = path.resolve((app || remote.app).getPath("userData"), filePath);
ensureDirSync(path.dirname(absPath)); ensureDirSync(path.dirname(absPath));
writeFileSync(absPath, contents, options); writeFileSync(absPath, contents, options);
return absPath; return absPath;
} }

View File

@ -16,6 +16,7 @@ class Singleton {
if (!Singleton.instances.has(this)) { if (!Singleton.instances.has(this)) {
Singleton.instances.set(this, Reflect.construct(this, args)); Singleton.instances.set(this, Reflect.construct(this, args));
} }
return Singleton.instances.get(this) as T; return Singleton.instances.get(this) as T;
} }

View File

@ -12,8 +12,10 @@
*/ */
export function splitArray<T>(array: T[], element: T): [T[], T[], boolean] { export function splitArray<T>(array: T[], element: T): [T[], T[], boolean] {
const index = array.indexOf(element); const index = array.indexOf(element);
if (index < 0) { if (index < 0) {
return [array, [], false]; return [array, [], false];
} }
return [array.slice(0, index), array.slice(index + 1, array.length), true]; return [array.slice(0, index), array.slice(index + 1, array.length), true];
} }

View File

@ -15,7 +15,7 @@ export function readFileFromTar<R = Buffer>({ tarPath, filePath, parseJson }: Re
await tar.list({ await tar.list({
file: tarPath, file: tarPath,
filter: path => path === filePath, filter: entryPath => path.normalize(entryPath) === filePath,
onentry(entry: FileStat) { onentry(entry: FileStat) {
entry.on("data", chunk => { entry.on("data", chunk => {
fileChunks.push(chunk); fileChunks.push(chunk);
@ -26,6 +26,7 @@ export function readFileFromTar<R = Buffer>({ tarPath, filePath, parseJson }: Re
entry.once("end", () => { entry.once("end", () => {
const data = Buffer.concat(fileChunks); const data = Buffer.concat(fileChunks);
const result = parseJson ? JSON.parse(data.toString("utf8")) : data; const result = parseJson ? JSON.parse(data.toString("utf8")) : data;
resolve(result); resolve(result);
}); });
}, },
@ -39,10 +40,14 @@ export function readFileFromTar<R = Buffer>({ tarPath, filePath, parseJson }: Re
export async function listTarEntries(filePath: string): Promise<string[]> { export async function listTarEntries(filePath: string): Promise<string[]> {
const entries: string[] = []; const entries: string[] = [];
await tar.list({ await tar.list({
file: filePath, file: filePath,
onentry: (entry: FileStat) => entries.push(entry.path as any as string), onentry: (entry: FileStat) => {
entries.push(path.normalize(entry.path as any as string));
},
}); });
return entries; return entries;
} }

View File

@ -30,6 +30,7 @@ defineGlobal("__static", {
if (isDevelopment) { if (isDevelopment) {
return path.resolve(contextDir, "static"); return path.resolve(contextDir, "static");
} }
return path.resolve(process.resourcesPath, "static"); return path.resolve(process.resourcesPath, "static");
} }
}); });

View File

@ -3,7 +3,7 @@ import { action, computed, observable, toJS, reaction } from "mobx";
import { BaseStore } from "./base-store"; import { BaseStore } from "./base-store";
import { clusterStore } from "./cluster-store"; import { clusterStore } from "./cluster-store";
import { appEventBus } from "./event-bus"; import { appEventBus } from "./event-bus";
import { broadcastMessage } from "../common/ipc"; import { broadcastMessage, handleRequest, requestMain } from "../common/ipc";
import logger from "../main/logger"; import logger from "../main/logger";
import type { ClusterId } from "./cluster-store"; import type { ClusterId } from "./cluster-store";
@ -33,32 +33,44 @@ export interface WorkspaceState {
*/ */
export class Workspace implements WorkspaceModel, WorkspaceState { export class Workspace implements WorkspaceModel, WorkspaceState {
/** /**
* Unique id * Unique id for workspace
*
* @observable * @observable
*/ */
@observable id: WorkspaceId; @observable id: WorkspaceId;
/** /**
* Workspace name * Workspace name
*
* @observable * @observable
*/ */
@observable name: string; @observable name: string;
/** /**
* Description * Workspace description
*
* @observable * @observable
*/ */
@observable description?: string; @observable description?: string;
/** /**
* Owner reference * Workspace owner reference
*
* If extension sets ownerRef then it needs to explicitly mark workspace as enabled onActivate (or when workspace is saved)
* *
* If an extension sets this then extension also needs explicitly to set workspace as enabled
* @observable * @observable
*/ */
@observable ownerRef?: string; @observable ownerRef?: string;
/** /**
* Is workspace enabled (disabled workspaces are currently hidden) * Is workspace enabled
*
* Workspaces that don't have ownerRef will be enabled by default. Workspaces with ownerRef need to explicitly enable a workspace.
*
* @observable * @observable
*/ */
@observable enabled: boolean; @observable enabled: boolean;
/**
* Last active cluster id
*
* @observable
*/
@observable lastActiveClusterId?: ClusterId; @observable lastActiveClusterId?: ClusterId;
constructor(data: WorkspaceModel) { constructor(data: WorkspaceModel) {
@ -83,9 +95,9 @@ export class Workspace implements WorkspaceModel, WorkspaceState {
* *
*/ */
getState(): WorkspaceState { getState(): WorkspaceState {
return { return toJS({
enabled: this.enabled enabled: this.enabled
}; });
} }
/** /**
@ -120,16 +132,45 @@ export class Workspace implements WorkspaceModel, WorkspaceState {
export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> { export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
static readonly defaultId: WorkspaceId = "default"; static readonly defaultId: WorkspaceId = "default";
private static stateRequestChannel = "workspace:states";
private constructor() { private constructor() {
super({ super({
configName: "lens-workspace-store", configName: "lens-workspace-store",
}); });
}
if (!ipcRenderer) { async load() {
setInterval(() => { await super.load();
this.pushState(); type workspaceStateSync = {
}, 5000); id: string;
state: WorkspaceState;
};
if (ipcRenderer) {
logger.info("[WORKSPACE-STORE] requesting initial state sync");
const workspaceStates: workspaceStateSync[] = await requestMain(WorkspaceStore.stateRequestChannel);
workspaceStates.forEach((workspaceState) => {
const workspace = this.getById(workspaceState.id);
if (workspace) {
workspace.setState(workspaceState.state);
}
});
} else {
handleRequest(WorkspaceStore.stateRequestChannel, (): workspaceStateSync[] => {
const states: workspaceStateSync[] = [];
this.workspacesList.forEach((workspace) => {
states.push({
state: workspace.getState(),
id: workspace.id
});
});
return states;
});
} }
} }
@ -187,6 +228,7 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
@action @action
setActive(id = WorkspaceStore.defaultId) { setActive(id = WorkspaceStore.defaultId) {
if (id === this.currentWorkspaceId) return; if (id === this.currentWorkspaceId) return;
if (!this.getById(id)) { if (!this.getById(id)) {
throw new Error(`workspace ${id} doesn't exist`); throw new Error(`workspace ${id} doesn't exist`);
} }
@ -196,11 +238,18 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
@action @action
addWorkspace(workspace: Workspace) { addWorkspace(workspace: Workspace) {
const { id, name } = workspace; const { id, name } = workspace;
if (!name.trim() || this.getByName(name.trim())) { if (!name.trim() || this.getByName(name.trim())) {
return; return;
} }
this.workspaces.set(id, workspace); this.workspaces.set(id, workspace);
if (!workspace.isManaged) {
workspace.enabled = true;
}
appEventBus.emit({name: "workspace", action: "add"}); appEventBus.emit({name: "workspace", action: "add"});
return workspace; return workspace;
} }
@ -218,10 +267,13 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
@action @action
removeWorkspaceById(id: WorkspaceId) { removeWorkspaceById(id: WorkspaceId) {
const workspace = this.getById(id); const workspace = this.getById(id);
if (!workspace) return; if (!workspace) return;
if (this.isDefault(id)) { if (this.isDefault(id)) {
throw new Error("Cannot remove default workspace"); throw new Error("Cannot remove default workspace");
} }
if (this.currentWorkspaceId === id) { if (this.currentWorkspaceId === id) {
this.currentWorkspaceId = WorkspaceStore.defaultId; // reset to default this.currentWorkspaceId = WorkspaceStore.defaultId; // reset to default
} }
@ -240,10 +292,12 @@ export class WorkspaceStore extends BaseStore<WorkspaceStoreModel> {
if (currentWorkspace) { if (currentWorkspace) {
this.currentWorkspaceId = currentWorkspace; this.currentWorkspaceId = currentWorkspace;
} }
if (workspaces.length) { if (workspaces.length) {
this.workspaces.clear(); this.workspaces.clear();
workspaces.forEach(ws => { workspaces.forEach(ws => {
const workspace = new Workspace(ws); const workspace = new Workspace(ws);
if (!workspace.isManaged) { if (!workspace.isManaged) {
workspace.enabled = true; workspace.enabled = true;
} }

View File

@ -1,15 +1,24 @@
import { ExtensionLoader } from "../extension-loader"; import { ExtensionLoader } from "../extension-loader";
import { ipcRenderer } from "electron";
import { extensionsStore } from "../extensions-store";
const manifestPath = "manifest/path"; const manifestPath = "manifest/path";
const manifestPath2 = "manifest/path2"; const manifestPath2 = "manifest/path2";
const manifestPath3 = "manifest/path3"; const manifestPath3 = "manifest/path3";
jest.mock("../extensions-store", () => ({
extensionsStore: {
whenLoaded: Promise.resolve(true),
mergeState: jest.fn()
}
}));
jest.mock( jest.mock(
"electron", "electron",
() => ({ () => ({
ipcRenderer: { ipcRenderer: {
invoke: jest.fn(async (channel: string) => { invoke: jest.fn(async (channel: string) => {
if (channel === "extensions:loaded") { if (channel === "extensions:main") {
return [ return [
[ [
manifestPath, manifestPath,
@ -44,7 +53,7 @@ jest.mock(
}), }),
on: jest.fn( on: jest.fn(
(channel: string, listener: (event: any, ...args: any[]) => void) => { (channel: string, listener: (event: any, ...args: any[]) => void) => {
if (channel === "extensions:loaded") { if (channel === "extensions:main") {
// First initialize with extensions 1 and 2 // First initialize with extensions 1 and 2
// and then broadcast event to remove extensioin 2 and add extension number 3 // and then broadcast event to remove extensioin 2 and add extension number 3
setTimeout(() => { setTimeout(() => {
@ -129,4 +138,29 @@ describe("ExtensionLoader", () => {
done(); done();
}, 10); }, 10);
}); });
it("updates ExtensionsStore after isEnabled is changed", async () => {
(extensionsStore.mergeState as any).mockClear();
// Disable sending events in this test
(ipcRenderer.on as any).mockImplementation();
const extensionLoader = new ExtensionLoader();
await extensionLoader.init();
expect(extensionsStore.mergeState).not.toHaveBeenCalled();
Array.from(extensionLoader.userExtensions.values())[0].isEnabled = false;
expect(extensionsStore.mergeState).toHaveBeenCalledWith({
"manifest/path": {
enabled: false,
name: "TestExtension"
},
"manifest/path2": {
enabled: true,
name: "TestExtension2"
}});
});
}); });

View File

@ -108,12 +108,15 @@ export abstract class ClusterFeature {
*/ */
protected renderTemplates(folderPath: string): string[] { protected renderTemplates(folderPath: string): string[] {
const resources: string[] = []; const resources: string[] = [];
logger.info(`[FEATURE]: render templates from ${folderPath}`); logger.info(`[FEATURE]: render templates from ${folderPath}`);
fs.readdirSync(folderPath).forEach(filename => { fs.readdirSync(folderPath).forEach(filename => {
const file = path.join(folderPath, filename); const file = path.join(folderPath, filename);
const raw = fs.readFileSync(file); const raw = fs.readFileSync(file);
if (filename.endsWith(".hb")) { if (filename.endsWith(".hb")) {
const template = hb.compile(raw.toString()); const template = hb.compile(raw.toString());
resources.push(template(this.templateContext)); resources.push(template(this.templateContext));
} else { } else {
resources.push(raw.toString()); resources.push(raw.toString());

View File

@ -3,6 +3,7 @@ import { extensionsStore } from "../extensions-store";
export const version = getAppVersion(); export const version = getAppVersion();
export { isSnap, isWindows, isMac, isLinux, appName, slackUrl, issuesTrackerUrl } from "../../common/vars"; export { isSnap, isWindows, isMac, isLinux, appName, slackUrl, issuesTrackerUrl } from "../../common/vars";
export function getEnabledExtensions(): string[] { export function getEnabledExtensions(): string[] {
return extensionsStore.enabledExtensions; return extensionsStore.enabledExtensions;
} }

View File

@ -25,6 +25,7 @@ export interface InstalledExtension {
} }
const logModule = "[EXTENSION-DISCOVERY]"; const logModule = "[EXTENSION-DISCOVERY]";
export const manifestFilename = "package.json"; export const manifestFilename = "package.json";
/** /**
@ -113,8 +114,8 @@ export class ExtensionDiscovery {
// chokidar works better than fs.watch // chokidar works better than fs.watch
chokidar.watch(this.localFolderPath, { chokidar.watch(this.localFolderPath, {
// Dont watch recursively into subdirectories // For adding and removing symlinks to work, the depth has to be 1.
depth: 0, depth: 1,
// Try to wait until the file has been completely copied. // Try to wait until the file has been completely copied.
// The OS might emit an event for added file even it's not completely written to the filesysten. // The OS might emit an event for added file even it's not completely written to the filesysten.
awaitWriteFinish: { awaitWriteFinish: {
@ -123,7 +124,7 @@ export class ExtensionDiscovery {
stabilityThreshold: 300 stabilityThreshold: 300
} }
}) })
// Extension add is detected by watching "<extensionDir>package.json" add // Extension add is detected by watching "<extensionDir>/package.json" add
.on("add", this.handleWatchFileAdd) .on("add", this.handleWatchFileAdd)
// Extension remove is detected by watching <extensionDir>" unlink // Extension remove is detected by watching <extensionDir>" unlink
.on("unlinkDir", this.handleWatchUnlinkDir); .on("unlinkDir", this.handleWatchUnlinkDir);
@ -133,7 +134,6 @@ export class ExtensionDiscovery {
if (path.basename(filePath) === manifestFilename) { if (path.basename(filePath) === manifestFilename) {
try { try {
const absPath = path.dirname(filePath); const absPath = path.dirname(filePath);
// this.loadExtensionFromPath updates this.packagesJson // this.loadExtensionFromPath updates this.packagesJson
const extension = await this.loadExtensionFromPath(absPath); const extension = await this.loadExtensionFromPath(absPath);
@ -251,6 +251,7 @@ export class ExtensionDiscovery {
manifestJson = __non_webpack_require__(manifestPath); manifestJson = __non_webpack_require__(manifestPath);
const installedManifestPath = path.join(this.nodeModulesPath, manifestJson.name, "package.json"); const installedManifestPath = path.join(this.nodeModulesPath, manifestJson.name, "package.json");
this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath); this.packagesJson.dependencies[manifestJson.name] = path.dirname(manifestPath);
const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath); const isEnabled = isBundled || extensionsStore.isEnabled(installedManifestPath);
@ -272,6 +273,7 @@ export class ExtensionDiscovery {
async loadExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> { async loadExtensions(): Promise<Map<LensExtensionId, InstalledExtension>> {
const bundledExtensions = await this.loadBundledExtensions(); const bundledExtensions = await this.loadBundledExtensions();
const localExtensions = await this.loadFromFolder(this.localFolderPath); const localExtensions = await this.loadFromFolder(this.localFolderPath);
await this.installPackages(); await this.installPackages();
const extensions = bundledExtensions.concat(localExtensions); const extensions = bundledExtensions.concat(localExtensions);
@ -333,12 +335,14 @@ export class ExtensionDiscovery {
} }
const extension = await this.loadExtensionFromPath(absPath); const extension = await this.loadExtensionFromPath(absPath);
if (extension) { if (extension) {
extensions.push(extension); extensions.push(extension);
} }
} }
logger.debug(`${logModule}: ${extensions.length} extensions loaded`, { folderPath, extensions }); logger.debug(`${logModule}: ${extensions.length} extensions loaded`, { folderPath, extensions });
return extensions; return extensions;
} }

View File

@ -37,6 +37,7 @@ export class ExtensionInstaller {
cwd: extensionPackagesRoot(), cwd: extensionPackagesRoot(),
silent: true silent: true
}); });
child.on("close", () => { child.on("close", () => {
resolve(); resolve();
}); });

View File

@ -1,5 +1,6 @@
import { app, ipcRenderer, remote } from "electron"; import { app, ipcRenderer, remote } from "electron";
import { EventEmitter } from "events"; import { EventEmitter } from "events";
import { isEqual } from "lodash";
import { action, computed, observable, reaction, toJS, when } from "mobx"; import { action, computed, observable, reaction, toJS, when } from "mobx";
import path from "path"; import path from "path";
import { getHostedCluster } from "../common/cluster-store"; import { getHostedCluster } from "../common/cluster-store";
@ -25,7 +26,12 @@ const logModule = "[EXTENSIONS-LOADER]";
export class ExtensionLoader { export class ExtensionLoader {
protected extensions = observable.map<LensExtensionId, InstalledExtension>(); protected extensions = observable.map<LensExtensionId, InstalledExtension>();
protected instances = observable.map<LensExtensionId, LensExtension>(); protected instances = observable.map<LensExtensionId, LensExtension>();
protected readonly requestExtensionsChannel = "extensions:loaded";
// IPC channel to broadcast changes to extensions from main
protected static readonly extensionsMainChannel = "extensions:main";
// IPC channel to broadcast changes to extensions from renderer
protected static readonly extensionsRendererChannel = "extensions:renderer";
// emits event "remove" of type LensExtension when the extension is removed // emits event "remove" of type LensExtension when the extension is removed
private events = new EventEmitter(); private events = new EventEmitter();
@ -45,6 +51,17 @@ export class ExtensionLoader {
return extensions; return extensions;
} }
// Transform userExtensions to a state object for storing into ExtensionsStore
@computed get storeState() {
return Object.fromEntries(
Array.from(this.userExtensions)
.map(([extId, extension]) => [extId, {
enabled: extension.isEnabled,
name: extension.manifest.name,
}])
);
}
@action @action
async init() { async init() {
if (ipcRenderer) { if (ipcRenderer) {
@ -53,7 +70,12 @@ export class ExtensionLoader {
await this.initMain(); await this.initMain();
} }
extensionsStore.manageState(this); await Promise.all([this.whenLoaded, extensionsStore.whenLoaded]);
// save state on change `extension.isEnabled`
reaction(() => this.storeState, extensionsState => {
extensionsStore.mergeState(extensionsState);
});
} }
initExtensions(extensions?: Map<LensExtensionId, InstalledExtension>) { initExtensions(extensions?: Map<LensExtensionId, InstalledExtension>) {
@ -95,28 +117,27 @@ export class ExtensionLoader {
this.loadOnMain(); this.loadOnMain();
this.broadcastExtensions(); this.broadcastExtensions();
reaction(() => this.extensions.toJS(), () => { reaction(() => this.toJSON(), () => {
this.broadcastExtensions(); this.broadcastExtensions();
}); });
handleRequest(this.requestExtensionsChannel, () => { handleRequest(ExtensionLoader.extensionsMainChannel, () => {
return Array.from(this.toJSON()); return Array.from(this.toJSON());
}); });
subscribeToBroadcast(ExtensionLoader.extensionsRendererChannel, (_event, extensions: [LensExtensionId, InstalledExtension][]) => {
this.syncExtensions(extensions);
});
} }
protected async initRenderer() { protected async initRenderer() {
const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => { const extensionListHandler = (extensions: [LensExtensionId, InstalledExtension][]) => {
this.isLoaded = true; this.isLoaded = true;
this.syncExtensions(extensions);
const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId); const receivedExtensionIds = extensions.map(([lensExtensionId]) => lensExtensionId);
// Add new extensions // Remove deleted extensions in renderer side only
extensions.forEach(([extId, ext]) => {
if (!this.extensions.has(extId)) {
this.extensions.set(extId, ext);
}
});
// Remove deleted extensions
this.extensions.forEach((_, lensExtensionId) => { this.extensions.forEach((_, lensExtensionId) => {
if (!receivedExtensionIds.includes(lensExtensionId)) { if (!receivedExtensionIds.includes(lensExtensionId)) {
this.removeExtension(lensExtensionId); this.removeExtension(lensExtensionId);
@ -124,14 +145,26 @@ export class ExtensionLoader {
}); });
}; };
requestMain(this.requestExtensionsChannel).then(extensionListHandler); reaction(() => this.toJSON(), () => {
subscribeToBroadcast(this.requestExtensionsChannel, (event, extensions: [LensExtensionId, InstalledExtension][]) => { this.broadcastExtensions(false);
});
requestMain(ExtensionLoader.extensionsMainChannel).then(extensionListHandler);
subscribeToBroadcast(ExtensionLoader.extensionsMainChannel, (_event, extensions: [LensExtensionId, InstalledExtension][]) => {
extensionListHandler(extensions); extensionListHandler(extensions);
}); });
} }
syncExtensions(extensions: [LensExtensionId, InstalledExtension][]) {
extensions.forEach(([lensExtensionId, extension]) => {
if (!isEqual(this.extensions.get(lensExtensionId), extension)) {
this.extensions.set(lensExtensionId, extension);
}
});
}
loadOnMain() { loadOnMain() {
logger.info(`${logModule}: load on main`); logger.debug(`${logModule}: load on main`);
this.autoInitExtensions(async (extension: LensMainExtension) => { this.autoInitExtensions(async (extension: LensMainExtension) => {
// Each .add returns a function to remove the item // Each .add returns a function to remove the item
const removeItems = [ const removeItems = [
@ -151,7 +184,7 @@ export class ExtensionLoader {
} }
loadOnClusterManagerRenderer() { loadOnClusterManagerRenderer() {
logger.info(`${logModule}: load on main renderer (cluster manager)`); logger.debug(`${logModule}: load on main renderer (cluster manager)`);
this.autoInitExtensions(async (extension: LensRendererExtension) => { this.autoInitExtensions(async (extension: LensRendererExtension) => {
const removeItems = [ const removeItems = [
registries.globalPageRegistry.add(extension.globalPages, extension), registries.globalPageRegistry.add(extension.globalPages, extension),
@ -174,8 +207,9 @@ export class ExtensionLoader {
} }
loadOnClusterRenderer() { loadOnClusterRenderer() {
logger.info(`${logModule}: load on cluster renderer (dashboard)`); logger.debug(`${logModule}: load on cluster renderer (dashboard)`);
const cluster = getHostedCluster(); const cluster = getHostedCluster();
this.autoInitExtensions(async (extension: LensRendererExtension) => { this.autoInitExtensions(async (extension: LensRendererExtension) => {
if (await extension.isEnabledForCluster(cluster) === false) { if (await extension.isEnabledForCluster(cluster) === false) {
return []; return [];
@ -203,24 +237,26 @@ export class ExtensionLoader {
protected autoInitExtensions(register: (ext: LensExtension) => Promise<Function[]>) { protected autoInitExtensions(register: (ext: LensExtension) => Promise<Function[]>) {
return reaction(() => this.toJSON(), installedExtensions => { return reaction(() => this.toJSON(), installedExtensions => {
for (const [extId, ext] of installedExtensions) { for (const [extId, extension] of installedExtensions) {
const alreadyInit = this.instances.has(extId); const alreadyInit = this.instances.has(extId);
if (ext.isEnabled && !alreadyInit) { if (extension.isEnabled && !alreadyInit) {
try { try {
const LensExtensionClass = this.requireExtension(ext); const LensExtensionClass = this.requireExtension(extension);
if (!LensExtensionClass) { if (!LensExtensionClass) {
continue; continue;
} }
const instance = new LensExtensionClass(ext); const instance = new LensExtensionClass(extension);
instance.whenEnabled(() => register(instance)); instance.whenEnabled(() => register(instance));
instance.enable(); instance.enable();
this.instances.set(extId, instance); this.instances.set(extId, instance);
} catch (err) { } catch (err) {
logger.error(`${logModule}: activation extension error`, { ext, err }); logger.error(`${logModule}: activation extension error`, { ext: extension, err });
} }
} else if (!ext.isEnabled && alreadyInit) { } else if (!extension.isEnabled && alreadyInit) {
this.removeInstance(extId); this.removeInstance(extId);
} }
} }
@ -231,12 +267,14 @@ export class ExtensionLoader {
protected requireExtension(extension: InstalledExtension): LensExtensionConstructor { protected requireExtension(extension: InstalledExtension): LensExtensionConstructor {
let extEntrypoint = ""; let extEntrypoint = "";
try { try {
if (ipcRenderer && extension.manifest.renderer) { if (ipcRenderer && extension.manifest.renderer) {
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer)); extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.renderer));
} else if (!ipcRenderer && extension.manifest.main) { } else if (!ipcRenderer && extension.manifest.main) {
extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.main)); extEntrypoint = path.resolve(path.join(path.dirname(extension.manifestPath), extension.manifest.main));
} }
if (extEntrypoint !== "") { if (extEntrypoint !== "") {
return __non_webpack_require__(extEntrypoint).default; return __non_webpack_require__(extEntrypoint).default;
} }
@ -257,8 +295,8 @@ export class ExtensionLoader {
}); });
} }
broadcastExtensions() { broadcastExtensions(main = true) {
broadcastMessage(this.requestExtensionsChannel, Array.from(this.toJSON())); broadcastMessage(main ? ExtensionLoader.extensionsMainChannel : ExtensionLoader.extensionsRendererChannel, Array.from(this.toJSON()));
} }
} }

View File

@ -7,11 +7,13 @@ export abstract class ExtensionStore<T> extends BaseStore<T> {
async loadExtension(extension: LensExtension) { async loadExtension(extension: LensExtension) {
this.extension = extension; this.extension = extension;
return super.load(); return super.load();
} }
async load() { async load() {
if (!this.extension) { return; } if (!this.extension) { return; }
return super.load(); return super.load();
} }

View File

@ -1,7 +1,6 @@
import type { LensExtensionId } from "./lens-extension"; import type { LensExtensionId } from "./lens-extension";
import type { ExtensionLoader } from "./extension-loader";
import { BaseStore } from "../common/base-store"; import { BaseStore } from "../common/base-store";
import { action, computed, observable, reaction, toJS } from "mobx"; import { action, computed, observable, toJS } from "mobx";
export interface LensExtensionsStoreModel { export interface LensExtensionsStoreModel {
extensions: Record<LensExtensionId, LensExtensionState>; extensions: Record<LensExtensionId, LensExtensionState>;
@ -28,40 +27,17 @@ export class ExtensionsStore extends BaseStore<LensExtensionsStoreModel> {
protected state = observable.map<LensExtensionId, LensExtensionState>(); protected state = observable.map<LensExtensionId, LensExtensionState>();
protected getState(extensionLoader: ExtensionLoader) {
const state: Record<LensExtensionId, LensExtensionState> = {};
return Array.from(extensionLoader.userExtensions).reduce((state, [extId, ext]) => {
state[extId] = {
enabled: ext.isEnabled,
name: ext.manifest.name,
};
return state;
}, state);
}
async manageState(extensionLoader: ExtensionLoader) {
await extensionLoader.whenLoaded;
await this.whenLoaded;
// apply state on changes from store
reaction(() => this.state.toJS(), extensionsState => {
extensionsState.forEach((state, extId) => {
const ext = extensionLoader.getExtension(extId);
if (ext && !ext.isBundled) {
ext.isEnabled = state.enabled;
}
});
});
// save state on change `extension.isEnabled`
reaction(() => this.getState(extensionLoader), extensionsState => {
this.state.merge(extensionsState);
});
}
isEnabled(extId: LensExtensionId) { isEnabled(extId: LensExtensionId) {
const state = this.state.get(extId); const state = this.state.get(extId);
return state && state.enabled; // by default false
// By default false, so that copied extensions are disabled by default.
// If user installs the extension from the UI, the Extensions component will specifically enable it.
return Boolean(state?.enabled);
}
@action
mergeState(extensionsState: Record<LensExtensionId, LensExtensionState>) {
this.state.merge(extensionsState);
} }
@action @action

View File

@ -86,6 +86,7 @@ export class LensExtension {
const cancelReaction = reaction(() => this.isEnabled, async (isEnabled) => { const cancelReaction = reaction(() => this.isEnabled, async (isEnabled) => {
if (isEnabled) { if (isEnabled) {
const handlerDisposers = await handlers(); const handlerDisposers = await handlers();
disposers.push(...handlerDisposers); disposers.push(...handlerDisposers);
} else { } else {
unregisterHandlers(); unregisterHandlers();
@ -93,6 +94,7 @@ export class LensExtension {
}, { }, {
fireImmediately: true fireImmediately: true
}); });
return () => { return () => {
unregisterHandlers(); unregisterHandlers();
cancelReaction(); cancelReaction();

View File

@ -13,6 +13,7 @@ export class LensMainExtension extends LensExtension {
pageId, pageId,
params: params ?? {}, // compile to url with params params: params ?? {}, // compile to url with params
}); });
await windowManager.navigate(pageUrl, frameId); await windowManager.navigate(pageUrl, frameId);
} }
} }

View File

@ -23,6 +23,7 @@ export class LensRendererExtension extends LensExtension {
pageId, pageId,
params: params ?? {}, // compile to url with params params: params ?? {}, // compile to url with params
}); });
navigate(pageUrl); navigate(pageUrl);
} }

View File

@ -73,6 +73,7 @@ describe("globalPageRegistry", () => {
describe("getByPageMenuTarget", () => { describe("getByPageMenuTarget", () => {
it("matching to first registered page without id", () => { it("matching to first registered page without id", () => {
const page = globalPageRegistry.getByPageMenuTarget({ extensionId: ext.name }); const page = globalPageRegistry.getByPageMenuTarget({ extensionId: ext.name });
expect(page.id).toEqual(undefined); expect(page.id).toEqual(undefined);
expect(page.extensionId).toEqual(ext.name); expect(page.extensionId).toEqual(ext.name);
expect(page.routePath).toEqual(getExtensionPageUrl({ extensionId: ext.name })); expect(page.routePath).toEqual(getExtensionPageUrl({ extensionId: ext.name }));
@ -83,6 +84,7 @@ describe("globalPageRegistry", () => {
pageId: "test-page", pageId: "test-page",
extensionId: ext.name extensionId: ext.name
}); });
expect(page.id).toEqual("test-page"); expect(page.id).toEqual("test-page");
}); });
@ -91,6 +93,7 @@ describe("globalPageRegistry", () => {
pageId: "wrong-page", pageId: "wrong-page",
extensionId: ext.name extensionId: ext.name
}); });
expect(page).toBeNull(); expect(page).toBeNull();
}); });
}); });

View File

@ -14,7 +14,9 @@ export class BaseRegistry<T> {
@action @action
add(items: T | T[]) { add(items: T | T[]) {
const itemArray = rectify(items); const itemArray = rectify(items);
this.items.push(...itemArray); this.items.push(...itemArray);
return () => this.remove(...itemArray); return () => this.remove(...itemArray);
} }

View File

@ -20,8 +20,10 @@ export class KubeObjectDetailRegistry extends BaseRegistry<KubeObjectDetailRegis
if (item.priority === null) { if (item.priority === null) {
item.priority = 50; item.priority = 50;
} }
return item; return item;
}); });
return items.sort((a, b) => b.priority - a.priority); return items.sort((a, b) => b.priority - a.priority);
} }
} }

View File

@ -35,8 +35,10 @@ export class GlobalPageMenuRegistry extends BaseRegistry<PageMenuRegistration> {
extensionId: ext.name, extensionId: ext.name,
...(menuItem.target || {}), ...(menuItem.target || {}),
}; };
return menuItem; return menuItem;
}); });
return super.add(normalizedItems); return super.add(normalizedItems);
} }
} }
@ -49,8 +51,10 @@ export class ClusterPageMenuRegistry extends BaseRegistry<ClusterPageMenuRegistr
extensionId: ext.name, extensionId: ext.name,
...(menuItem.target || {}), ...(menuItem.target || {}),
}; };
return menuItem; return menuItem;
}); });
return super.add(normalizedItems); return super.add(normalizedItems);
} }

View File

@ -44,10 +44,12 @@ export function getExtensionPageUrl<P extends object>({ extensionId, pageId = ""
const extensionBaseUrl = compile(`/extension/:name`)({ const extensionBaseUrl = compile(`/extension/:name`)({
name: sanitizeExtensionName(extensionId), // compile only with extension-id first and define base path name: sanitizeExtensionName(extensionId), // compile only with extension-id first and define base path
}); });
const extPageRoutePath = path.join(extensionBaseUrl, pageId); // page-id might contain route :param-s, so don't compile yet const extPageRoutePath = path.posix.join(extensionBaseUrl, pageId);
if (params) { if (params) {
return compile(extPageRoutePath)(params); // might throw error when required params not passed return compile(extPageRoutePath)(params); // might throw error when required params not passed
} }
return extPageRoutePath; return extPageRoutePath;
} }
@ -56,6 +58,7 @@ export class PageRegistry extends BaseRegistry<RegisteredPage> {
add(items: PageRegistration | PageRegistration[], ext: LensExtension) { add(items: PageRegistration | PageRegistration[], ext: LensExtension) {
const itemArray = rectify(items); const itemArray = rectify(items);
let registeredPages: RegisteredPage[] = []; let registeredPages: RegisteredPage[] = [];
try { try {
registeredPages = itemArray.map(page => ({ registeredPages = itemArray.map(page => ({
...page, ...page,
@ -69,6 +72,7 @@ export class PageRegistry extends BaseRegistry<RegisteredPage> {
error: String(err), error: String(err),
}); });
} }
return super.add(registeredPages); return super.add(registeredPages);
} }
@ -78,8 +82,10 @@ export class PageRegistry extends BaseRegistry<RegisteredPage> {
getByPageMenuTarget(target: PageMenuTarget = {}): RegisteredPage | null { getByPageMenuTarget(target: PageMenuTarget = {}): RegisteredPage | null {
const targetUrl = getExtensionPageUrl(target); const targetUrl = getExtensionPageUrl(target);
return this.getItems().find(({ id: pageId, extensionId }) => { return this.getItems().find(({ id: pageId, extensionId }) => {
const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params }); // compiled with provided params const pageUrl = getExtensionPageUrl({ extensionId, pageId, params: target.params }); // compiled with provided params
return targetUrl === pageUrl; return targetUrl === pageUrl;
}) || null; }) || null;
} }

View File

@ -42,6 +42,7 @@ export class ClusterStore extends Singleton {
if (!this.activeClusterId) { if (!this.activeClusterId) {
return null; return null;
} }
return this.getById(this.activeClusterId); return this.getById(this.activeClusterId);
} }

View File

@ -75,6 +75,7 @@ describe("create clusters", () => {
preferences: {}, preferences: {},
}) })
}; };
mockFs(mockOpts); mockFs(mockOpts);
jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValue(Promise.resolve(true)); jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValue(Promise.resolve(true));
c = new Cluster({ c = new Cluster({
@ -112,6 +113,7 @@ describe("create clusters", () => {
it("activating cluster should try to connect to cluster and do a refresh", async () => { it("activating cluster should try to connect to cluster and do a refresh", async () => {
const port = await getFreePort(); const port = await getFreePort();
jest.spyOn(ContextHandler.prototype, "ensureServer"); jest.spyOn(ContextHandler.prototype, "ensureServer");
const mockListNSs = jest.fn(); const mockListNSs = jest.fn();
@ -122,17 +124,13 @@ describe("create clusters", () => {
}; };
} }
}; };
jest.spyOn(Cluster.prototype, "isClusterAdmin").mockReturnValue(Promise.resolve(true)); jest.spyOn(Cluster.prototype, "isClusterAdmin").mockReturnValue(Promise.resolve(true));
jest.spyOn(Cluster.prototype, "canI") jest.spyOn(Cluster.prototype, "canI")
.mockImplementationOnce((attr: V1ResourceAttributes): Promise<boolean> => {
expect(attr.namespace).toBe("default");
expect(attr.resource).toBe("pods");
expect(attr.verb).toBe("list");
return Promise.resolve(true);
})
.mockImplementation((attr: V1ResourceAttributes): Promise<boolean> => { .mockImplementation((attr: V1ResourceAttributes): Promise<boolean> => {
expect(attr.namespace).toBe("default"); expect(attr.namespace).toBe("default");
expect(attr.verb).toBe("list"); expect(attr.verb).toBe("list");
return Promise.resolve(true); return Promise.resolve(true);
}); });
jest.spyOn(Cluster.prototype, "getProxyKubeconfig").mockReturnValue(mockKC as any); jest.spyOn(Cluster.prototype, "getProxyKubeconfig").mockReturnValue(mockKC as any);
@ -148,6 +146,7 @@ describe("create clusters", () => {
mockedRequest.mockImplementationOnce(((uri: any) => { mockedRequest.mockImplementationOnce(((uri: any) => {
expect(uri).toBe(`http://localhost:${port}/api-kube/version`); expect(uri).toBe(`http://localhost:${port}/api-kube/version`);
return Promise.resolve({ gitVersion: "1.2.3" }); return Promise.resolve({ gitVersion: "1.2.3" });
}) as any); }) as any);
@ -165,6 +164,7 @@ describe("create clusters", () => {
kubeConfigPath: "minikube-config.yml", kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId workspace: workspaceStore.currentWorkspaceId
}); });
await c.init(port); await c.init(port);
await c.activate(); await c.activate();

View File

@ -49,6 +49,7 @@ describe("kube auth proxy tests", () => {
it("calling exit multiple times shouldn't throw", async () => { it("calling exit multiple times shouldn't throw", async () => {
const port = await getFreePort(); const port = await getFreePort();
const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {}); const kap = new KubeAuthProxy(new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }), port, {});
kap.exit(); kap.exit();
kap.exit(); kap.exit();
kap.exit(); kap.exit();
@ -69,24 +70,29 @@ describe("kube auth proxy tests", () => {
jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false)); jest.spyOn(Kubectl.prototype, "ensureKubectl").mockReturnValueOnce(Promise.resolve(false));
mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => { mockedCP.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): ChildProcess => {
listeners[event] = listener; listeners[event] = listener;
return mockedCP; return mockedCP;
}); });
mockedCP.stderr = mock<Readable>(); mockedCP.stderr = mock<Readable>();
mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { mockedCP.stderr.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners[`stderr/${event}`] = listener; listeners[`stderr/${event}`] = listener;
return mockedCP.stderr; return mockedCP.stderr;
}); });
mockedCP.stdout = mock<Readable>(); mockedCP.stdout = mock<Readable>();
mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => { mockedCP.stdout.on.mockImplementation((event: string, listener: (message: any, sendHandle: any) => void): Readable => {
listeners[`stdout/${event}`] = listener; listeners[`stdout/${event}`] = listener;
return mockedCP.stdout; return mockedCP.stdout;
}); });
mockSpawn.mockImplementationOnce((command: string): ChildProcess => { mockSpawn.mockImplementationOnce((command: string): ChildProcess => {
expect(command).toBe(bundledKubectlPath()); expect(command).toBe(bundledKubectlPath());
return mockedCP; return mockedCP;
}); });
mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve()); mockWaitUntilUsed.mockReturnValueOnce(Promise.resolve());
const cluster = new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" }); const cluster = new Cluster({ id: "foobar", kubeConfigPath: "fake-path.yml" });
jest.spyOn(cluster, "apiUrl", "get").mockReturnValue("https://fake.k8s.internal"); jest.spyOn(cluster, "apiUrl", "get").mockReturnValue("https://fake.k8s.internal");
proxy = new KubeAuthProxy(cluster, port, {}); proxy = new KubeAuthProxy(cluster, port, {});
}); });

View File

@ -32,6 +32,7 @@ import { getFreePort } from "../port";
import fse from "fs-extra"; import fse from "fs-extra";
import { loadYaml } from "@kubernetes/client-node"; import { loadYaml } from "@kubernetes/client-node";
import { Console } from "console"; import { Console } from "console";
import * as path from "path";
console = new Console(process.stdout, process.stderr); // fix mockFS console = new Console(process.stdout, process.stderr); // fix mockFS
@ -64,6 +65,7 @@ describe("kubeconfig manager tests", () => {
preferences: {}, preferences: {},
}) })
}; };
mockFs(mockOpts); mockFs(mockOpts);
}); });
@ -83,9 +85,10 @@ describe("kubeconfig manager tests", () => {
const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port); const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port);
expect(logger.error).not.toBeCalled(); expect(logger.error).not.toBeCalled();
expect(kubeConfManager.getPath()).toBe("tmp/kubeconfig-foo"); expect(kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`);
const file = await fse.readFile(kubeConfManager.getPath()); const file = await fse.readFile(kubeConfManager.getPath());
const yml = loadYaml<any>(file.toString()); const yml = loadYaml<any>(file.toString());
expect(yml["current-context"]).toBe("minikube"); expect(yml["current-context"]).toBe("minikube");
expect(yml["clusters"][0]["cluster"]["server"]).toBe(`http://127.0.0.1:${port}/foo`); expect(yml["clusters"][0]["cluster"]["server"]).toBe(`http://127.0.0.1:${port}/foo`);
expect(yml["users"][0]["name"]).toBe("proxy"); expect(yml["users"][0]["name"]).toBe("proxy");
@ -101,8 +104,8 @@ describe("kubeconfig manager tests", () => {
const contextHandler = new ContextHandler(cluster); const contextHandler = new ContextHandler(cluster);
const port = await getFreePort(); const port = await getFreePort();
const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port); const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port);
const configPath = kubeConfManager.getPath(); const configPath = kubeConfManager.getPath();
expect(await fse.pathExists(configPath)).toBe(true); expect(await fse.pathExists(configPath)).toBe(true);
await kubeConfManager.unlink(); await kubeConfManager.unlink();
expect(await fse.pathExists(configPath)).toBe(false); expect(await fse.pathExists(configPath)).toBe(false);

View File

@ -14,6 +14,7 @@ export class AppUpdater {
public start() { public start() {
setInterval(AppUpdater.checkForUpdates, this.updateInterval); setInterval(AppUpdater.checkForUpdates, this.updateInterval);
return AppUpdater.checkForUpdates(); return AppUpdater.checkForUpdates();
} }
} }

View File

@ -20,6 +20,7 @@ export class BaseClusterDetector {
protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> { protected async k8sRequest<T = any>(path: string, options: RequestPromiseOptions = {}): Promise<T> {
const apiUrl = this.cluster.kubeProxyUrl + path; const apiUrl = this.cluster.kubeProxyUrl + path;
return request(apiUrl, { return request(apiUrl, {
json: true, json: true,
timeout: 30000, timeout: 30000,

View File

@ -7,17 +7,20 @@ export class ClusterIdDetector extends BaseClusterDetector {
public async detect() { public async detect() {
let id: string; let id: string;
try { try {
id = await this.getDefaultNamespaceId(); id = await this.getDefaultNamespaceId();
} catch(_) { } catch(_) {
id = this.cluster.apiUrl; id = this.cluster.apiUrl;
} }
const value = createHash("sha256").update(id).digest("hex"); const value = createHash("sha256").update(id).digest("hex");
return { value, accuracy: 100 }; return { value, accuracy: 100 };
} }
protected async getDefaultNamespaceId() { protected async getDefaultNamespaceId() {
const response = await this.k8sRequest("/api/v1/namespaces/default"); const response = await this.k8sRequest("/api/v1/namespaces/default");
return response.metadata.uid; return response.metadata.uid;
} }
} }

View File

@ -17,12 +17,16 @@ export class DetectorRegistry {
async detectForCluster(cluster: Cluster): Promise<ClusterMetadata> { async detectForCluster(cluster: Cluster): Promise<ClusterMetadata> {
const results: {[key: string]: ClusterDetectionResult } = {}; const results: {[key: string]: ClusterDetectionResult } = {};
for (const detectorClass of this.registry) { for (const detectorClass of this.registry) {
const detector = new detectorClass(cluster); const detector = new detectorClass(cluster);
try { try {
const data = await detector.detect(); const data = await detector.detect();
if (!data) continue; if (!data) continue;
const existingValue = results[detector.key]; const existingValue = results[detector.key];
if (existingValue && existingValue.accuracy > data.accuracy) continue; // previous value exists and is more accurate if (existingValue && existingValue.accuracy > data.accuracy) continue; // previous value exists and is more accurate
results[detector.key] = data; results[detector.key] = data;
} catch (e) { } catch (e) {
@ -30,9 +34,11 @@ export class DetectorRegistry {
} }
} }
const metadata: ClusterMetadata = {}; const metadata: ClusterMetadata = {};
for (const [key, result] of Object.entries(results)) { for (const [key, result] of Object.entries(results)) {
metadata[key] = result.value; metadata[key] = result.value;
} }
return metadata; return metadata;
} }
} }

View File

@ -7,30 +7,59 @@ export class DistributionDetector extends BaseClusterDetector {
public async detect() { public async detect() {
this.version = await this.getKubernetesVersion(); this.version = await this.getKubernetesVersion();
if (await this.isRancher()) {
return { value: "rancher", accuracy: 80}; if (this.isRke()) {
return { value: "rke", accuracy: 80};
} }
if (this.isK3s()) {
return { value: "k3s", accuracy: 80};
}
if (this.isGKE()) { if (this.isGKE()) {
return { value: "gke", accuracy: 80}; return { value: "gke", accuracy: 80};
} }
if (this.isEKS()) { if (this.isEKS()) {
return { value: "eks", accuracy: 80}; return { value: "eks", accuracy: 80};
} }
if (this.isIKS()) { if (this.isIKS()) {
return { value: "iks", accuracy: 80}; return { value: "iks", accuracy: 80};
} }
if (this.isAKS()) { if (this.isAKS()) {
return { value: "aks", accuracy: 80}; return { value: "aks", accuracy: 80};
} }
if (this.isDigitalOcean()) { if (this.isDigitalOcean()) {
return { value: "digitalocean", accuracy: 90}; return { value: "digitalocean", accuracy: 90};
} }
if (this.isMirantis()) {
return { value: "mirantis", accuracy: 90};
}
if (this.isMinikube()) { if (this.isMinikube()) {
return { value: "minikube", accuracy: 80}; return { value: "minikube", accuracy: 80};
} }
if (this.isMicrok8s()) {
return { value: "microk8s", accuracy: 80};
}
if (this.isKind()) {
return { value: "kind", accuracy: 70};
}
if (this.isDockerDesktop()) {
return { value: "docker-desktop", accuracy: 80};
}
if (this.isCustom()) { if (this.isCustom()) {
return { value: "custom", accuracy: 10}; return { value: "custom", accuracy: 10};
} }
return { value: "unknown", accuracy: 10}; return { value: "unknown", accuracy: 10};
} }
@ -38,6 +67,7 @@ export class DistributionDetector extends BaseClusterDetector {
if (this.cluster.version) return this.cluster.version; if (this.cluster.version) return this.cluster.version;
const response = await this.k8sRequest("/version"); const response = await this.k8sRequest("/version");
return response.gitVersion; return response.gitVersion;
} }
@ -57,6 +87,10 @@ export class DistributionDetector extends BaseClusterDetector {
return this.cluster.apiUrl.endsWith("azmk8s.io"); return this.cluster.apiUrl.endsWith("azmk8s.io");
} }
protected isMirantis() {
return this.version.includes("-mirantis-") || this.version.includes("-docker-");
}
protected isDigitalOcean() { protected isDigitalOcean() {
return this.cluster.apiUrl.endsWith("k8s.ondigitalocean.com"); return this.cluster.apiUrl.endsWith("k8s.ondigitalocean.com");
} }
@ -65,16 +99,27 @@ export class DistributionDetector extends BaseClusterDetector {
return this.cluster.contextName.startsWith("minikube"); return this.cluster.contextName.startsWith("minikube");
} }
protected isMicrok8s() {
return this.cluster.contextName.startsWith("microk8s");
}
protected isKind() {
return this.cluster.contextName.startsWith("kubernetes-admin@kind-");
}
protected isDockerDesktop() {
return this.cluster.contextName === "docker-desktop";
}
protected isCustom() { protected isCustom() {
return this.version.includes("+"); return this.version.includes("+");
} }
protected async isRancher() { protected isRke() {
try { return this.version.includes("-rancher");
const response = await this.k8sRequest("");
return response.data.find((api: any) => api?.apiVersion?.group === "meta.cattle.io") !== undefined;
} catch (e) {
return false;
} }
protected isK3s() {
return this.version.includes("+k3s");
} }
} }

View File

@ -8,6 +8,7 @@ export class LastSeenDetector extends BaseClusterDetector {
if (!this.cluster.accessible) return null; if (!this.cluster.accessible) return null;
await this.k8sRequest("/version"); await this.k8sRequest("/version");
return { value: new Date().toJSON(), accuracy: 100 }; return { value: new Date().toJSON(), accuracy: 100 };
} }
} }

View File

@ -7,11 +7,13 @@ export class NodesCountDetector extends BaseClusterDetector {
public async detect() { public async detect() {
if (!this.cluster.accessible) return null; if (!this.cluster.accessible) return null;
const nodeCount = await this.getNodeCount(); const nodeCount = await this.getNodeCount();
return { value: nodeCount, accuracy: 100}; return { value: nodeCount, accuracy: 100};
} }
protected async getNodeCount(): Promise<number> { protected async getNodeCount(): Promise<number> {
const response = await this.k8sRequest("/api/v1/nodes"); const response = await this.k8sRequest("/api/v1/nodes");
return response.items.length; return response.items.length;
} }
} }

View File

@ -7,11 +7,13 @@ export class VersionDetector extends BaseClusterDetector {
public async detect() { public async detect() {
const version = await this.getKubernetesVersion(); const version = await this.getKubernetesVersion();
return { value: version, accuracy: 100}; return { value: version, accuracy: 100};
} }
public async getKubernetesVersion() { public async getKubernetesVersion() {
const response = await this.k8sRequest("/version"); const response = await this.k8sRequest("/version");
return response.gitVersion; return response.gitVersion;
} }
} }

View File

@ -24,8 +24,10 @@ export class ClusterManager extends Singleton {
// auto-stop removed clusters // auto-stop removed clusters
autorun(() => { autorun(() => {
const removedClusters = Array.from(clusterStore.removedClusters.values()); const removedClusters = Array.from(clusterStore.removedClusters.values());
if (removedClusters.length > 0) { if (removedClusters.length > 0) {
const meta = removedClusters.map(cluster => cluster.getMeta()); const meta = removedClusters.map(cluster => cluster.getMeta());
logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta); logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta);
removedClusters.forEach(cluster => cluster.disconnect()); removedClusters.forEach(cluster => cluster.disconnect());
clusterStore.removedClusters.clear(); clusterStore.removedClusters.clear();
@ -70,7 +72,9 @@ export class ClusterManager extends Singleton {
// lens-server is connecting to 127.0.0.1:<port>/<uid> // lens-server is connecting to 127.0.0.1:<port>/<uid>
if (req.headers.host.startsWith("127.0.0.1")) { if (req.headers.host.startsWith("127.0.0.1")) {
const clusterId = req.url.split("/")[1]; const clusterId = req.url.split("/")[1];
cluster = clusterStore.getById(clusterId); cluster = clusterStore.getById(clusterId);
if (cluster) { if (cluster) {
// we need to swap path prefix so that request is proxied to kube api // we need to swap path prefix so that request is proxied to kube api
req.url = req.url.replace(`/${clusterId}`, apiKubePrefix); req.url = req.url.replace(`/${clusterId}`, apiKubePrefix);
@ -79,6 +83,7 @@ export class ClusterManager extends Singleton {
cluster = clusterStore.getById(req.headers["x-cluster-id"].toString()); cluster = clusterStore.getById(req.headers["x-cluster-id"].toString());
} else { } else {
const clusterId = getClusterIdFromHost(req.headers.host); const clusterId = getClusterIdFromHost(req.headers.host);
cluster = clusterStore.getById(clusterId); cluster = clusterStore.getById(clusterId);
} }

View File

@ -37,6 +37,7 @@ export type ClusterRefreshOptions = {
export interface ClusterState { export interface ClusterState {
initialized: boolean; initialized: boolean;
enabled: boolean;
apiUrl: string; apiUrl: string;
online: boolean; online: boolean;
disconnected: boolean; disconnected: boolean;
@ -224,6 +225,7 @@ export class Cluster implements ClusterModel, ClusterState {
*/ */
@computed get prometheusPreferences(): ClusterPrometheusPreferences { @computed get prometheusPreferences(): ClusterPrometheusPreferences {
const { prometheus, prometheusProvider } = this.preferences; const { prometheus, prometheusProvider } = this.preferences;
return toJS({ prometheus, prometheusProvider }, { return toJS({ prometheus, prometheusProvider }, {
recurseEverything: true, recurseEverything: true,
}); });
@ -239,6 +241,7 @@ export class Cluster implements ClusterModel, ClusterState {
constructor(model: ClusterModel) { constructor(model: ClusterModel) {
this.updateModel(model); this.updateModel(model);
const kubeconfig = this.getKubeconfig(); const kubeconfig = this.getKubeconfig();
if (kubeconfig.getContextObject(this.contextName)) { if (kubeconfig.getContextObject(this.contextName)) {
this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server; this.apiUrl = kubeconfig.getCluster(kubeconfig.getContextObject(this.contextName).cluster).server;
} }
@ -324,13 +327,16 @@ export class Cluster implements ClusterModel, ClusterState {
} }
logger.info(`[CLUSTER]: activate`, this.getMeta()); logger.info(`[CLUSTER]: activate`, this.getMeta());
await this.whenInitialized; await this.whenInitialized;
if (!this.eventDisposers.length) { if (!this.eventDisposers.length) {
this.bindEvents(); this.bindEvents();
} }
if (this.disconnected || !this.accessible) { if (this.disconnected || !this.accessible) {
await this.reconnect(); await this.reconnect();
} }
await this.refreshConnectionStatus(); await this.refreshConnectionStatus();
if (this.accessible) { if (this.accessible) {
await this.refreshAllowedResources(); await this.refreshAllowedResources();
this.isAdmin = await this.isClusterAdmin(); this.isAdmin = await this.isClusterAdmin();
@ -338,6 +344,7 @@ export class Cluster implements ClusterModel, ClusterState {
this.ensureKubectl(); this.ensureKubectl();
} }
this.activated = true; this.activated = true;
return this.pushState(); return this.pushState();
} }
@ -346,6 +353,7 @@ export class Cluster implements ClusterModel, ClusterState {
*/ */
protected async ensureKubectl() { protected async ensureKubectl() {
this.kubeCtl = new Kubectl(this.version); this.kubeCtl = new Kubectl(this.version);
return this.kubeCtl.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard return this.kubeCtl.ensureKubectl(); // download kubectl in background, so it's not blocking dashboard
} }
@ -382,9 +390,11 @@ export class Cluster implements ClusterModel, ClusterState {
logger.info(`[CLUSTER]: refresh`, this.getMeta()); logger.info(`[CLUSTER]: refresh`, this.getMeta());
await this.whenInitialized; await this.whenInitialized;
await this.refreshConnectionStatus(); await this.refreshConnectionStatus();
if (this.accessible) { if (this.accessible) {
this.isAdmin = await this.isClusterAdmin(); this.isAdmin = await this.isClusterAdmin();
await this.refreshAllowedResources(); await this.refreshAllowedResources();
if (opts.refreshMetadata) { if (opts.refreshMetadata) {
this.refreshMetadata(); this.refreshMetadata();
} }
@ -400,6 +410,7 @@ export class Cluster implements ClusterModel, ClusterState {
logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta()); logger.info(`[CLUSTER]: refreshMetadata`, this.getMeta());
const metadata = await detectorRegistry.detectForCluster(this); const metadata = await detectorRegistry.detectForCluster(this);
const existingMetadata = this.metadata; const existingMetadata = this.metadata;
this.metadata = Object.assign(existingMetadata, metadata); this.metadata = Object.assign(existingMetadata, metadata);
} }
@ -408,6 +419,7 @@ export class Cluster implements ClusterModel, ClusterState {
*/ */
@action async refreshConnectionStatus() { @action async refreshConnectionStatus() {
const connectionStatus = await this.getConnectionStatus(); const connectionStatus = await this.getConnectionStatus();
this.online = connectionStatus > ClusterStatus.Offline; this.online = connectionStatus > ClusterStatus.Offline;
this.accessible = connectionStatus == ClusterStatus.AccessGranted; this.accessible = connectionStatus == ClusterStatus.AccessGranted;
} }
@ -456,6 +468,7 @@ export class Cluster implements ClusterModel, ClusterState {
getMetrics(prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) { getMetrics(prometheusPath: string, queryParams: IMetricsReqParams & { query: string }) {
const prometheusPrefix = this.preferences.prometheus?.prefix || ""; const prometheusPrefix = this.preferences.prometheus?.prefix || "";
const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`; const metricsPath = `/api/v1/namespaces/${prometheusPath}/proxy${prometheusPrefix}/api/v1/query_range`;
return this.k8sRequest(metricsPath, { return this.k8sRequest(metricsPath, {
timeout: 0, timeout: 0,
resolveWithFullResponse: false, resolveWithFullResponse: false,
@ -468,28 +481,36 @@ export class Cluster implements ClusterModel, ClusterState {
try { try {
const versionDetector = new VersionDetector(this); const versionDetector = new VersionDetector(this);
const versionData = await versionDetector.detect(); const versionData = await versionDetector.detect();
this.metadata.version = versionData.value; this.metadata.version = versionData.value;
return ClusterStatus.AccessGranted; return ClusterStatus.AccessGranted;
} catch (error) { } catch (error) {
logger.error(`Failed to connect cluster "${this.contextName}": ${error}`); logger.error(`Failed to connect cluster "${this.contextName}": ${error}`);
if (error.statusCode) { if (error.statusCode) {
if (error.statusCode >= 400 && error.statusCode < 500) { if (error.statusCode >= 400 && error.statusCode < 500) {
this.failureReason = "Invalid credentials"; this.failureReason = "Invalid credentials";
return ClusterStatus.AccessDenied; return ClusterStatus.AccessDenied;
} else { } else {
this.failureReason = error.error || error.message; this.failureReason = error.error || error.message;
return ClusterStatus.Offline; return ClusterStatus.Offline;
} }
} else if (error.failed === true) { } else if (error.failed === true) {
if (error.timedOut === true) { if (error.timedOut === true) {
this.failureReason = "Connection timed out"; this.failureReason = "Connection timed out";
return ClusterStatus.Offline; return ClusterStatus.Offline;
} else { } else {
this.failureReason = "Failed to fetch credentials"; this.failureReason = "Failed to fetch credentials";
return ClusterStatus.AccessDenied; return ClusterStatus.AccessDenied;
} }
} }
this.failureReason = error.message; this.failureReason = error.message;
return ClusterStatus.Offline; return ClusterStatus.Offline;
} }
} }
@ -500,15 +521,18 @@ export class Cluster implements ClusterModel, ClusterState {
*/ */
async canI(resourceAttributes: V1ResourceAttributes): Promise<boolean> { async canI(resourceAttributes: V1ResourceAttributes): Promise<boolean> {
const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api); const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api);
try { try {
const accessReview = await authApi.createSelfSubjectAccessReview({ const accessReview = await authApi.createSelfSubjectAccessReview({
apiVersion: "authorization.k8s.io/v1", apiVersion: "authorization.k8s.io/v1",
kind: "SelfSubjectAccessReview", kind: "SelfSubjectAccessReview",
spec: { resourceAttributes } spec: { resourceAttributes }
}); });
return accessReview.body.status.allowed; return accessReview.body.status.allowed;
} catch (error) { } catch (error) {
logger.error(`failed to request selfSubjectAccessReview: ${error}`); logger.error(`failed to request selfSubjectAccessReview: ${error}`);
return false; return false;
} }
} }
@ -535,6 +559,7 @@ export class Cluster implements ClusterModel, ClusterState {
ownerRef: this.ownerRef, ownerRef: this.ownerRef,
accessibleNamespaces: this.accessibleNamespaces, accessibleNamespaces: this.accessibleNamespaces,
}; };
return toJS(model, { return toJS(model, {
recurseEverything: true recurseEverything: true
}); });
@ -546,6 +571,7 @@ export class Cluster implements ClusterModel, ClusterState {
getState(): ClusterState { getState(): ClusterState {
const state: ClusterState = { const state: ClusterState = {
initialized: this.initialized, initialized: this.initialized,
enabled: this.enabled,
apiUrl: this.apiUrl, apiUrl: this.apiUrl,
online: this.online, online: this.online,
ready: this.ready, ready: this.ready,
@ -556,6 +582,7 @@ export class Cluster implements ClusterModel, ClusterState {
allowedNamespaces: this.allowedNamespaces, allowedNamespaces: this.allowedNamespaces,
allowedResources: this.allowedResources, allowedResources: this.allowedResources,
}; };
return toJS(state, { return toJS(state, {
recurseEverything: true recurseEverything: true
}); });
@ -597,21 +624,16 @@ export class Cluster implements ClusterModel, ClusterState {
} }
const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api); const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api);
try { try {
const namespaceList = await api.listNamespace(); const namespaceList = await api.listNamespace();
const nsAccessStatuses = await Promise.all(
namespaceList.body.items.map(ns => this.canI({ return namespaceList.body.items.map(ns => ns.metadata.name);
namespace: ns.metadata.name,
resource: "pods",
verb: "list",
}))
);
return namespaceList.body.items
.filter((ns, i) => nsAccessStatuses[i])
.map(ns => ns.metadata.name);
} catch (error) { } catch (error) {
const ctx = this.getProxyKubeconfig().getContextObject(this.contextName); const ctx = this.getProxyKubeconfig().getContextObject(this.contextName);
if (ctx.namespace) return [ctx.namespace]; if (ctx.namespace) return [ctx.namespace];
return []; return [];
} }
} }
@ -629,6 +651,7 @@ export class Cluster implements ClusterModel, ClusterState {
namespace: this.allowedNamespaces[0] namespace: this.allowedNamespaces[0]
})) }))
); );
return apiResources return apiResources
.filter((resource, i) => resourceAccessStatuses[i]) .filter((resource, i) => resourceAccessStatuses[i])
.map(apiResource => apiResource.resource); .map(apiResource => apiResource.resource);

View File

@ -25,28 +25,34 @@ export class ContextHandler {
public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) { public setupPrometheus(preferences: ClusterPrometheusPreferences = {}) {
this.prometheusProvider = preferences.prometheusProvider?.type; this.prometheusProvider = preferences.prometheusProvider?.type;
this.prometheusPath = null; this.prometheusPath = null;
if (preferences.prometheus) { if (preferences.prometheus) {
const { namespace, service, port } = preferences.prometheus; const { namespace, service, port } = preferences.prometheus;
this.prometheusPath = `${namespace}/services/${service}:${port}`; this.prometheusPath = `${namespace}/services/${service}:${port}`;
} }
} }
protected async resolvePrometheusPath(): Promise<string> { protected async resolvePrometheusPath(): Promise<string> {
const prometheusService = await this.getPrometheusService(); const prometheusService = await this.getPrometheusService();
if (!prometheusService) return null; if (!prometheusService) return null;
const { service, namespace, port } = prometheusService; const { service, namespace, port } = prometheusService;
return `${namespace}/services/${service}:${port}`; return `${namespace}/services/${service}:${port}`;
} }
async getPrometheusProvider() { async getPrometheusProvider() {
if (!this.prometheusProvider) { if (!this.prometheusProvider) {
const service = await this.getPrometheusService(); const service = await this.getPrometheusService();
if (!service) { if (!service) {
return null; return null;
} }
logger.info(`using ${service.id} as prometheus provider`); logger.info(`using ${service.id} as prometheus provider`);
this.prometheusProvider = service.id; this.prometheusProvider = service.id;
} }
return prometheusProviders.find(p => p.id === this.prometheusProvider); return prometheusProviders.find(p => p.id === this.prometheusProvider);
} }
@ -54,9 +60,11 @@ export class ContextHandler {
const providers = this.prometheusProvider ? prometheusProviders.filter(provider => provider.id == this.prometheusProvider) : prometheusProviders; const providers = this.prometheusProvider ? prometheusProviders.filter(provider => provider.id == this.prometheusProvider) : prometheusProviders;
const prometheusPromises: Promise<PrometheusService>[] = providers.map(async (provider: PrometheusProvider): Promise<PrometheusService> => { const prometheusPromises: Promise<PrometheusService>[] = providers.map(async (provider: PrometheusProvider): Promise<PrometheusService> => {
const apiClient = this.cluster.getProxyKubeconfig().makeApiClient(CoreV1Api); const apiClient = this.cluster.getProxyKubeconfig().makeApiClient(CoreV1Api);
return await provider.getPrometheusService(apiClient); return await provider.getPrometheusService(apiClient);
}); });
const resolvedPrometheusServices = await Promise.all(prometheusPromises); const resolvedPrometheusServices = await Promise.all(prometheusPromises);
return resolvedPrometheusServices.filter(n => n)[0]; return resolvedPrometheusServices.filter(n => n)[0];
} }
@ -64,12 +72,14 @@ export class ContextHandler {
if (!this.prometheusPath) { if (!this.prometheusPath) {
this.prometheusPath = await this.resolvePrometheusPath(); this.prometheusPath = await this.resolvePrometheusPath();
} }
return this.prometheusPath; return this.prometheusPath;
} }
async resolveAuthProxyUrl() { async resolveAuthProxyUrl() {
const proxyPort = await this.ensurePort(); const proxyPort = await this.ensurePort();
const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : ""; const path = this.clusterUrl.path !== "/" ? this.clusterUrl.path : "";
return `http://127.0.0.1:${proxyPort}${path}`; return `http://127.0.0.1:${proxyPort}${path}`;
} }
@ -79,14 +89,17 @@ export class ContextHandler {
} }
const timeout = isWatchRequest ? 4 * 60 * 60 * 1000 : 30000; // 4 hours for watch request, 30 seconds for the rest const timeout = isWatchRequest ? 4 * 60 * 60 * 1000 : 30000; // 4 hours for watch request, 30 seconds for the rest
const apiTarget = await this.newApiTarget(timeout); const apiTarget = await this.newApiTarget(timeout);
if (!isWatchRequest) { if (!isWatchRequest) {
this.apiTarget = apiTarget; this.apiTarget = apiTarget;
} }
return apiTarget; return apiTarget;
} }
protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> { protected async newApiTarget(timeout: number): Promise<httpProxy.ServerOptions> {
const proxyUrl = await this.resolveAuthProxyUrl(); const proxyUrl = await this.resolveAuthProxyUrl();
return { return {
target: proxyUrl, target: proxyUrl,
changeOrigin: true, changeOrigin: true,
@ -101,6 +114,7 @@ export class ContextHandler {
if (!this.proxyPort) { if (!this.proxyPort) {
this.proxyPort = await getFreePort(); this.proxyPort = await getFreePort();
} }
return this.proxyPort; return this.proxyPort;
} }
@ -108,6 +122,7 @@ export class ContextHandler {
if (!this.kubeAuthProxy) { if (!this.kubeAuthProxy) {
await this.ensurePort(); await this.ensurePort();
const proxyEnv = Object.assign({}, process.env); const proxyEnv = Object.assign({}, process.env);
if (this.cluster.preferences.httpsProxy) { if (this.cluster.preferences.httpsProxy) {
proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy; proxyEnv.HTTPS_PROXY = this.cluster.preferences.httpsProxy;
} }

View File

@ -8,9 +8,10 @@ import logger from "./logger";
export function exitApp() { export function exitApp() {
const windowManager = WindowManager.getInstance<WindowManager>(); const windowManager = WindowManager.getInstance<WindowManager>();
const clusterManager = ClusterManager.getInstance<ClusterManager>(); const clusterManager = ClusterManager.getInstance<ClusterManager>();
appEventBus.emit({ name: "service", action: "close" }); appEventBus.emit({ name: "service", action: "close" });
windowManager.hide(); windowManager?.hide();
clusterManager.stop(); clusterManager?.stop();
logger.info("SERVICE:QUIT"); logger.info("SERVICE:QUIT");
setTimeout(() => { setTimeout(() => {
app.exit(); app.exit();

View File

@ -32,11 +32,14 @@ export class FilesystemProvisionerStore extends BaseStore<FSProvisionModel> {
const salt = randomBytes(32).toString("hex"); const salt = randomBytes(32).toString("hex");
const hashedName = SHA256(`${extensionName}/${salt}`).toString(); const hashedName = SHA256(`${extensionName}/${salt}`).toString();
const dirPath = path.resolve(app.getPath("userData"), "extension_data", hashedName); const dirPath = path.resolve(app.getPath("userData"), "extension_data", hashedName);
this.registeredExtensions.set(extensionName, dirPath); this.registeredExtensions.set(extensionName, dirPath);
} }
const dirPath = this.registeredExtensions.get(extensionName); const dirPath = this.registeredExtensions.get(extensionName);
await fse.ensureDir(dirPath); await fse.ensureDir(dirPath);
return dirPath; return dirPath;
} }

View File

@ -20,32 +20,39 @@ export class HelmChartManager {
public async chart(name: string) { public async chart(name: string) {
const charts = await this.charts(); const charts = await this.charts();
return charts[name]; return charts[name];
} }
public async charts(): Promise<any> { public async charts(): Promise<any> {
try { try {
const cachedYaml = await this.cachedYaml(); const cachedYaml = await this.cachedYaml();
return cachedYaml["entries"]; return cachedYaml["entries"];
} catch(error) { } catch(error) {
logger.error(error); logger.error(error);
return []; return [];
} }
} }
public async getReadme(name: string, version = "") { public async getReadme(name: string, version = "") {
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
if(version && version != "") { if(version && version != "") {
const { stdout } = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr);}); const { stdout } = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr);});
return stdout; return stdout;
} else { } else {
const { stdout } = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name}`).catch((error) => { throw(error.stderr);}); const { stdout } = await promiseExec(`"${helm}" show readme ${this.repo.name}/${name}`).catch((error) => { throw(error.stderr);});
return stdout; return stdout;
} }
} }
public async getValues(name: string, version = "") { public async getValues(name: string, version = "") {
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
if(version && version != "") { if(version && version != "") {
const { stdout } = await promiseExec(`"${helm}" show values ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr);}); const { stdout } = await promiseExec(`"${helm}" show values ${this.repo.name}/${name} --version ${version}`).catch((error) => { throw(error.stderr);});
@ -61,6 +68,7 @@ export class HelmChartManager {
if (!(this.repo.name in this.cache)) { if (!(this.repo.name in this.cache)) {
const cacheFile = await fs.promises.readFile(this.repo.cacheFilePath, "utf-8"); const cacheFile = await fs.promises.readFile(this.repo.cacheFilePath, "utf-8");
const data = yaml.safeLoad(cacheFile); const data = yaml.safeLoad(cacheFile);
for(const key in data["entries"]) { for(const key in data["entries"]) {
data["entries"][key].forEach((version: any) => { data["entries"][key].forEach((version: any) => {
version["repo"] = this.repo.name; version["repo"] = this.repo.name;
@ -69,6 +77,7 @@ export class HelmChartManager {
} }
this.cache[this.repo.name] = Buffer.from(JSON.stringify(data)); this.cache[this.repo.name] = Buffer.from(JSON.stringify(data));
} }
return JSON.parse(this.cache[this.repo.name].toString()); return JSON.parse(this.cache[this.repo.name].toString());
} }
} }

View File

@ -12,6 +12,7 @@ export class HelmCli extends LensBinary {
originalBinaryName: "helm", originalBinaryName: "helm",
newBinaryName: "helm3" newBinaryName: "helm3"
}; };
super(opts); super(opts);
} }

View File

@ -12,14 +12,15 @@ export class HelmReleaseManager {
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
const namespaceFlag = namespace ? `-n ${namespace}` : "--all-namespaces"; const namespaceFlag = namespace ? `-n ${namespace}` : "--all-namespaces";
const { stdout } = await promiseExec(`"${helm}" ls --output json ${namespaceFlag} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);}); const { stdout } = await promiseExec(`"${helm}" ls --output json ${namespaceFlag} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);});
const output = JSON.parse(stdout); const output = JSON.parse(stdout);
if (output.length == 0) { if (output.length == 0) {
return output; return output;
} }
output.forEach((release: any, index: number) => { output.forEach((release: any, index: number) => {
output[index] = toCamelCase(release); output[index] = toCamelCase(release);
}); });
return output; return output;
} }
@ -27,15 +28,19 @@ export class HelmReleaseManager {
public async installChart(chart: string, values: any, name: string, namespace: string, version: string, pathToKubeconfig: string){ public async installChart(chart: string, values: any, name: string, namespace: string, version: string, pathToKubeconfig: string){
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
const fileName = tempy.file({name: "values.yaml"}); const fileName = tempy.file({name: "values.yaml"});
await fs.promises.writeFile(fileName, yaml.safeDump(values)); await fs.promises.writeFile(fileName, yaml.safeDump(values));
try { try {
let generateName = ""; let generateName = "";
if (!name) { if (!name) {
generateName = "--generate-name"; generateName = "--generate-name";
name = ""; name = "";
} }
const { stdout } = await promiseExec(`"${helm}" install ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} ${generateName}`).catch((error) => { throw(error.stderr);}); const { stdout } = await promiseExec(`"${helm}" install ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} ${generateName}`).catch((error) => { throw(error.stderr);});
const releaseName = stdout.split("\n")[0].split(" ")[1].trim(); const releaseName = stdout.split("\n")[0].split(" ")[1].trim();
return { return {
log: stdout, log: stdout,
release: { release: {
@ -51,10 +56,12 @@ export class HelmReleaseManager {
public async upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster){ public async upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster){
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
const fileName = tempy.file({name: "values.yaml"}); const fileName = tempy.file({name: "values.yaml"});
await fs.promises.writeFile(fileName, yaml.safeDump(values)); await fs.promises.writeFile(fileName, yaml.safeDump(values));
try { try {
const { stdout } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);}); const { stdout } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);});
return { return {
log: stdout, log: stdout,
release: this.getRelease(name, namespace, cluster) release: this.getRelease(name, namespace, cluster)
@ -68,7 +75,9 @@ export class HelmReleaseManager {
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
const { stdout } = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);}); const { stdout } = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);});
const release = JSON.parse(stdout); const release = JSON.parse(stdout);
release.resources = await this.getResources(name, namespace, cluster); release.resources = await this.getResources(name, namespace, cluster);
return release; return release;
} }
@ -82,18 +91,21 @@ export class HelmReleaseManager {
public async getValues(name: string, namespace: string, pathToKubeconfig: string) { public async getValues(name: string, namespace: string, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
const { stdout, } = await promiseExec(`"${helm}" get values ${name} --all --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);}); const { stdout, } = await promiseExec(`"${helm}" get values ${name} --all --output yaml --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);});
return stdout; return stdout;
} }
public async getHistory(name: string, namespace: string, pathToKubeconfig: string) { public async getHistory(name: string, namespace: string, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
const { stdout } = await promiseExec(`"${helm}" history ${name} --output json --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);}); const { stdout } = await promiseExec(`"${helm}" history ${name} --output json --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);});
return JSON.parse(stdout); return JSON.parse(stdout);
} }
public async rollback(name: string, namespace: string, revision: number, pathToKubeconfig: string) { public async rollback(name: string, namespace: string, revision: number, pathToKubeconfig: string) {
const helm = await helmCli.binaryPath(); const helm = await helmCli.binaryPath();
const { stdout } = await promiseExec(`"${helm}" rollback ${name} ${revision} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);}); const { stdout } = await promiseExec(`"${helm}" rollback ${name} ${revision} --namespace ${namespace} --kubeconfig ${pathToKubeconfig}`).catch((error) => { throw(error.stderr);});
return stdout; return stdout;
} }
@ -104,6 +116,7 @@ export class HelmReleaseManager {
const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`).catch(() => { const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`).catch(() => {
return { stdout: JSON.stringify({items: []})}; return { stdout: JSON.stringify({items: []})};
}); });
return stdout; return stdout;
} }
} }

View File

@ -42,12 +42,14 @@ export class HelmRepoManager extends Singleton {
resolveWithFullResponse: true, resolveWithFullResponse: true,
timeout: 10000, timeout: 10000,
}); });
return orderBy<HelmRepo>(res.body, repo => repo.name); return orderBy<HelmRepo>(res.body, repo => repo.name);
} }
async init() { async init() {
helmCli.setLogger(logger); helmCli.setLogger(logger);
await helmCli.ensureBinary(); await helmCli.ensureBinary();
if (!this.initialized) { if (!this.initialized) {
this.helmEnv = await this.parseHelmEnv(); this.helmEnv = await this.parseHelmEnv();
await this.update(); await this.update();
@ -62,12 +64,15 @@ export class HelmRepoManager extends Singleton {
}); });
const lines = stdout.split(/\r?\n/); // split by new line feed const lines = stdout.split(/\r?\n/); // split by new line feed
const env: HelmEnv = {}; const env: HelmEnv = {};
lines.forEach((line: string) => { lines.forEach((line: string) => {
const [key, value] = line.split("="); const [key, value] = line.split("=");
if (key && value) { if (key && value) {
env[key] = value.replace(/"/g, ""); // strip quotas env[key] = value.replace(/"/g, ""); // strip quotas
} }
}); });
return env; return env;
} }
@ -75,6 +80,7 @@ export class HelmRepoManager extends Singleton {
if (!this.initialized) { if (!this.initialized) {
await this.init(); await this.init();
} }
try { try {
const repoConfigFile = this.helmEnv.HELM_REPOSITORY_CONFIG; const repoConfigFile = this.helmEnv.HELM_REPOSITORY_CONFIG;
const { repositories }: HelmRepoConfig = await readFile(repoConfigFile, "utf8") const { repositories }: HelmRepoConfig = await readFile(repoConfigFile, "utf8")
@ -82,22 +88,27 @@ export class HelmRepoManager extends Singleton {
.catch(() => ({ .catch(() => ({
repositories: [] repositories: []
})); }));
if (!repositories.length) { if (!repositories.length) {
await this.addRepo({ name: "bitnami", url: "https://charts.bitnami.com/bitnami" }); await this.addRepo({ name: "bitnami", url: "https://charts.bitnami.com/bitnami" });
return await this.repositories(); return await this.repositories();
} }
return repositories.map(repo => ({ return repositories.map(repo => ({
...repo, ...repo,
cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml` cacheFilePath: `${this.helmEnv.HELM_REPOSITORY_CACHE}/${repo.name}-index.yaml`
})); }));
} catch (error) { } catch (error) {
logger.error(`[HELM]: repositories listing error "${error}"`); logger.error(`[HELM]: repositories listing error "${error}"`);
return []; return [];
} }
} }
public async repository(name: string) { public async repository(name: string) {
const repositories = await this.repositories(); const repositories = await this.repositories();
return repositories.find(repo => repo.name == name); return repositories.find(repo => repo.name == name);
} }
@ -106,6 +117,7 @@ export class HelmRepoManager extends Singleton {
const { stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => { const { stdout } = await promiseExec(`"${helm}" repo update`).catch((error) => {
return { stdout: error.stdout }; return { stdout: error.stdout };
}); });
return stdout; return stdout;
} }
@ -115,6 +127,7 @@ export class HelmRepoManager extends Singleton {
const { stdout } = await promiseExec(`"${helm}" repo add ${name} ${url}`).catch((error) => { const { stdout } = await promiseExec(`"${helm}" repo add ${name} ${url}`).catch((error) => {
throw(error.stderr); throw(error.stderr);
}); });
return stdout; return stdout;
} }
@ -124,6 +137,7 @@ export class HelmRepoManager extends Singleton {
const { stdout } = await promiseExec(`"${helm}" repo remove ${name}`).catch((error) => { const { stdout } = await promiseExec(`"${helm}" repo remove ${name}`).catch((error) => {
throw(error.stderr); throw(error.stderr);
}); });
return stdout; return stdout;
} }
} }

View File

@ -11,18 +11,23 @@ class HelmService {
public async listCharts() { public async listCharts() {
const charts: any = {}; const charts: any = {};
await repoManager.init(); await repoManager.init();
const repositories = await repoManager.repositories(); const repositories = await repoManager.repositories();
for (const repo of repositories) { for (const repo of repositories) {
charts[repo.name] = {}; charts[repo.name] = {};
const manager = new HelmChartManager(repo); const manager = new HelmChartManager(repo);
let entries = await manager.charts(); let entries = await manager.charts();
entries = this.excludeDeprecated(entries); entries = this.excludeDeprecated(entries);
for (const key in entries) { for (const key in entries) {
entries[key] = entries[key][0]; entries[key] = entries[key][0];
} }
charts[repo.name] = entries; charts[repo.name] = entries;
} }
return charts; return charts;
} }
@ -34,50 +39,60 @@ class HelmService {
const repo = await repoManager.repository(repoName); const repo = await repoManager.repository(repoName);
const chartManager = new HelmChartManager(repo); const chartManager = new HelmChartManager(repo);
const chart = await chartManager.chart(chartName); const chart = await chartManager.chart(chartName);
result.readme = await chartManager.getReadme(chartName, version); result.readme = await chartManager.getReadme(chartName, version);
result.versions = chart; result.versions = chart;
return result; return result;
} }
public async getChartValues(repoName: string, chartName: string, version = "") { public async getChartValues(repoName: string, chartName: string, version = "") {
const repo = await repoManager.repository(repoName); const repo = await repoManager.repository(repoName);
const chartManager = new HelmChartManager(repo); const chartManager = new HelmChartManager(repo);
return chartManager.getValues(chartName, version); return chartManager.getValues(chartName, version);
} }
public async listReleases(cluster: Cluster, namespace: string = null) { public async listReleases(cluster: Cluster, namespace: string = null) {
await repoManager.init(); await repoManager.init();
return await releaseManager.listReleases(cluster.getProxyKubeconfigPath(), namespace); return await releaseManager.listReleases(cluster.getProxyKubeconfigPath(), namespace);
} }
public async getRelease(cluster: Cluster, releaseName: string, namespace: string) { public async getRelease(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release"); logger.debug("Fetch release");
return await releaseManager.getRelease(releaseName, namespace, cluster); return await releaseManager.getRelease(releaseName, namespace, cluster);
} }
public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) { public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release values"); logger.debug("Fetch release values");
return await releaseManager.getValues(releaseName, namespace, cluster.getProxyKubeconfigPath()); return await releaseManager.getValues(releaseName, namespace, cluster.getProxyKubeconfigPath());
} }
public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) { public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Fetch release history"); logger.debug("Fetch release history");
return await releaseManager.getHistory(releaseName, namespace, cluster.getProxyKubeconfigPath()); return await releaseManager.getHistory(releaseName, namespace, cluster.getProxyKubeconfigPath());
} }
public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) { public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) {
logger.debug("Delete release"); logger.debug("Delete release");
return await releaseManager.deleteRelease(releaseName, namespace, cluster.getProxyKubeconfigPath()); return await releaseManager.deleteRelease(releaseName, namespace, cluster.getProxyKubeconfigPath());
} }
public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) { public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) {
logger.debug("Upgrade release"); logger.debug("Upgrade release");
return await releaseManager.upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, cluster); return await releaseManager.upgradeRelease(releaseName, data.chart, data.values, namespace, data.version, cluster);
} }
public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) { public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) {
logger.debug("Rollback release"); logger.debug("Rollback release");
const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.getProxyKubeconfigPath()); const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.getProxyKubeconfigPath());
return { message: output }; return { message: output };
} }
@ -87,9 +102,11 @@ class HelmService {
if (Array.isArray(entry)) { if (Array.isArray(entry)) {
return entry[0]["deprecated"] != true; return entry[0]["deprecated"] != true;
} }
return entry["deprecated"] != true; return entry["deprecated"] != true;
}); });
} }
return entries; return entries;
} }

View File

@ -34,11 +34,13 @@ let clusterManager: ClusterManager;
let windowManager: WindowManager; let windowManager: WindowManager;
app.setName(appName); app.setName(appName);
if (!process.env.CICD) { if (!process.env.CICD) {
app.setPath("userData", workingDir); app.setPath("userData", workingDir);
} }
mangleProxyEnv(); mangleProxyEnv();
if (app.commandLine.getSwitchValue("proxy-server") !== "") { if (app.commandLine.getSwitchValue("proxy-server") !== "") {
process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server"); process.env.HTTPS_PROXY = app.commandLine.getSwitchValue("proxy-server");
} }
@ -48,6 +50,7 @@ app.on("ready", async () => {
await shellSync(); await shellSync();
const updater = new AppUpdater(); const updater = new AppUpdater();
updater.start(); updater.start();
registerFileProtocol("static", __static); registerFileProtocol("static", __static);
@ -110,6 +113,7 @@ app.on("ready", async () => {
app.on("activate", (event, hasVisibleWindows) => { app.on("activate", (event, hasVisibleWindows) => {
logger.info("APP:ACTIVATE", { hasVisibleWindows }); logger.info("APP:ACTIVATE", { hasVisibleWindows });
if (!hasVisibleWindows) { if (!hasVisibleWindows) {
windowManager.initMainWindow(); windowManager.initMainWindow();
} }
@ -121,6 +125,7 @@ app.on("will-quit", (event) => {
appEventBus.emit({name: "app", action: "close"}); appEventBus.emit({name: "app", action: "close"});
event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.) event.preventDefault(); // prevent app's default shutdown (e.g. required for telemetry, etc.)
clusterManager?.stop(); // close cluster connections clusterManager?.stop(); // close cluster connections
return; // skip exit to make tray work, to quit go to app's global menu or tray's menu return; // skip exit to make tray work, to quit go to app's global menu or tray's menu
}); });

View File

@ -45,6 +45,7 @@ export class KubeAuthProxy {
"--accept-hosts", this.acceptHosts, "--accept-hosts", this.acceptHosts,
"--reject-paths", "^[^/]" "--reject-paths", "^[^/]"
]; ];
if (process.env.DEBUG_PROXY === "true") { if (process.env.DEBUG_PROXY === "true") {
args.push("-v", "9"); args.push("-v", "9");
} }
@ -62,6 +63,7 @@ export class KubeAuthProxy {
this.proxyProcess.stdout.on("data", (data) => { this.proxyProcess.stdout.on("data", (data) => {
let logItem = data.toString(); let logItem = data.toString();
if (logItem.startsWith("Starting to serve on")) { if (logItem.startsWith("Starting to serve on")) {
logItem = "Authentication proxy started\n"; logItem = "Authentication proxy started\n";
} }
@ -80,19 +82,23 @@ export class KubeAuthProxy {
const error = data.split("http: proxy error:").slice(1).join("").trim(); const error = data.split("http: proxy error:").slice(1).join("").trim();
let errorMsg = error; let errorMsg = error;
const jsonError = error.split("Response: ")[1]; const jsonError = error.split("Response: ")[1];
if (jsonError) { if (jsonError) {
try { try {
const parsedError = JSON.parse(jsonError); const parsedError = JSON.parse(jsonError);
errorMsg = parsedError.error_description || parsedError.error || jsonError; errorMsg = parsedError.error_description || parsedError.error || jsonError;
} catch (_) { } catch (_) {
errorMsg = jsonError.trim(); errorMsg = jsonError.trim();
} }
} }
return errorMsg; return errorMsg;
} }
protected async sendIpcLogMessage(res: KubeAuthProxyLog) { protected async sendIpcLogMessage(res: KubeAuthProxyLog) {
const channel = `kube-auth:${this.cluster.id}`; const channel = `kube-auth:${this.cluster.id}`;
logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() }); logger.info(`[KUBE-AUTH]: out-channel "${channel}"`, { ...res, meta: this.cluster.getMeta() });
broadcastMessage(channel, res); broadcastMessage(channel, res);
} }

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