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