import { useState, useEffect, useCallback, useMemo } from 'react'; import { Plus, RefreshCw, Search, Database, Server, Zap, Leaf, Activity, HardDrive, ChevronRight, Trash2, Edit2, XCircle, Eye, EyeOff, ChevronLeft, Gauge, Wifi, WifiOff, AlertTriangle, CheckCircle, Signal, LayoutGrid, ListFilter, X, Loader2, BarChart3, Users, Copy, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useActionPermission } from '@/hooks/useAuth'; import PageHeader from '@/components/PageHeader'; import EmptyState from '@/components/EmptyState'; import { instanceApi } from '@/api/instance'; import EditInstanceDialog from '@/components/EditInstanceDialog'; /* ------------------------------------------------------------------ */ /* Types */ /* ------------------------------------------------------------------ */ interface InstanceItem { id: number; name: string; db_type: string; host: string; port: number; username: string; database: string | null; env: string; description: string | null; status: string; version: string | null; created_at: string; connections?: number; qps?: number; latency?: number; cpu?: number; memory?: number; disk?: number; } type ViewMode = 'grid' | 'list'; /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */ /* DB Brand Icons */ const dbTypeConfig: Record = { mysql: { icon: MySQLIcon, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' }, postgres: { icon: PostgreSQLIcon, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' }, redis: { icon: RedisIcon, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' }, mongo: { icon: MongoDBIcon, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' }, }; const envLabel: Record = { production: '生产', staging: '预发', development: '开发', test: '测试', uat: 'UAT', }; const envColor: Record = { production: 'bg-red-500/10 text-red-400 border-red-500/20', staging: 'bg-yellow-500/10 text-yellow-400 border-yellow-500/20', development: 'bg-green-500/10 text-green-400 border-green-500/20', test: 'bg-blue-500/10 text-blue-400 border-blue-500/20', uat: 'bg-purple-500/10 text-purple-400 border-purple-500/20', }; const statusLabel: Record = { connected: { text: '在线', online: true }, online: { text: '在线', online: true }, disconnected: { text: '离线', online: false }, offline: { text: '离线', online: false }, error: { text: '异常', online: false }, }; function getStatusInfo(status: string) { return statusLabel[status] || { text: '未知', online: false }; } /* ------------------------------------------------------------------ */ /* Health Score */ /* ------------------------------------------------------------------ */ function calcHealthScore(inst: InstanceItem): number { if (inst.status === 'offline' || inst.status === 'disconnected' || inst.status === 'error') return 0; let score = 100; const cpu = inst.cpu ?? 0; const memory = inst.memory ?? 0; const latency = inst.latency ?? 0; const disk = inst.disk ?? 0; if (cpu > 80) score -= 25; else if (cpu > 60) score -= 10; if (memory > 85) score -= 20; else if (memory > 70) score -= 10; if (latency > 10) score -= 20; else if (latency > 5) score -= 10; if (disk > 80) score -= 15; return Math.max(0, score); } function healthColor(score: number): string { if (score >= 90) return 'text-green-400'; if (score >= 70) return 'text-yellow-400'; if (score >= 50) return 'text-orange-400'; return 'text-red-400'; } function healthBg(score: number): string { if (score >= 90) return 'bg-green-500/15'; if (score >= 70) return 'bg-yellow-500/15'; if (score >= 50) return 'bg-orange-500/15'; return 'bg-red-500/15'; } function healthLabel(score: number): string { if (score >= 90) return '健康'; if (score >= 70) return '良好'; if (score >= 50) return '一般'; return '危险'; } /* ------------------------------------------------------------------ */ /* Main Page */ /* ------------------------------------------------------------------ */ export default function InstancePage() { const canAdd = useActionPermission('instance.add'); const canEdit = useActionPermission('instance.edit'); const canDelete = useActionPermission('instance.delete'); const [activeType, setActiveType] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); const [filterStatus, setFilterStatus] = useState(''); const [filterEnv, setFilterEnv] = useState(''); const [selectedInstance, setSelectedInstance] = useState(null); const [view, setView] = useState<'grid' | 'detail'>('grid'); const [layoutMode, setLayoutMode] = useState('grid'); const [instances, setInstances] = useState([]); const [loading, setLoading] = useState(false); const [showCreate, setShowCreate] = useState(false); const [showEdit, setShowEdit] = useState(false); const [editingInstance, setEditingInstance] = useState(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchDeleting, setBatchDeleting] = useState(false); const [showClone, setShowClone] = useState(false); const [cloneSource, setCloneSource] = useState(null); const typeTabs = [ { key: 'all', label: '全部', icon: LayoutGrid }, { key: 'mysql', label: 'MySQL', icon: Database }, { key: 'postgres', label: 'PostgreSQL', icon: Server }, { key: 'redis', label: 'Redis', icon: Zap }, { key: 'mongo', label: 'MongoDB', icon: Leaf }, ]; const fetchInstances = useCallback(async () => { setLoading(true); try { const res = await instanceApi.list({}); const data = Array.isArray(res) ? res : []; setInstances(data); } catch { /* keep existing */ } finally { setLoading(false); } }, []); useEffect(() => { fetchInstances(); }, [fetchInstances]); const filtered = useMemo(() => { return instances.filter(i => { if (activeType !== 'all' && i.db_type !== activeType) return false; if (filterStatus && i.status !== filterStatus) return false; if (filterEnv && i.env !== filterEnv) return false; if (searchQuery && !i.name.toLowerCase().includes(searchQuery.toLowerCase()) && !i.host.includes(searchQuery)) return false; return true; }); }, [instances, activeType, filterStatus, filterEnv, searchQuery]); const statusCounts = useMemo(() => ({ online: filtered.filter(i => getStatusInfo(i.status).online).length, offline: filtered.filter(i => !getStatusInfo(i.status).online).length, total: filtered.length, }), [filtered]); const avgHealth = useMemo(() => { if (filtered.length === 0) return 0; const total = filtered.reduce((sum, i) => sum + calcHealthScore(i), 0); return Math.round(total / filtered.length); }, [filtered]); const handleDelete = useCallback(async (id: number) => { if (!confirm('确定删除该实例?')) return; try { await instanceApi.delete(id); fetchInstances(); } catch { /* ignore */ } }, [fetchInstances]); const handleBatchDelete = useCallback(async () => { if (selectedIds.size === 0) return; if (!confirm(`确定删除选中的 ${selectedIds.size} 个实例?此操作不可恢复。`)) return; setBatchDeleting(true); try { const ids = Array.from(selectedIds); await Promise.all(ids.map(id => instanceApi.delete(id))); setSelectedIds(new Set()); fetchInstances(); } catch { /* ignore */ } finally { setBatchDeleting(false); } }, [selectedIds, fetchInstances]); const toggleSelect = useCallback((id: number) => { setSelectedIds(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const selectAll = useCallback(() => { if (selectedIds.size === filtered.length) { setSelectedIds(new Set()); } else { setSelectedIds(new Set(filtered.map(f => f.id))); } }, [filtered, selectedIds.size]); if (showCreate) { return ( setShowCreate(false)} onCreated={() => { setShowCreate(false); fetchInstances(); }} /> ); } if (showEdit && editingInstance) { return ( <> { setView('grid'); setShowEdit(false); setEditingInstance(null); }} config={dbTypeConfig[editingInstance.db_type] || dbTypeConfig.mysql} canEdit={canEdit} canDelete={canDelete} onDelete={() => { handleDelete(editingInstance.id); setView('grid'); setShowEdit(false); setEditingInstance(null); }} onEdit={() => {}} onClone={canAdd ? () => { setCloneSource(editingInstance); setShowClone(true); } : undefined} /> { setShowEdit(false); setEditingInstance(null); }} onSaved={() => { setShowEdit(false); setEditingInstance(null); fetchInstances(); }} /> {cloneSource && showClone && ( { setShowClone(false); setCloneSource(null); }} onSaved={() => { setShowClone(false); setCloneSource(null); fetchInstances(); }} mode="clone" /> )} ); } const selectedConfig = selectedInstance ? (dbTypeConfig[selectedInstance.db_type] || dbTypeConfig.mysql) : dbTypeConfig.mysql; if (view === 'detail' && selectedInstance) { return ( <> setView('grid')} config={selectedConfig} canEdit={canEdit} canDelete={canDelete} onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }} onEdit={() => { setEditingInstance(selectedInstance); setShowEdit(true); }} onClone={canAdd ? () => { setCloneSource(selectedInstance); setShowClone(true); } : undefined} /> {cloneSource && showClone && ( { setShowClone(false); setCloneSource(null); }} onSaved={() => { setShowClone(false); setCloneSource(null); fetchInstances(); }} mode="clone" /> )} ); } return (
setShowCreate(true), permission: 'instance.add' } : undefined} /> {/* Type Tabs */}
{typeTabs.map(tab => ( ))}
{/* Status Overview */}
{statusCounts.online}
在线实例
{statusCounts.offline}
离线实例
{statusCounts.total}
总实例数
{avgHealth}
平均健康分
{/* Filters */}
setSearchQuery(e.target.value)} />
{(searchQuery || filterStatus || filterEnv) && ( )}
{/* Batch Operations */} {selectedIds.size > 0 && (
已选择 {selectedIds.size} 个实例
{canDelete && ( )}
)} {/* Instance Display */} {loading && instances.length === 0 ? (
加载中...
) : filtered.length === 0 ? ( ) : layoutMode === 'grid' ? (
{filtered.map(instance => { const ic = dbTypeConfig[instance.db_type] || dbTypeConfig.mysql; const IconComp = ic.icon; const health = calcHealthScore(instance); const info = getStatusInfo(instance.status); const isSelected = selectedIds.has(instance.id); return (
{ setSelectedInstance(instance); setView('detail'); }} > {/* Selection checkbox */}
{ e.stopPropagation(); toggleSelect(instance.id); }} >
{isSelected && }
{envLabel[instance.env] || instance.env}
{info.online ? : } {info.text}

{instance.name}

{instance.host}:{instance.port}

{ic.label} {instance.version || ''}

{/* Health Score */}
健康评分 {health} {healthLabel(health)}
= 90 ? 'bg-green-400' : health >= 70 ? 'bg-yellow-400' : health >= 50 ? 'bg-orange-400' : 'bg-red-400' }`} style={{ width: `${health}%` }} />
{/* Metrics */}
{instance.connections}
连接
{instance.qps ? instance.qps.toLocaleString() : '-'}
QPS
{instance.latency}ms
延迟
查看详情
e.stopPropagation()}> {canAdd && ( )} {canEdit && ( )}
); })}
) : ( /* List Mode */
{filtered.map(instance => { const ic = dbTypeConfig[instance.db_type] || dbTypeConfig.mysql; const IconComp = ic.icon; const health = calcHealthScore(instance); const info = getStatusInfo(instance.status); const isSelected = selectedIds.has(instance.id); return ( { setSelectedInstance(instance); setView('detail'); }} > ); })}
0 ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)] hover:border-blue-400' }`} > {selectedIds.size === filtered.length && filtered.length > 0 && }
实例名称 类型 地址 环境 状态 健康分 连接/QPS 操作
{ e.stopPropagation(); toggleSelect(instance.id); }}>
{isSelected && }
{instance.name} {ic.label} {instance.host}:{instance.port} {envLabel[instance.env] || instance.env} {info.online ? : } {info.text} {health}
{instance.connections} 连接 {instance.qps?.toLocaleString()} QPS
{canAdd && ( )}
)} {/* Clone Dialog */} {cloneSource && showClone && ( { setShowClone(false); setCloneSource(null); }} onSaved={() => { setShowClone(false); setCloneSource(null); fetchInstances(); }} mode="clone" /> )}
); } /* ------------------------------------------------------------------ */ /* InstanceDetail */ /* ------------------------------------------------------------------ */ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete, onEdit, onClone }: { instance: InstanceItem; onBack: () => void; config: { icon: React.ElementType; color: string; bg: string }; canEdit: boolean; canDelete: boolean; onDelete: () => void; onEdit: () => void; onClone?: () => void; }) { const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null); const health = calcHealthScore(instance); const info = getStatusInfo(instance.status); const DetailIcon = config.icon; const handleTest = useCallback(async () => { setTesting(true); try { const res = await instanceApi.testConnection(instance.id); setTestResult(res as { success: boolean; message: string; version?: string }); } catch (e: unknown) { const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '连接失败'; setTestResult({ success: false, message: msg }); } finally { setTesting(false); } }, [instance.id]); return (
{/* Header */}

{instance.name} {info.online ? : } {info.text}

{instance.host}:{instance.port} · {instance.version || '版本未知'}

{onClone && ( )} {canEdit && ( )} {canDelete && ( )}
{/* Connection Test Result */} {testResult && (
{testResult.success ? : }
{testResult.message}
{testResult.version &&
版本: {testResult.version}
}
)} {/* Health + Metrics */}
{/* Health Score Card */} 健康评分
= 90 ? '#4ade80' : health >= 70 ? '#facc15' : health >= 50 ? '#fb923c' : '#f87171'} strokeWidth="8" strokeLinecap="round" strokeDasharray={`${(health / 100) * 264} 264`} className="transition-all duration-700" />
{health} {healthLabel(health)}
{[ { label: 'CPU 使用率', value: instance.cpu ?? 0, color: (instance.cpu ?? 0) > 80 ? 'bg-red-400' : (instance.cpu ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' }, { label: '内存使用率', value: instance.memory ?? 0, color: (instance.memory ?? 0) > 85 ? 'bg-red-400' : (instance.memory ?? 0) > 70 ? 'bg-yellow-400' : 'bg-green-400' }, { label: '磁盘使用率', value: instance.disk ?? 0, color: (instance.disk ?? 0) > 80 ? 'bg-red-400' : (instance.disk ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' }, ].map(item => (
{item.label} {item.value}%
))}
{/* Performance Metrics */} 性能指标
{[ { label: '连接数', value: instance.connections ?? 0, unit: '', icon: Users, color: 'text-blue-400', bg: 'bg-blue-400/10' }, { label: 'QPS', value: instance.qps ?? 0, unit: '/s', icon: Activity, color: 'text-green-400', bg: 'bg-green-400/10' }, { label: '延迟', value: instance.latency ?? 0, unit: 'ms', icon: Signal, color: 'text-orange-400', bg: 'bg-orange-400/10' }, { label: '运行时间', value: '-', unit: '', icon: HardDrive, color: 'text-purple-400', bg: 'bg-purple-400/10' }, ].map(m => (
{typeof m.value === 'number' ? m.value.toLocaleString() : m.value} {m.unit}
{m.label}
))}
{/* Connection Info */} 连接信息 {[ { label: '实例名称', value: instance.name }, { label: '主机地址', value: instance.host }, { label: '端口', value: instance.port.toString() }, { label: '用户名', value: instance.username }, { label: '默认数据库', value: instance.database || '-' }, { label: '运行环境', value: envLabel[instance.env] || instance.env }, { label: '版本', value: instance.version || '-' }, { label: '创建时间', value: new Date(instance.created_at).toLocaleString() }, ].map(item => (
{item.label} {item.value}
))}
{instance.description && ( 描述

{instance.description}

)}
); } /* ------------------------------------------------------------------ */ function MySQLIcon({ className }: { className?: string }) { return MySQL; } function PostgreSQLIcon({ className }: { className?: string }) { return PostgreSQL; } function RedisIcon({ className }: { className?: string }) { return Redis; } function MongoDBIcon({ className }: { className?: string }) { return MongoDB; } /* ------------------------------------------------------------------ */ /* Create Instance Form */ /* ------------------------------------------------------------------ */ const PRESET_MASK_FIELDS = [ 'mobile', 'concat_mobile', 'email_alias', 'email', 'password', 'ga_key', 'private_key', 'secret', 'secret_key', 'key_snow', 'telephone', 'username', 'real_name', 'token', 'two_factor_secret', 'content', ]; const DB_DEFAULT_PORTS: Record = { mysql: 3306, postgres: 5432, redis: 6379, mongo: 27017, }; const formSteps = [ { label: '类型选择', icon: Database }, { label: '基本信息', icon: Server }, { label: '连接信息', icon: Zap }, { label: '高级配置', icon: Activity }, ]; function CreateInstanceForm({ defaultType, onCancel, onCreated }: { defaultType: string; onCancel: () => void; onCreated: () => void; }) { const [currentStep, setCurrentStep] = useState(0); const [dbType, setDbType] = useState(defaultType); const [name, setName] = useState(''); const [host, setHost] = useState(''); 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 [redisDb, setRedisDb] = useState('0'); 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 [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 isRedis = dbType === 'redis'; const needUsername = !isRedis; const needDatabase = !isRedis; const finalEnv = env === '__custom__' ? customEnv.trim() : env; const canSubmit = testResult?.success === true; const handleTypeChange = useCallback((t: string) => { setDbType(t); setPort(String(DB_DEFAULT_PORTS[t] || 3306)); setTestResult(null); if (t === 'redis') { setUsername(''); setDatabase(''); } }, []); const handleSubmit = useCallback(async () => { 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: username || 'default', password: password || 'default', database: database || undefined, env: (finalEnv || 'development') as string, 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 parts = line.split(':'); return { table: parts[0]?.trim() || '', field: parts[1]?.trim() || parts[0]?.trim() || '', rule: parts[2]?.trim() || 'mask' }; }) : undefined, }); onCreated(); } catch (e: unknown) { const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '创建失败'; setError(msg); } finally { setSubmitting(false); } }, [name, host, port, dbType, username, password, database, finalEnv, accessMode, excludedDbs, maskFields, needUsername, onCreated]); const handleTestConnection = useCallback(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 string, access_mode: accessMode, }); setTestResult(res as { success: boolean; message: string; version?: string }); } catch (e: unknown) { const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '连接失败'; setTestResult({ success: false, message: msg }); } finally { setTesting(false); } }, [host, port, dbType, username, password, database, finalEnv, accessMode, needUsername, name]); const validateStep = useCallback((step: number): boolean => { setError(''); if (step === 1) { if (!name) { setError('请填写实例名称'); return false; } } if (step === 2) { if (!host || !port) { setError('请填写地址和端口'); return false; } if (needUsername && !username) { setError('请填写用户名'); return false; } } return true; }, [name, host, port, needUsername, username]); const goNext = useCallback(() => { if (validateStep(currentStep)) { setCurrentStep(s => Math.min(s + 1, formSteps.length - 1)); } }, [currentStep, validateStep]); const goPrev = useCallback(() => { setError(''); setCurrentStep(s => Math.max(s - 1, 0)); }, []); 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 transition-colors'; const labelCls = 'block text-sm font-medium mb-1.5'; return (
e.stopPropagation()} > {/* Header */}

