diff --git a/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx index cc68eb0a6bd..bed32d7578f 100644 --- a/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx @@ -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

Unknown

} -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 (
- + } @@ -172,7 +180,7 @@ export const ActivityStats = () => { ) : (

Unknown

)} - {project?.high_availability && } + {isHighAvailability && }
} /> diff --git a/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx index ae4df4aa17a..0e4c0df40f7 100644 --- a/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx +++ b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx @@ -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 ( diff --git a/apps/studio/components/interfaces/ProjectHome/TopSection.tsx b/apps/studio/components/interfaces/ProjectHome/TopSection.tsx index badc6cee39c..340783d2415 100644 --- a/apps/studio/components/interfaces/ProjectHome/TopSection.tsx +++ b/apps/studio/components/interfaces/ProjectHome/TopSection.tsx @@ -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 = () => {
{IS_PLATFORM && (
- +
)} @@ -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' )} > - + )} diff --git a/apps/studio/components/interfaces/ProjectHome/simulateFailover.utils.test.ts b/apps/studio/components/interfaces/ProjectHome/simulateFailover.utils.test.ts new file mode 100644 index 00000000000..1d05ac83d9f --- /dev/null +++ b/apps/studio/components/interfaces/ProjectHome/simulateFailover.utils.test.ts @@ -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') + }) +}) diff --git a/apps/studio/components/interfaces/ProjectHome/simulateFailover.utils.ts b/apps/studio/components/interfaces/ProjectHome/simulateFailover.utils.ts new file mode 100644 index 00000000000..8f9940d9555 --- /dev/null +++ b/apps/studio/components/interfaces/ProjectHome/simulateFailover.utils.ts @@ -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 +} diff --git a/apps/studio/components/interfaces/ProjectHome/useSimulatedFailover.ts b/apps/studio/components/interfaces/ProjectHome/useSimulatedFailover.ts new file mode 100644 index 00000000000..713b56a4b4c --- /dev/null +++ b/apps/studio/components/interfaces/ProjectHome/useSimulatedFailover.ts @@ -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 + + 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 }) +} diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DiagramFlow.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DiagramFlow.tsx index 4dd20536df8..e5855708369 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DiagramFlow.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DiagramFlow.tsx @@ -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(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 ( ({ + id, + position: { x, y }, + width, + height, + data: {}, +}) + +const edge = (id: string, extra?: Partial): 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) + }) +}) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DiagramFlow.utils.ts b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DiagramFlow.utils.ts new file mode 100644 index 00000000000..4794407c79f --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/DiagramFlow.utils.ts @@ -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 | undefined, + to: Pick +): 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, 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 } +} diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.test.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.test.tsx index 4c392e89444..30b8cc1e78c 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.test.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.test.tsx @@ -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({ message: 'upstream unavailable' }, { status: 500 }) + }), + http.get(`${API_URL}/platform/projects/:ref/ha-admin/v1/poolers`, () => { + haAdminRequests += 1 + return HttpResponse.json({ message: 'upstream unavailable' }, { status: 500 }) + }) + ) + + customRender( + + + , + { 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( + + + , + { 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( + + + , + { 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() + }) }) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.tsx index 8c31ccb71ea..1ef56d86daa 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.tsx @@ -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 = () => { ) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.utils.test.ts b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.utils.test.ts index bb8dc47848d..8f2c4df774c 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.utils.test.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.utils.test.ts @@ -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) + }) }) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.utils.ts b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.utils.ts index 8521ddea287..0dd0c507423 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.utils.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceConfiguration.utils.ts @@ -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[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 diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceEdge.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceEdge.tsx new file mode 100644 index 00000000000..4b6268933f3 --- /dev/null +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceEdge.tsx @@ -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 +} diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceNode.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceNode.tsx index f564131ad15..6aec02796e3 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceNode.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaInstanceNode.tsx @@ -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 = { +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 }) => ( {HA_POOLER_STATUS_LABELS[status]} ) +const PromotionBadge = ({ state }: { state: NonNullable }) => ( + + {state === 'promoting' ? 'Promoting' : 'Promoted'} + + +) + +const PoolerCardTitleRow = ({ + title, + children, +}: { + title: string + children?: ReactNode +}) => ( +
+

{title}

+ {children !== undefined && ( +
{children}
+ )} +
+) + const PoolerCardSubtitle = ({ availabilityZone, computeSize, @@ -73,8 +106,12 @@ export const MultigatewayNode = ({ data }: NodeProps> export const HaPrimaryNode = ({ data }: NodeProps>) => { // [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>) => { className={!hasGateway ? 'opacity-0' : ''} style={{ background: 'transparent' }} /> -
-
-
-
-
-
-
-

Primary Database

- {/* Stable live region so polled status changes are announced */} - - {status !== undefined && } - -
- {primaryRegion !== undefined && ( -

{primaryRegion.name}

- )} - -
+
+
+
+
+
+ + {/* Stable live region so polled status changes are announced */} + + {status !== undefined && } + + + {primaryRegion !== undefined && ( +

+ + {primaryRegion.name} +

+ )} +
- {primaryRegion !== undefined && ( - // Decorative — the visible text next to it already names the region - - )}
{/* 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>) => { } export const HaReplicaNode = ({ data }: NodeProps>) => { - 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 ( <>
-
-
- {status === 'coming_up' ? ( +
+ {status === 'coming_up' || isPromoting ? (
-
-
-

Read Replica

- {/* Stable live region so polled status changes are announced */} - - {status !== undefined && } - -
- -
+
+
+ + {promotion !== undefined && } + {/* Stable live region so polled status changes are announced */} + + {status !== undefined && } + + +
+ ) } diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.ts b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.ts index 8c1867d6105..418def840f0 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/HaTopology.utils.ts @@ -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, } }) diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx index 11c57cebee4..9f0418808a2 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx @@ -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 ( - {isHighAvailability ? : } + {isHighAvailability ? ( + + ) : ( + + )} ) } diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/useHaPooler.ts b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/useHaPooler.ts index af64b7301b8..fe7e3eddbc2 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/useHaPooler.ts +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/useHaPooler.ts @@ -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)), diff --git a/apps/studio/styles/globals.css b/apps/studio/styles/globals.css index 7c2183b1fc0..60512c809d3 100644 --- a/apps/studio/styles/globals.css +++ b/apps/studio/styles/globals.css @@ -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% {