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

fixes & refactoring

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman 2020-11-24 14:41:39 +02:00
parent 8b6616a591
commit 5fc0c639a1
10 changed files with 169 additions and 149 deletions

View File

@ -6,18 +6,18 @@ pr:
trigger: trigger:
branches: branches:
include: include:
- '*' - '*'
tags: tags:
include: include:
- "*" - "*"
jobs: jobs:
- job: Windows - job: Windows
pool: pool:
vmImage: windows-2019 vmImage: windows-2019
strategy: strategy:
matrix: matrix:
node_14.x: node_12.x:
node_version: 14.x node_version: 12.x
steps: steps:
- powershell: | - powershell: |
$CI_BUILD_TAG = git describe --tags $CI_BUILD_TAG = git describe --tags
@ -58,8 +58,8 @@ jobs:
vmImage: macOS-10.14 vmImage: macOS-10.14
strategy: strategy:
matrix: matrix:
node_14.x: node_12.x:
node_version: 14.x node_version: 12.x
steps: steps:
- script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG"
displayName: Set the tag name as an environment variable displayName: Set the tag name as an environment variable
@ -104,8 +104,8 @@ jobs:
vmImage: ubuntu-16.04 vmImage: ubuntu-16.04
strategy: strategy:
matrix: matrix:
node_14.x: node_12.x:
node_version: 14.x node_version: 12.x
steps: steps:
- script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG" - script: CI_BUILD_TAG=`git describe --tags` && echo "##vso[task.setvariable variable=CI_BUILD_TAG]$CI_BUILD_TAG"
displayName: Set the tag name as an environment variable displayName: Set the tag name as an environment variable

View File

