Skip to content

Commit 6a0b97e

Browse files
committed
fix(LogTable): implement virtual scrolling
- copied from server/apps/settings/src/components/Users/VirtualList.vue, omitting some redundant logic Signed-off-by: Maksim Sukharev <[email protected]>
1 parent bd0f151 commit 6a0b97e

File tree

2 files changed

+128
-38
lines changed

2 files changed

+128
-38
lines changed

src/components/table/LogTable.vue

Lines changed: 118 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
:open.sync="isModalOpen"
99
:current-entry.sync="currentRow"
1010
:log-entries="sortedRows" />
11-
<table class="log-table__table">
12-
<thead>
11+
<table ref="tableRoot" class="log-table__table">
12+
<thead role="rowgroup" class="log-table__header">
1313
<tr>
1414
<LogTableHeader :name="t('logreader', 'Level')"
1515
:sorted.sync="sortedByLevel" />
@@ -23,9 +23,9 @@
2323
<th><span class="hidden-visually">{{ t('logreader', 'Log entry actions') }}</span></th>
2424
</tr>
2525
</thead>
26-
<tbody ref="tableBody">
27-
<tr v-if="sortedByTime === 'ascending'">
28-
<td colspan="5" class="log-table__load-more">
26+
<tbody ref="tableBody" :style="tbodyStyle" class="log-table__body">
27+
<tr v-if="sortedByTime === 'ascending'" class="log-table__load-more">
28+
<td>
2929
<IntersectionObserver v-if="logStore.hasRemainingEntries" @intersection="loadMore">
3030
{{ t('logreader', 'Loading older log entries') }}
3131
</IntersectionObserver>
@@ -35,14 +35,14 @@
3535
</td>
3636
</tr>
3737

38-
<LogTableRow v-for="row, rowNumber in sortedRows"
38+
<LogTableRow v-for="(row, rowNumber) in renderedItems"
3939
:key="rowNumber"
4040
:row="row"
4141
@show-details="showDetailsForRow" />
4242
</tbody>
43-
<tfoot>
44-
<tr v-if="sortedByTime !== 'ascending'">
45-
<td colspan="5" class="log-table__load-more">
43+
<tfoot role="rowgroup" class="log-table__footer">
44+
<tr v-if="sortedByTime !== 'ascending'" class="log-table__load-more">
45+
<td>
4646
<IntersectionObserver v-if="logStore.hasRemainingEntries" @intersection="loadMore">
4747
{{ t('logreader', 'Loading older log entries') }}
4848
</IntersectionObserver>
@@ -59,17 +59,26 @@
5959
<script setup lang="ts">
6060
import type { ILogEntry, ISortingOptions } from '../../interfaces'
6161
62-
import { computed, nextTick, ref } from 'vue'
62+
import { computed, nextTick, onMounted, onBeforeUnmount, ref } from 'vue'
6363
import { translate as t } from '@nextcloud/l10n'
6464
import { useSettingsStore } from '../../store/settings'
6565
import { useLogStore } from '../../store/logging'
66+
import { debounce } from '../../utils/debounce'
67+
import { logger } from '../../utils/logger'
6668
6769
import IntersectionObserver from '../IntersectionObserver.vue'
6870
import LogDetailsModal from '../LogDetailsModal.vue'
6971
import LogTableHeader from './LogTableHeader.vue'
7072
import LogTableRow from './LogTableRow.vue'
7173
import LogSearch from '../LogSearch.vue'
7274
75+
// Items to render before and after the visible area
76+
const bufferItems = 3
77+
// Fixed height of LogTableRow
78+
const itemHeight = 42
79+
// Fixed height of LogTableHeader
80+
const theadHeight = 44
81+
7382
const settingsStore = useSettingsStore()
7483
const logStore = useLogStore()
7584
@@ -110,8 +119,9 @@ const showDetailsForRow = (row: ILogEntry) => {
110119
}
111120
112121
/**
113-
* Reference to the table body, used for keeping scroll position on loading more entries
122+
* Reference to the table elements, used for keeping scroll position on loading more entries
114123
*/
124+
const tableRoot = ref<HTMLElement>()
115125
const tableBody = ref<HTMLElement>()
116126
117127
/**
@@ -146,6 +156,52 @@ const sortedRows = computed(() => {
146156
sorted.sort((a, b) => order(byLevel, sortedByLevel.value, a, b) || order(byApp, sortedByApp.value, a, b) || order(byTime, sortedByTime.value, a, b))
147157
return sorted
148158
})
159+
160+
/**
161+
* Virtual scrolling logic
162+
*/
163+
const resizeObserver = ref<ResizeObserver | null>(null)
164+
165+
const firstVisibleRowIndex = ref(0)
166+
const startIndex = computed(() => Math.max(0, firstVisibleRowIndex.value - bufferItems))
167+
168+
const tableHeight = ref(0)
169+
const itemsInViewport = computed(() => Math.ceil((tableHeight.value - theadHeight) / itemHeight) + bufferItems * 2)
170+
171+
const renderedItems = computed(() => sortedRows.value.slice(startIndex.value, startIndex.value + itemsInViewport.value))
172+
173+
const tbodyStyle = computed(() => {
174+
const isOverScrolled = startIndex.value + itemsInViewport.value > sortedRows.value.length
175+
const lastIndex = sortedRows.value.length - startIndex.value - itemsInViewport.value
176+
const hiddenAfterItems = Math.min(sortedRows.value.length - startIndex.value, lastIndex)
177+
178+
return {
179+
paddingTop: `${startIndex.value * itemHeight}px`,
180+
paddingBottom: isOverScrolled ? 0 : `${hiddenAfterItems * itemHeight}px`,
181+
}
182+
})
183+
184+
onMounted(() => {
185+
resizeObserver.value = new ResizeObserver(debounce(() => {
186+
tableHeight.value = tableRoot.value?.clientHeight ?? 0
187+
logger.debug('VirtualList resizeObserver updated')
188+
onScroll()
189+
}, 100))
190+
191+
resizeObserver.value.observe(tableRoot.value!)
192+
tableRoot.value!.addEventListener('scroll', onScroll)
193+
})
194+
195+
onBeforeUnmount(() => {
196+
if (resizeObserver.value) {
197+
resizeObserver.value.disconnect()
198+
}
199+
})
200+
201+
function onScroll() {
202+
// Max 0 to prevent negative index
203+
firstVisibleRowIndex.value = Math.max(0, Math.round(tableRoot.value!.scrollTop / itemHeight))
204+
}
149205
</script>
150206

