From 928049ce2c90246165efeaac407ee591daa45c01 Mon Sep 17 00:00:00 2001 From: Alaister Young Date: Wed, 26 Aug 2026 16:51:36 +0800 Subject: [PATCH] [FE-3717] feat(studio): Multigres cluster topology diagram (#49298) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2026-08-20 at 8 23 42 PM 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/*`. ## 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. --------- Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com> --- .../Replication/ReplicationDiagram/Edges.tsx | 108 +------ .../ComputeMetricsFooter.tsx | 81 +++++ .../DiagramFlow.tsx | 132 ++++++++ .../HaInstanceConfiguration.tsx | 113 +++++++ .../HaInstanceConfiguration.utils.test.ts | 32 ++ .../HaInstanceConfiguration.utils.ts | 154 +++++++++ .../HaInstanceNode.tsx | 190 ++++++++++++ .../HaTopology.utils.test.ts | 292 ++++++++++++++++++ .../HaTopology.utils.ts | 192 ++++++++++++ .../InstanceConfiguration.constants.ts | 16 + .../InstanceConfiguration.tsx | 160 +++------- .../InstanceConfiguration.utils.test.ts | 28 ++ .../InstanceConfiguration.utils.ts | 10 +- .../InstanceNode.tsx | 55 +--- .../useHaPooler.ts | 45 +++ .../components/ui/ReactFlow/EdgeVisual.tsx | 88 ++++++ apps/studio/data/ha-admin/get-ha-admin.ts | 41 +++ .../data/ha-admin/ha-cluster-cells-query.ts | 27 ++ .../ha-admin/ha-cluster-databases-query.ts | 30 ++ .../ha-admin/ha-cluster-gateways-query.ts | 39 +++ .../data/ha-admin/ha-cluster-poolers-query.ts | 64 ++++ apps/studio/data/ha-admin/keys.ts | 9 + .../[ref]/database/replication/index.tsx | 28 +- apps/studio/styles/reactflow.css | 6 + 24 files changed, 1674 insertions(+), 266 deletions(-) create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/ComputeMetricsFooter.tsx create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DiagramFlow.tsx create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.tsx create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.utils.test.ts create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.utils.ts create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceNode.tsx create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.test.ts create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.ts create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.utils.test.ts create mode 100644 apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/useHaPooler.ts create mode 100644 apps/studio/components/ui/ReactFlow/EdgeVisual.tsx create mode 100644 apps/studio/data/ha-admin/get-ha-admin.ts create mode 100644 apps/studio/data/ha-admin/ha-cluster-cells-query.ts create mode 100644 apps/studio/data/ha-admin/ha-cluster-databases-query.ts create mode 100644 apps/studio/data/ha-admin/ha-cluster-gateways-query.ts create mode 100644 apps/studio/data/ha-admin/ha-cluster-poolers-query.ts create mode 100644 apps/studio/data/ha-admin/keys.ts diff --git a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx index 7d05a6b9e60..7bfab5a8b1d 100644 --- a/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx +++ b/apps/studio/components/interfaces/Database/Replication/ReplicationDiagram/Edges.tsx @@ -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, }} /> @@ -160,18 +97,7 @@ export const SmoothstepEdge = ({ }} className="nodrag nopan" > -
- -
+
diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/ComputeMetricsFooter.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/ComputeMetricsFooter.tsx new file mode 100644 index 00000000000..4ca4b8d3b43 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/ComputeMetricsFooter.tsx @@ -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 ( + + + + {/* Stable live region: announces loading/failure, never the polled values */} + + {isMetricsLoading && 'Loading metrics'} + {!isMetricsLoading && isMetricsError && 'Metrics unavailable'} + + {/* h-4 matches the text-xs line height so the card doesn't shift when metrics load */} + {isMetricsLoading && ( +