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:
+1
-1
@@ -13,7 +13,7 @@
|
||||
|
||||
## P1 — 核心功能缺失
|
||||
|
||||
- [ ] **内存分析** — `MEMORY USAGE` 扫描所有 key,虚拟化列表,按大小排序,暂停/恢复,大小过滤
|
||||
- [x] **内存分析** — `MEMORY USAGE` 扫描所有 key,虚拟化列表,按大小排序,暂停/恢复,大小过滤
|
||||
- [ ] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤
|
||||
- [ ] **自动更新** — electron-updater 集成,启动检查,下载进度,发行说明
|
||||
- [ ] **多标签页** — 单连接支持同时打开 Status/Keys/CLI/SlowLog/内存分析/批量删除 多个标签
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '数据库',
|
||||
|
||||
Reference in New Issue
Block a user