github.com/tilt-dev/tilt@v0.33.15-0.20240515162809-0a22ed45d8a0/web/src/OverviewLogPane.tsx (about) 1 import { History } from "history" 2 import React, { Component } from "react" 3 import { useHistory, useLocation } from "react-router" 4 import styled, { keyframes } from "styled-components" 5 import { 6 FilterLevel, 7 FilterSet, 8 filterSetsEqual, 9 FilterSource, 10 TermState, 11 } from "./logfilters" 12 import "./LogLine.scss" 13 import "./LogPane.scss" 14 import LogStore, { 15 LogUpdateAction, 16 LogUpdateEvent, 17 useLogStore, 18 } from "./LogStore" 19 import PathBuilder, { usePathBuilder } from "./PathBuilder" 20 import { RafContext, useRaf } from "./raf" 21 import { useStarredResources } from "./StarredResourcesContext" 22 import { Color, FontSize, SizeUnit } from "./style-helpers" 23 import Anser from "./third-party/anser/index.js" 24 import { LogLevel, LogLine, ResourceName } from "./types" 25 26 // The number of lines to display before an error. 27 export const PROLOGUE_LENGTH = 5 28 29 type OverviewLogComponentProps = { 30 manifestName: string 31 pathBuilder: PathBuilder 32 logStore: LogStore 33 raf: RafContext 34 filterSet: FilterSet 35 history: History 36 scrollToStoredLineIndex: number | null 37 starredResources: string[] 38 } 39 40 let LogPaneRoot = styled.section` 41 padding: 0 0 ${SizeUnit(0.25)} 0; 42 background-color: ${Color.gray10}; 43 width: 100%; 44 height: 100%; 45 overflow-y: auto; 46 box-sizing: border-box; 47 font-size: ${FontSize.smallest}; 48 ` 49 50 const blink = keyframes` 51 0% { 52 opacity: 1; 53 } 54 50% { 55 opacity: 0; 56 } 57 100% { 58 opacity: 1; 59 } 60 ` 61 62 let LogEnd = styled.div` 63 animation: ${blink} 1s infinite; 64 animation-timing-function: ease; 65 padding-top: ${SizeUnit(0.25)}; 66 padding-left: ${SizeUnit(0.625)}; 67 font-size: var(--log-font-scale); 68 ` 69 70 let anser = new Anser() 71 72 function newLineEl( 73 line: LogLine, 74 showManifestPrefix: boolean, 75 extraClasses: string[] 76 ): Element { 77 let text = line.text 78 let level = line.level 79 let buildEvent = line.buildEvent 80 let classes = ["LogLine"] 81 classes.push(...extraClasses) 82 if (level === "WARN") { 83 classes.push("is-warning") 84 } else if (level === "ERROR") { 85 classes.push("is-error") 86 } 87 if (buildEvent === "init") { 88 classes.push("is-buildEvent") 89 classes.push("is-buildEvent-init") 90 91 if (showManifestPrefix) { 92 // For build event lines, we put the manifest name is a suffix 93 // rather than a prefix, because it looks nicer. 94 text += ` • ${line.manifestName}` 95 } else { 96 // If we're viewing a single resource, we should make the build event log 97 // lines sticky, so that we always know context of the current logs. 98 classes.push("is-sticky") 99 } 100 } 101 if (buildEvent === "fallback") { 102 classes.push("is-buildEvent") 103 classes.push("is-buildEvent-fallback") 104 } 105 let span = document.createElement("span") 106 span.setAttribute("data-sl-index", String(line.storedLineIndex)) 107 span.classList.add(...classes) 108 109 if (showManifestPrefix && buildEvent !== "init") { 110 let prefix = document.createElement("span") 111 let name = line.manifestName 112 if (!name) { 113 name = "(global)" 114 } 115 prefix.title = name 116 prefix.className = "logLinePrefix" 117 prefix.innerHTML = anser.escapeForHtml(name) 118 span.appendChild(prefix) 119 } 120 121 let code = document.createElement("code") 122 code.classList.add("LogLine-content") 123 124 // newline ensures this takes up at least one line 125 let spacer = "\n" 126 code.innerHTML = anser.linkify( 127 anser.ansiToHtml(anser.escapeForHtml(text) + spacer, { 128 // Let anser colorize the html as it appears from various consoles 129 use_classes: false, 130 }) 131 ) 132 span.appendChild(code) 133 return span 134 } 135 136 // An index of lines such that lets us find: 137 // - The next line 138 // - The previous line 139 // - The line by stored line index. 140 type LineHashListEntry = { 141 prev?: LineHashListEntry | null 142 next?: LineHashListEntry | null 143 line: LogLine 144 el?: Element 145 } 146 147 class LineHashList { 148 private last: LineHashListEntry | null = null 149 private byStoredLineIndex: { [key: number]: LineHashListEntry } = {} 150 151 lookup(line: LogLine): LineHashListEntry | null { 152 return this.byStoredLineIndex[line.storedLineIndex] 153 } 154 155 lookupByStoredLineIndex(storedLineIndex: number): LineHashListEntry | null { 156 return this.byStoredLineIndex[storedLineIndex] 157 } 158 159 append(line: LogLine) { 160 let existing = this.byStoredLineIndex[line.storedLineIndex] 161 if (existing) { 162 existing.line = line 163 } else { 164 let last = this.last 165 let newEntry = { prev: last, line: line } 166 this.byStoredLineIndex[line.storedLineIndex] = newEntry 167 if (last) { 168 last.next = newEntry 169 } 170 this.last = newEntry 171 } 172 } 173 } 174 175 // The number of lines to render at a time. 176 export const renderWindow = 250 177 178 // React is not a great system for rendering logs. 179 // React has to build a virtual DOM, diffs the virtual DOM, and does 180 // spot updates of the actual DOM. 181 // 182 // But logs are append-only, so this wastes a lot of CPU doing diffs 183 // for things that never change. Other components (like xtermjs) manage 184 // rendering directly, but have a thin React wrapper to mount the component. 185 // So we use that rendering strategy here. 186 // 187 // This means that we can't use other react components (like styled-components) 188 // and have to use plain css + HTML. 189 export class OverviewLogComponent extends Component<OverviewLogComponentProps> { 190 autoscroll: boolean = true 191 needsScrollToLine: boolean = false 192 193 // The element containing all the log lines. 194 rootRef: React.RefObject<any> = React.createRef() 195 196 // The blinking cursor at the end of the component. 197 private cursorRef: React.RefObject<HTMLParagraphElement> = React.createRef() 198 199 // Track the scrollTop of the root element to see if the user is scrolling upwards. 200 scrollTop: number = -1 201 202 // Timer for tracking autoscroll. 203 autoscrollRafId: number | null = null 204 205 // Timer for tracking render 206 renderBufferRafId: number | null = null 207 208 // Lines to render at the end of the pane. 209 forwardBuffer: LogLine[] = [] 210 211 // Lines to render at the start of the pane. 212 backwardBuffer: LogLine[] = [] 213 214 private logCheckpoint: number = 0 215 216 private lineHashList: LineHashList = new LineHashList() 217 218 // When we're displaying warnings or errors, we want to display the last 219 // N lines before the error. So we keep track of the last N lines for each span. 220 private prologuesBySpanId: { [key: string]: LogLine[] } = {} 221 222 constructor(props: OverviewLogComponentProps) { 223 super(props) 224 225 this.onScroll = this.onScroll.bind(this) 226 this.onLogUpdate = this.onLogUpdate.bind(this) 227 this.renderBuffer = this.renderBuffer.bind(this) 228 } 229 230 scrollCursorIntoView() { 231 if (this.cursorRef.current?.scrollIntoView) { 232 this.cursorRef.current.scrollIntoView() 233 } 234 } 235 236 onLogUpdate(e: LogUpdateEvent) { 237 if (!this.rootRef.current || !this.cursorRef.current) { 238 return 239 } 240 241 if (e.action === LogUpdateAction.truncate) { 242 this.resetRender() 243 } 244 245 this.readLogsFromLogStore() 246 } 247 248 componentDidUpdate(prevProps: OverviewLogComponentProps) { 249 if (prevProps.logStore !== this.props.logStore) { 250 prevProps.logStore.removeUpdateListener(this.onLogUpdate) 251 this.props.logStore.addUpdateListener(this.onLogUpdate) 252 } 253 254 if ( 255 prevProps.manifestName !== this.props.manifestName || 256 !filterSetsEqual(prevProps.filterSet, this.props.filterSet) 257 ) { 258 this.resetRender() 259 260 if (typeof this.props.scrollToStoredLineIndex === "number") { 261 this.needsScrollToLine = true 262 } 263 this.autoscroll = !this.needsScrollToLine 264 265 this.readLogsFromLogStore() 266 } else if (prevProps.logStore !== this.props.logStore) { 267 this.resetRender() 268 this.readLogsFromLogStore() 269 } 270 } 271 272 componentDidMount() { 273 let rootEl = this.rootRef.current 274 if (!rootEl) { 275 return 276 } 277 278 if (typeof this.props.scrollToStoredLineIndex == "number") { 279 this.needsScrollToLine = true 280 } 281 this.autoscroll = !this.needsScrollToLine 282 283 rootEl.addEventListener("scroll", this.onScroll, { 284 passive: true, 285 }) 286 this.resetRender() 287 this.readLogsFromLogStore() 288 289 this.props.logStore.addUpdateListener(this.onLogUpdate) 290 } 291 292 componentWillUnmount() { 293 this.props.logStore.removeUpdateListener(this.onLogUpdate) 294 295 let rootEl = this.rootRef.current 296 if (!rootEl) { 297 return 298 } 299 rootEl.removeEventListener("scroll", this.onScroll) 300 301 if (this.autoscrollRafId) { 302 this.props.raf.cancelAnimationFrame(this.autoscrollRafId) 303 } 304 } 305 306 onScroll() { 307 let rootEl = this.rootRef.current 308 if (!rootEl) { 309 return 310 } 311 312 let scrollTop = rootEl.scrollTop 313 let oldScrollTop = this.scrollTop 314 let autoscroll = this.autoscroll 315 316 this.scrollTop = scrollTop 317 if (oldScrollTop === -1 || oldScrollTop === scrollTop) { 318 return 319 } 320 321 // If we're scrolled horizontally, cancel the autoscroll. 322 if (rootEl.scrollLeft > 0) { 323 if (this.autoscroll) { 324 this.autoscroll = false 325 this.maybeScheduleRender() 326 } 327 return 328 } 329 330 // If we're autoscrolling, and the user scrolled up, 331 // cancel the autoscroll. 332 if (autoscroll && scrollTop < oldScrollTop) { 333 if (this.autoscroll) { 334 this.autoscroll = false 335 this.maybeScheduleRender() 336 } 337 return 338 } 339 340 // If we're not autoscrolling, and the user scrolled down, 341 // we may have to re-engage the autoscroll. 342 if (!autoscroll && scrollTop > oldScrollTop) { 343 this.maybeEngageAutoscroll() 344 } 345 } 346 347 private maybeEngageAutoscroll() { 348 // We don't expect new log lines in snapshots. So when we scroll down, we don't need 349 // to worry about re-engaging autoscroll. 350 if (this.props.pathBuilder.isSnapshot()) { 351 return 352 } 353 354 if (this.needsScrollToLine) { 355 return 356 } 357 358 if (this.autoscrollRafId) { 359 this.props.raf.cancelAnimationFrame(this.autoscrollRafId) 360 } 361 362 this.autoscrollRafId = this.props.raf.requestAnimationFrame(() => { 363 let autoscroll = this.computeAutoScroll() 364 if (autoscroll) { 365 this.autoscroll = true 366 } 367 }) 368 } 369 370 // Compute whether we should auto-scroll from the state of the DOM. 371 // This forces a layout, so should be used sparingly. 372 private computeAutoScroll(): boolean { 373 let rootEl = this.rootRef.current 374 if (!rootEl) { 375 return true 376 } 377 378 // Always auto-scroll when we're recovering from a loading screen. 379 let cursorEl = this.cursorRef.current 380 if (!cursorEl) { 381 return true 382 } 383 384 // Never auto-scroll if we're horizontally scrolled. 385 if (rootEl.scrollLeft) { 386 return false 387 } 388 389 let lastElInView = 390 cursorEl.getBoundingClientRect().bottom <= 391 rootEl.getBoundingClientRect().bottom 392 return lastElInView 393 } 394 395 resetRender() { 396 let root = this.rootRef.current 397 let cursor = this.cursorRef.current 398 if (root) { 399 while (root.firstChild != cursor) { 400 root.removeChild(root.firstChild) 401 } 402 } 403 404 this.lineHashList = new LineHashList() 405 this.prologuesBySpanId = {} 406 this.logCheckpoint = 0 407 this.scrollTop = -1 408 409 if (this.renderBufferRafId) { 410 this.props.raf.cancelAnimationFrame(this.renderBufferRafId) 411 this.renderBufferRafId = 0 412 } 413 414 if (this.autoscrollRafId) { 415 this.props.raf.cancelAnimationFrame(this.autoscrollRafId) 416 this.autoscrollRafId = 0 417 } 418 } 419 420 matchesTermFilter(line: LogLine): boolean { 421 const { term } = this.props.filterSet 422 423 // Don't consider a filter term if the term hasn't been parsed for matching 424 if (!term || term.state !== TermState.Parsed) { 425 return true 426 } 427 428 return term.regexp.test(line.text) 429 } 430 431 // If we have a level filter on, check if this line matches the level filter. 432 matchesLevelFilter(line: LogLine): boolean { 433 let level = this.props.filterSet.level 434 if (level === FilterLevel.warn && line.level !== LogLevel.WARN) { 435 return false 436 } 437 if (level === FilterLevel.error && line.level !== LogLevel.ERROR) { 438 return false 439 } 440 return true 441 } 442 443 // Check if this line matches the current filter. 444 matchesFilter(line: LogLine): boolean { 445 if (line.buildEvent) { 446 // Always leave in build event logs. 447 // This makes it easier to see which logs belong to which builds. 448 return true 449 } 450 451 let source = this.props.filterSet.source 452 if ( 453 source === FilterSource.runtime && 454 line.spanId.indexOf("build:") === 0 455 ) { 456 return false 457 } 458 if (source === FilterSource.build && line.spanId.indexOf("build:") !== 0) { 459 return false 460 } 461 462 return this.matchesLevelFilter(line) && this.matchesTermFilter(line) 463 } 464 465 // Index this line so that we can display prologues to errors. 466 trackPrologueLine(line: LogLine) { 467 if (!this.prologuesBySpanId[line.spanId]) { 468 this.prologuesBySpanId[line.spanId] = [] 469 } 470 this.prologuesBySpanId[line.spanId].push(line) 471 } 472 473 // Gets the prologue for the given span, and clear the lines used for prologuing. 474 getAndClearPrologue(spanId: string): LogLine[] { 475 let lines = this.prologuesBySpanId[spanId] 476 if (!lines) { 477 return [] 478 } 479 480 delete this.prologuesBySpanId[spanId] 481 return lines.slice(-PROLOGUE_LENGTH) // last N lines 482 } 483 484 // Render new logs that have come in since the current checkpoint. 485 readLogsFromLogStore() { 486 let mn = this.props.manifestName 487 let logStore = this.props.logStore 488 let startCheckpoint = this.logCheckpoint 489 490 let patch = mn 491 ? mn === ResourceName.starred 492 ? logStore.starredLogPatchSet( 493 this.props.starredResources, 494 startCheckpoint 495 ) 496 : logStore.manifestLogPatchSet(mn, startCheckpoint) 497 : logStore.allLogPatchSet(startCheckpoint) 498 499 let lines: LogLine[] = [] 500 let shouldDisplayPrologues = this.props.filterSet.level !== FilterLevel.all 501 502 patch.lines.forEach((line) => { 503 let matches = this.matchesFilter(line) 504 if (matches) { 505 if (shouldDisplayPrologues) { 506 lines.push(...this.getAndClearPrologue(line.spanId)) 507 } 508 lines.push(line) 509 return 510 } else if (shouldDisplayPrologues) { 511 this.trackPrologueLine(line) 512 } 513 }) 514 515 this.logCheckpoint = patch.checkpoint 516 lines.forEach((line) => this.lineHashList.append(line)) 517 518 if (startCheckpoint) { 519 // If this is an incremental render, put the lines in the forward buffer. 520 lines.forEach((line) => { 521 this.forwardBuffer.push(line) 522 }) 523 } else { 524 // If this is the first render, put the lines in the backward buffer, so 525 // that the last lines get rendered first. 526 lines.forEach((line) => { 527 this.backwardBuffer.push(line) 528 }) 529 } 530 531 this.maybeScheduleRender() 532 } 533 534 // Schedule a render job if there's not one already scheduled. 535 maybeScheduleRender() { 536 if (this.renderBufferRafId) return 537 this.renderBufferRafId = this.props.raf.requestAnimationFrame( 538 this.renderBuffer 539 ) 540 } 541 542 shouldRenderForwardBuffer(): boolean { 543 return this.forwardBuffer.length > 0 544 } 545 546 // When we're in autoscrolling mode, rendering the backwards buffer makes the 547 // screen jiggle, because we have to render a few rows, then scroll down, then 548 // render a few rows, then scroll down. 549 // 550 // So when in autoscrol mode, only render until we have the "last window" of logs. 551 shouldRenderBackwardBuffer(): boolean { 552 if (this.backwardBuffer.length == 0) { 553 // Skip rendering if there's no lines in the buffer. 554 return false 555 } 556 557 if (!this.autoscroll) { 558 // Do render if we're scrolling up. 559 return true 560 } 561 562 // In autoscroll mode, only render if there aren't enough lines to fill the viewport. 563 return this.rootRef.current.scrollTop == 0 564 } 565 566 // We have two render buffers: 567 // - a buffer of newer logs that we haven't rendered yet. 568 // - a buffer of older logs that we haven't rendered yet. 569 // First, process the newer logs. 570 // If we're out of new logs to render, go back through the old logs. 571 // 572 // Each invocation of this method renders up to 2x renderWindow logs. 573 // If there are still logs left to render, it yields the thread and schedules 574 // another render. 575 renderBuffer() { 576 this.renderBufferRafId = 0 577 578 let root = this.rootRef.current 579 let cursor = this.cursorRef.current 580 if (!root || !cursor) { 581 return 582 } 583 584 if ( 585 !this.shouldRenderForwardBuffer() && 586 !this.shouldRenderBackwardBuffer() 587 ) { 588 return 589 } 590 591 // Render the lines in the forward buffer first. 592 let forwardLines = this.forwardBuffer.slice(0, renderWindow) 593 this.forwardBuffer = this.forwardBuffer.slice(renderWindow) 594 for (let i = 0; i < forwardLines.length; i++) { 595 let line = forwardLines[i] 596 this.renderLineHelper(line) 597 } 598 599 if (this.shouldRenderBackwardBuffer()) { 600 let backwardStart = Math.max(0, this.backwardBuffer.length - renderWindow) 601 let backwardLines = this.backwardBuffer.slice(backwardStart) 602 this.backwardBuffer = this.backwardBuffer.slice(0, backwardStart) 603 604 for (let i = backwardLines.length - 1; i >= 0; i--) { 605 let line = backwardLines[i] 606 this.renderLineHelper(line) 607 } 608 } 609 610 if (this.autoscroll) { 611 this.scrollCursorIntoView() 612 } 613 614 if (this.needsScrollToLine) { 615 let entry = this.lineHashList.lookupByStoredLineIndex( 616 this.props.scrollToStoredLineIndex as number 617 ) 618 if (entry?.el) { 619 entry.el.scrollIntoView({ block: "center" }) 620 this.needsScrollToLine = false 621 } 622 } 623 624 if (this.shouldRenderForwardBuffer() || this.shouldRenderBackwardBuffer()) { 625 this.renderBufferRafId = this.props.raf.requestAnimationFrame( 626 this.renderBuffer 627 ) 628 } 629 } 630 631 // Creates a DOM element with a permalink to an alert. 632 newAlertNavEl(line: LogLine) { 633 let div = document.createElement("button") 634 div.className = "LogLine-alertNav" 635 div.innerHTML = "… (more) …" 636 div.onclick = (e) => { 637 let storedLineIndex = line.storedLineIndex 638 let history = this.props.history 639 history.push( 640 this.props.pathBuilder.encpath`/r/${line.manifestName}/overview`, 641 { storedLineIndex } 642 ) 643 } 644 return div 645 } 646 647 // Helper function for rendering lines. Returns true if the line was 648 // successfully rendered. 649 // 650 // If the line has already been rendered, replace the rendered line. 651 // 652 // If it hasn't been rendered, but the next line has, put it before the next line. 653 // 654 // If it hasn't been rendered, but the previous line has, put it after the previous line. 655 // 656 // Otherwise, iterate through the lines until we find a place to put it. 657 renderLineHelper(line: LogLine) { 658 let entry = this.lineHashList.lookup(line) 659 if (!entry) { 660 // If the entry has been removed from the hash list for some reason, 661 // just ignore it. 662 return 663 } 664 665 let shouldDisplayPrologues = this.props.filterSet.level !== FilterLevel.all 666 let mn = this.props.manifestName 667 let showManifestName = !mn || mn === ResourceName.starred 668 let prevManifestName = entry.prev?.line.manifestName || "" 669 670 let extraClasses = [] 671 let isContextChange = !!entry.prev && prevManifestName !== line.manifestName 672 if (isContextChange) { 673 extraClasses.push("is-contextChange") 674 } 675 676 let isEndOfAlert = 677 shouldDisplayPrologues && 678 this.matchesLevelFilter(line) && 679 (!entry.next || entry.next?.line.level !== line.level) 680 if (isEndOfAlert) { 681 extraClasses.push("is-endOfAlert") 682 } 683 684 let isStartOfAlert = 685 shouldDisplayPrologues && 686 !line.buildEvent && 687 !this.matchesLevelFilter(line) && 688 (!entry.prev || 689 this.matchesLevelFilter(entry.prev.line) || 690 entry.prev.line.buildEvent) 691 if (isStartOfAlert) { 692 extraClasses.push("is-startOfAlert") 693 } 694 695 let lineEl = newLineEl(entry.line, showManifestName, extraClasses) 696 if (isStartOfAlert) { 697 lineEl.appendChild(this.newAlertNavEl(entry.line)) 698 } 699 700 let root = this.rootRef.current 701 let existingLineEl = entry.el 702 if (existingLineEl) { 703 root.replaceChild(lineEl, existingLineEl) 704 entry.el = lineEl 705 return 706 } 707 708 let nextEl = entry.next?.el 709 if (nextEl) { 710 root.insertBefore(lineEl, nextEl) 711 entry.el = lineEl 712 return 713 } 714 715 let prevEl = entry.prev?.el 716 if (prevEl) { 717 root.insertBefore(lineEl, prevEl.nextSibling) 718 entry.el = lineEl 719 return 720 } 721 722 // In the worst case scenario, we iterate through all lines to find a suitable place. 723 let cursor = this.cursorRef.current 724 for (let i = 0; i < root.children.length; i++) { 725 let child = root.children[i] 726 if ( 727 child == cursor || 728 Number(child.getAttribute("data-sl-index")) > line.storedLineIndex 729 ) { 730 root.insertBefore(lineEl, child) 731 entry.el = lineEl 732 return 733 } 734 } 735 } 736 737 render() { 738 return ( 739 <LogPaneRoot ref={this.rootRef} aria-label="Log pane"> 740 <LogEnd key="logEnd" className="logEnd" ref={this.cursorRef}> 741 █ 742 </LogEnd> 743 </LogPaneRoot> 744 ) 745 } 746 } 747 748 type OverviewLogPaneProps = { 749 manifestName: string 750 filterSet: FilterSet 751 } 752 753 export default function OverviewLogPane(props: OverviewLogPaneProps) { 754 let history = useHistory() 755 let location = useLocation() as any 756 let pathBuilder = usePathBuilder() 757 let logStore = useLogStore() 758 let raf = useRaf() 759 let starredContext = useStarredResources() 760 return ( 761 <OverviewLogComponent 762 manifestName={props.manifestName} 763 pathBuilder={pathBuilder} 764 logStore={logStore} 765 raf={raf} 766 filterSet={props.filterSet} 767 history={history} 768 scrollToStoredLineIndex={location?.state?.storedLineIndex} 769 starredResources={starredContext.starredResources} 770 /> 771 ) 772 }