diff --git a/backend/cmd/headlamp.go b/backend/cmd/headlamp.go index 87cb557c5ea..0a219bc25a6 100644 --- a/backend/cmd/headlamp.go +++ b/backend/cmd/headlamp.go @@ -234,6 +234,18 @@ func serveWithNoCacheHeader(fs http.Handler) http.HandlerFunc { } } +// defaultKubeConfigFile returns the default path to the kubeconfig file. +func defaultKubeConfigFile() (string, error) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %v", err) + } + + kubeConfigFile := filepath.Join(homeDir, ".kube", "config") + + return kubeConfigFile, nil +} + // defaultKubeConfigPersistenceDir returns the default directory to store kubeconfig // files of clusters that are loaded in Headlamp. func defaultKubeConfigPersistenceDir() (string, error) { @@ -1365,6 +1377,126 @@ func (c *HeadlampConfig) addContextsToStore(contexts []kubeconfig.Context, setup return setupErrors } +// collectMultiConfigPaths looks at the default dynamic directory +// (e.g. ~/.config/Headlamp/kubeconfigs) and returns any files found there. +// This is called from the 'else' block in deleteCluster(). +func (c *HeadlampConfig) collectMultiConfigPaths() ([]string, error) { + dynamicDir, err := defaultKubeConfigPersistenceDir() + if err != nil { + return nil, fmt.Errorf("getting default kubeconfig persistence dir: %w", err) + } + + entries, err := os.ReadDir(dynamicDir) + if err != nil { + return nil, fmt.Errorf("reading dynamic kubeconfig directory: %w", err) + } + + var configPaths []string //nolint:prealloc + + for _, entry := range entries { + // Optionally skip directories or non-kubeconfig files, if needed. + if entry.IsDir() { + continue + } + + filePath := filepath.Join(dynamicDir, entry.Name()) + + configPaths = append(configPaths, filePath) + } + + return configPaths, nil +} + +func removeContextFromDefaultKubeConfig( + w http.ResponseWriter, + contextName string, + configPaths ...string, +) error { + // If no specific paths passed, fallback to the default. + if len(configPaths) == 0 { + discoveredPath, err := defaultKubeConfigPersistenceFile() + if err != nil { + logger.Log( + logger.LevelError, + map[string]string{"cluster": contextName}, + err, + "getting default kubeconfig persistence file", + ) + http.Error(w, "getting default kubeconfig persistence file", http.StatusInternalServerError) + + return err + } + + configPaths = []string{discoveredPath} + } + + // Hand off to a small helper function that handles multi-file iteration. + return removeContextFromConfigs(w, contextName, configPaths) +} + +// removeContextFromConfigs does the real iteration over the configPaths. +func removeContextFromConfigs(w http.ResponseWriter, contextName string, configPaths []string) error { + var removed bool + + for _, filePath := range configPaths { + logger.Log( + logger.LevelInfo, + map[string]string{ + "cluster": contextName, + "kubeConfigPersistenceFile": filePath, + }, + nil, + "Trying to remove context from kubeconfig", + ) + + err := kubeconfig.RemoveContextFromFile(contextName, filePath) + if err == nil { + removed = true + + logger.Log(logger.LevelInfo, + map[string]string{"cluster": contextName, "file": filePath}, + nil, "Removed context from kubeconfig", + ) + + break + } + + if strings.Contains(err.Error(), "context not found") { + logger.Log(logger.LevelInfo, + map[string]string{"cluster": contextName, "file": filePath}, + nil, "Context not in this file; checking next.", + ) + + continue + } + + logger.Log(logger.LevelError, + map[string]string{"cluster": contextName}, + err, "removing cluster from kubeconfig", + ) + + http.Error(w, "removing cluster from kubeconfig", http.StatusInternalServerError) + + return err + } + + if !removed { + e := fmt.Errorf("context %q not found in any provided kubeconfig file(s)", contextName) + + logger.Log( + logger.LevelError, + map[string]string{"cluster": contextName}, + e, + "context not found in any file", + ) + http.Error(w, e.Error(), http.StatusBadRequest) + + return e + } + + return nil +} + // deleteCluster deletes the cluster from the store and updates the kubeconfig file. func (c *HeadlampConfig) deleteCluster(w http.ResponseWriter, r *http.Request) { if err := checkHeadlampBackendToken(w, r); err != nil { @@ -1384,28 +1516,42 @@ func (c *HeadlampConfig) deleteCluster(w http.ResponseWriter, r *http.Request) { return } - kubeConfigPersistenceFile, err := defaultKubeConfigPersistenceFile() - if err != nil { - logger.Log(logger.LevelError, map[string]string{"cluster": name}, - err, "getting default kubeconfig persistence file") - http.Error(w, "getting default kubeconfig persistence file", http.StatusInternalServerError) + removeKubeConfig := r.URL.Query().Get("removeKubeConfig") == "true" - return + if removeKubeConfig { + kubeConfigFile, err := defaultKubeConfigFile() + if err != nil { + logger.Log(logger.LevelError, map[string]string{"cluster": name}, + err, "failed to get default kubeconfig file path") + http.Error(w, "failed to get default kubeconfig file path", http.StatusInternalServerError) + + return + } + + err = kubeconfig.RemoveContextFromFile(name, kubeConfigFile) + if err != nil { + logger.Log(logger.LevelError, map[string]string{"cluster": name}, + err, "removing context from default kubeconfig file") + http.Error(w, "removing context from default kubeconfig file", http.StatusInternalServerError) + + return + } } - logger.Log(logger.LevelInfo, map[string]string{ - "cluster": name, - "kubeConfigPersistenceFile": kubeConfigPersistenceFile, - }, - nil, "Removing cluster from kubeconfig") + if !removeKubeConfig { + configPathsList, pathErr := c.collectMultiConfigPaths() + if pathErr != nil { + logger.Log(logger.LevelError, map[string]string{"cluster": name}, + pathErr, "collecting multi config paths") + http.Error(w, "collecting multi config paths", http.StatusInternalServerError) - err = kubeconfig.RemoveContextFromFile(name, kubeConfigPersistenceFile) - if err != nil { - logger.Log(logger.LevelError, map[string]string{"cluster": name}, - err, "removing cluster from kubeconfig") - http.Error(w, "removing cluster from kubeconfig", http.StatusInternalServerError) + return + } - return + if err := removeContextFromDefaultKubeConfig(w, name, configPathsList...); err != nil { + // removeContextFromDefaultKubeConfig writes any needed http.Error if it fails + return + } } logger.Log(logger.LevelInfo, map[string]string{"cluster": name, "proxy": name}, diff --git a/backend/cmd/headlamp_test.go b/backend/cmd/headlamp_test.go index 792732fd23f..f2e092ba32a 100644 --- a/backend/cmd/headlamp_test.go +++ b/backend/cmd/headlamp_test.go @@ -665,6 +665,34 @@ func TestRenameCluster(t *testing.T) { } } +func TestRemoveContextFromDefaultKubeConfig(t *testing.T) { + // 1) Create a temp directory for our test kubeconfig + tmpDir := t.TempDir() + mockConfigFile := filepath.Join(tmpDir, "config") + + // 2) Copy "kubeconfig_remove" (which includes 'kubedelta') into that file + testDataPath := filepath.Join("headlamp_testdata", "kubeconfig_remove") + testData, err := os.ReadFile(testDataPath) + require.NoError(t, err, "failed to read test data for 'kubeconfig_remove'") + + err = os.WriteFile(mockConfigFile, testData, 0o600) + require.NoError(t, err, "failed to write test kubeconfig") + + // 3) We need a fake http.ResponseWriter + w := httptest.NewRecorder() + + // 4) Call removeContextFromDefaultKubeConfig with our mock path as the third param + err = removeContextFromDefaultKubeConfig(w, "kubedelta", mockConfigFile) + require.NoError(t, err, "removeContextFromDefaultKubeConfig should succeed") + + // 5) Verify 'kubedelta' is removed from the file + updatedData, err := os.ReadFile(mockConfigFile) + require.NoError(t, err, "failed to read updated kubeconfig") + + require.NotContains(t, string(updatedData), "kubedelta", + "Expected 'kubedelta' context to be removed from kubeconfig") +} + func TestFileExists(t *testing.T) { // Test for existing file assert.True(t, fileExists("./headlamp_testdata/kubeconfig"), diff --git a/backend/cmd/headlamp_testdata/kubeconfig_remove b/backend/cmd/headlamp_testdata/kubeconfig_remove new file mode 100644 index 00000000000..a86d9146368 --- /dev/null +++ b/backend/cmd/headlamp_testdata/kubeconfig_remove @@ -0,0 +1,45 @@ +apiVersion: v1 +kind: Config +current-context: minikubetest +preferences: {} +clusters: + - cluster: + certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUMvakNDQWVhZ0F3SUJBZ0lCQURBTkJna3Foa2lHOXcwQkFRc0ZBREFWTVJNd0VRWURWUVFERXdwcmRXSmwKY201bGRHVnpNQjRYRFRJeU1EZ3lOakV4TURRMU0xb1hEVE15TURneU16RXhNRFExTTFvd0ZURVRNQkVHQTFVRQpBeE1LYTNWaVpYSnVaWFJsY3pDQ0FTSXdEUVlKS29aSWh2Y05BUUVCQlFBRGdnRVBBRENDQVFvQ2dnRUJBTnk3Ci9kREMxV0w3TXNSWGV2Z2tUQXkzcFZHMVVLa1VQeXd4cS9ETHBPdmRzQmloQjZoVmN1bWNZUTkzYUxLbERzSXMKR0Q0QUJkUFM4cEFPMzhMb3RBWWVDeDIwcDFPem9LYVMvVkp6ZlJKQWVUSStCY3dzRjh2U1VXYU0reWZ4STBPUgpnalE0OVR0eUppYURyS2tzbnd4R3Y0K0U3aWFhZUVPMG55U01EcnpON1RvYkVyb1pObHRzNkdMN2tpTDB0TG5ZCkorNnNtSHlhSGh6WThaR0JZMFdWUXpzNENFMnJ0Q1k5eTV4N2F3bUlDUWE2anBXVFVQazNqa0RMcU93bEQyRmMKcHNkeXI4a1Z3UUhTUUVnRkg2Yzgwdnp3Ny9RSUVDdGRYNlZRRnE1bzYzOWlvc3hQcXVKV3ZtMGVjdkx5dC81cApxNXZpNzMxWThEb0VDMjFtS2NzQ0F3RUFBYU5aTUZjd0RnWURWUjBQQVFIL0JBUURBZ0trTUE4R0ExVWRFd0VCCi93UUZNQU1CQWY4d0hRWURWUjBPQkJZRUZJNFFkU2FSRFVodi8wWjk0ZzV5RmlVdWlMZHBNQlVHQTFVZEVRUU8KTUF5Q0NtdDFZbVZ5Ym1WMFpYTXdEUVlKS29aSWh2Y05BUUVMQlFBRGdnRUJBQ3pWWUpBUzQ1UFBOSFVSaDJKWQpKWDFycmFMdGNTbzVuNG1DVy9oeE5YdHpCMlIzWkhPU0hnNmF2R3JNeFY4ZlpCdmtBdEJFaUYzM2JvRThzZzVhCjhhWHRFTjR5bzlZQ2FZc2ZXK2tNNlZDRUdtVWd5bm13aXltYTBzSW5USlZ1R3ZVbDVucVhjUHJJdW9OTVVrTUwKdCsrckxCb0NwY2xrN09VSTA0dXZvanpxc2hsQ0JiMURSOXRwT0s0Kys0UGdPait6OXZ0N3g0dzhMYlhvQmtvegozOEJyVEoyQ3NqbU0xS2ZqZXlpNWdHVmFjeE9YSXRjbXprNzRpQzZ0SjdqVm1MVmNacEc5ZElvcFk5WTBaTkQ0CmQzZjlmOGdCWkJzaXA0a3gxMmFxMlJ5dzFYNGVOaFY2dW5OaCtHVHNhNlFDSlJ0Zk9FK1Q4Njd2ZHlPbjZMb2wKYWQ4PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + extensions: + - extension: + last-update: Mon, 26 Dec 2022 20:33:03 IST + provider: minikubetest.sigs.k8s.io + version: v1.28.0 + name: cluster_info + server: https://127.0.0.1:60279 + name: minikubetest +contexts: + - context: + cluster: minikubetest + namespace: default + user: minikubetest + extensions: + - extension: + last-update: Mon, 26 Dec 2022 20:33:03 IST + provider: minikubetest.sigs.k8s.io + version: v1.28.0 + name: context_info + - extension: + creationTimestamp: null + customName: minikubetestworks + name: headlamp_info + name: minikubetest + - context: + cluster: minikubetest + namespace: default + user: kubedelta + name: kubedelta +users: + - name: minikubetest + user: + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURJekNDQWd1Z0F3SUJBZ0lJZlJpZk1qZWl1eFV3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TWpBNE1qWXhNVEEwTlROYUZ3MHlOREF4TURJeE1ESXhNelZhTURZeApGekFWQmdOVkJBb1REbk41YzNSbGJUcHRZWE4wWlhKek1Sc3dHUVlEVlFRREV4SmtiMk5yWlhJdFptOXlMV1JsCmMydDBiM0F3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRQzRLUmwrS0lsN0NJYVgKbzIwYjdBOVEvaURDbUN6dWFXMSs3WEJyelhiZHNmNkNaRzhVMWZYWTdVWXl3bXVhYkZldUFrUHRBT1hyWVg0YQpMTGZtWTRvdkZYc1RQWmtPUktJeWRFUmNnLy9hOStPd3d2c1ZCUUp4NFplbUtrN1NzaFYxcjl3WGVqVnJIUkFOCm5xQ3JIQVhFNHA5bmFKZHNkTXIyQWdDa0VIK01tTFNqTExNL1lWcnExdmJpRWRtUVFSWHduVnFwcmNyRXBIQzUKWWJJenl4cVZRWWZIZVdWc2N0SUxFeVdPMFQwMS9tYkZ2RVY4QW9BL3phekIycjF3Y0VaeUNSRXFXbExrS2RXTwpNYmU1WnlwMDNhQzlBOSs4cThQNFBEOUNnVXlrcVovN0xydGlja2k0TVBsK2VmaGFlUk9YSEJMSURuQmplTHJkCmJGdHpaOVhKQWdNQkFBR2pWakJVTUE0R0ExVWREd0VCL3dRRUF3SUZvREFUQmdOVkhTVUVEREFLQmdnckJnRUYKQlFjREFqQU1CZ05WSFJNQkFmOEVBakFBTUI4R0ExVWRJd1FZTUJhQUZJNFFkU2FSRFVodi8wWjk0ZzV5RmlVdQppTGRwTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFBRjNvaTFvNVlNM1UvOWxPRElhaUpmaGllNzdieG1pN3NwCitiL0NEOGRCTXhIdWpPVnBSaTFNaHRJa2U3N2U1RVVuZEFGRzYvQTQwK3c2TGtCYXJFUEl5R2daRlBvZkttcSsKRGlIMGxPZHBYY0hFd3laTjhWSmdRd0JKUkhKcDhBc0p3TGFYWGplU1FQdmZyeHhLdUFGenRzeXNaYlBMUkxoYQpjeXZmeDNwTE91ZVJ4MDJqQVZUUlNJUGNPZEV4SERPa0FGWFFCdDV4TFo2eGFKTU1VQjZXNUYwcVpPelFuVUZsCk80QUNNOEhnOEdKc2xqLzFqZnpZaGlneWdwL2psQ0Jkd1Izb2c1ZXFqaC9ZRzlxWHVsU2Z0WUNhMURaOEp2QnAKaGRSYzZxOVM0ZFdtRW9zMmkxTDA1WUs3ZFBaQk5JVHRLNkVzQS9CRCs0VlVWRHczZldkNQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb3dJQkFBS0NBUUVBdUNrWmZpaUpld2lHbDZOdEcrd1BVUDRnd3BnczdtbHRmdTF3YTgxMjNiSCtnbVJ2CkZOWDEyTzFHTXNKcm1teFhyZ0pEN1FEbDYyRitHaXkzNW1PS0x4VjdFejJaRGtTaU1uUkVYSVAvMnZmanNNTDcKRlFVQ2NlR1hwaXBPMHJJVmRhL2NGM28xYXgwUURaNmdxeHdGeE9LZloyaVhiSFRLOWdJQXBCQi9qSmkwb3l5egpQMkZhNnRiMjRoSFprRUVWOEoxYXFhM0t4S1J3dVdHeU04c2FsVUdIeDNsbGJITFNDeE1sanRFOU5mNW14YnhGCmZBS0FQODJzd2RxOWNIQkdjZ2tSS2xwUzVDblZqakczdVdjcWROMmd2UVBmdkt2RCtEdy9Rb0ZNcEttZit5NjcKWW5KSXVERDVmbm40V25rVGx4d1N5QTV3WTNpNjNXeGJjMmZWeVFJREFRQUJBb0lCQUdYMWUwTzV0Y1FFU0dBVAovd2lDZlVoZUtrMFNhMjNqdU5lWkpiREpwSkhCUmlOeTczMGRxR3Rka292djBCdEMrSmhDY05ENnVsRERQVW5JCmtGaGhxOU85bE5KbVBDTUdKTGJDWUViSVhoTWhRMUpONFMwV0JQQi84Ykh4b29wTVJrMU4vQkNUZkplOUUzSTIKN01WUFVuSmE5ZDRPcmlkQjBreTVkeGxlZVAraGFvT2NTejJGamhXbDEycnlqbm1ad0draU1BdnhHazBaejFkZgpxZ0QyUE5CRHYzMTdtMkJxYjdkcENaTmZsSi90MGtqQ3hGbm81UmRsUUl5NDhSeml4LytaV1ZSeWlsaVFjL2srCnY4UzRTWGljZjBDK1RzV0orZzBNU3NoaGs1SWV4OURzTmR1bmJrSTcxMDNoR2ZOcGZYbGlRVlZHTlZ2eHdNd2kKenV4eE9nRUNnWUVBeGFYbU9GSUhkbm1tU0tMbEp4KzhBc3pPcmdTNDFKM2RMemtUZUZhWU5STHY0Z1AyK05SQwpQcXAzYVRCRmNjNWFMTDFXcG1ZRkdGSkthejRUbm05dlRKN0Nhell6K1RZRWc4OWlidGJESmhFSlFaSGtRaDQxCnJXRlBHTERWL1ZVSk5kaStSRk9TU2xMVDVGY2gvS1NkdFFRazhkbStUempiWi94a29ZOVpKbmtDZ1lFQTdvZlQKRnQ0MytQY0w5T0ZoTGZjYnB2SUJkeDdzWlAyK09NVzF3eG92TnhTbElSelBSQi9QbXNsai9hUzV2VnFWOVlVdgp0YjVFaUM3cUVYN2JVS0lZT1hMY1c3N3ozcmxpODdMRW5CWkNDemFhdHk3Nmw1U2lneU16VDY3MS9DdjNSSVJYCkw5citoQ2ZUUThPVHBUVDR6R3ZhRWJrQldBcnRmeVZOTDhkVGxkRUNnWUF1TE44SFEyckk3QXpFSllKaHpKRXgKR2tZaTg2bDJ5dGJVNUlHKytJUWd5aWJPNTl3NEwrYTJHejlBak8xOGRCZ3ZJYUR2eVIvaG1jQVhJKzZUY2pkUApjRHU5cm5FZ0JOV3pNYTB1ZGZBcm9ZbEhEMTJEY09sYmMwTjJZa0hzS0lTNVZzVEUwNzEycmJraFBKWU5IeXhWCkVQM01udkZPTXR0WGhPakJzZXJEQ1FLQmdEOCtBOW1zVVdyUkZYcDN4eXhJdUN3clBmZzNXclhzRU9NOGlGU1MKUExKOTVzcEF1VE4ydTdSdWNQUnZHRS84Rklaa0thSW1NRVZyS3VRNG5pMWl6TWx1aXI1SWdxQXF4dkdXRkVyTwpHL1NkSmFncjdJVUVBNUtCWXJsZHlocHlEYjA4MldEMnowUjZ5cWpNMGZpYmN0dkFQTEUyUEFUNzRMdzFSNkhEClY0WUJBb0dCQUo4bUdVNWJnejBQUnJZL1hoUERER2tIa0Z3SHVocHhjL2tieWNweVVTZ2NDR2dsczFXTTdKOFUKQ05MV2pQc0pnSTNxeTJSN0xxaGxmNHJRK1orLy80WHdyNW1NYWpqSEVGZE93Z2xqbFZRdEljNWM4a2U3MzM1SwprbCtxblpJbUcwRy83R1hUZHhSOW9mQXBBZVFkT3pJTFY0K1YzZm5DRGNYMnBHWjZOOTJUCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg== + - name: kubedelta + user: + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURJekNDQWd1Z0F3SUJBZ0lJZlJpZk1qZWl1eFV3RFFZSktvWklodmNOQVFFTEJRQXdGVEVUTUJFR0ExVUUKQXhNS2EzVmlaWEp1WlhSbGN6QWVGdzB5TWpBNE1qWXhNVEEwTlROYUZ3MHlOREF4TURJeE1ESXhNelZhTURZeApGekFWQmdOVkJBb1REbk41YzNSbGJUcHRZWE4wWlhKek1Sc3dHUVlEVlFRREV4SmtiMk5yWlhJdFptOXlMV1JsCmMydDBiM0F3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRQzRLUmwrS0lsN0NJYVgKbzIwYjdBOVEvaURDbUN6dWFXMSs3WEJyelhiZHNmNkNaRzhVMWZYWTdVWXl3bXVhYkZldUFrUHRBT1hyWVg0YQpMTGZtWTRvdkZYc1RQWmtPUktJeWRFUmNnLy9hOStPd3d2c1ZCUUp4NFplbUtrN1NzaFYxcjl3WGVqVnJIUkFOCm5xQ3JIQVhFNHA5bmFKZHNkTXIyQWdDa0VIK01tTFNqTExNL1lWcnExdmJpRWRtUVFSWHduVnFwcmNyRXBIQzUKWWJJenl4cVZRWWZIZVdWc2N0SUxFeVdPMFQwMS9tYkZ2RVY4QW9BL3phekIycjF3Y0VaeUNSRXFXbExrS2RXTwpNYmU1WnlwMDNhQzlBOSs4cThQNFBEOUNnVXlrcVovN0xydGlja2k0TVBsK2VmaGFlUk9YSEJMSURuQmplTHJkCmJGdHpaOVhKQWdNQkFBR2pWakJVTUE0R0ExVWREd0VCL3dRRUF3SUZvREFUQmdOVkhTVUVEREFLQmdnckJnRUYKQlFjREFqQU1CZ05WSFJNQkFmOEVBakFBTUI4R0ExVWRJd1FZTUJhQUZJNFFkU2FSRFVodi8wWjk0ZzV5RmlVdQppTGRwTUEwR0NTcUdTSWIzRFFFQkN3VUFBNElCQVFBRjNvaTFvNVlNM1UvOWxPRElhaUpmaGllNzdieG1pN3NwCitiL0NEOGRCTXhIdWpPVnBSaTFNaHRJa2U3N2U1RVVuZEFGRzYvQTQwK3c2TGtCYXJFUEl5R2daRlBvZkttcSsKRGlIMGxPZHBYY0hFd3laTjhWSmdRd0JKUkhKcDhBc0p3TGFYWGplU1FQdmZyeHhLdUFGenRzeXNaYlBMUkxoYQpjeXZmeDNwTE91ZVJ4MDJqQVZUUlNJUGNPZEV4SERPa0FGWFFCdDV4TFo2eGFKTU1VQjZXNUYwcVpPelFuVUZsCk80QUNNOEhnOEdKc2xqLzFqZnpZaGlneWdwL2psQ0Jkd1Izb2c1ZXFqaC9ZRzlxWHVsU2Z0WUNhMURaOEp2QnAKaGRSYzZxOVM0ZFdtRW9zMmkxTDA1WUs3ZFBaQk5JVHRLNkVzQS9CRCs0VlVWRHczZldkNQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCg== + client-key-data: LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFb3dJQkFBS0NBUUVBdUNrWmZpaUpld2lHbDZOdEcrd1BVUDRnd3BnczdtbHRmdTF3YTgxMjNiSCtnbVJ2CkZOWDEyTzFHTXNKcm1teFhyZ0pEN1FEbDYyRitHaXkzNW1PS0x4VjdFejJaRGtTaU1uUkVYSVAvMnZmanNNTDcKRlFVQ2NlR1hwaXBPMHJJVmRhL2NGM28xYXgwUURaNmdxeHdGeE9LZloyaVhiSFRLOWdJQXBCQi9qSmkwb3l5egpQMkZhNnRiMjRoSFprRUVWOEoxYXFhM0t4S1J3dVdHeU04c2FsVUdIeDNsbGJITFNDeE1sanRFOU5mNW14YnhGCmZBS0FQODJzd2RxOWNIQkdjZ2tSS2xwUzVDblZqakczdVdjcWROMmd2UVBmdkt2RCtEdy9Rb0ZNcEttZit5NjcKWW5KSXVERDVmbm40V25rVGx4d1N5QTV3WTNpNjNXeGJjMmZWeVFJREFRQUJBb0lCQUdYMWUwTzV0Y1FFU0dBVAovd2lDZlVoZUtrMFNhMjNqdU5lWkpiREpwSkhCUmlOeTczMGRxR3Rka292djBCdEMrSmhDY05ENnVsRERQVW5JCmtGaGhxOU85bE5KbVBDTUdKTGJDWUViSVhoTWhRMUpONFMwV0JQQi84Ykh4b29wTVJrMU4vQkNUZkplOUUzSTIKN01WUFVuSmE5ZDRPcmlkQjBreTVkeGxlZVAraGFvT2NTejJGamhXbDEycnlqbm1ad0draU1BdnhHazBaejFkZgpxZ0QyUE5CRHYzMTdtMkJxYjdkcENaTmZsSi90MGtqQ3hGbm81UmRsUUl5NDhSeml4LytaV1ZSeWlsaVFjL2srCnY4UzRTWGljZjBDK1RzV0orZzBNU3NoaGs1SWV4OURzTmR1bmJrSTcxMDNoR2ZOcGZYbGlRVlZHTlZ2eHdNd2kKenV4eE9nRUNnWUVBeGFYbU9GSUhkbm1tU0tMbEp4KzhBc3pPcmdTNDFKM2RMemtUZUZhWU5STHY0Z1AyK05SQwpQcXAzYVRCRmNjNWFMTDFXcG1ZRkdGSkthejRUbm05dlRKN0Nhell6K1RZRWc4OWlidGJESmhFSlFaSGtRaDQxCnJXRlBHTERWL1ZVSk5kaStSRk9TU2xMVDVGY2gvS1NkdFFRazhkbStUempiWi94a29ZOVpKbmtDZ1lFQTdvZlQKRnQ0MytQY0w5T0ZoTGZjYnB2SUJkeDdzWlAyK09NVzF3eG92TnhTbElSelBSQi9QbXNsai9hUzV2VnFWOVlVdgp0YjVFaUM3cUVYN2JVS0lZT1hMY1c3N3ozcmxpODdMRW5CWkNDemFhdHk3Nmw1U2lneU16VDY3MS9DdjNSSVJYCkw5citoQ2ZUUThPVHBUVDR6R3ZhRWJrQldBcnRmeVZOTDhkVGxkRUNnWUF1TE44SFEyckk3QXpFSllKaHpKRXgKR2tZaTg2bDJ5dGJVNUlHKytJUWd5aWJPNTl3NEwrYTJHejlBak8xOGRCZ3ZJYUR2eVIvaG1jQVhJKzZUY2pkUApjRHU5cm5FZ0JOV3pNYTB1ZGZBcm9ZbEhEMTJEY09sYmMwTjJZa0hzS0lTNVZzVEUwNzEycmJraFBKWU5IeXhWCkVQM01udkZPTXR0WGhPakJzZXJEQ1FLQmdEOCtBOW1zVVdyUkZYcDN4eXhJdUN3clBmZzNXclhzRU9NOGlGU1MKUExKOTVzcEF1VE4ydTdSdWNQUnZHRS84Rklaa0thSW1NRVZyS3VRNG5pMWl6TWx1aXI1SWdxQXF4dkdXRkVyTwpHL1NkSmFncjdJVUVBNUtCWXJsZHlocHlEYjA4MldEMnowUjZ5cWpNMGZpYmN0dkFQTEUyUEFUNzRMdzFSNkhEClY0WUJBb0dCQUo4bUdVNWJnejBQUnJZL1hoUERER2tIa0Z3SHVocHhjL2tieWNweVVTZ2NDR2dsczFXTTdKOFUKQ05MV2pQc0pnSTNxeTJSN0xxaGxmNHJRK1orLy80WHdyNW1NYWpqSEVGZE93Z2xqbFZRdEljNWM4a2U3MzM1SwprbCtxblpJbUcwRy83R1hUZHhSOW9mQXBBZVFkT3pJTFY0K1YzZm5DRGNYMnBHWjZOOTJUCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0tCg== diff --git a/frontend/src/components/App/Home/index.tsx b/frontend/src/components/App/Home/index.tsx index abed5454a91..198c318444d 100644 --- a/frontend/src/components/App/Home/index.tsx +++ b/frontend/src/components/App/Home/index.tsx @@ -25,6 +25,24 @@ import { ConfirmDialog } from '../../common'; import ResourceTable from '../../common/Resource/ResourceTable'; import RecentClusters from './RecentClusters'; +/** + * Gets the origin of a cluster. + * + * @param cluster + * @returns A description of where the cluster is picked up from: dynamic, in-cluster, or from a kubeconfig file. + */ +function getOrigin(cluster: Cluster, t: any): string { + if (cluster?.meta_data?.source === 'kubeconfig') { + const kubeconfigPath = process.env.KUBECONFIG ?? '~/.kube/config'; + return `Kubeconfig: ${kubeconfigPath}`; + } else if (cluster.meta_data?.source === 'dynamic_cluster') { + return t('translation|Plugin'); + } else if (cluster.meta_data?.source === 'in_cluster') { + return t('translation|In-cluster'); + } + return 'Unknown'; +} + function ContextMenu({ cluster }: { cluster: Cluster }) { const { t } = useTranslation(['translation']); const history = useHistory(); @@ -33,8 +51,8 @@ function ContextMenu({ cluster }: { cluster: Cluster }) { const menuId = useId('context-menu'); const [openConfirmDialog, setOpenConfirmDialog] = React.useState(false); - function removeCluster(cluster: Cluster) { - deleteCluster(cluster.name || '') + function removeCluster(cluster: Cluster, removeKubeconfig?: boolean) { + deleteCluster(cluster.name || '', removeKubeconfig) .then(config => { dispatch(setConfig(config)); }) @@ -91,7 +109,8 @@ function ContextMenu({ cluster }: { cluster: Cluster }) { > {t('translation|Settings')} - {helpers.isElectron() && cluster.meta_data?.source === 'dynamic_cluster' && ( + + {helpers.isElectron() && ( { setOpenConfirmDialog(true); @@ -105,18 +124,28 @@ function ContextMenu({ cluster }: { cluster: Cluster }) { setOpenConfirmDialog(false)} + handleClose={() => { + setOpenConfirmDialog(false); + }} onConfirm={() => { setOpenConfirmDialog(false); - removeCluster(cluster); + if (cluster.meta_data?.source !== 'dynamic_cluster') { + removeCluster(cluster, true); + } else { + removeCluster(cluster); + } }} title={t('translation|Delete Cluster')} description={t( - 'translation|Are you sure you want to remove the cluster "{{ clusterName }}"?', + 'translation|This action will delete cluster "{{ clusterName }}" from {{ source }}.', { clusterName: cluster.name, + source: getOrigin(cluster, t), } )} + checkboxDescription={ + cluster.meta_data?.source !== 'dynamic_cluster' ? t('Delete from kubeconfig') : '' + } /> ); @@ -238,24 +267,6 @@ function HomeComponent(props: HomeComponentProps) { .sort(); } - /** - * Gets the origin of a cluster. - * - * @param cluster - * @returns A description of where the cluster is picked up from: dynamic, in-cluster, or from a kubeconfig file. - */ - function getOrigin(cluster: Cluster): string { - if (cluster.meta_data?.source === 'kubeconfig') { - const kubeconfigPath = process.env.KUBECONFIG ?? '~/.kube/config'; - return `Kubeconfig: ${kubeconfigPath}`; - } else if (cluster.meta_data?.source === 'dynamic_cluster') { - return t('translation|Plugin'); - } else if (cluster.meta_data?.source === 'in_cluster') { - return t('translation|In-cluster'); - } - return 'Unknown'; - } - const memoizedComponent = React.useMemo( () => ( @@ -286,10 +297,8 @@ function HomeComponent(props: HomeComponentProps) { }, { label: t('Origin'), - getValue: cluster => getOrigin(cluster), - render: ({ name }) => ( - {getOrigin(clusters[name])} - ), + getValue: cluster => getOrigin(cluster, t), + render: cluster => {getOrigin(cluster, t)}, }, { label: t('Status'), diff --git a/frontend/src/components/common/ConfirmDialog.stories.tsx b/frontend/src/components/common/ConfirmDialog.stories.tsx index 6afb12831d3..6ef459c452e 100644 --- a/frontend/src/components/common/ConfirmDialog.stories.tsx +++ b/frontend/src/components/common/ConfirmDialog.stories.tsx @@ -26,3 +26,19 @@ ConfirmDialogClosed.args = { title: 'A fine title', description: 'A really good description.', }; + +export const ConfirmDialogWithCheckbox = Template.bind({}); +ConfirmDialogWithCheckbox.args = { + open: true, + title: 'A fine title', + description: 'A really good description, only now we are using a checkbox.', + checkboxDescription: 'Click the checkbox.', +}; + +export const ConfirmDialogWithCheckboxClosed = Template.bind({}); +ConfirmDialogWithCheckboxClosed.args = { + open: false, + title: 'A fine title', + description: 'A really good description, only now we are using a checkbox.', + checkboxDescription: 'Click the checkbox.', +}; diff --git a/frontend/src/components/common/ConfirmDialog.tsx b/frontend/src/components/common/ConfirmDialog.tsx index 3ba49b0571a..70cecffb861 100644 --- a/frontend/src/components/common/ConfirmDialog.tsx +++ b/frontend/src/components/common/ConfirmDialog.tsx @@ -1,15 +1,28 @@ +import { Checkbox } from '@mui/material'; +import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import MuiDialog, { DialogProps as MuiDialogProps } from '@mui/material/Dialog'; import DialogActions from '@mui/material/DialogActions'; import DialogContent from '@mui/material/DialogContent'; import DialogContentText from '@mui/material/DialogContentText'; -import React, { ReactNode } from 'react'; +import React from 'react'; import { useTranslation } from 'react-i18next'; import { DialogTitle } from './Dialog'; export interface ConfirmDialogProps extends MuiDialogProps { + /** + * Title of the dialog box + */ title: string; - description: ReactNode; + /** + * Description of the dialog box + */ + description: string | React.ReactNode; + /* + * Description of the checkbox + * Note: If this is provided, an additional description will be rendered under the original and will require the checkbox to continue action. + */ + checkboxDescription?: string; onConfirm: () => void; handleClose: () => void; } @@ -17,12 +30,22 @@ export interface ConfirmDialogProps extends MuiDialogProps { export function ConfirmDialog(props: ConfirmDialogProps) { const { onConfirm, open, handleClose, title, description } = props; const { t } = useTranslation(); + const [checkedChoice, setcheckedChoice] = React.useState(false); function onConfirmationClicked() { handleClose(); onConfirm(); } + function closeDialog() { + setcheckedChoice(false); + handleClose(); + } + + function handleChoiceToggle() { + setcheckedChoice(!checkedChoice); + } + const focusedRef = React.useCallback((node: HTMLElement) => { if (node !== null) { node.setAttribute('tabindex', '-1'); @@ -34,21 +57,46 @@ export function ConfirmDialog(props: ConfirmDialogProps) {
{title} {description} + {props.checkboxDescription && ( + + + {props.checkboxDescription} + + + + )} - - + {props.checkboxDescription ? ( + + ) : ( + + )}
diff --git a/frontend/src/components/common/__snapshots__/ConfirmDialog.ConfirmDialogWithCheckbox.stories.storyshot b/frontend/src/components/common/__snapshots__/ConfirmDialog.ConfirmDialogWithCheckbox.stories.storyshot new file mode 100644 index 00000000000..97972a00de5 --- /dev/null +++ b/frontend/src/components/common/__snapshots__/ConfirmDialog.ConfirmDialogWithCheckbox.stories.storyshot @@ -0,0 +1,125 @@ + +