1
0
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:
Roman 2020-11-25 09:55:28 +02:00 committed by GitHub
parent 2da598b66e
commit 77ae31550a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 799 additions and 193 deletions

View File

@ -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",

View 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();
}
};
}

View 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
}

View File

@ -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
View 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,
});
}

View File

@ -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/";

View File

@ -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)

View File

@ -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("/", "--");
}

View File

@ -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", () => {

View File

@ -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

View File

@ -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([

View File

@ -1,12 +1,4 @@
.AddCluster {
.droppable {
box-shadow: 0 0 0 5px inset $primary;
> * {
pointer-events: none;
}
}
.hint {
margin-top: -$padding;
color: $textColorSecondary;

View File

@ -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,34 +348,17 @@ 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);
}
}}
>
<DropFileInput onDropFiles={this.onDropKubeConfig}>
<WizardLayout className="AddCluster" infoPanel={this.renderInfo()}>
<h2><Trans>Add Cluster</Trans></h2>
{this.renderKubeConfigSource()}
{this.renderContextSelector()}
@ -409,6 +396,7 @@ export class AddCluster extends React.Component {
/>
</div>
</WizardLayout>
</DropFileInput>
);
}
}

View File

@ -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;
}
}

View File

@ -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 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>
<div>
Check out documentation to <a href="https://docs.k8slens.dev/" target="_blank">learn more</a>
<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>}>
<DropFileInput onDropFiles={this.installOnDrop}>
<WizardLayout infoPanel={this.renderInfo()}>
<Input
autoFocus
theme="round-black"
className="SearchInput"
<SearchInput
placeholder={_i18n._(t`Search extensions`)}
value={this.search}
onChange={(value) => this.search = value}
/>
<div className="extension-list">
<div className="extensions-list">
{this.renderExtensions()}
</div>
</WizardLayout>
</DropFileInput>
</PageLayout>
);
}

View File

@ -0,0 +1,3 @@
.Clipboard {
cursor: pointer;
}

View 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;
}
}
}

View File

@ -0,0 +1 @@
export * from "./clipboard";

View File

@ -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) => {

View File

@ -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()

View File

@ -0,0 +1,9 @@
.DropFileInput {
&.droppable {
box-shadow: 0 0 0 5px $primary; // fixme: might not work sometimes
> * {
pointer-events: none;
}
}
}

View 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;
}
}
}

View File

@ -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';

View File

@ -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;
}

View File

@ -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",
});
return (
<div className={className}>
<label className="input-area flex gaps align-center">
{isString(iconLeft) ? <Icon material={iconLeft}/> : iconLeft}
{multiLine ? <textarea {...inputProps as any} /> : <input {...inputProps as any} />}
{isString(iconRight) ? <Icon material={iconRight} /> : iconRight}
{contentRight}
</label>
<div className="input-info flex gaps">
{!valid && dirty && (
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 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}
{contentRight}
</label>
{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}

View File

@ -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;

View File

@ -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() {

View File

@ -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>

View File

@ -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));
}
}

View File

@ -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>

View File

@ -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;

View File

@ -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 {

View File

@ -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);
}

View File

@ -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";

View 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);
}

View File

@ -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"