This commit is contained in:
hermes-$(hostname) committed 2026-07-08 08:53:48 +00:00
1 parent f5cf3f4440
commit 0fcdbc5f6c
29 files changed
+773 -264

No files matched your search

+207 -16
View File
@@ -13,12 +13,15 @@ import SqlEditor from '@/components/SqlEditor';
import SchemaSidebar from '@/components/SchemaSidebar';
import { queryApi } from '@/api/query';
import { instanceApi } from '@/api/instance';
import { redisApi } from '@/api/redis';
import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates';
import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail';
import type { DBType, QueryTemplate } from '@/lib/queryTemplates';
import { RedisHashQueryResult } from '@/features/redis/components/RedisHashQueryResult';
import { RedisStructuredValue } from '@/features/redis/components/RedisStructuredValue';
import { RedisValueViewerDialog } from '@/features/redis/components/RedisValueViewerDialog';
import type { RedisLineEditorState } from '@/features/redis/types';
import { formatTTL, tryFormatJsonText } from '@/features/redis/utils';
import { QueryHistoryDrawer } from '@/features/query/components/QueryHistoryDrawer';
import { QueryTemplatePanel } from '@/features/query/components/QueryTemplatePanel';
import type { QueryHistoryItem } from '@/features/query/types';
@@ -41,6 +44,21 @@ interface QueryResult {
masked_columns?: string[];
}
interface RedisKeyInfo {
key?: string;
name?: string;
type?: string;
ttl?: number;
}
interface RedisKeyDetail {
key: string;
name?: string;
type: string;
ttl: number;
value: unknown;
}
type SortDirection = 'asc' | 'desc' | null;
type SortState = { column: string | null; direction: SortDirection };
@@ -94,6 +112,13 @@ export default function QueriesPage() {
const [error, setError] = useState('');
const [result, setResult] = useState<QueryResult | null>(null);
const [redisLineViewer, setRedisLineViewer] = useState<RedisLineEditorState>({ open: false, field: '', value: '', format: 'Json' });
const [redisPattern, setRedisPattern] = useState('');
const [redisType, setRedisType] = useState('');
const [redisKeys, setRedisKeys] = useState<RedisKeyInfo[]>([]);
const [redisSelectedKey, setRedisSelectedKey] = useState<RedisKeyDetail | null>(null);
const [redisLoading, setRedisLoading] = useState(false);
const [redisDetailLoading, setRedisDetailLoading] = useState(false);
const [redisValueSearch, setRedisValueSearch] = useState('');
// -- Pagination --
const [pageSize, setPageSize] = useState(100);
@@ -163,6 +188,54 @@ export default function QueriesPage() {
const dbType = normalizeDbType(selectedInstance?.db_type);
const editorLang = dbType === 'redis' ? 'redis' as const : dbType === 'mongodb' ? 'mongodb' as const : 'sql' as const;
useEffect(() => {
setRedisKeys([]);
setRedisSelectedKey(null);
setRedisValueSearch('');
}, [instanceId, database]);
const handleRedisSearch = useCallback(async () => {
if (!instanceId) return;
const pattern = redisPattern.trim();
if (!pattern) {
setRedisKeys([]);
setRedisSelectedKey(null);
return;
}
setRedisLoading(true);
try {
const res = await redisApi.getKeys(Number(instanceId), {
db: Number(database || 0),
pattern,
type: redisType || undefined,
count: 80,
});
setRedisKeys((res?.keys || res || []) as RedisKeyInfo[]);
} catch {
setRedisKeys([]);
} finally {
setRedisLoading(false);
}
}, [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 copyRedisText = useCallback((text: string) => {
navigator.clipboard?.writeText(text).catch(() => undefined);
}, []);
useEffect(() => {
if (!instanceId || !database || (dbType !== 'mysql' && dbType !== 'postgres' && dbType !== 'postgresql')) return;
if (/^\d+$/.test(database)) return;
@@ -331,19 +404,19 @@ export default function QueriesPage() {
}, [history, favFilter, searchHistory]);
return (
<div className="flex h-[calc(100vh-3rem)] gap-0">
<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-hidden">
<div className="flex-1 flex flex-col min-w-0 overflow-visible md:overflow-hidden">
{/* -- 顶栏: 实例+库选择 -- */}
<div className="shrink-0 px-5 pt-4 pb-2">
<div className="shrink-0 px-0 md:px-5 pt-1 md:pt-4 pb-2">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2 flex-1 min-w-0">
<div className="flex items-center gap-2 flex-1 min-w-full md:min-w-0 flex-wrap md:flex-nowrap">
{selectedInstance && dbTypeIcon[dbType] && (
<img src={dbTypeIcon[dbType]} alt="" className="w-5 h-5 object-contain" />
)}
<select value={instanceId} onChange={e => setInstanceId(e.target.value)}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[200px]">
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[200px]">
<option value="">选择实例</option>
{instances.map(inst => (
<option key={inst.id} value={inst.id}>
@@ -354,12 +427,12 @@ export default function QueriesPage() {
{dbType === 'redis' ? (
<select value={database || '0'} onChange={e => setDatabase(e.target.value)}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-24">
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none w-full md:w-24">
{Array.from({ length: 16 }, (_, i) => <option key={i} value={String(i)}>DB{i}</option>)}
</select>
) : (
<select value={database} onChange={e => setDatabase(e.target.value)}
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-[160px]">
className="px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none min-w-0 w-full md:w-auto md:min-w-[160px]">
<option value="">{loadingDbs ? '加载中...' : '选择数据库'}</option>
{databases.map(db => <option key={db} value={db}>{db}</option>)}
</select>
@@ -367,7 +440,7 @@ export default function QueriesPage() {
</div>
{/* 右侧工具按钮组 */}
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0">
{instanceId && (
<button onClick={() => setShowSidebar(!showSidebar)}
className={`dg-btn dg-btn-ghost dg-btn-sm ${showSidebar ? 'bg-blue-500/15 text-blue-400 border-blue-500/30' : ''}`}>
@@ -387,7 +460,7 @@ export default function QueriesPage() {
历史 {history.length > 0 && <span className="text-[10px] px-1 py-0.5 rounded-full bg-[var(--dg-bg-primary)]">{history.length}</span>}
</button>
<button onClick={handleExecute} disabled={executing || !instanceId}
className="dg-btn dg-btn-primary">
className="dg-btn dg-btn-primary ml-auto md:ml-0">
{executing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
{executing ? '执行中...' : '执行'}
</button>
@@ -409,11 +482,11 @@ export default function QueriesPage() {
)}
{/* -- 编辑器 + 侧边栏 + 结果 (主体区) -- */}
<div className="flex-1 min-h-0 flex gap-0 overflow-hidden">
<div className="flex-1 min-h-0 flex gap-0 overflow-visible md:overflow-hidden">
{/* Schema 侧边栏(左侧,独立) */}
{showSidebar && instanceId && (
<>
<div className="shrink-0 border-r border-[var(--dg-border)] overflow-y-auto" style={{ width: sideWidth + 'px' }}>
<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) => {
@@ -428,7 +501,7 @@ export default function QueriesPage() {
</div>
<div
ref={sidebarDragRef}
className="w-1 shrink-0 cursor-col-resize hover:bg-blue-500/30 transition-colors relative group"
className="hidden md:block w-1 shrink-0 cursor-col-resize hover:bg-blue-500/30 transition-colors relative group"
onMouseDown={handleDragStart}
>
<div className="absolute inset-y-0 -left-1 -right-1" />
@@ -438,8 +511,126 @@ export default function QueriesPage() {
{/* 编辑器 + 查询结果(右侧主体,垂直排列) */}
<div className="flex-1 min-w-0 flex flex-col overflow-hidden">
{dbType === 'redis' && instanceId && (
<div className={`shrink-0 p-0 md:px-4 md:pt-4 pb-2 ${showSidebar ? 'md:hidden' : ''}`}>
<div className="dg-card overflow-hidden">
<div className="border-b border-[var(--dg-border)] px-3 py-2">
<div className="flex flex-col gap-2 lg:flex-row lg:items-center">
<div className="flex items-center gap-2 text-sm font-semibold">
<KeyRound className="h-4 w-4 text-red-400" />
Redis 键值可视化
</div>
<div className="flex-1" />
<div className="grid grid-cols-1 gap-2 sm:grid-cols-[minmax(0,1fr)_130px_auto] lg:w-[620px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
<input
value={redisPattern}
onChange={e => setRedisPattern(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleRedisSearch(); }}
className="h-9 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-9 pr-3 font-mono text-sm"
placeholder="输入 key/pattern,* 扫描全部"
/>
</div>
<select
value={redisType}
onChange={e => setRedisType(e.target.value)}
className="h-9 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] px-3 text-sm"
>
<option value="">全部类型</option>
<option value="string">String</option>
<option value="hash">Hash</option>
<option value="list">List</option>
<option value="set">Set</option>
<option value="zset">ZSet</option>
</select>
<button onClick={handleRedisSearch} disabled={redisLoading || !redisPattern.trim()} className="dg-btn dg-btn-primary h-9 disabled:opacity-40">
{redisLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
搜索
</button>
</div>
</div>
</div>
<div className="grid max-h-[520px] min-h-[260px] grid-cols-1 lg:grid-cols-[320px_minmax(0,1fr)]">
<div className="min-h-[180px] overflow-y-auto border-b border-[var(--dg-border)] lg:border-b-0 lg:border-r">
<div className="flex items-center justify-between border-b border-[var(--dg-border)] px-3 py-2 text-xs text-[var(--dg-text-muted)]">
<span>Keys</span>
<span>{redisKeys.length} 条</span>
</div>
{redisLoading ? (
<div className="space-y-2 p-3">
{Array.from({ length: 5 }, (_, i) => <div key={i} className="h-10 animate-pulse rounded-md bg-[var(--dg-bg-tertiary)]" />)}
</div>
) : redisKeys.length === 0 ? (
<div className="px-5 py-10 text-center text-sm text-[var(--dg-text-muted)]">输入 key/pattern 后搜索;输入 * 可扫描全部</div>
) : redisKeys.map((item, i) => {
const keyName = item.key || item.name || '';
const active = (redisSelectedKey?.key || redisSelectedKey?.name) === keyName;
return (
<button
key={keyName + i}
onClick={() => loadRedisKeyDetail(keyName)}
className={`block w-full border-b border-[var(--dg-border-light)] px-3 py-2.5 text-left transition-colors ${active ? 'bg-blue-500/10' : 'hover:bg-[var(--dg-bg-hover)]'}`}
>
<div className="flex items-center justify-between gap-2">
<span className="truncate font-mono text-xs text-[var(--dg-text-primary)]">{keyName}</span>
{item.type && <span className="rounded bg-[var(--dg-bg-tertiary)] px-1.5 py-0.5 text-[10px] uppercase text-[var(--dg-text-muted)]">{item.type}</span>}
</div>
{typeof item.ttl === 'number' && <div className="mt-1 text-[11px] text-[var(--dg-text-muted)]">TTL {formatTTL(item.ttl)}</div>}
</button>
);
})}
</div>
<div className="min-w-0 overflow-auto">
{redisDetailLoading ? (
<div className="m-4 h-52 animate-pulse rounded-lg bg-[var(--dg-bg-tertiary)]" />
) : redisSelectedKey ? (
<div className="flex h-full min-h-[260px] flex-col">
<div className="flex flex-col gap-2 border-b border-[var(--dg-border)] px-3 py-2 sm:flex-row sm:items-center">
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-xs text-[var(--dg-text-primary)]">{redisSelectedKey.key || redisSelectedKey.name}</div>
<div className="mt-1 flex items-center gap-2 text-[11px] text-[var(--dg-text-muted)]">
<span className="rounded bg-red-500/10 px-1.5 py-0.5 uppercase text-red-400">{redisSelectedKey.type}</span>
<span>TTL {formatTTL(redisSelectedKey.ttl)}</span>
</div>
</div>
<div className="relative w-full sm:w-64">
<Search className="absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-[var(--dg-text-muted)]" />
<input value={redisValueSearch} onChange={e => setRedisValueSearch(e.target.value)} className="h-8 w-full rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] pl-8 pr-3 text-xs" placeholder="搜索字段或值" />
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto p-3">
{redisSelectedKey.type === 'string' ? (
<pre className="min-h-[180px] whitespace-pre-wrap break-all rounded-lg border border-[var(--dg-border)] bg-[var(--dg-bg-primary)] p-3 font-mono text-xs leading-relaxed text-[var(--dg-text-secondary)]">
{typeof redisSelectedKey.value === 'string' ? (tryFormatJsonText(redisSelectedKey.value) || redisSelectedKey.value) : JSON.stringify(redisSelectedKey.value, null, 2)}
</pre>
) : (
<div className="overflow-hidden rounded-lg border border-[var(--dg-border)]">
<RedisStructuredValue
type={redisSelectedKey.type}
value={redisSelectedKey.value}
redisKey={redisSelectedKey.key || redisSelectedKey.name || ''}
search={redisValueSearch}
onCopy={copyRedisText}
onInspect={(title, value) => setRedisLineViewer({ open: true, field: title, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' })}
onEditHashLine={(field, value) => setRedisLineViewer({ open: true, field, value: typeof value === 'string' ? value : JSON.stringify(value, null, 2), format: 'Json' })}
onDeleteHashLine={() => undefined}
/>
</div>
)}
</div>
</div>
) : (
<div className="flex min-h-[260px] items-center justify-center px-5 text-center text-sm text-[var(--dg-text-muted)]">选择左侧 Key 查看结构化值;命令行查询仍可在下方执行</div>
)}
</div>
</div>
</div>
</div>
)}
{/* SQL 编辑器 */}
<div className="shrink-0 p-4 pb-2">
<div className="shrink-0 p-0 md:p-4 pb-2">
<div className="dg-card overflow-hidden flex flex-col">
<SqlEditor value={sql} onChange={setSql} placeholder={placeholderMap[dbType]}
language={editorLang} height="200px" onExecute={handleExecute}
@@ -449,7 +640,7 @@ export default function QueriesPage() {
{dbTypeIcon[dbType] && <img src={dbTypeIcon[dbType]} alt="" className="w-3 h-3 object-contain" />}
{dbTypeLabel[dbType]} 模式 · {sql.length} 字符
</span>
<div className="flex items-center gap-2">
<div className="hidden sm:flex items-center gap-2">
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Enter 执行</span>
<span className="px-1.5 py-0.5 rounded bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">Ctrl+Shift+F 格式化</span>
</div>
@@ -472,7 +663,7 @@ export default function QueriesPage() {
{/* -- 查询结果 (编辑器下方,独立滚动) -- */}
{result && (
<div className="flex-1 min-h-0 px-4 pb-4 overflow-hidden flex flex-col">
<div className="flex-1 min-h-0 px-0 md:px-4 pb-4 overflow-hidden flex flex-col">
<div className="dg-card overflow-hidden flex-1 flex flex-col">
{/* 结果标题栏 + 折叠按钮 */}
<div
@@ -565,7 +756,7 @@ export default function QueriesPage() {
/>
) : (
/* Standard table for other results */
<table className="w-full text-sm">
<table className="w-full min-w-[640px] text-sm">
<thead className="sticky top-0 z-10">
<tr className="dg-table-header">
<th className="text-left px-3 py-2 font-semibold text-xs text-[var(--dg-text-muted)] w-10">#</th>