From 0e1a4a6fa8340bd1ec5e05ebea297f99e0da0b2c Mon Sep 17 00:00:00 2001 From: Sebastian Malton Date: Wed, 21 Jul 2021 12:33:17 -0400 Subject: [PATCH] Fix local cluster delete flow - Now checks for the cluster being the current cluster and confirms the action - is fixed to work with several confirmations happinging at once Signed-off-by: Sebastian Malton --- .../catalog-entities/kubernetes-cluster.ts | 27 +-- src/common/cluster-ipc.ts | 2 + src/common/utils/index.ts | 5 + src/main/initializers/ipc.ts | 25 +-- .../confirm-dialog/confirm-dialog.tsx | 158 ++++++++---------- .../dialog/delete-cluster-dialog.tsx | 77 +++++++++ src/renderer/initializers/catalog.tsx | 138 +++++++++++++++ 7 files changed, 303 insertions(+), 129 deletions(-) create mode 100644 src/renderer/components/dialog/delete-cluster-dialog.tsx diff --git a/src/common/catalog-entities/kubernetes-cluster.ts b/src/common/catalog-entities/kubernetes-cluster.ts index 14a584ee8c..b2ff284069 100644 --- a/src/common/catalog-entities/kubernetes-cluster.ts +++ b/src/common/catalog-entities/kubernetes-cluster.ts @@ -21,13 +21,12 @@ import { catalogCategoryRegistry } from "../catalog/catalog-category-registry"; import { CatalogEntity, CatalogEntityActionContext, CatalogEntityContextMenuContext, CatalogEntityMetadata, CatalogEntityStatus } from "../catalog"; -import { clusterActivateHandler, clusterDeleteHandler, clusterDisconnectHandler } from "../cluster-ipc"; +import { clusterActivateHandler, clusterDisconnectHandler } from "../cluster-ipc"; import { ClusterStore } from "../cluster-store"; import { requestMain } from "../ipc"; import { CatalogCategory, CatalogCategorySpec } from "../catalog"; import { app } from "electron"; import type { CatalogEntitySpec } from "../catalog/catalog-entity"; -import { HotbarStore } from "../hotbar-store"; export interface KubernetesClusterPrometheusMetrics { address?: { @@ -102,25 +101,11 @@ export class KubernetesCluster extends CatalogEntity context.navigate(`/entity/${this.metadata.uid}/settings`) - }, - { - title: "Delete", - icon: "delete", - onClick: () => { - HotbarStore.getInstance().removeAllHotbarItems(this.getId()); - requestMain(clusterDeleteHandler, this.metadata.uid); - }, - confirm: { - // TODO: change this to be a

tag with better formatting once this code can accept it. - message: `Delete the "${this.metadata.name}" context from "${this.spec.kubeconfigPath}"?` - } - }, - ); + context.menuItems.push({ + title: "Settings", + icon: "edit", + onClick: () => context.navigate(`/entity/${this.metadata.uid}/settings`) + }); } switch (this.status.phase) { diff --git a/src/common/cluster-ipc.ts b/src/common/cluster-ipc.ts index 9191d93048..37ceaa12e2 100644 --- a/src/common/cluster-ipc.ts +++ b/src/common/cluster-ipc.ts @@ -25,5 +25,7 @@ export const clusterVisibilityHandler = "cluster:visibility"; export const clusterRefreshHandler = "cluster:refresh"; export const clusterDisconnectHandler = "cluster:disconnect"; export const clusterDeleteHandler = "cluster:delete"; +export const clusterSetDeletingHandler = "cluster:deleting:set"; +export const clusterClearDeletingHandler = "cluster:deleting:clear"; export const clusterKubectlApplyAllHandler = "cluster:kubectl-apply-all"; export const clusterKubectlDeleteAllHandler = "cluster:kubectl-delete-all"; diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts index 6035b9f408..aa135273aa 100644 --- a/src/common/utils/index.ts +++ b/src/common/utils/index.ts @@ -26,6 +26,11 @@ export function noop(...args: T): void { return void args; } +export enum ControlFlow { + Stop, + Continue +} + export * from "./app-version"; export * from "./autobind"; export * from "./base64"; diff --git a/src/main/initializers/ipc.ts b/src/main/initializers/ipc.ts index 3c3e14f923..9bea35ce2c 100644 --- a/src/main/initializers/ipc.ts +++ b/src/main/initializers/ipc.ts @@ -22,16 +22,13 @@ import { BrowserWindow, dialog, IpcMainInvokeEvent } from "electron"; import { KubernetesCluster } from "../../common/catalog-entities"; import { clusterFrameMap } from "../../common/cluster-frames"; -import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler } from "../../common/cluster-ipc"; -import { ClusterStore } from "../../common/cluster-store"; +import { clusterActivateHandler, clusterSetFrameIdHandler, clusterVisibilityHandler, clusterRefreshHandler, clusterDisconnectHandler, clusterKubectlApplyAllHandler, clusterKubectlDeleteAllHandler, clusterDeleteHandler, clusterSetDeletingHandler, clusterClearDeletingHandler } from "../../common/cluster-ipc"; import type { ClusterId } from "../../common/cluster-types"; +import { ClusterStore } from "../../common/cluster-store"; import { appEventBus } from "../../common/event-bus"; import { dialogShowOpenDialogHandler, ipcMainHandle } from "../../common/ipc"; import { catalogEntityRegistry } from "../catalog"; import { ClusterManager } from "../cluster-manager"; -import { bundledKubectlPath } from "../kubectl"; -import logger from "../logger"; -import { promiseExecFile } from "../promise-exec"; import { ResourceApplier } from "../resource-applier"; import { WindowManager } from "../window-manager"; @@ -79,27 +76,23 @@ export function initIpcMainHandlers() { } }); - ipcMainHandle(clusterDeleteHandler, async (event, clusterId: ClusterId) => { - appEventBus.emit({ name: "cluster", action: "remove" }); + ipcMainHandle(clusterDeleteHandler, (event, clusterId: ClusterId) => { const cluster = ClusterStore.getInstance().getById(clusterId); if (!cluster) { return; } - ClusterManager.getInstance().deleting.add(clusterId); cluster.disconnect(); clusterFrameMap.delete(cluster.id); - const kubectlPath = bundledKubectlPath(); - const args = ["config", "delete-context", cluster.contextName, "--kubeconfig", cluster.kubeConfigPath]; + }); - try { - await promiseExecFile(kubectlPath, args); - } catch ({ stderr }) { - logger.error(`[CLUSTER-REMOVE]: failed to remove cluster: ${stderr}`, { clusterId, context: cluster.contextName }); + ipcMainHandle(clusterSetDeletingHandler, (event, clusterId: string) => { + ClusterManager.getInstance().deleting.add(clusterId); + }); - throw `Failed to remove cluster: ${stderr}`; - } + ipcMainHandle(clusterClearDeletingHandler, (event, clusterId: string) => { + ClusterManager.getInstance().deleting.delete(clusterId); }); ipcMainHandle(clusterKubectlApplyAllHandler, async (event, clusterId: ClusterId, resources: string[], extraArgs: string[]) => { diff --git a/src/renderer/components/confirm-dialog/confirm-dialog.tsx b/src/renderer/components/confirm-dialog/confirm-dialog.tsx index a4a8bdcddf..875096db7a 100644 --- a/src/renderer/components/confirm-dialog/confirm-dialog.tsx +++ b/src/renderer/components/confirm-dialog/confirm-dialog.tsx @@ -22,17 +22,20 @@ import "./confirm-dialog.scss"; import React, { ReactNode } from "react"; -import { observable, makeObservable } from "mobx"; +import { action, observable } from "mobx"; import { observer } from "mobx-react"; import { cssNames, noop, prevDefault } from "../../utils"; import { Button, ButtonProps } from "../button"; import { Dialog, DialogProps } from "../dialog"; import { Icon } from "../icon"; -import { Notifications } from "../notifications"; export interface ConfirmDialogProps extends Partial { } +export interface ConfirmDialogRootProps { + className?: string; +} + export interface ConfirmDialogParams extends ConfirmDialogBooleanParams { ok?: () => any | Promise; cancel?: () => any | Promise; @@ -47,23 +50,24 @@ export interface ConfirmDialogBooleanParams { cancelButtonProps?: Partial; } -const dialogState = observable.object({ - isOpen: false, - params: null as ConfirmDialogParams, -}); +const dialogState = observable.set([], { deep: false }); @observer -export class ConfirmDialog extends React.Component { - @observable isSaving = false; - - constructor(props: ConfirmDialogProps) { - super(props); - makeObservable(this); - } +export class ConfirmDialog extends React.Component { + static defaultParams: Partial = { + ok: noop, + cancel: noop, + labelOk: "Ok", + labelCancel: "Cancel", + icon: , + }; + @action static open(params: ConfirmDialogParams) { - dialogState.isOpen = true; - dialogState.params = params; + dialogState.add({ + ...ConfirmDialog.defaultParams, + ...params, + }); } static confirm(params: ConfirmDialogBooleanParams): Promise { @@ -76,92 +80,62 @@ export class ConfirmDialog extends React.Component { }); } - static defaultParams: Partial = { - ok: noop, - cancel: noop, - labelOk: "Ok", - labelCancel: "Cancel", - icon: , - }; - - get params(): ConfirmDialogParams { - return Object.assign({}, ConfirmDialog.defaultParams, dialogState.params); - } - - ok = async () => { + ok = async (params: ConfirmDialogParams) => { try { - this.isSaving = true; - await (async () => this.params.ok())(); - } catch (error) { - Notifications.error( - <> -

Confirmation action failed:

-

{error?.message ?? error?.toString?.() ?? "Unknown error"}

- - ); - } finally { - this.isSaving = false; - dialogState.isOpen = false; + await params.ok(); + } catch {} finally { + dialogState.delete(params); } }; - onClose = () => { - this.isSaving = false; - }; - - close = async () => { + close = async (params: ConfirmDialogParams) => { try { - await Promise.resolve(this.params.cancel()); - } catch (error) { - Notifications.error( - <> -

Cancelling action failed:

-

{error?.message ?? error?.toString?.() ?? "Unknown error"}

- - ); - } finally { - this.isSaving = false; - dialogState.isOpen = false; + await params.cancel(); + } catch { } finally { + dialogState.delete(params); } }; render() { - const { className, ...dialogProps } = this.props; - const { - icon, labelOk, labelCancel, message, - okButtonProps = {}, - cancelButtonProps = {}, - } = this.params; + const { className } = this.props; - return ( - -
- {icon} {message} -
-
-
-
- ); + return [...dialogState].reduce((prev, params) => { + const { + icon, labelOk, labelCancel, message, + okButtonProps = {}, + cancelButtonProps = {}, + } = params; + + return ( + <> + {prev} + this.close(params)} + > +
+ {icon} {message} +
+
+
+
+ + ); + }, null); } } diff --git a/src/renderer/components/dialog/delete-cluster-dialog.tsx b/src/renderer/components/dialog/delete-cluster-dialog.tsx new file mode 100644 index 0000000000..810ec6606b --- /dev/null +++ b/src/renderer/components/dialog/delete-cluster-dialog.tsx @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2021 OpenLens Authors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import type { KubeConfig } from "@kubernetes/client-node"; +import React from "react"; +import type { Cluster } from "../../../main/cluster"; +import { ControlFlow, iter } from "../../utils"; +import { ConfirmDialog } from "../confirm-dialog"; +import { Select } from "../select"; + +export interface DeleteClusterDialogArgs { + config: KubeConfig; + cluster: Cluster; +} + +export async function deleteClusterConfirmDialog({ config, cluster }: DeleteClusterDialogArgs): Promise<[ControlFlow.Stop] | [ControlFlow.Continue, string | false]> { + const contextNames = new Set(config.getContexts().map(({ name }) => name)); + + contextNames.delete(cluster.contextName); + + if (config.currentContext !== cluster.contextName || contextNames.size === 0) { + return [ControlFlow.Continue, false]; + } + + const options = [ + { + label: "--unset current-context--", + value: false, + }, + ...iter.map(contextNames, name => ({ + label: name, + value: name, + })), + ]; + let selectedOption: string | false = false; + const didConfirm = await ConfirmDialog.confirm({ + labelOk: "Select context", + message: ( + <> +

+ The context you are deleting is the current-context in the {cluster.kubeConfigPath} file. + Please select one of the other contexts to replace it with. +

+
+