InstanceConfiguration.tsx411 lines · main
1import { PermissionAction } from '@supabase/shared-types/out/constants'
2import {
3 Background,
4 ColorMode,
5 Edge,
6 ReactFlow,
7 ReactFlowProvider,
8 useNodesInitialized,
9 useReactFlow,
10} from '@xyflow/react'
11import { partition } from 'lodash'
12import { ChevronDown, Globe2, Loader2, Network } from 'lucide-react'
13import { useTheme } from 'next-themes'
14import Link from 'next/link'
15import { useEffect, useMemo, useState } from 'react'
16
17import '@xyflow/react/dist/style.css'
18
19import { useParams } from 'common'
20import { useRouter } from 'next/router'
21import {
22 Button,
23 cn,
24 DropdownMenu,
25 DropdownMenuContent,
26 DropdownMenuItem,
27 DropdownMenuSeparator,
28 DropdownMenuTrigger,
29} from 'ui'
30
31import DropAllReplicasConfirmationModal from './DropAllReplicasConfirmationModal'
32import { DropReplicaConfirmationModal } from './DropReplicaConfirmationModal'
33import { SmoothstepEdge } from './Edge'
34import { REPLICA_STATUS } from './InstanceConfiguration.constants'
35import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceConfiguration.utils'
36import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode'
37import MapView from './MapView'
38import { RestartReplicaConfirmationModal } from './RestartReplicaConfirmationModal'
39import AlertError from '@/components/ui/AlertError'
40import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
41import { useLoadBalancersQuery } from '@/data/read-replicas/load-balancers-query'
42import { Database, useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
43import {
44 ReplicaInitializationStatus,
45 useReadReplicasStatusesQuery,
46} from '@/data/read-replicas/replicas-status-query'
47import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
48import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
49import {
50 useIsAwsCloudProvider,
51 useIsOrioleDb,
52 useSelectedProjectQuery,
53} from '@/hooks/misc/useSelectedProject'
54import { useStaticEffectEvent } from '@/hooks/useStaticEffectEvent'
55import { timeout } from '@/lib/helpers'
56
57interface InstanceConfigurationUIProps {
58 diagramOnly?: boolean
59}
60
61const InstanceConfigurationUI = ({ diagramOnly = false }: InstanceConfigurationUIProps) => {
62 const router = useRouter()
63 const reactFlow = useReactFlow()
64 const isOrioleDb = useIsOrioleDb()
65 const { resolvedTheme } = useTheme()
66 const { ref: projectRef } = useParams()
67 const { isPending: isLoadingProject } = useSelectedProjectQuery()
68
69 const isAws = useIsAwsCloudProvider()
70 const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
71 const newReplicaURL = `/project/${projectRef}/database/replication?type=Read+Replica`
72
73 const [view, setView] = useState<'flow' | 'map'>('flow')
74 const [showDeleteAllModal, setShowDeleteAllModal] = useState(false)
75 const [refetchInterval, setRefetchInterval] = useState<number | false>(10000)
76 const [selectedReplicaToDrop, setSelectedReplicaToDrop] = useState<Database>()
77 const [selectedReplicaToRestart, setSelectedReplicaToRestart] = useState<Database>()
78
79 const { can: canManageReplicas } = useAsyncCheckPermissions(PermissionAction.CREATE, 'projects')
80
81 const {
82 data: loadBalancers,
83 refetch: refetchLoadBalancers,
84 isSuccess: isSuccessLoadBalancers,
85 } = useLoadBalancersQuery({ projectRef })
86 const {
87 data,
88 error,
89 refetch: refetchReplicas,
90 isPending: isLoading,
91 isError,
92 isSuccess: isSuccessReplicas,
93 } = useReadReplicasQuery({ projectRef })
94 const [[primary], replicas] = useMemo(
95 () => partition(data ?? [], (db) => db.identifier === projectRef),
96 [data, projectRef]
97 )
98 const numReplicas = useMemo(() => data?.length ?? 0, [data])
99
100 const { data: replicasStatuses, isSuccess: isSuccessReplicasStatuses } =
101 useReadReplicasStatusesQuery(
102 { projectRef },
103 {
104 refetchInterval: refetchInterval,
105 refetchOnWindowFocus: false,
106 }
107 )
108
109 useEffect(() => {
110 if (!isSuccessReplicasStatuses) return
111 const refetch = async () => {
112 const fixedStatues = [
113 REPLICA_STATUS.ACTIVE_HEALTHY,
114 REPLICA_STATUS.ACTIVE_UNHEALTHY,
115 REPLICA_STATUS.INIT_READ_REPLICA_FAILED,
116 ]
117 const replicasInTransition = replicasStatuses.filter((db) => {
118 const { status } = db.replicaInitializationStatus || {}
119 return (
120 !fixedStatues.includes(db.status) || status === ReplicaInitializationStatus.InProgress
121 )
122 })
123 const hasTransientStatus = replicasInTransition.length > 0
124
125 // If any replica's status has changed, refetch databases
126 if (replicasStatuses.length !== numReplicas) {
127 await refetchReplicas()
128 setTimeout(() => refetchLoadBalancers(), 2000)
129 }
130
131 // If all replicas are active healthy, stop fetching statuses
132 if (!hasTransientStatus) {
133 setRefetchInterval(false)
134 }
135 }
136 refetch()
137 }, [
138 numReplicas,
139 isSuccessReplicasStatuses,
140 refetchLoadBalancers,
141 refetchReplicas,
142 replicasStatuses,
143 ])
144
145 const backgroundPatternColor =
146 resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)'
147
148 const nodes = useMemo(
149 () =>
150 isSuccessReplicas && isSuccessLoadBalancers && primary !== undefined
151 ? generateNodes({
152 primary,
153 replicas,
154 loadBalancers: loadBalancers ?? [],
155 onSelectRestartReplica: setSelectedReplicaToRestart,
156 onSelectDropReplica: setSelectedReplicaToDrop,
157 })
158 : [],
159 [isSuccessReplicas, isSuccessLoadBalancers, primary, replicas, loadBalancers]
160 )
161
162 const edges: Edge[] = useMemo(
163 () =>
164 isSuccessReplicas && isSuccessLoadBalancers
165 ? [
166 ...((loadBalancers ?? []).length > 0
167 ? [
168 {
169 id: `load-balancer-${primary.identifier}`,
170 source: 'load-balancer',
171 target: primary.identifier,
172 type: 'smoothstep',
173 animated: true,
174 className: 'cursor-default!',
175 },
176 ]
177 : []),
178 ...replicas.map((database) => {
179 return {
180 id: `${primary.identifier}-${database.identifier}`,
181 source: primary.identifier,
182 target: database.identifier,
183 type: 'smoothstep',
184 animated: true,
185 className: 'cursor-default!',
186 data: {
187 status: database.status,
188 identifier: database.identifier,
189 connectionString: database.connectionString,
190 },
191 }
192 }),
193 ]
194 : [],
195 [isSuccessLoadBalancers, isSuccessReplicas, loadBalancers, primary?.identifier, replicas]
196 )
197
198 const nodeTypes = useMemo(
199 () => ({
200 PRIMARY: PrimaryNode,
201 READ_REPLICA: ReplicaNode,
202 REGION: RegionNode,
203 LOAD_BALANCER: LoadBalancerNode,
204 }),
205 []
206 )
207
208 const edgeTypes = useMemo(
209 () => ({
210 smoothstep: SmoothstepEdge,
211 }),
212 []
213 )
214
215 const nodesInitialized = useNodesInitialized()
216 const [hasMeasuredLayout, setHasMeasuredLayout] = useState(false)
217
218 const setReactFlow = async ({ measured }: { measured: boolean }) => {
219 // Merge in React Flow's measured dimensions (if any) so dagre can use real
220 // heights instead of the first-paint fallbacks.
221 const measuredNodes = nodes.map((node) => {
222 const existing = reactFlow.getNode(node.id)
223 return existing?.measured ? { ...node, measured: existing.measured } : node
224 })
225 const graph = getDagreGraphLayout(measuredNodes, edges)
226 const { nodes: updatedNodes } = addRegionNodes(graph.nodes, graph.edges)
227 reactFlow.setNodes(updatedNodes)
228 reactFlow.setEdges(graph.edges)
229
230 // [Joshen] Odd fix to ensure that react flow snaps back to center when adding nodes
231 await timeout(1)
232 reactFlow.fitView({ maxZoom: 0.9, minZoom: 0.9 })
233 if (measured) setHasMeasuredLayout(true)
234 }
235
236 // First pass: lay out using fallback heights for any not-yet-measured nodes.
237 // The diagram is kept invisible until the measured pass below has run, so the
238 // user never sees the fallback positions.
239 // [Joshen] Just FYI this block is oddly triggering whenever we refocus on the viewport
240 // even if I change the dependency array to just data. Not blocker, just an area to optimize
241 useEffect(() => {
242 if (isSuccessReplicas && isSuccessLoadBalancers && nodes.length > 0 && view === 'flow') {
243 setReactFlow({ measured: false })
244 }
245 }, [isSuccessReplicas, isSuccessLoadBalancers, nodes, edges, view])
246
247 // Second pass: once React Flow has measured the nodes, re-run the layout so
248 // dagre uses real heights. Only `nodesInitialized` going true should trigger
249 // this — the first-pass effect above handles node/view changes.
250 const runMeasuredLayout = useStaticEffectEvent(() => {
251 if (nodesInitialized && nodes.length > 0 && view === 'flow') {
252 setReactFlow({ measured: true })
253 }
254 })
255 useEffect(() => {
256 runMeasuredLayout()
257 }, [nodesInitialized, runMeasuredLayout])
258
259 return (
260 <div className={cn('nowheel', diagramOnly ? 'h-full' : 'border-y')}>
261 <div
262 className={`${diagramOnly ? 'h-full' : 'h-[500px]'} w-full relative ${
263 isSuccessReplicas && !isLoadingProject ? '' : 'flex items-center justify-center px-28'
264 }`}
265 >
266 {(isLoading || isLoadingProject) && (
267 <Loader2 className="animate-spin text-foreground-light" />
268 )}
269 {isError && <AlertError error={error} subject="Failed to retrieve replicas" />}
270 {isSuccessReplicas && !isLoadingProject && (
271 <>
272 {!diagramOnly && infrastructureReadReplicas && (
273 <div className="z-10 absolute top-4 right-4 flex items-center justify-center gap-x-2">
274 <div className="flex items-center justify-center">
275 <ButtonTooltip
276 asChild
277 type="default"
278 disabled={!canManageReplicas || isOrioleDb}
279 className={cn(replicas.length > 0 ? 'rounded-r-none' : '')}
280 tooltip={{
281 content: {
282 side: 'bottom',
283 text: !canManageReplicas
284 ? 'You need additional permissions to deploy replicas'
285 : isOrioleDb
286 ? 'Read replicas are not supported with OrioleDB'
287 : undefined,
288 },
289 }}
290 >
291 <Link href={newReplicaURL}>Deploy a new replica</Link>
292 </ButtonTooltip>
293 {replicas.length > 0 && (
294 <DropdownMenu>
295 <DropdownMenuTrigger asChild>
296 <Button
297 type="default"
298 icon={<ChevronDown size={16} />}
299 className="px-1 rounded-l-none border-l-0"
300 />
301 </DropdownMenuTrigger>
302 <DropdownMenuContent align="end" className="w-52 *:space-x-2">
303 <DropdownMenuItem asChild>
304 <Link href={`/project/${projectRef}/settings/compute-and-disk`}>
305 Resize databases
306 </Link>
307 </DropdownMenuItem>
308 <DropdownMenuSeparator />
309 <DropdownMenuItem onClick={() => setShowDeleteAllModal(true)}>
310 <div>Remove all replicas</div>
311 </DropdownMenuItem>
312 </DropdownMenuContent>
313 </DropdownMenu>
314 )}
315 </div>
316 {isAws && (
317 <div className="flex items-center justify-center">
318 <Button
319 type="default"
320 icon={<Network size={15} />}
321 className={`rounded-r-none transition ${
322 view === 'flow' ? 'opacity-100' : 'opacity-50'
323 }`}
324 onClick={() => setView('flow')}
325 />
326 <Button
327 type="default"
328 icon={<Globe2 size={15} />}
329 className={`rounded-l-none transition ${
330 view === 'map' ? 'opacity-100' : 'opacity-50'
331 }`}
332 onClick={() => setView('map')}
333 />
334 </div>
335 )}
336 </div>
337 )}
338 {view === 'flow' ? (
339 <ReactFlow
340 // FIXME: https://github.com/xyflow/xyflow/issues/4876
341 colorMode={'' as unknown as ColorMode}
342 fitView
343 fitViewOptions={{ minZoom: 0.9, maxZoom: 0.9 }}
344 // Keep the diagram invisible (but laid out, so nodes can be
345 // measured) until the measured-height layout pass has run.
346 className={cn(
347 'instance-configuration transition-opacity duration-150',
348 hasMeasuredLayout ? 'opacity-100' : 'opacity-0'
349 )}
350 zoomOnPinch={false}
351 zoomOnScroll={false}
352 nodesDraggable={false}
353 nodesConnectable={false}
354 zoomOnDoubleClick={false}
355 edgesFocusable={false}
356 edgesReconnectable={false}
357 defaultNodes={[]}
358 defaultEdges={[]}
359 nodeTypes={nodeTypes}
360 edgeTypes={edgeTypes}
361 proOptions={{ hideAttribution: true }}
362 >
363 <Background color={backgroundPatternColor} />
364 </ReactFlow>
365 ) : (
366 <MapView
367 onSelectDeployNewReplica={() => router.push(newReplicaURL)}
368 onSelectRestartReplica={setSelectedReplicaToRestart}
369 onSelectDropReplica={setSelectedReplicaToDrop}
370 />
371 )}
372 </>
373 )}
374 </div>
375
376 {!diagramOnly && (
377 <>
378 <DropReplicaConfirmationModal
379 selectedReplica={selectedReplicaToDrop}
380 onSuccess={() => setRefetchInterval(5000)}
381 onCancel={() => setSelectedReplicaToDrop(undefined)}
382 />
383
384 <DropAllReplicasConfirmationModal
385 visible={showDeleteAllModal}
386 onSuccess={() => setRefetchInterval(5000)}
387 onCancel={() => setShowDeleteAllModal(false)}
388 />
389
390 <RestartReplicaConfirmationModal
391 selectedReplica={selectedReplicaToRestart}
392 onSuccess={() => setRefetchInterval(5000)}
393 onCancel={() => setSelectedReplicaToRestart(undefined)}
394 />
395 </>
396 )}
397 </div>
398 )
399}
400
401interface InstanceConfigurationProps {
402 diagramOnly?: boolean
403}
404
405export const InstanceConfiguration = ({ diagramOnly = false }: InstanceConfigurationProps) => {
406 return (
407 <ReactFlowProvider>
408 <InstanceConfigurationUI diagramOnly={diagramOnly} />
409 </ReactFlowProvider>
410 )
411}