replica promote prototype

This commit is contained in:
Saxon Fletcher committed 2026-09-25 11:14:47 +10:00
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 })
}
@@ -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(
@@ -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)
})
})
@@ -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 }
}
@@ -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()
})
})
@@ -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>
)
@@ -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)
})
})
@@ -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
@@ -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} />
}
@@ -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' }} />
</>
)
}
@@ -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,
}
})
@@ -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>
)
}
@@ -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)),
+21
View File
@@ -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% {