mirror of
https://github.com/supabase/supabase.git
synced 2026-06-12 17:27:58 +08:00
Studio is on `react@^19.2.6`, and `useEffectEvent` shipped stable in React 19.2 with the same signature as the userland polyfill. This drops the local hook in `apps/studio` and `apps/www` in favor of the built-in. **Removed:** - `apps/studio/hooks/useStaticEffectEvent.ts` - `apps/www/hooks/useStaticEffectEvent.ts` - `.claude/skills/use-static-effect-event/` — skill is obsolete **Changed:** - 26 call sites: dropped the `useStaticEffectEvent` import, added `useEffectEvent` to the existing `react` import, renamed call sites - `.claude/CLAUDE.md`: `apps/studio` row updated React 18 → React 19 - `.claude/skills/vercel-composition-patterns/SKILL.md`: removed stale "Studio uses React 18, skip these patterns" warning ## To test - `pnpm typecheck --filter=studio` — passes locally - `pnpm typecheck --filter=www` — passes locally - `grep -rn "useStaticEffectEvent"` returns nothing outside `node_modules` - Smoke-test areas that use the hook: schema visualizer edges (intersection check), spreadsheet import, sign-in/CLI login flows, side panels with unsaved-changes prompts **Out of scope:** pre-existing Tailwind lint warning on `DefaultEdge.tsx:141` (`outline` + `outline-1` conflict) — unrelated to this migration <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Internal event handling migrated to React’s built-in event hooks across the Studio app; no user-facing changes. * **Documentation** * Clarified React 19 compatibility and noted Studio now targets React 19. * Removed obsolete documentation for a deprecated internal hook. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46415?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
412 lines
15 KiB
TypeScript
412 lines
15 KiB
TypeScript
import { PermissionAction } from '@supabase/shared-types/out/constants'
|
|
import {
|
|
Background,
|
|
ColorMode,
|
|
Edge,
|
|
ReactFlow,
|
|
ReactFlowProvider,
|
|
useNodesInitialized,
|
|
useReactFlow,
|
|
} from '@xyflow/react'
|
|
import { partition } from 'lodash'
|
|
import { ChevronDown, Globe2, Loader2, Network } from 'lucide-react'
|
|
import { useTheme } from 'next-themes'
|
|
import Link from 'next/link'
|
|
import { useEffect, useEffectEvent, useMemo, useState } from 'react'
|
|
|
|
import '@xyflow/react/dist/style.css'
|
|
|
|
import { useParams } from 'common'
|
|
import { useRouter } from 'next/router'
|
|
import {
|
|
Button,
|
|
cn,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from 'ui'
|
|
|
|
import DropAllReplicasConfirmationModal from './DropAllReplicasConfirmationModal'
|
|
import { DropReplicaConfirmationModal } from './DropReplicaConfirmationModal'
|
|
import { SmoothstepEdge } from './Edge'
|
|
import { REPLICA_STATUS } from './InstanceConfiguration.constants'
|
|
import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceConfiguration.utils'
|
|
import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode'
|
|
import MapView from './MapView'
|
|
import { RestartReplicaConfirmationModal } from './RestartReplicaConfirmationModal'
|
|
import AlertError from '@/components/ui/AlertError'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { useLoadBalancersQuery } from '@/data/read-replicas/load-balancers-query'
|
|
import { Database, useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
|
import {
|
|
ReplicaInitializationStatus,
|
|
useReadReplicasStatusesQuery,
|
|
} from '@/data/read-replicas/replicas-status-query'
|
|
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
|
|
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
|
import {
|
|
useIsAwsCloudProvider,
|
|
useIsOrioleDb,
|
|
useSelectedProjectQuery,
|
|
} from '@/hooks/misc/useSelectedProject'
|
|
import { timeout } from '@/lib/helpers'
|
|
|
|
interface InstanceConfigurationUIProps {
|
|
diagramOnly?: boolean
|
|
}
|
|
|
|
const InstanceConfigurationUI = ({ diagramOnly = false }: InstanceConfigurationUIProps) => {
|
|
const router = useRouter()
|
|
const reactFlow = useReactFlow()
|
|
const isOrioleDb = useIsOrioleDb()
|
|
const { resolvedTheme } = useTheme()
|
|
const { ref: projectRef } = useParams()
|
|
const { isPending: isLoadingProject } = useSelectedProjectQuery()
|
|
|
|
const isAws = useIsAwsCloudProvider()
|
|
const { infrastructureReadReplicas } = useIsFeatureEnabled(['infrastructure:read_replicas'])
|
|
const newReplicaURL = `/project/${projectRef}/database/replication?type=Read+Replica`
|
|
|
|
const [view, setView] = useState<'flow' | 'map'>('flow')
|
|
const [showDeleteAllModal, setShowDeleteAllModal] = useState(false)
|
|
const [refetchInterval, setRefetchInterval] = useState<number | false>(10000)
|
|
const [selectedReplicaToDrop, setSelectedReplicaToDrop] = useState<Database>()
|
|
const [selectedReplicaToRestart, setSelectedReplicaToRestart] = useState<Database>()
|
|
|
|
const { can: canManageReplicas } = useAsyncCheckPermissions(PermissionAction.CREATE, 'projects')
|
|
|
|
const {
|
|
data: loadBalancers,
|
|
refetch: refetchLoadBalancers,
|
|
isSuccess: isSuccessLoadBalancers,
|
|
} = useLoadBalancersQuery({ projectRef })
|
|
const {
|
|
data,
|
|
error,
|
|
refetch: refetchReplicas,
|
|
isPending: isLoading,
|
|
isError,
|
|
isSuccess: isSuccessReplicas,
|
|
} = useReadReplicasQuery({ projectRef })
|
|
const [[primary], replicas] = useMemo(
|
|
() => partition(data ?? [], (db) => db.identifier === projectRef),
|
|
[data, projectRef]
|
|
)
|
|
const numReplicas = useMemo(() => data?.length ?? 0, [data])
|
|
|
|
const { data: replicasStatuses, isSuccess: isSuccessReplicasStatuses } =
|
|
useReadReplicasStatusesQuery(
|
|
{ projectRef },
|
|
{
|
|
refetchInterval: refetchInterval,
|
|
refetchOnWindowFocus: false,
|
|
}
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!isSuccessReplicasStatuses) return
|
|
const refetch = async () => {
|
|
const fixedStatues = [
|
|
REPLICA_STATUS.ACTIVE_HEALTHY,
|
|
REPLICA_STATUS.ACTIVE_UNHEALTHY,
|
|
REPLICA_STATUS.INIT_READ_REPLICA_FAILED,
|
|
]
|
|
const replicasInTransition = replicasStatuses.filter((db) => {
|
|
const { status } = db.replicaInitializationStatus || {}
|
|
return (
|
|
!fixedStatues.includes(db.status) || status === ReplicaInitializationStatus.InProgress
|
|
)
|
|
})
|
|
const hasTransientStatus = replicasInTransition.length > 0
|
|
|
|
// If any replica's status has changed, refetch databases
|
|
if (replicasStatuses.length !== numReplicas) {
|
|
await refetchReplicas()
|
|
setTimeout(() => refetchLoadBalancers(), 2000)
|
|
}
|
|
|
|
// If all replicas are active healthy, stop fetching statuses
|
|
if (!hasTransientStatus) {
|
|
setRefetchInterval(false)
|
|
}
|
|
}
|
|
refetch()
|
|
}, [
|
|
numReplicas,
|
|
isSuccessReplicasStatuses,
|
|
refetchLoadBalancers,
|
|
refetchReplicas,
|
|
replicasStatuses,
|
|
])
|
|
|
|
const backgroundPatternColor =
|
|
resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)'
|
|
|
|
const nodes = useMemo(
|
|
() =>
|
|
isSuccessReplicas && isSuccessLoadBalancers && primary !== undefined
|
|
? generateNodes({
|
|
primary,
|
|
replicas,
|
|
loadBalancers: loadBalancers ?? [],
|
|
onSelectRestartReplica: setSelectedReplicaToRestart,
|
|
onSelectDropReplica: setSelectedReplicaToDrop,
|
|
})
|
|
: [],
|
|
[isSuccessReplicas, isSuccessLoadBalancers, primary, replicas, loadBalancers]
|
|
)
|
|
|
|
const edges: Edge[] = useMemo(
|
|
() =>
|
|
isSuccessReplicas && isSuccessLoadBalancers
|
|
? [
|
|
...((loadBalancers ?? []).length > 0
|
|
? [
|
|
{
|
|
id: `load-balancer-${primary.identifier}`,
|
|
source: 'load-balancer',
|
|
target: primary.identifier,
|
|
type: 'smoothstep',
|
|
animated: true,
|
|
className: 'cursor-default!',
|
|
},
|
|
]
|
|
: []),
|
|
...replicas.map((database) => {
|
|
return {
|
|
id: `${primary.identifier}-${database.identifier}`,
|
|
source: primary.identifier,
|
|
target: database.identifier,
|
|
type: 'smoothstep',
|
|
animated: true,
|
|
className: 'cursor-default!',
|
|
data: {
|
|
status: database.status,
|
|
identifier: database.identifier,
|
|
connectionString: database.connectionString,
|
|
},
|
|
}
|
|
}),
|
|
]
|
|
: [],
|
|
[isSuccessLoadBalancers, isSuccessReplicas, loadBalancers, primary?.identifier, replicas]
|
|
)
|
|
|
|
const nodeTypes = useMemo(
|
|
() => ({
|
|
PRIMARY: PrimaryNode,
|
|
READ_REPLICA: ReplicaNode,
|
|
REGION: RegionNode,
|
|
LOAD_BALANCER: LoadBalancerNode,
|
|
}),
|
|
[]
|
|
)
|
|
|
|
const edgeTypes = useMemo(
|
|
() => ({
|
|
smoothstep: SmoothstepEdge,
|
|
}),
|
|
[]
|
|
)
|
|
|
|
const nodesInitialized = useNodesInitialized()
|
|
const [hasMeasuredLayout, setHasMeasuredLayout] = useState(false)
|
|
|
|
const setReactFlow = async ({ measured }: { measured: boolean }) => {
|
|
// Merge in React Flow's measured dimensions (if any) so dagre can use real
|
|
// heights instead of the first-paint fallbacks.
|
|
const measuredNodes = nodes.map((node) => {
|
|
const existing = reactFlow.getNode(node.id)
|
|
return existing?.measured ? { ...node, measured: existing.measured } : node
|
|
})
|
|
const graph = getDagreGraphLayout(measuredNodes, edges)
|
|
const { nodes: updatedNodes } = addRegionNodes(graph.nodes, graph.edges)
|
|
reactFlow.setNodes(updatedNodes)
|
|
reactFlow.setEdges(graph.edges)
|
|
|
|
// [Joshen] Odd fix to ensure that react flow snaps back to center when adding nodes
|
|
await timeout(1)
|
|
reactFlow.fitView({ maxZoom: 0.9, minZoom: 0.9 })
|
|
if (measured) setHasMeasuredLayout(true)
|
|
}
|
|
|
|
// First pass: lay out using fallback heights for any not-yet-measured nodes.
|
|
// The diagram is kept invisible until the measured pass below has run, so the
|
|
// user never sees the fallback positions.
|
|
// [Joshen] Just FYI this block is oddly triggering whenever we refocus on the viewport
|
|
// even if I change the dependency array to just data. Not blocker, just an area to optimize
|
|
useEffect(() => {
|
|
if (isSuccessReplicas && isSuccessLoadBalancers && nodes.length > 0 && view === 'flow') {
|
|
setReactFlow({ measured: false })
|
|
}
|
|
}, [isSuccessReplicas, isSuccessLoadBalancers, nodes, edges, view])
|
|
|
|
// Second pass: once React Flow has measured the nodes, re-run the layout so
|
|
// dagre uses real heights. Only `nodesInitialized` going true should trigger
|
|
// this — the first-pass effect above handles node/view changes.
|
|
const runMeasuredLayout = useEffectEvent(() => {
|
|
if (nodesInitialized && nodes.length > 0 && view === 'flow') {
|
|
setReactFlow({ measured: true })
|
|
}
|
|
})
|
|
useEffect(() => {
|
|
runMeasuredLayout()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- useEffectEvent fn intentionally not a dep (eslint-plugin-react-hooks v5 doesn't recognize stable useEffectEvent yet)
|
|
}, [nodesInitialized])
|
|
|
|
return (
|
|
<div className={cn('nowheel', diagramOnly ? 'h-full' : 'border-y')}>
|
|
<div
|
|
className={`${diagramOnly ? 'h-full' : 'h-[500px]'} w-full relative ${
|
|
isSuccessReplicas && !isLoadingProject ? '' : 'flex items-center justify-center px-28'
|
|
}`}
|
|
>
|
|
{(isLoading || isLoadingProject) && (
|
|
<Loader2 className="animate-spin text-foreground-light" />
|
|
)}
|
|
{isError && <AlertError error={error} subject="Failed to retrieve replicas" />}
|
|
{isSuccessReplicas && !isLoadingProject && (
|
|
<>
|
|
{!diagramOnly && infrastructureReadReplicas && (
|
|
<div className="z-10 absolute top-4 right-4 flex items-center justify-center gap-x-2">
|
|
<div className="flex items-center justify-center">
|
|
<ButtonTooltip
|
|
asChild
|
|
type="default"
|
|
disabled={!canManageReplicas || isOrioleDb}
|
|
className={cn(replicas.length > 0 ? 'rounded-r-none' : '')}
|
|
tooltip={{
|
|
content: {
|
|
side: 'bottom',
|
|
text: !canManageReplicas
|
|
? 'You need additional permissions to deploy replicas'
|
|
: isOrioleDb
|
|
? 'Read replicas are not supported with OrioleDB'
|
|
: undefined,
|
|
},
|
|
}}
|
|
>
|
|
<Link href={newReplicaURL}>Deploy a new replica</Link>
|
|
</ButtonTooltip>
|
|
{replicas.length > 0 && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="default"
|
|
icon={<ChevronDown size={16} />}
|
|
className="px-1 rounded-l-none border-l-0"
|
|
/>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-52 *:space-x-2">
|
|
<DropdownMenuItem asChild>
|
|
<Link href={`/project/${projectRef}/settings/compute-and-disk`}>
|
|
Resize databases
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem onClick={() => setShowDeleteAllModal(true)}>
|
|
<div>Remove all replicas</div>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
</div>
|
|
{isAws && (
|
|
<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
|
|
// FIXME: https://github.com/xyflow/xyflow/issues/4876
|
|
colorMode={'' as unknown as ColorMode}
|
|
fitView
|
|
fitViewOptions={{ minZoom: 0.9, maxZoom: 0.9 }}
|
|
// Keep the diagram invisible (but laid out, so nodes can be
|
|
// measured) until the measured-height layout pass has run.
|
|
className={cn(
|
|
'instance-configuration transition-opacity duration-150',
|
|
hasMeasuredLayout ? 'opacity-100' : 'opacity-0'
|
|
)}
|
|
zoomOnPinch={false}
|
|
zoomOnScroll={false}
|
|
nodesDraggable={false}
|
|
nodesConnectable={false}
|
|
zoomOnDoubleClick={false}
|
|
edgesFocusable={false}
|
|
edgesReconnectable={false}
|
|
defaultNodes={[]}
|
|
defaultEdges={[]}
|
|
nodeTypes={nodeTypes}
|
|
edgeTypes={edgeTypes}
|
|
proOptions={{ hideAttribution: true }}
|
|
>
|
|
<Background color={backgroundPatternColor} />
|
|
</ReactFlow>
|
|
) : (
|
|
<MapView
|
|
onSelectDeployNewReplica={() => router.push(newReplicaURL)}
|
|
onSelectRestartReplica={setSelectedReplicaToRestart}
|
|
onSelectDropReplica={setSelectedReplicaToDrop}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{!diagramOnly && (
|
|
<>
|
|
<DropReplicaConfirmationModal
|
|
selectedReplica={selectedReplicaToDrop}
|
|
onSuccess={() => setRefetchInterval(5000)}
|
|
onCancel={() => setSelectedReplicaToDrop(undefined)}
|
|
/>
|
|
|
|
<DropAllReplicasConfirmationModal
|
|
visible={showDeleteAllModal}
|
|
onSuccess={() => setRefetchInterval(5000)}
|
|
onCancel={() => setShowDeleteAllModal(false)}
|
|
/>
|
|
|
|
<RestartReplicaConfirmationModal
|
|
selectedReplica={selectedReplicaToRestart}
|
|
onSuccess={() => setRefetchInterval(5000)}
|
|
onCancel={() => setSelectedReplicaToRestart(undefined)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface InstanceConfigurationProps {
|
|
diagramOnly?: boolean
|
|
}
|
|
|
|
export const InstanceConfiguration = ({ diagramOnly = false }: InstanceConfigurationProps) => {
|
|
return (
|
|
<ReactFlowProvider>
|
|
<InstanceConfigurationUI diagramOnly={diagramOnly} />
|
|
</ReactFlowProvider>
|
|
)
|
|
}
|