init
This commit is contained in:
1 parent
c7fccf99c7
commit
8471f368eb
33 files changed
+444
-2095
No files matched your search
@@ -21,7 +21,6 @@ import MongoDBDetailPage from '@/pages/MongoDBDetailPage';
|
||||
import TicketCreatePage from '@/pages/TicketCreatePage';
|
||||
import TicketDetailPage from '@/pages/TicketDetailPage';
|
||||
import InstanceGroupPage from '@/pages/InstanceGroupPage';
|
||||
// import SlowQueryPage from '@/pages/SlowQueryPage';
|
||||
|
||||
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
||||
function RouteGuard() {
|
||||
|
||||
@@ -21,10 +21,6 @@ export const instanceDetailApi = {
|
||||
getGrants: (id: number, user: string, host: string) =>
|
||||
request.get<any, any>('/instances/' + id + '/detail/grants', { params: { user: user, host: host } }),
|
||||
|
||||
// Slow Queries
|
||||
getSlowQueries: (id: number, database?: string) =>
|
||||
request.get<any, any[]>('/instances/' + id + '/detail/slow-queries', { params: database ? { database: database } : {} }),
|
||||
|
||||
// Processlist
|
||||
getProcesslist: (id: number) =>
|
||||
request.get<any, any[]>('/instances/' + id + '/detail/processlist'),
|
||||
|
||||
+28
-34
@@ -1,37 +1,31 @@
|
||||
import request from './request';
|
||||
|
||||
export var redisApi = {
|
||||
getInfo: function (id: number) {
|
||||
return request.get('/redis/' + id + '/info');
|
||||
},
|
||||
getDbsize: function (id: number, db: number) {
|
||||
return request.get('/redis/' + id + '/dbsize', { params: { db: db } });
|
||||
},
|
||||
getKeys: function (id: number, params: Record<string, any>) {
|
||||
return request.get('/redis/' + id + '/keys', { params: params });
|
||||
},
|
||||
getKeyDetail: function (id: number, key: string, db: number) {
|
||||
return request.get('/redis/' + id + '/keys/' + encodeURIComponent(key), { params: { db: db } });
|
||||
},
|
||||
createKey: function (id: number, data: Record<string, any>) {
|
||||
return request.post('/redis/' + id + '/keys', data);
|
||||
},
|
||||
deleteKey: function (id: number, key: string, db: number) {
|
||||
return request.delete('/redis/' + id + '/keys/' + encodeURIComponent(key), { params: { db: db } });
|
||||
},
|
||||
setExpire: function (id: number, key: string, data: Record<string, any>) {
|
||||
return request.put('/redis/' + id + '/keys/' + encodeURIComponent(key) + '/expire', data);
|
||||
},
|
||||
flushDb: function (id: number, db: number) {
|
||||
return request.delete('/redis/' + id + '/flushdb', { params: { db: db } });
|
||||
},
|
||||
executeCommand: function (id: number, data: Record<string, any>) {
|
||||
return request.post('/redis/' + id + '/command', data);
|
||||
},
|
||||
getSlowlog: function (id: number, count: number) {
|
||||
return request.get('/redis/' + id + '/slowlog', { params: { count: count } });
|
||||
},
|
||||
getClients: function (id: number) {
|
||||
return request.get('/redis/' + id + '/clients');
|
||||
},
|
||||
export const redisApi = {
|
||||
// Redis 信息概览
|
||||
getInfo: (instanceId: number) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/info'),
|
||||
|
||||
// DBSIZE
|
||||
getDbsize: (instanceId: number, db?: number) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }),
|
||||
|
||||
// 键列表
|
||||
scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
|
||||
|
||||
// 键详情
|
||||
getKeyDetail: (instanceId: number, key: string, db?: number) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
|
||||
|
||||
// 慢日志
|
||||
getSlowlog: (instanceId: number, count?: number) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }),
|
||||
|
||||
// 客户端列表
|
||||
getClients: (instanceId: number) =>
|
||||
request.get<any, any>('/redis/' + instanceId + '/clients'),
|
||||
|
||||
// 执行命令
|
||||
executeCommand: (instanceId: number, data: { db: number; command: string }) =>
|
||||
request.post<any, any>('/redis/' + instanceId + '/command', data),
|
||||
};
|
||||
@@ -1,17 +0,0 @@
|
||||
import request from './request';
|
||||
|
||||
export const slowLogApi = {
|
||||
list: (instanceId: number) =>
|
||||
request.get<any, any>(`/slow-logs/instances/${instanceId}`),
|
||||
|
||||
collect: (instanceId: number, database: string = '', limit: number = 50) =>
|
||||
request.post<any, any>(`/slow-logs/instances/${instanceId}/collect`, null, {
|
||||
params: { database, limit },
|
||||
}),
|
||||
|
||||
report: (instanceId: number) =>
|
||||
request.get<any, any>(`/slow-logs/instances/${instanceId}/report`),
|
||||
|
||||
topQueries: () =>
|
||||
request.get<any, any>('/slow-logs/dashboard/top'),
|
||||
};
|
||||
File renamed without changes.
@@ -28,18 +28,6 @@ export const queryTemplates: QueryTemplate[] = [
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql', 'mongodb'],
|
||||
},
|
||||
{
|
||||
id: 'slow-queries',
|
||||
name: '查询慢SQL',
|
||||
description: '查看慢查询日志,定位执行时间较长的SQL语句',
|
||||
category: '性能分析',
|
||||
sql: {
|
||||
mysql: 'SELECT * FROM mysql.slow_log LIMIT 20;',
|
||||
postgresql:
|
||||
'SELECT * FROM pg_stat_statements ORDER BY total_time DESC LIMIT 20;',
|
||||
},
|
||||
supportedTypes: ['mysql', 'postgresql'],
|
||||
},
|
||||
{
|
||||
id: 'table-size',
|
||||
name: '查询表大小',
|
||||
|
||||
@@ -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' }) => {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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: '终止进程' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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 ───────── */
|
||||
|
||||
|
||||
@@ -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">-></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
Reference in new issue
Block a user