Prototype of the topo viz

- Plot all datacenters
- For each datacenter, plot all nodes
- For each node, plot all allocations by memory and cpu
- For empty nodes, highlight the emptiness
- When hovering over allocations, give them visual focus
This commit is contained in:
Michael Lange
2020-09-04 00:43:27 -07:00
parent a003a8a987
commit 773404a47f
9 changed files with 314 additions and 20 deletions

View File

@@ -1,6 +1,14 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { scaleLinear } from 'd3-scale';
import { max } from 'd3-array';
import RSVP from 'rsvp';
export default class TopoViz extends Component {
@tracked heightScale = null;
@tracked isLoaded = false;
get datacenters() {
const datacentersMap = this.args.nodes.reduce((datacenters, node) => {
if (!datacenters[node.datacenter]) datacenters[node.datacenter] = [];
@@ -12,4 +20,15 @@ export default class TopoViz extends Component {
.map(key => ({ name: key, nodes: datacentersMap[key] }))
.sortBy('name');
}
@action
async loadNodes() {
await RSVP.all(this.args.nodes.map(node => node.reload()));
// TODO: Make the range dynamic based on the extent of the domain
this.heightScale = scaleLinear()
.range([15, 50])
.domain([0, max(this.args.nodes.map(node => node.resources.memory))]);
this.isLoaded = true;
}
}

View File

@@ -0,0 +1,46 @@
import RSVP from 'rsvp';
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class TopoVizNode extends Component {
@tracked scheduledAllocations = [];
@tracked aggregatedNodeResources = { cpu: 0, memory: 0 };
@tracked isLoaded = false;
get aggregateNodeResources() {
return this.args.nodes.mapBy('resources');
}
get aggregatedAllocationResources() {
return this.scheduledAllocations.mapBy('resources').reduce(
(totals, allocation) => {
totals.cpu += allocation.cpu;
totals.memory += allocation.memory;
return totals;
},
{ cpu: 0, memory: 0 }
);
}
@action
async loadAllocations() {
await RSVP.all(this.args.nodes.mapBy('allocations'));
this.scheduledAllocations = this.args.nodes.reduce(
(all, node) => all.concat(node.allocations.filterBy('isScheduled')),
[]
);
this.aggregatedNodeResources = this.args.nodes.mapBy('resources').reduce(
(totals, node) => {
totals.cpu += node.cpu;
totals.memory += node.memory;
return totals;
},
{ cpu: 0, memory: 0 }
);
this.isLoaded = true;
}
}

View File

@@ -1,7 +1,124 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';
export default class TopoVizNode extends Component {
@tracked data = { cpu: [], memory: [] };
// @tracked height = 15;
@tracked dimensionsWidth = 0;
@tracked padding = 5;
@tracked activeAllocation = null;
get height() {
return this.args.heightScale ? this.args.heightScale(this.args.node.resources.memory) : 15;
}
get yOffset() {
return this.height + 2;
}
get maskHeight() {
return this.height + this.yOffset;
}
get totalHeight() {
return this.maskHeight + this.padding * 2;
}
get maskId() {
return `topo-viz-node-mask-${guidFor(this)}`;
}
export default class TopoViz extends Component {
get count() {
return this.args.node.get('allocations.length');
}
get allocations() {
return this.args.node.allocations.filterBy('isScheduled').sortBy('resources.memory');
}
@action
async reloadNode() {
if (this.args.node.isPartial) {
await this.args.node.reload();
this.data = this.computeData(this.dimensionsWidth);
}
}
@action
render(svg) {
this.dimensionsWidth = svg.clientWidth - this.padding * 2;
this.data = this.computeData(this.dimensionsWidth);
}
@action
highlightAllocation(allocation) {
this.activeAllocation = allocation;
}
@action
clearHighlight() {
this.activeAllocation = null;
}
computeData(width) {
// TODO: differentiate reserved and resources
if (!this.args.node.resources) return;
const totalCPU = this.args.node.resources.cpu;
const totalMemory = this.args.node.resources.memory;
let cpuOffset = 0;
let memoryOffset = 0;
const cpu = [];
const memory = [];
for (const allocation of this.allocations) {
const cpuPercent = allocation.resources.cpu / totalCPU;
const memoryPercent = allocation.resources.memory / totalMemory;
const isFirst = allocation === this.allocations[0];
let cpuWidth = cpuPercent * width - 1;
let memoryWidth = memoryPercent * width - 1;
if (isFirst) {
cpuWidth += 0.5;
memoryWidth += 0.5;
}
cpu.push({
allocation,
offset: cpuOffset * 100,
percent: cpuPercent * 100,
width: cpuWidth,
x: cpuOffset * width + (isFirst ? 0 : 0.5),
className: allocation.clientStatus,
});
memory.push({
allocation,
offset: memoryOffset * 100,
percent: memoryPercent * 100,
width: memoryWidth,
x: memoryOffset * width + (isFirst ? 0 : 0.5),
className: allocation.clientStatus,
});
cpuOffset += cpuPercent;
memoryOffset += memoryPercent;
}
const cpuRemainder = {
x: cpuOffset * width + 0.5,
width: width - cpuOffset * width,
};
const memoryRemainder = {
x: memoryOffset * width + 0.5,
width: width - memoryOffset * width,
};
return { cpu, memory, cpuRemainder, memoryRemainder };
}
}
// capture width on did insert element
// update width on window resize
// recompute data when width changes

View File

@@ -47,6 +47,11 @@ export default class Allocation extends Model {
@equal('clientStatus', 'running') isRunning;
@attr('boolean') isMigrating;
@computed('clientStatus')
get isScheduled() {
return ['pending', 'running', 'failed'].includes(this.clientStatus);
}
// An allocation model created from any allocation list response will be lacking
// many properties (some of which can always be null). This is an indicator that
// the allocation needs to be reloaded to get the complete allocation state.

View File

@@ -4,6 +4,7 @@
@import './charts/tooltip';
@import './charts/colors';
@import './charts/chart-annotation.scss';
@import './charts/topo-viz-node.scss';
.inline-chart {
height: 1.5rem;

View File

@@ -0,0 +1,46 @@
.chart.topo-viz-node {
display: block;
svg {
display: inline-block;
height: 100%;
width: 100%;
overflow: visible;
.node-background {
fill: $white-ter;
stroke-width: 1;
stroke: $grey-lighter;
}
.dimension-background {
fill: lighten($grey-lighter, 5%);
}
.dimensions.is-active {
.bar {
opacity: 0.2;
&.is-active {
opacity: 1;
}
}
}
}
& + .topo-viz-node {
margin-top: 1em;
}
&.is-empty {
.node-background {
stroke: $red;
stroke-width: 2;
fill: $white;
}
.dimension-background {
fill: none;
}
}
}

View File

@@ -1,5 +1,7 @@
<div class="topo-viz">
{{#each this.datacenters as |dc|}}
<TopoViz::Datacenter @datacenter={{dc.name}} @nodes={{dc.nodes}} />
{{/each}}
<div class="topo-viz" {{did-insert this.loadNodes}}>
{{#if this.isLoaded}}
{{#each this.datacenters as |dc|}}
<TopoViz::Datacenter @datacenter={{dc.name}} @nodes={{dc.nodes}} @heightScale={{this.heightScale}} />
{{/each}}
{{/if}}
</div>

View File

@@ -1,8 +1,16 @@
<div class="boxed-section topo-viz-datacenter">
<div class="boxed-section-head is-hollow">{{@datacenter}}</div>
<div class="boxed-section topo-viz-datacenter" {{did-insert this.loadAllocations}}>
<div class="boxed-section-head is-hollow">
{{@datacenter}}
{{this.scheduledAllocations.length}} Allocs,
{{@nodes.length}} Nodes,
{{this.aggregatedAllocationResources.memory}}/{{this.aggregatedNodeResources.memory}} MiB,
{{this.aggregatedAllocationResources.cpu}}/{{this.aggregatedNodeResources.cpu}} Mhz
</div>
<div class="boxed-section-body">
{{#each @nodes as |node|}}
<TopoViz::Node @node={{node}} />
{{/each}}
{{#if this.isLoaded}}
{{#each @nodes as |node|}}
<TopoViz::Node @node={{node}} @heightScale={{@heightScale}} />
{{/each}}
{{/if}}
</div>
</div>

View File

@@ -1,11 +1,61 @@
<div class="topo-viz-node">
<strong>Node! {{@node.name}}</strong> ({{this.count}} allocs) ({{@node.resources.memory}} MiB, {{@node.resources.cpu}} Mhz)
<ul>
{{#each @node.allocations as |allocation|}}
<li>
<LinkTo @route="allocations.allocation" @model={{allocation}}>{{allocation.name}} {{allocation.shortId}}</LinkTo>
({{allocation.resources.memory}} MiB, {{allocation.resources.cpu}} Mhz)
</li>
{{/each}}
</ul>
<div class="chart topo-viz-node {{unless this.allocations.length "is-empty"}}" {{did-insert this.reloadNode}}>
<p><strong>{{@node.name}}</strong> ({{this.count}} allocs) ({{@node.resources.memory}} MiB, {{@node.resources.cpu}} Mhz) (% reserved would be nice)</p>
<svg class="chart" height="{{this.totalHeight}}px" {{did-insert this.render}} {{window-resize this.render}}>
<defs>
<clipPath id="{{this.maskId}}">
<rect class="mask" x="0" y="0" width="{{this.dimensionsWidth}}px" height="{{this.maskHeight}}px" rx="2px" ry="2px" />
</clipPath>
</defs>
<rect class="node-background" width="100%" height="{{this.totalHeight}}px" rx="2px" ry="2px" />
<g
class="dimensions {{if this.activeAllocation "is-active"}}"
transform="translate({{this.padding}},{{this.padding}})"
width="{{this.dimensionsWidth}}px"
height="{{this.maskHeight}}px"
clip-path="url(#{{this.maskId}})"
pointer-events="all"
{{on "mouseout" this.clearHighlight}}
>
<g class="memory">
{{#if this.data.memoryRemainder}}
<rect
class="dimension-background"
x="{{this.data.memoryRemainder.x}}px"
width="{{this.data.memoryRemainder.width}}px"
height="{{this.height}}px" />
{{/if}}
{{#each this.data.memory as |memory|}}
<g class="bar {{memory.className}} {{if (eq this.activeAllocation memory.allocation) "is-active"}}" {{on "mouseenter" (fn this.highlightAllocation memory.allocation)}} >
<rect width="{{memory.width}}px" x="{{memory.x}}px" height="{{this.height}}px" class="layer-0" />
{{#if (or (eq memory.className "starting") (eq memory.className "pending"))}}
<rect width="{{memory.width}}px" x="{{memory.x}}px" height="{{this.height}}px" class="layer-1" />
{{/if}}
</g>
{{/each}}
</g>
<g class="cpu">
{{#if this.data.cpuRemainder}}
<rect
class="dimension-background"
x="{{this.data.cpuRemainder.x}}px"
y="{{this.yOffset}}px"
width="{{this.data.cpuRemainder.width}}px"
height="{{this.height}}px" />
{{/if}}
{{#each this.data.cpu as |cpu|}}
<g class="bar {{cpu.className}} {{if (eq this.activeAllocation cpu.allocation) "is-active"}}" {{on "mouseenter" (fn this.highlightAllocation cpu.allocation)}}>
<rect width="{{cpu.width}}px" x="{{cpu.x}}px" height="{{this.height}}px" y="{{this.yOffset}}px" class="layer-0" />
{{#if (or (eq cpu.className "starting") (eq cpu.className "pending"))}}
<rect width="{{cpu.width}}px" x="{{cpu.x}}px" height="{{this.height}}px" y="{{this.yOffset}}px" class="layer-1" />
{{/if}}
</g>
{{/each}}
</g>
</g>
</svg>
<div class="chart-tooltip {{if this.isActive "active" "inactive"}}" style={{this.tooltipStyle}}>
<LinkTo @route="allocations.allocation" @model={{this.activeAllocation}}>{{this.activeAllocation.name}} {{this.activeAllocation.shortId}}</LinkTo>
({{this.activeAllocation.resources.memory}} MiB, {{this.activeAllocation.resources.cpu}} Mhz)
</div>
</div>