diff --git a/frontend/src/api/instanceDetail.ts b/frontend/src/api/instanceDetail.ts index d613cd8..e20d7f2 100644 --- a/frontend/src/api/instanceDetail.ts +++ b/frontend/src/api/instanceDetail.ts @@ -1,48 +1,83 @@ import request from './request'; +/** 实例详情 API — 数据库/表/字段/索引/Redis Key */ export const instanceDetailApi = { - // Databases - listDatabases: (id: number) => - request.get('/instances/' + id + '/detail/databases'), + /** 获取 MySQL 数据库列表 */ + listDatabases: (instanceId: number) => + request.get(`/instances/${instanceId}/detail/databases`), - // Tables - listTables: (id: number, database: string) => - request.get('/instances/' + id + '/detail/tables', { params: { database: database } }), + /** 获取表列表(含引擎、行数等) */ + listTables: (instanceId: number, database: string) => + request.get(`/instances/${instanceId}/detail/tables`, { + params: { database }, + }), - // Table Detail - getTableDetail: (id: number, database: string, table: string) => - request.get('/instances/' + id + '/detail/table-detail', { params: { database: database, table: table } }), + /** 获取表详情(DDL + 字段 + 索引) */ + getTableDetail: (instanceId: number, database: string, table: string) => + request.get(`/instances/${instanceId}/detail/table-detail`, { + params: { database, table }, + }), - // Users - listUsers: (id: number) => - request.get('/instances/' + id + '/detail/users'), + /** SHOW PROCESSLIST */ + getProcesslist: (instanceId: number) => + request.get(`/instances/${instanceId}/detail/processlist`), - // Grants - getGrants: (id: number, user: string, host: string) => - request.get('/instances/' + id + '/detail/grants', { params: { user: user, host: host } }), + /** SHOW GLOBAL STATUS */ + getStatus: (instanceId: number) => + request.get; all_status: any[] }>( + `/instances/${instanceId}/detail/status` + ), - // Processlist - getProcesslist: (id: number) => - request.get('/instances/' + id + '/detail/processlist'), + /** 终止进程 */ + killProcess: (instanceId: number, pid: number) => + request.post(`/instances/${instanceId}/detail/processlist/${pid}/kill`), - // Kill Process - killProcess: (id: number, pid: number) => - request.post('/instances/' + id + '/detail/processlist/' + pid + '/kill'), - - // Status - getStatus: (id: number) => - request.get('/instances/' + id + '/detail/status'), - - // Variables - getVariables: (id: number) => - request.get('/instances/' + id + '/detail/variables'), + /** 获取数据库用户列表 */ + listUsers: (instanceId: number) => + request.get(`/instances/${instanceId}/detail/users`), }; -export const backupApi = { - list: (instanceId?: number) => - request.get('/backups', { params: instanceId ? { instance_id: instanceId } : {} }), - create: (data: any) => - request.post('/backups', data), - get: (id: number) => - request.get('/backups/' + id), -}; +/** 表元信息 */ +export interface TableMeta { + name: string; + engine: string; + rows: number; + data_length: number; + index_length: number; + comment: string; + collation: string; + create_time: string; + update_time: string; +} + +/** 表详情 */ +export interface TableDetail { + ddl: string; + columns: ColumnInfo[]; + indexes: IndexInfo[]; +} + +export interface ColumnInfo { + Field: string; + Type: string; + Null: string; + Key: string; + Default: string | null; + Extra: string; +} + +export interface IndexInfo { + Table: string; + Non_unique: number; + Key_name: string; + Seq_in_index: number; + Column_name: string; + Collation: string; + Cardinality: number; + Sub_part: string | null; + Packed: string | null; + Null: string; + Index_type: string; + Comment: string; + Index_comment: string; +} diff --git a/frontend/src/api/redis.ts b/frontend/src/api/redis.ts index 1dfd269..71f8eb2 100644 --- a/frontend/src/api/redis.ts +++ b/frontend/src/api/redis.ts @@ -28,4 +28,16 @@ export const redisApi = { // 执行命令 executeCommand: (instanceId: number, data: { db: number; command: string }) => request.post('/redis/' + instanceId + '/command', data), + + // 获取键列表(兼容旧接口) + getKeys: (instanceId: number, params?: { db?: number; pattern?: string; count?: number; type?: string; cursor?: number }) => + request.get('/redis/' + instanceId + '/keys', { params }), + + // 创建键 + createKey: (instanceId: number, data: { db?: number; key: string; type?: string; value: any; ttl?: number }) => + request.post('/redis/' + instanceId + '/keys', data), + + // 删除键 + deleteKey: (instanceId: number, key: string, db?: number) => + request.delete('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }), }; diff --git a/frontend/src/components/SchemaSidebar.tsx b/frontend/src/components/SchemaSidebar.tsx new file mode 100644 index 0000000..41fa9d4 --- /dev/null +++ b/frontend/src/components/SchemaSidebar.tsx @@ -0,0 +1,365 @@ +import { useState, useEffect, useCallback } from 'react'; +import { + ChevronDown, ChevronRight, Database, Table2, Columns3, + KeyRound, Search, Loader2, FolderOpen, Server, +} from 'lucide-react'; +import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail'; +import { redisApi } from '@/api/redis'; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ + +interface Props { + instanceId: string; + database: string; + dbType: string; + onInsert: (text: string) => void; +} + +interface RedisKeyItem { + key: string; + type: string; + ttl: number; +} + +/* ------------------------------------------------------------------ */ +/* Helpers */ +/* ------------------------------------------------------------------ */ + +function formatTTL(ttl: number): string { + if (ttl < 0) return '永不过期'; + if (ttl < 60) return `${ttl}s`; + if (ttl < 3600) return `${Math.floor(ttl / 60)}m`; + if (ttl < 86400) return `${Math.floor(ttl / 3600)}h`; + return `${Math.floor(ttl / 86400)}d`; +} + +/* ================================================================== */ +/* COMPONENT */ +/* ================================================================== */ + +export default function SchemaSidebar({ instanceId, database, dbType, onInsert }: Props) { + const isMySQL = dbType === 'mysql' || dbType === 'postgres' || dbType === 'postgresql'; + const isRedis = dbType === 'redis'; + + // -- MySQL State -- + const [tables, setTables] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [expandedTable, setExpandedTable] = useState(null); + const [tableColumns, setTableColumns] = useState([]); + const [loadingColumns, setLoadingColumns] = useState(false); + const [tableSearch, setTableSearch] = useState(''); + + // -- Redis State -- + const [redisDb, setRedisDb] = useState(0); + const [redisPattern, setRedisPattern] = useState('*'); + const [redisKeys, setRedisKeys] = useState([]); + const [loadingRedis, setLoadingRedis] = useState(false); + const [selectedKey, setSelectedKey] = useState(null); + const [keyDetail, setKeyDetail] = useState(null); + const [loadingDetail, setLoadingDetail] = useState(false); + + // -- Tab -- + const [activeTab, setActiveTab] = useState<'tables' | 'redis'>('tables'); + + /* ---------------------------------------------------------------- */ + /* Fetch tables */ + /* ---------------------------------------------------------------- */ + + useEffect(() => { + if (!instanceId || !database || !isMySQL) { setTables([]); return; } + setLoadingTables(true); + instanceDetailApi.listTables(Number(instanceId), database) + .then((data) => setTables(Array.isArray(data) ? data : [])) + .catch(() => setTables([])) + .finally(() => setLoadingTables(false)); + }, [instanceId, database, isMySQL]); + + /* ---------------------------------------------------------------- */ + /* Fetch columns on expand */ + /* ---------------------------------------------------------------- */ + + const handleExpandTable = useCallback(async (tableName: string) => { + if (expandedTable === tableName) { setExpandedTable(null); return; } + setExpandedTable(tableName); + setTableColumns([]); + setLoadingColumns(true); + try { + const detail = await instanceDetailApi.getTableDetail(Number(instanceId), database, tableName); + setTableColumns(detail?.columns || []); + } catch { /* ignore */ } + finally { setLoadingColumns(false); } + }, [expandedTable, instanceId, database]); + + /* ---------------------------------------------------------------- */ + /* 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]); + + /* ---------------------------------------------------------------- */ + /* 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]); + + /* ---------------------------------------------------------------- */ + /* Filtered tables */ + /* ---------------------------------------------------------------- */ + + const filteredTables = tables.filter(t => + !tableSearch || t.name.toLowerCase().includes(tableSearch.toLowerCase()) + ); + + /* ================================================================== */ + /* RENDER */ + /* ================================================================== */ + + if (!instanceId) { + return ( +
+
+ + 数据库导航 +
+
+

请先选择实例

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+ + {dbType.toUpperCase()} +
+ + {/* Tabs */} + {(isMySQL || isRedis) && ( +
+ {isMySQL && ( + + )} + {isRedis && ( + + )} +
+ )} + + {/* Content */} +
+ {activeTab === 'tables' && isMySQL && ( +
+ {/* Search */} +
+
+ + setTableSearch(e.target.value)} + className="w-full pl-7 pr-2 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500" + placeholder="搜索表..." + /> +
+
+ + {/* Table List */} +
+ {loadingTables ? ( +
+ 加载中... +
+ ) : filteredTables.length === 0 ? ( +
无表
+ ) : ( +
+ {filteredTables.map(t => ( +
+ + + {/* Columns */} + {expandedTable === t.name && ( +
+ {loadingColumns ? ( +
+ ... +
+ ) : ( + tableColumns.map(col => ( + + )) + )} +
+ )} +
+ ))} +
+ )} +
+
+ )} + + {activeTab === 'redis' && isRedis && ( +
+ {/* DB + Search */} +
+ +
+ + setRedisPattern(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()} + className="w-full pl-7 pr-2 py-1 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-[11px] focus:outline-none focus:border-blue-500" + placeholder="*" + /> +
+ +
+ + {/* Key List + Detail */} +
+ {redisKeys.length === 0 ? ( +
+ {loadingRedis ? '扫描中...' : '无 Keys'} +
+ ) : ( +
+ {redisKeys.map(k => ( +
+ + + {/* Key Detail */} + {selectedKey === k.key && ( +
+ {loadingDetail ? ( +
+ ... +
+ ) : keyDetail ? ( +
+
+ {keyDetail.type} + TTL: {formatTTL(keyDetail.ttl)} +
+
+                                {typeof keyDetail.value === 'object'
+                                  ? JSON.stringify(keyDetail.value, null, 2)
+                                  : String(keyDetail.value)}
+                              
+
+ ) : null} +
+ )} +
+ ))} +
+ )} +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index ae5b9dc..c09b541 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { ClipboardList, Clock, CheckCircle, AlertTriangle, Database, ChevronRight, X, AlertCircle, Info, Bell, @@ -125,6 +126,7 @@ export default function Dashboard() { const chartInstanceRef = useRef(null); const { theme } = useTheme(); const { user } = useAuth(); + const navigate = useNavigate(); const [dismissedIds, setDismissedIds] = useState>(new Set()); const [readIds, setReadIds] = useState>(new Set()); const [expandedId, setExpandedId] = useState(null); @@ -338,9 +340,9 @@ export default function Dashboard() { }, [stats]); const quickActions = [ - { label: '新建工单', icon: FileText, href: '/tickets', color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' }, - { label: '新建查询', icon: Search, href: '/queries', color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' }, - { label: '添加实例', icon: Database, href: '/instances', color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' }, + { label: '新建工单', icon: FileText, onClick: () => navigate('/tickets/create'), color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' }, + { label: '新建查询', icon: Search, onClick: () => navigate('/queries'), color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' }, + { label: '添加实例', icon: Database, onClick: () => navigate('/instances'), color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' }, ]; const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => { @@ -371,15 +373,15 @@ export default function Dashboard() { {/* ---- Quick Actions ---- */}
{quickActions.map(action => ( - {action.label} - + ))}
diff --git a/frontend/src/pages/QueriesPage.tsx b/frontend/src/pages/QueriesPage.tsx index 81bb049..0b91c3a 100644 --- a/frontend/src/pages/QueriesPage.tsx +++ b/frontend/src/pages/QueriesPage.tsx @@ -1,9 +1,10 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { Plus, X, AlertTriangle, CheckCircle, Play, Loader2, - ChevronDown, Clock, FileText, Sparkles, Star, - StarOff, Download, Search, ArrowUpDown, - ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, + ChevronDown, ChevronRight, Clock, FileText, Sparkles, Star, + StarOff, Download, Search, ArrowUpDown, Database, Table2, + ArrowUp, ArrowDown, Eye, Hash, PanelLeftClose, Columns3, + KeyRound, FolderOpen, Server, Zap, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useActionPermission } from '@/hooks/useAuth'; @@ -12,7 +13,10 @@ import EmptyState from '@/components/EmptyState'; import { queryApi } from '@/api/query'; import { instanceApi } from '@/api/instance'; import SqlEditor from '@/components/SqlEditor'; +import SchemaSidebar from '@/components/SchemaSidebar'; import { getTemplateSQL, getTemplatesForType } from '@/lib/queryTemplates'; +import { instanceDetailApi, type TableMeta, type ColumnInfo } from '@/api/instanceDetail'; +import { redisApi } from '@/api/redis'; import type { DBType, QueryTemplate } from '@/lib/queryTemplates'; /* ------------------------------------------------------------------ */ @@ -299,6 +303,7 @@ function NewQueryPanel({ const [rowHeight] = useState(36); const [visibleRange, setVisibleRange] = useState({ start: 0, end: 50 }); const resultContainerRef = useRef(null); + const [showSidebar, setShowSidebar] = useState(true); useEffect(() => { instanceApi.list().then(res => { @@ -564,6 +569,20 @@ function NewQueryPanel({ 查询语句 * +
+ {instanceId && ( + + )} +
{showTemplates && ( @@ -626,9 +646,23 @@ function NewQueryPanel({ )} - {/* CodeMirror Editor */} -
- + {/* Schema Sidebar */} + {showSidebar && instanceId && ( +
+ setSql(prev => prev + (prev.trim() ? ' ' : '') + text + ' ')} + /> +
+ )} + {/* Editor */} +
+
+
+
+ {/* Security notice */} diff --git a/frontend/src/pages/TicketCreatePage.tsx b/frontend/src/pages/TicketCreatePage.tsx index ae821b6..42f727c 100644 --- a/frontend/src/pages/TicketCreatePage.tsx +++ b/frontend/src/pages/TicketCreatePage.tsx @@ -4,9 +4,13 @@ import { ChevronRight, Check, AlertTriangle, Shield, Database, FileText, Sparkles, RotateCcw, Loader2, Info, ChevronDown, ChevronUp, Table2, Columns3, FolderOpen, + KeyRound, ListTree, Search, Hash, Clock, BarChart3, + X, Zap, Copy, CheckCheck, } from 'lucide-react'; import { ticketApi } from '@/api/ticket'; import { instanceApi } from '@/api/instance'; +import { instanceDetailApi, type TableMeta, type TableDetail } from '@/api/instanceDetail'; +import { redisApi } from '@/api/redis'; import SqlEditor, { formatSQL } from '@/components/SqlEditor'; /* ------------------------------------------------------------------ */ @@ -40,10 +44,11 @@ interface StepConfig { icon: React.ElementType; } -interface TableMeta { - name: string; - rows?: number; - type?: string; +interface RedisKeyItem { + key: string; + type: string; + ttl: number; + size?: number; } /* ------------------------------------------------------------------ */ @@ -86,7 +91,6 @@ const PRIORITY_OPTIONS = [ { value: 'high', label: '高' }, ]; -// 按使用频率排序 const DB_TYPE_OPTIONS = [ { value: '', label: '请选择数据库类型' }, { value: 'mysql', label: 'MySQL' }, @@ -101,16 +105,14 @@ const DB_TYPE_LABELS: Record = { postgres: 'PostgreSQL', postgresql: 'PostgreSQL', mongodb: 'MongoDB', - sqlite: 'SQLite', }; /* ------------------------------------------------------------------ */ -/* Helper: generate rollback SQL suggestion */ +/* Helper */ /* ------------------------------------------------------------------ */ function generateRollbackSuggestion(sql: string): string { const upper = sql.toUpperCase().trim(); - if (upper.startsWith('UPDATE')) { const tableMatch = sql.match(/UPDATE\s+(\w+)/i); const table = tableMatch ? tableMatch[1] : 'table'; @@ -118,29 +120,41 @@ function generateRollbackSuggestion(sql: string): string { const where = whereMatch ? whereMatch[1].trim() : ''; return `-- 建议回滚语句(请根据实际业务调整):\n-- UPDATE ${table} SET ... WHERE ${where || '...'};`; } - if (upper.startsWith('DELETE')) { const tableMatch = sql.match(/FROM\s+(\w+)/i) || sql.match(/DELETE\s+(\w+)/i); const table = tableMatch ? tableMatch[1] : 'table'; return `-- 建议回滚语句:\n-- INSERT INTO ${table} SELECT * FROM ${table}_backup WHERE ...;\n-- 或从 Binlog 恢复`; } - if (upper.startsWith('ALTER')) { return `-- DDL 回滚说明:\n-- 1. ALTER TABLE 操作无法直接回滚\n-- 2. 建议先使用 pt-online-schema-change 或 gh-ost 进行在线变更\n-- 3. 执行前请确保有完整备份`; } - if (upper.startsWith('INSERT')) { const tableMatch = sql.match(/INTO\s+(\w+)/i); const table = tableMatch ? tableMatch[1] : 'table'; return `-- 建议回滚语句:\n-- DELETE FROM ${table} WHERE ...;\n-- 请根据 INSERT 条件编写对应的 DELETE 语句`; } - return `-- 请根据业务逻辑编写回滚语句`; } -/* ------------------------------------------------------------------ */ -/* Component */ -/* ------------------------------------------------------------------ */ +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +function formatTTL(ttl: number): string { + if (ttl < 0) return '永不过期'; + if (ttl < 60) return `${ttl}s`; + if (ttl < 3600) return `${Math.floor(ttl / 60)}m`; + if (ttl < 86400) return `${Math.floor(ttl / 3600)}h`; + return `${Math.floor(ttl / 86400)}d`; +} + +/* ================================================================== */ +/* MAIN COMPONENT */ +/* ================================================================== */ export default function TicketCreatePage() { const navigate = useNavigate(); @@ -166,20 +180,30 @@ export default function TicketCreatePage() { const [auditing, setAuditing] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); - - // -- Audit -- const [auditResult, setAuditResult] = useState(null); - // -- Schema browser -- - const [showSchema, setShowSchema] = useState(false); + // -- Schema Browser State -- const [tables, setTables] = useState([]); const [loadingTables, setLoadingTables] = useState(false); const [expandedTable, setExpandedTable] = useState(null); - const [columns, setColumns] = useState>({}); - const [loadingColumns, setLoadingColumns] = useState(null); + const [tableDetail, setTableDetail] = useState(null); + const [loadingDetail, setLoadingDetail] = useState(false); + const [copiedDDL, setCopiedDDL] = useState(false); + + // -- Redis Browser State -- + const [redisDb, setRedisDb] = useState(0); + const [redisPattern, setRedisPattern] = useState('*'); + const [redisKeys, setRedisKeys] = useState([]); + const [loadingRedisKeys, setLoadingRedisKeys] = useState(false); + const [selectedRedisKey, setSelectedRedisKey] = useState(null); + const [redisKeyDetail, setRedisKeyDetail] = useState(null); + const [loadingRedisDetail, setLoadingRedisDetail] = useState(false); + + // -- Browser Tab -- + const [browserTab, setBrowserTab] = useState<'tables' | 'indexes' | 'redis'>('tables'); /* ---------------------------------------------------------------- */ - /* Fetch instances on mount */ + /* Fetch instances */ /* ---------------------------------------------------------------- */ useEffect(() => { @@ -193,10 +217,6 @@ export default function TicketCreatePage() { .finally(() => setLoadingInstances(false)); }, []); - /* ---------------------------------------------------------------- */ - /* Filtered instances by db type */ - /* ---------------------------------------------------------------- */ - const filteredInstances = useMemo(() => { if (!dbType) return instances; return instances.filter(i => { @@ -205,12 +225,7 @@ export default function TicketCreatePage() { }); }, [instances, dbType]); - /* ---------------------------------------------------------------- */ - /* Reset instance when dbType changes */ - /* ---------------------------------------------------------------- */ - useEffect(() => { - // If current instance doesn't match new dbType, reset it if (instanceId) { const current = instances.find(i => i.id === Number(instanceId)); if (current && dbType) { @@ -227,188 +242,174 @@ export default function TicketCreatePage() { }, [dbType, instances, instanceId]); /* ---------------------------------------------------------------- */ - /* Fetch databases when instance changes */ + /* Fetch databases */ /* ---------------------------------------------------------------- */ useEffect(() => { - if (!instanceId) { - setDatabases([]); - setDatabase(''); - return; - } + if (!instanceId) { setDatabases([]); setDatabase(''); return; } const inst = instances.find(i => i.id === Number(instanceId)); setLoadingDatabases(true); instanceApi.listDatabases(Number(instanceId)) .then((res: unknown) => { - const data = (res as { databases?: string[] })?.databases - || (res as string[]) - || []; + const data = (res as { databases?: string[] })?.databases || (res as string[]) || []; setDatabases(Array.isArray(data) ? data as string[] : []); - if (Array.isArray(data) && data.length > 0) { - setDatabase(String(data[0])); - } else if (inst?.db_type === 'redis') { - // Redis 默认 db0 - setDatabase('db0'); - } + if (Array.isArray(data) && data.length > 0) setDatabase(String(data[0])); + else if (inst?.db_type === 'redis') setDatabase('db0'); }) .catch(() => { setDatabases([]); - // Redis fallback - if (inst?.db_type === 'redis') { - setDatabase('db0'); - } + if (inst?.db_type === 'redis') setDatabase('db0'); }) .finally(() => setLoadingDatabases(false)); }, [instanceId, instances]); /* ---------------------------------------------------------------- */ - /* Schema browser: load tables */ + /* Fetch tables when instance+db changes */ /* ---------------------------------------------------------------- */ useEffect(() => { - if (!showSchema || !instanceId || !database) { - setTables([]); - return; - } + if (!instanceId || !database) { setTables([]); return; } + const inst = instances.find(i => i.id === Number(instanceId)); + if (!inst || inst.db_type !== 'mysql') return; setLoadingTables(true); - instanceApi.listTables(Number(instanceId), database) - .then((res: unknown) => { - const data = (res as { tables?: TableMeta[] })?.tables - || (res as TableMeta[]) - || []; - setTables(Array.isArray(data) ? data as TableMeta[] : []); - }) + instanceDetailApi.listTables(Number(instanceId), database) + .then((data) => setTables(Array.isArray(data) ? data : [])) .catch(() => setTables([])) .finally(() => setLoadingTables(false)); - }, [showSchema, instanceId, database]); + }, [instanceId, database, instances]); /* ---------------------------------------------------------------- */ - /* Schema browser: load columns for a table */ + /* Fetch table detail (columns + indexes) */ /* ---------------------------------------------------------------- */ const handleExpandTable = useCallback(async (tableName: string) => { - if (expandedTable === tableName) { - setExpandedTable(null); - return; - } + if (expandedTable === tableName) { setExpandedTable(null); return; } setExpandedTable(tableName); - - if (columns[tableName]) return; // already loaded - - setLoadingColumns(tableName); + setTableDetail(null); + setLoadingDetail(true); try { - const res = await instanceApi.listTables(Number(instanceId), `${database}/${tableName}`) as unknown; - const data = (res as { tables?: { columns?: { name: string }[] }[] })?.tables; - if (Array.isArray(data) && data[0]?.columns) { - const cols = data[0].columns.map((c: { name: string }) => c.name); - setColumns(prev => ({ ...prev, [tableName]: cols })); - } - } catch { - // ignore — columns not available - } finally { - setLoadingColumns(null); - } - }, [expandedTable, columns, instanceId, database]); + const detail = await instanceDetailApi.getTableDetail(Number(instanceId), database, tableName); + setTableDetail(detail); + } catch { /* ignore */ } + finally { setLoadingDetail(false); } + }, [expandedTable, instanceId, database]); /* ---------------------------------------------------------------- */ - /* Insert table/column name into SQL editor */ + /* Fetch Redis keys */ + /* ---------------------------------------------------------------- */ + + const fetchRedisKeys = useCallback(async () => { + if (!instanceId) return; + setLoadingRedisKeys(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, + size: k.size || 0, + })); + setRedisKeys(keys); + } catch { setRedisKeys([]); } + finally { setLoadingRedisKeys(false); } + }, [instanceId, redisDb, redisPattern]); + + useEffect(() => { + const inst = instances.find(i => i.id === Number(instanceId)); + if (inst?.db_type === 'redis' && browserTab === 'redis') { + fetchRedisKeys(); + } + }, [instanceId, browserTab, fetchRedisKeys, instances]); + + /* ---------------------------------------------------------------- */ + /* Fetch Redis key detail */ + /* ---------------------------------------------------------------- */ + + const handleSelectRedisKey = useCallback(async (key: string) => { + setSelectedRedisKey(key); + setLoadingRedisDetail(true); + try { + const detail = await redisApi.getKeyDetail(Number(instanceId), key, redisDb); + setRedisKeyDetail(detail); + } catch { setRedisKeyDetail(null); } + finally { setLoadingRedisDetail(false); } + }, [instanceId, redisDb]); + + /* ---------------------------------------------------------------- */ + /* Insert table/column/key into SQL editor */ /* ---------------------------------------------------------------- */ const insertToEditor = useCallback((text: string) => { setSqlContent(prev => { - const separator = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : ''; - return prev + separator + text + ' '; + const sep = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : ''; + return prev + sep + text + ' '; }); }, []); - /* ---------------------------------------------------------------- */ - /* SQL change callback */ - /* ---------------------------------------------------------------- */ - const handleSqlChange = useCallback((val: string) => { setSqlContent(val); - if (val.trim().length > 10) { - setRollbackSql(generateRollbackSuggestion(val)); - } + if (val.trim().length > 10) setRollbackSql(generateRollbackSuggestion(val)); }, []); - /* ---------------------------------------------------------------- */ - /* Format SQL */ - /* ---------------------------------------------------------------- */ - const handleFormatSQL = useCallback(() => { if (!sqlContent.trim()) return; - const formatted = formatSQL(sqlContent); - setSqlContent(formatted); + setSqlContent(formatSQL(sqlContent)); }, [sqlContent]); /* ---------------------------------------------------------------- */ - /* Audit handler */ + /* Audit handler */ /* ---------------------------------------------------------------- */ const handleAudit = useCallback(async () => { - if (!sqlContent.trim() || !instanceId) { - setError('请填写 SQL 语句并选择实例'); - return; - } - setAuditing(true); - setError(''); + if (!sqlContent.trim() || !instanceId) { setError('请填写 SQL 语句并选择实例'); return; } + setAuditing(true); setError(''); try { const result = await ticketApi.audit({ - sql_content: sqlContent, - instance_id: Number(instanceId), - database, + sql_content: sqlContent, instance_id: Number(instanceId), database, }) as unknown as AuditResult; setAuditResult(result); setStep(2); } catch (e: unknown) { const err = e as { response?: { data?: { detail?: string } } }; setError(err?.response?.data?.detail || '审核失败,请检查 SQL 语法'); - } finally { - setAuditing(false); - } + } finally { setAuditing(false); } }, [sqlContent, instanceId, database]); /* ---------------------------------------------------------------- */ - /* Submit handler */ + /* Submit handler */ /* ---------------------------------------------------------------- */ const handleSubmit = useCallback(async () => { if (!title.trim() || !sqlContent.trim() || !instanceId || !database || !reason.trim()) { - setError('请填写所有必填项'); - return; + setError('请填写所有必填项'); return; } - setSubmitting(true); - setError(''); + setSubmitting(true); setError(''); try { const inst = instances.find((i) => i.id === Number(instanceId)); await ticketApi.create({ - title: title.trim(), - db_type: inst?.db_type || dbType || 'mysql', - instance_id: Number(instanceId), - target_database: database, - sql_content: sqlContent, - rollback_sql: rollbackSql || undefined, - reason: reason.trim(), - priority: priority, + title: title.trim(), db_type: inst?.db_type || dbType || 'mysql', + instance_id: Number(instanceId), target_database: database, + sql_content: sqlContent, rollback_sql: rollbackSql || undefined, + reason: reason.trim(), priority: priority, }); navigate('/tickets'); } catch (e: unknown) { const err = e as { response?: { data?: { detail?: string } } }; setError(err?.response?.data?.detail || '提交失败,请稍后重试'); - } finally { - setSubmitting(false); - } + } finally { setSubmitting(false); } }, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, dbType, navigate]); /* ---------------------------------------------------------------- */ - /* Derived values */ + /* Derived values */ /* ---------------------------------------------------------------- */ - const selectedInstance = useMemo( - () => instances.find((i) => i.id === Number(instanceId)), - [instances, instanceId] - ); + const selectedInstance = useMemo(() => instances.find((i) => i.id === Number(instanceId)), [instances, instanceId]); + const isRedis = selectedInstance?.db_type === 'redis'; + const isMySQL = selectedInstance?.db_type === 'mysql' || selectedInstance?.db_type === 'postgresql' || selectedInstance?.db_type === 'postgres'; const riskLevel = useMemo(() => { if (!auditResult) return ''; @@ -420,7 +421,15 @@ export default function TicketCreatePage() { const isStep1Valid = title.trim() && sqlContent.trim() && instanceId && database && reason.trim(); /* ---------------------------------------------------------------- */ - /* Render helpers */ + /* Copy helper */ + /* ---------------------------------------------------------------- */ + + const copyToClipboard = useCallback(async (text: string) => { + try { await navigator.clipboard.writeText(text); setCopiedDDL(true); setTimeout(() => setCopiedDDL(false), 2000); } catch { } + }, []); + + /* ---------------------------------------------------------------- */ + /* RENDER: Step Indicator */ /* ---------------------------------------------------------------- */ const renderStepIndicator = () => ( @@ -429,143 +438,312 @@ export default function TicketCreatePage() { const isActive = step === s.num; const isDone = step > s.num; const StepIcon = s.icon; - return (
-
- {isDone ? ( - - ) : ( - - )} +
+ {isDone ? : } {s.label}
- {idx < STEPS.length - 1 && ( - - )} + {idx < STEPS.length - 1 && }
); })}
); - const renderError = () => - error ? ( -
- - {error} -
- ) : null; + const renderError = () => error ? ( +
+ + {error} +
+ ) : null; - /* ---------------------------------------------------------------- */ - /* Schema Browser Panel */ - /* ---------------------------------------------------------------- */ + /* ================================================================== */ + /* BROWSER PANEL — Tables / Indexes / Redis Keys */ + /* ================================================================== */ - const renderSchemaBrowser = () => { + const renderBrowserPanel = () => { if (!instanceId || !database) return null; + // Determine available tabs + const tabs: { id: 'tables' | 'indexes' | 'redis'; label: string; icon: React.ElementType }[] = []; + if (isMySQL) { + tabs.push({ id: 'tables', label: '表结构', icon: Table2 }); + tabs.push({ id: 'indexes', label: '索引', icon: BarChart3 }); + } + if (isRedis) { + tabs.push({ id: 'redis', label: 'Redis Keys', icon: KeyRound }); + } + if (tabs.length === 0) return null; + return ( -
- {/* Toggle button */} - +
+ {/* Tab Header */} +
+ {tabs.map(tab => ( + + ))} + 点击插入到 SQL +
- {/* Schema content */} - {showSchema && ( -
- {loadingTables ? ( -
- - 加载表列表... -
- ) : tables.length === 0 ? ( -

无可用表(或实例未连接)

- ) : ( -
- {tables.map(t => ( -
- {/* Table row */} - - - {/* Columns */} - {expandedTable === t.name && ( -
- {loadingColumns === t.name ? ( -
- - 加载字段... -
- ) : columns[t.name] ? ( -
- {columns[t.name].map(col => ( - - ))} -
- ) : ( -

字段信息不可用

- )} -
- )} -
- ))} -
- )} -
- )} + {/* Tab Content */} +
+ {browserTab === 'tables' && renderTablesTab()} + {browserTab === 'indexes' && renderIndexesTab()} + {browserTab === 'redis' && renderRedisTab()} +
); }; - /* ========== Step 1: Fill info ========== */ + /* ---- Tables Tab ---- */ + const renderTablesTab = () => { + if (loadingTables) return ( +
+ 加载表列表... +
+ ); + if (tables.length === 0) return ( +
无可用表
+ ); + + return ( +
+ {tables.map(t => ( +
+ {/* Table Row */} + + + {/* Expanded: Columns + DDL */} + {expandedTable === t.name && ( +
+ {loadingDetail ? ( +
+ 加载字段... +
+ ) : tableDetail ? ( +
+ {/* Columns */} +
+ {tableDetail.columns.map((col) => ( + + ))} +
+ + {/* DDL Preview */} + {tableDetail.ddl && ( +
+
+ CREATE TABLE + +
+
+                          {tableDetail.ddl}
+                        
+
+ )} +
+ ) : ( +

字段信息不可用

+ )} +
+ )} +
+ ))} +
+ ); + }; + + /* ---- Indexes Tab ---- */ + const renderIndexesTab = () => { + if (!expandedTable || !tableDetail?.indexes) return ( +
+ 请先选择一个表查看索引 +
+ ); + + const grouped = tableDetail.indexes.reduce((acc, idx) => { + if (!acc[idx.Key_name]) acc[idx.Key_name] = []; + acc[idx.Key_name].push(idx); + return acc; + }, {} as Record); + + return ( +
+
+ 表: + {expandedTable} + {Object.keys(grouped).length} 个索引 +
+ {Object.entries(grouped).map(([keyName, idxs]) => ( +
+
+ {keyName} + + {keyName === 'PRIMARY' ? '主键' : idxs[0]?.Index_type || '索引'} + + + {idxs[0]?.Cardinality?.toLocaleString()} 基数 + +
+
+ {idxs.map((idx, i) => ( + + ))} +
+
+ ))} +
+ ); + }; + + /* ---- Redis Tab ---- */ + const renderRedisTab = () => ( +
+ {/* Search Bar */} +
+ +
+ + setRedisPattern(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && fetchRedisKeys()} + className="w-full pl-7 pr-3 py-1.5 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded text-xs focus:outline-none focus:border-blue-500" + placeholder="Key pattern (e.g. user:*)" + /> +
+ +
+ + {/* Key Tree */} + {redisKeys.length > 0 ? ( +
+ {/* Key List */} +
+ {redisKeys.map((k) => ( + + ))} +
+ + {/* Key Detail */} +
+ {loadingRedisDetail ? ( +
+ 加载... +
+ ) : redisKeyDetail ? ( +
+
+ {redisKeyDetail.type} + TTL: {formatTTL(redisKeyDetail.ttl)} + {redisKeyDetail.size && {formatBytes(redisKeyDetail.size)}} +
+
+                  {typeof redisKeyDetail.value === 'object'
+                    ? JSON.stringify(redisKeyDetail.value, null, 2)
+                    : String(redisKeyDetail.value)}
+                
+
+ ) : ( +
选择 Key 查看详情
+ )} +
+
+ ) : ( +
+ {loadingRedisKeys ? '扫描中...' : '无 Keys'} +
+ )} +
+ ); + + /* ================================================================== */ + /* STEP 1: Fill Info */ + /* ================================================================== */ + const renderStep1 = () => (
{/* Title */} @@ -585,53 +763,34 @@ export default function TicketCreatePage() {
- setPriority(e.target.value)} + className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"> + {PRIORITY_OPTIONS.map((opt) => )}
-
- setDbType(e.target.value)} + className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors"> + {DB_TYPE_OPTIONS.map((opt) => )}
- {/* Instance (filtered by dbType) */} + {/* Instance */}
- setInstanceId(e.target.value)} disabled={loadingInstances || filteredInstances.length === 0} - className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50" - > + className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"> {filteredInstances.map((inst) => ( - ) : ( - databases.map((db) => ( - - )) - )} + className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50"> + {loadingDatabases ? : databases.map((db) => )} ) : ( - setDatabase(e.target.value)} + setDatabase(e.target.value)} disabled={!instanceId} className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50" - placeholder={instanceId ? '请输入数据库名' : '请先选择实例'} - /> + placeholder={instanceId ? '请输入数据库名' : '请先选择实例'} /> )}
- {/* Schema Browser */} - {renderSchemaBrowser()} - - {/* SQL Editor */} -
-
- -
- - - {sqlContent.trim() ? `${sqlContent.trim().split(/;\s*/).filter(Boolean).length} 条语句` : ''} - + {/* === SQL Editor + Browser Panel Layout === */} +
+ {/* Left: SQL Editor */} +
+
+
+ +
+ + + {sqlContent.trim() ? `${sqlContent.trim().split(/;\s*/).filter(Boolean).length} 条语句` : ''} + +
+
+ +

+ + 提交前必须通过 SQL 审核(语法检查 + 审核规则) +

+ + {/* Rollback SQL */} + {rollbackSql && ( +
+
+ + +
+ +
+ )} +
+ + {/* Right: Browser Panel */} +
+ {renderBrowserPanel()}
- -

- - 提交前必须通过 SQL 审核(语法检查 + 审核规则) -

- {/* Rollback SQL */} - {rollbackSql && ( -
-
- - -
- -
- )} - {/* Reason */}
); - /* ========== Step 2: Audit result ========== */ + /* ================================================================== */ + /* STEP 2 & 3 */ + /* ================================================================== */ + const renderStep2 = () => { if (!auditResult) return null; - return (
- {/* Header with score */}

- - SQL 审核结果 + SQL 审核结果

{auditResult.score} @@ -791,7 +923,6 @@ export default function TicketCreatePage() {
- {/* Findings */} {auditResult.findings.length === 0 ? (
@@ -816,7 +947,6 @@ export default function TicketCreatePage() {
)} - {/* Summary */}
错误
@@ -839,18 +969,13 @@ export default function TicketCreatePage() {
- {/* Navigation */}
- -
@@ -858,19 +983,15 @@ export default function TicketCreatePage() { ); }; - /* ========== Step 3: Confirm ========== */ const renderStep3 = () => { const inst = instances.find((i) => i.id === Number(instanceId)); - return (

- - 确认工单信息 + 确认工单信息

- {/* Info grid */}
标题 @@ -878,18 +999,14 @@ export default function TicketCreatePage() {
优先级 -

- {priority === 'high' ? '高' : priority === 'medium' ? '中' : '低'} -

+

{priority === 'high' ? '高' : priority === 'medium' ? '中' : '低'}

实例
{inst?.name || instanceId} - - ({inst?.host}:{inst?.port}) - + ({inst?.host}:{inst?.port})
@@ -898,7 +1015,6 @@ export default function TicketCreatePage() {
- {/* SQL Preview */}
SQL 语句
@@ -906,26 +1022,21 @@ export default function TicketCreatePage() {
             
- {/* Rollback preview */} {rollbackSql && (
回滚 SQL
-
-                {rollbackSql}
-              
+
{rollbackSql}
)} - {/* Reason */}
变更原因

{reason}

- {/* Audit badge */} {auditResult && (
@@ -937,39 +1048,26 @@ export default function TicketCreatePage() { )}
- {/* Navigation */}
- -
); }; - /* ---------------------------------------------------------------- */ - /* Main render */ - /* ---------------------------------------------------------------- */ + /* ================================================================== */ + /* MAIN RENDER */ + /* ================================================================== */ return ( -
+
{/* Header */}
@@ -984,13 +1082,9 @@ export default function TicketCreatePage() {
- {/* Steps */} {renderStepIndicator()} - - {/* Error */} {renderError()} - {/* Step content */} {step === 1 && renderStep1()} {step === 2 && renderStep2()} {step === 3 && renderStep3()}