后端: - Ticket model 新增 ticket_no 字段(格式: YYYYMMDDHHmmss+ms+seq, 如20260522160543841) - create_ticket 自动生成工单编号(flush后生成) - _enrich_ticket 新增 instance_name, current_approver_name 字段 - current_approver: 从 ticket_approvals 查询第一个 pending 状态的审批步骤获取审核人 - TicketResponse schema 新增 ticket_no, instance_name, current_approver_name - 新增 /instances/accessible endpoint(按用户权限组过滤实例, 无admin bypass) - instance_group response_model 改为 InstanceGroupDetailResponse(含readonly/write实例) - instance_group_members 脏数据清理, 重建 group_id=2 正确数据 - notification 模板修复: \n→\n 换行, 钉钉 \n→\n\n 双换行 - test_notification endpoint 改为真正调用 NotificationSender.send() - auditor 角色添加到 audit_rule/ticket 只读端点 - seed_data() 创建4个默认用户(admin/zhangsan/laoliu/lurenjia) - SQLite ALTER TABLE 添加 ticket_no 列, 迁移现有6条工单数据 前端: - TicketsPage 完全重设计: 工单编号/实例名称/提交人/当前审核人/状态/操作 - 状态映射简化: pending/reviewing/approved→审核中, success→完成, failed→失败 - 执行详情弹框: 解析 execute_result JSON 展示状态/影响行数/耗时/验证/原始输出/错误信息 - 状态流程可视化(提交→审批→通过→执行→完成) - 快速筛选: 全部/待审批/执行中/完成/失败 (合并 pending+reviewing+approved 统计) - TicketCreatePage: 去掉数据库类型选择器, 用 accessible(write) 过滤变更实例 - QueriesPage: 用 accessible(readonly) 过滤查询实例 - instance.ts 新增 accessible 方法 - 分页优化: 动态计算页码范围(总页>5时中间滚动)
1453 lines
69 KiB
TypeScript
1453 lines
69 KiB
TypeScript
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<string, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
|
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<string, string> = {
|
|
production: '生产', staging: '预发', development: '开发', test: '测试', uat: 'UAT',
|
|
};
|
|
|
|
const envColor: Record<string, string> = {
|
|
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<string, { text: string; online: boolean }> = {
|
|
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<string>('all');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState('');
|
|
const [filterEnv, setFilterEnv] = useState('');
|
|
const [selectedInstance, setSelectedInstance] = useState<InstanceItem | null>(null);
|
|
const [view, setView] = useState<'grid' | 'detail'>('grid');
|
|
const [layoutMode, setLayoutMode] = useState<ViewMode>('grid');
|
|
const [instances, setInstances] = useState<InstanceItem[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [showCreate, setShowCreate] = useState(false);
|
|
const [showEdit, setShowEdit] = useState(false);
|
|
const [editingInstance, setEditingInstance] = useState<InstanceItem | null>(null);
|
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
|
const [batchDeleting, setBatchDeleting] = useState(false);
|
|
const [showClone, setShowClone] = useState(false);
|
|
const [cloneSource, setCloneSource] = useState<InstanceItem | null>(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 (
|
|
<CreateInstanceForm
|
|
defaultType={activeType === 'all' ? 'mysql' : activeType}
|
|
onCancel={() => setShowCreate(false)}
|
|
onCreated={() => { setShowCreate(false); fetchInstances(); }}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (showEdit && editingInstance) {
|
|
return (
|
|
<>
|
|
<InstanceDetail
|
|
instance={editingInstance}
|
|
onBack={() => { 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}
|
|
/>
|
|
<EditInstanceDialog
|
|
instance={editingInstance}
|
|
open={showEdit}
|
|
onClose={() => { setShowEdit(false); setEditingInstance(null); }}
|
|
onSaved={() => { setShowEdit(false); setEditingInstance(null); fetchInstances(); }}
|
|
/>
|
|
{cloneSource && showClone && (
|
|
<EditInstanceDialog
|
|
instance={{ ...cloneSource, name: `${cloneSource.name}-副本`, id: 0 }}
|
|
open={showClone}
|
|
onClose={() => { 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 (
|
|
<>
|
|
<InstanceDetail
|
|
instance={selectedInstance}
|
|
onBack={() => 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 && (
|
|
<EditInstanceDialog
|
|
instance={{ ...cloneSource, name: `${cloneSource.name}-副本`, id: 0 }}
|
|
open={showClone}
|
|
onClose={() => { setShowClone(false); setCloneSource(null); }}
|
|
onSaved={() => { setShowClone(false); setCloneSource(null); fetchInstances(); }}
|
|
mode="clone"
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<PageHeader
|
|
title="数据源管理"
|
|
description="管理所有数据库实例,监控连接状态与性能指标"
|
|
secondaryAction={{ label: '刷新', icon: RefreshCw, onClick: fetchInstances }}
|
|
action={canAdd ? { label: '添加实例', icon: Plus, onClick: () => setShowCreate(true), permission: 'instance.add' } : undefined}
|
|
/>
|
|
|
|
{/* Type Tabs */}
|
|
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
|
|
{typeTabs.map(tab => (
|
|
<button
|
|
key={tab.key}
|
|
onClick={() => { setActiveType(tab.key); setSelectedIds(new Set()); }}
|
|
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-md text-sm font-medium transition-all ${
|
|
activeType === tab.key
|
|
? 'bg-blue-600 text-white shadow-sm'
|
|
: 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
|
|
}`}
|
|
>
|
|
<tab.icon className="w-3.5 h-3.5" />
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg">
|
|
<button
|
|
onClick={() => setLayoutMode('grid')}
|
|
className={`p-1.5 rounded-md transition-colors ${layoutMode === 'grid' ? 'bg-[var(--dg-bg-primary)] text-blue-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}
|
|
>
|
|
<LayoutGrid className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setLayoutMode('list')}
|
|
className={`p-1.5 rounded-md transition-colors ${layoutMode === 'list' ? 'bg-[var(--dg-bg-primary)] text-blue-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}
|
|
>
|
|
<ListFilter className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status Overview */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
<Card className="dg-card">
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-green-500/10 flex items-center justify-center">
|
|
<Wifi className="w-5 h-5 text-green-400" />
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold">{statusCounts.online}</div>
|
|
<div className="text-xs text-[var(--dg-text-muted)]">在线实例</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="dg-card">
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-red-500/10 flex items-center justify-center">
|
|
<WifiOff className="w-5 h-5 text-red-400" />
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold">{statusCounts.offline}</div>
|
|
<div className="text-xs text-[var(--dg-text-muted)]">离线实例</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="dg-card">
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
|
|
<Server className="w-5 h-5 text-blue-400" />
|
|
</div>
|
|
<div>
|
|
<div className="text-2xl font-bold">{statusCounts.total}</div>
|
|
<div className="text-xs text-[var(--dg-text-muted)]">总实例数</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card className="dg-card">
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className={`w-10 h-10 rounded-lg ${healthBg(avgHealth)} flex items-center justify-center`}>
|
|
<Gauge className={`w-5 h-5 ${healthColor(avgHealth)}`} />
|
|
</div>
|
|
<div>
|
|
<div className={`text-2xl font-bold ${healthColor(avgHealth)}`}>{avgHealth}</div>
|
|
<div className="text-xs text-[var(--dg-text-muted)]">平均健康分</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex items-center gap-3 p-4 dg-card flex-wrap">
|
|
<div className="relative flex-1 max-w-xs">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[var(--dg-text-muted)]" />
|
|
<input
|
|
className="w-full pl-10 pr-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm placeholder:text-[var(--dg-text-muted)] focus:border-blue-400 focus:outline-none transition-colors"
|
|
placeholder="搜索实例名称/地址"
|
|
value={searchQuery}
|
|
onChange={e => setSearchQuery(e.target.value)}
|
|
/>
|
|
</div>
|
|
<select
|
|
className="px-3 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"
|
|
value={filterStatus}
|
|
onChange={e => setFilterStatus(e.target.value)}
|
|
>
|
|
<option value="">全部状态</option>
|
|
<option value="online">在线</option>
|
|
<option value="offline">离线</option>
|
|
<option value="error">异常</option>
|
|
</select>
|
|
<select
|
|
className="px-3 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"
|
|
value={filterEnv}
|
|
onChange={e => setFilterEnv(e.target.value)}
|
|
>
|
|
<option value="">全部环境</option>
|
|
<option value="production">生产</option>
|
|
<option value="staging">预发</option>
|
|
<option value="development">开发</option>
|
|
<option value="test">测试</option>
|
|
<option value="uat">UAT</option>
|
|
</select>
|
|
{(searchQuery || filterStatus || filterEnv) && (
|
|
<button
|
|
onClick={() => { setSearchQuery(''); setFilterStatus(''); setFilterEnv(''); }}
|
|
className="flex items-center gap-1 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
清除筛选
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Batch Operations */}
|
|
{selectedIds.size > 0 && (
|
|
<div className="flex items-center justify-between p-3 bg-blue-500/5 border border-blue-500/20 rounded-lg">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm text-blue-400 font-medium">已选择 {selectedIds.size} 个实例</span>
|
|
<button
|
|
onClick={selectAll}
|
|
className="text-xs text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
|
>
|
|
{selectedIds.size === filtered.length ? '取消全选' : '全选'}
|
|
</button>
|
|
</div>
|
|
{canDelete && (
|
|
<button
|
|
onClick={handleBatchDelete}
|
|
disabled={batchDeleting}
|
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium bg-red-500/15 text-red-400 border border-red-500/30 rounded-md hover:bg-red-500/25 transition-all disabled:opacity-50"
|
|
>
|
|
{batchDeleting ? <Loader2 className="w-3 h-3 animate-spin" /> : <Trash2 className="w-3 h-3" />}
|
|
{batchDeleting ? '删除中...' : '批量删除'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Instance Display */}
|
|
{loading && instances.length === 0 ? (
|
|
<div className="flex items-center justify-center p-10">
|
|
<Loader2 className="w-6 h-6 animate-spin text-blue-400 mr-2" />
|
|
<span className="text-sm text-[var(--dg-text-muted)]">加载中...</span>
|
|
</div>
|
|
) : filtered.length === 0 ? (
|
|
<EmptyState type="search" />
|
|
) : layoutMode === 'grid' ? (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
{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 (
|
|
<div
|
|
key={instance.id}
|
|
className={`dg-card p-3.5 cursor-pointer dg-card-hover group relative transition-all ${
|
|
isSelected ? 'ring-2 ring-blue-500/50' : ''
|
|
}`}
|
|
onClick={() => { setSelectedInstance(instance); setView('detail'); }}
|
|
>
|
|
{/* Selection checkbox */}
|
|
<div
|
|
className="absolute top-3 left-3 z-10"
|
|
onClick={e => { e.stopPropagation(); toggleSelect(instance.id); }}
|
|
>
|
|
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-all ${
|
|
isSelected ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)] hover:border-blue-400'
|
|
}`}>
|
|
{isSelected && <CheckCircle className="w-3 h-3 text-white" />}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-start justify-between mb-4 pl-6">
|
|
<div className={`w-9 h-9 rounded-lg ${ic.bg} flex items-center justify-center`}>
|
|
<IconComp className="w-5 h-5 object-contain" />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className={`text-[11px] px-2 py-0.5 rounded-md border font-medium ${envColor[instance.env] || 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)]'}`}>
|
|
{envLabel[instance.env] || instance.env}
|
|
</span>
|
|
<div className={`flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium ${
|
|
info.online ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'
|
|
}`}>
|
|
{info.online ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
|
{info.text}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h3 className="text-sm font-semibold mb-0.5 truncate">{instance.name}</h3>
|
|
<p className="text-[11px] text-[var(--dg-text-muted)] mb-0.5">{instance.host}:{instance.port}</p>
|
|
<p className="text-[11px] text-[var(--dg-text-muted)] mb-2">
|
|
{ic.label} {instance.version || ''}
|
|
</p>
|
|
|
|
{/* Health Score */}
|
|
<div className="flex items-center gap-2 mb-2 p-1.5 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
|
<Gauge className={`w-4 h-4 ${healthColor(health)}`} />
|
|
<div className="flex-1">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-[10px] text-[var(--dg-text-muted)]">健康评分</span>
|
|
<span className={`text-xs font-bold ${healthColor(health)}`}>{health} <span className="font-normal text-[var(--dg-text-muted)]">{healthLabel(health)}</span></span>
|
|
</div>
|
|
<div className="h-1.5 rounded-full bg-[var(--dg-bg-primary)] mt-1 overflow-hidden">
|
|
<div className={`h-full rounded-full transition-all duration-500 ${
|
|
health >= 90 ? 'bg-green-400' : health >= 70 ? 'bg-yellow-400' : health >= 50 ? 'bg-orange-400' : 'bg-red-400'
|
|
}`} style={{ width: `${health}%` }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Metrics */}
|
|
<div className="grid grid-cols-3 gap-1.5 text-center">
|
|
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Users className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
|
<span className="text-[11px] font-semibold">{instance.connections}</span>
|
|
</div>
|
|
<span className="text-[9px] text-[var(--dg-text-muted)]">连接</span>
|
|
</div>
|
|
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Activity className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
|
<span className="text-[11px] font-semibold">{instance.qps ? instance.qps.toLocaleString() : '-'}</span>
|
|
</div>
|
|
<span className="text-[9px] text-[var(--dg-text-muted)]">QPS</span>
|
|
</div>
|
|
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<Signal className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
|
<span className="text-[11px] font-semibold">{instance.latency}ms</span>
|
|
</div>
|
|
<span className="text-[9px] text-[var(--dg-text-muted)]">延迟</span>
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
<div className="mt-2 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-between">
|
|
<span className="text-[11px] text-blue-400 flex items-center gap-1">
|
|
查看详情
|
|
<ChevronRight className="w-3 h-3" />
|
|
</span>
|
|
<div className="flex items-center gap-1" onClick={e => e.stopPropagation()}>
|
|
{canAdd && (
|
|
<button
|
|
onClick={() => {
|
|
setCloneSource(instance);
|
|
setShowClone(true);
|
|
}}
|
|
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
|
title="克隆实例"
|
|
>
|
|
<Copy className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
{canEdit && (
|
|
<button
|
|
onClick={() => { setEditingInstance(instance); setShowEdit(true); }}
|
|
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
|
title="编辑实例"
|
|
>
|
|
<Edit2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
/* List Mode */
|
|
<Card className="dg-card">
|
|
<CardContent className="p-0">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
<th className="text-left px-4 py-3 w-8">
|
|
<div
|
|
onClick={selectAll}
|
|
className={`w-4 h-4 rounded border-2 flex items-center justify-center cursor-pointer transition-all ${
|
|
selectedIds.size === filtered.length && filtered.length > 0 ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)] hover:border-blue-400'
|
|
}`}
|
|
>
|
|
{selectedIds.size === filtered.length && filtered.length > 0 && <CheckCircle className="w-3 h-3 text-white" />}
|
|
</div>
|
|
</th>
|
|
<th className="text-left px-4 py-3 font-semibold">实例名称</th>
|
|
<th className="text-left px-4 py-3 font-semibold">类型</th>
|
|
<th className="text-left px-4 py-3 font-semibold">地址</th>
|
|
<th className="text-left px-4 py-3 font-semibold">环境</th>
|
|
<th className="text-left px-4 py-3 font-semibold">状态</th>
|
|
<th className="text-left px-4 py-3 font-semibold">健康分</th>
|
|
<th className="text-left px-4 py-3 font-semibold">连接/QPS</th>
|
|
<th className="text-left px-4 py-3 font-semibold">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{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 (
|
|
<tr
|
|
key={instance.id}
|
|
className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer"
|
|
onClick={() => { setSelectedInstance(instance); setView('detail'); }}
|
|
>
|
|
<td className="px-4 py-3" onClick={e => { e.stopPropagation(); toggleSelect(instance.id); }}>
|
|
<div className={`w-4 h-4 rounded border-2 flex items-center justify-center transition-all ${
|
|
isSelected ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-border)]'
|
|
}`}>
|
|
{isSelected && <CheckCircle className="w-3 h-3 text-white" />}
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3 font-medium">{instance.name}</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`flex items-center gap-1.5 ${ic.color}`}>
|
|
<IconComp className="w-4 h-4 object-contain" />
|
|
<span className="text-xs">{ic.label}</span>
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3 text-[var(--dg-text-muted)]">{instance.host}:{instance.port}</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`text-[10px] px-2 py-0.5 rounded-md border font-medium ${envColor[instance.env] || ''}`}>
|
|
{envLabel[instance.env] || instance.env}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`inline-flex items-center gap-1 text-xs font-medium ${info.online ? 'text-green-400' : 'text-red-400'}`}>
|
|
{info.online ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
|
{info.text}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`text-xs font-bold ${healthColor(health)}`}>{health}</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
|
|
<span>{instance.connections} 连接</span>
|
|
<span>{instance.qps?.toLocaleString()} QPS</span>
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={e => { e.stopPropagation(); setSelectedInstance(instance); setView('detail'); }}
|
|
className="text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors"
|
|
>
|
|
查看
|
|
</button>
|
|
{canAdd && (
|
|
<button
|
|
onClick={e => { e.stopPropagation(); setCloneSource(instance); setShowClone(true); }}
|
|
className="text-[var(--dg-text-muted)] hover:text-blue-400 transition-colors"
|
|
title="克隆实例"
|
|
>
|
|
<Copy className="w-3.5 h-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Clone Dialog */}
|
|
{cloneSource && showClone && (
|
|
<EditInstanceDialog
|
|
instance={{ ...cloneSource, name: `${cloneSource.name}-副本`, id: 0 }}
|
|
open={showClone}
|
|
onClose={() => { setShowClone(false); setCloneSource(null); }}
|
|
onSaved={() => { setShowClone(false); setCloneSource(null); fetchInstances(); }}
|
|
mode="clone"
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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 (
|
|
<div className="space-y-5">
|
|
{/* Header */}
|
|
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={onBack}
|
|
className="w-9 h-9 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] flex items-center justify-center text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
<div className="flex items-center gap-3">
|
|
<div className={`w-10 h-10 rounded-xl ${config.bg} flex items-center justify-center`}>
|
|
<DetailIcon className={`w-5 h-5 ${config.color}`} />
|
|
</div>
|
|
<div>
|
|
<h1 className="text-xl font-semibold flex items-center gap-3">
|
|
{instance.name}
|
|
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${info.online ? 'bg-green-500/15 text-green-400' : 'bg-red-500/15 text-red-400'}`}>
|
|
{info.online ? <Wifi className="w-3 h-3" /> : <WifiOff className="w-3 h-3" />}
|
|
{info.text}
|
|
</span>
|
|
</h1>
|
|
<p className="text-sm text-[var(--dg-text-secondary)]">{instance.host}:{instance.port} · {instance.version || '版本未知'}</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={handleTest}
|
|
disabled={testing}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-blue-500/15 border border-blue-500/30 rounded-lg text-sm font-medium text-blue-400 hover:bg-blue-500/25 transition-all disabled:opacity-50"
|
|
>
|
|
<Zap className="w-4 h-4" />
|
|
{testing ? '测试中...' : '测试连接'}
|
|
</button>
|
|
{onClone && (
|
|
<button onClick={onClone} className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
|
|
<Copy className="w-4 h-4" />
|
|
克隆
|
|
</button>
|
|
)}
|
|
{canEdit && (
|
|
<button onClick={onEdit} className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
|
|
<Edit2 className="w-4 h-4" />
|
|
编辑
|
|
</button>
|
|
)}
|
|
{canDelete && (
|
|
<button
|
|
onClick={onDelete}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-red-400 hover:bg-red-500/10 hover:border-red-500/30 transition-all"
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
删除
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Connection Test Result */}
|
|
{testResult && (
|
|
<Card className={`dg-card ${testResult.success ? 'border-green-500/30' : 'border-red-500/30'}`}>
|
|
<CardContent className="p-4 flex items-center gap-3">
|
|
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${testResult.success ? 'bg-green-500/15' : 'bg-red-500/15'}`}>
|
|
{testResult.success ? <CheckCircle className="w-4 h-4 text-green-400" /> : <XCircle className="w-4 h-4 text-red-400" />}
|
|
</div>
|
|
<div>
|
|
<div className={`text-sm font-medium ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>{testResult.message}</div>
|
|
{testResult.version && <div className="text-xs text-[var(--dg-text-muted)]">版本: {testResult.version}</div>}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Health + Metrics */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
|
{/* Health Score Card */}
|
|
<Card className="dg-card">
|
|
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
|
<Gauge className={`w-4 h-4 ${healthColor(health)}`} />
|
|
健康评分
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-5">
|
|
<div className="flex items-center justify-center py-4">
|
|
<div className="relative w-28 h-28">
|
|
<svg className="w-28 h-28 -rotate-90" viewBox="0 0 100 100">
|
|
<circle cx="50" cy="50" r="42" fill="none" stroke="var(--dg-bg-tertiary)" strokeWidth="8" />
|
|
<circle
|
|
cx="50" cy="50" r="42" fill="none"
|
|
stroke={health >= 90 ? '#4ade80' : health >= 70 ? '#facc15' : health >= 50 ? '#fb923c' : '#f87171'}
|
|
strokeWidth="8"
|
|
strokeLinecap="round"
|
|
strokeDasharray={`${(health / 100) * 264} 264`}
|
|
className="transition-all duration-700"
|
|
/>
|
|
</svg>
|
|
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
|
<span className={`text-2xl font-bold ${healthColor(health)}`}>{health}</span>
|
|
<span className="text-[10px] text-[var(--dg-text-muted)]">{healthLabel(health)}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{[
|
|
{ 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 => (
|
|
<div key={item.label}>
|
|
<div className="flex justify-between text-xs mb-1">
|
|
<span className="text-[var(--dg-text-muted)]">{item.label}</span>
|
|
<span className="font-medium">{item.value}%</span>
|
|
</div>
|
|
<div className="h-1.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
|
<div className={`h-full rounded-full transition-all duration-500 ${item.color}`} style={{ width: `${item.value}%` }} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Performance Metrics */}
|
|
<Card className="dg-card lg:col-span-2">
|
|
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
|
<BarChart3 className="w-4 h-4 text-blue-400" />
|
|
性能指标
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-5">
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
|
{[
|
|
{ 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 => (
|
|
<div key={m.label} className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] text-center">
|
|
<div className={`w-8 h-8 rounded-lg ${m.bg} flex items-center justify-center mx-auto mb-2`}>
|
|
<m.icon className={`w-4 h-4 ${m.color}`} />
|
|
</div>
|
|
<div className="text-lg font-bold">
|
|
{typeof m.value === 'number' ? m.value.toLocaleString() : m.value}
|
|
<span className="text-xs font-normal text-[var(--dg-text-muted)]">{m.unit}</span>
|
|
</div>
|
|
<div className="text-[10px] text-[var(--dg-text-muted)]">{m.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{/* Connection Info */}
|
|
<Card className="border border-[var(--dg-border)]">
|
|
<CardHeader className="border-b border-[var(--dg-border-light)] py-3">
|
|
<CardTitle className="text-sm font-semibold">连接信息</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4 space-y-2">
|
|
{[
|
|
{ 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 => (
|
|
<div key={item.label} className="flex justify-between py-1.5 border-b border-[var(--dg-border-light)] last:border-0">
|
|
<span className="text-xs text-[var(--dg-text-secondary)]">{item.label}</span>
|
|
<span className="text-xs font-medium">{item.value}</span>
|
|
</div>
|
|
))}
|
|
</CardContent>
|
|
</Card>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{instance.description && (
|
|
<Card className="dg-card">
|
|
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
|
<CardTitle className="text-sm font-semibold">描述</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="p-4">
|
|
<p className="text-sm text-[var(--dg-text-secondary)]">{instance.description}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
function MySQLIcon({ className }: { className?: string }) {
|
|
return <img src="/db-logos/mysql.svg" alt="MySQL" className={className} />;
|
|
}
|
|
function PostgreSQLIcon({ className }: { className?: string }) {
|
|
return <img src="/db-logos/postgres.svg" alt="PostgreSQL" className={className} />;
|
|
}
|
|
function RedisIcon({ className }: { className?: string }) {
|
|
return <img src="/db-logos/redis.svg" alt="Redis" className={className} />;
|
|
}
|
|
function MongoDBIcon({ className }: { className?: string }) {
|
|
return <img src="/db-logos/mongo.svg" alt="MongoDB" className={className} />;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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<string, number> = {
|
|
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 (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
|
<div
|
|
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto"
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div className="sticky top-0 z-10 flex items-center justify-between px-6 py-4 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)]">
|
|
<div>
|
|
<h2 className="text-lg font-semibold">添加数据源</h2>
|
|
<p className="text-xs text-[var(--dg-text-muted)] mt-0.5">步骤 {currentStep + 1} / {formSteps.length}: {formSteps[currentStep].label}</p>
|
|
</div>
|
|
<button onClick={onCancel} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors">
|
|
<XCircle className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Step Indicator */}
|
|
<div className="px-6 pt-4">
|
|
<div className="flex items-center gap-2">
|
|
{formSteps.map((step, idx) => (
|
|
<div key={idx} className="flex items-center gap-2 flex-1">
|
|
<button
|
|
onClick={() => { if (idx < currentStep) setCurrentStep(idx); }}
|
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-all flex-1 justify-center ${
|
|
idx === currentStep ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' :
|
|
idx < currentStep ? 'bg-green-500/10 text-green-400 border border-green-500/20 cursor-pointer' :
|
|
'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border border-[var(--dg-border)]'
|
|
}`}
|
|
>
|
|
<step.icon className="w-3.5 h-3.5" />
|
|
{step.label}
|
|
</button>
|
|
{idx < formSteps.length - 1 && (
|
|
<ChevronRight className={`w-3 h-3 flex-shrink-0 ${idx < currentStep ? 'text-green-400' : 'text-[var(--dg-text-muted)]'}`} />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-6 space-y-4">
|
|
{/* Step 0: Type Selection */}
|
|
{currentStep === 0 && (
|
|
<Card className="dg-card">
|
|
<CardContent className="p-5 space-y-4">
|
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">选择数据源类型</h3>
|
|
<div className="grid grid-cols-4 gap-3">
|
|
{[
|
|
{ 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 => (
|
|
<button
|
|
key={t.value}
|
|
type="button"
|
|
onClick={() => handleTypeChange(t.value)}
|
|
className={`flex flex-col items-center gap-2 p-4 rounded-lg border-2 transition-all ${
|
|
dbType === t.value ? 'border-blue-500 bg-blue-500/5' : 'border-[var(--dg-border)] hover:border-[var(--dg-text-muted)]'
|
|
}`}
|
|
>
|
|
<t.Icon className="w-10 h-10 object-contain" />
|
|
<span className="text-xs font-medium">{t.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Step 1: Basic Info */}
|
|
{currentStep === 1 && (
|
|
<Card className="dg-card">
|
|
<CardContent className="p-5 space-y-4">
|
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">基本信息</h3>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelCls}>名称 <span className="text-red-400">*</span></label>
|
|
<input
|
|
value={name}
|
|
onChange={e => { setName(e.target.value); setTestResult(null); }}
|
|
className={inputCls}
|
|
placeholder="例如:prod-mysql-只读库"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>环境 <span className="text-red-400">*</span></label>
|
|
<div className="relative">
|
|
<select
|
|
value={env}
|
|
onChange={e => setEnv(e.target.value)}
|
|
className={inputCls + (env === '__custom__' ? ' mb-2' : '')}
|
|
>
|
|
<option value="development">开发</option>
|
|
<option value="staging">预发</option>
|
|
<option value="production">生产</option>
|
|
<option value="test">测试</option>
|
|
<option value="uat">UAT</option>
|
|
<option value="__custom__">自定义...</option>
|
|
</select>
|
|
{env === '__custom__' && (
|
|
<input
|
|
autoFocus
|
|
value={customEnv}
|
|
onChange={e => setCustomEnv(e.target.value)}
|
|
className={inputCls}
|
|
placeholder="输入自定义环境名"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{isRedis && (
|
|
<div className="p-3 rounded-lg bg-yellow-500/5 border border-yellow-500/20">
|
|
<p className="text-xs text-yellow-400 flex items-center gap-1.5">
|
|
<AlertTriangle className="w-3.5 h-3.5" />
|
|
Redis 实例无需用户名,密码可选
|
|
</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Step 2: Connection Info */}
|
|
{currentStep === 2 && (
|
|
<Card className="dg-card">
|
|
<CardContent className="p-5 space-y-4">
|
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">连接信息</h3>
|
|
<div className={isRedis ? 'grid grid-cols-2 gap-4' : 'grid grid-cols-3 gap-4'}>
|
|
<div>
|
|
<label className={labelCls}>地址 <span className="text-red-400">*</span></label>
|
|
<input
|
|
value={host}
|
|
onChange={e => { setHost(e.target.value); setTestResult(null); }}
|
|
className={inputCls}
|
|
placeholder="例如:mysql.example.com"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className={labelCls}>端口 <span className="text-red-400">*</span></label>
|
|
<input
|
|
value={port}
|
|
onChange={e => { setPort(e.target.value); setTestResult(null); }}
|
|
className={inputCls}
|
|
placeholder={String(DB_DEFAULT_PORTS[dbType] || 3306)}
|
|
/>
|
|
</div>
|
|
{!isRedis && (
|
|
<div>
|
|
<label className={labelCls}>用户名 <span className="text-red-400">*</span></label>
|
|
<input
|
|
value={username}
|
|
onChange={e => { setUsername(e.target.value); setTestResult(null); }}
|
|
className={inputCls}
|
|
placeholder="root"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className={labelCls}>{isRedis ? '密码' : '密码'} <span className={isRedis ? 'text-[var(--dg-text-muted)]' : 'text-red-400'}>{isRedis ? '' : '*'}</span></label>
|
|
<div className="relative">
|
|
<input
|
|
type={showPwd ? 'text' : 'password'}
|
|
value={password}
|
|
onChange={e => { setPassword(e.target.value); setTestResult(null); }}
|
|
className={inputCls + ' pr-10'}
|
|
placeholder={isRedis ? '可选,Redis 无密码可留空' : '数据库密码'}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPwd(!showPwd)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
|
|
>
|
|
{showPwd ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{needDatabase ? (
|
|
<div>
|
|
<label className={labelCls}>默认数据库</label>
|
|
<input
|
|
value={database}
|
|
onChange={e => { setDatabase(e.target.value); setTestResult(null); }}
|
|
className={inputCls}
|
|
placeholder="可选,留空则连接默认库"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div>
|
|
<label className={labelCls}>数据库编号</label>
|
|
<input
|
|
value={redisDb}
|
|
onChange={e => setRedisDb(e.target.value)}
|
|
className={inputCls}
|
|
placeholder="默认 0"
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Connection Mode */}
|
|
<div>
|
|
<label className={labelCls}>连接模式</label>
|
|
<div className="flex gap-3">
|
|
{[
|
|
{ value: 'readwrite' as const, label: '读写' },
|
|
{ value: 'write' as const, label: '写' },
|
|
{ value: 'read' as const, label: '读' },
|
|
].map(m => (
|
|
<button
|
|
key={m.value}
|
|
type="button"
|
|
onClick={() => setAccessMode(m.value)}
|
|
className={`px-5 py-2 rounded-lg text-sm font-medium transition-all ${
|
|
accessMode === m.value
|
|
? 'bg-blue-500/15 text-blue-400 border border-blue-500/30'
|
|
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] border border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]'
|
|
}`}
|
|
>
|
|
{m.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* Test Result */}
|
|
{testResult && (
|
|
<div className={`flex items-center gap-3 p-3 rounded-lg text-sm ${testResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
|
|
{testResult.success
|
|
? <><CheckCircle className="w-4 h-4 flex-shrink-0" /> 连接成功{testResult.version ? ` · ${testResult.version}` : ''}</>
|
|
: <><XCircle className="w-4 h-4 flex-shrink-0" /> {testResult.message}</>
|
|
}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Step 3: Advanced Config */}
|
|
{currentStep === 3 && (
|
|
<Card className="dg-card">
|
|
<CardContent className="p-5 space-y-4">
|
|
<h3 className="text-sm font-semibold text-[var(--dg-text-secondary)]">高级配置</h3>
|
|
{!isRedis && (
|
|
<div>
|
|
<label className={labelCls}>排除的数据库</label>
|
|
<input
|
|
value={excludedDbs}
|
|
onChange={e => setExcludedDbs(e.target.value)}
|
|
className={inputCls}
|
|
placeholder="以逗号分隔,如:sys, information_schema, mysql"
|
|
/>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<label className={labelCls}>脱敏字段</label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(PRESET_MASK_FIELDS.join(', ')).then(() => {
|
|
const btn = document.getElementById('copy-mask-btn');
|
|
if (btn) {
|
|
btn.textContent = '已复制';
|
|
setTimeout(() => { if (btn) btn.textContent = '复制全部'; }, 1500);
|
|
}
|
|
});
|
|
}}
|
|
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)]"
|
|
>
|
|
复制全部
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
const newFields = PRESET_MASK_FIELDS.map(f => f + ':mask').join('\n');
|
|
setMaskFields(maskFields ? maskFields + '\n' + newFields : newFields);
|
|
}}
|
|
className="text-xs text-blue-400 hover:text-blue-300 transition-colors px-2 py-0.5 rounded bg-blue-500/10"
|
|
>
|
|
一键填入
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5 mb-2">
|
|
{PRESET_MASK_FIELDS.map(f => {
|
|
const isSelected = maskFields.split(/[\n,:]/).some(s => s.trim() === f);
|
|
return (
|
|
<button
|
|
key={f}
|
|
type="button"
|
|
onClick={() => {
|
|
if (isSelected) {
|
|
setMaskFields(maskFields.split('\n').filter(line => {
|
|
return !line.split(':')[0]?.trim()?.includes(f);
|
|
}).join('\n'));
|
|
} else {
|
|
setMaskFields(maskFields ? maskFields + '\n' + f + ':mask' : f + ':mask');
|
|
}
|
|
}}
|
|
className={`px-2 py-0.5 rounded text-xs transition-colors ${
|
|
isSelected ? 'bg-blue-500/20 text-blue-400' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] hover:bg-[var(--dg-bg-hover)]'
|
|
}`}
|
|
>
|
|
{f}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
<textarea
|
|
value={maskFields}
|
|
onChange={e => setMaskFields(e.target.value)}
|
|
className={inputCls + ' min-h-[80px] resize-y font-mono text-xs'}
|
|
placeholder={'每行一个,格式:字段名:规则\n点击上方标签快速添加\n例如:\nmobile:mask\nemail:mask\npassword:hide'}
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{error && <div className="text-sm text-red-400 flex items-center gap-2"><AlertTriangle className="w-4 h-4" />{error}</div>}
|
|
|
|
{/* Navigation Buttons */}
|
|
<div className="flex items-center justify-between pt-2">
|
|
<button
|
|
onClick={handleTestConnection}
|
|
disabled={testing || !host || !port}
|
|
className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm font-medium text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] disabled:opacity-40 transition-all"
|
|
>
|
|
{testing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
|
|
{testing ? '测试中...' : '测试连接'}
|
|
</button>
|
|
<div className="flex gap-3">
|
|
{currentStep > 0 && (
|
|
<button onClick={goPrev} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
上一步
|
|
</button>
|
|
)}
|
|
{currentStep < formSteps.length - 1 ? (
|
|
<button onClick={goNext} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-sm text-white transition-colors">
|
|
下一步
|
|
</button>
|
|
) : (
|
|
<>
|
|
<button onClick={onCancel} className="px-4 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
取消
|
|
</button>
|
|
<button
|
|
onClick={handleSubmit}
|
|
disabled={submitting || !canSubmit}
|
|
className={`px-4 py-2 rounded-lg text-sm text-white transition-all ${
|
|
canSubmit ? 'bg-blue-600 hover:bg-blue-700' : 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] cursor-not-allowed'
|
|
}`}
|
|
>
|
|
{submitting ? '创建中...' : canSubmit ? '添加数据源' : '请先测试连接'}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|