github.com/hernad/nomad@v1.6.112/ui/app/components/topo-viz.js (about)

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