InstanceConfiguration.utils.ts208 lines · main
1import dagre from '@dagrejs/dagre'
2import { Edge, Node, Position } from '@xyflow/react'
3import { groupBy } from 'lodash'
4import { AWS_REGIONS, AWS_REGIONS_KEYS } from 'shared-data'
5
6import {
7 AVAILABLE_REPLICA_REGIONS,
8 AWS_REGIONS_COORDINATES,
9 NODE_HEIGHT_FALLBACKS,
10 NODE_SEP,
11 NODE_WIDTH,
12 ReplicaNodeData,
13} from './InstanceConfiguration.constants'
14import type { LoadBalancer } from '@/data/read-replicas/load-balancers-query'
15import type { Database } from '@/data/read-replicas/replicas-query'
16
17// [Joshen] Just FYI the nodes generation assumes each project only has one load balancer
18// Will need to change if this eventually becomes otherwise
19
20export const generateNodes = ({
21 primary,
22 replicas,
23 loadBalancers,
24 onSelectRestartReplica,
25 onSelectDropReplica,
26}: {
27 primary: Database
28 replicas: Database[]
29 loadBalancers: LoadBalancer[]
30 onSelectRestartReplica: (database: Database) => void
31 onSelectDropReplica: (database: Database) => void
32}): Node[] => {
33 const position = { x: 0, y: 0 }
34 const regions = groupBy(replicas, (d) => {
35 const region = AVAILABLE_REPLICA_REGIONS.find((region) => d.region.includes(region.region))
36 return region?.key
37 })
38
39 const loadBalancer = loadBalancers.find((x) =>
40 x.databases.some((db) => db.identifier === primary.identifier)
41 )
42 const loadBalancerNode: Node | undefined =
43 loadBalancer !== undefined
44 ? {
45 position,
46 id: 'load-balancer',
47 type: 'LOAD_BALANCER',
48 data: {
49 numDatabases: loadBalancer.databases.length,
50 },
51 }
52 : undefined
53
54 // [Joshen] We should be finding from AVAILABLE_REPLICA_REGIONS instead
55 // but because the new regions (zurich, stockholm, ohio, paris) dont have
56 // coordinates yet in AWS_REGIONS_COORDINATES - we'll need to add them in once
57 // they are ready to spin up coordinates for
58 const primaryRegion = Object.keys(AWS_REGIONS)
59 .map((key) => {
60 return {
61 key: key as AWS_REGIONS_KEYS,
62 name: AWS_REGIONS?.[key as AWS_REGIONS_KEYS].displayName,
63 region: AWS_REGIONS?.[key as AWS_REGIONS_KEYS].code,
64 coordinates: AWS_REGIONS_COORDINATES[key],
65 }
66 })
67 .find((region) => primary.region.includes(region.region))
68
69 // [Joshen] Once we have the coordinates for Zurich and Stockholm, we can remove the above
70 // and uncomment below for better simplicity
71 // const primaryRegion = AVAILABLE_REPLICA_REGIONS.find((region) =>
72 // primary.region.includes(region.region)
73 // )
74
75 const primaryNode: Node = {
76 position,
77 id: primary.identifier,
78 type: 'PRIMARY',
79 data: {
80 id: primary.identifier,
81 region:
82 primary.cloud_provider === 'FLY'
83 ? { name: 'Singapore (sin)', key: 'SOUTHEAST_ASIA' }
84 : (primaryRegion ?? { name: primary.region }),
85 provider: primary.cloud_provider,
86 inserted_at: primary.inserted_at,
87 computeSize: primary.size,
88 status: primary.status,
89 numReplicas: replicas.length,
90 numRegions: Object.keys(regions).length,
91 hasLoadBalancer: loadBalancer !== undefined,
92 },
93 }
94
95 const replicaNodes: Node[] = replicas
96 .sort((a, b) => (a.region > b.region ? 1 : -1))
97 .map((database) => {
98 const region = AVAILABLE_REPLICA_REGIONS.find((region) =>
99 database.region.includes(region.region)
100 )
101
102 return {
103 position,
104 id: database.identifier,
105 type: 'READ_REPLICA',
106 data: {
107 id: database.identifier,
108 region,
109 provider: database.cloud_provider,
110 inserted_at: database.inserted_at,
111 computeSize: database.size,
112 status: database.status,
113 onSelectRestartReplica: () => onSelectRestartReplica(database),
114 onSelectDropReplica: () => onSelectDropReplica(database),
115 },
116 }
117 })
118
119 return [
120 ...(loadBalancerNode !== undefined ? [loadBalancerNode] : []),
121 primaryNode,
122 ...replicaNodes,
123 ]
124}
125
126const getDagreNodeHeight = (node: Node) => {
127 if (node.measured?.height) return node.measured.height
128 return NODE_HEIGHT_FALLBACKS[node.type ?? ''] ?? 100
129}
130
131export const getDagreGraphLayout = (nodes: Node[], edges: Edge[]) => {
132 const dagreGraph = new dagre.graphlib.Graph()
133 dagreGraph.setDefaultEdgeLabel(() => ({}))
134 dagreGraph.setGraph({ rankdir: 'TB', ranksep: 60, nodesep: NODE_SEP })
135
136 nodes.forEach((node) => {
137 dagreGraph.setNode(node.id, {
138 width: NODE_WIDTH / 2,
139 height: getDagreNodeHeight(node),
140 })
141 })
142
143 edges.forEach((edge) => dagreGraph.setEdge(edge.source, edge.target))
144
145 dagre.layout(dagreGraph)
146
147 nodes.forEach((node) => {
148 const nodeWithPosition = dagreGraph.node(node.id)
149 node.targetPosition = Position.Top
150 node.sourcePosition = Position.Bottom
151 // We are shifting the dagre node position (anchor=center center) to the top left
152 // so it matches the React Flow node anchor point (top left).
153 node.position = {
154 x: nodeWithPosition.x - nodeWithPosition.width / 2,
155 y: nodeWithPosition.y - nodeWithPosition.height / 2,
156 }
157
158 return node
159 })
160
161 return { nodes, edges }
162}
163
164/**
165 * [Joshen] This is some customized logic to add region nodes as "subflow" as dagre doesn't support
166 * subflows, and I didn't want to go down a rabbit hole with the other layout libraries that react-flow
167 * supports. Definitely some things to improve in the future
168 * - Allow setting max number of nodes per row, so that the chart does not become too horizontally sparse
169 * when many many replicas created
170 * - Nodes are a bit too spaced out between each other within a region
171 */
172export const addRegionNodes = (nodes: Node[], edges: Edge[]) => {
173 const regionNodes: Node[] = []
174 const replicaNodes = nodes.filter(
175 (node) => node.type === 'READ_REPLICA'
176 ) as Node<ReplicaNodeData>[]
177
178 const nodesByRegion = groupBy(replicaNodes, (node) => node.data.region.key)
179 Object.entries(nodesByRegion).map(([key, value]) => {
180 const region = AVAILABLE_REPLICA_REGIONS.find((r) => r.key === key)
181 const nodeXPositions = value.map((x) => x.position.x)
182 const nodeYPositions = value.map((x) => x.position.y)
183
184 const minX = Math.min(...nodeXPositions)
185 const maxX = Math.max(...nodeXPositions)
186
187 const minY = Math.max(...nodeYPositions)
188
189 const regionNode: Node = {
190 id: key,
191 position: { x: minX - 10, y: minY - 10 },
192 width: maxX - minX + NODE_WIDTH / 2,
193 type: 'REGION',
194 data: { region, numReplicas: value.length },
195 }
196 regionNodes.push(regionNode)
197 })
198
199 return { nodes: [...regionNodes, ...nodes], edges }
200}
201
202export const formatSeconds = (value: number) => {
203 const hours = ~~(value / 3600)
204 const minutes = Math.floor((value % 3600) / 60)
205 const seconds = Math.floor(value % 60)
206
207 return `${hours > 0 ? `${hours}h` : ''} ${minutes > 0 ? `${minutes}m` : ''} ${seconds > 0 ? `${seconds}s` : ''}`.trim()
208}