Skip to content

Commit 85eb6c9

Browse files
committed
refactor(nginx-log): add virtual scroll container #1355
1 parent 464caf5 commit 85eb6c9

File tree

1 file changed

+194
-38
lines changed

1 file changed

+194
-38
lines changed

app/src/views/nginx_log/raw/RawLogViewer.vue

Lines changed: 194 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import type ReconnectingWebSocket from 'reconnecting-websocket'
33
import type { NginxLogData } from '@/api/nginx_log'
4+
import { useElementSize } from '@vueuse/core'
45
import { debounce } from 'lodash'
56
import nginx_log from '@/api/nginx_log'
67
import ws from '@/lib/websocket'
@@ -18,25 +19,98 @@ const logContainer = useTemplateRef('logContainer')
1819
1920
// Reactive data
2021
let websocket: ReconnectingWebSocket | WebSocket
21-
const buffer = ref('')
22+
// Line-based storage for virtualization
23+
const lines = ref<string[]>([])
24+
const tailFragment = ref('') // carry over partial line when appending
2225
const page = ref(0)
2326
const loading = ref(false)
2427
const filter = ref('')
28+
// Whether to follow the log tail (auto-scroll only when near bottom)
29+
const isFollowingBottom = ref(true)
2530
2631
// Setup log control data
2732
const control = computed<NginxLogData>(() => ({
2833
type: props.logType,
2934
path: props.logPath,
3035
}))
3136
32-
// Computed buffer with filtering
33-
const computedBuffer = computed(() => {
34-
if (filter.value)
35-
return buffer.value.split('\n').filter(line => line.match(filter.value)).join('\n')
37+
// Filtering
38+
const filterRegex = computed<RegExp | null>(() => {
39+
if (!filter.value)
40+
return null
41+
try {
42+
return new RegExp(filter.value)
43+
}
44+
catch {
45+
return null
46+
}
47+
})
48+
49+
const filteredIndices = computed<number[]>(() => {
50+
const total = lines.value.length
51+
if (!filterRegex.value)
52+
return Array.from({ length: total }, (_, i) => i)
53+
const regex = filterRegex.value
54+
const result: number[] = []
55+
for (let i = 0; i < total; i++) {
56+
if (regex!.test(lines.value[i]))
57+
result.push(i)
58+
}
59+
return result
60+
})
3661
37-
return buffer.value
62+
// Virtual scroll measurements
63+
const lineHeight = ref(24)
64+
const scrollTop = ref(0)
65+
const { height: containerHeight } = useElementSize(logContainer)
66+
// Dynamic overscan: 3x viewport lines, min 60 lines
67+
const overscanLines = computed(() => {
68+
const vh = containerHeight.value || 0
69+
const linesInView = Math.max(1, Math.ceil(vh / lineHeight.value))
70+
return Math.max(60, linesInView * 3)
3871
})
3972
73+
const totalCount = computed(() => filteredIndices.value.length)
74+
const atTop = ref(true)
75+
const atBottom = ref(false)
76+
const visibleStartIndex = computed(() => {
77+
if (atTop.value)
78+
return 0
79+
const start = Math.floor(scrollTop.value / lineHeight.value) - overscanLines.value
80+
// Also ensure we don't start beyond the last full window
81+
const viewportLines = Math.ceil((containerHeight.value || 0) / lineHeight.value)
82+
const maxStart = Math.max(0, totalCount.value - (viewportLines + overscanLines.value))
83+
return Math.min(Math.max(0, start), maxStart)
84+
})
85+
const visibleEndIndex = computed(() => {
86+
if (atBottom.value)
87+
return totalCount.value
88+
const viewportLines = Math.ceil((containerHeight.value || 0) / lineHeight.value)
89+
const maxVisible = viewportLines + overscanLines.value * 2
90+
const end = visibleStartIndex.value + Math.max(1, maxVisible)
91+
return Math.min(totalCount.value, end)
92+
})
93+
const topPaddingPx = computed(() => {
94+
// Clamp to avoid overshoot near top causing visible gap
95+
const px = visibleStartIndex.value * lineHeight.value
96+
return Math.max(0, Math.min(px, Math.max(0, totalCount.value * lineHeight.value - (containerHeight.value || 0))))
97+
})
98+
const bottomPaddingPx = computed(() => {
99+
// Ensure top+visible+bottom equals total height; avoid negative
100+
const totalHeight = totalCount.value * lineHeight.value
101+
const visibleHeight = (visibleEndIndex.value - visibleStartIndex.value) * lineHeight.value
102+
const px = totalHeight - topPaddingPx.value - visibleHeight
103+
return Math.max(0, px)
104+
})
105+
const visibleLines = computed(() => {
106+
const idxs = filteredIndices.value.slice(visibleStartIndex.value, visibleEndIndex.value)
107+
return idxs.map(i => lines.value[i])
108+
})
109+
110+
// Style objects to avoid string concatenation in template
111+
const topPaddingStyle = computed(() => ({ height: `${topPaddingPx.value}px` }))
112+
const bottomPaddingStyle = computed(() => ({ height: `${bottomPaddingPx.value}px` }))
113+
40114
// WebSocket functions
41115
function openWs() {
42116
websocket = ws('/api/nginx_log')
@@ -50,22 +124,57 @@ function openWs() {
50124
}
51125
}
52126
127+
function isNearBottom(elem: HTMLElement, thresholdPx: number = 40): boolean {
128+
return elem.scrollTop + elem.clientHeight >= elem.scrollHeight - thresholdPx
129+
}
130+
53131
function addLog(data: string, prepend: boolean = false) {
54-
if (prepend)
55-
buffer.value = data + buffer.value
56-
else
57-
buffer.value += data
132+
const elem = logContainer.value as HTMLElement | undefined
58133
59-
nextTick(() => {
60-
logContainer.value?.scroll({
61-
top: logContainer.value.scrollHeight,
62-
left: 0,
134+
// Prepend: keep viewport stable by compensating with added lines
135+
if (prepend) {
136+
const parts = data.split('\n')
137+
if (parts.length && parts[parts.length - 1] === '')
138+
parts.pop()
139+
140+
const addedCount = parts.length
141+
if (addedCount === 0)
142+
return
143+
144+
const prevScrollTop = elem?.scrollTop ?? 0
145+
lines.value = parts.concat(lines.value)
146+
nextTick(() => {
147+
if (elem)
148+
elem.scrollTop = prevScrollTop + addedCount * lineHeight.value
149+
if (elem)
150+
scrollTop.value = elem.scrollTop
63151
})
152+
return
153+
}
154+
155+
// Append: only auto-scroll to bottom when user is near bottom or following
156+
const shouldAutoScroll = elem ? (isFollowingBottom.value || isNearBottom(elem)) : true
157+
158+
const chunk = tailFragment.value + data
159+
const parts = chunk.split('\n')
160+
tailFragment.value = parts.pop() ?? ''
161+
if (parts.length)
162+
lines.value.push(...parts)
163+
164+
nextTick(() => {
165+
if (shouldAutoScroll && logContainer.value) {
166+
logContainer.value.scroll({
167+
top: logContainer.value.scrollHeight,
168+
left: 0,
169+
})
170+
scrollTop.value = logContainer.value.scrollTop
171+
}
64172
})
65173
}
66174
67175
function clearLog() {
68-
buffer.value = ''
176+
lines.value = []
177+
tailFragment.value = ''
69178
}
70179
71180
// Initialize log loading
@@ -83,27 +192,45 @@ function init() {
83192
}
84193
85194
// Scroll handling for pagination
86-
function onScrollLog() {
87-
if (!loading.value && page.value > 0) {
88-
loading.value = true
89-
90-
const elem = logContainer.value!
195+
// Prefetch threshold: start preloading when near top long before hitting it
196+
const prefetchTopThresholdPx = computed(() => {
197+
const vh = containerHeight.value || 0
198+
const minPx = lineHeight.value * 120 // at least ~120 lines
199+
return Math.max(minPx, vh * 1.25)
200+
})
91201
92-
if (elem?.scrollTop / elem?.scrollHeight < 0.333) {
93-
nginx_log.page(page.value, control.value).then(r => {
94-
page.value = r.page - 1
95-
addLog(r.content, true)
96-
}).finally(() => {
97-
loading.value = false
98-
})
99-
}
100-
else {
202+
function prefetchIfNeeded() {
203+
if (loading.value || page.value <= 0)
204+
return
205+
const elem = logContainer.value as HTMLElement | undefined
206+
if (!elem)
207+
return
208+
// Early prefetch when top padding is small (close to top)
209+
if (topPaddingPx.value <= prefetchTopThresholdPx.value) {
210+
loading.value = true
211+
nginx_log.page(page.value, control.value).then(r => {
212+
page.value = r.page - 1
213+
addLog(r.content, true)
214+
}).finally(() => {
101215
loading.value = false
102-
}
216+
})
103217
}
104218
}
105219
106-
const debounceScrollLog = debounce(onScrollLog, 100)
220+
const debouncedPrefetch = debounce(prefetchIfNeeded, 80)
221+
222+
function onScroll() {
223+
const elem = logContainer.value as HTMLElement | undefined
224+
if (elem) {
225+
scrollTop.value = elem.scrollTop
226+
isFollowingBottom.value = isNearBottom(elem)
227+
const vh = containerHeight.value || 0
228+
atTop.value = elem.scrollTop <= 1
229+
const maxScrollTop = Math.max(0, totalCount.value * lineHeight.value - vh)
230+
atBottom.value = maxScrollTop - elem.scrollTop <= 1
231+
}
232+
debouncedPrefetch()
233+
}
107234
108235
// Watch for auto refresh changes
109236
watch(() => props.autoRefresh, async value => {
@@ -134,6 +261,22 @@ watch(control, () => {
134261
// Initialize on mount
135262
onMounted(() => {
136263
init()
264+
// Try to measure line height after mount
265+
nextTick(() => {
266+
const elem = logContainer.value as HTMLElement | undefined
267+
if (!elem)
268+
return
269+
const probe = document.createElement('div')
270+
probe.className = 'nginx-log-line'
271+
probe.textContent = 'A'
272+
probe.style.visibility = 'hidden'
273+
probe.style.position = 'absolute'
274+
elem.appendChild(probe)
275+
const h = probe.getBoundingClientRect().height
276+
elem.removeChild(probe)
277+
if (h)
278+
lineHeight.value = h
279+
})
137280
})
138281
139282
// Cleanup on unmount
@@ -161,25 +304,38 @@ defineExpose({
161304
</AFormItem>
162305
</AForm>
163306

164-
<!-- Raw Log Display -->
307+
<!-- Raw Log Display (virtualized) -->
165308
<ACard>
166-
<pre
309+
<div
167310
ref="logContainer"
168-
v-dompurify-html="computedBuffer"
169311
class="nginx-log-container"
170-
@scroll="debounceScrollLog"
171-
/>
312+
@scroll="onScroll"
313+
>
314+
<div :style="topPaddingStyle" />
315+
<div
316+
v-for="(line, idx) in visibleLines"
317+
:key="visibleStartIndex + idx"
318+
class="nginx-log-line"
319+
v-text="line"
320+
/>
321+
<div :style="bottomPaddingStyle" />
322+
</div>
172323
</ACard>
173324
</div>
174325
</template>
175326

176327
<style lang="less">
177328
.nginx-log-container {
178329
height: 60vh;
179-
padding: 5px;
180-
margin-bottom: 0;
330+
padding: 0;
331+
margin: 0;
181332
182333
font-size: 12px;
183334
line-height: 2;
335+
overflow: auto;
336+
}
337+
338+
.nginx-log-line {
339+
white-space: pre; // prevent wrapping to keep constant line height
184340
}
185341
</style>

0 commit comments

Comments
 (0)