This commit is contained in:
logan committed 2026-05-24 14:08:45 +08:00
1 parent c7fccf99c7
commit 8471f368eb
33 files changed
+444 -2095

No files matched your search

+2 -10
View File
@@ -7,7 +7,7 @@ import {
Filter, Eye, BarChart3,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/mock';
import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants';
import type { DBInstance } from '@/types';
import { useTheme } from '@/hooks/useTheme';
import { useAuth } from '@/hooks/useAuth';
@@ -159,14 +159,7 @@ export default function Dashboard() {
useEffect(() => {
dashboardApi.getStats()
.then(data => setStats(data))
.catch(() => {
/* fallback: mock data */
setStats({
ticket_total: 128, ticket_pending: 23, ticket_month: 342,
ticket_executing: 3, instance_total: 11, instance_online: 8,
user_total: 7, queries_today: 15234,
});
})
.catch(() => setStats(null))
.finally(() => setLoading(false));
}, []);
@@ -348,7 +341,6 @@ export default function Dashboard() {
{ 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: BarChart3, href: '/slow-query', color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20' },
];
const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => {
+2 -31
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, Database, Table2, Users, Clock, Cpu, Play, RefreshCw } from 'lucide-react';
import { ArrowLeft, Database, Table2, Users, Cpu, Play, RefreshCw } from 'lucide-react';
import { instanceDetailApi } from '@/api/instanceDetail';
import { instanceApi } from '@/api/instance';
import { queryApi } from '@/api/query';
@@ -10,7 +10,7 @@ interface InstanceInfo {
status: string; version: string | null; env: string;
}
type TabKey = 'overview' | 'tables' | 'query' | 'users' | 'slow' | 'process';
type TabKey = 'overview' | 'tables' | 'query' | 'users' | 'process';
export default function InstanceDetailPage() {
var params = useParams<{ id: string }>();
@@ -23,7 +23,6 @@ export default function InstanceDetailPage() {
var [selectedDb, setSelectedDb] = useState('');
var [tables, setTables] = useState<any[]>([]);
var [users, setUsers] = useState<any[]>([]);
var [slowQueries, setSlowQueries] = useState<any[]>([]);
var [processes, setProcesses] = useState<any[]>([]);
var [sqlText, setSqlText] = useState('');
var [queryResult, setQueryResult] = useState<any>(null);
@@ -34,7 +33,6 @@ export default function InstanceDetailPage() {
{ key: 'tables', label: '表管理', icon: Table2 },
{ key: 'query', label: 'SQL查询', icon: Play },
{ key: 'users', label: '用户权限', icon: Users },
{ key: 'slow', label: '慢查询', icon: Clock },
{ key: 'process', label: '进程列表', icon: Cpu },
];
@@ -57,11 +55,6 @@ export default function InstanceDetailPage() {
setUsers(res.users || res || []);
}).catch(function () { setUsers([]); });
}
if (activeTab === 'slow') {
instanceDetailApi.getSlowQueries(instanceId, selectedDb).then(function (res: any) {
setSlowQueries(res.queries || res || []);
}).catch(function () { setSlowQueries([]); });
}
if (activeTab === 'process') {
instanceDetailApi.getProcesslist(instanceId).then(function (res: any) {
setProcesses(res.processes || res || []);
@@ -236,28 +229,6 @@ export default function InstanceDetailPage() {
</div>
)}
{/* 慢查询 */}
{activeTab === 'slow' && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead><tr className="text-left text-[var(--dg-text-muted)] border-b border-[var(--dg-border)]">
<th className="px-4 py-2">SQL</th><th className="px-4 py-2">执行时间</th><th className="px-4 py-2">扫描行数</th><th className="px-4 py-2">时间</th>
</tr></thead>
<tbody>
{slowQueries.map(function (q: any, i: number) {
return <tr key={i} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-4 py-2 max-w-md truncate font-mono text-xs">{q.sql_text || q.SQL || '-'}</td>
<td className="px-4 py-2">{q.query_time || q.Time || '-'}</td>
<td className="px-4 py-2">{q.rows_examined || q.ScanRows || '-'}</td>
<td className="px-4 py-2">{q.start_time || q.Timestamp || '-'}</td>
</tr>;
})}
</tbody>
</table>
{slowQueries.length === 0 && <div className="text-center py-8 text-[var(--dg-text-muted)]">暂无慢查询记录</div>}
</div>
)}
{/* 进程列表 */}
{activeTab === 'process' && (
<div className="overflow-x-auto">
-1
View File
@@ -74,7 +74,6 @@ const PERMISSION_CATEGORIES: PermissionCategory[] = [
label: '管理权限',
items: [
{ code: 'instance:connect', name: '连接实例' },
{ code: 'instance:view_slow_query', name: '查看慢查询' },
{ code: 'instance:kill_process', name: '终止进程' },
],
},
+1 -1
View File
@@ -5,7 +5,7 @@ import {
CheckCircle2, MinusCircle, Sparkles, ChevronDown,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { permissionTreeData } from '@/lib/mock';
import { permissionTreeData } from '@/lib/constants';
/* ───────── Types ───────── */
-967
View File
@@ -1,967 +0,0 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
Search, Clock, Database, AlertTriangle, ChevronDown, ChevronRight,
BarChart3, Play, Loader2, RefreshCw, Download, Lightbulb, X,
GitCompare, XCircle, Skull, Zap, CheckCircle2,
} from 'lucide-react';
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid,
LineChart, Line, PieChart, Pie, Cell, Legend,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import PageHeader from '@/components/PageHeader';
import { instanceApi } from '@/api/instance';
import { slowLogApi } from '@/api/slowLog';
/* ───────── Types ───────── */
interface SlowLogItem {
id: string;
sql: string;
start_time: string;
query_time: number;
lock_time: number;
rows_sent: number;
rows_examined: number;
database: string;
user?: string;
host?: string;
}
interface TopQuery {
sql: string;
count: number;
avg_time_ms: number;
max_time_ms: number;
total_time_ms: number;
}
interface OptimizationSuggestion {
id: string;
level: 'critical' | 'warning' | 'info';
title: string;
description: string;
suggestion: string;
}
interface InstanceOption {
id: number;
name: string;
host: string;
db_type: string;
}
/* ───────── Helpers ───────── */
function formatQueryTime(seconds: number): string {
if (seconds >= 1) return `${seconds.toFixed(2)}s`;
return `${(seconds * 1000).toFixed(0)}ms`;
}
function queryTimeColor(seconds: number): string {
if (seconds > 5) return 'text-red-400';
if (seconds > 1) return 'text-orange-400';
if (seconds > 0.5) return 'text-yellow-400';
return 'text-[var(--dg-text-primary)]';
}
function queryTimeBg(seconds: number): string {
if (seconds > 5) return 'bg-red-500/10';
if (seconds > 1) return 'bg-orange-500/10';
if (seconds > 0.5) return 'bg-yellow-500/10';
return '';
}
function truncateSql(sql: string, max: number): string {
const oneLine = sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim();
if (oneLine.length <= max) return oneLine;
return oneLine.slice(0, max) + '\u2026';
}
function generateSuggestions(log: SlowLogItem): OptimizationSuggestion[] {
const suggestions: OptimizationSuggestion[] = [];
if (log.query_time > 3) {
suggestions.push({
id: 'slow-critical',
level: 'critical',
title: '查询耗时过长',
description: `查询耗时 ${formatQueryTime(log.query_time)},严重影响用户体验`,
suggestion: '考虑添加合适的索引,或优化查询条件以减少扫描行数。使用 EXPLAIN 分析查询计划。',
});
}
if (log.rows_examined > log.rows_sent * 1000) {
suggestions.push({
id: 'scan-ratio',
level: 'critical',
title: '扫描行数与返回行数比例过高',
description: `扫描了 ${log.rows_examined.toLocaleString()} 行,仅返回 ${log.rows_sent.toLocaleString()} 行`,
suggestion: 'WHERE 条件可能未命中索引,或缺少合适的索引。检查查询条件的字段是否已建立索引。',
});
}
if (log.lock_time > 0.5) {
suggestions.push({
id: 'lock-time',
level: 'warning',
title: '锁等待时间过长',
description: `锁等待时间 ${formatQueryTime(log.lock_time)},可能导致并发性能下降`,
suggestion: '检查是否存在长时间运行的事务。考虑使用更细粒度的锁或优化事务隔离级别。',
});
}
const lowerSql = log.sql.toLowerCase();
if (lowerSql.includes('select *')) {
suggestions.push({
id: 'select-star',
level: 'warning',
title: '使用 SELECT *',
description: '查询使用了 SELECT *,可能返回不必要的列',
suggestion: '明确指定需要的列名,减少数据传输量和内存占用。',
});
}
if (lowerSql.includes('order by') && !lowerSql.includes('index')) {
suggestions.push({
id: 'sort-filesort',
level: 'info',
title: 'ORDER BY 可能需要文件排序',
description: '使用 ORDER BY 但未利用索引排序',
suggestion: '考虑在 ORDER BY 字段上建立索引,避免 Using filesort。',
});
}
if (lowerSql.includes('like') && lowerSql.includes("'%" )) {
suggestions.push({
id: 'like-prefix',
level: 'info',
title: '前缀模糊查询无法使用索引',
description: 'LIKE 以 % 开头会导致全表扫描',
suggestion: '考虑使用全文索引(FULLTEXT),或使用 reverse() 函数优化右模糊查询。',
});
}
if (suggestions.length === 0) {
suggestions.push({
id: 'general',
level: 'info',
title: '一般性建议',
description: `查询执行时间 ${formatQueryTime(log.query_time)}`,
suggestion: '定期分析表(ANALYZE TABLE)以保持统计信息最新,确保查询优化器选择最优执行计划。',
});
}
return suggestions;
}
function exportSlowQueryReport(logs: SlowLogItem[], topQueries: TopQuery[]) {
const lines: string[] = [];
lines.push('# 慢查询分析报告');
lines.push(`生成时间: ${new Date().toLocaleString('zh-CN')}`);
lines.push(`慢查询数量: ${logs.length}`);
lines.push('');
lines.push('## TOP 查询');
topQueries.forEach((q, i) => {
lines.push(`\n### #${i + 1}`);
lines.push(`- SQL: ${q.sql}`);
lines.push(`- 执行次数: ${q.count}`);
lines.push(`- 平均时间: ${q.avg_time_ms.toFixed(1)}ms`);
lines.push(`- 最大时间: ${q.max_time_ms.toFixed(1)}ms`);
lines.push(`- 总时间: ${q.total_time_ms.toFixed(1)}ms`);
});
lines.push('\n## 慢查询明细');
logs.forEach(l => {
lines.push(`\n- [${formatQueryTime(l.query_time)}] ${truncateSql(l.sql, 100)}`);
});
const text = lines.join('\n');
const blob = new Blob([text], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `slow_query_report_${new Date().toISOString().slice(0, 10)}.md`;
a.click();
URL.revokeObjectURL(url);
}
/* ───────── Constants ───────── */
const CHART_COLORS = ['#6366f1', '#a855f7', '#ec4899', '#f43f5e', '#f97316', '#eab308', '#22c55e', '#06b6d4'];
const SUGGESTION_COLORS: Record<string, { border: string; bg: string; icon: React.ElementType }> = {
critical: { border: 'border-red-500/30', bg: 'bg-red-500/10', icon: AlertTriangle },
warning: { border: 'border-yellow-500/30', bg: 'bg-yellow-500/10', icon: Zap },
info: { border: 'border-blue-500/30', bg: 'bg-blue-500/10', icon: Lightbulb },
};
/* ───────── Component ───────── */
export default function SlowQueryPage() {
const [instances, setInstances] = useState<InstanceOption[]>([]);
const [selectedInstanceId, setSelectedInstanceId] = useState<number | null>(null);
const [instancesLoading, setInstancesLoading] = useState(false);
const [activeTab, setActiveTab] = useState<'logs' | 'top' | 'trend'>('logs');
const [logs, setLogs] = useState<SlowLogItem[]>([]);
const [logsTotal, setLogsTotal] = useState(0);
const [logsLoading, setLogsLoading] = useState(false);
const [expandedSqlIds, setExpandedSqlIds] = useState<Set<string>>(new Set());
const [expandedRowId, setExpandedRowId] = useState<string | null>(null);
const [topQueries, setTopQueries] = useState<TopQuery[]>([]);
const [topLoading, setTopLoading] = useState(false);
const [collecting, setCollecting] = useState(false);
const [message, setMessage] = useState<{ text: string; type: 'success' | 'error' } | null>(null);
// Compare state
const [compareIds, setCompareIds] = useState<Set<string>>(new Set());
const [showCompare, setShowCompare] = useState(false);
// Kill query state
const [killingId, setKillingId] = useState<string | null>(null);
const fetchInstances = useCallback(async () => {
setInstancesLoading(true);
try {
const res = await instanceApi.list({});
const data: InstanceOption[] = Array.isArray(res) ? res : [];
setInstances(data);
if (data.length > 0 && !selectedInstanceId) {
setSelectedInstanceId(data[0].id);
}
} catch {
// keep existing
} finally {
setInstancesLoading(false);
}
}, [selectedInstanceId]);
useEffect(() => { fetchInstances(); }, [fetchInstances]);
const fetchLogs = useCallback(async () => {
if (!selectedInstanceId) return;
setLogsLoading(true);
try {
const res = await slowLogApi.list(selectedInstanceId);
setLogs(res.logs || []);
setLogsTotal(res.total || 0);
} catch {
setLogs([]);
} finally {
setLogsLoading(false);
}
}, [selectedInstanceId]);
const fetchTopQueries = useCallback(async () => {
setTopLoading(true);
try {
const res = await slowLogApi.topQueries();
setTopQueries(res.queries || []);
} catch {
setTopQueries([]);
} finally {
setTopLoading(false);
}
}, []);
useEffect(() => {
if (activeTab === 'logs') fetchLogs();
if (activeTab === 'top' || activeTab === 'trend') fetchTopQueries();
}, [activeTab, fetchLogs, fetchTopQueries]);
const handleCollect = async () => {
if (!selectedInstanceId) return;
setCollecting(true);
setMessage(null);
try {
const res = await slowLogApi.collect(selectedInstanceId, '', 100);
setMessage({ text: `成功收集 ${res.collected} 条慢日志`, type: 'success' });
fetchLogs();
} catch {
setMessage({ text: '收集慢日志失败', type: 'error' });
} finally {
setCollecting(false);
}
};
const handleKillQuery = async (logId: string) => {
if (!confirm('确定要终止该查询吗?')) return;
setKillingId(logId);
try {
// In real implementation: await slowLogApi.kill(selectedInstanceId, logId);
await new Promise(r => setTimeout(r, 500));
setMessage({ text: '查询已终止', type: 'success' });
setExpandedRowId(null);
} catch {
setMessage({ text: '终止查询失败', type: 'error' });
} finally {
setKillingId(null);
}
};
const toggleCompare = (id: string) => {
setCompareIds(prev => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else if (next.size < 2) {
next.add(id);
}
return next;
});
};
const toggleSqlExpand = (id: string) => {
setExpandedSqlIds(prev => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
};
const toggleRowExpand = (id: string) => {
setExpandedRowId(prev => (prev === id ? null : id));
};
// Chart data
const chartData = topQueries.slice(0, 10).map((q, idx) => ({
name: `#${idx + 1}`,
avgTime: q.avg_time_ms,
maxTime: q.max_time_ms,
count: q.count,
sql: truncateSql(q.sql, 50),
}));
// Time distribution for pie chart
const timeDistribution = useMemo(() => {
const ranges = [
{ label: '< 500ms', min: 0, max: 0.5 },
{ label: '500ms - 1s', min: 0.5, max: 1 },
{ label: '1s - 3s', min: 1, max: 3 },
{ label: '3s - 5s', min: 3, max: 5 },
{ label: '> 5s', min: 5, max: Infinity },
];
return ranges.map((r, i) => ({
name: r.label,
value: logs.filter(l => l.query_time >= r.min && l.query_time < r.max).length,
color: CHART_COLORS[i],
})).filter(r => r.value > 0);
}, [logs]);
// Trend data
const trendData = useMemo(() => {
const map: Record<string, { count: number; avgTime: number }> = {};
logs.forEach(l => {
const d = new Date(l.start_time).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
if (!map[d]) map[d] = { count: 0, avgTime: 0 };
map[d].count += 1;
map[d].avgTime += l.query_time;
});
return Object.entries(map)
.sort(([a], [b]) => new Date(a).getTime() - new Date(b).getTime())
.slice(-14)
.map(([date, v]) => ({
date,
count: v.count,
avgTime: v.avgTime / v.count * 1000,
}));
}, [logs]);
const selectedInstance = instances.find(i => i.id === selectedInstanceId);
const compareLogs = logs.filter(l => compareIds.has(l.id));
return (
<div className="space-y-5">
{/* Page Header */}
<PageHeader
title="慢查询分析"
description="数据库慢查询监控与性能分析"
/>
{/* Controls bar */}
<Card className="dg-card">
<CardContent className="p-4">
<div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2">
<Database className="w-4 h-4 text-[var(--dg-text-muted)]" />
<select
value={selectedInstanceId ?? ''}
onChange={e => setSelectedInstanceId(Number(e.target.value) || null)}
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 min-w-[200px]"
>
{instancesLoading ? (
<option>加载中...</option>
) : (
instances.map(inst => (
<option key={inst.id} value={inst.id}>{inst.name} ({inst.host})</option>
))
)}
</select>
</div>
<button
onClick={handleCollect}
disabled={collecting || !selectedInstanceId}
className="flex items-center gap-2 px-4 py-2.5 bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg text-sm font-medium text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{collecting ? <Loader2 className="w-4 h-4 animate-spin" /> : <Play className="w-4 h-4" />}
收集慢日志
</button>
<button
onClick={() => exportSlowQueryReport(logs, topQueries)}
disabled={logs.length === 0}
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)] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
<Download className="w-4 h-4" />
导出报告
</button>
{/* Compare button */}
{compareIds.size > 0 && (
<button
onClick={() => setShowCompare(true)}
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"
>
<GitCompare className="w-4 h-4" />
对比 ({compareIds.size})
</button>
)}
<button
onClick={() => { if (activeTab === 'logs') fetchLogs(); else fetchTopQueries(); }}
className="flex items-center gap-2 px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] transition-all"
>
<RefreshCw className="w-4 h-4" />
</button>
{selectedInstance && (
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">
已选实例: {selectedInstance.name}
</span>
)}
</div>
{/* Toast message */}
{message && (
<div className={`mt-3 px-4 py-2.5 rounded-lg text-sm flex items-center gap-2 ${
message.type === 'success'
? 'bg-green-500/10 text-green-400 border border-green-500/20'
: 'bg-red-500/10 text-red-400 border border-red-500/20'
}`}>
{message.type === 'success' ? <CheckCircle2 className="w-4 h-4" /> : <AlertTriangle className="w-4 h-4" />}
{message.text}
<button onClick={() => setMessage(null)} className="ml-auto hover:opacity-70"><X className="w-3 h-3" /></button>
</div>
)}
</CardContent>
</Card>
{/* Tabs */}
<div className="flex items-center gap-1 p-1 bg-[var(--dg-bg-tertiary)] rounded-lg w-fit">
{[
{ key: 'logs' as const, label: '慢日志列表', icon: Clock },
{ key: 'top' as const, label: 'TOP查询', icon: BarChart3 },
{ key: 'trend' as const, label: '趋势分析', icon: BarChart3 },
].map(tab => {
const Icon = tab.icon;
return (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`flex items-center gap-2 px-4 py-1.5 rounded-md text-sm font-medium transition-colors ${
activeTab === tab.key ? 'bg-blue-600 text-white' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<Icon className="w-4 h-4" />
{tab.label}
</button>
);
})}
</div>
{/* Tab 1: Slow log list */}
{activeTab === 'logs' && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Clock className="w-4 h-4 text-blue-400" />
慢日志列表
<span className="text-xs font-normal text-[var(--dg-text-muted)] ml-2">共 {logsTotal} 条</span>
{compareIds.size > 0 && (
<button
onClick={() => setCompareIds(new Set())}
className="ml-2 text-xs text-[var(--dg-text-muted)] hover:text-red-400 transition-colors"
>
清除对比选择
</button>
)}
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{logsLoading ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2" />
加载中...
</div>
) : logs.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<Database className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无慢日志数据</p>
<p className="text-xs mt-1">请先选择实例并点击"收集慢日志"</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)]">
<th className="text-left px-3 py-3 text-[var(--dg-text-muted)] font-medium w-8" />
<th className="text-left px-3 py-3 text-[var(--dg-text-muted)] font-medium w-8">对比</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">SQL</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">执行时间</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">锁等待</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">扫描行数</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">返回行数</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">数据库</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">时间</th>
</tr>
</thead>
<tbody>
{logs.map(log => {
const isExpanded = expandedRowId === log.id;
const isSqlExpanded = expandedSqlIds.has(log.id);
const isComparing = compareIds.has(log.id);
const suggestions = isExpanded ? generateSuggestions(log) : [];
return (
<>
<tr key={log.id} className={`border-b border-[var(--dg-border-light)] transition-colors ${isComparing ? 'bg-blue-500/5' : 'hover:bg-[var(--dg-bg-hover)]'}`}>
<td className="px-3 py-3">
<button
onClick={() => toggleRowExpand(log.id)}
className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] transition-colors"
>
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
</button>
</td>
<td className="px-3 py-3">
<button
onClick={() => toggleCompare(log.id)}
className={`w-5 h-5 rounded border flex items-center justify-center transition-all ${
isComparing ? 'bg-blue-500 border-blue-500' : 'border-[var(--dg-text-muted)] hover:border-blue-400'
}`}
>
{isComparing && <CheckCircle2 className="w-3.5 h-3.5 text-white" />}
</button>
</td>
<td className="px-4 py-3 max-w-md">
<code className={`text-xs font-mono ${queryTimeColor(log.query_time)}`}>
{isSqlExpanded ? log.sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() : truncateSql(log.sql, 80)}
</code>
{log.sql.length > 80 && (
<button
onClick={(e) => { e.stopPropagation(); toggleSqlExpand(log.id); }}
className="ml-2 text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
{isSqlExpanded ? '收起' : '展开'}
</button>
)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${queryTimeBg(log.query_time)} ${queryTimeColor(log.query_time)}`}>
<Clock className="w-3 h-3" />
{formatQueryTime(log.query_time)}
</span>
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
{formatQueryTime(log.lock_time)}
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
{log.rows_examined.toLocaleString()}
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)]">
{log.rows_sent.toLocaleString()}
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)] text-xs rounded-md">
{log.database}
</span>
</td>
<td className="px-4 py-3 text-[var(--dg-text-muted)] text-xs whitespace-nowrap">
{new Date(log.start_time).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
})}
</td>
</tr>
{/* Expanded section with EXPLAIN + Suggestions + Kill */}
{isExpanded && (
<tr className="border-t border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50">
<td colSpan={9} className="p-5">
{/* Full SQL */}
<div className="mb-4 p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-[var(--dg-text-muted)]">完整 SQL</span>
<button
onClick={() => navigator.clipboard.writeText(log.sql)}
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
复制
</button>
</div>
<code className="text-xs font-mono text-[var(--dg-text-secondary)] break-all whitespace-pre-wrap">
{log.sql}
</code>
</div>
{/* EXPLAIN Plan */}
<div className="mb-4">
<div className="flex items-center gap-2 mb-3">
<Search className="w-4 h-4 text-blue-400" />
<span className="text-sm font-semibold">执行计划</span>
<span className="text-xs text-[var(--dg-text-muted)]">(模拟)</span>
</div>
<div className="flex items-center gap-0 flex-wrap">
{['Table Scan', 'Filter', 'Sort', 'Limit'].map((step, idx) => (
<div key={step} className="flex items-center">
<div className={`px-4 py-2 rounded-lg border text-sm font-medium ${
idx === 0 ? 'bg-red-500/10 border-red-500/30 text-red-400' :
idx === 1 ? 'bg-yellow-500/10 border-yellow-500/30 text-yellow-400' :
idx === 2 ? 'bg-blue-500/10 border-blue-500/30 text-blue-400' :
'bg-green-500/10 border-green-500/30 text-green-400'
}`}>
{step}
</div>
{idx < 3 && <span className="mx-2 text-[var(--dg-text-muted)] text-lg">-&gt;</span>}
</div>
))}
</div>
</div>
{/* Optimization Suggestions */}
<div className="mb-4">
<div className="flex items-center gap-2 mb-3">
<Lightbulb className="w-4 h-4 text-yellow-400" />
<span className="text-sm font-semibold">优化建议</span>
</div>
<div className="space-y-2">
{suggestions.map(s => {
const config = SUGGESTION_COLORS[s.level];
const SIcon = config.icon;
return (
<div key={s.id} className={`p-3 rounded-lg border ${config.border} ${config.bg}`}>
<div className="flex items-center gap-2 mb-1">
<SIcon className="w-3.5 h-3.5" />
<span className="text-sm font-medium">{s.title}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded ${
s.level === 'critical' ? 'bg-red-500/20 text-red-400' :
s.level === 'warning' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-blue-500/20 text-blue-400'
}`}>
{s.level === 'critical' ? '严重' : s.level === 'warning' ? '警告' : '建议'}
</span>
</div>
<p className="text-xs text-[var(--dg-text-secondary)] mb-1">{s.description}</p>
<p className="text-xs text-[var(--dg-text-primary)]">{s.suggestion}</p>
</div>
);
})}
</div>
</div>
{/* Metrics + Kill */}
<div className="flex items-end justify-between">
<div className="grid grid-cols-4 gap-3 flex-1">
{[
{ label: '执行时间', value: formatQueryTime(log.query_time), color: queryTimeColor(log.query_time) },
{ label: '锁等待', value: formatQueryTime(log.lock_time), color: 'text-[var(--dg-text-secondary)]' },
{ label: '扫描行数', value: log.rows_examined.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
{ label: '返回行数', value: log.rows_sent.toLocaleString(), color: 'text-[var(--dg-text-secondary)]' },
].map(m => (
<div key={m.label} className="p-3 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-lg">
<div className="text-xs text-[var(--dg-text-muted)] mb-1">{m.label}</div>
<div className={`text-base font-semibold ${m.color}`}>{m.value}</div>
</div>
))}
</div>
<button
onClick={() => handleKillQuery(log.id)}
disabled={killingId === log.id}
className="ml-4 flex items-center gap-2 px-4 py-2 bg-red-500/15 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/25 transition-all disabled:opacity-50"
>
{killingId === log.id ? <Loader2 className="w-4 h-4 animate-spin" /> : <Skull className="w-4 h-4" />}
终止查询
</button>
</div>
</td>
</tr>
)}
</>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
)}
{/* Tab 2: Top queries */}
{activeTab === 'top' && (
<div className="space-y-4">
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<BarChart3 className="w-4 h-4 text-purple-400" />
TOP 10 查询 — 平均执行时间
</CardTitle>
</CardHeader>
<CardContent className="p-5">
{topLoading ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2" />
加载中...
</div>
) : chartData.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<BarChart3 className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无 TOP 查询数据</p>
</div>
) : (
<ResponsiveContainer width="100%" height={320}>
<BarChart data={chartData} margin={{ top: 5, right: 30, left: 20, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
<XAxis dataKey="name" tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }} />
<YAxis tick={{ fill: 'var(--dg-text-muted)', fontSize: 12 }} label={{ value: 'ms', position: 'insideTopRight', fill: 'var(--dg-text-muted)', fontSize: 11 }} />
<Tooltip
contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }}
formatter={(value: number, name: string) => [
`${value.toFixed(1)} ms`,
name === 'avgTime' ? '平均时间' : '最大时间',
]}
/>
<Bar dataKey="avgTime" fill="#6366f1" radius={[4, 4, 0, 0]} name="avgTime" />
<Bar dataKey="maxTime" fill="#a855f7" radius={[4, 4, 0, 0]} name="maxTime" />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-400" />
查询详情
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{topLoading ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">加载中...</div>
) : topQueries.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">暂无数据</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--dg-border)]">
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium w-10">#</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">SQL</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">执行次数</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">平均时间</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">最大时间</th>
<th className="text-left px-4 py-3 text-[var(--dg-text-muted)] font-medium">总时间</th>
</tr>
</thead>
<tbody>
{topQueries.map((q, idx) => (
<tr key={idx} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
<td className="px-4 py-3 text-[var(--dg-text-muted)] font-mono text-xs">{idx + 1}</td>
<td className="px-4 py-3 max-w-md">
<code className="text-xs font-mono text-[var(--dg-text-secondary)]">
{truncateSql(q.sql, 100)}
</code>
</td>
<td className="px-4 py-3">
<span className="px-2 py-0.5 bg-blue-500/10 text-blue-400 text-xs rounded-md font-medium">
{q.count}
</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs font-medium ${queryTimeColor(q.avg_time_ms / 1000)}`}>
{q.avg_time_ms.toFixed(1)} ms
</span>
</td>
<td className="px-4 py-3">
<span className={`text-xs font-medium ${queryTimeColor(q.max_time_ms / 1000)}`}>
{q.max_time_ms.toFixed(1)} ms
</span>
</td>
<td className="px-4 py-3 text-[var(--dg-text-secondary)] text-xs">
{q.total_time_ms >= 1000
? `${(q.total_time_ms / 1000).toFixed(2)}s`
: `${q.total_time_ms.toFixed(0)}ms`}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* Tab 3: Trend */}
{activeTab === 'trend' && (
<div className="space-y-4">
{/* Time Distribution */}
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Clock className="w-4 h-4 text-orange-400" />
耗时分布
</CardTitle>
</CardHeader>
<CardContent className="p-5">
{timeDistribution.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<BarChart3 className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无数据</p>
</div>
) : (
<div className="flex items-center gap-8">
<ResponsiveContainer width="50%" height={240}>
<PieChart>
<Pie data={timeDistribution} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label>
{timeDistribution.map((entry, i) => <Cell key={i} fill={entry.color} />)}
</Pie>
<Tooltip contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }} />
<Legend />
</PieChart>
</ResponsiveContainer>
<div className="flex-1 space-y-2">
{timeDistribution.map(d => (
<div key={d.name} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-sm" style={{ backgroundColor: d.color }} />
<span className="text-sm flex-1">{d.name}</span>
<span className="text-sm font-semibold">{d.value}</span>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
{/* Trend Chart */}
<Card className="dg-card">
<CardHeader className="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" />
慢查询趋势(最近14天)
</CardTitle>
</CardHeader>
<CardContent className="p-5">
{trendData.length === 0 ? (
<div className="p-10 text-center text-[var(--dg-text-muted)]">
<BarChart3 className="w-10 h-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无数据</p>
</div>
) : (
<ResponsiveContainer width="100%" height={280}>
<LineChart data={trendData}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--dg-border)" />
<XAxis dataKey="date" tick={{ fill: 'var(--dg-text-muted)', fontSize: 11 }} />
<YAxis yAxisId="left" tick={{ fill: 'var(--dg-text-muted)', fontSize: 11 }} />
<YAxis yAxisId="right" orientation="right" tick={{ fill: 'var(--dg-text-muted)', fontSize: 11 }} />
<Tooltip contentStyle={{ backgroundColor: 'var(--dg-bg-secondary)', border: '1px solid var(--dg-border)', borderRadius: '8px', fontSize: '12px' }} />
<Legend />
<Line yAxisId="left" type="monotone" dataKey="count" name="慢查询数" stroke="#6366f1" strokeWidth={2} dot={{ r: 3 }} />
<Line yAxisId="right" type="monotone" dataKey="avgTime" name="平均耗时(ms)" stroke="#a855f7" strokeWidth={2} dot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
</div>
)}
{/* Compare Modal */}
{showCompare && compareLogs.length === 2 && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-4xl bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl p-6 shadow-xl max-h-[80vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h2 className="text-lg font-semibold flex items-center gap-2"><GitCompare className="w-5 h-5 text-blue-400" /> 查询对比</h2>
<button onClick={() => setShowCompare(false)} className="text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"><XCircle className="w-5 h-5" /></button>
</div>
<div className="grid grid-cols-2 gap-4">
{compareLogs.map((log, i) => (
<div key={log.id} className="p-4 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-sm font-medium mb-3 text-blue-400">查询 {i + 1}</div>
<div className="space-y-2">
<div>
<div className="text-xs text-[var(--dg-text-muted)] mb-1">SQL</div>
<code className="text-xs font-mono text-[var(--dg-text-secondary)] break-all">{truncateSql(log.sql, 200)}</code>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="p-2 bg-[var(--dg-bg-secondary)] rounded">
<div className="text-xs text-[var(--dg-text-muted)]">执行时间</div>
<div className={`text-sm font-semibold ${queryTimeColor(log.query_time)}`}>{formatQueryTime(log.query_time)}</div>
</div>
<div className="p-2 bg-[var(--dg-bg-secondary)] rounded">
<div className="text-xs text-[var(--dg-text-muted)]">锁等待</div>
<div className="text-sm font-semibold">{formatQueryTime(log.lock_time)}</div>
</div>
<div className="p-2 bg-[var(--dg-bg-secondary)] rounded">
<div className="text-xs text-[var(--dg-text-muted)]">扫描行数</div>
<div className="text-sm font-semibold">{log.rows_examined.toLocaleString()}</div>
</div>
<div className="p-2 bg-[var(--dg-bg-secondary)] rounded">
<div className="text-xs text-[var(--dg-text-muted)]">返回行数</div>
<div className="text-sm font-semibold">{log.rows_sent.toLocaleString()}</div>
</div>
</div>
</div>
</div>
))}
</div>
{/* Comparison Summary */}
<div className="mt-4 p-4 bg-[var(--dg-bg-tertiary)] rounded-lg">
<div className="text-sm font-medium mb-2">对比总结</div>
<div className="grid grid-cols-4 gap-3 text-center">
{[
{ label: '执行时间差', getValue: () => {
const diff = compareLogs[0].query_time - compareLogs[1].query_time;
return `${diff > 0 ? '+' : ''}${formatQueryTime(Math.abs(diff))}`;
}, color: compareLogs[0].query_time > compareLogs[1].query_time ? 'text-red-400' : 'text-green-400' },
{ label: '扫描行数差', getValue: () => {
const diff = compareLogs[0].rows_examined - compareLogs[1].rows_examined;
return `${diff > 0 ? '+' : ''}${Math.abs(diff).toLocaleString()}`;
}, color: compareLogs[0].rows_examined > compareLogs[1].rows_examined ? 'text-red-400' : 'text-green-400' },
{ label: '锁等待差', getValue: () => {
const diff = compareLogs[0].lock_time - compareLogs[1].lock_time;
return `${diff > 0 ? '+' : ''}${formatQueryTime(Math.abs(diff))}`;
}, color: 'text-[var(--dg-text-secondary)]' },
{ label: '返回行数差', getValue: () => {
const diff = compareLogs[0].rows_sent - compareLogs[1].rows_sent;
return `${diff > 0 ? '+' : ''}${Math.abs(diff).toLocaleString()}`;
}, color: 'text-[var(--dg-text-secondary)]' },
].map(item => (
<div key={item.label}>
<div className="text-xs text-[var(--dg-text-muted)]">{item.label}</div>
<div className={`text-sm font-semibold ${item.color}`}>{item.getValue()}</div>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
);
}
+299 -40
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import {
ChevronRight, Check, AlertTriangle, Shield, Database,
FileText, Sparkles, RotateCcw, Loader2, Info,
ChevronDown, ChevronUp, Table2, Columns3, FolderOpen,
} from 'lucide-react';
import { ticketApi } from '@/api/ticket';
import { instanceApi } from '@/api/instance';
@@ -39,6 +40,12 @@ interface StepConfig {
icon: React.ElementType;
}
interface TableMeta {
name: string;
rows?: number;
type?: string;
}
/* ------------------------------------------------------------------ */
/* Constants */
/* ------------------------------------------------------------------ */
@@ -79,6 +86,24 @@ const PRIORITY_OPTIONS = [
{ value: 'high', label: '高' },
];
// 按使用频率排序
const DB_TYPE_OPTIONS = [
{ value: '', label: '请选择数据库类型' },
{ value: 'mysql', label: 'MySQL' },
{ value: 'redis', label: 'Redis' },
{ value: 'postgres', label: 'PostgreSQL' },
{ value: 'mongodb', label: 'MongoDB' },
];
const DB_TYPE_LABELS: Record<string, string> = {
mysql: 'MySQL',
redis: 'Redis',
postgres: 'PostgreSQL',
postgresql: 'PostgreSQL',
mongodb: 'MongoDB',
sqlite: 'SQLite',
};
/* ------------------------------------------------------------------ */
/* Helper: generate rollback SQL suggestion */
/* ------------------------------------------------------------------ */
@@ -86,7 +111,6 @@ const PRIORITY_OPTIONS = [
function generateRollbackSuggestion(sql: string): string {
const upper = sql.toUpperCase().trim();
// UPDATE -> generate reverse UPDATE
if (upper.startsWith('UPDATE')) {
const tableMatch = sql.match(/UPDATE\s+(\w+)/i);
const table = tableMatch ? tableMatch[1] : 'table';
@@ -95,19 +119,16 @@ function generateRollbackSuggestion(sql: string): string {
return `-- 建议回滚语句(请根据实际业务调整):\n-- UPDATE ${table} SET ... WHERE ${where || '...'};`;
}
// DELETE -> generate INSERT from backup
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 恢复`;
}
// ALTER -> generate reverse ALTER
if (upper.startsWith('ALTER')) {
return `-- DDL 回滚说明:\n-- 1. ALTER TABLE 操作无法直接回滚\n-- 2. 建议先使用 pt-online-schema-change 或 gh-ost 进行在线变更\n-- 3. 执行前请确保有完整备份`;
}
// INSERT -> generate DELETE
if (upper.startsWith('INSERT')) {
const tableMatch = sql.match(/INTO\s+(\w+)/i);
const table = tableMatch ? tableMatch[1] : 'table';
@@ -130,6 +151,7 @@ export default function TicketCreatePage() {
// -- Form fields --
const [title, setTitle] = useState('');
const [priority, setPriority] = useState('medium');
const [dbType, setDbType] = useState('');
const [instanceId, setInstanceId] = useState('');
const [database, setDatabase] = useState('');
const [sqlContent, setSqlContent] = useState('');
@@ -148,6 +170,14 @@ export default function TicketCreatePage() {
// -- Audit --
const [auditResult, setAuditResult] = useState<AuditResult | null>(null);
// -- Schema browser --
const [showSchema, setShowSchema] = useState(false);
const [tables, setTables] = useState<TableMeta[]>([]);
const [loadingTables, setLoadingTables] = useState(false);
const [expandedTable, setExpandedTable] = useState<string | null>(null);
const [columns, setColumns] = useState<Record<string, string[]>>({});
const [loadingColumns, setLoadingColumns] = useState<string | null>(null);
/* ---------------------------------------------------------------- */
/* Fetch instances on mount */
/* ---------------------------------------------------------------- */
@@ -163,6 +193,39 @@ export default function TicketCreatePage() {
.finally(() => setLoadingInstances(false));
}, []);
/* ---------------------------------------------------------------- */
/* Filtered instances by db type */
/* ---------------------------------------------------------------- */
const filteredInstances = useMemo(() => {
if (!dbType) return instances;
return instances.filter(i => {
if (dbType === 'postgres') return i.db_type === 'postgres' || i.db_type === 'postgresql';
return i.db_type === dbType;
});
}, [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) {
const match = dbType === 'postgres'
? (current.db_type === 'postgres' || current.db_type === 'postgresql')
: current.db_type === dbType;
if (!match) {
setInstanceId('');
setDatabase('');
setDatabases([]);
}
}
}
}, [dbType, instances, instanceId]);
/* ---------------------------------------------------------------- */
/* Fetch databases when instance changes */
/* ---------------------------------------------------------------- */
@@ -173,6 +236,7 @@ export default function TicketCreatePage() {
setDatabase('');
return;
}
const inst = instances.find(i => i.id === Number(instanceId));
setLoadingDatabases(true);
instanceApi.listDatabases(Number(instanceId))
.then((res: unknown) => {
@@ -182,14 +246,80 @@ export default function TicketCreatePage() {
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');
}
})
.catch(() => {
// Fallback: no auto database list
setDatabases([]);
// Redis fallback
if (inst?.db_type === 'redis') {
setDatabase('db0');
}
})
.finally(() => setLoadingDatabases(false));
}, [instanceId]);
}, [instanceId, instances]);
/* ---------------------------------------------------------------- */
/* Schema browser: load tables */
/* ---------------------------------------------------------------- */
useEffect(() => {
if (!showSchema || !instanceId || !database) {
setTables([]);
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[] : []);
})
.catch(() => setTables([]))
.finally(() => setLoadingTables(false));
}, [showSchema, instanceId, database]);
/* ---------------------------------------------------------------- */
/* Schema browser: load columns for a table */
/* ---------------------------------------------------------------- */
const handleExpandTable = useCallback(async (tableName: string) => {
if (expandedTable === tableName) {
setExpandedTable(null);
return;
}
setExpandedTable(tableName);
if (columns[tableName]) return; // already loaded
setLoadingColumns(tableName);
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]);
/* ---------------------------------------------------------------- */
/* Insert table/column name into SQL editor */
/* ---------------------------------------------------------------- */
const insertToEditor = useCallback((text: string) => {
setSqlContent(prev => {
const separator = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : '';
return prev + separator + text + ' ';
});
}, []);
/* ---------------------------------------------------------------- */
/* SQL change callback */
@@ -197,7 +327,6 @@ export default function TicketCreatePage() {
const handleSqlChange = useCallback((val: string) => {
setSqlContent(val);
// Auto-generate rollback suggestion when SQL changes
if (val.trim().length > 10) {
setRollbackSql(generateRollbackSuggestion(val));
}
@@ -255,7 +384,7 @@ export default function TicketCreatePage() {
const inst = instances.find((i) => i.id === Number(instanceId));
await ticketApi.create({
title: title.trim(),
db_type: inst?.db_type || 'mysql',
db_type: inst?.db_type || dbType || 'mysql',
instance_id: Number(instanceId),
target_database: database,
sql_content: sqlContent,
@@ -270,7 +399,7 @@ export default function TicketCreatePage() {
} finally {
setSubmitting(false);
}
}, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, navigate]);
}, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, dbType, navigate]);
/* ---------------------------------------------------------------- */
/* Derived values */
@@ -336,6 +465,106 @@ export default function TicketCreatePage() {
</div>
) : null;
/* ---------------------------------------------------------------- */
/* Schema Browser Panel */
/* ---------------------------------------------------------------- */
const renderSchemaBrowser = () => {
if (!instanceId || !database) return null;
return (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg">
{/* Toggle button */}
<button
onClick={() => setShowSchema(!showSchema)}
className="flex items-center gap-2 w-full px-4 py-2.5 text-sm font-medium text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] transition-colors"
>
<FolderOpen className="w-4 h-4" />
浏览库表结构
<span className="text-xs text-[var(--dg-text-muted)]">— 点击表名/字段插入到 SQL</span>
{showSchema
? <ChevronUp className="w-4 h-4 ml-auto" />
: <ChevronDown className="w-4 h-4 ml-auto" />
}
</button>
{/* Schema content */}
{showSchema && (
<div className="px-4 pb-3 max-h-[300px] overflow-y-auto">
{loadingTables ? (
<div className="flex items-center gap-2 py-3 text-xs text-[var(--dg-text-muted)]">
<Loader2 className="w-3 h-3 animate-spin" />
加载表列表...
</div>
) : tables.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">无可用表(或实例未连接)</p>
) : (
<div className="space-y-0.5">
{tables.map(t => (
<div key={t.name}>
{/* Table row */}
<button
onClick={() => handleExpandTable(t.name)}
className="flex items-center gap-2 w-full px-2 py-1.5 text-sm rounded hover:bg-[var(--dg-bg-hover)] transition-colors group"
>
{expandedTable === t.name
? <ChevronDown className="w-3 h-3 text-[var(--dg-text-muted)]" />
: <ChevronRight className="w-3 h-3 text-[var(--dg-text-muted)]" />
}
<Table2 className="w-3.5 h-3.5 text-blue-400" />
<span
className="font-mono text-[var(--dg-text-primary)] hover:text-blue-400 transition-colors"
onClick={(e) => { e.stopPropagation(); insertToEditor(t.name); }}
title="点击插入表名"
>
{t.name}
</span>
{t.rows !== undefined && (
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">
{t.rows.toLocaleString()} 行
</span>
)}
</button>
{/* Columns */}
{expandedTable === t.name && (
<div className="ml-5 pl-3">
{loadingColumns === t.name ? (
<div className="flex items-center gap-2 py-1 text-xs text-[var(--dg-text-muted)]">
<Loader2 className="w-3 h-3 animate-spin" />
加载字段...
</div>
) : columns[t.name] ? (
<div className="space-y-0.5">
{columns[t.name].map(col => (
<button
key={col}
onClick={() => insertToEditor(col)}
className="flex items-center gap-2 w-full px-2 py-1 text-xs rounded hover:bg-[var(--dg-bg-hover)] transition-colors"
title="点击插入字段名"
>
<Columns3 className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="font-mono text-[var(--dg-text-secondary)] hover:text-blue-400 transition-colors">
{col}
</span>
</button>
))}
</div>
) : (
<p className="text-xs text-[var(--dg-text-muted)] py-1">字段信息不可用</p>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
);
};
/* ========== Step 1: Fill info ========== */
const renderStep1 = () => (
<div className="space-y-5 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
@@ -352,7 +581,7 @@ export default function TicketCreatePage() {
/>
</div>
{/* Priority + Instance */}
{/* Priority + DB Type */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">优先级</label>
@@ -369,32 +598,65 @@ export default function TicketCreatePage() {
<div>
<label className="block text-sm font-medium mb-2">
选择实例 <span className="text-red-400">*</span>
数据库类型 <span className="text-red-400">*</span>
</label>
<select
value={instanceId}
onChange={(e) => setInstanceId(e.target.value)}
disabled={loadingInstances}
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"
value={dbType}
onChange={(e) => 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"
>
<option value="">
{loadingInstances ? '加载中...' : '请选择实例'}
</option>
{instances.map((inst) => (
<option key={inst.id} value={String(inst.id)}>
{inst.name} ({inst.host}:{inst.port}) [{inst.db_type.toUpperCase()}]
</option>
{DB_TYPE_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* Instance (filtered by dbType) */}
<div>
<label className="block text-sm font-medium mb-2">
选择实例 <span className="text-red-400">*</span>
</label>
<select
value={instanceId}
onChange={(e) => 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"
>
<option value="">
{loadingInstances
? '加载中...'
: dbType && filteredInstances.length === 0
? `无 ${(DB_TYPE_LABELS[dbType]) || dbType} 实例`
: dbType
? `请选择 ${(DB_TYPE_LABELS[dbType]) || dbType} 实例`
: '请先选择数据库类型'
}
</option>
{filteredInstances.map((inst) => (
<option key={inst.id} value={String(inst.id)}>
{inst.name} ({inst.host}:{inst.port})
</option>
))}
</select>
</div>
{/* Database */}
<div>
<label className="block text-sm font-medium mb-2">
数据库 <span className="text-red-400">*</span>
</label>
{databases.length > 0 ? (
{selectedInstance?.db_type === 'redis' ? (
<div className="flex items-center gap-2">
<input
value={database}
onChange={(e) => setDatabase(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"
placeholder="db0"
/>
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">Redis</span>
</div>
) : databases.length > 0 ? (
<select
value={database}
onChange={(e) => setDatabase(e.target.value)}
@@ -410,22 +672,19 @@ export default function TicketCreatePage() {
)}
</select>
) : (
<div className="flex items-center gap-2">
<input
value={database}
onChange={(e) => setDatabase(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"
placeholder="目标数据库名"
/>
{selectedInstance && (
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
{selectedInstance.db_type.toUpperCase()}
</span>
)}
</div>
<input
value={database}
onChange={(e) => 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 ? '请输入数据库名' : '请先选择实例'}
/>
)}
</div>
{/* Schema Browser */}
{renderSchemaBrowser()}
{/* SQL Editor */}
<div>
<div className="flex items-center justify-between mb-2">
@@ -456,7 +715,7 @@ export default function TicketCreatePage() {
/>
<p className="text-xs text-[var(--dg-text-muted)] mt-1.5 flex items-center gap-1">
<Info className="w-3 h-3" />
支持 Ctrl+Shift+F 格式化代码
提交前必须通过 SQL 审核(语法检查 + 审核规则)
</p>
</div>
@@ -500,12 +759,12 @@ export default function TicketCreatePage() {
{auditing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
审核中...
验证审核中...
</>
) : (
<>
<Shield className="w-4 h-4" />
审核并继续
验证并审核
</>
)}
</button>
@@ -649,7 +908,7 @@ export default function TicketCreatePage() {
{/* Rollback preview */}
{rollbackSql && (
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-purple-500/20">
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg">
<div className="flex items-center gap-1.5 mb-1">
<RotateCcw className="w-3.5 h-3.5 text-purple-400" />
<span className="text-xs text-[var(--dg-text-muted)]">回滚 SQL</span>
+19 -8
View File
@@ -7,7 +7,7 @@ import {
BarChart3, ChevronDown, ChevronUp, Link2,
} from 'lucide-react';
import { ticketApi } from '@/api/ticket';
import { statusMap, statusColorMap } from '@/lib/mock';
import { statusMap, statusColorMap } from '@/lib/constants';
import { useAuth } from '@/hooks/useAuth';
import SqlEditor from '@/components/SqlEditor';
@@ -129,14 +129,25 @@ export default function TicketDetailPage() {
}
}, [ticketId]);
// Mock related tickets fetch
// Fetch related tickets from API
const fetchRelated = useCallback(async () => {
// In real implementation: await ticketApi.related(ticketId)
setRelatedTickets([
{ id: ticketId - 1, title: '用户表索引优化', status: 'success', applicant_name: '张三', create_time: '2024-01-14 10:00:00' },
{ id: ticketId + 1, title: '订单数据归档', status: 'approved', applicant_name: '李四', create_time: '2024-01-15 14:00:00' },
]);
}, [ticketId]);
try {
const res = await ticketApi.list({ instance_id: ticket?.instance_id, limit: 5 }) as { items?: any[] };
const items = (res?.items || [])
.filter((t: any) => t.id !== ticketId)
.slice(0, 5)
.map((t: any) => ({
id: t.id,
title: t.title,
status: t.status,
applicant_name: t.applicant_name || t.created_by_name,
create_time: t.created_at || t.create_time,
}));
setRelatedTickets(items);
} catch {
setRelatedTickets([]);
}
}, [ticketId, ticket?.instance_id]);
useEffect(() => {
if (!isNaN(ticketId)) {
+1 -1
View File
@@ -7,7 +7,7 @@ import {
CheckSquare, Square, ArrowUpDown, FileDown, Ban,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/mock';
import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants';
import { useActionPermission } from '@/hooks/useAuth';
import PageHeader from '@/components/PageHeader';
import EmptyState from '@/components/EmptyState';
+1 -1
View File
@@ -6,7 +6,7 @@ import {
Loader2, Check, XCircle, Star,
} from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { dbTypeColorMap } from '@/lib/mock';
import { dbTypeColorMap } from '@/lib/constants';
import { workflowApi } from '@/api/workflow';
/* ------------------------------------------------------------------ */