mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 14:49:10 +03:00
replica promote prototype
This commit is contained in:
1 parent
ad5c4bb879
commit
7a7c5efb1f
19 files changed
+1339
-117
No files matched your search
@@ -7,6 +7,7 @@ import { TimestampInfo } from 'ui-patterns/TimestampInfo'
|
||||
|
||||
import { HighAvailabilityBadge } from './HighAvailabilityBadge'
|
||||
import { ServiceStatus } from './ServiceStatus'
|
||||
import type { FailoverSimulationPhase } from './simulateFailover.utils'
|
||||
import { ComputeBadgeWrapper } from '@/components/ui/ComputeBadgeWrapper'
|
||||
import { DisableInteraction } from '@/components/ui/DisableInteraction'
|
||||
import { SingleStat } from '@/components/ui/SingleStat'
|
||||
@@ -74,10 +75,17 @@ export const BranchStatValue = ({
|
||||
return <p className="text-foreground-lighter">Unknown</p>
|
||||
}
|
||||
|
||||
export const ActivityStats = () => {
|
||||
export const ActivityStats = ({
|
||||
isHighAvailabilityOverride,
|
||||
failoverPhase = 'off',
|
||||
}: {
|
||||
isHighAvailabilityOverride?: boolean
|
||||
failoverPhase?: FailoverSimulationPhase
|
||||
}) => {
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { isHighAvailability } = useHighAvailability()
|
||||
const { isHighAvailability: isProjectHighAvailability } = useHighAvailability()
|
||||
const isHighAvailability = isHighAvailabilityOverride ?? isProjectHighAvailability
|
||||
const { data: organization } = useSelectedOrganizationQuery()
|
||||
const { data: resourceWarnings } = useResourceWarningsQuery({ slug: organization?.slug })
|
||||
const projectResourceWarnings = resourceWarnings?.find((warning) => warning.project === ref)
|
||||
@@ -154,7 +162,7 @@ export const ActivityStats = () => {
|
||||
return (
|
||||
<div className="@container">
|
||||
<div className="grid grid-cols-1 @md:grid-cols-2 gap-2 @md:gap-6 flex-wrap">
|
||||
<ServiceStatus />
|
||||
<ServiceStatus isHighAvailabilityOverride={isHighAvailability} failoverPhase={failoverPhase} />
|
||||
|
||||
<SingleStat
|
||||
icon={<Cpu size={18} strokeWidth={1.5} className="text-foreground" />}
|
||||
@@ -172,7 +180,7 @@ export const ActivityStats = () => {
|
||||
) : (
|
||||
<p className="text-foreground-lighter">Unknown</p>
|
||||
)}
|
||||
{project?.high_availability && <HighAvailabilityBadge />}
|
||||
{isHighAvailability && <HighAvailabilityBadge />}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -6,6 +6,10 @@ import { cn, HoverCard, HoverCardContent, HoverCardTrigger, InfoIcon } from 'ui'
|
||||
|
||||
import { useUnifiedLogsPreview } from '../App/FeaturePreview/FeaturePreviewContext'
|
||||
import { resolveRealtimeServiceStatus, type ProjectServiceStatus } from './ServiceStatus.utils'
|
||||
import {
|
||||
getFailoverSimulationStatusLabel,
|
||||
type FailoverSimulationPhase,
|
||||
} from './simulateFailover.utils'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { SingleStat } from '@/components/ui/SingleStat'
|
||||
import { useBranchesQuery } from '@/data/branches/branches-query'
|
||||
@@ -101,10 +105,17 @@ const extractDbSchema = (response: ServiceHealthResponse | undefined) => {
|
||||
* minutes of when it was created). Might be related to decoupling "ready" state vs "health checks"
|
||||
*/
|
||||
|
||||
export const ServiceStatus = () => {
|
||||
export const ServiceStatus = ({
|
||||
isHighAvailabilityOverride,
|
||||
failoverPhase = 'off',
|
||||
}: {
|
||||
isHighAvailabilityOverride?: boolean
|
||||
failoverPhase?: FailoverSimulationPhase
|
||||
}) => {
|
||||
const { ref } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
const { isHighAvailability } = useHighAvailability()
|
||||
const { isHighAvailability: isProjectHighAvailability } = useHighAvailability()
|
||||
const isHighAvailability = isHighAvailabilityOverride ?? isProjectHighAvailability
|
||||
const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
|
||||
|
||||
const {
|
||||
@@ -310,7 +321,7 @@ export const ServiceStatus = () => {
|
||||
return 'Healthy'
|
||||
}
|
||||
|
||||
const overallStatusLabel = getOverallStatusLabel()
|
||||
const overallStatusLabel = getFailoverSimulationStatusLabel(failoverPhase) ?? getOverallStatusLabel()
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import Link from 'next/link'
|
||||
import { parseAsBoolean, useQueryState } from 'nuqs'
|
||||
import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
||||
|
||||
import { InstanceConfiguration } from '../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration'
|
||||
import { ActivityStats } from '@/components/interfaces/ProjectHome/ActivityStats'
|
||||
import { ProjectConnectionPopover } from '@/components/interfaces/ProjectHome/ProjectConnectionPopover'
|
||||
import { useSimulatedFailover } from '@/components/interfaces/ProjectHome/useSimulatedFailover'
|
||||
import { ProjectPausedState } from '@/components/layouts/ProjectLayout/PausedState/ProjectPausedState'
|
||||
import { InlineLink } from '@/components/ui/InlineLink'
|
||||
import { ProjectUpgradeFailedBanner } from '@/components/ui/ProjectUpgradeFailedBanner'
|
||||
import { useBranchesQuery } from '@/data/branches/branches-query'
|
||||
import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
|
||||
import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
|
||||
import { useIsOrioleDb, useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { DOCS_URL, IS_PLATFORM, PROJECT_STATUS } from '@/lib/constants'
|
||||
|
||||
export const TopSection = () => {
|
||||
const isOrioleDb = useIsOrioleDb()
|
||||
const { isHighAvailability: isProjectHighAvailability } = useHighAvailability()
|
||||
const [simulateHighAvailability] = useQueryState(
|
||||
'simulateHighAvailability',
|
||||
parseAsBoolean.withDefault(false)
|
||||
)
|
||||
const [simulateFailover] = useQueryState('simulateFailover', parseAsBoolean.withDefault(false))
|
||||
const { data: project, isLoading } = useSelectedProjectQuery()
|
||||
const { data: parentProject } = useProjectDetailQuery({ ref: project?.parent_project_ref })
|
||||
|
||||
@@ -27,6 +36,8 @@ export const TopSection = () => {
|
||||
const isMainBranch = currentBranch?.name === mainBranch?.name
|
||||
|
||||
const isPaused = project?.status === PROJECT_STATUS.INACTIVE
|
||||
const isHighAvailability = isProjectHighAvailability || simulateHighAvailability
|
||||
const failoverPhase = useSimulatedFailover(isHighAvailability && simulateFailover)
|
||||
const projectName =
|
||||
currentBranch && !isMainBranch
|
||||
? currentBranch.name
|
||||
@@ -84,7 +95,10 @@ export const TopSection = () => {
|
||||
</div>
|
||||
{IS_PLATFORM && (
|
||||
<div className="mt-8">
|
||||
<ActivityStats />
|
||||
<ActivityStats
|
||||
isHighAvailabilityOverride={isHighAvailability}
|
||||
failoverPhase={failoverPhase}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -95,7 +109,10 @@ export const TopSection = () => {
|
||||
'w-full h-[400px] md:h-[500px] border border-muted rounded-md overflow-hidden flex flex-col relative'
|
||||
)}
|
||||
>
|
||||
<InstanceConfiguration />
|
||||
<InstanceConfiguration
|
||||
simulateHighAvailability={simulateHighAvailability}
|
||||
failoverPhase={failoverPhase}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
FAILOVER_SIMULATION_FAILOVER_AT_MS,
|
||||
FAILOVER_SIMULATION_HEALTHY_MS,
|
||||
getFailoverSimulationDelayMs,
|
||||
getFailoverSimulationPhase,
|
||||
getFailoverSimulationStatusLabel,
|
||||
} from './simulateFailover.utils'
|
||||
|
||||
describe('getFailoverSimulationPhase', () => {
|
||||
it('is off when the simulation is disabled', () => {
|
||||
expect(getFailoverSimulationPhase({ enabled: false, elapsedMs: 0 })).toBe('off')
|
||||
expect(
|
||||
getFailoverSimulationPhase({ enabled: false, elapsedMs: FAILOVER_SIMULATION_HEALTHY_MS })
|
||||
).toBe('off')
|
||||
expect(
|
||||
getFailoverSimulationPhase({
|
||||
enabled: false,
|
||||
elapsedMs: 0,
|
||||
prefersReducedMotion: true,
|
||||
})
|
||||
).toBe('off')
|
||||
})
|
||||
|
||||
it('skips to failover when reduced motion is preferred', () => {
|
||||
expect(
|
||||
getFailoverSimulationPhase({
|
||||
enabled: true,
|
||||
elapsedMs: 0,
|
||||
prefersReducedMotion: true,
|
||||
})
|
||||
).toBe('failover')
|
||||
})
|
||||
|
||||
it('plays healthy, then promoting, then failover without looping', () => {
|
||||
expect(getFailoverSimulationPhase({ enabled: true, elapsedMs: 0 })).toBe('healthy')
|
||||
expect(
|
||||
getFailoverSimulationPhase({ enabled: true, elapsedMs: FAILOVER_SIMULATION_HEALTHY_MS - 1 })
|
||||
).toBe('healthy')
|
||||
expect(
|
||||
getFailoverSimulationPhase({ enabled: true, elapsedMs: FAILOVER_SIMULATION_HEALTHY_MS })
|
||||
).toBe('promoting')
|
||||
expect(
|
||||
getFailoverSimulationPhase({
|
||||
enabled: true,
|
||||
elapsedMs: FAILOVER_SIMULATION_FAILOVER_AT_MS - 1,
|
||||
})
|
||||
).toBe('promoting')
|
||||
expect(
|
||||
getFailoverSimulationPhase({ enabled: true, elapsedMs: FAILOVER_SIMULATION_FAILOVER_AT_MS })
|
||||
).toBe('failover')
|
||||
expect(
|
||||
getFailoverSimulationPhase({
|
||||
enabled: true,
|
||||
elapsedMs: FAILOVER_SIMULATION_FAILOVER_AT_MS + 60_000,
|
||||
})
|
||||
).toBe('failover')
|
||||
})
|
||||
|
||||
it('treats non-finite and negative elapsed time as healthy', () => {
|
||||
expect(getFailoverSimulationPhase({ enabled: true, elapsedMs: Number.NaN })).toBe('healthy')
|
||||
expect(getFailoverSimulationPhase({ enabled: true, elapsedMs: Number.POSITIVE_INFINITY })).toBe(
|
||||
'healthy'
|
||||
)
|
||||
expect(getFailoverSimulationPhase({ enabled: true, elapsedMs: -1 })).toBe('healthy')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFailoverSimulationDelayMs', () => {
|
||||
it('waits the remaining healthy window, then the promoting window, then stops', () => {
|
||||
expect(getFailoverSimulationDelayMs(0)).toBe(FAILOVER_SIMULATION_HEALTHY_MS)
|
||||
expect(getFailoverSimulationDelayMs(1)).toBe(FAILOVER_SIMULATION_HEALTHY_MS - 1)
|
||||
expect(getFailoverSimulationDelayMs(FAILOVER_SIMULATION_HEALTHY_MS)).toBe(
|
||||
FAILOVER_SIMULATION_FAILOVER_AT_MS - FAILOVER_SIMULATION_HEALTHY_MS
|
||||
)
|
||||
expect(getFailoverSimulationDelayMs(FAILOVER_SIMULATION_FAILOVER_AT_MS - 1)).toBe(1)
|
||||
expect(getFailoverSimulationDelayMs(FAILOVER_SIMULATION_FAILOVER_AT_MS)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('falls back to the healthy window for non-finite and negative elapsed time', () => {
|
||||
expect(getFailoverSimulationDelayMs(Number.NaN)).toBe(FAILOVER_SIMULATION_HEALTHY_MS)
|
||||
expect(getFailoverSimulationDelayMs(Number.NEGATIVE_INFINITY)).toBe(
|
||||
FAILOVER_SIMULATION_HEALTHY_MS
|
||||
)
|
||||
expect(getFailoverSimulationDelayMs(-10)).toBe(FAILOVER_SIMULATION_HEALTHY_MS)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFailoverSimulationStatusLabel', () => {
|
||||
it('returns copy only for in-progress and completed failover', () => {
|
||||
expect(getFailoverSimulationStatusLabel('off')).toBeUndefined()
|
||||
expect(getFailoverSimulationStatusLabel('healthy')).toBeUndefined()
|
||||
expect(getFailoverSimulationStatusLabel('promoting')).toBe('Promoting replica')
|
||||
expect(getFailoverSimulationStatusLabel('failover')).toBe('Replica promoted')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
export type FailoverSimulationPhase = 'off' | 'healthy' | 'promoting' | 'failover'
|
||||
|
||||
export const FAILOVER_SIMULATION_HEALTHY_MS = 3_000
|
||||
export const FAILOVER_SIMULATION_PROMOTING_MS = 2_500
|
||||
export const FAILOVER_SIMULATION_FAILOVER_AT_MS =
|
||||
FAILOVER_SIMULATION_HEALTHY_MS + FAILOVER_SIMULATION_PROMOTING_MS
|
||||
|
||||
/**
|
||||
* One-shot healthy → promoting → failover timeline for `simulateFailover=true`.
|
||||
* Reduced motion skips ahead to the completed failover layout.
|
||||
*/
|
||||
export const getFailoverSimulationPhase = ({
|
||||
enabled,
|
||||
elapsedMs,
|
||||
prefersReducedMotion = false,
|
||||
}: {
|
||||
enabled: boolean
|
||||
elapsedMs: number
|
||||
prefersReducedMotion?: boolean
|
||||
}): FailoverSimulationPhase => {
|
||||
if (!enabled) return 'off'
|
||||
if (prefersReducedMotion) return 'failover'
|
||||
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return 'healthy'
|
||||
if (elapsedMs < FAILOVER_SIMULATION_HEALTHY_MS) return 'healthy'
|
||||
if (elapsedMs < FAILOVER_SIMULATION_FAILOVER_AT_MS) return 'promoting'
|
||||
return 'failover'
|
||||
}
|
||||
|
||||
/** Milliseconds until the next phase, or `undefined` once failover has been reached. */
|
||||
export const getFailoverSimulationDelayMs = (elapsedMs: number): number | undefined => {
|
||||
if (!Number.isFinite(elapsedMs) || elapsedMs < 0) return FAILOVER_SIMULATION_HEALTHY_MS
|
||||
if (elapsedMs < FAILOVER_SIMULATION_HEALTHY_MS) {
|
||||
return FAILOVER_SIMULATION_HEALTHY_MS - elapsedMs
|
||||
}
|
||||
if (elapsedMs < FAILOVER_SIMULATION_FAILOVER_AT_MS) {
|
||||
return FAILOVER_SIMULATION_FAILOVER_AT_MS - elapsedMs
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const getFailoverSimulationStatusLabel = (
|
||||
phase: FailoverSimulationPhase
|
||||
): string | undefined => {
|
||||
if (phase === 'promoting') return 'Promoting replica'
|
||||
if (phase === 'failover') return 'Replica promoted'
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useReducedMotion } from 'common'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import {
|
||||
getFailoverSimulationDelayMs,
|
||||
getFailoverSimulationPhase,
|
||||
type FailoverSimulationPhase,
|
||||
} from './simulateFailover.utils'
|
||||
|
||||
/**
|
||||
* When `enabled` (the `simulateFailover` query param), plays a one-shot
|
||||
* healthy → promoting → failover sequence. Reduced-motion users skip to
|
||||
* the completed failover layout.
|
||||
*/
|
||||
export const useSimulatedFailover = (enabled: boolean): FailoverSimulationPhase => {
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const [elapsedMs, setElapsedMs] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || prefersReducedMotion) {
|
||||
setElapsedMs(0)
|
||||
return
|
||||
}
|
||||
|
||||
const startedAt = Date.now()
|
||||
setElapsedMs(0)
|
||||
let timeoutId: ReturnType<typeof setTimeout>
|
||||
|
||||
const tick = () => {
|
||||
const nextElapsedMs = Date.now() - startedAt
|
||||
setElapsedMs(nextElapsedMs)
|
||||
const delay = getFailoverSimulationDelayMs(nextElapsedMs)
|
||||
if (delay !== undefined) {
|
||||
timeoutId = setTimeout(tick, delay)
|
||||
}
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(tick, getFailoverSimulationDelayMs(0) ?? 0)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}, [enabled, prefersReducedMotion])
|
||||
|
||||
return getFailoverSimulationPhase({ enabled, elapsedMs, prefersReducedMotion })
|
||||
}
|
||||
+95
-7
@@ -8,25 +8,34 @@ import {
|
||||
ReactFlow,
|
||||
useNodesInitialized,
|
||||
useReactFlow,
|
||||
type Padding,
|
||||
} from '@xyflow/react'
|
||||
import { useReducedMotion } from 'common'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useEffectEvent, useState } from 'react'
|
||||
import { useEffect, useEffectEvent, useRef, useState } from 'react'
|
||||
|
||||
import '@xyflow/react/dist/style.css'
|
||||
|
||||
import { cn } from 'ui'
|
||||
|
||||
import {
|
||||
getLayoutTransitionFrame,
|
||||
hasLayoutDelta,
|
||||
} from './DiagramFlow.utils'
|
||||
import { getDagreGraphLayout } from './InstanceConfiguration.utils'
|
||||
import { timeout } from '@/lib/helpers'
|
||||
|
||||
interface DiagramFlowProps {
|
||||
nodes: Node[]
|
||||
edges: Edge[]
|
||||
/** Optional stable topology used only for positioning nodes. */
|
||||
layoutEdges?: 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
|
||||
fitViewPadding?: Padding
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -39,28 +48,47 @@ interface DiagramFlowProps {
|
||||
export const DiagramFlow = ({
|
||||
nodes,
|
||||
edges,
|
||||
layoutEdges,
|
||||
nodeTypes,
|
||||
edgeTypes,
|
||||
addGroupNodes,
|
||||
ranksep,
|
||||
fitViewPadding,
|
||||
}: DiagramFlowProps) => {
|
||||
const reactFlow = useReactFlow()
|
||||
const prefersReducedMotion = useReducedMotion()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const nodesInitialized = useNodesInitialized()
|
||||
const [hasMeasuredLayout, setHasMeasuredLayout] = useState(false)
|
||||
const layoutGenerationRef = useRef(0)
|
||||
const animationFrameRef = useRef<number | undefined>(undefined)
|
||||
|
||||
const backgroundPatternColor =
|
||||
resolvedTheme === 'dark' ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.4)'
|
||||
|
||||
const cancelLayoutAnimation = () => {
|
||||
if (animationFrameRef.current !== undefined) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
animationFrameRef.current = undefined
|
||||
}
|
||||
}
|
||||
|
||||
const fitDiagram = () => {
|
||||
reactFlow.fitView({ maxZoom: 0.9, minZoom: 0.9, padding: fitViewPadding })
|
||||
}
|
||||
|
||||
const setReactFlow = useEffectEvent(async ({ isMeasuredPass }: { isMeasuredPass: boolean }) => {
|
||||
const generation = ++layoutGenerationRef.current
|
||||
cancelLayoutAnimation()
|
||||
|
||||
// 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)
|
||||
const graph = getDagreGraphLayout(measuredNodes, layoutEdges ?? edges, { ranksep })
|
||||
const { nodes: groupedNodes } = addGroupNodes(graph.nodes, 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
|
||||
@@ -71,12 +99,64 @@ export const DiagramFlow = ({
|
||||
const existing = reactFlow.getNode(node.id)
|
||||
return existing?.measured ? { ...node, measured: existing.measured } : node
|
||||
})
|
||||
|
||||
const currentNodes = reactFlow.getNodes()
|
||||
const shouldAnimate =
|
||||
hasMeasuredLayout &&
|
||||
!prefersReducedMotion &&
|
||||
currentNodes.length > 0 &&
|
||||
hasLayoutDelta(currentNodes, updatedNodes)
|
||||
|
||||
if (shouldAnimate) {
|
||||
const fromEdges = reactFlow.getEdges()
|
||||
const startedAt = performance.now()
|
||||
let hasFitted = false
|
||||
|
||||
const step = (now: number) => {
|
||||
if (generation !== layoutGenerationRef.current) return
|
||||
const frame = getLayoutTransitionFrame({
|
||||
elapsedMs: now - startedAt,
|
||||
fromNodes: currentNodes,
|
||||
toNodes: updatedNodes,
|
||||
fromEdges,
|
||||
toEdges: edges,
|
||||
})
|
||||
reactFlow.setNodes(frame.nodes)
|
||||
reactFlow.setEdges(frame.edges)
|
||||
|
||||
if (frame.stage !== 'fade-out' && frame.stage !== 'move' && !hasFitted) {
|
||||
hasFitted = true
|
||||
fitDiagram()
|
||||
}
|
||||
|
||||
if (frame.stage === 'done') {
|
||||
animationFrameRef.current = undefined
|
||||
if (isMeasuredPass) setHasMeasuredLayout(true)
|
||||
return
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(step)
|
||||
return
|
||||
}
|
||||
|
||||
reactFlow.setEdges(edges)
|
||||
reactFlow.setNodes(updatedNodes)
|
||||
reactFlow.setEdges(graph.edges)
|
||||
|
||||
if (generation !== layoutGenerationRef.current) return
|
||||
|
||||
// Data-only updates (status, badges) should not yank the camera.
|
||||
if (hasMeasuredLayout && !hasLayoutDelta(currentNodes, updatedNodes)) {
|
||||
if (isMeasuredPass) setHasMeasuredLayout(true)
|
||||
return
|
||||
}
|
||||
|
||||
// [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 (generation !== layoutGenerationRef.current) return
|
||||
fitDiagram()
|
||||
if (isMeasuredPass) setHasMeasuredLayout(true)
|
||||
})
|
||||
|
||||
@@ -87,7 +167,7 @@ export const DiagramFlow = ({
|
||||
if (nodes.length > 0) {
|
||||
setReactFlow({ isMeasuredPass: false })
|
||||
}
|
||||
}, [nodes, edges])
|
||||
}, [nodes, edges, layoutEdges, fitViewPadding])
|
||||
|
||||
// Second pass: once React Flow has measured the nodes, re-run the layout so
|
||||
// dagre uses real heights. Only `nodesInitialized` going true should trigger
|
||||
@@ -101,12 +181,20 @@ export const DiagramFlow = ({
|
||||
runMeasuredLayout()
|
||||
}, [nodesInitialized])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (animationFrameRef.current !== undefined) {
|
||||
cancelAnimationFrame(animationFrameRef.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<ReactFlow
|
||||
// FIXME: https://github.com/xyflow/xyflow/issues/4876
|
||||
colorMode={'' as unknown as ColorMode}
|
||||
fitView
|
||||
fitViewOptions={{ minZoom: 0.9, maxZoom: 0.9 }}
|
||||
fitViewOptions={{ minZoom: 0.9, maxZoom: 0.9, padding: fitViewPadding }}
|
||||
// Keep the diagram invisible (but laid out, so nodes can be measured)
|
||||
// until the measured-height layout pass has run.
|
||||
className={cn(
|
||||
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { Edge, Node } from '@xyflow/react'
|
||||
|
||||
import {
|
||||
easeInOutCubic,
|
||||
EDGE_FADE_MS,
|
||||
getLayoutTransitionFrame,
|
||||
getLayoutTransitionStage,
|
||||
getLayoutTransitionTimings,
|
||||
hasLayoutDelta,
|
||||
interpolateNodes,
|
||||
lerp,
|
||||
NODE_LAYOUT_ANIMATION_MS,
|
||||
withEdgeOpacity,
|
||||
} from './DiagramFlow.utils'
|
||||
|
||||
const node = (id: string, x: number, y: number, width = 100, height = 40): Node => ({
|
||||
id,
|
||||
position: { x, y },
|
||||
width,
|
||||
height,
|
||||
data: {},
|
||||
})
|
||||
|
||||
const edge = (id: string, extra?: Partial<Edge>): Edge => ({
|
||||
id,
|
||||
source: 'a',
|
||||
target: 'b',
|
||||
...extra,
|
||||
})
|
||||
|
||||
describe('easeInOutCubic', () => {
|
||||
it('clamps and eases through the midpoint', () => {
|
||||
expect(easeInOutCubic(-1)).toBe(0)
|
||||
expect(easeInOutCubic(0)).toBe(0)
|
||||
expect(easeInOutCubic(0.5)).toBe(0.5)
|
||||
expect(easeInOutCubic(1)).toBe(1)
|
||||
expect(easeInOutCubic(2)).toBe(1)
|
||||
expect(easeInOutCubic(0.25)).toBeLessThan(0.25)
|
||||
expect(easeInOutCubic(0.75)).toBeGreaterThan(0.75)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lerp', () => {
|
||||
it('interpolates and handles a zero-length range', () => {
|
||||
expect(lerp(0, 10, 0)).toBe(0)
|
||||
expect(lerp(0, 10, 0.5)).toBe(5)
|
||||
expect(lerp(0, 10, 1)).toBe(10)
|
||||
expect(lerp(4, 4, 0.3)).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasLayoutDelta', () => {
|
||||
it('is false when positions and sizes match', () => {
|
||||
expect(hasLayoutDelta([node('a', 0, 0)], [node('a', 0, 0)])).toBe(false)
|
||||
})
|
||||
|
||||
it('is true when a node moves, resizes, is added, or is removed', () => {
|
||||
expect(hasLayoutDelta([node('a', 0, 0)], [node('a', 8, 0)])).toBe(true)
|
||||
expect(hasLayoutDelta([node('a', 0, 0, 100)], [node('a', 0, 0, 120)])).toBe(true)
|
||||
expect(hasLayoutDelta([node('a', 0, 0)], [node('a', 0, 0), node('b', 1, 1)])).toBe(true)
|
||||
expect(hasLayoutDelta([node('a', 0, 0), node('b', 1, 1)], [node('a', 0, 0)])).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('interpolateNodes', () => {
|
||||
it('returns the target list at the end of the animation', () => {
|
||||
const to = [node('a', 10, 20)]
|
||||
expect(interpolateNodes(new Map([['a', node('a', 0, 0)]]), to, 1)).toBe(to)
|
||||
})
|
||||
|
||||
it('keeps target data while interpolating position and size', () => {
|
||||
const from = node('a', 0, 0, 100, 40)
|
||||
const to: Node = { ...node('a', 10, 20, 200, 80), data: { label: 'promoted' } }
|
||||
const [interpolated] = interpolateNodes(new Map([['a', from]]), [to], 0.5)
|
||||
|
||||
expect(interpolated.data).toEqual({ label: 'promoted' })
|
||||
expect(interpolated.position.x).toBeGreaterThan(0)
|
||||
expect(interpolated.position.x).toBeLessThan(10)
|
||||
expect(interpolated.width).toBeGreaterThan(100)
|
||||
expect(interpolated.width).toBeLessThan(200)
|
||||
})
|
||||
|
||||
it('snaps new nodes to their target', () => {
|
||||
const to = [node('b', 5, 5)]
|
||||
expect(interpolateNodes(new Map(), to, 0.5)).toEqual(to)
|
||||
})
|
||||
})
|
||||
|
||||
describe('withEdgeOpacity', () => {
|
||||
it('sets opacity without dropping existing path styles', () => {
|
||||
const faded = withEdgeOpacity([edge('replication', { style: { strokeDasharray: '3 5' } })], 0.4)
|
||||
|
||||
expect(faded[0].style).toEqual({ strokeDasharray: '3 5', opacity: 0.4 })
|
||||
expect(faded[0].id).toBe('replication')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLayoutTransitionTimings', () => {
|
||||
it('includes fade windows only when that side has edges', () => {
|
||||
expect(
|
||||
getLayoutTransitionTimings({ hasOutgoingEdges: true, hasIncomingEdges: true })
|
||||
).toEqual({
|
||||
fadeOutMs: EDGE_FADE_MS,
|
||||
moveMs: NODE_LAYOUT_ANIMATION_MS,
|
||||
fadeInMs: EDGE_FADE_MS,
|
||||
moveStartsAt: EDGE_FADE_MS,
|
||||
fadeInStartsAt: EDGE_FADE_MS + NODE_LAYOUT_ANIMATION_MS,
|
||||
doneAt: EDGE_FADE_MS + NODE_LAYOUT_ANIMATION_MS + EDGE_FADE_MS,
|
||||
})
|
||||
expect(
|
||||
getLayoutTransitionTimings({ hasOutgoingEdges: false, hasIncomingEdges: false })
|
||||
).toEqual({
|
||||
fadeOutMs: 0,
|
||||
moveMs: NODE_LAYOUT_ANIMATION_MS,
|
||||
fadeInMs: 0,
|
||||
moveStartsAt: 0,
|
||||
fadeInStartsAt: NODE_LAYOUT_ANIMATION_MS,
|
||||
doneAt: NODE_LAYOUT_ANIMATION_MS,
|
||||
})
|
||||
expect(
|
||||
getLayoutTransitionTimings({ hasOutgoingEdges: true, hasIncomingEdges: false })
|
||||
).toMatchObject({ fadeOutMs: EDGE_FADE_MS, fadeInMs: 0 })
|
||||
expect(
|
||||
getLayoutTransitionTimings({ hasOutgoingEdges: false, hasIncomingEdges: true })
|
||||
).toMatchObject({ fadeOutMs: 0, fadeInMs: EDGE_FADE_MS })
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLayoutTransitionStage', () => {
|
||||
const timings = getLayoutTransitionTimings({
|
||||
hasOutgoingEdges: true,
|
||||
hasIncomingEdges: true,
|
||||
})
|
||||
|
||||
it('walks fade-out, move, fade-in, then done', () => {
|
||||
expect(getLayoutTransitionStage(0, timings)).toBe('fade-out')
|
||||
expect(getLayoutTransitionStage(timings.moveStartsAt - 1, timings)).toBe('fade-out')
|
||||
expect(getLayoutTransitionStage(timings.moveStartsAt, timings)).toBe('move')
|
||||
expect(getLayoutTransitionStage(timings.fadeInStartsAt - 1, timings)).toBe('move')
|
||||
expect(getLayoutTransitionStage(timings.fadeInStartsAt, timings)).toBe('fade-in')
|
||||
expect(getLayoutTransitionStage(timings.doneAt - 1, timings)).toBe('fade-in')
|
||||
expect(getLayoutTransitionStage(timings.doneAt, timings)).toBe('done')
|
||||
expect(getLayoutTransitionStage(timings.doneAt + 5_000, timings)).toBe('done')
|
||||
})
|
||||
|
||||
it('treats non-finite and negative elapsed time as the start', () => {
|
||||
expect(getLayoutTransitionStage(Number.NaN, timings)).toBe('fade-out')
|
||||
expect(getLayoutTransitionStage(Number.NEGATIVE_INFINITY, timings)).toBe('fade-out')
|
||||
expect(getLayoutTransitionStage(-20, timings)).toBe('fade-out')
|
||||
})
|
||||
|
||||
it('starts on move when there are no outgoing edges', () => {
|
||||
const moveFirst = getLayoutTransitionTimings({
|
||||
hasOutgoingEdges: false,
|
||||
hasIncomingEdges: true,
|
||||
})
|
||||
expect(getLayoutTransitionStage(0, moveFirst)).toBe('move')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLayoutTransitionFrame', () => {
|
||||
const fromNodes = [node('a', 0, 0)]
|
||||
const toNodes = [node('a', 100, 0)]
|
||||
const fromEdges = [edge('old')]
|
||||
const toEdges = [edge('new', { style: { strokeDasharray: '3 5' } })]
|
||||
const timings = getLayoutTransitionTimings({
|
||||
hasOutgoingEdges: true,
|
||||
hasIncomingEdges: true,
|
||||
})
|
||||
|
||||
it('fades outgoing edges while nodes stay put', () => {
|
||||
const start = getLayoutTransitionFrame({
|
||||
elapsedMs: 0,
|
||||
fromNodes,
|
||||
toNodes,
|
||||
fromEdges,
|
||||
toEdges,
|
||||
})
|
||||
const mid = getLayoutTransitionFrame({
|
||||
elapsedMs: EDGE_FADE_MS / 2,
|
||||
fromNodes,
|
||||
toNodes,
|
||||
fromEdges,
|
||||
toEdges,
|
||||
})
|
||||
|
||||
expect(start.stage).toBe('fade-out')
|
||||
expect(start.nodes).toBe(fromNodes)
|
||||
expect(start.edges[0].style?.opacity).toBe(1)
|
||||
expect(mid.nodes[0].position).toEqual({ x: 0, y: 0 })
|
||||
expect(mid.edges[0].style?.opacity).toBe(0.5)
|
||||
expect(mid.edges[0].id).toBe('old')
|
||||
})
|
||||
|
||||
it('clears edges while nodes interpolate', () => {
|
||||
const startMove = getLayoutTransitionFrame({
|
||||
elapsedMs: timings.moveStartsAt,
|
||||
fromNodes,
|
||||
toNodes,
|
||||
fromEdges,
|
||||
toEdges,
|
||||
})
|
||||
const midMove = getLayoutTransitionFrame({
|
||||
elapsedMs: timings.moveStartsAt + NODE_LAYOUT_ANIMATION_MS / 2,
|
||||
fromNodes,
|
||||
toNodes,
|
||||
fromEdges,
|
||||
toEdges,
|
||||
})
|
||||
|
||||
expect(startMove.stage).toBe('move')
|
||||
expect(startMove.edges).toEqual([])
|
||||
expect(startMove.nodes[0].position.x).toBe(0)
|
||||
expect(midMove.edges).toEqual([])
|
||||
expect(midMove.nodes[0].position.x).toBe(50)
|
||||
})
|
||||
|
||||
it('fades incoming edges in after nodes arrive', () => {
|
||||
const startFadeIn = getLayoutTransitionFrame({
|
||||
elapsedMs: timings.fadeInStartsAt,
|
||||
fromNodes,
|
||||
toNodes,
|
||||
fromEdges,
|
||||
toEdges,
|
||||
})
|
||||
const midFadeIn = getLayoutTransitionFrame({
|
||||
elapsedMs: timings.fadeInStartsAt + EDGE_FADE_MS / 2,
|
||||
fromNodes,
|
||||
toNodes,
|
||||
fromEdges,
|
||||
toEdges,
|
||||
})
|
||||
const done = getLayoutTransitionFrame({
|
||||
elapsedMs: timings.doneAt,
|
||||
fromNodes,
|
||||
toNodes,
|
||||
fromEdges,
|
||||
toEdges,
|
||||
})
|
||||
|
||||
expect(startFadeIn.stage).toBe('fade-in')
|
||||
expect(startFadeIn.nodes).toBe(toNodes)
|
||||
expect(startFadeIn.edges[0]).toMatchObject({
|
||||
id: 'new',
|
||||
style: { strokeDasharray: '3 5', opacity: 0 },
|
||||
})
|
||||
expect(midFadeIn.edges[0].style?.opacity).toBe(0.5)
|
||||
expect(done.stage).toBe('done')
|
||||
expect(done.nodes).toBe(toNodes)
|
||||
expect(done.edges).toBe(toEdges)
|
||||
})
|
||||
|
||||
it('skips a fade when that side has no edges', () => {
|
||||
const noOutgoing = getLayoutTransitionFrame({
|
||||
elapsedMs: 0,
|
||||
fromNodes,
|
||||
toNodes,
|
||||
fromEdges: [],
|
||||
toEdges,
|
||||
})
|
||||
const afterMove = getLayoutTransitionFrame({
|
||||
elapsedMs: NODE_LAYOUT_ANIMATION_MS,
|
||||
fromNodes,
|
||||
toNodes,
|
||||
fromEdges: [],
|
||||
toEdges: [],
|
||||
})
|
||||
|
||||
expect(noOutgoing.stage).toBe('move')
|
||||
expect(noOutgoing.edges).toEqual([])
|
||||
expect(afterMove.stage).toBe('done')
|
||||
expect(afterMove.edges).toEqual([])
|
||||
})
|
||||
|
||||
it('treats non-finite elapsed time as the start of the transition', () => {
|
||||
const frame = getLayoutTransitionFrame({
|
||||
elapsedMs: Number.NaN,
|
||||
fromNodes,
|
||||
toNodes,
|
||||
fromEdges,
|
||||
toEdges,
|
||||
})
|
||||
expect(frame.stage).toBe('fade-out')
|
||||
expect(frame.edges[0].style?.opacity).toBe(1)
|
||||
})
|
||||
})
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import type { Edge, Node } from '@xyflow/react'
|
||||
|
||||
export const NODE_LAYOUT_ANIMATION_MS = 800
|
||||
export const EDGE_FADE_MS = 250
|
||||
|
||||
export type LayoutTransitionStage = 'fade-out' | 'move' | 'fade-in' | 'done'
|
||||
|
||||
export type LayoutTransitionTimings = {
|
||||
fadeOutMs: number
|
||||
moveMs: number
|
||||
fadeInMs: number
|
||||
moveStartsAt: number
|
||||
fadeInStartsAt: number
|
||||
doneAt: number
|
||||
}
|
||||
|
||||
export const easeInOutCubic = (t: number): number => {
|
||||
if (t <= 0) return 0
|
||||
if (t >= 1) return 1
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
|
||||
}
|
||||
|
||||
export const lerp = (from: number, to: number, t: number): number => from + (to - from) * t
|
||||
|
||||
export const hasNodeLayoutDelta = (
|
||||
from: Pick<Node, 'position' | 'width' | 'height'> | undefined,
|
||||
to: Pick<Node, 'position' | 'width' | 'height'>
|
||||
): boolean => {
|
||||
if (from === undefined) return true
|
||||
return (
|
||||
from.position.x !== to.position.x ||
|
||||
from.position.y !== to.position.y ||
|
||||
(from.width ?? 0) !== (to.width ?? 0) ||
|
||||
(from.height ?? 0) !== (to.height ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
export const hasLayoutDelta = (from: Node[], to: Node[]): boolean => {
|
||||
if (from.length !== to.length) return true
|
||||
const fromById = new Map(from.map((node) => [node.id, node]))
|
||||
return to.some((node) => hasNodeLayoutDelta(fromById.get(node.id), node))
|
||||
}
|
||||
|
||||
/** Interpolates position and size from `fromById` onto the target node list. */
|
||||
export const interpolateNodes = (fromById: Map<string, Node>, to: Node[], t: number): Node[] => {
|
||||
const progress = easeInOutCubic(t)
|
||||
if (progress >= 1) return to
|
||||
|
||||
return to.map((node) => {
|
||||
const from = fromById.get(node.id)
|
||||
if (from === undefined) return node
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: lerp(from.position.x, node.position.x, progress),
|
||||
y: lerp(from.position.y, node.position.y, progress),
|
||||
},
|
||||
width:
|
||||
node.width !== undefined && from.width !== undefined
|
||||
? lerp(from.width, node.width, progress)
|
||||
: node.width,
|
||||
height:
|
||||
node.height !== undefined && from.height !== undefined
|
||||
? lerp(from.height, node.height, progress)
|
||||
: node.height,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const withEdgeOpacity = (edges: Edge[], opacity: number): Edge[] =>
|
||||
edges.map((edge) => ({
|
||||
...edge,
|
||||
style: { ...edge.style, opacity },
|
||||
}))
|
||||
|
||||
export const getLayoutTransitionTimings = ({
|
||||
hasOutgoingEdges,
|
||||
hasIncomingEdges,
|
||||
edgeFadeMs = EDGE_FADE_MS,
|
||||
nodeMoveMs = NODE_LAYOUT_ANIMATION_MS,
|
||||
}: {
|
||||
hasOutgoingEdges: boolean
|
||||
hasIncomingEdges: boolean
|
||||
edgeFadeMs?: number
|
||||
nodeMoveMs?: number
|
||||
}): LayoutTransitionTimings => {
|
||||
const fadeOutMs = hasOutgoingEdges ? edgeFadeMs : 0
|
||||
const fadeInMs = hasIncomingEdges ? edgeFadeMs : 0
|
||||
return {
|
||||
fadeOutMs,
|
||||
moveMs: nodeMoveMs,
|
||||
fadeInMs,
|
||||
moveStartsAt: fadeOutMs,
|
||||
fadeInStartsAt: fadeOutMs + nodeMoveMs,
|
||||
doneAt: fadeOutMs + nodeMoveMs + fadeInMs,
|
||||
}
|
||||
}
|
||||
|
||||
const clampElapsedMs = (elapsedMs: number): number =>
|
||||
!Number.isFinite(elapsedMs) || elapsedMs < 0 ? 0 : elapsedMs
|
||||
|
||||
export const getLayoutTransitionStage = (
|
||||
elapsedMs: number,
|
||||
timings: LayoutTransitionTimings
|
||||
): LayoutTransitionStage => {
|
||||
const elapsed = clampElapsedMs(elapsedMs)
|
||||
if (elapsed < timings.moveStartsAt) return 'fade-out'
|
||||
if (elapsed < timings.fadeInStartsAt) return 'move'
|
||||
if (elapsed < timings.doneAt) return 'fade-in'
|
||||
return 'done'
|
||||
}
|
||||
|
||||
const stageProgress = (elapsedMs: number, startAt: number, durationMs: number): number => {
|
||||
if (durationMs <= 0) return 1
|
||||
return Math.min(1, Math.max(0, (elapsedMs - startAt) / durationMs))
|
||||
}
|
||||
|
||||
/** Fade outgoing edges, interpolate nodes with no edges, then fade incoming edges in. */
|
||||
export const getLayoutTransitionFrame = ({
|
||||
elapsedMs,
|
||||
fromNodes,
|
||||
toNodes,
|
||||
fromEdges,
|
||||
toEdges,
|
||||
}: {
|
||||
elapsedMs: number
|
||||
fromNodes: Node[]
|
||||
toNodes: Node[]
|
||||
fromEdges: Edge[]
|
||||
toEdges: Edge[]
|
||||
}): { nodes: Node[]; edges: Edge[]; stage: LayoutTransitionStage } => {
|
||||
const elapsed = clampElapsedMs(elapsedMs)
|
||||
const timings = getLayoutTransitionTimings({
|
||||
hasOutgoingEdges: fromEdges.length > 0,
|
||||
hasIncomingEdges: toEdges.length > 0,
|
||||
})
|
||||
const stage = getLayoutTransitionStage(elapsed, timings)
|
||||
|
||||
if (stage === 'fade-out') {
|
||||
const opacity = 1 - stageProgress(elapsed, 0, timings.fadeOutMs)
|
||||
return { nodes: fromNodes, edges: withEdgeOpacity(fromEdges, opacity), stage }
|
||||
}
|
||||
|
||||
if (stage === 'move') {
|
||||
const t = stageProgress(elapsed, timings.moveStartsAt, timings.moveMs)
|
||||
const fromById = new Map(fromNodes.map((node) => [node.id, node]))
|
||||
return { nodes: interpolateNodes(fromById, toNodes, t), edges: [], stage }
|
||||
}
|
||||
|
||||
if (stage === 'fade-in') {
|
||||
const opacity = stageProgress(elapsed, timings.fadeInStartsAt, timings.fadeInMs)
|
||||
return { nodes: toNodes, edges: withEdgeOpacity(toEdges, opacity), stage }
|
||||
}
|
||||
|
||||
return { nodes: toNodes, edges: toEdges, stage }
|
||||
}
|
||||
+68
@@ -1,4 +1,5 @@
|
||||
import { screen, within } from '@testing-library/react'
|
||||
import { ReactFlowProvider } from '@xyflow/react'
|
||||
import { platformComponents as components } from 'api-types'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { describe, expect, test, vi } from 'vitest'
|
||||
@@ -126,4 +127,71 @@ describe('HaInstanceConfiguration', () => {
|
||||
|
||||
expect(await screen.findByText('Cluster topology unavailable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('uses mock topology data without calling HA admin when simulating High Availability', async () => {
|
||||
mockProject('ACTIVE_HEALTHY')
|
||||
let haAdminRequests = 0
|
||||
mswServer.use(
|
||||
http.get(`${API_URL}/platform/projects/:ref/ha-admin/v1/gateways`, () => {
|
||||
haAdminRequests += 1
|
||||
return HttpResponse.json<APIErrorBody>({ message: 'upstream unavailable' }, { status: 500 })
|
||||
}),
|
||||
http.get(`${API_URL}/platform/projects/:ref/ha-admin/v1/poolers`, () => {
|
||||
haAdminRequests += 1
|
||||
return HttpResponse.json<APIErrorBody>({ message: 'upstream unavailable' }, { status: 500 })
|
||||
})
|
||||
)
|
||||
|
||||
customRender(
|
||||
<ReactFlowProvider>
|
||||
<HaInstanceConfiguration simulateHighAvailability />
|
||||
</ReactFlowProvider>,
|
||||
{ profileContext: PROFILE_CONTEXT }
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Primary Database')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Read Replica')).toHaveLength(2)
|
||||
expect(screen.queryByText('Failed to retrieve cluster topology')).not.toBeInTheDocument()
|
||||
expect(haAdminRequests).toBe(0)
|
||||
})
|
||||
|
||||
test('shows the primary as unhealthy and a replica as promoting during the promoting step', async () => {
|
||||
mockProject('ACTIVE_HEALTHY')
|
||||
|
||||
customRender(
|
||||
<ReactFlowProvider>
|
||||
<HaInstanceConfiguration simulateHighAvailability failoverPhase="promoting" />
|
||||
</ReactFlowProvider>,
|
||||
{ profileContext: PROFILE_CONTEXT }
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Primary Database')).toBeInTheDocument()
|
||||
expect(screen.getByText('Promoting')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Promoted')).not.toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByText('Primary Database').parentElement!).getByText('Unhealthy')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('shows the primary as unhealthy and a replica as promoted and healthy during simulated failover', async () => {
|
||||
mockProject('ACTIVE_HEALTHY')
|
||||
|
||||
customRender(
|
||||
<ReactFlowProvider>
|
||||
<HaInstanceConfiguration simulateHighAvailability failoverPhase="failover" />
|
||||
</ReactFlowProvider>,
|
||||
{ profileContext: PROFILE_CONTEXT }
|
||||
)
|
||||
|
||||
expect(await screen.findByText('Primary Database')).toBeInTheDocument()
|
||||
expect(screen.getAllByText('Read Replica')).toHaveLength(2)
|
||||
expect(screen.getAllByText('Healthy')).toHaveLength(2)
|
||||
expect(screen.getByText('Unhealthy')).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByText('Primary Database').parentElement!).getByText('Unhealthy')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(screen.getByText('Promoted').parentElement!).getByText('Healthy')
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+73
-14
@@ -6,13 +6,19 @@ import { EmptyStatePresentational } from 'ui-patterns/EmptyStatePresentational'
|
||||
|
||||
import { DiagramFlow } from './DiagramFlow'
|
||||
import { addShardNodes, generateHaNodesAndEdges } from './HaInstanceConfiguration.utils'
|
||||
import { HaFailoverEdge } from './HaInstanceEdge'
|
||||
import { HaPrimaryNode, HaReplicaNode, HaShardNode, MultigatewayNode } from './HaInstanceNode'
|
||||
import { buildHaTopology, selectTopologyPoolers } from './HaTopology.utils'
|
||||
import { HA_RANKSEP } from './InstanceConfiguration.constants'
|
||||
import type { FailoverSimulationPhase } from '@/components/interfaces/ProjectHome/simulateFailover.utils'
|
||||
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'
|
||||
import type { Multigateway } from '@/data/ha-admin/ha-cluster-gateways-query'
|
||||
import {
|
||||
haClusterPoolersQueryOptions,
|
||||
type Multipooler,
|
||||
} from '@/data/ha-admin/ha-cluster-poolers-query'
|
||||
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
|
||||
import { PROJECT_STATUS } from '@/lib/constants'
|
||||
|
||||
@@ -23,8 +29,8 @@ const nodeTypes = {
|
||||
HA_SHARD: HaShardNode,
|
||||
}
|
||||
|
||||
// The gateway and replication edges both use React Flow's built-in smoothstep.
|
||||
const edgeTypes = {}
|
||||
const edgeTypes = { failover: HaFailoverEdge }
|
||||
const fitViewPadding = { top: '48px', right: '16px', bottom: '16px', left: '16px' } as const
|
||||
|
||||
const POOLER_STATUS_REFRESH_MS = 30_000
|
||||
|
||||
@@ -33,48 +39,99 @@ const POOLER_STATUS_REFRESH_MS = 30_000
|
||||
* gateway tier → shard group → primary + read replicas, driven by the
|
||||
* read-only multiadmin `/ha-admin` passthrough.
|
||||
*/
|
||||
export const HaInstanceConfiguration = () => {
|
||||
export const HaInstanceConfiguration = ({
|
||||
simulateHighAvailability = false,
|
||||
failoverPhase = 'off',
|
||||
}: {
|
||||
simulateHighAvailability?: boolean
|
||||
failoverPhase?: FailoverSimulationPhase
|
||||
}) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { data: project } = useSelectedProjectQuery()
|
||||
|
||||
const isProjectBuilding =
|
||||
project?.status === PROJECT_STATUS.COMING_UP || project?.status === PROJECT_STATUS.UNKNOWN
|
||||
|
||||
const simulatedTopology = useMemo(() => {
|
||||
const region = project?.region ?? 'us-east-1'
|
||||
const shardKey = { database: 'postgres', tableGroup: 'default', shard: '0-inf' }
|
||||
|
||||
return {
|
||||
gateways: [
|
||||
{ id: { cell: `${region}a`, name: 'simulated-gateway' } },
|
||||
] satisfies Multigateway[],
|
||||
poolers: [
|
||||
{
|
||||
id: { cell: `${region}a`, name: 'simulated-primary' },
|
||||
shardKey,
|
||||
routingState: { role: 'ROUTING_ROLE_PRIMARY' },
|
||||
type: 'PRIMARY',
|
||||
},
|
||||
{
|
||||
id: { cell: `${region}b`, name: 'simulated-replica' },
|
||||
shardKey,
|
||||
routingState: { role: 'ROUTING_ROLE_REPLICA' },
|
||||
type: 'REPLICA',
|
||||
},
|
||||
{
|
||||
id: { cell: `${region}c`, name: 'simulated-replica-2' },
|
||||
shardKey,
|
||||
routingState: { role: 'ROUTING_ROLE_REPLICA' },
|
||||
type: 'REPLICA',
|
||||
},
|
||||
] satisfies Multipooler[],
|
||||
}
|
||||
}, [project?.region])
|
||||
|
||||
const gatewaysQueryOptions = haClusterGatewaysQueryOptions({ projectRef })
|
||||
const poolersQueryOptions = haClusterPoolersQueryOptions({ projectRef })
|
||||
|
||||
// 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,
|
||||
data: queriedGatewaysData,
|
||||
error: gatewaysError,
|
||||
isPending: isPendingGateways,
|
||||
isError: isErrorGateways,
|
||||
} = useQuery({
|
||||
...haClusterGatewaysQueryOptions({ projectRef }),
|
||||
...gatewaysQueryOptions,
|
||||
enabled: !simulateHighAvailability && gatewaysQueryOptions.enabled,
|
||||
refetchInterval: POOLER_STATUS_REFRESH_MS,
|
||||
})
|
||||
|
||||
const {
|
||||
data: poolers,
|
||||
data: queriedPoolers,
|
||||
error: poolersError,
|
||||
isPending: isPendingPoolers,
|
||||
isError: isErrorPoolers,
|
||||
} = useQuery({
|
||||
...haClusterPoolersQueryOptions({ projectRef }),
|
||||
...poolersQueryOptions,
|
||||
enabled: !simulateHighAvailability && poolersQueryOptions.enabled,
|
||||
refetchInterval: POOLER_STATUS_REFRESH_MS,
|
||||
select: selectTopologyPoolers,
|
||||
})
|
||||
|
||||
const gateways = gatewaysData?.gateways
|
||||
const gateways = simulateHighAvailability
|
||||
? simulatedTopology.gateways
|
||||
: queriedGatewaysData?.gateways
|
||||
const poolers = simulateHighAvailability ? simulatedTopology.poolers : queriedPoolers
|
||||
const failoverLayoutPhase =
|
||||
failoverPhase === 'promoting' || failoverPhase === 'failover' ? failoverPhase : undefined
|
||||
|
||||
const { nodes, edges } = useMemo(
|
||||
const { nodes, edges, layoutEdges } = useMemo(
|
||||
() =>
|
||||
generateHaNodesAndEdges(
|
||||
buildHaTopology({ gateways: gateways ?? [], poolers: poolers ?? [] })
|
||||
buildHaTopology({
|
||||
gateways: gateways ?? [],
|
||||
poolers: poolers ?? [],
|
||||
}),
|
||||
{ isSimulated: simulateHighAvailability, failoverPhase: failoverLayoutPhase }
|
||||
),
|
||||
[gateways, poolers]
|
||||
[failoverLayoutPhase, gateways, poolers, simulateHighAvailability]
|
||||
)
|
||||
|
||||
const isPending = isPendingGateways || isPendingPoolers
|
||||
const isError = isErrorGateways || isErrorPoolers
|
||||
const isPending = !simulateHighAvailability && (isPendingGateways || isPendingPoolers)
|
||||
const isError = !simulateHighAvailability && (isErrorGateways || isErrorPoolers)
|
||||
const error = gatewaysError ?? poolersError
|
||||
|
||||
// While the project is provisioning, the /ha-admin endpoints fail or return an
|
||||
@@ -139,10 +196,12 @@ export const HaInstanceConfiguration = () => {
|
||||
<DiagramFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
layoutEdges={layoutEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
addGroupNodes={addShardNodes}
|
||||
ranksep={HA_RANKSEP}
|
||||
fitViewPadding={fitViewPadding}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+127
-1
@@ -1,7 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { generateHaNodesAndEdges } from './HaInstanceConfiguration.utils'
|
||||
import { addShardNodes, generateHaNodesAndEdges } from './HaInstanceConfiguration.utils'
|
||||
import { buildHaTopology } from './HaTopology.utils'
|
||||
import { HA_RANKSEP } from './InstanceConfiguration.constants'
|
||||
import { getDagreGraphLayout } from './InstanceConfiguration.utils'
|
||||
|
||||
const failoverTopology = () =>
|
||||
buildHaTopology({
|
||||
gateways: [{ id: { cell: 'cell-1', name: 'gw-1' } }],
|
||||
poolers: [
|
||||
{
|
||||
id: { cell: 'cell-1', name: 'p-1' },
|
||||
shardKey: { shard: '0' },
|
||||
routingState: { role: 'ROUTING_ROLE_PRIMARY' },
|
||||
},
|
||||
{
|
||||
id: { cell: 'cell-2', name: 'p-2' },
|
||||
shardKey: { shard: '0' },
|
||||
routingState: { role: 'ROUTING_ROLE_REPLICA' },
|
||||
},
|
||||
{
|
||||
id: { cell: 'cell-3', name: 'p-3' },
|
||||
shardKey: { shard: '0' },
|
||||
routingState: { role: 'ROUTING_ROLE_REPLICA' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
describe('generateHaNodesAndEdges', () => {
|
||||
it('gives every pooler a unique node id even when identities are omitted', () => {
|
||||
@@ -29,4 +53,106 @@ describe('generateHaNodesAndEdges', () => {
|
||||
expect(edges).toHaveLength(2)
|
||||
expect(new Set(edges.map((edge) => edge.id)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps the healthy layout: gateway to primary, primary to each replica', () => {
|
||||
const { nodes, edges, layoutEdges } = generateHaNodesAndEdges(failoverTopology())
|
||||
const primary = nodes.find((node) => node.type === 'HA_PRIMARY')
|
||||
const replicaIds = nodes.filter((node) => node.type === 'HA_REPLICA').map((node) => node.id)
|
||||
|
||||
expect(edges).toEqual(layoutEdges)
|
||||
expect(edges.filter((edge) => edge.source === 'multigateway')).toEqual([
|
||||
expect.objectContaining({ target: primary?.id, type: 'smoothstep' }),
|
||||
])
|
||||
expect(edges.filter((edge) => edge.source === primary?.id).map((edge) => edge.target)).toEqual(
|
||||
replicaIds
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the healthy layout while a replica is promoting', () => {
|
||||
const healthy = generateHaNodesAndEdges(failoverTopology())
|
||||
const { nodes, edges, layoutEdges } = generateHaNodesAndEdges(failoverTopology(), {
|
||||
failoverPhase: 'promoting',
|
||||
})
|
||||
const primary = nodes.find((node) => node.type === 'HA_PRIMARY')
|
||||
const promotingReplica = nodes.find((node) => node.data.promotion === 'promoting')
|
||||
const gatewayEdges = edges.filter((edge) => edge.source === 'multigateway')
|
||||
|
||||
expect(primary?.data.statusOverride).toBe('unhealthy')
|
||||
expect(promotingReplica).toBeDefined()
|
||||
expect(nodes.some((node) => node.data.promotion === 'promoted')).toBe(false)
|
||||
expect(gatewayEdges).toEqual([
|
||||
expect.objectContaining({ target: primary?.id, type: 'smoothstep' }),
|
||||
])
|
||||
expect(layoutEdges.map((edge) => `${edge.source}->${edge.target}`)).toEqual(
|
||||
healthy.layoutEdges.map((edge) => `${edge.source}->${edge.target}`)
|
||||
)
|
||||
})
|
||||
|
||||
it('marks the primary unhealthy and routes the gateway to the promoted replica during failover', () => {
|
||||
const { nodes, edges, layoutEdges } = generateHaNodesAndEdges(failoverTopology(), {
|
||||
failoverPhase: 'failover',
|
||||
})
|
||||
const primary = nodes.find((node) => node.type === 'HA_PRIMARY')
|
||||
const promotedReplica = nodes.find(
|
||||
(node) => node.type === 'HA_REPLICA' && node.data.promotion === 'promoted'
|
||||
)
|
||||
const secondaryReplicas = nodes.filter(
|
||||
(node) => node.type === 'HA_REPLICA' && node.data.promotion !== 'promoted'
|
||||
)
|
||||
const gatewayEdges = edges.filter((edge) => edge.source === 'multigateway')
|
||||
const primaryEdges = edges.filter((edge) => edge.source === primary?.id)
|
||||
const promotedEdges = edges.filter((edge) => edge.source === promotedReplica?.id)
|
||||
const gatewayLayoutTargets = layoutEdges
|
||||
.filter((edge) => edge.source === 'multigateway')
|
||||
.map((edge) => edge.target)
|
||||
|
||||
expect(primary?.data.statusOverride).toBe('unhealthy')
|
||||
expect(promotedReplica?.data.statusOverride).toBe('healthy')
|
||||
expect(gatewayEdges).toHaveLength(1)
|
||||
expect(gatewayEdges[0].target).toBe(promotedReplica?.id)
|
||||
expect(gatewayEdges.some((edge) => edge.target === primary?.id)).toBe(false)
|
||||
expect(gatewayEdges.every((edge) => edge.type === 'failover')).toBe(true)
|
||||
expect(primaryEdges).toHaveLength(0)
|
||||
expect(promotedEdges.map((edge) => edge.target)).toEqual(
|
||||
secondaryReplicas.map((node) => node.id)
|
||||
)
|
||||
expect(promotedEdges.every((edge) => edge.type === 'smoothstep')).toBe(true)
|
||||
expect(promotedEdges.every((edge) => edge.animated)).toBe(true)
|
||||
expect(promotedEdges.every((edge) => edge.style?.strokeDasharray === '3 5')).toBe(true)
|
||||
expect(gatewayLayoutTargets).toEqual(expect.arrayContaining([primary?.id, promotedReplica?.id]))
|
||||
})
|
||||
|
||||
it('places the promoted replica on the primary row with remaining replicas below it', () => {
|
||||
const { nodes, layoutEdges } = generateHaNodesAndEdges(failoverTopology(), {
|
||||
failoverPhase: 'failover',
|
||||
})
|
||||
const { nodes: laidOut } = getDagreGraphLayout(nodes, layoutEdges, { ranksep: HA_RANKSEP })
|
||||
const primary = laidOut.find((node) => node.type === 'HA_PRIMARY')
|
||||
const promoted = laidOut.find(
|
||||
(node) => node.type === 'HA_REPLICA' && node.data.promotion === 'promoted'
|
||||
)
|
||||
const secondary = laidOut.find(
|
||||
(node) => node.type === 'HA_REPLICA' && node.data.promotion !== 'promoted'
|
||||
)
|
||||
|
||||
expect(primary).toBeDefined()
|
||||
expect(promoted).toBeDefined()
|
||||
expect(secondary).toBeDefined()
|
||||
// Same rank: dagre centers nodes, and the replica card is shorter than the primary.
|
||||
expect(Math.abs((primary?.position.y ?? 0) - (promoted?.position.y ?? 0))).toBeLessThan(80)
|
||||
expect(secondary?.position.y ?? 0).toBeGreaterThan(promoted?.position.y ?? 0)
|
||||
expect(secondary?.position.y ?? 0).toBeGreaterThan(primary?.position.y ?? 0)
|
||||
})
|
||||
|
||||
it('does not drop the failed primary below its laid-out row', () => {
|
||||
const { nodes, layoutEdges } = generateHaNodesAndEdges(failoverTopology(), {
|
||||
failoverPhase: 'failover',
|
||||
})
|
||||
const { nodes: laidOut } = getDagreGraphLayout(nodes, layoutEdges, { ranksep: HA_RANKSEP })
|
||||
const primary = laidOut.find((node) => node.type === 'HA_PRIMARY')
|
||||
const { nodes: groupedNodes } = addShardNodes(laidOut, [])
|
||||
const groupedPrimary = groupedNodes.find((node) => node.id === primary?.id)
|
||||
|
||||
expect(groupedPrimary?.position.y).toBe(primary?.position.y)
|
||||
})
|
||||
})
|
||||
+91
-19
@@ -1,7 +1,7 @@
|
||||
import { Edge, Node } from '@xyflow/react'
|
||||
import { groupBy } from 'lodash'
|
||||
|
||||
import { getPoolerKey, hasPoolerIdentity, HaTopology } from './HaTopology.utils'
|
||||
import { getPoolerKey, HaPoolerStatus, hasPoolerIdentity, HaTopology } from './HaTopology.utils'
|
||||
import {
|
||||
NODE_CARD_WIDTH,
|
||||
SHARD_HEADER_HEIGHT,
|
||||
@@ -20,21 +20,55 @@ export type HaPoolerNodeData = {
|
||||
shardName: string
|
||||
// Only set on primary nodes; hides the unused top handle when no gateway exists.
|
||||
hasGateway?: boolean
|
||||
promotion?: 'promoting' | 'promoted'
|
||||
statusOverride?: HaPoolerStatus
|
||||
}
|
||||
|
||||
export type HaShardNodeData = {
|
||||
name: string
|
||||
}
|
||||
|
||||
const REPLICATION_EDGE_STYLE = { strokeDasharray: '3 5' } as const
|
||||
|
||||
// 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 createSmoothstepEdge = ({
|
||||
source,
|
||||
target,
|
||||
animated = false,
|
||||
dashed = false,
|
||||
}: {
|
||||
source: string
|
||||
target: string
|
||||
animated?: boolean
|
||||
dashed?: boolean
|
||||
}): Edge => ({
|
||||
id: `${source}-${target}`,
|
||||
source,
|
||||
target,
|
||||
type: 'smoothstep',
|
||||
animated,
|
||||
className: 'cursor-default!',
|
||||
...(dashed ? { style: REPLICATION_EDGE_STYLE } : {}),
|
||||
})
|
||||
|
||||
export const generateHaNodesAndEdges = (
|
||||
topology: HaTopology,
|
||||
{
|
||||
isSimulated = false,
|
||||
failoverPhase,
|
||||
}: { isSimulated?: boolean; failoverPhase?: 'promoting' | 'failover' } = {}
|
||||
): { nodes: Node[]; edges: Edge[]; layoutEdges: Edge[] } => {
|
||||
const isPromoting = failoverPhase === 'promoting'
|
||||
const isFailover = failoverPhase === 'failover'
|
||||
const isPrimaryFailed = isPromoting || isFailover
|
||||
const position = { x: 0, y: 0 }
|
||||
const nodes: Node[] = []
|
||||
const edges: Edge[] = []
|
||||
const layoutEdges: 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.
|
||||
@@ -50,9 +84,13 @@ export const generateHaNodesAndEdges = (topology: HaTopology): { nodes: Node[];
|
||||
|
||||
topology.shards.forEach((shard) => {
|
||||
let primaryId: string | undefined
|
||||
const replicaIds: string[] = []
|
||||
|
||||
if (shard.primary !== undefined) {
|
||||
primaryId = getPoolerNodeId(shard.primary, `${shard.id}-primary`)
|
||||
let statusOverride: HaPoolerStatus | undefined
|
||||
if (isPrimaryFailed) statusOverride = 'unhealthy'
|
||||
else if (isSimulated) statusOverride = 'healthy'
|
||||
nodes.push({
|
||||
position,
|
||||
id: primaryId,
|
||||
@@ -63,22 +101,18 @@ export const generateHaNodesAndEdges = (topology: HaTopology): { nodes: Node[];
|
||||
shardId: shard.id,
|
||||
shardName: shard.name,
|
||||
hasGateway,
|
||||
statusOverride,
|
||||
} 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}`)
|
||||
let promotion: HaPoolerNodeData['promotion']
|
||||
if (replicaIndex === 0 && isFailover) promotion = 'promoted'
|
||||
if (replicaIndex === 0 && isPromoting) promotion = 'promoting'
|
||||
replicaIds.push(replicaId)
|
||||
|
||||
nodes.push({
|
||||
position,
|
||||
id: replicaId,
|
||||
@@ -88,23 +122,61 @@ export const generateHaNodesAndEdges = (topology: HaTopology): { nodes: Node[];
|
||||
name: replica.id?.name,
|
||||
shardId: shard.id,
|
||||
shardName: shard.name,
|
||||
promotion,
|
||||
statusOverride: isSimulated || promotion !== undefined ? 'healthy' : undefined,
|
||||
} satisfies HaPoolerNodeData,
|
||||
})
|
||||
})
|
||||
|
||||
if (primaryId !== undefined) {
|
||||
edges.push({
|
||||
id: `${primaryId}-${replicaId}`,
|
||||
const promotedId = isFailover ? replicaIds[0] : undefined
|
||||
|
||||
// Healthy and promoting keep the original fan-out so the replica can later
|
||||
// animate onto the primary row. Failover makes the promoted replica the
|
||||
// parent of remaining replicas (same smoothstep geometry as primary → replica).
|
||||
if (promotedId !== undefined) {
|
||||
replicaIds.slice(1).forEach((replicaId) => {
|
||||
const replicationEdge = createSmoothstepEdge({
|
||||
source: promotedId,
|
||||
target: replicaId,
|
||||
animated: true,
|
||||
dashed: true,
|
||||
})
|
||||
edges.push(replicationEdge)
|
||||
layoutEdges.push(replicationEdge)
|
||||
})
|
||||
} else if (primaryId !== undefined) {
|
||||
replicaIds.forEach((replicaId) => {
|
||||
const replicationEdge = createSmoothstepEdge({
|
||||
source: primaryId,
|
||||
target: replicaId,
|
||||
type: 'smoothstep',
|
||||
animated: true,
|
||||
className: 'cursor-default!',
|
||||
})
|
||||
edges.push(replicationEdge)
|
||||
layoutEdges.push(replicationEdge)
|
||||
})
|
||||
}
|
||||
|
||||
if (hasGateway) {
|
||||
const gatewayTargetId = promotedId ?? primaryId
|
||||
if (gatewayTargetId !== undefined) {
|
||||
edges.push({
|
||||
...createSmoothstepEdge({ source: 'multigateway', target: gatewayTargetId }),
|
||||
type: isFailover ? 'failover' : 'smoothstep',
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Keep the failed primary on the gateway row so it stays beside the
|
||||
// promoted replica instead of dropping into the replica rank.
|
||||
if (primaryId !== undefined) {
|
||||
layoutEdges.push(createSmoothstepEdge({ source: 'multigateway', target: primaryId }))
|
||||
}
|
||||
if (promotedId !== undefined) {
|
||||
layoutEdges.push(createSmoothstepEdge({ source: 'multigateway', target: promotedId }))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { nodes, edges }
|
||||
return { nodes, edges, layoutEdges }
|
||||
}
|
||||
|
||||
const getNodeWidth = (node: Node) => node.measured?.width ?? NODE_CARD_WIDTH
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { BaseEdge, type EdgeProps } from '@xyflow/react'
|
||||
|
||||
const EDGE_GUTTER = 28
|
||||
const CORNER_RADIUS = 8
|
||||
/** Turns soon after leaving the gateway so the corner sits above the primary row. */
|
||||
const FAILOVER_DIVERGE_OFFSET = 48
|
||||
|
||||
/** Routes failover traffic sideways before descending, avoiding the primary card. */
|
||||
export const HaFailoverEdge = ({
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
markerEnd,
|
||||
style,
|
||||
}: EdgeProps) => {
|
||||
const direction = targetX < sourceX ? -1 : 1
|
||||
const routeX = targetX + direction * EDGE_GUTTER
|
||||
const sourceTurnY = Math.min(sourceY + FAILOVER_DIVERGE_OFFSET, targetY - EDGE_GUTTER * 2)
|
||||
const targetTurnY = targetY - EDGE_GUTTER
|
||||
const radius = Math.min(
|
||||
CORNER_RADIUS,
|
||||
EDGE_GUTTER / 2,
|
||||
Math.max(0, (targetTurnY - sourceTurnY) / 2)
|
||||
)
|
||||
|
||||
const edgePath = [
|
||||
`M ${sourceX} ${sourceY}`,
|
||||
`L ${sourceX} ${sourceTurnY - radius}`,
|
||||
`Q ${sourceX} ${sourceTurnY} ${sourceX + direction * radius} ${sourceTurnY}`,
|
||||
`L ${routeX - direction * radius} ${sourceTurnY}`,
|
||||
`Q ${routeX} ${sourceTurnY} ${routeX} ${sourceTurnY + radius}`,
|
||||
`L ${routeX} ${targetTurnY - radius}`,
|
||||
`Q ${routeX} ${targetTurnY} ${routeX - direction * radius} ${targetTurnY}`,
|
||||
`L ${targetX + direction * radius} ${targetTurnY}`,
|
||||
`Q ${targetX} ${targetTurnY} ${targetX} ${targetTurnY + radius}`,
|
||||
`L ${targetX} ${targetY}`,
|
||||
].join(' ')
|
||||
|
||||
return <BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
|
||||
}
|
||||
+95
-57
@@ -1,5 +1,6 @@
|
||||
import { Handle, Node, NodeProps, Position } from '@xyflow/react'
|
||||
import { Database, DatabaseBackup, HelpCircle, Layers, Loader2, Network } from 'lucide-react'
|
||||
import { type ReactNode } from 'react'
|
||||
import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui'
|
||||
|
||||
import { ComputeMetricsFooter } from './ComputeMetricsFooter'
|
||||
@@ -11,19 +12,51 @@ import {
|
||||
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'
|
||||
import { RegionFlag } from '@/components/ui/RegionFlag'
|
||||
|
||||
const STATUS_BADGE_VARIANTS: Record<HaPoolerStatus, 'success' | 'warning' | 'default'> = {
|
||||
const STATUS_BADGE_VARIANTS: Record<
|
||||
HaPoolerStatus,
|
||||
'success' | 'warning' | 'destructive' | 'default'
|
||||
> = {
|
||||
healthy: 'success',
|
||||
coming_up: 'default',
|
||||
going_down: 'default',
|
||||
unhealthy: 'warning',
|
||||
unhealthy: 'destructive',
|
||||
}
|
||||
|
||||
const PoolerStatusBadge = ({ status }: { status: HaPoolerStatus }) => (
|
||||
<Badge variant={STATUS_BADGE_VARIANTS[status]}>{HA_POOLER_STATUS_LABELS[status]}</Badge>
|
||||
)
|
||||
|
||||
const PromotionBadge = ({ state }: { state: NonNullable<HaPoolerNodeData['promotion']> }) => (
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center overflow-hidden rounded-md text-center font-mono uppercase',
|
||||
'whitespace-nowrap font-medium tracking-[0.06em] text-[11px] leading-[1.1] px-[5.5px] py-[3px]',
|
||||
'border border-purple-700 bg-purple-400 text-purple-1100',
|
||||
'dark:border-purple-600/50 dark:bg-purple-100'
|
||||
)}
|
||||
>
|
||||
{state === 'promoting' ? 'Promoting' : 'Promoted'}
|
||||
<span className="animate-badge-shimmer pointer-events-none absolute inset-0 bg-gradient-to-br from-transparent via-white/35 to-transparent blur-md" />
|
||||
</span>
|
||||
)
|
||||
|
||||
const PoolerCardTitleRow = ({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
children?: ReactNode
|
||||
}) => (
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<p className="text-sm">{title}</p>
|
||||
{children !== undefined && (
|
||||
<div className="inline-flex shrink-0 items-center gap-x-2">{children}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const PoolerCardSubtitle = ({
|
||||
availabilityZone,
|
||||
computeSize,
|
||||
@@ -73,8 +106,12 @@ export const MultigatewayNode = ({ data }: NodeProps<Node<MultigatewayNodeData>>
|
||||
|
||||
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 })
|
||||
const { cell, name, hasGateway, statusOverride } = data
|
||||
const { status, availabilityZone, computeSize, primaryRegion } = useHaPoolerCard({
|
||||
cell,
|
||||
name,
|
||||
statusOverride,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -84,35 +121,32 @@ export const HaPrimaryNode = ({ data }: NodeProps<Node<HaPoolerNodeData>>) => {
|
||||
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
|
||||
className={cn(
|
||||
'flex flex-col rounded-sm bg-surface-100 border border-default',
|
||||
status === 'unhealthy' &&
|
||||
'bg-destructive-200 border-destructive-400 motion-safe:animate-ha-primary-fail-flash'
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-x-3 p-3" style={{ width: NODE_CARD_WIDTH }}>
|
||||
<div className="w-8 h-8 shrink-0 bg-brand-500 border border-brand-600 rounded-md flex items-center justify-center">
|
||||
<Database aria-hidden="true" size={16} />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-y-0.5">
|
||||
<PoolerCardTitleRow title="Primary Database">
|
||||
{/* Stable live region so polled status changes are announced */}
|
||||
<span role="status" className="inline-flex items-center">
|
||||
{status !== undefined && <PoolerStatusBadge status={status} />}
|
||||
</span>
|
||||
</PoolerCardTitleRow>
|
||||
{primaryRegion !== undefined && (
|
||||
<p className="flex items-center gap-x-1.5 text-sm text-foreground-light">
|
||||
<RegionFlag className="w-4 shrink-0" region={primaryRegion.region} />
|
||||
<span>{primaryRegion.name}</span>
|
||||
</p>
|
||||
)}
|
||||
<PoolerCardSubtitle availabilityZone={availabilityZone} computeSize={computeSize} />
|
||||
</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. */}
|
||||
@@ -124,43 +158,47 @@ export const HaPrimaryNode = ({ data }: NodeProps<Node<HaPoolerNodeData>>) => {
|
||||
}
|
||||
|
||||
export const HaReplicaNode = ({ data }: NodeProps<Node<HaPoolerNodeData>>) => {
|
||||
const { cell, name } = data
|
||||
const { status, availabilityZone, computeSize } = useHaPoolerCard({ cell, name })
|
||||
const { cell, name, promotion, statusOverride } = data
|
||||
const { status, availabilityZone, computeSize } = useHaPoolerCard({
|
||||
cell,
|
||||
name,
|
||||
statusOverride,
|
||||
})
|
||||
const isPromoting = promotion === 'promoting'
|
||||
|
||||
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"
|
||||
className="flex gap-x-3 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' ? (
|
||||
<div
|
||||
className={cn(
|
||||
'w-8 h-8 shrink-0 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' || isPromoting ? (
|
||||
<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 className="flex min-w-0 flex-1 flex-col gap-y-0.5">
|
||||
<PoolerCardTitleRow title="Read Replica">
|
||||
{promotion !== undefined && <PromotionBadge state={promotion} />}
|
||||
{/* Stable live region so polled status changes are announced */}
|
||||
<span role="status" className="inline-flex items-center">
|
||||
{status !== undefined && <PoolerStatusBadge status={status} />}
|
||||
</span>
|
||||
</PoolerCardTitleRow>
|
||||
<PoolerCardSubtitle availabilityZone={availabilityZone} computeSize={computeSize} />
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} style={{ background: 'transparent' }} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+4
-1
@@ -154,11 +154,14 @@ export const buildHaTopology = ({
|
||||
best === undefined || compareRoutingRules(candidate, best) > 0 ? candidate : best,
|
||||
undefined
|
||||
)
|
||||
|
||||
const orderedReplicas = [...primaries.filter((pooler) => pooler !== primary), ...replicas]
|
||||
|
||||
return {
|
||||
id,
|
||||
name: `Shard ${index + 1}`,
|
||||
primary,
|
||||
replicas: [...primaries.filter((pooler) => pooler !== primary), ...replicas],
|
||||
replicas: orderedReplicas,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+18
-3
@@ -11,6 +11,7 @@ import { HaInstanceConfiguration } from './HaInstanceConfiguration'
|
||||
import { addRegionNodes, generateNodes } from './InstanceConfiguration.utils'
|
||||
import { LoadBalancerNode, PrimaryNode, RegionNode, ReplicaNode } from './InstanceNode'
|
||||
import MapView from './MapView'
|
||||
import type { FailoverSimulationPhase } from '@/components/interfaces/ProjectHome/simulateFailover.utils'
|
||||
import { REPLICA_STATUS } from '@/components/interfaces/Settings/Infrastructure/ReadReplicas/ReadReplicas.constants'
|
||||
import { AlertError } from '@/components/ui/AlertError'
|
||||
import { useLoadBalancersQuery } from '@/data/read-replicas/load-balancers-query'
|
||||
@@ -216,8 +217,15 @@ const InstanceConfigurationUI = () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const InstanceConfiguration = () => {
|
||||
const { isHighAvailability, isPending } = useHighAvailability()
|
||||
export const InstanceConfiguration = ({
|
||||
simulateHighAvailability = false,
|
||||
failoverPhase = 'off',
|
||||
}: {
|
||||
simulateHighAvailability?: boolean
|
||||
failoverPhase?: FailoverSimulationPhase
|
||||
}) => {
|
||||
const { isHighAvailability: isProjectHighAvailability, isPending } = useHighAvailability()
|
||||
const isHighAvailability = isProjectHighAvailability || simulateHighAvailability
|
||||
|
||||
// Wait for the project record so an HA project never briefly mounts the
|
||||
// standard diagram (and fires its queries) before swapping.
|
||||
@@ -232,7 +240,14 @@ export const InstanceConfiguration = () => {
|
||||
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
{isHighAvailability ? <HaInstanceConfiguration /> : <InstanceConfigurationUI />}
|
||||
{isHighAvailability ? (
|
||||
<HaInstanceConfiguration
|
||||
simulateHighAvailability={simulateHighAvailability}
|
||||
failoverPhase={failoverPhase}
|
||||
/>
|
||||
) : (
|
||||
<InstanceConfigurationUI />
|
||||
)}
|
||||
</ReactFlowProvider>
|
||||
)
|
||||
}
|
||||
+29
-6
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'common'
|
||||
|
||||
import { formatCellAsAvailabilityZone, getPoolerStatus } from './HaTopology.utils'
|
||||
import { formatCellAsAvailabilityZone, getPoolerStatus, HaPoolerStatus } 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'
|
||||
@@ -11,11 +11,21 @@ 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 }) => {
|
||||
export const useHaPooler = ({
|
||||
cell,
|
||||
name,
|
||||
enabled = true,
|
||||
}: {
|
||||
cell?: string
|
||||
name?: string
|
||||
enabled?: boolean
|
||||
}) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const queryOptions = haClusterPoolersQueryOptions({ projectRef })
|
||||
|
||||
return useQuery({
|
||||
...haClusterPoolersQueryOptions({ projectRef }),
|
||||
...queryOptions,
|
||||
enabled: enabled && queryOptions.enabled,
|
||||
select: (data) =>
|
||||
(data.poolers ?? []).find((pooler) => pooler.id?.cell === cell && pooler.id?.name === name),
|
||||
})
|
||||
@@ -27,17 +37,30 @@ export const useHaPooler = ({ cell, name }: { cell?: string; name?: string }) =>
|
||||
* 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 }) => {
|
||||
export const useHaPoolerCard = ({
|
||||
cell,
|
||||
name,
|
||||
statusOverride,
|
||||
}: {
|
||||
cell?: string
|
||||
name?: string
|
||||
statusOverride?: HaPoolerStatus
|
||||
}) => {
|
||||
const { ref: projectRef } = useParams()
|
||||
const { projectHomepageShowInstanceSize } = useIsFeatureEnabled([
|
||||
'project_homepage:show_instance_size',
|
||||
])
|
||||
|
||||
const { database: primary } = usePrimaryDatabase({ projectRef })
|
||||
const { data: pooler } = useHaPooler({ cell, name })
|
||||
const { data: pooler } = useHaPooler({ cell, name, enabled: statusOverride === undefined })
|
||||
|
||||
return {
|
||||
status: pooler !== undefined ? getPoolerStatus(pooler) : undefined,
|
||||
status:
|
||||
statusOverride !== undefined
|
||||
? statusOverride
|
||||
: pooler !== undefined
|
||||
? getPoolerStatus(pooler)
|
||||
: undefined,
|
||||
availabilityZone: formatCellAsAvailabilityZone(cell),
|
||||
computeSize: projectHomepageShowInstanceSize ? primary?.size : undefined,
|
||||
primaryRegion: AVAILABLE_REPLICA_REGIONS.find((r) => primary?.region.includes(r.region)),
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
--animate-chevron-up: chevron-up 2s ease-in-out infinite;
|
||||
--animate-sway: sway 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
--animate-marketplace-featured-progress: marketplace-featured-progress linear forwards;
|
||||
--animate-ha-primary-fail-flash: ha-primary-fail-flash 0.7s ease-in-out both;
|
||||
|
||||
/* font sizing and weights optimized for Inter */
|
||||
--text-sm: 0.8125rem;
|
||||
@@ -118,6 +119,26 @@
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes ha-primary-fail-flash {
|
||||
0%,
|
||||
28% {
|
||||
background-color: var(--background-surface-100);
|
||||
border-color: var(--border-color-default);
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
14%,
|
||||
42% {
|
||||
background-color: var(--color-destructive-200);
|
||||
border-color: var(--color-destructive-400);
|
||||
box-shadow: 0 0 0 3px hsl(var(--destructive-600) / 0.4);
|
||||
}
|
||||
100% {
|
||||
background-color: var(--color-destructive-200);
|
||||
border-color: var(--color-destructive-400);
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes chevron-up {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
Reference in new issue
Block a user