Files
supabase/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.test.ts
Alaister Young 928049ce2c [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>
2026-08-26 16:51:36 +08:00

293 lines
11 KiB
TypeScript

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([])
})
})