Skip to content

Commit 55b5e24

Browse files
feat: Support multiple contexts (#3892)
* Replacing select with radio buttons * Correct context chooser * Choose context when loading by kube config id * Error handling and correct style * Correct test * Test correction * Test correction * Test correction * Validate radio buttons * Test correction * test correction * Test correction * Error message corrected * PR correction
1 parent dc4f31b commit 55b5e24

File tree

11 files changed

+300
-113
lines changed

11 files changed

+300
-113
lines changed

public/i18n/en.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ clusters:
108108
list:
109109
no-clusters-found: No clusters found
110110
messages:
111+
choose-cluster: Choose cluster
111112
wrong-configuration: Cannot apply the configuration
112113
connection-failed: Error connecting to cluster. Check your configuration and make sure the cluster is online.
113114
name_singular: Cluster
@@ -143,7 +144,6 @@ clusters:
143144
title: Storage Type
144145
token: Token
145146
wizard:
146-
all-contexts: All contexts
147147
auth:
148148
client-id: Client ID
149149
client-secret: Client Secret
@@ -152,12 +152,14 @@ clusters:
152152
token: Token
153153
using-oidc: OIDC provider
154154
context: Context to connect
155+
provide-context: Provide Context
155156
kubeconfig-upload: Drop a .kubeconfig file or click to
156157
editor-placeholder: Paste .kubeconfig here
157158
incomplete: We couldn't find enough authentication information for {{context}} in your kubeconfig. You can enter it manually.
158159
intro: To connect a cluster, you have to provide the cluster configuration – called “kubeconfig” in the Kubernetes world.
159160
kubeconfig: Provide Kubeconfig
160-
multi-context-info: All contexts will be connected. Be aware that only {{context}} is validated. The remaining contexts are connected without validation.
161+
several-context-info: You have more than one cluster in your SAP BTP account.
162+
several-context-question: Which one do you want to open?
161163
not-an-object: kubeconfig is not an object
162164
storage: Privacy
163165
token-info: If you don't know how to get your token, ask your authentication provider.
@@ -230,6 +232,7 @@ common:
230232
add-all: Add all
231233
button-disabled: Disabled
232234
cancel: Cancel
235+
choose: Choose
233236
clone: Clone
234237
close: Close
235238
connect: Connect

src/components/App/App.tsx

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { useEffect } from 'react';
2-
import { Navigate, Route, Routes } from 'react-router';
2+
import { createPortal } from 'react-dom';
3+
import {
4+
Navigate,
5+
Route,
6+
Routes,
7+
useNavigate,
8+
useSearchParams,
9+
} from 'react-router';
310
import { useTranslation } from 'react-i18next';
4-
import { useRecoilValue, useSetRecoilState } from 'recoil';
11+
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
512

613
import { useUrl } from 'hooks/useUrl';
714
import { useSentry } from 'hooks/useSentry';
@@ -19,9 +26,10 @@ import { useLoginWithKubeconfigID } from 'components/App/useLoginWithKubeconfigI
1926
import { useMakeGardenerLoginRoute } from 'components/Gardener/useMakeGardenerLoginRoute';
2027
import { useHandleResetEndpoint } from 'components/Clusters/shared';
2128
import { useResourceSchemas } from './resourceSchemas/useResourceSchemas';
22-
import { useAfterInitHook } from 'state/useAfterInitHook';
29+
import { removePreviousPath, useAfterInitHook } from 'state/useAfterInitHook';
2330
import useSidebarCondensed from 'sidebar/useSidebarCondensed';
2431
import { useGetValidationEnabledSchemas } from 'state/validationEnabledSchemasAtom';
32+
import { multipleContexts } from 'state/multipleContextsAtom';
2533

2634
import { SplitterElement, SplitterLayout } from '@ui5/webcomponents-react';
2735
import { showKymaCompanionState } from 'state/companion/showKymaCompanionAtom';
@@ -34,6 +42,7 @@ import ClusterList from 'components/Clusters/views/ClusterList';
3442
import ClusterRoutes from './ClusterRoutes';
3543
import { IncorrectPath } from './IncorrectPath';
3644
import { Spinner } from 'shared/components/Spinner/Spinner';
45+
import { ContextChooserMessage } from 'components/Clusters/components/ContextChooser/ContextChooser';
3746

3847
import { themeState } from 'state/preferences/themeAtom';
3948
import { initTheme } from './initTheme';
@@ -49,6 +58,9 @@ export default function App() {
4958
const { namespace } = useUrl();
5059
const makeGardenerLoginRoute = useMakeGardenerLoginRoute();
5160
const { t, i18n } = useTranslation();
61+
const navigate = useNavigate();
62+
const [search] = useSearchParams();
63+
const [contextsState, setContextsState] = useRecoilState(multipleContexts);
5264

5365
useEffect(() => {
5466
setNamespace(namespace);
@@ -98,17 +110,42 @@ export default function App() {
98110
<Header />
99111
<div id="page-wrap">
100112
<Sidebar key={cluster?.name} />
113+
{search.get('kubeconfigID') &&
114+
!!contextsState?.contexts?.length &&
115+
kubeconfigIdState === 'loading' &&
116+
createPortal(
117+
<ContextChooserMessage
118+
contexts={contextsState?.contexts}
119+
setValue={(value: string) =>
120+
setContextsState(state => ({
121+
...state,
122+
chosenContext: value,
123+
}))
124+
}
125+
onCancel={() => {
126+
setContextsState({} as any);
127+
removePreviousPath();
128+
navigate('/clusters');
129+
}}
130+
/>,
131+
document.body,
132+
)}
101133
<ContentWrapper>
102134
<Routes key={cluster?.name}>
103-
<Route
104-
path="*"
105-
element={
106-
<IncorrectPath
107-
to="clusters"
108-
message={t('components.incorrect-path.message.clusters')}
135+
{kubeconfigIdState !== 'loading' &&
136+
!search.get('kubeconfigID') && (
137+
<Route
138+
path="*"
139+
element={
140+
<IncorrectPath
141+
to="clusters"
142+
message={t(
143+
'components.incorrect-path.message.clusters',
144+
)}
145+
/>
146+
}
109147
/>
110-
}
111-
/>
148+
)}
112149
<Route path="clusters" element={<ClusterList />} />
113150
<Route
114151
path="cluster/:currentClusterName"

src/components/App/useLoginWithKubeconfigID.ts

Lines changed: 98 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { addByContext } from 'components/Clusters/shared';
22
import { ClustersState, clustersState } from 'state/clustersAtom';
3-
import { useRecoilValue } from 'recoil';
3+
import { SetterOrUpdater, useRecoilState, useRecoilValue } from 'recoil';
44
import { useEffect, useState } from 'react';
5-
import { useSearchParams } from 'react-router';
5+
import { NavigateFunction, useNavigate, useSearchParams } from 'react-router';
66
import { useTranslation } from 'react-i18next';
77
import jsyaml from 'js-yaml';
88
import { ValidKubeconfig } from 'types';
@@ -16,6 +16,11 @@ import { ConfigFeature } from 'state/types';
1616
import { removePreviousPath } from 'state/useAfterInitHook';
1717
import { configurationAtom } from 'state/configuration/configurationAtom';
1818
import { ClusterStorage } from 'state/types';
19+
import {
20+
KubeConfigMultipleState,
21+
multipleContexts,
22+
} from 'state/multipleContextsAtom';
23+
import { useNotification } from 'shared/contexts/NotificationContext';
1924

2025
export interface KubeconfigIdFeature extends ConfigFeature {
2126
config: {
@@ -54,40 +59,27 @@ export async function loadKubeconfigById(
5459
return payload;
5560
}
5661

57-
const loadKubeconfigIdCluster = async (
58-
kubeconfigId: string,
59-
kubeconfigIdFeature: KubeconfigIdFeature,
62+
const addClusters = async (
63+
kubeconfig: ValidKubeconfig,
6064
clusters: ClustersState,
6165
clusterInfo: useClustersInfoType,
66+
kubeconfigIdFeature: KubeconfigIdFeature,
6267
t: TFunction,
68+
notification?: any,
69+
navigate?: NavigateFunction,
6370
) => {
64-
try {
65-
const kubeconfig = await loadKubeconfigById(
66-
kubeconfigId,
67-
kubeconfigIdFeature!,
68-
t,
69-
);
70-
71-
if (!kubeconfig.contexts.length) {
72-
return;
73-
}
74-
75-
const isOnlyOneCluster = kubeconfig.contexts.length === 1;
76-
const currentContext = kubeconfig['current-context'];
77-
const showClustersOverview =
78-
kubeconfigIdFeature.config?.showClustersOverview;
79-
80-
const isK8CurrentCluster = (name: string) =>
81-
!!currentContext && currentContext === name;
82-
83-
const shouldRedirectToCluster = (name: string) =>
84-
!showClustersOverview && (isOnlyOneCluster || isK8CurrentCluster(name));
71+
const isOnlyOneCluster = kubeconfig.contexts.length === 1;
72+
const currentContext = kubeconfig['current-context'];
73+
const showClustersOverview = kubeconfigIdFeature.config?.showClustersOverview;
74+
const isK8CurrentCluster = (name: string) =>
75+
!!currentContext && currentContext === name;
76+
const shouldRedirectToCluster = (name: string) =>
77+
!showClustersOverview && (isOnlyOneCluster || isK8CurrentCluster(name));
8578

86-
// add the clusters
79+
try {
8780
kubeconfig.contexts.forEach(context => {
8881
const previousStorageMethod: ClusterStorage =
8982
clusters![context.name]?.config?.storage || 'sessionStorage';
90-
9183
addByContext(
9284
{
9385
kubeconfig,
@@ -103,6 +95,50 @@ const loadKubeconfigIdCluster = async (
10395
if (showClustersOverview) {
10496
window.location.href = window.location.origin + '/clusters';
10597
}
98+
} catch (e) {
99+
if (notification) {
100+
notification.notifyError({
101+
content: `${t('clusters.messages.wrong-configuration')}. ${
102+
e instanceof Error && e?.message ? e.message : ''
103+
}`,
104+
});
105+
}
106+
if (navigate) {
107+
navigate('/clusters');
108+
removePreviousPath();
109+
}
110+
console.warn(e);
111+
}
112+
};
113+
114+
const loadKubeconfigIdCluster = async (
115+
kubeconfigId: string,
116+
kubeconfigIdFeature: KubeconfigIdFeature,
117+
clusters: ClustersState,
118+
clusterInfo: useClustersInfoType,
119+
t: TFunction,
120+
setContextsState?: SetterOrUpdater<KubeConfigMultipleState>,
121+
) => {
122+
try {
123+
const kubeconfig = await loadKubeconfigById(
124+
kubeconfigId,
125+
kubeconfigIdFeature!,
126+
t,
127+
);
128+
129+
if (!kubeconfig?.contexts?.length) {
130+
return;
131+
}
132+
133+
if (kubeconfig.contexts.length > 1 && setContextsState) {
134+
setContextsState(state => ({
135+
...state,
136+
...kubeconfig,
137+
}));
138+
} else {
139+
addClusters(kubeconfig, clusters, clusterInfo, kubeconfigIdFeature, t);
140+
return 'done';
141+
}
106142
} catch (e) {
107143
if (e instanceof Error) {
108144
alert(t('kubeconfig-id.error', { error: e.message }));
@@ -121,6 +157,9 @@ export function useLoginWithKubeconfigID() {
121157
const kubeconfigIdFeature = useFeature<KubeconfigIdFeature>('KUBECONFIG_ID');
122158
const configuration = useRecoilValue(configurationAtom);
123159
const clusters = useRecoilValue(clustersState);
160+
const [contextsState, setContextsState] = useRecoilState(multipleContexts);
161+
const notification = useNotification();
162+
const navigate = useNavigate();
124163
const [search] = useSearchParams();
125164
const { t } = useTranslation();
126165
const clusterInfo = useClustersInfo();
@@ -129,6 +168,29 @@ export function useLoginWithKubeconfigID() {
129168
KubeconfigIdHandleState
130169
>('not started');
131170

171+
useEffect(() => {
172+
if (contextsState?.chosenContext) {
173+
const kubeconfig = {
174+
...contextsState,
175+
contexts: contextsState.contexts.filter(
176+
context => context.name === contextsState.chosenContext,
177+
),
178+
'current-context': contextsState.chosenContext,
179+
};
180+
addClusters(
181+
kubeconfig,
182+
clusters,
183+
clusterInfo,
184+
kubeconfigIdFeature,
185+
t,
186+
notification,
187+
navigate,
188+
);
189+
setHandledKubeconfigId('done');
190+
}
191+
// eslint-disable-next-line react-hooks/exhaustive-deps
192+
}, [contextsState]);
193+
132194
useEffect(() => {
133195
const dependenciesReady = !!configuration?.features && !!clusters;
134196
const flowStarted = handledKubeconfigId !== 'not started';
@@ -154,8 +216,14 @@ export function useLoginWithKubeconfigID() {
154216
clusters,
155217
clusterInfo,
156218
t,
157-
).then(() => setHandledKubeconfigId('done'));
219+
setContextsState,
220+
).then(val => {
221+
if (val === 'done') {
222+
setHandledKubeconfigId('done');
223+
}
224+
});
158225
}, [
226+
contextsState,
159227
search,
160228
clusters,
161229
kubeconfigIdFeature,
@@ -164,6 +232,7 @@ export function useLoginWithKubeconfigID() {
164232
handledKubeconfigId,
165233
configuration,
166234
setCurrentCluster,
235+
setContextsState,
167236
]);
168237

169238
return handledKubeconfigId;

src/components/Clusters/components/AddClusterWizard.js

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export function AddClusterWizard({
5151
const setShowWizard = useSetRecoilState(showAddClusterWizard);
5252
const [showTitleDescription, setShowTitleDescription] = useState(false);
5353
const setIsFormOpen = useSetRecoilState(isFormOpenState);
54+
const [chosenContext, setChosenContext] = useState(undefined);
5455

5556
const {
5657
isValid: authValid,
@@ -126,8 +127,9 @@ export function AddClusterWizard({
126127
setIsFormOpen({ formOpen: false });
127128
} catch (e) {
128129
notification.notifyError({
129-
title: t('clusters.messages.wrong-configuration'),
130-
content: t('common.tooltips.error') + e.message,
130+
content: `${t('clusters.messages.wrong-configuration')}. ${
131+
e instanceof Error && e?.message ? e.message : ''
132+
}`,
131133
});
132134
console.warn(e);
133135
}
@@ -139,11 +141,14 @@ export function AddClusterWizard({
139141
};
140142

141143
const isCurrentStepInvalid = step => {
144+
const invalidMultipleContexts = !hasOneContext && !chosenContext;
142145
switch (step) {
143146
case 1:
144147
return !kubeconfig;
145148
case 2:
146-
return kubeconfig && (!hasAuth || !hasOneContext) ? !authValid : false;
149+
return kubeconfig && (!hasAuth || !hasOneContext)
150+
? !authValid || invalidMultipleContexts
151+
: false;
147152
default:
148153
return false;
149154
}
@@ -183,7 +188,12 @@ export function AddClusterWizard({
183188
}}
184189
className="cluster-wizard__auth-form"
185190
>
186-
{!hasOneContext && <ContextChooser />}
191+
{!hasOneContext && (
192+
<ContextChooser
193+
chosenContext={chosenContext}
194+
setChosenContext={setChosenContext}
195+
/>
196+
)}
187197
{!hasAuth && <AuthForm revalidate={revalidate} />}
188198
</ResourceForm.Single>
189199
</div>

0 commit comments

Comments
 (0)