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 }