Skip to content

Commit ae55e3b

Browse files
committed
[web-console] Implement search bar for logs tab
Unify logs implementation across pipeline page, profiler page and the standalone profiler app Signed-off-by: Karakatiza666 <bulakh.96@gmail.com>
1 parent c58569a commit ae55e3b

22 files changed

Lines changed: 883 additions & 157 deletions

bun.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

js-packages/common-ui/package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@
1313
"flowbite-svelte": "^1.31.0",
1414
"monaco-editor": "0.55.1",
1515
"publint": "^0.3.15",
16+
"runed": "0.37.1",
17+
"strip-ansi": "7.1.2",
1618
"svelte": "5.55.7",
1719
"svelte-check": "^4.3.5",
1820
"tailwindcss": "^4.1.17",
21+
"tiny-invariant": "1.3.3",
1922
"typescript": "^5.9.3",
23+
"virtua": "0.48.6",
2024
"vite": "^8.0.0"
2125
},
2226
"peerDependencies": {
@@ -25,7 +29,11 @@
2529
"fancy-ansi": "^0.1.3",
2630
"flowbite-svelte": "^1.31.0",
2731
"monaco-editor": "0.55.1",
28-
"svelte": "5.55.7"
32+
"runed": "0.37.1",
33+
"strip-ansi": "7.1.2",
34+
"svelte": "5.55.7",
35+
"tiny-invariant": "1.3.3",
36+
"virtua": "0.48.6"
2937
},
3038
"exports": {
3139
".": {
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
<script lang="ts">
2+
import { untrack, type Snippet } from 'svelte'
3+
import { Virtualizer, type VirtualizerHandle } from 'virtua/svelte'
4+
import stripAnsi from 'strip-ansi'
5+
import ANSIDecoratedText from './ANSIDecoratedText.svelte'
6+
import ScrollDownFab from './ScrollDownFab.svelte'
7+
import { useReverseScrollContainer } from './useReverseScrollContainer.svelte'
8+
import { virtualSelect } from './userSelect'
9+
import {
10+
applySearchHighlight,
11+
emptySearchState,
12+
findMatchOffsets,
13+
findOccurrence,
14+
type SearchState
15+
} from './logSearch'
16+
17+
/** Shape of the slice passed by {@link virtualSelect}'s copy interceptor — row/column
18+
* endpoints over the virtualised list. */
19+
type CopySlice =
20+
| 'all'
21+
| { start: { row: number; col: number }; end: { row: number; col: number } }
22+
23+
interface Props {
24+
/** Lines to render, one per row. Hosts split their source (a string, a stream, ...) into
25+
* lines before passing them in. */
26+
lines: string[]
27+
/** Offset added to the row index when computing the Virtualizer's stable key — needed
28+
* when the buffer evicts from the front (a streaming log shifts indices downward). */
29+
firstLineIndex?: number
30+
/** Externally-controlled search state. The owner advances it on submit via
31+
* {@link advanceSearch} — kept here as a prop so callers can wire the search input
32+
* wherever fits their layout. */
33+
search?: SearchState
34+
/** Show a left gutter with 1-based row numbers. Implemented via per-row `counter-set` +
35+
* `::before` generated content so the line number is NOT a DOM text node, keeping the
36+
* search-offset TreeWalker pointed at the actual log content. */
37+
showLineNumbers?: boolean
38+
/** When true, the container starts at the bottom and re-sticks as content grows — the
39+
* streaming-log behaviour. When false (default), the container starts at the top and
40+
* never auto-scrolls on mount — appropriate for static log dumps. */
41+
streaming?: boolean
42+
/** Extra classes for the scroll container — wrappers add background, padding, etc.
43+
* The monospace font is already baked in. */
44+
class?: string
45+
/** Inline style for the scroll container — for values that don't fit Tailwind utilities. */
46+
style?: string
47+
/** Renders above the scroll container — for status banners (e.g. "logs were skipped"). */
48+
header?: Snippet
49+
/** Override the text produced by copy / Ctrl-C across the virtualised list. Default
50+
* joins `lines` (ANSI-stripped) with `\n`. Hosts whose rows already carry trailing
51+
* newlines pass an override that joins with `''`. */
52+
getCopyContent?: (slice: CopySlice) => string
53+
/** Invoked when the user presses Ctrl-F / Cmd-F while focus is inside the log list.
54+
* Hosts typically focus their search input here. When omitted, the browser's native
55+
* find-in-page is left to handle the shortcut. */
56+
onSearchShortcut?: () => void
57+
}
58+
59+
let {
60+
lines,
61+
firstLineIndex = 0,
62+
search = emptySearchState,
63+
showLineNumbers = false,
64+
streaming = false,
65+
class: className = '',
66+
style,
67+
header,
68+
getCopyContent,
69+
onSearchShortcut
70+
}: Props = $props()
71+
72+
// Reverse-scroll lives here (not in wrappers) so search behaviour and auto-scroll behaviour
73+
// stay co-located. `initialStickToBottom` flips with `streaming`: streams start at the
74+
// bottom and re-stick on growth; static bundles start at the top. The capture-by-initial
75+
// value is intentional — `streaming` is a structural switch chosen at mount time, not a
76+
// runtime-toggleable prop.
77+
// svelte-ignore state_referenced_locally
78+
const reverseScroll = useReverseScrollContainer({
79+
observeContentElement: (e) => e.firstElementChild!,
80+
initialStickToBottom: streaming
81+
})
82+
83+
let scrollContainer: HTMLDivElement | undefined = $state()
84+
let virtualizer: VirtualizerHandle = $state()!
85+
86+
// `CSS.highlights` is keyed by a static name (the `::highlight(<name>)` selector below can
87+
// not take a dynamic suffix). One LogList visible at a time per host, so a fixed name
88+
// shared across embedders is fine.
89+
const highlightName = 'feldera-log-list-search'
90+
91+
const matchedIndex = $derived(
92+
search.pattern ? findOccurrence(lines, search.pattern, search.occurrenceIndex) : -1
93+
)
94+
95+
function paintHighlight() {
96+
if (!scrollContainer || matchedIndex < 0 || !search.pattern) {
97+
applySearchHighlight(highlightName, null, [])
98+
return
99+
}
100+
const el = scrollContainer.querySelector<HTMLElement>(`[data-rowindex="${matchedIndex}"]`)
101+
applySearchHighlight(
102+
highlightName,
103+
el,
104+
el ? findMatchOffsets(stripAnsi(lines[matchedIndex]), search.pattern) : []
105+
)
106+
}
107+
108+
// Re-paint when the matched row index shifts (streaming evicts rows) or the pattern changes.
109+
// Pure paint — never moves the viewport.
110+
$effect(() => {
111+
void matchedIndex
112+
void search.pattern
113+
paintHighlight()
114+
})
115+
116+
// Bring the match into view ONCE per submission. Tracked only on the SearchState fields,
117+
// and the lookup runs `untrack`ed so streaming updates can't re-trigger the scroll — the
118+
// user retains scroll control after the initial jump.
119+
$effect(() => {
120+
const pattern = search.pattern
121+
const occ = search.occurrenceIndex
122+
if (!pattern) return
123+
untrack(() => {
124+
const idx = findOccurrence(lines, pattern, occ)
125+
if (idx < 0) return
126+
virtualizer?.scrollToIndex(idx, { align: 'center' })
127+
// virtua mounts the freshly-visible row on the next frame; paint then so the Range is
128+
// created against the new DOM node rather than the previously-cleared one.
129+
requestAnimationFrame(paintHighlight)
130+
})
131+
})
132+
133+
// Drop the registered Highlight on unmount so it doesn't leak across hosts.
134+
$effect(() => () => applySearchHighlight(highlightName, null, []))
135+
136+
function defaultGetCopyContent(slice: CopySlice): string {
137+
if (slice === 'all') return lines.map(stripAnsi).join('\n')
138+
const result = lines.slice(slice.start.row, slice.end.row + 1).map(stripAnsi)
139+
result[0] = result[0].slice(slice.start.col)
140+
result[result.length - 1] = result[result.length - 1].slice(
141+
0,
142+
slice.end.col - (slice.start.row === slice.end.row ? slice.start.col : 0)
143+
)
144+
return result.join('\n')
145+
}
146+
</script>
147+
148+
<!-- The outer wrapper is positioned so ScrollDownFab (absolute-positioned) can anchor inside
149+
LogList itself — wrappers don't have to add their own `relative` parent. -->
150+
<div class="relative flex h-full w-full flex-col">
151+
{@render header?.()}
152+
<!-- onscroll re-paints the highlight: when the matched row scrolls into view, virtua mounts
153+
a fresh DOM node, so the previously-painted Range is gone and must be re-created
154+
against the new node. The monospace font + user-select: contain are baked in here so
155+
wrappers don't have to repeat them. -->
156+
<div
157+
bind:this={scrollContainer}
158+
role="textbox"
159+
tabindex={-1}
160+
class="log-list-scroll scrollbar w-full flex-1 overflow-y-auto whitespace-pre-wrap {className}"
161+
{style}
162+
use:reverseScroll.action
163+
use:virtualSelect={{
164+
getRoot: (node) => node.firstElementChild!,
165+
getCopyContent: getCopyContent ?? defaultGetCopyContent
166+
}}
167+
onkeydown={(e) => {
168+
// Ctrl-F (Win/Linux) or Cmd-F (Mac) — redirect to host's search input. Only intercept
169+
// when the host supplied a handler; otherwise the browser's find-in-page is left alone.
170+
if (
171+
onSearchShortcut &&
172+
(e.key === 'f' || e.key === 'F') &&
173+
(e.ctrlKey || e.metaKey) &&
174+
!e.altKey &&
175+
!e.shiftKey
176+
) {
177+
e.preventDefault()
178+
onSearchShortcut()
179+
}
180+
}}
181+
>
182+
<Virtualizer
183+
data={lines}
184+
getKey={(_, i) => i + firstLineIndex}
185+
bind:this={virtualizer}
186+
onscroll={paintHighlight}
187+
>
188+
{#snippet children(value, index)}
189+
<div
190+
data-rowindex={index}
191+
class:logline={showLineNumbers}
192+
style:counter-set={showLineNumbers ? `line ${index + 1}` : undefined}
193+
>
194+
<ANSIDecoratedText {value} />
195+
</div>
196+
{/snippet}
197+
</Virtualizer>
198+
</div>
199+
<ScrollDownFab {reverseScroll}></ScrollDownFab>
200+
</div>
201+
202+
<style>
203+
/* Monospace font baked in. Both embedding apps load DM Mono via @fontsource/dm-mono, so it
204+
resolves; consumers without that font fall back to the system monospace stack. */
205+
.log-list-scroll {
206+
font-family:
207+
'DM Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
208+
'Courier New', monospace;
209+
user-select: contain;
210+
}
211+
/* `::highlight()` is registered globally on `CSS.highlights`, so the matching CSS rule
212+
also has to live at the document root. Class-scoped variants (Tailwind's
213+
`[&::highlight(...)]:bg-…`) work in Chromium but Firefox only honors `::highlight()`
214+
rules whose selector matches at the document level. */
215+
:global(::highlight(feldera-log-list-search)) {
216+
background-color: var(--color-secondary-200);
217+
}
218+
:global(.dark ::highlight(feldera-log-list-search)) {
219+
background-color: var(--color-secondary-800);
220+
}
221+
/* Line-number gutter. Generated content (`::before`) is invisible to DOM text-node
222+
walkers — keeps the search-offset mapping in {@link applySearchHighlight} aligned to
223+
the log content, ignoring the displayed line number. */
224+
.logline {
225+
position: relative;
226+
padding-left: 4rem;
227+
word-break: break-all;
228+
}
229+
.logline::before {
230+
content: counter(line);
231+
position: absolute;
232+
left: 0;
233+
width: 3rem;
234+
padding-right: 0.5rem;
235+
text-align: right;
236+
user-select: none;
237+
color: var(--color-surface-400);
238+
border-right: 1px solid var(--color-surface-200);
239+
}
240+
:global(.dark) .logline::before {
241+
color: var(--color-surface-600);
242+
border-right-color: var(--color-surface-800);
243+
}
244+
</style>

js-packages/web-console/src/lib/components/other/ScrollDownFab.svelte renamed to js-packages/common-ui/src/lib/ScrollDownFab.svelte

File renamed without changes.

js-packages/common-ui/src/lib/index.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ export { default as Tooltip } from './Tooltip.svelte'
55
export { default as Popover } from './Popover.svelte'
66
export { default as PersistentContent } from './PersistentContent.svelte'
77
export { default as ANSIDecoratedText } from './ANSIDecoratedText.svelte'
8+
export { default as LogList } from './LogList.svelte'
9+
export { default as ScrollDownFab } from './ScrollDownFab.svelte'
10+
export { useReverseScrollContainer } from './useReverseScrollContainer.svelte'
11+
export { selectScope, virtualSelect } from './userSelect'
12+
export { stripAnsi } from 'fancy-ansi'
813
export {
914
default as MonacoEditor,
1015
exportedThemes,
@@ -17,3 +22,15 @@ export {
1722
type PersistentHandle
1823
} from './persistentRect.svelte'
1924
export { setSelections, type CodePosition, type CodeRange } from './monaco'
25+
export {
26+
advanceSearch,
27+
applySearchHighlight,
28+
compileSearchPattern,
29+
emptySearchState,
30+
findMatchOffsets,
31+
findOccurrence,
32+
searchPatternsEqual,
33+
type LineMatcher,
34+
type SearchPattern,
35+
type SearchState
36+
} from './logSearch'

0 commit comments

Comments
 (0)