添加数据源

步骤 {currentStep + 1} / {formSteps.length}: {formSteps[currentStep].label}

{/* Step Indicator */}
{formSteps.map((step, idx) => (
{idx < formSteps.length - 1 && ( )}
))}
{/* Step 0: Type Selection */} {currentStep === 0 && (

选择数据源类型

{[ { value: 'mysql', label: 'MySQL', Icon: MySQLIcon }, { value: 'postgres', label: 'PostgreSQL', Icon: PostgreSQLIcon }, { value: 'redis', label: 'Redis', Icon: RedisIcon }, { value: 'mongo', label: 'MongoDB', Icon: MongoDBIcon }, ].map(t => ( ))}
)} {/* Step 1: Basic Info */} {currentStep === 1 && (

基本信息

{ setName(e.target.value); setTestResult(null); }} className={inputCls} placeholder="例如:prod-mysql-只读库" />
{env === '__custom__' && ( setCustomEnv(e.target.value)} className={inputCls} placeholder="输入自定义环境名" /> )}
{isRedis && (

Redis 实例无需用户名,密码可选

)}
)} {/* Step 2: Connection Info */} {currentStep === 2 && (

连接信息

{ 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" />
)}
{ setPassword(e.target.value); setTestResult(null); }} className={inputCls + ' pr-10'} placeholder={isRedis ? '可选,Redis 无密码可留空' : '数据库密码'} />
{needDatabase ? (
{ setDatabase(e.target.value); setTestResult(null); }} className={inputCls} placeholder="可选,留空则连接默认库" />
) : (
setRedisDb(e.target.value)} className={inputCls} placeholder="默认 0" />
)}
{/* Connection Mode */}
{[ { value: 'readwrite' as const, label: '读写' }, { value: 'write' as const, label: '写' }, { value: 'read' as const, label: '读' }, ].map(m => ( ))}
{/* Test Result */} {testResult && (
{testResult.success ? <> 连接成功{testResult.version ? ` · ${testResult.version}` : ''} : <> {testResult.message} }
)}
)} {/* Step 3: Advanced Config */} {currentStep === 3 && (

高级配置

{!isRedis && (
setExcludedDbs(e.target.value)} className={inputCls} placeholder="以逗号分隔,如:sys, information_schema, mysql" />
)}
{PRESET_MASK_FIELDS.map(f => { const isSelected = maskFields.split(/[\n,:]/).some(s => s.trim() === f); return ( ); })}