update
This commit is contained in:
1 parent
f5cf3f4440
commit
0fcdbc5f6c
29 files changed
+773
-264
No files matched your search
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user