diff --git a/backend/app/api/v1/endpoints/instance.py b/backend/app/api/v1/endpoints/instance.py index 2e97050..b13b8ba 100644 --- a/backend/app/api/v1/endpoints/instance.py +++ b/backend/app/api/v1/endpoints/instance.py @@ -90,6 +90,20 @@ async def create_instance( return _serialize_json_fields(instance) +@router.post("/test-connection", response_model=ConnectionTestResult) +async def test_new_connection( + data: InstanceCreate, + current_user: User = Depends(require_role("admin", "dba")), +): + """测试连接参数(不保存实例)""" + fake = Instance( + name=data.name, db_type=data.db_type, host=data.host, port=data.port, + username=data.username, password=data.password, database=data.database, + env=data.env, access_mode=data.access_mode, + ) + return await InstanceService.test_connection(fake) + + @router.get("/{instance_id}", response_model=InstanceResponse) async def get_instance( instance_id: int, diff --git a/frontend/src/api/instance.ts b/frontend/src/api/instance.ts index 2bc3b59..0a2538d 100644 --- a/frontend/src/api/instance.ts +++ b/frontend/src/api/instance.ts @@ -7,6 +7,7 @@ export const instanceApi = { update: (id: number, data: any) => request.put(`/instances/${id}`, data), delete: (id: number) => request.delete(`/instances/${id}`), testConnection: (id: number) => request.post(`/instances/${id}/test`), + testNewConnection: (data: any) => request.post('/instances/test-connection', data), listDatabases: (id: number) => request.get(`/instances/${id}/databases`), listTables: (id: number, db: string) => request.get(`/instances/${id}/databases/${db}/tables`), }; diff --git a/frontend/src/pages/InstancePage.tsx b/frontend/src/pages/InstancePage.tsx index 34262ab..ed33191 100644 --- a/frontend/src/pages/InstancePage.tsx +++ b/frontend/src/pages/InstancePage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Plus, RefreshCw, Search, Database, Server, Zap, Leaf, Activity, HardDrive, ChevronRight, Trash2, Edit2, - XCircle, Eye, EyeOff, X, + XCircle, Eye, EyeOff, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -324,59 +324,60 @@ const PRESET_MASK_FIELDS = [ 'telephone', 'username', 'real_name', 'token', 'two_factor_secret', 'content', ]; -interface WorkflowStep { - stage: string; - role: string; - assignee: string; -} +const DB_DEFAULT_PORTS: Record = { + mysql: 3306, postgres: 5432, redis: 6379, mongo: 27017, +}; function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: string; onCancel: () => void; onCreated: () => void }) { - const [name, setName] = useState(''); const [dbType, setDbType] = useState(defaultType); + const [name, setName] = useState(''); const [host, setHost] = useState(''); - const [port, setPort] = useState('3306'); + const [port, setPort] = useState(String(DB_DEFAULT_PORTS[defaultType] || 3306)); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [showPwd, setShowPwd] = useState(false); const [database, setDatabase] = useState(''); const [env, setEnv] = useState('development'); + const [customEnv, setCustomEnv] = useState(''); const [accessMode, setAccessMode] = useState<'readwrite' | 'read' | 'write'>('readwrite'); const [excludedDbs, setExcludedDbs] = useState(''); const [maskFields, setMaskFields] = useState(''); - const [workflow, setWorkflow] = useState([ - { stage: '提交阶段', role: '提交人', assignee: '' }, - { stage: '开发负责人', role: '开发负责人', assignee: '' }, - { stage: '执行人', role: '执行人', assignee: '' }, - ]); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null); - const updateWorkflow = (idx: number, field: keyof WorkflowStep, value: string) => { - setWorkflow(prev => prev.map((w, i) => i === idx ? { ...w, [field]: value } : w)); - }; + const isRedis = dbType === 'redis'; + const isMongo = dbType === 'mongo'; + const needUsername = !isRedis; + const needDatabase = !isRedis; - const addWorkflowStep = () => { - setWorkflow(prev => [...prev, { stage: '', role: '', assignee: '' }]); - }; + const finalEnv = env === '__custom__' ? customEnv.trim() : env; + const canSubmit = testResult?.success === true; - const removeWorkflowStep = (idx: number) => { - setWorkflow(prev => prev.filter((_, i) => i !== idx)); + const handleTypeChange = (t: string) => { + setDbType(t); + setPort(String(DB_DEFAULT_PORTS[t] || 3306)); + setTestResult(null); + if (t === 'redis') { setUsername(''); setDatabase(''); } }; const handleSubmit = async () => { - if (!name || !host || !port || !username || !password) { setError('请填写所有必填项'); return; } - setSubmitting(true); - setError(''); + if (!name) { setError('请填写名称'); return; } + if (!host || !port) { setError('请填写地址和端口'); return; } + if (needUsername && !username) { setError('请填写用户名'); return; } + setSubmitting(true); setError(''); try { await instanceApi.create({ name, db_type: dbType, host, port: Number(port), - username, password, database: database || undefined, - env, access_mode: accessMode, - workflow: workflow.filter(w => w.stage || w.assignee).length > 0 ? workflow.filter(w => w.stage || w.assignee) : undefined, + username: username || 'default', password: password || 'default', + database: database || undefined, + env: (finalEnv || 'development') as any, + access_mode: accessMode, excluded_databases: excludedDbs ? excludedDbs.split(',').map(s => s.trim()).filter(Boolean) : undefined, mask_fields: maskFields ? maskFields.split('\n').filter(Boolean).map(line => { - const [table = '', field = '', rule = 'mask'] = line.split(':'); - return { table: table.trim(), field: field.trim(), rule: rule.trim() || 'mask' }; + const parts = line.split(':'); + return { table: parts[0]?.trim() || '', field: parts[1]?.trim() || parts[0]?.trim() || '', rule: parts[2]?.trim() || 'mask' }; }) : undefined, }); onCreated(); @@ -387,6 +388,26 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: } }; + // 测试连接(不保存) + const handleTestConnection = async () => { + if (!host || !port) { setError('请填写地址和端口'); return; } + if (needUsername && !username) { setError('请填写用户名'); return; } + setTesting(true); setError(''); setTestResult(null); + try { + const res = await instanceApi.testNewConnection({ + name: name || '__test__', db_type: dbType, host, port: Number(port), + username: username || 'default', password: password || 'default', + database: database || undefined, + env: (finalEnv || 'development') as any, access_mode: accessMode, + }); + setTestResult(res as any); + } catch (e: any) { + setTestResult({ success: false, message: e?.response?.data?.detail || '连接失败' }); + } finally { + setTesting(false); + } + }; + const inputCls = 'w-full px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none'; const labelCls = 'block text-sm font-medium mb-1.5'; @@ -399,63 +420,129 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
- {/* 数据源信息 */} + {/* ① 类型选择 — 最前面 */} -

数据源信息

-
-
- - -
-
- - setName(e.target.value)} className={inputCls} placeholder="例如:prod-mysql-只读库" /> -
-
-
-
- - setHost(e.target.value)} className={inputCls} placeholder="例如:mysql.example.com" /> -
-
- - setPort(e.target.value)} className={inputCls} placeholder="3306" /> -
-
- - -
-
-
-
- - setUsername(e.target.value)} className={inputCls} placeholder="admin" /> -
-
- -
- setPassword(e.target.value)} className={inputCls + ' pr-10'} placeholder="数据库密码" /> - -
-
-
-
- - setDatabase(e.target.value)} className={inputCls} placeholder="可选,留空则连接默认库" /> + ); + })}
- {/* 连接模式 */} + {/* ② 基本信息 */} + + +

