RR Scaffolding Work (#18658)

* Init demo world map for read replicas

* Update package.json

* Scaffold and mock data for flow view of project settings infrastructure configuration

* Shift map stuff and install deck.gl to experiment

* Flesh out map view a bit more

* Scaffold deploy new replica panel

* Add restart replica option

* Add animations, shift node generation into utils, attempt to show region nodes

* Experiments

* General improvement to chart and map UI

* Improve some copy writing for restart and drop replica modals

* Simplify loading, error, success states in DatabaseSettings

* Scaffold DB selection in connection string section

* Link replica node to connection string

* Scaffold database selection for SQL editor

* Make DatabaseSelector into a component

* Add database selector to infra activity

* Update

* Hide read replicas UI behind a feature flag

* Update flags

* Update package-lock

---------

Co-authored-by: Jonathan Summers-Muir <MildTomato@users.noreply.github.com>
This commit is contained in:
Joshen LimandJonathan Summers-Muir authored and GitHub committed 2023-11-27 12:50:19 +08:00
1 parent 81e146f26b
commit 3620ca8ebe
23 files changed
+13043 -289

No files matched your search

@@ -3,8 +3,11 @@ import { IS_PLATFORM } from 'lib/constants'
import { detectOS } from 'lib/helpers'
import { Button, IconAlignLeft, IconCommand, IconCornerDownLeft } from 'ui'
import DatabaseSelector from 'components/ui/DatabaseSelector'
import { useState } from 'react'
import FavoriteButton from './FavoriteButton'
import SavingIndicator from './SavingIndicator'
import { useFlag } from 'hooks'
import SizeToggleButton from './SizeToggleButton'
export type UtilityActionsProps = {
@@ -25,13 +28,20 @@ const UtilityActions = ({
executeQuery,
}: UtilityActionsProps) => {
const os = detectOS()
const readReplicasEnabled = useFlag('readReplicas')
const [selectedDatabaseId, setSelectedDatabaseId] = useState<string>('1')
return (
<>
<SavingIndicator id={id} />
{IS_PLATFORM && <FavoriteButton id={id} />}
<SizeToggleButton id={id} />
{/* [Joshen] Am opting to remove this - i don't think its useful? */}
{/* [Joshen] Keeping in mind to not sprawl controls everywhere */}
{/* [Joshen] There's eventually gonna be user impersonation here as well so let's see */}
{/* <SizeToggleButton id={id} /> */}
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger asChild>
<Button
@@ -54,26 +64,35 @@ const UtilityActions = ({
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<Button
onClick={() => executeQuery()}
disabled={isDisabled || isExecuting}
loading={isExecuting}
type="default"
size="tiny"
className="mx-2"
iconRight={
<div className="flex items-center space-x-1">
{os === 'macos' ? (
<IconCommand size={10} strokeWidth={1.5} />
) : (
<p className="text-xs text-foreground-light">CTRL</p>
)}
<IconCornerDownLeft size={10} strokeWidth={1.5} />
</div>
}
>
{hasSelection ? 'Run selected' : 'Run'}
</Button>
<div className="flex items-center justify-between gap-x-2 mx-2">
{readReplicasEnabled && (
<DatabaseSelector
selectedDatabaseId={selectedDatabaseId}
onChangeDatabaseId={setSelectedDatabaseId}
/>
)}
<Button
onClick={() => executeQuery()}
disabled={isDisabled || isExecuting}
loading={isExecuting}
type="default"
size="tiny"
iconRight={
<div className="flex items-center space-x-1">
{os === 'macos' ? (
<IconCommand size={10} strokeWidth={1.5} />
) : (
<p className="text-xs text-foreground-light">CTRL</p>
)}
<IconCornerDownLeft size={10} strokeWidth={1.5} />
</div>
}
>
{hasSelection ? 'Run selected' : 'Run'}
</Button>
</div>
</>
)
}
@@ -1,7 +1,7 @@
import { useParams, useTelemetryProps } from 'common'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
@@ -13,26 +13,39 @@ import {
Tabs,
} from 'ui'
import AlertError from 'components/ui/AlertError'
import DatabaseSelector from 'components/ui/DatabaseSelector'
import Panel from 'components/ui/Panel'
import ShimmeringLoader from 'components/ui/ShimmeringLoader'
import { useProjectSettingsQuery } from 'data/config/project-settings-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { useSelectedOrganization } from 'hooks'
import { useFlag, useSelectedOrganization } from 'hooks'
import { pluckObjectFields } from 'lib/helpers'
import Telemetry from 'lib/telemetry'
import { MOCK_DATABASES } from '../../Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
import ConfirmDisableReadOnlyModeModal from './ConfirmDisableReadOnlyModal'
import ResetDbPassword from './ResetDbPassword'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
const DatabaseSettings = () => {
const router = useRouter()
const { ref: projectRef } = useParams()
const { ref: projectRef, connectionString } = useParams()
const telemetryProps = useTelemetryProps()
const organization = useSelectedOrganization()
const selectedOrganization = useSelectedOrganization()
const readReplicasEnabled = useFlag('readReplicas')
const connectionStringsRef = useRef<HTMLDivElement>(null)
const [selectedDatabaseId, setSelectedDatabaseId] = useState<string>('1')
const [showConfirmationModal, setShowConfirmationModal] = useState(false)
const { data, isLoading, isError } = useProjectSettingsQuery({ projectRef })
// [Joshen] Read replicas mock UI stuff
const [open, setOpen] = useState(false)
const databases = MOCK_DATABASES
const selectedDatabase = databases.find((db) => db.id.toString() === selectedDatabaseId)
const { data, error, isLoading, isError, isSuccess } = useProjectSettingsQuery({ projectRef })
const { data: subscription } = useOrgSubscriptionQuery({ orgSlug: organization?.slug })
const { data: resourceWarnings } = useResourceWarningsQuery()
@@ -40,97 +53,12 @@ const DatabaseSettings = () => {
(resourceWarnings ?? [])?.find((warning) => warning.project === projectRef)
?.is_readonly_mode_enabled ?? false
if (isError) {
return (
<div className="mx-auto p-6 text-center sm:w-full md:w-3/4">
<p className="text-foreground-light">Error loading database settings</p>
</div>
)
}
if (isLoading) {
return (
<div className="space-y-10">
<section className="space-y-6">
<h3 className="text-foreground mb-2 text-xl">Database Settings</h3>
<Panel
title={
<h5 key="panel-title" className="mb-0">
Connection info
</h5>
}
className="!m-0"
>
<Panel.Content className="space-y-8">
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="grid gap-2 items-center md:grid md:grid-cols-12 md:gap-x-4 w-full"
>
<ShimmeringLoader className="h-4 w-1/3 col-span-4" delayIndex={i} />
<ShimmeringLoader className="h-8 w-full col-span-8" delayIndex={i} />
</div>
))}
</Panel.Content>
</Panel>
</section>
<ResetDbPassword disabled />
<section className="space-y-6">
<Panel
title={
<h5 key="panel-title" className="mb-0" id="connection-string">
Connection string
</h5>
}
className="!m-0"
>
<Panel.Content>
<Tabs type="underlined" size="small">
<Tabs.Panel id="psql" label="PSQL">
<ShimmeringLoader className="h-8 w-full" />
</Tabs.Panel>
<Tabs.Panel id="uri" label="URI">
<ShimmeringLoader className="h-8 w-full" />
</Tabs.Panel>
<Tabs.Panel id="golang" label="Golang">
<ShimmeringLoader className="h-8 w-full" />
</Tabs.Panel>
<Tabs.Panel id="jdbc" label="JDBC">
<ShimmeringLoader className="h-8 w-full" />
</Tabs.Panel>
<Tabs.Panel id="dotnet" label=".NET">
<ShimmeringLoader className="h-8 w-full" />
</Tabs.Panel>
<Tabs.Panel id="nodejs" label="Nodejs">
<ShimmeringLoader className="h-8 w-full" />
</Tabs.Panel>
<Tabs.Panel id="php" label="PHP">
<ShimmeringLoader className="h-8 w-full" />
</Tabs.Panel>
<Tabs.Panel id="python" label="Python">
<ShimmeringLoader className="h-8 w-full" />
</Tabs.Panel>
</Tabs>
</Panel.Content>
</Panel>
</section>
</div>
)
}
const { project } = data
const { project } = data ?? {}
const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at']
const connectionInfo = pluckObjectFields(project, DB_FIELDS)
const connectionInfo =
project !== undefined
? pluckObjectFields(project, DB_FIELDS)
: { db_user: '', db_host: '', db_port: '', db_name: '' }
const handleCopy = (labelValue?: string) =>
Telemetry.sendEvent(
@@ -155,6 +83,13 @@ const DatabaseSettings = () => {
`${connectionInfo.db_port.toString()} -d ${connectionInfo.db_name} ` +
`-U ${connectionInfo.db_user}`
useEffect(() => {
if (connectionString !== undefined && connectionStringsRef.current !== undefined) {
setSelectedDatabaseId(connectionString)
connectionStringsRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
}, [connectionString])
return (
<>
<div className="space-y-10">
@@ -224,189 +159,223 @@ const DatabaseSettings = () => {
className="!m-0"
>
<Panel.Content className="space-y-6">
<Input
className="input-mono"
layout="horizontal"
readOnly
copy
disabled
value={connectionInfo.db_host}
label="Host"
onCopy={() => {
handleCopy('Host')
}}
/>
{isLoading &&
Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="grid gap-2 items-center md:grid md:grid-cols-12 md:gap-x-4 w-full"
>
<ShimmeringLoader className="h-4 w-1/3 col-span-4" delayIndex={i} />
<ShimmeringLoader className="h-8 w-full col-span-8" delayIndex={i} />
</div>
))}
{isError && (
<AlertError error={error} subject="Failed to retrieve database settings" />
)}
{isSuccess && (
<>
<Input
className="input-mono"
layout="horizontal"
readOnly
copy
disabled
value={connectionInfo.db_host}
label="Host"
onCopy={() => {
handleCopy('Host')
}}
/>
<Input
className="input-mono"
layout="horizontal"
readOnly
copy
disabled
value={connectionInfo.db_name}
label="Database name"
/>
<Input
className="input-mono"
layout="horizontal"
readOnly
copy
disabled
value={connectionInfo.db_name}
label="Database name"
/>
<Input
className="input-mono"
layout="horizontal"
readOnly
copy
disabled
value={connectionInfo.db_port.toString()}
label="Port"
/>
<Input
className="input-mono"
layout="horizontal"
readOnly
copy
disabled
value={connectionInfo.db_port.toString()}
label="Port"
/>
<Input
layout="horizontal"
className="input-mono table-input-cell text-base"
readOnly
copy
disabled
value={connectionInfo.db_user}
label="User"
/>
<Input
layout="horizontal"
className="input-mono table-input-cell text-base"
readOnly
copy
disabled
value={connectionInfo.db_user}
label="User"
/>
<Input
className="input-mono"
layout="horizontal"
disabled
readOnly
value={'[The password you provided when you created this project]'}
label="Password"
/>
<Input
className="input-mono"
layout="horizontal"
disabled
readOnly
value={'[The password you provided when you created this project]'}
label="Password"
/>
</>
)}
</Panel.Content>
</Panel>
</section>
<ResetDbPassword />
<ResetDbPassword disabled={isLoading || isError} />
<section className="space-y-6">
<Panel
title={
<h5 key="panel-title" className="mb-0">
Connection string
</h5>
<div ref={connectionStringsRef} className="w-full flex items-center justify-between">
<h5 key="panel-title" className="mb-0">
Connection string
</h5>
{readReplicasEnabled && (
<DatabaseSelector
selectedDatabaseId={selectedDatabaseId}
onChangeDatabaseId={setSelectedDatabaseId}
/>
)}
</div>
}
className="!m-0"
>
<Panel.Content>
<Tabs type="underlined" size="small">
<Tabs.Panel id="psql" label="PSQL">
<Input
copy
readOnly
disabled
value={psqlConnString}
onCopy={() => {
handleCopy('PSQL')
}}
/>
</Tabs.Panel>
{isLoading && <ShimmeringLoader className="h-8 w-full" />}
{isError && (
<AlertError error={error} subject="Failed to retrieve database settings" />
)}
{isSuccess && (
<Tabs type="underlined" size="small">
<Tabs.Panel id="psql" label="PSQL">
<Input
copy
readOnly
disabled
value={psqlConnString}
onCopy={() => {
handleCopy('PSQL')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="uri" label="URI">
<Input
copy
readOnly
disabled
value={uriConnString}
onCopy={() => {
handleCopy('URI')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="uri" label="URI">
<Input
copy
readOnly
disabled
value={uriConnString}
onCopy={() => {
handleCopy('URI')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="golang" label="Golang">
<Input
copy
readOnly
disabled
value={golangConnString}
onCopy={() => {
handleCopy('Golang')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="golang" label="Golang">
<Input
copy
readOnly
disabled
value={golangConnString}
onCopy={() => {
handleCopy('Golang')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="jdbc" label="JDBC">
<Input
copy
readOnly
disabled
value={
`jdbc:postgresql://${
connectionInfo.db_host
}:${connectionInfo.db_port.toString()}` +
`/${connectionInfo.db_name}?user=${connectionInfo.db_user}&password=[YOUR-PASSWORD]`
}
onCopy={() => {
handleCopy('JDBC')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="jdbc" label="JDBC">
<Input
copy
readOnly
disabled
value={
`jdbc:postgresql://${
connectionInfo.db_host
}:${connectionInfo.db_port.toString()}` +
`/${connectionInfo.db_name}?user=${connectionInfo.db_user}&password=[YOUR-PASSWORD]`
}
onCopy={() => {
handleCopy('JDBC')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="dotnet" label=".NET">
<Input
copy
readOnly
disabled
value={
`User Id=${connectionInfo.db_user};Password=[YOUR-PASSWORD];` +
`Server=${
connectionInfo.db_host
};Port=${connectionInfo.db_port.toString()};` +
`Database=${connectionInfo.db_name}`
}
onCopy={() => {
handleCopy('.NET')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="dotnet" label=".NET">
<Input
copy
readOnly
disabled
value={
`User Id=${connectionInfo.db_user};Password=[YOUR-PASSWORD];` +
`Server=${
connectionInfo.db_host
};Port=${connectionInfo.db_port.toString()};` +
`Database=${connectionInfo.db_name}`
}
onCopy={() => {
handleCopy('.NET')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="nodejs" label="Nodejs">
<Input
copy
readOnly
disabled
value={uriConnString}
onCopy={() => {
handleCopy('Nodejs')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="nodejs" label="Nodejs">
<Input
copy
readOnly
disabled
value={uriConnString}
onCopy={() => {
handleCopy('Nodejs')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="php" label="PHP">
<Input
copy
readOnly
disabled
value={golangConnString}
onCopy={() => {
handleCopy('PHP')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="php" label="PHP">
<Input
copy
readOnly
disabled
value={golangConnString}
onCopy={() => {
handleCopy('PHP')
}}
/>
</Tabs.Panel>
<Tabs.Panel id="python" label="Python">
<Input
copy
readOnly
disabled
value={
`user=${connectionInfo.db_user} password=[YOUR-PASSWORD]` +
` host=${connectionInfo.db_host} port=${connectionInfo.db_port.toString()}` +
` database=${connectionInfo.db_name}`
}
onCopy={() => {
handleCopy('Python')
}}
/>
</Tabs.Panel>
</Tabs>
<Tabs.Panel id="python" label="Python">
<Input
copy
readOnly
disabled
value={
`user=${connectionInfo.db_user} password=[YOUR-PASSWORD]` +
` host=${
connectionInfo.db_host
} port=${connectionInfo.db_port.toString()}` +
` database=${connectionInfo.db_name}`
}
onCopy={() => {
handleCopy('Python')
}}
/>
</Tabs.Panel>
</Tabs>
)}
</Panel.Content>
</Panel>
</section>
</div>
<ConfirmDisableReadOnlyModeModal
visible={showConfirmationModal}
onClose={() => setShowConfirmationModal(false)}
@@ -112,7 +112,7 @@ const ResetDbPassword = ({ disabled = false }) => {
</div>
<div className="flex items-end justify-end">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Tooltip.Trigger asChild>
<Button
type="default"
disabled={!canResetDbPassword || !isProjectActive || disabled}
@@ -101,7 +101,7 @@ const DiskSizeConfiguration = ({ disabled = false }: DiskSizeConfigurationProps)
</div>
<div className="flex items-end justify-end">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Tooltip.Trigger asChild>
<Button
type="default"
disabled={!canUpdateDiskSizeConfig || disabled}
@@ -132,7 +132,7 @@ const SSLConfiguration = () => {
<IconLoader className="animate-spin" strokeWidth={1.5} size={16} />
)}
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Tooltip.Trigger asChild>
<Toggle
checked={isEnforced}
disabled={
@@ -182,7 +182,7 @@ const SSLConfiguration = () => {
</div>
<div className="flex items-end justify-end">
<Tooltip.Root delayDuration={0}>
<Tooltip.Trigger>
<Tooltip.Trigger asChild>
<Button type="default" disabled={!hasSSLCertificate} icon={<IconDownload />}>
<a
href={`https://supabase-downloads.s3-ap-southeast-1.amazonaws.com/${env}/ssl/${env}-ca-2021.crt`}
@@ -26,14 +26,18 @@ import { useInfraMonitoringQuery } from 'data/analytics/infra-monitoring-query'
import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-query'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { useResourceWarningsQuery } from 'data/usage/resource-warnings-query'
import { useSelectedOrganization } from 'hooks'
import { useFlag, useSelectedOrganization } from 'hooks'
import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from 'lib/constants'
import { INFRA_ACTIVITY_METRICS } from './Infrastructure.constants'
import DatabaseSelector from 'components/ui/DatabaseSelector'
const InfrastructureActivity = () => {
const { ref: projectRef } = useParams()
const organization = useSelectedOrganization()
const [dateRange, setDateRange] = useState<any>()
const [selectedDatabaseId, setSelectedDatabaseId] = useState<string>('1')
const readReplicasEnabled = useFlag('readReplicas')
const { data: subscription, isLoading: isLoadingSubscription } = useOrgSubscriptionQuery({
orgSlug: organization?.slug,
@@ -182,7 +186,13 @@ const InfrastructureActivity = () => {
</div>
</ScaffoldContainer>
<ScaffoldContainer className="sticky top-0 py-6 border-b bg-background z-10">
<div className="flex items-center space-x-4">
<div className="flex items-center gap-x-4">
{readReplicasEnabled && (
<DatabaseSelector
selectedDatabaseId={selectedDatabaseId}
onChangeDatabaseId={setSelectedDatabaseId}
/>
)}
{!isLoadingSubscription && (
<>
<DateRangePicker
@@ -0,0 +1,109 @@
import { useParams } from 'common'
import { useEffect, useState } from 'react'
import { Listbox, SidePanel } from 'ui'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { AVAILABLE_REPLICA_REGIONS } from './InstanceConfiguration.constants'
import { AWS_REGIONS, AWS_REGIONS_DEFAULT, AWS_REGIONS_KEYS, BASE_PATH } from 'lib/constants'
// [Joshen] FYI this is purely for AWS only, need to update to support Fly eventually
interface DeployNewReplicaPanelProps {
visible: boolean
selectedDefaultRegion?: AWS_REGIONS_KEYS
onClose: () => void
}
const DeployNewReplicaPanel = ({
visible,
selectedDefaultRegion,
onClose,
}: DeployNewReplicaPanelProps) => {
const { ref: projectRef } = useParams()
const { data: addons, isSuccess } = useProjectAddonsQuery({ projectRef })
const computeAddons =
addons?.available_addons.find((addon) => addon.type === 'compute_instance')?.variants ?? []
// Opting for useState temporarily as Listbox doesn't seem to work with react-hook-form yet
const [defaultRegion] =
Object.entries(AWS_REGIONS).find(([key, name]) => name === AWS_REGIONS_DEFAULT) ?? []
const defaultCompute = computeAddons.find((option) => option.name === 'Small')?.identifier
const [selectedRegion, setSelectedRegion] = useState(defaultRegion)
const [selectedCompute, setSelectedCompute] = useState(defaultCompute)
const selectedComputeMeta = computeAddons.find((addon) => addon.identifier === selectedCompute)
const onSubmit = async () => {
console.log('Deploy', { selectedRegion, selectedCompute })
onClose()
}
useEffect(() => {
if (visible && isSuccess) {
if (selectedDefaultRegion !== undefined) {
setSelectedRegion(selectedDefaultRegion)
} else if (defaultRegion) {
setSelectedRegion(defaultRegion)
}
if (defaultCompute !== undefined) setSelectedCompute(defaultCompute)
}
}, [visible, isSuccess])
return (
<SidePanel
visible={visible}
onCancel={onClose}
onConfirm={() => onSubmit()}
header="Deploy a new read replica"
>
<SidePanel.Content className="flex flex-col py-4 gap-y-8">
<Listbox
size="small"
id="region"
name="region"
value={selectedRegion}
onChange={setSelectedRegion}
label="Select a region to deploy your read replica in"
>
{AVAILABLE_REPLICA_REGIONS.map((region) => (
<Listbox.Option
key={region.key}
label={region.name}
value={region.key}
addOnBefore={() => (
<img
alt="region icon"
className="w-5 rounded-sm"
src={`${BASE_PATH}/img/regions/${region.key}.svg`}
/>
)}
>
{region.name}
</Listbox.Option>
))}
</Listbox>
<Listbox
size="small"
id="compute"
name="compute"
value={selectedCompute}
onChange={setSelectedCompute}
label="Select the instance size for your read replica"
>
{computeAddons.map((option) => (
<Listbox.Option key={option.identifier} label={option.name} value={option.identifier}>
{option.name}
</Listbox.Option>
))}
</Listbox>
<p className="text-xs text-foreground-light">
Show some preview info on cost for deploying this replica here
</p>
</SidePanel.Content>
</SidePanel>
)
}
export default DeployNewReplicaPanel
@@ -0,0 +1,142 @@
import { AWS_REGIONS, AWS_REGIONS_KEYS } from 'lib/constants'
export interface DatabaseConfiguration {
id: number
type: 'PRIMARY' | 'READ_REPLICA'
cloud_provider: 'AWS' | 'FLY'
region: string
size: string
status: string
inserted_at: string
}
export interface Region {
key: AWS_REGIONS_KEYS
name: string
region: string
coordinates: [number, number]
}
// ReactFlow is scaling everything by the factor of 2
export const NODE_WIDTH = 560
export const NODE_ROW_HEIGHT = 50
export const NODE_SEP = 20
export const MOCK_DATABASES: DatabaseConfiguration[] = [
{
id: 1,
type: 'PRIMARY',
cloud_provider: 'AWS',
region: 'ap-southeast-1b',
size: 't4g.micro',
status: 'ACTIVE_HEALTHY',
inserted_at: '2023-11-01 06:47:46.837002',
},
{
id: 2,
type: 'READ_REPLICA',
cloud_provider: 'AWS',
region: 'ap-northeast-1',
size: 't4g.micro',
status: 'ACTIVE_HEALTHY',
inserted_at: '2023-11-01 06:47:46.837002',
},
{
id: 3,
type: 'READ_REPLICA',
cloud_provider: 'AWS',
region: 'ap-southeast-1b',
size: 't4g.micro',
status: 'ACTIVE_HEALTHY',
inserted_at: '2023-11-01 06:47:46.837002',
},
{
id: 4,
type: 'READ_REPLICA',
cloud_provider: 'AWS',
region: 'ap-northeast-2',
size: 't4g.micro',
status: 'ACTIVE_HEALTHY',
inserted_at: '2023-11-01 06:47:46.837002',
},
{
id: 5,
type: 'READ_REPLICA',
cloud_provider: 'AWS',
region: 'eu-central-1',
size: 't4g.micro',
status: 'ACTIVE_HEALTHY',
inserted_at: '2023-11-01 06:47:46.837002',
},
{
id: 6,
type: 'READ_REPLICA',
cloud_provider: 'AWS',
region: 'eu-central-1',
size: 't4g.micro',
status: 'ACTIVE_HEALTHY',
inserted_at: '2023-11-01 06:47:46.837002',
},
{
id: 7,
type: 'READ_REPLICA',
cloud_provider: 'AWS',
region: 'ap-southeast-1',
size: 't4g.micro',
status: 'ACTIVE_HEALTHY',
inserted_at: '2023-11-01 06:47:46.837002',
},
{
id: 8,
type: 'READ_REPLICA',
cloud_provider: 'AWS',
region: 'ap-southeast-1',
size: 't4g.micro',
status: 'ACTIVE_HEALTHY',
inserted_at: '2023-11-01 06:47:46.837002',
},
]
// [Joshen] Coordinates from https://github.com/jsonmaur/aws-regions/issues/11
const AWS_REGIONS_COORDINATES: { [key: string]: [number, number] } = {
SOUTHEAST_ASIA: [103.8, 1.37],
NORTHEAST_ASIA: [139.42, 35.41],
NORTHEAST_ASIA_2: [126.98, 37.56],
CENTRAL_CANADA: [-73.6, 45.5],
WEST_US: [-121.96, 37.35],
EAST_US: [-78.45, 38.13],
WEST_EU: [-8, 53],
WEST_EU_2: [-0.1, 51],
CENTRAL_EU: [8, 50],
SOUTH_ASIA: [72.88, 19.08],
OCEANIA: [151.2, -33.86],
SOUTH_AMERICA: [-46.38, -23.34],
}
const AWS_REGIONS_VALUES: { [key: string]: string } = {
SOUTHEAST_ASIA: 'ap-southeast-1',
NORTHEAST_ASIA: 'ap-northeast-1',
NORTHEAST_ASIA_2: 'ap-northeast-2',
CENTRAL_CANADA: 'ca-central-1',
WEST_US: 'us-west-1',
EAST_US: 'es-east-1',
WEST_EU: 'eu-west-1',
WEST_EU_2: 'eu-west-2',
CENTRAL_EU: 'eu-central-1',
SOUTH_ASIA: 'ap-south-1',
OCEANIA: 'ap-southeast-2',
SOUTH_AMERICA: 'sa-east-1',
}
// [Joshen] Just to make sure that we just depend on AWS_REGIONS to determine available
// regions for replicas. Just FYI - might need to update this if we support Fly in future
export const AVAILABLE_REPLICA_REGIONS: Region[] = Object.keys(AWS_REGIONS)
.map((key) => {
return {
key: key as AWS_REGIONS_KEYS,
name: AWS_REGIONS[key as AWS_REGIONS_KEYS],
region: AWS_REGIONS_VALUES[key],
coordinates: AWS_REGIONS_COORDINATES[key],
}
})
.filter((x) => x.coordinates !== undefined)
@@ -0,0 +1,218 @@
import { useParams } from 'common'
import { partition } from 'lodash'
import { Globe2, Network } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useMemo, useState } from 'react'
import ReactFlow, { Background, Edge, ReactFlowProvider, useReactFlow } from 'reactflow'
import 'reactflow/dist/style.css'
import {
AlertDescription_Shadcn_,
AlertTitle_Shadcn_,
Alert_Shadcn_,
Button,
IconAlertTriangle,
Modal,
} from 'ui'
import ConfirmationModal from 'components/ui/ConfirmationModal'
import { AWS_REGIONS_KEYS } from 'lib/constants'
import DeployNewReplicaPanel from './DeployNewReplicaPanel'
import { DatabaseConfiguration, MOCK_DATABASES } from './InstanceConfiguration.constants'
import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceConfiguration.utils'
import { PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode'
import MapView from './MapView'
import ResizeReplicaPanel from './ResizeReplicaPanel'
// [Joshen] Just FYI, UI assumes single provider for primary + replicas
// [Joshen] Idea to visualize grouping based on region: https://reactflow.dev/examples/layout/sub-flows
// [Joshen] Show flags for regions
const InstanceConfigurationUI = () => {
const reactFlow = useReactFlow()
const { resolvedTheme } = useTheme()
const { ref: projectRef } = useParams()
const [view, setView] = useState<'flow' | 'map'>('flow')
const [showNewReplicaPanel, setShowNewReplicaPanel] = useState(false)
const [newReplicaRegion, setNewReplicaRegion] = useState<AWS_REGIONS_KEYS>()
const [selectedReplicaToResize, setSelectedReplicaToResize] = useState<DatabaseConfiguration>()
const [selectedReplicaToDrop, setSelectedReplicaToDrop] = useState<DatabaseConfiguration>()
const [selectedReplicaToRestart, setSelectedReplicaToRestart] = useState<DatabaseConfiguration>()
const backgroundPatternColor =
resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)'
const [[primary], replicas] = partition(MOCK_DATABASES, (database) => database.type === 'PRIMARY')
const nodes = generateNodes(MOCK_DATABASES, {
onSelectRestartReplica: setSelectedReplicaToRestart,
onSelectResizeReplica: setSelectedReplicaToResize,
onSelectDropReplica: setSelectedReplicaToDrop,
})
const edges: Edge[] = replicas.map((database) => {
return {
id: `${primary.id}-${database.id}`,
source: `database-${primary.id}`,
target: `database-${database.id}`,
type: 'smoothstep',
animated: true,
}
})
const nodeTypes = useMemo(
() => ({ PRIMARY: PrimaryNode, READ_REPLICA: ReplicaNode, REGION: RegionNode }),
[]
)
const onConfirmDropReplica = () => {
console.log('Drop replica', selectedReplicaToDrop)
}
const onConfirmRestartReplica = () => {
console.log('Restart replica', selectedReplicaToRestart)
}
return (
<>
<div className="h-[500px] w-full relative">
<div className="z-10 absolute top-4 right-4 flex items-center justify-center gap-x-2">
<Button type="default" onClick={() => setShowNewReplicaPanel(true)}>
Deploy a new replica
</Button>
<div className="flex items-center justify-center">
<Button
type="default"
icon={<Network size={15} />}
className={`rounded-r-none transition ${
view === 'flow' ? 'opacity-100' : 'opacity-50'
}`}
onClick={() => setView('flow')}
/>
<Button
type="default"
icon={<Globe2 size={15} />}
className={`rounded-l-none transition ${
view === 'map' ? 'opacity-100' : 'opacity-50'
}`}
onClick={() => setView('map')}
/>
</div>
</div>
{view === 'flow' ? (
<ReactFlow
fitView
fitViewOptions={{ minZoom: 0.9, maxZoom: 1 }}
className="instance-configuration"
zoomOnPinch={false}
nodesDraggable={false}
nodesConnectable={false}
zoomOnDoubleClick={false}
edgesFocusable={false}
edgesUpdatable={false}
defaultNodes={[]}
defaultEdges={[]}
nodeTypes={nodeTypes}
onInit={() => {
const graph = getDagreGraphLayout(nodes, edges)
const xxx = addRegionNodes(graph.nodes, graph.edges)
reactFlow.setNodes(xxx.nodes)
reactFlow.setEdges(graph.edges)
}}
proOptions={{ hideAttribution: true }}
>
<Background color={backgroundPatternColor} />
</ReactFlow>
) : (
<MapView
onSelectDeployNewReplica={(region) => {
setNewReplicaRegion(region)
setShowNewReplicaPanel(true)
}}
onSelectRestartReplica={setSelectedReplicaToRestart}
onSelectResizeReplica={setSelectedReplicaToResize}
onSelectDropReplica={setSelectedReplicaToDrop}
/>
)}
</div>
<DeployNewReplicaPanel
visible={showNewReplicaPanel}
selectedDefaultRegion={newReplicaRegion}
onClose={() => {
setNewReplicaRegion(undefined)
setShowNewReplicaPanel(false)
}}
/>
<ResizeReplicaPanel
visible={selectedReplicaToResize !== undefined}
selectedReplica={selectedReplicaToResize}
onClose={() => setSelectedReplicaToResize(undefined)}
/>
<ConfirmationModal
danger
size="medium"
visible={selectedReplicaToDrop !== undefined}
header="Confirm to drop selected replica?"
buttonLabel="Drop replica"
buttonLoadingLabel="Dropping replica"
onSelectCancel={() => setSelectedReplicaToDrop(undefined)}
onSelectConfirm={() => onConfirmDropReplica()}
>
<Modal.Content className="py-3">
<Alert_Shadcn_ variant="warning">
<IconAlertTriangle strokeWidth={2} />
<AlertTitle_Shadcn_>This action cannot be undone</AlertTitle_Shadcn_>
<AlertDescription_Shadcn_>
You may still deploy a new replica in this region thereafter
</AlertDescription_Shadcn_>
</Alert_Shadcn_>
<div className="text-sm px-1 pt-4">
<p>Before deleting this replica, consider:</p>
<ul className="text-foreground-light py-1 list-disc mx-4 space-y-1">
<li>
Network traffic from this region may slow down, especially if you have no other
replicas in this region
</li>
</ul>
</div>
</Modal.Content>
</ConfirmationModal>
<ConfirmationModal
size="medium"
visible={selectedReplicaToRestart !== undefined}
header="Confirm to restart selected replica?"
buttonLabel="Restart replica"
buttonLoadingLabel="Restarting replica"
onSelectCancel={() => setSelectedReplicaToRestart(undefined)}
onSelectConfirm={() => onConfirmRestartReplica()}
>
<Modal.Content className="py-3">
<p className="text-sm">Before restarting the replica, consider:</p>
<ul className="text-sm text-foreground-light py-1 list-disc mx-4 space-y-1">
<li>
Network traffic from this region may slow down while the replica is restarting,
especially if you have no other replicas in this region
</li>
</ul>
<p className="text-sm mt-2">
Are you sure you want to restart this replica (ID: {selectedReplicaToRestart?.id}) now?{' '}
</p>
</Modal.Content>
</ConfirmationModal>
</>
)
}
const InstanceConfiguration = () => {
return (
<ReactFlowProvider>
<InstanceConfigurationUI />
</ReactFlowProvider>
)
}
export default InstanceConfiguration
@@ -0,0 +1,134 @@
import dagre from '@dagrejs/dagre'
import { Edge, Node, Position } from 'reactflow'
import {
AVAILABLE_REPLICA_REGIONS,
DatabaseConfiguration,
NODE_ROW_HEIGHT,
NODE_SEP,
NODE_WIDTH,
} from './InstanceConfiguration.constants'
import { groupBy } from 'lodash'
export const generateNodes = (
databases: DatabaseConfiguration[],
{
onSelectRestartReplica,
onSelectResizeReplica,
onSelectDropReplica,
}: {
onSelectRestartReplica: (database: DatabaseConfiguration) => void
onSelectResizeReplica: (database: DatabaseConfiguration) => void
onSelectDropReplica: (database: DatabaseConfiguration) => void
}
): Node[] => {
const position = { x: 0, y: 0 }
const replicas = databases.filter((d) => d.type === 'READ_REPLICA')
const regions = groupBy(replicas, (d) => {
const region = AVAILABLE_REPLICA_REGIONS.find((region) => d.region.includes(region.region))
return region?.key
})
const databaseNodes: Node[] = databases
.sort((a, b) => (a.region > b.region ? 1 : -1))
.map((database) => {
const region = AVAILABLE_REPLICA_REGIONS.find((region) =>
database.region.includes(region.region)
)
return {
position,
id: `database-${database.id}`,
type: database.type,
data: {
id: database.id,
region,
label: database.type === 'PRIMARY' ? 'Primary Database' : 'Read Replica',
provider: database.cloud_provider,
inserted_at: database.inserted_at,
computeSize: database.size,
...(database.type === 'READ_REPLICA'
? {
onSelectRestartReplica: () => onSelectRestartReplica(database),
onSelectResizeReplica: () => onSelectResizeReplica(database),
onSelectDropReplica: () => onSelectDropReplica(database),
}
: {}),
...(database.type === 'PRIMARY'
? {
numReplicas: replicas.length,
numRegions: Object.keys(regions).length,
}
: {}),
},
}
})
return [...databaseNodes]
}
export const getDagreGraphLayout = (nodes: Node[], edges: Edge[]) => {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
dagreGraph.setGraph({ rankdir: 'TB', ranksep: 200, nodesep: NODE_SEP })
nodes.forEach((node) =>
dagreGraph.setNode(node.id, { width: NODE_WIDTH / 2, height: NODE_ROW_HEIGHT / 2 })
)
edges.forEach((edge) => dagreGraph.setEdge(edge.source, edge.target))
dagre.layout(dagreGraph)
nodes.forEach((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
node.targetPosition = Position.Top
node.sourcePosition = Position.Bottom
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
node.position = {
x: nodeWithPosition.x - nodeWithPosition.width / 2,
y: nodeWithPosition.y - nodeWithPosition.height / 2,
}
return node
})
return { nodes, edges }
}
/**
* [Joshen] This is some customized logic to add region nodes as "subflow" as dagre doesn't support
* subflows, and I didn't want to go down a rabbit hole with the other layout libraries that react-flow
* supports. Definitely some things to improve in the future
* - Allow setting max number of nodes per row, so that the chart does not become too horizontally sparse
* when many many replicas created
* - Nodes are a bit too spaced out between each other within a region
*/
export const addRegionNodes = (nodes: Node[], edges: Edge[]) => {
const regionNodes: Node[] = []
const replicaNodes = nodes.filter((node) => node.type === 'READ_REPLICA')
const nodesByRegion = groupBy(replicaNodes, (node) => node.data.region.key)
Object.entries(nodesByRegion).map(([key, value]) => {
const region = AVAILABLE_REPLICA_REGIONS.find((r) => r.key === key)
const nodeXPositions = value.map((x) => x.position.x)
const nodeYPositions = value.map((x) => x.position.y)
const minX = Math.min(...nodeXPositions)
const maxX = Math.max(...nodeXPositions)
const minY = Math.max(...nodeYPositions)
const regionNode: Node = {
id: key,
position: { x: minX - 10, y: minY - 10 },
width: maxX - minX,
type: 'REGION',
data: { region, numReplicas: value.length },
}
regionNodes.push(regionNode)
})
return { nodes: [...regionNodes, ...nodes], edges }
}
@@ -0,0 +1,191 @@
import { useParams } from 'common'
import dayjs from 'dayjs'
import { Database, DatabaseBackup } from 'lucide-react'
import Link from 'next/link'
import { Handle, NodeProps, Position } from 'reactflow'
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconMoreVertical,
} from 'ui'
import { BASE_PATH } from 'lib/constants'
import { NODE_SEP, NODE_WIDTH, Region } from './InstanceConfiguration.constants'
interface NodeData {
id: string
label: string
provider: string
region: Region
computeSize: string
inserted_at: string
}
interface PrimaryNodeData extends NodeData {
numReplicas: number
numRegions: number
}
interface ReplicaNodeData extends NodeData {
onSelectRestartReplica: () => void
onSelectResizeReplica: () => void
onSelectDropReplica: () => void
}
export const PrimaryNode = ({ data }: NodeProps<PrimaryNodeData>) => {
const { label, provider, region, computeSize, numReplicas, numRegions } = data
return (
<>
<div className="flex flex-col rounded 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">{label}</p>
<p className="flex items-center gap-x-1">
<span className="text-xs text-foreground-light">{region.name}</span>
</p>
<p className="flex items-center gap-x-1">
<span className="text-xs text-foreground-light">{provider}</span>
<span className="text-xs text-foreground-light">•</span>
<span className="text-xs text-foreground-light">{computeSize}</span>
</p>
</div>
</div>
<img
alt="region icon"
className="w-8 rounded-sm mt-0.5"
src={`${BASE_PATH}/img/regions/${region.key}.svg`}
/>
</div>
{numReplicas > 0 && (
<div className="border-t p-3 py-2">
<p className="text-sm text-foreground-light">
<span className="text-foreground">{numReplicas} replicas</span> deployed across{' '}
<span className="text-foreground">{numRegions} regions</span>
</p>
</div>
)}
</div>
<Handle
type="source"
id="handle-b"
position={Position.Bottom}
style={{ background: 'transparent' }}
/>
</>
)
}
export const ReplicaNode = ({ data }: NodeProps<ReplicaNodeData>) => {
const {
id,
label,
provider,
region,
computeSize,
inserted_at,
onSelectRestartReplica,
onSelectResizeReplica,
onSelectDropReplica,
} = data
const { ref } = useParams()
const created = dayjs(inserted_at).format('DD MMM YYYY')
return (
<>
<Handle
type="target"
id="handle-t"
position={Position.Top}
style={{ background: 'transparent' }}
/>
<div
className="flex justify-between rounded bg-surface-100 border border-default p-3"
style={{ width: NODE_WIDTH / 2 - 10 }}
>
<div className="flex gap-x-3">
<div className="w-8 h-8 bg-brand-400 border border-brand-500 rounded-md flex items-center justify-center">
<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">
{label} {id}
</p>
{/* [Joshen] Some status indication perhaps */}
<Badge color="green">Healthy</Badge>
</div>
<div className="my-0.5">
<p className="flex text-xs text-foreground-light items-center gap-x-1">
{region.name}
</p>
<p className="flex text-xs text-foreground-light items-center gap-x-1">
<span>{provider}</span>
<span>•</span>
<span>{computeSize}</span>
</p>
</div>
<p className="text-xs text-foreground-light">Created: {created}</p>
</div>
</div>
<div>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button type="text" icon={<IconMoreVertical />} className="px-1" />
</DropdownMenuTrigger>
<DropdownMenuContent className="p-0 w-40" side="bottom" align="end">
<DropdownMenuItem className="gap-x-2">
<Link href={`/project/${ref}/settings/database?connectionString=${id}`}>
View connection string
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectRestartReplica()}>
Restart replica
</DropdownMenuItem>
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectResizeReplica()}>
Resize replica
</DropdownMenuItem>
<div className="border-t" />
<DropdownMenuItem className="gap-x-2" onClick={() => onSelectDropReplica()}>
Drop replica
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</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 bg-black/10 border border-default border-white/10 border-2 p-3"
style={{ width: regionNodeWidth, height: 150 }}
>
<div className="absolute bottom-2 flex items-center justify-between gap-x-2">
<img
alt="region icon"
className="w-5 rounded-sm"
src={`${BASE_PATH}/img/regions/${region.key}.svg`}
/>
<p className="text-sm">{region.name}</p>
</div>
</div>
)
}
@@ -0,0 +1,5 @@
const InstanceResizeModal = () => {
return <div>Hello</div>
}
export default InstanceResizeModal
@@ -0,0 +1,331 @@
import { useParams } from 'common'
import dayjs from 'dayjs'
import { partition, uniqBy } from 'lodash'
import Link from 'next/link'
import { useEffect, useMemo, useState } from 'react'
import {
ComposableMap,
Geographies,
Geography,
Line,
Marker,
ZoomableGroup,
} from 'react-simple-maps'
import {
Badge,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
IconMoreVertical,
ScrollArea,
} from 'ui'
import { AWS_REGIONS_KEYS, BASE_PATH } from 'lib/constants'
import {
AVAILABLE_REPLICA_REGIONS,
DatabaseConfiguration,
MOCK_DATABASES,
} from './InstanceConfiguration.constants'
import GeographyData from './MapData.json'
// [Joshen] Foresee that we'll skip this view for initial launch
interface MapViewProps {
onSelectDeployNewReplica: (region: AWS_REGIONS_KEYS) => void
onSelectRestartReplica: (database: DatabaseConfiguration) => void
onSelectResizeReplica: (database: DatabaseConfiguration) => void
onSelectDropReplica: (database: DatabaseConfiguration) => void
}
const MapView = ({
onSelectDeployNewReplica,
onSelectRestartReplica,
onSelectResizeReplica,
onSelectDropReplica,
}: MapViewProps) => {
const { ref } = useParams()
const [mount, setMount] = useState(false)
const [zoom, setZoom] = useState<number>(1)
const [center, setCenter] = useState<[number, number]>([14, 7])
const [tooltip, setTooltip] = useState<{
x: number
y: number
region?: { key: string; country?: string; name?: string }
network?: any
}>()
const [[primary], replicas] = partition(MOCK_DATABASES, (database) => database.type === 'PRIMARY')
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 === 1.5 && selectedRegionKey !== undefined
const selectedRegion = AVAILABLE_REPLICA_REGIONS.find(
(region) => region.region === selectedRegionKey
)
const databasesInSelectedRegion = useMemo(
() =>
MOCK_DATABASES.filter((database) => database.region.includes(selectedRegionKey))
.sort((a, b) => (a.id > b.id ? 1 : 0))
.sort((database) => (database.type === 'PRIMARY' ? -1 : 0)),
[selectedRegionKey]
)
useEffect(() => {
setTimeout(() => setMount(true), 100)
}, [])
return (
<div className="bg-background">
<ComposableMap projectionConfig={{ scale: 140 }} height={354}>
<ZoomableGroup
className={mount ? 'transition-all duration-300' : ''}
center={center}
zoom={zoom}
minZoom={1}
maxZoom={1.5}
filterZoomEvent={({ constructor: { name } }) =>
!['MouseEvent', 'WheelEvent'].includes(name)
}
>
<Geographies geography={GeographyData}>
{({ geographies }) =>
geographies.map((geo) => (
<Geography
key={geo.rsmKey}
geography={geo}
strokeWidth={0.3}
pointerEvents="none"
className="fill-gray-300 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.id}-${primary.id}`}
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 databases =
MOCK_DATABASES.filter((database) => database.region.includes(region.region)) ?? []
const coordinates = AVAILABLE_REPLICA_REGIONS.find(
(r) => r.region === region.region
)?.coordinates
const hasNoDatabases = databases.length === 0
const hasPrimary = databases.some((database) => database.type === 'PRIMARY')
const replicas = databases.filter((database) => database.type === 'READ_REPLICA') ?? []
return (
<Marker
key={region.key}
coordinates={coordinates}
onMouseEnter={(event) =>
setTooltip({
x: event.clientX,
y: event.clientY,
region: {
key: region.key,
country: region.name,
name: hasNoDatabases
? undefined
: hasPrimary
? `Primary Database${
replicas.length > 0 ? ` + ${replicas.length} replicas` : ''
}`
: `${replicas.length} Read Replica${
replicas.length > 1 ? 's' : ''
} deployed`,
},
})
}
onMouseLeave={() => setTooltip(undefined)}
onClick={() => {
if (coordinates) {
setCenter(coordinates)
setZoom(1.5)
}
}}
>
{selectedRegionKey === region.region && (
<circle
r={4}
className={`animate-ping ${hasNoDatabases ? 'fill-white/30' : 'fill-brand'}`}
/>
)}
<circle
r={4}
className={`cursor-pointer ${
hasNoDatabases
? 'fill-background-surface-300 stroke-white/20'
: hasPrimary
? 'fill-brand stroke-brand-500'
: 'fill-brand-500 stroke-brand-400'
}`}
/>
</Marker>
)
})}
</ZoomableGroup>
</ComposableMap>
{tooltip?.region !== undefined && (
<div
className="absolute w-[220px] bg-black bg-opacity-50 rounded border"
style={{ left: tooltip.x - 420, top: tooltip.y - 130 }}
>
<div className="px-3 py-2 flex flex-col gap-y-2">
<div className="flex items-center gap-x-2">
<img
alt="region icon"
className="w-5 rounded-sm"
src={`${BASE_PATH}/img/regions/${tooltip.region.key}.svg`}
/>
<p className="text-xs">{tooltip.region.country}</p>
</div>
<p
className={`text-xs ${
tooltip.region.name === undefined ? 'text-foreground-light' : ''
}`}
>
{tooltip.region.name ?? 'No databases deployed'}
</p>
</div>
</div>
)}
{showRegionDetails && selectedRegion && (
<div className="absolute bottom-4 right-4 flex flex-col bg-black bg-opacity-50 backdrop-blur-sm border rounded 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>
<img
alt="region icon"
className="w-10 rounded-sm"
src={`${BASE_PATH}/img/regions/${selectedRegion.key}.svg`}
/>
</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, HH:mm:ss (ZZ)')
return (
<li
key={database.id}
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.type === 'PRIMARY'
? 'Primary Database'
: `Read Replica (ID: ${database.id})`}
{database.type === 'READ_REPLICA' && <Badge color="green">Healthy</Badge>}
</p>
<p className="text-xs text-foreground-light">AWS • {database.size}</p>
{database.type === 'READ_REPLICA' && (
<p className="text-xs text-foreground-light">Created on: {created}</p>
)}
</div>
{database.type === 'READ_REPLICA' && (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button type="text" icon={<IconMoreVertical />} className="px-1" />
</DropdownMenuTrigger>
<DropdownMenuContent className="p-0 w-40" side="bottom" align="end">
<DropdownMenuItem className="gap-x-2">
<Link
href={`/project/${ref}/settings/database?connectionString=${database.id}`}
>
View connection string
</Link>
</DropdownMenuItem>
<DropdownMenuItem
className="gap-x-2"
onClick={() => onSelectRestartReplica(database)}
>
Restart replica
</DropdownMenuItem>
<DropdownMenuItem
className="gap-x-2"
onClick={() => onSelectResizeReplica(database)}
>
Resize replica
</DropdownMenuItem>
<div className="border-t" />
<DropdownMenuItem
className="gap-x-2"
onClick={() => onSelectDropReplica(database)}
>
Drop replica
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</li>
)
})}
</ul>
</ScrollArea>
)}
<div
className={`flex items-center justify-end gap-x-2 px-4 py-4 ${
databasesInSelectedRegion.length > 0 ? 'border-t' : ''
}`}
>
<Button type="default" onClick={() => onSelectDeployNewReplica(selectedRegion.key)}>
Deploy new replica here
</Button>
<Button
type="default"
onClick={() => {
setCenter([14, 7])
setZoom(1)
}}
>
Close
</Button>
</div>
</div>
)}
</div>
)
}
export default MapView
@@ -0,0 +1,83 @@
import { useParams } from 'common'
import { useEffect, useState } from 'react'
import { Listbox, SidePanel } from 'ui'
import { useProjectAddonsQuery } from 'data/subscriptions/project-addons-query'
import { AVAILABLE_REPLICA_REGIONS, DatabaseConfiguration } from './InstanceConfiguration.constants'
import { AWS_REGIONS, AWS_REGIONS_DEFAULT, AWS_REGIONS_KEYS, BASE_PATH } from 'lib/constants'
// [Joshen] FYI this is purely for AWS only, need to update to support Fly eventually
interface ResizeReplicaPanelProps {
visible: boolean
selectedReplica?: DatabaseConfiguration
onClose: () => void
}
const ResizeReplicaPanel = ({ visible, selectedReplica, onClose }: ResizeReplicaPanelProps) => {
const { ref: projectRef } = useParams()
const { data: addons, isSuccess } = useProjectAddonsQuery({ projectRef })
const computeAddons =
addons?.available_addons.find((addon) => addon.type === 'compute_instance')?.variants ?? []
const defaultCompute = computeAddons.find((option) => option.name === 'Small')?.identifier
const [selectedCompute, setSelectedCompute] = useState(defaultCompute)
const selectedComputeMeta = computeAddons.find((addon) => addon.identifier === selectedCompute)
const replicaRegion = AVAILABLE_REPLICA_REGIONS.find((r) =>
selectedReplica?.region.includes(r.region)
)
const onSubmit = async () => {
console.log('Resize', { selectedCompute })
onClose()
}
useEffect(() => {
if (visible && isSuccess) {
}
}, [visible, isSuccess])
return (
<SidePanel
visible={visible}
onCancel={onClose}
onConfirm={() => onSubmit()}
header={`Resize read replica (ID: ${selectedReplica?.id})`}
>
<SidePanel.Content className="flex flex-col py-4 gap-y-8">
<div className="flex flex-col gap-y-1">
<p className="text-sm text-foreground-light">Replica is currently deployed in</p>
<div className="flex items-center gap-x-2">
<img
alt="region icon"
className="w-7 rounded-sm"
src={`${BASE_PATH}/img/regions/${replicaRegion?.key}.svg`}
/>
<p>{replicaRegion?.name}</p>
</div>
</div>
<Listbox
size="small"
id="compute"
name="compute"
value={selectedCompute}
onChange={setSelectedCompute}
label="Select the instance size for your read replica"
>
{computeAddons.map((option) => (
<Listbox.Option key={option.identifier} label={option.name} value={option.identifier}>
{option.name}
</Listbox.Option>
))}
</Listbox>
<p className="text-xs text-foreground-light">
Show some preview info on cost for deploying this replica here
</p>
</SidePanel.Content>
</SidePanel>
)
}
export default ResizeReplicaPanel
@@ -23,6 +23,7 @@ import {
Input,
} from 'ui'
import ProjectUpgradeAlert from '../General/Infrastructure/ProjectUpgradeAlert'
import InstanceConfiguration from './InfrastructureConfiguration/InstanceConfiguration'
const InfrastructureInfo = () => {
const { ref } = useParams()
@@ -45,6 +46,7 @@ const InfrastructureInfo = () => {
const latestPgVersion = (latest_app_version ?? '').split('supabase-postgres-')[1]
const showDbUpgrades = useFlag('databaseUpgrades')
const readReplicasEnabled = useFlag('readReplicas')
const subject = 'Request%20for%20Postgres%20upgrade%20for%20project'
const message = `Upgrade information:%0A• Manual intervention reason: ${requires_manual_intervention}`
@@ -60,19 +62,30 @@ const InfrastructureInfo = () => {
</div>
</div>
</ScaffoldContainer>
<ScaffoldDivider />
{readReplicasEnabled && (
<>
<InstanceConfiguration />
<ScaffoldDivider />
</>
)}
<ScaffoldContainer>
{!readReplicasEnabled && (
<ScaffoldSection>
<ScaffoldSectionDetail>
<p>Configuration</p>
<p className="text-foreground-light text-sm">Information on your server provider</p>
</ScaffoldSectionDetail>
<ScaffoldSectionContent>
<Input readOnly disabled value={project?.cloud_provider} label="Cloud provider" />
<Input readOnly disabled value={project?.region} label="Region" />
</ScaffoldSectionContent>
</ScaffoldSection>
)}
<ScaffoldSection>
<ScaffoldSectionDetail>
<p>Configuration</p>
<p className="text-foreground-light text-sm">Information on your server provider</p>
</ScaffoldSectionDetail>
<ScaffoldSectionContent>
<Input readOnly disabled value={project?.cloud_provider} label="Cloud provider" />
<Input readOnly disabled value={project?.region} label="Region" />
</ScaffoldSectionContent>
</ScaffoldSection>
<ScaffoldSection className="!pt-0">
<ScaffoldSectionDetail>
<p>Service Versions</p>
<p className="text-foreground-light text-sm">
@@ -0,0 +1,87 @@
import { MOCK_DATABASES } from 'components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants'
import { useState } from 'react'
import {
Popover_Shadcn_,
PopoverTrigger_Shadcn_,
Button,
IconChevronDown,
PopoverContent_Shadcn_,
Command_Shadcn_,
CommandList_Shadcn_,
CommandGroup_Shadcn_,
ScrollArea,
CommandItem_Shadcn_,
IconCheck,
} from 'ui'
interface DatabaseSelectorProps {
selectedDatabaseId: string
onChangeDatabaseId: (id: string) => void
}
const DatabaseSelector = ({ selectedDatabaseId, onChangeDatabaseId }: DatabaseSelectorProps) => {
const [open, setOpen] = useState(false)
const databases = MOCK_DATABASES.sort((a, b) => (a.id > b.id ? 1 : -1))
const selectedDatabase = databases.find((db) => db.id.toString() === selectedDatabaseId)
return (
<Popover_Shadcn_ open={open} onOpenChange={setOpen} modal={false}>
<PopoverTrigger_Shadcn_ asChild>
<div className="flex items-center space-x-2 cursor-pointer">
<Button
type="default"
className="pr-2"
iconRight={
<IconChevronDown className="text-foreground-light" strokeWidth={2} size={12} />
}
>
Source:{' '}
<span className="capitalize">
{(selectedDatabase?.type ?? '').split('_').join(' ').toLowerCase()}
{selectedDatabase?.type === 'PRIMARY' && ' database'}
</span>{' '}
{selectedDatabase?.type === 'READ_REPLICA' && <span>(ID: {selectedDatabase?.id})</span>}
</Button>
</div>
</PopoverTrigger_Shadcn_>
<PopoverContent_Shadcn_ className="p-0 w-48" side="bottom" align="end">
<Command_Shadcn_>
<CommandList_Shadcn_>
<CommandGroup_Shadcn_>
<ScrollArea className={(databases || []).length > 7 ? 'h-[210px]' : ''}>
{databases?.map((database) => {
return (
<CommandItem_Shadcn_
key={database.id}
value={database.id.toString()}
className="cursor-pointer w-full"
onSelect={() => {
onChangeDatabaseId(database.id.toString())
setOpen(false)
}}
onClick={() => {
onChangeDatabaseId(database.id.toString())
setOpen(false)
}}
>
<div className="w-full flex items-center justify-between">
<p>
{database.type === 'PRIMARY'
? 'Primary database'
: `Read replica (ID: ${database.id})`}
</p>
{database.id.toString() === selectedDatabaseId && <IconCheck />}
</div>
</CommandItem_Shadcn_>
)
})}
</ScrollArea>
</CommandGroup_Shadcn_>
</CommandList_Shadcn_>
</Command_Shadcn_>
</PopoverContent_Shadcn_>
</Popover_Shadcn_>
)
}
export default DatabaseSelector
@@ -3,6 +3,7 @@ import { get } from 'lib/common/fetch'
import { API_URL, DEFAULT_PROJECT_API_SERVICE_ID } from 'lib/constants'
import { useCallback } from 'react'
import { configKeys } from './keys'
import { ResponseError } from 'types'
export type ProjectSettingsVariables = {
projectRef?: string
@@ -77,7 +78,7 @@ export async function getProjectSettings(
}
export type ProjectSettingsData = Awaited<ReturnType<typeof getProjectSettings>>
export type ProjectSettingsError = unknown
export type ProjectSettingsError = ResponseError
export const useProjectSettingsQuery = <TData = ProjectSettingsData>(
{ projectRef }: ProjectSettingsVariables,
@@ -22,6 +22,8 @@ export const AWS_REGIONS = {
// SOUTH_AFRICA: 'South Africa (Cape Town)',
} as const
export type AWS_REGIONS_KEYS = keyof typeof AWS_REGIONS
export const FLY_REGIONS = {
SOUTHEAST_ASIA: 'Singapore',
} as const
+2
View File
@@ -84,6 +84,7 @@
"react-hot-toast": "^2.4.1",
"react-inlinesvg": "^4.0.4",
"react-markdown": "^8.0.3",
"react-simple-maps": "^3.0.0",
"react-split": "^2.0.13",
"react-tracked": "^1.7.11",
"react-virtualized-auto-sizer": "^1.0.20",
@@ -127,6 +128,7 @@
"@types/react-datepicker": "^4.3.4",
"@types/react-dom": "^18.2.8",
"@types/react-grid-layout": "^1.3.0",
"@types/react-simple-maps": "^3.0.1",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/react-window-infinite-loader": "^1.0.5",
+1
View File
@@ -13,6 +13,7 @@ import 'styles/storage.scss'
import 'styles/stripe.scss'
import 'styles/toast.scss'
import 'styles/ui.scss'
import 'styles/reactflow.scss'
import { loader } from '@monaco-editor/react'
import { TooltipProvider } from '@radix-ui/react-tooltip'
+18
View File
@@ -0,0 +1,18 @@
.instance-configuration {
.react-flow__pane {
@apply cursor-default;
}
.react-flow__node {
@apply cursor-default;
}
}
.map-path {
animation: dash 8s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: 100;
}
}
+178
View File
@@ -808,6 +808,7 @@
"react-hot-toast": "^2.4.1",
"react-inlinesvg": "^4.0.4",
"react-markdown": "^8.0.3",
"react-simple-maps": "^3.0.0",
"react-split": "^2.0.13",
"react-tracked": "^1.7.11",
"react-virtualized-auto-sizer": "^1.0.20",
@@ -851,6 +852,7 @@
"@types/react-datepicker": "^4.3.4",
"@types/react-dom": "^18.2.8",
"@types/react-grid-layout": "^1.3.0",
"@types/react-simple-maps": "^3.0.1",
"@types/react-virtualized-auto-sizer": "^1.0.1",
"@types/react-window": "^1.8.5",
"@types/react-window-infinite-loader": "^1.0.5",
@@ -11235,6 +11237,58 @@
"redux": "^4.0.0"
}
},
"node_modules/@types/react-simple-maps": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.4.tgz",
"integrity": "sha512-U9qnX0wVhxldrTpsase44fIoLpyO1OT/hgNMRoJTixj1qjpMRdSRIfih93mR3D/Tss/8CmM7dPwKMjtaGkDpmw==",
"dev": true,
"dependencies": {
"@types/d3-geo": "^2",
"@types/d3-zoom": "^2",
"@types/geojson": "*",
"@types/react": "*"
}
},
"node_modules/@types/react-simple-maps/node_modules/@types/d3-color": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz",
"integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==",
"dev": true
},
"node_modules/@types/react-simple-maps/node_modules/@types/d3-geo": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz",
"integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==",
"dev": true,
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/react-simple-maps/node_modules/@types/d3-interpolate": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz",
"integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==",
"dev": true,
"dependencies": {
"@types/d3-color": "^2"
}
},
"node_modules/@types/react-simple-maps/node_modules/@types/d3-selection": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.4.tgz",
"integrity": "sha512-5a21DF7avVPmiUau8KTsv5r76yGqbMgq4QtByoCBPXUrVFWFkd3Ob4OOhmePNRbQqfUCNFjgB4sO7sUURnKcBg==",
"dev": true
},
"node_modules/@types/react-simple-maps/node_modules/@types/d3-zoom": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.7.tgz",
"integrity": "sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==",
"dev": true,
"dependencies": {
"@types/d3-interpolate": "^2",
"@types/d3-selection": "^2"
}
},
"node_modules/@types/react-syntax-highlighter": {
"version": "15.5.7",
"dev": true,
@@ -29746,6 +29800,112 @@
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-simple-maps": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz",
"integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==",
"dependencies": {
"d3-geo": "^2.0.2",
"d3-selection": "^2.0.0",
"d3-zoom": "^2.0.0",
"topojson-client": "^3.1.0"
},
"peerDependencies": {
"prop-types": "^15.7.2",
"react": "^16.8.0 || 17.x || 18.x",
"react-dom": "^16.8.0 || 17.x || 18.x"
}
},
"node_modules/react-simple-maps/node_modules/d3-array": {
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz",
"integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==",
"dependencies": {
"internmap": "^1.0.0"
}
},
"node_modules/react-simple-maps/node_modules/d3-color": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz",
"integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ=="
},
"node_modules/react-simple-maps/node_modules/d3-dispatch": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz",
"integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA=="
},
"node_modules/react-simple-maps/node_modules/d3-drag": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz",
"integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==",
"dependencies": {
"d3-dispatch": "1 - 2",
"d3-selection": "2"
}
},
"node_modules/react-simple-maps/node_modules/d3-ease": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz",
"integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ=="
},
"node_modules/react-simple-maps/node_modules/d3-geo": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz",
"integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==",
"dependencies": {
"d3-array": "^2.5.0"
}
},
"node_modules/react-simple-maps/node_modules/d3-interpolate": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz",
"integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==",
"dependencies": {
"d3-color": "1 - 2"
}
},
"node_modules/react-simple-maps/node_modules/d3-selection": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz",
"integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA=="
},
"node_modules/react-simple-maps/node_modules/d3-timer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz",
"integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA=="
},
"node_modules/react-simple-maps/node_modules/d3-transition": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz",
"integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==",
"dependencies": {
"d3-color": "1 - 2",
"d3-dispatch": "1 - 2",
"d3-ease": "1 - 2",
"d3-interpolate": "1 - 2",
"d3-timer": "1 - 2"
},
"peerDependencies": {
"d3-selection": "2"
}
},
"node_modules/react-simple-maps/node_modules/d3-zoom": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz",
"integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==",
"dependencies": {
"d3-dispatch": "1 - 2",
"d3-drag": "2",
"d3-interpolate": "1 - 2",
"d3-selection": "2",
"d3-transition": "2"
}
},
"node_modules/react-simple-maps/node_modules/internmap": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz",
"integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="
},
"node_modules/react-smooth": {
"version": "2.0.4",
"license": "MIT",
@@ -33515,6 +33675,24 @@
"version": "2.3.6",
"license": "MIT"
},
"node_modules/topojson-client": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
"dependencies": {
"commander": "2"
},
"bin": {
"topo2geo": "bin/topo2geo",
"topomerge": "bin/topomerge",
"topoquantize": "bin/topoquantize"
}
},
"node_modules/topojson-client/node_modules/commander": {
"version": "2.20.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="
},
"node_modules/toposort": {
"version": "2.0.2",
"license": "MIT"