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

allow to remove cluster from icon's context-menu, random fixes

Signed-off-by: Roman <ixrock@gmail.com>
This commit is contained in:
Roman 2020-07-17 12:47:11 +03:00
parent 33d3181113
commit e11d8582f1
10 changed files with 88 additions and 299 deletions

View File

@ -65,7 +65,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
if (ipcRenderer) {
ipcRenderer.on("cluster:state", (event, clusterState: ClusterState) => {
this.applyWithoutSync(() => {
logger.info(`[CLUSTER-STORE]: received cluster(${clusterState.id}) update`, clusterState);
logger.debug(`[CLUSTER-STORE]: received state update for cluster=${clusterState.id}`, clusterState);
const cluster = this.getById(clusterState.id);
if (cluster) cluster.updateModel(clusterState)
})
@ -85,6 +85,10 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
return Array.from(this.clusters.values());
}
hasContext(name: string) {
return this.clustersList.some(cluster => cluster.contextName === name);
}
getById(id: ClusterId): Cluster {
return this.clusters.get(id);
}
@ -94,18 +98,23 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
@action
addCluster(model: ClusterModel): Cluster {
async addCluster(model: ClusterModel, activate = true): Promise<Cluster> {
const cluster = new Cluster(model);
this.clusters.set(model.id, cluster);
if (activate) this.activeClusterId = model.id;
return cluster;
}
@action
removeById(clusterId: ClusterId): void {
if (this.activeClusterId === clusterId) {
this.activeClusterId = null;
async removeById(clusterId: ClusterId) {
const cluster = this.getById(clusterId);
if (cluster) {
this.clusters.delete(clusterId);
if (this.activeClusterId === clusterId) {
this.activeClusterId = null;
}
unlink(cluster.kubeConfigPath).catch(() => null);
}
this.clusters.delete(clusterId);
}
@action
@ -116,7 +125,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
@action
protected async uploadClusterIcon({ clusterId, ...upload }: ClusterIconUpload): Promise<string> {
protected async uploadIcon({ clusterId, ...upload }: ClusterIconUpload): Promise<string> {
const cluster = this.getById(clusterId);
if (cluster) {
tracker.event("cluster", "upload-icon");
@ -129,7 +138,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
}
@action
protected resetClusterIcon(clusterId: ClusterId) {
protected resetIcon(clusterId: ClusterId) {
const cluster = this.getById(clusterId);
if (cluster) {
tracker.event("cluster", "reset-icon")
@ -167,7 +176,7 @@ export class ClusterStore extends BaseStore<ClusterStoreModel> {
this.clusters.replace(newClusters);
this.removedClusters.replace(removedClusters);
// "auto-select" first cluster if not available or invalid from config file
// "auto-select" first cluster if available
if (!this.activeClusterId && newClusters.size) {
this.activeClusterId = Array.from(newClusters.values())[0].id;
}

View File

@ -31,14 +31,14 @@ export function sendMessage({ channel, webContentId, filter, args = [] }: IpcMes
}
views.forEach(webContent => {
const type = webContent.getType();
logger.info(`[IPC]: sending message "${channel}" to ${type}=${webContent.id}`);
logger.debug(`[IPC]: sending message "${channel}" to ${type}=${webContent.id}`, { args });
webContent.send(channel, ...[args].flat());
})
}
// todo: support timeout + merge with sendMessage?
export async function invokeMessage<T extends any[], R = any>(channel: IpcChannel, ...args: T): Promise<R> {
logger.debug(`[IPC]: invoke channel "${channel}"`, args);
logger.debug(`[IPC]: invoke channel "${channel}"`, { args });
return ipcRenderer.invoke(channel, ...args);
}
@ -46,7 +46,7 @@ export async function invokeMessage<T extends any[], R = any>(channel: IpcChanne
export function handleMessage<T extends any[]>(channel: IpcChannel, handler: IpcMessageHandler<T>, options: IpcHandleOpts = {}) {
const { timeout = 0 } = options;
ipcMain.handle(channel, async (event, ...args: T) => {
logger.info(`[IPC]: handle "${channel}"`, { event, args });
logger.debug(`[IPC]: handle "${channel}"`, { args });
return new Promise(async (resolve, reject) => {
let timerId;
if (timeout) {
@ -60,7 +60,7 @@ export function handleMessage<T extends any[]>(channel: IpcChannel, handler: Ipc
clearTimeout(timerId);
return result;
} catch (err) {
logger.debug(`[IPC]: handling "${channel}" error`, err);
logger.debug(`[IPC]: handling "${channel}" error`, { err });
}
})
})

View File

@ -5,23 +5,31 @@ import { ClusterId, clusterStore } from "../common/cluster-store"
import { handleMessage } from "../common/ipc";
import { tracker } from "../common/tracker";
import { Cluster, ClusterIpcEvent } from "./cluster"
import logger from "./logger";
export class ClusterManager {
constructor(public readonly port: number) {
// auto-init clusters
autorun(() => {
clusterStore.clustersList
.filter(cluster => !cluster.initialized)
.forEach(cluster => cluster.init(port));
clusterStore.clusters.forEach(cluster => {
if (cluster.initialized) return;
cluster.init(port);
logger.info(`[CLUSTER-MANAGER]: initializing cluster`, cluster.getMeta());
});
});
// auto-stop removed clusters
autorun(() => {
clusterStore.removedClusters.forEach(cluster => cluster.stop());
clusterStore.removedClusters.clear();
const { removedClusters } = clusterStore;
const meta = Array.from(removedClusters.values()).map(cluster => cluster.getMeta());
logger.info(`[CLUSTER-MANAGER]: removing clusters`, meta);
removedClusters.forEach(cluster => cluster.destroy());
removedClusters.clear();
}, {
delay: 250
});
// listen ipc-events which could be handled *only* in main-process (nodeIntegration=true)
// listen for ipc-events that must be handled *only* in main-process (nodeIntegration=true)
handleMessage(ClusterIpcEvent.STOP, this.stopCluster.bind(this));
}
@ -37,7 +45,7 @@ export class ClusterManager {
protected stopCluster(clusterId: ClusterId) {
tracker.event("cluster", "stop");
this.getCluster(clusterId)?.stop();
this.getCluster(clusterId)?.destroy();
}
getClusterForRequest(req: http.IncomingMessage): Cluster {

View File

@ -24,6 +24,7 @@ export enum ClusterStatus {
}
export interface ClusterState extends ClusterModel {
initialized?: boolean;
apiUrl: string;
online?: boolean;
accessible?: boolean;
@ -98,12 +99,13 @@ export class Cluster implements ClusterModel {
bindEvents(viewId: number) {
if (!this.initialized) return;
logger.info(`[CLUSTER]: bind events`, this.getMeta());
const refreshStatusTimer = setInterval(() => this.refreshStatus(), 30000); // every 30s
const refreshEventsTimer = setInterval(() => this.refreshEvents(), 3000); // every 3s
this.disposers.push(
() => clearTimeout(refreshStatusTimer),
() => clearTimeout(refreshEventsTimer),
() => clearInterval(refreshStatusTimer),
() => clearInterval(refreshEventsTimer),
reaction(() => this.getState(), clusterState => {
sendMessage({
@ -118,15 +120,24 @@ export class Cluster implements ClusterModel {
}
unbindEvents() {
if (!this.initialized) return;
logger.info(`[CLUSTER]: unbind events`, this.getMeta());
this.disposers.forEach(dispose => dispose());
this.disposers.length = 0;
}
stop() {
if (!this.initialized) return;
this.contextHandler.stopServer();
this.kubeconfigManager.unlink();
this.unbindEvents();
}
destroy() {
try {
this.stop();
this.unbindEvents();
this.kubeconfigManager.unlink();
} catch (err) {
logger.error(`[CLUSTER]: destroy() throws: ${err}`, this.getMeta());
}
}
@action
@ -318,6 +329,7 @@ export class Cluster implements ClusterModel {
getState(): ClusterState {
const state: ClusterState = {
...this.toJSON(),
initialized: this.initialized,
apiUrl: this.apiUrl,
online: this.online,
accessible: this.accessible,
@ -333,4 +345,12 @@ export class Cluster implements ClusterModel {
recurseEverything: true
})
}
// get cluster system meta, e.g. use in "logger"
getMeta() {
return {
id: this.id,
name: this.contextName,
}
}
}

View File

@ -69,7 +69,7 @@ export class KubeconfigManager {
}
unlink() {
logger.debug('Deleting temporary kubeconfig: ' + this.tempFile)
logger.info('Deleting temporary kubeconfig: ' + this.tempFile)
fs.unlinkSync(this.tempFile)
}
}

View File

@ -51,7 +51,6 @@ export class WindowManager {
}
}, {
fireImmediately: true,
delay: 250,
}),
// auto-destroy views for removed clusters

View File

@ -1,261 +0,0 @@
<template>
<div class="content">
<b-container fluid class="h-100">
<b-row align-h="around">
<b-col lg="7">
<div class="card">
<h2>Add Cluster</h2>
<div class="add-cluster">
<b-form @submit.prevent="doAddCluster">
<b-form-group
label="Choose config:"
>
<b-form-file
v-model="file"
:state="Boolean(file)"
placeholder="Choose a file or drop it here..."
drop-placeholder="Drop file here..."
@input="reloadKubeContexts()"
/>
<div class="mt-3">
Selected file: {{ file ? file.name : '' }}
</div>
<b-form-select
id="kubecontext-select"
v-model="kubecontext"
:options="contextNames"
@change="onSelect($event)"
/>
<b-button v-b-toggle.collapse-advanced variant="link">
Proxy settings
</b-button>
</b-form-group>
<b-collapse id="collapse-advanced">
<b-form-group
label="HTTP Proxy server. Used for communicating with Kubernetes API."
description="A HTTP proxy server URL (format: http://<address>:<port>)."
>
<b-form-input
v-model="httpsProxy"
/>
</b-form-group>
</b-collapse>
<b-form-group
label="Kubeconfig:"
v-if="status === 'ERROR' || kubecontext === 'custom'"
>
<div class="editor">
<prism-editor v-model="clusterconfig" language="yaml" />
</div>
</b-form-group>
<b-alert variant="danger" show v-if="status === 'ERROR'">
{{ errorMsg }}
<div v-if="errorDetails !== ''">
<b-button v-b-toggle.collapse-error variant="link" size="sm">
Show details
</b-button>
<b-collapse id="collapse-error">
<code>
{{ errorDetails }}
</code>
</b-collapse>
</div>
</b-alert>
<b-form-row>
<b-col>
<b-button variant="primary" type="submit" :disabled="clusterconfig === ''">
<b-spinner small v-if="isProcessing" label="Small Spinner" />
{{ addButtonText }}
</b-button>
</b-col>
</b-form-row>
</b-form>
</div>
</div>
</b-col>
<!--info-panel-->
</b-row>
</b-container>
</div>
</template>
<script>
import * as PrismEditor from 'vue-prism-editor'
import * as k8s from "@kubernetes/client-node"
import { dumpConfigYaml } from "../../../common/kube-helpers"
import ClustersMixin from "@/_vue/mixins/ClustersMixin";
import * as path from "path"
import fs from 'fs'
import { v4 as uuidv4 } from 'uuid';
class ClusterAccessError extends Error {}
export default {
name: 'AddClusterPage',
mixins: [ClustersMixin],
props: { },
components: {
PrismEditor,
},
data(){
return {
file: null,
filepath: null,
clusterconfig: "",
httpsProxy: "",
kubecontext: "",
status: "",
errorMsg: "",
errorCluster: "",
errorDetails: "",
seenContexts: []
}
},
mounted: function() {
this.filepath = path.join(process.env.HOME, '.kube', 'config')
this.file = new File(fs.readFileSync(this.filepath), this.filepath)
this.$store.dispatch("reloadAvailableKubeContexts", this.filepath);
this.seenContexts = JSON.parse(JSON.stringify(this.$store.getters.seenContexts)) // clone seenContexts from store
this.storeSeenContexts()
},
computed: {
isProcessing: function() {
return this.status === "PROCESSING";
},
addButtonText: function() {
if (this.kubecontext === "custom") {
return "Add Cluster(s)"
} else {
return "Add Cluster"
}
},
contextNames: function() {
const configs = this.availableContexts
const names = configs.map((kc) => {
return { text: kc.currentContext + (this.isNewContext(kc.currentContext) ? " (new)": ""), value: dumpConfigYaml(kc) }
})
names.unshift({text: "Select kubeconfig", value: ""})
names.push({text: "Custom ...", value: "custom"})
return names;
},
},
methods: {
reloadKubeContexts() {
this.filepath = this.file.path
this.$store.dispatch("reloadAvailableKubeContexts", this.file.path);
},
isNewContext(context) {
return this.newContexts.indexOf(context) > -1
},
storeSeenContexts() {
const configs = this.$store.getters.availableKubeContexts
const contexts = configs.map((kc) => {
return kc.currentContext
})
this.$store.dispatch("addSeenContexts", contexts)
},
onSelect: function() {
this.status = "";
if (this.kubecontext === "custom") {
this.clusterconfig = "";
} else {
this.clusterconfig = this.kubecontext;
}
},
doAddCluster: async function() {
// Clear previous error details
this.errorMsg = ""
this.errorCluster = ""
this.errorDetails = ""
this.status = "PROCESSING"
try {
const kc = new k8s.KubeConfig();
kc.loadFromString(this.clusterconfig); // throws TypeError if we cannot parse kubeconfig
const clusterId = uuidv4();
// We need to store the kubeconfig to "app-home"/
if (this.kubecontext === "custom") {
this.filepath = saveConfigToAppFiles(clusterId, this.clusterconfig)
}
const clusterInfo = {
id: clusterId,
kubeConfigPath: this.filepath,
contextName: kc.currentContext,
preferences: {
clusterName: kc.currentContext
},
workspace: this.$store.getters.currentWorkspace.id
}
if (this.httpsProxy) {
clusterInfo.preferences.httpsProxy = this.httpsProxy
}
console.log("sending clusterInfo:", clusterInfo)
let res = await this.$store.dispatch('addCluster', clusterInfo)
console.log("addCluster result:", res)
if(!res){
this.status = "ERROR";
return false;
}
this.status = "SUCCESS"
this.$router.push({
name: "cluster-page",
params: {
id: res.id
},
}).catch((err) => {})
} catch (error) {
console.log("addCluster raised:", error)
if(typeof error === 'string') {
this.errorMsg = error;
} else if(error instanceof TypeError) {
this.errorMsg = "cannot parse kubeconfig";
} else if(error.response && error.response.statusCode === 401) {
this.errorMsg = "invalid kubeconfig (access denied)"
} else if(error.message) {
this.errorMsg = error.message
} else if(error instanceof ClusterAccessError) {
this.errorMsg = `Invalid kubeconfig context ${error.context}`
this.errorCluster = error.cluster
this.errorDetails = error.details
}
this.status = "ERROR";
return false;
}
return true;
}
}
}
</script>
<style scoped lang="scss">
.help{
border-left: 1px solid #353a3e;
padding-top: 20px;
&:first-child{
padding-top: 0;
}
h3{
padding: 0.75rem 0 0.75rem 0;
}
height: 100vh;
overflow-y: auto;
}
h2{
padding: 0.75rem;
}
.card {
margin-top: 20px;
}
.add-cluster {
padding: 0.75rem;
}
.btn-link {
padding-left: 0;
}
</style>

View File

@ -16,16 +16,17 @@ import { tracker } from "../../../common/tracker";
import { clusterStore } from "../../../common/cluster-store";
import { workspaceStore } from "../../../common/workspace-store";
import { v4 as uuid } from "uuid"
import { navigate } from "../../navigation";
@observer
export class AddCluster extends React.Component {
readonly custom: any = "custom"
@observable.ref clusterConfig: KubeConfig;
@observable.ref kubeConfig: KubeConfig; // local ~/.kube/config (if available)
@observable.ref error: React.ReactNode;
@observable isWaiting = false
@observable showSettings = false
@observable error = ""
@observable proxyServer = ""
@observable customConfig = ""
@ -49,13 +50,15 @@ export class AddCluster extends React.Component {
@computed get clusterOptions() {
const options: SelectOption<KubeConfig>[] = [];
if (this.kubeConfig) {
splitConfig(this.kubeConfig).forEach(kubeConfig => {
const contextName = kubeConfig.getCurrentContext();
const contexts = splitConfig(this.kubeConfig)
.filter(kc => !clusterStore.hasContext(kc.currentContext));
contexts.forEach(kubeConfig => {
const isNew = false; // fixme: detect new context since last visit
options.push({
value: kubeConfig,
label: <>
{contextName}
{kubeConfig.currentContext}
{isNew && <span className="new"> <Trans>(new)</Trans></span>}
</>,
})
@ -72,16 +75,16 @@ export class AddCluster extends React.Component {
tracker.event("cluster", "add");
const { clusterConfig, customConfig, proxyServer } = this;
const clusterId = uuid();
this.isWaiting = true
this.error = ""
try {
const config = this.isCustom ? loadConfig(customConfig) : clusterConfig;
if (!config) {
this.error = "Please select kubeconfig"
this.error = <Trans>Please select kubeconfig</Trans>
return;
}
this.error = ""
this.isWaiting = true
validateConfig(config);
clusterStore.addCluster({
await clusterStore.addCluster({
id: clusterId,
kubeConfigPath: saveConfigToAppFiles(clusterId, config),
workspace: workspaceStore.currentWorkspaceId,
@ -90,7 +93,8 @@ export class AddCluster extends React.Component {
clusterName: config.currentContext,
httpsProxy: proxyServer || undefined,
},
})
});
navigate("/");
} catch (err) {
this.error = String(err);
} finally {

View File

@ -33,7 +33,7 @@
}
> .clusters {
@include hidden-scrollbar;
//@include hidden-scrollbar; // fixme: uncomment after refactoring tooltip.tsx
--flex-gap: #{$padding * 2};
padding: $padding;

View File

@ -18,6 +18,7 @@ import { addClusterURL } from "../+add-cluster";
import { clusterSettingsURL } from "../+cluster-settings";
import { landingURL } from "../+landing-page";
import { Tooltip, TooltipContent } from "../tooltip";
import { ConfirmDialog } from "../confirm-dialog";
// fixme: allow to rearrange clusters with drag&drop
// fixme: disconnect cluster from context-menu
@ -50,7 +51,6 @@ export class ClustersMenu extends React.Component<Props> {
label: _i18n._(t`Settings`),
click: () => navigate(clusterSettingsURL())
}));
if (cluster.initialized) {
menu.append(new MenuItem({
label: _i18n._(t`Disconnect`),
@ -59,6 +59,16 @@ export class ClustersMenu extends React.Component<Props> {
}
}))
}
menu.append(new MenuItem({
label: _i18n._(t`Remove`),
click: () => {
ConfirmDialog.open({
ok: () => clusterStore.removeById(cluster.id),
labelOk: _i18n._(t`Remove`),
message: <p>Are you sure want to remove cluster <b title={cluster.id}>{cluster.contextName}</b>?</p>,
})
}
}));
menu.popup({
window: remote.getCurrentWindow()
})