MapView.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { useParams } from 'common'
  3. import dayjs from 'dayjs'
  4. import { partition, uniqBy } from 'lodash'
  5. import { MoreVertical } from 'lucide-react'
  6. import Link from 'next/link'
  7. import { parseAsBoolean, useQueryState } from 'nuqs'
  8. import { useEffect, useState } from 'react'
  9. import {
  10. ComposableMap,
  11. Geographies,
  12. Geography,
  13. Line,
  14. Marker,
  15. ZoomableGroup,
  16. } from 'react-simple-maps'
  17. import type { AWS_REGIONS_KEYS } from 'shared-data'
  18. import {
  19. Badge,
  20. Button,
  21. DropdownMenu,
  22. DropdownMenuContent,
  23. DropdownMenuItem,
  24. DropdownMenuSeparator,
  25. DropdownMenuTrigger,
  26. ScrollArea,
  27. } from 'ui'
  28. import { AVAILABLE_REPLICA_REGIONS, REPLICA_STATUS } from './InstanceConfiguration.constants'
  29. import GeographyData from './MapData.json'
  30. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  31. import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
  32. import { Database, useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
  33. import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
  34. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  35. import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
  36. import { BASE_PATH } from '@/lib/constants'
  37. import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
  38. // [Joshen] Foresee that we'll skip this view for initial launch
  39. interface MapViewProps {
  40. onSelectDeployNewReplica: (region: AWS_REGIONS_KEYS) => void
  41. onSelectRestartReplica: (database: Database) => void
  42. onSelectDropReplica: (database: Database) => void
  43. }
  44. const MapView = ({
  45. onSelectDeployNewReplica,
  46. onSelectRestartReplica,
  47. onSelectDropReplica,
  48. }: MapViewProps) => {
  49. const { ref } = useParams()
  50. const dbSelectorState = useDatabaseSelectorStateSnapshot()
  51. const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
  52. 'project_homepage:show_instance_size',
  53. ])
  54. const [mount, setMount] = useState(false)
  55. const [zoom, setZoom] = useState<number>(1.5)
  56. const [center, setCenter] = useState<[number, number]>([14, 7])
  57. const [tooltip, setTooltip] = useState<{
  58. x: number
  59. y: number
  60. region: { key: string; country?: string; name?: string; region?: string }
  61. }>()
  62. const { can: canManageReplicas } = useAsyncCheckPermissions(PermissionAction.CREATE, 'projects')
  63. const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
  64. const { data } = useReadReplicasQuery({ projectRef: ref })
  65. const databases = data ?? []
  66. const [[primary], replicas] = partition(databases, (db) => db.identifier === ref)
  67. const primaryCoordinates = AVAILABLE_REPLICA_REGIONS.find((region) =>
  68. primary.region.includes(region.region)
  69. )?.coordinates ?? [0, 0]
  70. const uniqueRegionsByReplicas = uniqBy(replicas, (r) => {
  71. return AVAILABLE_REPLICA_REGIONS.find((region) => r.region.includes(region.region))?.key
  72. })
  73. const selectedRegionKey =
  74. AVAILABLE_REPLICA_REGIONS.find((region) => region.coordinates === center)?.region ?? ''
  75. const showRegionDetails = zoom === 2.0 && selectedRegionKey !== undefined
  76. const selectedRegion = AVAILABLE_REPLICA_REGIONS.find(
  77. (region) => region.region === selectedRegionKey
  78. )
  79. const databasesInSelectedRegion = databases
  80. .filter((database) => database.region.includes(selectedRegionKey))
  81. .sort((a, b) => (a.inserted_at > b.inserted_at ? 1 : 0))
  82. .sort((database) => (database.identifier === ref ? -1 : 0))
  83. useEffect(() => {
  84. setTimeout(() => setMount(true), 100)
  85. }, [])
  86. return (
  87. <div className="bg-studio h-[500px] relative">
  88. <ComposableMap projectionConfig={{ scale: 155 }} className="w-full h-full">
  89. <ZoomableGroup
  90. className={mount ? 'transition-all duration-300' : ''}
  91. center={center}
  92. zoom={zoom}
  93. minZoom={1.5}
  94. maxZoom={2.0}
  95. filterZoomEvent={({ constructor: { name } }) =>
  96. !['MouseEvent', 'WheelEvent'].includes(name)
  97. }
  98. >
  99. <Geographies geography={GeographyData}>
  100. {({ geographies }) =>
  101. geographies.map((geo) => (
  102. <Geography
  103. key={geo.rsmKey}
  104. geography={geo}
  105. strokeWidth={0.3}
  106. pointerEvents="none"
  107. className="fill-gray-800 stroke-gray-900 dark:fill-gray-300 dark:stroke-gray-200"
  108. />
  109. ))
  110. }
  111. </Geographies>
  112. {uniqueRegionsByReplicas.map((database) => {
  113. const coordinates = AVAILABLE_REPLICA_REGIONS.find((region) =>
  114. database.region.includes(region.region)
  115. )?.coordinates
  116. if (coordinates !== primaryCoordinates) {
  117. return (
  118. <Line
  119. key={`line-${database.identifier}-${primary.identifier}`}
  120. from={coordinates}
  121. to={primaryCoordinates}
  122. stroke="white"
  123. strokeWidth={1}
  124. strokeLinecap="round"
  125. strokeOpacity={0.2}
  126. strokeDasharray={'3, 3'}
  127. className="map-path"
  128. />
  129. )
  130. } else {
  131. return null
  132. }
  133. })}
  134. {AVAILABLE_REPLICA_REGIONS.map((region) => {
  135. const dbs =
  136. databases.filter((database) => database.region.includes(region.region)) ?? []
  137. const coordinates = AVAILABLE_REPLICA_REGIONS.find(
  138. (r) => r.region === region.region
  139. )?.coordinates
  140. const hasNoDatabases = dbs.length === 0
  141. const hasPrimary = dbs.some((database) => database.identifier === ref)
  142. const replicas = dbs.filter((database) => database.identifier !== ref) ?? []
  143. return (
  144. <Marker
  145. key={region.key}
  146. coordinates={coordinates}
  147. onMouseEnter={() => {
  148. setTooltip({
  149. x: coordinates![0],
  150. y: coordinates![1],
  151. region: {
  152. key: region.key,
  153. country: region.name,
  154. region: region.region,
  155. name: hasNoDatabases
  156. ? undefined
  157. : hasPrimary
  158. ? `Primary Database${
  159. replicas.length > 0
  160. ? ` + ${replicas.length} replica${replicas.length > 1 ? 's' : ''} `
  161. : ''
  162. }`
  163. : `${replicas.length} Read Replica${
  164. replicas.length > 1 ? 's' : ''
  165. } deployed`,
  166. },
  167. })
  168. }}
  169. onMouseLeave={() => setTooltip(undefined)}
  170. onClick={() => {
  171. if (coordinates) {
  172. setCenter(coordinates)
  173. setZoom(2.0)
  174. }
  175. }}
  176. >
  177. {selectedRegionKey === region.region && (
  178. <circle
  179. r={4}
  180. className={`animate-ping ${
  181. hasNoDatabases ? 'fill-border-stronger' : 'fill-brand'
  182. }`}
  183. />
  184. )}
  185. <circle
  186. r={4}
  187. className={`cursor-pointer ${
  188. hasNoDatabases
  189. ? 'fill-background-surface-300 stroke-border-stronger'
  190. : hasPrimary
  191. ? 'fill-brand stroke-brand-500'
  192. : 'fill-brand-500 stroke-brand-400'
  193. }`}
  194. />
  195. </Marker>
  196. )
  197. })}
  198. {tooltip !== undefined && zoom === 1.5 && (
  199. <Marker coordinates={[tooltip.x - 47, tooltip.y - 5]}>
  200. <foreignObject width={220} height={66.25}>
  201. <div className="bg-studio/50 rounded-sm border">
  202. <div className="px-3 py-2 flex flex-col">
  203. <div className="flex items-center gap-x-2">
  204. <img
  205. alt="region icon"
  206. className="w-4 rounded-xs"
  207. src={`${BASE_PATH}/img/regions/${tooltip.region.region}.svg`}
  208. />
  209. <p className="text-[10px]">{tooltip.region.country}</p>
  210. </div>
  211. <p
  212. className={`text-[10px] ${
  213. tooltip.region.name === undefined ? 'text-foreground-light' : ''
  214. }`}
  215. >
  216. {tooltip.region.name ?? 'No databases deployed'}
  217. </p>
  218. </div>
  219. </div>
  220. </foreignObject>
  221. </Marker>
  222. )}
  223. </ZoomableGroup>
  224. </ComposableMap>
  225. {showRegionDetails && selectedRegion && (
  226. <div className="absolute bottom-4 right-4 flex flex-col bg-studio/50 backdrop-blur-xs border rounded-sm w-[400px]">
  227. <div className="flex items-center justify-between py-4 px-4 border-b">
  228. <div>
  229. <p className="text-xs text-foreground-light">
  230. {databasesInSelectedRegion.length} database
  231. {databasesInSelectedRegion.length > 1 ? 's' : ''} deployed in
  232. </p>
  233. <p className="text-sm">{selectedRegion.name}</p>
  234. </div>
  235. <img
  236. alt="region icon"
  237. className="w-10 rounded-xs"
  238. src={`${BASE_PATH}/img/regions/${selectedRegion.region}.svg`}
  239. />
  240. </div>
  241. {databasesInSelectedRegion.length > 0 && (
  242. <ScrollArea style={{ height: databasesInSelectedRegion.length > 2 ? '180px' : 'auto' }}>
  243. <ul className={`flex flex-col divide-y`}>
  244. {databasesInSelectedRegion.map((database) => {
  245. const created = dayjs(database.inserted_at).format('DD MMM YYYY, HH:mm:ss (ZZ)')
  246. return (
  247. <li
  248. key={database.identifier}
  249. className="text-sm px-4 py-2 flex items-center justify-between"
  250. >
  251. <div className="flex flex-col gap-y-1">
  252. <p className="flex items-center gap-x-2">
  253. {database.identifier === ref
  254. ? 'Primary Database'
  255. : `Read Replica ${
  256. database.identifier.length > 0 &&
  257. `(ID: ${formatDatabaseID(database.identifier)})`
  258. }`}
  259. {database.status === REPLICA_STATUS.ACTIVE_HEALTHY ? (
  260. <Badge variant="success">Healthy</Badge>
  261. ) : database.status === REPLICA_STATUS.COMING_UP ? (
  262. <Badge>Coming up</Badge>
  263. ) : database.status === REPLICA_STATUS.RESTARTING ? (
  264. <Badge>Restarting</Badge>
  265. ) : database.status === REPLICA_STATUS.RESIZING ? (
  266. <Badge>Resizing</Badge>
  267. ) : (
  268. <Badge variant="warning">Unhealthy</Badge>
  269. )}
  270. </p>
  271. <p className="text-xs text-foreground-light">
  272. AWS{projectHomepageShowInstanceSize ? ` • ${database.size}` : ''}
  273. </p>
  274. {database.identifier !== ref && (
  275. <p className="text-xs text-foreground-light">Created on: {created}</p>
  276. )}
  277. </div>
  278. {database.identifier !== ref && (
  279. <DropdownMenu>
  280. <DropdownMenuTrigger asChild>
  281. <Button type="text" icon={<MoreVertical />} className="px-1" />
  282. </DropdownMenuTrigger>
  283. <DropdownMenuContent className="w-40" side="bottom" align="end">
  284. <DropdownMenuItem
  285. className="gap-x-2"
  286. disabled={database.status !== REPLICA_STATUS.ACTIVE_HEALTHY}
  287. onClick={() => {
  288. setShowConnect(true)
  289. dbSelectorState.setSelectedDatabaseId(database.identifier)
  290. }}
  291. >
  292. View connection string
  293. </DropdownMenuItem>
  294. <DropdownMenuItem
  295. className="gap-x-2"
  296. disabled={database.status !== REPLICA_STATUS.ACTIVE_HEALTHY}
  297. >
  298. <Link
  299. href={`/project/${ref}/observability/database?db=${database.identifier}&chart=replication-lag`}
  300. >
  301. View replication lag
  302. </Link>
  303. </DropdownMenuItem>
  304. <DropdownMenuSeparator />
  305. <DropdownMenuItem
  306. className="gap-x-2"
  307. onClick={() => onSelectRestartReplica(database)}
  308. disabled={database.status !== REPLICA_STATUS.ACTIVE_HEALTHY}
  309. >
  310. Restart replica
  311. </DropdownMenuItem>
  312. <DropdownMenuItemTooltip
  313. className="gap-x-2 pointer-events-auto!"
  314. disabled={!canManageReplicas}
  315. onClick={() => onSelectDropReplica(database)}
  316. tooltip={{
  317. content: {
  318. side: 'left',
  319. text: 'You need additional permissions to drop replicas',
  320. },
  321. }}
  322. >
  323. Drop replica
  324. </DropdownMenuItemTooltip>
  325. </DropdownMenuContent>
  326. </DropdownMenu>
  327. )}
  328. </li>
  329. )
  330. })}
  331. </ul>
  332. </ScrollArea>
  333. )}
  334. <div
  335. className={`flex items-center justify-end gap-x-2 px-4 py-4 ${
  336. databasesInSelectedRegion.length > 0 ? 'border-t' : ''
  337. }`}
  338. >
  339. <ButtonTooltip
  340. type="default"
  341. disabled={!canManageReplicas}
  342. onClick={() => onSelectDeployNewReplica(selectedRegion.key)}
  343. tooltip={{
  344. content: {
  345. side: 'bottom',
  346. text: !canManageReplicas
  347. ? 'You need additional permissions to deploy replicas'
  348. : undefined,
  349. },
  350. }}
  351. >
  352. Deploy new replica here
  353. </ButtonTooltip>
  354. <Button
  355. type="default"
  356. onClick={() => {
  357. setCenter([14, 7])
  358. setZoom(1.5)
  359. }}
  360. >
  361. Close
  362. </Button>
  363. </div>
  364. </div>
  365. )}
  366. </div>
  367. )
  368. }
  369. export default MapView