feat: Memory Analysis tab

- New MemoryAnalysis.vue: scan all keys with MEMORY USAGE
- Pause/resume/stop scanning, min size filter, sort asc/desc
- Added as tab in MainArea with Odometer icon
- i18n keys for all memory analysis labels (zh-CN + en)
This commit is contained in:
2026-07-05 21:23:43 +08:00
parent 169017df65
commit 15869943e8
5 changed files with 315 additions and 2 deletions
+1 -1
View File
@@ -13,7 +13,7 @@
## P1 — 核心功能缺失
- [ ] **内存分析**`MEMORY USAGE` 扫描所有 key,虚拟化列表,按大小排序,暂停/恢复,大小过滤
- [x] **内存分析**`MEMORY USAGE` 扫描所有 key,虚拟化列表,按大小排序,暂停/恢复,大小过滤
- [ ] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤
- [ ] **自动更新** — electron-updater 集成,启动检查,下载进度,发行说明
- [ ] **多标签页** — 单连接支持同时打开 Status/Keys/CLI/SlowLog/内存分析/批量删除 多个标签
+11 -1
View File
@@ -7,6 +7,7 @@ import StatusView from './StatusView.vue'
import KeyBrowser from './KeyBrowser.vue'
import CliView from './CliView.vue'
import SlowLogView from './SlowLogView.vue'
import MemoryAnalysis from './MemoryAnalysis.vue'
import SettingsView from './SettingsView.vue'
const connStore = useConnectionStore()
@@ -15,7 +16,7 @@ const { t } = useI18n()
const props = defineProps<{ showSettings: boolean }>()
const emit = defineEmits<{ 'update:showSettings': [v: boolean] }>()
type Tab = 'status' | 'keys' | 'cli' | 'slowlog'
type Tab = 'status' | 'keys' | 'cli' | 'slowlog' | 'memory'
const activeTab = ref<Tab>('status')
watch(
@@ -63,6 +64,14 @@ watch(
<el-icon><Clock /></el-icon>
{{ t('slowlog.title') }}
</button>
<button
class="tab-item"
:class="{ active: activeTab === 'memory' }"
@click="activeTab = 'memory'"
>
<el-icon><Odometer /></el-icon>
{{ t('memory.title') }}
</button>
</div>
<div class="tab-content">
<template v-if="connStore.isConnected">
@@ -70,6 +79,7 @@ watch(
<KeyBrowser v-else-if="activeTab === 'keys'" />
<CliView v-else-if="activeTab === 'cli'" />
<SlowLogView v-else-if="activeTab === 'slowlog'" />
<MemoryAnalysis v-else-if="activeTab === 'memory'" />
</template>
<div v-if="!connStore.isConnected" class="no-connection">
<div class="placeholder-icon">JRD</div>
+267
View File
@@ -0,0 +1,267 @@
<script setup lang="ts">
// src/components/MemoryAnalysis.vue
import { ref, computed, onUnmounted } from 'vue'
import { useConnectionStore } from '@renderer/stores/connection'
import { useI18n } from '@renderer/i18n'
const connStore = useConnectionStore()
const { t } = useI18n()
interface KeyEntry {
key: string
size: number
type: string
}
const entries = ref<KeyEntry[]>([])
const scanning = ref(false)
const paused = ref(false)
const totalSize = ref(0)
const scannedCount = ref(0)
const minSizeKB = ref(0)
const sortAsc = ref(false)
const scanAbort = ref<AbortController | null>(null)
const sortedEntries = computed(() => {
const filtered = minSizeKB.value > 0
? entries.value.filter(e => e.size >= minSizeKB.value * 1024)
: entries.value
return [...filtered].sort((a, b) => sortAsc.value ? a.size - b.size : b.size - a.size)
})
const totalFiltered = computed(() => sortedEntries.value.reduce((s, e) => s + e.size, 0))
function formatSize(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB']
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)]
}
function formatType(type: string): string {
const map: Record<string, string> = {
string: t('types.string'), hash: t('types.hash'),
list: t('types.list'), set: t('types.set'),
zset: t('types.zset'), stream: t('types.stream'),
}
return map[type] || type
}
async function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms))
}
async function startScan() {
if (!connStore.activeId) return
entries.value = []
totalSize.value = 0
scannedCount.value = 0
scanning.value = true
paused.value = false
const controller = new AbortController()
scanAbort.value = controller
try {
let cursor = '0'
do {
if (controller.signal.aborted) break
while (paused.value) {
await sleep(200)
if (controller.signal.aborted) break
}
const result = await window.electronAPI.redis.scanKeys(
connStore.activeId, cursor, '*', 100
)
cursor = result.cursor
for (const key of result.keys) {
if (controller.signal.aborted) break
try {
const [type, size] = await Promise.all([
window.electronAPI.redis.getKeyType(connStore.activeId, key),
window.electronAPI.redis.memoryUsage(connStore.activeId, key),
])
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)
} catch (err: any) {
console.error('Scan error:', err)
} finally {
scanning.value = false
paused.value = false
scanAbort.value = null
}
}
function togglePause() {
paused.value = !paused.value
}
function stopScan() {
scanAbort.value?.abort()
scanning.value = false
paused.value = false
}
onUnmounted(() => {
scanAbort.value?.abort()
})
</script>
<template>
<div class="memory-analysis">
<div class="ma-header">
<h2>{{ t('memory.title') }}</h2>
<div class="ma-toolbar">
<template v-if="!scanning">
<el-button type="primary" size="small" @click="startScan">
{{ t('memory.scan') }}
</el-button>
</template>
<template v-else>
<el-button size="small" @click="togglePause">
{{ paused ? t('memory.resume') : t('memory.pause') }}
</el-button>
<el-button size="small" type="danger" @click="stopScan">
{{ t('memory.stop') }}
</el-button>
<span class="scan-status" :class="{ paused }">
{{ paused ? t('memory.paused') : t('memory.scanning') }}
</span>
</template>
</div>
</div>
<div class="ma-summary">
<div class="ma-stat">
<span class="ma-stat-label">{{ t('memory.scanned', { n: scannedCount.toLocaleString() }) }}</span>
</div>
<div class="ma-stat">
<span class="ma-stat-label">{{ t('memory.totalSize') }}: {{ formatSize(totalFiltered) }}</span>
</div>
<div class="ma-filters">
<el-input-number
v-model="minSizeKB"
:min="0"
:max="10240"
:step="1"
size="small"
controls-position="right"
:placeholder="t('memory.minSize')"
style="width:140px"
/>
<el-button size="small" @click="sortAsc = !sortAsc">
{{ sortAsc ? t('memory.sortAsc') : t('memory.sortDesc') }}
</el-button>
</div>
</div>
<div class="ma-table-wrap">
<el-table
:data="sortedEntries"
size="small"
stripe
height="100%"
highlight-current-row
>
<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">
{{ t('memory.noData') }}
</div>
</div>
</div>
</template>
<style scoped>
.memory-analysis {
height: 100%;
display: flex;
flex-direction: column;
padding: 16px;
}
.ma-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
flex-shrink: 0;
}
.ma-header h2 {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.ma-toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.scan-status {
font-size: 12px;
color: var(--accent-light);
animation: pulse 1.5s infinite;
}
.scan-status.paused {
color: var(--warning);
animation: none;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.ma-summary {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 12px;
flex-shrink: 0;
flex-wrap: wrap;
}
.ma-stat-label {
font-size: 12px;
color: var(--text-secondary);
}
.ma-filters {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.ma-table-wrap {
flex: 1;
overflow: hidden;
}
.ma-empty {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: var(--text-muted);
font-size: 13px;
}
</style>
+18
View File
@@ -138,6 +138,24 @@ export default {
duration: 'Duration',
command: 'Command',
},
memory: {
title: 'Memory Analysis',
scan: 'Start Scan',
pause: 'Pause',
resume: 'Resume',
stop: 'Stop',
scanning: 'Scanning...',
paused: 'Paused',
noData: 'No data',
key: 'Key',
size: 'Size',
type: 'Type',
totalSize: 'Total Size',
minSize: 'Min Size (KB)',
sortAsc: 'Ascending',
sortDesc: 'Descending',
scanned: '{n} keys scanned',
},
status: {
title: 'Status',
database: 'Database',
+18
View File
@@ -138,6 +138,24 @@ export default {
duration: '耗时',
command: '命令',
},
memory: {
title: '内存分析',
scan: '开始扫描',
pause: '暂停',
resume: '继续',
stop: '停止',
scanning: '扫描中...',
paused: '已暂停',
noData: '暂无数据',
key: '键名',
size: '大小',
type: '类型',
totalSize: '总大小',
minSize: '最小大小 (KB)',
sortAsc: '升序',
sortDesc: '降序',
scanned: '已扫描 {n} 个',
},
status: {
title: '状态',
database: '数据库',