mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 01:15:03 +03:00
[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:
1 parent
013af4ed58
commit
928049ce2c
24 files changed
+1674
-266
No files matched your search
+17
-91
@@ -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>
|
||||
</>
|
||||
|
||||
+81
@@ -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>
|
||||
)
|
||||
}
|
||||
+132
@@ -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>
|
||||
)
|
||||
}
|
||||
+113
@@ -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>
|
||||
)
|
||||
}
|
||||
+32
@@ -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)
|
||||
})
|
||||
})
|
||||
+154
@@ -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 }
|
||||
}
|
||||
+190
@@ -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>
|
||||
)
|
||||
}
|
||||
+292
@@ -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([])
|
||||
})
|
||||
})
|
||||
+192
@@ -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,
|
||||
}))
|
||||
+16
@@ -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
|
||||
|
||||
+48
-112
@@ -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>
|
||||
)
|
||||
}
|
||||
+28
@@ -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)
|
||||
})
|
||||
})
|
||||
+7
-3
@@ -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, {
|
||||
|
||||
+2
-53
@@ -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"
|
||||
|
||||
+45
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.react-flow__edge.animated path {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.map-path {
|
||||
animation: dash 8s linear infinite;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user