fix: memory analysis dark mode stripe, scan speed, and NaN total size
- Dark mode: add --el-fill-color-lighter and --el-table-current-row-bg-color to .el-table overrides so striped rows render with proper contrast - Scan speed: add batchKeyMemoryUsage pipeline (TYPE + MEMORY USAGE in one Redis round-trip instead of 2×N sequential IPC calls) - Scan speed: decouple scan loop from UI via 300ms flush buffer, keeping el-table re-render frequency capped at ~3/s - Scan speed: use shallowRef for entries to skip deep reactive proxies - NaN fix: handle ioredis stringNumbers:true by converting via Number() instead of typeof === 'number' - formatSize: guard against non-finite input, add TB/PB units
This commit is contained in:
@@ -149,6 +149,9 @@ export function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle('redis:memoryUsage', async (_e, id: string, key: string) => {
|
ipcMain.handle('redis:memoryUsage', async (_e, id: string, key: string) => {
|
||||||
return redis.memoryUsage(id, key)
|
return redis.memoryUsage(id, key)
|
||||||
})
|
})
|
||||||
|
ipcMain.handle('redis:batchMemoryUsage', async (_e, id: string, keys: string[]) => {
|
||||||
|
return redis.batchKeyMemoryUsage(id, keys)
|
||||||
|
})
|
||||||
|
|
||||||
// Redis - String
|
// Redis - String
|
||||||
ipcMain.handle('redis:getString', async (_e, id: string, key: string) => {
|
ipcMain.handle('redis:getString', async (_e, id: string, key: string) => {
|
||||||
|
|||||||
@@ -51,6 +51,41 @@ export async function memoryUsage(id: string, key: string): Promise<number | nul
|
|||||||
return client.memory('USAGE', key) as Promise<number | null>
|
return client.memory('USAGE', key) as Promise<number | null>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface KeyMemoryInfo {
|
||||||
|
key: string
|
||||||
|
type: string
|
||||||
|
size: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch-fetch TYPE + MEMORY USAGE for multiple keys via a single ioredis pipeline.
|
||||||
|
* One Redis round-trip instead of 2×N sequential commands.
|
||||||
|
*/
|
||||||
|
export async function batchKeyMemoryUsage(
|
||||||
|
id: string,
|
||||||
|
keys: string[]
|
||||||
|
): Promise<KeyMemoryInfo[]> {
|
||||||
|
const client = requireClient(id)
|
||||||
|
if (keys.length === 0) return []
|
||||||
|
const pipeline = client.pipeline()
|
||||||
|
for (const key of keys) {
|
||||||
|
pipeline.type(key)
|
||||||
|
pipeline.memory('USAGE', key)
|
||||||
|
}
|
||||||
|
const results = await pipeline.exec()
|
||||||
|
const entries: KeyMemoryInfo[] = []
|
||||||
|
for (let i = 0; i < keys.length; i++) {
|
||||||
|
const typeRes = results?.[i * 2]
|
||||||
|
const sizeRes = results?.[i * 2 + 1]
|
||||||
|
const type = typeRes?.[0] ? 'none' : String(typeRes?.[1] ?? 'none')
|
||||||
|
const rawSize = sizeRes?.[0] ? null : sizeRes?.[1]
|
||||||
|
// ioredis stringNumbers:true may return strings like "528" instead of 528
|
||||||
|
const size = Number.isFinite(Number(rawSize)) ? Number(rawSize) : 0
|
||||||
|
entries.push({ key: keys[i], type, size })
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
export async function scanKeys(
|
export async function scanKeys(
|
||||||
id: string,
|
id: string,
|
||||||
cursor: string,
|
cursor: string,
|
||||||
|
|||||||
Vendored
+1
@@ -55,6 +55,7 @@ export interface ElectronAPI {
|
|||||||
setTTL: (id: string, key: string, ttl: number) => Promise<void>
|
setTTL: (id: string, key: string, ttl: number) => Promise<void>
|
||||||
batchDelete: (id: string, keys: string[]) => Promise<number>
|
batchDelete: (id: string, keys: string[]) => Promise<number>
|
||||||
memoryUsage: (id: string, key: string) => Promise<number | null>
|
memoryUsage: (id: string, key: string) => Promise<number | null>
|
||||||
|
batchMemoryUsage: (id: string, keys: string[]) => Promise<{ key: string; type: string; size: number }[]>
|
||||||
// String
|
// String
|
||||||
getString: (id: string, key: string) => Promise<string | null>
|
getString: (id: string, key: string) => Promise<string | null>
|
||||||
setString: (id: string, key: string, value: string) => Promise<void>
|
setString: (id: string, key: string, value: string) => Promise<void>
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ const electronAPI = {
|
|||||||
// Memory
|
// Memory
|
||||||
memoryUsage: (id: string, key: string): Promise<number | null> =>
|
memoryUsage: (id: string, key: string): Promise<number | null> =>
|
||||||
ipcRenderer.invoke('redis:memoryUsage', id, key),
|
ipcRenderer.invoke('redis:memoryUsage', id, key),
|
||||||
|
batchMemoryUsage: (id: string, keys: string[]): Promise<{ key: string; type: string; size: number }[]> =>
|
||||||
|
ipcRenderer.invoke('redis:batchMemoryUsage', id, keys),
|
||||||
batchDelete: (id: string, keys: string[]): Promise<number> =>
|
batchDelete: (id: string, keys: string[]): Promise<number> =>
|
||||||
ipcRenderer.invoke('redis:batchDelete', id, keys),
|
ipcRenderer.invoke('redis:batchDelete', id, keys),
|
||||||
// Stream
|
// Stream
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onUnmounted } from 'vue'
|
import { ref, shallowRef, computed, onUnmounted } from 'vue'
|
||||||
import { useConnectionStore } from '@renderer/stores/connection'
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
import { useI18n } from '@renderer/i18n'
|
import { useI18n } from '@renderer/i18n'
|
||||||
|
|
||||||
@@ -12,15 +12,18 @@ interface KeyEntry {
|
|||||||
type: string
|
type: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = ref<KeyEntry[]>([])
|
const entries = shallowRef<KeyEntry[]>([])
|
||||||
const scanning = ref(false)
|
const scanning = ref(false)
|
||||||
const paused = ref(false)
|
const paused = ref(false)
|
||||||
const totalSize = ref(0)
|
|
||||||
const scannedCount = ref(0)
|
const scannedCount = ref(0)
|
||||||
const minSizeKB = ref(0)
|
const minSizeKB = ref(0)
|
||||||
const sortAsc = ref(false)
|
const sortAsc = ref(false)
|
||||||
const scanAbort = ref<AbortController | null>(null)
|
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 sortedEntries = computed(() => {
|
const sortedEntries = computed(() => {
|
||||||
const filtered = minSizeKB.value > 0
|
const filtered = minSizeKB.value > 0
|
||||||
? entries.value.filter(e => e.size >= minSizeKB.value * 1024)
|
? entries.value.filter(e => e.size >= minSizeKB.value * 1024)
|
||||||
@@ -31,10 +34,11 @@ const sortedEntries = computed(() => {
|
|||||||
const totalFiltered = computed(() => sortedEntries.value.reduce((s, e) => s + e.size, 0))
|
const totalFiltered = computed(() => sortedEntries.value.reduce((s, e) => s + e.size, 0))
|
||||||
|
|
||||||
function formatSize(bytes: number): string {
|
function formatSize(bytes: number): string {
|
||||||
if (bytes === 0) return '0 B'
|
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
|
||||||
const units = ['B', 'KB', 'MB', 'GB']
|
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||||
return (bytes / Math.pow(1024, Math.floor(i))).toFixed(i > 0 ? 1 : 0) + ' ' + units[Math.min(i, units.length - 1)]
|
const idx = Math.min(i, units.length - 1)
|
||||||
|
return (bytes / Math.pow(1024, idx)).toFixed(idx > 0 ? 1 : 0) + ' ' + units[idx]
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatType(type: string): string {
|
function formatType(type: string): string {
|
||||||
@@ -53,13 +57,24 @@ async function sleep(ms: number) {
|
|||||||
async function startScan() {
|
async function startScan() {
|
||||||
if (!connStore.activeId) return
|
if (!connStore.activeId) return
|
||||||
entries.value = []
|
entries.value = []
|
||||||
totalSize.value = 0
|
|
||||||
scannedCount.value = 0
|
scannedCount.value = 0
|
||||||
scanning.value = true
|
scanning.value = true
|
||||||
paused.value = false
|
paused.value = false
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
scanAbort.value = controller
|
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
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (buffer.length === 0) return
|
||||||
|
const chunk = buffer.splice(0)
|
||||||
|
entries.value = [...entries.value, ...chunk]
|
||||||
|
}
|
||||||
|
|
||||||
|
flushTimer = setInterval(flush, FLUSH_INTERVAL)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let cursor = '0'
|
let cursor = '0'
|
||||||
do {
|
do {
|
||||||
@@ -69,30 +84,23 @@ async function startScan() {
|
|||||||
if (controller.signal.aborted) break
|
if (controller.signal.aborted) break
|
||||||
}
|
}
|
||||||
const result = await window.electronAPI.redis.scanKeys(
|
const result = await window.electronAPI.redis.scanKeys(
|
||||||
connStore.activeId, cursor, '*', 100
|
connStore.activeId, cursor, '*', SCAN_COUNT
|
||||||
)
|
)
|
||||||
cursor = result.cursor
|
cursor = result.cursor
|
||||||
|
if (controller.signal.aborted || result.keys.length === 0) continue
|
||||||
|
|
||||||
for (const key of result.keys) {
|
const batch = await window.electronAPI.redis.batchMemoryUsage(
|
||||||
if (controller.signal.aborted) break
|
connStore.activeId, result.keys
|
||||||
try {
|
)
|
||||||
const [type, size] = await Promise.all([
|
if (controller.signal.aborted) break
|
||||||
window.electronAPI.redis.getKeyType(connStore.activeId, key),
|
buffer.push(...batch)
|
||||||
window.electronAPI.redis.memoryUsage(connStore.activeId, key),
|
scannedCount.value += batch.length
|
||||||
])
|
|
||||||
const sz = size ?? 0
|
|
||||||
entries.value.push({ key, size: sz, type })
|
|
||||||
totalSize.value += sz
|
|
||||||
scannedCount.value++
|
|
||||||
} catch {
|
|
||||||
// skip keys that error
|
|
||||||
}
|
|
||||||
await sleep(10) // throttle to avoid overwhelming Redis
|
|
||||||
}
|
|
||||||
} while (cursor !== '0' && !controller.signal.aborted)
|
} while (cursor !== '0' && !controller.signal.aborted)
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Scan error:', err)
|
console.error('Scan error:', err)
|
||||||
} finally {
|
} finally {
|
||||||
|
if (flushTimer) clearInterval(flushTimer)
|
||||||
|
flush() // final flush: push any remaining buffered entries
|
||||||
scanning.value = false
|
scanning.value = false
|
||||||
paused.value = false
|
paused.value = false
|
||||||
scanAbort.value = null
|
scanAbort.value = null
|
||||||
|
|||||||
@@ -147,6 +147,8 @@ body {
|
|||||||
--el-table-text-color: var(--text-primary);
|
--el-table-text-color: var(--text-primary);
|
||||||
--el-table-header-text-color: var(--text-secondary);
|
--el-table-header-text-color: var(--text-secondary);
|
||||||
--el-table-border-color: var(--border-color);
|
--el-table-border-color: var(--border-color);
|
||||||
|
--el-fill-color-lighter: var(--bg-secondary);
|
||||||
|
--el-table-current-row-bg-color: var(--bg-card-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dialog */
|
/* Dialog */
|
||||||
|
|||||||
Reference in New Issue
Block a user