InstanceNode.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. import { Handle, Node, NodeProps, Position } from '@xyflow/react'
  2. import { useParams } from 'common'
  3. import dayjs from 'dayjs'
  4. import { Database, DatabaseBackup, HelpCircle, Loader2, MoreVertical } from 'lucide-react'
  5. import Link from 'next/link'
  6. import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
  7. import { toast } from 'sonner'
  8. import {
  9. Badge,
  10. Button,
  11. cn,
  12. copyToClipboard,
  13. DropdownMenu,
  14. DropdownMenuContent,
  15. DropdownMenuItem,
  16. DropdownMenuSeparator,
  17. DropdownMenuTrigger,
  18. Tooltip,
  19. TooltipContent,
  20. TooltipTrigger,
  21. } from 'ui'
  22. import { TimestampInfo } from 'ui-patterns'
  23. import {
  24. ERROR_STATES,
  25. INIT_PROGRESS,
  26. LoadBalancerData,
  27. NODE_SEP,
  28. NODE_WIDTH,
  29. PrimaryNodeData,
  30. REGION_NODE_HEIGHT,
  31. REPLICA_STATUS,
  32. ReplicaNodeData,
  33. } from './InstanceConfiguration.constants'
  34. import { formatSeconds } from './InstanceConfiguration.utils'
  35. import { metricColor } from './InstanceNode.utils'
  36. import SparkBar from '@/components/ui/SparkBar'
  37. import {
  38. DatabaseInitEstimations,
  39. ReplicaInitializationStatus,
  40. useReadReplicasStatusesQuery,
  41. } from '@/data/read-replicas/replicas-status-query'
  42. import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
  43. import { useComputeMetrics } from '@/hooks/analytics/useComputeMetrics'
  44. import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
  45. import { BASE_PATH } from '@/lib/constants'
  46. import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
  47. export const LoadBalancerNode = ({ data }: NodeProps<Node<LoadBalancerData>>) => {
  48. const { ref } = useParams()
  49. const { numDatabases } = data
  50. return (
  51. <>
  52. <div className="flex flex-col rounded-sm bg-surface-100 border border-default">
  53. <div
  54. className="flex items-start justify-between p-3 gap-x-4"
  55. style={{ width: NODE_WIDTH / 2 - 10 }}
  56. >
  57. <div className="flex gap-x-3">
  58. <div className="min-w-8 h-8 bg-blue-600 border border-blue-800 rounded-md flex items-center justify-center">
  59. <Database size={16} />
  60. </div>
  61. <div className="flex flex-col gap-y-0.5">
  62. <p className="text-sm">API Load Balancer</p>
  63. <p className="text-sm text-foreground-light">
  64. Distributes incoming API requests across{' '}
  65. <span className="text-foreground">{numDatabases} databases</span>
  66. </p>
  67. </div>
  68. </div>
  69. <DropdownMenu>
  70. <DropdownMenuTrigger asChild>
  71. <Button type="text" icon={<MoreVertical />} className="px-1" />
  72. </DropdownMenuTrigger>
  73. <DropdownMenuContent className="w-40" side="bottom" align="end">
  74. <DropdownMenuItem asChild className="gap-x-2">
  75. <Link href={`/project/${ref}/integrations/data_api/overview?source=load-balancer`}>
  76. View API URL
  77. </Link>
  78. </DropdownMenuItem>
  79. </DropdownMenuContent>
  80. </DropdownMenu>
  81. </div>
  82. </div>
  83. <Handle type="source" position={Position.Bottom} style={{ background: 'transparent' }} />
  84. </>
  85. )
  86. }
  87. export const PrimaryNode = ({ data }: NodeProps<Node<PrimaryNodeData>>) => {
  88. // [Joshen] Just FYI Handles cannot be conditionally rendered
  89. const { region, computeSize, numReplicas, numRegions, hasLoadBalancer } = data
  90. const { ref } = useParams()
  91. const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
  92. 'project_homepage:show_instance_size',
  93. ])
  94. const {
  95. cpu,
  96. disk,
  97. memory,
  98. connections,
  99. isLoading: metricsLoading,
  100. isError: metricsError,
  101. } = useComputeMetrics({
  102. projectRef: ref,
  103. })
  104. const observabilityUrl = `/project/${ref}/observability/database`
  105. return (
  106. <>
  107. <Handle
  108. type="target"
  109. position={Position.Top}
  110. className={!hasLoadBalancer ? 'opacity-0' : ''}
  111. style={{ background: 'transparent' }}
  112. />
  113. <div className="flex flex-col rounded-sm bg-surface-100 border border-default">
  114. <div
  115. className="flex items-start justify-between p-3"
  116. style={{ width: NODE_WIDTH / 2 - 10 }}
  117. >
  118. <div className="flex gap-x-3">
  119. <div className="w-8 h-8 bg-brand-500 border border-brand-600 rounded-md flex items-center justify-center">
  120. <Database size={16} />
  121. </div>
  122. <div className="flex flex-col gap-y-0.5">
  123. <p className="text-sm">Primary Database</p>
  124. <p className="flex items-center gap-x-1">
  125. <span className="text-sm text-foreground-light">{region.name}</span>
  126. </p>
  127. <p className="flex items-center gap-x-1">
  128. <Tooltip>
  129. <TooltipTrigger asChild>
  130. <span
  131. className="text-sm transition text-foreground-light hover:text-foreground"
  132. onClick={async () =>
  133. await copyToClipboard(region.region, () => toast('Copied project region'))
  134. }
  135. >
  136. {region.region}
  137. </span>
  138. </TooltipTrigger>
  139. <TooltipContent side="bottom">Click to copy</TooltipContent>
  140. </Tooltip>
  141. {projectHomepageShowInstanceSize && (
  142. <>
  143. <span className="text-sm text-foreground-lighter">·</span>
  144. <span className="text-sm text-foreground-light">{computeSize}</span>
  145. </>
  146. )}
  147. </p>
  148. </div>
  149. </div>
  150. <img
  151. alt="region icon"
  152. className="w-8 rounded-xs mt-0.5"
  153. src={`${BASE_PATH}/img/regions/${region.region}.svg`}
  154. />
  155. </div>
  156. {numReplicas > 0 && (
  157. <div className="border-t p-3 py-2">
  158. <p className="text-sm text-foreground-light">
  159. <span className="text-foreground">
  160. {numReplicas} replica{numReplicas > 1 ? 's' : ''}
  161. </span>{' '}
  162. deployed across{' '}
  163. <span className="text-foreground">
  164. {numRegions} region{numRegions > 1 ? 's' : ''}
  165. </span>
  166. </p>
  167. </div>
  168. )}
  169. <Tooltip>
  170. <TooltipTrigger asChild>
  171. <Link
  172. href={observabilityUrl}
  173. className="border-t px-3 py-2 hover:bg-surface-200 transition flex items-center gap-x-3 text-xs"
  174. >
  175. {metricsLoading ? (
  176. <div className="h-3 w-44 rounded-sm bg-surface-300 animate-pulse" />
  177. ) : metricsError ? (
  178. <span className="text-foreground-lighter">Metrics unavailable</span>
  179. ) : (
  180. <>
  181. <span>
  182. CPU <span className={metricColor(cpu)}>{cpu.toFixed(0)}%</span>
  183. </span>
  184. <span className="text-foreground-lighter">·</span>
  185. <span>
  186. Disk <span className={metricColor(disk)}>{disk.toFixed(0)}%</span>
  187. </span>
  188. <span className="text-foreground-lighter">·</span>
  189. <span>
  190. RAM <span className={metricColor(memory)}>{memory.toFixed(0)}%</span>
  191. </span>
  192. {connections.max > 0 && (
  193. <>
  194. <span className="text-foreground-lighter">·</span>
  195. <span className="text-foreground-light">
  196. {connections.current}/{connections.max} conns
  197. </span>
  198. </>
  199. )}
  200. </>
  201. )}
  202. </Link>
  203. </TooltipTrigger>
  204. <TooltipContent side="bottom">Go to Database Report</TooltipContent>
  205. </Tooltip>
  206. </div>
  207. <Handle
  208. type="source"
  209. position={Position.Bottom}
  210. className={numReplicas === 0 ? 'opacity-0' : ''}
  211. style={{ background: 'transparent' }}
  212. />
  213. </>
  214. )
  215. }
  216. export const ReplicaNode = ({ data }: NodeProps<Node<ReplicaNodeData>>) => {
  217. const { ref } = useParams()
  218. const { id, region, computeSize, status, inserted_at } = data
  219. const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
  220. 'project_homepage:show_instance_size',
  221. ])
  222. const state = useDatabaseSelectorStateSnapshot()
  223. const [, setConnect] = useQueryStates({
  224. showConnect: parseAsBoolean.withDefault(false),
  225. source: parseAsString,
  226. })
  227. const { data: databaseStatuses } = useReadReplicasStatusesQuery({ projectRef: ref })
  228. const { replicaInitializationStatus } =
  229. (databaseStatuses ?? []).find((db) => db.identifier === id) || {}
  230. const {
  231. status: initStatus,
  232. progress,
  233. estimations,
  234. error,
  235. } = (replicaInitializationStatus as {
  236. status?: string
  237. progress?: string
  238. estimations?: DatabaseInitEstimations
  239. error?: string
  240. }) ?? { status: undefined, progress: undefined, estimations: undefined, error: undefined }
  241. const created = dayjs(inserted_at).format('DD MMM YYYY')
  242. const stage = progress !== undefined ? Number(progress.split('_')[0]) : 0
  243. const stagePercent = stage / (Object.keys(INIT_PROGRESS).length - 1)
  244. const isInTransition =
  245. (
  246. [
  247. REPLICA_STATUS.UNKNOWN,
  248. REPLICA_STATUS.COMING_UP,
  249. REPLICA_STATUS.GOING_DOWN,
  250. REPLICA_STATUS.RESTORING,
  251. REPLICA_STATUS.RESTARTING,
  252. REPLICA_STATUS.RESIZING,
  253. REPLICA_STATUS.INIT_READ_REPLICA,
  254. ] as string[]
  255. ).includes(status) || initStatus === ReplicaInitializationStatus.InProgress
  256. return (
  257. <>
  258. <Handle type="target" position={Position.Top} style={{ background: 'transparent' }} />
  259. <div
  260. className="flex justify-between items-start rounded-sm bg-surface-100 border border-default p-3"
  261. style={{ width: NODE_WIDTH / 2 - 10 }}
  262. >
  263. <div className="flex gap-x-3">
  264. <div
  265. className={cn(
  266. 'w-8 h-8 border rounded-md flex items-center justify-center',
  267. status === REPLICA_STATUS.ACTIVE_HEALTHY &&
  268. initStatus === ReplicaInitializationStatus.Completed
  269. ? 'bg-brand-400 border-brand-500'
  270. : 'bg-surface-100 border-foreground/20'
  271. )}
  272. >
  273. {isInTransition ? (
  274. <Loader2 className="animate-spin" size={16} />
  275. ) : (
  276. <DatabaseBackup size={16} />
  277. )}
  278. </div>
  279. <div className="flex flex-col gap-y-0.5">
  280. <div className="flex items-center gap-x-2">
  281. <p className="text-sm truncate">
  282. Replica {id.length > 0 && `(ID: ${formatDatabaseID(id)})`}
  283. </p>
  284. {initStatus === ReplicaInitializationStatus.InProgress ||
  285. status === REPLICA_STATUS.COMING_UP ||
  286. status === REPLICA_STATUS.UNKNOWN ||
  287. status === REPLICA_STATUS.INIT_READ_REPLICA ? (
  288. <Badge>Coming up</Badge>
  289. ) : initStatus === ReplicaInitializationStatus.Failed ||
  290. status === REPLICA_STATUS.INIT_READ_REPLICA_FAILED ? (
  291. <>
  292. <Badge variant="destructive">Init failed</Badge>
  293. <Tooltip>
  294. <TooltipTrigger>
  295. <HelpCircle size={16} />
  296. </TooltipTrigger>
  297. <TooltipContent
  298. side="bottom"
  299. align="end"
  300. alignOffset={-70}
  301. className="w-60 text-center"
  302. >
  303. Replica failed to initialize. Please drop this replica and spin up a new one.
  304. </TooltipContent>
  305. </Tooltip>
  306. </>
  307. ) : status === REPLICA_STATUS.GOING_DOWN ? (
  308. <Badge>Going down</Badge>
  309. ) : status === REPLICA_STATUS.RESTARTING ? (
  310. <Badge>Restarting</Badge>
  311. ) : status === REPLICA_STATUS.RESIZING ? (
  312. <Badge>Resizing</Badge>
  313. ) : status === REPLICA_STATUS.ACTIVE_HEALTHY ? (
  314. <Badge variant="success">Healthy</Badge>
  315. ) : (
  316. <Badge variant="warning">Unhealthy</Badge>
  317. )}
  318. </div>
  319. <div className="my-0.5">
  320. <p className="text-sm text-foreground-light">{region.name}</p>
  321. <p className="flex text-sm text-foreground-light items-center gap-x-1">
  322. <Tooltip>
  323. <TooltipTrigger asChild>
  324. <span
  325. className="text-sm transition text-foreground-light hover:text-foreground"
  326. onClick={async () =>
  327. await copyToClipboard(region.region, () => toast('Copied replica region'))
  328. }
  329. >
  330. {region.region}
  331. </span>
  332. </TooltipTrigger>
  333. <TooltipContent side="bottom">Click to copy</TooltipContent>
  334. </Tooltip>
  335. {projectHomepageShowInstanceSize && !!computeSize && (
  336. <>
  337. <span className="text-foreground-lighter">·</span>
  338. <span>{computeSize}</span>
  339. </>
  340. )}
  341. </p>
  342. </div>
  343. {initStatus === ReplicaInitializationStatus.InProgress && progress !== undefined ? (
  344. <Tooltip>
  345. <TooltipTrigger asChild>
  346. <div className="w-56">
  347. <SparkBar
  348. labelBottom={INIT_PROGRESS[progress as keyof typeof INIT_PROGRESS]}
  349. labelBottomClass="text-xs normal-nums! text-foreground-light"
  350. type="horizontal"
  351. value={stagePercent * 100}
  352. max={100}
  353. barClass="bg-brand"
  354. />
  355. </div>
  356. </TooltipTrigger>
  357. {estimations !== undefined && (
  358. <TooltipContent asChild side="bottom">
  359. <div className="w-56">
  360. <p className="text-foreground-light mb-0.5">Duration estimates:</p>
  361. {estimations.baseBackupDownloadEstimateSeconds !== undefined && (
  362. <p>
  363. Base backup download:{' '}
  364. {formatSeconds(estimations.baseBackupDownloadEstimateSeconds)}
  365. </p>
  366. )}
  367. {estimations.walArchiveReplayEstimateSeconds !== undefined && (
  368. <p>
  369. WAL archive replay:{' '}
  370. {formatSeconds(estimations.walArchiveReplayEstimateSeconds)}
  371. </p>
  372. )}
  373. </div>
  374. </TooltipContent>
  375. )}
  376. </Tooltip>
  377. ) : error !== undefined ? (
  378. <p className="text-sm text-foreground-light">
  379. Error: {ERROR_STATES[error as keyof typeof ERROR_STATES]}
  380. </p>
  381. ) : (
  382. <p className="text-sm text-foreground-light">
  383. Created:{' '}
  384. <TimestampInfo className="text-sm" utcTimestamp={inserted_at} label={created} />
  385. </p>
  386. )}
  387. </div>
  388. </div>
  389. <DropdownMenu>
  390. <DropdownMenuTrigger asChild>
  391. <Button type="text" icon={<MoreVertical />} className="px-1" />
  392. </DropdownMenuTrigger>
  393. <DropdownMenuContent className="w-40" side="bottom" align="end">
  394. <DropdownMenuItem
  395. className="gap-x-2"
  396. onClick={() => {
  397. setConnect({ showConnect: true, source: id })
  398. state.setSelectedDatabaseId(id)
  399. }}
  400. >
  401. View connection string
  402. </DropdownMenuItem>
  403. <DropdownMenuSeparator />
  404. <DropdownMenuItem className="gap-x-2">
  405. <Link href={`/project/${ref}/database/replication/replica/${id}`}>
  406. Manage replica
  407. </Link>
  408. </DropdownMenuItem>
  409. </DropdownMenuContent>
  410. </DropdownMenu>
  411. </div>
  412. </>
  413. )
  414. }
  415. export const RegionNode = ({ data }: any) => {
  416. const { region, numReplicas } = data
  417. const regionNodeWidth =
  418. 20 + (NODE_WIDTH / 2 - 10) * numReplicas + (numReplicas - 1) * (NODE_SEP + 10)
  419. return (
  420. <div
  421. className="relative flex justify-between rounded-sm bg-black/10 border border-default border-white/10 border-2 p-3"
  422. style={{ width: regionNodeWidth, height: REGION_NODE_HEIGHT }}
  423. >
  424. <div className="absolute bottom-2 flex items-center justify-between gap-x-2">
  425. <img
  426. alt="region icon"
  427. className="w-5 rounded-xs"
  428. src={`${BASE_PATH}/img/regions/${region.region}.svg`}
  429. />
  430. <p className="text-sm">{region.name}</p>
  431. </div>
  432. </div>
  433. )
  434. }