InstanceConfiguration.utils.ts 6.7 KB

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