mirror of
https://github.com/supabase/supabase.git
synced 2026-09-06 09:59:03 +08:00
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>
155 lines
5.0 KiB
TypeScript
155 lines
5.0 KiB
TypeScript
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 }
|
|
}
|