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

Add a few missing folders to be linted.

Signed-off-by: Panu Horsmalahti <phorsmalahti@mirantis.com>
This commit is contained in:
Panu Horsmalahti 2020-11-19 17:05:26 +02:00
parent 1477bb8274
commit 0b182ccf6f
29 changed files with 376 additions and 374 deletions

View File

@ -5,7 +5,7 @@ module.exports = {
getVersion: jest.fn().mockReturnValue("3.0.0"),
getLocale: jest.fn().mockRejectedValue("en"),
getPath: jest.fn((name: string) => {
return "tmp"
return "tmp";
}),
},
remote: {

View File

@ -1,9 +1,9 @@
// Generate tray icons from SVG to PNG + different sizes and colors (B&W)
// Command: `yarn build:tray-icons`
import path from "path"
import path from "path";
import sharp from "sharp";
import jsdom from "jsdom"
import fs from "fs-extra"
import jsdom from "jsdom";
import fs from "fs-extra";
export async function generateTrayIcon(
{
@ -14,15 +14,15 @@ export async function generateTrayIcon(
pixelSize = 32,
shouldUseDarkColors = false, // managed by electron.nativeTheme.shouldUseDarkColors
} = {}) {
outputFilename += shouldUseDarkColors ? "_dark" : ""
dpiSuffix = dpiSuffix !== "1x" ? `@${dpiSuffix}` : ""
const pngIconDestPath = path.resolve(outputFolder, `${outputFilename}${dpiSuffix}.png`)
outputFilename += shouldUseDarkColors ? "_dark" : "";
dpiSuffix = dpiSuffix !== "1x" ? `@${dpiSuffix}` : "";
const pngIconDestPath = path.resolve(outputFolder, `${outputFilename}${dpiSuffix}.png`);
try {
// Modify .SVG colors
const trayIconColor = shouldUseDarkColors ? "white" : "black";
const svgDom = await jsdom.JSDOM.fromFile(svgIconPath);
const svgRoot = svgDom.window.document.body.getElementsByTagName("svg")[0];
svgRoot.innerHTML += `<style>* {fill: ${trayIconColor} !important;}</style>`
svgRoot.innerHTML += `<style>* {fill: ${trayIconColor} !important;}</style>`;
const svgIconBuffer = Buffer.from(svgRoot.outerHTML);
// Resize and convert to .PNG

View File

@ -1,3 +1,3 @@
import { helmCli } from "../src/main/helm/helm-cli"
import { helmCli } from "../src/main/helm/helm-cli";
helmCli.ensureBinary()
helmCli.ensureBinary();

View File

@ -1,10 +1,10 @@
import packageInfo from "../package.json"
import fs from "fs"
import request from "request"
import md5File from "md5-file"
import requestPromise from "request-promise-native"
import { ensureDir, pathExists } from "fs-extra"
import path from "path"
import packageInfo from "../package.json";
import fs from "fs";
import request from "request";
import md5File from "md5-file";
import requestPromise from "request-promise-native";
import { ensureDir, pathExists } from "fs-extra";
import path from "path";
class KubectlDownloader {
public kubectlVersion: string
@ -14,7 +14,7 @@ class KubectlDownloader {
constructor(clusterVersion: string, platform: string, arch: string, target: string) {
this.kubectlVersion = clusterVersion;
const binaryName = platform === "windows" ? "kubectl.exe" : "kubectl"
const binaryName = platform === "windows" ? "kubectl.exe" : "kubectl";
this.url = `https://storage.googleapis.com/kubernetes-release/release/v${this.kubectlVersion}/bin/${platform}/${arch}/${binaryName}`;
this.dirname = path.dirname(target);
this.path = target;
@ -25,83 +25,85 @@ class KubectlDownloader {
method: "HEAD",
uri: this.url,
resolveWithFullResponse: true
}).catch((error) => { console.log(error) })
}).catch((error) => { console.log(error); });
if (response.headers["etag"]) {
return response.headers["etag"].replace(/"/g, "")
return response.headers["etag"].replace(/"/g, "");
}
return ""
return "";
}
public async checkBinary() {
const exists = await pathExists(this.path)
const exists = await pathExists(this.path);
if (exists) {
const hash = md5File.sync(this.path)
const etag = await this.urlEtag()
const hash = md5File.sync(this.path);
const etag = await this.urlEtag();
if(hash == etag) {
console.log("Kubectl md5sum matches the remote etag")
return true
console.log("Kubectl md5sum matches the remote etag");
return true;
}
console.log("Kubectl md5sum " + hash + " does not match the remote etag " + etag + ", unlinking and downloading again")
await fs.promises.unlink(this.path)
console.log("Kubectl md5sum " + hash + " does not match the remote etag " + etag + ", unlinking and downloading again");
await fs.promises.unlink(this.path);
}
return false
return false;
}
public async downloadKubectl() {
const exists = await this.checkBinary();
if(exists) {
console.log("Already exists and is valid")
return
console.log("Already exists and is valid");
return;
}
await ensureDir(path.dirname(this.path), 0o755)
await ensureDir(path.dirname(this.path), 0o755);
const file = fs.createWriteStream(this.path)
console.log(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`)
const file = fs.createWriteStream(this.path);
console.log(`Downloading kubectl ${this.kubectlVersion} from ${this.url} to ${this.path}`);
const requestOpts: request.UriOptions & request.CoreOptions = {
uri: this.url,
gzip: true
}
const stream = request(requestOpts)
};
const stream = request(requestOpts);
stream.on("complete", () => {
console.log("kubectl binary download finished")
file.end(() => {})
})
console.log("kubectl binary download finished");
// eslint-disable-next-line @typescript-eslint/no-empty-function
file.end(() => {});
});
stream.on("error", (error) => {
console.log(error)
fs.unlink(this.path, () => {})
throw(error)
})
console.log(error);
// eslint-disable-next-line @typescript-eslint/no-empty-function
fs.unlink(this.path, () => {});
throw(error);
});
return new Promise((resolve, reject) => {
file.on("close", () => {
console.log("kubectl binary download closed")
console.log("kubectl binary download closed");
fs.chmod(this.path, 0o755, (err) => {
if (err) reject(err);
})
resolve()
})
stream.pipe(file)
})
});
resolve();
});
stream.pipe(file);
});
}
}
const downloadVersion = packageInfo.config.bundledKubectlVersion;
const baseDir = path.join(process.env.INIT_CWD, 'binaries', 'client')
const baseDir = path.join(process.env.INIT_CWD, 'binaries', 'client');
const downloads = [
{ platform: 'linux', arch: 'amd64', target: path.join(baseDir, 'linux', 'x64', 'kubectl') },
{ platform: 'darwin', arch: 'amd64', target: path.join(baseDir, 'darwin', 'x64', 'kubectl') },
{ platform: 'windows', arch: 'amd64', target: path.join(baseDir, 'windows', 'x64', 'kubectl.exe') },
{ platform: 'windows', arch: '386', target: path.join(baseDir, 'windows', 'ia32', 'kubectl.exe') }
]
];
downloads.forEach((dlOpts) => {
console.log(dlOpts)
console.log(dlOpts);
const downloader = new KubectlDownloader(downloadVersion, dlOpts.platform, dlOpts.arch, dlOpts.target);
console.log("Downloading: " + JSON.stringify(dlOpts));
downloader.downloadKubectl().then(() => downloader.checkBinary().then(() => console.log("Download complete")))
})
downloader.downloadKubectl().then(() => downloader.checkBinary().then(() => console.log("Download complete")));
});

View File

@ -1,4 +1,4 @@
const { notarize } = require('electron-notarize')
const { notarize } = require('electron-notarize');
exports.default = async function notarizing(context) {
const { electronPlatformName, appOutDir } = context;

View File

@ -1,9 +1,9 @@
import * as fs from "fs"
import * as path from "path"
import packageInfo from "../src/extensions/npm/extensions/package.json"
import appInfo from "../package.json"
import * as fs from "fs";
import * as path from "path";
import packageInfo from "../src/extensions/npm/extensions/package.json";
import appInfo from "../package.json";
const packagePath = path.join(__dirname, "../src/extensions/npm/extensions/package.json")
const packagePath = path.join(__dirname, "../src/extensions/npm/extensions/package.json");
packageInfo.version = appInfo.version
fs.writeFileSync(packagePath, JSON.stringify(packageInfo, null, 2) + "\n")
packageInfo.version = appInfo.version;
fs.writeFileSync(packagePath, JSON.stringify(packageInfo, null, 2) + "\n");

View File

@ -1,10 +1,10 @@
import { LensRendererExtension, Component } from "@k8slens/extensions";
import { CoffeeDoodle } from "react-open-doodles";
import path from "path";
import React from "react"
import React from "react";
export function ExampleIcon(props: Component.IconProps) {
return <Component.Icon {...props} material="pages" tooltip={path.basename(__filename)}/>
return <Component.Icon {...props} material="pages" tooltip={path.basename(__filename)}/>;
}
export class ExamplePage extends React.Component<{ extension: LensRendererExtension }> {
@ -16,7 +16,7 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens
render() {
const doodleStyle = {
width: "200px"
}
};
return (
<div className="flex column gaps align-flex-start">
<div style={doodleStyle}><CoffeeDoodle accent="#3d90ce" /></div>
@ -24,6 +24,6 @@ export class ExamplePage extends React.Component<{ extension: LensRendererExtens
<p>File: <i>{__filename}</i></p>
<Component.Button accent label="Deactivate" onClick={this.deactivate}/>
</div>
)
);
}
}

View File

@ -1,6 +1,6 @@
import { LensRendererExtension } from "@k8slens/extensions";
import { ExampleIcon, ExamplePage } from "./page"
import React from "react"
import { ExampleIcon, ExamplePage } from "./page";
import React from "react";
export default class ExampleExtension extends LensRendererExtension {
clusterPages = [

View File

@ -1,5 +1,5 @@
import { LensRendererExtension, K8sApi } from "@k8slens/extensions";
import { resolveStatus, resolveStatusForCronJobs, resolveStatusForPods } from "./src/resolver"
import { resolveStatus, resolveStatusForCronJobs, resolveStatusForPods } from "./src/resolver";
export default class EventResourceStatusRendererExtension extends LensRendererExtension {
kubeObjectStatusTexts = [

View File

@ -1,9 +1,9 @@
import { K8sApi } from "@k8slens/extensions";
export function resolveStatus(object: K8sApi.KubeObject): K8sApi.KubeObjectStatus {
const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi)
const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi);
const events = (eventStore as K8sApi.EventStore).getEventsByObject(object);
let warnings = events.filter(evt => evt.isWarning());
const warnings = events.filter(evt => evt.isWarning());
if (!events.length || !warnings.length) {
return null;
}
@ -12,16 +12,16 @@ export function resolveStatus(object: K8sApi.KubeObject): K8sApi.KubeObjectStatu
level: K8sApi.KubeObjectStatusLevel.WARNING,
text: `${event.message}`,
timestamp: event.metadata.creationTimestamp
}
};
}
export function resolveStatusForPods(pod: K8sApi.Pod): K8sApi.KubeObjectStatus {
if (!pod.hasIssues()) {
return null
return null;
}
const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi)
const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi);
const events = (eventStore as K8sApi.EventStore).getEventsByObject(pod);
let warnings = events.filter(evt => evt.isWarning());
const warnings = events.filter(evt => evt.isWarning());
if (!events.length || !warnings.length) {
return null;
}
@ -30,13 +30,13 @@ export function resolveStatusForPods(pod: K8sApi.Pod): K8sApi.KubeObjectStatus {
level: K8sApi.KubeObjectStatusLevel.WARNING,
text: `${event.message}`,
timestamp: event.metadata.creationTimestamp
}
};
}
export function resolveStatusForCronJobs(cronJob: K8sApi.CronJob): K8sApi.KubeObjectStatus {
const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi)
const eventStore = K8sApi.apiManager.getStore(K8sApi.eventApi);
let events = (eventStore as K8sApi.EventStore).getEventsByObject(cronJob);
let warnings = events.filter(evt => evt.isWarning());
const warnings = events.filter(evt => evt.isWarning());
if (cronJob.isNeverRun()) {
events = events.filter(event => event.reason != "FailedNeedsStart");
}
@ -48,5 +48,5 @@ export function resolveStatusForCronJobs(cronJob: K8sApi.CronJob): K8sApi.KubeOb
level: K8sApi.KubeObjectStatusLevel.WARNING,
text: `${event.message}`,
timestamp: event.metadata.creationTimestamp
}
};
}

View File

@ -6,7 +6,7 @@ export default class LicenseLensMainExtension extends LensMainExtension {
parentId: "help",
label: "License",
async click() {
Util.openExternal("https://k8slens.dev/licenses/eula.md")
Util.openExternal("https://k8slens.dev/licenses/eula.md");
}
}
]

View File

@ -1,4 +1,4 @@
import path from "path"
import path from "path";
const outputPath = path.resolve(__dirname, 'dist');

View File

@ -1,6 +1,6 @@
import { LensRendererExtension } from "@k8slens/extensions"
import { MetricsFeature } from "./src/metrics-feature"
import React from "react"
import { LensRendererExtension } from "@k8slens/extensions";
import { MetricsFeature } from "./src/metrics-feature";
import React from "react";
export default class ClusterMetricsFeatureExtension extends LensRendererExtension {
clusterFeatures = [
@ -14,7 +14,7 @@ export default class ClusterMetricsFeatureExtension extends LensRendererExtensio
Install this only if you don't have existing Prometheus stack installed.
You can see preview of manifests <a href="https://github.com/lensapp/lens/tree/master/extensions/lens-metrics/resources" target="_blank">here</a>.
</span>
)
);
}
},
feature: new MetricsFeature()

View File

@ -1,6 +1,6 @@
import { ClusterFeature, Store, K8sApi } from "@k8slens/extensions"
import semver from "semver"
import * as path from "path"
import { ClusterFeature, Store, K8sApi } from "@k8slens/extensions";
import semver from "semver";
import * as path from "path";
export interface MetricsConfiguration {
// Placeholder for Metrics config structure
@ -51,46 +51,46 @@ export class MetricsFeature extends ClusterFeature.Feature {
async install(cluster: Store.Cluster): Promise<void> {
// Check if there are storageclasses
const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass)
const scs = await storageClassApi.list()
const storageClassApi = K8sApi.forCluster(cluster, K8sApi.StorageClass);
const scs = await storageClassApi.list();
this.config.persistence.enabled = scs.some(sc => (
sc.metadata?.annotations?.['storageclass.kubernetes.io/is-default-class'] === 'true' ||
sc.metadata?.annotations?.['storageclass.beta.kubernetes.io/is-default-class'] === 'true'
));
super.applyResources(cluster, super.renderTemplates(path.join(__dirname, "../resources/")))
super.applyResources(cluster, super.renderTemplates(path.join(__dirname, "../resources/")));
}
async upgrade(cluster: Store.Cluster): Promise<void> {
return this.install(cluster)
return this.install(cluster);
}
async updateStatus(cluster: Store.Cluster): Promise<ClusterFeature.FeatureStatus> {
try {
const statefulSet = K8sApi.forCluster(cluster, K8sApi.StatefulSet)
const prometheus = await statefulSet.get({name: "prometheus", namespace: "lens-metrics"})
const statefulSet = K8sApi.forCluster(cluster, K8sApi.StatefulSet);
const prometheus = await statefulSet.get({name: "prometheus", namespace: "lens-metrics"});
if (prometheus?.kind) {
this.status.installed = true;
this.status.currentVersion = prometheus.spec.template.spec.containers[0].image.split(":")[1];
this.status.canUpgrade = semver.lt(this.status.currentVersion, this.latestVersion, true);
} else {
this.status.installed = false
this.status.installed = false;
}
} catch(e) {
if (e?.error?.code === 404) {
this.status.installed = false
this.status.installed = false;
}
}
return this.status
return this.status;
}
async uninstall(cluster: Store.Cluster): Promise<void> {
const namespaceApi = K8sApi.forCluster(cluster, K8sApi.Namespace)
const clusterRoleBindingApi = K8sApi.forCluster(cluster, K8sApi.ClusterRoleBinding)
const clusterRoleApi = K8sApi.forCluster(cluster, K8sApi.ClusterRole)
const namespaceApi = K8sApi.forCluster(cluster, K8sApi.Namespace);
const clusterRoleBindingApi = K8sApi.forCluster(cluster, K8sApi.ClusterRoleBinding);
const clusterRoleApi = K8sApi.forCluster(cluster, K8sApi.ClusterRole);
await namespaceApi.delete({name: "lens-metrics"})
await clusterRoleBindingApi.delete({name: "lens-prometheus"})
await clusterRoleApi.delete({name: "lens-prometheus"}) }
await namespaceApi.delete({name: "lens-metrics"});
await clusterRoleBindingApi.delete({name: "lens-prometheus"});
await clusterRoleApi.delete({name: "lens-prometheus"}); }
}

View File

@ -1,6 +1,6 @@
import { LensRendererExtension } from "@k8slens/extensions";
import React from "react"
import { NodeMenu, NodeMenuProps } from "./src/node-menu"
import React from "react";
import { NodeMenu, NodeMenuProps } from "./src/node-menu";
export default class NodeMenuRendererExtension extends LensRendererExtension {
kubeObjectMenuItems = [

View File

@ -1,5 +1,5 @@
import React from "react";
import { Component, K8sApi, Navigation} from "@k8slens/extensions"
import { Component, K8sApi, Navigation} from "@k8slens/extensions";
export interface NodeMenuProps extends Component.KubeObjectMenuProps<K8sApi.Node> {
}
@ -15,7 +15,7 @@ export function NodeMenu(props: NodeMenuProps) {
newTab: true,
});
Navigation.hideDetails();
}
};
const shell = () => {
Component.createTerminalTab({
@ -23,15 +23,15 @@ export function NodeMenu(props: NodeMenuProps) {
node: nodeName,
});
Navigation.hideDetails();
}
};
const cordon = () => {
sendToTerminal(`kubectl cordon ${nodeName}`);
}
};
const unCordon = () => {
sendToTerminal(`kubectl uncordon ${nodeName}`)
}
sendToTerminal(`kubectl uncordon ${nodeName}`);
};
const drain = () => {
const command = `kubectl drain ${nodeName} --delete-local-data --ignore-daemonsets --force`;
@ -43,8 +43,8 @@ export function NodeMenu(props: NodeMenuProps) {
Are you sure you want to drain <b>{nodeName}</b>?
</p>
),
})
}
});
};
return (
<>

View File

@ -1,7 +1,7 @@
import { LensRendererExtension } from "@k8slens/extensions";
import { PodShellMenu, PodShellMenuProps } from "./src/shell-menu"
import { PodLogsMenu, PodLogsMenuProps } from "./src/logs-menu"
import React from "react"
import { PodShellMenu, PodShellMenuProps } from "./src/shell-menu";
import { PodLogsMenu, PodLogsMenuProps } from "./src/logs-menu";
import React from "react";
export default class PodMenuRendererExtension extends LensRendererExtension {
kubeObjectMenuItems = [

View File

@ -19,7 +19,7 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
}
render() {
const { object: pod, toolbar } = this.props
const { object: pod, toolbar } = this.props;
const containers = pod.getAllContainers();
const statuses = pod.getContainerStatuses();
if (!containers.length) return null;
@ -33,25 +33,25 @@ export class PodLogsMenu extends React.Component<PodLogsMenuProps> {
<Component.SubMenu>
{
containers.map(container => {
const { name } = container
const { name } = container;
const status = statuses.find(status => status.name === name);
const brick = status ? (
<Component.StatusBrick
className={Util.cssNames(Object.keys(status.state)[0], { ready: status.ready })}
/>
) : null
) : null;
return (
<Component.MenuItem key={name} onClick={Util.prevDefault(() => this.showLogs(container))} className="flex align-center">
{brick}
{name}
</Component.MenuItem>
)
);
})
}
</Component.SubMenu>
</>
)}
</Component.MenuItem>
)
);
}
}

View File

@ -9,16 +9,16 @@ export interface PodShellMenuProps extends Component.KubeObjectMenuProps<K8sApi.
export class PodShellMenu extends React.Component<PodShellMenuProps> {
async execShell(container?: string) {
Navigation.hideDetails();
const { object: pod } = this.props
const containerParam = container ? `-c ${container}` : ""
let command = `kubectl exec -i -t -n ${pod.getNs()} ${pod.getName()} ${containerParam} "--"`
const { object: pod } = this.props;
const containerParam = container ? `-c ${container}` : "";
let command = `kubectl exec -i -t -n ${pod.getNs()} ${pod.getName()} ${containerParam} "--"`;
if (window.navigator.platform !== "Win32") {
command = `exec ${command}`
command = `exec ${command}`;
}
if (pod.getSelectedNodeOs() === "windows") {
command = `${command} powershell`
command = `${command} powershell`;
} else {
command = `${command} sh -c "clear; (bash || ash || sh)"`
command = `${command} sh -c "clear; (bash || ash || sh)"`;
}
const shell = Component.createTerminalTab({
@ -32,7 +32,7 @@ export class PodShellMenu extends React.Component<PodShellMenuProps> {
}
render() {
const { object, toolbar } = this.props
const { object, toolbar } = this.props;
const containers = object.getRunningContainers();
if (!containers.length) return null;
return (
@ -51,13 +51,13 @@ export class PodShellMenu extends React.Component<PodShellMenuProps> {
<Component.StatusBrick/>
{name}
</Component.MenuItem>
)
);
})
}
</Component.SubMenu>
</>
)}
</Component.MenuItem>
)
);
}
}

View File

@ -1,8 +1,8 @@
// TODO: support localization / figure out how to extract / consume i18n strings
import "./support.scss"
import React from "react"
import { observer } from "mobx-react"
import "./support.scss";
import React from "react";
import { observer } from "mobx-react";
import { App, Component } from "@k8slens/extensions";
@observer

View File

@ -1,4 +1,4 @@
import path from "path"
import path from "path";
const outputPath = path.resolve(__dirname, 'dist');

View File

@ -1,18 +1,18 @@
import { LensMainExtension } from "@k8slens/extensions";
import { telemetryPreferencesStore } from "./src/telemetry-preferences-store"
import { telemetryPreferencesStore } from "./src/telemetry-preferences-store";
import { tracker } from "./src/tracker";
export default class TelemetryMainExtension extends LensMainExtension {
async onActivate() {
console.log("telemetry main extension activated")
tracker.start()
tracker.reportPeriodically()
await telemetryPreferencesStore.loadExtension(this)
console.log("telemetry main extension activated");
tracker.start();
tracker.reportPeriodically();
await telemetryPreferencesStore.loadExtension(this);
}
onDeactivate() {
tracker.stop()
console.log("telemetry main extension deactivated")
tracker.stop();
console.log("telemetry main extension deactivated");
}
}

View File

@ -1,8 +1,8 @@
import { LensRendererExtension } from "@k8slens/extensions";
import { telemetryPreferencesStore } from "./src/telemetry-preferences-store"
import { TelemetryPreferenceHint, TelemetryPreferenceInput } from "./src/telemetry-preference"
import { tracker } from "./src/tracker"
import React from "react"
import { telemetryPreferencesStore } from "./src/telemetry-preferences-store";
import { TelemetryPreferenceHint, TelemetryPreferenceInput } from "./src/telemetry-preference";
import { tracker } from "./src/tracker";
import React from "react";
export default class TelemetryRendererExtension extends LensRendererExtension {
appPreferences = [
@ -16,8 +16,8 @@ export default class TelemetryRendererExtension extends LensRendererExtension {
];
async onActivate() {
console.log("telemetry extension activated")
tracker.start()
await telemetryPreferencesStore.loadExtension(this)
console.log("telemetry extension activated");
tracker.start();
await telemetryPreferencesStore.loadExtension(this);
}
}

View File

@ -1,19 +1,19 @@
import { Component } from "@k8slens/extensions"
import React from "react"
import { Component } from "@k8slens/extensions";
import React from "react";
import { observer } from "mobx-react";
import { TelemetryPreferencesStore } from "./telemetry-preferences-store"
import { TelemetryPreferencesStore } from "./telemetry-preferences-store";
@observer
export class TelemetryPreferenceInput extends React.Component<{telemetry: TelemetryPreferencesStore}, {}> {
render() {
const { telemetry } = this.props
const { telemetry } = this.props;
return (
<Component.Checkbox
label="Allow telemetry & usage tracking"
value={telemetry.enabled}
onChange={v => { telemetry.enabled = v; }}
/>
)
);
}
}
@ -21,6 +21,6 @@ export class TelemetryPreferenceHint extends React.Component {
render() {
return (
<span>Telemetry & usage data is collected to continuously improve the Lens experience.</span>
)
);
}
}

View File

@ -1,5 +1,5 @@
import { Store } from "@k8slens/extensions";
import { toJS } from "mobx"
import { toJS } from "mobx";
export type TelemetryPreferencesModel = {
enabled: boolean;
@ -14,11 +14,11 @@ export class TelemetryPreferencesStore extends Store.ExtensionStore<TelemetryPre
defaults: {
enabled: true
}
})
});
}
protected fromStore({ enabled }: TelemetryPreferencesModel): void {
this.enabled = enabled
this.enabled = enabled;
}
toJSON(): TelemetryPreferencesModel {
@ -26,8 +26,8 @@ export class TelemetryPreferencesStore extends Store.ExtensionStore<TelemetryPre
enabled: this.enabled
}, {
recurseEverything: true
})
});
}
}
export const telemetryPreferencesStore = TelemetryPreferencesStore.getInstance<TelemetryPreferencesStore>()
export const telemetryPreferencesStore = TelemetryPreferencesStore.getInstance<TelemetryPreferencesStore>();

View File

@ -1,8 +1,8 @@
import { EventBus, Util, Store, App } from "@k8slens/extensions"
import ua from "universal-analytics"
import Analytics from "analytics-node"
import { machineIdSync } from "node-machine-id"
import { telemetryPreferencesStore } from "./telemetry-preferences-store"
import { EventBus, Util, Store, App } from "@k8slens/extensions";
import ua from "universal-analytics";
import Analytics from "analytics-node";
import { machineIdSync } from "node-machine-id";
import { telemetryPreferencesStore } from "./telemetry-preferences-store";
export class Tracker extends Util.Singleton {
static readonly GA_ID = "UA-159377374-1"
@ -23,69 +23,69 @@ export class Tracker extends Util.Singleton {
private constructor() {
super();
this.anonymousId = machineIdSync()
this.os = this.resolveOS()
this.userAgent = `Lens ${App.version} (${this.os})`
this.anonymousId = machineIdSync();
this.os = this.resolveOS();
this.userAgent = `Lens ${App.version} (${this.os})`;
try {
this.visitor = ua(Tracker.GA_ID, this.anonymousId, { strictCidFormat: false })
this.visitor = ua(Tracker.GA_ID, this.anonymousId, { strictCidFormat: false });
} catch (error) {
this.visitor = ua(Tracker.GA_ID)
this.visitor = ua(Tracker.GA_ID);
}
this.analytics = new Analytics(Tracker.SEGMENT_KEY, { flushAt: 1 })
this.visitor.set("dl", "https://telemetry.k8slens.dev")
this.visitor.set("ua", this.userAgent)
this.analytics = new Analytics(Tracker.SEGMENT_KEY, { flushAt: 1 });
this.visitor.set("dl", "https://telemetry.k8slens.dev");
this.visitor.set("ua", this.userAgent);
}
start() {
if (this.started === true) { return }
if (this.started === true) { return; }
this.started = true
this.started = true;
const handler = (ev: EventBus.AppEvent) => {
this.event(ev.name, ev.action, ev.params)
}
this.eventHandlers.push(handler)
EventBus.appEventBus.addListener(handler)
this.event(ev.name, ev.action, ev.params);
};
this.eventHandlers.push(handler);
EventBus.appEventBus.addListener(handler);
}
reportPeriodically() {
this.reportInterval = setInterval(() => {
this.reportData()
}, 60 * 60 * 1000) // report every 1h
this.reportData();
}, 60 * 60 * 1000); // report every 1h
}
stop() {
if (!this.started) { return }
if (!this.started) { return; }
this.started = false
this.started = false;
for (const handler of this.eventHandlers) {
EventBus.appEventBus.removeListener(handler)
EventBus.appEventBus.removeListener(handler);
}
if (this.reportInterval) {
clearInterval(this.reportInterval)
clearInterval(this.reportInterval);
}
}
protected async isTelemetryAllowed(): Promise<boolean> {
return telemetryPreferencesStore.enabled
return telemetryPreferencesStore.enabled;
}
protected reportData() {
const clustersList = Store.clusterStore.enabledClustersList
const clustersList = Store.clusterStore.enabledClustersList;
this.event("generic-data", "report", {
appVersion: App.version,
os: this.os,
clustersCount: clustersList.length,
workspacesCount: Store.workspaceStore.enabledWorkspacesList.length
})
});
clustersList.forEach((cluster) => {
if (!cluster?.metadata.lastSeen) { return }
this.reportClusterData(cluster)
})
if (!cluster?.metadata.lastSeen) { return; }
this.reportClusterData(cluster);
});
}
protected reportClusterData(cluster: Store.ClusterModel) {
@ -96,26 +96,26 @@ export class Tracker extends Util.Singleton {
distribution: cluster.metadata.distribution,
nodesCount: cluster.metadata.nodes,
lastSeen: cluster.metadata.lastSeen
})
});
}
protected resolveOS() {
let os = ""
let os = "";
if (App.isMac) {
os = "MacOS"
os = "MacOS";
} else if(App.isWindows) {
os = "Windows"
os = "Windows";
} else if (App.isLinux) {
os = "Linux"
os = "Linux";
if (App.isSnap) {
os += "; Snap"
os += "; Snap";
} else {
os += "; AppImage"
os += "; AppImage";
}
} else {
os = "Unknown"
os = "Unknown";
}
return os
return os;
}
protected async event(eventCategory: string, eventAction: string, otherParams = {}) {
@ -128,7 +128,7 @@ export class Tracker extends Util.Singleton {
ec: eventCategory,
ea: eventAction,
...otherParams,
}).send()
}).send();
this.analytics.track({
anonymousId: this.anonymousId,
@ -141,9 +141,9 @@ export class Tracker extends Util.Singleton {
...otherParams,
},
})
});
} catch (err) {
console.error(`Failed to track "${eventCategory}:${eventAction}"`, err)
console.error(`Failed to track "${eventCategory}:${eventAction}"`, err);
}
}
}

View File

@ -4,159 +4,159 @@
TEST_NAMESPACE namespace. This is done to minimize destructive impact of the cluster tests on an existing minikube
cluster and vice versa.
*/
import { Application } from "spectron"
import * as util from "../helpers/utils"
import { spawnSync } from "child_process"
import { Application } from "spectron";
import * as util from "../helpers/utils";
import { spawnSync } from "child_process";
const describeif = (condition: boolean) => condition ? describe : describe.skip
const itif = (condition: boolean) => condition ? it : it.skip
const describeif = (condition: boolean) => condition ? describe : describe.skip;
const itif = (condition: boolean) => condition ? it : it.skip;
jest.setTimeout(60000)
jest.setTimeout(60000);
// FIXME (!): improve / simplify all css-selectors + use [data-test-id="some-id"] (already used in some tests below)
describe("Lens integration tests", () => {
const TEST_NAMESPACE = "integration-tests"
const TEST_NAMESPACE = "integration-tests";
const BACKSPACE = "\uE003"
let app: Application
const BACKSPACE = "\uE003";
let app: Application;
const appStart = async () => {
app = util.setup()
await app.start()
app = util.setup();
await app.start();
// Wait for splash screen to be closed
while (await app.client.getWindowCount() > 1);
await app.client.windowByIndex(0)
await app.client.waitUntilWindowLoaded()
}
await app.client.windowByIndex(0);
await app.client.waitUntilWindowLoaded();
};
const clickWhatsNew = async (app: Application) => {
await app.client.waitUntilTextExists("h1", "What's new?")
await app.client.click("button.primary")
await app.client.waitUntilTextExists("h1", "Welcome")
}
await app.client.waitUntilTextExists("h1", "What's new?");
await app.client.click("button.primary");
await app.client.waitUntilTextExists("h1", "Welcome");
};
describe("app start", () => {
beforeAll(appStart, 20000)
beforeAll(appStart, 20000);
afterAll(async () => {
if (app && app.isRunning()) {
return util.tearDown(app)
return util.tearDown(app);
}
})
});
it('shows "whats new"', async () => {
await clickWhatsNew(app)
})
await clickWhatsNew(app);
});
it('shows "add cluster"', async () => {
await app.electron.ipcRenderer.send('test-menu-item-click', "File", "Add Cluster")
await app.client.waitUntilTextExists("h2", "Add Cluster")
})
await app.electron.ipcRenderer.send('test-menu-item-click', "File", "Add Cluster");
await app.client.waitUntilTextExists("h2", "Add Cluster");
});
describe("preferences page", () => {
it('shows "preferences"', async () => {
let appName: string = process.platform === "darwin" ? "Lens" : "File"
await app.electron.ipcRenderer.send('test-menu-item-click', appName, "Preferences")
await app.client.waitUntilTextExists("h2", "Preferences")
})
const appName: string = process.platform === "darwin" ? "Lens" : "File";
await app.electron.ipcRenderer.send('test-menu-item-click', appName, "Preferences");
await app.client.waitUntilTextExists("h2", "Preferences");
});
it('ensures helm repos', async () => {
await app.client.waitUntilTextExists("div.repos #message-stable", "stable") // wait for the helm-cli to fetch the stable repo
await app.client.click("#HelmRepoSelect") // click the repo select to activate the drop-down
await app.client.waitUntilTextExists("div.Select__option", "") // wait for at least one option to appear (any text)
})
})
await app.client.waitUntilTextExists("div.repos #message-stable", "stable"); // wait for the helm-cli to fetch the stable repo
await app.client.click("#HelmRepoSelect"); // click the repo select to activate the drop-down
await app.client.waitUntilTextExists("div.Select__option", ""); // wait for at least one option to appear (any text)
});
});
it.skip('quits Lens"', async () => {
await app.client.keys(['Meta', 'Q'])
await app.client.keys('Meta')
})
})
await app.client.keys(['Meta', 'Q']);
await app.client.keys('Meta');
});
});
const minikubeReady = (): boolean => {
// determine if minikube is running
let status = spawnSync("minikube status", { shell: true })
let status = spawnSync("minikube status", { shell: true });
if (status.status !== 0) {
console.warn("minikube not running")
return false
console.warn("minikube not running");
return false;
}
// Remove TEST_NAMESPACE if it already exists
status = spawnSync(`minikube kubectl -- get namespace ${TEST_NAMESPACE}`, { shell: true })
status = spawnSync(`minikube kubectl -- get namespace ${TEST_NAMESPACE}`, { shell: true });
if (status.status === 0) {
console.warn(`Removing existing ${TEST_NAMESPACE} namespace`)
status = spawnSync(`minikube kubectl -- delete namespace ${TEST_NAMESPACE}`, { shell: true })
console.warn(`Removing existing ${TEST_NAMESPACE} namespace`);
status = spawnSync(`minikube kubectl -- delete namespace ${TEST_NAMESPACE}`, { shell: true });
if (status.status !== 0) {
console.warn(`Error removing ${TEST_NAMESPACE} namespace: ${status.stderr.toString()}`)
return false
console.warn(`Error removing ${TEST_NAMESPACE} namespace: ${status.stderr.toString()}`);
return false;
}
console.log(status.stdout.toString())
console.log(status.stdout.toString());
}
return true
}
const ready = minikubeReady()
return true;
};
const ready = minikubeReady();
const addMinikubeCluster = async (app: Application) => {
await app.client.click("div.add-cluster")
await app.client.waitUntilTextExists("div", "Select kubeconfig file")
await app.client.click("div.Select__control") // show the context drop-down list
await app.client.waitUntilTextExists("div", "minikube")
await app.client.click("div.add-cluster");
await app.client.waitUntilTextExists("div", "Select kubeconfig file");
await app.client.click("div.Select__control"); // show the context drop-down list
await app.client.waitUntilTextExists("div", "minikube");
if (!await app.client.$("button.primary").isEnabled()) {
await app.client.click("div.minikube") // select minikube context
await app.client.click("div.minikube"); // select minikube context
} // else the only context, which must be 'minikube', is automatically selected
await app.client.click("div.Select__control") // hide the context drop-down list (it might be obscuring the Add cluster(s) button)
await app.client.click("button.primary") // add minikube cluster
}
await app.client.click("div.Select__control"); // hide the context drop-down list (it might be obscuring the Add cluster(s) button)
await app.client.click("button.primary"); // add minikube cluster
};
const waitForMinikubeDashboard = async (app: Application) => {
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started")
await app.client.waitForExist(`iframe[name="minikube"]`)
await app.client.frame("minikube")
await app.client.waitUntilTextExists("span.link-text", "Cluster")
}
await app.client.waitUntilTextExists("pre.kube-auth-out", "Authentication proxy started");
await app.client.waitForExist(`iframe[name="minikube"]`);
await app.client.frame("minikube");
await app.client.waitUntilTextExists("span.link-text", "Cluster");
};
describeif(ready)("cluster tests", () => {
let clusterAdded = false
let clusterAdded = false;
const addCluster = async () => {
await clickWhatsNew(app)
await addMinikubeCluster(app)
await waitForMinikubeDashboard(app)
await app.client.click('a[href="/nodes"]')
await app.client.waitUntilTextExists("div.TableCell", "Ready")
}
await clickWhatsNew(app);
await addMinikubeCluster(app);
await waitForMinikubeDashboard(app);
await app.client.click('a[href="/nodes"]');
await app.client.waitUntilTextExists("div.TableCell", "Ready");
};
describe("cluster add", () => {
beforeAll(appStart, 20000)
beforeAll(appStart, 20000);
afterAll(async () => {
if (app && app.isRunning()) {
return util.tearDown(app)
return util.tearDown(app);
}
})
});
it('allows to add a cluster', async () => {
await addCluster()
clusterAdded = true
})
})
await addCluster();
clusterAdded = true;
});
});
const appStartAddCluster = async () => {
if (clusterAdded) {
await appStart()
await addCluster()
}
await appStart();
await addCluster();
}
};
describe("cluster pages", () => {
beforeAll(appStartAddCluster, 40000)
beforeAll(appStartAddCluster, 40000);
afterAll(async () => {
if (app && app.isRunning()) {
return util.tearDown(app)
return util.tearDown(app);
}
})
});
const tests: {
drawer?: string
@ -394,119 +394,119 @@ describe("Lens integration tests", () => {
tests.forEach(({ drawer = "", drawerId = "", pages }) => {
if (drawer !== "") {
it(`shows ${drawer} drawer`, async () => {
expect(clusterAdded).toBe(true)
await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`)
await app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name)
})
expect(clusterAdded).toBe(true);
await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`);
await app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name);
});
}
pages.forEach(({ name, href, expectedSelector, expectedText }) => {
it(`shows ${drawer}->${name} page`, async () => {
expect(clusterAdded).toBe(true)
await app.client.click(`a[href^="/${href}"]`)
await app.client.waitUntilTextExists(expectedSelector, expectedText)
})
})
expect(clusterAdded).toBe(true);
await app.client.click(`a[href^="/${href}"]`);
await app.client.waitUntilTextExists(expectedSelector, expectedText);
});
});
if (drawer !== "") {
// hide the drawer
it(`hides ${drawer} drawer`, async () => {
expect(clusterAdded).toBe(true)
await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`)
await expect(app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name, 100)).rejects.toThrow()
})
expect(clusterAdded).toBe(true);
await app.client.click(`.sidebar-nav [data-test-id="${drawerId}"] span.link-text`);
await expect(app.client.waitUntilTextExists(`a[href^="/${pages[0].href}"]`, pages[0].name, 100)).rejects.toThrow();
});
}
})
})
});
});
describe("viewing pod logs", () => {
beforeEach(appStartAddCluster, 40000)
beforeEach(appStartAddCluster, 40000);
afterEach(async () => {
if (app && app.isRunning()) {
return util.tearDown(app)
return util.tearDown(app);
}
})
});
it(`shows a logs for a pod`, async () => {
expect(clusterAdded).toBe(true)
expect(clusterAdded).toBe(true);
// Go to Pods page
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text")
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods")
await app.client.click('a[href^="/pods"]')
await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver")
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text");
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods");
await app.client.click('a[href^="/pods"]');
await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver");
// Open logs tab in dock
await app.client.click(".list .TableRow:first-child")
await app.client.waitForVisible(".Drawer")
await app.client.click(".drawer-title .Menu li:nth-child(2)")
await app.client.click(".list .TableRow:first-child");
await app.client.waitForVisible(".Drawer");
await app.client.click(".drawer-title .Menu li:nth-child(2)");
// Check if controls are available
await app.client.waitForVisible(".PodLogs .VirtualList")
await app.client.waitForVisible(".PodLogControls")
await app.client.waitForVisible(".PodLogControls .SearchInput")
await app.client.waitForVisible(".PodLogControls .SearchInput input")
await app.client.waitForVisible(".PodLogs .VirtualList");
await app.client.waitForVisible(".PodLogControls");
await app.client.waitForVisible(".PodLogControls .SearchInput");
await app.client.waitForVisible(".PodLogControls .SearchInput input");
// Search for semicolon
await app.client.keys(":")
await app.client.waitForVisible(".PodLogs .list span.active")
await app.client.keys(":");
await app.client.waitForVisible(".PodLogs .list span.active");
// Click through controls
await app.client.click(".PodLogControls .timestamps-icon")
await app.client.click(".PodLogControls .undo-icon")
})
})
await app.client.click(".PodLogControls .timestamps-icon");
await app.client.click(".PodLogControls .undo-icon");
});
});
describe("cluster operations", () => {
beforeEach(appStartAddCluster, 40000)
beforeEach(appStartAddCluster, 40000);
afterEach(async () => {
if (app && app.isRunning()) {
return util.tearDown(app)
return util.tearDown(app);
}
})
});
it('shows default namespace', async () => {
expect(clusterAdded).toBe(true)
await app.client.click('a[href="/namespaces"]')
await app.client.waitUntilTextExists("div.TableCell", "default")
await app.client.waitUntilTextExists("div.TableCell", "kube-system")
})
expect(clusterAdded).toBe(true);
await app.client.click('a[href="/namespaces"]');
await app.client.waitUntilTextExists("div.TableCell", "default");
await app.client.waitUntilTextExists("div.TableCell", "kube-system");
});
it(`creates ${TEST_NAMESPACE} namespace`, async () => {
expect(clusterAdded).toBe(true)
await app.client.click('a[href="/namespaces"]')
await app.client.waitUntilTextExists("div.TableCell", "default")
await app.client.waitUntilTextExists("div.TableCell", "kube-system")
await app.client.click("button.add-button")
await app.client.waitUntilTextExists("div.AddNamespaceDialog", "Create Namespace")
await app.client.keys(`${TEST_NAMESPACE}\n`)
await app.client.waitForExist(`.name=${TEST_NAMESPACE}`)
})
expect(clusterAdded).toBe(true);
await app.client.click('a[href="/namespaces"]');
await app.client.waitUntilTextExists("div.TableCell", "default");
await app.client.waitUntilTextExists("div.TableCell", "kube-system");
await app.client.click("button.add-button");
await app.client.waitUntilTextExists("div.AddNamespaceDialog", "Create Namespace");
await app.client.keys(`${TEST_NAMESPACE}\n`);
await app.client.waitForExist(`.name=${TEST_NAMESPACE}`);
});
it(`creates a pod in ${TEST_NAMESPACE} namespace`, async () => {
expect(clusterAdded).toBe(true)
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text")
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods")
await app.client.click('a[href^="/pods"]')
await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver")
await app.client.click('.Icon.new-dock-tab')
await app.client.waitUntilTextExists("li.MenuItem.create-resource-tab", "Create resource")
await app.client.click("li.MenuItem.create-resource-tab")
await app.client.waitForVisible(".CreateResource div.ace_content")
expect(clusterAdded).toBe(true);
await app.client.click(".sidebar-nav [data-test-id='workloads'] span.link-text");
await app.client.waitUntilTextExists('a[href^="/pods"]', "Pods");
await app.client.click('a[href^="/pods"]');
await app.client.waitUntilTextExists("div.TableCell", "kube-apiserver");
await app.client.click('.Icon.new-dock-tab');
await app.client.waitUntilTextExists("li.MenuItem.create-resource-tab", "Create resource");
await app.client.click("li.MenuItem.create-resource-tab");
await app.client.waitForVisible(".CreateResource div.ace_content");
// Write pod manifest to editor
await app.client.keys("apiVersion: v1\n")
await app.client.keys("kind: Pod\n")
await app.client.keys("metadata:\n")
await app.client.keys(" name: nginx-create-pod-test\n")
await app.client.keys(`namespace: ${TEST_NAMESPACE}\n`)
await app.client.keys(BACKSPACE + "spec:\n")
await app.client.keys(" containers:\n")
await app.client.keys("- name: nginx-create-pod-test\n")
await app.client.keys(" image: nginx:alpine\n")
await app.client.keys("apiVersion: v1\n");
await app.client.keys("kind: Pod\n");
await app.client.keys("metadata:\n");
await app.client.keys(" name: nginx-create-pod-test\n");
await app.client.keys(`namespace: ${TEST_NAMESPACE}\n`);
await app.client.keys(BACKSPACE + "spec:\n");
await app.client.keys(" containers:\n");
await app.client.keys("- name: nginx-create-pod-test\n");
await app.client.keys(" image: nginx:alpine\n");
// Create deployment
await app.client.waitForEnabled("button.Button=Create & Close")
await app.client.click("button.Button=Create & Close")
await app.client.waitForEnabled("button.Button=Create & Close");
await app.client.click("button.Button=Create & Close");
// Wait until first bits of pod appears on dashboard
await app.client.waitForExist(".name=nginx-create-pod-test")
await app.client.waitForExist(".name=nginx-create-pod-test");
// Open pod details
await app.client.click(".name=nginx-create-pod-test")
await app.client.waitUntilTextExists("div.drawer-title-text", "Pod: nginx-create-pod-test")
})
})
})
})
await app.client.click(".name=nginx-create-pod-test");
await app.client.waitUntilTextExists("div.drawer-title-text", "Pod: nginx-create-pod-test");
});
});
});
});

