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

+3 -3
View File
@@ -23,7 +23,7 @@ async def list_audit_rules(
db_type: Optional[str] = None,
is_enabled: Optional[bool] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
current_user: User = Depends(require_role("admin", "dba", "auditor")),
):
stmt = select(AuditRule).order_by(AuditRule.id)
if rule_type:
@@ -62,7 +62,7 @@ async def create_audit_rule(
@router.get("/stats/summary")
async def rule_stats(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
current_user: User = Depends(require_role("admin", "dba", "auditor")),
):
total_stmt = select(func.count(AuditRule.id))
total = (await db.execute(total_stmt)).scalar()
@@ -130,7 +130,7 @@ async def seed_audit_rules(
async def get_audit_rule(
rule_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
current_user: User = Depends(require_role("admin", "dba", "auditor")),
):
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
rule = result.scalar_one_or_none()
-1
View File
@@ -49,7 +49,6 @@ DB_TYPE_INFO: Dict[str, Dict[str, Any]] = {
"查看索引": "SHOW INDEX FROM table_name",
"查看连接": "SHOW PROCESSLIST",
"查看状态": "SHOW STATUS LIKE 'Threads%'",
"查看慢查询": "SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 20",
},
"limitations": [
"不支持存储过程调用",
@@ -217,42 +217,6 @@ async def show_grants(
raise HTTPException(status_code=500, detail="查询权限失败: " + str(e))
# ---- Slow Queries ----
@router.get("/{instance_id}/detail/slow-queries")
async def slow_queries(
instance_id: int,
database: Optional[str] = Query(None, description="数据库名"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
instance = await _get_decrypted_instance(db, instance_id)
# P0: 权限检查
await check_instance_access(current_user, instance, db)
if instance.db_type != "mysql":
raise HTTPException(status_code=400, detail="仅支持MySQL实例")
try:
# Try querying mysql.slow_log table first
try:
sql = (
"SELECT start_time, user_host, query_time, lock_time, "
"rows_sent, rows_examined, sql_text "
"FROM mysql.slow_log ORDER BY start_time DESC LIMIT 100"
)
rows = await _mysql_query(instance, sql, database=database)
return rows
except Exception:
# Fallback: show slow query variables
rows = await _mysql_query(
instance,
"SHOW VARIABLES LIKE 'slow%'",
database=database,
)
return rows
except Exception as e:
raise HTTPException(status_code=500, detail="查询慢查询失败: " + str(e))
# ---- Processlist ----
@router.get("/{instance_id}/detail/processlist")
+3 -7
View File
@@ -100,12 +100,11 @@ async def test_ldap_connection(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""测试 LDAP 连接 — 使用 mock 模拟"""
"""测试 LDAP 连接"""
ldap = await _get_existing(db)
if not ldap:
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
# Use mock for testing — python-ldap may not be installed
try:
import ldap as _ldap # noqa
# Real LDAP test path
@@ -131,12 +130,9 @@ async def test_ldap_connection(
return {"success": True, "message": f"LDAP 连接成功,用户 {data.test_user} 验证通过"}
except ImportError:
# Mock path — python-ldap not installed
host = ldap.ldap_host
port = ldap.ldap_port
return {
"success": True,
"message": f"[Mock] LDAP 连接模拟成功 ({host}:{port}),用户 {data.test_user} 验证通过",
"success": False,
"message": "python-ldap 未安装,无法测试 LDAP 连接。请执行: pip install python-ldap",
}
except Exception as e:
return {"success": False, "message": f"LDAP 连接失败: {str(e)}"}
+19 -22
View File
@@ -124,7 +124,9 @@ async def test_notification(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin")),
):
"""测试发送通知 — 目前返回模拟结果"""
"""测试发送通知 — 实际发送测试消息到配置的渠道。"""
from app.services.notification_sender import NotificationSender
result = await db.execute(
select(NotificationChannel).where(NotificationChannel.id == channel_id)
)
@@ -133,24 +135,19 @@ async def test_notification(
raise HTTPException(status_code=404, detail="通知渠道不存在")
config = json.loads(channel.config) if channel.config else {}
channel_type = channel.channel_type
# Simulated test — in production this would actually send a message
if channel_type == "dingtalk":
if not config.get("webhook_url"):
return {"success": False, "message": "缺少 webhook_url 配置"}
return {"success": True, "message": f"钉钉测试消息已发送至 {config['webhook_url'][:30]}..."}
elif channel_type == "webhook_feishu":
if not config.get("webhook_url"):
return {"success": False, "message": "缺少 webhook_url 配置"}
return {"success": True, "message": f"飞书测试消息已发送至 {config['webhook_url'][:30]}..."}
elif channel_type == "email":
required = ["smtp_host", "smtp_port", "smtp_user", "from_addr"]
missing = [k for k in required if not config.get(k)]
if missing:
return {"success": False, "message": f"缺少必要配置: {', '.join(missing)}"}
return {"success": True, "message": f"邮件测试已发送: {config['smtp_user']} -> test@example.com"}
return {"success": False, "message": f"不支持的通知类型: {channel_type}"}
sender = NotificationSender()
test_data = {
"title": "测试通知",
"applicant": current_user.display_name or current_user.username,
"db_type": "mysql",
"target_database": "test_db",
"sql_preview": "SELECT 1",
"priority": "normal",
"approver": "系统",
"ticket_id": "TEST",
"result": "成功",
"duration": "10",
"affected_rows": "0",
}
result = await sender.send(channel.channel_type, config, "ticket_created", test_data)
return result
-103
View File
@@ -1,103 +0,0 @@
"""慢日志 API。"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user, require_role
from app.core.crypto import decrypt_password
from app.models.user import User
from app.models.instance import Instance
from app.services.slow_log_collector import SlowLogCollector
router = APIRouter(prefix="/slow-logs", tags=["慢日志分析"])
@router.get("/instances/{instance_id}")
async def list_slow_logs(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""获取指定实例的慢日志。"""
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
collector = SlowLogCollector()
logs = collector.get_slow_logs(instance_id)
return {
"instance_id": instance_id,
"total": len(logs),
"logs": [log.to_dict() for log in logs],
}
@router.post("/instances/{instance_id}/collect")
async def collect_slow_logs(
instance_id: int,
database: str = "",
limit: int = 50,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""手动收集慢日志。"""
result = await db.execute(select(Instance).where(Instance.id == instance_id))
instance = result.scalar_one_or_none()
if not instance:
raise HTTPException(status_code=404, detail="数据源不存在")
if not database:
database = instance.database or ""
collector = SlowLogCollector()
instance.password = decrypt_password(instance.password)
try:
if instance.db_type == "mysql":
logs = await collector.collect_mysql_slow_logs(instance, database, limit)
elif instance.db_type in ("postgres", "postgresql"):
logs = await collector.collect_postgres_slow_logs(instance, database, limit)
else:
raise HTTPException(status_code=400, detail=f"不支持的类型: {instance.db_type}")
except Exception as e:
raise HTTPException(status_code=500, detail=f"收集失败: {str(e)}")
await collector.save_slow_logs(instance_id, logs)
report = collector.get_report(instance_id)
return report.to_dict()
@router.get("/instances/{instance_id}/report")
async def slow_log_report(
instance_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(require_role("admin", "dba")),
):
"""获取慢日志分析报告。"""
collector = SlowLogCollector()
report = collector.get_report(instance_id)
return report.to_dict()
@router.get("/dashboard/top")
async def dashboard_top_queries(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""仪表盘慢查询 TOP 10。"""
collector = SlowLogCollector()
all_logs = []
for instance_id, logs in collector._logs.items():
all_logs.extend(logs)
# 全局 TOP 10
top = sorted(all_logs, key=lambda x: x.avg_time_ms, reverse=True)[:10]
return {
"total_instances": len(collector._logs),
"top_queries": [q.to_dict() for q in top],
}
+1 -2
View File
@@ -5,7 +5,7 @@ from app.api.v1.endpoints import (
announcement, notification, ldap_config,
instance_detail, backup, redis, dictionary,
instance_group, masking_rule, rollback,
workflow, impact, schema_drift, slow_log, pool,
workflow, impact, schema_drift, pool,
mongo, query_history, sql_assistant, db_types,
settings,
)
@@ -34,7 +34,6 @@ api_router.include_router(rollback.router)
api_router.include_router(workflow.router)
api_router.include_router(impact.router)
api_router.include_router(schema_drift.router)
api_router.include_router(slow_log.router)
api_router.include_router(pool.router)
api_router.include_router(query_history.router)
api_router.include_router(sql_assistant.router)
+20 -11
View File
@@ -29,17 +29,27 @@ async def seed_workflows():
async def seed_data():
"""Create default admin user from environment config."""
"""Create default users with different roles."""
from app.core.security import hash_password
default_users = [
{"username": "admin", "display_name": "超级管理员", "role": "admin", "password": "admin123"},
{"username": "dba", "display_name": "张三", "role": "dba", "password": "admin123"},
{"username": "auditor", "display_name": "老六", "role": "auditor", "password": "admin123"},
{"username": "developer", "display_name": "路人甲", "role": "developer", "password": "admin123"},
]
async with async_session() as db:
result = await db.execute(select(User).where(User.username == settings.ADMIN_USERNAME))
if not result.scalar_one_or_none():
db.add(User(
username=settings.ADMIN_USERNAME,
display_name="管理员",
role="admin",
hashed_password=hash_password(settings.ADMIN_PASSWORD),
))
await db.commit()
for u in default_users:
result = await db.execute(select(User).where(User.username == u["username"]))
if not result.scalar_one_or_none():
db.add(User(
username=u["username"],
display_name=u["display_name"],
role=u["role"],
hashed_password=hash_password(u["password"]),
))
await db.commit()
@asynccontextmanager
@@ -62,7 +72,6 @@ async def lifespan(app: FastAPI):
import app.models.notification_template # noqa
import app.models.scheduled_task # noqa
import app.models.schema_revision # noqa
import app.models.slow_log # noqa
import app.models.query_history # noqa
await init_db()
-21
View File
@@ -1,21 +0,0 @@
"""慢日志模型。"""
from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, func
from app.core.database import Base
class SlowLog(Base):
"""慢日志记录。"""
__tablename__ = "slow_logs"
id = Column(Integer, primary_key=True, autoincrement=True)
instance_id = Column(Integer, ForeignKey("instances.id"), nullable=False)
database = Column(String(100), nullable=True)
sql_digest = Column(String(64), nullable=True)
sql_text = Column(Text, nullable=True)
execution_count = Column(Integer, default=1)
avg_time_ms = Column(Integer, nullable=True)
max_time_ms = Column(Integer, nullable=True)
rows_examined = Column(Integer, nullable=True)
rows_sent = Column(Integer, nullable=True)
created_at = Column(DateTime, server_default=func.now())
+1 -1
View File
@@ -13,7 +13,7 @@ class User(Base):
email = Column(String(100), unique=True, nullable=True)
phone = Column(String(20), nullable=True)
hashed_password = Column(String(200), nullable=False)
role = Column(String(20), nullable=False, default="developer") # admin / dba / developer / guest
role = Column(String(20), nullable=False, default="developer") # admin / dba / auditor / developer / guest
is_active = Column(Boolean, default=True)
last_login = Column(DateTime, nullable=True)
created_at = Column(DateTime, server_default=func.now())
+21 -18
View File
@@ -18,40 +18,40 @@ class NotificationSender:
# 内置消息模板
TEMPLATES = {
"ticket_created": {
"title": "📝 新工单待审批: {title}",
"body": "**申请人**: {applicant}\\n**数据库**: {db_type}@{target_database}\\n**SQL摘要**: {sql_preview}\\n**优先级**: {priority}",
"title": "新工单待审批: {title}",
"body": "**申请人**: {applicant}\n**数据库**: {db_type}@{target_database}\n**SQL摘要**: {sql_preview}",
},
"ticket_approved": {
"title": "✅ 工单已审批通过: {title}",
"body": "**审批人**: {approver}\\n**工单ID**: {ticket_id}\\n**状态**: 已批准,可执行",
"title": "工单已审批通过: {title}",
"body": "**审批人**: {approver}\n**工单ID**: {ticket_id}\n**状态**: 已批准,可执行",
},
"ticket_rejected": {
"title": "❌ 工单已被拒绝: {title}",
"body": "**审批人**: {approver}\\n**拒绝原因**: {comment}\\n**工单ID**: {ticket_id}",
"title": "工单已被拒绝: {title}",
"body": "**审批人**: {approver}\n**拒绝原因**: {comment}\n**工单ID**: {ticket_id}",
},
"ticket_executed": {
"title": "🚀 工单执行完成: {title}",
"body": "**执行结果**: {result}\\n**耗时**: {duration}ms\\n**影响行数**: {affected_rows}",
"title": "工单执行完成: {title}",
"body": "**执行结果**: {result}\n**耗时**: {duration}ms\n**影响行数**: {affected_rows}",
},
"ticket_failed": {
"title": "⚠️ 工单执行失败: {title}",
"body": "**错误信息**: {error}\\n**工单ID**: {ticket_id}\\n请及时处理",
"title": "工单执行失败: {title}",
"body": "**错误信息**: {error}\n**工单ID**: {ticket_id}\n请及时处理",
},
"ticket_rollback": {
"title": "🔄 工单已回滚: {title}",
"body": "**回滚人**: {operator}\\n**工单ID**: {ticket_id}",
"title": "工单已回滚: {title}",
"body": "**回滚人**: {operator}\n**工单ID**: {ticket_id}",
},
"backup_completed": {
"title": "💾 备份完成: {title}",
"body": "**数据库**: {database}\\n**文件大小**: {size}\\n**耗时**: {duration}s",
"title": "备份完成: {title}",
"body": "**数据库**: {database}\n**文件大小**: {size}\n**耗时**: {duration}s",
},
"backup_failed": {
"title": "❌ 备份失败: {title}",
"body": "**错误信息**: {error}\\n请检查数据库连接和磁盘空间",
"title": "备份失败: {title}",
"body": "**错误信息**: {error}\n请检查数据库连接和磁盘空间",
},
"schema_drift_detected": {
"title": "🔍 检测到 Schema 漂移: {instance_name}",
"body": "**数据库**: {database}\\n**发现 {drift_count} 处变更**:\\n{drift_details}",
"title": "检测到 Schema 漂移: {instance_name}",
"body": "**数据库**: {database}\n**发现 {drift_count} 处变更**:\n{drift_details}",
},
}
@@ -101,6 +101,9 @@ class NotificationSender:
title = template["title"].format(**data)
body = template["body"].format(**data)
# 钉钉 markdown 单个 \n 视为空格,需要 \n\n 才能换行
body = body.replace("\n", "\n\n")
payload = {
"msgtype": "markdown",
"markdown": {
-269
View File
@@ -1,269 +0,0 @@
"""慢日志收集器 — 从数据库收集和分析慢查询日志。
支持:
- MySQL: performance_schema.events_statements_summary_by_digest
- PostgreSQL: pg_stat_statements
生成慢查询报告,包含 TOP N 慢查询、执行时间趋势等。
"""
import hashlib
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Dict, Any, Optional
from app.models.instance import Instance
from app.services.db_connector import DBConnector
from app.core.crypto import decrypt_password
@dataclass
class SlowLogEntry:
"""慢日志条目。"""
sql_digest: str # SQL 指纹
sql_text: str # 归一化 SQL
execution_count: int # 执行次数
avg_time_ms: float # 平均执行时间(ms)
max_time_ms: float # 最大执行时间(ms)
min_time_ms: float # 最小执行时间(ms)
rows_examined: int # 扫描行数
rows_sent: int # 返回行数
first_seen: Optional[str] = None
last_seen: Optional[str] = None
def to_dict(self) -> dict:
return {
"sql_digest": self.sql_digest,
"sql_text": self.sql_text[:200] if self.sql_text else "",
"execution_count": self.execution_count,
"avg_time_ms": round(self.avg_time_ms, 2),
"max_time_ms": round(self.max_time_ms, 2),
"min_time_ms": round(self.min_time_ms, 2),
"rows_examined": self.rows_examined,
"rows_sent": self.rows_sent,
"first_seen": self.first_seen,
"last_seen": self.last_seen,
}
@dataclass
class SlowLogReport:
"""慢日志分析报告。"""
total_slow_queries: int
avg_execution_time_ms: float
max_execution_time_ms: float
top_queries: List[SlowLogEntry]
time_distribution: Dict[str, int] # 按时间段分布
def to_dict(self) -> dict:
return {
"total_slow_queries": self.total_slow_queries,
"avg_execution_time_ms": round(self.avg_execution_time_ms, 2),
"max_execution_time_ms": round(self.max_execution_time_ms, 2),
"top_queries": [q.to_dict() for q in self.top_queries],
"time_distribution": self.time_distribution,
}
class SlowLogCollector:
"""慢日志收集器。"""
# 内存中的慢日志存储: {instance_id: [SlowLogEntry, ...]}
_logs: Dict[int, List[SlowLogEntry]] = {}
async def collect_mysql_slow_logs(self, instance: Instance,
database: str,
limit: int = 50) -> List[SlowLogEntry]:
"""从 MySQL performance_schema 收集慢日志。
需要 MySQL 开启 performance_schema。
"""
sql = """
SELECT
DIGEST_TEXT as sql_text,
COUNT_STAR as execution_count,
ROUND(AVG_TIMER_WAIT / 1000000000, 3) as avg_time_ms,
ROUND(MAX_TIMER_WAIT / 1000000000, 3) as max_time_ms,
ROUND(MIN_TIMER_WAIT / 1000000000, 3) as min_time_ms,
SUM_ROWS_EXAMINED as rows_examined,
SUM_ROWS_SENT as rows_sent,
FIRST_SEEN,
LAST_SEEN
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME = %s
AND AVG_TIMER_WAIT > 100000000000 -- > 100ms
ORDER BY AVG_TIMER_WAIT DESC
LIMIT %s
"""
try:
instance.password = decrypt_password(instance.password)
rows, _ = await DBConnector._query_mysql(instance, "performance_schema", sql)
except Exception as e:
# performance_schema 可能不可用,使用替代查询
return await self._collect_mysql_slow_logs_fallback(instance, database, limit)
entries = []
for row in rows:
sql_text = row.get("sql_text", "")
digest = hashlib.md5(sql_text.encode()).hexdigest()[:16]
entries.append(SlowLogEntry(
sql_digest=digest,
sql_text=sql_text[:500],
execution_count=int(row.get("execution_count", 0) or 0),
avg_time_ms=float(row.get("avg_time_ms", 0) or 0),
max_time_ms=float(row.get("max_time_ms", 0) or 0),
min_time_ms=float(row.get("min_time_ms", 0) or 0),
rows_examined=int(row.get("rows_examined", 0) or 0),
rows_sent=int(row.get("rows_sent", 0) or 0),
first_seen=str(row.get("FIRST_SEEN", "")) if row.get("FIRST_SEEN") else None,
last_seen=str(row.get("LAST_SEEN", "")) if row.get("LAST_SEEN") else None,
))
return entries
async def _collect_mysql_slow_logs_fallback(self, instance: Instance,
database: str,
limit: int) -> List[SlowLogEntry]:
"""备用方案:使用 SHOW PROCESSLIST 获取当前慢查询。"""
sql = "SHOW FULL PROCESSLIST"
try:
instance.password = decrypt_password(instance.password)
rows, _ = await DBConnector._query_mysql(instance, database, sql)
except Exception:
return []
entries = []
for row in rows:
time_val = row.get("Time", 0)
info = row.get("Info", "")
if time_val and int(time_val) > 1 and info and len(info) > 10:
digest = hashlib.md5(info.encode()).hexdigest()[:16]
entries.append(SlowLogEntry(
sql_digest=digest,
sql_text=info[:500],
execution_count=1,
avg_time_ms=float(time_val) * 1000,
max_time_ms=float(time_val) * 1000,
min_time_ms=float(time_val) * 1000,
rows_examined=0,
rows_sent=0,
))
return entries[:limit]
async def collect_postgres_slow_logs(self, instance: Instance,
database: str,
limit: int = 50) -> List[SlowLogEntry]:
"""从 PostgreSQL pg_stat_statements 收集慢日志。
需要安装 pg_stat_statements 扩展。
"""
sql = """
SELECT
query as sql_text,
calls as execution_count,
ROUND((total_exec_time / calls)::numeric, 3) as avg_time_ms,
ROUND(max_exec_time::numeric, 3) as max_time_ms,
ROUND(min_exec_time::numeric, 3) as min_time_ms,
rows as rows_examined,
rows as rows_sent
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = $1)
AND total_exec_time / calls > 100 -- > 100ms
ORDER BY total_exec_time / calls DESC
LIMIT $2
"""
try:
instance.password = decrypt_password(instance.password)
# 需要先创建扩展
await DBConnector._query_postgres(instance, database,
"CREATE EXTENSION IF NOT EXISTS pg_stat_statements")
rows, _ = await DBConnector._query_postgres(instance, database, sql)
except Exception:
# pg_stat_statements 可能不可用
return []
entries = []
for row in rows:
sql_text = row.get("sql_text", "")
digest = hashlib.md5(sql_text.encode()).hexdigest()[:16]
entries.append(SlowLogEntry(
sql_digest=digest,
sql_text=sql_text[:500],
execution_count=int(row.get("execution_count", 0) or 0),
avg_time_ms=float(row.get("avg_time_ms", 0) or 0),
max_time_ms=float(row.get("max_time_ms", 0) or 0),
min_time_ms=float(row.get("min_time_ms", 0) or 0),
rows_examined=int(row.get("rows_examined", 0) or 0),
rows_sent=int(row.get("rows_sent", 0) or 0),
))
return entries
async def save_slow_logs(self, instance_id: int,
logs: List[SlowLogEntry]) -> None:
"""保存慢日志到内存存储。"""
if instance_id not in self._logs:
self._logs[instance_id] = []
# 追加新日志,限制每个实例最多保存 1000 条
self._logs[instance_id].extend(logs)
if len(self._logs[instance_id]) > 1000:
self._logs[instance_id] = self._logs[instance_id][-1000:]
def get_slow_logs(self, instance_id: int) -> List[SlowLogEntry]:
"""获取保存的慢日志。"""
return self._logs.get(instance_id, [])
def get_report(self, instance_id: int) -> SlowLogReport:
"""生成慢日志报告。"""
logs = self._logs.get(instance_id, [])
if not logs:
return SlowLogReport(
total_slow_queries=0,
avg_execution_time_ms=0,
max_execution_time_ms=0,
top_queries=[],
time_distribution={},
)
total_avg = sum(q.avg_time_ms for q in logs) / len(logs)
max_time = max(q.max_time_ms for q in logs)
# 按执行时间分布
distribution = {
"0-100ms": 0,
"100-500ms": 0,
"500ms-1s": 0,
"1s-5s": 0,
">5s": 0,
}
for q in logs:
if q.avg_time_ms < 100:
distribution["0-100ms"] += 1
elif q.avg_time_ms < 500:
distribution["100-500ms"] += 1
elif q.avg_time_ms < 1000:
distribution["500ms-1s"] += 1
elif q.avg_time_ms < 5000:
distribution["1s-5s"] += 1
else:
distribution[">5s"] += 1
# TOP 10
top = sorted(logs, key=lambda x: x.avg_time_ms, reverse=True)[:10]
return SlowLogReport(
total_slow_queries=len(logs),
avg_execution_time_ms=round(total_avg, 2),
max_execution_time_ms=round(max_time, 2),
top_queries=top,
time_distribution=distribution,
)
def clear_logs(self, instance_id: int) -> None:
"""清除指定实例的慢日志。"""
self._logs.pop(instance_id, None)
-1
View File
@@ -17,7 +17,6 @@ from app.models.announcement import Announcement
from app.models.notification import NotificationChannel
from app.models.ldap_config import LdapConfig
from app.models.backup import Backup
from app.models.slow_log import SlowLog
from app.models.scheduled_task import ScheduledTask
from app.models.schema_revision import SchemaRevision
from app.models.masking_rule import MaskingRule
-22
View File
@@ -177,28 +177,6 @@ class TestShowGrants:
assert len(data["grants"]) >= 1
class TestSlowQueries:
async def test_slow_queries_success(self, client: AsyncClient, test_instance, admin_token):
mock_conn, _ = _mock_mysql_connection([
{"start_time": "2024-01-01 00:00:00", "user_host": "root[root]@localhost",
"query_time": "00:00:05", "lock_time": "00:00:00", "rows_sent": 1,
"rows_examined": 1000, "sql_text": "SELECT * FROM big_table"},
])
mock_aiomysql = _make_aiomysql_mock()
mock_aiomysql.connect = AsyncMock(return_value=mock_conn)
with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}):
resp = await client.get(
"/api/v1/instances/" + str(test_instance.id) + "/detail/slow-queries",
params={"database": "test_db"},
headers=auth_header(admin_token),
)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
class TestProcesslist:
async def test_show_processlist_success(self, client: AsyncClient, test_instance, admin_token):
mock_conn, _ = _mock_mysql_connection([
+6 -7
View File
@@ -267,8 +267,8 @@ async def test_create_ldap_config_empty_password(client: AsyncClient, admin_toke
# ────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_ldap_connection_mock_success(client: AsyncClient, admin_token: str):
"""配置存在时 POST /test 在 mock 模式下应返回 success=True"""
async def test_ldap_connection_real_failure(client: AsyncClient, admin_token: str):
"""配置存在时 POST /test 应尝试真实连接,测试环境无 LDAP 服务器则返回 success=False"""
await client.put(
"/api/v1/ldap-config",
json=VALID_LDAP_PAYLOAD,
@@ -282,11 +282,10 @@ async def test_ldap_connection_mock_success(client: AsyncClient, admin_token: st
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
# mock 模式消息中包含 Mock 字样
assert "Mock" in data["message"]
# 消息中应包含主机和测试用户信息
assert "zhangsan" in data["message"]
# 测试环境无真实 LDAP 服务器,连接应失败
assert data["success"] is False
# 消息中应包含连接错误信息
assert any(kw in data["message"] for kw in ["error", "fail", "未安装", "连接失败", "LDAP"])
@pytest.mark.asyncio
+10 -5
View File
@@ -1,5 +1,6 @@
"""Comprehensive tests for NotificationChannel CRUD, permissions, config masking, and test-send."""
import pytest
from unittest.mock import AsyncMock, patch
from httpx import AsyncClient
from tests.conftest import auth_header
@@ -268,7 +269,9 @@ async def test_delete_nonexistent_channel(client: AsyncClient, admin_token: str)
@pytest.mark.asyncio
async def test_send_dingtalk_success(client: AsyncClient, admin_token: str):
@patch("app.services.notification_sender.NotificationSender.send", new_callable=AsyncMock)
async def test_send_dingtalk_success(mock_send, client: AsyncClient, admin_token: str):
mock_send.return_value = {"success": True, "message": "钉钉测试消息发送成功"}
create = await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(admin_token))
cid = create.json()["id"]
@@ -297,7 +300,9 @@ async def test_send_dingtalk_missing_webhook(client: AsyncClient, admin_token: s
@pytest.mark.asyncio
async def test_send_feishu_success(client: AsyncClient, admin_token: str):
@patch("app.services.notification_sender.NotificationSender.send", new_callable=AsyncMock)
async def test_send_feishu_success(mock_send, client: AsyncClient, admin_token: str):
mock_send.return_value = {"success": True, "message": "飞书测试消息发送成功"}
create = await client.post(BASE, json=FEISHU_PAYLOAD, headers=auth_header(admin_token))
cid = create.json()["id"]
@@ -325,7 +330,9 @@ async def test_send_feishu_missing_webhook(client: AsyncClient, admin_token: str
@pytest.mark.asyncio
async def test_send_email_success(client: AsyncClient, admin_token: str):
@patch("app.services.notification_sender.NotificationSender.send", new_callable=AsyncMock)
async def test_send_email_success(mock_send, client: AsyncClient, admin_token: str):
mock_send.return_value = {"success": True, "message": "邮件测试消息发送成功"}
create = await client.post(BASE, json=EMAIL_PAYLOAD, headers=auth_header(admin_token))
cid = create.json()["id"]
@@ -351,9 +358,7 @@ async def test_send_email_missing_smtp_config(client: AsyncClient, admin_token:
assert resp.status_code == 200
data = resp.json()
assert data["success"] is False
assert "smtp_port" in data["message"]
assert "smtp_user" in data["message"]
assert "from_addr" in data["message"]
@pytest.mark.asyncio
+7 -5
View File
@@ -2,6 +2,7 @@
import json
import pytest
from unittest.mock import AsyncMock, patch
from httpx import AsyncClient
from tests.conftest import auth_header
@@ -194,13 +195,15 @@ async def test_delete_notification_channel(client: AsyncClient, admin_token: str
@pytest.mark.asyncio
async def test_notification_test_endpoint(client: AsyncClient, admin_token: str):
@patch("app.services.notification_sender.NotificationSender.send", new_callable=AsyncMock)
async def test_notification_test_endpoint(mock_send, client: AsyncClient, admin_token: str):
mock_send.return_value = {"success": True, "message": "钉钉测试消息发送成功"}
create = await client.post(
"/api/v1/notifications",
json={
"name": "钉钉测试",
"channel_type": "dingtalk",
"config": {"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=test"},
"config": {"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=***"},
"is_enabled": True,
},
headers=auth_header(admin_token),
@@ -365,9 +368,8 @@ async def test_ldap_test_connection(client: AsyncClient, admin_token: str):
)
assert resp.status_code == 200
data = resp.json()
# Mock mode should return success
assert data["success"] is True
assert "Mock" in data["message"]
# 测试环境无真实 LDAP 服务器,应返回失败
assert data["success"] is False
@pytest.mark.asyncio
-433
View File
@@ -1,433 +0,0 @@
"""慢日志 API 端点测试 — P1 审计闭环。
测试范围:
- 慢日志列表查看
- 慢日志收集(需mock数据库连接)
- 慢日志分析报告
- 仪表盘 TOP 10 查询
- 权限控制(仅 admin/dba)
"""
import pytest
import pytest_asyncio
from unittest.mock import patch, AsyncMock, MagicMock
from httpx import AsyncClient
from app.core.security import create_access_token, hash_password
from app.models.user import User
from app.models.instance import Instance
from app.services.slow_log_collector import SlowLogCollector, SlowLogEntry, SlowLogReport
@pytest_asyncio.fixture
async def admin_headers(admin_token: str) -> dict:
return {"Authorization": f"Bearer {admin_token}"}
@pytest_asyncio.fixture
async def dba_headers(dba_token: str) -> dict:
return {"Authorization": f"Bearer {dba_token}"}
@pytest_asyncio.fixture
async def developer_headers(developer_token: str) -> dict:
return {"Authorization": f"Bearer {developer_token}"}
@pytest_asyncio.fixture
async def guest_headers(guest_token: str) -> dict:
return {"Authorization": f"Bearer {guest_token}"}
# ── SlowLogEntry 和 SlowLogReport 单元测试 ──
class TestSlowLogModels:
"""慢日志数据模型测试。"""
def test_slow_log_entry_to_dict(self):
"""测试 SlowLogEntry 序列化。"""
entry = SlowLogEntry(
sql_digest="abc123",
sql_text="SELECT * FROM users WHERE id = ?",
execution_count=100,
avg_time_ms=250.456,
max_time_ms=1000.789,
min_time_ms=50.123,
rows_examined=10000,
rows_sent=100,
first_seen="2025-01-01 00:00:00",
last_seen="2025-01-02 00:00:00",
)
d = entry.to_dict()
assert d["sql_digest"] == "abc123"
assert d["execution_count"] == 100
assert d["avg_time_ms"] == 250.46 # rounded
assert d["rows_examined"] == 10000
assert d["first_seen"] == "2025-01-01 00:00:00"
def test_slow_log_entry_long_sql_truncated(self):
"""测试长SQL被截断。"""
long_sql = "SELECT * FROM t WHERE " + "id = 1 AND " * 100
entry = SlowLogEntry(
sql_digest="x", sql_text=long_sql,
execution_count=1, avg_time_ms=100,
max_time_ms=100, min_time_ms=100,
rows_examined=1, rows_sent=1,
)
d = entry.to_dict()
assert len(d["sql_text"]) <= 200
def test_slow_log_report_to_dict(self):
"""测试 SlowLogReport 序列化。"""
entry = SlowLogEntry(
sql_digest="abc", sql_text="SELECT 1",
execution_count=1, avg_time_ms=100,
max_time_ms=100, min_time_ms=100,
rows_examined=1, rows_sent=1,
)
report = SlowLogReport(
total_slow_queries=1,
avg_execution_time_ms=100.555,
max_execution_time_ms=100.0,
top_queries=[entry],
time_distribution={"00-06": 0, "06-12": 1, "12-18": 0, "18-24": 0},
)
d = report.to_dict()
assert d["total_slow_queries"] == 1
assert d["avg_execution_time_ms"] == 100.56
assert len(d["top_queries"]) == 1
assert d["time_distribution"]["06-12"] == 1
# ── SlowLogCollector 单元测试 ──
class TestSlowLogCollector:
"""慢日志收集器单元测试。"""
def test_save_and_get_slow_logs(self):
"""测试保存和获取慢日志。"""
collector = SlowLogCollector()
collector._logs = {}
entries = [
SlowLogEntry(
sql_digest=f"digest_{i}", sql_text=f"SELECT {i}",
execution_count=i + 1, avg_time_ms=100 * (i + 1),
max_time_ms=200 * (i + 1), min_time_ms=10,
rows_examined=1000 * (i + 1), rows_sent=10,
)
for i in range(3)
]
import asyncio
asyncio.get_event_loop().run_until_complete(
collector.save_slow_logs(1, entries)
)
logs = collector.get_slow_logs(1)
assert len(logs) == 3
def test_get_slow_logs_empty(self):
"""测试获取不存在的实例慢日志。"""
collector = SlowLogCollector()
collector._logs = {}
logs = collector.get_slow_logs(999)
assert logs == []
def test_get_report_empty(self):
"""测试获取空报告。"""
collector = SlowLogCollector()
collector._logs = {}
report = collector.get_report(999)
assert report.total_slow_queries == 0
def test_get_report_with_data(self):
"""测试获取有数据的报告。"""
collector = SlowLogCollector()
entries = [
SlowLogEntry(
sql_digest="d1", sql_text="SELECT * FROM users",
execution_count=10, avg_time_ms=500,
max_time_ms=1000, min_time_ms=200,
rows_examined=50000, rows_sent=100,
),
SlowLogEntry(
sql_digest="d2", sql_text="SELECT * FROM orders",
execution_count=5, avg_time_ms=300,
max_time_ms=600, min_time_ms=100,
rows_examined=30000, rows_sent=50,
),
]
import asyncio
asyncio.get_event_loop().run_until_complete(
collector.save_slow_logs(1, entries)
)
report = collector.get_report(1)
assert report.total_slow_queries == 2
assert report.max_execution_time_ms == 1000
assert len(report.top_queries) <= 20 # TOP 20 max
# ── API 端点权限测试 ──
class TestSlowLogAPIPermissions:
"""慢日志 API 权限控制测试。"""
@pytest.mark.asyncio
async def test_list_slow_logs_unauthorized(self, client: AsyncClient):
"""测试未认证访问。"""
resp = await client.get("/api/v1/slow-logs/instances/1")
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_list_slow_logs_developer_forbidden(
self, client: AsyncClient, developer_headers: dict
):
"""测试开发者无权查看慢日志。"""
resp = await client.get(
"/api/v1/slow-logs/instances/1",
headers=developer_headers,
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_list_slow_logs_guest_forbidden(
self, client: AsyncClient, guest_headers: dict
):
"""测试访客无权查看慢日志。"""
resp = await client.get(
"/api/v1/slow-logs/instances/1",
headers=guest_headers,
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_collect_slow_logs_developer_forbidden(
self, client: AsyncClient, developer_headers: dict
):
"""测试开发者无权收集慢日志。"""
resp = await client.post(
"/api/v1/slow-logs/instances/1/collect",
headers=developer_headers,
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_report_developer_forbidden(
self, client: AsyncClient, developer_headers: dict
):
"""测试开发者无权查看报告。"""
resp = await client.get(
"/api/v1/slow-logs/instances/1/report",
headers=developer_headers,
)
assert resp.status_code == 403
# ── API 端点功能测试 ──
class TestSlowLogAPIFunctional:
"""慢日志 API 功能测试。"""
@pytest.mark.asyncio
async def test_list_slow_logs_not_found(
self, client: AsyncClient, admin_headers: dict, db
):
"""测试查看不存在实例的慢日志。"""
resp = await client.get(
"/api/v1/slow-logs/instances/999",
headers=admin_headers,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_list_slow_logs_empty(
self, client: AsyncClient, admin_headers: dict, test_instance
):
"""测试查看实例慢日志(空列表)。"""
collector = SlowLogCollector()
collector._logs = {}
with patch.object(SlowLogCollector, '_logs', {}):
resp = await client.get(
f"/api/v1/slow-logs/instances/{test_instance.id}",
headers=admin_headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["instance_id"] == test_instance.id
assert data["total"] == 0
assert data["logs"] == []
@pytest.mark.asyncio
async def test_list_slow_logs_with_data(
self, client: AsyncClient, admin_headers: dict, test_instance
):
"""测试查看实例慢日志(有数据)。"""
entries = [
SlowLogEntry(
sql_digest="d1", sql_text="SELECT * FROM users",
execution_count=10, avg_time_ms=500,
max_time_ms=1000, min_time_ms=200,
rows_examined=50000, rows_sent=100,
),
]
collector = SlowLogCollector()
collector._logs = {test_instance.id: entries}
with patch.object(SlowLogCollector, '_logs', {test_instance.id: entries}):
resp = await client.get(
f"/api/v1/slow-logs/instances/{test_instance.id}",
headers=admin_headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert data["logs"][0]["sql_digest"] == "d1"
@pytest.mark.asyncio
async def test_collect_slow_logs_unsupported_type(
self, client: AsyncClient, admin_headers: dict, db
):
"""测试收集不支持类型的慢日志。"""
# 创建Redis实例(redis不支持慢日志收集)
instance = Instance(
name="测试Redis", db_type="redis",
host="127.0.0.1", port=6379,
username="", password="test123",
database="0", env="development",
status="disconnected",
)
db.add(instance)
await db.commit()
await db.refresh(instance)
# endpoint先解密密码再检查类型,可能返回400或500
resp = await client.post(
f"/api/v1/slow-logs/instances/{instance.id}/collect",
headers=admin_headers,
)
# 接受400(不支持类型)或500(解密/连接错误)
assert resp.status_code in (400, 500)
@pytest.mark.asyncio
async def test_collect_slow_logs_not_found(
self, client: AsyncClient, admin_headers: dict
):
"""测试收集不存在实例的慢日志。"""
resp = await client.post(
"/api/v1/slow-logs/instances/999/collect",
headers=admin_headers,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_report_not_found(
self, client: AsyncClient, admin_headers: dict
):
"""测试获取不存在实例的报告。"""
resp = await client.get(
"/api/v1/slow-logs/instances/999/report",
headers=admin_headers,
)
# 报告端点不检查实例存在性,返回空报告
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_dashboard_top_queries(
self, client: AsyncClient, admin_headers: dict
):
"""测试仪表盘 TOP 查询。"""
collector = SlowLogCollector()
collector._logs = {}
with patch.object(SlowLogCollector, '_logs', {}):
resp = await client.get(
"/api/v1/slow-logs/dashboard/top",
headers=admin_headers,
)
assert resp.status_code == 200
data = resp.json()
assert "total_instances" in data
assert "top_queries" in data
@pytest.mark.asyncio
async def test_dashboard_top_queries_with_data(
self, client: AsyncClient, admin_headers: dict
):
"""测试仪表盘 TOP 查询(有数据)。"""
entries = [
SlowLogEntry(
sql_digest=f"d{i}", sql_text=f"SELECT {i}",
execution_count=i + 1, avg_time_ms=100 * (i + 1),
max_time_ms=200 * (i + 1), min_time_ms=10,
rows_examined=1000, rows_sent=10,
)
for i in range(15)
]
with patch.object(SlowLogCollector, '_logs', {1: entries}):
resp = await client.get(
"/api/v1/slow-logs/dashboard/top",
headers=admin_headers,
)
assert resp.status_code == 200
data = resp.json()
assert data["total_instances"] == 1
assert len(data["top_queries"]) <= 10 # TOP 10
@pytest.mark.asyncio
async def test_dba_can_access_slow_logs(
self, client: AsyncClient, dba_headers: dict, test_instance
):
"""测试DBA可以查看慢日志。"""
with patch.object(SlowLogCollector, '_logs', {}):
resp = await client.get(
f"/api/v1/slow-logs/instances/{test_instance.id}",
headers=dba_headers,
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_dba_can_collect_slow_logs(
self, client: AsyncClient, dba_headers: dict, test_instance
):
"""测试DBA可以收集慢日志。"""
# mock collect方法避免真实数据库连接
mock_entries = [
SlowLogEntry(
sql_digest="d1", sql_text="SELECT 1",
execution_count=1, avg_time_ms=100,
max_time_ms=100, min_time_ms=100,
rows_examined=1, rows_sent=1,
)
]
with patch.object(
SlowLogCollector, 'collect_mysql_slow_logs',
return_value=mock_entries
):
resp = await client.post(
f"/api/v1/slow-logs/instances/{test_instance.id}/collect",
headers=dba_headers,
)
# 可能500因为save方法,但至少不被403拒绝
assert resp.status_code in (200, 500)
@pytest.mark.asyncio
async def test_dashboard_any_authenticated_user(
self, client: AsyncClient, developer_headers: dict
):
"""测试仪表盘TOP查询对所有登录用户开放。"""
with patch.object(SlowLogCollector, '_logs', {}):
resp = await client.get(
"/api/v1/slow-logs/dashboard/top",
headers=developer_headers,
)
# dashboard/top 使用 get_current_user(不是 require_role)
assert resp.status_code == 200
-1
View File
@@ -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() {
-4
View File
@@ -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
View File
@@ -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),
};
-17
View File
@@ -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.
-12
View File
@@ -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: '查询表大小',
+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';
/* ------------------------------------------------------------------ */