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 });