Skip to content

Commit 6c757cf

Browse files
Release hotfixes
More resilient workbench boot, and fix cluster switcher bug
1 parent 008912e commit 6c757cf

11 files changed

Lines changed: 329 additions & 63 deletions

File tree

assets/src/components/cd/utils/ClusterSelector.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { ComponentPropsWithRef, useCallback, useMemo, useState } from 'react'
2121
import { useTheme } from 'styled-components'
2222

2323
import { useProjectId } from '../../contexts/ProjectsContext'
24+
import { withCurrentCluster } from '../../kubernetes/clusterSelection'
2425
import { useFetchPaginatedData } from '../../utils/table/useFetchPaginatedData'
2526
import { ClusterUpgradeChip } from '../clusters/ClusterUpgradeButton'
2627

@@ -66,8 +67,12 @@ export default function ClusterSelector({
6667
)
6768

6869
const clusters = useMemo(
69-
() => data?.clusters?.edges?.flatMap((e) => (e?.node ? e.node : [])) || [],
70-
[data?.clusters?.edges]
70+
() =>
71+
withCurrentCluster(
72+
data?.clusters?.edges?.flatMap((e) => (e?.node ? e.node : [])) || [],
73+
data?.cluster
74+
),
75+
[data?.cluster, data?.clusters?.edges]
7176
)
7277

7378
const findCluster = useCallback(

assets/src/components/kubernetes/Cluster.tsx

Lines changed: 78 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { EmptyState } from '@pluralsh/design-system'
22
import { isEmpty } from 'lodash'
3-
import { createContext, useContext, useEffect, useMemo } from 'react'
3+
import {
4+
createContext,
5+
useContext,
6+
useEffect,
7+
useLayoutEffect,
8+
useMemo,
9+
} from 'react'
410
import {
511
Navigate,
612
Outlet,
@@ -29,11 +35,14 @@ import { useProjectId } from '../contexts/ProjectsContext'
2935
import { GqlError } from '../utils/Alert'
3036
import LoadingIndicator from '../utils/LoadingIndicator'
3137
import { useSimpleToast } from '../utils/SimpleToastContext'
38+
import {
39+
getDefaultKubernetesClusterId,
40+
isKubernetesClusterMissing,
41+
LAST_SELECTED_CLUSTER_KEY,
42+
} from './clusterSelection'
3243
import { DataSelectProvider } from './common/DataSelect'
3344
import { getNamespaceListLoadError } from './common/namespaceList'
3445

35-
import { LAST_SELECTED_CLUSTER_KEY } from './Navigation'
36-
3746
type ClusterContextT = {
3847
clusters: KubernetesClusterFragment[]
3948
refetch?: Nullable<() => void>
@@ -111,30 +120,39 @@ export default function Cluster({
111120
const { search } = useLocation()
112121
const navigate = useNavigate()
113122

114-
const { data, error, refetch, loading } = useKubernetesClustersQuery({
115-
pollInterval: 60_000,
116-
fetchPolicy: 'cache-and-network',
117-
variables: {
118-
currentClusterId: clusterId,
119-
hasCurrentClusterId: !!clusterId,
120-
projectId,
121-
},
122-
})
123+
const { data, previousData, error, refetch, loading } =
124+
useKubernetesClustersQuery({
125+
pollInterval: 60_000,
126+
fetchPolicy: 'cache-and-network',
127+
variables: {
128+
currentClusterId: clusterId,
129+
hasCurrentClusterId: !!clusterId,
130+
projectId,
131+
},
132+
})
123133

134+
// Variable changes miss the cache, so `data` is empty while the new cluster
135+
// loads. Keep the previous result on screen so the dashboard doesn't unmount.
136+
const queryData = data ?? previousData
124137
const clusters = useMemo(
125-
() => mapExistingNodes(data?.clusters),
126-
[data?.clusters]
138+
() => mapExistingNodes(queryData?.clusters),
139+
[queryData?.clusters]
127140
)
128-
const currentCluster = data?.cluster
129-
130-
const hasCurrentClusterId =
131-
currentCluster?.id === clusterId ||
132-
clusters.some(({ id }) => id === clusterId)
133-
134-
const cluster =
135-
currentCluster?.id === clusterId
136-
? currentCluster
137-
: clusters.find(({ id }) => id === clusterId)
141+
const currentCluster = [data?.cluster, queryData?.cluster].find(
142+
(candidate) => candidate?.id === clusterId
143+
)
144+
const cluster = currentCluster ?? clusters.find(({ id }) => id === clusterId)
145+
// Don't unmount the dashboard while the new cluster(id:) result is in flight.
146+
const clusterForContext =
147+
cluster ?? (loading ? queryData?.cluster : undefined)
148+
149+
const clusterMissing = isKubernetesClusterMissing({
150+
clusterId,
151+
loading,
152+
hasData: !!data,
153+
currentClusterId: data?.cluster?.id,
154+
clusterIds: mapExistingNodes(data?.clusters).map(({ id }) => id),
155+
})
138156

139157
const namespaceQueryOptions = getNamespacesOptions({
140158
client: AxiosInstance(clusterId!),
@@ -184,39 +202,48 @@ export default function Cluster({
184202
}, [clusterId, namespaceListError, popToast])
185203

186204
const context = useMemo(
187-
() => ({ clusters, refetch, cluster, namespaces }) as ClusterContextT,
188-
[clusters, refetch, cluster, namespaces]
205+
() =>
206+
({
207+
clusters,
208+
refetch,
209+
cluster: clusterForContext,
210+
namespaces,
211+
}) as ClusterContextT,
212+
[clusters, refetch, clusterForContext, namespaces]
189213
)
190214

191-
const defaultClusterId = useMemo(() => {
192-
if (isEmpty(clusters)) return undefined
193-
194-
const lastSelectedClusterId = sessionStorage.getItem(
195-
LAST_SELECTED_CLUSTER_KEY
196-
)
197-
const lastSelectedClusterExists = clusters.some(
198-
({ id }) => id === lastSelectedClusterId
199-
)
200-
const mgmtCluster = clusters.find(({ self }) => !!self)
215+
const defaultClusterId = useMemo(
216+
() =>
217+
getDefaultKubernetesClusterId(
218+
clusters,
219+
sessionStorage.getItem(LAST_SELECTED_CLUSTER_KEY)
220+
),
221+
[clusters]
222+
)
201223

202-
return lastSelectedClusterExists
203-
? lastSelectedClusterId
204-
: mgmtCluster
205-
? mgmtCluster?.id
206-
: clusters[0].id
207-
}, [clusters])
224+
useLayoutEffect(() => {
225+
if (cluster && clusterId && cluster.id === clusterId) {
226+
sessionStorage.setItem(LAST_SELECTED_CLUSTER_KEY, cluster.id)
227+
}
228+
}, [cluster, clusterId])
208229

209230
useEffect(() => {
210-
if (clusterId && defaultClusterId && !hasCurrentClusterId) {
211-
navigate(`${defaultClusterId}${search}`, {
212-
replace: true,
213-
})
214-
}
215-
}, [defaultClusterId, navigate, search, clusterId, hasCurrentClusterId])
231+
if (!clusterMissing || !defaultClusterId) return
232+
233+
navigate(`${getDefaultClusterPath(defaultClusterId)}${search}`, {
234+
replace: true,
235+
})
236+
}, [
237+
clusterMissing,
238+
defaultClusterId,
239+
getDefaultClusterPath,
240+
navigate,
241+
search,
242+
])
216243

217244
useEffect(() => {
218245
refetchNamespaces()
219-
}, [refetchNamespaces, cluster])
246+
}, [refetchNamespaces, clusterId])
220247

221248
if (error)
222249
return (
@@ -228,7 +255,7 @@ export default function Cluster({
228255
</div>
229256
)
230257

231-
if (loading && !data) return <LoadingIndicator />
258+
if (!queryData) return <LoadingIndicator />
232259

233260
if (!clusterId && defaultClusterId)
234261
return (
@@ -238,7 +265,7 @@ export default function Cluster({
238265
/>
239266
)
240267

241-
if (!cluster) return <EmptyState message="No clusters found." />
268+
if (!cluster && !loading) return <EmptyState message="No clusters found." />
242269

243270
return (
244271
<ClusterContext value={context}>

assets/src/components/kubernetes/Navigation.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReactNode, useLayoutEffect, useMemo, useState } from 'react'
1+
import { ReactNode, useMemo, useState } from 'react'
22
import { Outlet, useLocation, useNavigate, useParams } from 'react-router-dom'
33
import { useTheme } from 'styled-components'
44

@@ -10,6 +10,7 @@ import {
1010
getKubernetesAbsPath,
1111
NETWORK_REL_PATH,
1212
RBAC_REL_PATH,
13+
replaceKubernetesClusterId,
1314
STORAGE_REL_PATH,
1415
WORKLOADS_REL_PATH,
1516
} from '../../routes/kubernetesRoutesConsts'
@@ -24,7 +25,7 @@ import { DataSelectInputs } from './common/DataSelect'
2425

2526
export const NAMESPACE_PARAM = 'namespace'
2627
export const FILTER_PARAM = 'filter'
27-
export const LAST_SELECTED_CLUSTER_KEY = 'plural-last-selected-cluster'
28+
export { LAST_SELECTED_CLUSTER_KEY } from './clusterSelection'
2829

2930
const directory: Directory = [
3031
{ path: WORKLOADS_REL_PATH, label: 'Workloads' },
@@ -51,10 +52,6 @@ export default function Navigation() {
5152
[]
5253
)
5354

54-
useLayoutEffect(() => {
55-
if (clusterId) sessionStorage.setItem(LAST_SELECTED_CLUSTER_KEY, clusterId)
56-
}, [pathname, clusterId])
57-
5855
return (
5956
<ResponsiveLayoutPage>
6057
<ResponsiveLayoutSidenavContainer>
@@ -72,7 +69,11 @@ export default function Navigation() {
7269
allowDeselect={false}
7370
hideTitleContent
7471
onClusterChange={(cluster) => {
75-
if (cluster?.id) navigate(pathname.replace(clusterId, cluster.id))
72+
if (!cluster?.id || cluster.id === clusterId) return
73+
74+
navigate(
75+
replaceKubernetesClusterId(pathname, clusterId, cluster.id)
76+
)
7677
}}
7778
/>
7879
<SideNavEntries
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { replaceKubernetesClusterId } from '../../routes/kubernetesRoutesConsts'
3+
import {
4+
getDefaultKubernetesClusterId,
5+
isKubernetesClusterMissing,
6+
withCurrentCluster,
7+
} from './clusterSelection'
8+
9+
describe('replaceKubernetesClusterId', () => {
10+
it('swaps only the kubernetes cluster path segment', () => {
11+
expect(
12+
replaceKubernetesClusterId(
13+
'/kubernetes/cluster-a/workloads/deployments',
14+
'cluster-a',
15+
'cluster-b'
16+
)
17+
).toBe('/kubernetes/cluster-b/workloads/deployments')
18+
})
19+
20+
it('does not use a raw substring replace', () => {
21+
expect(
22+
replaceKubernetesClusterId(
23+
'/kubernetes/cluster-a/workloads/cluster-a',
24+
'cluster-a',
25+
'cluster-b'
26+
)
27+
).toBe('/kubernetes/cluster-b/workloads/cluster-a')
28+
})
29+
30+
it('leaves unrelated paths unchanged', () => {
31+
expect(
32+
replaceKubernetesClusterId(
33+
'/cd/clusters/cluster-a',
34+
'cluster-a',
35+
'cluster-b'
36+
)
37+
).toBe('/cd/clusters/cluster-a')
38+
})
39+
})
40+
41+
describe('withCurrentCluster', () => {
42+
it('prepends the current cluster when it is missing from the page', () => {
43+
expect(
44+
withCurrentCluster([{ id: 'a' }, { id: 'b' }], { id: 'current' })
45+
).toEqual([{ id: 'current' }, { id: 'a' }, { id: 'b' }])
46+
})
47+
48+
it('does not duplicate the current cluster', () => {
49+
expect(withCurrentCluster([{ id: 'a' }, { id: 'b' }], { id: 'a' })).toEqual(
50+
[{ id: 'a' }, { id: 'b' }]
51+
)
52+
})
53+
})
54+
55+
describe('getDefaultKubernetesClusterId', () => {
56+
const clusters = [
57+
{ id: 'worker', self: false },
58+
{ id: 'mgmt', self: true },
59+
]
60+
61+
it('prefers the last selected cluster when it still exists', () => {
62+
expect(getDefaultKubernetesClusterId(clusters, 'worker')).toBe('worker')
63+
})
64+
65+
it('falls back to the management cluster', () => {
66+
expect(getDefaultKubernetesClusterId(clusters, 'gone')).toBe('mgmt')
67+
})
68+
69+
it('returns undefined when there are no clusters', () => {
70+
expect(getDefaultKubernetesClusterId([], 'worker')).toBeUndefined()
71+
})
72+
})
73+
74+
describe('isKubernetesClusterMissing', () => {
75+
it('does not treat a cluster as missing while the query is in flight', () => {
76+
expect(
77+
isKubernetesClusterMissing({
78+
clusterId: 'b',
79+
loading: true,
80+
hasData: true,
81+
currentClusterId: 'a',
82+
clusterIds: ['a'],
83+
})
84+
).toBe(false)
85+
})
86+
87+
it('does not redirect when the requested cluster is in the page even if cluster(id:) is stale', () => {
88+
expect(
89+
isKubernetesClusterMissing({
90+
clusterId: 'b',
91+
loading: false,
92+
hasData: true,
93+
currentClusterId: 'a',
94+
clusterIds: ['a', 'b'],
95+
})
96+
).toBe(false)
97+
})
98+
99+
it('does not redirect when the requested cluster is only on cluster(id:)', () => {
100+
expect(
101+
isKubernetesClusterMissing({
102+
clusterId: 'b',
103+
loading: false,
104+
hasData: true,
105+
currentClusterId: 'b',
106+
clusterIds: ['a'],
107+
})
108+
).toBe(false)
109+
})
110+
111+
it('flags a settled query whose cluster is neither listed nor returned by id', () => {
112+
expect(
113+
isKubernetesClusterMissing({
114+
clusterId: 'missing',
115+
loading: false,
116+
hasData: true,
117+
currentClusterId: undefined,
118+
clusterIds: ['a', 'mgmt'],
119+
})
120+
).toBe(true)
121+
})
122+
})

0 commit comments

Comments
 (0)