Skip to content

Commit

Permalink
Merge pull request #9077 from hashicorp/f-ui/topo-viz
Browse files Browse the repository at this point in the history
UI: Topology Visualization
  • Loading branch information
DingoEatingFuzz authored Oct 15, 2020
2 parents 0302fee + 8f94a98 commit 5302ab0
Show file tree
Hide file tree
Showing 60 changed files with 3,665 additions and 93 deletions.
66 changes: 66 additions & 0 deletions ui/app/components/flex-masonry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { run } from '@ember/runloop';
import { action } from '@ember/object';
import { minIndex, max } from 'd3-array';

export default class FlexMasonry extends Component {
@tracked element = null;

@action
captureElement(element) {
this.element = element;
}

@action
reflow() {
run.next(() => {
// There's nothing to do if this is a single column layout
if (!this.element || this.args.columns === 1 || !this.args.columns) return;

const columns = new Array(this.args.columns).fill(null).map(() => ({
height: 0,
elements: [],
}));

const items = this.element.querySelectorAll('.flex-masonry-item');

// First pass: assign each element to a column based on the running heights of each column
for (let item of items) {
const styles = window.getComputedStyle(item);
const marginTop = parseFloat(styles.marginTop);
const marginBottom = parseFloat(styles.marginBottom);
const height = item.clientHeight;

// Pick the shortest column accounting for margins
const column = columns[minIndex(columns, c => c.height)];

// Add the new element's height to the column height
column.height += marginTop + height + marginBottom;
column.elements.push(item);
}

// Second pass: assign an order to each element based on their column and position in the column
columns
.mapBy('elements')
.flat()
.forEach((dc, index) => {
dc.style.order = index;
});

// Guarantee column wrapping as predicted (if the first item of a column is shorter than the difference
// beteen the height of the column and the previous column, then flexbox will naturally place the first
// item at the end of the previous column).
columns.forEach((column, index) => {
const nextHeight = index < columns.length - 1 ? columns[index + 1].height : 0;
const item = column.elements.lastObject;
if (item) {
item.style.flexBasis = item.clientHeight + Math.max(0, nextHeight - column.height) + 'px';
}
});

// Set the max height of the container to the height of the tallest column
this.element.style.maxHeight = max(columns.mapBy('height')) + 1 + 'px';
});
}
}
271 changes: 271 additions & 0 deletions ui/app/components/topo-viz.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action, set } from '@ember/object';
import { run } from '@ember/runloop';
import { scaleLinear } from 'd3-scale';
import { extent, deviation, mean } from 'd3-array';
import { line, curveBasis } from 'd3-shape';

