11<script setup lang="ts">
22import type ReconnectingWebSocket from ' reconnecting-websocket'
33import type { NginxLogData } from ' @/api/nginx_log'
4+ import { useElementSize } from ' @vueuse/core'
45import { debounce } from ' lodash'
56import nginx_log from ' @/api/nginx_log'
67import ws from ' @/lib/websocket'
@@ -18,25 +19,98 @@ const logContainer = useTemplateRef('logContainer')
1819
1920// Reactive data
2021let 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
2225const page = ref (0 )
2326const loading = ref (false )
2427const 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
2732const 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
41115function 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+
53131function 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
67175function 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
109236watch (() => props .autoRefresh , async value => {
@@ -134,6 +261,22 @@ watch(control, () => {
134261// Initialize on mount
135262onMounted (() => {
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 : 5 px ;
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