Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Topology Visualization #9077

Merged
merged 52 commits into from
Oct 15, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
753bfbf
Scaffold a new topology page
DingoEatingFuzz Aug 13, 2020
1e9e2a1
Small cluster example scenario for the topo viz
DingoEatingFuzz Aug 24, 2020
6d9f59f
Scaffold topo viz components
DingoEatingFuzz Sep 3, 2020
b6867ef
Add ember modifiers addon
DingoEatingFuzz Sep 4, 2020
a003a8a
Quick window resize modifier
DingoEatingFuzz Sep 4, 2020
773404a
Prototype of the topo viz
DingoEatingFuzz Sep 4, 2020
7cf3b74
Fix factory bug that made it so pending allocs had no resources
DingoEatingFuzz Sep 4, 2020
b3ddd1f
Start click interaction for topo viz allocs
DingoEatingFuzz Sep 4, 2020
606910f
Tweak topo scenario
DingoEatingFuzz Sep 4, 2020
36df145
Fleshing out the first prototype of the topology visualization
DingoEatingFuzz Sep 11, 2020
0b3a78b
Touch up topo viz interactions
DingoEatingFuzz Sep 11, 2020
c2b7f7c
Add cluster details to the topology page
DingoEatingFuzz Sep 11, 2020
f5c3d0e
Super rough allocation details, needs some style love
DingoEatingFuzz Sep 11, 2020
f56f631
Associate sibling allocations by drawing lines
DingoEatingFuzz Sep 11, 2020
290f7d8
Guard against undefined denominators
DingoEatingFuzz Sep 11, 2020
3cd464d
Updated alloc and cluster details on topo page
DingoEatingFuzz Sep 11, 2020
98dd258
Medium scale topo scenario
DingoEatingFuzz Sep 11, 2020
2704c3e
CSS Grid based masonry layout
DingoEatingFuzz Sep 11, 2020
e9626ce
Add more variety to the node heights
DingoEatingFuzz Sep 12, 2020
9991dc6
Make the alloc select render path more efficient by not needlessly re…
DingoEatingFuzz Sep 17, 2020
d13c065
Refactor topo viz to do as much computation upfront & use faster data…
DingoEatingFuzz Sep 24, 2020
37cc9b7
Connect the memory and cpu rectangles
DingoEatingFuzz Sep 24, 2020
be2d0fc
A better loading screen for the topo viz while nodes load
DingoEatingFuzz Sep 24, 2020
a9e2917
Second attempt at a masonry layout
DingoEatingFuzz Sep 25, 2020
c152ccb
New FlexMasonry component implements a masonry layout using flexbox
DingoEatingFuzz Sep 29, 2020
c5e8474
Apply FlexMasonry to the TopoViz component
DingoEatingFuzz Sep 29, 2020
4e81ab8
Conditionally use the FlexMasonry layout for datacenters within TopoViz
DingoEatingFuzz Sep 29, 2020
f5f964b
Make the topo viz denser when there are >50 nodes
DingoEatingFuzz Sep 29, 2020
a4c8ce4
Fix a re-render bug with flexmasonry
DingoEatingFuzz Oct 8, 2020
bb68a14
Improved curves for allocation associations
DingoEatingFuzz Oct 8, 2020
de9124e
Label empty clients in the topo viz chart
DingoEatingFuzz Oct 10, 2020
927343f
Add icons to denote draining or ineligibility of clients
DingoEatingFuzz Oct 10, 2020
90dbab1
Filter total alloc count by only scheduled allocs
DingoEatingFuzz Oct 10, 2020
639b3ff
More information about clients in the info panel
DingoEatingFuzz Oct 11, 2020
611b0b3
FlexMasonry integration tests
DingoEatingFuzz Oct 11, 2020
84f88ec
Test coverage for TopoViz::Node
DingoEatingFuzz Oct 12, 2020
faaf697
Migrate to the new resources properties for allocs and nodes
DingoEatingFuzz Oct 12, 2020
b185d09
Update topo viz code to use new alloc/node resources pattern
DingoEatingFuzz Oct 12, 2020
334f72c
Unit test for for GiB in format-bytes
DingoEatingFuzz Oct 12, 2020
f9dccf9
Adjust topo viz controller to new resource code
DingoEatingFuzz Oct 13, 2020
a5ea84d
Update scenarios to use new resources code
DingoEatingFuzz Oct 13, 2020
9390d24
Remove temp reloading of nodes in the TopoViz component
DingoEatingFuzz Oct 14, 2020
3a48dbf
Test coverage for TopoViz::Datacenter
DingoEatingFuzz Oct 14, 2020
13415df
Unit and integration tests for TopoViz component
DingoEatingFuzz Oct 15, 2020
b5809d4
Some light topology acceptance tests
DingoEatingFuzz Oct 15, 2020
229fa29
Treat legend term pairs as single wrapping elements
DingoEatingFuzz Oct 15, 2020
e540312
Reset the standard environment values
DingoEatingFuzz Oct 15, 2020
223011e
Describe the glimmer-factory better including the motive
DingoEatingFuzz Oct 15, 2020
2cf4b5d
Remove the scenarios I didn't need/didn't get to
DingoEatingFuzz Oct 15, 2020
0d920ae
Factor out the common sum aggregator used in the topology controller
DingoEatingFuzz Oct 15, 2020
214ce4a
Work around Safari's lack of text transform support
DingoEatingFuzz Oct 15, 2020
8f94a98
Typo
DingoEatingFuzz Oct 15, 2020
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate the explanations, I hope this ends up being useful somewhere else too!

}
}
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate these comments, would be nice if it were easy to have a reference diagram, but such is not the way hehe

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