starling-devex 0.1.2

Starling: a local dev orchestrator with a central daemon, shared named-URL proxy, and a k9s-style TUI (a Rust port of Tilt + portless)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
import React, { Component } from "react"
import { useNavigate, useLocation } from "react-router-dom"
import styled, { keyframes } from "styled-components"
import { FilterSet, filterSetsEqual } from "./logfilters"
import "./LogLine.scss"
import "./LogPane.scss"
import LogStore, {
  LogUpdateAction,
  LogUpdateEvent,
  useLogStore,
} from "./LogStore"
import { DISPLAY_LOG_PROLOGUE_LENGTH, LogDisplay } from "./logs"
import PathBuilder, { usePathBuilder } from "./PathBuilder"
import { RafContext, useRaf } from "./raf"
import { useStarredResources } from "./StarredResourcesContext"
import { Color, FontSize, SizeUnit } from "./style-helpers"
import Anser from "./third-party/anser/index.js"
import { LogLine, ResourceName } from "./types"

// The number of lines to display before an error.
export const PROLOGUE_LENGTH = DISPLAY_LOG_PROLOGUE_LENGTH

type OverviewLogComponentProps = {
  manifestName: string
  pathBuilder: PathBuilder
  logStore: LogStore
  raf: RafContext
  filterSet: FilterSet
  navigate: ReturnType<typeof useNavigate>
  scrollToStoredLineIndex: number | null
  starredResources: string[]
}

let LogPaneRoot = styled.section`
  padding: 0 0 ${SizeUnit(0.25)} 0;
  background-color: ${Color.gray10};
  width: 100%;
  height: 100%;
  overflow-y: auto;
  box-sizing: border-box;
  font-size: ${FontSize.smallest};
`

const blink = keyframes`
0% {
  opacity: 1;
}
50% {
  opacity: 0;
}
100% {
  opacity: 1;
}
`

let LogEnd = styled.div`
  animation: ${blink} 1s infinite;
  animation-timing-function: ease;
  padding-top: ${SizeUnit(0.25)};
  padding-left: ${SizeUnit(0.625)};
  font-size: var(--log-font-scale);
`

let anser = new Anser()

function newLineEl(
  line: LogLine,
  showManifestPrefix: boolean,
  extraClasses: string[]
): Element {
  let text = line.text
  let level = line.level
  let buildEvent = line.buildEvent
  let classes = ["LogLine"]
  classes.push(...extraClasses)
  if (level === "WARN") {
    classes.push("is-warning")
  } else if (level === "ERROR") {
    classes.push("is-error")
  }
  if (buildEvent === "init") {
    classes.push("is-buildEvent")
    classes.push("is-buildEvent-init")

    if (showManifestPrefix) {
      // For build event lines, we put the manifest name is a suffix
      // rather than a prefix, because it looks nicer.
      text += ` • ${line.manifestName}`
    } else {
      // If we're viewing a single resource, we should make the build event log
      // lines sticky, so that we always know context of the current logs.
      classes.push("is-sticky")
    }
  }
  if (buildEvent === "fallback") {
    classes.push("is-buildEvent")
    classes.push("is-buildEvent-fallback")
  }
  let span = document.createElement("span")
  span.setAttribute("data-sl-index", String(line.storedLineIndex))
  span.classList.add(...classes)

  if (showManifestPrefix && buildEvent !== "init") {
    let prefix = document.createElement("span")
    let name = line.manifestName
    if (!name) {
      name = "(global)"
    }
    prefix.title = name
    prefix.className = "logLinePrefix"
    prefix.innerHTML = anser.escapeForHtml(name)
    span.appendChild(prefix)
  }

  let code = document.createElement("code")
  code.classList.add("LogLine-content")

  // newline ensures this takes up at least one line
  let spacer = "\n"
  code.innerHTML = anser.linkify(
    anser.ansiToHtml(anser.escapeForHtml(text) + spacer, {
      // Let anser colorize the html as it appears from various consoles
      use_classes: false,
    })
  )
  span.appendChild(code)
  return span
}

