github.com/zppinho/prow@v0.0.0-20240510014325-1738badeb017/cmd/deck/static/prow/prow.ts (about) 1 import moment from "moment"; 2 import {ProwJob, ProwJobList, ProwJobState, ProwJobType, Pull} from "../api/prow"; 3 import {createAbortProwJobIcon} from "../common/abort"; 4 import {cell, formatDuration, icon} from "../common/common"; 5 import {createRerunProwJobIcon} from "../common/rerun"; 6 import {getParameterByName} from "../common/urls"; 7 import {FuzzySearch} from './fuzzy-search'; 8 import {JobHistogram, JobSample} from './histogram'; 9 10 declare const allBuilds: ProwJobList; 11 declare const spyglass: boolean; 12 declare const rerunCreatesJob: boolean; 13 declare const csrfToken: string; 14 15 function genShortRefKey(baseRef: string, pulls: Pull[] = []) { 16 return [baseRef, ...pulls.map((p) => p.number)].filter((n) => n).join(","); 17 } 18 19 function genLongRefKey(baseRef: string, baseSha: string, pulls: Pull[] = []) { 20 return [ 21 [baseRef, baseSha].filter((n) => n).join(":"), 22 ...pulls.map((p) => [p.number, p.sha].filter((n) => n).join(":")), 23 ] 24 .filter((n) => n) 25 .join(","); 26 } 27 28 interface RepoOptions { 29 types: {[key: string]: boolean}; 30 repos: {[key: string]: boolean}; 31 jobs: {[key: string]: boolean}; 32 authors: {[key: string]: boolean}; 33 pulls: {[key: string]: boolean}; 34 states: {[key: string]: boolean}; 35 clusters: {[key: string]: boolean}; 36 } 37 38 function optionsForRepo(repository: string): RepoOptions { 39 const opts: RepoOptions = { 40 authors: {}, 41 clusters: {}, 42 jobs: {}, 43 pulls: {}, 44 repos: {}, 45 states: {}, 46 types: {}, 47 }; 48 49 for (const build of allBuilds.items) { 50 const { 51 spec: { 52 cluster = "", 53 type = "", 54 job = "", 55 refs: { 56 org = "", repo = "", pulls = [], base_ref = "", 57 } = {}, 58 }, 59 status: { 60 state = "", 61 }, 62 } = build; 63 64 opts.types[type] = true; 65 opts.clusters[cluster] = true; 66 opts.states[state] = true; 67 68 69 const repoKey = `${org}/${repo}`; 70 if (repoKey) { 71 opts.repos[repoKey] = true; 72 } 73 if (!repository || repository === repoKey) { 74 opts.jobs[job] = true; 75 76 if (pulls.length) { 77 for (const pull of pulls) { 78 opts.authors[pull.author] = true; 79 opts.pulls[pull.number] = true; 80 } 81 } 82 } 83 } 84 85 return opts; 86 } 87 88 function redrawOptions(fz: FuzzySearch, opts: RepoOptions) { 89 const ts = Object.keys(opts.types).sort(); 90 const selectedType = addOptions(ts, "type") as ProwJobType; 91 const rs = Object.keys(opts.repos).filter((r) => r !== "/").sort(); 92 addOptions(rs, "repo"); 93 const js = Object.keys(opts.jobs).sort(); 94 const jobInput = document.getElementById("job-input") as HTMLInputElement; 95 const jobList = document.getElementById("job-list") as HTMLUListElement; 96 addOptionFuzzySearch(fz, js, "job", jobList, jobInput); 97 98 if (selectedType !== "periodic" && selectedType !== "postsubmit") { 99 const ps = Object.keys(opts.pulls).sort((a, b) => Number(b) - Number(a)); 100 addOptions(ps, "pull"); 101 const as = Object.keys(opts.authors).sort( 102 (a, b) => a.toLowerCase().localeCompare(b.toLowerCase())); 103 addOptions(as, "author"); 104 } else { 105 addOptions([], "pull"); 106 addOptions([], "author"); 107 } 108 const ss = Object.keys(opts.states).sort(); 109 addOptions(ss, "state"); 110 const cs = Object.keys(opts.clusters).sort(); 111 addOptions(cs, "cluster"); 112 } 113 114 function adjustScroll(el: Element): void { 115 const parent = el.parentElement; 116 const parentRect = parent.getBoundingClientRect(); 117 const elRect = el.getBoundingClientRect(); 118 119 if (elRect.top < parentRect.top) { 120 parent.scrollTop -= elRect.height; 121 } else if (elRect.top + elRect.height >= parentRect.top 122 + parentRect.height) { 123 parent.scrollTop += elRect.height; 124 } 125 } 126 127 function handleDownKey(): void { 128 const activeSearches = 129 document.getElementsByClassName("active-fuzzy-search"); 130 if (activeSearches !== null && activeSearches.length !== 1) { 131 return; 132 } 133 const activeSearch = activeSearches[0]; 134 if (activeSearch.tagName !== "UL" || 135 activeSearch.childElementCount === 0) { 136 return; 137 } 138 139 const selectedJobs = activeSearch.getElementsByClassName("job-selected"); 140 if (selectedJobs.length > 1) { 141 return; 142 } 143 if (selectedJobs.length === 0) { 144 // If no job selected, select the first one that visible in the list. 145 const jobs = Array.from(activeSearch.children) 146 .filter((elChild) => { 147 const childRect = elChild.getBoundingClientRect(); 148 const listRect = activeSearch.getBoundingClientRect(); 149 return childRect.top >= listRect.top && 150 (childRect.top < listRect.top + listRect.height); 151 }); 152 if (jobs.length === 0) { 153 return; 154 } 155 jobs[0].classList.add("job-selected"); 156 return; 157 } 158 const selectedJob = selectedJobs[0] ; 159 const nextSibling = selectedJob.nextElementSibling; 160 if (!nextSibling) { 161 return; 162 } 163 164 selectedJob.classList.remove("job-selected"); 165 nextSibling.classList.add("job-selected"); 166 adjustScroll(nextSibling); 167 } 168 169 function handleUpKey(): void { 170 const activeSearches = 171 document.getElementsByClassName("active-fuzzy-search"); 172 if (activeSearches && activeSearches.length !== 1) { 173 return; 174 } 175 const activeSearch = activeSearches[0]; 176 if (activeSearch.tagName !== "UL" || 177 activeSearch.childElementCount === 0) { 178 return; 179 } 180 181 const selectedJobs = activeSearch.getElementsByClassName("job-selected"); 182 if (selectedJobs.length !== 1) { 183 return; 184 } 185 186 const selectedJob = selectedJobs[0] ; 187 const previousSibling = selectedJob.previousElementSibling; 188 if (!previousSibling) { 189 return; 190 } 191 192 selectedJob.classList.remove("job-selected"); 193 previousSibling.classList.add("job-selected"); 194 adjustScroll(previousSibling); 195 } 196 197 window.onload = (): void => { 198 const topNavigator = document.getElementById("top-navigator")!; 199 let navigatorTimeOut: any; 200 const main = document.querySelector("main")! ; 201 main.onscroll = () => { 202 topNavigator.classList.add("hidden"); 203 if (navigatorTimeOut) { 204 clearTimeout(navigatorTimeOut); 205 } 206 navigatorTimeOut = setTimeout(() => { 207 if (main.scrollTop === 0) { 208 topNavigator.classList.add("hidden"); 209 } else if (main.scrollTop > 100) { 210 topNavigator.classList.remove("hidden"); 211 } 212 }, 100); 213 }; 214 topNavigator.onclick = () => { 215 main.scrollTop = 0; 216 }; 217 218 document.addEventListener("keydown", (event) => { 219 if (event.keyCode === 40) { 220 handleDownKey(); 221 } else if (event.keyCode === 38) { 222 handleUpKey(); 223 } 224 }); 225 // Register selection on change functions 226 const filterBox = document.getElementById("filter-box")!; 227 const options = filterBox.querySelectorAll("select")!; 228 options.forEach((opt) => { 229 opt.onchange = () => { 230 redraw(fz); 231 }; 232 }); 233 // Attach job status bar on click 234 const stateFilter = document.getElementById("state")! as HTMLSelectElement; 235 document.querySelectorAll(".job-bar-state").forEach((jb) => { 236 const state = jb.id.slice("job-bar-".length); 237 if (state === "unknown") { 238 return; 239 } 240 jb.addEventListener("click", () => { 241 stateFilter.value = state; 242 stateFilter.onchange.call(stateFilter, new Event("change")); 243 }); 244 }); 245 // Attach job histogram on click to scroll the selected build into view 246 const jobHistogram = document.getElementById("job-histogram-content") as HTMLTableSectionElement; 247 jobHistogram.addEventListener("click", (event) => { 248 const target = event.target as HTMLElement; 249 if (target == null) { 250 return; 251 } 252 if (!target.classList.contains('active')) { 253 return; 254 } 255 const row = target.dataset.sampleRow; 256 if (row == null || row.length === 0) { 257 return; 258 } 259 const rowNumber = Number(row); 260 const builds = document.getElementById("builds")!.getElementsByTagName("tbody")[0]; 261 if (builds == null || rowNumber >= builds.childNodes.length) { 262 return; 263 } 264 const targetRow = builds.childNodes[rowNumber] as HTMLTableRowElement; 265 targetRow.scrollIntoView(); 266 }); 267 window.addEventListener("popstate", () => { 268 const optsPopped = optionsForRepo(""); 269 const fzPopped = initFuzzySearch( 270 "job", 271 "job-input", 272 "job-list", 273 Object.keys(optsPopped.jobs).sort()); 274 redrawOptions(fzPopped, optsPopped); 275 redraw(fzPopped, false); 276 }); 277 // set dropdown based on options from query string 278 const opts = optionsForRepo(""); 279 const fz = initFuzzySearch( 280 "job", 281 "job-input", 282 "job-list", 283 Object.keys(opts.jobs).sort()); 284 redrawOptions(fz, opts); 285 redraw(fz); 286 }; 287 288 function displayFuzzySearchResult(el: HTMLElement, inputContainer: ClientRect | DOMRect): void { 289 el.classList.add("active-fuzzy-search"); 290 el.style.top = `${inputContainer.height - 1 }px`; 291 el.style.width = `${inputContainer.width }px`; 292 el.style.height = `${200 }px`; 293 el.style.zIndex = "9999"; 294 } 295 296 function fuzzySearch(fz: FuzzySearch, id: string, list: HTMLElement, input: HTMLInputElement): void { 297 const inputValue = input.value.trim(); 298 addOptionFuzzySearch(fz, fz.search(inputValue), id, list, input, true); 299 } 300 301 function validToken(token: number): boolean { 302 // 0-9 303 if (token >= 48 && token <= 57) { 304 return true; 305 } 306 // a-z 307 if (token >= 65 && token <= 90) { 308 return true; 309 } 310 // - and backspace 311 return token === 189 || token === 8; 312 } 313 314 function handleEnterKeyDown(fz: FuzzySearch, list: HTMLElement, input: HTMLInputElement): void { 315 const selectedJobs = list.getElementsByClassName("job-selected"); 316 if (selectedJobs && selectedJobs.length === 1) { 317 input.value = (selectedJobs[0] as HTMLElement).innerHTML; 318 } 319 // TODO(@qhuynh96): according to discussion in https://github.com/kubernetes/test-infra/pull/7165, the 320 // fuzzy search should respect user input no matter it is in the list or not. User may 321 // experience being redirected back to default view if the search input is invalid. 322 input.blur(); 323 list.classList.remove("active-fuzzy-search"); 324 redraw(fz); 325 } 326 327 function registerFuzzySearchHandler(fz: FuzzySearch, id: string, list: HTMLElement, input: HTMLInputElement): void { 328 input.addEventListener("keydown", (event) => { 329 if (event.keyCode === 13) { 330 handleEnterKeyDown(fz, list, input); 331 } else if (validToken(event.keyCode)) { 332 // Delay 1 frame that the input character is recorded before getting 333 // input value 334 setTimeout(() => fuzzySearch(fz, id, list, input), 32); 335 } 336 }); 337 } 338 339 function initFuzzySearch(id: string, inputId: string, listId: string, 340 data: string[]): FuzzySearch { 341 const fz = new FuzzySearch(data); 342 const el = document.getElementById(id)!; 343 const input = document.getElementById(inputId)! as HTMLInputElement; 344 const list = document.getElementById(listId)!; 345 346 list.classList.remove("active-fuzzy-search"); 347 input.addEventListener("focus", () => { 348 fuzzySearch(fz, id, list, input); 349 displayFuzzySearchResult(list, el.getBoundingClientRect()); 350 }); 351 input.addEventListener("blur", () => list.classList.remove("active-fuzzy-search")); 352 353 registerFuzzySearchHandler(fz, id, list, input); 354 return fz; 355 } 356 357 function registerJobResultEventHandler(fz: FuzzySearch, li: HTMLElement, input: HTMLInputElement) { 358 li.addEventListener("mousedown", (event) => { 359 input.value = (event.currentTarget as HTMLElement).innerHTML; 360 redraw(fz); 361 }); 362 li.addEventListener("mouseover", (event) => { 363 const selectedJobs = document.getElementsByClassName("job-selected"); 364 if (!selectedJobs) { 365 return; 366 } 367 368 for (const job of Array.from(selectedJobs)) { 369 job.classList.remove("job-selected"); 370 } 371 (event.currentTarget as HTMLElement).classList.add("job-selected"); 372 }); 373 li.addEventListener("mouseout", (event) => { 374 (event.currentTarget as HTMLElement).classList.remove("job-selected"); 375 }); 376 } 377 378 function addOptionFuzzySearch(fz: FuzzySearch, data: string[], id: string, 379 list: HTMLElement, input: HTMLInputElement, 380 stopAutoFill?: boolean): void { 381 if (!stopAutoFill) { 382 input.value = getParameterByName(id) || ''; 383 } 384 while (list.firstChild) { 385 list.removeChild(list.firstChild); 386 } 387 list.scrollTop = 0; 388 for (const datum of data) { 389 const li = document.createElement("li"); 390 li.innerHTML = datum; 391 registerJobResultEventHandler(fz, li, input); 392 list.appendChild(li); 393 } 394 } 395 396 function addOptions(options: string[], selectID: string): string | undefined { 397 const sel = document.getElementById(selectID)! as HTMLSelectElement; 398 while (sel.length > 1) { 399 sel.removeChild(sel.lastChild); 400 } 401 const param = getParameterByName(selectID); 402 for (const option of options) { 403 const o = document.createElement("option"); 404 o.text = option; 405 if (param && option === param) { 406 o.selected = true; 407 } 408 sel.appendChild(o); 409 } 410 return param; 411 } 412 413 function selectionText(sel: HTMLSelectElement): string { 414 return sel.selectedIndex === 0 ? "" : sel.options[sel.selectedIndex].text; 415 } 416 417 function equalSelected(sel: string, t: string): boolean { 418 return sel === "" || sel === t; 419 } 420 421 function groupKey(build: ProwJob): string { 422 const {refs: {repo = "", pulls = [], base_ref = "", base_sha = ""} = {}} = build.spec; 423 const pr = pulls.length ? pulls[0].number : 0; 424 return `${repo} ${pr} ${genLongRefKey(base_ref, base_sha, pulls)}`; 425 } 426 427 // escapeRegexLiteral ensures the given string is escaped so that it is treated as 428 // an exact value when used within a RegExp. This is the standard substitution recommended 429 // by https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions. 430 function escapeRegexLiteral(s: string): string { 431 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 432 } 433 434 function redraw(fz: FuzzySearch, pushState = true): void { 435 const rerunStatus = getParameterByName("rerun"); 436 const modal = document.getElementById('rerun')!; 437 const modalContent = document.querySelector('.modal-content')!; 438 const builds = document.getElementById("builds")!.getElementsByTagName( 439 "tbody")[0]; 440 while (builds.firstChild) { 441 builds.removeChild(builds.firstChild); 442 } 443 444 const args: string[] = []; 445 446 function getSelection(name: string): string { 447 const sel = selectionText(document.getElementById(name) as HTMLSelectElement); 448 if (sel && name !== 'repo' && !opts[`${name }s` as keyof RepoOptions][sel]) { 449 return ""; 450 } 451 if (sel !== "") { 452 args.push(`${name}=${encodeURIComponent(sel)}`); 453 } 454 return sel; 455 } 456 457 function getSelectionFuzzySearch(id: string, inputId: string): RegExp { 458 const input = document.getElementById(inputId) as HTMLInputElement; 459 const inputText = input.value; 460 if (inputText === "") { 461 return new RegExp(''); 462 } 463 if (inputText !== "") { 464 args.push(`${id}=${encodeURIComponent(inputText)}`); 465 } 466 if (inputText !== "" && opts[`${id }s` as keyof RepoOptions][inputText]) { 467 return new RegExp(`^${escapeRegexLiteral(inputText)}$`); 468 } 469 const expr = inputText.split('*').map(escapeRegexLiteral); 470 return new RegExp(`^${expr.join('.*')}$`); 471 } 472 473 const repoSel = getSelection("repo"); 474 const opts = optionsForRepo(repoSel); 475 476 const typeSel = getSelection("type") as ProwJobType; 477 const pullSel = getSelection("pull"); 478 const authorSel = getSelection("author"); 479 const jobSel = getSelectionFuzzySearch("job", "job-input"); 480 const stateSel = getSelection("state"); 481 const clusterSel = getSelection("cluster"); 482 483 if (pushState && window.history && window.history.pushState !== undefined) { 484 if (args.length > 0) { 485 history.pushState(null, "", `/?${ args.join('&')}`); 486 } else { 487 history.pushState(null, "", "/"); 488 } 489 } 490 fz.setDict(Object.keys(opts.jobs)); 491 redrawOptions(fz, opts); 492 493 let lastKey = ''; 494 const jobCountMap = new Map() as Map<ProwJobState, number>; 495 const jobInterval: [number, number][] = [[3600 * 3, 0], [3600 * 12, 0], [3600 * 48, 0]]; 496 let currentInterval = 0; 497 const jobHistogram = new JobHistogram(); 498 const now = Date.now() / 1000; 499 let totalJob = 0; 500 let displayedJob = 0; 501 502 for (let i = 0; i < allBuilds.items.length; i++) { 503 const build = allBuilds.items[i]; 504 const { 505 metadata: { 506 name: prowJobName = "", 507 }, 508 spec: { 509 cluster = "", 510 type = "", 511 job = "", 512 agent = "", 513 refs: {repo_link = "", base_sha = "", base_link = "", pulls = [], base_ref = ""} = {}, 514 pod_spec, 515 }, 516 status: {startTime, completionTime = "", state = "", pod_name, build_id = "", url = ""}, 517 } = build; 518 519 let buildUrl = url; 520 if (url.includes('/view/')) { 521 buildUrl = `${window.location.origin}/${url.slice(url.indexOf('/view/') + 1)}`; 522 } 523 524 let org = ""; 525 let repo = ""; 526 if (build.spec.refs !== undefined) { 527 org = build.spec.refs.org; 528 repo = build.spec.refs.repo; 529 } else if (build.spec.extra_refs !== undefined && build.spec.extra_refs.length > 0 ) { 530 org = build.spec.extra_refs[0].org; 531 repo = build.spec.extra_refs[0].repo; 532 } 533 534 if (!equalSelected(typeSel, type)) { 535 continue; 536 } 537 if (!equalSelected(repoSel, `${org}/${repo}`)) { 538 continue; 539 } 540 if (!equalSelected(stateSel, state)) { 541 continue; 542 } 543 if (!equalSelected(clusterSel, cluster)) { 544 continue; 545 } 546 if (!jobSel.test(job)) { 547 continue; 548 } 549 550 if (pullSel) { 551 if (!pulls.length) { 552 continue; 553 } 554 555 if (!pulls.some((pull: Pull): boolean => { 556 const {number: prNumber} = pull; 557 return equalSelected(pullSel, prNumber.toString()); 558 })) { 559 continue; 560 } 561 } 562 563 if (authorSel) { 564 if (!pulls.length) { 565 continue; 566 } 567 568 if (!pulls.some((pull: Pull): boolean => { 569 const {author} = pull; 570 return equalSelected(authorSel, author); 571 })) { 572 continue; 573 } 574 } 575 576 totalJob++; 577 jobCountMap.set(state, (jobCountMap.get(state) || 0) + 1); 578 const dashCell = "-"; 579 580 // accumulate a count of the percentage of successful jobs over each interval 581 const started = Date.parse(startTime) / 1000; 582 const finished = Date.parse(completionTime) / 1000; 583 584 const durationSec = completionTime ? finished - started : 0; 585 const durationStr = completionTime ? formatDuration(durationSec) : dashCell; 586 587 if (currentInterval >= 0 && (now - started) > jobInterval[currentInterval][0]) { 588 const successCount = jobCountMap.get("success") || 0; 589 const failureCount = jobCountMap.get("failure") || 0; 590 591 const total = successCount + failureCount; 592 if (total > 0) { 593 jobInterval[currentInterval][1] = successCount / total; 594 } else { 595 jobInterval[currentInterval][1] = 0; 596 } 597 currentInterval++; 598 if (currentInterval >= jobInterval.length) { 599 currentInterval = -1; 600 } 601 } 602 603 if (displayedJob >= 500) { 604 jobHistogram.add(new JobSample(started, durationSec, state, -1)); 605 continue; 606 } else { 607 jobHistogram.add(new JobSample(started, durationSec, state, builds.childElementCount)); 608 } 609 displayedJob++; 610 const r = document.createElement("tr"); 611 // State column 612 r.appendChild(cell.state(state)); 613 // Log column 614 r.appendChild(createLogCell(build, buildUrl)); 615 // Rerun column 616 r.appendChild(createRerunCell(modal, modalContent, prowJobName)); 617 // Abort column 618 r.appendChild(createAbortCell(modal, modalContent, job, state, prowJobName)); 619 // Job Yaml column 620 r.appendChild(createViewJobCell(prowJobName)); 621 // Repository column 622 const key = groupKey(build); 623 if (key !== lastKey) { 624 // This is a different PR or commit than the previous row. 625 lastKey = key; 626 r.className = "changed"; 627 628 if (type === "periodic") { 629 r.appendChild(cell.text(dashCell)); 630 } else { 631 let repoLink = repo_link; 632 if (!repoLink) { 633 repoLink = `/github-link?dest=${org}/${repo}`; 634 } 635 r.appendChild(cell.link(`${org}/${repo}`, repoLink)); 636 } 637 if (type === "presubmit") { 638 if (pulls.length) { 639 r.appendChild(cell.prRevision(`${org}/${repo}`, pulls[0])); 640 } else { 641 r.appendChild(cell.text(dashCell)); 642 } 643 } else if (type === "batch") { 644 r.appendChild(batchRevisionCell(build)); 645 } else if (type === "postsubmit") { 646 r.appendChild(cell.commitRevision(`${org}/${repo}`, base_ref, base_sha, base_link)); 647 } else if (type === "periodic") { 648 r.appendChild(cell.text(dashCell)); 649 } 650 } else { 651 // Don't render identical cells for the same PR/commit. 652 r.appendChild(cell.text(dashCell)); 653 r.appendChild(cell.text(dashCell)); 654 } 655 if (spyglass) { 656 // this logic exists for legacy jobs that are configured for gubernator compatibility 657 const buildIndex = buildUrl.indexOf('/build/'); 658 if (buildIndex !== -1) { 659 const gcsUrl = `${window.location.origin}/view/gcs/${buildUrl.substring(buildIndex + '/build/'.length)}`; 660 r.appendChild(createSpyglassCell(gcsUrl)); 661 } else if (buildUrl.includes('/view/')) { 662 r.appendChild(createSpyglassCell(buildUrl)); 663 } else { 664 r.appendChild(cell.text('')); 665 } 666 } else { 667 r.appendChild(cell.text('')); 668 } 669 // Results column 670 if (buildUrl === "") { 671 r.appendChild(cell.text(job)); 672 } else { 673 r.appendChild(cell.link(job, buildUrl)); 674 } 675 // Started column 676 r.appendChild(cell.time(i.toString(), moment.unix(started))); 677 // Duration column 678 r.appendChild(cell.text(durationStr)); 679 builds.appendChild(r); 680 } 681 682 // fill out the remaining intervals if necessary 683 if (currentInterval !== -1) { 684 let successCount = jobCountMap.get("success"); 685 if (!successCount) { 686 successCount = 0; 687 } 688 let failureCount = jobCountMap.get("failure"); 689 if (!failureCount) { 690 failureCount = 0; 691 } 692 const total = successCount + failureCount; 693 for (let i = currentInterval; i < jobInterval.length; i++) { 694 if (total > 0) { 695 jobInterval[i][1] = successCount / total; 696 } else { 697 jobInterval[i][1] = 0; 698 } 699 } 700 } 701 702 const jobSummary = document.getElementById("job-histogram-summary")!; 703 const success = jobInterval.map((interval) => { 704 if (interval[1] < 0.5) { 705 return `${formatDuration(interval[0])}: <span class="state failure">${Math.ceil(interval[1] * 100)}%</span>`; 706 } 707 return `${formatDuration(interval[0])}: <span class="state success">${Math.ceil(interval[1] * 100)}%</span>`; 708 }).join(", "); 709 jobSummary.innerHTML = `Success rate over time: ${success}`; 710 const jobCount = document.getElementById("job-count")!; 711 jobCount.textContent = `Showing ${displayedJob}/${totalJob} jobs`; 712 drawJobBar(totalJob, jobCountMap); 713 714 // if we aren't filtering the output, cap the histogram y axis to 2 hours because it 715 // contains the bulk of our jobs 716 let max = Number.MAX_SAFE_INTEGER; 717 if (totalJob === allBuilds.items.length) { 718 max = 2 * 3600; 719 } 720 drawJobHistogram(totalJob, jobHistogram, now - (12 * 3600), now, max); 721 if (rerunStatus === "gh_redirect") { 722 modal.style.display = "block"; 723 modalContent.innerHTML = "Rerunning that job requires GitHub login. Now that you're logged in, try again"; 724 } 725 // we need to upgrade DOM for new created dynamic elements 726 // see https://getmdl.io/started/index.html#dynamic 727 componentHandler.upgradeDom(); 728 } 729 730 function createAbortCell(modal: HTMLElement, modalContent: Element, job: string, state: ProwJobState, prowjob: string): HTMLTableCellElement { 731 const c = document.createElement("td"); 732 c.appendChild(createAbortProwJobIcon(modal, modalContent, job, state, prowjob, csrfToken)); 733 return c; 734 } 735 736 function createRerunCell(modal: HTMLElement, rerunElement: Element, prowjob: string): HTMLTableDataCellElement { 737 const c = document.createElement("td"); 738 c.appendChild(createRerunProwJobIcon(modal, rerunElement, prowjob, rerunCreatesJob, csrfToken)); 739 return c; 740 } 741 742 function createLogCell(build: ProwJob, buildUrl: string): HTMLTableDataCellElement { 743 const { agent, job, pod_spec } = build.spec; 744 const { pod_name, build_id } = build.status; 745 746 if ((agent === "kubernetes" && pod_name) || agent !== "kubernetes") { 747 const logIcon = icon.create("description", "Build log"); 748 if (pod_spec == null || pod_spec.containers.length <= 1) { 749 logIcon.href = `log?job=${job}&id=${build_id}`; 750 } else { 751 // this logic exists for legacy jobs that are configured for gubernator compatibility 752 const buildIndex = buildUrl.indexOf('/build/'); 753 if (buildIndex !== -1) { 754 const gcsUrl = `${window.location.origin}/view/gcs/${buildUrl.substring(buildIndex + '/build/'.length)}`; 755 logIcon.href = gcsUrl; 756 } else if (buildUrl.includes('/view/')) { 757 logIcon.href = buildUrl; 758 } else { 759 logIcon.href = `log?job=${job}&id=${build_id}`; 760 } 761 } 762 const c = document.createElement("td"); 763 c.appendChild(logIcon); 764 return c; 765 } 766 return cell.text(""); 767 } 768 769 function createViewJobCell(prowjob: string): HTMLTableDataCellElement { 770 const c = document.createElement("td"); 771 const i = icon.create("pageview", "Show job YAML", () => gtag("event", "view_job_yaml", {event_category: "engagement", transport_type: "beacon"})); 772 i.href = `/prowjob?prowjob=${prowjob}`; 773 c.appendChild(i); 774 return c; 775 } 776 777 function batchRevisionCell(build: ProwJob): HTMLTableDataCellElement { 778 const {refs: {org = "", repo = "", pulls = []} = {}} = build.spec; 779 780 const c = document.createElement("td"); 781 if (!pulls.length) { 782 return c; 783 } 784 for (let i = 0; i < pulls.length; i++) { 785 if (i !== 0) { 786 c.appendChild(document.createElement("br")); 787 } 788 cell.addPRRevision(c, `${org}/${repo}`, pulls[i]); 789 } 790 return c; 791 } 792 793 function drawJobBar(total: number, jobCountMap: Map<ProwJobState, number>): void { 794 const states: ProwJobState[] = ["success", "pending", "triggered", "error", "failure", "aborted", ""]; 795 states.sort((s1, s2) => { 796 return jobCountMap.get(s1)! - jobCountMap.get(s2)!; 797 }); 798 states.forEach((state, index) => { 799 const count = jobCountMap.get(state); 800 // If state is undefined or empty, treats it as unknown state. 801 if (!state) { 802 state = "unknown"; 803 } 804 const id = `job-bar-${ state}`; 805 const el = document.getElementById(id)!; 806 const tt = document.getElementById(`${state }-tooltip`)!; 807 if (!count || count === 0 || total === 0) { 808 el.textContent = ""; 809 tt.textContent = ""; 810 el.style.width = "0"; 811 } else { 812 el.textContent = count.toString(); 813 tt.textContent = `${count} ${stateToAdj(state)} jobs`; 814 if (index === states.length - 1) { 815 el.style.width = "auto"; 816 } else { 817 el.style.width = `${Math.max((count / total * 100), 1) }%`; 818 } 819 } 820 }); 821 } 822 823 function stateToAdj(state: ProwJobState): string { 824 switch (state) { 825 case "success": 826 return "succeeded"; 827 case "failure": 828 return "failed"; 829 default: 830 return state; 831 } 832 } 833 834 function drawJobHistogram(total: number, jobHistogram: JobHistogram, start: number, end: number, maximum: number): void { 835 const startEl = document.getElementById("job-histogram-start") as HTMLSpanElement; 836 if (startEl != null) { 837 startEl.textContent = `${formatDuration(end - start)} ago`; 838 } 839 840 // make sure the empty table is hidden 841 const tableEl = document.getElementById("job-histogram") as HTMLTableElement; 842 const labelsEl = document.getElementById("job-histogram-labels") as HTMLDivElement; 843 if (jobHistogram.length === 0) { 844 tableEl.style.display = "none"; 845 labelsEl.style.display = "none"; 846 return; 847 } 848 tableEl.style.display = ""; 849 labelsEl.style.display = ""; 850 851 const el = document.getElementById("job-histogram-content") as HTMLTableSectionElement; 852 el.title = `Showing ${jobHistogram.length} builds from last ${formatDuration(end - start)} by start time and duration, newest to oldest.`; 853 const rows = 10; 854 const width = 12; 855 const cols = Math.round(el.clientWidth / width); 856 857 // initialize the table if the row count changes 858 if (el.childNodes.length !== rows) { 859 el.innerHTML = ""; 860 for (let i = 0; i < rows; i++) { 861 const tr = document.createElement('tr'); 862 for (let j = 0; j < cols; j++) { 863 const td = document.createElement('td'); 864 tr.appendChild(td); 865 } 866 el.appendChild(tr); 867 } 868 } 869 870 const buckets = jobHistogram.buckets(start, end, cols); 871 buckets.limitMaximum(maximum); 872 873 // show the max and mid y-axis labels rounded up to the nearest 10 minute mark 874 let maxY = buckets.max; 875 maxY = Math.ceil(maxY / 600); 876 const yMax = document.getElementById("job-histogram-labels-y-max") as HTMLSpanElement; 877 yMax.innerText = `${formatDuration(maxY * 600)}+`; 878 const yMid = document.getElementById("job-histogram-labels-y-mid") as HTMLSpanElement; 879 yMid.innerText = `${formatDuration(maxY / 2 * 600)}`; 880 881 // populate the buckets 882 buckets.data.forEach((bucket, colIndex) => { 883 let lastRowIndex = 0; 884 buckets.linearChunks(bucket, rows).forEach((samples, rowIndex) => { 885 lastRowIndex = rowIndex + 1; 886 const td = el.childNodes[rows - 1 - rowIndex].childNodes[cols - colIndex - 1] as HTMLTableCellElement; 887 if (samples.length === 0) { 888 td.removeAttribute('title'); 889 td.className = ''; 890 return; 891 } 892 td.dataset.sampleRow = String(samples[0].row); 893 const failures = samples.reduce((sum, sample) => { 894 return sample.state !== 'success' ? sum + 1 : sum; 895 }, 0); 896 if (failures === 0) { 897 td.title = `${samples.length} succeeded`; 898 } else { 899 if (failures === samples.length) { 900 td.title = `${failures} failed`; 901 } else { 902 td.title = `${failures}/${samples.length} failed`; 903 } 904 } 905 td.style.opacity = String(0.2 + samples.length / bucket.length * 0.8); 906 if (samples[0].row !== -1) { 907 td.className = `active success-${Math.floor(10 - (failures / samples.length) * 10)}`; 908 } else { 909 td.className = `success-${Math.floor(10 - (failures / samples.length) * 10)}`; 910 } 911 }); 912 for (let rowIndex = lastRowIndex; rowIndex < rows; rowIndex++) { 913 const td = el.childNodes[rows - 1 - rowIndex].childNodes[cols - colIndex - 1] as HTMLTableCellElement; 914 td.removeAttribute('title'); 915 td.className = ''; 916 } 917 }); 918 } 919 920 function createSpyglassCell(url: string): HTMLTableDataCellElement { 921 const i = icon.create('visibility', 'View in Spyglass'); 922 i.href = url; 923 const c = document.createElement('td'); 924 c.appendChild(i); 925 return c; 926 }