diff --git a/ui/app/components/topo-viz.js b/ui/app/components/topo-viz.js index b0d7e11c4..0e6d7b032 100644 --- a/ui/app/components/topo-viz.js +++ b/ui/app/components/topo-viz.js @@ -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; + } } diff --git a/ui/app/components/topo-viz/datacenter.js b/ui/app/components/topo-viz/datacenter.js new file mode 100644 index 000000000..0d224353c --- /dev/null +++ b/ui/app/components/topo-viz/datacenter.js @@ -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; + } +} diff --git a/ui/app/components/topo-viz/node.js b/ui/app/components/topo-viz/node.js index 3e28f1304..7d8b6bd8b 100644 --- a/ui/app/components/topo-viz/node.js +++ b/ui/app/components/topo-viz/node.js @@ -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 diff --git a/ui/app/models/allocation.js b/ui/app/models/allocation.js index a9a60d351..3aa09fa1c 100644 --- a/ui/app/models/allocation.js +++ b/ui/app/models/allocation.js @@ -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. diff --git a/ui/app/styles/charts.scss b/ui/app/styles/charts.scss index bdb259dd2..cfa14ddbb 100644 --- a/ui/app/styles/charts.scss +++ b/ui/app/styles/charts.scss @@ -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; diff --git a/ui/app/styles/charts/topo-viz-node.scss b/ui/app/styles/charts/topo-viz-node.scss new file mode 100644 index 000000000..f0dadf106 --- /dev/null +++ b/ui/app/styles/charts/topo-viz-node.scss @@ -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; + } + } +} diff --git a/ui/app/templates/components/topo-viz.hbs b/ui/app/templates/components/topo-viz.hbs index 40860cb0b..03e1a96c7 100644 --- a/ui/app/templates/components/topo-viz.hbs +++ b/ui/app/templates/components/topo-viz.hbs @@ -1,5 +1,7 @@ -
- {{#each this.datacenters as |dc|}} - - {{/each}} +
+ {{#if this.isLoaded}} + {{#each this.datacenters as |dc|}} + + {{/each}} + {{/if}}
diff --git a/ui/app/templates/components/topo-viz/datacenter.hbs b/ui/app/templates/components/topo-viz/datacenter.hbs index 60aa94605..532425716 100644 --- a/ui/app/templates/components/topo-viz/datacenter.hbs +++ b/ui/app/templates/components/topo-viz/datacenter.hbs @@ -1,8 +1,16 @@ -
-
{{@datacenter}}
+
+
+ {{@datacenter}} + {{this.scheduledAllocations.length}} Allocs, + {{@nodes.length}} Nodes, + {{this.aggregatedAllocationResources.memory}}/{{this.aggregatedNodeResources.memory}} MiB, + {{this.aggregatedAllocationResources.cpu}}/{{this.aggregatedNodeResources.cpu}} Mhz +
- {{#each @nodes as |node|}} - - {{/each}} + {{#if this.isLoaded}} + {{#each @nodes as |node|}} + + {{/each}} + {{/if}}
diff --git a/ui/app/templates/components/topo-viz/node.hbs b/ui/app/templates/components/topo-viz/node.hbs index 0e0581013..819a58401 100644 --- a/ui/app/templates/components/topo-viz/node.hbs +++ b/ui/app/templates/components/topo-viz/node.hbs @@ -1,11 +1,61 @@ -
- Node! {{@node.name}} ({{this.count}} allocs) ({{@node.resources.memory}} MiB, {{@node.resources.cpu}} Mhz) -
    - {{#each @node.allocations as |allocation|}} -
  • - {{allocation.name}} {{allocation.shortId}} - ({{allocation.resources.memory}} MiB, {{allocation.resources.cpu}} Mhz) -
  • - {{/each}} -
+
+

{{@node.name}} ({{this.count}} allocs) ({{@node.resources.memory}} MiB, {{@node.resources.cpu}} Mhz) (% reserved would be nice)

+ + + + + + + + + + {{#if this.data.memoryRemainder}} + + {{/if}} + {{#each this.data.memory as |memory|}} + + + {{#if (or (eq memory.className "starting") (eq memory.className "pending"))}} + + {{/if}} + + {{/each}} + + + {{#if this.data.cpuRemainder}} + + {{/if}} + {{#each this.data.cpu as |cpu|}} + + + {{#if (or (eq cpu.className "starting") (eq cpu.className "pending"))}} + + {{/if}} + + {{/each}} + + + +
+ {{this.activeAllocation.name}} {{this.activeAllocation.shortId}} + ({{this.activeAllocation.resources.memory}} MiB, {{this.activeAllocation.resources.cpu}} Mhz) +
+