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:
Logan.Li committed 2026-10-26 18:39:38 +08:00
1 parent 6604fdf48e
commit ff539b47e0
8 files changed
+1910 -799

No files matched your search

+302 -135
View File
@@ -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