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  }