| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- import { Handle, Node, NodeProps, Position } from '@xyflow/react'
- import { useParams } from 'common'
- import dayjs from 'dayjs'
- import { Database, DatabaseBackup, HelpCircle, Loader2, MoreVertical } from 'lucide-react'
- import Link from 'next/link'
- import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
- import { toast } from 'sonner'
- import {
- Badge,
- Button,
- cn,
- copyToClipboard,
- DropdownMenu,
- DropdownMenuContent,
- DropdownMenuItem,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
- Tooltip,
- TooltipContent,
- TooltipTrigger,
- } from 'ui'
- import { TimestampInfo } from 'ui-patterns'
- import {
- ERROR_STATES,
- INIT_PROGRESS,
- LoadBalancerData,
- NODE_SEP,
- NODE_WIDTH,
- PrimaryNodeData,
- REGION_NODE_HEIGHT,
- REPLICA_STATUS,
- ReplicaNodeData,
- } from './InstanceConfiguration.constants'
- import { formatSeconds } from './InstanceConfiguration.utils'
- import { metricColor } from './InstanceNode.utils'
- import SparkBar from '@/components/ui/SparkBar'
- import {
- DatabaseInitEstimations,
- ReplicaInitializationStatus,
- useReadReplicasStatusesQuery,
- } from '@/data/read-replicas/replicas-status-query'
- import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
- import { useComputeMetrics } from '@/hooks/analytics/useComputeMetrics'
- import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
- import { BASE_PATH } from '@/lib/constants'
- import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
- export const LoadBalancerNode = ({ data }: NodeProps<Node<LoadBalancerData>>) => {
- const { ref } = useParams()
- const { numDatabases } = data
- return (
- <>
- <div className="flex flex-col rounded-sm bg-surface-100 border border-default">
- <div
- className="flex items-start justify-between p-3 gap-x-4"
- style={{ width: NODE_WIDTH / 2 - 10 }}
- >
- <div className="flex gap-x-3">
- <div className="min-w-8 h-8 bg-blue-600 border border-blue-800 rounded-md flex items-center justify-center">
- <Database size={16} />
- </div>
- <div className="flex flex-col gap-y-0.5">
- <p className="text-sm">API Load Balancer</p>
- <p className="text-sm text-foreground-light">
- Distributes incoming API requests across{' '}
- <span className="text-foreground">{numDatabases} databases</span>
- </p>
- </div>
- </div>
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button type="text" icon={<MoreVertical />} className="px-1" />
- </DropdownMenuTrigger>
- <DropdownMenuContent className="w-40" side="bottom" align="end">
- <DropdownMenuItem asChild className="gap-x-2">
- <Link href={`/project/${ref}/integrations/data_api/overview?source=load-balancer`}>
- View API URL
- </Link>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- </div>
- <Handle type="source" position={Position.Bottom} style={{ background: 'transparent' }} />
- </>
- )
- }
- export const PrimaryNode = ({ data }: NodeProps<Node<PrimaryNodeData>>) => {
- // [Joshen] Just FYI Handles cannot be conditionally rendered
- const { region, computeSize, numReplicas, numRegions, hasLoadBalancer } = data
- const { ref } = useParams()
- const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
- 'project_homepage:show_instance_size',
- ])
- const {
- cpu,
- disk,
- memory,
- connections,
- isLoading: metricsLoading,
- isError: metricsError,
- } = useComputeMetrics({
- projectRef: ref,
- })
- const observabilityUrl = `/project/${ref}/observability/database`
- return (
- <>
- <Handle
- type="target"
- position={Position.Top}
- className={!hasLoadBalancer ? 'opacity-0' : ''}
- style={{ background: 'transparent' }}
- />
- <div className="flex flex-col rounded-sm bg-surface-100 border border-default">
- <div
- className="flex items-start justify-between p-3"
- style={{ width: NODE_WIDTH / 2 - 10 }}
- >
- <div className="flex gap-x-3">
- <div className="w-8 h-8 bg-brand-500 border border-brand-600 rounded-md flex items-center justify-center">
- <Database size={16} />
- </div>
- <div className="flex flex-col gap-y-0.5">
- <p className="text-sm">Primary Database</p>
- <p className="flex items-center gap-x-1">
- <span className="text-sm text-foreground-light">{region.name}</span>
- </p>
- <p className="flex items-center gap-x-1">
- <Tooltip>
- <TooltipTrigger asChild>
- <span
- className="text-sm transition text-foreground-light hover:text-foreground"
- onClick={async () =>
- await copyToClipboard(region.region, () => toast('Copied project region'))
- }
- >
- {region.region}
- </span>
- </TooltipTrigger>
- <TooltipContent side="bottom">Click to copy</TooltipContent>
- </Tooltip>
- {projectHomepageShowInstanceSize && (
- <>
- <span className="text-sm text-foreground-lighter">·</span>
- <span className="text-sm text-foreground-light">{computeSize}</span>
- </>
- )}
- </p>
- </div>
- </div>
- <img
- alt="region icon"
- className="w-8 rounded-xs mt-0.5"
- src={`${BASE_PATH}/img/regions/${region.region}.svg`}
- />
- </div>
- {numReplicas > 0 && (
- <div className="border-t p-3 py-2">
- <p className="text-sm text-foreground-light">
- <span className="text-foreground">
- {numReplicas} replica{numReplicas > 1 ? 's' : ''}
- </span>{' '}
- deployed across{' '}
- <span className="text-foreground">
- {numRegions} region{numRegions > 1 ? 's' : ''}
- </span>
- </p>
- </div>
- )}
- <Tooltip>
- <TooltipTrigger asChild>
- <Link
- href={observabilityUrl}
- className="border-t px-3 py-2 hover:bg-surface-200 transition flex items-center gap-x-3 text-xs"
- >
- {metricsLoading ? (
- <div className="h-3 w-44 rounded-sm bg-surface-300 animate-pulse" />
- ) : metricsError ? (
- <span className="text-foreground-lighter">Metrics unavailable</span>
- ) : (
- <>
- <span>
- CPU <span className={metricColor(cpu)}>{cpu.toFixed(0)}%</span>
- </span>
- <span className="text-foreground-lighter">·</span>
- <span>
- Disk <span className={metricColor(disk)}>{disk.toFixed(0)}%</span>
- </span>
- <span className="text-foreground-lighter">·</span>
- <span>
- RAM <span className={metricColor(memory)}>{memory.toFixed(0)}%</span>
- </span>
- {connections.max > 0 && (
- <>
- <span className="text-foreground-lighter">·</span>
- <span className="text-foreground-light">
- {connections.current}/{connections.max} conns
- </span>
- </>
- )}
- </>
- )}
- </Link>
- </TooltipTrigger>
- <TooltipContent side="bottom">Go to Database Report</TooltipContent>
- </Tooltip>
- </div>
- <Handle
- type="source"
- position={Position.Bottom}
- className={numReplicas === 0 ? 'opacity-0' : ''}
- style={{ background: 'transparent' }}
- />
- </>
- )
- }
- export const ReplicaNode = ({ data }: NodeProps<Node<ReplicaNodeData>>) => {
- const { ref } = useParams()
- const { id, region, computeSize, status, inserted_at } = data
- const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
- 'project_homepage:show_instance_size',
- ])
- const state = useDatabaseSelectorStateSnapshot()
- const [, setConnect] = useQueryStates({
- showConnect: parseAsBoolean.withDefault(false),
- source: parseAsString,
- })
- const { data: databaseStatuses } = useReadReplicasStatusesQuery({ projectRef: ref })
- const { replicaInitializationStatus } =
- (databaseStatuses ?? []).find((db) => db.identifier === id) || {}
- const {
- status: initStatus,
- progress,
- estimations,
- error,
- } = (replicaInitializationStatus as {
- status?: string
- progress?: string
- estimations?: DatabaseInitEstimations
- error?: string
- }) ?? { status: undefined, progress: undefined, estimations: undefined, error: undefined }
- const created = dayjs(inserted_at).format('DD MMM YYYY')
- const stage = progress !== undefined ? Number(progress.split('_')[0]) : 0
- const stagePercent = stage / (Object.keys(INIT_PROGRESS).length - 1)
- const isInTransition =
- (
- [
- REPLICA_STATUS.UNKNOWN,
- REPLICA_STATUS.COMING_UP,
- REPLICA_STATUS.GOING_DOWN,
- REPLICA_STATUS.RESTORING,
- REPLICA_STATUS.RESTARTING,
- REPLICA_STATUS.RESIZING,
- REPLICA_STATUS.INIT_READ_REPLICA,
- ] as string[]
- ).includes(status) || initStatus === ReplicaInitializationStatus.InProgress
- return (
- <>
- <Handle type="target" position={Position.Top} style={{ background: 'transparent' }} />
- <div
- className="flex justify-between items-start rounded-sm bg-surface-100 border border-default p-3"
- style={{ width: NODE_WIDTH / 2 - 10 }}
- >
- <div className="flex gap-x-3">
- <div
- className={cn(
- 'w-8 h-8 border rounded-md flex items-center justify-center',
- status === REPLICA_STATUS.ACTIVE_HEALTHY &&
- initStatus === ReplicaInitializationStatus.Completed
- ? 'bg-brand-400 border-brand-500'
- : 'bg-surface-100 border-foreground/20'
- )}
- >
- {isInTransition ? (
- <Loader2 className="animate-spin" size={16} />
- ) : (
- <DatabaseBackup size={16} />
- )}
- </div>
- <div className="flex flex-col gap-y-0.5">
- <div className="flex items-center gap-x-2">
- <p className="text-sm truncate">
- Replica {id.length > 0 && `(ID: ${formatDatabaseID(id)})`}
- </p>
- {initStatus === ReplicaInitializationStatus.InProgress ||
- status === REPLICA_STATUS.COMING_UP ||
- status === REPLICA_STATUS.UNKNOWN ||
- status === REPLICA_STATUS.INIT_READ_REPLICA ? (
- <Badge>Coming up</Badge>
- ) : initStatus === ReplicaInitializationStatus.Failed ||
- status === REPLICA_STATUS.INIT_READ_REPLICA_FAILED ? (
- <>
- <Badge variant="destructive">Init failed</Badge>
- <Tooltip>
- <TooltipTrigger>
- <HelpCircle size={16} />
- </TooltipTrigger>
- <TooltipContent
- side="bottom"
- align="end"
- alignOffset={-70}
- className="w-60 text-center"
- >
- Replica failed to initialize. Please drop this replica and spin up a new one.
- </TooltipContent>
- </Tooltip>
- </>
- ) : status === REPLICA_STATUS.GOING_DOWN ? (
- <Badge>Going down</Badge>
- ) : status === REPLICA_STATUS.RESTARTING ? (
- <Badge>Restarting</Badge>
- ) : status === REPLICA_STATUS.RESIZING ? (
- <Badge>Resizing</Badge>
- ) : status === REPLICA_STATUS.ACTIVE_HEALTHY ? (
- <Badge variant="success">Healthy</Badge>
- ) : (
- <Badge variant="warning">Unhealthy</Badge>
- )}
- </div>
- <div className="my-0.5">
- <p className="text-sm text-foreground-light">{region.name}</p>
- <p className="flex text-sm text-foreground-light items-center gap-x-1">
- <Tooltip>
- <TooltipTrigger asChild>
- <span
- className="text-sm transition text-foreground-light hover:text-foreground"
- onClick={async () =>
- await copyToClipboard(region.region, () => toast('Copied replica region'))
- }
- >
- {region.region}
- </span>
- </TooltipTrigger>
- <TooltipContent side="bottom">Click to copy</TooltipContent>
- </Tooltip>
- {projectHomepageShowInstanceSize && !!computeSize && (
- <>
- <span className="text-foreground-lighter">·</span>
- <span>{computeSize}</span>
- </>
- )}
- </p>
- </div>
- {initStatus === ReplicaInitializationStatus.InProgress && progress !== undefined ? (
- <Tooltip>
- <TooltipTrigger asChild>
- <div className="w-56">
- <SparkBar
- labelBottom={INIT_PROGRESS[progress as keyof typeof INIT_PROGRESS]}
- labelBottomClass="text-xs normal-nums! text-foreground-light"
- type="horizontal"
- value={stagePercent * 100}
- max={100}
- barClass="bg-brand"
- />
- </div>
- </TooltipTrigger>
- {estimations !== undefined && (
- <TooltipContent asChild side="bottom">
- <div className="w-56">
- <p className="text-foreground-light mb-0.5">Duration estimates:</p>
- {estimations.baseBackupDownloadEstimateSeconds !== undefined && (
- <p>
- Base backup download:{' '}
- {formatSeconds(estimations.baseBackupDownloadEstimateSeconds)}
- </p>
- )}
- {estimations.walArchiveReplayEstimateSeconds !== undefined && (
- <p>
- WAL archive replay:{' '}
- {formatSeconds(estimations.walArchiveReplayEstimateSeconds)}
- </p>
- )}
- </div>
- </TooltipContent>
- )}
- </Tooltip>
- ) : error !== undefined ? (
- <p className="text-sm text-foreground-light">
- Error: {ERROR_STATES[error as keyof typeof ERROR_STATES]}
- </p>
- ) : (
- <p className="text-sm text-foreground-light">
- Created:{' '}
- <TimestampInfo className="text-sm" utcTimestamp={inserted_at} label={created} />
- </p>
- )}
- </div>
- </div>
- <DropdownMenu>
- <DropdownMenuTrigger asChild>
- <Button type="text" icon={<MoreVertical />} className="px-1" />
- </DropdownMenuTrigger>
- <DropdownMenuContent className="w-40" side="bottom" align="end">
- <DropdownMenuItem
- className="gap-x-2"
- onClick={() => {
- setConnect({ showConnect: true, source: id })
- state.setSelectedDatabaseId(id)
- }}
- >
- View connection string
- </DropdownMenuItem>
- <DropdownMenuSeparator />
- <DropdownMenuItem className="gap-x-2">
- <Link href={`/project/${ref}/database/replication/replica/${id}`}>
- Manage replica
- </Link>
- </DropdownMenuItem>
- </DropdownMenuContent>
- </DropdownMenu>
- </div>
- </>
- )
- }
- export const RegionNode = ({ data }: any) => {
- const { region, numReplicas } = data
- const regionNodeWidth =
- 20 + (NODE_WIDTH / 2 - 10) * numReplicas + (numReplicas - 1) * (NODE_SEP + 10)
- return (
- <div
- className="relative flex justify-between rounded-sm bg-black/10 border border-default border-white/10 border-2 p-3"
- style={{ width: regionNodeWidth, height: REGION_NODE_HEIGHT }}
- >
- <div className="absolute bottom-2 flex items-center justify-between gap-x-2">
- <img
- alt="region icon"
- className="w-5 rounded-xs"
- src={`${BASE_PATH}/img/regions/${region.region}.svg`}
- />
- <p className="text-sm">{region.name}</p>
- </div>
- </div>
- )
- }
|