// An index of lines such that lets us find:
// - The next line
// - The previous line
// - The line by stored line index.
type LineHashListEntry = {
  prev?: LineHashListEntry | null
  next?: LineHashListEntry | null
  line: LogLine
  el?: Element
}

class LineHashList {
  private last: LineHashListEntry | null = null
  private byStoredLineIndex: { [key: number]: LineHashListEntry } = {}

  lookup(line: LogLine): LineHashListEntry | null {
    return this.byStoredLineIndex[line.storedLineIndex]
  }

  lookupByStoredLineIndex(storedLineIndex: number): LineHashListEntry | null {
    return this.byStoredLineIndex[storedLineIndex]
  }

  append(line: LogLine) {
    let existing = this.byStoredLineIndex[line.storedLineIndex]
    if (existing) {
      existing.line = line
    } else {
      let last = this.last
      let newEntry = { prev: last, line: line }
      this.byStoredLineIndex[line.storedLineIndex] = newEntry
      if (last) {
        last.next = newEntry
      }
      this.last = newEntry
    }
  }
}

// The number of lines to render at a time.
export const renderWindow = 250

// React is not a great system for rendering logs.
// React has to build a virtual DOM, diffs the virtual DOM, and does
// spot updates of the actual DOM.
//
// But logs are append-only, so this wastes a lot of CPU doing diffs
// for things that never change. Other components (like xtermjs) manage
// rendering directly, but have a thin React wrapper to mount the component.
// So we use that rendering strategy here.
//
// This means that we can't use other react components (like styled-components)
// and have to use plain css + HTML.
export class OverviewLogComponent extends Component<OverviewLogComponentProps> {
  autoscroll: boolean = true
  needsScrollToLine: boolean = false

  // The element containing all the log lines.
  rootRef: React.RefObject<any> = React.createRef()

  // The blinking cursor at the end of the component.
  private cursorRef: React.RefObject<HTMLParagraphElement> = React.createRef()

  // Track the scrollTop of the root element to see if the user is scrolling upwards.
  scrollTop: number = -1

  // Timer for tracking autoscroll.
  autoscrollRafId: number | null = null

  // Timer for tracking render
  renderBufferRafId: number | null = null

  // Lines to render at the end of the pane.
  forwardBuffer: LogLine[] = []

  // Lines to render at the start of the pane.
  backwardBuffer: LogLine[] = []

  private logCheckpoint: number = 0

  private lineHashList: LineHashList = new LineHashList()

  private logDisplay: LogDisplay

  constructor(props: OverviewLogComponentProps) {
    super(props)

    this.logDisplay = new LogDisplay(props.filterSet)
    this.onScroll = this.onScroll.bind(this)
    this.onLogUpdate = this.onLogUpdate.bind(this)
    this.renderBuffer = this.renderBuffer.bind(this)
  }

  scrollCursorIntoView() {
    if (this.cursorRef.current?.scrollIntoView) {
      this.cursorRef.current.scrollIntoView()
    }
  }

  onLogUpdate(e: LogUpdateEvent) {
    if (!this.rootRef.current || !this.cursorRef.current) {
      return
    }

    if (e.action === LogUpdateAction.truncate) {
      this.resetRender()
    }

    this.readLogsFromLogStore()
  }

  componentDidUpdate(prevProps: OverviewLogComponentProps) {
    if (prevProps.logStore !== this.props.logStore) {
      prevProps.logStore.removeUpdateListener(this.onLogUpdate)
      this.props.logStore.addUpdateListener(this.onLogUpdate)
    }

    if (
      prevProps.manifestName !== this.props.manifestName ||
      !filterSetsEqual(prevProps.filterSet, this.props.filterSet)
    ) {
      this.resetRender()

      if (typeof this.props.scrollToStoredLineIndex === "number") {
        this.needsScrollToLine = true
      }
      this.autoscroll = !this.needsScrollToLine

      this.readLogsFromLogStore()
    } else if (prevProps.logStore !== this.props.logStore) {
      this.resetRender()
      this.readLogsFromLogStore()
    }
  }

