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] **连接颜色标记** — 每个连接可选颜色(预定义色板)
|
||||||
- [x] **连接拖拽排序** — Sortable.js 拖拽重排连接列表
|
- [x] **连接拖拽排序** — Sortable.js 拖拽重排连接列表
|
||||||
- [x] **复制连接** — 右键复制现有连接配置
|
- [x] **复制连接** — 右键复制现有连接配置
|
||||||
- [ ] **侧边栏可拖拽** — 调整宽度 200–1500px,持久化
|
- [x] **侧边栏可拖拽** — 调整宽度 200–1500px,持久化
|
||||||
- [ ] **DB 自定义名称** — 用户可重命名 DB0 → 生产库,下拉显示
|
- [ ] **DB 自定义名称** — 用户可重命名 DB0 → 生产库,下拉显示
|
||||||
- [ ] **DB key 数量显示** — 下拉框显示每个 DB 的 key 数量
|
- [x] **DB key 数量显示** — 下拉框显示每个 DB 的 key 数量
|
||||||
- [ ] **DB 数量自动检测** — CONFIG GET databases + INFO keyspace
|
- [x] **DB 数量自动检测** — CONFIG GET databases + INFO keyspace
|
||||||
- [ ] **DB 记忆** — 每个连接记住最后选择的数据库
|
- [x] **DB 记忆** — 每个连接记住最后选择的数据库
|
||||||
- [ ] **更多语言** — de/es/fr/it/ko/pt/ru/tr/ua/vi/tw(当前仅 en/zh-CN)
|
- [ ] **更多语言** — de/es/fr/it/ko/pt/ru/tr/ua/vi/tw(当前仅 en/zh-CN)
|
||||||
- [ ] **页面缩放** — 0.5x–2.0x 缩放控制
|
- [ ] **页面缩放** — 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 SlowLog** — 每个 master 节点分别查询
|
||||||
- [ ] **Cluster Keyspace 表** — 集群模式显示各节点 key 数量
|
- [ ] **Cluster Keyspace 表** — 集群模式显示各节点 key 数量
|
||||||
- [ ] **Status 自动刷新开关** — 可暂停/恢复的刷新(当前固定 1s)
|
- [x] **Status 自动刷新开关** — 可暂停/恢复的刷新(当前固定 1s)
|
||||||
- [ ] **Status 搜索过滤** — INFO 表格搜索
|
- [x] **Status 搜索过滤** — INFO 表格搜索
|
||||||
- [ ] **Key 统计表** — 每个 DB 的 Keys/Expires/Avg TTL 排序表
|
- [x] **Key 统计表** — 每个 DB 的 Keys/Expires/Avg TTL 排序表
|
||||||
- [ ] **大值保护** — >20MB 显示占位符而非崩溃
|
- [x] **大值保护** — >20MB 显示占位符而非崩溃
|
||||||
- [ ] **Hash 字段 TTL** — Redis 7.4+ HEXPIRE 支持
|
- [x] **Hash 字段 TTL** — Redis 7.4+ HEXPIRE 支持
|
||||||
- [ ] **Editor 行内过滤** — 每个编辑器的表头搜索
|
- [x] **Editor 行内过滤** — 每个编辑器的表头搜索
|
||||||
- [ ] **Zset 排序切换** — ASC/DESC 单选按钮
|
- [x] **Zset 排序切换** — ASC/DESC 单选按钮
|
||||||
- [ ] **CLI 自动同步** — CLI 执行 SELECT 后自动同步 UI
|
- [x] **CLI 自动同步** — CLI 执行 SELECT 后自动同步 UI
|
||||||
- [ ] **CLI 执行后刷新 key 列表** — set/hset/del 等命令后自动刷新
|
- [x] **CLI 执行后刷新 key 列表** — set/hset/del 等命令后自动刷新
|
||||||
- [ ] **标签页滚轮切换** — 鼠标滚轮切换标签
|
- [ ] **标签页滚轮切换** — 鼠标滚轮切换标签
|
||||||
- [ ] **标签页右键菜单** — 关闭/关闭其他/关闭左侧/关闭右侧
|
- [ ] **标签页右键菜单** — 关闭/关闭其他/关闭左侧/关闭右侧
|
||||||
- [ ] **Element Plus 深色适配** — 所有组件无白底
|
- [ ] **Element Plus 深色适配** — 所有组件无白底
|
||||||
|
|||||||
@@ -1,21 +1,77 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// src/components/DbSelector.vue
|
// src/components/DbSelector.vue
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, computed } from 'vue'
|
||||||
import { useConnectionStore } from '@renderer/stores/connection'
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
import { useKeyStore } from '@renderer/stores/key'
|
import { useKeyStore } from '@renderer/stores/key'
|
||||||
|
import { useI18n } from '@renderer/i18n'
|
||||||
|
|
||||||
const connStore = useConnectionStore()
|
const connStore = useConnectionStore()
|
||||||
const keyStore = useKeyStore()
|
const keyStore = useKeyStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
const selectedDb = ref(0)
|
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(
|
watch(
|
||||||
() => connStore.isConnected,
|
() => connStore.isConnected,
|
||||||
(connected) => {
|
async (connected) => {
|
||||||
if (connected) {
|
if (connected) {
|
||||||
selectedDb.value = 0
|
selectedDb.value = 0
|
||||||
keyStore.currentDb = 0
|
keyStore.currentDb = 0
|
||||||
|
await fetchDbInfo()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -23,6 +79,7 @@ watch(
|
|||||||
async function switchDb(db: number) {
|
async function switchDb(db: number) {
|
||||||
if (!connStore.activeId) return
|
if (!connStore.activeId) return
|
||||||
selectedDb.value = db
|
selectedDb.value = db
|
||||||
|
connStore.saveLastDb(connStore.activeId, db)
|
||||||
await keyStore.selectDb(connStore.activeId, db)
|
await keyStore.selectDb(connStore.activeId, db)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -34,13 +91,13 @@ async function switchDb(db: number) {
|
|||||||
:model-value="selectedDb"
|
:model-value="selectedDb"
|
||||||
size="small"
|
size="small"
|
||||||
@change="switchDb"
|
@change="switchDb"
|
||||||
style="width: 60px"
|
style="width: 130px"
|
||||||
>
|
>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="db in dbs"
|
v-for="db in dbList"
|
||||||
:key="db"
|
:key="db.value"
|
||||||
:label="db"
|
:label="db.label"
|
||||||
:value="db"
|
:value="db.value"
|
||||||
/>
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// src/components/HashEditor.vue
|
// src/components/HashEditor.vue
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch, computed, onMounted } from 'vue'
|
||||||
import { useKeyStore } from '@renderer/stores/key'
|
import { useKeyStore } from '@renderer/stores/key'
|
||||||
import { useConnectionStore } from '@renderer/stores/connection'
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
import { useI18n } from '@renderer/i18n'
|
import { useI18n } from '@renderer/i18n'
|
||||||
@@ -13,6 +13,7 @@ const { t } = useI18n()
|
|||||||
interface HashField {
|
interface HashField {
|
||||||
field: string
|
field: string
|
||||||
value: string
|
value: string
|
||||||
|
ttl?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = ref<HashField[]>([])
|
const fields = ref<HashField[]>([])
|
||||||
@@ -20,12 +21,22 @@ const loading = ref(false)
|
|||||||
const showAddDialog = ref(false)
|
const showAddDialog = ref(false)
|
||||||
const newFieldName = ref('')
|
const newFieldName = ref('')
|
||||||
const newFieldValue = 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(
|
watch(
|
||||||
() => keyStore.keyData,
|
() => keyStore.keyData,
|
||||||
(newData) => {
|
(newData) => {
|
||||||
if (Array.isArray(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 }
|
{ immediate: true }
|
||||||
@@ -37,11 +48,62 @@ async function refresh() {
|
|||||||
try {
|
try {
|
||||||
const result = await window.electronAPI.redis.hashScan(connStore.activeId, keyStore.selectedKey, '0', 100)
|
const result = await window.electronAPI.redis.hashScan(connStore.activeId, keyStore.selectedKey, '0', 100)
|
||||||
keyStore.keyData = result.fields
|
keyStore.keyData = result.fields
|
||||||
|
await checkHexpireSupport()
|
||||||
|
if (supportsHexpire.value) {
|
||||||
|
await loadFieldTtls()
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
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) {
|
async function saveField(item: HashField) {
|
||||||
if (!connStore.activeId || !keyStore.selectedKey) return
|
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||||
try {
|
try {
|
||||||
@@ -83,12 +145,17 @@ async function addField() {
|
|||||||
<div class="hash-editor">
|
<div class="hash-editor">
|
||||||
<div class="editor-toolbar">
|
<div class="editor-toolbar">
|
||||||
<span class="type-badge">HASH</span>
|
<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" type="primary" @click="showAddDialog = true">{{ t('editor.addField') }}</el-button>
|
||||||
<el-button size="small" @click="refresh" :loading="loading">{{ t('common.refresh') }}</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>
|
||||||
|
|
||||||
<div class="hash-table-wrap">
|
<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">
|
<el-table-column prop="field" :label="t('editor.field')" min-width="150">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-input v-model="row.field" size="small" class="hash-input" />
|
<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" />
|
<el-input v-model="row.value" size="small" class="hash-input" />
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 }">
|
<template #default="{ row }">
|
||||||
<el-button size="small" type="primary" text @click="saveField(row)">{{ t('editor.save') }}</el-button>
|
<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>
|
<el-button size="small" type="danger" text @click="deleteField(row)">{{ t('common.delete') }}</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -168,4 +241,20 @@ async function addField() {
|
|||||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
||||||
font-size: 12px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ async function removeMember(member: string) {
|
|||||||
const filteredMembers = ref<string[]>([])
|
const filteredMembers = ref<string[]>([])
|
||||||
watch([members, filterText], () => {
|
watch([members, filterText], () => {
|
||||||
filteredMembers.value = filterText.value
|
filteredMembers.value = filterText.value
|
||||||
? members.value.filter(m => m.includes(filterText.value))
|
? members.value.filter(m => m.toLowerCase().includes(filterText.value.toLowerCase()))
|
||||||
: members.value
|
: members.value
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// src/components/Sidebar.vue
|
// src/components/Sidebar.vue
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import ConnectionList from './ConnectionList.vue'
|
import ConnectionList from './ConnectionList.vue'
|
||||||
import NewConnectionDialog from './NewConnectionDialog.vue'
|
import NewConnectionDialog from './NewConnectionDialog.vue'
|
||||||
import CommandLog from './CommandLog.vue'
|
import CommandLog from './CommandLog.vue'
|
||||||
@@ -19,6 +19,41 @@ const editingConnection = ref<ConnectionConfig | null>(null)
|
|||||||
const collapsed = ref(false)
|
const collapsed = ref(false)
|
||||||
const commandLogVisible = 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) {
|
function handleEdit(conn: ConnectionConfig | null) {
|
||||||
editingConnection.value = conn
|
editingConnection.value = conn
|
||||||
dialogVisible.value = true
|
dialogVisible.value = true
|
||||||
@@ -103,10 +138,11 @@ useKeyboard({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<div class="sidebar-header" v-if="!collapsed">
|
||||||
<ConnectionList @edit="handleEdit" />
|
<ConnectionList @edit="handleEdit" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="drag-handle" @mousedown="startResize"></div>
|
||||||
<button class="collapse-btn" @click="collapsed = !collapsed" :title="collapsed ? '展开' : '收缩'">
|
<button class="collapse-btn" @click="collapsed = !collapsed" :title="collapsed ? '展开' : '收缩'">
|
||||||
<el-icon>
|
<el-icon>
|
||||||
<component :is="collapsed ? 'ArrowRight' : 'ArrowLeft'" />
|
<component :is="collapsed ? 'ArrowRight' : 'ArrowLeft'" />
|
||||||
@@ -122,7 +158,6 @@ useKeyboard({
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: 260px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-right: 1px solid var(--border-color);
|
border-right: 1px solid var(--border-color);
|
||||||
@@ -130,11 +165,15 @@ useKeyboard({
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar:not(.resizing) {
|
||||||
transition: width 0.2s ease;
|
transition: width 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.collapsed {
|
.sidebar.collapsed {
|
||||||
width: 36px;
|
width: 36px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
@@ -142,6 +181,22 @@ useKeyboard({
|
|||||||
overflow: hidden;
|
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 {
|
.collapse-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// src/components/SlowLogView.vue
|
// src/components/SlowLogView.vue
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useConnectionStore } from '@renderer/stores/connection'
|
import { useConnectionStore } from '@renderer/stores/connection'
|
||||||
import { useI18n } from '@renderer/i18n'
|
import { useI18n } from '@renderer/i18n'
|
||||||
|
|
||||||
@@ -16,6 +16,20 @@ interface SlowLogEntry {
|
|||||||
|
|
||||||
const entries = ref<SlowLogEntry[]>([])
|
const entries = ref<SlowLogEntry[]>([])
|
||||||
const loading = ref(false)
|
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() {
|
async function refresh() {
|
||||||
if (!connStore.activeId) return
|
if (!connStore.activeId) return
|
||||||
@@ -28,6 +42,7 @@ async function refresh() {
|
|||||||
duration: item[2],
|
duration: item[2],
|
||||||
command: Array.isArray(item[3]) ? item[3].join(' ') : String(item[3]),
|
command: Array.isArray(item[3]) ? item[3].join(' ') : String(item[3]),
|
||||||
}))
|
}))
|
||||||
|
await fetchConfig()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -43,6 +58,12 @@ function formatTime(ts: number) {
|
|||||||
return new Date(ts * 1000).toLocaleString()
|
return new Date(ts * 1000).toLocaleString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (connStore.activeId) {
|
||||||
|
fetchConfig()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
defineExpose({ refresh })
|
defineExpose({ refresh })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -67,6 +88,10 @@ defineExpose({ refresh })
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -102,4 +127,14 @@ defineExpose({ refresh })
|
|||||||
font-family: 'Cascadia Code', monospace;
|
font-family: 'Cascadia Code', monospace;
|
||||||
font-size: 12px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// src/components/StatusView.vue
|
// 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 { useConnectionStore } from '@renderer/stores/connection'
|
||||||
import { useI18n } from '@renderer/i18n'
|
import { useI18n } from '@renderer/i18n'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
@@ -15,8 +15,73 @@ interface InfoSection {
|
|||||||
items: { key: string; value: string }[]
|
items: { key: string; value: string }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface KeyStatEntry {
|
||||||
|
db: string
|
||||||
|
keys: number
|
||||||
|
expires: number
|
||||||
|
avgTtl: number | null
|
||||||
|
}
|
||||||
|
|
||||||
const sections = ref<InfoSection[]>([])
|
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
|
// Redis INFO section name translations
|
||||||
const sectionTranslations: Record<string, string> = {
|
const sectionTranslations: Record<string, string> = {
|
||||||
'Server': 'status.sectionServer',
|
'Server': 'status.sectionServer',
|
||||||
@@ -73,6 +138,7 @@ async function refresh() {
|
|||||||
|
|
||||||
function startAutoRefresh() {
|
function startAutoRefresh() {
|
||||||
stopAutoRefresh()
|
stopAutoRefresh()
|
||||||
|
if (!autoRefreshEnabled.value) return
|
||||||
refresh()
|
refresh()
|
||||||
refreshTimer = setInterval(refresh, 1000)
|
refreshTimer = setInterval(refresh, 1000)
|
||||||
}
|
}
|
||||||
@@ -132,6 +198,13 @@ defineExpose({ refresh })
|
|||||||
<div class="status-actions">
|
<div class="status-actions">
|
||||||
<el-button size="small" type="danger" @click="handleFlushDb">{{ t('status.flushDb') }}</el-button>
|
<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" @click="refresh">{{ t('common.refresh') }}</el-button>
|
||||||
|
<el-button
|
||||||
|
size="small"
|
||||||
|
:type="autoRefreshEnabled ? 'primary' : 'default'"
|
||||||
|
@click="toggleAutoRefresh"
|
||||||
|
>
|
||||||
|
{{ t('status.autoRefresh') }}
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -146,8 +219,33 @@ defineExpose({ refresh })
|
|||||||
</div>
|
</div>
|
||||||
</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 class="info-sections">
|
||||||
<div v-for="section in sections" :key="section.title" class="info-section">
|
<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>
|
<h3 class="section-title">{{ translateSectionTitle(section.title) }}</h3>
|
||||||
<div class="section-items">
|
<div class="section-items">
|
||||||
<div v-for="item in section.items" :key="item.key" class="info-row">
|
<div v-for="item in section.items" :key="item.key" class="info-row">
|
||||||
@@ -156,6 +254,10 @@ defineExpose({ refresh })
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="no-results">
|
||||||
|
{{ t('status.noResults') }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -263,4 +365,37 @@ defineExpose({ refresh })
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
font-family: 'Cascadia Code', 'Fira Code', monospace;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -20,6 +20,20 @@ const isEditing = ref(false)
|
|||||||
const selectedFormat = ref('auto')
|
const selectedFormat = ref('auto')
|
||||||
const detectedFormat = ref('text')
|
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(() => {
|
const monacoTheme = computed(() => {
|
||||||
if (appStore.theme === 'light') return 'vs'
|
if (appStore.theme === 'light') return 'vs'
|
||||||
return 'vs-dark'
|
return 'vs-dark'
|
||||||
@@ -174,7 +188,12 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-content">
|
<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
|
<vue-monaco-editor
|
||||||
|
v-else
|
||||||
v-model:value="displayValue"
|
v-model:value="displayValue"
|
||||||
:theme="monacoTheme"
|
:theme="monacoTheme"
|
||||||
:language="monacoLanguage"
|
:language="monacoLanguage"
|
||||||
@@ -224,4 +243,18 @@ onUnmounted(() => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const filterText = ref('')
|
|||||||
const showAddDialog = ref(false)
|
const showAddDialog = ref(false)
|
||||||
const newMember = ref('')
|
const newMember = ref('')
|
||||||
const newScore = ref(0)
|
const newScore = ref(0)
|
||||||
|
const sortOrder = ref<'asc' | 'desc'>('asc')
|
||||||
|
|
||||||
async function loadMembers() {
|
async function loadMembers() {
|
||||||
if (!connStore.activeId || !keyStore.selectedKey) return
|
if (!connStore.activeId || !keyStore.selectedKey) return
|
||||||
@@ -71,10 +72,18 @@ async function removeMember(member: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filteredItems = ref<ZsetItem[]>([])
|
const filteredItems = ref<ZsetItem[]>([])
|
||||||
watch([items, filterText], () => {
|
watch([items, filterText, sortOrder], () => {
|
||||||
filteredItems.value = filterText.value
|
let result = items.value
|
||||||
? items.value.filter(i => i.member.includes(filterText.value))
|
if (filterText.value) {
|
||||||
: items.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 })
|
}, { immediate: true })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -84,6 +93,10 @@ watch([items, filterText], () => {
|
|||||||
<span class="type-badge">ZSET</span>
|
<span class="type-badge">ZSET</span>
|
||||||
<span class="item-count">{{ t('editor.members', { n: items.length }) }}</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-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" type="primary" @click="showAddDialog = true">{{ t('common.add') }}</el-button>
|
||||||
<el-button size="small" @click="loadMembers" :loading="loading">{{ t('common.refresh') }}</el-button>
|
<el-button size="small" @click="loadMembers" :loading="loading">{{ t('common.refresh') }}</el-button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -179,6 +179,14 @@ export default {
|
|||||||
ack: 'ACK',
|
ack: 'ACK',
|
||||||
ackConfirm: 'ACK selected entries?',
|
ackConfirm: 'ACK selected entries?',
|
||||||
noGroups: 'No consumer groups',
|
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: {
|
cli: {
|
||||||
title: 'CLI',
|
title: 'CLI',
|
||||||
@@ -205,6 +213,8 @@ export default {
|
|||||||
time: 'Time',
|
time: 'Time',
|
||||||
duration: 'Duration',
|
duration: 'Duration',
|
||||||
command: 'Command',
|
command: 'Command',
|
||||||
|
threshold: 'Threshold',
|
||||||
|
maxLen: 'Max Length',
|
||||||
},
|
},
|
||||||
memory: {
|
memory: {
|
||||||
title: 'Memory Analysis',
|
title: 'Memory Analysis',
|
||||||
@@ -255,6 +265,14 @@ export default {
|
|||||||
connecting: 'Connecting...',
|
connecting: 'Connecting...',
|
||||||
error: 'Error: {error}',
|
error: 'Error: {error}',
|
||||||
noConnection: 'No connection',
|
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',
|
sectionServer: 'Server',
|
||||||
sectionClients: 'Clients',
|
sectionClients: 'Clients',
|
||||||
sectionMemory: 'Memory',
|
sectionMemory: 'Memory',
|
||||||
@@ -269,6 +287,8 @@ export default {
|
|||||||
},
|
},
|
||||||
db: {
|
db: {
|
||||||
label: 'DB',
|
label: 'DB',
|
||||||
|
keyCount: '{n} keys',
|
||||||
|
noKeys: 'empty',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
|
|||||||
@@ -179,6 +179,14 @@ export default {
|
|||||||
ack: 'ACK',
|
ack: 'ACK',
|
||||||
ackConfirm: '确认 ACK 选中的条目?',
|
ackConfirm: '确认 ACK 选中的条目?',
|
||||||
noGroups: '暂无消费者组',
|
noGroups: '暂无消费者组',
|
||||||
|
valueTooLarge: '值过大无法显示 (>20MB)',
|
||||||
|
download: '下载',
|
||||||
|
setFieldTtl: '设置字段 TTL',
|
||||||
|
fieldTtl: '字段 TTL',
|
||||||
|
sortAsc: '升序',
|
||||||
|
sortDesc: '降序',
|
||||||
|
filteredCount: '已过滤 {n} 项',
|
||||||
|
fieldCount: '{n} 个字段',
|
||||||
},
|
},
|
||||||
cli: {
|
cli: {
|
||||||
title: 'CLI',
|
title: 'CLI',
|
||||||
@@ -205,6 +213,8 @@ export default {
|
|||||||
time: '时间',
|
time: '时间',
|
||||||
duration: '耗时',
|
duration: '耗时',
|
||||||
command: '命令',
|
command: '命令',
|
||||||
|
threshold: '阈值',
|
||||||
|
maxLen: '最大长度',
|
||||||
},
|
},
|
||||||
memory: {
|
memory: {
|
||||||
title: '内存分析',
|
title: '内存分析',
|
||||||
@@ -255,6 +265,14 @@ export default {
|
|||||||
connecting: '连接中...',
|
connecting: '连接中...',
|
||||||
error: '错误: {error}',
|
error: '错误: {error}',
|
||||||
noConnection: '无连接',
|
noConnection: '无连接',
|
||||||
|
autoRefresh: '自动刷新',
|
||||||
|
searchInfo: '搜索 INFO...',
|
||||||
|
noResults: '无匹配结果',
|
||||||
|
keyStats: '键空间统计',
|
||||||
|
dbColumn: '数据库',
|
||||||
|
keysColumn: '键数',
|
||||||
|
expiresColumn: '过期数',
|
||||||
|
avgTtlColumn: '平均 TTL',
|
||||||
sectionServer: '服务器',
|
sectionServer: '服务器',
|
||||||
sectionClients: '客户端',
|
sectionClients: '客户端',
|
||||||
sectionMemory: '内存',
|
sectionMemory: '内存',
|
||||||
@@ -269,6 +287,8 @@ export default {
|
|||||||
},
|
},
|
||||||
db: {
|
db: {
|
||||||
label: '数据库',
|
label: '数据库',
|
||||||
|
keyCount: '{n} 个键',
|
||||||
|
noKeys: '空',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
title: '设置',
|
title: '设置',
|
||||||
|
|||||||
@@ -42,8 +42,17 @@ export const useConnectionStore = defineStore('connection', () => {
|
|||||||
const serverInfoMap = ref<Record<string, string>>({})
|
const serverInfoMap = ref<Record<string, string>>({})
|
||||||
const healthMap = ref<Record<string, boolean>>({})
|
const healthMap = ref<Record<string, boolean>>({})
|
||||||
const currentDb = ref<number>(0)
|
const currentDb = ref<number>(0)
|
||||||
|
const lastDbMap = ref<Record<string, number>>({})
|
||||||
const healthTimers = new Map<string, ReturnType<typeof setInterval>>()
|
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(() =>
|
const activeConnection = computed(() =>
|
||||||
connections.value.find((c: ConnectionConfig) => c.id === activeId.value) ?? null
|
connections.value.find((c: ConnectionConfig) => c.id === activeId.value) ?? null
|
||||||
)
|
)
|
||||||
@@ -123,6 +132,14 @@ export const useConnectionStore = defineStore('connection', () => {
|
|||||||
connectedIds.value.push(conn.id)
|
connectedIds.value.push(conn.id)
|
||||||
activeId.value = conn.id
|
activeId.value = conn.id
|
||||||
serverInfoMap.value[conn.id] = await window.electronAPI.redis.getInfo(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)
|
startHealthCheck(conn.id)
|
||||||
} else {
|
} else {
|
||||||
error.value = result.error || 'Connection failed'
|
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)) {
|
if (connectedIds.value.includes(id)) {
|
||||||
activeId.value = 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
|
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> {
|
async function importConnections(jsonStr: string): Promise<number> {
|
||||||
try {
|
try {
|
||||||
const imported = JSON.parse(jsonStr) as ConnectionConfig[]
|
const imported = JSON.parse(jsonStr) as ConnectionConfig[]
|
||||||
@@ -274,9 +304,10 @@ export const useConnectionStore = defineStore('connection', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
connections, activeId, connectedIds, connecting, error, serverInfo, healthOk, currentDb,
|
connections, activeId, connectedIds, connecting, error, serverInfo, healthOk, currentDb, lastDbMap,
|
||||||
activeConnection, isConnected,
|
activeConnection, isConnected,
|
||||||
loadConnections, decryptPassword, getInfo, saveConnection, deleteConnection, reorderConnections,
|
loadConnections, decryptPassword, getInfo, saveConnection, deleteConnection, reorderConnections,
|
||||||
connect, disconnect, disconnectById, switchTo, isConnectedById, testConnection, exportConnections, importConnections,
|
connect, disconnect, disconnectById, switchTo, isConnectedById, testConnection, exportConnections, importConnections,
|
||||||
|
saveLastDb,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user