@ -47,7 +47,7 @@
"bundledHelmVersion": "3.3.4" "bundledHelmVersion": "3.3.4"
}, },
"engines": { "engines": {
"node": ">=12 <=14" "node": ">=12 <13"
}, },
"lingui": { "lingui": {
"locales": [ "locales": [

View File

@ -11,24 +11,23 @@ export interface DownloadFileTicket {
cancel(): void; cancel(): void;
} }
export function downloadFile(opts: DownloadFileOptions): DownloadFileTicket { export function downloadFile({ url, gzip = true }: DownloadFileOptions): DownloadFileTicket {
const { url, gzip = true } = opts;
const fileChunks: Buffer[] = []; const fileChunks: Buffer[] = [];
const req = request(url, { gzip }); const req = request(url, { gzip });
const promise: Promise<Buffer> = new Promise((resolve, reject) => { const promise: Promise<Buffer> = new Promise((resolve, reject) => {
req.on("data", (chunk: Buffer) => { req.on("data", (chunk: Buffer) => {
fileChunks.push(chunk); fileChunks.push(chunk);
}); });
req.on("complete", () => { req.once("error", err => {
resolve(Buffer.concat(fileChunks));
});
req.on("error", err => {
reject({ url, err }); reject({ url, err });
}); });
req.once("complete", () => {
resolve(Buffer.concat(fileChunks));
});
}); });
return { return {
url: url, url,
promise: promise, promise,
cancel() { cancel() {
req.abort(); req.abort();
} }

View File

@ -25,10 +25,10 @@ export function readFileFromTar(tarFilePath: string, opts: ReadFileFromTarOpts):
entry.on("data", chunk => { entry.on("data", chunk => {
fileChunks.push(chunk); fileChunks.push(chunk);
}); });
entry.on("error", err => { entry.once("error", err => {
reject(`Reading ${entry.path} error: ${err}`); reject(`Reading ${entry.path} error: ${err}`);
}); });
entry.on("end", () => { entry.once("end", () => {
resolve(Buffer.concat(fileChunks)); resolve(Buffer.concat(fileChunks));
}); });
}, },

View File

@ -41,3 +41,5 @@ export const apiKubePrefix = "/api-kube"; // k8s cluster apis
// Links // Links
export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues"; export const issuesTrackerUrl = "https://github.com/lensapp/lens/issues";
export const slackUrl = "https://join.slack.com/t/k8slens/shared_invite/enQtOTc5NjAyNjYyOTk4LWU1NDQ0ZGFkOWJkNTRhYTc2YjVmZDdkM2FkNGM5MjhiYTRhMDU2NDQ1MzIyMDA4ZGZlNmExOTc0N2JmY2M3ZGI"; 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

@ -97,15 +97,6 @@ export class ExtensionManager {
} }
} }
getNpmPackageTarballUrl(packageName: string) {
try {
const command = [this.npmPath, "view", packageName, "dist.tarball", "--silent"];
return child_process.execSync(command.join(" "), { encoding: "utf8" }).trim();
} catch (err) {
return null;
}
}
protected installPackages(): Promise<void> { protected installPackages(): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const child = child_process.fork(this.npmPath, ["install", "--silent", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"], { const child = child_process.fork(this.npmPath, ["install", "--silent", "--no-audit", "--only=prod", "--prefer-offline", "--no-package-lock"], {

View File

@ -1,7 +1,7 @@
import { app, BrowserWindow, dialog, ipcMain, IpcMainEvent, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron"; import { app, BrowserWindow, dialog, ipcMain, IpcMainEvent, Menu, MenuItem, MenuItemConstructorOptions, webContents, shell } from "electron";
import { autorun } from "mobx"; import { autorun } from "mobx";
import { WindowManager } from "./window-manager"; 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 { addClusterURL } from "../renderer/components/+add-cluster/add-cluster.route";
import { preferencesURL } from "../renderer/components/+preferences/preferences.route"; import { preferencesURL } from "../renderer/components/+preferences/preferences.route";
import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route"; import { whatsNewURL } from "../renderer/components/+whats-new/whats-new.route";
@ -24,6 +24,7 @@ export function showAbout(browserWindow: BrowserWindow) {
`${appName}: ${app.getVersion()}`, `${appName}: ${app.getVersion()}`,
`Electron: ${process.versions.electron}`, `Electron: ${process.versions.electron}`,
`Chrome: ${process.versions.chrome}`, `Chrome: ${process.versions.chrome}`,
`Node: ${process.versions.node}`,
`Copyright 2020 Mirantis, Inc.`, `Copyright 2020 Mirantis, Inc.`,
]; ];
dialog.showMessageBoxSync(browserWindow, { dialog.showMessageBoxSync(browserWindow, {
@ -215,13 +216,13 @@ export function buildMenu(windowManager: WindowManager) {
{ {
label: "Documentation", label: "Documentation",
click: async () => { click: async () => {
shell.openExternal('https://docs.k8slens.dev/'); shell.openExternal(docsUrl);
}, },
}, },
{ {
label: "Support", label: "Support",
click: async () => { click: async () => {
shell.openExternal('https://docs.k8slens.dev/latest/support/'); shell.openExternal(supportUrl);
}, },
}, },
...ignoreOnMac([ ...ignoreOnMac([

View File

@ -22,16 +22,6 @@
> .flex.gaps { > .flex.gaps {
--flex-gap: #{$padding}; --flex-gap: #{$padding};
} }
.install-extension {
.Clipboard {
font-size: $font-size-small;
&:hover {
color: $textColorSecondary;
}
}
}
} }
.extensions-path { .extensions-path {
@ -43,10 +33,24 @@
} }
} }
.Clipboard {
display: inline;
vertical-align: baseline;
font-size: $font-size-small;
&:hover {
color: $textColorSecondary;
}
}
.SearchInput { .SearchInput {
--spacing: #{$padding}; --spacing: #{$padding};
} }
.SubTitle {
font-style: italic;
}
.WizardLayout { .WizardLayout {
padding: 0; padding: 0;

View File

@ -12,6 +12,7 @@ import { Button } from "../button";
import { WizardLayout } from "../layout/wizard-layout"; import { WizardLayout } from "../layout/wizard-layout";
import { DropFileInput, Input, InputValidators, SearchInput } from "../input"; import { DropFileInput, Input, InputValidators, SearchInput } from "../input";
import { Icon } from "../icon"; import { Icon } from "../icon";
import { SubTitle } from "../layout/sub-title";
import { PageLayout } from "../layout/page-layout"; import { PageLayout } from "../layout/page-layout";
import { Clipboard } from "../clipboard"; import { Clipboard } from "../clipboard";
import logger from "../../../main/logger"; import logger from "../../../main/logger";
@ -21,6 +22,7 @@ import { LensExtensionManifest, sanitizeExtensionName } from "../../../extension
import { Notifications } from "../notifications"; import { Notifications } from "../notifications";
import { downloadFile } from "../../../common/utils"; import { downloadFile } from "../../../common/utils";
import { extractTar, readFileFromTar } from "../../../common/utils/tar"; import { extractTar, readFileFromTar } from "../../../common/utils/tar";
import { docsUrl } from "../../../common/vars";
interface InstallRequest { interface InstallRequest {
fileName: string; fileName: string;
@ -28,7 +30,11 @@ interface InstallRequest {
data?: Buffer; data?: Buffer;
} }
interface InstallRequestValidated extends InstallRequest { interface InstallRequestPreloaded extends InstallRequest {
data: Buffer;
}
interface InstallRequestValidated extends InstallRequestPreloaded {
manifest: LensExtensionManifest; manifest: LensExtensionManifest;
tmpFile: string; // temp file for unpacking tmpFile: string; // temp file for unpacking
} }
@ -54,6 +60,10 @@ export class Extensions extends React.Component {
return extensionManager.localFolderPath; return extensionManager.localFolderPath;
} }
getExtensionPackageTemp(fileName = "") {
return path.join(os.tmpdir(), "lens-extensions", fileName);
}
getExtensionDestFolder(name: string) { getExtensionDestFolder(name: string) {
return path.join(this.extensionsPath, sanitizeExtensionName(name)); return path.join(this.extensionsPath, sanitizeExtensionName(name));
} }
@ -79,24 +89,16 @@ export class Extensions extends React.Component {
} }
}; };
installExtensions = () => { addExtensions = () => {
if (this.downloadUrl) { const { downloadUrl } = this;
this.installFromNpmOrUrl(this.downloadUrl); if (downloadUrl && InputValidators.isUrl.validate(downloadUrl)) {
this.downloadUrl = ""; this.installFromUrl(downloadUrl);
} else { } else {
this.installFromSelectFileDialog(); this.installFromSelectFileDialog();
} }
}; };
installFromNpmOrUrl = async (url = this.downloadUrl) => { installFromUrl = async (url: string) => {
if (!InputValidators.isUrl.validate(url)) {
const npmPackageName = url;
url = extensionManager.getNpmPackageTarballUrl(npmPackageName);
if (!url) {
Notifications.error(`Error: npm package "${npmPackageName}" not found!`);
return;
}
}
try { try {
const { promise: filePromise } = downloadFile({ url }); const { promise: filePromise } = downloadFile({ url });
this.requestInstall([{ this.requestInstall([{
@ -105,10 +107,7 @@ export class Extensions extends React.Component {
}]); }]);
} catch (err) { } catch (err) {
Notifications.error( Notifications.error(
<div className="flex column gaps"> <p>Installation via URL has failed: <b>{String(err)}</b></p>
<p>Installation from URL has failed: <b>{String(err)}</b></p>
<p>URL: <em>{url}</em></p>
</div>
); );
} }
}; };
@ -123,89 +122,110 @@ export class Extensions extends React.Component {
); );
}; };
async requestInstall(installRequests: InstallRequest[]) { async preloadExtensions(requests: InstallRequest[], { showError = true } = {}) {
const pendingFiles: Promise<any>[] = []; 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[];
}
// read extensions with provided system path if any async validatePackage(filePath: string): Promise<LensExtensionManifest> {
installRequests.forEach(ext => { const packageJson: Buffer = await readFileFromTar(filePath, {
if (ext.data) return; // tarball from npm contains single root folder "package/*"
const promise = fse.readFile(ext.filePath) fileMatcher: (path: string) => !!path.match(/(\w+\/)?package\.json$/),
.then(data => ext.data = data) notFoundMessage: "Invalid extension, package.json not found",
.catch(err => {
Notifications.error(`Error while reading "${ext.filePath}": ${String(err)}`);
});
pendingFiles.push(promise);
}); });
await Promise.all(pendingFiles); const manifest: LensExtensionManifest = JSON.parse(packageJson.toString("utf8"));
installRequests = installRequests.filter(item => item.data); // remove items with reading errors if (!manifest.lens && !manifest.renderer) {
throw `package.json must specify "main" and/or "renderer" fields`;
}
return manifest;
}
// prepare temp folder async createTempFilesAndValidate(requests: InstallRequestPreloaded[], { showErrors = true } = {}) {
const tempFolder = path.join(os.tmpdir(), "lens-extensions"); const validatedRequests: InstallRequestValidated[] = [];
await fse.ensureDir(tempFolder);
// copy files to temp, get extension info from package.json and do basic validation // copy files to temp
const validatedInstalls: Promise<InstallRequestValidated>[] = installRequests.map(async installReq => { await fse.ensureDir(this.getExtensionPackageTemp());
const { fileName, data } = installReq; requests.forEach(req => {
const tempFile = path.join(tempFolder, fileName); const tempFile = this.getExtensionPackageTemp(req.fileName);
await fse.writeFileSync(tempFile, data); // copy to temp fse.writeFileSync(tempFile, req.data);
try { });
const packageJson: Buffer = await readFileFromTar(tempFile, {
// tarball from npm contains single root folder "package/*" // validate packages
fileMatcher: (path: string) => !!path.match(/(\w+\/)?package\.json$/), await Promise.all(
notFoundMessage: "Extension's manifest file (package.json) not found", requests.map(async req => {
}); const tempFile = this.getExtensionPackageTemp(req.fileName);
const manifest: LensExtensionManifest = JSON.parse(packageJson.toString("utf8")); try {
if (!manifest.lens && !manifest.renderer) { const manifest = await this.validatePackage(tempFile);
throw `package.json must specify "main" and/or "renderer" fields`; validatedRequests.push({
...req,
manifest: manifest,
tmpFile: 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 { })
...installReq, );
manifest: manifest, return validatedRequests;
tmpFile: tempFile, }
};
} catch (err) { async requestInstall(requests: InstallRequest[]) {
fse.unlink(tempFile).catch(() => null); // remove invalid temp file const preloadedRequests = await this.preloadExtensions(requests);
Notifications.error( const validatedRequests = await this.createTempFilesAndValidate(preloadedRequests);
<div className="flex column gaps">
<p>Installing <em>{fileName}</em> has failed, skipping.</p> validatedRequests.forEach(install => {
<p>Reason: <em>{String(err)}</em></p> 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> </div>
); );
} }
}); });
// final step, provide UI with extension info for reviewing and confirming installation
const extensions = await Promise.all(validatedInstalls);
extensions.forEach(install => {
if (!install) {
return; // skip validating errors if any
}
const { fileName, manifest } = install;
const { name, version, description } = manifest;
const extensionFolder = this.getExtensionDestFolder(name);
const folderExists = fse.existsSync(extensionFolder);
const removeNotification = Notifications.info(
<div className="InstallingExtensionNotification flex gaps align-center">
<div className="flex column gaps">
<p>Install extension <b title={fileName}>{name}@{version}</b>?</p>
<p>Description: <em>{description}</em></p>
{folderExists && (
<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, tmpFile, manifest: { name, version } }: InstallRequestValidated) { async unpackExtension({ fileName, tmpFile, manifest: { name, version } }: InstallRequestValidated) {
logger.info(`Unpacking extension ${name} from ${fileName}`); const extName = `${name}@${version}`;
logger.info(`Unpacking extension ${extName}`, { fileName, tmpFile });
const unpackingTempFolder = path.join(path.dirname(tmpFile), path.basename(tmpFile) + "-unpacked"); const unpackingTempFolder = path.join(path.dirname(tmpFile), path.basename(tmpFile) + "-unpacked");
const extensionFolder = this.getExtensionDestFolder(name); const extensionFolder = this.getExtensionDestFolder(name);
try { try {
@ -218,18 +238,18 @@ export class Extensions extends React.Component {
const unpackedFiles = await fse.readdir(unpackingTempFolder); const unpackedFiles = await fse.readdir(unpackingTempFolder);
let unpackedRootFolder = unpackingTempFolder; let unpackedRootFolder = unpackingTempFolder;
if (unpackedFiles.length === 1) { if (unpackedFiles.length === 1) {
// handle case when extension.tgz packed with top root folder, // check if %extension.tgz was packed with single top folder,
// e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball // e.g. "npm pack %ext_name" downloads file with "package" root folder within tarball
unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]); unpackedRootFolder = path.join(unpackingTempFolder, unpackedFiles[0]);
} }
await fse.ensureDir(extensionFolder); await fse.ensureDir(extensionFolder);
await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true }); await fse.move(unpackedRootFolder, extensionFolder, { overwrite: true });
Notifications.ok( Notifications.ok(
<p>Extension <b>{name}/{version}</b> successfully installed!</p> <p>Extension <b>{extName}</b> successfully installed!</p>
); );
} catch (err) { } catch (err) {
Notifications.error( Notifications.error(
<p>Installing extension <b>{name}</b> has failed: <em>{err}</em></p> <p>Installing extension <b>{extName}</b> has failed: <em>{err}</em></p>
); );
} finally { } finally {
// clean up // clean up
@ -247,34 +267,38 @@ export class Extensions extends React.Component {
features of Lens are built as extensions and use the same Extension API. features of Lens are built as extensions and use the same Extension API.
</div> </div>
<div> <div>
<p><em>Extensions loaded from:</em></p> <SubTitle title="Extensions loaded from:"/>
<div className="extensions-path flex inline" onClick={() => shell.openPath(this.extensionsPath)}> <div className="extensions-path flex inline" onClick={() => shell.openPath(this.extensionsPath)}>
<Icon material="folder" tooltip={{ children: "Open folder", preferredPositions: "bottom" }}/> <Icon material="folder" tooltip={{ children: "Open folder", preferredPositions: "bottom" }}/>
<code>{this.extensionsPath}</code> <code>{this.extensionsPath}</code>
</div> </div>
</div> </div>
<div className="install-extension flex column gaps"> <div className="install-extension flex column gaps">
<em> <SubTitle title="Install extensions:"/>
Install extensions from tarball ({this.supportedFormats.join(", ")}):
</em>
<Input <Input
showErrorsAsTooltip={true} showErrorsAsTooltip={true}
className="box grow" className="box grow"
theme="round-black" theme="round-black"
placeholder="URL or npm-package-name" iconLeft="link"
placeholder={`URL to packed extension (${this.supportedFormats.join(", ")})`}
validators={InputValidators.isUrl}
value={this.downloadUrl} value={this.downloadUrl}
onChange={v => this.downloadUrl = v} onChange={v => this.downloadUrl = v}
onSubmit={this.installExtensions} onSubmit={this.addExtensions}
/> />
<Button <Button
primary primary
label="Add extensions" label="Add extensions"
onClick={this.installExtensions} onClick={this.addExtensions}
/> />
<p className="hint"> <p className="hint">
<Trans><b>Pro-Tip 1</b>: you can download packed extension from NPM via</Trans> <Trans><b>Pro-Tip 1</b>: you can obtain package tarball from NPM via</Trans>{" "}
<Clipboard showNotification> <Clipboard showNotification>
<code>npm pack %package-name</code> <code>npm view %package dist.tarball</code>
</Clipboard>
<span> or download package first with </span>
<Clipboard showNotification>
<code>npm pack %package</code>
</Clipboard> </Clipboard>
</p> </p>
<p className="hint"> <p className="hint">
@ -284,7 +308,7 @@ export class Extensions extends React.Component {
<div className="more-info flex inline gaps align-center"> <div className="more-info flex inline gaps align-center">
<Icon material="local_fire_department"/> <Icon material="local_fire_department"/>
<p> <p>
Check out documentation to <a href="https://docs.k8slens.dev/" target="_blank">learn more</a> Check out documentation to <a href={docsUrl} target="_blank">learn more</a>
</p> </p>
</div> </div>
</div> </div>

View File

@ -88,12 +88,11 @@
//- Themes //- Themes
&.theme { &.theme {
&.invalid {
box-shadow: 0 0 0 2px $colorError;
border-radius: $radius;
}
&.round-black { &.round-black {
&.invalid label {
border-color: $colorSoftError !important;
}
label { label {
background: $mainBackground; background: $mainBackground;
border: 1px solid $borderFaintColor; border: 1px solid $borderFaintColor;
@ -115,6 +114,6 @@
.Tooltip.InputTooltipError { .Tooltip.InputTooltipError {
--bgc: #{$colorError}; --bgc: #{$colorError};
--border: 1px solid $colorSoftError; --border: none;
--color: white; --color: white;
} }