k8s.io/test-infra/triage@v0.0.0-20240520184403-27c6b4c223d8/render.js (about)

     1  "use strict";
     2  
     3  var rightArrow = "\u25ba";
     4  var downArrow = "\u25bc";
     5  
     6  const kGraphHeight = 200;      // keep synchronized with style.css:div.graph
     7  const kCollapseThreshold = 5;  // maximum number of entries before being collapsed
     8  
     9  if (Object.entries === undefined) {
    10    // Simple polyfill for Safari compatibility.
    11    // Object.entries is an ES2017 feature.
    12    Object.entries = function(obj) {
    13      var ret = [];
    14      for (let key of Object.keys(obj)) {
    15        ret.push([key, obj[key]]);
    16      }
    17      return ret;
    18    }
    19  }
    20  
    21  if (typeof Element !== 'undefined') {
    22    // Useful extension for DOM nodes.
    23    Element.prototype.removeChildren = function() {
    24      while (this.firstChild) {
    25        this.removeChild(this.firstChild);
    26      }
    27    }
    28  }
    29  
    30  // Create a new DOM node of `type` with `opts` attributes and with given children.
    31  // If children is a string, or an array with string elements, they become text nodes.
    32  function createElement(type, opts, children) {
    33    var el = document.createElement(type);
    34    if (opts) {
    35      for (let [key, value] of Object.entries(opts)) {
    36        if (typeof value === "object") {
    37          for (let [subkey, subvalue] of Object.entries(value)) {
    38            el[key][subkey] = subvalue;
    39          }
    40        } else {
    41          el[key] = value;
    42        }
    43      }
    44    }
    45    if (children) {
    46      if (typeof children === "string") {
    47        el.textContent = children;
    48      } else {
    49        for (let child of children) {
    50          if (typeof child === "string")
    51            child = document.createTextNode(child);
    52          el.appendChild(child);
    53        }
    54      }
    55    }
    56    return el;
    57  }
    58  
    59  // Like createElement, but also appends the new node to parent's children.
    60  function addElement(parent, type, opts, children) {
    61    var el = createElement(type, opts, children);
    62    parent.appendChild(el);
    63    return el;
    64  }
    65  
    66  function spyglassURLForBuild(build, test) {
    67    let buildPath = builds.jobPaths[build.job] + '/' + build.number;
    68    var spyglassURL = 'https://prow.k8s.io/view/gs/' + buildPath.slice(5);
    69    if (build.pr) {
    70      spyglassURL = spyglassURL.replace(/(\/pr-logs\/pull\/([^/]*\/)?)\d+\//, '$1' + build.pr + '/');
    71      //                                                   ^ optional repo segment, not present for k/k jobs
    72    }
    73    return spyglassURL;
    74  }
    75  
    76  // Turn a build object into a link with information.
    77  function buildToHtml(build, test, skipNumber) {
    78    let started = tsToString(build.started);
    79    return `<a href="${spyglassURLForBuild(build, test)}" target="_blank" rel="noopener">${skipNumber ? "" : build.number} ${started}</a>`;
    80  }
    81  
    82  // Turn a job and array of build numbers into a list of build links.
    83  function buildNumbersToHtml(job, buildNumbers, test) {
    84    var buildCount = builds.count(job);
    85    var pct = buildNumbers.length / builds.count(job);
    86    var out = `Failed in ${Math.round(pct * 100)}% (${buildNumbers.length}/${buildCount}) of builds: <ul>`;
    87    for (let number of buildNumbers) {
    88      out += '\n<li>' + buildToHtml(builds.get(job, number), test);
    89    }
    90    out += '\n</ul>';
    91    return out;
    92  }
    93  
    94  // Append a list item containing information about a job's runs.
    95  function addBuildListItem(jobList, job, buildNumbers, hits, test) {
    96    var jobEl = addElement(jobList, 'li', null, [sparkLineSVG(hits), ` ${buildNumbers.length} ${job} ${rightArrow}`,
    97      createElement('p', {
    98        style: {display: 'none'},
    99        dataset: {job: job, test: test || '', buildNumbers: JSON.stringify(buildNumbers)},
   100      })
   101    ]);
   102  }
   103  
   104  // Render a list of builds as a list of jobs with expandable build sections.
   105  function renderJobs(parent, clusterId) {
   106    if (parent.children.length > 0) {
   107      return;  // already done
   108    }
   109  
   110    var counts = clustered.makeCounts(clusterId);
   111  
   112    var jobs = {};
   113    for (let build of clustered.buildsForClusterById(clusterId)) {
   114      let job = build.job;
   115      if (!jobs[job]) {
   116        jobs[job] = new Set();
   117      }
   118      jobs[job].add(build.number);
   119    }
   120  
   121    var jobs = Object.entries(jobs);
   122    jobs = sortByKey(jobs, j => [-dayCounts(counts[j[0]]), -j[1].size]);
   123  
   124    var jobAllSection = false;
   125    var dayCount = dayCounts(counts['']);
   126    var countSum = 0;
   127  
   128    var jobList = addElement(parent, 'ul');
   129    for (let [job, buildNumbersSet] of jobs) {
   130      // This sort isn't strictly correct - our numbers are too large - but in practice we shouldn't
   131      // have ID numbers this similar anyway, and it's not fatal if builds this close together
   132      // are sorted wrong.
   133      let buildNumbers = Array.from(buildNumbersSet).sort((a,b) => Number(b) - Number(a));
   134      var count = counts[job];
   135      if (jobs.length > kCollapseThreshold && !jobAllSection && countSum > 0.8 * dayCount) {
   136        addElement(jobList, 'button', {className: 'rest', title: 'Show Daily Bottom 20%'}, 'More');
   137        addElement(jobList, 'hr');
   138        jobAllSection = true;
   139      }
   140      countSum += dayCounts(count);
   141      addBuildListItem(jobList, job, buildNumbers, count);
   142    }
   143  }
   144  
   145  // Return an SVG path displaying the given histogram arr, with width
   146  // being per element and height being the total height of the graph.
   147  function sparkLinePath(arr, width, height) {
   148    var max = 0;
   149    for (var i = 0; i < arr.length; i++) {
   150      if (arr[i] > max)
   151        max = arr[i];
   152    }
   153    var scale = max > 0 ? height / max : 1;
   154  
   155    // Full documentation here: https://www.w3.org/TR/SVG/paths.html#PathData
   156    // Basics:
   157    // 0,0 is the top left corner
   158    // Commands:
   159    //    M x y: move to x, y
   160    //    h dx: move horizontally +/- dx
   161    //    V y: move vertically to y
   162    // Here, we're drawing a histogram as a single polygon with right angles.
   163    var out = 'M0,' + height;
   164    var x = 0, y = height;
   165    for (var i = 0; i < arr.length; i++) {
   166      var h = height - Math.ceil(arr[i] * scale);
   167      if (h != y) {
   168        // h2V0 draws horizontally across, then a line to the top of the canvas.
   169        out += `h${i * width - x}V${h}`;
   170        x = i * width;
   171        y = h;
   172      }
   173    }
   174    out += `h${arr.length * width - x}`;
   175    if (y != height)
   176      out += `V${height}`;
   177  
   178    return out;
   179  }
   180  
   181  function sparkLineSVG(arr) {
   182    var width = 4;
   183    var height = 16;
   184    var path = sparkLinePath(arr, width, height);
   185    return createElement('span', {
   186      dataset: {tooltip: 'hits over last week, newest on the right'},
   187      innerHTML: `<svg height=${height} width='${(arr.length) * width}'><path d="${path}" /></svg>`,
   188    });
   189  }
   190  
   191  function dayCounts(arr) {
   192    var l = arr.length;
   193    return arr[l-1]+arr[l-2]+arr[l-3]+arr[l-4];
   194  }
   195  
   196  function renderLatest(el, keyId) {
   197    var ctxs = [];
   198    for (let ctx of clustered.buildsWithContextForClusterById(keyId)) {
   199      ctxs.push(ctx);
   200    }
   201    ctxs.sort((a, b) => { return (b[0].started - a[0].started) || (b[2] < a[2]); })
   202    var n = 0;
   203    addElement(el, 'tr', null, [
   204      createElement('th', null, 'Time'),
   205      createElement('th', null, 'Job'),
   206      createElement('th', null, 'Test')
   207    ]);
   208    var buildsEmittedSet = new Set();
   209    var buildsEmitted = [];
   210    var n = 0;
   211    for (let [build, job, test] of ctxs) {
   212      var key = job + build.number;
   213      if (buildsEmittedSet.has(key)) continue;
   214      buildsEmittedSet.add(key);
   215      buildsEmitted.push([build, job, test]);
   216      addElement(el, 'tr', null, [
   217        createElement('td', {innerHTML: `${buildToHtml(build, test, true)}`}),
   218        createElement('td', null, job),
   219        createElement('td', null, test),
   220      ]);
   221      if (++n >= 5) break;
   222    }
   223    return buildsEmitted;
   224  }
   225  
   226  // Return a list of strings and spans made from text according to spans.
   227  // Spans is a list of [text segment length, span segment length, ...] repeating.
   228  function renderSpans(text, spans) {
   229    if (!spans) {
   230      return [text];
   231    }
   232    if (spans.length > 1000) {
   233      console.warn(`Not highlighting excessive number of spans to avoid browser hang: ${spans.length}`);
   234      return [text];
   235    }
   236    var out = [];
   237    var c = 0;
   238    for (var i = 0; i < spans.length; i += 2) {
   239      out.push(text.slice(c, c + spans[i]));
   240      c += spans[i];
   241      if (i + 1 < spans.length) {
   242        out.push(createElement('span',
   243                               {className: 'mm', title: 'not present in all failure messages'},
   244                               text.slice(c, c + spans[i+1])));
   245        c += spans[i + 1];
   246      }
   247    }
   248    return out;
   249  }
   250  
   251  function makeGitHubIssue(id, text, owner, latestBuilds) {
   252    let title = `Failure cluster [${id.slice(0, 8)}...]`;
   253    let body = `### Failure cluster [${id}](https://go.k8s.io/triage#${id})
   254  
   255  ##### Error text:
   256  \`\`\`
   257  ${text.slice(0, Math.min(text.length, 1500))}
   258  \`\`\`
   259  #### Recent failures:
   260  `;
   261    for (let [build, job, test] of latestBuilds) {
   262      const url = spyglassURLForBuild(build, test);
   263      const started = tsToString(build.started);
   264      body += `[${started} ${job}](${url})\n`
   265    }
   266    body += `\n\n/kind failing-test`;
   267    body += '\n<!-- If this is a flake, please add: /kind flake -->';
   268    if (owner) {
   269      body += `\n\n/sig ${owner}`;
   270    } else {
   271      body += '\n\n<!-- Please assign a SIG using: /sig SIG-NAME -->';
   272    }
   273    return [title, body];
   274  }
   275  
   276  // Render a section for each cluster, including the text, a graph, and expandable sections
   277  // to dive into failures for each test or job.
   278  function renderCluster(top, cluster) {
   279    let {key, id, text, tests, spans, owner} = cluster;
   280  
   281    function plural(count, word, suffix) {
   282      return count + ' ' + word + (count == 1 ? '' : suffix);
   283    }
   284  
   285    function swapArrow(el) {
   286      el.textContent = el.textContent.replace(downArrow, rightArrow);
   287    }
   288  
   289    var counts = clustered.makeCounts(id);
   290  
   291    var clusterSum = clustersSum(tests);
   292    var todayCount = clustered.getHitsInLastDayById(id);
   293    var ownerTag = createElement('span', {className: 'owner sig-' + (owner || ''), dataset: {tooltip: 'inferred owner'}});
   294    var fileBug = createElement('a', {href: '#', target: '_blank', rel: 'noopener'}, 'file bug');
   295    var failureNode = addElement(top, 'div', {id: id, className: 'failure'}, [
   296      createElement('h2', null, [
   297        `${plural(clusterSum, 'test failure', 's')} (${todayCount} today) look like `,
   298        createElement('a', {href: '#' + id}, 'link'),
   299        createElement('a', {href: 'https://github.com/search?type=Issues&q=org:kubernetes%20' + id, target: '_blank', rel: 'noopener'}, 'search github'),
   300        fileBug,
   301        ownerTag,
   302      ]),
   303      createElement('pre', null, options.showNormalize ? key : renderSpans(text, spans)),
   304      createElement('div', {className: 'graph', dataset: {cluster: id}}),
   305    ]);
   306  
   307    if (owner) {
   308      ownerTag.innerText = owner;
   309    } else {
   310      ownerTag.remove();
   311    }
   312  
   313    var latest = createElement('table');
   314    var list = addElement(failureNode, 'ul', null, [
   315      createElement('span', null, [`Latest Failures`, latest]),
   316    ]);
   317  
   318    var latestBuilds = renderLatest(latest, id);
   319  
   320    fileBug.addEventListener('click', () => {
   321      let [title, body] = makeGitHubIssue(id, text, owner, latestBuilds);
   322      title = encodeURIComponent(title);
   323      body = encodeURIComponent(body);
   324      fileBug.href = `https://github.com/kubernetes/kubernetes/issues/new?body=${body}&title=${title}`;
   325    })
   326  
   327    var clusterJobs = addElement(list, 'li');
   328  
   329    var jobSet = new Set();
   330  
   331    var testList = createElement('ul');
   332  
   333    var expander = addElement(list, 'li', null, [`Failed in ${plural(tests.length, 'Test', 's')} ${downArrow}`, testList]);
   334  
   335    // If we expanded all the tests and jobs, how many rows would it take?
   336    var jobCount = sum(tests, t => t.jobs.length);
   337  
   338    // Sort tests by descending [last day hits, total hits]
   339    var testsSorted = sortByKey(tests, t => [-dayCounts(counts[t.name]), -sum(t.jobs, j => j.builds.length)]);
   340  
   341    var allTestsDayCount = dayCounts(counts['']);
   342    var testsDayCountSum = 0;
   343  
   344    var allSection = false;
   345    var testsShown = 0;
   346    var i = 0;
   347  
   348    for (var test of testsSorted) {
   349      i++;
   350      var testCount = sum(test.jobs, j => j.builds.length);
   351  
   352      var testDayCount = dayCounts(counts[test.name]);
   353  
   354      if (tests.length > kCollapseThreshold) {
   355        if (!allSection && testsDayCountSum > 0.8 * allTestsDayCount) {
   356          testsShown = i;
   357          addElement(testList, 'button', {className: 'rest', title: 'Show Daily Bottom 20% Tests'}, 'More');
   358          addElement(testList, 'hr');
   359          allSection = true;
   360        }
   361      }
   362  
   363      var testDayCountSum = 0;
   364      testsDayCountSum += testDayCount;
   365  
   366      var el = addElement(testList, 'li', null, [
   367        sparkLineSVG(counts[test.name]),
   368        ` ${testCount} ${test.name} ${rightArrow}`,
   369      ]);
   370  
   371      var jobList = addElement(el, 'ul', {style: {display: 'none'}});
   372  
   373      var jobs = sortByKey(test.jobs, j => [-dayCounts(counts[j.name + ' ' + test.name]), -j.builds.length]);
   374  
   375      var jobAllSection = false;
   376  
   377      var j = 0;
   378      for (var job of jobs) {
   379        var jobCount = counts[job.name + ' ' + test.name];
   380        if (jobs.length > kCollapseThreshold && !jobAllSection && testDayCountSum > 0.8 * testDayCount) {
   381          addElement(jobList, 'button', {className: 'rest', title: 'Show Daily Bottom 20% Jobs'}, 'More');
   382          addElement(jobList, 'hr');
   383          jobAllSection = true;
   384        }
   385        jobSet.add(job.name);
   386        addBuildListItem(jobList, job.name, job.builds, jobCount, test.name);
   387        testDayCountSum += dayCounts(jobCount);
   388      }
   389    }
   390  
   391    if ((testsShown === 0 && tests.length > kCollapseThreshold) || testsShown > kCollapseThreshold) {
   392      testList.style.display = 'none';
   393      swapArrow(expander.firstChild);
   394    }
   395  
   396    clusterJobs.innerHTML = `Failed in ${plural(jobSet.size, 'Job', 's')} ${rightArrow}<div style="display:none" class="jobs" data-cluster="${id}">`;
   397    if (jobSet.size <= 10) {  // automatically expand small job lists to save clicking
   398      expand(clusterJobs.children[0]);
   399    }
   400  
   401    return 1;
   402  }
   403  
   404  // Convert an array of integers into a histogram array of two-element arrays.
   405  function makeBuckets(hits, width, start, end) {
   406    var cur = start;
   407    cur -= (cur % width);  // align to width
   408    var counts = new Uint32Array(Math.floor((end - cur) / width) + 1);
   409    for (var hit of hits) {
   410      counts[Math.floor((hit - cur) / width)] += 1;
   411    }
   412    var buckets = [];
   413    for (var c of counts) {
   414      buckets.push([cur, c]);
   415      cur += width;
   416    }
   417    return buckets;
   418  }
   419  
   420  // Display a line graph on `element` showing failure occurrences.
   421  function renderGraph(element, buildsIterator) {
   422    // Defer rendering until later if the Charts API isn't available.
   423    if (!google.charts.loaded) {
   424      setTimeout(() => renderGraph(element, buildsIterator), 100);
   425      return;
   426    }
   427  
   428    // Find every build time in the current cluster.
   429    var hits = [];
   430    var buildsSeen = new Set();
   431    var buildTimes = [];  // one for each build
   432    for (let build of buildsIterator) {
   433      hits.push(build.started);
   434      let buildKey = build.job + build.number;
   435      if (!buildsSeen.has(buildKey)) {
   436        buildsSeen.add(buildKey);
   437        buildTimes.push(build.started);
   438      }
   439    }
   440  
   441    var width = 60 * 60; // Bucket into 1 hour chunks
   442    var widthRecip = 60 * 60 / width;
   443    var hitBuckets = makeBuckets(hits, width, builds.timespan[0], builds.timespan[1]);
   444    var buildBuckets = makeBuckets(buildTimes, width, builds.timespan[0], builds.timespan[1]);
   445    var buckets = buildBuckets.map((x, i) => [new Date(x[0] * 1000), x[1] * widthRecip, hitBuckets[i][1] * widthRecip]);
   446  
   447    var data = new google.visualization.DataTable();
   448    data.addColumn('date', 'X');
   449    data.addColumn('number', 'Builds');
   450    data.addColumn('number', 'Tests');
   451    data.addRows(buckets);
   452  
   453    var formatter = new google.visualization.DateFormat({'pattern': 'yyyy-MM-dd HH:mm z'});
   454    formatter.format(data, 0);
   455  
   456    var options = {
   457      width: 1200,
   458      height: kGraphHeight,
   459      hAxis: {title: 'Time', format: 'M/d'},
   460      vAxis: {title: 'Failures per hour'},
   461      legend: {position: 'none'},
   462      focusTarget: 'category',
   463    };
   464  
   465    var chart = new google.visualization.LineChart(element);
   466    chart.draw(data, options);
   467  }
   468  
   469  // When someone clicks on an expandable element, render the sub content as necessary.
   470  function expand(target) {
   471    if (target.nodeName === "BUTTON" && target.className === "rest") {
   472      target.remove();
   473      return true;
   474    }
   475    while (target.nodeName !== "LI" && target.parentNode) {
   476      target = target.parentNode;
   477    }
   478    var text = target.childNodes[target.childNodes.length - 2];
   479    var child = target.children[target.children.length - 1];
   480    if (target.nodeName == "LI" && child && text) {
   481      if (text.textContent.includes(rightArrow)) {
   482        text.textContent = text.textContent.replace(rightArrow, downArrow);
   483        child.style = "";
   484  
   485        if (child.dataset.buildNumbers) {
   486          var job = child.dataset.job;
   487          var test = child.dataset.test;
   488          var buildNumbers = JSON.parse(child.dataset.buildNumbers);
   489          child.innerHTML = buildNumbersToHtml(job, buildNumbers, test);
   490        } else if (child.dataset.cluster) {
   491          var cluster = child.dataset.cluster;
   492          if (child.className === 'graph') {
   493            renderGraph(child, clustered.buildsForClusterById(cluster));
   494          } else if (child.className === 'jobs') {
   495            renderJobs(child, cluster);
   496          }
   497        }
   498  
   499        return true;
   500      } else if (text.textContent.includes(downArrow)) {
   501        text.textContent = text.textContent.replace(downArrow, rightArrow);
   502        child.style = "display: none";
   503        return true;
   504      }
   505    }
   506    return false;
   507  }
   508  
   509  if (typeof module !== 'undefined' && module.exports) {
   510    // enable node.js `require` to work for testing
   511    module.exports = {
   512      makeBuckets: makeBuckets,
   513      sparkLinePath: sparkLinePath,
   514      spyglassURLForBuild: spyglassURLForBuild,
   515    }
   516  }