[FE-3717] feat(studio): Multigres cluster topology diagram (#49298)

Adds an infrastructure/topology diagram for High Availability
(Multigres) projects showing the real cluster topology — gateway tier,
shard group, and the primary + read replicas inside it — on both the
project homepage and the database/replication page, replacing the
primary-only view and the "Replication unavailable" empty state.

<img width="790" height="541" alt="Screenshot 2026-08-20 at 8 23 42 PM"
src="https://github.com/user-attachments/assets/0bce21e3-2091-4285-84ca-60fdecb10d39"
/>

Addresses
[FE-3717](https://linear.app/supabase/issue/FE-3717/show-replicas-in-replication-diagram).

**Added:**

- `data/ha-admin/` — read-only queries for the mgmt-api
`/ha-admin/v1/{gateways,poolers,cells,databases}` multiadmin passthrough
(ported from `bobbie/ha-stub`, re-authored to `queryOptions`). Responses
are validated with zod at the fetch boundary (all fields optional per
proto3 zero-value omission; enum-shaped fields stay plain strings so new
proto values degrade gracefully); malformed payloads surface through the
diagram's error fallback.
- `HaTopology.utils.ts` — pure topology mapper (+ 26 unit tests): shard
grouping, primary identified via `routingState.role` (deprecated `type`
as fallback) with **failover-safe election** — when the outgoing and
incoming primary briefly both claim `ROUTING_ROLE_PRIMARY`, the highest
routing rule (coordinator term, leader subterm) wins, matching the
multigateway's own election — plus status mapping onto the existing
Healthy / Coming up / Going down / Unhealthy vocabulary, and an AZ
formatter for `id.cell` that degrades to the raw cell name.
- HA diagram nodes/edges: `Multigateway` card, shard group box with
header pill (`Shard 1`, `Automatic failover` + tooltip), `Primary
Database` card styled like the standard diagram's — neutral border,
green icon chip (with the standard CPU / Disk / RAM footer — connections
omitted until their meaning through the multigateway is confirmed),
`Read Replica` cards, and the standard animated replication edges
(status lives on the card badges). Poolers and gateways poll every 30s
without re-running layout (topology projection + structural sharing).
Drag-to-pan works through the shard group box, and the metrics footer's
skeleton matches the loaded row height so the card doesn't shift.
- Accessibility: the failover tooltip trigger is a keyboard-focusable
button, status badges sit in stable `role="status"` live regions, the
region flag is decorative (`alt=""`), and the edge dash/spinner
animations respect `prefers-reduced-motion` (applied to the pipelines
diagram's edges too).
- Fallbacks: `AlertError` ("Failed to retrieve cluster topology") when
either ha-admin query errors, and a "Cluster topology unavailable" empty
state when the topology comes back empty — never a half-rendered
diagram.

**Changed:**

- `InstanceConfiguration` is now topology-source-aware: it branches
internally on `useHighAvailability()`, so both surfaces (homepage
`TopSection` and the replication page) get the right diagram with no new
wiring. The two-pass measured dagre layout moved into a shared
`DiagramFlow`; `nodeTypes`/`edgeTypes` are module-level consts.
- `getEdgeVisual` + the mid-edge icon chip lifted out of
`ReplicationDiagram/Edges.tsx` into
`components/ui/ReactFlow/EdgeVisual.tsx` so both diagrams derive edge
icon + line style from one state object (no behavior change for the
pipelines diagram). The primary card's CPU/Disk/RAM footer is likewise
extracted into a shared `ComputeMetricsFooter`.
- Fixes a latent relayout loop inherited from the region-box pattern:
handing React Flow a freshly created (unmeasured) group node on every
layout pass reset `nodesInitialized`, re-triggering the measured pass
and `fitView` forever — which made the diagram snap back to center and
effectively unpannable. The shared `DiagramFlow` now re-attaches known
measurements to group nodes, which also covers the standard diagram's
region boxes.
- Standard diagram: the API Load Balancer → primary edge is now static —
no data flows over it, the line only indicates a relation.
- `database/replication` page: the HA early-return empty state is
replaced by the diagram under a "High Availability cluster topology"
header. Non-HA projects are untouched.

**Intentional deviations from the mock** (for design review):

1. **No per-replica regions** — alpha replicas are one-per-cell inside a
single region, so the mock's `eu-west-1` / `ap-southeast-1` on sibling
replicas would be false. Availability zone per node, region shown once
on the primary.
2. **"Primary Database", not "Main Database"** — matches the string both
existing diagrams already ship, and the same component now renders both
project types.
3. **No collapse chevron on the shard header** — alpha has exactly one
shard; collapsing it would hide the whole diagram. The group box still
ships; add collapse when `shards.length > 1`.
4. **Failover shown on the shard group, not replica cards** — failover
is a cohort property; per-card badging would assert readiness we can't
verify without a per-pooler `/status` fanout.
5. **Standard node/edge styling reused** (per review) — neutral primary
border + green chip and the default animated edges instead of the mock's
green ring and dashed green arrowed edges, keeping the HA and non-HA
diagrams visually consistent.

**Confirmed against a real local Multigres cluster:** cells are named
`cell-1`/`cell-2`/… (not AZ-shaped — the AZ formatter falls back to the
raw cell name as designed); `GET /platform/projects/{ref}/databases`
returns only the primary row for HA projects; and the `/ha-admin`
passthrough returns **each gateway/pooler record once per cell it fans
out to** — the topology mapper dedupes by id, but worth confirming with
@sbc-bobbie whether the backend should dedupe.

**Known alpha limitation:** node health and the "replicating" edge state
derive from the pooler's *topology record*
(`lifecycleStatus`/`servingStatus`), not a live probe — a pooler that
crashes without publishing a terminal state can read as healthy until
the topology evicts its record, and a serving replica with paused replay
still shows a green edge. This matches the existing replication
diagram's semantics (`ACTIVE_HEALTHY` ⇒ animated edge). Live per-pooler
signals (WAL receiver state, replay position) exist on `GET
/poolers/{cell}/{name}/status` but need a per-pooler fanout —
deliberately deferred, noted on `getPoolerStatus`.

**Still to confirm** (doesn't block review): whether the `/ha-admin`
passthrough is deployed to production or staging-only (if staging-only,
this should get a flag before GA).

## To test

Tested end-to-end locally against a real Multigres project
(standard-project regression pass, HA creation flow, error fallback
against real 500s, and full topology + polling + console checks against
live multiadmin data):

- **HA project homepage**: diagram card shows Multigateway → shard box
(`Shard 1`, count badge, `Automatic failover` tooltip) → green-bordered
Primary Database (region, AZ, size) + Read Replica cards (AZ), dashed
green animated edges to healthy replicas. No flow/map toggle for HA.
- **HA project → Database → Replication**: same diagram under a "High
Availability cluster topology" header; no Destinations section; the old
"Replication unavailable…" state is gone.
- **Error path**: if `/ha-admin/v1/*` fails, both surfaces show "Failed
to retrieve cluster topology" with Contact support — no partial diagram.
- **Standard project regression**: homepage diagram (primary card, flow
⇄ map toggle round-trips), replication page (pipelines diagram +
Destinations) all unchanged; zero requests to `/ha-admin/*`.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
  - Added High Availability topology diagrams to the Replication page.
- Display gateways, primary databases, replicas, shards, statuses,
regions, infrastructure details, and compute metrics.
- Added observability links and live topology updates with loading,
error, and unavailable states.

- **Bug Fixes**
- Improved handling of incomplete infrastructure identities and
unexpected data.
  - Corrected topology layout, node spacing, and visual edge behavior.

- **Accessibility**
- Reduced-motion preferences now disable diagram animations and loading
effects.
  - Improved status announcements for assistive technologies.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
Alaister YoungandAlaister Young authored and GitHub committed 2026-08-26 16:51:36 +08:00
1 parent 013af4ed58
commit 928049ce2c
24 files changed
+1674 -266

No files matched your search

@@ -1,11 +1,14 @@
import { BaseEdge, EdgeLabelRenderer, getSmoothStepPath, type EdgeProps } from '@xyflow/react'
import { useParams } from 'common'
import { ArrowRight, Loader2, Square, X, type LucideIcon } from 'lucide-react'
import { useParams, useReducedMotion } from 'common'
import { useMemo } from 'react'
import { cn } from 'ui'
import { getStatusName } from '../Pipeline.utils'
import { STATUS_REFRESH_FREQUENCY_MS } from '../Replication.constants'
import {
EdgeVisualChip,
getEdgeVisual,
type ReplicationState,
} from '@/components/ui/ReactFlow/EdgeVisual'
import { useReplicationPipelineStatusQuery } from '@/data/replication/pipeline-status-query'
import { useReplicationPipelinesQuery } from '@/data/replication/pipelines-query'
import {
@@ -19,67 +22,6 @@ type EdgeData = {
shiftEdgeEnd: boolean
}
interface ReplicationState {
isComingUp: boolean
isReplicating: boolean
isFailed: boolean
}
interface EdgeVisual {
Icon: LucideIcon
// CSS color shared by the icon and the connecting line so they always match.
color: string
opacity: number
dashArray: string
shouldAnimate: boolean
shouldSpin?: boolean
isFilled?: boolean
strokeWidth?: number
}
// Picks the icon + line appearance for a replication state. Both the icon and the line are derived
// here from the same state so they always stay in sync. We deliberately don't surface lag: the line
// just communicates whether data is moving, stopped, starting, or broken.
const getEdgeVisual = ({ isComingUp, isReplicating, isFailed }: ReplicationState): EdgeVisual => {
if (isFailed) {
return {
Icon: X,
color: 'hsl(var(--destructive-default))',
opacity: 1,
dashArray: '5 5',
shouldAnimate: false,
strokeWidth: 4,
}
}
if (isComingUp) {
return {
Icon: Loader2,
color: 'var(--foreground-light)',
opacity: 1,
dashArray: '5',
shouldAnimate: true,
shouldSpin: true,
}
}
if (isReplicating) {
return {
Icon: ArrowRight,
color: 'hsl(var(--brand-default))',
opacity: 1,
dashArray: '5',
shouldAnimate: true,
}
}
return {
Icon: Square,
color: 'var(--foreground-lighter)',
opacity: 0.5,
dashArray: '5 5',
shouldAnimate: false,
isFilled: true,
}
}
export const SmoothstepEdge = ({
sourceX,
sourceY,
@@ -92,6 +34,7 @@ export const SmoothstepEdge = ({
data,
}: EdgeProps) => {
const { ref: projectRef = 'default' } = useParams()
const prefersReducedMotion = useReducedMotion()
const { identifier, shiftEdgeEnd } = (data || {}) as EdgeData
const { data: pipelinesData } = useReplicationPipelinesQuery({ projectRef })
@@ -126,16 +69,7 @@ export const SmoothstepEdge = ({
targetPosition,
})
const {
Icon,
color,
opacity,
dashArray,
shouldAnimate,
shouldSpin,
isFilled,
strokeWidth = 2,
} = getEdgeVisual(replicationState)
const visual = getEdgeVisual(replicationState)
return (
<>
@@ -144,11 +78,14 @@ export const SmoothstepEdge = ({
markerEnd={markerEnd}
style={{
...style,
stroke: color,
strokeWidth,
opacity,
strokeDasharray: dashArray,
animation: shouldAnimate ? 'dashdraw 0.5s linear infinite' : undefined,
stroke: visual.color,
strokeWidth: visual.strokeWidth,
opacity: visual.opacity,
strokeDasharray: visual.dashArray,
animation:
visual.shouldAnimate && !prefersReducedMotion
? 'dashdraw 0.5s linear infinite'
: undefined,
}}
/>
<EdgeLabelRenderer>
@@ -160,18 +97,7 @@ export const SmoothstepEdge = ({
}}
className="nodrag nopan"
>
<div
className={cn(
'w-6 h-6 rounded-full flex items-center justify-center border bg-surface-100'
)}
style={{ borderColor: color }}
>
<Icon
size={14}
className={cn(shouldSpin && 'animate-spin')}
style={{ color, fill: isFilled ? color : undefined }}
/>
</div>
<EdgeVisualChip visual={visual} />
</div>
</EdgeLabelRenderer>
</>
@@ -0,0 +1,81 @@
import { useParams } from 'common'
import Link from 'next/link'
import { Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { metricColor } from './InstanceNode.utils'
import { useComputeMetrics } from '@/hooks/analytics/useComputeMetrics'
/**
* Compute metrics row at the bottom of a primary database node card, linking
* to the database report. Metrics are project-scoped, which reports the
* primary on both standard and High Availability projects.
*/
export const ComputeMetricsFooter = ({ showConnections = true }: { showConnections?: boolean }) => {
const { ref } = useParams()
const {
cpu,
disk,
memory,
connections,
isLoading: isMetricsLoading,
isError: isMetricsError,
} = useComputeMetrics({
projectRef: ref,
})
const observabilityUrl = `/project/${ref}/observability/database`
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={observabilityUrl}
className="border-t px-3 py-2 hover:bg-surface-200 transition flex items-center gap-x-3 text-xs"
>
{/* Stable live region: announces loading/failure, never the polled values */}
<span role="status" className="sr-only">
{isMetricsLoading && 'Loading metrics'}
{!isMetricsLoading && isMetricsError && 'Metrics unavailable'}
</span>
{/* h-4 matches the text-xs line height so the card doesn't shift when metrics load */}
{isMetricsLoading && (
<div
aria-hidden="true"
className="h-4 w-44 rounded-sm bg-surface-300 motion-safe:animate-pulse"
/>
)}
{!isMetricsLoading && isMetricsError && (
<span aria-hidden="true" className="text-foreground-lighter">
Metrics unavailable
</span>
)}
{!isMetricsLoading && !isMetricsError && (
<>
<span>
CPU <span className={metricColor(cpu)}>{cpu.toFixed(0)}%</span>
</span>
<span className="text-foreground-lighter">·</span>
<span>
Disk <span className={metricColor(disk)}>{disk.toFixed(0)}%</span>
</span>
<span className="text-foreground-lighter">·</span>
<span>
RAM <span className={metricColor(memory)}>{memory.toFixed(0)}%</span>
</span>
{showConnections && connections.max > 0 && (
<>
<span className="text-foreground-lighter">·</span>
<span className="text-foreground-light">
{connections.peak}/{connections.max} conns
</span>
</>
)}
</>
)}
</Link>
</TooltipTrigger>
<TooltipContent side="bottom">Go to Database Report</TooltipContent>
</Tooltip>
)
}
@@ -0,0 +1,132 @@
import {
Background,
ColorMode,
Edge,
EdgeTypes,
Node,
NodeTypes,
ReactFlow,
useNodesInitialized,
useReactFlow,
} from '@xyflow/react'
import { useTheme } from 'next-themes'
import { useEffect, useEffectEvent, useState } from 'react'
import '@xyflow/react/dist/style.css'
import { cn } from 'ui'
import { getDagreGraphLayout } from './InstanceConfiguration.utils'
import { timeout } from '@/lib/helpers'
interface DiagramFlowProps {
nodes: Node[]
edges: Edge[]
nodeTypes: NodeTypes
edgeTypes: EdgeTypes
/** Prepends background group nodes (regions, shards) after the dagre layout runs */
addGroupNodes: (nodes: Node[], edges: Edge[]) => { nodes: Node[]; edges: Edge[] }
ranksep?: number
}
/**
* Shared React Flow canvas for the infrastructure diagrams, laid out with a
* two-pass measured dagre layout: the first pass uses fallback heights while
* the diagram is held invisible, then `useNodesInitialized` triggers a second
* pass that re-runs dagre with the real measured node heights before fading
* the diagram in. Must be rendered inside a `ReactFlowProvider`.
*/
export const DiagramFlow = ({
nodes,
edges,
nodeTypes,
edgeTypes,
addGroupNodes,
ranksep,
}: DiagramFlowProps) => {
const reactFlow = useReactFlow()
const { resolvedTheme } = useTheme()
const nodesInitialized = useNodesInitialized()
const [hasMeasuredLayout, setHasMeasuredLayout] = useState(false)
const backgroundPatternColor =
resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)'
const setReactFlow = useEffectEvent(async ({ isMeasuredPass }: { isMeasuredPass: 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, { ranksep })
const { nodes: groupedNodes } = addGroupNodes(graph.nodes, graph.edges)
// Re-attach known measurements to the freshly created group nodes too —
// handing setNodes an unmeasured node resets the store's nodesInitialized
// flag, which would re-trigger the measured pass below on every re-measure
// and loop forever (each iteration re-running fitView, so the diagram
// snaps back to center and can't be panned).
const updatedNodes = groupedNodes.map((node) => {
if (node.measured !== undefined) return node
const existing = reactFlow.getNode(node.id)
return existing?.measured ? { ...node, measured: existing.measured } : node
})
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 (isMeasuredPass) 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.
useEffect(() => {
if (nodes.length > 0) {
setReactFlow({ isMeasuredPass: false })
}
}, [nodes, edges])
// 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 changes.
const runMeasuredLayout = useEffectEvent(() => {
if (nodesInitialized && nodes.length > 0) {
setReactFlow({ isMeasuredPass: true })
}
})
useEffect(() => {
runMeasuredLayout()
}, [nodesInitialized])
return (
<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>
)
}
@@ -0,0 +1,113 @@
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'common'
import { Loader2 } from 'lucide-react'
import { useMemo } from 'react'
import { DiagramFlow } from './DiagramFlow'
import { addShardNodes, generateHaNodesAndEdges } from './HaInstanceConfiguration.utils'
import { HaPrimaryNode, HaReplicaNode, HaShardNode, MultigatewayNode } from './HaInstanceNode'
import { buildHaTopology, selectTopologyPoolers } from './HaTopology.utils'
import { HA_RANKSEP } from './InstanceConfiguration.constants'
import { AlertError } from '@/components/ui/AlertError'
import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState'
import { haClusterGatewaysQueryOptions } from '@/data/ha-admin/ha-cluster-gateways-query'
import { haClusterPoolersQueryOptions } from '@/data/ha-admin/ha-cluster-poolers-query'
const nodeTypes = {
HA_GATEWAY: MultigatewayNode,
HA_PRIMARY: HaPrimaryNode,
HA_REPLICA: HaReplicaNode,
HA_SHARD: HaShardNode,
}
// The gateway and replication edges both use React Flow's built-in smoothstep.
const edgeTypes = {}
const POOLER_STATUS_REFRESH_MS = 30_000
/**
* Cluster topology diagram for High Availability (Multigres) projects:
* gateway tier → shard group → primary + read replicas, driven by the
* read-only multiadmin `/ha-admin` passthrough.
*/
export const HaInstanceConfiguration = () => {
const { ref: projectRef } = useParams()
// Gateways poll on the same interval as poolers so the gateway count and
// gateway→primary edge track cluster changes while the page stays open.
const {
data: gatewaysData,
error: gatewaysError,
isPending: isPendingGateways,
isError: isErrorGateways,
} = useQuery({
...haClusterGatewaysQueryOptions({ projectRef }),
refetchInterval: POOLER_STATUS_REFRESH_MS,
})
const {
data: poolers,
error: poolersError,
isPending: isPendingPoolers,
isError: isErrorPoolers,
} = useQuery({
...haClusterPoolersQueryOptions({ projectRef }),
refetchInterval: POOLER_STATUS_REFRESH_MS,
select: selectTopologyPoolers,
})
const gateways = gatewaysData?.gateways
const { nodes, edges } = useMemo(
() =>
generateHaNodesAndEdges(
buildHaTopology({ gateways: gateways ?? [], poolers: poolers ?? [] })
),
[gateways, poolers]
)
const isPending = isPendingGateways || isPendingPoolers
const isError = isErrorGateways || isErrorPoolers
const error = gatewaysError ?? poolersError
if (isPending) {
return (
<div role="status" className="h-full w-full flex items-center justify-center">
<span className="sr-only">Loading cluster topology...</span>
<Loader2 aria-hidden="true" className="motion-safe:animate-spin text-foreground-light" />
</div>
)
}
if (isError) {
return (
<div className="h-full w-full flex items-center justify-center px-28">
<AlertError error={error} subject="Failed to retrieve cluster topology" />
</div>
)
}
if ((poolers ?? []).length === 0) {
return (
<div className="h-full w-full flex items-center justify-center p-6">
<HighAvailabilityDisabledEmptyState
title="Cluster topology unavailable"
description="Topology data for this project is not available yet. Contact support if this persists."
/>
</div>
)
}
return (
<div className="nowheel h-full w-full relative">
<DiagramFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
addGroupNodes={addShardNodes}
ranksep={HA_RANKSEP}
/>
</div>
)
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { generateHaNodesAndEdges } from './HaInstanceConfiguration.utils'
import { buildHaTopology } from './HaTopology.utils'
describe('generateHaNodesAndEdges', () => {
it('gives every pooler a unique node id even when identities are omitted', () => {
const topology = buildHaTopology({
gateways: [],
poolers: [
{
id: { cell: 'cell-1', name: 'p-1' },
shardKey: { shard: '0' },
routingState: { role: 'ROUTING_ROLE_PRIMARY' },
},
{ shardKey: { shard: '0' }, routingState: { role: 'ROUTING_ROLE_REPLICA' } },
{ shardKey: { shard: '0' }, routingState: { role: 'ROUTING_ROLE_REPLICA' } },
],
})
const { nodes, edges } = generateHaNodesAndEdges(topology)
const poolerNodeIds = nodes
.filter((node) => node.type === 'HA_PRIMARY' || node.type === 'HA_REPLICA')
.map((node) => node.id)
expect(poolerNodeIds).toHaveLength(3)
expect(new Set(poolerNodeIds).size).toBe(3)
// Each replica still gets its own edge from the primary.
expect(edges).toHaveLength(2)
expect(new Set(edges.map((edge) => edge.id)).size).toBe(2)
})
})
@@ -0,0 +1,154 @@
import { Edge, Node } from '@xyflow/react'
import { groupBy } from 'lodash'
import { getPoolerKey, hasPoolerIdentity, HaTopology } from './HaTopology.utils'
import {
NODE_CARD_WIDTH,
SHARD_HEADER_HEIGHT,
SHARD_NODE_PADDING,
} from './InstanceConfiguration.constants'
import { getDagreNodeHeight } from './InstanceConfiguration.utils'
export type MultigatewayNodeData = {
numGateways: number
}
export type HaPoolerNodeData = {
cell?: string
name?: string
shardId: string
shardName: string
// Only set on primary nodes; hides the unused top handle when no gateway exists.
hasGateway?: boolean
}
export type HaShardNodeData = {
name: string
}
// Records without a complete identity fall back to a positional id so two of
// them never collide on the same React Flow node id (which would drop a card).
const getPoolerNodeId = (pooler: Parameters<typeof getPoolerKey>[0], fallbackId: string) =>
hasPoolerIdentity(pooler) ? `pooler-${getPoolerKey(pooler)}` : `pooler-${fallbackId}`
export const generateHaNodesAndEdges = (topology: HaTopology): { nodes: Node[]; edges: Edge[] } => {
const position = { x: 0, y: 0 }
const nodes: Node[] = []
const edges: Edge[] = []
// The alpha runs one multigateway per cell; the diagram collapses them into a
// single gateway node with a count rather than rendering one card per cell.
const hasGateway = topology.gateways.length > 0
if (hasGateway) {
nodes.push({
position,
id: 'multigateway',
type: 'HA_GATEWAY',
data: { numGateways: topology.gateways.length } satisfies MultigatewayNodeData,
})
}
topology.shards.forEach((shard) => {
let primaryId: string | undefined
if (shard.primary !== undefined) {
primaryId = getPoolerNodeId(shard.primary, `${shard.id}-primary`)
nodes.push({
position,
id: primaryId,
type: 'HA_PRIMARY',
data: {
cell: shard.primary.id?.cell,
name: shard.primary.id?.name,
shardId: shard.id,
shardName: shard.name,
hasGateway,
} satisfies HaPoolerNodeData,
})
if (hasGateway) {
edges.push({
id: `multigateway-${primaryId}`,
source: 'multigateway',
target: primaryId,
type: 'smoothstep',
className: 'cursor-default!',
})
}
}
shard.replicas.forEach((replica, replicaIndex) => {
const replicaId = getPoolerNodeId(replica, `${shard.id}-replica-${replicaIndex}`)
nodes.push({
position,
id: replicaId,
type: 'HA_REPLICA',
data: {
cell: replica.id?.cell,
name: replica.id?.name,
shardId: shard.id,
shardName: shard.name,
} satisfies HaPoolerNodeData,
})
if (primaryId !== undefined) {
edges.push({
id: `${primaryId}-${replicaId}`,
source: primaryId,
target: replicaId,
type: 'smoothstep',
animated: true,
className: 'cursor-default!',
})
}
})
})
return { nodes, edges }
}
const getNodeWidth = (node: Node) => node.measured?.width ?? NODE_CARD_WIDTH
/**
* Prepends a background group node per shard wrapping its primary + replicas,
* using the same fake-subflow trick as `addRegionNodes` (dagre has no subflow
* support, so the box is sized from the laid-out children's bounding box).
*/
export const addShardNodes = (nodes: Node[], edges: Edge[]) => {
const shardNodes: Node[] = []
const poolerNodes = nodes.filter(
(node) => node.type === 'HA_PRIMARY' || node.type === 'HA_REPLICA'
) as Node<HaPoolerNodeData>[]
const nodesByShard = groupBy(poolerNodes, (node) => node.data.shardId)
Object.entries(nodesByShard).forEach(([shardId, children]) => {
const minX = Math.min(...children.map((node) => node.position.x))
const maxX = Math.max(...children.map((node) => node.position.x + getNodeWidth(node)))
const minY = Math.min(...children.map((node) => node.position.y))
const maxY = Math.max(...children.map((node) => node.position.y + getDagreNodeHeight(node)))
shardNodes.push({
id: `shard-${shardId}`,
position: {
x: minX - SHARD_NODE_PADDING,
y: minY - SHARD_HEADER_HEIGHT - SHARD_NODE_PADDING,
},
// Explicit dimensions so React Flow renders the node immediately —
// without them a node stays `visibility: hidden` until a measurement
// pass that this post-layout background node never gets.
width: maxX - minX + SHARD_NODE_PADDING * 2,
height: maxY - minY + SHARD_HEADER_HEIGHT + SHARD_NODE_PADDING * 2,
// The box covers most of the canvas; making it non-interactive lets
// React Flow drop its pointer events so drag-to-pan passes through to
// the pane. The header pill re-enables pointer events for its tooltip.
selectable: false,
focusable: false,
type: 'HA_SHARD',
data: {
name: children[0].data.shardName,
} satisfies HaShardNodeData,
})
})
return { nodes: [...shardNodes, ...nodes], edges }
}
@@ -0,0 +1,190 @@
import { Handle, Node, NodeProps, Position } from '@xyflow/react'
import { Database, DatabaseBackup, HelpCircle, Layers, Loader2, Network } from 'lucide-react'
import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
import { ComputeMetricsFooter } from './ComputeMetricsFooter'
import {
HaPoolerNodeData,
HaShardNodeData,
MultigatewayNodeData,
} from './HaInstanceConfiguration.utils'
import { HA_POOLER_STATUS_LABELS, HaPoolerStatus } from './HaTopology.utils'
import { NODE_CARD_WIDTH } from './InstanceConfiguration.constants'
import { useHaPoolerCard } from './useHaPooler'
import { BASE_PATH } from '@/lib/constants'
const STATUS_BADGE_VARIANTS: Record<HaPoolerStatus, 'success' | 'warning' | 'default'> = {
healthy: 'success',
coming_up: 'default',
going_down: 'default',
unhealthy: 'warning',
}
const PoolerStatusBadge = ({ status }: { status: HaPoolerStatus }) => (
<Badge variant={STATUS_BADGE_VARIANTS[status]}>{HA_POOLER_STATUS_LABELS[status]}</Badge>
)
const PoolerCardSubtitle = ({
availabilityZone,
computeSize,
}: {
availabilityZone?: string
computeSize?: string
}) => (
<p className="flex items-center gap-x-1 text-sm text-foreground-light">
{availabilityZone !== undefined && <span>{availabilityZone}</span>}
{availabilityZone !== undefined && !!computeSize && (
<span className="text-foreground-lighter">·</span>
)}
{!!computeSize && <span>{computeSize}</span>}
</p>
)
export const MultigatewayNode = ({ data }: NodeProps<Node<MultigatewayNodeData>>) => {
const { numGateways } = data
return (
<>
<div className="flex flex-col rounded-sm bg-surface-100 border border-default">
<div className="flex items-start p-3 gap-x-4" style={{ width: NODE_CARD_WIDTH }}>
<div className="flex gap-x-3">
<div className="min-w-8 h-8 bg-blue-600 border border-blue-800 rounded-md flex items-center justify-center">
<Network aria-hidden="true" size={16} />
</div>
<div className="flex flex-col gap-y-0.5">
<p className="text-sm">Multigateway</p>
<p className="flex items-center gap-x-1 text-sm text-foreground-light">
<span>Load balancer</span>
{numGateways > 1 && (
<>
<span className="text-foreground-lighter">·</span>
<span>{numGateways} instances</span>
</>
)}
</p>
</div>
</div>
</div>
</div>
<Handle type="source" position={Position.Bottom} style={{ background: 'transparent' }} />
</>
)
}
export const HaPrimaryNode = ({ data }: NodeProps<Node<HaPoolerNodeData>>) => {
// [Joshen] Just FYI Handles cannot be conditionally rendered
const { cell, name, hasGateway } = data
const { status, availabilityZone, computeSize, primaryRegion } = useHaPoolerCard({ cell, name })
return (
<>
<Handle
type="target"
position={Position.Top}
className={!hasGateway ? 'opacity-0' : ''}
style={{ background: 'transparent' }}
/>
<div className="flex flex-col rounded-sm bg-surface-100 border border-default">
<div className="flex items-start justify-between p-3" style={{ width: NODE_CARD_WIDTH }}>
<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 aria-hidden="true" size={16} />
</div>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-x-2">
<p className="text-sm">Primary Database</p>
{/* Stable live region so polled status changes are announced */}
<span role="status">
{status !== undefined && <PoolerStatusBadge status={status} />}
</span>
</div>
{primaryRegion !== undefined && (
<p className="text-sm text-foreground-light">{primaryRegion.name}</p>
)}
<PoolerCardSubtitle availabilityZone={availabilityZone} computeSize={computeSize} />
</div>
</div>
{primaryRegion !== undefined && (
// Decorative — the visible text next to it already names the region
<img
alt=""
aria-hidden="true"
className="w-8 rounded-xs mt-0.5"
src={`${BASE_PATH}/img/regions/${primaryRegion.region}.svg`}
/>
)}
</div>
{/* Whether connection metrics are meaningful through the multigateway
is unconfirmed, so they're left off for HA projects. */}
<ComputeMetricsFooter showConnections={false} />
</div>
<Handle type="source" position={Position.Bottom} style={{ background: 'transparent' }} />
</>
)
}
export const HaReplicaNode = ({ data }: NodeProps<Node<HaPoolerNodeData>>) => {
const { cell, name } = data
const { status, availabilityZone, computeSize } = useHaPoolerCard({ cell, name })
return (
<>
<Handle type="target" position={Position.Top} style={{ background: 'transparent' }} />
<div
className="flex items-start rounded-sm bg-surface-100 border border-default p-3"
style={{ width: NODE_CARD_WIDTH }}
>
<div className="flex gap-x-3">
<div
className={cn(
'w-8 h-8 border rounded-md flex items-center justify-center',
status === 'healthy'
? 'bg-brand-400 border-brand-500'
: 'bg-surface-100 border-foreground/20'
)}
>
{status === 'coming_up' ? (
<Loader2 aria-hidden="true" className="motion-safe:animate-spin" size={16} />
) : (
<DatabaseBackup aria-hidden="true" size={16} />
)}
</div>
<div className="flex flex-col gap-y-0.5">
<div className="flex items-center gap-x-2">
<p className="text-sm">Read Replica</p>
{/* Stable live region so polled status changes are announced */}
<span role="status">
{status !== undefined && <PoolerStatusBadge status={status} />}
</span>
</div>
<PoolerCardSubtitle availabilityZone={availabilityZone} computeSize={computeSize} />
</div>
</div>
</div>
</>
)
}
export const HaShardNode = ({ data }: NodeProps<Node<HaShardNodeData>>) => {
const { name } = data
return (
<div className="relative h-full w-full rounded-md border border-default bg-surface-100/25">
<div className="pointer-events-auto absolute top-3 left-3 flex items-center gap-x-2 rounded-full border border-default bg-surface-100 pl-3 pr-2 py-1">
<Layers aria-hidden="true" size={14} className="text-foreground-light" />
<p className="text-sm">{name}</p>
<Tooltip>
{/* Renders a button so the explanation is keyboard reachable */}
<TooltipTrigger className="flex items-center gap-x-1 text-sm text-foreground-light">
Automatic failover
<HelpCircle aria-hidden="true" size={14} />
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-72 text-center">
If the primary database fails, a replica in this shard is automatically promoted to take
its place.
</TooltipContent>
</Tooltip>
</div>
</div>
)
}
@@ -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> = {}): 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> = {}): 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> = {}): 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([])
})
})
@@ -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<Multipooler, 'id'>) =>
`${pooler.id?.cell ?? 'unknown'}-${pooler.id?.name ?? 'unknown'}`
export const hasPoolerIdentity = (pooler: Pick<Multipooler, 'id'>) =>
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<HaPoolerStatus, string> = {
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<Multipooler | undefined>(
(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<Multipooler['routingState']>['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,
}))
@@ -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<string, number> = {
@@ -54,6 +67,9 @@ export const NODE_HEIGHT_FALLBACKS: Record<string, number> = {
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
@@ -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 (
<div className={cn('nowheel h-full')}>
<div className="nowheel h-full">
<div
className={`h-full w-full relative ${
isSuccessReplicas && !isLoadingProject ? '' : 'flex items-center justify-center px-28'
}`}
>
{(isLoading || isLoadingProject) && (
<Loader2 className="animate-spin text-foreground-light" />
<div role="status">
<span className="sr-only">Loading infrastructure...</span>
<Loader2
aria-hidden="true"
className="motion-safe:animate-spin text-foreground-light"
/>
</div>
)}
{isError && <AlertError error={error} subject="Failed to retrieve replicas" />}
{isSuccessReplicas && !isLoadingProject && (
@@ -259,32 +201,13 @@ const InstanceConfigurationUI = () => {
</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={[]}
<DiagramFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
proOptions={{ hideAttribution: true }}
>
<Background color={backgroundPatternColor} />
</ReactFlow>
addGroupNodes={addRegionNodes}
/>
) : (
<MapView />
)}
@@ -296,9 +219,22 @@ const InstanceConfigurationUI = () => {
}
export const InstanceConfiguration = () => {
const { isHighAvailability, isPending } = useHighAvailability()
// Wait for the project record so an HA project never briefly mounts the
// standard diagram (and fires its queries) before swapping.
if (isPending) {
return (
<div role="status" className="h-full w-full flex items-center justify-center">
<span className="sr-only">Loading infrastructure...</span>
<Loader2 aria-hidden="true" className="motion-safe:animate-spin text-foreground-light" />
</div>
)
}
return (
<ReactFlowProvider>
<InstanceConfigurationUI />
{isHighAvailability ? <HaInstanceConfiguration /> : <InstanceConfigurationUI />}
</ReactFlowProvider>
)
}
@@ -0,0 +1,28 @@
import { Edge, Node } from '@xyflow/react'
import { describe, expect, it } from 'vitest'
import { NODE_HEIGHT_FALLBACKS } from './InstanceConfiguration.constants'
import { getDagreGraphLayout } from './InstanceConfiguration.utils'
const makeNodes = (): Node[] => [
{ id: 'primary', position: { x: 0, y: 0 }, data: {}, type: 'PRIMARY' },
{ id: 'replica', position: { x: 0, y: 0 }, data: {}, type: 'READ_REPLICA' },
]
const edges: Edge[] = [{ id: 'primary-replica', source: 'primary', target: 'replica' }]
// Vertical gap between a node's bottom edge and the next rank's top edge.
const getRankGap = (nodes: Node[]) =>
nodes[1].position.y - (nodes[0].position.y + NODE_HEIGHT_FALLBACKS.PRIMARY)
describe('getDagreGraphLayout', () => {
it('separates ranks by the default ranksep', () => {
const { nodes } = getDagreGraphLayout(makeNodes(), edges)
expect(getRankGap(nodes)).toBe(60)
})
it('separates ranks by a custom ranksep', () => {
const { nodes } = getDagreGraphLayout(makeNodes(), edges, { ranksep: 96 })
expect(getRankGap(nodes)).toBe(96)
})
})
@@ -114,15 +114,19 @@ export const generateNodes = ({
]
}
const getDagreNodeHeight = (node: Node) => {
export const getDagreNodeHeight = (node: Node) => {
if (node.measured?.height) return node.measured.height
return NODE_HEIGHT_FALLBACKS[node.type ?? ''] ?? 100
}
export const getDagreGraphLayout = (nodes: Node[], edges: Edge[]) => {
export const getDagreGraphLayout = (
nodes: Node[],
edges: Edge[],
{ ranksep = 60 }: { ranksep?: number } = {}
) => {
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
dagreGraph.setGraph({ rankdir: 'TB', ranksep: 60, nodesep: NODE_SEP })
dagreGraph.setGraph({ rankdir: 'TB', ranksep, nodesep: NODE_SEP })
nodes.forEach((node) => {
dagreGraph.setNode(node.id, {
@@ -21,6 +21,7 @@ import {
} from 'ui'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { ComputeMetricsFooter } from './ComputeMetricsFooter'
import {
ERROR_STATES,
INIT_PROGRESS,
@@ -32,7 +33,6 @@ import {
ReplicaNodeData,
} from './InstanceConfiguration.constants'
import { formatSeconds } from './InstanceConfiguration.utils'
import { metricColor } from './InstanceNode.utils'
import { getReadReplicaPath } from '@/components/interfaces/Settings/Infrastructure/Infrastructure.utils'
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
import { RegionFlag } from '@/components/ui/RegionFlag'
@@ -43,7 +43,6 @@ import {
useReadReplicasStatusesQuery,
} from '@/data/read-replicas/replicas-status-query'
import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
import { useComputeMetrics } from '@/hooks/analytics/useComputeMetrics'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
@@ -92,25 +91,11 @@ export const LoadBalancerNode = ({ data }: NodeProps<Node<LoadBalancerData>>) =>
export const PrimaryNode = ({ data }: NodeProps<Node<PrimaryNodeData>>) => {
// [Joshen] Just FYI Handles cannot be conditionally rendered
const { region, computeSize, numReplicas, numRegions, hasLoadBalancer } = data
const { ref } = useParams()
const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
'project_homepage:show_instance_size',
])
const {
cpu,
disk,
memory,
connections,
isLoading: metricsLoading,
isError: metricsError,
} = useComputeMetrics({
projectRef: ref,
})
const observabilityUrl = `/project/${ref}/observability/database`
return (
<>
<Handle
@@ -171,43 +156,7 @@ export const PrimaryNode = ({ data }: NodeProps<Node<PrimaryNodeData>>) => {
</p>
</div>
)}
<Tooltip>
<TooltipTrigger asChild>
<Link
href={observabilityUrl}
className="border-t px-3 py-2 hover:bg-surface-200 transition flex items-center gap-x-3 text-xs"
>
{metricsLoading ? (
<div className="h-3 w-44 rounded-sm bg-surface-300 animate-pulse" />
) : metricsError ? (
<span className="text-foreground-lighter">Metrics unavailable</span>
) : (
<>
<span>
CPU <span className={metricColor(cpu)}>{cpu.toFixed(0)}%</span>
</span>
<span className="text-foreground-lighter">·</span>
<span>
Disk <span className={metricColor(disk)}>{disk.toFixed(0)}%</span>
</span>
<span className="text-foreground-lighter">·</span>
<span>
RAM <span className={metricColor(memory)}>{memory.toFixed(0)}%</span>
</span>
{connections.max > 0 && (
<>
<span className="text-foreground-lighter">·</span>
<span className="text-foreground-light">
{connections.peak}/{connections.max} conns
</span>
</>
)}
</>
)}
</Link>
</TooltipTrigger>
<TooltipContent side="bottom">Go to Database Report</TooltipContent>
</Tooltip>
<ComputeMetricsFooter />
</div>
<Handle
type="source"
@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'common'
import { formatCellAsAvailabilityZone, getPoolerStatus } from './HaTopology.utils'
import { AVAILABLE_REPLICA_REGIONS } from './InstanceConfiguration.constants'
import { haClusterPoolersQueryOptions } from '@/data/ha-admin/ha-cluster-poolers-query'
import { usePrimaryDatabase } from '@/data/read-replicas/replicas-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
/**
* Subscribes to a single pooler's live state from the shared poolers query, so
* each node/edge updates on its own without re-laying-out the whole diagram.
*/
export const useHaPooler = ({ cell, name }: { cell?: string; name?: string }) => {
const { ref: projectRef } = useParams()
return useQuery({
...haClusterPoolersQueryOptions({ projectRef }),
select: (data) =>
(data.poolers ?? []).find((pooler) => pooler.id?.cell === cell && pooler.id?.name === name),
})
}
/**
* Everything a pooler node card displays. Region and compute size aren't in
* multiadmin — they come from the primary database row. HA locks compute to
* one project-level size, so the primary's size is correct for every node in
* the cluster.
*/
export const useHaPoolerCard = ({ cell, name }: { cell?: string; name?: string }) => {
const { ref: projectRef } = useParams()
const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
'project_homepage:show_instance_size',
])
const { database: primary } = usePrimaryDatabase({ projectRef })
const { data: pooler } = useHaPooler({ cell, name })
return {
status: pooler !== undefined ? getPoolerStatus(pooler) : undefined,
availabilityZone: formatCellAsAvailabilityZone(cell),
computeSize: projectHomepageShowInstanceSize ? primary?.size : undefined,
primaryRegion: AVAILABLE_REPLICA_REGIONS.find((r) => primary?.region.includes(r.region)),
}
}
@@ -0,0 +1,88 @@
import { ArrowRight, Loader2, Square, X, type LucideIcon } from 'lucide-react'
import { cn } from 'ui'
export interface ReplicationState {
isComingUp: boolean
isReplicating: boolean
isFailed: boolean
}
export interface EdgeVisual {
Icon: LucideIcon
// CSS color shared by the icon and the connecting line so they always match.
color: string
opacity: number
dashArray: string
strokeWidth: number
shouldAnimate: boolean
shouldSpin?: boolean
isFilled?: boolean
}
// Picks the icon + line appearance for a replication state. Both the icon and the line are derived
// here from the same state so they always stay in sync. We deliberately don't surface lag: the line
// just communicates whether data is moving, stopped, starting, or broken.
export const getEdgeVisual = ({
isComingUp,
isReplicating,
isFailed,
}: ReplicationState): EdgeVisual => {
if (isFailed) {
return {
Icon: X,
color: 'hsl(var(--destructive-default))',
opacity: 1,
dashArray: '5 5',
shouldAnimate: false,
strokeWidth: 4,
}
}
if (isComingUp) {
return {
Icon: Loader2,
color: 'var(--foreground-light)',
opacity: 1,
dashArray: '5',
strokeWidth: 2,
shouldAnimate: true,
shouldSpin: true,
}
}
if (isReplicating) {
return {
Icon: ArrowRight,
color: 'hsl(var(--brand-default))',
opacity: 1,
dashArray: '5',
strokeWidth: 2,
shouldAnimate: true,
}
}
return {
Icon: Square,
color: 'var(--foreground-lighter)',
opacity: 0.5,
dashArray: '5 5',
strokeWidth: 2,
shouldAnimate: false,
isFilled: true,
}
}
/** The round icon chip rendered mid-edge, bordered in the edge's color. */
export const EdgeVisualChip = ({ visual }: { visual: EdgeVisual }) => {
const { Icon, color, shouldSpin, isFilled } = visual
return (
<div
className="w-6 h-6 rounded-full flex items-center justify-center border bg-surface-100"
style={{ borderColor: color }}
>
<Icon
size={14}
className={cn(shouldSpin && 'motion-safe:animate-spin')}
style={{ color, fill: isFilled ? color : undefined }}
/>
</div>
)
}
+41
View File
@@ -0,0 +1,41 @@
import { z } from 'zod'
import { get, handleError } from '@/data/fetchers'
/**
* Read-only slices of the multiadmin API, surfaced through the mgmt-api
* `/ha-admin` GET passthrough. The passthrough is a catch-all proxy, so these
* paths aren't in Studio's generated OpenAPI types — each resource validates
* the response against its own zod schema instead.
*
* mgmt-api forwards `/ha-admin/v1/<x>` → `/multiadmin/v1/<x>`, which the
* project edge gateway rewrites to multiadmin's `/api/v1/<x>`.
*/
export async function getHaAdmin(
projectRef: string | undefined,
subPath: string,
signal?: AbortSignal
): Promise<unknown> {
if (!projectRef) throw new Error('projectRef is required')
const { data, error } = await get(
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- off-schema passthrough path
`/platform/projects/${projectRef}/ha-admin/v1/${subPath}` as any,
{ signal }
)
if (error) handleError(error)
return data
}
/**
* Validates a passthrough response, surfacing a readable error (which lands in
* the diagram's AlertError fallback) instead of a raw zod issue dump.
*/
export function parseHaAdminResponse<T extends z.ZodTypeAny>(schema: T, data: unknown): z.infer<T> {
const parsed = schema.safeParse(data)
if (!parsed.success) {
throw new Error('Received an unexpected response from the cluster admin API')
}
return parsed.data
}
@@ -0,0 +1,27 @@
import { queryOptions } from '@tanstack/react-query'
import { z } from 'zod'
import { getHaAdmin, parseHaAdminResponse } from './get-ha-admin'
import { haAdminKeys } from './keys'
import { IS_PLATFORM } from '@/lib/constants'
import type { ResponseError } from '@/types'
export type HaClusterCellsVariables = { projectRef?: string }
export type HaClusterCellsError = ResponseError
// Every field is optional because proto3 JSON omits zero values.
const haClusterCellsResponseSchema = z.object({ names: z.array(z.string()).optional() })
async function getHaClusterCells({ projectRef }: HaClusterCellsVariables, signal?: AbortSignal) {
const data = await getHaAdmin(projectRef, 'cells', signal)
return parseHaAdminResponse(haClusterCellsResponseSchema, data)
}
export type HaClusterCellsData = Awaited<ReturnType<typeof getHaClusterCells>>
export const haClusterCellsQueryOptions = ({ projectRef }: HaClusterCellsVariables) =>
queryOptions({
queryKey: haAdminKeys.cells(projectRef),
queryFn: ({ signal }) => getHaClusterCells({ projectRef }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
})
@@ -0,0 +1,30 @@
import { queryOptions } from '@tanstack/react-query'
import { z } from 'zod'
import { getHaAdmin, parseHaAdminResponse } from './get-ha-admin'
import { haAdminKeys } from './keys'
import { IS_PLATFORM } from '@/lib/constants'
import type { ResponseError } from '@/types'
export type HaClusterDatabasesVariables = { projectRef?: string }
export type HaClusterDatabasesError = ResponseError
// Every field is optional because proto3 JSON omits zero values.
const haClusterDatabasesResponseSchema = z.object({ names: z.array(z.string()).optional() })
async function getHaClusterDatabases(
{ projectRef }: HaClusterDatabasesVariables,
signal?: AbortSignal
) {
const data = await getHaAdmin(projectRef, 'databases', signal)
return parseHaAdminResponse(haClusterDatabasesResponseSchema, data)
}
export type HaClusterDatabasesData = Awaited<ReturnType<typeof getHaClusterDatabases>>
export const haClusterDatabasesQueryOptions = ({ projectRef }: HaClusterDatabasesVariables) =>
queryOptions({
queryKey: haAdminKeys.databases(projectRef),
queryFn: ({ signal }) => getHaClusterDatabases({ projectRef }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
})
@@ -0,0 +1,39 @@
import { queryOptions } from '@tanstack/react-query'
import { z } from 'zod'
import { getHaAdmin, parseHaAdminResponse } from './get-ha-admin'
import { haAdminKeys } from './keys'
import { IS_PLATFORM } from '@/lib/constants'
import type { ResponseError } from '@/types'
export type HaClusterGatewaysVariables = { projectRef?: string }
export type HaClusterGatewaysError = ResponseError
// Every field is optional because proto3 JSON omits zero values.
const multigatewaySchema = z.object({
id: z.object({ cell: z.string().optional(), name: z.string().optional() }).optional(),
hostname: z.string().optional(),
})
const haClusterGatewaysResponseSchema = z.object({
gateways: z.array(multigatewaySchema).optional(),
})
export type Multigateway = z.infer<typeof multigatewaySchema>
async function getHaClusterGateways(
{ projectRef }: HaClusterGatewaysVariables,
signal?: AbortSignal
) {
const data = await getHaAdmin(projectRef, 'gateways', signal)
return parseHaAdminResponse(haClusterGatewaysResponseSchema, data)
}
export type HaClusterGatewaysData = Awaited<ReturnType<typeof getHaClusterGateways>>
export const haClusterGatewaysQueryOptions = ({ projectRef }: HaClusterGatewaysVariables) =>
queryOptions({
queryKey: haAdminKeys.gateways(projectRef),
queryFn: ({ signal }) => getHaClusterGateways({ projectRef }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
})
@@ -0,0 +1,64 @@
import { queryOptions } from '@tanstack/react-query'
import { z } from 'zod'
import { getHaAdmin, parseHaAdminResponse } from './get-ha-admin'
import { haAdminKeys } from './keys'
import { IS_PLATFORM } from '@/lib/constants'
import type { ResponseError } from '@/types'
export type HaClusterPoolersVariables = { projectRef?: string }
export type HaClusterPoolersError = ResponseError
// Hand-modelled subset of multiadmin's OpenAPI spec. Every field is optional
// because proto3 JSON omits zero values, and enum-shaped fields stay plain
// strings (expected values in comments) so new proto values degrade gracefully
// instead of failing the whole parse.
const multipoolerSchema = z.object({
id: z.object({ cell: z.string().optional(), name: z.string().optional() }).optional(),
shardKey: z
.object({
database: z.string().optional(),
tableGroup: z.string().optional(),
shard: z.string().optional(),
})
.optional(),
// 'UNKNOWN' | 'PRIMARY' | 'REPLICA' | 'DRAINED' — deprecated (derived, not
// authoritative), prefer `routingState.role`
type: z.string().optional(),
// 'SERVING' | 'DISABLED' | 'DRAINING'
servingStatus: z.string().optional(),
hostname: z.string().optional(),
lifecycleStatus: z.object({ status: z.string().optional() }).optional(),
routingState: z
.object({
role: z.string().optional(),
// proto int64s, serialized as strings in JSON
rule: z
.object({ coordinatorTerm: z.string().optional(), leaderSubterm: z.string().optional() })
.optional(),
})
.optional(),
})
const haClusterPoolersResponseSchema = z.object({
poolers: z.array(multipoolerSchema).optional(),
})
export type Multipooler = z.infer<typeof multipoolerSchema>
async function getHaClusterPoolers(
{ projectRef }: HaClusterPoolersVariables,
signal?: AbortSignal
) {
const data = await getHaAdmin(projectRef, 'poolers', signal)
return parseHaAdminResponse(haClusterPoolersResponseSchema, data)
}
export type HaClusterPoolersData = Awaited<ReturnType<typeof getHaClusterPoolers>>
export const haClusterPoolersQueryOptions = ({ projectRef }: HaClusterPoolersVariables) =>
queryOptions({
queryKey: haAdminKeys.poolers(projectRef),
queryFn: ({ signal }) => getHaClusterPoolers({ projectRef }, signal),
enabled: IS_PLATFORM && typeof projectRef !== 'undefined',
})
+9
View File
@@ -0,0 +1,9 @@
export const haAdminKeys = {
cells: (projectRef: string | undefined) => ['projects', projectRef, 'ha-admin', 'cells'] as const,
databases: (projectRef: string | undefined) =>
['projects', projectRef, 'ha-admin', 'databases'] as const,
poolers: (projectRef: string | undefined) =>
['projects', projectRef, 'ha-admin', 'poolers'] as const,
gateways: (projectRef: string | undefined) =>
['projects', projectRef, 'ha-admin', 'gateways'] as const,
}
@@ -12,9 +12,9 @@ import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { ReadReplicasMovedCallout } from '@/components/interfaces/Database/Replication/DestinationPanel/ReadReplicasMovedCallout'
import { Destinations } from '@/components/interfaces/Database/Replication/Destinations'
import { ReplicationDiagram } from '@/components/interfaces/Database/Replication/ReplicationDiagram'
import { InstanceConfiguration } from '@/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration'
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState'
import { UnknownInterface } from '@/components/ui/UnknownInterface'
import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
@@ -33,12 +33,26 @@ const DatabaseReplicationPage: NextPageWithLayout = () => {
if (isHighAvailability) {
return (
<div className="flex h-full w-full items-center justify-center p-6">
<HighAvailabilityDisabledEmptyState
title="Replication unavailable on High Availability projects"
description="We're working to bring replication to High Availability projects. Contact support if this is blocking your work."
/>
</div>
<>
<PageHeader size="large">
<PageHeaderMeta>
<PageHeaderSummary>
<PageHeaderTitle>Replication</PageHeaderTitle>
<PageHeaderDescription>High Availability cluster topology</PageHeaderDescription>
</PageHeaderSummary>
</PageHeaderMeta>
</PageHeader>
<PageContainer size="large">
<PageSection>
<PageSectionContent>
<div className="relative h-[500px] w-full overflow-hidden rounded-md border border-muted">
<InstanceConfiguration />
</div>
</PageSectionContent>
</PageSection>
</PageContainer>
</>
)
}
+6
View File
@@ -9,6 +9,12 @@
}
}
@media (prefers-reduced-motion: reduce) {
.react-flow__edge.animated path {
animation: none;
}
}
.map-path {
animation: dash 8s linear infinite;
}