151207
<style lang="scss" scoped>
@@ -158,37 +214,65 @@ const sortedRows = computed(() => {
158214
width: calc(100% - 12px);
159215
margin-inline: 6px;
160216
table-layout: fixed;
217+
218+
// Necessary for virtual scroll optimized rendering
219+
display: block;
220+
overflow: auto;
221+
height: 100%;
222+
will-change: scroll-position;
161223
}
162224
163225
&__load-more {
164-
text-align: center;
165-
padding-block: 4px;
166-
}
226+
display: flex;
167227
168-
th, td {
169-
// level column
170-
&:nth-child(1) {
171-
width: 108px;
228+
:deep(td) {
229+
flex-basis: 100%;
230+
text-align: center;
231+
padding-block: 4px;
172232
}
173-
// app column
174-
&:nth-child(2) {
175-
width: 168px;
176-
}
177-
// message column
178-
&:nth-child(3) {
179-
width: 418px;
180-
}
181-
// time column
182-
&:nth-child(4) {
183-
width: 168px;
233+
}
234+
235+
&__header,
236+
&__body,
237+
&__footer {
238+
display: flex;
239+
flex-direction: column;
240+
width: 100%;
241+
242+
:deep(tr) {
243+
display: flex;
184244
}
185-
// actions column
186-
&:last-child {
187-
width: 62px; // 44px button + 18px padding
245+
246+
:deep(th),
247+
:deep(td) {
248+
// level column
249+
&:nth-child(1) {
250+
width: 108px;
251+
}
252+
// app column
253+
&:nth-child(2) {
254+
width: 168px;
255+
}
256+
// message column
257+
&:nth-child(3) {
258+
width: 418px;
259+
flex-grow: 1;
260+
}
261+
// time column
262+
&:nth-child(4) {
263+
width: 168px;
264+
}
265+
// actions column
266+
&:last-child {
267+
width: 62px; // 44px button + 18px padding
268+
}
188269
}
189270
}
190271
191-
thead {
272+
&__header {
273+
position: sticky;
274+
top: 0;
275+
z-index: 1;
192276
min-height: 44px;
193277
194278
:deep(th) {
@@ -200,7 +284,7 @@ const sortedRows = computed(() => {
200284
}
201285
}
202286
203-
tbody {
287+
&__body {
204288
// Some spacing for first row
205289
&:before {
206290
content: '\200c';

src/components/table/LogTableRow.vue

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
<div class="row-message__text">
1616
<LogException v-if="row.exception" :exception="row.exception" />
1717
<!-- Show log message if either there is no exception or a custom message was added -->
18-
<div v-if="!row.exception || row.message !== row.exception.Message" class="row-message__text_message" :title="row.message">
18+
<div v-if="!row.exception || (isExpanded && row.message !== row.exception.Message)"
19+
class="row-message__text_message" :title="row.message">
1920
{{ row.message }}
2021
</div>
2122
</div>
@@ -117,6 +118,11 @@ const timestamp = computed(() => Date.parse(props.row.time))
117118
*/
118119
const isExpanded = ref(false)
119120
121+
// FIXME: as components reused, another row will be expanded during scroll, so should close on prop change
122+
watch(() => props.row, () => {
123+
isExpanded.value = false
124+
})
125+
120126
/**
121127
* Human readable and localized level name
122128
*/
@@ -175,10 +181,10 @@ watch(isExpanded, () => resizeTabeRow)
175181

176182
<style lang="scss" scoped>
177183
td {
178-
display: table-cell;
184+
display: block;
179185
overflow: hidden;
180186
text-overflow: ellipsis;
181-
vertical-align: top;
187+
min-height: 42px;
182188
padding-block-start: 4px;
183189
padding-inline: 18px 0;
184190
}
@@ -215,7 +221,7 @@ td {
215221
}
216222
217223
tr {
218-
display: table-row;
224+
display: flex;
219225
&.expanded {
220226
white-space: normal;
221227

0 commit comments

Comments
 (0)