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

Merge branch 'master' into fix/isolate-react-select-emotion-styles

This commit is contained in:
Alex Andreev 2021-03-24 10:48:54 +03:00
commit 5ada05940c
35 changed files with 403 additions and 177 deletions

View File

@ -225,6 +225,12 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
workspaceStore.setLastActiveClusterId(clusterId);
}
deactivate(id: ClusterId) {
if (this.isActive(id)) {
this.setActive(null);
}
}
@action
swapIconOrders(workspace: WorkspaceId, from: number, to: number) {
const clusters = this.getByWorkspaceId(workspace);

View File

@ -0,0 +1,11 @@
/**
* This channel is broadcast on whenever the cluster fails to list namespaces
* during a refresh and no `accessibleNamespaces` have been set.
*/
export const ClusterListNamespaceForbiddenChannel = "cluster:list-namespace-forbidden";
export type ListNamespaceForbiddenArgs = [clusterId: string];
export function isListNamespaceForbiddenArgs(args: unknown[]): args is ListNamespaceForbiddenArgs {
return args.length === 1 && typeof args[0] === "string";
}

View File

@ -1,4 +1,5 @@
export * from "./ipc";
export * from "./invalid-kubeconfig";
export * from "./update-available";
export * from "./update-available.ipc";
export * from "./cluster.ipc";
export * from "./type-enforced-ipc";

View File

@ -3,10 +3,7 @@ import { UpdateInfo } from "electron-updater";
export const UpdateAvailableChannel = "update-available";
export const AutoUpdateLogPrefix = "[UPDATE-CHECKER]";
/**
* [<back-channel>, <update-info>]
*/
export type UpdateAvailableFromMain = [string, UpdateInfo];
export type UpdateAvailableFromMain = [backChannel: string, updateInfo: UpdateInfo];
export function areArgsUpdateAvailableFromMain(args: unknown[]): args is UpdateAvailableFromMain {
if (args.length !== 2) {
@ -32,7 +29,7 @@ export type BackchannelArg = {
now: boolean;
};
export type UpdateAvailableToBackchannel = [BackchannelArg];
export type UpdateAvailableToBackchannel = [updateDecision: BackchannelArg];
export function areArgsUpdateAvailableToBackchannel(args: unknown[]): args is UpdateAvailableToBackchannel {
if (args.length !== 1) {

View File

@ -10,6 +10,7 @@ import { kubeConfigDefaultPath, loadConfig } from "./kube-helpers";
import { appEventBus } from "./event-bus";
import logger from "../main/logger";
import path from "path";
import { fileNameMigration } from "../migrations/user-store";
export interface UserStoreModel {
kubeConfigPath: string;
@ -37,7 +38,7 @@ export class UserStore extends BaseStore<UserStoreModel> {
private constructor() {
super({
// configName: "lens-user-store", // todo: migrate from default "config.json"
configName: "lens-user-store",
migrations,
});
@ -85,6 +86,16 @@ export class UserStore extends BaseStore<UserStoreModel> {
}
}
async load(): Promise<void> {
/**
* This has to be here before the call to `new Config` in `super.load()`
* as we have to make sure that file is in the expected place for that call
*/
await fileNameMigration();
return super.load();
}
get isNewVersion() {
return semver.gt(getAppVersion(), this.lastSeenAppVersion);
}

View File

@ -85,8 +85,8 @@ describe("kubeconfig manager tests", () => {
const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port);
expect(logger.error).not.toBeCalled();
expect(kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`);
const file = await fse.readFile(kubeConfManager.getPath());
expect(await kubeConfManager.getPath()).toBe(`tmp${path.sep}kubeconfig-foo`);
const file = await fse.readFile(await kubeConfManager.getPath());
const yml = loadYaml<any>(file.toString());
expect(yml["current-context"]).toBe("minikube");
@ -104,12 +104,12 @@ describe("kubeconfig manager tests", () => {
const contextHandler = new ContextHandler(cluster);
const port = await getFreePort();
const kubeConfManager = await KubeconfigManager.create(cluster, contextHandler, port);
const configPath = kubeConfManager.getPath();
const configPath = await kubeConfManager.getPath();
expect(await fse.pathExists(configPath)).toBe(true);
await kubeConfManager.unlink();
expect(await fse.pathExists(configPath)).toBe(false);
await kubeConfManager.unlink(); // doesn't throw
expect(kubeConfManager.getPath()).toBeUndefined();
expect(await kubeConfManager.getPath()).toBeUndefined();
});
});

View File

@ -4,9 +4,9 @@ import type { IMetricsReqParams } from "../renderer/api/endpoints/metrics.api";
import type { WorkspaceId } from "../common/workspace-store";
import { action, comparer, computed, observable, reaction, toJS, when } from "mobx";
import { apiKubePrefix } from "../common/vars";
import { broadcastMessage, InvalidKubeconfigChannel } from "../common/ipc";
import { broadcastMessage, InvalidKubeconfigChannel, ClusterListNamespaceForbiddenChannel } from "../common/ipc";
import { ContextHandler } from "./context-handler";
import { AuthorizationV1Api, CoreV1Api, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node";
import { AuthorizationV1Api, CoreV1Api, HttpError, KubeConfig, V1ResourceAttributes } from "@kubernetes/client-node";
import { Kubectl } from "./kubectl";
import { KubeconfigManager } from "./kubeconfig-manager";
import { loadConfig, validateKubeConfig } from "../common/kube-helpers";
@ -482,14 +482,16 @@ export class Cluster implements ClusterModel, ClusterState {
/**
* @internal
*/
getProxyKubeconfig(): KubeConfig {
return loadConfig(this.getProxyKubeconfigPath());
async getProxyKubeconfig(): Promise<KubeConfig> {
const kubeconfigPath = await this.getProxyKubeconfigPath();
return loadConfig(kubeconfigPath);
}
/**
* @internal
*/
getProxyKubeconfigPath(): string {
async getProxyKubeconfigPath(): Promise<string> {
return this.kubeconfigManager.getPath();
}
@ -565,7 +567,7 @@ export class Cluster implements ClusterModel, ClusterState {
* @param resourceAttributes resource attributes
*/
async canI(resourceAttributes: V1ResourceAttributes): Promise<boolean> {
const authApi = this.getProxyKubeconfig().makeApiClient(AuthorizationV1Api);
const authApi = (await this.getProxyKubeconfig()).makeApiClient(AuthorizationV1Api);
try {
const accessReview = await authApi.createSelfSubjectAccessReview({
@ -680,18 +682,22 @@ export class Cluster implements ClusterModel, ClusterState {
return this.accessibleNamespaces;
}
const api = this.getProxyKubeconfig().makeApiClient(CoreV1Api);
const api = (await this.getProxyKubeconfig()).makeApiClient(CoreV1Api);
try {
const namespaceList = await api.listNamespace();
return namespaceList.body.items.map(ns => ns.metadata.name);
} catch (error) {
const ctx = this.getProxyKubeconfig().getContextObject(this.contextName);
const ctx = (await this.getProxyKubeconfig()).getContextObject(this.contextName);
const namespaceList = [ctx.namespace].filter(Boolean);
if (ctx.namespace) return [ctx.namespace];
if (namespaceList.length === 0 && error instanceof HttpError && error.statusCode === 403) {
logger.info("[CLUSTER]: listing namespaces is forbidden, broadcasting", { clusterId: this.id });
broadcastMessage(ClusterListNamespaceForbiddenChannel, this.id);
}
return [];
return namespaceList;
}
}

View File

@ -59,7 +59,7 @@ export class ContextHandler {
async getPrometheusService(): Promise<PrometheusService> {
const providers = this.prometheusProvider ? prometheusProviders.filter(provider => provider.id == this.prometheusProvider) : prometheusProviders;
const prometheusPromises: Promise<PrometheusService>[] = providers.map(async (provider: PrometheusProvider): Promise<PrometheusService> => {
const apiClient = this.cluster.getProxyKubeconfig().makeApiClient(CoreV1Api);
const apiClient = (await this.cluster.getProxyKubeconfig()).makeApiClient(CoreV1Api);
return await provider.getPrometheusService(apiClient);
});

View File

@ -56,11 +56,12 @@ export class HelmReleaseManager {
public async upgradeRelease(name: string, chart: string, values: any, namespace: string, version: string, cluster: Cluster){
const helm = await helmCli.binaryPath();
const fileName = tempy.file({name: "values.yaml"});
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
await fs.promises.writeFile(fileName, yaml.safeDump(values));
try {
const { stdout } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);});
const { stdout } = await promiseExec(`"${helm}" upgrade ${name} ${chart} --version ${version} -f ${fileName} --namespace ${namespace} --kubeconfig ${proxyKubeconfig}`).catch((error) => { throw(error.stderr);});
return {
log: stdout,
@ -73,7 +74,9 @@ export class HelmReleaseManager {
public async getRelease(name: string, namespace: string, cluster: Cluster) {
const helm = await helmCli.binaryPath();
const { stdout } = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${cluster.getProxyKubeconfigPath()}`).catch((error) => { throw(error.stderr);});
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
const { stdout } = await promiseExec(`"${helm}" status ${name} --output json --namespace ${namespace} --kubeconfig ${proxyKubeconfig}`).catch((error) => { throw(error.stderr);});
const release = JSON.parse(stdout);
release.resources = await this.getResources(name, namespace, cluster);
@ -112,7 +115,7 @@ export class HelmReleaseManager {
protected async getResources(name: string, namespace: string, cluster: Cluster) {
const helm = await helmCli.binaryPath();
const kubectl = await cluster.kubeCtl.getPath();
const pathToKubeconfig = cluster.getProxyKubeconfigPath();
const pathToKubeconfig = await cluster.getProxyKubeconfigPath();
const { stdout } = await promiseExec(`"${helm}" get manifest ${name} --namespace ${namespace} --kubeconfig ${pathToKubeconfig} | "${kubectl}" get -n ${namespace} --kubeconfig ${pathToKubeconfig} -f - -o=json`).catch(() => {
return { stdout: JSON.stringify({items: []})};
});

View File

@ -8,7 +8,9 @@ import { HelmChartList, RepoHelmChartList } from "../../renderer/api/endpoints/h
class HelmService {
public async installChart(cluster: Cluster, data: { chart: string; values: {}; name: string; namespace: string; version: string }) {
return await releaseManager.installChart(data.chart, data.values, data.name, data.namespace, data.version, cluster.getProxyKubeconfigPath());
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
return await releaseManager.installChart(data.chart, data.values, data.name, data.namespace, data.version, proxyKubeconfig);
}
public async listCharts() {
@ -53,8 +55,9 @@ class HelmService {
public async listReleases(cluster: Cluster, namespace: string = null) {
await repoManager.init();
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
return await releaseManager.listReleases(cluster.getProxyKubeconfigPath(), namespace);
return await releaseManager.listReleases(proxyKubeconfig, namespace);
}
public async getRelease(cluster: Cluster, releaseName: string, namespace: string) {
@ -64,21 +67,27 @@ class HelmService {
}
public async getReleaseValues(cluster: Cluster, releaseName: string, namespace: string) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Fetch release values");
return await releaseManager.getValues(releaseName, namespace, cluster.getProxyKubeconfigPath());
return await releaseManager.getValues(releaseName, namespace, proxyKubeconfig);
}
public async getReleaseHistory(cluster: Cluster, releaseName: string, namespace: string) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Fetch release history");
return await releaseManager.getHistory(releaseName, namespace, cluster.getProxyKubeconfigPath());
return await releaseManager.getHistory(releaseName, namespace, proxyKubeconfig);
}
public async deleteRelease(cluster: Cluster, releaseName: string, namespace: string) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Delete release");
return await releaseManager.deleteRelease(releaseName, namespace, cluster.getProxyKubeconfigPath());
return await releaseManager.deleteRelease(releaseName, namespace, proxyKubeconfig);
}
public async updateRelease(cluster: Cluster, releaseName: string, namespace: string, data: { chart: string; values: {}; version: string }) {
@ -88,8 +97,10 @@ class HelmService {
}
public async rollback(cluster: Cluster, releaseName: string, namespace: string, revision: number) {
const proxyKubeconfig = await cluster.getProxyKubeconfigPath();
logger.debug("Rollback release");
const output = await releaseManager.rollback(releaseName, namespace, revision, cluster.getProxyKubeconfigPath());
const output = await releaseManager.rollback(releaseName, namespace, revision, proxyKubeconfig);
return { message: output };
}

View File

@ -29,6 +29,7 @@ import { LensProtocolRouterMain } from "./protocol-handler";
import { getAppVersion, getAppVersionFromProxyServer } from "../common/utils";
import { bindBroadcastHandlers } from "../common/ipc";
import { startUpdateChecking } from "./app-updater";
import { IpcRendererNavigationEvents } from "../renderer/navigation/events";
const workingDir = path.join(app.getPath("appData"), appName);
let proxyPort: number;
@ -162,7 +163,7 @@ app.on("ready", async () => {
windowManager.initMainWindow();
}
ipcMain.on("renderer:loaded", () => {
ipcMain.on(IpcRendererNavigationEvents.LOADED, () => {
startUpdateChecking();
LensProtocolRouterMain
.getInstance<LensProtocolRouterMain>()

View File

@ -24,14 +24,24 @@ export class KubeconfigManager {
protected async init() {
try {
await this.contextHandler.ensurePort();
await this.createProxyKubeconfig();
this.tempFile = await this.createProxyKubeconfig();
} catch (err) {
logger.error(`Failed to created temp config for auth-proxy`, { err });
}
}
getPath() {
async getPath() {
// create proxy kubeconfig if it is removed
if (this.tempFile !== undefined && !(await fs.pathExists(this.tempFile))) {
try {
this.tempFile = await this.createProxyKubeconfig();
} catch (err) {
logger.error(`Failed to created temp config for auth-proxy`, { err });
}
}
return this.tempFile;
}
protected resolveProxyUrl() {
@ -71,9 +81,8 @@ export class KubeconfigManager {
// write
const configYaml = dumpConfigYaml(proxyConfig);
fs.ensureDir(path.dirname(tempFile));
fs.writeFileSync(tempFile, configYaml, { mode: 0o600 });
this.tempFile = tempFile;
await fs.ensureDir(path.dirname(tempFile));
await fs.writeFile(tempFile, configYaml, { mode: 0o600 });
logger.debug(`Created temp kubeconfig "${contextName}" at "${tempFile}": \n${configYaml}`);
return tempFile;

View File

@ -17,10 +17,10 @@ export class NodeShellSession extends ShellSession {
super(socket, cluster);
this.nodeName = nodeName;
this.podId = `node-shell-${uuid()}`;
this.kc = cluster.getProxyKubeconfig();
}
public async open() {
this.kc = await this.cluster.getProxyKubeconfig();
const shell = await this.kubectl.getPath();
let args = [];

View File

@ -23,12 +23,13 @@ export class ResourceApplier {
protected async kubectlApply(content: string): Promise<string> {
const { kubeCtl } = this.cluster;
const kubectlPath = await kubeCtl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
return new Promise<string>((resolve, reject) => {
const fileName = tempy.file({ name: "resource.yaml" });
fs.writeFileSync(fileName, content);
const cmd = `"${kubectlPath}" apply --kubeconfig "${this.cluster.getProxyKubeconfigPath()}" -o json -f "${fileName}"`;
const cmd = `"${kubectlPath}" apply --kubeconfig "${proxyKubeconfigPath}" -o json -f "${fileName}"`;
logger.debug(`shooting manifests with: ${cmd}`);
const execEnv: NodeJS.ProcessEnv = Object.assign({}, process.env);
@ -54,6 +55,7 @@ export class ResourceApplier {
public async kubectlApplyAll(resources: string[]): Promise<string> {
const { kubeCtl } = this.cluster;
const kubectlPath = await kubeCtl.getPath();
const proxyKubeconfigPath = await this.cluster.getProxyKubeconfigPath();
return new Promise((resolve, reject) => {
const tmpDir = tempy.directory();
@ -62,7 +64,7 @@ export class ResourceApplier {
resources.forEach((resource, index) => {
fs.writeFileSync(path.join(tmpDir, `${index}.yaml`), resource);
});
const cmd = `"${kubectlPath}" apply --kubeconfig "${this.cluster.getProxyKubeconfigPath()}" -o json -f "${tmpDir}"`;
const cmd = `"${kubectlPath}" apply --kubeconfig "${proxyKubeconfigPath}" -o json -f "${tmpDir}"`;
console.log("shooting manifests with:", cmd);
exec(cmd, (error, stdout, stderr) => {

View File

@ -44,7 +44,7 @@ class KubeconfigRoute extends LensApi {
public async routeServiceAccountRoute(request: LensApiRequest) {
const { params, response, cluster} = request;
const client = cluster.getProxyKubeconfig().makeApiClient(CoreV1Api);
const client = (await cluster.getProxyKubeconfig()).makeApiClient(CoreV1Api);
const secretList = await client.listNamespacedSecret(params.namespace);
const secret = secretList.body.items.find(secret => {
const { annotations } = secret.metadata;

View File

@ -92,7 +92,7 @@ class PortForwardRoute extends LensApi {
namespace,
name: resourceName,
port,
kubeConfig: cluster.getProxyKubeconfigPath()
kubeConfig: await cluster.getProxyKubeconfigPath()
});
const started = await portForward.start();

View File

@ -6,7 +6,6 @@ import shellEnv from "shell-env";
import { app } from "electron";
import { Kubectl } from "./kubectl";
import { Cluster } from "./cluster";
import { ClusterPreferences } from "../common/cluster-store";
import { helmCli } from "./helm/helm-cli";
import { isWindows } from "../common/vars";
import { appEventBus } from "../common/event-bus";
@ -24,20 +23,18 @@ export class ShellSession extends EventEmitter {
protected kubectlBinDir: string;
protected kubectlPathDir: string;
protected helmBinDir: string;
protected preferences: ClusterPreferences;
protected running = false;
protected clusterId: string;
protected cluster: Cluster;
constructor(socket: WebSocket, cluster: Cluster) {
super();
this.websocket = socket;
this.kubeconfigPath = cluster.getProxyKubeconfigPath();
this.kubectl = new Kubectl(cluster.version);
this.preferences = cluster.preferences || {};
this.clusterId = cluster.id;
this.cluster = cluster;
}
public async open() {
this.kubeconfigPath = await this.cluster.getProxyKubeconfigPath();
this.kubectlBinDir = await this.kubectl.binDir();
const pathFromPreferences = userStore.preferences.kubectlBinariesPath || this.kubectl.getBundledPath();
@ -65,11 +62,13 @@ export class ShellSession extends EventEmitter {
}
protected cwd(): string {
if(!this.preferences || !this.preferences.terminalCWD || this.preferences.terminalCWD === "") {
const { preferences } = this.cluster;
if(!preferences || !preferences.terminalCWD || preferences.terminalCWD === "") {
return null;
}
return this.preferences.terminalCWD;
return preferences.terminalCWD;
}
protected async getShellArgs(shell: string): Promise<Array<string>> {
@ -88,15 +87,17 @@ export class ShellSession extends EventEmitter {
}
protected async getCachedShellEnv() {
let env = ShellSession.shellEnvs.get(this.clusterId);
const { id: clusterId } = this.cluster;
let env = ShellSession.shellEnvs.get(clusterId);
if (!env) {
env = await this.getShellEnv();
ShellSession.shellEnvs.set(this.clusterId, env);
ShellSession.shellEnvs.set(clusterId, env);
} else {
// refresh env in the background
this.getShellEnv().then((shellEnv: any) => {
ShellSession.shellEnvs.set(this.clusterId, shellEnv);
ShellSession.shellEnvs.set(clusterId, shellEnv);
});
}
@ -107,6 +108,7 @@ export class ShellSession extends EventEmitter {
const env = clearKubeconfigEnvVars(JSON.parse(JSON.stringify(await shellEnv())));
const pathStr = [this.kubectlBinDir, this.helmBinDir, process.env.PATH].join(path.delimiter);
const shell = userStore.preferences.shell || process.env.SHELL || process.env.PTYSHELL;
const { preferences } = this.cluster;
if(isWindows) {
env["SystemRoot"] = process.env.SystemRoot;
@ -138,8 +140,8 @@ export class ShellSession extends EventEmitter {
env["TERM_PROGRAM"] = app.getName();
env["TERM_PROGRAM_VERSION"] = app.getVersion();
if (this.preferences.httpsProxy) {
env["HTTPS_PROXY"] = this.preferences.httpsProxy;
if (preferences.httpsProxy) {
env["HTTPS_PROXY"] = preferences.httpsProxy;
}
const no_proxy = ["localhost", "127.0.0.1", env["NO_PROXY"]];

View File

@ -8,6 +8,7 @@ import { initMenu } from "./menu";
import { initTray } from "./tray";
import { Singleton } from "../common/utils";
import { ClusterFrameInfo, clusterFrameMap } from "../common/cluster-frames";
import { IpcRendererNavigationEvents } from "../renderer/navigation/events";
import logger from "./logger";
export class WindowManager extends Singleton {
@ -65,13 +66,13 @@ export class WindowManager extends Singleton {
shell.openExternal(url);
});
this.mainWindow.webContents.on("dom-ready", () => {
appEventBus.emit({name: "app", action: "dom-ready"});
appEventBus.emit({ name: "app", action: "dom-ready" });
});
this.mainWindow.on("focus", () => {
appEventBus.emit({name: "app", action: "focus"});
appEventBus.emit({ name: "app", action: "focus" });
});
this.mainWindow.on("blur", () => {
appEventBus.emit({name: "app", action: "blur"});
appEventBus.emit({ name: "app", action: "blur" });
});
// clean up
@ -115,7 +116,7 @@ export class WindowManager extends Singleton {
protected bindEvents() {
// track visible cluster from ui
subscribeToBroadcast("cluster-view:current-id", (event, clusterId: ClusterId) => {
subscribeToBroadcast(IpcRendererNavigationEvents.CLUSTER_VIEW_CURRENT_ID, (event, clusterId: ClusterId) => {
this.activeClusterId = clusterId;
});
}
@ -139,7 +140,9 @@ export class WindowManager extends Singleton {
await this.ensureMainWindow();
const frameInfo = Array.from(clusterFrameMap.values()).find((frameInfo) => frameInfo.frameId === frameId);
const channel = frameInfo ? "renderer:navigate-in-cluster" : "renderer:navigate";
const channel = frameInfo
? IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER
: IpcRendererNavigationEvents.NAVIGATE_IN_APP;
this.sendToView({
channel,
@ -152,7 +155,7 @@ export class WindowManager extends Singleton {
const frameInfo = clusterFrameMap.get(this.activeClusterId);
if (frameInfo) {
this.sendToView({ channel: "renderer:reload", frameInfo });
this.sendToView({ channel: IpcRendererNavigationEvents.RELOAD_PAGE, frameInfo });
} else {
webContents.getFocusedWebContents()?.reload();
}

View File

@ -0,0 +1,22 @@
import fse from "fs-extra";
import { app, remote } from "electron";
import path from "path";
export async function fileNameMigration() {
const userDataPath = (app || remote.app).getPath("userData");
const configJsonPath = path.join(userDataPath, "config.json");
const lensUserStoreJsonPath = path.join(userDataPath, "lens-user-store.json");
try {
await fse.move(configJsonPath, lensUserStoreJsonPath);
} catch (error) {
if (error.code === "ENOENT" && error.path === configJsonPath) { // (No such file or directory)
return; // file already moved
} else if (error.message === "dest already exists.") {
await fse.remove(configJsonPath);
} else {
// pass other errors along
throw error;
}
}
}

View File

@ -1,6 +1,11 @@
// User store migrations
import version210Beta4 from "./2.1.0-beta.4";
import { fileNameMigration } from "./file-name-migration";
export {
fileNameMigration
};
export default {
...version210Beta4,

View File

@ -15,6 +15,7 @@ import { clusterStore } from "../../../common/cluster-store";
import { PageLayout } from "../layout/page-layout";
import { requestMain } from "../../../common/ipc";
import { clusterActivateHandler, clusterRefreshHandler } from "../../../common/cluster-ipc";
import { navigation } from "../../navigation";
interface Props extends RouteComponentProps<IClusterSettingsRouteParams> {
}
@ -30,6 +31,10 @@ export class ClusterSettings extends React.Component<Props> {
}
componentDidMount() {
const { hash } = navigation.location;
document.getElementById(hash.slice(1))?.scrollIntoView();
disposeOnUnmount(this, [
reaction(() => this.cluster, this.refreshCluster, {
fireImmediately: true,

View File

@ -16,7 +16,7 @@ export class ClusterAccessibleNamespaces extends React.Component<Props> {
render() {
return (
<>
<SubTitle title="Accessible Namespaces" />
<SubTitle title="Accessible Namespaces" id="accessible-namespaces" />
<p>This setting is useful for manually specifying which namespaces you have access to. This is useful when you do not have permissions to list namespaces.</p>
<EditableList
placeholder="Add new namespace..."

View File

@ -50,6 +50,10 @@
color: white;
}
*:target {
color: $textColorAccent;
}
html {
font-size: 62.5%; // 1 rem == 10px
color: $textColorPrimary;

View File

@ -0,0 +1,51 @@
import React from "react";
import uniqueId from "lodash/uniqueId";
import { clusterSettingsURL } from "../+cluster-settings";
import { landingURL } from "../+landing-page";
import { clusterStore } from "../../../common/cluster-store";
import { broadcastMessage, requestMain } from "../../../common/ipc";
import { clusterDisconnectHandler } from "../../../common/cluster-ipc";
import { ConfirmDialog } from "../confirm-dialog";
import { Cluster } from "../../../main/cluster";
import { Tooltip } from "../../components//tooltip";
import { IpcRendererNavigationEvents } from "../../navigation/events";
const navigate = (route: string) =>
broadcastMessage(IpcRendererNavigationEvents.NAVIGATE_IN_APP, route);
/**
* Creates handlers for high-level actions
* that could be performed on an individual cluster
* @param cluster Cluster
*/
export const ClusterActions = (cluster: Cluster) => ({
showSettings: () => navigate(clusterSettingsURL({
params: { clusterId: cluster.id }
})),
disconnect: async () => {
clusterStore.deactivate(cluster.id);
navigate(landingURL());
await requestMain(clusterDisconnectHandler, cluster.id);
},
remove: () => {
const tooltipId = uniqueId("tooltip_target_");
return ConfirmDialog.open({
okButtonProps: {
primary: false,
accent: true,
label: "Remove"
},
ok: () => {
clusterStore.deactivate(cluster.id);
clusterStore.removeById(cluster.id);
navigate(landingURL());
},
message: <p>
Are you sure want to remove cluster <b id={tooltipId}>{cluster.name}</b>?
<Tooltip targetId={tooltipId}>{cluster.id}</Tooltip>
</p>
});
}
});

View File

@ -2,7 +2,6 @@ import "./clusters-menu.scss";
import React from "react";
import { remote } from "electron";
import { requestMain } from "../../../common/ipc";
import type { Cluster } from "../../../main/cluster";
import { DragDropContext, Draggable, DraggableProvided, Droppable, DroppableProvided, DropResult } from "react-beautiful-dnd";
import { observer } from "mobx-react";
@ -13,12 +12,10 @@ import { Icon } from "../icon";
import { autobind, cssNames, IClassName } from "../../utils";
import { isActiveRoute, navigate } from "../../navigation";
import { addClusterURL } from "../+add-cluster";
import { clusterSettingsURL } from "../+cluster-settings";
import { landingURL } from "../+landing-page";
import { ConfirmDialog } from "../confirm-dialog";
import { clusterViewURL } from "./cluster-view.route";
import { ClusterActions } from "./cluster-actions";
import { getExtensionPageUrl, globalPageMenuRegistry, globalPageRegistry } from "../../../extensions/registries";
import { clusterDisconnectHandler } from "../../../common/cluster-ipc";
import { commandRegistry } from "../../../extensions/registries/command-registry";
import { CommandOverlay } from "../command-palette/command-container";
import { computed, observable } from "mobx";
@ -40,51 +37,24 @@ export class ClustersMenu extends React.Component<Props> {
showContextMenu = (cluster: Cluster) => {
const { Menu, MenuItem } = remote;
const menu = new Menu();
const actions = ClusterActions(cluster);
menu.append(new MenuItem({
label: `Settings`,
click: () => {
navigate(clusterSettingsURL({
params: {
clusterId: cluster.id
}
}));
}
click: actions.showSettings
}));
if (cluster.online) {
menu.append(new MenuItem({
label: `Disconnect`,
click: async () => {
if (clusterStore.isActive(cluster.id)) {
navigate(landingURL());
clusterStore.setActive(null);
}
await requestMain(clusterDisconnectHandler, cluster.id);
}
click: actions.disconnect
}));
}
if (!cluster.isManaged) {
menu.append(new MenuItem({
label: `Remove`,
click: () => {
ConfirmDialog.open({
okButtonProps: {
primary: false,
accent: true,
label: `Remove`,
},
ok: () => {
if (clusterStore.activeClusterId === cluster.id) {
navigate(landingURL());
clusterStore.setActive(null);
}
clusterStore.removeById(cluster.id);
},
message: <p>Are you sure want to remove cluster <b title={cluster.id}>{cluster.contextName}</b>?</p>,
});
}
click: actions.remove
}));
}
menu.popup({

View File

@ -1 +1,2 @@
export * from "./cluster-manager";
export * from "./cluster-actions";

View File

@ -1,7 +1,7 @@
import "./edit-resource.scss";
import React from "react";
import { observable, when } from "mobx";
import { action, computed, observable } from "mobx";
import { observer } from "mobx-react";
import jsYaml from "js-yaml";
import { IDockTab } from "./dock.store";
@ -11,6 +11,7 @@ import { InfoPanel } from "./info-panel";
import { Badge } from "../badge";
import { EditorPanel } from "./editor-panel";
import { Spinner } from "../spinner";
import { KubeObject } from "../../api/kube-object";
interface Props {
className?: string;
@ -21,14 +22,6 @@ interface Props {
export class EditResource extends React.Component<Props> {
@observable error = "";
async componentDidMount() {
await when(() => this.isReady);
if (!this.tabData.draft) {
this.saveDraft(this.resource); // make initial dump to editor
}
}
get tabId() {
return this.props.tab.id;
}
@ -37,20 +30,32 @@ export class EditResource extends React.Component<Props> {
return editResourceStore.isReady(this.tabId);
}
get tabData() {
return editResourceStore.getData(this.tabId);
}
get resource() {
get resource(): KubeObject | undefined {
return editResourceStore.getResource(this.tabId);
}
saveDraft(draft: string | object) {
@computed get draft(): string {
if (!this.isReady) {
return ""; // wait until tab's data and kube-object resource are loaded
}
const { draft } = editResourceStore.getData(this.tabId);
if (typeof draft === "string") {
return draft;
}
return jsYaml.dump(this.resource); // dump resource first time
}
@action
saveDraft(draft: string | KubeObject) {
if (typeof draft === "object") {
draft = draft ? jsYaml.dump(draft) : undefined;
}
editResourceStore.setData(this.tabId, {
...this.tabData,
...editResourceStore.getData(this.tabId),
draft,
});
}
@ -64,9 +69,8 @@ export class EditResource extends React.Component<Props> {
if (this.error) {
return;
}
const { draft } = this.tabData;
const store = editResourceStore.getStore(this.tabId);
const updatedResource = await store.update(this.resource, jsYaml.safeLoad(draft));
const updatedResource = await store.update(this.resource, jsYaml.safeLoad(this.draft));
this.saveDraft(updatedResource); // update with new resourceVersion to avoid further errors on save
const resourceType = updatedResource.kind;
@ -80,14 +84,12 @@ export class EditResource extends React.Component<Props> {
};
render() {
if (!this.isReady) {
const { tabId, error, onChange, save, draft, isReady, resource } = this;
if (!isReady) {
return <Spinner center/>;
}
const { tabId, error, onChange, save } = this;
const { kind, getNs, getName } = this.resource;
const { draft } = this.tabData;
return (
<div className={cssNames("EditResource flex column", this.props.className)}>
<InfoPanel
@ -98,9 +100,9 @@ export class EditResource extends React.Component<Props> {
submittingMessage="Applying.."
controls={(
<div className="resource-info flex gaps align-center">
<span>Kind:</span> <Badge label={kind}/>
<span>Name:</span><Badge label={getName()}/>
<span>Namespace:</span> <Badge label={getNs() || "global"}/>
<span>Kind:</span> <Badge label={resource.kind}/>
<span>Name:</span><Badge label={resource.getName()}/>
<span>Namespace:</span> <Badge label={resource.getNs() || "global"}/>
</div>
)}
/>

View File

@ -7,15 +7,19 @@ import "@testing-library/jest-dom/extend-expect";
import { MainLayoutHeader } from "../main-layout-header";
import { Cluster } from "../../../../main/cluster";
import { workspaceStore } from "../../../../common/workspace-store";
import { broadcastMessage } from "../../../../common/ipc";
import { broadcastMessage, requestMain } from "../../../../common/ipc";
import { clusterDisconnectHandler } from "../../../../common/cluster-ipc";
import { ConfirmDialog } from "../../confirm-dialog";
import { IpcRendererNavigationEvents } from "../../../navigation/events";
const mockBroadcastIpc = broadcastMessage as jest.MockedFunction<typeof broadcastMessage>;
const mockRequestMain = requestMain as jest.MockedFunction<typeof requestMain>;
const cluster: Cluster = new Cluster({
id: "foo",
contextName: "minikube",
kubeConfigPath: "minikube-config.yml",
workspace: workspaceStore.currentWorkspaceId
workspace: workspaceStore.currentWorkspaceId,
});
describe("<MainLayoutHeader />", () => {
@ -25,20 +29,12 @@ describe("<MainLayoutHeader />", () => {
expect(container).toBeInstanceOf(HTMLElement);
});
it("renders gear icon", () => {
it("renders three dots icon", () => {
const { container } = render(<MainLayoutHeader cluster={cluster} />);
const icon = container.querySelector(".Icon .icon");
expect(icon).toBeInstanceOf(HTMLElement);
expect(icon).toHaveTextContent("settings");
});
it("navigates to cluster settings", () => {
const { container } = render(<MainLayoutHeader cluster={cluster} />);
const icon = container.querySelector(".Icon");
fireEvent.click(icon);
expect(mockBroadcastIpc).toBeCalledWith("renderer:navigate", "/cluster/foo/settings");
expect(icon).toHaveTextContent("more_vert");
});
it("renders cluster name", () => {
@ -46,4 +42,60 @@ describe("<MainLayoutHeader />", () => {
expect(getByText("minikube")).toBeInTheDocument();
});
describe("Cluster Actions Menu", () => {
let settingsBtn: Element;
let disconnectBtn: Element;
let removeBtn: Element;
beforeEach(() => {
const { container } = render(<div>
<MainLayoutHeader cluster={cluster} />
<ConfirmDialog />
</div>);
const icon = container.querySelector(".Icon");
cluster.online = true;
fireEvent.click(icon);
[settingsBtn, disconnectBtn, removeBtn] = Array.from(document.querySelectorAll("ul.ClusterActionsMenu > li"))
.map(el => el.querySelector("span"));
});
afterEach(() => {
cluster.online = false;
});
it("renders cluster menu items", () => {
expect(settingsBtn).toBeDefined();
expect(settingsBtn.textContent).toBe("Settings");
expect(disconnectBtn).toBeDefined();
expect(disconnectBtn.textContent).toBe("Disconnect");
expect(removeBtn).toBeDefined();
expect(removeBtn.textContent).toBe("Remove");
});
it("navigates to cluster settings", () => {
fireEvent.click(settingsBtn);
expect(mockBroadcastIpc).toBeCalledWith(IpcRendererNavigationEvents.NAVIGATE_IN_APP, "/cluster/foo/settings");
});
it("disconnects from cluster", () => {
fireEvent.click(disconnectBtn);
expect(mockRequestMain).toBeCalledWith(clusterDisconnectHandler, cluster.id);
});
it("opens 'Remove cluster' dialog", async () => {
fireEvent.click(removeBtn);
const dialog = document.querySelector(".ConfirmDialog");
expect(dialog).toBeDefined();
expect(dialog).not.toBe(null);
const okBtn = dialog.querySelector("button.ok");
expect(okBtn.textContent).toBe("Remove");
});
});
});

View File

@ -1,11 +1,10 @@
import { observer } from "mobx-react";
import React from "react";
import { observer } from "mobx-react";
import { clusterSettingsURL } from "../+cluster-settings";
import { broadcastMessage } from "../../../common/ipc";
import { ClusterActions } from "../cluster-manager";
import { Cluster } from "../../../main/cluster";
import { cssNames } from "../../utils";
import { Icon } from "../icon";
import { MenuActions, MenuItem } from "../menu";
interface Props {
cluster: Cluster
@ -13,21 +12,28 @@ interface Props {
}
export const MainLayoutHeader = observer(({ cluster, className }: Props) => {
const actions = ClusterActions(cluster);
const renderMenu = () => (
<MenuActions autoCloseOnSelect className="ClusterActionsMenu">
<MenuItem onClick={actions.showSettings}>
<span>Settings</span>
</MenuItem>
<MenuItem onClick={actions.disconnect}>
<span>Disconnect</span>
</MenuItem>
{
!cluster.isManaged && (
<MenuItem onClick={actions.remove}>
<span>Remove</span>
</MenuItem>
)
}
</MenuActions>);
return (
<header className={cssNames("flex gaps align-center justify-space-between", className)}>
<span className="cluster">{cluster.name}</span>
<Icon
material="settings"
tooltip="Open cluster settings"
interactive
onClick={() => {
broadcastMessage("renderer:navigate", clusterSettingsURL({
params: {
clusterId: cluster.id
}
}));
}}
/>
{renderMenu()}
</header>
);
});

View File

@ -6,19 +6,18 @@ interface Props {
className?: string;
title: React.ReactNode;
compact?: boolean; // no bottom padding
id?: string;
}
export class SubTitle extends React.Component<Props> {
render() {
const { compact, title, children } = this.props;
let { className } = this.props;
className = cssNames("SubTitle", className, {
const { className, compact, title, children, id } = this.props;
const classNames = cssNames("SubTitle", className, {
compact,
});
return (
<div className={className}>
<div className={classNames} id={id}>
{title} {children}
</div>
);

View File

@ -1,10 +1,13 @@
import React from "react";
import { ipcRenderer, IpcRendererEvent } from "electron";
import { areArgsUpdateAvailableFromMain, UpdateAvailableChannel, onCorrect, UpdateAvailableFromMain, BackchannelArg } from "../../common/ipc";
import { areArgsUpdateAvailableFromMain, UpdateAvailableChannel, onCorrect, UpdateAvailableFromMain, BackchannelArg, ClusterListNamespaceForbiddenChannel, isListNamespaceForbiddenArgs, ListNamespaceForbiddenArgs } from "../../common/ipc";
import { Notifications, notificationsStore } from "../components/notifications";
import { Button } from "../components/button";
import { isMac } from "../../common/vars";
import { invalidKubeconfigHandler } from "./invalid-kubeconfig-handler";
import { clusterStore } from "../../common/cluster-store";
import { navigate } from "../navigation";
import { clusterSettingsURL } from "../components/+cluster-settings";
function sendToBackchannel(backchannel: string, notificationId: string, data: BackchannelArg): void {
notificationsStore.remove(notificationId);
@ -42,7 +45,8 @@ function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, update
<Button active outlined label="No" onClick={() => sendToBackchannel(backchannel, notificationId, { doUpdate: false })} />
</div>
</div>
), {
),
{
id: notificationId,
onClose() {
sendToBackchannel(backchannel, notificationId, { doUpdate: false });
@ -51,6 +55,42 @@ function UpdateAvailableHandler(event: IpcRendererEvent, ...[backchannel, update
);
}
const listNamespacesForbiddenHandlerDisplayedAt = new Map<string, number>();
const intervalBetweenNotifications = 1000 * 60; // 60s
function ListNamespacesForbiddenHandler(event: IpcRendererEvent, ...[clusterId]: ListNamespaceForbiddenArgs): void {
const lastDisplayedAt = listNamespacesForbiddenHandlerDisplayedAt.get(clusterId);
const wasDisplayed = Boolean(lastDisplayedAt);
const now = Date.now();
if (!wasDisplayed || (now - lastDisplayedAt) > intervalBetweenNotifications) {
listNamespacesForbiddenHandlerDisplayedAt.set(clusterId, now);
} else {
// don't bother the user too often
return;
}
const notificationId = `list-namespaces-forbidden:${clusterId}`;
Notifications.info(
(
<div className="flex column gaps">
<b>Add Accessible Namespaces</b>
<p>Cluster <b>{clusterStore.active.name}</b> does not have permissions to list namespaces. Please add the namespaces you have access to.</p>
<div className="flex gaps row align-left box grow">
<Button active outlined label="Go to Accessible Namespaces Settings" onClick={()=> {
navigate(clusterSettingsURL({ params: { clusterId }, fragment: "accessible-namespaces" }));
notificationsStore.remove(notificationId);
}} />
</div>
</div>
),
{
id: notificationId,
}
);
}
export function registerIpcHandlers() {
onCorrect({
source: ipcRenderer,
@ -59,4 +99,10 @@ export function registerIpcHandlers() {
verifier: areArgsUpdateAvailableFromMain,
});
onCorrect(invalidKubeconfigHandler);
onCorrect({
source: ipcRenderer,
channel: ClusterListNamespaceForbiddenChannel,
listener: ListNamespacesForbiddenHandler,
verifier: isListNamespaceForbiddenArgs,
});
}

View File

@ -16,6 +16,7 @@ import { LensProtocolRouterRenderer, bindProtocolAddRouteHandlers } from "./prot
import { registerIpcHandlers } from "./ipc";
import { ipcRenderer } from "electron";
import { CacheProvider } from "@emotion/react";
import { IpcRendererNavigationEvents } from "./navigation/events";
import createCache from "@emotion/cache";
@observer
@ -32,7 +33,7 @@ export class LensApp extends React.Component {
});
registerIpcHandlers();
ipcRenderer.send("renderer:loaded");
ipcRenderer.send(IpcRendererNavigationEvents.LOADED);
}
render() {

View File

@ -4,6 +4,14 @@ import { getMatchedClusterId, navigate } from "./helpers";
import { broadcastMessage, subscribeToBroadcast } from "../../common/ipc";
import logger from "../../main/logger";
export const enum IpcRendererNavigationEvents {
RELOAD_PAGE = "renderer:page-reload",
CLUSTER_VIEW_CURRENT_ID = "renderer:cluster-id-of-active-view",
NAVIGATE_IN_APP = "renderer:navigate",
NAVIGATE_IN_CLUSTER = "renderer:navigate-in-cluster",
LOADED = "renderer:loaded",
}
export function bindEvents() {
if (!ipcRenderer) {
return;
@ -16,7 +24,7 @@ export function bindEvents() {
}
// Reload dashboard window
subscribeToBroadcast("renderer:reload", () => {
subscribeToBroadcast(IpcRendererNavigationEvents.RELOAD_PAGE, () => {
location.reload();
});
}
@ -25,13 +33,13 @@ export function bindEvents() {
function bindClusterManagerRouteEvents() {
// Keep track of active cluster-id for handling IPC/menus/etc.
reaction(() => getMatchedClusterId(), clusterId => {
broadcastMessage("cluster-view:current-id", clusterId);
broadcastMessage(IpcRendererNavigationEvents.CLUSTER_VIEW_CURRENT_ID, clusterId);
}, {
fireImmediately: true
});
// Handle navigation via IPC
subscribeToBroadcast("renderer:navigate", (event, url: string) => {
subscribeToBroadcast(IpcRendererNavigationEvents.NAVIGATE_IN_APP, (event, url: string) => {
logger.info(`[IPC]: ${event.type}: ${url}`, { currentLocation: location.href });
navigate(url);
});
@ -39,7 +47,7 @@ function bindClusterManagerRouteEvents() {
// Handle cluster-view renderer process events within iframes
function bindClusterFrameRouteEvents() {
subscribeToBroadcast("renderer:navigate-cluster-view", (event, url: string) => {
subscribeToBroadcast(IpcRendererNavigationEvents.NAVIGATE_IN_CLUSTER, (event, url: string) => {
logger.info(`[IPC]: ${event.type}: ${url}`, { currentLocation: location.href });
navigate(url);
});

View File

@ -31,23 +31,14 @@ metricRelabelings:
### Jsonnet
The required label replacements are bundled in [jsonnet/custom-prometheus](../jsonnet/custom-prometheus.jsonnet). To install it copy the file or use
[Jsonnet Bundler](https://github.com/jsonnet-bundler/jsonnet-bundler). For jsonnet bundler add the following dependency to your `jsonnetfile.json`:
The required label replacements are bundled in [jsonnet/lens/custom-prometheus](../jsonnet/lens/custom-prometheus.jsonnet). To install it copy the file or use
[Jsonnet Bundler](https://github.com/jsonnet-bundler/jsonnet-bundler).
```
{
"name": "lens",
"source": {
"git": {
"remote": "https://github.com/lensapp/lens",
"subdir": "jsonnet"
}
},
"version": "master"
}
```bash
jb init && jb install https://github.com/lensapp/lens/jsonnet/lens@master
```
and run `jb install`. When the installation was successful include it into your definitions. Using the [example](https://github.com/coreos/kube-prometheus#compiling)
When the installation was successful include it into your definitions. Using the [example](https://github.com/coreos/kube-prometheus#compiling)
of kube-prometheus, e.g.:
```