sigs.k8s.io/prow@v0.0.0-20240503223140-c5e374dc7eb1/pkg/spyglass/lenses/buildlog/buildlog.ts (about)

     1  function showElem(elem: HTMLElement): void {
     2    elem.className = 'shown';
     3    elem.innerHTML = ansiToHTML(elem.innerHTML);
     4  }
     5  
     6  // given a string containing ansi formatting directives, return a new one
     7  // with designated regions of text marked with the appropriate color directives,
     8  // and with all unknown directives stripped
     9  function ansiToHTML(orig: string): string {
    10    // Given a cmd (like "32" or "0;97"), some enclosed body text, and the original string,
    11    // either return the body wrapped in an element to achieve the desired result, or the
    12    // original string if nothing works.
    13    function annotate(cmd: string, body: string): string {
    14      const code = +(cmd.replace('0;', ''));
    15      if (code === 0) {
    16        // reset
    17        return body;
    18      } else if (code === 1) {
    19        // bold
    20        return `<strong>${body}</strong>`;
    21      } else if (code === 3) {
    22        // italic
    23        return `<em>${body}</em>`;
    24      } else if (30 <= code && code <= 37) {
    25        // foreground color
    26        return `<span class="ansi-${(code - 30)}">${body}</span>`;
    27      } else if (90 <= code && code <= 97) {
    28        // foreground color, bright
    29        return `<span class="ansi-${(code - 90 + 8)}">${body}</span>`;
    30      }
    31      return body;  // fallback: don't change anything
    32    }
    33    // Find commands, optionally followed by a bold command, with some content, then a reset command.
    34    // Unpaired commands are *not* handled here, but they're very uncommon.
    35    const filtered = orig.replace(/\033\[([0-9;]*)\w(\033\[1m)?([^\033]*?)\033\[0m/g, (match: string, cmd: string, bold: string, body: string, offset: number, str: string) => {
    36      if (bold !== undefined) {
    37        // normal code + bold
    38        return `<strong>${annotate(cmd, body)}</strong>`;
    39      }
    40      return annotate(cmd, body);
    41    });
    42    // Strip out anything left over.
    43    return filtered.replace(/\033\[([0-9;]*\w)/g, (match: string, cmd: string, offset: number, str: string) => {
    44      console.log('unhandled ansi code: ', cmd, "context:", filtered);
    45      return '';
    46    });
    47  }
    48  
    49  interface ArtifactRequest {
    50    artifact: string;
    51    bottom?: number;
    52    length?: number;
    53    offset?: number;
    54    startLine: number;
    55    top?: number;
    56    saveEnd?: number;
    57  }
    58  
    59  async function replaceElementWithContent(element: HTMLDivElement, top: number, bottom: number) {
    60  
    61    // <div data-foo="1" data-bar="this"> will show up as element.dataset = {"foo": "1", "bar": "this"}
    62    const {artifact, offset, length, startLine} = element.dataset;
    63  
    64    // length! => we know these values are non-null:
    65    // - we know this because its tightly coupled with template.html
    66    // TODO(fejta): consider more robust code, looser coupling.
    67    const r: ArtifactRequest = {
    68      artifact,
    69      bottom,
    70      length: Number(length),
    71      offset: Number(offset),
    72      startLine: Number(startLine),
    73      top,
    74    };
    75    const content = await spyglass.request(JSON.stringify(r));
    76    showElem(element);
    77    element.outerHTML = ansiToHTML(content);
    78    fixLinks(document.documentElement);
    79    for (const button of Array.from(document.querySelectorAll<HTMLDivElement>(".show-skipped"))) {
    80      if (button.classList.contains("showable")) {
    81        continue;
    82      }
    83      button.addEventListener('click', handleShowSkipped);
    84      button.classList.add("showable");
    85    }
    86  
    87    for (const button of Array.from(document.querySelectorAll<HTMLDivElement>(".show-skipped"))) {
    88      button.addEventListener('click', handleShowSkipped);
    89    }
    90  
    91    // Remove the "show all" button if we no longer need it.
    92    // TODO(fejta): avoid id selectors: https://google.github.io/styleguide/htmlcssguide.html#ID_Selectors
    93    const log = document.getElementById(`${r.artifact}-content`)!;
    94    const skipped = log.querySelectorAll<HTMLElement>(".show-skipped");
    95    if (skipped.length === 0) {
    96      const button = document.querySelector('button.show-all-button')!;
    97      button.parentNode.removeChild(button);
    98    }
    99    spyglass.contentUpdated();
   100  }
   101  
   102  async function handleShowSkipped(this: HTMLDivElement, e: MouseEvent): Promise<void> {
   103    // Don't do anything unless they actually clicked the button.
   104    let target: HTMLButtonElement;
   105    if (!(e.target instanceof HTMLButtonElement)) {
   106      if (e.target instanceof Node && e.target.parentElement instanceof HTMLButtonElement) {
   107        target = e.target.parentElement;
   108      } else {
   109        return;
   110      }
   111    } else {
   112      target = e.target;
   113    }
   114  
   115    const classes: DOMTokenList = target.classList;
   116    let top = 0;
   117    let bottom = 0;
   118    if (classes.contains("top")) {
   119      top = 10;
   120    }
   121    if (classes.contains("bottom")) {
   122      bottom = 10;
   123    }
   124  
   125    for (const mod of [e.altKey, e.metaKey, e.ctrlKey, e.shiftKey]) {
   126      if (!mod) {
   127        continue;
   128      }
   129      bottom *= 10;
   130      top *= 10;
   131    }
   132  
   133    await replaceElementWithContent(this, top, bottom);
   134  }
   135  
   136  async function handleShowAll(this: HTMLButtonElement) {
   137    // Remove ourselves immediately.
   138    if (this.parentElement) {
   139      this.parentElement.removeChild(this);
   140    }
   141  
   142    const {artifact} = this.dataset;
   143    const content = await spyglass.request(JSON.stringify({artifact, offset: 0, length: -1}));
   144    document.getElementById(`${artifact}-content`)!.innerHTML = `<tbody class="shown">${ansiToHTML(content)}</tbody>`;
   145    spyglass.contentUpdated();
   146  }
   147  
   148  async function handleAnalyze(this: HTMLButtonElement) {
   149    this.disabled = true;
   150    this.title = "Requesting analysis...";
   151    try {
   152      const {artifact} = this.dataset;
   153      const content = await spyglass.request(JSON.stringify({artifact, analyze: true}));
   154      interface JsonResponse {
   155        min: number;
   156        max: number;
   157        pinned: boolean;
   158        error: string;
   159      }
   160      const result: JsonResponse = JSON.parse(content);
   161      if (result.error) {
   162        this.title = `Analysis failed: ${  result.error}`;
   163        console.log("Failed to analyze", result.error);
   164        return;
   165      }
   166      this.title = `Analysis returned lines ${result.min}-${result.max}`;
   167      await focusLines(artifact, result.min, result.max, this.parentElement);
   168      if (!result.pinned) {
   169        location.hash = `#${artifact}:${result.min}-${result.max}`;
   170      } else {
   171        location.hash = "";
   172      }
   173    } catch (err) {
   174      this.title = `Analysis failed: ${  err}`;
   175    } finally {
   176      this.textContent = "Reanalyze";
   177      this.disabled = false;
   178    }
   179  }
   180  
   181  async function focusLines(artifact: string, startNum: number, endNum: number, selector: Selector|null): Promise<void> {
   182    const firstEl = await highlightLines(artifact, startNum, endNum, 'focus-line', selector);
   183    if (!firstEl) {
   184      return;
   185    }
   186    clipLine(firstEl);
   187    scrollTo(firstEl);
   188  }
   189  
   190  async function handlePin(e: MouseEvent) {
   191    const result = parseHash();
   192    if (!result) {
   193      return;
   194    }
   195    const [artifact, start, end] = result;
   196    const r: ArtifactRequest = {
   197      artifact,
   198      saveEnd: end,
   199      startLine: start,
   200    };
   201    const content = await spyglass.request(JSON.stringify(r));
   202    if (content !== "") {
   203      console.log("Failed to pin lines", content, r);
   204      return;
   205    }
   206    const button = document.getElementById("annotate-pin");
   207    if (button) {
   208      // TODO(fejta): class on great grandparent and/or data- on pin to make this more efficient
   209      await focusLines(artifact, start, end, button.parentElement.parentElement.parentElement);
   210    }
   211    location.hash = "";
   212  
   213    clearHighlightedLines('highlighted-line', document);
   214  }
   215  
   216  function handleLineLink(e: MouseEvent): void {
   217    if (!e.target) {
   218      return;
   219    }
   220    const el = e.target as HTMLElement;
   221    if (!el.dataset.lineNumber) {
   222      return;
   223    }
   224    const multiple = e.shiftKey;
   225    const goal = Number(el.dataset.lineNumber);
   226    if (isNaN(goal)) {
   227      return;
   228    }
   229    let result = parseHash();
   230    if (result === null || !multiple) {
   231      result = ["", goal, goal];
   232    }
   233    let startNum = result[1];
   234    let endNum = result[2];
   235    if (goal > startNum) {
   236      endNum = goal;
   237    } else {
   238      [startNum, endNum] = [goal, startNum];
   239    }
   240    if (endNum !== startNum) {
   241      location.hash = `#${el.dataset.artifact}:${startNum}-${endNum}`;
   242    } else {
   243      location.hash = `#${el.dataset.artifact}:${startNum}`;
   244    }
   245    e.preventDefault();
   246  }
   247  
   248  interface Selector {
   249    querySelectorAll(s: string): NodeListOf<Element>;
   250  }
   251  
   252  function clearHighlightedLines(highlight: string, selector: Selector): void {
   253    for (const oldEl of Array.from(selector.querySelectorAll(`.${highlight}`))) {
   254      oldEl.classList.remove(highlight);
   255    }
   256    const button = document.getElementById("annotate-pin");
   257    if (button) {
   258      button.remove();
   259    }
   260  }
   261  
   262  function fixLinks(parent: HTMLElement): void {
   263    const links = parent.querySelectorAll<HTMLAnchorElement>('a[data-artifact][data-line-number]');
   264    for (const link of Array.from(links)) {
   265      link.href = spyglass.makeFragmentLink(`${link.dataset.artifact}:${link.dataset.lineNumber}`);
   266    }
   267  }
   268  
   269  async function loadLine(artifact: string, line: number): Promise<boolean> {
   270    const showers = document.querySelectorAll<HTMLDivElement>(`.show-skipped[data-artifact="${artifact}"]`);
   271    for (const shower of Array.from(showers)) {
   272      if (line >= Number(shower.dataset.startLine) && line <= Number(shower.dataset.endLine)) {
   273        // TODO(fejta): could maybe do something smarter here than the whole
   274        // block.
   275        await replaceElementWithContent(shower, 0, 0);
   276        return true;
   277      }
   278    }
   279    return false;
   280  }
   281  
   282  // parseHash extracts an artifact and line range.
   283  //
   284  // Expects URL fragment to be any of the following forms:
   285  // * <empty>
   286  // * single line: #artifact:5
   287  // * range of lines: #artifact:5-12.
   288  function parseHash(): [string, number, number]|null {
   289    const hash = location.hash.substr(1);
   290    const colonPos = hash.lastIndexOf(':');
   291    if (colonPos === -1) {
   292      return null;
   293    }
   294    const artifact = hash.substring(0, colonPos);
   295    const lineRange = hash.substring(colonPos + 1);
   296    const hyphenPos = lineRange.lastIndexOf('-');
   297  
   298    let startNum;
   299    let endNum;
   300  
   301    if (hyphenPos > 0 ) {
   302      startNum = Number(lineRange.substring(0, hyphenPos));
   303      endNum = Number(lineRange.substring(hyphenPos + 1));
   304    } else {
   305      startNum = Number(lineRange);
   306      endNum = startNum;
   307    }
   308    if (isNaN(startNum) || isNaN(endNum)) {
   309      return null;
   310    }
   311    if (endNum < startNum) { // ensure start has the smallest value.
   312      [startNum, endNum] = [endNum, startNum];
   313    }
   314    return [artifact, startNum, endNum];
   315  }
   316  
   317  async function handleHash(): Promise<void> {
   318    const klass = 'highlighted-line';
   319    const result = parseHash();
   320    if (!result) {
   321      clearHighlightedLines(klass, document);
   322      return;
   323    }
   324    const [artifact, startNum, endNum] = result;
   325  
   326    const firstEl = await highlightLines(artifact, startNum, endNum, klass, document);
   327  
   328    if (!firstEl) {
   329      return;
   330    }
   331  
   332    const content = document.getElementById(`${artifact}-content`);
   333    if (content && content.classList.contains("savable")) {
   334      pinLine(firstEl);
   335    }
   336    scrollTo(firstEl);
   337  }
   338  
   339  function scrollTo(elem: Element) {
   340    const top = elem.getBoundingClientRect().top + window.pageYOffset - 50;
   341    spyglass.scrollTo(0, top).then();
   342  }
   343  
   344  async function highlightLines(artifact: string, startNum: number, endNum: number, highlight = 'highlighted-line', selector: Selector|null): Promise<HTMLDivElement|null> {
   345    let firstEl: HTMLDivElement|null = null;
   346    for (let lineNum = startNum; lineNum <= endNum; lineNum++) {
   347      const lineId = `${artifact}:${lineNum}`;
   348      let lineEl = document.getElementById(lineId);
   349      if (!lineEl) {
   350        if (!await loadLine(artifact, lineNum)) {
   351          return null;
   352        }
   353        lineEl = document.getElementById(lineId);
   354        if (!lineEl) {
   355          return null;
   356        }
   357      }
   358      if (firstEl === null) {
   359        if (lineEl instanceof HTMLDivElement) {
   360          firstEl = lineEl;
   361        } else {
   362          return null;
   363        }
   364        if (selector) {
   365          clearHighlightedLines(highlight, selector);
   366        }
   367      }
   368      lineEl.classList.add(highlight);
   369    }
   370    return firstEl;
   371  }
   372  
   373  function pinLine(lineEl: HTMLDivElement) {
   374    let pin = document.getElementById("annotate-pin");
   375    if (!pin) {
   376      pin = document.createElement("button");
   377      pin.classList.add("annotate-pin");
   378      pin.title = "Pin selected lines to always display on page load";
   379      pin.id = "annotate-pin";
   380      pin.innerHTML = "<i class='material-icons'>push_pin</i>";
   381      pin.addEventListener('click', handlePin);
   382    }
   383    lineEl.insertAdjacentElement("afterbegin", pin);
   384  }
   385  
   386  function clipLine(lineEl: HTMLDivElement) {
   387    let pin = document.getElementById("focus-clip");
   388    if (!pin) {
   389      pin = document.createElement("button");
   390      pin.classList.add("focus-clip");
   391      pin.id = "focus-clip";
   392      pin.innerHTML = "<i class='material-icons'>attachment</i>";
   393    }
   394    lineEl.insertAdjacentElement("afterbegin", pin);
   395  }
   396  
   397  window.addEventListener('hashchange', () => handleHash());
   398  
   399  window.addEventListener('load', () => {
   400    const shown = document.getElementsByClassName("shown");
   401    for (const child of Array.from(shown)) {
   402      child.innerHTML = ansiToHTML(child.innerHTML);
   403    }
   404  
   405    for (const button of Array.from(document.querySelectorAll<HTMLDivElement>(".show-skipped"))) {
   406      button.addEventListener('click', handleShowSkipped);
   407      button.classList.add("showable");
   408    }
   409  
   410    for (const button of Array.from(document.querySelectorAll<HTMLButtonElement>(".show-all-button"))) {
   411      button.addEventListener('click', handleShowAll);
   412    }
   413  
   414    for (const button of Array.from(document.querySelectorAll<HTMLButtonElement>(".analyze-button"))) {
   415      button.addEventListener('click', handleAnalyze);
   416    }
   417  
   418    for (const container of Array.from(document.querySelectorAll<HTMLElement>('.loglines'))) {
   419      container.addEventListener('click', handleLineLink, {capture: true});
   420    }
   421    fixLinks(document.documentElement);
   422  
   423    handleHash();
   424  });