InstanceConfiguration.tsx 15 KB

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