  componentDidMount() {
    let rootEl = this.rootRef.current
    if (!rootEl) {
      return
    }

    if (typeof this.props.scrollToStoredLineIndex == "number") {
      this.needsScrollToLine = true
    }
    this.autoscroll = !this.needsScrollToLine

    rootEl.addEventListener("scroll", this.onScroll, {
      passive: true,
    })
    this.resetRender()
    this.readLogsFromLogStore()

    this.props.logStore.addUpdateListener(this.onLogUpdate)
  }

  componentWillUnmount() {
    this.props.logStore.removeUpdateListener(this.onLogUpdate)

    let rootEl = this.rootRef.current
    if (!rootEl) {
      return
    }
    rootEl.removeEventListener("scroll", this.onScroll)

    if (this.autoscrollRafId) {
      this.props.raf.cancelAnimationFrame(this.autoscrollRafId)
    }
  }

  onScroll() {
    let rootEl = this.rootRef.current
    if (!rootEl) {
      return
    }

    let scrollTop = rootEl.scrollTop
    let oldScrollTop = this.scrollTop
    let autoscroll = this.autoscroll

    this.scrollTop = scrollTop
    if (oldScrollTop === -1 || oldScrollTop === scrollTop) {
      return
    }

    // If we're scrolled horizontally, cancel the autoscroll.
    if (rootEl.scrollLeft > 0) {
      if (this.autoscroll) {
        this.autoscroll = false
        this.maybeScheduleRender()
      }
      return
    }

    // If we're autoscrolling, and the user scrolled up,
    // cancel the autoscroll.
    if (autoscroll && scrollTop < oldScrollTop) {
      if (this.autoscroll) {
        this.autoscroll = false
        this.maybeScheduleRender()
      }
      return
    }

    // If we're not autoscrolling, and the user scrolled down,
    // we may have to re-engage the autoscroll.
    if (!autoscroll && scrollTop > oldScrollTop) {
      this.maybeEngageAutoscroll()
    }
  }

  private maybeEngageAutoscroll() {
    // We don't expect new log lines in snapshots. So when we scroll down, we don't need
    // to worry about re-engaging autoscroll.
    if (this.props.pathBuilder.isSnapshot()) {
      return
    }

    if (this.needsScrollToLine) {
      return
    }

    if (this.autoscrollRafId) {
      this.props.raf.cancelAnimationFrame(this.autoscrollRafId)
    }

    this.autoscrollRafId = this.props.raf.requestAnimationFrame(() => {
      let autoscroll = this.computeAutoScroll()
      if (autoscroll) {
        this.autoscroll = true
      }
    })
  }

  // Compute whether we should auto-scroll from the state of the DOM.
  // This forces a layout, so should be used sparingly.
  private computeAutoScroll(): boolean {
    let rootEl = this.rootRef.current
    if (!rootEl) {
      return true
    }

    // Always auto-scroll when we're recovering from a loading screen.
    let cursorEl = this.cursorRef.current
    if (!cursorEl) {
      return true
    }

    // Never auto-scroll if we're horizontally scrolled.
    if (rootEl.scrollLeft) {
      return false
    }

    let lastElInView =
      cursorEl.getBoundingClientRect().bottom <=
      rootEl.getBoundingClientRect().bottom
    return lastElInView
  }

  resetRender() {
    let root = this.rootRef.current
    let cursor = this.cursorRef.current
    if (root) {
      while (root.firstChild != cursor) {
        root.removeChild(root.firstChild)
      }
    }

    this.lineHashList = new LineHashList()
    this.logDisplay = new LogDisplay(this.props.filterSet)
    this.logCheckpoint = 0
    this.scrollTop = -1

    if (this.renderBufferRafId) {
      this.props.raf.cancelAnimationFrame(this.renderBufferRafId)
      this.renderBufferRafId = 0
    }

    if (this.autoscrollRafId) {
      this.props.raf.cancelAnimationFrame(this.autoscrollRafId)
      this.autoscrollRafId = 0
    }
  }

