github.com/Ilhicas/nomad@v1.0.4-0.20210304152020-e86851182bc3/ui/app/components/topo-viz.js (about)

     1  import Component from '@glimmer/component';
     2  import { tracked } from '@glimmer/tracking';
     3  import { action, set } from '@ember/object';
     4  import { inject as service } from '@ember/service';
     5  import { run } from '@ember/runloop';
     6  import { scaleLinear } from 'd3-scale';
     7  import { extent, deviation, mean } from 'd3-array';
     8  import { line, curveBasis } from 'd3-shape';
     9  import styleStringProperty from '../utils/properties/style-string';
    10  
    11  export default class TopoViz extends Component {
    12    @service system;
    13  
    14    @tracked element = null;
    15    @tracked topology = { datacenters: [] };
    16  
    17    @tracked activeNode = null;
    18    @tracked activeAllocation = null;
    19    @tracked activeEdges = [];
    20    @tracked edgeOffset = { x: 0, y: 0 };
    21    @tracked viewportColumns = 2;
    22  
    23    @tracked highlightAllocation = null;
    24    @tracked tooltipProps = {};
    25  
    26    @styleStringProperty('tooltipProps') tooltipStyle;
    27  
    28    get isSingleColumn() {
    29      if (this.topology.datacenters.length <= 1 || this.viewportColumns === 1) return true;
    30  
    31      // Compute the coefficient of variance to determine if it would be
    32      // better to stack datacenters or place them in columns
    33      const nodeCounts = this.topology.datacenters.map(datacenter => datacenter.nodes.length);
    34      const variationCoefficient = deviation(nodeCounts) / mean(nodeCounts);
    35  
    36      // The point at which the varation is too extreme for a two column layout
    37      const threshold = 0.5;
    38      if (variationCoefficient > threshold) return true;
    39      return false;
    40    }
    41  
    42    get datacenterIsSingleColumn() {
    43      // If there are enough nodes, use two columns of nodes within
    44      // a single column layout of datacenters to increase density.
    45      if (this.viewportColumns === 1) return true;
    46      return !this.isSingleColumn || (this.isSingleColumn && this.args.nodes.length <= 20);
    47    }
    48  
    49    // Once a cluster is large enough, the exact details of a node are
    50    // typically irrelevant and a waste of space.
    51    get isDense() {
    52      return this.args.nodes.length > 50;
    53    }
    54  
    55    dataForNode(node) {
    56      return {
    57        node,
    58        datacenter: node.datacenter,
    59        memory: node.resources.memory,
    60        cpu: node.resources.cpu,
    61        allocations: [],
    62        isSelected: false,
    63      };
    64    }
    65  
    66    dataForAllocation(allocation, node) {
    67      const jobId = allocation.belongsTo('job').id();
    68      return {
    69        allocation,
    70        node,
    71        jobId,
    72        groupKey: JSON.stringify([jobId, allocation.taskGroupName]),
    73        memory: allocation.allocatedResources.memory,
    74        cpu: allocation.allocatedResources.cpu,
    75        memoryPercent: allocation.allocatedResources.memory / node.memory,
    76        cpuPercent: allocation.allocatedResources.cpu / node.cpu,
    77        isSelected: false,
    78      };
    79    }
    80  
    81    @action
    82    buildTopology() {
    83      const nodes = this.args.nodes;
    84      const allocations = this.args.allocations;
    85  
    86      // Nodes may not have a resources property due to having an old Nomad agent version.
    87      const badNodes = [];
    88  
    89      // Wrap nodes in a topo viz specific data structure and build an index to speed up allocation assignment
    90      const nodeContainers = [];
    91      const nodeIndex = {};
    92      nodes.forEach(node => {
    93        if (!node.resources) {
    94          badNodes.push(node);
    95          return;
    96        }
    97  
    98        const container = this.dataForNode(node);
    99        nodeContainers.push(container);
   100        nodeIndex[node.id] = container;
   101      });
   102  
   103      // Wrap allocations in a topo viz specific data structure, assign allocations to nodes, and build an allocation
   104      // index keyed off of job and task group
   105      const allocationIndex = {};
   106      allocations.forEach(allocation => {
   107        const nodeId = allocation.belongsTo('node').id();
   108        const nodeContainer = nodeIndex[nodeId];
   109  
   110        // Ignore orphaned allocations and allocations on nodes with an old Nomad agent version.
   111        if (!nodeContainer) return;
   112  
   113        const allocationContainer = this.dataForAllocation(allocation, nodeContainer);
   114        nodeContainer.allocations.push(allocationContainer);
   115  
   116        const key = allocationContainer.groupKey;
   117        if (!allocationIndex[key]) allocationIndex[key] = [];
   118        allocationIndex[key].push(allocationContainer);
   119      });
   120  
   121      // Group nodes into datacenters
   122      const datacentersMap = nodeContainers.reduce((datacenters, nodeContainer) => {
   123        if (!datacenters[nodeContainer.datacenter]) datacenters[nodeContainer.datacenter] = [];
   124        datacenters[nodeContainer.datacenter].push(nodeContainer);
   125        return datacenters;
   126      }, {});
   127  
   128      // Turn hash of datacenters into a sorted array
   129      const datacenters = Object.keys(datacentersMap)
   130        .map(key => ({ name: key, nodes: datacentersMap[key] }))
   131        .sortBy('name');
   132  
   133      const topology = {
   134        datacenters,
   135        allocationIndex,
   136        selectedKey: null,
   137        heightScale: scaleLinear()
   138          .range([15, 40])
   139          .domain(extent(nodeContainers.mapBy('memory'))),
   140      };
   141      this.topology = topology;
   142  
   143      if (badNodes.length && this.args.onDataError) {
   144        this.args.onDataError([
   145          {
   146            type: 'filtered-nodes',
   147            context: badNodes,
   148          },
   149        ]);
   150      }
   151    }
   152  
   153    @action
   154    captureElement(element) {
   155      this.element = element;
   156      this.determineViewportColumns();
   157    }
   158  
   159    @action
   160    showNodeDetails(node) {
   161      if (this.activeNode) {
   162        set(this.activeNode, 'isSelected', false);
   163      }
   164  
   165      this.activeNode = this.activeNode === node ? null : node;
   166  
   167      if (this.activeNode) {
   168        set(this.activeNode, 'isSelected', true);
   169      }
   170  
   171      if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode);
   172    }
   173  
   174    @action showTooltip(allocation, element) {
   175      const bbox = element.getBoundingClientRect();
   176      this.highlightAllocation = allocation;
   177      this.tooltipProps = {
   178        left: window.scrollX + bbox.left + bbox.width / 2,
   179        top: window.scrollY + bbox.top,
   180      };
   181    }
   182  
   183    @action hideTooltip() {
   184      this.highlightAllocation = null;
   185    }
   186  
   187    @action
   188    associateAllocations(allocation) {
   189      if (this.activeAllocation === allocation) {
   190        this.activeAllocation = null;
   191        this.activeEdges = [];
   192  
   193        if (this.topology.selectedKey) {
   194          const selectedAllocations = this.topology.allocationIndex[this.topology.selectedKey];
   195          if (selectedAllocations) {
   196            selectedAllocations.forEach(allocation => {
   197              set(allocation, 'isSelected', false);
   198            });
   199          }
   200          set(this.topology, 'selectedKey', null);
   201        }
   202      } else {
   203        if (this.activeNode) {
   204          set(this.activeNode, 'isSelected', false);
   205        }
   206        this.activeNode = null;
   207        this.activeAllocation = allocation;
   208        const selectedAllocations = this.topology.allocationIndex[this.topology.selectedKey];
   209        if (selectedAllocations) {
   210          selectedAllocations.forEach(allocation => {
   211            set(allocation, 'isSelected', false);
   212          });
   213        }
   214  
   215        set(this.topology, 'selectedKey', allocation.groupKey);
   216        const newAllocations = this.topology.allocationIndex[this.topology.selectedKey];
   217        if (newAllocations) {
   218          newAllocations.forEach(allocation => {
   219            set(allocation, 'isSelected', true);
   220          });
   221        }
   222  
   223        // Only show the lines if the selected allocations are sparse (low count relative to the client count or low count generally).
   224        if (newAllocations.length < 10 || newAllocations.length < this.args.nodes.length * 0.75) {
   225          this.computedActiveEdges();
   226        } else {
   227          this.activeEdges = [];
   228        }
   229      }
   230      if (this.args.onAllocationSelect)
   231        this.args.onAllocationSelect(this.activeAllocation && this.activeAllocation.allocation);
   232      if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode);
   233    }
   234  
   235    @action
   236    determineViewportColumns() {
   237      this.viewportColumns = this.element.clientWidth < 900 ? 1 : 2;
   238    }
   239  
   240    @action
   241    resizeEdges() {
   242      if (this.activeEdges.length > 0) {
   243        this.computedActiveEdges();
   244      }
   245    }
   246  
   247    @action
   248    computedActiveEdges() {
   249      // Wait a render cycle
   250      run.next(() => {
   251        const path = line().curve(curveBasis);
   252        // 1. Get the active element
   253        const allocation = this.activeAllocation.allocation;
   254        const activeEl = this.element.querySelector(`[data-allocation-id="${allocation.id}"]`);
   255        const activePoint = centerOfBBox(activeEl.getBoundingClientRect());
   256  
   257        // 2. Collect the mem and cpu pairs for all selected allocs
   258        const selectedMem = Array.from(this.element.querySelectorAll('.memory .bar.is-selected'));
   259        const selectedPairs = selectedMem.map(mem => {
   260          const id = mem.closest('[data-allocation-id]').dataset.allocationId;
   261          const cpu = mem
   262            .closest('.topo-viz-node')
   263            .querySelector(`.cpu .bar[data-allocation-id="${id}"]`);
   264          return [mem, cpu];
   265        });
   266        const selectedPoints = selectedPairs.map(pair => {
   267          return pair.map(el => centerOfBBox(el.getBoundingClientRect()));
   268        });
   269  
   270        // 3. For each pair, compute the midpoint of the truncated triangle of points [Mem, Cpu, Active]
   271        selectedPoints.forEach(points => {
   272          const d1 = pointBetween(points[0], activePoint, 100, 0.5);
   273          const d2 = pointBetween(points[1], activePoint, 100, 0.5);
   274          points.push(midpoint(d1, d2));
   275        });
   276  
   277        // 4. Generate curves for each active->mem and active->cpu pair going through the bisector
   278        const curves = [];
   279        // Steps are used to restrict the range of curves. The closer control points are placed, the less
   280        // curvature the curve generator will generate.
   281        const stepsMain = [0, 0.8, 1.0];
   282        // The second prong the fork does not need to retrace the entire path from the activePoint
   283        const stepsSecondary = [0.8, 1.0];
   284        selectedPoints.forEach(points => {
   285          curves.push(
   286            curveFromPoints(...pointsAlongPath(activePoint, points[2], stepsMain), points[0]),
   287            curveFromPoints(...pointsAlongPath(activePoint, points[2], stepsSecondary), points[1])
   288          );
   289        });
   290  
   291        this.activeEdges = curves.map(curve => path(curve));
   292        this.edgeOffset = { x: window.scrollX, y: window.scrollY };
   293      });
   294    }
   295  }
   296  
   297  function centerOfBBox(bbox) {
   298    return {
   299      x: bbox.x + bbox.width / 2,
   300      y: bbox.y + bbox.height / 2,
   301    };
   302  }
   303  
   304  function dist(p1, p2) {
   305    return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
   306  }
   307  
   308  // Return the point between p1 and p2 at len (or pct if len > dist(p1, p2))
   309  function pointBetween(p1, p2, len, pct) {
   310    const d = dist(p1, p2);
   311    const ratio = d < len ? pct : len / d;
   312    return pointBetweenPct(p1, p2, ratio);
   313  }
   314  
   315  function pointBetweenPct(p1, p2, pct) {
   316    const dx = p2.x - p1.x;
   317    const dy = p2.y - p1.y;
   318    return { x: p1.x + dx * pct, y: p1.y + dy * pct };
   319  }
   320  
   321  function pointsAlongPath(p1, p2, pcts) {
   322    return pcts.map(pct => pointBetweenPct(p1, p2, pct));
   323  }
   324  
   325  function midpoint(p1, p2) {
   326    return pointBetweenPct(p1, p2, 0.5);
   327  }
   328  
   329  function curveFromPoints(...points) {
   330    return points.map(p => [p.x, p.y]);
   331  }