diff --git a/frontend/src/api/datasource/redis.ts b/frontend/src/api/datasource/redis.ts index 960c7df..216e191 100644 --- a/frontend/src/api/datasource/redis.ts +++ b/frontend/src/api/datasource/redis.ts @@ -1,17 +1,36 @@ import request from '../request'; +/** 键摘要(TTL 毫秒,-1 永久) */ +export interface RedisKeySummary { + key: string; + type: string; + ttl: number; +} + +/** 游标分页扫描响应 */ +export interface RedisScanPage { + keys: RedisKeySummary[]; + /** 不透明游标;空串表示已扫完 */ + next_cursor: string; + exact: RedisKeySummary | null; + cluster: boolean; +} + export const redisApi = { // Redis 信息概览 - getInfo: (instanceId: number) => - request.get('/redis/' + instanceId + '/info'), + getInfo: (instanceId: number) => request.get('/redis/' + instanceId + '/info'), + + // 库列表(集群恒 [0]) + getDbs: (instanceId: number) => + request.get('/redis/' + instanceId + '/dbs'), // DBSIZE getDbsize: (instanceId: number, db?: number) => request.get('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }), - // 键列表 - scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) => - request.get('/redis/' + instanceId + '/keys', { params }), + // 键列表(游标分页;无通配符自动按 *关键词* 包含匹配并做精确命中探测) + scanKeys: (instanceId: number, params: { db?: number; pattern?: string; type?: string; cursor?: string }) => + request.get('/redis/' + instanceId + '/keys', { params }), // 键详情 getKeyDetail: (instanceId: number, key: string, db?: number) => @@ -22,24 +41,31 @@ export const redisApi = { request.get('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }), // 客户端列表 - getClients: (instanceId: number) => - request.get('/redis/' + instanceId + '/clients'), + getClients: (instanceId: number) => request.get('/redis/' + instanceId + '/clients'), - // 执行命令 + // 执行只读命令(整串,引号感知;白名单外的命令会被拒绝) executeCommand: (instanceId: number, data: { db: number; command: string }) => request.post('/redis/' + instanceId + '/command', data), - // 获取键列表(兼容旧接口) - getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) => - request.get('/redis/' + instanceId + '/keys', { params }), + // Hash 行级编辑(op: set/del,不整键重写) + updateKeyField: ( + instanceId: number, + key: string, + data: { db?: number; op: 'set' | 'del'; field: string; value?: string }, + ) => + request.post('/redis/' + instanceId + '/keys/' + encodeURIComponent(key) + '/field', data, { + params: { db: data.db ?? 0 }, + }), // 创建键 createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) => request.post('/redis/' + instanceId + '/keys', data), - // 更新键值(整体替换 value,并可设置 TTL) + // 更新键值(整体替换 value;ttl 省略时保留原 TTL) updateKey: (instanceId: number, key: string, data: { db?: number; type: string; value: any; ttl?: number }) => - request.put('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), data, { params: { db: data.db ?? 0 } }), + request.put('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), data, { + params: { db: data.db ?? 0 }, + }), // 删除键 deleteKey: (instanceId: number, key: string, db?: number) => diff --git a/frontend/src/components/SchemaSidebar.tsx b/frontend/src/components/SchemaSidebar.tsx index e9ff405..692455e 100644 --- a/frontend/src/components/SchemaSidebar.tsx +++ b/frontend/src/components/SchemaSidebar.tsx @@ -1,10 +1,20 @@ import { useState, useEffect, useCallback } from 'react'; import { - ChevronDown, ChevronRight, Database, Table2, Columns3, - KeyRound, Search, Loader2, FolderOpen, Server, + ChevronDown, + ChevronRight, + Database, + Table2, + Columns3, + KeyRound, + Search, + Loader2, + FolderOpen, + Server, } from 'lucide-react'; import { instanceDetailApi, type TableMeta, type ColumnInfo, type IndexInfo } from '@/api/instanceDetail'; import { redisApi } from '@/api/redis'; +import { RedisKeyBrowser } from '@/features/redis/keyBrowser/RedisKeyBrowser'; +import { useRedisDbs } from '@/features/redis/keyBrowser/useRedisKeyBrowser'; /* ------------------------------------------------------------------ */ /* Types */ @@ -22,156 +32,6 @@ interface Props { singleDb?: boolean; } -interface RedisKeyItem { - key: string; - type: string; - ttl: number; -} - -/** Redis key 树节点 */ -interface RedisTreeNode { - name: string; - fullPrefix: string; - isLeaf: boolean; - keyItem?: RedisKeyItem; // 叶子节点才有 - children: Map; -} - -/** 把 flat keys 按 ':' 构建成树 */ -function buildRedisTree(keys: RedisKeyItem[]): RedisTreeNode { - const root: RedisTreeNode = { name: '', fullPrefix: '', isLeaf: false, children: new Map() }; - for (const item of keys) { - const parts = item.key.split(':'); - let node = root; - let prefix = ''; - for (let i = 0; i < parts.length; i++) { - prefix += (i === 0 ? '' : ':') + parts[i]; - if (!node.children.has(parts[i])) { - node.children.set(parts[i], { - name: parts[i], - fullPrefix: prefix, - isLeaf: i === parts.length - 1, - keyItem: i === parts.length - 1 ? item : undefined, - children: new Map(), - }); - } - node = node.children.get(parts[i])!; - } - } - return root; -} - -/** Redis 树形渲染组件 */ -/** 根据 key type 生成查询命令 */ -function redisCommand(key: string, type: string): string { - switch (type) { - case 'hash': return `HGETALL ${key}`; - case 'list': return `LRANGE ${key} 0 -1`; - case 'set': return `SMEMBERS ${key}`; - case 'zset': return `ZRANGE ${key} 0 -1 WITHSCORES`; - default: return `GET ${key}`; - } -} - -function RedisTree({ tree, expanded, setExpanded, selectedKey, onSelectKey, keyDetail, loadingDetail, onInsert }: { - tree: RedisTreeNode; - expanded: Set; - setExpanded: React.Dispatch>>; - selectedKey: string | null; - onSelectKey: (key: string) => void; - keyDetail: any; - loadingDetail: boolean; - onInsert?: (cmd: string) => void; -}) { - const toggle = (prefix: string) => { - setExpanded(prev => { - const next = new Set(prev); - next.has(prefix) ? next.delete(prefix) : next.add(prefix); - return next; - }); - }; - - const renderNode = (node: RedisTreeNode, depth: number) => { - const sorted = [...node.children.values()].sort((a, b) => { - // 文件夹优先,叶子在后 - if (a.isLeaf !== b.isLeaf) return a.isLeaf ? 1 : -1; - return a.name.localeCompare(b.name); - }); - - return sorted.map(child => { - const indent = depth * 12; - - if (child.isLeaf && child.keyItem) { - // 叶子节点:实际 Redis key - return ( -
- - {selectedKey === child.keyItem.key && ( -
- {loadingDetail ? ( -
- ... -
- ) : keyDetail ? ( -
-
- {keyDetail.type} - TTL: {formatTTL(keyDetail.ttl)} -
-
-                      {typeof keyDetail.value === 'object'
-                        ? JSON.stringify(keyDetail.value, null, 2)
-                        : String(keyDetail.value)}
-                    
-
- ) : null} -
- )} -
- ); - } - - // 文件夹节点 - const isOpen = expanded.has(child.fullPrefix); - const childCount = child.children.size; - return ( -
- - {isOpen && renderNode(child, depth + 1)} -
- ); - }); - }; - - return
{renderNode(tree, 0)}
; -} - -/** 每个数据库节点缓存的表列表 */ interface DbNode { name: string; tables: TableMeta[]; @@ -195,7 +55,15 @@ function formatTTL(ttl: number): string { /* COMPONENT */ /* ================================================================== */ -export default function SchemaSidebar({ instanceId, database, dbType, clusterMode, onInsert, onDatabaseChange, singleDb }: Props) { +export default function SchemaSidebar({ + instanceId, + database, + dbType, + clusterMode, + onInsert, + onDatabaseChange, + singleDb, +}: Props) { const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql'; const isRedis = dbType === 'redis'; @@ -211,12 +79,9 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod const [detailTab, setDetailTab] = useState<'columns' | 'indexes'>('columns'); const [search, setSearch] = useState(''); - // -- Redis State -- + // -- Redis State(浏览逻辑在共享 RedisKeyBrowser,这里只留库选择与详情预览) -- const [redisDb, setRedisDb] = useState(0); - const [redisPattern, setRedisPattern] = useState('*'); - const [redisKeys, setRedisKeys] = useState([]); - const [loadingRedis, setLoadingRedis] = useState(false); - const [redisExpanded, setRedisExpanded] = useState>(new Set()); + const redisDbs = useRedisDbs(Number(instanceId)); const [selectedKey, setSelectedKey] = useState(null); const [keyDetail, setKeyDetail] = useState(null); const [loadingDetail, setLoadingDetail] = useState(false); @@ -224,14 +89,24 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod // -- Tab -- const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables'); + // 驱动类型切换时落到对应 tab:redis → Keys,SQL 库 → 库表 + useEffect(() => { + if (isRedis) setActiveTab('redis'); + else setActiveTab('tables'); + }, [isRedis]); + /* ---------------------------------------------------------------- */ /* Fetch databases (level 1) */ /* ---------------------------------------------------------------- */ useEffect(() => { - if (!instanceId || !isMySQL) { setDatabases([]); return; } + if (!instanceId || !isMySQL) { + setDatabases([]); + return; + } setLoadingDbs(true); - instanceDetailApi.listDatabases(Number(instanceId)) + instanceDetailApi + .listDatabases(Number(instanceId)) .then((data) => { const list = Array.isArray(data) ? data : []; setDatabases(list); @@ -255,116 +130,95 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod /* Expand database → load tables (level 2) */ /* ---------------------------------------------------------------- */ - const handleExpandDb = useCallback(async (dbName: string) => { - if (expandedDb === dbName) { - setExpandedDb(null); - return; - } - setExpandedDb(dbName); - setExpandedTable(null); + const handleExpandDb = useCallback( + async (dbName: string) => { + if (expandedDb === dbName) { + setExpandedDb(null); + return; + } + setExpandedDb(dbName); + setExpandedTable(null); - // 已加载过则跳过 - if (dbNodes[dbName]?.loaded) return; + // 已加载过则跳过 + if (dbNodes[dbName]?.loaded) return; - setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: true, loaded: false } })); - try { - const data = await instanceDetailApi.listTables(Number(instanceId), dbName); - const tables = Array.isArray(data) ? data : []; - setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables, loading: false, loaded: true } })); - } catch { - setDbNodes(prev => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: false, loaded: true } })); - } - }, [expandedDb, instanceId, dbNodes]); + setDbNodes((prev) => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: true, loaded: false } })); + try { + const data = await instanceDetailApi.listTables(Number(instanceId), dbName); + const tables = Array.isArray(data) ? data : []; + setDbNodes((prev) => ({ ...prev, [dbName]: { name: dbName, tables, loading: false, loaded: true } })); + } catch { + setDbNodes((prev) => ({ ...prev, [dbName]: { name: dbName, tables: [], loading: false, loaded: true } })); + } + }, + [expandedDb, instanceId, dbNodes], + ); /* ---------------------------------------------------------------- */ /* Expand table → load columns (level 3) */ /* ---------------------------------------------------------------- */ - const handleExpandTable = useCallback(async (dbName: string, tableName: string) => { - const key = `${dbName}.${tableName}`; - if (expandedTable === key) { setExpandedTable(null); return; } - setExpandedTable(key); - setTableColumns([]); - setTableIndexes([]); - setDetailTab('columns'); - setLoadingColumns(true); - try { - const detail = await instanceDetailApi.getTableDetail(Number(instanceId), dbName, tableName); - setTableColumns(detail?.columns || []); - setTableIndexes(detail?.indexes || []); - } catch { /* ignore */ } - finally { setLoadingColumns(false); } - }, [expandedTable, instanceId]); + const handleExpandTable = useCallback( + async (dbName: string, tableName: string) => { + const key = `${dbName}.${tableName}`; + if (expandedTable === key) { + setExpandedTable(null); + return; + } + setExpandedTable(key); + setTableColumns([]); + setTableIndexes([]); + setDetailTab('columns'); + setLoadingColumns(true); + try { + const detail = await instanceDetailApi.getTableDetail(Number(instanceId), dbName, tableName); + setTableColumns(detail?.columns || []); + setTableIndexes(detail?.indexes || []); + } catch { + /* ignore */ + } finally { + setLoadingColumns(false); + } + }, + [expandedTable, instanceId], + ); /* ---------------------------------------------------------------- */ /* Fetch Redis keys */ /* ---------------------------------------------------------------- */ - const fetchRedisKeys = useCallback(async () => { - if (!instanceId) return; - setLoadingRedis(true); - try { - const res = await redisApi.scanKeys(Number(instanceId), { - db: redisDb, pattern: redisPattern || '*', count: 100, - }); - const keys = (res?.keys || []).map((k: any) => ({ - key: typeof k === 'string' ? k : k.key, - type: typeof k === 'string' ? 'string' : (k.type || 'string'), - ttl: k.ttl ?? -1, - })); - setRedisKeys(keys); - } catch { setRedisKeys([]); } - finally { setLoadingRedis(false); } - }, [instanceId, redisDb, redisPattern]); - - useEffect(() => { - if (isRedis && instanceId) fetchRedisKeys(); - }, [isRedis, instanceId, fetchRedisKeys]); - - useEffect(() => { - const keyword = redisPattern.trim(); - if (!isRedis || redisKeys.length === 0 || !keyword || keyword === '*') return; - - setRedisExpanded(prev => { - const next = new Set(prev); - redisKeys.forEach(item => { - const parts = item.key.split(':'); - let prefix = ''; - for (let i = 0; i < parts.length - 1; i++) { - prefix += (i === 0 ? '' : ':') + parts[i]; - next.add(prefix); - } - }); - return next; - }); - }, [isRedis, redisKeys, redisPattern]); - /* ---------------------------------------------------------------- */ /* Fetch Redis key detail */ /* ---------------------------------------------------------------- */ - const handleSelectKey = useCallback(async (key: string) => { - setSelectedKey(key); - setLoadingDetail(true); - try { - const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb); - setKeyDetail(detail); - } catch { setKeyDetail(null); } - finally { setLoadingDetail(false); } - }, [instanceId, redisDb]); + const handleSelectKey = useCallback( + async (key: string) => { + setSelectedKey(key); + setLoadingDetail(true); + try { + const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb); + setKeyDetail(detail); + } catch { + setKeyDetail(null); + } finally { + setLoadingDetail(false); + } + }, + [instanceId, redisDb], + ); /* ---------------------------------------------------------------- */ /* Filtered databases (search matches db name or table name) */ /* ---------------------------------------------------------------- */ - const filteredDatabases = databases.filter(dbName => { + const filteredDatabases = databases.filter((dbName) => { if (singleDb && database && dbName !== database) return false; if (!search) return true; const kw = search.toLowerCase(); if (dbName.toLowerCase().includes(kw)) return true; const node = dbNodes[dbName]; if (node?.loaded) { - return node.tables.some(t => t.name.toLowerCase().includes(kw)); + return node.tables.some((t) => t.name.toLowerCase().includes(kw)); } return false; }); @@ -374,7 +228,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod if (!node?.loaded) return []; if (!search) return node.tables; const kw = search.toLowerCase(); - return node.tables.filter(t => t.name.toLowerCase().includes(kw)); + return node.tables.filter((t) => t.name.toLowerCase().includes(kw)); }; /* ================================================================== */ @@ -465,7 +319,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
无数据库
) : (
- {filteredDatabases.map(dbName => { + {filteredDatabases.map((dbName) => { const isExpanded = expandedDb === dbName; const node = dbNodes[dbName]; const tables = getFilteredTables(dbName); @@ -483,14 +337,17 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod isActive ? 'text-blue-400' : '' }`} > - {isExpanded - ? - : - } + {isExpanded ? ( + + ) : ( + + )} {dbName} {isActive && ( - 当前 + + 当前 + )} @@ -509,7 +366,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod ) : tables.length === 0 ? (
无表
) : ( - tables.map(t => { + tables.map((t) => { const tableKey = `${dbName}.${t.name}`; const isTableExpanded = expandedTable === tableKey; @@ -517,22 +374,28 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
@@ -572,48 +435,83 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
{/* 字段列表 */} - {detailTab === 'columns' && tableColumns.map(col => ( - - ))} + {detailTab === 'columns' && + tableColumns.map((col) => ( + + ))} {/* 索引列表 */} - {detailTab === 'indexes' && ( - tableIndexes.length === 0 ? ( -
无索引
+ {detailTab === 'indexes' && + (tableIndexes.length === 0 ? ( +
+ 无索引 +
) : ( Object.entries( - tableIndexes.reduce((acc, idx) => { - if (!acc[idx.Key_name]) acc[idx.Key_name] = []; - acc[idx.Key_name].push(idx); - return acc; - }, {} as Record) + tableIndexes.reduce( + (acc, idx) => { + if (!acc[idx.Key_name]) acc[idx.Key_name] = []; + acc[idx.Key_name].push(idx); + return acc; + }, + {} as Record, + ), ).map(([keyName, cols]) => (
- - {keyName} - {!cols[0].Non_unique && UNI} - {cols[0].Index_type} + + + {keyName} + + {!cols[0].Non_unique && ( + + UNI + + )} + + {cols[0].Index_type} +
- {cols.sort((a, b) => a.Seq_in_index - b.Seq_in_index).map(c => ( - {c.Column_name} - ))} + {cols + .sort((a, b) => a.Seq_in_index - b.Seq_in_index) + .map((c) => ( + + {c.Column_name} + + ))}
)) - ) - )} + ))} )}
@@ -635,68 +533,63 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod {activeTab === 'redis' && isRedis && (
- {/* DB + Search */} -
- {clusterMode ? ( -
- 集群模式 · DB0 -
- ) : ( + {clusterMode || redisDbs.cluster ? ( +
+ 集群模式 · DB0 +
+ ) : ( - )} -
- - setRedisPattern(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()} - className="w-full pl-7 pr-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500" - placeholder="*" - /> -
- -
- - {/* Key Tree + Detail */} -
- {loadingRedis ? ( -
- {Array.from({ length: 6 }, (_, i) => ( -
-
-
-
+ )} +
+ { + handleSelectKey(key); + }} + onInsertCommand={onInsert} + renderDetail={(key) => { + if (selectedKey !== key) return null; + if (loadingDetail) { + return ( +
+ ... +
+ ); + } + if (!keyDetail) return null; + return ( +
+
+ + {keyDetail.type} + + TTL: {formatTTL(keyDetail.ttl)} +
+
+                        {typeof keyDetail.value === 'object'
+                          ? JSON.stringify(keyDetail.value, null, 2)
+                          : String(keyDetail.value)}
+                      
- ))} -
- ) : redisKeys.length === 0 ? ( -
无 Keys
- ) : ( - - )} + ); + }} + />
)} diff --git a/frontend/src/features/redis/keyBrowser/RedisKeyBrowser.tsx b/frontend/src/features/redis/keyBrowser/RedisKeyBrowser.tsx new file mode 100644 index 0000000..e2da9c4 --- /dev/null +++ b/frontend/src/features/redis/keyBrowser/RedisKeyBrowser.tsx @@ -0,0 +1,313 @@ +/** + * Redis 键浏览复合组件(搜索 + 类型筛选 + 分组树 + 游标加载更多)。 + * 用途:RedisDetailPage 键管理 tab(标准模式)与 SchemaSidebar Redis 分支(compact 模式)共用。 + * 必需 props:instanceId / db / onKeySelect;已知限制:树按 `:` 分组,分隔符不可配。 + */ +import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState, type ReactNode } from 'react'; +import { Search, X, ChevronDown, ChevronRight, FolderOpen, KeyRound, Loader2, RefreshCw } from 'lucide-react'; +import { redisApi } from '@/api/datasource/redis'; +import { + buildRedisKeyTree, + removeRedisKeyFromTree, + sortTreeChildren, + redisTypeColor, + formatRedisTtl, + redisReadCommand, + type RedisTreeNode, +} from './keyTree'; +import { useRedisKeyBrowser } from './useRedisKeyBrowser'; + +export interface RedisKeyBrowserHandle { + /** 重新执行当前搜索(新建键后调用) */ + refresh: () => void; + /** 本地移除一个键(删除成功后调用) */ + removeKey: (keyName: string) => void; +} + +interface Props { + instanceId: number; + db: number; + selectedKey?: string | null; + onKeySelect: (key: string, type: string) => void; + /** 叶子点击时额外回调(如向编辑器插入读命令) */ + onInsertCommand?: (cmd: string) => void; + /** 选中键的内联详情预览(SchemaSidebar 模式) */ + renderDetail?: (key: string) => ReactNode; + /** 搜索区上方的额外工具(如「新建」按钮) */ + toolbar?: ReactNode; + compact?: boolean; +} + +const TYPE_OPTIONS = [ + { value: '', label: '全部类型' }, + { value: 'string', label: 'String' }, + { value: 'hash', label: 'Hash' }, + { value: 'list', label: 'List' }, + { value: 'set', label: 'Set' }, + { value: 'zset', label: 'ZSet' }, + { value: 'stream', label: 'Stream' }, +]; + +export const RedisKeyBrowser = forwardRef(function RedisKeyBrowser( + { instanceId, db, selectedKey, onKeySelect, onInsertCommand, renderDetail, toolbar, compact = false }, + ref, +) { + const browser = useRedisKeyBrowser(instanceId, db); + const [expanded, setExpanded] = useState>(new Set()); + const lastSearchKeyRef = useRef(''); + + useImperativeHandle( + ref, + () => ({ + refresh: browser.search, + removeKey: browser.removeKey, + }), + [browser.search, browser.removeKey], + ); + + const tree = useMemo(() => buildRedisKeyTree(browser.items, browser.exact), [browser.items, browser.exact]); + + // 新搜索完成且带关键字时自动展开文件夹(加载更多不触发) + useEffect(() => { + const kw = browser.pattern.trim(); + const searchKey = `${kw}|${browser.typeFilter}|${db}`; + const isFreshSearch = lastSearchKeyRef.current !== searchKey; + lastSearchKeyRef.current = searchKey; + if (!isFreshSearch || browser.items.length === 0) return; + if (!kw || kw === '*') return; + setExpanded(() => { + const next = new Set(); + browser.items.forEach((item) => { + const parts = item.key.split(':'); + let prefix = ''; + for (let i = 0; i < parts.length - 1; i++) { + prefix += (i === 0 ? '' : ':') + parts[i]; + next.add(prefix); + } + }); + return next; + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [browser.items]); + + const toggle = (prefix: string) => { + setExpanded((prev) => { + const next = new Set(prev); + if (next.has(prefix)) next.delete(prefix); + else next.add(prefix); + return next; + }); + }; + + const inputCls = compact + ? 'w-full bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500' + : 'h-9 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] font-mono text-sm'; + const selectCls = compact + ? 'px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500' + : 'h-9 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm'; + const searchBtnCls = compact + ? 'px-2 py-1 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50' + : 'h-9 px-4 rounded-md bg-[var(--dg-blue)] text-sm font-medium text-white hover:opacity-90 disabled:opacity-50'; + + const renderNode = (node: RedisTreeNode, depth: number): ReactNode => { + const sorted = sortTreeChildren([...node.children.values()]); + const indent = depth * 12; + + return sorted.map((child) => { + if (child.isLeaf && child.keyItem) { + const item = child.keyItem; + const active = selectedKey === item.key; + return ( +
+ + {active && renderDetail && ( +
+ {renderDetail(item.key)} +
+ )} +
+ ); + } + + const isOpen = expanded.has(child.fullPrefix); + return ( +
+ + {isOpen && renderNode(child, depth + 1)} +
+ ); + }); + }; + + const hasQuery = browser.pattern.trim() !== '' || browser.typeFilter !== ''; + + return ( +
+ {/* 搜索区 */} +
+ {toolbar} +
+ + browser.setPattern(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') browser.search(); + }} + className={`${inputCls} ${compact ? 'pl-7 pr-7 py-1' : 'pl-9 pr-9'}`} + placeholder={compact ? '搜索 key,回车扫描;* 全部' : '输入关键词或 pattern,回车扫描(* 扫全部)'} + /> + {browser.pattern && ( + + )} +
+
+ + +
+
+ + {/* 计数行 */} +
+ + 共 {browser.total} 键{browser.exact ? ' · 精确命中置顶' : ''} + + {browser.total > 0 && ( + + )} +
+ + {/* 树 */} +
+ {browser.loading ? ( +
+ {Array.from({ length: compact ? 6 : 8 }, (_, i) => ( +
+ ))} +
+ ) : !hasQuery && browser.items.length === 0 ? ( +
+ 输入关键词搜索;留空回车可浏览全部 +
+ ) : browser.items.length === 0 && !browser.exact ? ( +
+ 无匹配 Keys +
+ ) : ( + <> + {renderNode(tree, 0)} + {browser.hasMore && ( + + )} + {!browser.hasMore && browser.total > 0 && ( +
+ 已全部加载 +
+ )} + + )} +
+
+ ); +}); diff --git a/frontend/src/features/redis/keyBrowser/keyTree.ts b/frontend/src/features/redis/keyBrowser/keyTree.ts new file mode 100644 index 0000000..416be9b --- /dev/null +++ b/frontend/src/features/redis/keyBrowser/keyTree.ts @@ -0,0 +1,169 @@ +/** + * Redis 键分组树(对标 opsxdb src/redis.ts): + * - 按 `:` 分隔符把键编成 folder 树,文件夹带递归 key 计数 + * - 无分隔符的键留根层;精确命中(exact)置顶根层 + * - 支持本地移除(删键后不整树重拉) + */ +import type { RedisKeySummary } from '@/api/datasource/redis'; + +export interface RedisTreeNode { + name: string; + fullPrefix: string; + isLeaf: boolean; + keyItem?: RedisKeySummary; + children: Map; + /** 该子树下(递归)的叶子键数 */ + leafCount: number; +} + +export const REDIS_KEY_SEPARATOR = ':'; + +/** 类型色点(string 绿/hash 蓝/list 琥珀/set 紫/zset 青/stream 粉) */ +export function redisTypeColor(type: string): string { + switch (type) { + case 'string': + return 'bg-emerald-400'; + case 'hash': + return 'bg-blue-400'; + case 'list': + return 'bg-amber-400'; + case 'set': + return 'bg-purple-400'; + case 'zset': + return 'bg-cyan-400'; + case 'stream': + return 'bg-pink-400'; + default: + return 'bg-red-400'; + } +} + +/** TTL 毫秒 → 可读(-1 永久 / 45s / 5m30s / 3h20m / 2d5h) */ +export function formatRedisTtl(ttlMs: number | undefined): string { + if (ttlMs == null || ttlMs < 0) return '永久'; + if (ttlMs === 0) return '即将过期'; + const seconds = Math.ceil(ttlMs / 1000); + if (seconds < 60) return `${seconds}s`; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m${seconds % 60 ? ' ' + (seconds % 60) + 's' : ''}`; + if (seconds < 86400) + return `${Math.floor(seconds / 3600)}h${Math.floor((seconds % 3600) / 60) ? ' ' + Math.floor((seconds % 3600) / 60) + 'm' : ''}`; + return `${Math.floor(seconds / 86400)}d${Math.floor((seconds % 86400) / 3600) ? ' ' + Math.floor((seconds % 86400) / 3600) + 'h' : ''}`; +} + +function countLeaves(node: RedisTreeNode): number { + if (node.isLeaf) return 1; + let n = 0; + for (const child of node.children.values()) n += countLeaves(child); + return n; +} + +/** 建树:exact 命中(若有)置顶根层 */ +export function buildRedisKeyTree(keys: RedisKeySummary[], exact?: RedisKeySummary | null): RedisTreeNode { + const root: RedisTreeNode = { name: '', fullPrefix: '', isLeaf: false, children: new Map(), leafCount: 0 }; + const seen = new Set(); + if (exact) { + root.children.set(exact.key, { + name: exact.key, + fullPrefix: exact.key, + isLeaf: true, + keyItem: exact, + children: new Map(), + leafCount: 1, + }); + seen.add(exact.key); + } + for (const item of keys) { + if (seen.has(item.key)) continue; + seen.add(item.key); + const parts = item.key.split(REDIS_KEY_SEPARATOR); + if (parts.length === 1) { + // 无分隔符:留根层 + root.children.set(item.key, { + name: item.key, + fullPrefix: item.key, + isLeaf: true, + keyItem: item, + children: new Map(), + leafCount: 1, + }); + continue; + } + let node = root; + let prefix = ''; + for (let i = 0; i < parts.length - 1; i++) { + prefix += (i === 0 ? '' : REDIS_KEY_SEPARATOR) + parts[i]; + let child = node.children.get(parts[i]); + if (!child || child.isLeaf) { + child = { name: parts[i], fullPrefix: prefix, isLeaf: false, children: new Map(), leafCount: 0 }; + node.children.set(parts[i], child); + } + node = child; + } + const leafName = parts[parts.length - 1]; + node.children.set(leafName, { + name: leafName, + fullPrefix: item.key, + isLeaf: true, + keyItem: item, + children: new Map(), + leafCount: 1, + }); + } + root.leafCount = countLeaves(root); + return root; +} + +/** 本地移除一个键(含空文件夹剪枝);返回树是否变化 */ +export function removeRedisKeyFromTree(root: RedisTreeNode, keyName: string): boolean { + const parts = keyName.split(REDIS_KEY_SEPARATOR); + const path: RedisTreeNode[] = [root]; + let node = root; + for (let i = 0; i < parts.length - 1; i++) { + const child = node.children.get(parts[i]); + if (!child) return false; + path.push(child); + node = child; + } + const leafName = parts[parts.length - 1]; + if (!node.children.delete(leafName)) return false; + // 从深处向根剪空文件夹并重算计数 + for (let i = path.length - 1; i >= 1; i--) { + const parent = path[i - 1]; + const child = path[i]; + child.leafCount = countLeaves(child); + if (!child.isLeaf && child.children.size === 0) { + parent.children.delete(child.name); + } + } + root.leafCount = countLeaves(root); + return true; +} + +/** 文件夹优先、同层 numeric 感知排序 */ +export function sortTreeChildren(children: RedisTreeNode[]): RedisTreeNode[] { + return [...children].sort((a, b) => { + if (a.isLeaf !== b.isLeaf) return a.isLeaf ? 1 : -1; + const na = Number(a.name); + const nb = Number(b.name); + const aNum = !Number.isNaN(na) && a.name.trim() !== ''; + const bNum = !Number.isNaN(nb) && b.name.trim() !== ''; + if (aNum && bNum) return na - nb; + return a.name.localeCompare(b.name); + }); +} + +/** 根据 key type 生成查询命令 */ +export function redisReadCommand(key: string, type: string): string { + switch (type) { + case 'hash': + return `HGETALL ${key}`; + case 'list': + return `LRANGE ${key} 0 -1`; + case 'set': + return `SMEMBERS ${key}`; + case 'zset': + return `ZRANGE ${key} 0 -1 WITHSCORES`; + default: + return `GET ${key}`; + } +} diff --git a/frontend/src/features/redis/keyBrowser/useRedisKeyBrowser.ts b/frontend/src/features/redis/keyBrowser/useRedisKeyBrowser.ts new file mode 100644 index 0000000..1e0002f --- /dev/null +++ b/frontend/src/features/redis/keyBrowser/useRedisKeyBrowser.ts @@ -0,0 +1,178 @@ +/** + * Redis 键浏览数据 hook(游标分页状态机): + * - 请求序号守卫:过期响应直接丢弃(快速切库/改筛选不竞态) + * - 增量合并:加载更多按键名 Map 追加,不整表重拉 + * - db / pattern / type 任一变化都重置游标 + */ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { redisApi, type RedisKeySummary } from '@/api/datasource/redis'; + +export interface UseRedisKeyBrowserResult { + items: RedisKeySummary[]; + exact: RedisKeySummary | null; + loading: boolean; + loadingMore: boolean; + hasMore: boolean; + pattern: string; + typeFilter: string; + setPattern: (v: string) => void; + setTypeFilter: (v: string) => void; + /** 用当前 pattern/typeFilter 发起新搜索 */ + search: () => void; + loadMore: () => void; + /** 本地移除一个键(删除成功后调用,不重拉) */ + removeKey: (keyName: string) => void; + total: number; +} + +export function useRedisKeyBrowser(instanceId: number, db: number): UseRedisKeyBrowserResult { + const [items, setItems] = useState([]); + const [exact, setExact] = useState(null); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [hasMore, setHasMore] = useState(false); + const [total, setTotal] = useState(0); + + const [pattern, setPatternState] = useState(''); + const [typeFilter, setTypeFilterState] = useState(''); + + const cursorRef = useRef(''); + const seqRef = useRef(0); + const mergedRef = useRef>(new Map()); + // 输入后立即回车时组件尚未重渲染,参数必须由 setter 即时同步(不能等渲染期赋值) + const argsRef = useRef({ pattern: '', typeFilter: '' }); + const setPattern = useCallback((v: string) => { + argsRef.current.pattern = v; + setPatternState(v); + }, []); + const setTypeFilter = useCallback((v: string) => { + argsRef.current.typeFilter = v; + setTypeFilterState(v); + }, []); + + const search = useCallback(() => { + const seq = ++seqRef.current; + cursorRef.current = ''; + mergedRef.current = new Map(); + setLoading(true); + setHasMore(false); + + redisApi + .scanKeys(instanceId, { + db, + pattern: argsRef.current.pattern.trim(), + type: argsRef.current.typeFilter || undefined, + }) + .then((res) => { + if (seq !== seqRef.current) return; + const keys = res?.keys || []; + keys.forEach((k) => mergedRef.current.set(k.key, k)); + setItems([...mergedRef.current.values()]); + setExact(res?.exact || null); + cursorRef.current = res?.next_cursor || ''; + setHasMore(Boolean(res?.next_cursor)); + setTotal(mergedRef.current.size); + }) + .catch(() => { + if (seq !== seqRef.current) return; + setItems([]); + setExact(null); + setTotal(0); + }) + .finally(() => { + if (seq !== seqRef.current) return; + setLoading(false); + }); + }, [instanceId, db]); + + const loadMore = useCallback(() => { + if (!cursorRef.current || loadingMore || loading) return; + const seq = seqRef.current; + setLoadingMore(true); + redisApi + .scanKeys(instanceId, { + db, + pattern: argsRef.current.pattern.trim(), + type: argsRef.current.typeFilter || undefined, + cursor: cursorRef.current, + }) + .then((res) => { + if (seq !== seqRef.current) return; + (res?.keys || []).forEach((k) => mergedRef.current.set(k.key, k)); + setItems([...mergedRef.current.values()]); + cursorRef.current = res?.next_cursor || ''; + setHasMore(Boolean(res?.next_cursor)); + setTotal(mergedRef.current.size); + }) + .catch(() => { + /* 拦截器已提示 */ + }) + .finally(() => { + if (seq !== seqRef.current) return; + setLoadingMore(false); + }); + }, [instanceId, db, loading, loadingMore]); + + const removeKey = useCallback((keyName: string) => { + if (mergedRef.current.delete(keyName)) { + setItems([...mergedRef.current.values()]); + setTotal(mergedRef.current.size); + setExact((prev) => (prev?.key === keyName ? null : prev)); + } + }, []); + + // 切库 / 换实例:清空并按当前条件重搜 + useEffect(() => { + seqRef.current++; + cursorRef.current = ''; + mergedRef.current = new Map(); + setItems([]); + setExact(null); + setHasMore(false); + setTotal(0); + if (instanceId) search(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [instanceId, db]); + + return { + items, + exact, + loading, + loadingMore, + hasMore, + pattern, + typeFilter, + setPattern, + setTypeFilter, + search, + loadMore, + removeKey, + total, + }; +} + +/** 库列表(集群恒 [0]);实例变化自动重拉 */ +export function useRedisDbs(instanceId: number): { dbs: number[]; cluster: boolean } { + const [dbs, setDbs] = useState([0]); + const [cluster, setCluster] = useState(false); + + useEffect(() => { + if (!instanceId) return; + let cancelled = false; + redisApi + .getDbs(instanceId) + .then((res) => { + if (cancelled) return; + setDbs(res?.dbs?.length ? res.dbs : [0]); + setCluster(Boolean(res?.cluster)); + }) + .catch(() => { + /* 集群探测失败按单机 0-15 处理 */ + }); + return () => { + cancelled = true; + }; + }, [instanceId]); + + return { dbs, cluster }; +} diff --git a/frontend/src/pages/QueriesPage.tsx b/frontend/src/pages/QueriesPage.tsx index f08839b..2d62213 100644 --- a/frontend/src/pages/QueriesPage.tsx +++ b/frontend/src/pages/QueriesPage.tsx @@ -17,8 +17,13 @@ import { RedisKeyValuePanel } from '@/features/query/components/RedisKeyValuePan import { QueryResultPanel } from '@/features/query/components/QueryResultPanel'; import { dbTypeIcon, normalizeDbType } from '@/features/query/constants'; import type { - QueryHistoryItem, QueryInstanceOption, QueryResult, - RedisKeyDetail, RedisKeyInfo, SortDirection, SortState, + QueryHistoryItem, + QueryInstanceOption, + QueryResult, + RedisKeyDetail, + RedisKeyInfo, + SortDirection, + SortState, } from '@/features/query/types'; import api from '@/api/request'; import { readStorage, writeStorage } from '@/lib/storage'; @@ -35,7 +40,12 @@ export default function QueriesPage() { const [executing, setExecuting] = useState(false); const [error, setError] = useState(''); const [result, setResult] = useState(null); - const [redisLineViewer, setRedisLineViewer] = useState({ open: false, field: '', value: '', format: 'Json' }); + const [redisLineViewer, setRedisLineViewer] = useState({ + open: false, + field: '', + value: '', + format: 'Json', + }); const [redisPattern, setRedisPattern] = useState(''); const [redisType, setRedisType] = useState(''); const [redisKeys, setRedisKeys] = useState([]); @@ -66,8 +76,10 @@ export default function QueriesPage() { const saved = readStorage('queryFavorites'); const favIds = saved ? new Set(JSON.parse(saved)) : new Set(); const initialHistory: QueryHistoryItem[] = []; - return initialHistory.map(h => ({ ...h, favorite: favIds.has(h.id) })); - } catch { return []; } + return initialHistory.map((h) => ({ ...h, favorite: favIds.has(h.id) })); + } catch { + return []; + } }); const [favFilter, setFavFilter] = useState(false); const [searchHistory, setSearchHistory] = useState(''); @@ -75,39 +87,74 @@ export default function QueriesPage() { /* ---- Data loading ---- */ useEffect(() => { - instanceApi.accessible('readonly').then(res => { - setInstances(Array.isArray(res) ? res : []); - }).catch(() => { setInstances([]); }); + instanceApi + .accessible('readonly') + .then((res) => { + setInstances(Array.isArray(res) ? res : []); + }) + .catch(() => { + setInstances([]); + }); }, []); useEffect(() => { - if (!instanceId) { setDatabases([]); setDatabase(''); setEditorSchema({}); return; } - const inst = instances.find(i => i.id === Number(instanceId)); - if (inst?.db_type === 'redis') { setDatabases([]); setDatabase('0'); setEditorSchema({}); return; } + if (!instanceId) { + setDatabases([]); + setDatabase(''); + setEditorSchema({}); + return; + } + const inst = instances.find((i) => i.id === Number(instanceId)); + if (inst?.db_type === 'redis') { + setDatabases([]); + setDatabase('0'); + setEditorSchema({}); + return; + } const type = inst?.db_type || ''; if (type === 'mysql' || type === 'postgres' || type === 'postgresql') { setLoadingDbs(true); - instanceDetailApi.listDatabases(Number(instanceId)) - .then(async data => { + instanceDetailApi + .listDatabases(Number(instanceId)) + .then(async (data) => { const dbs = Array.isArray(data) ? data : []; setDatabases(dbs); const schema: Record> = {}; - await Promise.all(dbs.map(async db => { - try { - const tables = await instanceDetailApi.listTables(Number(instanceId), db); - if (Array.isArray(tables)) { schema[db] = {}; for (const t of tables) { schema[db][t.name] = []; } } - } catch { /* ignore */ } - })); + await Promise.all( + dbs.map(async (db) => { + try { + const tables = await instanceDetailApi.listTables(Number(instanceId), db); + if (Array.isArray(tables)) { + schema[db] = {}; + for (const t of tables) { + schema[db][t.name] = []; + } + } + } catch { + /* ignore */ + } + }), + ); setEditorSchema(schema); }) - .catch(() => { setDatabases([]); setEditorSchema({}); }) + .catch(() => { + setDatabases([]); + setEditorSchema({}); + }) .finally(() => setLoadingDbs(false)); - } else { setDatabases([]); setEditorSchema({}); } + } else { + setDatabases([]); + setEditorSchema({}); + } }, [instanceId, instances]); - const selectedInstance = useMemo(() => instances.find(inst => String(inst.id) === instanceId), [instances, instanceId]); + const selectedInstance = useMemo( + () => instances.find((inst) => String(inst.id) === instanceId), + [instances, instanceId], + ); const dbType = normalizeDbType(selectedInstance?.db_type); - const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const; + const editorLang = + dbType === 'redis' ? ('redis' as const) : dbType === 'mongodb' ? ('mongodb' as const) : ('sql' as const); useEffect(() => { setRedisKeys([]); @@ -125,11 +172,10 @@ export default function QueriesPage() { } setRedisLoading(true); try { - const res = await redisApi.getKeys(Number(instanceId), { + const res = await redisApi.scanKeys(Number(instanceId), { db: Number(database || 0), pattern, type: redisType || undefined, - count: 80, }); setRedisKeys((res?.keys || res || []) as RedisKeyInfo[]); } catch { @@ -139,100 +185,189 @@ export default function QueriesPage() { } }, [instanceId, database, redisPattern, redisType]); - const loadRedisKeyDetail = useCallback(async (keyName: string) => { - if (!instanceId) return; - setRedisDetailLoading(true); - try { - const detail = await redisApi.getKeyDetail(Number(instanceId), keyName, Number(database || 0)); - setRedisSelectedKey(detail as RedisKeyDetail); - setRedisValueSearch(''); - } catch { - setRedisSelectedKey(null); - } finally { - setRedisDetailLoading(false); - } - }, [instanceId, database]); + const loadRedisKeyDetail = useCallback( + async (keyName: string) => { + if (!instanceId) return; + setRedisDetailLoading(true); + try { + const detail = await redisApi.getKeyDetail(Number(instanceId), keyName, Number(database || 0)); + setRedisSelectedKey(detail as RedisKeyDetail); + setRedisValueSearch(''); + } catch { + setRedisSelectedKey(null); + } finally { + setRedisDetailLoading(false); + } + }, + [instanceId, database], + ); const copyRedisText = useCallback((text: string) => { navigator.clipboard?.writeText(text).catch(() => undefined); }, []); const handleInspectRedisValue = useCallback((field: string, value: unknown) => { - setRedisLineViewer({ open: true, field, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' }); + setRedisLineViewer({ + open: true, + field, + value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), + format: 'Json', + }); }, []); useEffect(() => { if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgresql')) return; if (/^\d+$/.test(database)) return; - instanceDetailApi.getDbSchema(Number(instanceId), database) - .then(data => { if (data && typeof data === 'object') setEditorSchema(prev => ({ ...prev, [database]: data as Record })); }) - .catch(() => { /* ignore */ }); + instanceDetailApi + .getDbSchema(Number(instanceId), database) + .then((data) => { + if (data && typeof data === 'object') + setEditorSchema((prev) => ({ ...prev, [database]: data as Record })); + }) + .catch(() => { + /* ignore */ + }); }, [instanceId, database, dbType]); /* ---- Templates ---- */ const templates = useMemo(() => getTemplatesForType(dbType), [dbType]); - const templateCategories = useMemo(() => { const cats = new Set(templates.map(t => t.category)); return ['all', ...Array.from(cats)]; }, [templates]); + const templateCategories = useMemo(() => { + const cats = new Set(templates.map((t) => t.category)); + return ['all', ...Array.from(cats)]; + }, [templates]); const filteredTemplates = useMemo(() => { let r = templates; - if (templateCategory !== 'all') r = r.filter(t => t.category === templateCategory); - if (templateSearch) { const kw = templateSearch.toLowerCase(); r = r.filter(t => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw)); } + if (templateCategory !== 'all') r = r.filter((t) => t.category === templateCategory); + if (templateSearch) { + const kw = templateSearch.toLowerCase(); + r = r.filter((t) => t.name.toLowerCase().includes(kw) || t.description.toLowerCase().includes(kw)); + } return r; }, [templates, templateCategory, templateSearch]); - const handleApplyTemplate = useCallback((tpl: QueryTemplate) => { - setSql(getTemplateSQL(tpl, dbType, { db: database || undefined })); - setShowTemplates(false); - }, [dbType, database]); + const handleApplyTemplate = useCallback( + (tpl: QueryTemplate) => { + setSql(getTemplateSQL(tpl, dbType, { db: database || undefined })); + setShowTemplates(false); + }, + [dbType, database], + ); - const catLabel: Record = { all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询' }; + const catLabel: Record = { + all: '全部', + 表结构: '表结构', + 性能分析: '性能分析', + 数据统计: '数据统计', + 基础查询: '基础查询', + }; /* ---- Execute ---- */ - const handleExecute = useCallback(async (pageArg: number | unknown = 1) => { - const page = typeof pageArg === 'number' && Number.isFinite(pageArg) ? pageArg : 1; - if (!sql || !instanceId) { setError('请选择实例并输入查询语句'); return; } - if (/\{(?:table|db|collection)\}/.test(sql)) { setError('SQL 中含有未替换的占位符,请先替换或删除'); return; } - setExecuting(true); setError(''); setCurrentPage(page); - if (page === 1) setResult(null); - try { - const offset = (page - 1) * pageSize; - const res = await queryApi.execute({ instance_id: Number(instanceId), database: database || undefined, sql, limit: pageSize, offset }) as unknown as QueryResult; - const normalized = { ...res, execution_time_ms: res.execution_time_ms || res.cost_ms || 0 }; - if (normalized.error) { - setError(normalized.error); - setResult(null); - } else { - setResult(normalized); + const handleExecute = useCallback( + async (pageArg: number | unknown = 1) => { + const page = typeof pageArg === 'number' && Number.isFinite(pageArg) ? pageArg : 1; + if (!sql || !instanceId) { + setError('请选择实例并输入查询语句'); + return; } - if (page === 1) { - setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'success', execution_time_ms: normalized.execution_time_ms, result_preview: JSON.stringify(normalized.rows?.[0] || {}).substring(0, 100), created_at: new Date().toISOString(), favorite: false }, ...prev]); + if (/\{(?:table|db|collection)\}/.test(sql)) { + setError('SQL 中含有未替换的占位符,请先替换或删除'); + return; } - } catch (e: unknown) { - const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data?.detail; - const msg = Array.isArray(raw) - ? raw.map(v => v.msg).join('; ') - : (typeof raw === 'string' ? raw : raw ? JSON.stringify(raw) : '查询失败'); - setError(msg); - if (page === 1) { - setHistory(prev => [{ id: Date.now(), sql, instance_id: Number(instanceId), database, db_type: dbType, status: 'failed', execution_time_ms: 0, result_preview: msg, created_at: new Date().toISOString(), favorite: false }, ...prev]); + setExecuting(true); + setError(''); + setCurrentPage(page); + if (page === 1) setResult(null); + try { + const offset = (page - 1) * pageSize; + const res = (await queryApi.execute({ + instance_id: Number(instanceId), + database: database || undefined, + sql, + limit: pageSize, + offset, + })) as unknown as QueryResult; + const normalized = { ...res, execution_time_ms: res.execution_time_ms || res.cost_ms || 0 }; + if (normalized.error) { + setError(normalized.error); + setResult(null); + } else { + setResult(normalized); + } + if (page === 1) { + setHistory((prev) => [ + { + id: Date.now(), + sql, + instance_id: Number(instanceId), + database, + db_type: dbType, + status: 'success', + execution_time_ms: normalized.execution_time_ms, + result_preview: JSON.stringify(normalized.rows?.[0] || {}).substring(0, 100), + created_at: new Date().toISOString(), + favorite: false, + }, + ...prev, + ]); + } + } catch (e: unknown) { + const raw = (e as { response?: { data?: { detail?: string | Array<{ msg: string }> } } })?.response?.data + ?.detail; + const msg = Array.isArray(raw) + ? raw.map((v) => v.msg).join('; ') + : typeof raw === 'string' + ? raw + : raw + ? JSON.stringify(raw) + : '查询失败'; + setError(msg); + if (page === 1) { + setHistory((prev) => [ + { + id: Date.now(), + sql, + instance_id: Number(instanceId), + database, + db_type: dbType, + status: 'failed', + execution_time_ms: 0, + result_preview: msg, + created_at: new Date().toISOString(), + favorite: false, + }, + ...prev, + ]); + } + } finally { + setExecuting(false); } - } finally { setExecuting(false); } - }, [sql, instanceId, database, dbType, pageSize]); + }, + [sql, instanceId, database, dbType, pageSize], + ); /* ---- Result Sorting ---- */ const sortedResult = useMemo(() => { if (!result || !sortState.column || !sortState.direction) return result; if (!Array.isArray(result.rows)) return result; - const col = sortState.column; const dir = sortState.direction === 'asc' ? 1 : -1; - return { ...result, rows: [...result.rows].sort((a, b) => { - const av = a[col] ?? ''; const bv = b[col] ?? ''; - if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir; - return String(av).localeCompare(String(bv)) * dir; - })}; + const col = sortState.column; + const dir = sortState.direction === 'asc' ? 1 : -1; + return { + ...result, + rows: [...result.rows].sort((a, b) => { + const av = a[col] ?? ''; + const bv = b[col] ?? ''; + if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir; + return String(av).localeCompare(String(bv)) * dir; + }), + }; }, [result, sortState]); const handleSort = useCallback((col: string) => { - setSortState(prev => { - if (prev.column === col) { if (prev.direction === 'asc') return { column: col, direction: 'desc' as SortDirection }; if (prev.direction === 'desc') return { column: null, direction: null }; } + setSortState((prev) => { + if (prev.column === col) { + if (prev.direction === 'asc') return { column: col, direction: 'desc' as SortDirection }; + if (prev.direction === 'desc') return { column: null, direction: null }; + } return { column: col, direction: 'asc' as SortDirection }; }); }, []); @@ -240,11 +375,15 @@ export default function QueriesPage() { const handleExportResult = useCallback(async () => { if (!sql || !instanceId) return; try { - const blob = await api.post('/query/export', { - instance_id: Number(instanceId), - database: database || undefined, - sql, - }, { responseType: 'blob' }); + const blob = await api.post( + '/query/export', + { + instance_id: Number(instanceId), + database: database || undefined, + sql, + }, + { responseType: 'blob' }, + ); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; @@ -257,65 +396,82 @@ export default function QueriesPage() { }, [sql, instanceId, database]); // 修改每页条数后回到第 1 页并重新执行(沿用旧版时序:本次执行仍取旧 pageSize 闭包) - const handlePageSizeChange = useCallback((size: number) => { - setPageSize(size); - setCurrentPage(1); - handleExecute(1); - }, [handleExecute]); + const handlePageSizeChange = useCallback( + (size: number) => { + setPageSize(size); + setCurrentPage(1); + handleExecute(1); + }, + [handleExecute], + ); /* ---- History ---- */ const toggleFavorite = useCallback((id: number) => { - setHistory(prev => { - const next = prev.map(h => h.id === id ? { ...h, favorite: !h.favorite } : h); - try { writeStorage('queryFavorites', JSON.stringify(next.filter(h => h.favorite).map(h => h.id))); } catch { /* noop */ } + setHistory((prev) => { + const next = prev.map((h) => (h.id === id ? { ...h, favorite: !h.favorite } : h)); + try { + writeStorage('queryFavorites', JSON.stringify(next.filter((h) => h.favorite).map((h) => h.id))); + } catch { + /* noop */ + } return next; }); }, []); const handleClearHistory = useCallback(() => { - if (!clearConfirm) { setClearConfirm(true); setTimeout(() => setClearConfirm(false), 3000); return; } - setHistory([]); setClearConfirm(false); + if (!clearConfirm) { + setClearConfirm(true); + setTimeout(() => setClearConfirm(false), 3000); + return; + } + setHistory([]); + setClearConfirm(false); }, [clearConfirm]); // -- Sidebar 拖拽 -- - const handleDragStart = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - const startX = e.clientX; - const startWidth = sideWidth; - const onMove = (ev: MouseEvent) => { - const delta = ev.clientX - startX; - const next = Math.min(600, Math.max(200, startWidth + delta)); - setSideWidth(next); - }; - const onUp = () => { - document.removeEventListener('mousemove', onMove); - document.removeEventListener('mouseup', onUp); - setSideWidth(w => { - writeStorage('querySideWidth', String(w)); - return w; - }); - }; - document.addEventListener('mousemove', onMove); - document.addEventListener('mouseup', onUp); - }, [sideWidth]); + const handleDragStart = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = sideWidth; + const onMove = (ev: MouseEvent) => { + const delta = ev.clientX - startX; + const next = Math.min(600, Math.max(200, startWidth + delta)); + setSideWidth(next); + }; + const onUp = () => { + document.removeEventListener('mousemove', onMove); + document.removeEventListener('mouseup', onUp); + setSideWidth((w) => { + writeStorage('querySideWidth', String(w)); + return w; + }); + }; + document.addEventListener('mousemove', onMove); + document.addEventListener('mouseup', onUp); + }, + [sideWidth], + ); const filteredHistory = useMemo(() => { let r = history; - if (favFilter) r = r.filter(h => h.favorite); - if (searchHistory) { const kw = searchHistory.toLowerCase(); r = r.filter(h => h.sql.toLowerCase().includes(kw) || h.db_type.toLowerCase().includes(kw)); } + if (favFilter) r = r.filter((h) => h.favorite); + if (searchHistory) { + const kw = searchHistory.toLowerCase(); + r = r.filter((h) => h.sql.toLowerCase().includes(kw) || h.db_type.toLowerCase().includes(kw)); + } return r; }, [history, favFilter, searchHistory]); - const handleToggleSidebar = useCallback(() => setShowSidebar(v => !v), []); - const handleToggleTemplates = useCallback(() => setShowTemplates(v => !v), []); - const handleToggleHistory = useCallback(() => setShowHistory(v => !v), []); - const handleToggleResult = useCallback(() => setShowResult(v => !v), []); + const handleToggleSidebar = useCallback(() => setShowSidebar((v) => !v), []); + const handleToggleTemplates = useCallback(() => setShowTemplates((v) => !v), []); + const handleToggleHistory = useCallback(() => setShowHistory((v) => !v), []); + const handleToggleResult = useCallback(() => setShowResult((v) => !v), []); return (
{/* ====== 主区域 ====== */}
- {/* -- 顶栏: 实例+库选择 -- */} -
- + { if (dbType === 'redis') { setSql(text); } else { - setSql(prev => prev + (prev.trim() ? ' ' : '') + text + ' '); + setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' '); } }} onDatabaseChange={setDatabase} - singleDb={!!database} /> + singleDb={!!database} + />
- {error} + + {error}
)} @@ -448,9 +612,12 @@ export default function QueriesPage() { 查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
-
{/* end 编辑器+结果区 */} -
{/* end 主体区 (sidebar + 编辑器+结果) */} -
{/* end 主区域 */} +
+ {/* end 编辑器+结果区 */} +
+ {/* end 主体区 (sidebar + 编辑器+结果) */} +
+ {/* end 主区域 */} {showHistory && ( (); - var navigate = useNavigate(); - var instanceId = Number(params.id); + const params = useParams<{ id: string }>(); + const navigate = useNavigate(); + const instanceId = Number(params.id); - var [info, setInfo] = useState>({}); - var [infoSectionsMap, setInfoSectionsMap] = useState>>({}); - var [infoSearch, setInfoSearch] = useState(''); - var [activeTab, setActiveTab] = useState('keys'); - var [selectedDb, setSelectedDb] = useState(0); - var [keyPattern, setKeyPattern] = useState(''); - var [keyType, setKeyType] = useState(''); - var [keys, setKeys] = useState([]); - var [selectedKey, setSelectedKey] = useState(null); - var [detailLoading, setDetailLoading] = useState(false); - var [valueMode, setValueMode] = useState('table'); - var [stringFormat, setStringFormat] = useState('Json'); - var [inspectValue, setInspectValue] = useState<{ title: string; value: any } | null>(null); - var [lineEditor, setLineEditor] = useState({ open: false, originalField: '', field: '', value: '', format: 'Json' }); - var [valueSearch, setValueSearch] = useState(''); - var [editValue, setEditValue] = useState(''); - var [editTtl, setEditTtl] = useState(-1); - var [savingKey, setSavingKey] = useState(false); - var [showAddKey, setShowAddKey] = useState(false); - var [newKey, setNewKey] = useState({ key: '', value: '', type: 'string', ttl: -1 }); - var [cmdInput, setCmdInput] = useState(''); - var [cmdOutput, setCmdOutput] = useState([]); - var [slowlog, setSlowlog] = useState([]); - var [clients, setClients] = useState([]); - var [loading, setLoading] = useState(false); - const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null); + const [info, setInfo] = useState>({}); + const [infoSectionsMap, setInfoSectionsMap] = useState>>({}); + const [infoSearch, setInfoSearch] = useState(''); + const [activeTab, setActiveTab] = useState('keys'); + const [selectedDb, setSelectedDb] = useState(0); + const keyBrowserRef = useRef(null); + const dbsInfo = useRedisDbs(instanceId); + const [selectedKey, setSelectedKey] = useState(null); + const [detailLoading, setDetailLoading] = useState(false); + const [valueMode, setValueMode] = useState('table'); + const [stringFormat, setStringFormat] = useState('Json'); + const [inspectValue, setInspectValue] = useState<{ title: string; value: any } | null>(null); + const [lineEditor, setLineEditor] = useState({ + open: false, + originalField: '', + field: '', + value: '', + format: 'Json', + }); + const [valueSearch, setValueSearch] = useState(''); + const [editValue, setEditValue] = useState(''); + const [editTtl, setEditTtl] = useState(-1); + const [savingKey, setSavingKey] = useState(false); + const [showAddKey, setShowAddKey] = useState(false); + const [newKey, setNewKey] = useState({ key: '', value: '', type: 'string', ttl: -1 }); + const [cmdInput, setCmdInput] = useState(''); + const [cmdOutput, setCmdOutput] = useState([]); + const [slowlog, setSlowlog] = useState([]); + const [clients, setClients] = useState([]); + const [loading, setLoading] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState<{ msg: string; onConfirm: () => void } | null>(null); - var tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [ + const tabs: { key: TabKey; label: string; icon: React.ElementType }[] = [ { key: 'keys', label: '键管理', icon: Key }, { key: 'monitor', label: '监控信息', icon: Activity }, { key: 'cli', label: '命令行', icon: Terminal }, { key: 'perf', label: '性能分析', icon: Gauge }, ]; - useEffect(function () { - redisApi.getInfo(instanceId).then(function (res: any) { - var flatInfo = res.info || res || {}; - setInfo(flatInfo); - setInfoSectionsMap(res.sections || {}); - }).catch(function () { setInfo({}); setInfoSectionsMap({}); }); - }, [instanceId]); + useEffect( + function () { + redisApi + .getInfo(instanceId) + .then(function (res: any) { + const flatInfo = res.info || res || {}; + setInfo(flatInfo); + setInfoSectionsMap(res.sections || {}); + }) + .catch(function () { + setInfo({}); + setInfoSectionsMap({}); + }); + }, + [instanceId], + ); - var fetchKeys = function () { - if (!keyPattern.trim()) { - setKeys([]); - setSelectedKey(null); - return; - } - setLoading(true); - redisApi.getKeys(instanceId, { db: selectedDb, pattern: keyPattern, type: keyType || undefined, count: 50 }).then(function (res: any) { - setKeys(res.keys || res || []); - }).catch(function () { setKeys([]); }).finally(function () { setLoading(false); }); - }; - - var loadKeyDetail = function (keyName: string) { + const loadKeyDetail = function (keyName: string) { setDetailLoading(true); - redisApi.getKeyDetail(instanceId, keyName, selectedDb).then(function (res: any) { - var detail = res as RedisKeyDetail; - setSelectedKey(detail); - setEditValue(stringifyValue(detail.type, detail.value)); - setEditTtl(ttlMsToSeconds(detail.ttl)); - setValueMode(detail.type === 'string' ? 'json' : 'table'); - setStringFormat(detail.type === 'string' ? 'Json' : 'String'); - setInspectValue(null); - setValueSearch(''); - }).catch(function () { - toast('error', '加载 Redis Key 详情失败'); - }).finally(function () { - setDetailLoading(false); - }); + redisApi + .getKeyDetail(instanceId, keyName, selectedDb) + .then(function (res: any) { + const detail = res as RedisKeyDetail; + setSelectedKey(detail); + setEditValue(stringifyValue(detail.type, detail.value)); + setEditTtl(ttlMsToSeconds(detail.ttl)); + setValueMode(detail.type === 'string' ? 'json' : 'table'); + setStringFormat(detail.type === 'string' ? 'Json' : 'String'); + setInspectValue(null); + setValueSearch(''); + }) + .catch(function () { + toast('error', '加载 Redis Key 详情失败'); + }) + .finally(function () { + setDetailLoading(false); + }); }; - var handleAddKey = function () { - var parsedValue: any; + const handleAddKey = function () { + let parsedValue: any; try { parsedValue = parseEditorValue(newKey.type, newKey.value); } catch { toast('error', newKey.type === 'hash' ? 'Hash 值需要 JSON Object' : '该类型的值需要 JSON Array'); return; } - redisApi.createKey(instanceId, { db: selectedDb, key: newKey.key, value: parsedValue, type: newKey.type, ttl: newKey.ttl > 0 ? newKey.ttl : undefined }).then(function () { - setShowAddKey(false); - setNewKey({ key: '', value: '', type: 'string', ttl: -1 }); - fetchKeys(); - toast('success', 'Key 已新增'); - }).catch(function () {}); + redisApi + .createKey(instanceId, { + db: selectedDb, + key: newKey.key, + value: parsedValue, + type: newKey.type, + ttl: newKey.ttl > 0 ? newKey.ttl : undefined, + }) + .then(function () { + setShowAddKey(false); + setNewKey({ key: '', value: '', type: 'string', ttl: -1 }); + keyBrowserRef.current?.refresh(); + toast('success', 'Key 已新增'); + }) + .catch(function () {}); }; - var handleDeleteKey = function (keyName: string) { - setDeleteConfirm({msg: '确定删除该键?', onConfirm: async () => { - redisApi.deleteKey(instanceId, keyName, selectedDb).then(function () { - if ((selectedKey?.key || selectedKey?.name) === keyName) setSelectedKey(null); - fetchKeys(); - toast('success', 'Key 已删除'); - }).catch(function () {}); - }}); + const handleDeleteKey = function (keyName: string) { + setDeleteConfirm({ + msg: '确定删除该键?', + onConfirm: async () => { + redisApi + .deleteKey(instanceId, keyName, selectedDb) + .then(function () { + if ((selectedKey?.key || selectedKey?.name) === keyName) setSelectedKey(null); + keyBrowserRef.current?.removeKey(keyName); + toast('success', 'Key 已删除'); + }) + .catch(function () {}); + }, + }); return; }; - var handleCopyValue = function () { + const handleCopyValue = function () { if (!selectedKey) return; copyText(editValue, '已复制当前值'); }; var copyText = function (text: string, message: string) { - navigator.clipboard?.writeText(text).then(function () { - toast('success', message); - }).catch(function () { - toast('warning', '复制失败,请手动复制'); - }); + navigator.clipboard + ?.writeText(text) + .then(function () { + toast('success', message); + }) + .catch(function () { + toast('warning', '复制失败,请手动复制'); + }); }; - var inspectCellValue = function (title: string, value: any) { + const inspectCellValue = function (title: string, value: any) { setInspectValue({ title: title, value: value }); setValueMode('json'); }; - var openHashLineEditor = function (field: string, value: any) { + const openHashLineEditor = function (field: string, value: any) { setLineEditor({ open: true, originalField: field, field: field, value: String(value ?? ''), format: 'Json' }); }; - var updateHashValue = function (nextValue: Record, successMessage: string) { + /** Hash 单字段编辑:走行级 HSET/HDEL,不整键重写、天然保留其余字段与 TTL */ + const saveHashField = function (op: 'set' | 'del', field: string, value: string, successMessage: string) { if (!selectedKey) return; - var keyName = selectedKey.key || selectedKey.name || ''; + const keyName = selectedKey.key || selectedKey.name || ''; setSavingKey(true); - redisApi.updateKey(instanceId, keyName, { - db: selectedDb, - type: 'hash', - value: nextValue, - ttl: editTtl > 0 ? editTtl : undefined, - }).then(function () { - toast('success', successMessage); - setLineEditor({ open: false, originalField: '', field: '', value: '', format: 'Json' }); - loadKeyDetail(keyName); - fetchKeys(); - }).catch(function () {}).finally(function () { - setSavingKey(false); - }); + redisApi + .updateKeyField(instanceId, keyName, { db: selectedDb, op, field, value }) + .then(function () { + toast('success', successMessage); + setLineEditor({ open: false, originalField: '', field: '', value: '', format: 'Json' }); + loadKeyDetail(keyName); + }) + .catch(function () {}) + .finally(function () { + setSavingKey(false); + }); }; - var handleSaveHashLine = function () { + const handleSaveHashLine = function () { if (!selectedKey || selectedKey.type !== 'hash') return; - var field = lineEditor.field.trim(); + const field = lineEditor.field.trim(); if (!field) { toast('error', 'Field 不能为空'); return; } - var nextValue = Object.assign({}, selectedKey.value || {}); - if (lineEditor.originalField && lineEditor.originalField !== field) { - delete nextValue[lineEditor.originalField]; + // 改名 = 删旧字段 + 写新字段(两次行级操作) + const renamed = lineEditor.originalField && lineEditor.originalField !== field; + if (renamed) { + saveHashField('del', lineEditor.originalField!, '', 'Hash 行已保存'); } - nextValue[field] = lineEditor.value; - updateHashValue(nextValue, 'Hash 行已保存'); + saveHashField('set', field, lineEditor.value, renamed ? 'Hash 行已保存(字段已改名)' : 'Hash 行已保存'); }; - var handleDeleteHashLine = function (field: string) { - if (!selectedKey || selectedKey.type !== 'hash') return; - const keySnapshot = selectedKey; - setDeleteConfirm({ msg: '确定删除字段 ' + field + '?', onConfirm: async () => { - var nextValue = Object.assign({}, keySnapshot.value || {}); - delete nextValue[field]; - updateHashValue(nextValue, 'Hash 字段已删除'); - }}); + const handleDeleteHashLine = function (field: string) { + setDeleteConfirm({ + msg: '确定删除字段 ' + field + '?', + onConfirm: async () => { + saveHashField('del', field, '', 'Hash 字段已删除'); + }, + }); }; - var handleSaveKey = function () { + const handleSaveKey = function () { if (!selectedKey) return; - var keyName = selectedKey.key || selectedKey.name || ''; - var parsedValue: any; + const keyName = selectedKey.key || selectedKey.name || ''; + let parsedValue: any; try { parsedValue = parseEditorValue(selectedKey.type, editValue); } catch (e: any) { @@ -210,87 +249,181 @@ export default function RedisDetailPage() { return; } setSavingKey(true); - redisApi.updateKey(instanceId, keyName, { - db: selectedDb, - type: selectedKey.type, - value: parsedValue, - ttl: editTtl > 0 ? editTtl : undefined, - }).then(function () { - toast('success', 'Key 已保存'); - loadKeyDetail(keyName); - fetchKeys(); - }).catch(function () {}).finally(function () { - setSavingKey(false); - }); + redisApi + .updateKey(instanceId, keyName, { + db: selectedDb, + type: selectedKey.type, + value: parsedValue, + ttl: editTtl > 0 ? editTtl : undefined, + }) + .then(function () { + toast('success', 'Key 已保存'); + loadKeyDetail(keyName); + keyBrowserRef.current?.refresh(); + }) + .catch(function () {}) + .finally(function () { + setSavingKey(false); + }); }; - var handleCommand = function () { + const handleCommand = function () { if (!cmdInput.trim()) return; - var cmd = cmdInput; + const cmd = cmdInput; setCmdInput(''); - setCmdOutput(function (prev) { return prev.concat(['> ' + cmd]); }); - redisApi.executeCommand(instanceId, { db: selectedDb, command: cmd }).then(function (res: any) { - setCmdOutput(function (prev) { return prev.concat([String(res.result || res.data || JSON.stringify(res))]); }); - }).catch(function (e: any) { - setCmdOutput(function (prev) { return prev.concat(['ERROR: ' + (e?.response?.data?.detail || '执行失败')]); }); + setCmdOutput(function (prev) { + return prev.concat(['> ' + cmd]); }); + redisApi + .executeCommand(instanceId, { db: selectedDb, command: cmd }) + .then(function (res: any) { + let line: string; + if (res && res.error) { + line = 'ERROR: ' + res.error; + } else if (Array.isArray(res?.result)) { + line = res.result + .map(function (r: any) { + return typeof r === 'object' ? JSON.stringify(r) : String(r); + }) + .join('\n'); + } else if (res?.result !== undefined && res?.result !== null && typeof res.result === 'object') { + line = JSON.stringify(res.result, null, 2); + } else { + line = String(res?.result); + } + setCmdOutput(function (prev) { + return prev.concat([line]); + }); + }) + .catch(function (e: any) { + setCmdOutput(function (prev) { + return prev.concat(['ERROR: ' + (e?.response?.data?.detail || '执行失败')]); + }); + }); }; - useEffect(function () { - if (activeTab === 'perf') { - redisApi.getSlowlog(instanceId, 20).then(function (res: any) { setSlowlog(res.slowlog || res || []); }).catch(function () { setSlowlog([]); }); - redisApi.getClients(instanceId).then(function (res: any) { setClients(res.clients || res || []); }).catch(function () { setClients([]); }); - } - }, [activeTab, instanceId]); + useEffect( + function () { + if (activeTab === 'perf') { + redisApi + .getSlowlog(instanceId, 20) + .then(function (res: any) { + setSlowlog(res.slowlog || res || []); + }) + .catch(function () { + setSlowlog([]); + }); + redisApi + .getClients(instanceId) + .then(function (res: any) { + setClients(res.clients || res || []); + }) + .catch(function () { + setClients([]); + }); + } + }, + [activeTab, instanceId], + ); - var renderFormattedValue = function () { + const renderFormattedValue = function () { if (!selectedKey) return ''; if (inspectValue) { - var inspectedText = typeof inspectValue.value === 'string' ? inspectValue.value : JSON.stringify(inspectValue.value, null, 2); + const inspectedText = + typeof inspectValue.value === 'string' ? inspectValue.value : JSON.stringify(inspectValue.value, null, 2); return tryFormatJsonText(inspectedText) || inspectedText; } if (selectedKey.type === 'string') return formatStringByMode(String(selectedKey.value ?? ''), stringFormat); return JSON.stringify(normalizeValueForEdit(selectedKey.type, selectedKey.value), null, 2); }; - var getInfoValue = function (key: string, fallback: string = '-') { - var value = info[key]; + const getInfoValue = function (key: string, fallback: string = '-') { + const value = info[key]; return value == null || value === '' ? fallback : String(value); }; - var usedMemory = Number(info.used_memory || 0); - var maxMemory = Number(info.maxmemory || 0); - var memoryPercent = maxMemory > 0 ? Math.min(100, Math.round((usedMemory / maxMemory) * 100)) : 0; + const usedMemory = Number(info.used_memory || 0); + const maxMemory = Number(info.maxmemory || 0); + const memoryPercent = maxMemory > 0 ? Math.min(100, Math.round((usedMemory / maxMemory) * 100)) : 0; - var keyMetricCards = [ + const keyMetricCards = [ { group: 'Server', label: 'Redis Version', value: getInfoValue('redis_version'), hint: getInfoValue('redis_mode') }, - { group: 'Memory', label: 'Used Memory', value: getInfoValue('used_memory_human', getInfoValue('used_memory')), hint: maxMemory > 0 ? memoryPercent + '% of ' + getInfoValue('maxmemory_human', getInfoValue('maxmemory')) : 'maxmemory 未设置' }, - { group: 'Memory', label: 'Used Memory Peak', value: getInfoValue('used_memory_peak_human', getInfoValue('used_memory_peak')), hint: getInfoValue('used_memory_peak_perc') }, - { group: 'Memory', label: 'Used Memory Lua', value: getInfoValue('used_memory_lua_human', getInfoValue('used_memory_lua')), hint: 'scripts ' + getInfoValue('used_memory_scripts_human', getInfoValue('used_memory_scripts')) }, - { group: 'Stats', label: 'Connected Clients', value: getInfoValue('connected_clients'), hint: 'max ' + getInfoValue('maxclients') }, - { group: 'Stats', label: 'Total Connections', value: Number(info.total_connections_received || 0).toLocaleString(), hint: 'rejected ' + getInfoValue('rejected_connections') }, - { group: 'Stats', label: 'Total Commands', value: Number(info.total_commands_processed || 0).toLocaleString(), hint: getInfoValue('instantaneous_ops_per_sec') + ' ops/s' }, - { group: 'Keyspace', label: 'DB Size', value: Number(info.db_size || 0).toLocaleString(), hint: getInfoValue('db0') }, + { + group: 'Memory', + label: 'Used Memory', + value: getInfoValue('used_memory_human', getInfoValue('used_memory')), + hint: + maxMemory > 0 + ? memoryPercent + '% of ' + getInfoValue('maxmemory_human', getInfoValue('maxmemory')) + : 'maxmemory 未设置', + }, + { + group: 'Memory', + label: 'Used Memory Peak', + value: getInfoValue('used_memory_peak_human', getInfoValue('used_memory_peak')), + hint: getInfoValue('used_memory_peak_perc'), + }, + { + group: 'Memory', + label: 'Used Memory Lua', + value: getInfoValue('used_memory_lua_human', getInfoValue('used_memory_lua')), + hint: 'scripts ' + getInfoValue('used_memory_scripts_human', getInfoValue('used_memory_scripts')), + }, + { + group: 'Stats', + label: 'Connected Clients', + value: getInfoValue('connected_clients'), + hint: 'max ' + getInfoValue('maxclients'), + }, + { + group: 'Stats', + label: 'Total Connections', + value: Number(info.total_connections_received || 0).toLocaleString(), + hint: 'rejected ' + getInfoValue('rejected_connections'), + }, + { + group: 'Stats', + label: 'Total Commands', + value: Number(info.total_commands_processed || 0).toLocaleString(), + hint: getInfoValue('instantaneous_ops_per_sec') + ' ops/s', + }, + { + group: 'Keyspace', + label: 'DB Size', + value: Number(info.db_size || 0).toLocaleString(), + hint: getInfoValue('db0'), + }, ]; - var fallbackSections = Object.keys(infoSectionsMap).length > 0 ? infoSectionsMap : { 'All Redis Info': info }; - var normalizedInfoSearch = infoSearch.trim().toLowerCase(); - var searchableInfoSections = Object.entries(fallbackSections).map(function (section) { - var entries = Object.entries(section[1] || {}).filter(function (entry) { - return !normalizedInfoSearch - || section[0].toLowerCase().includes(normalizedInfoSearch) - || entry[0].toLowerCase().includes(normalizedInfoSearch) - || String(entry[1]).toLowerCase().includes(normalizedInfoSearch); + const fallbackSections = Object.keys(infoSectionsMap).length > 0 ? infoSectionsMap : { 'All Redis Info': info }; + const normalizedInfoSearch = infoSearch.trim().toLowerCase(); + const searchableInfoSections = Object.entries(fallbackSections) + .map(function (section) { + const entries = Object.entries(section[1] || {}).filter(function (entry) { + return ( + !normalizedInfoSearch || + section[0].toLowerCase().includes(normalizedInfoSearch) || + entry[0].toLowerCase().includes(normalizedInfoSearch) || + String(entry[1]).toLowerCase().includes(normalizedInfoSearch) + ); + }); + return { name: section[0], entries: entries }; + }) + .filter(function (section) { + return section.entries.length > 0; }); - return { name: section[0], entries: entries }; - }).filter(function (section) { return section.entries.length > 0; }); return (
{/* 头部 */}
-
@@ -307,10 +440,14 @@ export default function RedisDetailPage() { return (
- {card.group} + + {card.group} +
{card.value}
-
{card.label} · {card.hint}
+
+ {card.label} · {card.hint} +
{card.label === 'Used Memory' && maxMemory > 0 && (
@@ -325,12 +462,24 @@ export default function RedisDetailPage() { {/* Tab */}
{tabs.map(function (tab) { - var Icon = tab.icon; - return ; + const Icon = tab.icon; + return ( + + ); })}
@@ -338,55 +487,44 @@ export default function RedisDetailPage() { {activeTab === 'keys' && (
-
-
- - -
-
- - -
-
- - -
-
- -
- Keys - {keys.length} 条 -
- -
- {loading ? ( -
- {Array.from({ length: 8 }, function (_, i) { - return
; - })} -
- ) : keys.length === 0 ? ( -
输入 key/pattern 后搜索;输入 * 可扫描全部
- ) : keys.map(function (k: any, i: number) { - var keyName = k.key || k.name || k; - var active = (selectedKey?.key || selectedKey?.name) === keyName; - return ( - - ); - })} -
+
+ } + />
@@ -394,21 +532,59 @@ export default function RedisDetailPage() {
选择一个 Key 查看详情
-
支持结构化查看、格式化 JSON、编辑值和 TTL
+
+ 支持结构化查看、格式化 JSON、编辑值和 TTL +
) : (
- {selectedKey.type} - + + {selectedKey.type} + +
- TTL - + + TTL + +
- - - + + +
@@ -420,30 +596,65 @@ export default function RedisDetailPage() { { key: 'json' as ValueMode, label: '格式化', icon: Code2 }, { key: 'raw' as ValueMode, label: '编辑', icon: FileText }, ].map(function (mode) { - var Icon = mode.icon; - return ; + const Icon = mode.icon; + return ( + + ); })}
{selectedKey.type === 'string' && valueMode === 'json' && ( - {STRING_FORMAT_OPTIONS.map(function (format) { - return ; + return ( + + ); })} )} {inspectValue && valueMode === 'json' && ( - )}
- +
@@ -451,15 +662,34 @@ export default function RedisDetailPage() { {detailLoading ? (
) : valueMode === 'raw' ? ( -