mirror of
https://github.com/supabase/supabase.git
synced 2026-06-12 08:29:15 +08:00
## Summary The homepage instance diagram looks visually off when a project has read replicas — see [FE-3390](https://linear.app/supabase/issue/FE-3390/adjust-homepage-diagram-spacing-for-read-replicas). Root cause: the dagre layout in `InstanceConfiguration.utils.ts` was passing a fake `height: 25` (and a `-70` hack for the load balancer) for every node, regardless of how tall the rendered node actually is. When the Primary card grew taller after #45274 added the compute-metrics row, the gap between the Primary node and the Read Replica node became visually tight while the gap between the Load Balancer and Primary stayed loose — the diagram looked lopsided. ## Fix - Pass the **real measured height** of each node to dagre, sourced from React Flow's internal measurement (`node.measured.height`). - Run the layout in two passes: 1. First pass uses small per-type fallback heights so React Flow has something to render and measure against. 2. Once `useNodesInitialized()` flips true, re-run dagre with the measured heights and fitView. - Keep the diagram at `opacity-0` until the measured pass completes, so the fallback positions are never visible to the user. Subsequent data refetches don't re-hide the diagram — once measured, it stays visible. - Drop `NODE_ROW_HEIGHT` and the load-balancer `-70` hack. Replaced with a small `NODE_HEIGHT_FALLBACKS` table used only on first paint. - `ranksep` reduced from 160 → 60 since dagre now knows real heights. Closes FE-3390. ## Test plan - [x] Open the project homepage for a project with no read replicas — diagram renders centered, single Primary node. - [x] Open the project homepage for a project **with** a read replica — Load Balancer → Primary → Replica spacing is visually balanced; no flash of fallback positions on initial render; the diagram fades in once. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Improvements** * More accurate infrastructure diagram layout with better node sizing and spacing for initial and measured renders. * Introduced fallback heights so diagrams render correctly on first paint before measurements are available. * Region containers now use a consistent fixed height for stable layout. * Smoother, visually consistent diagram initialization with improved opacity transition during layout setup. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46075?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
209 lines
6.7 KiB
TypeScript
209 lines
6.7 KiB
TypeScript
import dagre from '@dagrejs/dagre'
|
|
import { Edge, Node, Position } from '@xyflow/react'
|
|
import { groupBy } from 'lodash'
|
|
import { AWS_REGIONS, AWS_REGIONS_KEYS } from 'shared-data'
|
|
|
|
import {
|
|
AVAILABLE_REPLICA_REGIONS,
|
|
AWS_REGIONS_COORDINATES,
|
|
NODE_HEIGHT_FALLBACKS,
|
|
NODE_SEP,
|
|
NODE_WIDTH,
|
|
ReplicaNodeData,
|
|
} from './InstanceConfiguration.constants'
|
|
import type { LoadBalancer } from '@/data/read-replicas/load-balancers-query'
|
|
import type { Database } from '@/data/read-replicas/replicas-query'
|
|
|
|
// [Joshen] Just FYI the nodes generation assumes each project only has one load balancer
|
|
// Will need to change if this eventually becomes otherwise
|
|
|
|
export const generateNodes = ({
|
|
primary,
|
|
replicas,
|
|
loadBalancers,
|
|
onSelectRestartReplica,
|
|
onSelectDropReplica,
|
|
}: {
|
|
primary: Database
|
|
replicas: Database[]
|
|
loadBalancers: LoadBalancer[]
|
|
onSelectRestartReplica: (database: Database) => void
|
|
onSelectDropReplica: (database: Database) => void
|
|
}): Node[] => {
|
|
const position = { x: 0, y: 0 }
|
|
const regions = groupBy(replicas, (d) => {
|
|
const region = AVAILABLE_REPLICA_REGIONS.find((region) => d.region.includes(region.region))
|
|
return region?.key
|
|
})
|
|
|
|
const loadBalancer = loadBalancers.find((x) =>
|
|
x.databases.some((db) => db.identifier === primary.identifier)
|
|
)
|
|
const loadBalancerNode: Node | undefined =
|
|
loadBalancer !== undefined
|
|
? {
|
|
position,
|
|
id: 'load-balancer',
|
|
type: 'LOAD_BALANCER',
|
|
data: {
|
|
numDatabases: loadBalancer.databases.length,
|
|
},
|
|
}
|
|
: undefined
|
|
|
|
// [Joshen] We should be finding from AVAILABLE_REPLICA_REGIONS instead
|
|
// but because the new regions (zurich, stockholm, ohio, paris) dont have
|
|
// coordinates yet in AWS_REGIONS_COORDINATES - we'll need to add them in once
|
|
// they are ready to spin up coordinates for
|
|
const primaryRegion = Object.keys(AWS_REGIONS)
|
|
.map((key) => {
|
|
return {
|
|
key: key as AWS_REGIONS_KEYS,
|
|
name: AWS_REGIONS?.[key as AWS_REGIONS_KEYS].displayName,
|
|
region: AWS_REGIONS?.[key as AWS_REGIONS_KEYS].code,
|
|
coordinates: AWS_REGIONS_COORDINATES[key],
|
|
}
|
|
})
|
|
.find((region) => primary.region.includes(region.region))
|
|
|
|
// [Joshen] Once we have the coordinates for Zurich and Stockholm, we can remove the above
|
|
// and uncomment below for better simplicity
|
|
// const primaryRegion = AVAILABLE_REPLICA_REGIONS.find((region) =>
|
|
// primary.region.includes(region.region)
|
|
// )
|
|
|
|
const primaryNode: Node = {
|
|
position,
|
|
id: primary.identifier,
|
|
type: 'PRIMARY',
|
|
data: {
|
|
id: primary.identifier,
|
|
region:
|
|
primary.cloud_provider === 'FLY'
|
|
? { name: 'Singapore (sin)', key: 'SOUTHEAST_ASIA' }
|
|
: (primaryRegion ?? { name: primary.region }),
|
|
provider: primary.cloud_provider,
|
|
inserted_at: primary.inserted_at,
|
|
computeSize: primary.size,
|
|
status: primary.status,
|
|
numReplicas: replicas.length,
|
|
numRegions: Object.keys(regions).length,
|
|
hasLoadBalancer: loadBalancer !== undefined,
|
|
},
|
|
}
|
|
|
|
const replicaNodes: Node[] = replicas
|
|
.sort((a, b) => (a.region > b.region ? 1 : -1))
|
|
.map((database) => {
|
|
const region = AVAILABLE_REPLICA_REGIONS.find((region) =>
|
|
database.region.includes(region.region)
|
|
)
|
|
|
|
return {
|
|
position,
|
|
id: database.identifier,
|
|
type: 'READ_REPLICA',
|
|
data: {
|
|
id: database.identifier,
|
|
region,
|
|
provider: database.cloud_provider,
|
|
inserted_at: database.inserted_at,
|
|
computeSize: database.size,
|
|
status: database.status,
|
|
onSelectRestartReplica: () => onSelectRestartReplica(database),
|
|
onSelectDropReplica: () => onSelectDropReplica(database),
|
|
},
|
|
}
|
|
})
|
|
|
|
return [
|
|
...(loadBalancerNode !== undefined ? [loadBalancerNode] : []),
|
|
primaryNode,
|
|
...replicaNodes,
|
|
]
|
|
}
|
|
|
|
const getDagreNodeHeight = (node: Node) => {
|
|
if (node.measured?.height) return node.measured.height
|
|
return NODE_HEIGHT_FALLBACKS[node.type ?? ''] ?? 100
|
|
}
|
|
|
|
export const getDagreGraphLayout = (nodes: Node[], edges: Edge[]) => {
|
|
const dagreGraph = new dagre.graphlib.Graph()
|
|
dagreGraph.setDefaultEdgeLabel(() => ({}))
|
|
dagreGraph.setGraph({ rankdir: 'TB', ranksep: 60, nodesep: NODE_SEP })
|
|
|
|
nodes.forEach((node) => {
|
|
dagreGraph.setNode(node.id, {
|
|
width: NODE_WIDTH / 2,
|
|
height: getDagreNodeHeight(node),
|
|
})
|
|
})
|
|
|
|
edges.forEach((edge) => dagreGraph.setEdge(edge.source, edge.target))
|
|
|
|
dagre.layout(dagreGraph)
|
|
|
|
nodes.forEach((node) => {
|
|
const nodeWithPosition = dagreGraph.node(node.id)
|
|
node.targetPosition = Position.Top
|
|
node.sourcePosition = Position.Bottom
|
|
// We are shifting the dagre node position (anchor=center center) to the top left
|
|
// so it matches the React Flow node anchor point (top left).
|
|
node.position = {
|
|
x: nodeWithPosition.x - nodeWithPosition.width / 2,
|
|
y: nodeWithPosition.y - nodeWithPosition.height / 2,
|
|
}
|
|
|
|
return node
|
|
})
|
|
|
|
return { nodes, edges }
|
|
}
|
|
|
|
/**
|
|
* [Joshen] This is some customized logic to add region nodes as "subflow" as dagre doesn't support
|
|
* subflows, and I didn't want to go down a rabbit hole with the other layout libraries that react-flow
|
|
* supports. Definitely some things to improve in the future
|
|
* - Allow setting max number of nodes per row, so that the chart does not become too horizontally sparse
|
|
* when many many replicas created
|
|
* - Nodes are a bit too spaced out between each other within a region
|
|
*/
|
|
export const addRegionNodes = (nodes: Node[], edges: Edge[]) => {
|
|
const regionNodes: Node[] = []
|
|
const replicaNodes = nodes.filter(
|
|
(node) => node.type === 'READ_REPLICA'
|
|
) as Node<ReplicaNodeData>[]
|
|
|
|
const nodesByRegion = groupBy(replicaNodes, (node) => node.data.region.key)
|
|
Object.entries(nodesByRegion).map(([key, value]) => {
|
|
const region = AVAILABLE_REPLICA_REGIONS.find((r) => r.key === key)
|
|
const nodeXPositions = value.map((x) => x.position.x)
|
|
const nodeYPositions = value.map((x) => x.position.y)
|
|
|
|
const minX = Math.min(...nodeXPositions)
|
|
const maxX = Math.max(...nodeXPositions)
|
|
|
|
const minY = Math.max(...nodeYPositions)
|
|
|
|
const regionNode: Node = {
|
|
id: key,
|
|
position: { x: minX - 10, y: minY - 10 },
|
|
width: maxX - minX + NODE_WIDTH / 2,
|
|
type: 'REGION',
|
|
data: { region, numReplicas: value.length },
|
|
}
|
|
regionNodes.push(regionNode)
|
|
})
|
|
|
|
return { nodes: [...regionNodes, ...nodes], edges }
|
|
}
|
|
|
|
export const formatSeconds = (value: number) => {
|
|
const hours = ~~(value / 3600)
|
|
const minutes = Math.floor((value % 3600) / 60)
|
|
const seconds = Math.floor(value % 60)
|
|
|
|
return `${hours > 0 ? `${hours}h` : ''} ${minutes > 0 ? `${minutes}m` : ''} ${seconds > 0 ? `${seconds}s` : ''}`.trim()
|
|
}
|