InstanceConfiguration.utils.ts208 lines · main
| 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 | |
| 6 | import { |
| 7 | AVAILABLE_REPLICA_REGIONS, |
| 8 | AWS_REGIONS_COORDINATES, |
| 9 | NODE_HEIGHT_FALLBACKS, |
| 10 | NODE_SEP, |
| 11 | NODE_WIDTH, |
| 12 | ReplicaNodeData, |
| 13 | } from './InstanceConfiguration.constants' |
| 14 | import type { LoadBalancer } from '@/data/read-replicas/load-balancers-query' |
| 15 | import 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 | |
| 20 | export 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 | |
| 126 | const getDagreNodeHeight = (node: Node) => { |
| 127 | if (node.measured?.height) return node.measured.height |
| 128 | return NODE_HEIGHT_FALLBACKS[node.type ?? ''] ?? 100 |
| 129 | } |
| 130 | |
| 131 | export 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 | */ |
| 172 | export 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 | |
| 202 | export 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 | } |