mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03: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>
392 lines
15 KiB
TypeScript
392 lines
15 KiB
TypeScript
import { Handle, Node, NodeProps, Position } from '@xyflow/react'
|
|
import { useParams } from 'common'
|
|
import dayjs from 'dayjs'
|
|
import { Database, DatabaseBackup, HelpCircle, Loader2, MoreVertical } from 'lucide-react'
|
|
import Link from 'next/link'
|
|
import { parseAsBoolean, parseAsString, useQueryStates } from 'nuqs'
|
|
import { toast } from 'sonner'
|
|
import {
|
|
Badge,
|
|
Button,
|
|
cn,
|
|
copyToClipboard,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from 'ui'
|
|
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
|
|
|
import { ComputeMetricsFooter } from './ComputeMetricsFooter'
|
|
import {
|
|
ERROR_STATES,
|
|
INIT_PROGRESS,
|
|
LoadBalancerData,
|
|
NODE_SEP,
|
|
NODE_WIDTH,
|
|
PrimaryNodeData,
|
|
REGION_NODE_HEIGHT,
|
|
ReplicaNodeData,
|
|
} from './InstanceConfiguration.constants'
|
|
import { formatSeconds } from './InstanceConfiguration.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'
|
|
import { SparkBar } from '@/components/ui/SparkBar'
|
|
import {
|
|
DatabaseInitEstimations,
|
|
ReplicaInitializationStatus,
|
|
useReadReplicasStatusesQuery,
|
|
} from '@/data/read-replicas/replicas-status-query'
|
|
import { formatDatabaseID } from '@/data/read-replicas/replicas.utils'
|
|
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
|
|
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
|
|
|
|
export const LoadBalancerNode = ({ data }: NodeProps<Node<LoadBalancerData>>) => {
|
|
const { ref } = useParams()
|
|
const { numDatabases } = data
|
|
|
|
return (
|
|
<>
|
|
<div className="flex flex-col rounded-sm bg-surface-100 border border-default">
|
|
<div
|
|
className="flex items-start justify-between p-3 gap-x-4"
|
|
style={{ width: NODE_WIDTH / 2 - 10 }}
|
|
>
|
|
<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">
|
|
<Database size={16} />
|
|
</div>
|
|
<div className="flex flex-col gap-y-0.5">
|
|
<p className="text-sm">API Load Balancer</p>
|
|
<p className="text-sm text-foreground-light">
|
|
Distributes incoming API requests across{' '}
|
|
<span className="text-foreground">{numDatabases} databases</span>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="text" icon={<MoreVertical />} className="px-1" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-40" side="bottom" align="end">
|
|
<DropdownMenuItem asChild className="gap-x-2">
|
|
<Link href={`/project/${ref}/integrations/data_api/overview?source=load-balancer`}>
|
|
View API URL
|
|
</Link>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
<Handle type="source" position={Position.Bottom} style={{ background: 'transparent' }} />
|
|
</>
|
|
)
|
|
}
|
|
|
|
export const PrimaryNode = ({ data }: NodeProps<Node<PrimaryNodeData>>) => {
|
|
// [Joshen] Just FYI Handles cannot be conditionally rendered
|
|
const { region, computeSize, numReplicas, numRegions, hasLoadBalancer } = data
|
|
|
|
const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
|
|
'project_homepage:show_instance_size',
|
|
])
|
|
|
|
return (
|
|
<>
|
|
<Handle
|
|
type="target"
|
|
position={Position.Top}
|
|
className={!hasLoadBalancer ? '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_WIDTH / 2 - 10 }}
|
|
>
|
|
<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 size={16} />
|
|
</div>
|
|
<div className="flex flex-col gap-y-0.5">
|
|
<p className="text-sm">Primary Database</p>
|
|
<p className="flex items-center gap-x-1">
|
|
<span className="text-sm text-foreground-light">{region.name}</span>
|
|
</p>
|
|
<p className="flex items-center gap-x-1">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span
|
|
className="text-sm transition text-foreground-light hover:text-foreground"
|
|
onClick={async () =>
|
|
await copyToClipboard(region.region, () => toast('Copied project region'))
|
|
}
|
|
>
|
|
{region.region}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">Click to copy</TooltipContent>
|
|
</Tooltip>
|
|
{projectHomepageShowInstanceSize && (
|
|
<>
|
|
<span className="text-sm text-foreground-lighter">·</span>
|
|
<span className="text-sm text-foreground-light">{computeSize}</span>
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<RegionFlag className="mt-0.5 w-8" region={region.region} />
|
|
</div>
|
|
{numReplicas > 0 && (
|
|
<div className="border-t p-3 py-2">
|
|
<p className="text-sm text-foreground-light">
|
|
<span className="text-foreground">
|
|
{numReplicas} replica{numReplicas > 1 ? 's' : ''}
|
|
</span>{' '}
|
|
deployed across{' '}
|
|
<span className="text-foreground">
|
|
{numRegions} region{numRegions > 1 ? 's' : ''}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
)}
|
|
<ComputeMetricsFooter />
|
|
</div>
|
|
<Handle
|
|
type="source"
|
|
position={Position.Bottom}
|
|
className={numReplicas === 0 ? 'opacity-0' : ''}
|
|
style={{ background: 'transparent' }}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export const ReplicaNode = ({ data }: NodeProps<Node<ReplicaNodeData>>) => {
|
|
const { ref } = useParams()
|
|
const { id, region, computeSize, status, inserted_at } = data
|
|
const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
|
|
'project_homepage:show_instance_size',
|
|
])
|
|
|
|
const state = useDatabaseSelectorStateSnapshot()
|
|
const [, setConnect] = useQueryStates({
|
|
showConnect: parseAsBoolean.withDefault(false),
|
|
source: parseAsString,
|
|
})
|
|
|
|
const { data: databaseStatuses } = useReadReplicasStatusesQuery({ projectRef: ref })
|
|
const { replicaInitializationStatus } =
|
|
(databaseStatuses ?? []).find((db) => db.identifier === id) || {}
|
|
|
|
const {
|
|
status: initStatus,
|
|
progress,
|
|
estimations,
|
|
error,
|
|
} = (replicaInitializationStatus as {
|
|
status?: string
|
|
progress?: string
|
|
estimations?: DatabaseInitEstimations
|
|
error?: string
|
|
}) ?? { status: undefined, progress: undefined, estimations: undefined, error: undefined }
|
|
|
|
const created = dayjs(inserted_at).format('DD MMM YYYY')
|
|
const stage = progress !== undefined ? Number(progress.split('_')[0]) : 0
|
|
const stagePercent = stage / (Object.keys(INIT_PROGRESS).length - 1)
|
|
|
|
const isInTransition =
|
|
(
|
|
[
|
|
REPLICA_STATUS.UNKNOWN,
|
|
REPLICA_STATUS.COMING_UP,
|
|
REPLICA_STATUS.GOING_DOWN,
|
|
REPLICA_STATUS.RESTORING,
|
|
REPLICA_STATUS.RESTARTING,
|
|
REPLICA_STATUS.RESIZING,
|
|
REPLICA_STATUS.INIT_READ_REPLICA,
|
|
] as string[]
|
|
).includes(status) || initStatus === ReplicaInitializationStatus.InProgress
|
|
|
|
return (
|
|
<>
|
|
<Handle type="target" position={Position.Top} style={{ background: 'transparent' }} />
|
|
<div
|
|
className="flex justify-between items-start rounded-sm bg-surface-100 border border-default p-3"
|
|
style={{ width: NODE_WIDTH / 2 - 10 }}
|
|
>
|
|
<div className="flex gap-x-3">
|
|
<div
|
|
className={cn(
|
|
'w-8 h-8 border rounded-md flex items-center justify-center',
|
|
status === REPLICA_STATUS.ACTIVE_HEALTHY &&
|
|
initStatus === ReplicaInitializationStatus.Completed
|
|
? 'bg-brand-400 border-brand-500'
|
|
: 'bg-surface-100 border-foreground/20'
|
|
)}
|
|
>
|
|
{isInTransition ? (
|
|
<Loader2 className="animate-spin" size={16} />
|
|
) : (
|
|
<DatabaseBackup size={16} />
|
|
)}
|
|
</div>
|
|
<div className="flex flex-col gap-y-0.5">
|
|
<div className="flex items-center gap-x-2">
|
|
<p className="text-sm truncate">
|
|
Replica {id.length > 0 && `(ID: ${formatDatabaseID(id)})`}
|
|
</p>
|
|
{initStatus === ReplicaInitializationStatus.InProgress ||
|
|
status === REPLICA_STATUS.COMING_UP ||
|
|
status === REPLICA_STATUS.UNKNOWN ||
|
|
status === REPLICA_STATUS.INIT_READ_REPLICA ? (
|
|
<Badge>Coming up</Badge>
|
|
) : initStatus === ReplicaInitializationStatus.Failed ||
|
|
status === REPLICA_STATUS.INIT_READ_REPLICA_FAILED ? (
|
|
<>
|
|
<Badge variant="destructive">Init failed</Badge>
|
|
<Tooltip>
|
|
<TooltipTrigger>
|
|
<HelpCircle size={16} />
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side="bottom"
|
|
align="end"
|
|
alignOffset={-70}
|
|
className="w-60 text-center"
|
|
>
|
|
Replica failed to initialize. Please drop this replica and spin up a new one.
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</>
|
|
) : status === REPLICA_STATUS.GOING_DOWN ? (
|
|
<Badge>Going down</Badge>
|
|
) : status === REPLICA_STATUS.RESTARTING ? (
|
|
<Badge>Restarting</Badge>
|
|
) : status === REPLICA_STATUS.RESIZING ? (
|
|
<Badge>Resizing</Badge>
|
|
) : status === REPLICA_STATUS.ACTIVE_HEALTHY ? (
|
|
<Badge variant="success">Healthy</Badge>
|
|
) : (
|
|
<Badge variant="warning">Unhealthy</Badge>
|
|
)}
|
|
</div>
|
|
<div className="my-0.5">
|
|
<p className="text-sm text-foreground-light">{region.name}</p>
|
|
<p className="flex text-sm text-foreground-light items-center gap-x-1">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<span
|
|
className="text-sm transition text-foreground-light hover:text-foreground"
|
|
onClick={async () =>
|
|
await copyToClipboard(region.region, () => toast('Copied replica region'))
|
|
}
|
|
>
|
|
{region.region}
|
|
</span>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="bottom">Click to copy</TooltipContent>
|
|
</Tooltip>
|
|
{projectHomepageShowInstanceSize && !!computeSize && (
|
|
<>
|
|
<span className="text-foreground-lighter">·</span>
|
|
<span>{computeSize}</span>
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
{initStatus === ReplicaInitializationStatus.InProgress && progress !== undefined ? (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<div className="w-56">
|
|
<SparkBar
|
|
labelBottom={INIT_PROGRESS[progress as keyof typeof INIT_PROGRESS]}
|
|
labelBottomClass="text-xs normal-nums! text-foreground-light"
|
|
type="horizontal"
|
|
value={stagePercent * 100}
|
|
max={100}
|
|
barClass="bg-brand"
|
|
/>
|
|
</div>
|
|
</TooltipTrigger>
|
|
{estimations !== undefined && (
|
|
<TooltipContent asChild side="bottom">
|
|
<div className="w-56">
|
|
<p className="text-foreground-light mb-0.5">Duration estimates:</p>
|
|
{estimations.baseBackupDownloadEstimateSeconds !== undefined && (
|
|
<p>
|
|
Base backup download:{' '}
|
|
{formatSeconds(estimations.baseBackupDownloadEstimateSeconds)}
|
|
</p>
|
|
)}
|
|
{estimations.walArchiveReplayEstimateSeconds !== undefined && (
|
|
<p>
|
|
WAL archive replay:{' '}
|
|
{formatSeconds(estimations.walArchiveReplayEstimateSeconds)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</TooltipContent>
|
|
)}
|
|
</Tooltip>
|
|
) : error !== undefined ? (
|
|
<p className="text-sm text-foreground-light">
|
|
Error: {ERROR_STATES[error as keyof typeof ERROR_STATES]}
|
|
</p>
|
|
) : (
|
|
<p className="text-sm text-foreground-light">
|
|
Created:{' '}
|
|
<TimestampInfo className="text-sm" utcTimestamp={inserted_at} label={created} />
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="text" icon={<MoreVertical />} className="px-1" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="w-40" side="bottom" align="end">
|
|
<DropdownMenuItem
|
|
className="gap-x-2"
|
|
onClick={() => {
|
|
setConnect({ showConnect: true, source: id })
|
|
state.setSelectedDatabaseId(id)
|
|
}}
|
|
>
|
|
View connection string
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="gap-x-2">
|
|
<Link href={getReadReplicaPath(ref, id)}>Manage replica</Link>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|
|
|
|
export const RegionNode = ({ data }: any) => {
|
|
const { region, numReplicas } = data
|
|
const regionNodeWidth =
|
|
20 + (NODE_WIDTH / 2 - 10) * numReplicas + (numReplicas - 1) * (NODE_SEP + 10)
|
|
|
|
return (
|
|
<div
|
|
className="relative flex justify-between rounded-sm bg-black/10 border border-default border-white/10 border-2 p-3"
|
|
style={{ width: regionNodeWidth, height: REGION_NODE_HEIGHT }}
|
|
>
|
|
<div className="absolute bottom-2 flex items-center justify-between gap-x-2">
|
|
<RegionFlag className="w-5" region={region.region} />
|
|
<p className="text-sm">{region.name}</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|