mirror of
https://github.com/kemko/nomad.git
synced 2026-01-06 10:25:42 +03:00
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:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
46
ui/app/components/topo-viz/datacenter.js
Normal file
46
ui/app/components/topo-viz/datacenter.js
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
46
ui/app/styles/charts/topo-viz-node.scss
Normal file
46
ui/app/styles/charts/topo-viz-node.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user