diff --git a/package.json b/package.json index 53d67fb862..eeda6f6c4c 100644 --- a/package.json +++ b/package.json @@ -225,7 +225,6 @@ "electron-devtools-installer": "^3.1.1", "electron-updater": "^4.3.1", "electron-window-state": "^5.0.3", - "file-type": "^14.7.1", "filenamify": "^4.1.0", "fs-extra": "^9.0.1", "handlebars": "^4.7.6", diff --git a/src/common/utils/downloadFile.ts b/src/common/utils/downloadFile.ts index a58e9242b4..4c65901d3d 100644 --- a/src/common/utils/downloadFile.ts +++ b/src/common/utils/downloadFile.ts @@ -3,6 +3,7 @@ import request from "request"; export interface DownloadFileOptions { url: string; gzip?: boolean; + timeout?: number; } export interface DownloadFileTicket { @@ -11,9 +12,9 @@ export interface DownloadFileTicket { cancel(): void; } -export function downloadFile({ url, gzip = true }: DownloadFileOptions): DownloadFileTicket { +export function downloadFile({ url, timeout, gzip = true }: DownloadFileOptions): DownloadFileTicket { const fileChunks: Buffer[] = []; - const req = request(url, { gzip }); + const req = request(url, { gzip, timeout }); const promise: Promise = new Promise((resolve, reject) => { req.on("data", (chunk: Buffer) => { fileChunks.push(chunk); diff --git a/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx b/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx index d43f571495..3887487816 100644 --- a/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx +++ b/src/renderer/components/+cluster-settings/components/cluster-proxy-setting.tsx @@ -2,8 +2,7 @@ import React from "react"; import { observable, autorun } from "mobx"; import { observer, disposeOnUnmount } from "mobx-react"; import { Cluster } from "../../../../main/cluster"; -import { Input } from "../../input"; -import { isUrl } from "../../input/input_validators"; +import { Input, InputValidators } from "../../input"; import { SubTitle } from "../../layout/sub-title"; interface Props { @@ -41,7 +40,7 @@ export class ClusterProxySetting extends React.Component { onChange={this.onChange} onBlur={this.save} placeholder="http://
:" - validators={isUrl} + validators={this.proxy ? InputValidators.isUrl : undefined} /> ); diff --git a/src/renderer/components/+extensions/extensions.scss b/src/renderer/components/+extensions/extensions.scss index d0716feda9..79f96c74af 100644 --- a/src/renderer/components/+extensions/extensions.scss +++ b/src/renderer/components/+extensions/extensions.scss @@ -1,61 +1,36 @@ -.Extensions { +.PageLayout.Extensions { $spacing: $padding * 2; - --width: 100%; - --max-width: auto; + --width: 50%; - .extensions-list { - .extension { - --flex-gap: $padding / 3; - padding: $padding $spacing; - background: $colorVague; - border-radius: $radius; + h2 { + margin-bottom: $padding; + } - &:not(:first-of-type) { - margin-top: $spacing; - } + .no-extensions { + --flex-gap: #{$padding}; + padding: $padding; + + code { + font-size: $font-size-small; } } - .extensions-info { + .install-extension { + margin: $spacing * 2 0; + } + + .installed-extensions { --flex-gap: #{$spacing}; - > .flex.gaps { - --flex-gap: #{$padding}; - } - } - - .extensions-path { - word-break: break-all; - - &:hover code { - color: $textColorSecondary; - cursor: pointer; - } - } - - .Clipboard { - display: inline; - vertical-align: baseline; - font-size: $font-size-small; - - &:hover { - color: $textColorSecondary; + .extension { + padding: $padding $spacing; + background: $layoutBackground; + border-radius: $radius; } } .SearchInput { - --spacing: #{$padding}; - width: 100%; - max-width: none; - } - - .WizardLayout { - padding: 0; - - .info-col { - flex: 0.6; - align-self: flex-start; - } + --spacing: 10px; } } diff --git a/src/renderer/components/+extensions/extensions.tsx b/src/renderer/components/+extensions/extensions.tsx index 8bcbc4cb8f..46e3a61ae4 100644 --- a/src/renderer/components/+extensions/extensions.tsx +++ b/src/renderer/components/+extensions/extensions.tsx @@ -9,8 +9,7 @@ import { observer } from "mobx-react"; import { t, Trans } from "@lingui/macro"; import { _i18n } from "../../i18n"; import { Button } from "../button"; -import { WizardLayout } from "../layout/wizard-layout"; -import { DropFileInput, Input, InputValidators, SearchInput } from "../input"; +import { DropFileInput, Input, InputValidator, InputValidators, SearchInput } from "../input"; import { Icon } from "../icon"; import { SubTitle } from "../layout/sub-title"; import { PageLayout } from "../layout/page-layout"; @@ -21,6 +20,8 @@ import { LensExtensionManifest, sanitizeExtensionName } from "../../../extension import { Notifications } from "../notifications"; import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils"; import { docsUrl } from "../../../common/vars"; +import { prevDefault } from "../../utils"; +import { TooltipPosition } from "../tooltip"; interface InstallRequest { fileName: string; @@ -40,8 +41,16 @@ interface InstallRequestValidated extends InstallRequestPreloaded { @observer export class Extensions extends React.Component { private supportedFormats = [".tar", ".tgz"]; + + private installPathValidator: InputValidator = { + message: Invalid URL or absolute path, + validate(value: string) { + return InputValidators.isUrl.validate(value) || InputValidators.isPath.validate(value); + } + }; + @observable search = ""; - @observable downloadUrl = ""; + @observable installPath = ""; @computed get extensions() { const searchText = this.search.toLowerCase(); @@ -87,25 +96,25 @@ export class Extensions extends React.Component { } }; - addExtensions = () => { - const { downloadUrl } = this; - if (downloadUrl && InputValidators.isUrl.validate(downloadUrl)) { - this.installFromUrl(downloadUrl); - } else { - this.installFromSelectFileDialog(); - } - }; - - installFromUrl = async (url: string) => { + installFromUrlOrPath = async () => { + const { installPath } = this; + if (!installPath) return; + const fileName = path.basename(installPath); try { - const { promise: filePromise } = downloadFile({ url }); - this.requestInstall([{ - fileName: path.basename(url), - data: await filePromise, - }]); + // install via url + // fixme: improve error messages for non-tar-file URLs + if (InputValidators.isUrl.validate(installPath)) { + const { promise: filePromise } = downloadFile({ url: installPath, timeout: 60000 /*1m*/ }); + const data = await filePromise; + this.requestInstall({ fileName, data }); + } + // otherwise installing from system path + else if (InputValidators.isPath.validate(installPath)) { + this.requestInstall({ fileName, filePath: installPath }); + } } catch (err) { Notifications.error( -

Installation via URL has failed: {String(err)}

+

Installation has failed: {String(err)}

); } }; @@ -198,7 +207,8 @@ export class Extensions extends React.Component { return validatedRequests; } - async requestInstall(requests: InstallRequest[]) { + async requestInstall(init: InstallRequest | InstallRequest[]) { + const requests = Array.isArray(init) ? init : [init]; const preloadedRequests = await this.preloadExtensions(requests); const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests); @@ -265,49 +275,16 @@ export class Extensions extends React.Component { } } - renderInfo() { - return ( -
-

Lens Extensions

-
- 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. - Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself. - Check out documentation to learn more. -
-
- - this.downloadUrl = v} - onSubmit={this.addExtensions} - /> -
-
- ); - } - renderExtensions() { const { extensions, extensionsPath, search } = this; if (!extensions.length) { return ( -
- {search && No search results found} - {!search &&

There are no extensions in {extensionsPath}

} +
+ +
+ {search &&

No search results found

} + {!search &&

There are no extensions in {extensionsPath}

} +
); } @@ -316,11 +293,11 @@ export class Extensions extends React.Component { const { name, description } = manifest; return (
-
-
+
+
Name: {name}
-
+
Description: {description}
@@ -336,21 +313,64 @@ export class Extensions extends React.Component { } render() { + const topHeader =

Manage Lens Extensions

; + const { installPath } = this; return ( - Extensions}> - - + + +

Lens Extensions

+
+ 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. + Rich extensibility model lets extension authors plug directly into the Lens UI and contribute functionality through the same APIs used by Lens itself. + Check out documentation to learn more. +
+ +
+ Install Extension:}/> +
+ this.installPath = v} + onSubmit={this.installFromUrlOrPath} + iconLeft="link" + iconRight={ + Browse} + /> + } + /> +
+
+ +

Installed Extensions

+
this.search = value} /> -
- {this.renderExtensions()} -
- - - + {this.renderExtensions()} +
+
+
); } } diff --git a/src/renderer/components/+preferences/kubectl-binaries.tsx b/src/renderer/components/+preferences/kubectl-binaries.tsx index 3b0b258ab4..118298c561 100644 --- a/src/renderer/components/+preferences/kubectl-binaries.tsx +++ b/src/renderer/components/+preferences/kubectl-binaries.tsx @@ -1,8 +1,7 @@ import React, { useState } from 'react'; import { Trans } from '@lingui/macro'; -import { isPath } from '../input/input_validators'; import { Checkbox } from '../checkbox'; -import { Input } from '../input'; +import { Input, InputValidators } from '../input'; import { SubTitle } from '../layout/sub-title'; import { UserPreferences, userStore } from '../../../common/user-store'; import { observer } from 'mobx-react'; @@ -12,6 +11,7 @@ import { SelectOption, Select } from '../select'; export const KubectlBinaries = observer(({ preferences }: { preferences: UserPreferences }) => { const [downloadPath, setDownloadPath] = useState(preferences.downloadBinariesPath || ""); const [binariesPath, setBinariesPath] = useState(preferences.kubectlBinariesPath || ""); + const pathValidator = downloadPath ? InputValidators.isPath : undefined; const downloadMirrorOptions: SelectOption[] = [ { value: "default", label: "Default (Google)" }, @@ -47,7 +47,7 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre theme="round-black" value={downloadPath} placeholder={userStore.getDefaultKubectlPath()} - validators={isPath} + validators={pathValidator} onChange={setDownloadPath} onBlur={save} disabled={!preferences.downloadKubectlBinaries} @@ -60,7 +60,7 @@ export const KubectlBinaries = observer(({ preferences }: { preferences: UserPre theme="round-black" placeholder={bundledKubectlPath()} value={binariesPath} - validators={isPath} + validators={pathValidator} onChange={setBinariesPath} onBlur={save} disabled={preferences.downloadKubectlBinaries} diff --git a/src/renderer/components/dock/pod-log-search.tsx b/src/renderer/components/dock/pod-log-search.tsx index 296022a40f..dbbcf4901a 100644 --- a/src/renderer/components/dock/pod-log-search.tsx +++ b/src/renderer/components/dock/pod-log-search.tsx @@ -60,7 +60,7 @@ export const PodLogSearch = observer((props: PodLogSearchProps) => { 0 && findCounts} onClear={onClear} onKeyDown={onKeyDown} diff --git a/src/renderer/components/input/drop-file-input.tsx b/src/renderer/components/input/drop-file-input.tsx index 70dd8ddf9c..a99e61ef2b 100644 --- a/src/renderer/components/input/drop-file-input.tsx +++ b/src/renderer/components/input/drop-file-input.tsx @@ -61,7 +61,7 @@ export class DropFileInput extends React.Component< const isValidContentElem = React.isValidElement(contentElem); if (isValidContentElem) { const contentElemProps: React.HTMLProps = { - className: cssNames("DropFileInput", className, { + className: cssNames("DropFileInput", contentElem.props.className, className, { droppable: this.dropAreaActive, }), onDragEnter, diff --git a/src/renderer/components/input/input.scss b/src/renderer/components/input/input.scss index b4c3e21703..48d0d3f353 100644 --- a/src/renderer/components/input/input.scss +++ b/src/renderer/components/input/input.scss @@ -89,8 +89,10 @@ &.theme { &.round-black { - &.invalid label { - border-color: $colorSoftError !important; + &.invalid.dirty { + label { + border-color: $colorSoftError; + } } label { diff --git a/src/renderer/components/input/input.tsx b/src/renderer/components/input/input.tsx index cddfcb92eb..1fa460f18a 100644 --- a/src/renderer/components/input/input.tsx +++ b/src/renderer/components/input/input.tsx @@ -3,13 +3,13 @@ import "./input.scss"; import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react"; import { autobind, cssNames, debouncePromise, getRandId } from "../../utils"; import { Icon } from "../icon"; +import { Tooltip, TooltipProps } from "../tooltip"; import * as Validators from "./input_validators"; import { InputValidator } from "./input_validators"; import isString from "lodash/isString"; import isFunction from "lodash/isFunction"; import isBoolean from "lodash/isBoolean"; import uniqueId from "lodash/uniqueId"; -import { Tooltip } from "../tooltip"; const { conditionalValidators, ...InputValidators } = Validators; export { InputValidators, InputValidator }; @@ -26,7 +26,7 @@ export type InputProps = Omit; // show validation errors as a tooltip :hover (instead of block below) iconLeft?: string | React.ReactNode; // material-icon name in case of string-type iconRight?: string | React.ReactNode; contentRight?: string | React.ReactNode; // Any component of string goes after iconRight @@ -63,6 +63,10 @@ export class Input extends React.Component { errors: [], }; + isValid() { + return this.state.valid; + } + setValue(value: string) { if (value !== this.getValue()) { const nativeInputValueSetter = Object.getOwnPropertyDescriptor(this.input.constructor.prototype, "value").set; @@ -268,7 +272,8 @@ export class Input extends React.Component { render() { const { multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip, - maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, + maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight, id, + dirty: _dirty, // excluded from passing to input-element ...inputProps } = this.props; const { focused, dirty, valid, validating, errors } = this.state; @@ -294,29 +299,35 @@ export class Input extends React.Component { ref: this.bindRef, spellCheck: "false", }); - const tooltipId = showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined; const showErrors = errors.length > 0 && !valid && dirty; const errorsInfo = (
{errors.map((error, i) =>

{error}

)}
); + const componentId = id || showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined; + let tooltipError: React.ReactNode; + if (showErrorsAsTooltip && showErrors) { + const tooltipProps = typeof showErrorsAsTooltip === "object" ? showErrorsAsTooltip : {}; + tooltipProps.className = cssNames("InputTooltipError", tooltipProps.className); + tooltipError = ( + +
+ + {errorsInfo} +
+
+ ); + } return ( -
+
+ {tooltipError}