Fix local cluster delete flow (#3473)

* Fix local cluster delete flow

- Now checks for the cluster being the current cluster and confirms the
  action

- <ConfirmDialog> is fixed to work with several confirmations happinging
  at once

Signed-off-by: Sebastian Malton <sebastian@malton.name>

* Custom delete cluster dialog

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Saving kubeconfig file refactoring

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Switching current-context from dialog

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Cleaning up css styles

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Show Delete menu item only for local cluster

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Make all dialogs rounded

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Releasing kubeconfig file after lock

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Adding delete cluster dialog tests

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Cleaning up

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Delete cluster dialog refactoring

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Fixing typo

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Removing temp file on error

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Updating tests

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Do not switch context if select is hidden

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Removing spread symbols

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Moving cluster delete event from catalog initializer to ipc

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Refactor warning messages

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Getting spread (...) back to fix Select render

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Using async form without promises

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Do not show context switcher if no contexts left

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* Updating the tests

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

* don't allow delete of the active cluster before setting to new active cluster (#3749)

* don't allow user to delete the active cluster before setting to new active cluster

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

* fix lint issues

Signed-off-by: Jim Ehrismann <jehrismann@mirantis.com>

* Show delete button if no more contexts available

Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

Co-authored-by: Alex Andreev <alex.andreev.email@gmail.com>
Signed-off-by: Alex Andreev <alex.andreev.email@gmail.com>

Co-authored-by: Alex Andreev <alex.andreev.email@gmail.com>
Co-authored-by: Jim Ehrismann <40840436+jim-docker@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2021-09-08 15:06:14 +03:00
1 parent b9aec3ad9f
commit d72158cc63
16 files changed
+751 -40

No files matched your search

@@ -35,6 +35,7 @@ import { HotbarMenu } from "../hotbar/hotbar-menu";
import { EntitySettings } from "../+entity-settings";
import { Welcome } from "../+welcome";
import * as routes from "../../../common/routes";
import { DeleteClusterDialog } from "../delete-cluster-dialog";
import { reaction } from "mobx";
import { navigation } from "../../navigation";
import { setEntityOnRouteMatch } from "../../../main/catalog-sources/helpers/general-active-sync";
@@ -71,6 +72,7 @@ export class ClusterManager extends React.Component {
</main>
<HotbarMenu/>
<BottomBar/>
<DeleteClusterDialog/>
</div>
);
}
@@ -33,7 +33,8 @@
max-width: 50vw;
min-width: 45 * $unit;
background-color: white;
outline: $unit solid rgba(255, 255, 255, .15);
border-radius: $radius;
line-height: 1.5;
}
.confirm-content {
@@ -44,7 +45,7 @@
> .Icon {
margin-left: inherit;
margin-right: $margin;
margin-right: $margin * 2;
color: $colorSoftError;
}
@@ -60,9 +61,11 @@
.confirm-buttons {
background: #f4f4f4;
padding: $spacing;
padding: $padding * 2.5;
display: flex;
justify-content: flex-end;
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
> * {
margin-left: $margin
@@ -0,0 +1,249 @@
/**
* 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 "@testing-library/jest-dom/extend-expect";
import { KubeConfig } from "@kubernetes/client-node";
import { fireEvent, render } from "@testing-library/react";
import mockFs from "mock-fs";
import React from "react";
import selectEvent from "react-select-event";
import { Cluster } from "../../../../main/cluster";
import { DeleteClusterDialog } from "../delete-cluster-dialog";
jest.mock("electron", () => ({
app: {
getPath: () => "tmp",
},
}));
const kubeconfig = `
apiVersion: v1
clusters:
- cluster:
server: https://localhost
name: test
- cluster:
server: http://localhost
name: other-cluster
contexts:
- context:
cluster: test
user: test
name: test
- context:
cluster: test
user: test
name: test2
- context:
cluster: other-cluster
user: test
name: other-context
current-context: other-context
kind: Config
preferences: {}
users:
- name: test
user:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
const singleClusterConfig = `
apiVersion: v1
clusters:
- cluster:
server: http://localhost
name: other-cluster
contexts:
- context:
cluster: other-cluster
user: test
name: other-context
current-context: other-context
kind: Config
preferences: {}
users:
- name: test
user:
token: kubeconfig-user-q4lm4:xxxyyyy
`;
let config: KubeConfig;
describe("<DeleteClusterDialog />", () => {
describe("Kubeconfig with different clusters", () => {
beforeEach(async () => {
const mockOpts = {
"temp-kube-config": kubeconfig,
};
mockFs(mockOpts);
config = new KubeConfig();
config.loadFromString(kubeconfig);
});
afterEach(() => {
mockFs.restore();
});
it("renders w/o errors", () => {
const { container } = render(<DeleteClusterDialog />);
expect(container).toBeInstanceOf(HTMLElement);
});
it("shows warning when deleting non-current-context cluster", () => {
const cluster = new Cluster({
id: "test",
contextName: "test",
preferences: {
clusterName: "minikube"
},
kubeConfigPath: "./temp-kube-config",
});
DeleteClusterDialog.open({ cluster, config });
const { getByText } = render(<DeleteClusterDialog />);
const message = "The contents of kubeconfig file will be changed!";
expect(getByText(message)).toBeInstanceOf(HTMLElement);
});
it("shows warning when deleting current-context cluster", () => {
const cluster = new Cluster({
id: "other-cluster",
contextName: "other-context",
preferences: {
clusterName: "other-cluster"
},
kubeConfigPath: "./temp-kube-config",
});
DeleteClusterDialog.open({ cluster, config });
const { getByTestId } = render(<DeleteClusterDialog />);
expect(getByTestId("current-context-warning")).toBeInstanceOf(HTMLElement);
});
it("shows context switcher when deleting current cluster", async () => {
const cluster = new Cluster({
id: "other-cluster",
contextName: "other-context",
preferences: {
clusterName: "other-cluster"
},
kubeConfigPath: "./temp-kube-config",
});
DeleteClusterDialog.open({ cluster, config });
const { getByText } = render(<DeleteClusterDialog />);
expect(getByText("Select...")).toBeInTheDocument();
selectEvent.openMenu(getByText("Select..."));
expect(getByText("test")).toBeInTheDocument();
expect(getByText("test2")).toBeInTheDocument();
});
it("shows context switcher after checkbox click", async () => {
const cluster = new Cluster({
id: "some-cluster",
contextName: "test",
preferences: {
clusterName: "test"
},
kubeConfigPath: "./temp-kube-config",
});
DeleteClusterDialog.open({ cluster, config });
const { getByText, getByTestId } = render(<DeleteClusterDialog />);
const link = getByTestId("context-switch");
expect(link).toBeInstanceOf(HTMLElement);
fireEvent.click(link);
expect(getByText("Select...")).toBeInTheDocument();
selectEvent.openMenu(getByText("Select..."));
expect(getByText("test")).toBeInTheDocument();
expect(getByText("test2")).toBeInTheDocument();
});
it("shows warning for internal kubeconfig cluster", () => {
const cluster = new Cluster({
id: "some-cluster",
contextName: "test",
preferences: {
clusterName: "test"
},
kubeConfigPath: "./temp-kube-config",
});
const spy = jest.spyOn(cluster, "isInLocalKubeconfig").mockImplementation(() => true);
DeleteClusterDialog.open({ cluster, config });
const { getByTestId } = render(<DeleteClusterDialog />);
expect(getByTestId("internal-kubeconfig-warning")).toBeInstanceOf(HTMLElement);
spy.mockRestore();
});
});
describe("Kubeconfig with single cluster", () => {
beforeEach(async () => {
const mockOpts = {
"temp-kube-config": singleClusterConfig,
};
mockFs(mockOpts);
config = new KubeConfig();
config.loadFromString(singleClusterConfig);
});
afterEach(() => {
mockFs.restore();
});
it("shows warning if no other contexts left", () => {
const cluster = new Cluster({
id: "other-cluster",
contextName: "other-context",
preferences: {
clusterName: "other-cluster"
},
kubeConfigPath: "./temp-kube-config",
});
DeleteClusterDialog.open({ cluster, config });
const { getByTestId } = render(<DeleteClusterDialog />);
expect(getByTestId("no-more-contexts-warning")).toBeInstanceOf(HTMLElement);
});
});
});
@@ -0,0 +1,41 @@
.warning {
@apply mt-4 flex py-4 px-6 rounded-md items-center;
background: #fad8d7;
color: #797979;
}
.warningIcon {
@apply mr-5;
font-size: 26px;
}
.dialog {
> div {
@apply rounded-md bg-white;
max-width: 600px;
min-width: calc(45 * var(--unit));
}
b {
word-break: break-all;
}
}
.dialogContent {
@apply p-9 leading-9;
}
.dialogButtons {
@apply flex justify-end p-7 rounded-md;
background: #f4f4f4;
> * {
margin-left: var(--margin)
}
}
.hr {
@apply mt-7;
height: 1px;
background: #dfdfdf80;
}
@@ -0,0 +1,279 @@
/**
* 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 styles from "./delete-cluster-dialog.module.css";
import { computed, makeObservable, observable } from "mobx";
import { observer } from "mobx-react";
import React from "react";
import { Button } from "../button";
import type { KubeConfig } from "@kubernetes/client-node";
import type { Cluster } from "../../../main/cluster";
import { saveKubeconfig } from "./save-config";
import { requestMain } from "../../../common/ipc";
import { clusterClearDeletingHandler, clusterDeleteHandler, clusterSetDeletingHandler } from "../../../common/cluster-ipc";
import { Notifications } from "../notifications";
import { HotbarStore } from "../../../common/hotbar-store";
import { boundMethod } from "autobind-decorator";
import { Dialog } from "../dialog";
import { Icon } from "../icon";
import { Select } from "../select";
import { Checkbox } from "../checkbox";
type DialogState = {
isOpen: boolean,
config?: KubeConfig,
cluster?: Cluster
};
const dialogState: DialogState = observable({
isOpen: false
});
type Props = {};
@observer
export class DeleteClusterDialog extends React.Component {
showContextSwitch = false;
newCurrentContext = "";
constructor(props: Props) {
super(props);
makeObservable(this, {
showContextSwitch: observable,
newCurrentContext: observable
});
}
static open({ config, cluster }: Partial<DialogState>) {
dialogState.isOpen = true;
dialogState.config = config;
dialogState.cluster = cluster;
}
static close() {
dialogState.isOpen = false;
dialogState.cluster = null;
dialogState.config = null;
}
@boundMethod
onOpen() {
this.newCurrentContext = "";
if (this.isCurrentContext()) {
this.showContextSwitch = true;
}
}
@boundMethod
onClose() {
DeleteClusterDialog.close();
this.showContextSwitch = false;
}
removeContext() {
dialogState.config.contexts = dialogState.config.contexts.filter(item =>
item.name !== dialogState.cluster.contextName
);
}
changeCurrentContext() {
if (this.newCurrentContext && this.showContextSwitch) {
dialogState.config.currentContext = this.newCurrentContext;
}
}
@boundMethod
async onDelete() {
const { cluster, config } = dialogState;
await requestMain(clusterSetDeletingHandler, cluster.id);
this.removeContext();
this.changeCurrentContext();
try {
await saveKubeconfig(config, cluster.kubeConfigPath);
HotbarStore.getInstance().removeAllHotbarItems(cluster.id);
await requestMain(clusterDeleteHandler, cluster.id);
} catch(error) {
Notifications.error(`Cannot remove cluster, failed to process config file. ${error}`);
await requestMain(clusterClearDeletingHandler, cluster.id);
}
this.onClose();
}
@computed get disableDelete() {
const { cluster, config } = dialogState;
const noContextsAvailable = config.contexts.filter(context => context.name !== cluster.contextName).length == 0;
const newContextNotSelected = this.newCurrentContext === "";
if (noContextsAvailable) {
return false;
}
return this.showContextSwitch && newContextNotSelected;
}
isCurrentContext() {
return dialogState.config.currentContext == dialogState.cluster.contextName;
}
renderCurrentContextSwitch() {
if (!this.showContextSwitch) return null;
const { cluster, config } = dialogState;
const contexts = config.contexts.filter(context => context.name !== cluster.contextName);
const options = [
...contexts.map(context => ({
label: context.name,
value: context.name,
})),
];
return (
<div className="mt-4">
<Select
options={options}
value={this.newCurrentContext}
onChange={({ value }) => this.newCurrentContext = value}
themeName="light"
className="ml-[1px] mr-[1px]"
/>
</div>
);
}
renderDeleteMessage() {
const { cluster } = dialogState;
if (cluster.isInLocalKubeconfig()) {
return (
<div>
Delete the <b>{cluster.getMeta().name}</b> context from Lens&apos;s internal kubeconfig?
</div>
);
}
return (
<div>
Delete the <b>{cluster.getMeta().name}</b> context from <b>{cluster.kubeConfigPath}</b>?
</div>
);
}
getWarningMessage() {
const { cluster, config } = dialogState;
const contexts = config.contexts.filter(context => context.name !== cluster.contextName);
if (!contexts.length) {
return (
<p data-testid="no-more-contexts-warning">
This will remove the last context in kubeconfig. There will be no active context.
</p>
);
}
if (this.isCurrentContext()) {
return (
<p data-testid="current-context-warning">
This will remove active context in kubeconfig. Use drop down below to&nbsp;select a&nbsp;different one.
</p>
);
}
if (cluster.isInLocalKubeconfig()) {
return (
<p data-testid="internal-kubeconfig-warning">
Are you sure you want to delete it? It can be re-added through the copy/paste mechanism.
</p>
);
}
return (
<p data-testid="kubeconfig-change-warning">The contents of kubeconfig file will be changed!</p>
);
}
renderWarning() {
return (
<div className={styles.warning}>
<Icon material="warning_amber" className={styles.warningIcon}/>
{this.getWarningMessage()}
</div>
);
}
render() {
const { cluster, config, isOpen } = dialogState;
if (!cluster || !config) return null;
const contexts = config.contexts.filter(context => context.name !== cluster.contextName);
return (
<Dialog
className={styles.dialog}
isOpen={isOpen}
close={this.onClose}
onOpen={this.onOpen}
>
<div className={styles.dialogContent}>
{this.renderDeleteMessage()}
{this.renderWarning()}
<hr className={styles.hr}/>
{contexts.length > 0 && (
<>
<div className="mt-4">
<Checkbox
data-testid="context-switch"
theme="light"
label={(
<>
<span className="font-semibold">Select current-context</span>{" "}
{!this.isCurrentContext() && "(optional)"}
</>
)}
value={this.showContextSwitch}
onChange={value => this.showContextSwitch = this.isCurrentContext() ? true : value}
/>
</div>
{this.renderCurrentContextSwitch()}
</>
)}
</div>
<div className={styles.dialogButtons}>
<Button
onClick={this.onClose} plain
label="Cancel"
/>
<Button
onClick={this.onDelete}
autoFocus accent
label="Delete Context"
disabled={this.disableDelete}
/>
</div>
</Dialog>
);
}
}
@@ -0,0 +1,22 @@
/**
* 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.
*/
export * from "./delete-cluster-dialog";
@@ -0,0 +1,43 @@
/**
* 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 fs from "fs";
import tempy from "tempy";
import * as lockFile from "proper-lockfile";
import YAML from "json-to-pretty-yaml";
import { noop } from "../../utils";
export async function saveKubeconfig(config: KubeConfig, path: string) {
const tmpFilePath = tempy.file();
try {
const release = await lockFile.lock(path);
const contents = YAML.stringify(JSON.parse(config.exportConfig()));
await fs.promises.writeFile(tmpFilePath, contents);
await fs.promises.rename(tmpFilePath, path);
release();
} catch (e) {
await fs.unlink(tmpFilePath, noop);
throw new Error(`Failed to acquire lock file.\n${e}`);
}
}
@@ -47,6 +47,8 @@
color: white;
background: $dialogHeaderBackground;
padding: var(--wizard-spacing);
border-top-left-radius: $radius;
border-top-right-radius: $radius;
b {
font-weight: $font-weight-normal;
@@ -79,6 +81,8 @@
.WizardStep {
background: var(--wizard-step-bgc);
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
> .step-content.scrollable {
@include scrollableContent();
@@ -92,6 +96,8 @@
> :last-child {
padding: var(--wizard-spacing);
background: $dialogFooterBackground;
border-bottom-left-radius: $radius;
border-bottom-right-radius: $radius;
.back-btn {
margin-left: auto;
+37
View File
@@ -20,14 +20,51 @@
*/
import React from "react";
import fs from "fs";
import "../../common/catalog-entities/kubernetes-cluster";
import { WebLinkCategory } from "../../common/catalog-entities";
import { ClusterStore } from "../../common/cluster-store";
import { catalogCategoryRegistry } from "../api/catalog-category-registry";
import { WeblinkAddCommand } from "../components/catalog-entities/weblink-add-command";
import { CommandOverlay } from "../components/command-palette";
import { loadConfigFromString } from "../../common/kube-helpers";
import { DeleteClusterDialog } from "../components/delete-cluster-dialog";
function initWebLinks() {
WebLinkCategory.onAdd = () => CommandOverlay.open(<WeblinkAddCommand />);
}
function initKubernetesClusters() {
catalogCategoryRegistry
.getForGroupKind("entity.k8slens.dev", "KubernetesCluster")
.on("contextMenuOpen", (entity, context) => {
if (entity.metadata?.source == "local") {
context.menuItems.push({
title: "Delete",
icon: "delete",
onClick: () => onClusterDelete(entity.metadata.uid)
});
}
});
}
async function onClusterDelete(clusterId: string) {
const cluster = ClusterStore.getInstance().getById(clusterId);
if (!cluster) {
return console.warn("[KUBERNETES-CLUSTER]: cannot delete cluster, does not exist in store", { clusterId });
}
const { config, error } = loadConfigFromString(await fs.promises.readFile(cluster.kubeConfigPath, "utf-8"));
if (error) {
throw error;
}
DeleteClusterDialog.open({ cluster, config });
}
export function initCatalog() {
initWebLinks();
initKubernetesClusters();
}