feat: P3 batch 2 - sidebar resize, DB features, Status/SlowLog, editor improvements
- Resizable sidebar (200-500px, localStorage persistence) - DB auto-detection (CONFIG GET databases) + key count in dropdown - DB memory (last selected DB per connection) - Status auto-refresh toggle + search filter + key statistics table - SlowLog config display (threshold + max len) - Large value protection (>20MB placeholder + download) - Hash field TTL (HEXPIRE support detection) - Editor inline filters (Hash/Set/Zset) - Zset ASC/DESC sort toggle - CLI auto-sync SELECT + refresh key list on write commands - i18n keys for all features (zh-CN + en)
This commit is contained in:
+14
-14
@@ -48,26 +48,26 @@
|
||||
- [x] **连接颜色标记** — 每个连接可选颜色(预定义色板)
|
||||
- [x] **连接拖拽排序** — Sortable.js 拖拽重排连接列表
|
||||
- [x] **复制连接** — 右键复制现有连接配置
|
||||
- [ ] **侧边栏可拖拽** — 调整宽度 200–1500px,持久化
|
||||
- [x] **侧边栏可拖拽** — 调整宽度 200–1500px,持久化
|
||||
- [ ] **DB 自定义名称** — 用户可重命名 DB0 → 生产库,下拉显示
|
||||
- [ ] **DB key 数量显示** — 下拉框显示每个 DB 的 key 数量
|
||||
- [ ] **DB 数量自动检测** — CONFIG GET databases + INFO keyspace
|
||||
- [ ] **DB 记忆** — 每个连接记住最后选择的数据库
|
||||
- [x] **DB key 数量显示** — 下拉框显示每个 DB 的 key 数量
|
||||
- [x] **DB 数量自动检测** — CONFIG GET databases + INFO keyspace
|
||||
- [x] **DB 记忆** — 每个连接记住最后选择的数据库
|
||||
- [ ] **更多语言** — de/es/fr/it/ko/pt/ru/tr/ua/vi/tw(当前仅 en/zh-CN)
|
||||
- [ ] **页面缩放** — 0.5x–2.0x 缩放控制
|
||||
- [ ] **自定义字体** — 用户可选等宽字体
|
||||
- [ ] **SlowLog 配置显示** — slowlog-log-slower-than / slowlog-max-len
|
||||
- [x] **SlowLog 配置显示** — slowlog-log-slower-than / slowlog-max-len
|
||||
- [ ] **Cluster SlowLog** — 每个 master 节点分别查询
|
||||
- [ ] **Cluster Keyspace 表** — 集群模式显示各节点 key 数量
|
||||
- [ ] **Status 自动刷新开关** — 可暂停/恢复的刷新(当前固定 1s)
|
||||
- [ ] **Status 搜索过滤** — INFO 表格搜索
|
||||
- [ ] **Key 统计表** — 每个 DB 的 Keys/Expires/Avg TTL 排序表
|
||||
- [ ] **大值保护** — >20MB 显示占位符而非崩溃
|
||||
- [ ] **Hash 字段 TTL** — Redis 7.4+ HEXPIRE 支持
|
||||
- [ ] **Editor 行内过滤** — 每个编辑器的表头搜索
|
||||
- [ ] **Zset 排序切换** — ASC/DESC 单选按钮
|
||||
- [ ] **CLI 自动同步** — CLI 执行 SELECT 后自动同步 UI
|
||||
- [ ] **CLI 执行后刷新 key 列表** — set/hset/del 等命令后自动刷新
|
||||
- [x] **Status 自动刷新开关** — 可暂停/恢复的刷新(当前固定 1s)
|
||||
- [x] **Status 搜索过滤** — INFO 表格搜索
|
||||
- [x] **Key 统计表** — 每个 DB 的 Keys/Expires/Avg TTL 排序表
|
||||
- [x] **大值保护** — >20MB 显示占位符而非崩溃
|
||||
- [x] **Hash 字段 TTL** — Redis 7.4+ HEXPIRE 支持
|
||||
- [x] **Editor 行内过滤** — 每个编辑器的表头搜索
|
||||
- [x] **Zset 排序切换** — ASC/DESC 单选按钮
|
||||
- [x] **CLI 自动同步** — CLI 执行 SELECT 后自动同步 UI
|
||||
- [x] **CLI 执行后刷新 key 列表** — set/hset/del 等命令后自动刷新
|
||||
- [ ] **标签页滚轮切换** — 鼠标滚轮切换标签
|
||||
- [ ] **标签页右键菜单** — 关闭/关闭其他/关闭左侧/关闭右侧
|
||||
- [ ] **Element Plus 深色适配** — 所有组件无白底
|
||||
|
||||
@@ -1,21 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
// src/components/DbSelector.vue
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, computed } from 'vue'
|
||||
import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useKeyStore } from '@renderer/stores/key'
|
||||
import { useI18n } from '@renderer/i18n'
|
||||
|
||||
const connStore = useConnectionStore()
|
||||
const keyStore = useKeyStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const selectedDb = ref(0)
|
||||
const dbs = Array.from({ length: 16 }, (_, i) => i)
|
||||
const dbCount = ref(16)
|
||||
const dbKeyCounts = ref<Record<number, number>>({})
|
||||
|
||||
const dbList = computed(() => {
|
||||
return Array.from({ length: dbCount.value }, (_, i) => {
|
||||
const count = dbKeyCounts.value[i]
|
||||
const label = count !== undefined && count > 0
|
||||
? `DB${i} (${t('db.keyCount', { n: count })})`
|
||||
: count === 0
|
||||
? `DB${i} (${t('db.noKeys')})`
|
||||
: `DB${i}`
|
||||
return { value: i, label }
|
||||
})
|
||||
})
|
||||
|
||||
async function fetchDbInfo() {
|
||||
if (!connStore.activeId) return
|
||||
try {
|
||||
// Get actual DB count from CONFIG GET databases
|
||||
const configResult = await window.electronAPI.redis.execute(
|
||||
connStore.activeId, 'CONFIG', ['GET', 'databases']
|
||||
)
|
||||
if (configResult && configResult[1]) {
|
||||
dbCount.value = parseInt(configResult[1], 10) || 16
|
||||
}
|
||||
} catch {
|
||||
dbCount.value = 16
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse INFO keyspace for per-DB key counts
|
||||
const info = await connStore.getInfo()
|
||||
const keyspace: Record<number, number> = {}
|
||||
const match = info.match(/# Keyspace[\s\S]*?(?=\n#|$)/)
|
||||
if (match) {
|
||||
const lines = match[0].split('\n')
|
||||
for (const line of lines) {
|
||||
const dbMatch = line.match(/^db(\d+):keys=(\d+)/)
|
||||
if (dbMatch) {
|
||||
keyspace[parseInt(dbMatch[1], 10)] = parseInt(dbMatch[2], 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Mark DBs with 0 keys explicitly
|
||||
for (let i = 0; i < dbCount.value; i++) {
|
||||
if (!(i in keyspace)) {
|
||||
keyspace[i] = 0
|
||||
}
|
||||
}
|
||||
dbKeyCounts.value = keyspace
|
||||
} catch {
|
||||
dbKeyCounts.value = {}
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => connStore.isConnected,
|
||||
(connected) => {
|
||||
async (connected) => {
|
||||
if (connected) {
|
||||
selectedDb.value = 0
|
||||
keyStore.currentDb = 0
|
||||
await fetchDbInfo()
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -23,6 +79,7 @@ watch(
|
||||
async function switchDb(db: number) {
|
||||
if (!connStore.activeId) return
|
||||
selectedDb.value = db
|
||||
connStore.saveLastDb(connStore.activeId, db)
|
||||
await keyStore.selectDb(connStore.activeId, db)
|
||||
}
|
||||
</script>
|
||||
@@ -34,13 +91,13 @@ async function switchDb(db: number) {
|
||||
:model-value="selectedDb"
|
||||
size="small"
|
||||
@change="switchDb"
|
||||
style="width: 60px"
|
||||
style="width: 130px"
|
||||
>
|
||||
<el-option
|
||||
v-for="db in dbs"
|
||||
:key="db"
|
||||
:label="db"
|
||||
:value="db"
|
||||
v-for="db in dbList"
|
||||
:key="db.value"
|
||||
:label="db.label"
|
||||
:value="db.value"
|
||||
/>
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// src/components/HashEditor.vue
|
||||
import { ref, watch } from 'vue'
|
||||
import { ref, watch, computed, onMounted } from 'vue'
|
||||
import { useKeyStore } from '@renderer/stores/key'
|
||||
import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useI18n } from '@renderer/i18n'
|
||||
@@ -13,6 +13,7 @@ const { t } = useI18n()
|
||||
interface HashField {
|
||||
field: string
|
||||
value: string
|
||||
ttl?: number | null
|
||||
}
|
||||
|
||||
const fields = ref<HashField[]>([])
|
||||
@@ -20,12 +21,22 @@ const loading = ref(false)
|
||||
const showAddDialog = ref(false)
|
||||
const newFieldName = ref('')
|
||||
const newFieldValue = ref('')
|
||||
const filterText = ref('')
|
||||
const supportsHexpire = ref(false)
|
||||
|
||||
const filteredFields = computed(() => {
|
||||
if (!filterText.value) return fields.value
|
||||
const lower = filterText.value.toLowerCase()
|
||||
return fields.value.filter(
|
||||
f => f.field.toLowerCase().includes(lower) || f.value.toLowerCase().includes(lower)
|
||||
)
|
||||
})
|
||||
|
||||
watch(
|
||||
() => keyStore.keyData,
|
||||
(newData) => {
|
||||
if (Array.isArray(newData)) {
|
||||
fields.value = newData.map(([field, value]) => ({ field, value }))
|
||||
fields.value = newData.map(([field, value]) => ({ field, value, ttl: null }))
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -37,11 +48,62 @@ async function refresh() {
|
||||
try {
|
||||
const result = await window.electronAPI.redis.hashScan(connStore.activeId, keyStore.selectedKey, '0', 100)
|
||||
keyStore.keyData = result.fields
|
||||
await checkHexpireSupport()
|
||||
if (supportsHexpire.value) {
|
||||
await loadFieldTtls()
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkHexpireSupport() {
|
||||
if (!connStore.activeId) return
|
||||
try {
|
||||
const result = await window.electronAPI.redis.execute(connStore.activeId, 'COMMAND', ['INFO', 'HEXPIRE'])
|
||||
supportsHexpire.value = Array.isArray(result) && result.length > 0
|
||||
} catch {
|
||||
supportsHexpire.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFieldTtls() {
|
||||
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||
for (const field of fields.value) {
|
||||
try {
|
||||
const ttlResult = await window.electronAPI.redis.execute(connStore.activeId, 'HTTL', [keyStore.selectedKey, 'FIELDS', '1', field.field])
|
||||
field.ttl = Number(ttlResult)
|
||||
} catch {
|
||||
field.ttl = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setFieldTtl(field: HashField) {
|
||||
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||
try {
|
||||
const { value: seconds } = await ElMessageBox.prompt(t('editor.ttlTitle'), t('editor.setFieldTtl'), {
|
||||
inputValue: '-1',
|
||||
inputPlaceholder: t('key.ttlHint'),
|
||||
})
|
||||
if (seconds === '-1') {
|
||||
// Remove TTL via PERSIST equivalent — HEXPIRE with 0 or use PERSIST on field
|
||||
await window.electronAPI.redis.execute(connStore.activeId, 'HEXPIRE', [keyStore.selectedKey, '0', 'FIELDS', '1', field.field])
|
||||
field.ttl = -1
|
||||
} else {
|
||||
await window.electronAPI.redis.execute(connStore.activeId, 'HEXPIRE', [keyStore.selectedKey, seconds, 'FIELDS', '1', field.field])
|
||||
field.ttl = Number(seconds)
|
||||
}
|
||||
ElMessage.success(t('editor.saved'))
|
||||
} catch (err: any) {
|
||||
if (err !== 'cancel') ElMessage.error(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
checkHexpireSupport()
|
||||
})
|
||||
|
||||
async function saveField(item: HashField) {
|
||||
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||
try {
|
||||
@@ -83,12 +145,17 @@ async function addField() {
|
||||
<div class="hash-editor">
|
||||
<div class="editor-toolbar">
|
||||
<span class="type-badge">HASH</span>
|
||||
<span class="item-count">{{ t('editor.fieldCount', { n: fields.length }) }}</span>
|
||||
<el-input v-model="filterText" :placeholder="t('editor.filter')" size="small" clearable style="width: 150px" />
|
||||
<el-button size="small" type="primary" @click="showAddDialog = true">{{ t('editor.addField') }}</el-button>
|
||||
<el-button size="small" @click="refresh" :loading="loading">{{ t('common.refresh') }}</el-button>
|
||||
<span v-if="filterText && filteredFields.length !== fields.length" class="filtered-count">
|
||||
{{ t('editor.filteredCount', { n: filteredFields.length }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="hash-table-wrap">
|
||||
<el-table :data="fields" size="small" class="hash-table" max-height="400">
|
||||
<el-table :data="filteredFields" size="small" class="hash-table" max-height="400">
|
||||
<el-table-column prop="field" :label="t('editor.field')" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<el-input v-model="row.field" size="small" class="hash-input" />
|
||||
@@ -99,9 +166,15 @@ async function addField() {
|
||||
<el-input v-model="row.value" size="small" class="hash-input" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="120">
|
||||
<el-table-column v-if="supportsHexpire" :label="t('editor.fieldTtl')" width="100">
|
||||
<template #default="{ row }">
|
||||
<span class="field-ttl">{{ row.ttl != null ? row.ttl : '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" text @click="saveField(row)">{{ t('editor.save') }}</el-button>
|
||||
<el-button v-if="supportsHexpire" size="small" type="warning" text @click="setFieldTtl(row)">{{ t('editor.setFieldTtl') }}</el-button>
|
||||
<el-button size="small" type="danger" text @click="deleteField(row)">{{ t('common.delete') }}</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -168,4 +241,20 @@ async function addField() {
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.item-count {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.filtered-count {
|
||||
font-size: 11px;
|
||||
color: var(--accent-light);
|
||||
}
|
||||
|
||||
.field-ttl {
|
||||
font-size: 12px;
|
||||
font-family: 'Cascadia Code', monospace;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -62,7 +62,7 @@ async function removeMember(member: string) {
|
||||
const filteredMembers = ref<string[]>([])
|
||||
watch([members, filterText], () => {
|
||||
filteredMembers.value = filterText.value
|
||||
? members.value.filter(m => m.includes(filterText.value))
|
||||
? members.value.filter(m => m.toLowerCase().includes(filterText.value.toLowerCase()))
|
||||
: members.value
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// src/components/Sidebar.vue
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import ConnectionList from './ConnectionList.vue'
|
||||
import NewConnectionDialog from './NewConnectionDialog.vue'
|
||||
import CommandLog from './CommandLog.vue'
|
||||
@@ -19,6 +19,41 @@ const editingConnection = ref<ConnectionConfig | null>(null)
|
||||
const collapsed = ref(false)
|
||||
const commandLogVisible = ref(false)
|
||||
|
||||
// Resizable sidebar
|
||||
const sidebarWidth = ref(parseInt(localStorage.getItem('sidebarWidth') || '260', 10))
|
||||
const isResizing = ref(false)
|
||||
|
||||
function startResize(e: MouseEvent) {
|
||||
e.preventDefault()
|
||||
isResizing.value = true
|
||||
document.addEventListener('mousemove', onResize)
|
||||
document.addEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
function onResize(e: MouseEvent) {
|
||||
if (!isResizing.value) return
|
||||
const newWidth = Math.max(200, Math.min(500, e.clientX))
|
||||
sidebarWidth.value = newWidth
|
||||
}
|
||||
|
||||
function stopResize() {
|
||||
if (!isResizing.value) return
|
||||
isResizing.value = false
|
||||
localStorage.setItem('sidebarWidth', String(sidebarWidth.value))
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// Ensure width is within bounds after loading from localStorage
|
||||
sidebarWidth.value = Math.max(200, Math.min(500, sidebarWidth.value))
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mousemove', onResize)
|
||||
document.removeEventListener('mouseup', stopResize)
|
||||
})
|
||||
|
||||
function handleEdit(conn: ConnectionConfig | null) {
|
||||
editingConnection.value = conn
|
||||
dialogVisible.value = true
|
||||
@@ -103,10 +138,11 @@ useKeyboard({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="sidebar" :class="{ collapsed }">
|
||||
<aside class="sidebar" :class="{ collapsed, resizing: isResizing }" :style="{ width: collapsed ? '36px' : sidebarWidth + 'px' }">
|
||||
<div class="sidebar-header" v-if="!collapsed">
|
||||
<ConnectionList @edit="handleEdit" />
|
||||
</div>
|
||||
<div class="drag-handle" @mousedown="startResize"></div>
|
||||
<button class="collapse-btn" @click="collapsed = !collapsed" :title="collapsed ? '展开' : '收缩'">
|
||||
<el-icon>
|
||||
<component :is="collapsed ? 'ArrowRight' : 'ArrowLeft'" />
|
||||
@@ -122,7 +158,6 @@ useKeyboard({
|
||||
|
||||
<style scoped>
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
background: var(--bg-secondary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
@@ -130,11 +165,15 @@ useKeyboard({
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar:not(.resizing) {
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.sidebar.collapsed {
|
||||
width: 36px;
|
||||
width: 36px !important;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
@@ -142,6 +181,22 @@ useKeyboard({
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.drag-handle:hover,
|
||||
.sidebar.resizing .drag-handle {
|
||||
background: var(--accent);
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
.collapse-btn {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// src/components/SlowLogView.vue
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useI18n } from '@renderer/i18n'
|
||||
|
||||
@@ -16,6 +16,20 @@ interface SlowLogEntry {
|
||||
|
||||
const entries = ref<SlowLogEntry[]>([])
|
||||
const loading = ref(false)
|
||||
const configThreshold = ref<string | null>(null)
|
||||
const configMaxLen = ref<string | null>(null)
|
||||
|
||||
async function fetchConfig() {
|
||||
if (!connStore.activeId) return
|
||||
try {
|
||||
const threshold = await window.electronAPI.redis.execute(connStore.activeId, 'CONFIG', ['GET', 'slowlog-log-slower-than'])
|
||||
const maxLen = await window.electronAPI.redis.execute(connStore.activeId, 'CONFIG', ['GET', 'slowlog-max-len'])
|
||||
configThreshold.value = Array.isArray(threshold) && threshold.length >= 2 ? String(threshold[1]) : null
|
||||
configMaxLen.value = Array.isArray(maxLen) && maxLen.length >= 2 ? String(maxLen[1]) : null
|
||||
} catch {
|
||||
// config fetch failed silently
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!connStore.activeId) return
|
||||
@@ -28,6 +42,7 @@ async function refresh() {
|
||||
duration: item[2],
|
||||
command: Array.isArray(item[3]) ? item[3].join(' ') : String(item[3]),
|
||||
}))
|
||||
await fetchConfig()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -43,6 +58,12 @@ function formatTime(ts: number) {
|
||||
return new Date(ts * 1000).toLocaleString()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (connStore.activeId) {
|
||||
fetchConfig()
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ refresh })
|
||||
</script>
|
||||
|
||||
@@ -67,6 +88,10 @@ defineExpose({ refresh })
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
<div class="config-footer" v-if="configThreshold !== null || configMaxLen !== null">
|
||||
<span v-if="configThreshold !== null">{{ t('slowlog.threshold') }}: {{ formatDuration(Number(configThreshold)) }}</span>
|
||||
<span v-if="configMaxLen !== null">{{ t('slowlog.maxLen') }}: {{ configMaxLen }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -102,4 +127,14 @@ defineExpose({ refresh })
|
||||
font-family: 'Cascadia Code', monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.config-footer {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
padding: 8px 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
// src/components/StatusView.vue
|
||||
import { ref, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useConnectionStore } from '@renderer/stores/connection'
|
||||
import { useI18n } from '@renderer/i18n'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
@@ -15,8 +15,73 @@ interface InfoSection {
|
||||
items: { key: string; value: string }[]
|
||||
}
|
||||
|
||||
interface KeyStatEntry {
|
||||
db: string
|
||||
keys: number
|
||||
expires: number
|
||||
avgTtl: number | null
|
||||
}
|
||||
|
||||
const sections = ref<InfoSection[]>([])
|
||||
|
||||
// Auto-refresh toggle state (persisted to localStorage, default ON)
|
||||
const autoRefreshEnabled = ref(localStorage.getItem('statusAutoRefresh') !== 'false')
|
||||
|
||||
function toggleAutoRefresh() {
|
||||
autoRefreshEnabled.value = !autoRefreshEnabled.value
|
||||
localStorage.setItem('statusAutoRefresh', String(autoRefreshEnabled.value))
|
||||
if (autoRefreshEnabled.value && connStore.isConnected) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
const searchQuery = ref('')
|
||||
|
||||
const filteredSections = computed(() => {
|
||||
const q = searchQuery.value.trim().toLowerCase()
|
||||
if (!q) return sections.value
|
||||
return sections.value
|
||||
.map((sec) => {
|
||||
const titleMatch = sec.title.toLowerCase().includes(q)
|
||||
const filteredItems = sec.items.filter(
|
||||
(item) =>
|
||||
item.key.toLowerCase().includes(q) || item.value.toLowerCase().includes(q)
|
||||
)
|
||||
if (titleMatch) {
|
||||
return { ...sec, items: sec.items }
|
||||
}
|
||||
if (filteredItems.length > 0) {
|
||||
return { ...sec, items: filteredItems }
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter((s): s is InfoSection => s !== null)
|
||||
})
|
||||
|
||||
// Key Statistics from Keyspace section
|
||||
const keyStats = computed<KeyStatEntry[]>(() => {
|
||||
const keyspaceSection = sections.value.find((s) => s.title === 'Keyspace')
|
||||
if (!keyspaceSection) return []
|
||||
return keyspaceSection.items.map((item) => {
|
||||
const db = item.key
|
||||
const keys = parseInt(item.value.match(/keys=(\d+)/)?.[1] ?? '0', 10)
|
||||
const expires = parseInt(item.value.match(/expires=(\d+)/)?.[1] ?? '0', 10)
|
||||
const avgTtlRaw = item.value.match(/avg_ttl=(\d+)/)?.[1]
|
||||
const avgTtl = avgTtlRaw ? parseInt(avgTtlRaw, 10) : null
|
||||
return { db, keys, expires, avgTtl }
|
||||
})
|
||||
})
|
||||
|
||||
function formatAvgTtl(us: number | null): string {
|
||||
if (us === null) return '-'
|
||||
if (us < 1000) return `${us}μs`
|
||||
if (us < 1000000) return `${(us / 1000).toFixed(1)}ms`
|
||||
return `${(us / 1000000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
// Redis INFO section name translations
|
||||
const sectionTranslations: Record<string, string> = {
|
||||
'Server': 'status.sectionServer',
|
||||
@@ -73,6 +138,7 @@ async function refresh() {
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh()
|
||||
if (!autoRefreshEnabled.value) return
|
||||
refresh()
|
||||
refreshTimer = setInterval(refresh, 1000)
|
||||
}
|
||||
@@ -132,6 +198,13 @@ defineExpose({ refresh })
|
||||
<div class="status-actions">
|
||||
<el-button size="small" type="danger" @click="handleFlushDb">{{ t('status.flushDb') }}</el-button>
|
||||
<el-button size="small" @click="refresh">{{ t('common.refresh') }}</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
:type="autoRefreshEnabled ? 'primary' : 'default'"
|
||||
@click="toggleAutoRefresh"
|
||||
>
|
||||
{{ t('status.autoRefresh') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -146,15 +219,44 @@ defineExpose({ refresh })
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search filter -->
|
||||
<div class="search-bar">
|
||||
<el-input
|
||||
v-model="searchQuery"
|
||||
:placeholder="t('status.searchInfo')"
|
||||
size="small"
|
||||
clearable
|
||||
prefix-icon="Search"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Key Statistics table -->
|
||||
<div v-if="keyStats.length > 0" class="key-stats-section">
|
||||
<h3 class="section-title">{{ t('status.keyStats') }}</h3>
|
||||
<el-table :data="keyStats" size="small" stripe max-height="200">
|
||||
<el-table-column :label="t('status.dbColumn')" prop="db" width="100" />
|
||||
<el-table-column :label="t('status.keysColumn')" prop="keys" width="100" />
|
||||
<el-table-column :label="t('status.expiresColumn')" prop="expires" width="100" />
|
||||
<el-table-column :label="t('status.avgTtlColumn')" width="120">
|
||||
<template #default="{ row }">{{ formatAvgTtl(row.avgTtl) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="info-sections">
|
||||
<div v-for="section in sections" :key="section.title" class="info-section">
|
||||
<h3 class="section-title">{{ translateSectionTitle(section.title) }}</h3>
|
||||
<div class="section-items">
|
||||
<div v-for="item in section.items" :key="item.key" class="info-row">
|
||||
<span class="info-key">{{ item.key }}</span>
|
||||
<span class="info-val">{{ item.value }}</span>
|
||||
<template v-if="filteredSections.length > 0">
|
||||
<div v-for="section in filteredSections" :key="section.title" class="info-section">
|
||||
<h3 class="section-title">{{ translateSectionTitle(section.title) }}</h3>
|
||||
<div class="section-items">
|
||||
<div v-for="item in section.items" :key="item.key" class="info-row">
|
||||
<span class="info-key">{{ item.key }}</span>
|
||||
<span class="info-val">{{ item.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="no-results">
|
||||
{{ t('status.noResults') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,4 +365,37 @@ defineExpose({ refresh })
|
||||
color: var(--text-primary);
|
||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.key-stats-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.key-stats-section .section-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-light);
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-bottom: none;
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.key-stats-section .el-table {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: var(--text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,6 +20,20 @@ const isEditing = ref(false)
|
||||
const selectedFormat = ref('auto')
|
||||
const detectedFormat = ref('text')
|
||||
|
||||
const MAX_DISPLAY_SIZE = 20 * 1024 * 1024 // 20MB
|
||||
|
||||
const isValueTooLarge = computed(() => editedValue.value.length > MAX_DISPLAY_SIZE)
|
||||
|
||||
function downloadValue() {
|
||||
const blob = new Blob([editedValue.value], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `value_${keyStore.selectedKey || 'unknown'}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const monacoTheme = computed(() => {
|
||||
if (appStore.theme === 'light') return 'vs'
|
||||
return 'vs-dark'
|
||||
@@ -174,7 +188,12 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</div>
|
||||
<div class="editor-content">
|
||||
<div v-if="isValueTooLarge" class="value-too-large">
|
||||
<span class="too-large-text">{{ t('editor.valueTooLarge') }}</span>
|
||||
<el-button size="small" type="primary" @click="downloadValue">{{ t('editor.download') }}</el-button>
|
||||
</div>
|
||||
<vue-monaco-editor
|
||||
v-else
|
||||
v-model:value="displayValue"
|
||||
:theme="monacoTheme"
|
||||
:language="monacoLanguage"
|
||||
@@ -224,4 +243,18 @@ onUnmounted(() => {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.value-too-large {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.too-large-text {
|
||||
font-size: 14px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -21,6 +21,7 @@ const filterText = ref('')
|
||||
const showAddDialog = ref(false)
|
||||
const newMember = ref('')
|
||||
const newScore = ref(0)
|
||||
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||
|
||||
async function loadMembers() {
|
||||
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||
@@ -71,10 +72,18 @@ async function removeMember(member: string) {
|
||||
}
|
||||
|
||||
const filteredItems = ref<ZsetItem[]>([])
|
||||
watch([items, filterText], () => {
|
||||
filteredItems.value = filterText.value
|
||||
? items.value.filter(i => i.member.includes(filterText.value))
|
||||
: items.value
|
||||
watch([items, filterText, sortOrder], () => {
|
||||
let result = items.value
|
||||
if (filterText.value) {
|
||||
const lower = filterText.value.toLowerCase()
|
||||
result = result.filter(i =>
|
||||
i.member.toLowerCase().includes(lower) || String(i.score).includes(lower)
|
||||
)
|
||||
}
|
||||
result = [...result].sort((a, b) =>
|
||||
sortOrder.value === 'asc' ? a.score - b.score : b.score - a.score
|
||||
)
|
||||
filteredItems.value = result
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
@@ -84,6 +93,10 @@ watch([items, filterText], () => {
|
||||
<span class="type-badge">ZSET</span>
|
||||
<span class="item-count">{{ t('editor.members', { n: items.length }) }}</span>
|
||||
<el-input v-model="filterText" :placeholder="t('editor.filter')" size="small" clearable style="width: 150px" />
|
||||
<el-radio-group v-model="sortOrder" size="small">
|
||||
<el-radio-button value="asc">{{ t('editor.sortAsc') }}</el-radio-button>
|
||||
<el-radio-button value="desc">{{ t('editor.sortDesc') }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-button size="small" type="primary" @click="showAddDialog = true">{{ t('common.add') }}</el-button>
|
||||
<el-button size="small" @click="loadMembers" :loading="loading">{{ t('common.refresh') }}</el-button>
|
||||
</div>
|
||||
|
||||
@@ -179,6 +179,14 @@ export default {
|
||||
ack: 'ACK',
|
||||
ackConfirm: 'ACK selected entries?',
|
||||
noGroups: 'No consumer groups',
|
||||
valueTooLarge: 'Value too large to display (>20MB)',
|
||||
download: 'Download',
|
||||
setFieldTtl: 'Set Field TTL',
|
||||
fieldTtl: 'Field TTL',
|
||||
sortAsc: 'Ascending',
|
||||
sortDesc: 'Descending',
|
||||
filteredCount: '{n} filtered',
|
||||
fieldCount: '{n} fields',
|
||||
},
|
||||
cli: {
|
||||
title: 'CLI',
|
||||
@@ -205,6 +213,8 @@ export default {
|
||||
time: 'Time',
|
||||
duration: 'Duration',
|
||||
command: 'Command',
|
||||
threshold: 'Threshold',
|
||||
maxLen: 'Max Length',
|
||||
},
|
||||
memory: {
|
||||
title: 'Memory Analysis',
|
||||
@@ -255,6 +265,14 @@ export default {
|
||||
connecting: 'Connecting...',
|
||||
error: 'Error: {error}',
|
||||
noConnection: 'No connection',
|
||||
autoRefresh: 'Auto Refresh',
|
||||
searchInfo: 'Search INFO...',
|
||||
noResults: 'No results',
|
||||
keyStats: 'Key Statistics',
|
||||
dbColumn: 'Database',
|
||||
keysColumn: 'Keys',
|
||||
expiresColumn: 'Expires',
|
||||
avgTtlColumn: 'Avg TTL',
|
||||
sectionServer: 'Server',
|
||||
sectionClients: 'Clients',
|
||||
sectionMemory: 'Memory',
|
||||
@@ -269,6 +287,8 @@ export default {
|
||||
},
|
||||
db: {
|
||||
label: 'DB',
|
||||
keyCount: '{n} keys',
|
||||
noKeys: 'empty',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
|
||||
@@ -179,6 +179,14 @@ export default {
|
||||
ack: 'ACK',
|
||||
ackConfirm: '确认 ACK 选中的条目?',
|
||||
noGroups: '暂无消费者组',
|
||||
valueTooLarge: '值过大无法显示 (>20MB)',
|
||||
download: '下载',
|
||||
setFieldTtl: '设置字段 TTL',
|
||||
fieldTtl: '字段 TTL',
|
||||
sortAsc: '升序',
|
||||
sortDesc: '降序',
|
||||
filteredCount: '已过滤 {n} 项',
|
||||
fieldCount: '{n} 个字段',
|
||||
},
|
||||
cli: {
|
||||
title: 'CLI',
|
||||
@@ -205,6 +213,8 @@ export default {
|
||||
time: '时间',
|
||||
duration: '耗时',
|
||||
command: '命令',
|
||||
threshold: '阈值',
|
||||
maxLen: '最大长度',
|
||||
},
|
||||
memory: {
|
||||
title: '内存分析',
|
||||
@@ -255,6 +265,14 @@ export default {
|
||||
connecting: '连接中...',
|
||||
error: '错误: {error}',
|
||||
noConnection: '无连接',
|
||||
autoRefresh: '自动刷新',
|
||||
searchInfo: '搜索 INFO...',
|
||||
noResults: '无匹配结果',
|
||||
keyStats: '键空间统计',
|
||||
dbColumn: '数据库',
|
||||
keysColumn: '键数',
|
||||
expiresColumn: '过期数',
|
||||
avgTtlColumn: '平均 TTL',
|
||||
sectionServer: '服务器',
|
||||
sectionClients: '客户端',
|
||||
sectionMemory: '内存',
|
||||
@@ -269,6 +287,8 @@ export default {
|
||||
},
|
||||
db: {
|
||||
label: '数据库',
|
||||
keyCount: '{n} 个键',
|
||||
noKeys: '空',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
|
||||
@@ -42,8 +42,17 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
const serverInfoMap = ref<Record<string, string>>({})
|
||||
const healthMap = ref<Record<string, boolean>>({})
|
||||
const currentDb = ref<number>(0)
|
||||
const lastDbMap = ref<Record<string, number>>({})
|
||||
const healthTimers = new Map<string, ReturnType<typeof setInterval>>()
|
||||
|
||||
// Load lastDbMap from localStorage
|
||||
try {
|
||||
const saved = localStorage.getItem('lastDbMap')
|
||||
if (saved) {
|
||||
lastDbMap.value = JSON.parse(saved)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const activeConnection = computed(() =>
|
||||
connections.value.find((c: ConnectionConfig) => c.id === activeId.value) ?? null
|
||||
)
|
||||
@@ -123,6 +132,14 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
connectedIds.value.push(conn.id)
|
||||
activeId.value = conn.id
|
||||
serverInfoMap.value[conn.id] = await window.electronAPI.redis.getInfo(conn.id)
|
||||
// Restore last selected DB for this connection
|
||||
const lastDb = lastDbMap.value[conn.id] ?? 0
|
||||
if (lastDb !== 0) {
|
||||
try {
|
||||
await window.electronAPI.redis.selectDb(conn.id, lastDb)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
currentDb.value = lastDb
|
||||
startHealthCheck(conn.id)
|
||||
} else {
|
||||
error.value = result.error || 'Connection failed'
|
||||
@@ -134,9 +151,16 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
}
|
||||
}
|
||||
|
||||
function switchTo(id: string) {
|
||||
async function switchTo(id: string) {
|
||||
if (connectedIds.value.includes(id)) {
|
||||
activeId.value = id
|
||||
const lastDb = lastDbMap.value[id] ?? 0
|
||||
if (lastDb !== currentDb.value) {
|
||||
try {
|
||||
await window.electronAPI.redis.selectDb(id, lastDb)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
currentDb.value = lastDb
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +277,12 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
return data
|
||||
}
|
||||
|
||||
function saveLastDb(connId: string, db: number) {
|
||||
lastDbMap.value[connId] = db
|
||||
currentDb.value = db
|
||||
localStorage.setItem('lastDbMap', JSON.stringify(lastDbMap.value))
|
||||
}
|
||||
|
||||
async function importConnections(jsonStr: string): Promise<number> {
|
||||
try {
|
||||
const imported = JSON.parse(jsonStr) as ConnectionConfig[]
|
||||
@@ -274,9 +304,10 @@ export const useConnectionStore = defineStore('connection', () => {
|
||||
}
|
||||
|
||||
return {
|
||||
connections, activeId, connectedIds, connecting, error, serverInfo, healthOk, currentDb,
|
||||
connections, activeId, connectedIds, connecting, error, serverInfo, healthOk, currentDb, lastDbMap,
|
||||
activeConnection, isConnected,
|
||||
loadConnections, decryptPassword, getInfo, saveConnection, deleteConnection, reorderConnections,
|
||||
connect, disconnect, disconnectById, switchTo, isConnectedById, testConnection, exportConnections, importConnections,
|
||||
saveLastDb,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user