基本信息

+
+
+ + { setName(e.target.value); setTestResult(null); }} className={inputCls} placeholder="例如:prod-mysql-只读库" /> +
+
+ +
+ + {env === '__custom__' && ( + setCustomEnv(e.target.value)} className={inputCls} placeholder="输入自定义环境名" /> + )} +
+
+
+
+
+ + {/* ③ 连接信息 — 根据类型动态渲染 */} + + +

连接信息

+
+
+ + { setHost(e.target.value); setTestResult(null); }} className={inputCls} placeholder="例如:mysql.example.com" /> +
+
+ + { setPort(e.target.value); setTestResult(null); }} className={inputCls} placeholder={String(DB_DEFAULT_PORTS[dbType] || 3306)} /> +
+ {!isRedis && ( +
+ + { setUsername(e.target.value); setTestResult(null); }} className={inputCls} placeholder="root" /> +
+ )} +
+
+ {!isRedis ? ( +
+ +
+ { setPassword(e.target.value); setTestResult(null); }} className={inputCls + ' pr-10'} placeholder="数据库密码" /> + +
+
+ ) : ( +
+ +
+ { setPassword(e.target.value); setTestResult(null); }} className={inputCls + ' pr-10'} placeholder="可选,Redis 无密码可留空" /> + +
+
+ )} + {needDatabase ? ( +
+ + { setDatabase(e.target.value); setTestResult(null); }} className={inputCls} placeholder={isMongo ? '可选,如 mydb' : '可选,留空则连接默认库'} /> +
+ ) : ( +
+ + +
+ )} +
+ + {/* 连接测试结果 */} + {testResult && ( +
+ {testResult.success + ? <> 连接成功{testResult.version ? ` · ${testResult.version}` : ''} + : <> {testResult.message}} +
+ )} +
+
+ + {/* ④ 连接模式 */}

连接模式

@@ -465,15 +552,11 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: { value: 'write' as const, label: '写' }, { value: 'read' as const, label: '读' }, ].map(m => ( - ))} @@ -481,37 +564,16 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType:
- {/* 流程配置 */} - - -
-

流程

- -
-
- {workflow.map((step, idx) => ( -
- {idx + 1} - updateWorkflow(idx, 'stage', e.target.value)} className={inputCls + ' flex-1'} placeholder="提交阶段" /> - updateWorkflow(idx, 'role', e.target.value)} className={inputCls + ' w-32'} placeholder="角色" /> - updateWorkflow(idx, 'assignee', e.target.value)} className={inputCls + ' flex-1'} placeholder="负责人" /> - {workflow.length > 1 && ( - - )} -
- ))} -
-
-
- - {/* 排除的数据库 & 脱敏字段 */} + {/* ⑤ 高级配置 */}

高级配置

-
- - setExcludedDbs(e.target.value)} className={inputCls} placeholder="以逗号分隔,如:sys, information_schema, mysql" /> -
+ {!isRedis && ( +
+ + setExcludedDbs(e.target.value)} className={inputCls} placeholder="以逗号分隔,如:sys, information_schema, mysql" /> +
+ )}
@@ -523,7 +585,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: }); }} id="copy-mask-btn" className="text-xs text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors px-2 py-0.5 rounded bg-[var(--dg-bg-tertiary)]">复制全部
@@ -535,8 +597,7 @@ function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: - +
+ + +