export default class TopoViz extends Component {
@tracked element = null;
@tracked topology = { datacenters: [] };

@tracked activeNode = null;
@tracked activeAllocation = null;
@tracked activeEdges = [];
@tracked edgeOffset = { x: 0, y: 0 };

get isSingleColumn() {
if (this.topology.datacenters.length <= 1) return true;

// Compute the coefficient of variance to determine if it would be
// better to stack datacenters or place them in columns
const nodeCounts = this.topology.datacenters.map(datacenter => datacenter.nodes.length);
const variationCoefficient = deviation(nodeCounts) / mean(nodeCounts);

// The point at which the varation is too extreme for a two column layout
const threshold = 0.5;
if (variationCoefficient > threshold) return true;
return false;
}

get datacenterIsSingleColumn() {
// If there are enough nodes, use two columns of nodes within
// a single column layout of datacenters to increase density.
return !this.isSingleColumn || (this.isSingleColumn && this.args.nodes.length <= 20);
}

// Once a cluster is large enough, the exact details of a node are
// typically irrelevant and a waste of space.
get isDense() {
return this.args.nodes.length > 50;
}

dataForNode(node) {
return {
node,
datacenter: node.datacenter,
memory: node.resources.memory,
cpu: node.resources.cpu,
allocations: [],
isSelected: false,
};
}

dataForAllocation(allocation, node) {
const jobId = allocation.belongsTo('job').id();
return {
allocation,
node,
jobId,
groupKey: JSON.stringify([jobId, allocation.taskGroupName]),
memory: allocation.allocatedResources.memory,
cpu: allocation.allocatedResources.cpu,
memoryPercent: allocation.allocatedResources.memory / node.memory,
cpuPercent: allocation.allocatedResources.cpu / node.cpu,
isSelected: false,
};
}

@action
buildTopology() {
const nodes = this.args.nodes;
const allocations = this.args.allocations;

// Wrap nodes in a topo viz specific data structure and build an index to speed up allocation assignment
const nodeContainers = [];
const nodeIndex = {};
nodes.forEach(node => {
const container = this.dataForNode(node);
nodeContainers.push(container);
nodeIndex[node.id] = container;
});

// Wrap allocations in a topo viz specific data structure, assign allocations to nodes, and build an allocation
// index keyed off of job and task group
const allocationIndex = {};
allocations.forEach(allocation => {
const nodeId = allocation.belongsTo('node').id();
const nodeContainer = nodeIndex[nodeId];
if (!nodeContainer)
throw new Error(`Node ${nodeId} for alloc ${allocation.id} not in index.`);

const allocationContainer = this.dataForAllocation(allocation, nodeContainer);
nodeContainer.allocations.push(allocationContainer);

const key = allocationContainer.groupKey;
if (!allocationIndex[key]) allocationIndex[key] = [];
allocationIndex[key].push(allocationContainer);
});

// Group nodes into datacenters
const datacentersMap = nodeContainers.reduce((datacenters, nodeContainer) => {
if (!datacenters[nodeContainer.datacenter]) datacenters[nodeContainer.datacenter] = [];
datacenters[nodeContainer.datacenter].push(nodeContainer);
return datacenters;
}, {});

// Turn hash of datacenters into a sorted array
const datacenters = Object.keys(datacentersMap)
.map(key => ({ name: key, nodes: datacentersMap[key] }))
.sortBy('name');

const topology = {
datacenters,
allocationIndex,
selectedKey: null,
heightScale: scaleLinear()
.range([15, 40])
.domain(extent(nodeContainers.mapBy('memory'))),
};
this.topology = topology;
}

@action
captureElement(element) {
this.element = element;
}

@action
showNodeDetails(node) {
if (this.activeNode) {
set(this.activeNode, 'isSelected', false);
}

this.activeNode = this.activeNode === node ? null : node;

if (this.activeNode) {
set(this.activeNode, 'isSelected', true);
}

if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode);
}

@action
associateAllocations(allocation) {
if (this.activeAllocation === allocation) {
this.activeAllocation = null;
this.activeEdges = [];

if (this.topology.selectedKey) {
const selectedAllocations = this.topology.allocationIndex[this.topology.selectedKey];
if (selectedAllocations) {
selectedAllocations.forEach(allocation => {
set(allocation, 'isSelected', false);
});
}
set(this.topology, 'selectedKey', null);
}
} else {
if (this.activeNode) {
set(this.activeNode, 'isSelected', false);
}
this.activeNode = null;
this.activeAllocation = allocation;
const selectedAllocations = this.topology.allocationIndex[this.topology.selectedKey];
if (selectedAllocations) {
selectedAllocations.forEach(allocation => {
set(allocation, 'isSelected', false);
});
}

set(this.topology, 'selectedKey', allocation.groupKey);
const newAllocations = this.topology.allocationIndex[this.topology.selectedKey];
if (newAllocations) {
newAllocations.forEach(allocation => {
set(allocation, 'isSelected', true);
});
}

this.computedActiveEdges();
}
if (this.args.onAllocationSelect)
this.args.onAllocationSelect(this.activeAllocation && this.activeAllocation.allocation);
if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode);
}