  // Render new logs that have come in since the current checkpoint.
  readLogsFromLogStore() {
    let mn = this.props.manifestName
    let logStore = this.props.logStore
    let startCheckpoint = this.logCheckpoint

    let patch = mn
      ? mn === ResourceName.starred
        ? logStore.starredLogPatchSet(
            this.props.starredResources,
            startCheckpoint
          )
        : logStore.manifestLogPatchSet(mn, startCheckpoint)
      : logStore.allLogPatchSet(startCheckpoint)

    let lines = this.logDisplay.filterLines(patch.lines)

    this.logCheckpoint = patch.checkpoint
    lines.forEach((line) => this.lineHashList.append(line))

    if (startCheckpoint) {
      // If this is an incremental render, put the lines in the forward buffer.
      lines.forEach((line) => {
        this.forwardBuffer.push(line)
      })
    } else {
      // If this is the first render, put the lines in the backward buffer, so
      // that the last lines get rendered first.
      lines.forEach((line) => {
        this.backwardBuffer.push(line)
      })
    }

    this.maybeScheduleRender()
  }

  // Schedule a render job if there's not one already scheduled.
  maybeScheduleRender() {
    if (this.renderBufferRafId) return
    this.renderBufferRafId = this.props.raf.requestAnimationFrame(
      this.renderBuffer
    )
  }

  shouldRenderForwardBuffer(): boolean {
    return this.forwardBuffer.length > 0
  }

  // When we're in autoscrolling mode, rendering the backwards buffer makes the
  // screen jiggle, because we have to render a few rows, then scroll down, then
  // render a few rows, then scroll down.
  //
  // So when in autoscrol mode, only render until we have the "last window" of logs.
  shouldRenderBackwardBuffer(): boolean {
    if (this.backwardBuffer.length == 0) {
      // Skip rendering if there's no lines in the buffer.
      return false
    }

    if (!this.autoscroll) {
      // Do render if we're scrolling up.
      return true
    }

    // In autoscroll mode, only render if there aren't enough lines to fill the viewport.
    return this.rootRef.current.scrollTop == 0
  }

  // We have two render buffers:
  // - a buffer of newer logs that we haven't rendered yet.
  // - a buffer of older logs that we haven't rendered yet.
  // First, process the newer logs.
  // If we're out of new logs to render, go back through the old logs.
  //
  // Each invocation of this method renders up to 2x renderWindow logs.
  // If there are still logs left to render, it yields the thread and schedules
  // another render.
  renderBuffer() {
    this.renderBufferRafId = 0

    let root = this.rootRef.current
    let cursor = this.cursorRef.current
    if (!root || !cursor) {
      return
    }

    if (
      !this.shouldRenderForwardBuffer() &&
      !this.shouldRenderBackwardBuffer()
    ) {
      return
    }

    // Render the lines in the forward buffer first.
    let forwardLines = this.forwardBuffer.slice(0, renderWindow)
    this.forwardBuffer = this.forwardBuffer.slice(renderWindow)
    for (let i = 0; i < forwardLines.length; i++) {
      let line = forwardLines[i]
      this.renderLineHelper(line)
    }

    if (this.shouldRenderBackwardBuffer()) {
      let backwardStart = Math.max(0, this.backwardBuffer.length - renderWindow)
      let backwardLines = this.backwardBuffer.slice(backwardStart)
      this.backwardBuffer = this.backwardBuffer.slice(0, backwardStart)

      for (let i = backwardLines.length - 1; i >= 0; i--) {
        let line = backwardLines[i]
        this.renderLineHelper(line)
      }
    }

    if (this.autoscroll) {
      this.scrollCursorIntoView()
    }

    if (this.needsScrollToLine) {
      let entry = this.lineHashList.lookupByStoredLineIndex(
        this.props.scrollToStoredLineIndex as number
      )
      if (entry?.el) {
        entry.el.scrollIntoView({ block: "center" })
        this.needsScrollToLine = false
      }
    }

    if (this.shouldRenderForwardBuffer() || this.shouldRenderBackwardBuffer()) {
      this.renderBufferRafId = this.props.raf.requestAnimationFrame(
        this.renderBuffer
      )
    }
  }

