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