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