mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
## Problem Top section map view on project overview wasn't very usable on smaller window sizes. No ability to drag and see any instances on west coast without stretching viewport or it being cut off. ## Solution Adds dragging to map area so you can bring into view locations out of the bounding box. ## Review instructions 1. Switch to map view and **drag the map** — it should pan, and the previously cut-off regions should be reachable. 2. The drag should **track the cursor 1:1**, with no rubber-band lag. (The 300ms transform transition is what caused that; it's now suppressed mid-drag.) 3. Drag hard toward each edge — the map should **stop at the frame bounds** rather than sliding off into empty background. 4. Cursor should read `grab`, and `grabbing` while dragging. ### Regressions to rule out 5. **Wheel/trackpad scroll over the map** must not zoom it, and the page behind it must still scroll normally. Trackpad pinch must not zoom either. 6. **Double-click** on the map must not zoom. 7. **Click a region marker** — still recenters, zooms to 2.0, and opens the region detail panel bottom-right. The recenter should still animate smoothly. 8. **Close** in that panel still resets to the default center and zoom. 9. **Hover a marker** at default zoom — the country/database tooltip still appears; the dashed lines between primary and replicas still render. 10. Toggle back to **flow view** — unchanged. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Map view now supports dragging to pan, with cursor changes to indicate when panning is available or in progress. * **Bug Fixes** * Map zoom input is filtered based on event type, modifier keys, and mouse-button state. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
354 lines
14 KiB
TypeScript
354 lines
14 KiB
TypeScript
import { useParams } from 'common'
|
|
import dayjs from 'dayjs'
|
|
import { partition, uniqBy } from 'lodash'
|
|
import { MoreVertical } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { parseAsBoolean, useQueryState } from 'nuqs'
|
|
import { useCallback, useEffect, useState } from 'react'
|
|
import {
|
|
ComposableMap,
|
|
Geographies,
|
|
Geography,
|
|
Line,
|
|
Marker,
|
|
ZoomableGroup,
|
|
} from 'react-simple-maps'
|
|
import {
|
|
Badge,
|
|
Button,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
ScrollArea,
|
|
} from 'ui'
|
|
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
|
|
|
import { AVAILABLE_REPLICA_REGIONS } from './InstanceConfiguration.constants'
|
|
import GeographyData from './MapData.json'
|
|
import { getReadReplicaPath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
|
|
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
|
|
import { RegionFlag } from '@/components/ui/RegionFlag'
|
|
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
|
import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
|
|
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
|
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
|
|
|
|
const MAP_WIDTH = 800
|
|
const MAP_HEIGHT = 600
|
|
|
|
const MapView = () => {
|
|
const { ref } = useParams()
|
|
const dbSelectorState = useDatabaseSelectorStateSnapshot()
|
|
const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
|
|
'project_homepage:show_instance_size',
|
|
])
|
|
|
|
const [mount, setMount] = useState(false)
|
|
const [isPanning, setIsPanning] = useState(false)
|
|
const [zoom, setZoom] = useState<number>(1.5)
|
|
const [center, setCenter] = useState<[number, number]>([14, 7])
|
|
const [tooltip, setTooltip] = useState<{
|
|
x: number
|
|
y: number
|
|
region: { key: string; country?: string; name?: string; region?: string }
|
|
}>()
|
|
const [, setShowConnect] = useQueryState('showConnect', parseAsBoolean.withDefault(false))
|
|
|
|
const { data } = useReadReplicasQuery({ projectRef: ref })
|
|
const databases = data ?? []
|
|
const [[primary], replicas] = partition(databases, (db) => db.identifier === ref)
|
|
|
|
const primaryCoordinates = AVAILABLE_REPLICA_REGIONS.find((region) =>
|
|
primary.region.includes(region.region)
|
|
)?.coordinates ?? [0, 0]
|
|
const uniqueRegionsByReplicas = uniqBy(replicas, (r) => {
|
|
return AVAILABLE_REPLICA_REGIONS.find((region) => r.region.includes(region.region))?.key
|
|
})
|
|
|
|
const selectedRegionKey =
|
|
AVAILABLE_REPLICA_REGIONS.find((region) => region.coordinates === center)?.region ?? ''
|
|
const showRegionDetails = zoom === 2.0 && selectedRegionKey !== undefined
|
|
const selectedRegion = AVAILABLE_REPLICA_REGIONS.find(
|
|
(region) => region.region === selectedRegionKey
|
|
)
|
|
const databasesInSelectedRegion = databases
|
|
.filter((database) => database.region.includes(selectedRegionKey))
|
|
.sort((a, b) => (a.inserted_at > b.inserted_at ? 1 : 0))
|
|
.sort((database) => (database.identifier === ref ? -1 : 0))
|
|
|
|
useEffect(() => {
|
|
setTimeout(() => setMount(true), 100)
|
|
}, [])
|
|
|
|
const filterZoomEvent = useCallback((event: SVGElement) => {
|
|
const { type, ctrlKey, button } = event as unknown as MouseEvent
|
|
return type !== 'wheel' && type !== 'dblclick' && !ctrlKey && !button
|
|
}, [])
|
|
const onMoveStart = useCallback(() => setIsPanning(true), [])
|
|
const onMoveEnd = useCallback(() => setIsPanning(false), [])
|
|
|
|
return (
|
|
<div className="bg-studio h-full relative cursor-grab active:cursor-grabbing">
|
|
<ComposableMap
|
|
width={MAP_WIDTH}
|
|
height={MAP_HEIGHT}
|
|
projectionConfig={{ scale: 155 }}
|
|
className="w-full h-full"
|
|
>
|
|
<ZoomableGroup
|
|
className={mount && !isPanning ? 'transition-all duration-300' : ''}
|
|
center={center}
|
|
zoom={zoom}
|
|
minZoom={1.5}
|
|
maxZoom={2.0}
|
|
translateExtent={[
|
|
[0, 0],
|
|
[MAP_WIDTH, MAP_HEIGHT],
|
|
]}
|
|
filterZoomEvent={filterZoomEvent}
|
|
onMoveStart={onMoveStart}
|
|
onMoveEnd={onMoveEnd}
|
|
>
|
|
<Geographies geography={GeographyData}>
|
|
{({ geographies }) =>
|
|
geographies.map((geo) => (
|
|
<Geography
|
|
key={geo.rsmKey}
|
|
geography={geo}
|
|
strokeWidth={0.3}
|
|
pointerEvents="none"
|
|
className="fill-gray-800 stroke-gray-900 dark:fill-gray-300 dark:stroke-gray-200"
|
|
/>
|
|
))
|
|
}
|
|
</Geographies>
|
|
|
|
{uniqueRegionsByReplicas.map((database) => {
|
|
const coordinates = AVAILABLE_REPLICA_REGIONS.find((region) =>
|
|
database.region.includes(region.region)
|
|
)?.coordinates
|
|
|
|
if (coordinates !== primaryCoordinates) {
|
|
return (
|
|
<Line
|
|
key={`line-${database.identifier}-${primary.identifier}`}
|
|
from={coordinates}
|
|
to={primaryCoordinates}
|
|
stroke="white"
|
|
strokeWidth={1}
|
|
strokeLinecap="round"
|
|
strokeOpacity={0.2}
|
|
strokeDasharray={'3, 3'}
|
|
className="map-path"
|
|
/>
|
|
)
|
|
} else {
|
|
return null
|
|
}
|
|
})}
|
|
|
|
{AVAILABLE_REPLICA_REGIONS.map((region) => {
|
|
const dbs =
|
|
databases.filter((database) => database.region.includes(region.region)) ?? []
|
|
const coordinates = AVAILABLE_REPLICA_REGIONS.find(
|
|
(r) => r.region === region.region
|
|
)?.coordinates
|
|
|
|
const hasNoDatabases = dbs.length === 0
|
|
const hasPrimary = dbs.some((database) => database.identifier === ref)
|
|
const replicas = dbs.filter((database) => database.identifier !== ref) ?? []
|
|
|
|
return (
|
|
<Marker
|
|
key={region.key}
|
|
coordinates={coordinates}
|
|
onMouseEnter={() => {
|
|
setTooltip({
|
|
x: coordinates![0],
|
|
y: coordinates![1],
|
|
region: {
|
|
key: region.key,
|
|
country: region.name,
|
|
region: region.region,
|
|
name: hasNoDatabases
|
|
? undefined
|
|
: hasPrimary
|
|
? `Primary Database${
|
|
replicas.length > 0
|
|
? ` + ${replicas.length} replica${replicas.length > 1 ? 's' : ''} `
|
|
: ''
|
|
}`
|
|
: `${replicas.length} Read Replica${
|
|
replicas.length > 1 ? 's' : ''
|
|
} deployed`,
|
|
},
|
|
})
|
|
}}
|
|
onMouseLeave={() => setTooltip(undefined)}
|
|
onClick={() => {
|
|
if (coordinates) {
|
|
setCenter(coordinates)
|
|
setZoom(2.0)
|
|
}
|
|
}}
|
|
>
|
|
{selectedRegionKey === region.region && (
|
|
<circle
|
|
r={4}
|
|
className={`animate-ping ${
|
|
hasNoDatabases ? 'fill-border-stronger' : 'fill-primary-bright'
|
|
}`}
|
|
/>
|
|
)}
|
|
<circle
|
|
r={4}
|
|
className={`cursor-pointer ${
|
|
hasNoDatabases
|
|
? 'fill-background-surface-300 stroke-border-stronger'
|
|
: hasPrimary
|
|
? 'fill-primary-bright stroke-brand-500'
|
|
: 'fill-brand-500 stroke-brand-400'
|
|
}`}
|
|
/>
|
|
</Marker>
|
|
)
|
|
})}
|
|
|
|
{tooltip !== undefined && zoom === 1.5 && (
|
|
<Marker coordinates={[tooltip.x - 47, tooltip.y - 5]}>
|
|
<foreignObject width={220} height={66.25}>
|
|
<div className="bg-studio/50 rounded-sm border">
|
|
<div className="px-3 py-2 flex flex-col">
|
|
<div className="flex items-center gap-x-2">
|
|
<RegionFlag className="w-4" region={tooltip.region.region ?? ''} />
|
|
<p className="text-[10px]">{tooltip.region.country}</p>
|
|
</div>
|
|
<p
|
|
className={`text-[10px] ${
|
|
tooltip.region.name === undefined ? 'text-foreground-light' : ''
|
|
}`}
|
|
>
|
|
{tooltip.region.name ?? 'No databases deployed'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</foreignObject>
|
|
</Marker>
|
|
)}
|
|
</ZoomableGroup>
|
|
</ComposableMap>
|
|
|
|
{showRegionDetails && selectedRegion && (
|
|
<div className="absolute bottom-4 right-4 flex flex-col bg-studio/50 backdrop-blur-xs border rounded-sm w-[400px]">
|
|
<div className="flex items-center justify-between py-4 px-4 border-b">
|
|
<div>
|
|
<p className="text-xs text-foreground-light">
|
|
{databasesInSelectedRegion.length} database
|
|
{databasesInSelectedRegion.length > 1 ? 's' : ''} deployed in
|
|
</p>
|
|
<p className="text-sm">{selectedRegion.name}</p>
|
|
</div>
|
|
<RegionFlag className="w-10" region={selectedRegion.region} />
|
|
</div>
|
|
|
|
{databasesInSelectedRegion.length > 0 && (
|
|
<ScrollArea style={{ height: databasesInSelectedRegion.length > 2 ? '180px' : 'auto' }}>
|
|
<ul className={`flex flex-col divide-y`}>
|
|
{databasesInSelectedRegion.map((database) => {
|
|
const created = dayjs(database.inserted_at).format('DD MMM YYYY')
|
|
|
|
return (
|
|
<li
|
|
key={database.identifier}
|
|
className="text-sm px-4 py-2 flex items-center justify-between"
|
|
>
|
|
<div className="flex flex-col gap-y-1">
|
|
<p className="flex items-center gap-x-2">
|
|
{database.identifier === ref
|
|
? 'Primary Database'
|
|
: `Read Replica ${
|
|
database.identifier.length > 0 &&
|
|
`(ID: ${formatDatabaseID(database.identifier)})`
|
|
}`}
|
|
{database.status === REPLICA_STATUS.ACTIVE_HEALTHY ? (
|
|
<Badge variant="success">Healthy</Badge>
|
|
) : database.status === REPLICA_STATUS.COMING_UP ? (
|
|
<Badge>Coming up</Badge>
|
|
) : database.status === REPLICA_STATUS.RESTARTING ? (
|
|
<Badge>Restarting</Badge>
|
|
) : database.status === REPLICA_STATUS.RESIZING ? (
|
|
<Badge>Resizing</Badge>
|
|
) : (
|
|
<Badge variant="warning">Unhealthy</Badge>
|
|
)}
|
|
</p>
|
|
<div>
|
|
<p className="text-xs text-foreground-light">
|
|
AWS{projectHomepageShowInstanceSize ? ` • ${database.size}` : ''}
|
|
</p>
|
|
{database.identifier !== ref && (
|
|
<p className="text-xs text-foreground-light">
|
|
Created on:{' '}
|
|
<TimestampInfo label={created} utcTimestamp={database.inserted_at} />
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{database.identifier !== ref && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="text" icon={<MoreVertical />} className="px-1" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-40" side="bottom" align="end">
|
|
<DropdownMenuItem
|
|
className="gap-x-2"
|
|
disabled={database.status !== REPLICA_STATUS.ACTIVE_HEALTHY}
|
|
onClick={() => {
|
|
setShowConnect(true)
|
|
dbSelectorState.setSelectedDatabaseId(database.identifier)
|
|
}}
|
|
>
|
|
View connection string
|
|
</DropdownMenuItem>
|
|
|
|
<DropdownMenuSeparator />
|
|
|
|
<DropdownMenuItem className="gap-x-2" asChild>
|
|
<Link href={getReadReplicaPath(ref, database.identifier)}>
|
|
Manage replica
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</li>
|
|
)
|
|
})}
|
|
</ul>
|
|
</ScrollArea>
|
|
)}
|
|
|
|
<div
|
|
className={`flex items-center justify-end gap-x-2 px-4 py-2 ${
|
|
databasesInSelectedRegion.length > 0 ? 'border-t' : ''
|
|
}`}
|
|
>
|
|
<Button
|
|
onClick={() => {
|
|
setCenter([14, 7])
|
|
setZoom(1.5)
|
|
}}
|
|
>
|
|
Close
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default MapView
|