mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Allow to install packed extensions from URL or local file (#1456)
* Option to install an extension from filesystem/url #1227 -- part 1 (UI) Signed-off-by: Roman <ixrock@gmail.com> * DropFileInput: common component to handle droped files (replaced also in add-cluster-page) Signed-off-by: Roman <ixrock@gmail.com> * fix: install via url-string on input.submit Signed-off-by: Roman <ixrock@gmail.com> * ui tweaks & minor fixes Signed-off-by: Roman <ixrock@gmail.com> * more ui/ux tweaks & fixes Signed-off-by: Roman <ixrock@gmail.com> * layout fixes Signed-off-by: Roman <ixrock@gmail.com> * component renaming: `copy-to-click` => `copy-to-clipboard` => `clipboard` Signed-off-by: Roman <ixrock@gmail.com> * reworks -- part 1 Signed-off-by: Roman <ixrock@gmail.com> * fix downloading file, added common/utils/downloadFile Signed-off-by: Roman <ixrock@gmail.com> * confirm before install, unpack tar first steps Signed-off-by: Roman <ixrock@gmail.com> * installation flow, extracting .tgz Signed-off-by: Roman <ixrock@gmail.com> * clean up, fix lint issues Signed-off-by: Roman <ixrock@gmail.com> * update .azure-pipelines.yml Signed-off-by: Roman <ixrock@gmail.com> * fixes & refactoring Signed-off-by: Roman <ixrock@gmail.com> * fix lint harder :/ Signed-off-by: Roman <ixrock@gmail.com> * fix validation Signed-off-by: Roman <ixrock@gmail.com> * fix validation harder Signed-off-by: Roman <ixrock@gmail.com> * responding to comments, fixed package validation Signed-off-by: Roman <ixrock@gmail.com> * common/utils/tar.ts: reject with Error-type Signed-off-by: Roman <ixrock@gmail.com> * fix: unit-tests Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
parent
2da598b66e
commit
77ae31550a
@ -47,7 +47,7 @@
|
||||
"bundledHelmVersion": "3.3.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0 <13.0"
|
||||
"node": ">=12 <13"
|
||||
},
|
||||
"lingui": {
|
||||
"locales": [
|
||||
@ -214,7 +214,7 @@
|
||||
"@types/node": "^12.12.45",
|
||||
"@types/proper-lockfile": "^4.1.1",
|
||||
"@types/react-beautiful-dnd": "^13.0.0",
|
||||
"@types/tar": "^4.0.3",
|
||||
"@types/tar": "^4.0.4",
|
||||
"array-move": "^3.0.0",
|
||||
"await-lock": "^2.1.0",
|
||||
"chalk": "^4.1.0",
|
||||
@ -251,7 +251,7 @@
|
||||
"serializr": "^2.0.3",
|
||||
"shell-env": "^3.0.0",
|
||||
"spdy": "^4.0.2",
|
||||
"tar": "^6.0.2",
|
||||
"tar": "^6.0.5",
|
||||
"tcp-port-used": "^1.0.1",
|
||||
"tempy": "^0.5.0",
|
||||
"uuid": "^8.1.0",
|
||||
@ -314,7 +314,6 @@
|
||||
"@types/sharp": "^0.26.0",
|
||||
"@types/shelljs": "^0.8.8",
|
||||
"@types/spdy": "^3.4.4",
|
||||
"@types/tar": "^4.0.3",
|
||||
"@types/tcp-port-used": "^1.0.0",
|
||||
"@types/tempy": "^0.3.0",
|
||||
"@types/terser-webpack-plugin": "^3.0.0",
|
||||
|
||||
35
src/common/utils/downloadFile.ts
Normal file
35
src/common/utils/downloadFile.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import request from "request";
|
||||
|
||||
export interface DownloadFileOptions {
|
||||
url: string;
|
||||
gzip?: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadFileTicket {
|
||||
url: string;
|
||||
promise: Promise<Buffer>;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
export function downloadFile({ url, gzip = true }: DownloadFileOptions): DownloadFileTicket {
|
||||
const fileChunks: Buffer[] = [];
|
||||
const req = request(url, { gzip });
|
||||
const promise: Promise<Buffer> = new Promise((resolve, reject) => {
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
fileChunks.push(chunk);
|
||||
});
|
||||
req.once("error", err => {
|
||||
reject({ url, err });
|
||||
});
|
||||
req.once("complete", () => {
|
||||
resolve(Buffer.concat(fileChunks));
|
||||
});
|
||||
});
|
||||
return {
|
||||
url,
|
||||
promise,
|
||||
cancel() {
|
||||
req.abort();
|
||||
}
|
||||
};
|
||||
}
|
||||
5
src/common/utils/escapeRegExp.ts
Normal file
5
src/common/utils/escapeRegExp.ts
Normal file
@ -0,0 +1,5 @@
|
||||
// Helper to sanitize / escape special chars for passing to RegExp-constructor
|
||||
|
||||
export function escapeRegExp(str: string) {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
|
||||
}
|
||||
@ -15,3 +15,6 @@ export * from "./saveToAppFiles";
|
||||
export * from "./singleton";
|
||||
export * from "./openExternal";
|
||||
export * from "./rectify-array";
|
||||
export * from "./downloadFile";
|
||||
export * from "./escapeRegExp";
|
||||
export * from "./tar";
|
||||
|
||||
55
src/common/utils/tar.ts
Normal file
55
src/common/utils/tar.ts
Normal file
@ -0,0 +1,55 @@
|
||||
// Helper for working with tarball files (.tar, .tgz)
|
||||
// Docs: https://github.com/npm/node-tar
|
||||
import tar, { ExtractOptions, FileStat } from "tar";
|
||||
import path from "path";
|
||||
|
||||
export interface ReadFileFromTarOpts {
|
||||
tarPath: string;
|
||||
filePath: string;
|
||||
parseJson?: boolean;
|
||||
}
|
||||
|
||||
export function readFileFromTar<R = Buffer>({ tarPath, filePath, parseJson }: ReadFileFromTarOpts): Promise<R> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
const fileChunks: Buffer[] = [];
|
||||
|
||||
await tar.list({
|
||||
file: tarPath,
|
||||
filter: path => path === filePath,
|
||||
onentry(entry: FileStat) {
|
||||
entry.on("data", chunk => {
|
||||
fileChunks.push(chunk);
|
||||
});
|
||||
entry.once("error", err => {
|
||||
reject(new Error(`reading file has failed ${entry.path}: ${err}`));
|
||||
});
|
||||
entry.once("end", () => {
|
||||
const data = Buffer.concat(fileChunks);
|
||||
const result = parseJson ? JSON.parse(data.toString("utf8")) : data;
|
||||
resolve(result);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!fileChunks.length) {
|
||||
reject(new Error("Not found"));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function listTarEntries(filePath: string): Promise<string[]> {
|
||||
const entries: string[] = [];
|
||||
await tar.list({
|
||||
file: filePath,
|
||||
onentry: (entry: FileStat) => entries.push(entry.path as any as string),
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function extractTar(filePath: string, opts: ExtractOptions & { sync?: boolean } = {}) {
|
||||
return tar.extract({
|
||||
file: filePath,
|
||||
cwd: path.dirname(filePath),
|
||||
...opts,
|
||||
});
|
||||
}
|
||||
@ -41,3 +41,5 @@ export const apiKubePrefix = "/api-kube"; // k8s cluster apis
|
||||
// Links
|
||||
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues";
|
||||
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI";
|
||||
export const docsUrl = "https://docs.k8slens.dev/";
|
||||
export const supportUrl = "https://docs.k8slens.dev/latest/support/";
|
||||
|
||||
@ -17,7 +17,7 @@ export interface InstalledExtension {
|
||||
}
|
||||
|
||||
const logModule = "[EXTENSION-DISCOVERY]";
|
||||
const manifestFilename = "package.json";
|
||||
export const manifestFilename = "package.json";
|
||||
|
||||
/**
|
||||
* Returns true if the lstat is for a directory-like file (e.g. isDirectory or symbolic link)
|
||||
|
||||
@ -12,6 +12,7 @@ export interface LensExtensionManifest {
|
||||
description?: string;
|
||||
main?: string; // path to %ext/dist/main.js
|
||||
renderer?: string; // path to %ext/dist/renderer.js
|
||||
lens?: object; // fixme: add more required fields for validation
|
||||
}
|
||||
|
||||
export class LensExtension {
|
||||
@ -109,3 +110,7 @@ export class LensExtension {
|
||||
// mock
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeExtensionName(name: string) {
|
||||
return name.replace("@", "").replace("/", "--");
|
||||
}
|
||||
|
||||
@ -25,8 +25,8 @@ describe("getPageUrl", () => {
|
||||
expect(getExtensionPageUrl({ extensionId: ext.name, pageId: "/test" })).toBe("/extension/foo-bar/test");
|
||||
});
|
||||
|
||||
it("removes @", () => {
|
||||
expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo-bar");
|
||||
it("removes @ and replace `/` to `--`", () => {
|
||||
expect(getExtensionPageUrl({ extensionId: "@foo/bar" })).toBe("/extension/foo--bar");
|
||||
});
|
||||
|
||||
it("adds / prefix", () => {
|
||||
|
||||
@ -5,7 +5,7 @@ import path from "path";
|
||||
import { action } from "mobx";
|
||||
import { compile } from "path-to-regexp";
|
||||
import { BaseRegistry } from "./base-registry";
|
||||
import { LensExtension } from "../lens-extension";
|
||||
import { LensExtension, sanitizeExtensionName } from "../lens-extension";
|
||||
import logger from "../../main/logger";
|
||||
import { recitfy } from "../../common/utils";
|
||||
|
||||
@ -45,10 +45,6 @@ export interface PageComponents {
|
||||
Page: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
export function sanitizeExtensionName(name: string) {
|
||||
return name.replace("@", "").replace("/", "-");
|
||||
}
|
||||
|
||||
export function getExtensionPageUrl<P extends object>({ extensionId, pageId = "", params }: PageMenuTarget<P>): string {
|
||||
const extensionBaseUrl = compile(`/extension/:name`)({
|
||||
name: sanitizeExtensionName(extensionId), // compile only with extension-id first and define base path
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { app, BrowserWindow, dialog, ipcMain, IpcMainEvent, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron";
|
||||
import { autorun } from "mobx";
|
||||
import { WindowManager } from "./window-manager";
|
||||
import { appName, isMac, isWindows, isTestEnv } from "../common/vars";
|
||||
import { appName, isMac, isWindows, isTestEnv, docsUrl, supportUrl } from "../common/vars";
|
||||
import { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route";
|
||||
import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
|
||||
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
|
||||
@ -24,6 +24,7 @@ export function showAbout(browserWindow: BrowserWindow) {
|
||||
`${appName}: ${app.getVersion()}`,
|
||||
`Electron: ${process.versions.electron}`,
|
||||
`Chrome: ${process.versions.chrome}`,
|
||||
`Node: ${process.versions.node}`,
|
||||
`Copyright 2020 Mirantis, Inc.`,
|
||||
];
|
||||
dialog.showMessageBoxSync(browserWindow, {
|
||||
@ -215,13 +216,13 @@ export function buildMenu(windowManager: WindowManager) {
|
||||
{
|
||||
label: "Documentation",
|
||||
click: async () => {
|
||||
shell.openExternal('https://docs.k8slens.dev/');
|
||||
shell.openExternal(docsUrl);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: "Support",
|
||||
click: async () => {
|
||||
shell.openExternal('https://docs.k8slens.dev/latest/support/');
|
||||
shell.openExternal(supportUrl);
|
||||
},
|
||||
},
|
||||
...ignoreOnMac([
|
||||
|
||||
@ -1,12 +1,4 @@
|
||||
.AddCluster {
|
||||
.droppable {
|
||||
box-shadow: 0 0 0 5px inset $primary;
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: -$padding;
|
||||
color: $textColorSecondary;
|
||||
|
||||
@ -8,7 +8,7 @@ import { KubeConfig } from "@kubernetes/client-node";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { t, Trans } from "@lingui/macro";
|
||||
import { Select, SelectOption } from "../select";
|
||||
import { Input } from "../input";
|
||||
import { DropFileInput, Input } from "../input";
|
||||
import { AceEditor } from "../ace-editor";
|
||||
import { Button } from "../button";
|
||||
import { Icon } from "../icon";
|
||||
@ -44,7 +44,6 @@ export class AddCluster extends React.Component {
|
||||
@observable proxyServer = "";
|
||||
@observable isWaiting = false;
|
||||
@observable showSettings = false;
|
||||
@observable dropAreaActive = false;
|
||||
|
||||
componentDidMount() {
|
||||
clusterStore.setActive(null);
|
||||
@ -121,6 +120,11 @@ export class AddCluster extends React.Component {
|
||||
}
|
||||
};
|
||||
|
||||
onDropKubeConfig = (files: File[]) => {
|
||||
this.sourceTab = KubeConfigSourceTab.FILE;
|
||||
this.setKubeConfig(files[0].path);
|
||||
};
|
||||
|
||||
@action
|
||||
addClusters = () => {
|
||||
let newClusters: ClusterModel[] = [];
|
||||
@ -139,7 +143,7 @@ export class AddCluster extends React.Component {
|
||||
return true;
|
||||
} catch (err) {
|
||||
this.error = String(err.message);
|
||||
if (err instanceof ExecValidationNotFoundError ) {
|
||||
if (err instanceof ExecValidationNotFoundError) {
|
||||
Notifications.error(<Trans>Error while adding cluster(s): {this.error}</Trans>);
|
||||
return false;
|
||||
} else {
|
||||
@ -230,7 +234,7 @@ export class AddCluster extends React.Component {
|
||||
<Tab
|
||||
value={KubeConfigSourceTab.FILE}
|
||||
label={<Trans>Select kubeconfig file</Trans>}
|
||||
active={this.sourceTab == KubeConfigSourceTab.FILE} />
|
||||
active={this.sourceTab == KubeConfigSourceTab.FILE}/>
|
||||
<Tab
|
||||
value={KubeConfigSourceTab.TEXT}
|
||||
label={<Trans>Paste as text</Trans>}
|
||||
@ -344,71 +348,55 @@ export class AddCluster extends React.Component {
|
||||
return (
|
||||
<div className={cssNames("kube-context flex gaps align-center", context)}>
|
||||
<span>{context}</span>
|
||||
{isNew && <Icon small material="fiber_new" />}
|
||||
{isSelected && <Icon small material="check" className="box right" />}
|
||||
{isNew && <Icon small material="fiber_new"/>}
|
||||
{isSelected && <Icon small material="check" className="box right"/>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
render() {
|
||||
const addDisabled = this.selectedContexts.length === 0;
|
||||
|
||||
return (
|
||||
<WizardLayout
|
||||
className="AddCluster"
|
||||
infoPanel={this.renderInfo()}
|
||||
contentClass={{ droppable: this.dropAreaActive }}
|
||||
contentProps={{
|
||||
onDragEnter: event => this.dropAreaActive = true,
|
||||
onDragLeave: event => this.dropAreaActive = false,
|
||||
onDragOver: event => {
|
||||
event.preventDefault(); // enable onDrop()-callback
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
},
|
||||
onDrop: event => {
|
||||
this.sourceTab = KubeConfigSourceTab.FILE;
|
||||
this.dropAreaActive = false;
|
||||
this.setKubeConfig(event.dataTransfer.files[0].path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<h2><Trans>Add Cluster</Trans></h2>
|
||||
{this.renderKubeConfigSource()}
|
||||
{this.renderContextSelector()}
|
||||
<div className="cluster-settings">
|
||||
<a href="#" onClick={() => this.showSettings = !this.showSettings}>
|
||||
<Trans>Proxy settings</Trans>
|
||||
</a>
|
||||
</div>
|
||||
{this.showSettings && (
|
||||
<div className="proxy-settings">
|
||||
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
|
||||
<Input
|
||||
autoFocus
|
||||
value={this.proxyServer}
|
||||
onChange={value => this.proxyServer = value}
|
||||
theme="round-black"
|
||||
/>
|
||||
<small className="hint">
|
||||
{'A HTTP proxy server URL (format: http://<address>:<port>).'}
|
||||
</small>
|
||||
<DropFileInput onDropFiles={this.onDropKubeConfig}>
|
||||
<WizardLayout className="AddCluster" infoPanel={this.renderInfo()}>
|
||||
<h2><Trans>Add Cluster</Trans></h2>
|
||||
{this.renderKubeConfigSource()}
|
||||
{this.renderContextSelector()}
|
||||
<div className="cluster-settings">
|
||||
<a href="#" onClick={() => this.showSettings = !this.showSettings}>
|
||||
<Trans>Proxy settings</Trans>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{this.error && (
|
||||
<div className="error">{this.error}</div>
|
||||
)}
|
||||
<div className="actions-panel">
|
||||
<Button
|
||||
primary
|
||||
disabled={addDisabled}
|
||||
label={<Trans>Add cluster(s)</Trans>}
|
||||
onClick={this.addClusters}
|
||||
waiting={this.isWaiting}
|
||||
tooltip={addDisabled ? _i18n._("Select at least one cluster to add.") : undefined}
|
||||
tooltipOverrideDisabled
|
||||
/>
|
||||
</div>
|
||||
</WizardLayout>
|
||||
{this.showSettings && (
|
||||
<div className="proxy-settings">
|
||||
<p>HTTP Proxy server. Used for communicating with Kubernetes API.</p>
|
||||
<Input
|
||||
autoFocus
|
||||
value={this.proxyServer}
|
||||
onChange={value => this.proxyServer = value}
|
||||
theme="round-black"
|
||||
/>
|
||||
<small className="hint">
|
||||
{'A HTTP proxy server URL (format: http://<address>:<port>).'}
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{this.error && (
|
||||
<div className="error">{this.error}</div>
|
||||
)}
|
||||
<div className="actions-panel">
|
||||
<Button
|
||||
primary
|
||||
disabled={addDisabled}
|
||||
label={<Trans>Add cluster(s)</Trans>}
|
||||
onClick={this.addClusters}
|
||||
waiting={this.isWaiting}
|
||||
tooltip={addDisabled ? _i18n._("Select at least one cluster to add.") : undefined}
|
||||
tooltipOverrideDisabled
|
||||
/>
|
||||
</div>
|
||||
</WizardLayout>
|
||||
</DropFileInput>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,22 +1,54 @@
|
||||
.Extensions {
|
||||
$spacing: $padding * 2;
|
||||
--width: 100%;
|
||||
--max-width: auto;
|
||||
|
||||
.extension-list {
|
||||
.extensions-list {
|
||||
.extension {
|
||||
--flex-gap: $padding / 3;
|
||||
padding: $padding $padding * 2;
|
||||
padding: $padding $spacing;
|
||||
background: $colorVague;
|
||||
border-radius: $radius;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: $padding * 2;
|
||||
margin-top: $spacing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.extensions-info {
|
||||
--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;
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
--spacing: #{$padding};
|
||||
}
|
||||
|
||||
.SubTitle {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.WizardLayout {
|
||||
@ -27,15 +59,28 @@
|
||||
align-self: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.SearchInput {
|
||||
margin-top: $margin / 2;
|
||||
margin-bottom: $margin * 2;
|
||||
max-width: none;
|
||||
.InstallingExtensionNotification {
|
||||
.remove-folder-warning {
|
||||
font-size: $font-size-small;
|
||||
font-style: italic;
|
||||
opacity: .8;
|
||||
cursor: pointer;
|
||||
|
||||
> label {
|
||||
padding: $padding $padding * 2;
|
||||
border-radius: $radius;
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline;
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.Button {
|
||||
background-color: unset;
|
||||
border: 1px solid currentColor;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,8 @@
|
||||
import "./extensions.scss";
|
||||
import { shell } from "electron";
|
||||
import { remote, shell } from "electron";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import fse from "fs-extra";
|
||||
import React from "react";
|
||||
import { computed, observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
@ -7,15 +10,39 @@ import { t, Trans } from "@lingui/macro";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { Button } from "../button";
|
||||
import { WizardLayout } from "../layout/wizard-layout";
|
||||
import { Input } from "../input";
|
||||
import { DropFileInput, Input, InputValidators, SearchInput } from "../input";
|
||||
import { Icon } from "../icon";
|
||||
import { SubTitle } from "../layout/sub-title";
|
||||
import { PageLayout } from "../layout/page-layout";
|
||||
import { Clipboard } from "../clipboard";
|
||||
import logger from "../../../main/logger";
|
||||
import { extensionLoader } from "../../../extensions/extension-loader";
|
||||
import { extensionDiscovery } from "../../../extensions/extension-discovery";
|
||||
import { extensionDiscovery, manifestFilename } from "../../../extensions/extension-discovery";
|
||||
import { LensExtensionManifest, sanitizeExtensionName } from "../../../extensions/lens-extension";
|
||||
import { Notifications } from "../notifications";
|
||||
import { downloadFile, extractTar, listTarEntries, readFileFromTar } from "../../../common/utils";
|
||||
import { docsUrl } from "../../../common/vars";
|
||||
|
||||
interface InstallRequest {
|
||||
fileName: string;
|
||||
filePath?: string;
|
||||
data?: Buffer;
|
||||
}
|
||||
|
||||
interface InstallRequestPreloaded extends InstallRequest {
|
||||
data: Buffer;
|
||||
}
|
||||
|
||||
interface InstallRequestValidated extends InstallRequestPreloaded {
|
||||
manifest: LensExtensionManifest;
|
||||
tempFile: string; // temp system path to packed extension for unpacking
|
||||
}
|
||||
|
||||
@observer
|
||||
export class Extensions extends React.Component {
|
||||
private supportedFormats = [".tar", ".tgz"];
|
||||
@observable search = "";
|
||||
@observable downloadUrl = "";
|
||||
|
||||
@computed get extensions() {
|
||||
const searchText = this.search.toLowerCase();
|
||||
@ -32,27 +59,265 @@ export class Extensions extends React.Component {
|
||||
return extensionDiscovery.localFolderPath;
|
||||
}
|
||||
|
||||
getExtensionPackageTemp(fileName = "") {
|
||||
return path.join(os.tmpdir(), "lens-extensions", fileName);
|
||||
}
|
||||
|
||||
getExtensionDestFolder(name: string) {
|
||||
return path.join(this.extensionsPath, sanitizeExtensionName(name));
|
||||
}
|
||||
|
||||
installFromSelectFileDialog = async () => {
|
||||
const { dialog, BrowserWindow, app } = remote;
|
||||
const { canceled, filePaths } = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow(), {
|
||||
defaultPath: app.getPath("downloads"),
|
||||
properties: ["openFile", "multiSelections"],
|
||||
message: _i18n._(t`Select extensions to install (formats: ${this.supportedFormats.join(", ")}), `),
|
||||
buttonLabel: _i18n._(t`Use configuration`),
|
||||
filters: [
|
||||
{ name: "tarball", extensions: this.supportedFormats }
|
||||
]
|
||||
});
|
||||
if (!canceled && filePaths.length) {
|
||||
this.requestInstall(
|
||||
filePaths.map(filePath => ({
|
||||
fileName: path.basename(filePath),
|
||||
filePath,
|
||||
}))
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
addExtensions = () => {
|
||||
const { downloadUrl } = this;
|
||||
if (downloadUrl && InputValidators.isUrl.validate(downloadUrl)) {
|
||||
this.installFromUrl(downloadUrl);
|
||||
} else {
|
||||
this.installFromSelectFileDialog();
|
||||
}
|
||||
};
|
||||
|
||||
installFromUrl = async (url: string) => {
|
||||
try {
|
||||
const { promise: filePromise } = downloadFile({ url });
|
||||
this.requestInstall([{
|
||||
fileName: path.basename(url),
|
||||
data: await filePromise,
|
||||
}]);
|
||||
} catch (err) {
|
||||
Notifications.error(
|
||||
<p>Installation via URL has failed: <b>{String(err)}</b></p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
installOnDrop = (files: File[]) => {
|
||||
logger.info('Install from D&D');
|
||||
return this.requestInstall(
|
||||
files.map(file => ({
|
||||
fileName: path.basename(file.path),
|
||||
filePath: file.path,
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) {
|
||||
const preloadedRequests = requests.filter(req => req.data);
|
||||
await Promise.all(
|
||||
requests
|
||||
.filter(req => !req.data && req.filePath)
|
||||
.map(req => {
|
||||
return fse.readFile(req.filePath).then(data => {
|
||||
req.data = data;
|
||||
preloadedRequests.push(req);
|
||||
}).catch(err => {
|
||||
if (showError) {
|
||||
Notifications.error(`Error while reading "${req.filePath}": ${String(err)}`);
|
||||
}
|
||||
});
|
||||
})
|
||||
);
|
||||
return preloadedRequests as InstallRequestPreloaded[];
|
||||
}
|
||||
|
||||
async validatePackage(filePath: string): Promise<LensExtensionManifest> {
|
||||
const tarFiles = await listTarEntries(filePath);
|
||||
|
||||
// tarball from npm contains single root folder "package/*"
|
||||
const rootFolder = tarFiles[0].split("/")[0];
|
||||
const packedInRootFolder = tarFiles.every(entry => entry.startsWith(rootFolder));
|
||||
const manifestLocation = packedInRootFolder ? path.join(rootFolder, manifestFilename) : manifestFilename;
|
||||
|
||||
if (!tarFiles.includes(manifestLocation)) {
|
||||
throw new Error(`invalid extension bundle, ${manifestFilename} not found`);
|
||||
}
|
||||
const manifest = await readFileFromTar<LensExtensionManifest>({
|
||||
tarPath: filePath,
|
||||
filePath: manifestLocation,
|
||||
parseJson: true,
|
||||
});
|
||||
if (!manifest.lens && !manifest.renderer) {
|
||||
throw new Error(`${manifestFilename} must specify "main" and/or "renderer" fields`);
|
||||
}
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) {
|
||||
const validatedRequests: InstallRequestValidated[] = [];
|
||||
|
||||
// copy files to temp
|
||||
await fse.ensureDir(this.getExtensionPackageTemp());
|
||||
requests.forEach(req => {
|
||||
const tempFile = this.getExtensionPackageTemp(req.fileName);
|
||||
fse.writeFileSync(tempFile, req.data);
|
||||
});
|
||||
|
||||
// validate packages
|
||||
await Promise.all(
|
||||
requests.map(async req => {
|
||||
const tempFile = this.getExtensionPackageTemp(req.fileName);
|
||||
try {
|
||||
const manifest = await this.validatePackage(tempFile);
|
||||
validatedRequests.push({
|
||||
...req,
|
||||
manifest,
|
||||
tempFile,
|
||||
});
|
||||
} catch (err) {
|
||||
fse.unlink(tempFile).catch(() => null); // remove invalid temp package
|
||||
if (showErrors) {
|
||||
Notifications.error(
|
||||
<div className="flex column gaps">
|
||||
<p>Installing <em>{req.fileName}</em> has failed, skipping.</p>
|
||||
<p>Reason: <em>{String(err)}</em></p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
return validatedRequests;
|
||||
}
|
||||
|
||||
async requestInstall(requests: InstallRequest[]) {
|
||||
const preloadedRequests = await this.preloadExtensions(requests);
|
||||
const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
|
||||
|
||||
validatedRequests.forEach(install => {
|
||||
const { name, version, description } = install.manifest;
|
||||
const extensionFolder = this.getExtensionDestFolder(name);
|
||||
const folderExists = fse.existsSync(extensionFolder);
|
||||
if (!folderExists) {
|
||||
// auto-install extension if not yet exists
|
||||
this.unpackExtension(install);
|
||||
} else {
|
||||
// otherwise confirmation required (re-install / update)
|
||||
const removeNotification = Notifications.info(
|
||||
<div className="InstallingExtensionNotification flex gaps align-center">
|
||||
<div className="flex column gaps">
|
||||
<p>Install extension <b>{name}@{version}</b>?</p>
|
||||
<p>Description: <em>{description}</em></p>
|
||||
<div className="remove-folder-warning" onClick={() => shell.openPath(extensionFolder)}>
|
||||
<b>Warning:</b> <code>{extensionFolder}</code> will be removed before installation.
|
||||
</div>
|
||||
</div>
|
||||
<Button autoFocus label="Install" onClick={() => {
|
||||
removeNotification();
|
||||
this.unpackExtension(install);
|
||||
}}/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async unpackExtension({ fileName, tempFile, manifest: { name, version } }: InstallRequestValidated) {
|
||||
const extName = `${name}@${version}`;
|
||||
logger.info(`Unpacking extension ${extName}`, { fileName, tempFile });
|
||||
const unpackingTempFolder = path.join(path.dirname(tempFile), path.basename(tempFile) + "-unpacked");
|
||||
const extensionFolder = this.getExtensionDestFolder(name);
|
||||
try {
|
||||
// extract to temp folder first
|
||||
await fse.remove(unpackingTempFolder).catch(Function);
|
||||
await fse.ensureDir(unpackingTempFolder);
|
||||
await extractTar(tempFile, { cwd: unpackingTempFolder });
|
||||
|
||||
// move contents to extensions folder
|
||||
const unpackedFiles = await fse.readdir(unpackingTempFolder);
|
||||
let unpackedRootFolder = unpackingTempFolder;
|
||||
if (unpackedFiles.length === 1) {
|
||||
// check if %extension.tgz was packed with single top folder,
|
||||
// e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball
|
||||
unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]);
|
||||
}
|
||||
await fse.ensureDir(extensionFolder);
|
||||
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
|
||||
Notifications.ok(
|
||||
<p>Extension <b>{extName}</b> successfully installed!</p>
|
||||
);
|
||||
} catch (err) {
|
||||
Notifications.error(
|
||||
<p>Installing extension <b>{extName}</b> has failed: <em>{err}</em></p>
|
||||
);
|
||||
} finally {
|
||||
// clean up
|
||||
fse.remove(unpackingTempFolder).catch(Function);
|
||||
fse.unlink(tempFile).catch(Function);
|
||||
}
|
||||
}
|
||||
|
||||
renderInfo() {
|
||||
return (
|
||||
<div className="flex column gaps">
|
||||
<div className="extensions-info flex column gaps">
|
||||
<h2>Lens Extension API</h2>
|
||||
<div>
|
||||
The Extensions API in Lens allows users to customize and enhance the Lens experience by creating their own menus or page content that is extended from the existing pages. Many of the core
|
||||
features of Lens are built as extensions and use the same Extension API.
|
||||
</div>
|
||||
<div>
|
||||
Extensions loaded from:
|
||||
<div className="extensions-path flex inline">
|
||||
<SubTitle title="Extensions loaded from:"/>
|
||||
<div className="extensions-path flex inline" onClick={() => shell.openPath(this.extensionsPath)}>
|
||||
<Icon material="folder" tooltip={{ children: "Open folder", preferredPositions: "bottom" }}/>
|
||||
<code>{this.extensionsPath}</code>
|
||||
<Icon
|
||||
material="folder"
|
||||
tooltip="Open folder"
|
||||
onClick={() => shell.openPath(this.extensionsPath)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
Check out documentation to <a href="https://docs.k8slens.dev/" target="_blank">learn more</a>
|
||||
<div className="install-extension flex column gaps">
|
||||
<SubTitle title="Install extensions:"/>
|
||||
<Input
|
||||
showErrorsAsTooltip={true}
|
||||
className="box grow"
|
||||
theme="round-black"
|
||||
iconLeft="link"
|
||||
placeholder={`URL to packed extension (${this.supportedFormats.join(", ")})`}
|
||||
validators={InputValidators.isUrl}
|
||||
value={this.downloadUrl}
|
||||
onChange={v => this.downloadUrl = v}
|
||||
onSubmit={this.addExtensions}
|
||||
/>
|
||||
<Button
|
||||
primary
|
||||
label="Add extensions"
|
||||
onClick={this.addExtensions}
|
||||
/>
|
||||
<p className="hint">
|
||||
<Trans><b>Pro-Tip 1</b>: you can obtain package tarball from NPM via</Trans>{" "}
|
||||
<Clipboard showNotification>
|
||||
<code>npm view %package dist.tarball</code>
|
||||
</Clipboard>
|
||||
<span> or download package first with </span>
|
||||
<Clipboard showNotification>
|
||||
<code>npm pack %package</code>
|
||||
</Clipboard>
|
||||
</p>
|
||||
<p className="hint">
|
||||
<Trans><b>Pro-Tip 2</b>: you can drag & drop extension's tarball here to request installation</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="more-info flex inline gaps align-center">
|
||||
<Icon material="local_fire_department"/>
|
||||
<p>
|
||||
Check out documentation to <a href={docsUrl} target="_blank">learn more</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -95,19 +360,18 @@ export class Extensions extends React.Component {
|
||||
render() {
|
||||
return (
|
||||
<PageLayout showOnTop className="Extensions" header={<h2>Extensions</h2>}>
|
||||
<WizardLayout infoPanel={this.renderInfo()}>
|
||||
<Input
|
||||
autoFocus
|
||||
theme="round-black"
|
||||
className="SearchInput"
|
||||
placeholder={_i18n._(t`Search extensions`)}
|
||||
value={this.search}
|
||||
onChange={(value) => this.search = value}
|
||||
/>
|
||||
<div className="extension-list">
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</WizardLayout>
|
||||
<DropFileInput onDropFiles={this.installOnDrop}>
|
||||
<WizardLayout infoPanel={this.renderInfo()}>
|
||||
<SearchInput
|
||||
placeholder={_i18n._(t`Search extensions`)}
|
||||
value={this.search}
|
||||
onChange={(value) => this.search = value}
|
||||
/>
|
||||
<div className="extensions-list">
|
||||
{this.renderExtensions()}
|
||||
</div>
|
||||
</WizardLayout>
|
||||
</DropFileInput>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
3
src/renderer/components/clipboard/clipboard.scss
Normal file
3
src/renderer/components/clipboard/clipboard.scss
Normal file
@ -0,0 +1,3 @@
|
||||
.Clipboard {
|
||||
cursor: pointer;
|
||||
}
|
||||
62
src/renderer/components/clipboard/clipboard.tsx
Normal file
62
src/renderer/components/clipboard/clipboard.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import "./clipboard.scss";
|
||||
import React from "react";
|
||||
import { findDOMNode } from "react-dom";
|
||||
import { autobind } from "../../../common/utils";
|
||||
import { Notifications } from "../notifications";
|
||||
import { copyToClipboard } from "../../utils/copyToClipboard";
|
||||
import logger from "../../../main/logger";
|
||||
import { cssNames } from "../../utils";
|
||||
|
||||
export interface CopyToClipboardProps {
|
||||
resetSelection?: boolean;
|
||||
showNotification?: boolean;
|
||||
cssSelectorLimit?: string; // allows to copy partial content with css-selector in children-element context
|
||||
getNotificationMessage?(copiedText: string): React.ReactNode;
|
||||
}
|
||||
|
||||
export const defaultProps: Partial<CopyToClipboardProps> = {
|
||||
getNotificationMessage(copiedText: string) {
|
||||
return <p>Copied to clipboard: <em>{copiedText}</em></p>;
|
||||
}
|
||||
};
|
||||
|
||||
export class Clipboard extends React.Component<CopyToClipboardProps> {
|
||||
static displayName = "Clipboard";
|
||||
static defaultProps = defaultProps as object;
|
||||
|
||||
get rootElem(): HTMLElement {
|
||||
return findDOMNode(this) as HTMLElement;
|
||||
}
|
||||
|
||||
get rootReactElem(): React.ReactElement<React.HTMLProps<any>> {
|
||||
return React.Children.only(this.props.children) as React.ReactElement;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
onClick(evt: React.MouseEvent) {
|
||||
if (this.rootReactElem.props.onClick) {
|
||||
this.rootReactElem.props.onClick(evt); // pass event to children-root-element if any
|
||||
}
|
||||
const { showNotification, resetSelection, getNotificationMessage, cssSelectorLimit } = this.props;
|
||||
const contentElem = this.rootElem.querySelector<any>(cssSelectorLimit) || this.rootElem;
|
||||
if (contentElem) {
|
||||
const { copiedText, copied } = copyToClipboard(contentElem, { resetSelection });
|
||||
if (copied && showNotification) {
|
||||
Notifications.ok(getNotificationMessage(copiedText));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
try {
|
||||
const rootElem = this.rootReactElem;
|
||||
return React.cloneElement(rootElem, {
|
||||
className: cssNames(Clipboard.displayName, rootElem.props.className),
|
||||
onClick: this.onClick,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`Invalid usage components/CopyToClick usage. Children must contain root html element.`, { err: String(err) });
|
||||
return this.rootReactElem;
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/renderer/components/clipboard/index.ts
Normal file
1
src/renderer/components/clipboard/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./clipboard";
|
||||
@ -6,7 +6,7 @@ import { Select, SelectOption } from "../select";
|
||||
import { Badge } from "../badge";
|
||||
import { Icon } from "../icon";
|
||||
import { _i18n } from "../../i18n";
|
||||
import { cssNames, downloadFile } from "../../utils";
|
||||
import { cssNames, saveFileDialog } from "../../utils";
|
||||
import { Pod } from "../../api/endpoints";
|
||||
import { PodLogSearch, PodLogSearchProps } from "./pod-log-search";
|
||||
|
||||
@ -39,7 +39,7 @@ export const PodLogControls = observer((props: Props) => {
|
||||
|
||||
const downloadLogs = () => {
|
||||
const fileName = selectedContainer ? selectedContainer.name : pod.getName();
|
||||
downloadFile(fileName + ".log", logs.join("\n"), "text/plain");
|
||||
saveFileDialog(fileName + ".log", logs.join("\n"), "text/plain");
|
||||
};
|
||||
|
||||
const onContainerChange = (option: SelectOption) => {
|
||||
|
||||
@ -32,7 +32,7 @@ export class Icon extends React.PureComponent<IconProps> {
|
||||
|
||||
get isInteractive() {
|
||||
const { interactive, onClick, href, link } = this.props;
|
||||
return interactive || !!(onClick || href || link);
|
||||
return interactive ?? !!(onClick || href || link);
|
||||
}
|
||||
|
||||
@autobind()
|
||||
|
||||
9
src/renderer/components/input/drop-file-input.scss
Normal file
9
src/renderer/components/input/drop-file-input.scss
Normal file
@ -0,0 +1,9 @@
|
||||
.DropFileInput {
|
||||
&.droppable {
|
||||
box-shadow: 0 0 0 5px $primary; // fixme: might not work sometimes
|
||||
|
||||
> * {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
76
src/renderer/components/input/drop-file-input.tsx
Normal file
76
src/renderer/components/input/drop-file-input.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import "./drop-file-input.scss";
|
||||
import React from "react";
|
||||
import { autobind, cssNames, IClassName } from "../../utils";
|
||||
import { observable } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import logger from "../../../main/logger";
|
||||
|
||||
export interface DropFileInputProps extends React.DOMAttributes<any> {
|
||||
className?: IClassName;
|
||||
disabled?: boolean;
|
||||
onDropFiles(files: File[], meta: DropFileMeta): void;
|
||||
}
|
||||
|
||||
export interface DropFileMeta<T extends HTMLElement = any> {
|
||||
evt: React.DragEvent<T>;
|
||||
}
|
||||
|
||||
@observer
|
||||
export class DropFileInput<T extends HTMLElement = any> extends React.Component<DropFileInputProps> {
|
||||
@observable dropAreaActive = false;
|
||||
|
||||
@autobind()
|
||||
onDragEnter() {
|
||||
this.dropAreaActive = true;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
onDragLeave() {
|
||||
this.dropAreaActive = false;
|
||||
}
|
||||
|
||||
@autobind()
|
||||
onDragOver(evt: React.DragEvent<T>) {
|
||||
if (this.props.onDragOver) {
|
||||
this.props.onDragOver(evt);
|
||||
}
|
||||
evt.preventDefault(); // enable onDrop()-callback
|
||||
evt.dataTransfer.dropEffect = "move";
|
||||
}
|
||||
|
||||
@autobind()
|
||||
onDrop(evt: React.DragEvent<T>) {
|
||||
if (this.props.onDrop) {
|
||||
this.props.onDrop(evt);
|
||||
}
|
||||
this.dropAreaActive = false;
|
||||
const files = Array.from(evt.dataTransfer.files);
|
||||
if (files.length > 0) {
|
||||
this.props.onDropFiles(files, { evt });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { disabled, className } = this.props;
|
||||
const { onDragEnter, onDragLeave, onDragOver, onDrop } = this;
|
||||
try {
|
||||
const contentElem = React.Children.only(this.props.children) as React.ReactElement<React.HTMLProps<HTMLElement>>;
|
||||
const isValidContentElem = React.isValidElement(contentElem);
|
||||
if (!disabled && isValidContentElem) {
|
||||
const contentElemProps: React.HTMLProps<HTMLElement> = {
|
||||
className: cssNames("DropFileInput", className, {
|
||||
droppable: this.dropAreaActive,
|
||||
}),
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
};
|
||||
return React.cloneElement(contentElem, contentElemProps);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error("Invalid root content-element for DropFileInput", { err: String(err) });
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,3 +2,4 @@ export * from './input';
|
||||
export * from './search-input';
|
||||
export * from './search-input-url';
|
||||
export * from './file-input';
|
||||
export * from './drop-file-input';
|
||||
|
||||
@ -89,6 +89,10 @@
|
||||
|
||||
&.theme {
|
||||
&.round-black {
|
||||
&.invalid label {
|
||||
border-color: $colorSoftError !important;
|
||||
}
|
||||
|
||||
label {
|
||||
background: $mainBackground;
|
||||
border: 1px solid $borderFaintColor;
|
||||
@ -107,3 +111,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.Tooltip.InputTooltipError {
|
||||
--bgc: #{$colorError};
|
||||
--border: none;
|
||||
--color: white;
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import "./input.scss";
|
||||
|
||||
import React, { DOMAttributes, InputHTMLAttributes, TextareaHTMLAttributes } from "react";
|
||||
import { autobind, cssNames, debouncePromise } from "../../utils";
|
||||
import { autobind, cssNames, debouncePromise, getRandId } from "../../utils";
|
||||
import { Icon } from "../icon";
|
||||
import * as Validators from "./input_validators";
|
||||
import { InputValidator } from "./input_validators";
|
||||
@ -9,6 +9,7 @@ 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 };
|
||||
@ -25,6 +26,7 @@ export type InputProps<T = string> = Omit<InputElementProps, "onChange" | "onSub
|
||||
maxRows?: number; // when multiLine={true} define max rows size
|
||||
dirty?: boolean; // show validation errors even if the field wasn't touched yet
|
||||
showValidationLine?: boolean; // show animated validation line for async validators
|
||||
showErrorsAsTooltip?: boolean; // 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
|
||||
@ -265,7 +267,7 @@ export class Input extends React.Component<InputProps, State> {
|
||||
|
||||
render() {
|
||||
const {
|
||||
multiLine, showValidationLine, validators, theme, maxRows, children,
|
||||
multiLine, showValidationLine, validators, theme, maxRows, children, showErrorsAsTooltip,
|
||||
maxLength, rows, disabled, autoSelectOnFocus, iconLeft, iconRight, contentRight,
|
||||
...inputProps
|
||||
} = this.props;
|
||||
@ -292,21 +294,31 @@ export class Input extends React.Component<InputProps, State> {
|
||||
ref: this.bindRef,
|
||||
spellCheck: "false",
|
||||
});
|
||||
|
||||
const tooltipId = showErrorsAsTooltip ? getRandId({ prefix: "input_tooltip_id" }) : undefined;
|
||||
const showErrors = errors.length > 0 && !valid && dirty;
|
||||
const errorsInfo = (
|
||||
<div className="errors box grow">
|
||||
{errors.map((error, i) => <p key={i}>{error}</p>)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={className}>
|
||||
<label className="input-area flex gaps align-center">
|
||||
<div id={tooltipId} className={className}>
|
||||
<label className="input-area flex gaps align-center" id="">
|
||||
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
|
||||
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
|
||||
{isString(iconRight) ? <Icon material={iconRight} /> : iconRight}
|
||||
{isString(iconRight) ? <Icon material={iconRight}/> : iconRight}
|
||||
{contentRight}
|
||||
</label>
|
||||
<div className="input-info flex gaps">
|
||||
{!valid && dirty && (
|
||||
<div className="errors box grow">
|
||||
{errors.map((error, i) => <p key={i}>{error}</p>)}
|
||||
{showErrorsAsTooltip && showErrors && (
|
||||
<Tooltip targetId={tooltipId} className="InputTooltipError">
|
||||
<div className="flex gaps align-center">
|
||||
<Icon material="error_outline"/>
|
||||
{errorsInfo}
|
||||
</div>
|
||||
)}
|
||||
</Tooltip>
|
||||
)}
|
||||
<div className="input-info flex gaps">
|
||||
{!showErrorsAsTooltip && showErrors && errorsInfo}
|
||||
{this.showMaxLenIndicator && (
|
||||
<div className="maxLengthIndicator box right">
|
||||
{this.getValue().length} / {maxLength}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
.Input.SearchInput {
|
||||
--compact-focus-width: 190px;
|
||||
--spacing: 6px 6px 6px 10px;
|
||||
|
||||
max-width: 900px;
|
||||
min-width: 220px;
|
||||
@ -10,7 +11,7 @@
|
||||
border: none;
|
||||
border-radius: $radius;
|
||||
box-shadow: 0 0 0 1px $halfGray;
|
||||
padding: 6px 6px 6px 10px;
|
||||
padding: var(--spacing);
|
||||
|
||||
.Icon {
|
||||
height: $margin * 2;
|
||||
|
||||
@ -7,7 +7,7 @@ import jsYaml from "js-yaml";
|
||||
import { Trans } from "@lingui/macro";
|
||||
import { AceEditor } from "../ace-editor";
|
||||
import { ServiceAccount } from "../../api/endpoints";
|
||||
import { copyToClipboard, cssNames, downloadFile } from "../../utils";
|
||||
import { copyToClipboard, cssNames, saveFileDialog } from "../../utils";
|
||||
import { Button } from "../button";
|
||||
import { Dialog, DialogProps } from "../dialog";
|
||||
import { Icon } from "../icon";
|
||||
@ -67,7 +67,7 @@ export class KubeConfigDialog extends React.Component<Props> {
|
||||
};
|
||||
|
||||
download = () => {
|
||||
downloadFile("config", this.config, "text/yaml");
|
||||
saveFileDialog("config", this.config, "text/yaml");
|
||||
};
|
||||
|
||||
render() {
|
||||
|
||||
@ -11,7 +11,6 @@ export interface WizardLayoutProps extends React.DOMAttributes<any> {
|
||||
infoPanelClass?: IClassName;
|
||||
infoPanel?: React.ReactNode;
|
||||
centered?: boolean; // Centering content horizontally
|
||||
contentProps?: React.DOMAttributes<HTMLElement>
|
||||
}
|
||||
|
||||
@observer
|
||||
@ -19,7 +18,7 @@ export class WizardLayout extends React.Component<WizardLayoutProps> {
|
||||
render() {
|
||||
const {
|
||||
className, contentClass, infoPanelClass, infoPanel, header, headerClass, centered,
|
||||
children, contentProps = {}, ...props
|
||||
children, ...props
|
||||
} = this.props;
|
||||
return (
|
||||
<div {...props} className={cssNames("WizardLayout", { centered }, className)}>
|
||||
@ -28,7 +27,7 @@ export class WizardLayout extends React.Component<WizardLayoutProps> {
|
||||
{header}
|
||||
</div>
|
||||
)}
|
||||
<div {...contentProps} className={cssNames("content-col flex column gaps", contentClass)}>
|
||||
<div className={cssNames("content-col flex column gaps", contentClass)}>
|
||||
<div className="flex column gaps">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { action, observable } from "mobx";
|
||||
import { autobind } from "../../utils";
|
||||
import isObject from "lodash/isObject";
|
||||
import uniqueId from "lodash/uniqueId";
|
||||
import { JsonApiErrorParsed } from "../../api/json-api";
|
||||
|
||||
@ -23,21 +22,25 @@ export interface Notification {
|
||||
|
||||
@autobind()
|
||||
export class NotificationsStore {
|
||||
public notifications = observable<Notification>([], { deep: false });
|
||||
public notifications = observable.array<Notification>([], { deep: false });
|
||||
|
||||
protected autoHideTimers = new Map<NotificationId, number>();
|
||||
|
||||
addAutoHideTimer(notification: Notification) {
|
||||
this.removeAutoHideTimer(notification);
|
||||
const { id, timeout } = notification;
|
||||
if (timeout) {
|
||||
const timer = window.setTimeout(() => this.remove(id), timeout);
|
||||
getById(id: NotificationId): Notification | null {
|
||||
return this.notifications.find(item => item.id === id) ?? null;
|
||||
}
|
||||
|
||||
addAutoHideTimer(id: NotificationId) {
|
||||
const notification = this.getById(id);
|
||||
if (!notification) return;
|
||||
this.removeAutoHideTimer(id);
|
||||
if (notification?.timeout) {
|
||||
const timer = window.setTimeout(() => this.remove(id), notification.timeout);
|
||||
this.autoHideTimers.set(id, timer);
|
||||
}
|
||||
}
|
||||
|
||||
removeAutoHideTimer(notification: Notification) {
|
||||
const { id } = notification;
|
||||
removeAutoHideTimer(id: NotificationId) {
|
||||
if (this.autoHideTimers.has(id)) {
|
||||
clearTimeout(this.autoHideTimers.get(id));
|
||||
this.autoHideTimers.delete(id);
|
||||
@ -45,22 +48,24 @@ export class NotificationsStore {
|
||||
}
|
||||
|
||||
@action
|
||||
add(notification: Notification) {
|
||||
if (!notification.id) {
|
||||
notification.id = uniqueId("notification_");
|
||||
add(notification: Notification): () => void {
|
||||
const id = notification.id ?? (
|
||||
notification.id = uniqueId("notification_")
|
||||
);
|
||||
const index = this.notifications.findIndex(item => item.id === id);
|
||||
if (index > -1) {
|
||||
this.notifications.splice(index, 1, notification); // update existing with same id
|
||||
} else {
|
||||
this.notifications.push(notification); // add new
|
||||
}
|
||||
const index = this.notifications.findIndex(item => item.id === notification.id);
|
||||
if (index > -1) this.notifications.splice(index, 1, notification);
|
||||
else this.notifications.push(notification);
|
||||
this.addAutoHideTimer(notification);
|
||||
this.addAutoHideTimer(id);
|
||||
return () => this.remove(id);
|
||||
}
|
||||
|
||||
@action
|
||||
remove(itemOrId: NotificationId | Notification) {
|
||||
if (!isObject(itemOrId)) {
|
||||
itemOrId = this.notifications.find(item => item.id === itemOrId);
|
||||
}
|
||||
return this.notifications.remove(itemOrId as Notification);
|
||||
remove(id: NotificationId) {
|
||||
this.removeAutoHideTimer(id);
|
||||
this.notifications.remove(this.getById(id));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@ import { reaction } from "mobx";
|
||||
import { disposeOnUnmount, observer } from "mobx-react";
|
||||
import { JsonApiErrorParsed } from "../../api/json-api";
|
||||
import { cssNames, prevDefault } from "../../utils";
|
||||
import { NotificationMessage, Notification, notificationsStore, NotificationStatus } from "./notifications.store";
|
||||
import { Notification, NotificationMessage, notificationsStore, NotificationStatus } from "./notifications.store";
|
||||
import { Animate } from "../animate";
|
||||
import { Icon } from "../icon";
|
||||
|
||||
@ -75,16 +75,16 @@ export class Notifications extends React.Component {
|
||||
<Animate key={id}>
|
||||
<div
|
||||
className={cssNames("notification flex align-center", status)}
|
||||
onMouseLeave={() => addAutoHideTimer(notification)}
|
||||
onMouseEnter={() => removeAutoHideTimer(notification)}>
|
||||
onMouseLeave={() => addAutoHideTimer(id)}
|
||||
onMouseEnter={() => removeAutoHideTimer(id)}>
|
||||
<div className="box center">
|
||||
<Icon material="info_outline" />
|
||||
<Icon material="info_outline"/>
|
||||
</div>
|
||||
<div className="message box grow">{msgText}</div>
|
||||
<div className="box center">
|
||||
<Icon
|
||||
material="close" className="close"
|
||||
onClick={prevDefault(() => remove(notification))}
|
||||
onClick={prevDefault(() => remove(id))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,15 +1,20 @@
|
||||
|
||||
.Tooltip {
|
||||
--bgc: #{$mainBackground};
|
||||
--radius: #{$radius};
|
||||
--color: #{$textColorSecondary};
|
||||
--border: 1px solid #{$borderColor};
|
||||
|
||||
// use positioning relative to viewport (window)
|
||||
// https://developer.mozilla.org/en-US/docs/Web/CSS/position
|
||||
position: fixed;
|
||||
margin: 0 !important;
|
||||
background: $mainBackground;
|
||||
background: var(--bgc);
|
||||
font-size: small;
|
||||
font-weight: normal;
|
||||
border: 1px solid $borderColor;
|
||||
border-radius: $radius;
|
||||
color: $textColorSecondary;
|
||||
border: var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--color);
|
||||
white-space: normal;
|
||||
padding: .5em;
|
||||
text-align: center;
|
||||
|
||||
@ -1,19 +1,25 @@
|
||||
// Helper for selecting element's text content and copy in clipboard
|
||||
|
||||
export function copyToClipboard(elem: HTMLElement, resetSelection = true): boolean {
|
||||
export function copyToClipboard(elem: HTMLElement, { resetSelection = true } = {}) {
|
||||
let clearSelection: () => void;
|
||||
if (isSelectable(elem)) {
|
||||
elem.select();
|
||||
clearSelection = () => elem.setSelectionRange(0, 0);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
const selection = window.getSelection();
|
||||
selection.selectAllChildren(elem);
|
||||
clearSelection = () => selection.removeAllRanges();
|
||||
}
|
||||
const copyResult = document.execCommand("copy");
|
||||
if (resetSelection) clearSelection();
|
||||
return copyResult;
|
||||
const selectedText = document.getSelection().toString();
|
||||
const isCopied = document.execCommand("copy");
|
||||
if (resetSelection) {
|
||||
clearSelection();
|
||||
}
|
||||
return {
|
||||
copied: isCopied,
|
||||
copiedText: selectedText,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
|
||||
function isSelectable(elem: HTMLElement): elem is HTMLInputElement {
|
||||
|
||||
@ -1,12 +0,0 @@
|
||||
export function downloadFile(filename: string, contents: any, type: string) {
|
||||
const data = new Blob([contents], { type });
|
||||
const url = URL.createObjectURL(data);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@ -7,7 +7,7 @@ export * from "../../common/utils";
|
||||
export * from "./cssVar";
|
||||
export * from "./cssNames";
|
||||
export * from "../../common/event-emitter";
|
||||
export * from "./downloadFile";
|
||||
export * from "./saveFile";
|
||||
export * from "./prevDefault";
|
||||
export * from "./createStorage";
|
||||
export * from "./interval";
|
||||
|
||||
18
src/renderer/utils/saveFile.ts
Normal file
18
src/renderer/utils/saveFile.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* Request default save-file dialog in browser.
|
||||
* @param filename Name of file to be saved locally
|
||||
* @param contents String or Buffer
|
||||
* @param type Content-type
|
||||
*/
|
||||
export function saveFileDialog(filename: string, contents: BlobPart | BlobPart[], type: string) {
|
||||
const data = new Blob([contents].flat(), { type });
|
||||
const url = URL.createObjectURL(data);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
28
yarn.lock
28
yarn.lock
@ -2410,10 +2410,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/tapable/-/tapable-1.0.5.tgz#9adbc12950582aa65ead76bffdf39fe0c27a3c02"
|
||||
integrity sha512-/gG2M/Imw7cQFp8PGvz/SwocNrmKFjFsm5Pb8HdbHkZ1K8pmuPzOX4VeVoiEecFCVf4CsN1r3/BRvx+6sNqwtQ==
|
||||
|
||||
"@types/tar@^4.0.3":
|
||||
version "4.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/tar/-/tar-4.0.3.tgz#e2cce0b8ff4f285293243f5971bd7199176ac489"
|
||||
integrity sha512-Z7AVMMlkI8NTWF0qGhC4QIX0zkV/+y0J8x7b/RsHrN0310+YNjoJd8UrApCiGBCWtKjxS9QhNqLi2UJNToh5hA==
|
||||
"@types/tar@^4.0.4":
|
||||
version "4.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@types/tar/-/tar-4.0.4.tgz#d680de60855e7778a51c672b755869a3b8d2889f"
|
||||
integrity sha512-0Xv+xcmkTsOZdIF4yCnd7RkOOyfyqPaqJ7RZFKnwdxfDbkN3eAAE9sHl8zJFqBz4VhxolW9EErbjR1oyH7jK2A==
|
||||
dependencies:
|
||||
"@types/minipass" "*"
|
||||
"@types/node" "*"
|
||||
@ -9819,6 +9819,14 @@ minizlib@^2.1.0:
|
||||
minipass "^3.0.0"
|
||||
yallist "^4.0.0"
|
||||
|
||||
minizlib@^2.1.1:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
|
||||
integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==
|
||||
dependencies:
|
||||
minipass "^3.0.0"
|
||||
yallist "^4.0.0"
|
||||
|
||||
mississippi@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/mississippi/-/mississippi-3.0.0.tgz#ea0a3291f97e0b5e8776b363d5f0a12d94c67022"
|
||||
@ -13731,6 +13739,18 @@ tar@^6.0.2:
|
||||
mkdirp "^1.0.3"
|
||||
yallist "^4.0.0"
|
||||
|
||||
tar@^6.0.5:
|
||||
version "6.0.5"
|
||||
resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f"
|
||||
integrity sha512-0b4HOimQHj9nXNEAA7zWwMM91Zhhba3pspja6sQbgTpynOJf+bkjBnfybNYzbpLbnwXnbyB4LOREvlyXLkCHSg==
|
||||
dependencies:
|
||||
chownr "^2.0.0"
|
||||
fs-minipass "^2.0.0"
|
||||
minipass "^3.0.0"
|
||||
minizlib "^2.1.1"
|
||||
mkdirp "^1.0.3"
|
||||
yallist "^4.0.0"
|
||||
|
||||
tcp-port-used@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/tcp-port-used/-/tcp-port-used-1.0.1.tgz#46061078e2d38c73979a2c2c12b5a674e6689d70"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user