  // Creates a DOM element with a permalink to an alert.
  newAlertNavEl(line: LogLine) {
    let div = document.createElement("button")
    div.className = "LogLine-alertNav"
    div.innerHTML = "… (more) …"
    div.onclick = (e) => {
      let storedLineIndex = line.storedLineIndex
      this.props.navigate(
        this.props.pathBuilder.encpath`/r/${line.manifestName}/overview`,
        { state: { storedLineIndex } }
      )
    }
    return div
  }

  // Helper function for rendering lines. Returns true if the line was
  // successfully rendered.
  //
  // If the line has already been rendered, replace the rendered line.
  //
  // If it hasn't been rendered, but the next line has, put it before the next line.
  //
  // If it hasn't been rendered, but the previous line has, put it after the previous line.
  //
  // Otherwise, iterate through the lines until we find a place to put it.
  renderLineHelper(line: LogLine) {
    let entry = this.lineHashList.lookup(line)
    if (!entry) {
      // If the entry has been removed from the hash list for some reason,
      // just ignore it.
      return
    }

    let shouldDisplayPrologues = this.logDisplay.shouldDisplayPrologues()
    let mn = this.props.manifestName
    let showManifestName = !mn || mn === ResourceName.starred
    let prevManifestName = entry.prev?.line.manifestName || ""

    let extraClasses = []
    let isContextChange = !!entry.prev && prevManifestName !== line.manifestName
    if (isContextChange) {
      extraClasses.push("is-contextChange")
    }

    let isEndOfAlert =
      shouldDisplayPrologues &&
      this.logDisplay.matchesLevelFilter(line) &&
      (!entry.next || entry.next?.line.level !== line.level)
    if (isEndOfAlert) {
      extraClasses.push("is-endOfAlert")
    }

    let isStartOfAlert =
      shouldDisplayPrologues &&
      !line.buildEvent &&
      !this.logDisplay.matchesLevelFilter(line) &&
      (!entry.prev ||
        this.logDisplay.matchesLevelFilter(entry.prev.line) ||
        entry.prev.line.buildEvent)
    if (isStartOfAlert) {
      extraClasses.push("is-startOfAlert")
    }

    let lineEl = newLineEl(entry.line, showManifestName, extraClasses)
    if (isStartOfAlert) {
      lineEl.appendChild(this.newAlertNavEl(entry.line))
    }

    let root = this.rootRef.current
    let existingLineEl = entry.el
    if (existingLineEl) {
      root.replaceChild(lineEl, existingLineEl)
      entry.el = lineEl
      return
    }

    let nextEl = entry.next?.el
    if (nextEl) {
      root.insertBefore(lineEl, nextEl)
      entry.el = lineEl
      return
    }

    let prevEl = entry.prev?.el
    if (prevEl) {
      root.insertBefore(lineEl, prevEl.nextSibling)
      entry.el = lineEl
      return
    }

    // In the worst case scenario, we iterate through all lines to find a suitable place.
    let cursor = this.cursorRef.current
    for (let i = 0; i < root.children.length; i++) {
      let child = root.children[i]
      if (
        child == cursor ||
        Number(child.getAttribute("data-sl-index")) > line.storedLineIndex
      ) {
        root.insertBefore(lineEl, child)
        entry.el = lineEl
        return
      }
    }
  }

  render() {
    return (
      <LogPaneRoot ref={this.rootRef} aria-label="Log pane">
        <LogEnd key="logEnd" className="logEnd" ref={this.cursorRef}>
          &#9608;
        </LogEnd>
      </LogPaneRoot>
    )
  }
}

type OverviewLogPaneProps = {
  manifestName: string
  filterSet: FilterSet
}

export default function OverviewLogPane(props: OverviewLogPaneProps) {
  const navigate = useNavigate()
  let location = useLocation() as any
  let pathBuilder = usePathBuilder()
  let logStore = useLogStore()
  let raf = useRaf()
  let starredContext = useStarredResources()

  return (
    <OverviewLogComponent
      manifestName={props.manifestName}
      pathBuilder={pathBuilder}
      logStore={logStore}
      raf={raf}
      filterSet={props.filterSet}
      navigate={navigate}
      scrollToStoredLineIndex={location?.state?.storedLineIndex}
      starredResources={starredContext.starredResources}
    />
  )
}