From 6acf2dc0f1687be25e8abdfd6fd6c1a1546987d4 Mon Sep 17 00:00:00 2001 From: Carson McManus Date: Thu, 14 Mar 2024 16:24:08 -0400 Subject: [PATCH] vis: add TreeDisplay (#1495) * minimally working tree layout * add some new aggregation utils * format * add comment * refactor how building the full tree works * fix type errors * merge duplicate monoliths and manually connect balancers to monoliths * better balancer node positioning * fix monolith trees overlapping * adjust radius of clients * fix tree placement for monoliths with no rooms * hook up event bus to tree display * add attributes for source and target node ids for links * format * fix zoom conflicting with other panels * add text to nodes in tree * make TreeDisplay remember current zoom position across rerenders * add tree panel in provisioned dashboard * format --- packages/ott-vis-panel/src/aggregate.spec.ts | 42 ++- packages/ott-vis-panel/src/aggregate.ts | 62 ++- .../src/components/CorePanel.tsx | 3 + .../src/components/TreeDisplay.spec.tsx | 35 ++ .../src/components/TreeDisplay.tsx | 354 ++++++++++++++++++ packages/ott-vis-panel/src/module.ts | 4 + .../provisioning/dashboards/dashboard.json | 44 ++- 7 files changed, 538 insertions(+), 6 deletions(-) create mode 100644 packages/ott-vis-panel/src/components/TreeDisplay.spec.tsx create mode 100644 packages/ott-vis-panel/src/components/TreeDisplay.tsx diff --git a/packages/ott-vis-panel/src/aggregate.spec.ts b/packages/ott-vis-panel/src/aggregate.spec.ts index 3a88c0630..fb2716e64 100644 --- a/packages/ott-vis-panel/src/aggregate.spec.ts +++ b/packages/ott-vis-panel/src/aggregate.spec.ts @@ -1,5 +1,11 @@ import type { SystemState } from "ott-vis"; -import { aggMonolithRooms, countRoomClients, groupMonolithsByRegion } from "./aggregate"; +import { + aggMonolithRooms, + countRoomClients, + dedupeMonoliths, + dedupeRooms, + groupMonolithsByRegion, +} from "./aggregate"; const sampleSystemState: SystemState = [ { @@ -116,4 +122,38 @@ describe("aggregation helpers", () => { cdg: ["0c85b46e-d343-46a3-ae4f-5f2aa1a8bdac", "f21df607-b572-4bdd-aa2f-3fead21bba86"], }); }); + + it("dedupes rooms", () => { + const rooms = [ + { name: "foo", clients: 1 }, + { name: "bar", clients: 2 }, + { name: "foo", clients: 1 }, + ]; + expect(dedupeRooms(rooms)).toEqual([ + { name: "foo", clients: 2 }, + { name: "bar", clients: 2 }, + ]); + }); + + it("dedupes rooms using sample data", () => { + const rooms = sampleSystemState.flatMap(b => b.monoliths.flatMap(m => m.rooms)); + expect(dedupeRooms(rooms)).toEqual([ + { name: "foo", clients: 3 }, + { name: "bar", clients: 2 }, + { name: "baz", clients: 3 }, + { name: "qux", clients: 4 }, + ]); + }); + + it("dedupes monoliths", () => { + const monoliths = [ + { id: "a", region: "x", rooms: [{ name: "foo", clients: 2 }] }, + { id: "b", region: "x", rooms: [] }, + { id: "a", region: "x", rooms: [{ name: "foo", clients: 1 }] }, + ]; + expect(dedupeMonoliths(monoliths)).toEqual([ + { id: "a", region: "x", rooms: [{ name: "foo", clients: 3 }] }, + { id: "b", region: "x", rooms: [] }, + ]); + }); }); diff --git a/packages/ott-vis-panel/src/aggregate.ts b/packages/ott-vis-panel/src/aggregate.ts index 94b3f36d7..f5eafd15f 100644 --- a/packages/ott-vis-panel/src/aggregate.ts +++ b/packages/ott-vis-panel/src/aggregate.ts @@ -1,4 +1,4 @@ -import type { SystemState } from "ott-vis"; +import type { Monolith, Room, SystemState } from "ott-vis"; /** * Builds a map of room names to the number of clients in each room from the room state. @@ -46,3 +46,63 @@ export function groupMonolithsByRegion(state: SystemState): Record [k, Array.from(v)])); } + +/** + * Reduces two room states into a single room state. + * @param rA + * @param rB + * @returns + */ +function reduceRoom(rA: Room, rB: Room): Room { + if (rA.name !== rB.name) { + throw new Error("Cannot reduce rooms with different names"); + } + // FIXME: (perf) This is a potentially hot path, and we should avoid creating a new object here. + return { + name: rA.name, + clients: rA.clients + rB.clients, + }; +} + +function reduceMonolith(mA: Monolith, mB: Monolith): Monolith { + if (mA.id !== mB.id) { + throw new Error("Cannot reduce monoliths with different ids"); + } + // FIXME: (perf) This is a potentially hot path, and we should avoid creating a new object here. + return { + id: mA.id, + region: mA.region, + rooms: dedupeRooms([...mA.rooms, ...mB.rooms]), + }; +} + +export function dedupeItems( + items: T[], + getKey: (item: T) => string, + reduce: (a: T, b: T) => T +): T[] { + const itemMap = new Map(); + for (const item of items) { + const key = getKey(item); + let existingItem = itemMap.get(key); + if (!existingItem) { + existingItem = item; + itemMap.set(key, existingItem); + continue; + } + itemMap.set(key, reduce(existingItem, item)); + } + return Array.from(itemMap.values()); +} + +/** + * Takes a list of rooms and produces a new list of rooms such that each room only appears once. + * @param rooms List of all rooms across all balancers + */ +export function dedupeRooms(rooms: Room[]): Room[] { + return dedupeItems(rooms, room => room.name, reduceRoom); +} + +export function dedupeMonoliths(monoliths: Monolith[]): Monolith[] { + return dedupeItems(monoliths, monolith => monolith.id, reduceMonolith); +} diff --git a/packages/ott-vis-panel/src/components/CorePanel.tsx b/packages/ott-vis-panel/src/components/CorePanel.tsx index 15ff53e88..ce9613ef3 100644 --- a/packages/ott-vis-panel/src/components/CorePanel.tsx +++ b/packages/ott-vis-panel/src/components/CorePanel.tsx @@ -8,6 +8,7 @@ import GlobalView from "./views/GlobalView"; import RegionView from "./views/RegionView"; import { LoadingState } from "@grafana/schema"; import { useEventBus, type BusEvent } from "eventbus"; +import TreeDisplay from "./TreeDisplay"; interface Props extends PanelProps {} @@ -49,6 +50,8 @@ export const CorePanel: React.FC = ({ options, data, width, height }) => return ; } else if (options.view === "region") { return ; + } else if (options.view === "tree") { + return ; } else { return
Invalid view
; } diff --git a/packages/ott-vis-panel/src/components/TreeDisplay.spec.tsx b/packages/ott-vis-panel/src/components/TreeDisplay.spec.tsx new file mode 100644 index 000000000..3da4bbd70 --- /dev/null +++ b/packages/ott-vis-panel/src/components/TreeDisplay.spec.tsx @@ -0,0 +1,35 @@ +import * as d3 from "d3"; +import { sizeOfTree } from "./TreeDisplay"; + +describe("TreeDisplay", () => { + it("should find the size of any d3 tree", () => { + interface FooTree { + name: string; + children: FooTree[]; + } + const tree: FooTree = { + name: "root", + children: [ + { + name: "child1", + children: [ + { + name: "child1.1", + children: [], + }, + ], + }, + { + name: "child2", + children: [], + }, + ], + }; + const treeLayout = d3.tree().nodeSize([10, 10]); + const root = d3.hierarchy(tree); + treeLayout(root); + const [width, height] = sizeOfTree(root); + expect(width).toBeGreaterThan(0); + expect(height).toBeGreaterThan(0); + }); +}); diff --git a/packages/ott-vis-panel/src/components/TreeDisplay.tsx b/packages/ott-vis-panel/src/components/TreeDisplay.tsx new file mode 100644 index 000000000..002469a91 --- /dev/null +++ b/packages/ott-vis-panel/src/components/TreeDisplay.tsx @@ -0,0 +1,354 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import * as d3 from "d3"; +import type { Monolith, SystemState } from "ott-vis/types"; +import { dedupeMonoliths } from "aggregate"; +import { useEventBus } from "eventbus"; + +interface TreeDisplayProps { + systemState: SystemState; + width: number; + height: number; +} + +const color = d3.scaleOrdinal(d3.schemeCategory10); + +interface TreeNode { + id: string; + region: string; + group: string; + children: TreeNode[]; +} + +// @ts-expect-error currently unused and i don't want to remove it yet +function buildFullTree(systemState: SystemState): TreeNode { + const tree: TreeNode = { + id: "root", + region: "global", + group: "root", + children: [], + }; + const monoliths = systemState.flatMap(balancer => balancer.monoliths); + const monolithNodes: Map = new Map( + buildMonolithTrees(monoliths).map(monolith => { + return [monolith.id, monolith]; + }) + ); + + for (const balancer of systemState) { + const balancerNode: TreeNode = { + id: balancer.id, + region: balancer.region, + group: "balancer", + children: [], + }; + tree.children.push(balancerNode); + for (const monolith of balancer.monoliths) { + balancerNode.children.push(monolithNodes.get(monolith.id) as TreeNode); + } + } + return tree; +} + +function buildMonolithTrees(monoliths: Monolith[]): TreeNode[] { + return dedupeMonoliths(monoliths).map(monolith => { + const roomNodes: TreeNode[] = monolith.rooms.map(room => { + return { + id: room.name, + region: monolith.region, + group: "room", + children: Array.from({ length: room.clients }, (_, index) => { + return { + id: `${room.name}-${index}`, + region: monolith.region, + group: "client", + children: [], + }; + }), + }; + }); + const monolithNode: TreeNode = { + id: monolith.id, + region: monolith.region, + group: "monolith", + children: roomNodes, + }; + return monolithNode; + }); +} + +/** + * Gets the physical size of a tree after it's been laid out. Does not account for the size of the actual nodes, just the space they take up. + * @returns [width, height] + */ +export function sizeOfTree(tree: d3.HierarchyNode): [number, number] { + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + tree.each(node => { + // @ts-expect-error d3 adds x and y to the node + minX = Math.min(minX, node.x); + // @ts-expect-error d3 adds x and y to the node + minY = Math.min(minY, node.y); + // @ts-expect-error d3 adds x and y to the node + maxX = Math.max(maxX, node.x); + // @ts-expect-error d3 adds x and y to the node + maxY = Math.max(maxY, node.y); + }); + return [maxX - minX, maxY - minY]; +} + +interface Node { + id: string; + x: number; + y: number; +} + +interface BalancerNode extends Node { + region: string; + group: string; +} + +interface MonolithNode extends Node { + tree: d3.HierarchyNode; +} + +const NODE_RADIUS = 20; + +function radius(node: TreeNode) { + if (node.group === "client") { + return 8; + } + return NODE_RADIUS; +} + +const TreeDisplay: React.FC = ({ systemState, width, height }) => { + const svgRef = useRef(null); + // const systemTree = useMemo(() => buildFullTree(systemState), [systemState]); + const monolithTrees = useMemo( + () => buildMonolithTrees(systemState.flatMap(b => b.monoliths)), + [systemState] + ); + + const [chartTransform, setChartTransform] = useState("translate(0, 0)"); + + useEffect(() => { + if (svgRef.current) { + // because d3-hierarchy doesn't support trees with multiple parents, we need to do manual layouts for balancers and monoliths, but we can use the built-in tree layout for monolith down to clients + + const svg = d3.select(svgRef.current); + const wholeGraph = svg.select("g.chart").attr("transform", chartTransform); + const gb2mLinks = wholeGraph.selectAll("g.b2m-links"); + + // build all the sub-trees first + const builtMonolithTrees: d3.HierarchyNode[] = []; + for (const monolithTree of monolithTrees) { + const treeLayout = d3.tree().nodeSize([NODE_RADIUS * 2, 120]); + const root = d3.hierarchy(monolithTree); + treeLayout(root); + builtMonolithTrees.push(root); + } + + // compute positions of monolith trees + // note: we are actually using the width here because the trees are being rotated 90 deg + const monolithTreeHeights = builtMonolithTrees.map(tree => sizeOfTree(tree)[0]); + const monolithTreeYs = monolithTreeHeights.reduce( + (acc, height, i) => { + acc.push(acc[i] + Math.max(height, NODE_RADIUS * 2 + 10)); + return acc; + }, + [0] + ); + const monolithNodes = monolithTrees.map((monolith, i) => { + const node: MonolithNode = { + tree: builtMonolithTrees[i], + id: monolith.id, + x: 100, + y: monolithTreeYs[i], + }; + return node; + }); + + // create nodes for all the balancers evenly spaced along the full height of the monolith trees + // but also guarenteeing that they don't overlap with each other or the monoliths with some padding + const fullHeight = monolithTreeYs[monolithTreeYs.length - 1]; + const lerp = d3.interpolateNumber(0, fullHeight); + const lerpincr = 1 / systemState.length; + const yincr = Math.max(lerp(lerpincr), NODE_RADIUS * 2 + 20); + const balancerNodes = systemState.map((balancer, i) => { + const node: BalancerNode = { + id: balancer.id, + region: balancer.region, + group: "balancer", + x: 0, + y: i * yincr, + }; + return node; + }); + + const balancerGroup = wholeGraph.select("g.balancers"); + const balancerCircles = balancerGroup.selectAll(".balancer").data(balancerNodes); + balancerCircles + .enter() + .append("circle") + .attr("class", "balancer") + .attr("r", NODE_RADIUS + 10) + .attr("fill", d => color(d.group)) + .attr("stroke", "white") + .attr("stroke-width", 2) + .attr("cx", d => d.x) + .attr("cy", d => d.y) + .attr("data-nodeid", d => d.id); + balancerCircles.exit().remove(); + const balancerTexts = balancerGroup.selectAll(".balancer-text").data(balancerNodes); + balancerTexts + .enter() + .append("text") + .attr("class", "balancer-text") + .attr("text-anchor", "middle") + .attr("alignment-baseline", "middle") + .attr("font-family", "Inter, Helvetica, Arial, sans-serif") + .attr("font-size", 10) + .attr("stroke-width", 0) + .attr("fill", "white") + .attr("x", d => d.x) + .attr("y", d => d.y + 4) + .text(d => `${d.region.substring(0, 3)} ${d.id}`.substring(0, 10)); + balancerTexts.exit().remove(); + + // create groups for all the monoliths + const monolithGroup = wholeGraph.select("g.monoliths"); + const monolithGroups = monolithGroup.selectAll(".monolith").data(monolithNodes); + monolithGroups + .enter() + .append("g") + .attr("class", "monolith") + .attr("transform", (d, i) => `translate(${d.x}, ${d.y})`) + .each(function (d) { + const diagonal = d3 + .linkHorizontal() + .x((d: any) => d.y) + .y((d: any) => d.x); + + const monolith = d3.select(this); + const monolithLinks = monolith.selectAll(".treelink").data(d.tree.links()); + monolithLinks + .enter() + .append("path") + .attr("class", "treelink") + .attr("d", diagonal) + .attr("fill", "none") + .attr("stroke", "white") + .attr("stroke-width", 1.5) + .attr("data-nodeid-source", d => d.source.data.id) + .attr("data-nodeid-target", d => d.target.data.id); + monolithLinks.exit().remove(); + + const monolithCircles = monolith + .selectAll(".monolith") + .data(d.tree.descendants()); + monolithCircles + .enter() + .append("circle") + .attr("class", "monolith") + .attr("r", d => radius(d.data)) + .attr("fill", d => color(d.data.group)) + .attr("stroke", "white") + .attr("stroke-width", 2) + .attr("cx", (d: any) => d.y) + .attr("cy", (d: any) => d.x) + .attr("data-nodeid", d => d.data.id); + monolithCircles.exit().remove(); + const monolithTexts = monolith + .selectAll(".monolith-text") + .data(d.tree.descendants()); + monolithTexts + .enter() + // intentionally not showing room and client names -- user generated content can contain offensive material + .filter(d => d.data.group === "monolith") + .append("text") + .attr("class", "monolith-text") + .attr("text-anchor", "middle") + .attr("alignment-baseline", "middle") + .attr("font-family", "Inter, Helvetica, Arial, sans-serif") + .attr("font-size", 10) + .attr("stroke-width", 0) + .attr("fill", "white") + .attr("x", (d: any) => d.y) + .attr("y", (d: any) => d.x + 4) + .text(d => `${d.data.id}`.substring(0, 6)); + monolithTexts.exit().remove(); + }); + + // create the links between balancers and monoliths + interface B2MLink { + source: BalancerNode; + target: MonolithNode; + } + const diagonal = d3 + .linkHorizontal() + .x(d => d.x) + .y(d => d.y); + + const b2mLinkData = balancerNodes.flatMap(balancer => { + return monolithNodes.map(monolith => { + return { + source: balancer, + target: monolith, + }; + }); + }); + const balancerMonolithLinks = gb2mLinks.selectAll(".b2m-link").data(b2mLinkData); + balancerMonolithLinks + .enter() + .append("path") + .attr("class", "b2m-link") + .attr("d", diagonal) + .attr("fill", "none") + .attr("stroke", "white") + .attr("stroke-width", 1.5) + .attr("data-nodeid-source", d => d.source.id) + .attr("data-nodeid-target", d => d.target.id); + balancerMonolithLinks.exit().remove(); + + const zoom = d3.zoom().on("zoom", handleZoom); + function handleZoom(e: any) { + svg.select("g.chart").attr("transform", e.transform); + setChartTransform(e.transform); + } + svg.call(zoom); + } + }, [systemState, monolithTrees, width, height, chartTransform]); + + const eventBus = useEventBus(); + useEffect(() => { + const sub = eventBus.subscribe(event => { + d3.select(`[data-nodeid="${event.node_id}"]`) + .transition() + .duration(100) + .attrTween("stroke", () => d3.interpolateRgb("#f00", "#fff")) + .attrTween("stroke-width", () => t => d3.interpolateNumber(4, 1.5)(t).toString()); + }); + + return () => { + sub.unsubscribe(); + }; + }, [eventBus]); + + return ( + + + + + + + + ); +}; + +export default TreeDisplay; diff --git a/packages/ott-vis-panel/src/module.ts b/packages/ott-vis-panel/src/module.ts index 9bd61cb20..bcd785726 100644 --- a/packages/ott-vis-panel/src/module.ts +++ b/packages/ott-vis-panel/src/module.ts @@ -19,6 +19,10 @@ export const plugin = new PanelPlugin(CorePanel).setPanelOptions(bu value: "region", label: "Region", }, + { + value: "tree", + label: "Tree", + }, ], }, }) diff --git a/packages/ott-vis/provisioning/dashboards/dashboard.json b/packages/ott-vis/provisioning/dashboards/dashboard.json index 69c0a67ad..05f0a61af 100644 --- a/packages/ott-vis/provisioning/dashboards/dashboard.json +++ b/packages/ott-vis/provisioning/dashboards/dashboard.json @@ -90,6 +90,42 @@ "title": "Region", "type": "ott-vis-panel" }, + { + "datasource": { + "type": "ott-vis-datasource", + "uid": "P8AFEECD30EDC727B" + }, + "gridPos": { + "h": 14, + "w": 24, + "x": 0, + "y": 19 + }, + "id": 6, + "options": { + "view": "tree" + }, + "targets": [ + { + "datasource": { + "type": "ott-vis-datasource", + "uid": "P8AFEECD30EDC727B" + }, + "refId": "A" + }, + { + "datasource": { + "type": "ott-vis-datasource", + "uid": "P8AFEECD30EDC727B" + }, + "hide": false, + "refId": "B", + "stream": true + } + ], + "title": "Tree", + "type": "ott-vis-panel" + }, { "datasource": { "type": "datasource", @@ -128,7 +164,7 @@ "h": 8, "w": 9, "x": 0, - "y": 19 + "y": 33 }, "id": 4, "options": { @@ -257,7 +293,7 @@ "h": 8, "w": 7, "x": 9, - "y": 19 + "y": 33 }, "id": 2, "options": { @@ -336,7 +372,7 @@ "h": 8, "w": 8, "x": 16, - "y": 19 + "y": 33 }, "id": 3, "options": { @@ -370,6 +406,6 @@ "timezone": "", "title": "Provisioned ott-vis dashboard", "uid": "c5ea3bcd-f966-47d8-8456-4536ddc45ff0", - "version": 4, + "version": 28, "weekStart": "" }