diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b319c97770..fcf548f795 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,7 +2,7 @@ name: Test on: pull_request: branches: - - "*" + - "**" push: branches: - master diff --git a/integration/__tests__/cluster-pages.tests.ts b/integration/__tests__/cluster-pages.tests.ts index 8dae67dcaf..be032afab3 100644 --- a/integration/__tests__/cluster-pages.tests.ts +++ b/integration/__tests__/cluster-pages.tests.ts @@ -36,7 +36,7 @@ function getSidebarSelectors(itemId: string) { return { expandSubMenu: `${root} .nav-item`, - subMenuLink: (href: string) => `.Sidebar .sub-menu a[href^="/${href}"]`, + subMenuLink: (href: string) => `[data-testid=cluster-sidebar] .sub-menu a[href^="/${href}"]`, }; } diff --git a/integration/helpers/utils.ts b/integration/helpers/utils.ts index f0e82809fb..f9a10e1d4d 100644 --- a/integration/helpers/utils.ts +++ b/integration/helpers/utils.ts @@ -113,7 +113,7 @@ export async function lauchMinikubeClusterFromCatalog(window: Page): Promise { + describe("scale", () => { + const requestMock = { + patch: () => ({}), + } as unknown as KubeJsonApi; + + const sub = new DeploymentApiTest({ objectConstructor: Deployment }); + + sub.setRequest(requestMock); + + it("requests Kubernetes API with PATCH verb and correct amount of replicas", () => { + const patchSpy = jest.spyOn(requestMock, "patch"); + + sub.scale({ namespace: "default", name: "deployment-1"}, 5); + + expect(patchSpy).toHaveBeenCalledWith("/apis/apps/v1/namespaces/default/deployments/deployment-1/scale", { + data: { + spec: { + replicas: 5 + } + } + }, + { + headers: { + "content-type": "application/merge-patch+json" + } + }); + }); + }); +}); diff --git a/src/common/k8s-api/__tests__/stateful-set.api.test.ts b/src/common/k8s-api/__tests__/stateful-set.api.test.ts new file mode 100644 index 0000000000..d1b90be181 --- /dev/null +++ b/src/common/k8s-api/__tests__/stateful-set.api.test.ts @@ -0,0 +1,60 @@ +/** + * 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 { StatefulSet, StatefulSetApi } from "../endpoints/stateful-set.api"; +import type { KubeJsonApi } from "../kube-json-api"; + +class StatefulSetApiTest extends StatefulSetApi { + public setRequest(request: any) { + this.request = request; + } +} + +describe("StatefulSetApi", () => { + describe("scale", () => { + const requestMock = { + patch: () => ({}), + } as unknown as KubeJsonApi; + + const sub = new StatefulSetApiTest({ objectConstructor: StatefulSet }); + + sub.setRequest(requestMock); + + it("requests Kubernetes API with PATCH verb and correct amount of replicas", () => { + const patchSpy = jest.spyOn(requestMock, "patch"); + + sub.scale({ namespace: "default", name: "statefulset-1"}, 5); + + expect(patchSpy).toHaveBeenCalledWith("/apis/apps/v1/namespaces/default/statefulsets/statefulset-1/scale", { + data: { + spec: { + replicas: 5 + } + } + }, + { + headers: { + "content-type": "application/merge-patch+json" + } + }); + }); + }); +}); diff --git a/src/common/k8s-api/endpoints/deployment.api.ts b/src/common/k8s-api/endpoints/deployment.api.ts index dd364ab6c2..721db669b7 100644 --- a/src/common/k8s-api/endpoints/deployment.api.ts +++ b/src/common/k8s-api/endpoints/deployment.api.ts @@ -41,13 +41,17 @@ export class DeploymentApi extends KubeApi { } scale(params: { namespace: string; name: string }, replicas: number) { - return this.request.put(this.getScaleApiUrl(params), { + return this.request.patch(this.getScaleApiUrl(params), { data: { - metadata: params, spec: { replicas } } + }, + { + headers: { + "content-type": "application/merge-patch+json" + } }); } diff --git a/src/common/k8s-api/endpoints/stateful-set.api.ts b/src/common/k8s-api/endpoints/stateful-set.api.ts index 8328ef9bfd..c8f742d7cd 100644 --- a/src/common/k8s-api/endpoints/stateful-set.api.ts +++ b/src/common/k8s-api/endpoints/stateful-set.api.ts @@ -39,13 +39,17 @@ export class StatefulSetApi extends KubeApi { } scale(params: { namespace: string; name: string }, replicas: number) { - return this.request.put(this.getScaleApiUrl(params), { + return this.request.patch(this.getScaleApiUrl(params), { data: { - metadata: params, spec: { replicas } } + }, + { + headers: { + "content-type": "application/merge-patch+json" + } }); } } diff --git a/src/common/kube-helpers.ts b/src/common/kube-helpers.ts index aff05e68c6..4bd420e7b2 100644 --- a/src/common/kube-helpers.ts +++ b/src/common/kube-helpers.ts @@ -25,11 +25,11 @@ import path from "path"; import os from "os"; import yaml from "js-yaml"; import logger from "../main/logger"; -import commandExists from "command-exists"; import { ExecValidationNotFoundError } from "./custom-errors"; import { Cluster, Context, newClusters, newContexts, newUsers, User } from "@kubernetes/client-node/dist/config_types"; import { resolvePath } from "./utils"; import Joi from "joi"; +import which from "which"; export type KubeConfigValidationOpts = { validateCluster?: boolean; @@ -295,13 +295,17 @@ export function validateKubeConfig(config: KubeConfig, contextName: string, vali // Validate exec command if present if (validateExec && user?.exec) { - const execCommand = user.exec["command"]; - // check if the command is absolute or not - const isAbsolute = path.isAbsolute(execCommand); + try { + which.sync(user.exec.command); - // validate the exec struct in the user object, start with the command field - if (!commandExists.sync(execCommand)) { - return new ExecValidationNotFoundError(execCommand, isAbsolute); + // If this doesn't throw an error it also means that it has found the executable. + } catch (error) { + switch (error?.code) { + case "ENOENT": + return new ExecValidationNotFoundError(user.exec.command); + default: + return error; + } } } diff --git a/src/common/logger.ts b/src/common/logger.ts index 15506f7a46..825fa2493b 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -25,7 +25,6 @@ import type Transport from "winston-transport"; import { consoleFormat } from "winston-console-format"; import { isDebugging, isTestEnv } from "./vars"; import BrowserConsole from "winston-transport-browserconsole"; -import { SentryTransport } from "./logger-transports"; import { getPath } from "./utils/getPath"; const logLevel = process.env.LOG_LEVEL @@ -36,9 +35,7 @@ const logLevel = process.env.LOG_LEVEL ? "error" : "info"; -const transports: Transport[] = [ - new SentryTransport("error") -]; +const transports: Transport[] = []; if (ipcMain) { transports.push( diff --git a/src/common/sentry.ts b/src/common/sentry.ts index 06bef321d1..8c386c4499 100644 --- a/src/common/sentry.ts +++ b/src/common/sentry.ts @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import { CaptureConsole, Dedupe, Offline } from "@sentry/integrations"; +import { Dedupe, Offline } from "@sentry/integrations"; import * as Sentry from "@sentry/electron"; import { sentryDsn, isProduction } from "./vars"; import { UserStore } from "./user-store"; @@ -65,7 +65,6 @@ export function SentryInit() { }, dsn: sentryDsn, integrations: [ - new CaptureConsole({ levels: ["error"] }), new Dedupe(), new Offline(), ], diff --git a/src/main/cluster-manager.ts b/src/main/cluster-manager.ts index fdc3bf3e1a..307b4592f1 100644 --- a/src/main/cluster-manager.ts +++ b/src/main/cluster-manager.ts @@ -193,7 +193,10 @@ export class ClusterManager extends Singleton { } else { cluster.kubeConfigPath = entity.spec.kubeconfigPath; cluster.contextName = entity.spec.kubeconfigContext; - cluster.accessibleNamespaces = entity.spec.accessibleNamespaces ?? []; + + if (entity.spec.accessibleNamespace) { + cluster.accessibleNamespaces = entity.spec.accessibleNamespaces; + } this.updateEntityFromCluster(cluster); } diff --git a/src/main/kubectl.ts b/src/main/kubectl.ts index fbfd044dab..4695e8272e 100644 --- a/src/main/kubectl.ts +++ b/src/main/kubectl.ts @@ -275,7 +275,7 @@ export class Kubectl { return false; }); - isValid = !await this.checkBinary(this.path, false); + isValid = await this.checkBinary(this.path, false); } if (!isValid) { diff --git a/src/main/window-manager.ts b/src/main/window-manager.ts index b0014eac47..be6d71498e 100644 --- a/src/main/window-manager.ts +++ b/src/main/window-manager.ts @@ -82,7 +82,7 @@ export class WindowManager extends Singleton { show: false, minWidth: 700, // accommodate 800 x 600 display minimum minHeight: 500, // accommodate 800 x 600 display minimum - titleBarStyle: "hidden", + titleBarStyle: "hiddenInset", backgroundColor: "#1e2124", webPreferences: { preload: path.join(__static, "build", "preload.js"), diff --git a/src/renderer/api/terminal-api.ts b/src/renderer/api/terminal-api.ts index 347dfac224..59e248fba5 100644 --- a/src/renderer/api/terminal-api.ts +++ b/src/renderer/api/terminal-api.ts @@ -58,7 +58,6 @@ export class TerminalApi extends WebSocketApi { public onReady = new EventEmitter<[]>(); @observable public isReady = false; - @observable public shellRunCommandsFinished = false; public readonly url: string; constructor(protected options: TerminalApiQuery) { @@ -92,7 +91,6 @@ export class TerminalApi extends WebSocketApi { connect() { this.emitStatus("Connecting ..."); this.onData.addListener(this._onReady, { prepend: true }); - this.onData.addListener(this._onShellRunCommandsFinished); super.connect(this.url); } @@ -109,24 +107,6 @@ export class TerminalApi extends WebSocketApi { this.onReady.removeAllListeners(); } - _onShellRunCommandsFinished = (data: string) => { - if (!data) { - return; - } - - /** - * This is a heuistic for ditermining when a shell has finished executing - * its own rc file (or RunCommands file) such as `.bashrc` or `.zshrc`. - * - * This heuistic assumes that the prompt line of a terminal is a single line - * and ends with a whitespace character. - */ - if (data.match(/\r?\n/) === null && data.match(/\s$/)) { - this.shellRunCommandsFinished = true; - this.onData.removeListener(this._onShellRunCommandsFinished); - } - }; - @boundMethod protected _onReady(data: string) { if (!data) return true; diff --git a/src/renderer/components/+add-cluster/add-cluster.scss b/src/renderer/components/+add-cluster/add-cluster.scss index faac757c40..8abb38e9d0 100644 --- a/src/renderer/components/+add-cluster/add-cluster.scss +++ b/src/renderer/components/+add-cluster/add-cluster.scss @@ -50,4 +50,11 @@ display: block; padding-top: 6px; } + + .actions-panel { + .Spinner { + vertical-align: middle; + margin-left: $spacing; + } + } } diff --git a/src/renderer/components/+add-cluster/add-cluster.tsx b/src/renderer/components/+add-cluster/add-cluster.tsx index e987c1d4bb..16a6dec19e 100644 --- a/src/renderer/components/+add-cluster/add-cluster.tsx +++ b/src/renderer/components/+add-cluster/add-cluster.tsx @@ -24,7 +24,7 @@ import "./add-cluster.scss"; import type { KubeConfig } from "@kubernetes/client-node"; import fse from "fs-extra"; import { debounce } from "lodash"; -import { action, computed, observable, makeObservable } from "mobx"; +import { action, computed, observable, makeObservable, runInAction } from "mobx"; import { observer } from "mobx-react"; import path from "path"; import React from "react"; @@ -41,6 +41,7 @@ import { SettingLayout } from "../layout/setting-layout"; import MonacoEditor from "react-monaco-editor"; import { ThemeStore } from "../../theme.store"; import { UserStore } from "../../../common/user-store"; +import { Spinner } from "../spinner"; interface Option { config: KubeConfig; @@ -62,6 +63,7 @@ export class AddCluster extends React.Component { @observable kubeContexts = observable.map(); @observable customConfig = ""; @observable isWaiting = false; + @observable isCheckingInput = false; @observable errorText: string; constructor(props: {}) { @@ -80,14 +82,35 @@ export class AddCluster extends React.Component { ].filter(Boolean); } - @action - refreshContexts = debounce(() => { - const { config, error } = loadConfigFromString(this.customConfig.trim() || "{}"); + _refreshContexts = debounce(() => { + runInAction(() => { + try { + const text = this.customConfig.trim(); - this.kubeContexts.replace(getContexts(config)); - this.errorText = error?.toString(); + if (!text) { + return this.kubeContexts.clear(); + } + + const { config, error } = loadConfigFromString(text); + + this.kubeContexts.replace(getContexts(config)); + this.errorText = error?.toString(); + } catch (error) { + this.kubeContexts.clear(); + this.errorText = error?.toString() || "An error occured"; + } finally { + this.isCheckingInput = false; + } + }); }, 500); + refreshContexts = () => { + // Clear the kubeContexts immediately + this.isCheckingInput = true; + this.kubeContexts.clear(); + this._refreshContexts(); + }; + @action addClusters = async () => { this.isWaiting = true; @@ -145,6 +168,7 @@ export class AddCluster extends React.Component { tooltip={this.kubeContexts.size === 0 || "Paste in at least one cluster to add."} tooltipOverrideDisabled /> + {this.isCheckingInput && } ); diff --git a/src/renderer/components/+apps-helm-charts/helm-charts.tsx b/src/renderer/components/+apps-helm-charts/helm-charts.tsx index 8a47654adc..da5f9f8c5a 100644 --- a/src/renderer/components/+apps-helm-charts/helm-charts.tsx +++ b/src/renderer/components/+apps-helm-charts/helm-charts.tsx @@ -55,6 +55,14 @@ export class HelmCharts extends Component { return helmChartStore.getByName(chartName, repo); } + onDetails = (chart: HelmChart) => { + if (chart === this.selectedChart) { + this.hideDetails(); + } else { + this.showDetails(chart); + } + }; + showDetails = (chart: HelmChart) => { if (!chart) { navigation.push(helmChartsURL()); @@ -121,7 +129,7 @@ export class HelmCharts extends Component { { className: "menu" } ]} detailsItem={this.selectedChart} - onDetails={this.showDetails} + onDetails={this.onDetails} /> {this.selectedChart && ( { }); } + onDetails = (item: HelmRelease) => { + if (item === this.selectedRelease) { + this.hideDetails(); + } else { + this.showDetails(item); + } + }; + showDetails = (item: HelmRelease) => { navigation.push(releaseURL({ params: { @@ -169,7 +177,7 @@ export class HelmReleases extends Component { message: this.renderRemoveDialogMessage(selectedItems) })} detailsItem={this.selectedRelease} - onDetails={this.showDetails} + onDetails={this.onDetails} /> { } onDetails = (item: CatalogEntityItem) => { - this.catalogEntityStore.selectedItemId = item.getId(); + if (this.catalogEntityStore.selectedItemId === item.getId()) { + this.catalogEntityStore.selectedItemId = null; + } else { + this.catalogEntityStore.selectedItemId = item.getId(); + } }; onMenuItemClick(menuItem: CatalogEntityContextMenu) { @@ -245,28 +248,25 @@ export class Catalog extends React.Component { } return ( - <> - - -
- { this.renderList() } -
- { - this.catalogEntityStore.selectedItem - ? this.catalogEntityStore.selectedItemId = null} - /> - : ( - - - - ) - } -
- + +
+ { this.renderList() } +
+ { + this.catalogEntityStore.selectedItem + ? this.catalogEntityStore.selectedItemId = null} + /> + : ( + + + + ) + } +
); } } diff --git a/src/renderer/components/+cluster/cluster-issues.tsx b/src/renderer/components/+cluster/cluster-issues.tsx index 9b5f73b9a6..fb5b98bc28 100644 --- a/src/renderer/components/+cluster/cluster-issues.tsx +++ b/src/renderer/components/+cluster/cluster-issues.tsx @@ -33,7 +33,7 @@ import { boundMethod, cssNames, prevDefault } from "../../utils"; import type { ItemObject } from "../../../common/item.store"; import { Spinner } from "../spinner"; import { ThemeStore } from "../../theme.store"; -import { kubeSelectedUrlParam, showDetails } from "../kube-detail-params"; +import { kubeSelectedUrlParam, toggleDetails } from "../kube-detail-params"; import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; import { apiManager } from "../../../common/k8s-api/api-manager"; @@ -124,7 +124,7 @@ export class ClusterIssues extends React.Component { key={getId()} sortItem={warning} selected={selfLink === kubeSelectedUrlParam.get()} - onClick={prevDefault(() => showDetails(selfLink))} + onClick={prevDefault(() => toggleDetails(selfLink))} > {message} diff --git a/src/renderer/components/+welcome/__test__/welcome.test.tsx b/src/renderer/components/+welcome/__test__/welcome.test.tsx index c0d17faebe..496c69ef18 100644 --- a/src/renderer/components/+welcome/__test__/welcome.test.tsx +++ b/src/renderer/components/+welcome/__test__/welcome.test.tsx @@ -51,23 +51,6 @@ describe("", () => { WelcomeBannerRegistry.resetInstance(); }); - it("renders items in the top bar", async () => { - const testId = "testId"; - const text = "topBarItem"; - - TopBarRegistry.getInstance().getItems = jest.fn().mockImplementationOnce(() => [ - { - components: { - Item: () => {text} - } - } - ]); - - render(); - - expect(screen.getByTestId(testId)).toHaveTextContent(text); - }); - it("renders registered in WelcomeBannerRegistry and hide logo", async () => { const testId = "testId"; diff --git a/src/renderer/components/+welcome/welcome.tsx b/src/renderer/components/+welcome/welcome.tsx index f813c957ab..8c8e3f231a 100644 --- a/src/renderer/components/+welcome/welcome.tsx +++ b/src/renderer/components/+welcome/welcome.tsx @@ -27,7 +27,6 @@ import { Icon } from "../icon"; import { productName, slackUrl } from "../../../common/vars"; import { WelcomeMenuRegistry } from "../../../extensions/registries"; import { WelcomeBannerRegistry } from "../../../extensions/registries"; -import { TopBar } from "../layout/topbar"; export const defaultWidth = 320; @@ -48,56 +47,53 @@ export class Welcome extends React.Component { }, defaultWidth); return ( - <> - -
-
- {welcomeBanner.length > 0 ? ( - 1} - autoPlay={true} - navButtonsAlwaysInvisible={true} - indicatorIconButtonProps={{ - style: { - color: "var(--iconActiveBackground)" - } - }} - activeIndicatorIconButtonProps={{ - style: { - color: "var(--iconActiveColor)" - } - }} - interval={8000} - > - {welcomeBanner.map((item, index) => - - )} - - ) : } +
+
+ {welcomeBanner.length > 0 ? ( + 1} + autoPlay={true} + navButtonsAlwaysInvisible={true} + indicatorIconButtonProps={{ + style: { + color: "var(--iconActiveBackground)" + } + }} + activeIndicatorIconButtonProps={{ + style: { + color: "var(--iconActiveColor)" + } + }} + interval={8000} + > + {welcomeBanner.map((item, index) => + + )} + + ) : } -
-
-

Welcome to {productName} 5!

+
+
+

Welcome to {productName} 5!

-

- To get you started we have auto-detected your clusters in your kubeconfig file and added them to the catalog, your centralized view for managing all your cloud-native resources. -

- If you have any questions or feedback, please join our Lens Community slack channel. -

+

+ To get you started we have auto-detected your clusters in your kubeconfig file and added them to the catalog, your centralized view for managing all your cloud-native resources. +

+ If you have any questions or feedback, please join our Lens Community slack channel. +

- -
+
- +
); } } diff --git a/src/renderer/components/app.scss b/src/renderer/components/app.scss index e1d002c8f7..92b0a8b29d 100755 --- a/src/renderer/components/app.scss +++ b/src/renderer/components/app.scss @@ -39,7 +39,6 @@ --font-weight-normal: 400; --font-weight-bold: 500; --main-layout-header: 40px; - --drag-region-height: 22px; } *, *:before, *:after { @@ -105,7 +104,7 @@ html, body { left: 0; top: 0; width: 100%; - height: var(--drag-region-height); + height: var(--main-layout-header); z-index: 1000; pointer-events: none; } diff --git a/src/renderer/components/cluster-manager/cluster-manager.scss b/src/renderer/components/cluster-manager/cluster-manager.scss index e82c0d3aca..cc6a4a8e37 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.scss +++ b/src/renderer/components/cluster-manager/cluster-manager.scss @@ -21,10 +21,11 @@ .ClusterManager { --bottom-bar-height: 22px; + --hotbar-width: 75px; display: grid; grid-template-areas: - "menu topbar" + "topbar topbar" "menu main" "bottom-bar bottom-bar"; grid-template-rows: auto 1fr min-content; @@ -48,7 +49,7 @@ #lens-views { position: absolute; left: 0; - top: 40px; // Move below top bar + top: 0; right: 0; bottom: 0; display: flex; diff --git a/src/renderer/components/cluster-manager/cluster-manager.tsx b/src/renderer/components/cluster-manager/cluster-manager.tsx index 687490f94c..8e041ccc7f 100644 --- a/src/renderer/components/cluster-manager/cluster-manager.tsx +++ b/src/renderer/components/cluster-manager/cluster-manager.tsx @@ -39,6 +39,7 @@ import { DeleteClusterDialog } from "../delete-cluster-dialog"; import { reaction } from "mobx"; import { navigation } from "../../navigation"; import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync"; +import { TopBar } from "../layout/topbar"; @observer export class ClusterManager extends React.Component { @@ -51,6 +52,7 @@ export class ClusterManager extends React.Component { render() { return (
+
diff --git a/src/renderer/components/cluster-manager/cluster-view.tsx b/src/renderer/components/cluster-manager/cluster-view.tsx index 1c8a9435fc..db48e051c8 100644 --- a/src/renderer/components/cluster-manager/cluster-view.tsx +++ b/src/renderer/components/cluster-manager/cluster-view.tsx @@ -34,7 +34,6 @@ import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; import { navigate } from "../../navigation"; import { catalogURL, ClusterViewRouteParams } from "../../../common/routes"; import { previousActiveTab } from "../+catalog"; -import { TopBar } from "../layout/topbar"; interface Props extends RouteComponentProps { } @@ -105,7 +104,6 @@ export class ClusterView extends React.Component { render() { return (
- {this.renderStatus()}
); diff --git a/src/renderer/components/delete-cluster-dialog/save-config.ts b/src/renderer/components/delete-cluster-dialog/save-config.ts index d9aa03e2a5..536926a6b1 100644 --- a/src/renderer/components/delete-cluster-dialog/save-config.ts +++ b/src/renderer/components/delete-cluster-dialog/save-config.ts @@ -19,25 +19,18 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import type { KubeConfig } from "@kubernetes/client-node"; +import { dumpYaml, KubeConfig } from "@kubernetes/client-node"; import fs from "fs"; -import tempy from "tempy"; import * as lockFile from "proper-lockfile"; -import YAML from "json-to-pretty-yaml"; -import { noop } from "../../utils"; export async function saveKubeconfig(config: KubeConfig, path: string) { - const tmpFilePath = tempy.file(); - try { const release = await lockFile.lock(path); - const contents = YAML.stringify(JSON.parse(config.exportConfig())); + const contents = dumpYaml(JSON.parse(config.exportConfig())); - await fs.promises.writeFile(tmpFilePath, contents); - await fs.promises.rename(tmpFilePath, path); - release(); + await fs.promises.writeFile(path, contents); + await release(); } catch (e) { - await fs.unlink(tmpFilePath, noop); throw new Error(`Failed to acquire lock file.\n${e}`); } } diff --git a/src/renderer/components/dock/terminal.store.ts b/src/renderer/components/dock/terminal.store.ts index dd5251bac2..b5116a94e8 100644 --- a/src/renderer/components/dock/terminal.store.ts +++ b/src/renderer/components/dock/terminal.store.ts @@ -117,15 +117,18 @@ export class TerminalStore extends Singleton { await when(() => this.connections.has(tab.id)); - const rcIsFinished = when(() => this.connections.get(tab.id).shellRunCommandsFinished); + const shellIsReady = when(() => this.connections.get(tab.id).isReady); const notifyVeryLong = setTimeout(() => { - rcIsFinished.cancel(); - Notifications.info("Terminal shell is taking a long time to complete startup. Please check your .rc file. Bypassing shell completion check.", { - timeout: 4_000, - }); + shellIsReady.cancel(); + Notifications.info( + "If terminal shell is not ready please check your shell init files, if applicable.", + { + timeout: 4_000, + }, + ); }, 10_000); - await rcIsFinished.catch(noop); + await shellIsReady.catch(noop); clearTimeout(notifyVeryLong); } diff --git a/src/renderer/components/hotbar/hotbar-menu.scss b/src/renderer/components/hotbar/hotbar-menu.scss index 3b7300507a..8dbc60853a 100644 --- a/src/renderer/components/hotbar/hotbar-menu.scss +++ b/src/renderer/components/hotbar/hotbar-menu.scss @@ -25,13 +25,9 @@ position: relative; text-align: center; background: $clusterMenuBackground; - padding-top: 28px; - width: 75px; - - .is-mac &:before { - content: ""; - height: 4px; // extra spacing for mac-os "traffic-light" buttons - } + padding-top: 1px; + width: var(--hotbar-width); + overflow: hidden; .HotbarItems { --cellWidth: 40px; diff --git a/src/renderer/components/kube-detail-params/params.ts b/src/renderer/components/kube-detail-params/params.ts index 09256077d0..856f4968a6 100644 --- a/src/renderer/components/kube-detail-params/params.ts +++ b/src/renderer/components/kube-detail-params/params.ts @@ -41,6 +41,16 @@ export const kubeSelectedUrlParam = createPageParam({ } }); +export function toggleDetails(selfLink: string, resetSelected = true) { + const current = kubeSelectedUrlParam.get() === selfLink; + + if (current) { + hideDetails(); + } else { + showDetails(selfLink, resetSelected); + } +} + export function showDetails(selfLink = "", resetSelected = true) { const detailsUrl = getDetailsUrl(selfLink, resetSelected); diff --git a/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx b/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx index d55e30b28f..cefcc4b0f2 100644 --- a/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx +++ b/src/renderer/components/kube-object-list-layout/kube-object-list-layout.tsx @@ -31,7 +31,7 @@ import { kubeWatchApi } from "../../../common/k8s-api/kube-watch-api"; import { clusterContext } from "../context"; import { NamespaceSelectFilter } from "../+namespaces/namespace-select-filter"; import { ResourceKindMap, ResourceNames } from "../../utils/rbac"; -import { kubeSelectedUrlParam, showDetails } from "../kube-detail-params"; +import { kubeSelectedUrlParam, toggleDetails } from "../kube-detail-params"; export interface KubeObjectListLayoutProps extends ItemListLayoutProps { store: KubeObjectStore; @@ -39,7 +39,7 @@ export interface KubeObjectListLayoutProps extends ItemLis } const defaultProps: Partial> = { - onDetails: (item: KubeObject) => showDetails(item.selfLink), + onDetails: (item: KubeObject) => toggleDetails(item.selfLink), }; @observer diff --git a/src/renderer/components/layout/sidebar.module.css b/src/renderer/components/layout/sidebar.module.css new file mode 100644 index 0000000000..ad8f13cfa1 --- /dev/null +++ b/src/renderer/components/layout/sidebar.module.css @@ -0,0 +1,56 @@ +/** + * 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. + */ + +.sidebarNav { + @apply flex overflow-auto flex-col; + width: var(--sidebar-width); + padding-bottom: calc(var(--padding) * 3); + + /* Shadow above scrolling content from https://gist.github.com/distinctgrey/7548778 */ + background: + linear-gradient(var(--sidebarBackground) 30%, rgba(255,255,255,0)), + linear-gradient(rgba(255,255,255,0), var(--sidebarBackground) 70%) 0 100%, + radial-gradient(farthest-side at 50% 0, rgba(0,0,0,.2), rgba(0,0,0,0)), + radial-gradient(farthest-side at 50% 100%, rgba(0,0,0,.2), rgba(0,0,0,0)) 0 100%; + background-repeat: no-repeat; + background-size: 100% 40px, 100% 40px, 100% 12px, 100% 12px; + background-attachment: local, local, scroll, scroll; +} + +.sidebarNav :global(.Icon) { + box-sizing: content-box; + padding: 3px; + border-radius: 50%; +} + +.cluster { + @apply flex items-center m-5; +} + +.clusterName { + @apply font-bold overflow-hidden; + word-break: break-word; + color: var(--textColorAccent); + display: -webkit-box; + /* Simulate text-overflow:ellipsis styles but for multiple text lines */ + -webkit-line-clamp: 3; + -webkit-box-orient: vertical; +} \ No newline at end of file diff --git a/src/renderer/components/layout/sidebar.tsx b/src/renderer/components/layout/sidebar.tsx index 1f584d176f..92dd03f311 100644 --- a/src/renderer/components/layout/sidebar.tsx +++ b/src/renderer/components/layout/sidebar.tsx @@ -19,7 +19,7 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -import "./sidebar.scss"; +import styles from "./sidebar.module.css"; import type { TabLayoutRoute } from "./tab-layout"; import React from "react"; @@ -41,6 +41,7 @@ import { Apps } from "../+apps"; import * as routes from "../../../common/routes"; import { Config } from "../+config"; import { catalogEntityRegistry } from "../../api/catalog-entity-registry"; +import { HotbarIcon } from "../hotbar/hotbar-icon"; interface Props { className?: string; @@ -177,6 +178,29 @@ export class Sidebar extends React.Component { }); } + renderCluster() { + if (!this.clusterEntity) { + return null; + } + + const { metadata, spec } = this.clusterEntity; + + return ( +
+ +
+ {metadata.name} +
+
+ ); + } + get clusterEntity() { return catalogEntityRegistry.activeEntity; } @@ -185,13 +209,9 @@ export class Sidebar extends React.Component { const { className } = this.props; return ( -
- {this.clusterEntity && ( -
- {this.clusterEntity.metadata.name} -
- )} -
+
+ {this.renderCluster()} +
{ diff --git a/yarn.lock b/yarn.lock index 0f6ef99431..71ea43cd8d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2147,6 +2147,11 @@ anymatch "^3.0.0" source-map "^0.6.0" +"@types/which@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@types/which/-/which-2.0.1.tgz#27ecd67f915b7c3d6ba552135bb1eecd66e63501" + integrity sha512-Jjakcv8Roqtio6w1gr0D7y6twbhx6gGgFGF5BLwajPpnOIOxFkakFhCq+LmyyeAz7BX6ULrjBOxdKaCDy+4+dQ== + "@types/ws@^6.0.1": version "6.0.4" resolved "https://registry.yarnpkg.com/@types/ws/-/ws-6.0.4.tgz#7797707c8acce8f76d8c34b370d4645b70421ff1" @@ -4093,11 +4098,6 @@ combined-stream@^1.0.6, combined-stream@^1.0.8, combined-stream@~1.0.6: dependencies: delayed-stream "~1.0.0" -command-exists@1.2.9: - version "1.2.9" - resolved "https://registry.yarnpkg.com/command-exists/-/command-exists-1.2.9.tgz#c50725af3808c8ab0260fd60b01fbfa25b954f69" - integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== - commander@2.9.0: version "2.9.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.9.0.tgz#9c99094176e12240cb22d6c5146098400fe0f7d4" @@ -8704,14 +8704,6 @@ json-stringify-safe@^5.0.1, json-stringify-safe@~5.0.1: resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= -json-to-pretty-yaml@^1.2.2: - version "1.2.2" - resolved "https://registry.yarnpkg.com/json-to-pretty-yaml/-/json-to-pretty-yaml-1.2.2.tgz#f4cd0bd0a5e8fe1df25aaf5ba118b099fd992d5b" - integrity sha1-9M0L0KXo/h3yWq9boRiwmf2ZLVs= - dependencies: - remedial "^1.0.7" - remove-trailing-spaces "^1.0.6" - json3@^3.3.3: version "3.3.3" resolved "https://registry.yarnpkg.com/json3/-/json3-3.3.3.tgz#7fc10e375fc5ae42c4705a5cc0aa6f62be305b81" @@ -12306,21 +12298,11 @@ relateurl@^0.2.7: resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" integrity sha1-VNvzd+UUQKypCkzSdGANP/LYiKk= -remedial@^1.0.7: - version "1.0.8" - resolved "https://registry.yarnpkg.com/remedial/-/remedial-1.0.8.tgz#a5e4fd52a0e4956adbaf62da63a5a46a78c578a0" - integrity sha512-/62tYiOe6DzS5BqVsNpH/nkGlX45C/Sp6V+NtiN6JQNS1Viay7cWkazmRkrQrdFj2eshDe96SIQNIoMxqhzBOg== - remove-trailing-separator@^1.0.1: version "1.1.0" resolved "https://registry.yarnpkg.com/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz#c24bce2a283adad5bc3f58e0d48249b92379d8ef" integrity sha1-wkvOKig62tW8P1jg1IJJuSN52O8= -remove-trailing-spaces@^1.0.6: - version "1.0.8" - resolved "https://registry.yarnpkg.com/remove-trailing-spaces/-/remove-trailing-spaces-1.0.8.tgz#4354d22f3236374702f58ee373168f6d6887ada7" - integrity sha512-O3vsMYfWighyFbTd8hk8VaSj9UAGENxAtX+//ugIst2RMk5e03h6RoIS+0ylsFxY1gvmPuAY/PO4It+gPEeySA== - renderkid@^2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-2.0.3.tgz#380179c2ff5ae1365c522bf2fcfcff01c5b74149"