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 — 核心功能缺失
|
## P1 — 核心功能缺失
|
||||||
|
|
||||||
- [ ] **内存分析** — `MEMORY USAGE` 扫描所有 key,虚拟化列表,按大小排序,暂停/恢复,大小过滤
|
- [x] **内存分析** — `MEMORY USAGE` 扫描所有 key,虚拟化列表,按大小排序,暂停/恢复,大小过滤
|
||||||
- [ ] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤
|
- [ ] **命令日志** — 记录每次命令的耗时/连接/时间,最多 5000 条,只写模式过滤
|
||||||
- [ ] **自动更新** — electron-updater 集成,启动检查,下载进度,发行说明
|
- [ ] **自动更新** — electron-updater 集成,启动检查,下载进度,发行说明
|
||||||
- [ ] **多标签页** — 单连接支持同时打开 Status/Keys/CLI/SlowLog/内存分析/批量删除 多个标签
|
- [ ] **多标签页** — 单连接支持同时打开 Status/Keys/CLI/SlowLog/内存分析/批量删除 多个标签
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import StatusView from './StatusView.vue'
|
|||||||
import KeyBrowser from './KeyBrowser.vue'
|
import KeyBrowser from './KeyBrowser.vue'
|
||||||
import CliView from './CliView.vue'
|
import CliView from './CliView.vue'
|
||||||
import SlowLogView from './SlowLogView.vue'
|
import SlowLogView from './SlowLogView.vue'
|
||||||
|
import MemoryAnalysis from './MemoryAnalysis.vue'
|
||||||
import SettingsView from './SettingsView.vue'
|
import SettingsView from './SettingsView.vue'
|
||||||
|
|
||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
@@ -15,7 +16,7 @@ const { t } = useI18n()
|
|||||||
const props = defineProps<{ showSettings: boolean }>()
|
const props = defineProps<{ showSettings: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:showSettings': [v: 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')
|
const activeTab = ref<Tab>('status')
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -63,6 +64,14 @@ watch(
|
|||||||
<el-icon><Clock /></el-icon>
|
<el-icon><Clock /></el-icon>
|
||||||
{{ t('slowlog.title') }}
|
{{ t('slowlog.title') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === 'memory' }"
|
||||||
|
@click="activeTab = 'memory'"
|
||||||
|
>
|
||||||
|
<el-icon><Odometer /></el-icon>
|
||||||
|
{{ t('memory.title') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
<template v-if="connStore.isConnected">
|
<template v-if="connStore.isConnected">
|
||||||
@@ -70,6 +79,7 @@ watch(
|
|||||||
<KeyBrowser v-else-if="activeTab === 'keys'" />
|
<KeyBrowser v-else-if="activeTab === 'keys'" />
|
||||||
<CliView v-else-if="activeTab === 'cli'" />
|
<CliView v-else-if="activeTab === 'cli'" />
|
||||||
<SlowLogView v-else-if="activeTab === 'slowlog'" />
|
<SlowLogView v-else-if="activeTab === 'slowlog'" />
|
||||||
|
<MemoryAnalysis v-else-if="activeTab === 'memory'" />
|
||||||
</template>
|
</template>
|
||||||
<div v-if="!connStore.isConnected" class="no-connection">
|
<div v-if="!connStore.isConnected" class="no-connection">
|
||||||
<div class="placeholder-icon">JRD</div>
|
<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',
|
duration: 'Duration',
|
||||||
command: 'Command',
|
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: {
|
status: {
|
||||||
title: 'Status',
|
title: 'Status',
|
||||||
database: 'Database',
|
database: 'Database',
|
||||||
|
|||||||
@@ -138,6 +138,24 @@ export default {
|
|||||||
duration: '耗时',
|
duration: '耗时',
|
||||||
command: '命令',
|
command: '命令',
|
||||||
},
|
},
|
||||||
|
memory: {
|
||||||
|
title: '内存分析',
|
||||||
|
scan: '开始扫描',
|
||||||
|
pause: '暂停',
|
||||||
|
resume: '继续',
|
||||||
|
stop: '停止',
|
||||||
|
scanning: '扫描中...',
|
||||||
|
paused: '已暂停',
|
||||||
|
noData: '暂无数据',
|
||||||
|
key: '键名',
|
||||||
|
size: '大小',
|
||||||
|
type: '类型',
|
||||||
|
totalSize: '总大小',
|
||||||
|
minSize: '最小大小 (KB)',
|
||||||
|
sortAsc: '升序',
|
||||||
|
sortDesc: '降序',
|
||||||
|
scanned: '已扫描 {n} 个',
|
||||||
|
},
|
||||||
status: {
|
status: {
|
||||||
title: '状态',
|
title: '状态',
|
||||||
database: '数据库',
|
database: '数据库',
|
||||||
|
|||||||
Reference in New Issue
Block a user