perf: replace el-table with virtual scroll for memory analysis
- Replace el-table (renders all DOM rows) with lightweight virtual scroll (only renders visible rows + buffer). This is the key fix for scan lag. - Reduce flush interval from 300ms to 100ms (matches legacy setTimeout(100)) - Virtual list uses fixed 36px row height, calculates visible range from scrollTop, and translates only the rendered window. - Stripe rows via CSS alt class instead of Element Plus stripe prop. - Column layout: flex-based with key (flex:1), type (80px), size (100px).
This commit is contained in:
@@ -21,8 +21,13 @@ const sortAsc = ref(false)
|
||||
const scanAbort = ref<AbortController | null>(null)
|
||||
|
||||
const SCAN_COUNT = 200
|
||||
/** How often (ms) to flush buffered scan results into the reactive ref */
|
||||
const FLUSH_INTERVAL = 300
|
||||
const FLUSH_INTERVAL = 100
|
||||
|
||||
// --- Virtual scroll ---
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const ROW_HEIGHT = 36
|
||||
const BUFFER = 10
|
||||
|
||||
const sortedEntries = computed(() => {
|
||||
const filtered = minSizeKB.value > 0
|
||||
@@ -33,6 +38,34 @@ const sortedEntries = computed(() => {
|
||||
|
||||
const totalFiltered = computed(() => sortedEntries.value.reduce((s, e) => s + e.size, 0))
|
||||
|
||||
const totalHeight = computed(() => sortedEntries.value.length * ROW_HEIGHT)
|
||||
|
||||
const visibleRange = computed(() => {
|
||||
const container = listRef.value
|
||||
if (!container) return { start: 0, end: 50 }
|
||||
const viewHeight = container.clientHeight - ROW_HEIGHT // minus header
|
||||
const start = Math.max(0, Math.floor(scrollTop.value / ROW_HEIGHT) - BUFFER)
|
||||
const end = Math.min(
|
||||
sortedEntries.value.length,
|
||||
Math.ceil((scrollTop.value + viewHeight) / ROW_HEIGHT) + BUFFER,
|
||||
)
|
||||
return { start, end }
|
||||
})
|
||||
|
||||
const visibleItems = computed(() => {
|
||||
const { start, end } = visibleRange.value
|
||||
return sortedEntries.value.slice(start, end).map((item, i) => ({
|
||||
...item,
|
||||
_idx: start + i,
|
||||
}))
|
||||
})
|
||||
|
||||
const offsetY = computed(() => visibleRange.value.start * ROW_HEIGHT)
|
||||
|
||||
function onScroll(e: Event) {
|
||||
scrollTop.value = (e.target as HTMLElement).scrollTop
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||
@@ -58,12 +91,12 @@ async function startScan() {
|
||||
if (!connStore.activeId) return
|
||||
entries.value = []
|
||||
scannedCount.value = 0
|
||||
scrollTop.value = 0
|
||||
scanning.value = true
|
||||
paused.value = false
|
||||
const controller = new AbortController()
|
||||
scanAbort.value = controller
|
||||
|
||||
// Non-reactive buffer: scan loop pushes here, timer flushes to entries.value
|
||||
const buffer: KeyEntry[] = []
|
||||
let flushTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@@ -84,13 +117,13 @@ async function startScan() {
|
||||
if (controller.signal.aborted) break
|
||||
}
|
||||
const result = await window.electronAPI.redis.scanKeys(
|
||||
connStore.activeId, cursor, '*', SCAN_COUNT
|
||||
connStore.activeId, cursor, '*', SCAN_COUNT,
|
||||
)
|
||||
cursor = result.cursor
|
||||
if (controller.signal.aborted || result.keys.length === 0) continue
|
||||
|
||||
const batch = await window.electronAPI.redis.batchMemoryUsage(
|
||||
connStore.activeId, result.keys
|
||||
connStore.activeId, result.keys,
|
||||
)
|
||||
if (controller.signal.aborted) break
|
||||
buffer.push(...batch)
|
||||
@@ -100,7 +133,7 @@ async function startScan() {
|
||||
console.error('Scan error:', err)
|
||||
} finally {
|
||||
if (flushTimer) clearInterval(flushTimer)
|
||||
flush() // final flush: push any remaining buffered entries
|
||||
flush()
|
||||
scanning.value = false
|
||||
paused.value = false
|
||||
scanAbort.value = null
|
||||
@@ -170,23 +203,31 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ma-table-wrap">
|
||||
<el-table
|
||||
:data="sortedEntries"
|
||||
size="small"
|
||||
stripe
|
||||
height="100%"
|
||||
highlight-current-row
|
||||
<div class="ma-list-wrap">
|
||||
<!-- Header row (sticky) -->
|
||||
<div class="ma-list-header">
|
||||
<span class="ma-col ma-col-key">{{ t('memory.key') }}</span>
|
||||
<span class="ma-col ma-col-type">{{ t('memory.type') }}</span>
|
||||
<span class="ma-col ma-col-size">{{ t('memory.size') }}</span>
|
||||
</div>
|
||||
<!-- Virtual scroll body -->
|
||||
<div v-if="sortedEntries.length > 0" ref="listRef" class="ma-list-body" @scroll="onScroll">
|
||||
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
|
||||
<div :style="{ transform: `translateY(${offsetY}px)` }">
|
||||
<div
|
||||
v-for="item in visibleItems"
|
||||
:key="item.key"
|
||||
class="ma-row"
|
||||
:class="{ 'ma-row-alt': item._idx % 2 !== 0 }"
|
||||
>
|
||||
<el-table-column prop="key" :label="t('memory.key')" min-width="200" show-overflow-tooltip />
|
||||
<el-table-column prop="type" :label="t('memory.type')" width="100">
|
||||
<template #default="{ row }">{{ formatType(row.type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="size" :label="t('memory.size')" width="120" sortable>
|
||||
<template #default="{ row }">{{ formatSize(row.size) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!scanning && entries.length === 0" class="ma-empty">
|
||||
<span class="ma-col ma-col-key" :title="item.key">{{ item.key }}</span>
|
||||
<span class="ma-col ma-col-type">{{ formatType(item.type) }}</span>
|
||||
<span class="ma-col ma-col-size">{{ formatSize(item.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!scanning && entries.length === 0" class="ma-empty">
|
||||
{{ t('memory.noData') }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -258,9 +299,83 @@ onUnmounted(() => {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ma-table-wrap {
|
||||
/* --- Virtual list --- */
|
||||
.ma-list-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.ma-list-header {
|
||||
display: flex;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ma-list-header .ma-col {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ma-list-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.ma-row {
|
||||
display: flex;
|
||||
height: 36px;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ma-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.ma-row-alt {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.ma-row-alt:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.ma-col {
|
||||
font-size: 12px;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ma-col-key {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ma-col-type {
|
||||
width: 80px;
|
||||
flex-shrink: 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ma-col-size {
|
||||
width: 100px;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.ma-empty {
|
||||
|
||||
Reference in New Issue
Block a user