+ {/* Stable live region so polled status changes are announced */}
+
+ {status !== undefined && }
+
+
+
+
+
+
+ >
+ )
+}
+
+export const HaShardNode = ({ data }: NodeProps>) => {
+ const { name } = data
+
+ return (
+
+
+
+
{name}
+
+ {/* Renders a button so the explanation is keyboard reachable */}
+
+ Automatic failover
+
+
+
+ If the primary database fails, a replica in this shard is automatically promoted to take
+ its place.
+
+
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.test.ts b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.test.ts
new file mode 100644
index 00000000000..6e8d77ba78d
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.test.ts
@@ -0,0 +1,292 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ buildHaTopology,
+ formatCellAsAvailabilityZone,
+ getPoolerStatus,
+ isPrimaryPooler,
+ selectTopologyPoolers,
+} from './HaTopology.utils'
+import type { Multipooler } from '@/data/ha-admin/ha-cluster-poolers-query'
+
+const primaryPooler = (overrides: Partial = {}): Multipooler => ({
+ id: { cell: 'eu-central-1a', name: 'pooler-1' },
+ shardKey: { database: 'postgres', shard: '0' },
+ routingState: { role: 'ROUTING_ROLE_PRIMARY' },
+ ...overrides,
+})
+
+const replicaPooler = (overrides: Partial = {}): Multipooler => ({
+ id: { cell: 'eu-central-1b', name: 'pooler-2' },
+ shardKey: { database: 'postgres', shard: '0' },
+ routingState: { role: 'ROUTING_ROLE_REPLICA' },
+ ...overrides,
+})
+
+describe('isPrimaryPooler', () => {
+ it('uses routingState.role as the authoritative signal', () => {
+ expect(isPrimaryPooler(primaryPooler())).toBe(true)
+ expect(isPrimaryPooler(replicaPooler())).toBe(false)
+ })
+
+ it('prefers routingState.role over the deprecated type field', () => {
+ expect(isPrimaryPooler(replicaPooler({ type: 'PRIMARY' }))).toBe(false)
+ })
+
+ it('falls back to the deprecated type field when routingState is omitted', () => {
+ expect(isPrimaryPooler({ type: 'PRIMARY' })).toBe(true)
+ expect(isPrimaryPooler({ type: 'REPLICA' })).toBe(false)
+ })
+
+ it('treats a pooler with every field omitted as a replica', () => {
+ expect(isPrimaryPooler({})).toBe(false)
+ })
+})
+
+describe('getPoolerStatus', () => {
+ it('treats a pooler with every field omitted as healthy (proto3 zero values mean SERVING)', () => {
+ expect(getPoolerStatus({})).toBe('healthy')
+ })
+
+ it('maps lifecycle states with or without the proto enum prefix', () => {
+ expect(getPoolerStatus({ lifecycleStatus: { status: 'STARTING' } })).toBe('coming_up')
+ expect(getPoolerStatus({ lifecycleStatus: { status: 'LIFECYCLE_STARTING' } })).toBe('coming_up')
+ expect(getPoolerStatus({ lifecycleStatus: { status: 'STOPPING' } })).toBe('going_down')
+ expect(getPoolerStatus({ lifecycleStatus: { status: 'SHUTDOWN' } })).toBe('unhealthy')
+ expect(getPoolerStatus({ lifecycleStatus: { status: 'QUARANTINED' } })).toBe('unhealthy')
+ })
+
+ it('falls back to serving status when the lifecycle is active', () => {
+ const active = { lifecycleStatus: { status: 'ACTIVE' } }
+ expect(getPoolerStatus({ ...active })).toBe('healthy')
+ expect(getPoolerStatus({ ...active, servingStatus: 'DRAINING' })).toBe('going_down')
+ expect(getPoolerStatus({ ...active, servingStatus: 'DISABLED' })).toBe('unhealthy')
+ expect(getPoolerStatus({ ...active, servingStatus: 'SERVING' })).toBe('healthy')
+ })
+
+ it('prioritizes a terminal lifecycle over the serving status', () => {
+ expect(
+ getPoolerStatus({ lifecycleStatus: { status: 'QUARANTINED' }, servingStatus: 'SERVING' })
+ ).toBe('unhealthy')
+ })
+})
+
+describe('formatCellAsAvailabilityZone', () => {
+ it('returns an AZ-shaped cell as is', () => {
+ expect(formatCellAsAvailabilityZone('eu-central-1a')).toBe('eu-central-1a')
+ })
+
+ it('extracts an AZ-shaped substring from a longer cell name', () => {
+ expect(formatCellAsAvailabilityZone('cell-eu-central-1a')).toBe('eu-central-1a')
+ expect(formatCellAsAvailabilityZone('ap-southeast-2c-0')).toBe('ap-southeast-2c')
+ })
+
+ it('falls back to the raw cell name when no AZ shape is found', () => {
+ expect(formatCellAsAvailabilityZone('cell-1')).toBe('cell-1')
+ })
+
+ it('returns undefined for an omitted cell', () => {
+ expect(formatCellAsAvailabilityZone(undefined)).toBeUndefined()
+ expect(formatCellAsAvailabilityZone('')).toBeUndefined()
+ })
+})
+
+describe('selectTopologyPoolers', () => {
+ it('projects poolers down to identity, shard, and routing role', () => {
+ const projected = selectTopologyPoolers({
+ poolers: [
+ {
+ id: { cell: 'cell-1', name: 'p-1' },
+ shardKey: { database: 'postgres', tableGroup: 'default', shard: '0-inf' },
+ type: 'PRIMARY',
+ hostname: 'some-host',
+ servingStatus: 'DRAINING',
+ lifecycleStatus: { status: 'LIFECYCLE_ACTIVE' },
+ routingState: {
+ role: 'ROUTING_ROLE_PRIMARY',
+ rule: { coordinatorTerm: '2', leaderSubterm: '1' },
+ },
+ },
+ ],
+ })
+
+ // Volatile fields (lifecycle, serving status, hostname) are dropped so the
+ // projection stays deep-equal across polls when the topology is unchanged.
+ // The routing rule is kept — it decides the primary during failover.
+ expect(projected).toEqual([
+ {
+ id: { cell: 'cell-1', name: 'p-1' },
+ shardKey: { database: 'postgres', tableGroup: 'default', shard: '0-inf' },
+ routingState: {
+ role: 'ROUTING_ROLE_PRIMARY',
+ rule: { coordinatorTerm: '2', leaderSubterm: '1' },
+ },
+ type: 'PRIMARY',
+ },
+ ])
+ })
+
+ it('preserves omitted fields as undefined and handles an empty response', () => {
+ expect(selectTopologyPoolers({})).toEqual([])
+ expect(selectTopologyPoolers({ poolers: [{}] })).toEqual([
+ { id: undefined, shardKey: undefined, routingState: undefined, type: undefined },
+ ])
+ })
+})
+
+describe('buildHaTopology', () => {
+ it('groups poolers into a shard with a primary and replicas', () => {
+ const primary = primaryPooler()
+ const replicaB = replicaPooler()
+ const replicaC = replicaPooler({ id: { cell: 'eu-central-1c', name: 'pooler-3' } })
+
+ const topology = buildHaTopology({
+ gateways: [{ id: { cell: 'eu-central-1a', name: 'gateway-1' } }],
+ poolers: [replicaC, primary, replicaB],
+ })
+
+ expect(topology.gateways).toHaveLength(1)
+ expect(topology.shards).toHaveLength(1)
+ expect(topology.shards[0].name).toBe('Shard 1')
+ expect(topology.shards[0].primary).toEqual(primary)
+ // Replicas are sorted by cell/name for a stable layout.
+ expect(topology.shards[0].replicas).toEqual([replicaB, replicaC])
+ })
+
+ it('splits poolers with different shard keys into separate shards', () => {
+ const topology = buildHaTopology({
+ gateways: [],
+ poolers: [
+ primaryPooler({ shardKey: { database: 'postgres', shard: '1' } }),
+ primaryPooler({
+ id: { cell: 'eu-central-1b', name: 'pooler-9' },
+ shardKey: { database: 'postgres', shard: '0' },
+ }),
+ ],
+ })
+
+ expect(topology.shards).toHaveLength(2)
+ expect(topology.shards.map((shard) => shard.name)).toEqual(['Shard 1', 'Shard 2'])
+ expect(topology.shards[0].primary?.shardKey?.shard).toBe('0')
+ expect(topology.shards[1].primary?.shardKey?.shard).toBe('1')
+ })
+
+ it('groups poolers with omitted shard keys into a single shard', () => {
+ const topology = buildHaTopology({
+ gateways: [],
+ poolers: [primaryPooler({ shardKey: undefined }), replicaPooler({ shardKey: undefined })],
+ })
+
+ expect(topology.shards).toHaveLength(1)
+ expect(topology.shards[0].replicas).toHaveLength(1)
+ })
+
+ it('keeps extra primaries as replicas instead of dropping them (tie keeps sort order)', () => {
+ const first = primaryPooler()
+ const second = primaryPooler({ id: { cell: 'eu-central-1b', name: 'pooler-2' } })
+
+ const topology = buildHaTopology({ gateways: [], poolers: [second, first] })
+
+ expect(topology.shards[0].primary).toEqual(first)
+ expect(topology.shards[0].replicas).toEqual([second])
+ })
+
+ it('elects the primary claimant with the highest coordinator term during failover', () => {
+ // '9' vs '10' also guards against lexicographic string comparison.
+ const outgoing = primaryPooler({
+ routingState: { role: 'ROUTING_ROLE_PRIMARY', rule: { coordinatorTerm: '9' } },
+ })
+ const incoming = primaryPooler({
+ id: { cell: 'eu-central-1b', name: 'pooler-2' },
+ routingState: { role: 'ROUTING_ROLE_PRIMARY', rule: { coordinatorTerm: '10' } },
+ })
+
+ const topology = buildHaTopology({ gateways: [], poolers: [outgoing, incoming] })
+
+ expect(topology.shards[0].primary).toEqual(incoming)
+ expect(topology.shards[0].replicas).toEqual([outgoing])
+ })
+
+ it('breaks coordinator-term ties on leader subterm', () => {
+ const lower = primaryPooler({
+ routingState: {
+ role: 'ROUTING_ROLE_PRIMARY',
+ rule: { coordinatorTerm: '2', leaderSubterm: '1' },
+ },
+ })
+ const higher = primaryPooler({
+ id: { cell: 'eu-central-1b', name: 'pooler-2' },
+ routingState: {
+ role: 'ROUTING_ROLE_PRIMARY',
+ rule: { coordinatorTerm: '2', leaderSubterm: '2' },
+ },
+ })
+
+ const topology = buildHaTopology({ gateways: [], poolers: [lower, higher] })
+
+ expect(topology.shards[0].primary).toEqual(higher)
+ })
+
+ it('prefers a claimant with a routing rule over one without (omitted terms are zero)', () => {
+ const withoutRule = primaryPooler()
+ const withRule = primaryPooler({
+ id: { cell: 'eu-central-1b', name: 'pooler-2' },
+ routingState: { role: 'ROUTING_ROLE_PRIMARY', rule: { coordinatorTerm: '1' } },
+ })
+
+ const topology = buildHaTopology({ gateways: [], poolers: [withoutRule, withRule] })
+
+ expect(topology.shards[0].primary).toEqual(withRule)
+ expect(topology.shards[0].replicas).toEqual([withoutRule])
+ })
+
+ it('handles a shard with no primary', () => {
+ const topology = buildHaTopology({ gateways: [], poolers: [replicaPooler()] })
+
+ expect(topology.shards[0].primary).toBeUndefined()
+ expect(topology.shards[0].replicas).toHaveLength(1)
+ })
+
+ it('dedupes repeated records by id (the passthrough returns one copy per cell)', () => {
+ const gateway = { id: { cell: 'cell-1', name: 'gw-1' } }
+ const primary = primaryPooler({
+ id: { cell: 'cell-1', name: 'p-1' },
+ lifecycleStatus: { status: 'LIFECYCLE_ACTIVE' },
+ })
+ const replica = replicaPooler({ id: { cell: 'cell-2', name: 'p-2' } })
+
+ const topology = buildHaTopology({
+ gateways: [gateway, gateway, gateway],
+ poolers: [primary, replica, primary, replica, primary, replica],
+ })
+
+ expect(topology.gateways).toEqual([gateway])
+ expect(topology.shards).toHaveLength(1)
+ // Duplicate copies of the primary must not be demoted to replicas.
+ expect(topology.shards[0].primary).toEqual(primary)
+ expect(topology.shards[0].replicas).toEqual([replica])
+ })
+
+ it('never dedupes records with omitted identity', () => {
+ const identityless = (overrides: Partial = {}): Multipooler => ({
+ shardKey: { database: 'postgres', shard: '0' },
+ routingState: { role: 'ROUTING_ROLE_REPLICA' },
+ ...overrides,
+ })
+
+ const topology = buildHaTopology({
+ gateways: [{}, {}],
+ poolers: [primaryPooler(), identityless(), identityless()],
+ })
+
+ expect(topology.gateways).toHaveLength(2)
+ expect(topology.shards[0].replicas).toHaveLength(2)
+ })
+
+ it('returns an empty topology for empty responses', () => {
+ const topology = buildHaTopology({ gateways: [], poolers: [] })
+
+ expect(topology.gateways).toEqual([])
+ expect(topology.shards).toEqual([])
+ })
+})
diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.ts b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.ts
new file mode 100644
index 00000000000..1624caf53b2
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.ts
@@ -0,0 +1,192 @@
+import { groupBy, partition, uniqBy } from 'lodash'
+
+import type { Multigateway } from '@/data/ha-admin/ha-cluster-gateways-query'
+import type { HaClusterPoolersData, Multipooler } from '@/data/ha-admin/ha-cluster-poolers-query'
+
+/**
+ * Pure helpers mapping the multiadmin cluster state (gateways + poolers) onto
+ * the shapes the High Availability infrastructure diagram renders. Every field
+ * on the multiadmin responses is optional because proto3 JSON omits zero
+ * values — an absent field means "the default", not "missing data".
+ */
+
+export type HaPoolerStatus = 'healthy' | 'coming_up' | 'going_down' | 'unhealthy'
+
+export interface HaShard {
+ id: string
+ name: string
+ primary?: Multipooler
+ replicas: Multipooler[]
+}
+
+export interface HaTopology {
+ gateways: Multigateway[]
+ shards: HaShard[]
+}
+
+export const getPoolerKey = (pooler: Pick) =>
+ `${pooler.id?.cell ?? 'unknown'}-${pooler.id?.name ?? 'unknown'}`
+
+export const hasPoolerIdentity = (pooler: Pick) =>
+ pooler.id?.cell !== undefined && pooler.id?.name !== undefined
+
+/**
+ * `routingState.role` is the authoritative writable signal; the deprecated
+ * `type` field is derived and only used as a fallback when the routing state is
+ * missing. The role is omitted entirely when it is ROUTING_ROLE_UNKNOWN
+ * (proto3 zero value).
+ */
+export const isPrimaryPooler = (pooler: Multipooler) => {
+ const role = pooler.routingState?.role
+ if (role !== undefined) return role === 'ROUTING_ROLE_PRIMARY'
+ return pooler.type === 'PRIMARY'
+}
+
+/**
+ * Health as reported by the pooler's topology record — not a live probe: a
+ * pooler that crashes without publishing STOPPING/SHUTDOWN can leave an
+ * ACTIVE/SERVING record behind until the topology evicts it, and "healthy"
+ * says nothing about replication progress. Live signals (WAL receiver state,
+ * replay position) exist on `GET /poolers/{cell}/{name}/status` but require a
+ * per-pooler fanout.
+ */
+export const getPoolerStatus = (pooler: Multipooler): HaPoolerStatus => {
+ // Lifecycle values may arrive with or without the proto enum prefix.
+ const lifecycle = (pooler.lifecycleStatus?.status ?? '').replace(/^LIFECYCLE_/, '')
+
+ if (lifecycle === 'QUARANTINED' || lifecycle === 'SHUTDOWN') return 'unhealthy'
+ if (lifecycle === 'STARTING') return 'coming_up'
+ if (lifecycle === 'STOPPING') return 'going_down'
+
+ // Lifecycle is ACTIVE or unknown: fall back to the serving status. An absent
+ // servingStatus means SERVING (proto3 zero value), i.e. the node is taking
+ // traffic, so the default reads as healthy.
+ if (pooler.servingStatus === 'DRAINING') return 'going_down'
+ if (pooler.servingStatus === 'DISABLED') return 'unhealthy'
+ return 'healthy'
+}
+
+// Matches the status vocabulary of the read replica surfaces (getStatusLabel).
+export const HA_POOLER_STATUS_LABELS: Record = {
+ healthy: 'Healthy',
+ coming_up: 'Coming up',
+ going_down: 'Going down',
+ unhealthy: 'Unhealthy',
+}
+
+const AWS_AZ_REGEX = /\b[a-z]{2}(?:-[a-z]+)+-\d[a-z]\b/
+
+/**
+ * Cells map 1:1 to availability zones in the alpha, but the exact cell naming
+ * format is unconfirmed — extract an AZ-shaped substring when there is one and
+ * fall back to the raw cell name otherwise.
+ */
+export const formatCellAsAvailabilityZone = (cell: string | undefined) => {
+ if (!cell) return undefined
+ return AWS_AZ_REGEX.exec(cell)?.[0] ?? cell
+}
+
+// Routing-rule terms are proto int64s, serialized as strings in JSON and
+// omitted when zero. Failover counts stay far below Number's safe range.
+const parseTerm = (value: string | undefined) => {
+ const parsed = Number(value ?? 0)
+ return Number.isFinite(parsed) ? parsed : 0
+}
+
+// Orders two primary claimants by routing rule: (coordinator term, leader
+// subterm), greatest wins.
+const compareRoutingRules = (a: Multipooler, b: Multipooler) => {
+ const ruleA = a.routingState?.rule
+ const ruleB = b.routingState?.rule
+ return (
+ parseTerm(ruleA?.coordinatorTerm) - parseTerm(ruleB?.coordinatorTerm) ||
+ parseTerm(ruleA?.leaderSubterm) - parseTerm(ruleB?.leaderSubterm)
+ )
+}
+
+export const buildHaTopology = ({
+ gateways,
+ poolers,
+}: {
+ gateways: Multigateway[]
+ poolers: Multipooler[]
+}): HaTopology => {
+ // The /ha-admin passthrough can return the same record multiple times (one
+ // copy per cell it fans out to), so both lists must be deduped by id —
+ // duplicate poolers would otherwise produce duplicate React Flow node ids,
+ // and extra copies of the primary would render as replicas. Records without
+ // a complete identity can't be told apart, so they are never deduped (keying
+ // by the record itself keeps each one unique).
+ const uniqueGateways = uniqBy(gateways, (gateway) =>
+ hasPoolerIdentity(gateway) ? getPoolerKey(gateway) : gateway
+ )
+ const uniquePoolers = uniqBy(poolers, (pooler) =>
+ hasPoolerIdentity(pooler) ? getPoolerKey(pooler) : pooler
+ )
+
+ const sortedPoolers = [...uniquePoolers].sort((a, b) =>
+ getPoolerKey(a).localeCompare(getPoolerKey(b))
+ )
+ const poolersByShard = groupBy(
+ sortedPoolers,
+ (pooler) =>
+ `${pooler.shardKey?.database ?? ''}/${pooler.shardKey?.tableGroup ?? ''}/${pooler.shardKey?.shard ?? ''}`
+ )
+
+ const shards = Object.entries(poolersByShard)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([id, shardPoolers], index) => {
+ const [primaries, replicas] = partition(shardPoolers, isPrimaryPooler)
+ // During a failover the outgoing and incoming primary can briefly both
+ // claim ROUTING_ROLE_PRIMARY — the highest routing rule wins, matching
+ // the multigateway's election. Losing claimants render as replicas
+ // rather than being dropped; ties keep the first in sorted order so the
+ // result stays deterministic.
+ const primary = primaries.reduce(
+ (best, candidate) =>
+ best === undefined || compareRoutingRules(candidate, best) > 0 ? candidate : best,
+ undefined
+ )
+ return {
+ id,
+ name: `Shard ${index + 1}`,
+ primary,
+ replicas: [...primaries.filter((pooler) => pooler !== primary), ...replicas],
+ }
+ })
+
+ return { gateways: uniqueGateways, shards }
+}
+
+/**
+ * Query `select` projecting poolers down to the fields the topology depends on
+ * (identity, shard, routing role) — live status is self-fetched by the
+ * individual nodes and edges. React Query's structural sharing then keeps the
+ * result referentially stable across polls, so refetches only re-run
+ * layout/fitView when the topology actually changes (volatile fields like
+ * lifecycle timestamps would otherwise churn the data identity on every poll).
+ */
+const projectRoutingRule = (rule: NonNullable['rule']) =>
+ rule === undefined
+ ? undefined
+ : { coordinatorTerm: rule.coordinatorTerm, leaderSubterm: rule.leaderSubterm }
+
+const projectRoutingState = (routingState: Multipooler['routingState']) =>
+ routingState === undefined
+ ? undefined
+ : { role: routingState.role, rule: projectRoutingRule(routingState.rule) }
+
+export const selectTopologyPoolers = (data: HaClusterPoolersData): Multipooler[] =>
+ (data.poolers ?? []).map((pooler) => ({
+ id: pooler.id === undefined ? undefined : { cell: pooler.id.cell, name: pooler.id.name },
+ shardKey:
+ pooler.shardKey === undefined
+ ? undefined
+ : {
+ database: pooler.shardKey.database,
+ tableGroup: pooler.shardKey.tableGroup,
+ shard: pooler.shardKey.shard,
+ },
+ routingState: projectRoutingState(pooler.routingState),
+ type: pooler.type,
+ }))
diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants.ts b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants.ts
index 2d49e4739cc..7f8f2b2b6db 100644
--- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants.ts
+++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.constants.ts
@@ -44,9 +44,22 @@ export type EdgeData = {
export const NODE_WIDTH = 660
export const NODE_SEP = 20
+// Rendered width of the node cards; dagre lays out with NODE_WIDTH / 2 so
+// cards get a little breathing room between columns.
+export const NODE_CARD_WIDTH = NODE_WIDTH / 2 - 10
+
// The region wrapper is a static, non-measured sibling node with a fixed size.
export const REGION_NODE_HEIGHT = 162
+// The shard wrapper is a static, non-measured background node sized around its
+// children, with room reserved above them for the group header pill.
+export const SHARD_NODE_PADDING = 16
+export const SHARD_HEADER_HEIGHT = 42
+
+// dagre rank separation for the High Availability layout — leaves room for the
+// shard group header between the gateway row and the primary row.
+export const HA_RANKSEP = 96
+
// First-paint fallback heights for the dagre layout, only used before React
// Flow has measured the real nodes. Subsequent layouts use node.measured.height.
export const NODE_HEIGHT_FALLBACKS: Record = {
@@ -54,6 +67,9 @@ export const NODE_HEIGHT_FALLBACKS: Record = {
PRIMARY: 140,
READ_REPLICA: 140,
REGION: REGION_NODE_HEIGHT,
+ HA_GATEWAY: 64,
+ HA_PRIMARY: 150,
+ HA_REPLICA: 90,
}
// [Joshen] Coordinates from https://github.com/tobilg/aws-edge-locations/blob/main/data/aws-edge-locations.json
diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx
index c49a7416010..7b4b4732259 100644
--- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx
+++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx
@@ -1,24 +1,14 @@
-import {
- Background,
- ColorMode,
- Edge,
- ReactFlow,
- ReactFlowProvider,
- useNodesInitialized,
- useReactFlow,
-} from '@xyflow/react'
+import { Edge, ReactFlowProvider } from '@xyflow/react'
+import { useParams } from 'common'
import { partition } from 'lodash'
import { Globe2, Loader2, Network } from 'lucide-react'
-import { useTheme } from 'next-themes'
-import { useEffect, useEffectEvent, useMemo, useState } from 'react'
-
-import '@xyflow/react/dist/style.css'
-
-import { useParams } from 'common'
-import { Button, cn } from 'ui'
+import { useEffect, useMemo, useState } from 'react'
+import { Button } from 'ui'
+import { DiagramFlow } from './DiagramFlow'
import { SmoothstepEdge } from './Edge'
-import { addRegionNodes, generateNodes, getDagreGraphLayout } from './InstanceConfiguration.utils'
+import { HaInstanceConfiguration } from './HaInstanceConfiguration'
+import { addRegionNodes, generateNodes } from './InstanceConfiguration.utils'
import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode'
import MapView from './MapView'
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
@@ -29,13 +19,22 @@ import {
ReplicaInitializationStatus,
useReadReplicasStatusesQuery,
} from '@/data/read-replicas/replicas-status-query'
+import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useIsAwsCloudProvider, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
-import { timeout } from '@/lib/helpers'
+
+const nodeTypes = {
+ PRIMARY: PrimaryNode,
+ READ_REPLICA: ReplicaNode,
+ REGION: RegionNode,
+ LOAD_BALANCER: LoadBalancerNode,
+}
+
+const edgeTypes = {
+ smoothstep: SmoothstepEdge,
+}
const InstanceConfigurationUI = () => {
- const reactFlow = useReactFlow()
- const { resolvedTheme } = useTheme()
const { ref: projectRef } = useParams()
const { isPending: isLoadingProject } = useSelectedProjectQuery()
@@ -109,9 +108,6 @@ const InstanceConfigurationUI = () => {
replicasStatuses,
])
- const backgroundPatternColor =
- resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)'
-
const nodes = useMemo(
() =>
isSuccessReplicas && isSuccessLoadBalancers && primary !== undefined
@@ -135,7 +131,8 @@ const InstanceConfigurationUI = () => {
source: 'load-balancer',
target: primary.identifier,
type: 'smoothstep',
- animated: true,
+ // Static: no data flows between the load balancer and the
+ // database — the line only indicates a relation.
className: 'cursor-default!',
},
]
@@ -160,76 +157,21 @@ const InstanceConfigurationUI = () => {
[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()
- }, [nodesInitialized])
-
return (
-