feat(redis): shared key browser with grouped tree + cursor pagination
New src/features/redis/keyBrowser/ shared by RedisDetailPage keys tab and SchemaSidebar redis branch (both previously flat one-shot lists): - keyTree: ':'-separated folder tree with recursive key counts, numeric sort, exact-hit pinned at root, local key removal (no full refetch), type color dots, readable TTL (45s/5m30s/2d5h) - useRedisKeyBrowser: cursor state machine + request-sequence guard + Map merge on load-more; setter-synced args (Enter right after typing no longer races stale render), auto-search on db/instance change - RedisKeyBrowser: enter-to-scan search bar (SCAN has cost - no debounce, opsxdb-style) with clear button, effective type filter, load-more button with cursor hint, '已全部加载' end state - useRedisDbs: real db list from /redis/:id/dbs (cluster -> DB0 banner) - RedisDetailPage: hash row edits now use field-level HSET/HDEL endpoint (preserves other fields + TTL); delete/add update tree locally; CLI output renders arrays/objects/errors properly - SchemaSidebar: redis branch swaps to shared browser (compact mode, inline detail preview + insert-command preserved), auto-switches to Keys tab for redis instances - vite dev proxy target overridable via API_PROXY_TARGET (local testing) Browser-verified: 200/page with load-more (200->400->600), exact hit badge, keyword auto-expand, key detail panel, queries sidebar
This commit is contained in:
1 parent
6604fdf48e
commit
ff539b47e0
8 files changed
+1910
-799
No files matched your search
@@ -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<any, any>('/redis/' + instanceId + '/info'),
|
||||
getInfo: (instanceId: number) => request.get<any, any>('/redis/' + instanceId + '/info'),
|
||||
|
||||
// 库列表(集群恒 [0])
|
||||
getDbs: (instanceId: number) =>
|
||||
request.get<any, { dbs: number[]; cluster: boolean }>('/redis/' + instanceId + '/dbs'),
|
||||
|
||||
// DBSIZE
|
||||
getDbsize: (instanceId: number, db?: number) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }),
|
||||
|
||||
// 键列表
|
||||
scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
|
||||
// 键列表(游标分页;无通配符自动按 *关键词* 包含匹配并做精确命中探测)
|
||||
scanKeys: (instanceId: number, params: { db?: number; pattern?: string; type?: string; cursor?: string }) =>
|
||||
request.get<any, RedisScanPage>('/redis/' + instanceId + '/keys', { params }),
|
||||
|
||||
// 键详情
|
||||
getKeyDetail: (instanceId: number, key: string, db?: number) =>
|
||||
@@ -22,24 +41,31 @@ export const redisApi = {
|
||||
request.get<any, any>('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }),
|
||||
|
||||
// 客户端列表
|
||||
getClients: (instanceId: number) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/clients'),
|
||||
getClients: (instanceId: number) => request.get<any, any>('/redis/' + instanceId + '/clients'),
|
||||
|
||||
// 执行命令
|
||||
// 执行只读命令(整串,引号感知;白名单外的命令会被拒绝)
|
||||
executeCommand: (instanceId: number, data: { db: number; command: string }) =>
|
||||
request.post<any, any>('/redis/' + instanceId + '/command', data),
|
||||
|
||||
// 获取键列表(兼容旧接口)
|
||||
getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) =>
|
||||
request.get<any, any>('/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<any, any>('/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<any, any>('/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<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), data, { params: { db: data.db ?? 0 } }),
|
||||
request.put<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), data, {
|
||||
params: { db: data.db ?? 0 },
|
||||
}),
|
||||
|
||||
// 删除键
|
||||
deleteKey: (instanceId: number, key: string, db?: number) =>
|
||||
|
||||
@@ -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<string, RedisTreeNode>;
|
||||
}
|
||||
|
||||
/** 把 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<string>;
|
||||
setExpanded: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
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 (
|
||||
<div key={child.fullPrefix}>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSelectKey(child.keyItem!.key);
|
||||
if (onInsert) onInsert(redisCommand(child.keyItem!.key, child.keyItem!.type));
|
||||
}}
|
||||
className={`flex items-center gap-1.5 w-full px-2 py-1 text-xs transition-colors ${
|
||||
selectedKey === child.keyItem!.key ? 'bg-blue-500/10 text-blue-400' : 'hover:bg-[var(--dg-bg-hover)]'
|
||||
}`}
|
||||
style={{ paddingLeft: 8 + indent }}
|
||||
>
|
||||
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
<span className="font-mono truncate flex-1 text-left">{child.name}</span>
|
||||
<span className="text-[9px] px-1 rounded bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] flex-shrink-0">{child.keyItem.type}</span>
|
||||
</button>
|
||||
{selectedKey === child.keyItem.key && (
|
||||
<div className="px-2 pb-2" style={{ paddingLeft: 8 + indent }}>
|
||||
{loadingDetail ? (
|
||||
<div className="flex items-center gap-1 p-1 text-[10px] text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
|
||||
</div>
|
||||
) : keyDetail ? (
|
||||
<div className="p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">{keyDetail.type}</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(keyDetail.ttl)}</span>
|
||||
</div>
|
||||
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
|
||||
{typeof keyDetail.value === 'object'
|
||||
? JSON.stringify(keyDetail.value, null, 2)
|
||||
: String(keyDetail.value)}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 文件夹节点
|
||||
const isOpen = expanded.has(child.fullPrefix);
|
||||
const childCount = child.children.size;
|
||||
return (
|
||||
<div key={child.fullPrefix}>
|
||||
<button
|
||||
onClick={() => toggle(child.fullPrefix)}
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1 text-xs text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
style={{ paddingLeft: 8 + indent }}
|
||||
>
|
||||
{isOpen
|
||||
? <ChevronDown className="w-3 h-3 flex-shrink-0" />
|
||||
: <ChevronRight className="w-3 h-3 flex-shrink-0" />
|
||||
}
|
||||
<FolderOpen className="w-3 h-3 text-amber-400/70 flex-shrink-0" />
|
||||
<span className="truncate flex-1 text-left">{child.name}</span>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)]">{childCount}</span>
|
||||
</button>
|
||||
{isOpen && renderNode(child, depth + 1)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return <div>{renderNode(tree, 0)}</div>;
|
||||
}
|
||||
|
||||
/** 每个数据库节点缓存的表列表 */
|
||||
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<RedisKeyItem[]>([]);
|
||||
const [loadingRedis, setLoadingRedis] = useState(false);
|
||||
const [redisExpanded, setRedisExpanded] = useState<Set<string>>(new Set());
|
||||
const redisDbs = useRedisDbs(Number(instanceId));
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(null);
|
||||
const [keyDetail, setKeyDetail] = useState<any>(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
|
||||
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无数据库</div>
|
||||
) : (
|
||||
<div>
|
||||
{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
|
||||
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
}
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
)}
|
||||
<Database className="w-3 h-3 text-amber-400 flex-shrink-0" />
|
||||
<span className="font-mono truncate flex-1 text-left">{dbName}</span>
|
||||
{isActive && (
|
||||
<span className="text-[8px] px-1 bg-blue-500/10 text-blue-400 rounded flex-shrink-0">当前</span>
|
||||
<span className="text-[8px] px-1 bg-blue-500/10 text-blue-400 rounded flex-shrink-0">
|
||||
当前
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -509,7 +366,7 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
||||
) : tables.length === 0 ? (
|
||||
<div className="px-3 py-1 text-[10px] text-[var(--dg-text-muted)]">无表</div>
|
||||
) : (
|
||||
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
|
||||
<div key={t.name}>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
if (e.altKey) { onInsert(`${dbName}.${t.name}`); }
|
||||
else { handleExpandTable(dbName, t.name); }
|
||||
if (e.altKey) {
|
||||
onInsert(`${dbName}.${t.name}`);
|
||||
} else {
|
||||
handleExpandTable(dbName, t.name);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1.5 w-full px-2 py-1 text-[11px] hover:bg-[var(--dg-bg-hover)] transition-colors group"
|
||||
title={`单击展开/折叠 · Alt+点击插入表名`}
|
||||
>
|
||||
{isTableExpanded
|
||||
? <ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
: <ChevronRight className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
}
|
||||
{isTableExpanded ? (
|
||||
<ChevronDown className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-2.5 h-2.5 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
)}
|
||||
<Table2 className="w-2.5 h-2.5 text-blue-400 flex-shrink-0" />
|
||||
<span className="font-mono truncate flex-1 text-left hover:text-blue-400 transition-colors">
|
||||
{t.name}
|
||||
</span>
|
||||
{t.rows !== undefined && (
|
||||
<span className="text-[8px] text-[var(--dg-text-muted)] flex-shrink-0">{t.rows.toLocaleString()}</span>
|
||||
<span className="text-[8px] text-[var(--dg-text-muted)] flex-shrink-0">
|
||||
{t.rows.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -572,48 +435,83 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
||||
</div>
|
||||
|
||||
{/* 字段列表 */}
|
||||
{detailTab === 'columns' && tableColumns.map(col => (
|
||||
<button
|
||||
key={col.Field}
|
||||
onClick={() => onInsert(`${t.name}.${col.Field}`)}
|
||||
className="flex items-center gap-1.5 w-full px-2 py-0.5 text-[10px] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
|
||||
>
|
||||
<Columns3 className="w-2 h-2 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400 truncate">{col.Field}</span>
|
||||
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">{col.Type}</span>
|
||||
{col.Key === 'PRI' && <span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">PK</span>}
|
||||
</button>
|
||||
))}
|
||||
{detailTab === 'columns' &&
|
||||
tableColumns.map((col) => (
|
||||
<button
|
||||
key={col.Field}
|
||||
onClick={() => onInsert(`${t.name}.${col.Field}`)}
|
||||
className="flex items-center gap-1.5 w-full px-2 py-0.5 text-[10px] hover:bg-[var(--dg-bg-hover)] transition-colors"
|
||||
title={`${col.Type} ${col.Null === 'NO' ? 'NOT NULL' : ''}`}
|
||||
>
|
||||
<Columns3 className="w-2 h-2 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400 truncate">
|
||||
{col.Field}
|
||||
</span>
|
||||
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">
|
||||
{col.Type}
|
||||
</span>
|
||||
{col.Key === 'PRI' && (
|
||||
<span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">
|
||||
PK
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* 索引列表 */}
|
||||
{detailTab === 'indexes' && (
|
||||
tableIndexes.length === 0 ? (
|
||||
<div className="px-3 py-1.5 text-[10px] text-[var(--dg-text-muted)]">无索引</div>
|
||||
{detailTab === 'indexes' &&
|
||||
(tableIndexes.length === 0 ? (
|
||||
<div className="px-3 py-1.5 text-[10px] text-[var(--dg-text-muted)]">
|
||||
无索引
|
||||
</div>
|
||||
) : (
|
||||
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<string, IndexInfo[]>)
|
||||
tableIndexes.reduce(
|
||||
(acc, idx) => {
|
||||
if (!acc[idx.Key_name]) acc[idx.Key_name] = [];
|
||||
acc[idx.Key_name].push(idx);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, IndexInfo[]>,
|
||||
),
|
||||
).map(([keyName, cols]) => (
|
||||
<div key={keyName} className="px-2 py-1">
|
||||
<div className="flex items-center gap-1 text-[10px]">
|
||||
<KeyRound className="w-2 h-2 flex-shrink-0" style={{ color: cols[0].Non_unique ? 'var(--dg-text-muted)' : '#facc15' }} />
|
||||
<span className="font-mono text-[var(--dg-text-secondary)] truncate">{keyName}</span>
|
||||
{!cols[0].Non_unique && <span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">UNI</span>}
|
||||
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">{cols[0].Index_type}</span>
|
||||
<KeyRound
|
||||
className="w-2 h-2 flex-shrink-0"
|
||||
style={{
|
||||
color: cols[0].Non_unique
|
||||
? 'var(--dg-text-muted)'
|
||||
: '#facc15',
|
||||
}}
|
||||
/>
|
||||
<span className="font-mono text-[var(--dg-text-secondary)] truncate">
|
||||
{keyName}
|
||||
</span>
|
||||
{!cols[0].Non_unique && (
|
||||
<span className="text-[7px] px-0.5 bg-yellow-500/10 text-yellow-400 rounded flex-shrink-0">
|
||||
UNI
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[8px] text-[var(--dg-text-muted)] ml-auto flex-shrink-0">
|
||||
{cols[0].Index_type}
|
||||
</span>
|
||||
</div>
|
||||
<div className="ml-4 flex flex-wrap gap-x-2">
|
||||
{cols.sort((a, b) => a.Seq_in_index - b.Seq_in_index).map(c => (
|
||||
<span key={c.Column_name} className="text-[9px] font-mono text-[var(--dg-text-muted)]">{c.Column_name}</span>
|
||||
))}
|
||||
{cols
|
||||
.sort((a, b) => a.Seq_in_index - b.Seq_in_index)
|
||||
.map((c) => (
|
||||
<span
|
||||
key={c.Column_name}
|
||||
className="text-[9px] font-mono text-[var(--dg-text-muted)]"
|
||||
>
|
||||
{c.Column_name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
)}
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -635,68 +533,63 @@ export default function SchemaSidebar({ instanceId, database, dbType, clusterMod
|
||||
|
||||
{activeTab === 'redis' && isRedis && (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* DB + Search */}
|
||||
<div className="px-2 py-2 border-b border-[var(--dg-border-light)] space-y-1.5">
|
||||
{clusterMode ? (
|
||||
<div className="px-2 py-1 text-[10px] text-amber-400 bg-amber-500/10 rounded text-center">
|
||||
集群模式 · DB0
|
||||
</div>
|
||||
) : (
|
||||
{clusterMode || redisDbs.cluster ? (
|
||||
<div className="px-2 py-1.5 text-[10px] text-amber-400 bg-amber-500/10 border-b border-[var(--dg-border-light)] text-center">
|
||||
集群模式 · DB0
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={redisDb}
|
||||
onChange={(e) => { setRedisDb(Number(e.target.value)); }}
|
||||
className="w-full px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
|
||||
onChange={(e) => {
|
||||
setRedisDb(Number(e.target.value));
|
||||
setSelectedKey(null);
|
||||
}}
|
||||
className="mx-2 mt-2 px-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500"
|
||||
>
|
||||
{Array.from({ length: 16 }, (_, i) => (
|
||||
<option key={i} value={i}>DB{i}</option>
|
||||
{redisDbs.dbs.map((i) => (
|
||||
<option key={i} value={i}>
|
||||
DB{i}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--dg-text-muted)]" />
|
||||
<input
|
||||
value={redisPattern}
|
||||
onChange={(e) => 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="*"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchRedisKeys}
|
||||
disabled={loadingRedis}
|
||||
className="w-full px-2 py-1 bg-blue-500/15 text-blue-400 rounded text-[11px] hover:bg-blue-500/25 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loadingRedis ? <Loader2 className="w-3 h-3 animate-spin mx-auto" /> : '扫描'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Key Tree + Detail */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loadingRedis ? (
|
||||
<div className="space-y-1 p-2">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div key={i} className="flex items-center gap-2 px-2 py-1.5">
|
||||
<div className="w-3 h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse" />
|
||||
<div className="h-3.5 bg-[var(--dg-bg-tertiary)] rounded animate-pulse flex-1" />
|
||||
<div className="h-3 bg-[var(--dg-bg-tertiary)] rounded animate-pulse w-10" />
|
||||
)}
|
||||
<div className="flex-1 min-h-0">
|
||||
<RedisKeyBrowser
|
||||
compact
|
||||
instanceId={Number(instanceId)}
|
||||
db={redisDb}
|
||||
selectedKey={selectedKey}
|
||||
onKeySelect={(key) => {
|
||||
handleSelectKey(key);
|
||||
}}
|
||||
onInsertCommand={onInsert}
|
||||
renderDetail={(key) => {
|
||||
if (selectedKey !== key) return null;
|
||||
if (loadingDetail) {
|
||||
return (
|
||||
<div className="flex items-center gap-1 p-1 text-[10px] text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="w-2.5 h-2.5 animate-spin" /> ...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!keyDetail) return null;
|
||||
return (
|
||||
<div className="p-2 bg-[var(--dg-bg-primary)] rounded border border-[var(--dg-border)] space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-blue-500/10 text-blue-400">
|
||||
{keyDetail.type}
|
||||
</span>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)]">TTL: {formatTTL(keyDetail.ttl)}</span>
|
||||
</div>
|
||||
<pre className="text-[10px] font-mono text-[var(--dg-text-secondary)] overflow-auto max-h-[120px] whitespace-pre-wrap">
|
||||
{typeof keyDetail.value === 'object'
|
||||
? JSON.stringify(keyDetail.value, null, 2)
|
||||
: String(keyDetail.value)}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : redisKeys.length === 0 ? (
|
||||
<div className="p-3 text-xs text-[var(--dg-text-muted)] text-center">无 Keys</div>
|
||||
) : (
|
||||
<RedisTree
|
||||
tree={buildRedisTree(redisKeys)}
|
||||
expanded={redisExpanded}
|
||||
setExpanded={setRedisExpanded}
|
||||
selectedKey={selectedKey}
|
||||
onSelectKey={handleSelectKey}
|
||||
keyDetail={keyDetail}
|
||||
loadingDetail={loadingDetail}
|
||||
onInsert={onInsert}
|
||||
/>
|
||||
)}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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<RedisKeyBrowserHandle, Props>(function RedisKeyBrowser(
|
||||
{ instanceId, db, selectedKey, onKeySelect, onInsertCommand, renderDetail, toolbar, compact = false },
|
||||
ref,
|
||||
) {
|
||||
const browser = useRedisKeyBrowser(instanceId, db);
|
||||
const [expanded, setExpanded] = useState<Set<string>>(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<string>();
|
||||
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 (
|
||||
<div key={`leaf:${child.fullPrefix}`}>
|
||||
<button
|
||||
title={`${item.key} · ${item.type} · TTL ${formatRedisTtl(item.ttl)}`}
|
||||
onClick={() => {
|
||||
onKeySelect(item.key, item.type);
|
||||
onInsertCommand?.(redisReadCommand(item.key, item.type));
|
||||
}}
|
||||
className={`flex items-center gap-1.5 w-full ${compact ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-xs'} transition-colors ${
|
||||
active
|
||||
? 'bg-[var(--dg-blue)]/10 text-blue-400'
|
||||
: 'hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]'
|
||||
}`}
|
||||
style={{ paddingLeft: 8 + indent }}
|
||||
>
|
||||
{node === tree && browser.exact?.key === item.key && (
|
||||
<span className="rounded bg-emerald-500/15 px-1 text-[9px] text-emerald-400 flex-shrink-0">精确</span>
|
||||
)}
|
||||
<KeyRound className="w-3 h-3 text-[var(--dg-text-muted)] flex-shrink-0" />
|
||||
<span className="font-mono truncate flex-1 text-left" title={item.key}>
|
||||
{child.name}
|
||||
</span>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full flex-shrink-0 ${redisTypeColor(item.type)}`}
|
||||
title={item.type}
|
||||
/>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)] flex-shrink-0">{formatRedisTtl(item.ttl)}</span>
|
||||
</button>
|
||||
{active && renderDetail && (
|
||||
<div className={compact ? 'px-2 pb-2' : 'px-3 pb-2'} style={{ paddingLeft: 12 + indent }}>
|
||||
{renderDetail(item.key)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOpen = expanded.has(child.fullPrefix);
|
||||
return (
|
||||
<div key={`dir:${child.fullPrefix}`}>
|
||||
<button
|
||||
onClick={() => toggle(child.fullPrefix)}
|
||||
className={`flex items-center gap-1.5 w-full ${compact ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-xs'} text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-colors`}
|
||||
style={{ paddingLeft: 8 + indent }}
|
||||
title={child.fullPrefix}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-3 h-3 flex-shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 flex-shrink-0" />
|
||||
)}
|
||||
<FolderOpen className="w-3 h-3 text-amber-400/70 flex-shrink-0" />
|
||||
<span className="truncate flex-1 text-left font-mono">{child.name}</span>
|
||||
<span className="text-[9px] text-[var(--dg-text-muted)] flex-shrink-0">{child.leafCount}</span>
|
||||
</button>
|
||||
{isOpen && renderNode(child, depth + 1)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const hasQuery = browser.pattern.trim() !== '' || browser.typeFilter !== '';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 h-full flex-col">
|
||||
{/* 搜索区 */}
|
||||
<div
|
||||
className={
|
||||
compact
|
||||
? 'border-b border-[var(--dg-border-light)] px-2 py-2 space-y-1.5'
|
||||
: 'border-b border-[var(--dg-border)] p-3 space-y-2'
|
||||
}
|
||||
>
|
||||
{toolbar}
|
||||
<div className={compact ? 'relative' : 'relative mb-0'}>
|
||||
<Search
|
||||
className={`absolute ${compact ? 'left-2 w-3 h-3' : 'left-3 h-4 w-4'} top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)]`}
|
||||
/>
|
||||
<input
|
||||
value={browser.pattern}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
onClick={() => {
|
||||
browser.setPattern('');
|
||||
}}
|
||||
className={`absolute top-1/2 -translate-y-1/2 ${compact ? 'right-2' : 'right-3'} text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]`}
|
||||
title="清空"
|
||||
>
|
||||
<X className={compact ? 'w-3 h-3' : 'w-4 h-4'} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className={compact ? 'flex gap-1.5' : 'grid grid-cols-[1fr_auto] gap-2'}>
|
||||
<select
|
||||
value={browser.typeFilter}
|
||||
onChange={(e) => {
|
||||
browser.setTypeFilter(e.target.value);
|
||||
}}
|
||||
className={selectCls}
|
||||
>
|
||||
{TYPE_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={browser.search}
|
||||
disabled={browser.loading}
|
||||
className={`${searchBtnCls} inline-flex items-center justify-center gap-1.5`}
|
||||
title="从当前游标继续扫描,不会一次读取全部 Key"
|
||||
>
|
||||
{browser.loading ? (
|
||||
<Loader2 className={compact ? 'w-3 h-3' : 'w-4 h-4'} animate-spin />
|
||||
) : (
|
||||
<Search className={compact ? 'w-3 h-3' : 'w-4 h-4'} />
|
||||
)}
|
||||
{!compact && '搜索'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 计数行 */}
|
||||
<div
|
||||
className={`flex items-center justify-between border-b border-[var(--dg-border-light)] ${compact ? 'px-2 py-1.5 text-[10px]' : 'px-3 py-2 text-xs'} text-[var(--dg-text-muted)]`}
|
||||
>
|
||||
<span>
|
||||
共 {browser.total} 键{browser.exact ? ' · 精确命中置顶' : ''}
|
||||
</span>
|
||||
{browser.total > 0 && (
|
||||
<button
|
||||
onClick={browser.search}
|
||||
disabled={browser.loading}
|
||||
className="inline-flex items-center gap-1 hover:text-[var(--dg-text-primary)]"
|
||||
title="重新扫描"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${browser.loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 树 */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto scrollbar-thin">
|
||||
{browser.loading ? (
|
||||
<div className={`space-y-2 ${compact ? 'p-2' : 'p-3'}`}>
|
||||
{Array.from({ length: compact ? 6 : 8 }, (_, i) => (
|
||||
<div key={i} className="h-6 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
|
||||
))}
|
||||
</div>
|
||||
) : !hasQuery && browser.items.length === 0 ? (
|
||||
<div
|
||||
className={`${compact ? 'p-3' : 'px-6 py-16'} text-center ${compact ? 'text-[11px]' : 'text-sm'} text-[var(--dg-text-muted)]`}
|
||||
>
|
||||
输入关键词搜索;留空回车可浏览全部
|
||||
</div>
|
||||
) : browser.items.length === 0 && !browser.exact ? (
|
||||
<div
|
||||
className={`${compact ? 'p-3' : 'px-6 py-16'} text-center ${compact ? 'text-[11px]' : 'text-sm'} text-[var(--dg-text-muted)]`}
|
||||
>
|
||||
无匹配 Keys
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{renderNode(tree, 0)}
|
||||
{browser.hasMore && (
|
||||
<button
|
||||
onClick={browser.loadMore}
|
||||
disabled={browser.loadingMore}
|
||||
className={`w-full ${compact ? 'py-1.5 text-[10px]' : 'py-2 text-xs'} text-blue-400 hover:bg-blue-500/5 disabled:opacity-50`}
|
||||
title="继续从当前游标扫描,不会一次读取全部 Key"
|
||||
>
|
||||
{browser.loadingMore ? <Loader2 className="w-3 h-3 animate-spin mx-auto" /> : '加载更多'}
|
||||
</button>
|
||||
)}
|
||||
{!browser.hasMore && browser.total > 0 && (
|
||||
<div
|
||||
className={`${compact ? 'py-1.5 text-[10px]' : 'py-2 text-xs'} text-center text-[var(--dg-text-muted)]`}
|
||||
>
|
||||
已全部加载
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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<string, RedisTreeNode>;
|
||||
/** 该子树下(递归)的叶子键数 */
|
||||
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<string>();
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
@@ -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<RedisKeySummary[]>([]);
|
||||
const [exact, setExact] = useState<RedisKeySummary | null>(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<string>('');
|
||||
const seqRef = useRef(0);
|
||||
const mergedRef = useRef<Map<string, RedisKeySummary>>(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<number[]>([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 };
|
||||
}
|
||||
+302
-135
@@ -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<QueryResult | null>(null);
|
||||
const [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({ open: false, field: '', value: '', format: 'Json' });
|
||||
const [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({
|
||||
open: false,
|
||||
field: '',
|
||||
value: '',
|
||||
format: 'Json',
|
||||
});
|
||||
const [redisPattern, setRedisPattern] = useState('');
|
||||
const [redisType, setRedisType] = useState('');
|
||||
const [redisKeys, setRedisKeys] = useState<RedisKeyInfo[]>([]);
|
||||
@@ -66,8 +76,10 @@ export default function QueriesPage() {
|
||||
const saved = readStorage('queryFavorites');
|
||||
const favIds = saved ? new Set<number>(JSON.parse(saved)) : new Set<number>();
|
||||
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<string, Record<string, string[]>> = {};
|
||||
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<string, string[]> })); })
|
||||
.catch(() => { /* ignore */ });
|
||||
instanceDetailApi
|
||||
.getDbSchema(Number(instanceId), database)
|
||||
.then((data) => {
|
||||
if (data && typeof data === 'object')
|
||||
setEditorSchema((prev) => ({ ...prev, [database]: data as Record<string, string[]> }));
|
||||
})
|
||||
.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<string, string> = { all: '全部', '表结构': '表结构', '性能分析': '性能分析', '数据统计': '数据统计', '基础查询': '基础查询' };
|
||||
const catLabel: Record<string, string> = {
|
||||
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<unknown, Blob>('/query/export', {
|
||||
instance_id: Number(instanceId),
|
||||
database: database || undefined,
|
||||
sql,
|
||||
}, { responseType: 'blob' });
|
||||
const blob = await api.post<unknown, Blob>(
|
||||
'/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 (
|
||||
<div className="flex h-auto min-h-[calc(100vh-7rem)] md:h-[calc(100vh-3rem)] gap-0">
|
||||
{/* ====== 主区域 ====== */}
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-visible md:overflow-hidden">
|
||||
|
||||
{/* -- 顶栏: 实例+库选择 -- */}
|
||||
<QueryTopBar
|
||||
instances={instances}
|
||||
@@ -357,18 +513,25 @@ export default function QueriesPage() {
|
||||
{/* Schema 侧边栏(左侧,独立) */}
|
||||
{showSidebar && instanceId && (
|
||||
<>
|
||||
<div className="hidden md:block shrink-0 border-r border-[var(--dg-border)] overflow-y-auto" style={{ width: sideWidth + 'px' }}>
|
||||
<SchemaSidebar instanceId={instanceId} database={database} dbType={dbType}
|
||||
<div
|
||||
className="hidden md:block shrink-0 border-r border-[var(--dg-border)] overflow-y-auto"
|
||||
style={{ width: sideWidth + 'px' }}
|
||||
>
|
||||
<SchemaSidebar
|
||||
instanceId={instanceId}
|
||||
database={database}
|
||||
dbType={dbType}
|
||||
clusterMode={selectedInstance?.cluster_mode}
|
||||
onInsert={(text) => {
|
||||
if (dbType === 'redis') {
|
||||
setSql(text);
|
||||
} else {
|
||||
setSql(prev => prev + (prev.trim() ? ' ' : '') + text + ' ');
|
||||
setSql((prev) => prev + (prev.trim() ? ' ' : '') + text + ' ');
|
||||
}
|
||||
}}
|
||||
onDatabaseChange={setDatabase}
|
||||
singleDb={!!database} />
|
||||
singleDb={!!database}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={sidebarDragRef}
|
||||
@@ -416,7 +579,8 @@ export default function QueriesPage() {
|
||||
{error && (
|
||||
<div className="shrink-0 px-4 pb-2">
|
||||
<div className="flex items-center gap-2 p-3 bg-red-500/10 border border-red-500/20 rounded-lg text-sm text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />{error}
|
||||
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -448,9 +612,12 @@ export default function QueriesPage() {
|
||||
查询结果中的敏感字段将自动脱敏。MySQL/PG 仅支持 SELECT 查询,Redis/MongoDB 支持只读命令。
|
||||
</div>
|
||||
</div>
|
||||
</div>{/* end 编辑器+结果区 */}
|
||||
</div>{/* end 主体区 (sidebar + 编辑器+结果) */}
|
||||
</div>{/* end 主区域 */}
|
||||
</div>
|
||||
{/* end 编辑器+结果区 */}
|
||||
</div>
|
||||
{/* end 主体区 (sidebar + 编辑器+结果) */}
|
||||
</div>
|
||||
{/* end 主区域 */}
|
||||
|
||||
{showHistory && (
|
||||
<QueryHistoryDrawer
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+17
-12
@@ -1,16 +1,20 @@
|
||||
import { execSync } from "child_process"
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
import { execSync } from 'child_process';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// 构建指纹:版本 + commit hash + 构建时间(设置页「关于」展示,排障救命)
|
||||
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, "package.json"), "utf-8"))
|
||||
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, 'package.json'), 'utf-8'));
|
||||
const gitHash = (() => {
|
||||
try { return execSync("git rev-parse --short HEAD").toString().trim() } catch { return "unknown" }
|
||||
})()
|
||||
const buildTime = new Date().toISOString()
|
||||
const APP_VERSION = `${pkg.version}+${gitHash} (${buildTime.slice(0, 16)})`
|
||||
try {
|
||||
return execSync('git rev-parse --short HEAD').toString().trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
})();
|
||||
const buildTime = new Date().toISOString();
|
||||
const APP_VERSION = `${pkg.version}+${gitHash} (${buildTime.slice(0, 16)})`;
|
||||
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
@@ -35,14 +39,15 @@ export default defineConfig({
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:9000',
|
||||
// 本地联调可用 API_PROXY_TARGET=http://localhost:9001 npm run dev 指向测试实例
|
||||
target: process.env.API_PROXY_TARGET || 'http://localhost:9000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user