View File

@ -4,7 +4,7 @@ const AppPaths: Partial<Record<NodeJS.Platform, string>> = {
"win32": "./dist/win-unpacked/Lens.exe",
"linux": "./dist/linux-unpacked/kontena-lens",
"darwin": "./dist/mac/Lens.app/Contents/MacOS/Lens",
}
};
export function setup(): Application {
return new Application({
@ -16,16 +16,16 @@ export function setup(): Application {
env: {
CICD: "true"
}
})
});
}
export async function tearDown(app: Application) {
let mpid: any = app.mainProcess.pid
let pid = await mpid()
await app.stop()
const mpid: any = app.mainProcess.pid;
const pid = await mpid();
await app.stop();
try {
process.kill(pid, "SIGKILL");
} catch (e) {
console.error(e)
console.error(e);
}
}

View File

@ -37,7 +37,7 @@
"download:kubectl": "yarn run ts-node build/download_kubectl.ts",
"download:helm": "yarn run ts-node build/download_helm.ts",
"build:tray-icons": "yarn run ts-node build/build_tray_icon.ts",
"lint": "yarn run eslint $@ --ext js,ts,tsx --max-warnings=0 src/",
"lint": "yarn run eslint $@ --ext js,ts,tsx --max-warnings=0 src/ integration/ __mocks__/ build/",
"lint:fix": "yarn run lint --fix",
"mkdocs-serve-local": "docker build -t mkdocs-serve-local:latest mkdocs/ && docker run --rm -it -p 8000:8000 -v ${PWD}:/docs mkdocs-serve-local:latest",
"typedocs-extensions-api": "yarn run typedoc --ignoreCompilerErrors --readme docs/extensions/typedoc-readme.md.tpl --name @k8slens/extensions --out docs/extensions/api --mode library --excludePrivate --hideBreadcrumbs --includes src/ src/extensions/extension-api.ts"