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

Add kubesync shortcut to catalog

- Add ability to register new app preference groupings

- Switch all lens preferences to be retreived from the registry

- Dynamically render preference's navigation based on the existance of
  items in each grouping

- Add ability for settings to declare that they shouldn't be rendered

Signed-off-by: Sebastian Malton <sebastian@malton.name>
This commit is contained in:
Sebastian Malton 2021-06-30 23:12:06 -04:00
parent eef5dd5f9c
commit 207f0dfd62
18 changed files with 621 additions and 379 deletions

View File

@ -60,14 +60,13 @@ describe("Lens integration tests", () => {
await app.client.waitUntilTextExists("[data-testid=application-header]", "Application"); await app.client.waitUntilTextExists("[data-testid=application-header]", "Application");
}); });
it("shows all tabs and their contents", async () => { it.each([
await app.client.click("[data-testid=application-tab]"); ["application", "Application"],
await app.client.click("[data-testid=proxy-tab]"); ["proxy", "Proxy"],
await app.client.waitUntilTextExists("[data-testid=proxy-header]", "Proxy"); ["kubernetes", "Kubernetes"],
await app.client.click("[data-testid=kube-tab]"); ])("Can click the %s tab and see the %s header", async (tab, header) => {
await app.client.waitUntilTextExists("[data-testid=kubernetes-header]", "Kubernetes"); await app.client.click(`[data-testid=${tab}-tab]`);
await app.client.click("[data-testid=telemetry-tab]"); await app.client.waitUntilTextExists(`[data-testid=${tab}-header]`, header);
await app.client.waitUntilTextExists("[data-testid=telemetry-header]", "Telemetry");
}); });
it("ensures helm repos", async () => { it("ensures helm repos", async () => {
@ -77,7 +76,7 @@ describe("Lens integration tests", () => {
fail("Lens failed to add any repositories"); fail("Lens failed to add any repositories");
} }
await app.client.click("[data-testid=kube-tab]"); await app.client.click("[data-testid=kubernetes-tab]");
await app.client.waitUntilTextExists("div.repos .repoName", repos[0].name); // wait for the helm-cli to fetch the repo(s) await app.client.waitUntilTextExists("div.repos .repoName", repos[0].name); // wait for the helm-cli to fetch the repo(s)
await app.client.click("#HelmRepoSelect"); // click the repo select to activate the drop-down await app.client.click("#HelmRepoSelect"); // click the repo select to activate the drop-down
await app.client.waitUntilTextExists("div.Select__option", ""); // wait for at least one option to appear (any text) await app.client.waitUntilTextExists("div.Select__option", ""); // wait for at least one option to appear (any text)

View File

@ -25,7 +25,7 @@ import { clusterActivateHandler, clusterDeleteHandler, clusterDisconnectHandler
import { ClusterStore } from "../cluster-store"; import { ClusterStore } from "../cluster-store";
import { requestMain } from "../ipc"; import { requestMain } from "../ipc";
import { CatalogCategory, CatalogCategorySpec } from "../catalog"; import { CatalogCategory, CatalogCategorySpec } from "../catalog";
import { addClusterURL } from "../routes"; import { addClusterURL, preferencesURL } from "../routes";
import { app } from "electron"; import { app } from "electron";
import type { CatalogEntitySpec } from "../catalog/catalog-entity"; import type { CatalogEntitySpec } from "../catalog/catalog-entity";
import { HotbarStore } from "../hotbar-store"; import { HotbarStore } from "../hotbar-store";
@ -172,13 +172,18 @@ export class KubernetesClusterCategory extends CatalogCategory {
super(); super();
this.on("catalogAddMenu", (ctx: CatalogEntityAddMenuContext) => { this.on("catalogAddMenu", (ctx: CatalogEntityAddMenuContext) => {
ctx.menuItems.push({ ctx.menuItems.push(
icon: "text_snippet", {
title: "Add from kubeconfig", icon: "text_snippet",
onClick: () => { title: "Add from kubeconfig",
ctx.navigate(addClusterURL()); onClick: () => ctx.navigate(addClusterURL()),
} },
}); {
icon: "settings",
title: "Sync kubeconfig file(s)",
onClick: () => ctx.navigate(preferencesURL({ fragment: "kube-sync" })),
},
);
}); });
} }
} }

View File

@ -209,6 +209,6 @@ export class UserStore extends BaseStore<UserStoreModel> /* implements UserStore
* Getting default directory to download kubectl binaries * Getting default directory to download kubectl binaries
* @returns string * @returns string
*/ */
export function getDefaultKubectlPath(): string { export function getDefaultKubectlDownloadPath(): string {
return path.join((app || remote.app).getPath("userData"), "binaries"); return path.join((app || remote.app).getPath("userData"), "binaries");
} }

View File

@ -273,6 +273,7 @@ export class ExtensionLoader extends Singleton {
this.autoInitExtensions(async (extension: LensRendererExtension) => { this.autoInitExtensions(async (extension: LensRendererExtension) => {
const removeItems = [ const removeItems = [
registries.GlobalPageRegistry.getInstance().add(extension.globalPages, extension), registries.GlobalPageRegistry.getInstance().add(extension.globalPages, extension),
registries.AppPreferenceKindRegistry.getInstance().add(extension.appPreferenceKinds),
registries.AppPreferenceRegistry.getInstance().add(extension.appPreferences), registries.AppPreferenceRegistry.getInstance().add(extension.appPreferences),
registries.EntitySettingRegistry.getInstance().add(extension.entitySettings), registries.EntitySettingRegistry.getInstance().add(extension.entitySettings),
registries.StatusBarRegistry.getInstance().add(extension.statusBarItems), registries.StatusBarRegistry.getInstance().add(extension.statusBarItems),

View File

@ -31,6 +31,7 @@ export class LensRendererExtension extends LensExtension {
clusterPageMenus: registries.ClusterPageMenuRegistration[] = []; clusterPageMenus: registries.ClusterPageMenuRegistration[] = [];
kubeObjectStatusTexts: registries.KubeObjectStatusRegistration[] = []; kubeObjectStatusTexts: registries.KubeObjectStatusRegistration[] = [];
appPreferences: registries.AppPreferenceRegistration[] = []; appPreferences: registries.AppPreferenceRegistration[] = [];
appPreferenceKinds: registries.AppPreferenceKindRegistration[] = [];
entitySettings: registries.EntitySettingRegistration[] = []; entitySettings: registries.EntitySettingRegistration[] = [];
statusBarItems: registries.StatusBarRegistration[] = []; statusBarItems: registries.StatusBarRegistration[] = [];
kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = []; kubeObjectDetailItems: registries.KubeObjectDetailRegistration[] = [];

View File

@ -19,30 +19,87 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
import type { IComputedValue } from "mobx/dist/internal";
import type React from "react"; import type React from "react";
import { BaseRegistry } from "./base-registry"; import { BaseRegistry } from "./base-registry";
export interface AppPreferenceComponents { export interface AppPreferenceComponents {
Hint: React.ComponentType<any>; /**
* This will be rendered below the `<Input>` with slightly smaller font size
*
* @optional
*/
Hint?: React.ComponentType<any>;
/**
* The component for rendering the interactive part of the setting
*/
Input: React.ComponentType<any>; Input: React.ComponentType<any>;
} }
export interface AppPreferenceRegistration { export interface AppPreferenceRegistration {
/**
* The text that will be displayed as the title to the preference
*/
title: string; title: string;
/**
* The id of your setting, used for several purposes including the navigation
* to specific settings.
*
* @optional If not provided then computed from `title`
*/
id?: string; id?: string;
/**
* Which preferences tab to display this setting.
*
* @default "extensions"
*/
showInPreferencesTab?: string; showInPreferencesTab?: string;
/**
* A function for hiding the setting. If the function returns true then this
* setting will not be rendered.
*
* @default false
*/
hide?: boolean | IComputedValue<boolean>;
/**
* The components used for rendering the settings
*/
components: AppPreferenceComponents; components: AppPreferenceComponents;
} }
export interface RegisteredAppPreference extends AppPreferenceRegistration { export type RegisteredAppPreference = Required<AppPreferenceRegistration>;
export interface AppPreferenceKindRegistration {
id: string; id: string;
title: string;
}
/**
* These are the default preferences kinds provided by Lens
*/
export enum AppPreferenceKind {
Application = "application",
Proxy = "proxy",
Kubernetes = "kubernetes",
Telemetry = "telemetry",
Extensions = "extensions",
Other = "other"
} }
export class AppPreferenceRegistry extends BaseRegistry<AppPreferenceRegistration, RegisteredAppPreference> { export class AppPreferenceRegistry extends BaseRegistry<AppPreferenceRegistration, RegisteredAppPreference> {
getRegisteredItem(item: AppPreferenceRegistration): RegisteredAppPreference { getRegisteredItem({ id, showInPreferencesTab, hide = false, ...item}: AppPreferenceRegistration): RegisteredAppPreference {
return { return {
id: item.id || item.title.toLowerCase().replace(/[^0-9a-zA-Z]+/g, "-"), id: id || item.title.toLowerCase().replace(/[^0-9a-zA-Z]+/g, "-"),
showInPreferencesTab: showInPreferencesTab || AppPreferenceKind.Extensions,
hide,
...item, ...item,
}; };
} }
} }
export class AppPreferenceKindRegistry extends BaseRegistry<AppPreferenceKindRegistration> {}

View File

@ -32,6 +32,7 @@ import { customRequest } from "../common/request";
import { getBundledKubectlVersion } from "../common/utils/app-version"; import { getBundledKubectlVersion } from "../common/utils/app-version";
import { isDevelopment, isWindows, isTestEnv } from "../common/vars"; import { isDevelopment, isWindows, isTestEnv } from "../common/vars";
import { SemVer } from "semver"; import { SemVer } from "semver";
import type { SelectOption } from "../renderer/components/select";
const bundledVersion = getBundledKubectlVersion(); const bundledVersion = getBundledKubectlVersion();
const kubectlMap: Map<string, string> = new Map([ const kubectlMap: Map<string, string> = new Map([
@ -55,6 +56,12 @@ const packageMirrors: Map<string, string> = new Map([
["default", "https://storage.googleapis.com/kubernetes-release/release"], ["default", "https://storage.googleapis.com/kubernetes-release/release"],
["china", "https://mirror.azure.cn/kubernetes/kubectl"] ["china", "https://mirror.azure.cn/kubernetes/kubectl"]
]); ]);
export const downloadMirrorOptions: SelectOption<string>[] = [
{ value: "default", label: "Default (Google)" },
{ value: "china", label: "China (Azure)" },
];
let bundledPath: string; let bundledPath: string;
const initScriptVersionString = "# lens-initscript v3\n"; const initScriptVersionString = "# lens-initscript v3\n";

View File

@ -74,6 +74,8 @@ export async function bootstrap(App: AppComponent) {
rootElem.classList.toggle("is-mac", isMac); rootElem.classList.toggle("is-mac", isMac);
initializers.initRegistries(); initializers.initRegistries();
initializers.initAppPreferenceKindRegistry();
initializers.initAppPreferenceRegistry();
initializers.initCommandRegistry(); initializers.initCommandRegistry();
initializers.initEntitySettingsRegistry(); initializers.initEntitySettingsRegistry();
initializers.initKubeObjectMenuRegistry(); initializers.initKubeObjectMenuRegistry();

View File

@ -20,19 +20,17 @@
*/ */
import React from "react"; import React from "react";
import { remote } from "electron";
import { Avatar, IconButton, List, ListItem, ListItemAvatar, ListItemSecondaryAction, ListItemText, Paper } from "@material-ui/core"; import { Avatar, IconButton, List, ListItem, ListItemAvatar, ListItemSecondaryAction, ListItemText, Paper } from "@material-ui/core";
import { Description, Folder, Delete, HelpOutline } from "@material-ui/icons"; import { Description, Folder, Delete, HelpOutline } from "@material-ui/icons";
import { action, computed, observable, reaction, makeObservable } from "mobx"; import { action, computed, observable, reaction, makeObservable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import fse from "fs-extra"; import fse from "fs-extra";
import { KubeconfigSyncEntry, KubeconfigSyncValue, UserStore } from "../../../common/user-store"; import { KubeconfigSyncEntry, KubeconfigSyncValue, UserStore } from "../../../common/user-store";
import { Button } from "../button";
import { SubTitle } from "../layout/sub-title";
import { Spinner } from "../spinner"; import { Spinner } from "../spinner";
import logger from "../../../main/logger"; import logger from "../../../main/logger";
import { iter } from "../../utils"; import { iter } from "../../utils";
import { isWindows } from "../../../common/vars"; import { isWindows } from "../../../common/vars";
import { PathPicker } from "../path-picker";
interface SyncInfo { interface SyncInfo {
type: "file" | "folder" | "unknown"; type: "file" | "folder" | "unknown";
@ -70,8 +68,6 @@ async function getMapEntry({ filePath, ...data}: KubeconfigSyncEntry): Promise<[
} }
} }
type SelectPathOptions = ("openFile" | "openDirectory")[];
@observer @observer
export class KubeconfigSyncs extends React.Component { export class KubeconfigSyncs extends React.Component {
syncs = observable.map<string, Value>(); syncs = observable.map<string, Value>();
@ -109,24 +105,13 @@ export class KubeconfigSyncs extends React.Component {
} }
@action @action
async openDialog(message: string, actions: SelectPathOptions) { onPick = async (filePaths: string[]) => {
const { dialog, BrowserWindow } = remote;
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
properties: ["showHiddenFiles", "multiSelections", ...actions],
message,
buttonLabel: "Sync",
});
if (canceled) {
return;
}
const newEntries = await Promise.all(filePaths.map(filePath => getMapEntry({ filePath }))); const newEntries = await Promise.all(filePaths.map(filePath => getMapEntry({ filePath })));
for (const [filePath, info] of newEntries) { for (const [filePath, info] of newEntries) {
this.syncs.set(filePath, info); this.syncs.set(filePath, info);
} }
} };
renderEntryIcon(entry: Entry) { renderEntryIcon(entry: Entry) {
switch (entry.info.type) { switch (entry.info.type) {
@ -188,27 +173,30 @@ export class KubeconfigSyncs extends React.Component {
if (isWindows) { if (isWindows) {
return ( return (
<div className="flex gaps align-center"> <div className="flex gaps align-center">
<Button <PathPicker
primary
label="Sync file(s)" label="Sync file(s)"
className="box grow" className="box grow"
onClick={() => void this.openDialog("Sync file(s)", ["openFile"])} onPick={this.onPick}
buttonLabel="Sync"
properties={["showHiddenFiles", "multiSelections", "openFile"]}
/> />
<Button <PathPicker
primary
label="Sync folder(s)" label="Sync folder(s)"
className="box grow" className="box grow"
onClick={() => void this.openDialog("Sync folder(s)", ["openDirectory"])} onPick={this.onPick}
buttonLabel="Sync"
properties={["showHiddenFiles", "multiSelections", "openDirectory"]}
/> />
</div> </div>
); );
} }
return ( return (
<Button <PathPicker
primary
label="Sync file(s) and folder(s)" label="Sync file(s) and folder(s)"
onClick={() => void this.openDialog("Sync file(s) and folder(s)", ["openFile", "openDirectory"])} onPick={this.onPick}
buttonLabel="Sync"
properties={["showHiddenFiles", "multiSelections", "openFile", "openDirectory"]}
/> />
); );
} }
@ -216,14 +204,8 @@ export class KubeconfigSyncs extends React.Component {
render() { render() {
return ( return (
<> <>
<section className="small"> {this.renderSyncButtons()}
<SubTitle title="Files and Folders to sync" /> {this.renderEntries()}
{this.renderSyncButtons()}
<div className="hint">
Sync an individual file or all files in a folder (non-recursive).
</div>
{this.renderEntries()}
</section>
</> </>
); );
} }

View File

@ -1,111 +0,0 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import React, { useState } from "react";
import { Input, InputValidators } from "../input";
import { SubTitle } from "../layout/sub-title";
import { getDefaultKubectlPath, UserStore } from "../../../common/user-store";
import { observer } from "mobx-react";
import { bundledKubectlPath } from "../../../main/kubectl";
import { SelectOption, Select } from "../select";
import { FormSwitch, Switcher } from "../switch";
export const KubectlBinaries = observer(() => {
const userStore = UserStore.getInstance();
const [downloadPath, setDownloadPath] = useState(userStore.downloadBinariesPath || "");
const [binariesPath, setBinariesPath] = useState(userStore.kubectlBinariesPath || "");
const pathValidator = downloadPath ? InputValidators.isPath : undefined;
const downloadMirrorOptions: SelectOption<string>[] = [
{ value: "default", label: "Default (Google)" },
{ value: "china", label: "China (Azure)" },
];
const save = () => {
userStore.downloadBinariesPath = downloadPath;
userStore.kubectlBinariesPath = binariesPath;
};
return (
<>
<section className="small">
<SubTitle title="Kubectl binary download"/>
<FormSwitch
control={
<Switcher
checked={userStore.downloadKubectlBinaries}
onChange={v => userStore.downloadKubectlBinaries = v.target.checked}
name="kubectl-download"
/>
}
label="Download kubectl binaries matching the Kubernetes cluster version"
/>
</section>
<hr className="small"/>
<section className="small">
<SubTitle title="Download mirror" />
<Select
placeholder="Download mirror for kubectl"
options={downloadMirrorOptions}
value={userStore.downloadMirror}
onChange={({ value }: SelectOption) => userStore.downloadMirror = value}
disabled={!userStore.downloadKubectlBinaries}
themeName="lens"
/>
</section>
<hr className="small"/>
<section className="small">
<SubTitle title="Directory for binaries" />
<Input
theme="round-black"
value={userStore.downloadBinariesPath}
placeholder={getDefaultKubectlPath()}
validators={pathValidator}
onChange={setDownloadPath}
onBlur={save}
disabled={!userStore.downloadKubectlBinaries}
/>
<div className="hint">
The directory to download binaries into.
</div>
</section>
<hr className="small"/>
<section className="small">
<SubTitle title="Path to kubectl binary" />
<Input
theme="round-black"
placeholder={bundledKubectlPath()}
value={binariesPath}
validators={pathValidator}
onChange={setBinariesPath}
onBlur={save}
disabled={userStore.downloadKubectlBinaries}
/>
</section>
</>
);
});

View File

@ -22,67 +22,39 @@
import "./preferences.scss"; import "./preferences.scss";
import React from "react"; import React from "react";
import moment from "moment-timezone"; import { observable, reaction, makeObservable } from "mobx";
import { computed, observable, reaction, makeObservable } from "mobx";
import { disposeOnUnmount, observer } from "mobx-react"; import { disposeOnUnmount, observer } from "mobx-react";
import { isWindows } from "../../../common/vars"; import { AppPreferenceKind, AppPreferenceKindRegistry, AppPreferenceRegistry, RegisteredAppPreference } from "../../../extensions/registries/app-preference-registry";
import { AppPreferenceRegistry, RegisteredAppPreference } from "../../../extensions/registries/app-preference-registry";
import { UserStore } from "../../../common/user-store";
import { ThemeStore } from "../../theme.store";
import { Input } from "../input";
import { SubTitle } from "../layout/sub-title"; import { SubTitle } from "../layout/sub-title";
import { Select, SelectOption } from "../select";
import { HelmCharts } from "./helm-charts";
import { KubectlBinaries } from "./kubectl-binaries";
import { navigation } from "../../navigation"; import { navigation } from "../../navigation";
import { Tab, Tabs } from "../tabs"; import { Tab, Tabs } from "../tabs";
import { FormSwitch, Switcher } from "../switch";
import { KubeconfigSyncs } from "./kubeconfig-syncs";
import { SettingLayout } from "../layout/setting-layout"; import { SettingLayout } from "../layout/setting-layout";
import { Checkbox } from "../checkbox"; import { iter } from "../../utils";
import { sentryDsn } from "../../../common/vars";
enum Pages {
Application = "application",
Proxy = "proxy",
Kubernetes = "kubernetes",
Telemetry = "telemetry",
Extensions = "extensions",
Other = "other"
}
@observer @observer
export class Preferences extends React.Component { export class Preferences extends React.Component {
@observable httpProxy = UserStore.getInstance().httpsProxy || ""; @observable activeTab: string = AppPreferenceKind.Application;
@observable shell = UserStore.getInstance().shell || "";
@observable activeTab = Pages.Application;
constructor(props: {}) { constructor(props: {}) {
super(props); super(props);
makeObservable(this); makeObservable(this);
} }
@computed get themeOptions(): SelectOption<string>[] {
return ThemeStore.getInstance().themes.map(theme => ({
label: theme.name,
value: theme.id,
}));
}
timezoneOptions: SelectOption<string>[] = moment.tz.names().map(zone => ({
label: zone,
value: zone,
}));
componentDidMount() { componentDidMount() {
disposeOnUnmount(this, [ disposeOnUnmount(this, [
reaction(() => navigation.location.hash, hash => { reaction(() => navigation.location.hash, hash => {
const fragment = hash.slice(1); // hash is /^(#\w.)?$/ const fragment = hash.slice(1); // hash is /^(#\w.)?$/
if (fragment) { if (fragment.length > 0) {
// ignore empty fragments const settingsItem = AppPreferenceRegistry.getInstance().getItems().find(item => item.id === fragment);
document.getElementById(fragment)?.scrollIntoView();
if (settingsItem) {
this.activeTab = settingsItem.showInPreferencesTab;
setTimeout(() => {
document.getElementById(fragment)?.scrollIntoView();
}, 150);
}
} }
}, { }, {
fireImmediately: true fireImmediately: true
@ -90,206 +62,76 @@ export class Preferences extends React.Component {
]); ]);
} }
onTabChange = (tabId: Pages) => { onTabChange = (tabId: string) => {
this.activeTab = tabId; this.activeTab = tabId;
}; };
renderNavigation() { renderNavigation() {
const extensions = AppPreferenceRegistry.getInstance().getItems().filter(e => !e.showInPreferencesTab); const activeTabs = new Set(
iter.map(
iter.filter(
AppPreferenceRegistry.getInstance().getItems(),
item => !item.hide,
),
item => item.showInPreferencesTab
)
);
const tabs = AppPreferenceKindRegistry.getInstance().getItems();
return ( return (
<Tabs className="flex column" scrollable={false} onChange={this.onTabChange} value={this.activeTab}> <Tabs className="flex column" scrollable={false} onChange={this.onTabChange} value={this.activeTab}>
<div className="header">Preferences</div> <div className="header">Preferences</div>
<Tab value={Pages.Application} label="Application" data-testid="application-tab"/> {
<Tab value={Pages.Proxy} label="Proxy" data-testid="proxy-tab"/> ...tabs
<Tab value={Pages.Kubernetes} label="Kubernetes" data-testid="kube-tab"/> .filter(tab => activeTabs.has(tab.id))
<Tab value={Pages.Telemetry} label="Telemetry" data-testid="telemetry-tab"/> .map(tab => <Tab key={tab.id} value={tab.id} label={tab.title} data-testid={`${tab.id}-tab`} />)
{extensions.length > 0 &&
<Tab value={Pages.Extensions} label="Extensions" data-testid="extensions-tab"/>
} }
</Tabs> </Tabs>
); );
} }
renderExtension({ title, id, components: { Hint, Input } }: RegisteredAppPreference) { renderActiveTab() {
const entries = AppPreferenceRegistry.getInstance().getItems().filter(item => (
item.showInPreferencesTab === this.activeTab
&& !item.hide
));
const tab = AppPreferenceKindRegistry.getInstance().getItems().find(item => item.id === this.activeTab);
return (
<section id={tab.id}>
<h2 data-testid={`${tab.id}-header`}>{tab.title}</h2>
{...entries.map(this.renderPreference)}
</section>
);
}
renderPreference = ({ title, id, components: { Hint, Input } }: RegisteredAppPreference) => {
return ( return (
<React.Fragment key={id}> <React.Fragment key={id}>
<section id={id} className="small"> <section id={id} className="small">
<SubTitle title={title}/> <SubTitle title={title}/>
<Input/> <Input/>
<div className="hint"> {
<Hint/> Hint && (
</div> <small className="hint">
<Hint/>
</small>
)
}
</section> </section>
<hr className="small"/> <hr className="small"/>
</React.Fragment> </React.Fragment>
); );
} };
render() { render() {
const extensions = AppPreferenceRegistry.getInstance().getItems();
const telemetryExtensions = extensions.filter(e => e.showInPreferencesTab == Pages.Telemetry);
const defaultShell = process.env.SHELL
|| process.env.PTYSHELL
|| (
isWindows
? "powershell.exe"
: "System default shell"
);
return ( return (
<SettingLayout <SettingLayout
navigation={this.renderNavigation()} navigation={this.renderNavigation()}
className="Preferences" className="Preferences"
contentGaps={false} contentGaps={false}
> >
{this.activeTab == Pages.Application && ( {this.renderActiveTab()}
<section id="application">
<h2 data-testid="application-header">Application</h2>
<section id="appearance">
<SubTitle title="Theme"/>
<Select
options={this.themeOptions}
value={UserStore.getInstance().colorTheme}
onChange={({ value }: SelectOption) => UserStore.getInstance().colorTheme = value}
themeName="lens"
/>
</section>
<hr/>
<section id="shell">
<SubTitle title="Terminal Shell Path"/>
<Input
theme="round-black"
placeholder={defaultShell}
value={this.shell}
onChange={v => this.shell = v}
onBlur={() => UserStore.getInstance().shell = this.shell}
/>
</section>
<hr/>
<section id="other">
<SubTitle title="Start-up"/>
<FormSwitch
control={
<Switcher
checked={UserStore.getInstance().openAtLogin}
onChange={v => UserStore.getInstance().openAtLogin = v.target.checked}
name="startup"
/>
}
label="Automatically start Lens on login"
/>
</section>
<hr />
<section id="locale">
<SubTitle title="Locale Timezone" />
<Select
options={this.timezoneOptions}
value={UserStore.getInstance().localeTimezone}
onChange={({ value }: SelectOption) => UserStore.getInstance().setLocaleTimezone(value)}
themeName="lens"
/>
</section>
</section>
)}
{this.activeTab == Pages.Proxy && (
<section id="proxy">
<section>
<h2 data-testid="proxy-header">Proxy</h2>
<SubTitle title="HTTP Proxy"/>
<Input
theme="round-black"
placeholder="Type HTTP proxy url (example: http://proxy.acme.org:8080)"
value={this.httpProxy}
onChange={v => this.httpProxy = v}
onBlur={() => UserStore.getInstance().httpsProxy = this.httpProxy}
/>
<small className="hint">
Proxy is used only for non-cluster communication.
</small>
</section>
<hr className="small"/>
<section className="small">
<SubTitle title="Certificate Trust"/>
<FormSwitch
control={
<Switcher
checked={UserStore.getInstance().allowUntrustedCAs}
onChange={v => UserStore.getInstance().allowUntrustedCAs = v.target.checked}
name="startup"
/>
}
label="Allow untrusted Certificate Authorities"
/>
<small className="hint">
This will make Lens to trust ANY certificate authority without any validations.{" "}
Needed with some corporate proxies that do certificate re-writing.{" "}
Does not affect cluster communications!
</small>
</section>
</section>
)}
{this.activeTab == Pages.Kubernetes && (
<section id="kubernetes">
<section id="kubectl">
<h2 data-testid="kubernetes-header">Kubernetes</h2>
<KubectlBinaries />
</section>
<hr/>
<section id="kube-sync">
<h2 data-testid="kubernetes-sync-header">Kubeconfig Syncs</h2>
<KubeconfigSyncs />
</section>
<hr/>
<section id="helm">
<h2>Helm Charts</h2>
<HelmCharts/>
</section>
</section>
)}
{this.activeTab == Pages.Telemetry && (
<section id="telemetry">
<h2 data-testid="telemetry-header">Telemetry</h2>
{telemetryExtensions.map(this.renderExtension)}
{sentryDsn ? (
<React.Fragment key='sentry'>
<section id='sentry' className="small">
<SubTitle title='Automatic Error Reporting' />
<Checkbox
label="Allow automatic error reporting"
value={UserStore.getInstance().allowErrorReporting}
onChange={value => {
UserStore.getInstance().allowErrorReporting = value;
}}
/>
<div className="hint">
<span>
Automatic error reports provide vital information about issues and application crashes.
It is highly recommended to keep this feature enabled to ensure fast turnaround for issues you might encounter.
</span>
</div>
</section>
<hr className="small" />
</React.Fragment>) :
// we don't need to shows the checkbox at all if Sentry dsn is not a valid url
null
}
</section>
)}
{this.activeTab == Pages.Extensions && (
<section id="extensions">
<h2>Extensions</h2>
{extensions.filter(e => !e.showInPreferencesTab).map(this.renderExtension)}
</section>
)}
</SettingLayout> </SettingLayout>
); );
} }

View File

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

View File

@ -0,0 +1,71 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { FileFilter, OpenDialogOptions, remote } from "electron";
import { observer } from "mobx-react";
import React from "react";
import { cssNames } from "../../utils";
import { Button } from "../button";
export interface PathPickerProps {
className?: string;
label: string;
disabled?: boolean;
onPick?: (paths: string[]) => void;
onCancel?: () => void;
defaultPath?: string;
buttonLabel?: string;
filters?: FileFilter[];
properties?: OpenDialogOptions["properties"];
securityScopedBookmarks?: boolean;
}
@observer
export class PathPicker extends React.Component<PathPickerProps> {
async onClick() {
const { onPick, onCancel, label, className, disabled, ...dialogOptions } = this.props;
const { dialog, BrowserWindow } = remote;
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
message: label,
...dialogOptions,
});
if (canceled) {
onCancel?.();
} else {
onPick?.(filePaths);
}
}
render() {
const { className, label, disabled } = this.props;
return (
<Button
primary
label={label}
disabled={disabled}
className={cssNames("PathPicker", className)}
onClick={() => void this.onClick()}
/>
);
}
}

View File

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

View File

@ -0,0 +1,301 @@
/**
* Copyright (c) 2021 OpenLens Authors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { observer } from "mobx-react";
import moment from "moment";
import React from "react";
import { getDefaultKubectlDownloadPath, UserStore } from "../../common/user-store";
import { isWindows, sentryDsn } from "../../common/vars";
import { AppPreferenceKind, AppPreferenceRegistry } from "../../extensions/registries";
import { bundledKubectlPath, downloadMirrorOptions } from "../../main/kubectl";
import { HelmCharts } from "../components/+preferences/helm-charts";
import { KubeconfigSyncs } from "../components/+preferences/kubeconfig-syncs";
import { Button } from "../components/button";
import { Checkbox } from "../components/checkbox";
import { Input } from "../components/input";
import { PathPicker } from "../components/path-picker";
import { Select, SelectOption } from "../components/select";
import { FormSwitch, Switcher } from "../components/switch";
import { ThemeStore } from "../theme.store";
export function initAppPreferenceRegistry() {
AppPreferenceRegistry.getInstance()
.add([
{
id: "appearance",
title: "Theme",
components: {
Input: observer(() => (
<Select
options={ThemeStore.getInstance().themeOptions}
value={UserStore.getInstance().colorTheme}
onChange={({ value }: SelectOption<string>) => UserStore.getInstance().colorTheme = value}
themeName="lens"
/>
)),
},
showInPreferencesTab: AppPreferenceKind.Application,
},
{
id: "shell",
title: "Terminal Shell Path",
components: {
Input: observer(() => (
<Input
theme="round-black"
placeholder={(
process.env.SHELL
|| process.env.PTYSHELL
|| (
isWindows
? "powershell.exe"
: "System default shell"
)
)}
onBlur={(evt) => UserStore.getInstance().shell = evt.target.innerText}
/>
)),
},
showInPreferencesTab: AppPreferenceKind.Application,
},
{
id: "start-up",
title: "Start-up",
components: {
Input: observer(() => (
<FormSwitch
control={
<Switcher
checked={UserStore.getInstance().openAtLogin}
onChange={v => UserStore.getInstance().openAtLogin = v.target.checked}
name="startup"
/>
}
label="Automatically start Lens on login"
/>
)),
},
showInPreferencesTab: AppPreferenceKind.Application,
},
{
id: "locale",
title: "Locale Timezone",
components: {
Input: observer(() => (
<Select
options={moment.tz.names()}
value={UserStore.getInstance().localeTimezone}
onChange={({ value }: SelectOption) => UserStore.getInstance().setLocaleTimezone(value)}
themeName="lens"
/>
)),
},
showInPreferencesTab: AppPreferenceKind.Application,
},
{
id: "http-proxy",
title: "HTTP Proxy",
components: {
Input: observer(() => (
<Input
theme="round-black"
placeholder="Type HTTP proxy url (example: http://proxy.acme.org:8080)"
onBlur={(evt) => UserStore.getInstance().httpsProxy = evt.target.innerText}
/>
)),
Hint: () => <>Proxy is used only for non-cluster communication.</>,
},
showInPreferencesTab: AppPreferenceKind.Proxy,
},
{
id: "certificate-trust",
title: "Certificate Trust",
components: {
Input: observer(() => (
<FormSwitch
control={
<Switcher
checked={UserStore.getInstance().allowUntrustedCAs}
onChange={v => UserStore.getInstance().allowUntrustedCAs = v.target.checked}
name="startup"
/>
}
label="Allow untrusted Certificate Authorities"
/>
)),
Hint: () => (
<>
This will make Lens to trust ANY certificate authority without any validations.{" "}
Needed with some corporate proxies that do certificate re-writing.{" "}
Does not affect cluster communications!
</>
),
},
showInPreferencesTab: AppPreferenceKind.Proxy,
},
{
id: "kubectl-binary-download",
title: "Kubectl binary download",
components: {
Input: observer(() => (
<FormSwitch
control={
<Switcher
checked={UserStore.getInstance().downloadKubectlBinaries}
onChange={v => UserStore.getInstance().downloadKubectlBinaries = v.target.checked}
name="kubectl-download"
/>
}
label="Download kubectl binaries matching the Kubernetes cluster version"
/>
)),
},
showInPreferencesTab: AppPreferenceKind.Kubernetes,
},
{
id: "kubectl-download-mirror",
title: "Kubectl Download mirror",
components: {
Input: observer(() => (
<Select
placeholder="Download mirror for kubectl"
options={downloadMirrorOptions}
value={UserStore.getInstance().downloadMirror}
onChange={({ value }: SelectOption) => UserStore.getInstance().downloadMirror = value}
disabled={!UserStore.getInstance().downloadKubectlBinaries}
themeName="lens"
/>
)),
},
showInPreferencesTab: AppPreferenceKind.Kubernetes,
},
{
id: "kubectl-download-directory",
title: "Directory for Kubectl binaries",
components: {
Input: observer(() => (
<>
<p>Current Directory:</p>
<code className="overflow-x-scroll whitespace-nowrap">
{UserStore.getInstance().downloadBinariesPath || getDefaultKubectlDownloadPath()}
</code>
<div className="flex gaps align-center">
<PathPicker
className="box grow"
label="Select target directory"
onPick={([dirPath]) => UserStore.getInstance().downloadBinariesPath = dirPath}
buttonLabel="Select"
properties={["showHiddenFiles", "openDirectory"]}
disabled={!UserStore.getInstance().downloadKubectlBinaries}
/>
<Button
className="box"
accent
label="Clear"
onClick={() => UserStore.getInstance().downloadBinariesPath = undefined}
disabled={!UserStore.getInstance().downloadKubectlBinaries || !UserStore.getInstance().downloadBinariesPath}
/>
</div>
</>
)),
Hint: () => <>The directory to download binaries into.</>,
},
showInPreferencesTab: AppPreferenceKind.Kubernetes,
},
{
id: "kubectl-path",
title: "Path to kubectl binary",
components: {
Input: observer(() => (
<>
<p>Current kubectl path:</p>
<code className="overflow-x-scroll whitespace-nowrap">
{UserStore.getInstance().kubectlBinariesPath || bundledKubectlPath()}
</code>
<label>{UserStore.getInstance().kubectlBinariesPath}</label>
<div className="flex gaps align-center">
<PathPicker
className="box grow"
label="Select kubectl binary"
onPick={([binPath]) => UserStore.getInstance().kubectlBinariesPath = binPath}
buttonLabel="Select"
properties={["showHiddenFiles", "openFile"]}
disabled={UserStore.getInstance().downloadKubectlBinaries}
/>
<Button
className="box"
accent
label="Clear"
onClick={() => UserStore.getInstance().kubectlBinariesPath = undefined}
disabled={UserStore.getInstance().downloadKubectlBinaries || !UserStore.getInstance().kubectlBinariesPath}
/>
</div>
</>
)),
},
showInPreferencesTab: AppPreferenceKind.Kubernetes,
},
{
id: "kube-sync",
title: "Kubeconfig Syncs",
components: {
Input: KubeconfigSyncs,
Hint: () => (
<>
Sync an individual file or all files in a folder (non-recursive).
</>
)
},
showInPreferencesTab: AppPreferenceKind.Kubernetes,
},
{
id: "helm",
title: "Helm Charts",
components: {
Input: HelmCharts,
},
showInPreferencesTab: AppPreferenceKind.Kubernetes,
},
{
id: "sentry",
title: "Automatic Error Reporting",
components: {
Input: () => (
<Checkbox
label="Allow automatic error reporting"
value={UserStore.getInstance().allowErrorReporting}
onChange={value => {
UserStore.getInstance().allowErrorReporting = value;
}}
/>
),
Hint: () => (
<>
Automatic error reports provide vital information about issues and application crashes.{" "}
It is highly recommended to keep this feature enabled to ensure fast turnaround for issues you might encounter.
</>
)
},
showInPreferencesTab: AppPreferenceKind.Telemetry,
hide: Boolean(sentryDsn),
}
]);
}

View File

@ -19,6 +19,8 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
export * from "./app-preferences-kind-registry";
export * from "./app-preferences-registry";
export * from "./catalog-entity-detail-registry"; export * from "./catalog-entity-detail-registry";
export * from "./catalog"; export * from "./catalog";
export * from "./command-registry"; export * from "./command-registry";

View File

@ -23,6 +23,7 @@ import * as registries from "../../extensions/registries";
export function initRegistries() { export function initRegistries() {
registries.AppPreferenceRegistry.createInstance(); registries.AppPreferenceRegistry.createInstance();
registries.AppPreferenceKindRegistry.createInstance();
registries.CatalogEntityDetailRegistry.createInstance(); registries.CatalogEntityDetailRegistry.createInstance();
registries.ClusterPageMenuRegistry.createInstance(); registries.ClusterPageMenuRegistry.createInstance();
registries.ClusterPageRegistry.createInstance(); registries.ClusterPageRegistry.createInstance();

View File

@ -25,6 +25,7 @@ import { UserStore } from "../common/user-store";
import logger from "../main/logger"; import logger from "../main/logger";
import darkTheme from "./themes/lens-dark.json"; import darkTheme from "./themes/lens-dark.json";
import lightTheme from "./themes/lens-light.json"; import lightTheme from "./themes/lens-light.json";
import type { SelectOption } from "./components/select";
export type ThemeId = string; export type ThemeId = string;
@ -67,6 +68,13 @@ export class ThemeStore extends Singleton {
return this.allThemes.get(this.activeThemeId) ?? this.allThemes.get("lens-dark"); return this.allThemes.get(this.activeThemeId) ?? this.allThemes.get("lens-dark");
} }
@computed get themeOptions(): SelectOption<string>[] {
return this.themes.map(theme => ({
label: theme.name,
value: theme.id,
}));
}
constructor() { constructor() {
super(); super();