@action
computedActiveEdges() {
// Wait a render cycle
run.next(() => {
const path = line().curve(curveBasis);
// 1. Get the active element
const allocation = this.activeAllocation.allocation;
const activeEl = this.element.querySelector(`[data-allocation-id="${allocation.id}"]`);
const activePoint = centerOfBBox(activeEl.getBoundingClientRect());

// 2. Collect the mem and cpu pairs for all selected allocs
const selectedMem = Array.from(this.element.querySelectorAll('.memory .bar.is-selected'));
const selectedPairs = selectedMem.map(mem => {
const id = mem.closest('[data-allocation-id]').dataset.allocationId;
const cpu = mem
.closest('.topo-viz-node')
.querySelector(`.cpu .bar[data-allocation-id="${id}"]`);
return [mem, cpu];
});
const selectedPoints = selectedPairs.map(pair => {
return pair.map(el => centerOfBBox(el.getBoundingClientRect()));
});

// 3. For each pair, compute the midpoint of the truncated triangle of points [Mem, Cpu, Active]
selectedPoints.forEach(points => {
const d1 = pointBetween(points[0], activePoint, 100, 0.5);
const d2 = pointBetween(points[1], activePoint, 100, 0.5);
points.push(midpoint(d1, d2));
});

// 4. Generate curves for each active->mem and active->cpu pair going through the bisector
const curves = [];
// Steps are used to restrict the range of curves. The closer control points are placed, the less
// curvature the curve generator will generate.
const stepsMain = [0, 0.8, 1.0];
// The second prong the fork does not need to retrace the entire path from the activePoint
const stepsSecondary = [0.8, 1.0];
selectedPoints.forEach(points => {
curves.push(
curveFromPoints(...pointsAlongPath(activePoint, points[2], stepsMain), points[0]),
curveFromPoints(...pointsAlongPath(activePoint, points[2], stepsSecondary), points[1])
);
});

this.activeEdges = curves.map(curve => path(curve));
this.edgeOffset = { x: window.visualViewport.pageLeft, y: window.visualViewport.pageTop };
});
}
}

function centerOfBBox(bbox) {
return {
x: bbox.x + bbox.width / 2,
y: bbox.y + bbox.height / 2,
};
}

function dist(p1, p2) {
return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
}

// Return the point between p1 and p2 at len (or pct if len > dist(p1, p2))
function pointBetween(p1, p2, len, pct) {
const d = dist(p1, p2);
const ratio = d < len ? pct : len / d;
return pointBetweenPct(p1, p2, ratio);
}

function pointBetweenPct(p1, p2, pct) {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
return { x: p1.x + dx * pct, y: p1.y + dy * pct };
}

function pointsAlongPath(p1, p2, pcts) {
return pcts.map(pct => pointBetweenPct(p1, p2, pct));
}

function midpoint(p1, p2) {
return pointBetweenPct(p1, p2, 0.5);
}

function curveFromPoints(...points) {
return points.map(p => [p.x, p.y]);
}
32 changes: 32 additions & 0 deletions ui/app/components/topo-viz/datacenter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Component from '@glimmer/component';

export default class TopoVizDatacenter extends Component {
get scheduledAllocations() {
return this.args.datacenter.nodes.reduce(
(all, node) => all.concat(node.allocations.filterBy('allocation.isScheduled')),
[]
);
}

get aggregatedAllocationResources() {
return this.scheduledAllocations.reduce(
(totals, allocation) => {
totals.cpu += allocation.cpu;
totals.memory += allocation.memory;
return totals;
},
{ cpu: 0, memory: 0 }
);
}

get aggregatedNodeResources() {
return this.args.datacenter.nodes.reduce(
(totals, node) => {
totals.cpu += node.cpu;
totals.memory += node.memory;
return totals;
},
{ cpu: 0, memory: 0 }
);
}
}
Loading

0 comments on commit 5302ab0

Please sign in to comment.