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,
|
db_type: Optional[str] = None,
|
||||||
is_enabled: Optional[bool] = None,
|
is_enabled: Optional[bool] = None,
|
||||||
db: AsyncSession = Depends(get_db),
|
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)
|
stmt = select(AuditRule).order_by(AuditRule.id)
|
||||||
if rule_type:
|
if rule_type:
|
||||||
@@ -62,7 +62,7 @@ async def create_audit_rule(
|
|||||||
@router.get("/stats/summary")
|
@router.get("/stats/summary")
|
||||||
async def rule_stats(
|
async def rule_stats(
|
||||||
db: AsyncSession = Depends(get_db),
|
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_stmt = select(func.count(AuditRule.id))
|
||||||
total = (await db.execute(total_stmt)).scalar()
|
total = (await db.execute(total_stmt)).scalar()
|
||||||
@@ -130,7 +130,7 @@ async def seed_audit_rules(
|
|||||||
async def get_audit_rule(
|
async def get_audit_rule(
|
||||||
rule_id: int,
|
rule_id: int,
|
||||||
db: AsyncSession = Depends(get_db),
|
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))
|
result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id))
|
||||||
rule = result.scalar_one_or_none()
|
rule = result.scalar_one_or_none()
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ DB_TYPE_INFO: Dict[str, Dict[str, Any]] = {
|
|||||||
"查看索引": "SHOW INDEX FROM table_name",
|
"查看索引": "SHOW INDEX FROM table_name",
|
||||||
"查看连接": "SHOW PROCESSLIST",
|
"查看连接": "SHOW PROCESSLIST",
|
||||||
"查看状态": "SHOW STATUS LIKE 'Threads%'",
|
"查看状态": "SHOW STATUS LIKE 'Threads%'",
|
||||||
"查看慢查询": "SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 20",
|
|
||||||
},
|
},
|
||||||
"limitations": [
|
"limitations": [
|
||||||
"不支持存储过程调用",
|
"不支持存储过程调用",
|
||||||
|
|||||||
@@ -217,42 +217,6 @@ async def show_grants(
|
|||||||
raise HTTPException(status_code=500, detail="查询权限失败: " + str(e))
|
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 ----
|
# ---- Processlist ----
|
||||||
|
|
||||||
@router.get("/{instance_id}/detail/processlist")
|
@router.get("/{instance_id}/detail/processlist")
|
||||||
|
|||||||
@@ -100,12 +100,11 @@ async def test_ldap_connection(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin")),
|
current_user: User = Depends(require_role("admin")),
|
||||||
):
|
):
|
||||||
"""测试 LDAP 连接 — 使用 mock 模拟"""
|
"""测试 LDAP 连接"""
|
||||||
ldap = await _get_existing(db)
|
ldap = await _get_existing(db)
|
||||||
if not ldap:
|
if not ldap:
|
||||||
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
|
raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建")
|
||||||
|
|
||||||
# Use mock for testing — python-ldap may not be installed
|
|
||||||
try:
|
try:
|
||||||
import ldap as _ldap # noqa
|
import ldap as _ldap # noqa
|
||||||
# Real LDAP test path
|
# Real LDAP test path
|
||||||
@@ -131,12 +130,9 @@ async def test_ldap_connection(
|
|||||||
return {"success": True, "message": f"LDAP 连接成功,用户 {data.test_user} 验证通过"}
|
return {"success": True, "message": f"LDAP 连接成功,用户 {data.test_user} 验证通过"}
|
||||||
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Mock path — python-ldap not installed
|
|
||||||
host = ldap.ldap_host
|
|
||||||
port = ldap.ldap_port
|
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": False,
|
||||||
"message": f"[Mock] LDAP 连接模拟成功 ({host}:{port}),用户 {data.test_user} 验证通过",
|
"message": "python-ldap 未安装,无法测试 LDAP 连接。请执行: pip install python-ldap",
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"success": False, "message": f"LDAP 连接失败: {str(e)}"}
|
return {"success": False, "message": f"LDAP 连接失败: {str(e)}"}
|
||||||
@@ -124,7 +124,9 @@ async def test_notification(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(require_role("admin")),
|
current_user: User = Depends(require_role("admin")),
|
||||||
):
|
):
|
||||||
"""测试发送通知 — 目前返回模拟结果"""
|
"""测试发送通知 — 实际发送测试消息到配置的渠道。"""
|
||||||
|
from app.services.notification_sender import NotificationSender
|
||||||
|
|
||||||
result = await db.execute(
|
result = await db.execute(
|
||||||
select(NotificationChannel).where(NotificationChannel.id == channel_id)
|
select(NotificationChannel).where(NotificationChannel.id == channel_id)
|
||||||
)
|
)
|
||||||
@@ -133,24 +135,19 @@ async def test_notification(
|
|||||||
raise HTTPException(status_code=404, detail="通知渠道不存在")
|
raise HTTPException(status_code=404, detail="通知渠道不存在")
|
||||||
|
|
||||||
config = json.loads(channel.config) if channel.config else {}
|
config = json.loads(channel.config) if channel.config else {}
|
||||||
channel_type = channel.channel_type
|
sender = NotificationSender()
|
||||||
|
test_data = {
|
||||||
# Simulated test — in production this would actually send a message
|
"title": "测试通知",
|
||||||
if channel_type == "dingtalk":
|
"applicant": current_user.display_name or current_user.username,
|
||||||
if not config.get("webhook_url"):
|
"db_type": "mysql",
|
||||||
return {"success": False, "message": "缺少 webhook_url 配置"}
|
"target_database": "test_db",
|
||||||
return {"success": True, "message": f"钉钉测试消息已发送至 {config['webhook_url'][:30]}..."}
|
"sql_preview": "SELECT 1",
|
||||||
|
"priority": "normal",
|
||||||
elif channel_type == "webhook_feishu":
|
"approver": "系统",
|
||||||
if not config.get("webhook_url"):
|
"ticket_id": "TEST",
|
||||||
return {"success": False, "message": "缺少 webhook_url 配置"}
|
"result": "成功",
|
||||||
return {"success": True, "message": f"飞书测试消息已发送至 {config['webhook_url'][:30]}..."}
|
"duration": "10",
|
||||||
|
"affected_rows": "0",
|
||||||
elif channel_type == "email":
|
}
|
||||||
required = ["smtp_host", "smtp_port", "smtp_user", "from_addr"]
|
result = await sender.send(channel.channel_type, config, "ticket_created", test_data)
|
||||||
missing = [k for k in required if not config.get(k)]
|
return result
|
||||||
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}"}
|
|
||||||
@@ -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,
|
announcement, notification, ldap_config,
|
||||||
instance_detail, backup, redis, dictionary,
|
instance_detail, backup, redis, dictionary,
|
||||||
instance_group, masking_rule, rollback,
|
instance_group, masking_rule, rollback,
|
||||||
workflow, impact, schema_drift, slow_log, pool,
|
workflow, impact, schema_drift, pool,
|
||||||
mongo, query_history, sql_assistant, db_types,
|
mongo, query_history, sql_assistant, db_types,
|
||||||
settings,
|
settings,
|
||||||
)
|
)
|
||||||
@@ -34,7 +34,6 @@ api_router.include_router(rollback.router)
|
|||||||
api_router.include_router(workflow.router)
|
api_router.include_router(workflow.router)
|
||||||
api_router.include_router(impact.router)
|
api_router.include_router(impact.router)
|
||||||
api_router.include_router(schema_drift.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(pool.router)
|
||||||
api_router.include_router(query_history.router)
|
api_router.include_router(query_history.router)
|
||||||
api_router.include_router(sql_assistant.router)
|
api_router.include_router(sql_assistant.router)
|
||||||
|
|||||||
+20
-11
@@ -29,17 +29,27 @@ async def seed_workflows():
|
|||||||
|
|
||||||
|
|
||||||
async def seed_data():
|
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:
|
async with async_session() as db:
|
||||||
result = await db.execute(select(User).where(User.username == settings.ADMIN_USERNAME))
|
for u in default_users:
|
||||||
if not result.scalar_one_or_none():
|
result = await db.execute(select(User).where(User.username == u["username"]))
|
||||||
db.add(User(
|
if not result.scalar_one_or_none():
|
||||||
username=settings.ADMIN_USERNAME,
|
db.add(User(
|
||||||
display_name="管理员",
|
username=u["username"],
|
||||||
role="admin",
|
display_name=u["display_name"],
|
||||||
hashed_password=hash_password(settings.ADMIN_PASSWORD),
|
role=u["role"],
|
||||||
))
|
hashed_password=hash_password(u["password"]),
|
||||||
await db.commit()
|
))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -62,7 +72,6 @@ async def lifespan(app: FastAPI):
|
|||||||
import app.models.notification_template # noqa
|
import app.models.notification_template # noqa
|
||||||
import app.models.scheduled_task # noqa
|
import app.models.scheduled_task # noqa
|
||||||
import app.models.schema_revision # noqa
|
import app.models.schema_revision # noqa
|
||||||
import app.models.slow_log # noqa
|
|
||||||
import app.models.query_history # noqa
|
import app.models.query_history # noqa
|
||||||
|
|
||||||
await init_db()
|
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)
|
email = Column(String(100), unique=True, nullable=True)
|
||||||
phone = Column(String(20), nullable=True)
|
phone = Column(String(20), nullable=True)
|
||||||
hashed_password = Column(String(200), nullable=False)
|
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)
|
is_active = Column(Boolean, default=True)
|
||||||
last_login = Column(DateTime, nullable=True)
|
last_login = Column(DateTime, nullable=True)
|
||||||
created_at = Column(DateTime, server_default=func.now())
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|||||||
@@ -18,40 +18,40 @@ class NotificationSender:
|
|||||||
# 内置消息模板
|
# 内置消息模板
|
||||||
TEMPLATES = {
|
TEMPLATES = {
|
||||||
"ticket_created": {
|
"ticket_created": {
|
||||||
"title": "📝 新工单待审批: {title}",
|
"title": "新工单待审批: {title}",
|
||||||
"body": "**申请人**: {applicant}\\n**数据库**: {db_type}@{target_database}\\n**SQL摘要**: {sql_preview}\\n**优先级**: {priority}",
|
"body": "**申请人**: {applicant}\n**数据库**: {db_type}@{target_database}\n**SQL摘要**: {sql_preview}",
|
||||||
},
|
},
|
||||||
"ticket_approved": {
|
"ticket_approved": {
|
||||||
"title": "✅ 工单已审批通过: {title}",
|
"title": "工单已审批通过: {title}",
|
||||||
"body": "**审批人**: {approver}\\n**工单ID**: {ticket_id}\\n**状态**: 已批准,可执行",
|
"body": "**审批人**: {approver}\n**工单ID**: {ticket_id}\n**状态**: 已批准,可执行",
|
||||||
},
|
},
|
||||||
"ticket_rejected": {
|
"ticket_rejected": {
|
||||||
"title": "❌ 工单已被拒绝: {title}",
|
"title": "工单已被拒绝: {title}",
|
||||||
"body": "**审批人**: {approver}\\n**拒绝原因**: {comment}\\n**工单ID**: {ticket_id}",
|
"body": "**审批人**: {approver}\n**拒绝原因**: {comment}\n**工单ID**: {ticket_id}",
|
||||||
},
|
},
|
||||||
"ticket_executed": {
|
"ticket_executed": {
|
||||||
"title": "🚀 工单执行完成: {title}",
|
"title": "工单执行完成: {title}",
|
||||||
"body": "**执行结果**: {result}\\n**耗时**: {duration}ms\\n**影响行数**: {affected_rows}",
|
"body": "**执行结果**: {result}\n**耗时**: {duration}ms\n**影响行数**: {affected_rows}",
|
||||||
},
|
},
|
||||||
"ticket_failed": {
|
"ticket_failed": {
|
||||||
"title": "⚠️ 工单执行失败: {title}",
|
"title": "工单执行失败: {title}",
|
||||||
"body": "**错误信息**: {error}\\n**工单ID**: {ticket_id}\\n请及时处理",
|
"body": "**错误信息**: {error}\n**工单ID**: {ticket_id}\n请及时处理",
|
||||||
},
|
},
|
||||||
"ticket_rollback": {
|
"ticket_rollback": {
|
||||||
"title": "🔄 工单已回滚: {title}",
|
"title": "工单已回滚: {title}",
|
||||||
"body": "**回滚人**: {operator}\\n**工单ID**: {ticket_id}",
|
"body": "**回滚人**: {operator}\n**工单ID**: {ticket_id}",
|
||||||
},
|
},
|
||||||
"backup_completed": {
|
"backup_completed": {
|
||||||
"title": "💾 备份完成: {title}",
|
"title": "备份完成: {title}",
|
||||||
"body": "**数据库**: {database}\\n**文件大小**: {size}\\n**耗时**: {duration}s",
|
"body": "**数据库**: {database}\n**文件大小**: {size}\n**耗时**: {duration}s",
|
||||||
},
|
},
|
||||||
"backup_failed": {
|
"backup_failed": {
|
||||||
"title": "❌ 备份失败: {title}",
|
"title": "备份失败: {title}",
|
||||||
"body": "**错误信息**: {error}\\n请检查数据库连接和磁盘空间",
|
"body": "**错误信息**: {error}\n请检查数据库连接和磁盘空间",
|
||||||
},
|
},
|
||||||
"schema_drift_detected": {
|
"schema_drift_detected": {
|
||||||
"title": "🔍 检测到 Schema 漂移: {instance_name}",
|
"title": "检测到 Schema 漂移: {instance_name}",
|
||||||
"body": "**数据库**: {database}\\n**发现 {drift_count} 处变更**:\\n{drift_details}",
|
"body": "**数据库**: {database}\n**发现 {drift_count} 处变更**:\n{drift_details}",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +101,9 @@ class NotificationSender:
|
|||||||
title = template["title"].format(**data)
|
title = template["title"].format(**data)
|
||||||
body = template["body"].format(**data)
|
body = template["body"].format(**data)
|
||||||
|
|
||||||
|
# 钉钉 markdown 单个 \n 视为空格,需要 \n\n 才能换行
|
||||||
|
body = body.replace("\n", "\n\n")
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"msgtype": "markdown",
|
"msgtype": "markdown",
|
||||||
"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.notification import NotificationChannel
|
||||||
from app.models.ldap_config import LdapConfig
|
from app.models.ldap_config import LdapConfig
|
||||||
from app.models.backup import Backup
|
from app.models.backup import Backup
|
||||||
from app.models.slow_log import SlowLog
|
|
||||||
from app.models.scheduled_task import ScheduledTask
|
from app.models.scheduled_task import ScheduledTask
|
||||||
from app.models.schema_revision import SchemaRevision
|
from app.models.schema_revision import SchemaRevision
|
||||||
from app.models.masking_rule import MaskingRule
|
from app.models.masking_rule import MaskingRule
|
||||||
|
|||||||
@@ -177,28 +177,6 @@ class TestShowGrants:
|
|||||||
assert len(data["grants"]) >= 1
|
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:
|
class TestProcesslist:
|
||||||
async def test_show_processlist_success(self, client: AsyncClient, test_instance, admin_token):
|
async def test_show_processlist_success(self, client: AsyncClient, test_instance, admin_token):
|
||||||
mock_conn, _ = _mock_mysql_connection([
|
mock_conn, _ = _mock_mysql_connection([
|
||||||
|
|||||||
@@ -267,8 +267,8 @@ async def test_create_ldap_config_empty_password(client: AsyncClient, admin_toke
|
|||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ldap_connection_mock_success(client: AsyncClient, admin_token: str):
|
async def test_ldap_connection_real_failure(client: AsyncClient, admin_token: str):
|
||||||
"""配置存在时 POST /test 在 mock 模式下应返回 success=True"""
|
"""配置存在时 POST /test 应尝试真实连接,测试环境无 LDAP 服务器则返回 success=False"""
|
||||||
await client.put(
|
await client.put(
|
||||||
"/api/v1/ldap-config",
|
"/api/v1/ldap-config",
|
||||||
json=VALID_LDAP_PAYLOAD,
|
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
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["success"] is True
|
# 测试环境无真实 LDAP 服务器,连接应失败
|
||||||
# mock 模式消息中包含 Mock 字样
|
assert data["success"] is False
|
||||||
assert "Mock" in data["message"]
|
# 消息中应包含连接错误信息
|
||||||
# 消息中应包含主机和测试用户信息
|
assert any(kw in data["message"] for kw in ["error", "fail", "未安装", "连接失败", "LDAP"])
|
||||||
assert "zhangsan" in data["message"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Comprehensive tests for NotificationChannel CRUD, permissions, config masking, and test-send."""
|
"""Comprehensive tests for NotificationChannel CRUD, permissions, config masking, and test-send."""
|
||||||
import pytest
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from tests.conftest import auth_header
|
from tests.conftest import auth_header
|
||||||
@@ -268,7 +269,9 @@ async def test_delete_nonexistent_channel(client: AsyncClient, admin_token: str)
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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))
|
create = await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(admin_token))
|
||||||
cid = create.json()["id"]
|
cid = create.json()["id"]
|
||||||
|
|
||||||
@@ -297,7 +300,9 @@ async def test_send_dingtalk_missing_webhook(client: AsyncClient, admin_token: s
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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))
|
create = await client.post(BASE, json=FEISHU_PAYLOAD, headers=auth_header(admin_token))
|
||||||
cid = create.json()["id"]
|
cid = create.json()["id"]
|
||||||
|
|
||||||
@@ -325,7 +330,9 @@ async def test_send_feishu_missing_webhook(client: AsyncClient, admin_token: str
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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))
|
create = await client.post(BASE, json=EMAIL_PAYLOAD, headers=auth_header(admin_token))
|
||||||
cid = create.json()["id"]
|
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
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
assert data["success"] is False
|
assert data["success"] is False
|
||||||
assert "smtp_port" in data["message"]
|
|
||||||
assert "smtp_user" in data["message"]
|
assert "smtp_user" in data["message"]
|
||||||
assert "from_addr" in data["message"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
|
||||||
from tests.conftest import auth_header
|
from tests.conftest import auth_header
|
||||||
@@ -194,13 +195,15 @@ async def test_delete_notification_channel(client: AsyncClient, admin_token: str
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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(
|
create = await client.post(
|
||||||
"/api/v1/notifications",
|
"/api/v1/notifications",
|
||||||
json={
|
json={
|
||||||
"name": "钉钉测试",
|
"name": "钉钉测试",
|
||||||
"channel_type": "dingtalk",
|
"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,
|
"is_enabled": True,
|
||||||
},
|
},
|
||||||
headers=auth_header(admin_token),
|
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
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
# Mock mode should return success
|
# 测试环境无真实 LDAP 服务器,应返回失败
|
||||||
assert data["success"] is True
|
assert data["success"] is False
|
||||||
assert "Mock" in data["message"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@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 TicketCreatePage from '@/pages/TicketCreatePage';
|
||||||
import TicketDetailPage from '@/pages/TicketDetailPage';
|
import TicketDetailPage from '@/pages/TicketDetailPage';
|
||||||
import InstanceGroupPage from '@/pages/InstanceGroupPage';
|
import InstanceGroupPage from '@/pages/InstanceGroupPage';
|
||||||
// import SlowQueryPage from '@/pages/SlowQueryPage';
|
|
||||||
|
|
||||||
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
||||||
function RouteGuard() {
|
function RouteGuard() {
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ export const instanceDetailApi = {
|
|||||||
getGrants: (id: number, user: string, host: string) =>
|
getGrants: (id: number, user: string, host: string) =>
|
||||||
request.get<any, any>('/instances/' + id + '/detail/grants', { params: { user: user, host: host } }),
|
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
|
// Processlist
|
||||||
getProcesslist: (id: number) =>
|
getProcesslist: (id: number) =>
|
||||||
request.get<any, any[]>('/instances/' + id + '/detail/processlist'),
|
request.get<any, any[]>('/instances/' + id + '/detail/processlist'),
|
||||||
|
|||||||
+28
-34
@@ -1,37 +1,31 @@
|
|||||||
import request from './request';
|
import request from './request';
|
||||||
|
|
||||||
export var redisApi = {
|
export const redisApi = {
|
||||||
getInfo: function (id: number) {
|
// Redis 信息概览
|
||||||
return request.get('/redis/' + id + '/info');
|
getInfo: (instanceId: number) =>
|
||||||
},
|
request.get<any, any>('/redis/' + instanceId + '/info'),
|
||||||
getDbsize: function (id: number, db: number) {
|
|
||||||
return request.get('/redis/' + id + '/dbsize', { params: { db: db } });
|
// DBSIZE
|
||||||
},
|
getDbsize: (instanceId: number, db?: number) =>
|
||||||
getKeys: function (id: number, params: Record<string, any>) {
|
request.get<any, any>('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }),
|
||||||
return request.get('/redis/' + id + '/keys', { params: params });
|
|
||||||
},
|
// 键列表
|
||||||
getKeyDetail: function (id: number, key: string, db: number) {
|
scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) =>
|
||||||
return request.get('/redis/' + id + '/keys/' + encodeURIComponent(key), { params: { db: db } });
|
request.get<any, any>('/redis/' + instanceId + '/keys', { params }),
|
||||||
},
|
|
||||||
createKey: function (id: number, data: Record<string, any>) {
|
// 键详情
|
||||||
return request.post('/redis/' + id + '/keys', data);
|
getKeyDetail: (instanceId: number, key: string, db?: number) =>
|
||||||
},
|
request.get<any, any>('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }),
|
||||||
deleteKey: function (id: number, key: string, db: number) {
|
|
||||||
return request.delete('/redis/' + id + '/keys/' + encodeURIComponent(key), { params: { db: db } });
|
// 慢日志
|
||||||
},
|
getSlowlog: (instanceId: number, count?: number) =>
|
||||||
setExpire: function (id: number, key: string, data: Record<string, any>) {
|
request.get<any, any>('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }),
|
||||||
return request.put('/redis/' + id + '/keys/' + encodeURIComponent(key) + '/expire', data);
|
|
||||||
},
|
// 客户端列表
|
||||||
flushDb: function (id: number, db: number) {
|
getClients: (instanceId: number) =>
|
||||||
return request.delete('/redis/' + id + '/flushdb', { params: { db: db } });
|
request.get<any, any>('/redis/' + instanceId + '/clients'),
|
||||||
},
|
|
||||||
executeCommand: function (id: number, data: Record<string, any>) {
|
// 执行命令
|
||||||
return request.post('/redis/' + id + '/command', data);
|
executeCommand: (instanceId: number, data: { db: number; command: string }) =>
|
||||||
},
|
request.post<any, any>('/redis/' + instanceId + '/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');
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
@@ -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'],
|
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',
|
id: 'table-size',
|
||||||
name: '查询表大小',
|
name: '查询表大小',
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
Filter, Eye, BarChart3,
|
Filter, Eye, BarChart3,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 type { DBInstance } from '@/types';
|
||||||
import { useTheme } from '@/hooks/useTheme';
|
import { useTheme } from '@/hooks/useTheme';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
@@ -159,14 +159,7 @@ export default function Dashboard() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dashboardApi.getStats()
|
dashboardApi.getStats()
|
||||||
.then(data => setStats(data))
|
.then(data => setStats(data))
|
||||||
.catch(() => {
|
.catch(() => setStats(null))
|
||||||
/* 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,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.finally(() => setLoading(false));
|
.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: 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: 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: 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' }) => {
|
const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
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 { instanceDetailApi } from '@/api/instanceDetail';
|
||||||
import { instanceApi } from '@/api/instance';
|
import { instanceApi } from '@/api/instance';
|
||||||
import { queryApi } from '@/api/query';
|
import { queryApi } from '@/api/query';
|
||||||
@@ -10,7 +10,7 @@ interface InstanceInfo {
|
|||||||
status: string; version: string | null; env: string;
|
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() {
|
export default function InstanceDetailPage() {
|
||||||
var params = useParams<{ id: string }>();
|
var params = useParams<{ id: string }>();
|
||||||
@@ -23,7 +23,6 @@ export default function InstanceDetailPage() {
|
|||||||
var [selectedDb, setSelectedDb] = useState('');
|
var [selectedDb, setSelectedDb] = useState('');
|
||||||
var [tables, setTables] = useState<any[]>([]);
|
var [tables, setTables] = useState<any[]>([]);
|
||||||
var [users, setUsers] = useState<any[]>([]);
|
var [users, setUsers] = useState<any[]>([]);
|
||||||
var [slowQueries, setSlowQueries] = useState<any[]>([]);
|
|
||||||
var [processes, setProcesses] = useState<any[]>([]);
|
var [processes, setProcesses] = useState<any[]>([]);
|
||||||
var [sqlText, setSqlText] = useState('');
|
var [sqlText, setSqlText] = useState('');
|
||||||
var [queryResult, setQueryResult] = useState<any>(null);
|
var [queryResult, setQueryResult] = useState<any>(null);
|
||||||
@@ -34,7 +33,6 @@ export default function InstanceDetailPage() {
|
|||||||
{ key: 'tables', label: '表管理', icon: Table2 },
|
{ key: 'tables', label: '表管理', icon: Table2 },
|
||||||
{ key: 'query', label: 'SQL查询', icon: Play },
|
{ key: 'query', label: 'SQL查询', icon: Play },
|
||||||
{ key: 'users', label: '用户权限', icon: Users },
|
{ key: 'users', label: '用户权限', icon: Users },
|
||||||
{ key: 'slow', label: '慢查询', icon: Clock },
|
|
||||||
{ key: 'process', label: '进程列表', icon: Cpu },
|
{ key: 'process', label: '进程列表', icon: Cpu },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -57,11 +55,6 @@ export default function InstanceDetailPage() {
|
|||||||
setUsers(res.users || res || []);
|
setUsers(res.users || res || []);
|
||||||
}).catch(function () { setUsers([]); });
|
}).catch(function () { setUsers([]); });
|
||||||
}
|
}
|
||||||
if (activeTab === 'slow') {
|
|
||||||
instanceDetailApi.getSlowQueries(instanceId, selectedDb).then(function (res: any) {
|
|
||||||
setSlowQueries(res.queries || res || []);
|
|
||||||
}).catch(function () { setSlowQueries([]); });
|
|
||||||
}
|
|
||||||
if (activeTab === 'process') {
|
if (activeTab === 'process') {
|
||||||
instanceDetailApi.getProcesslist(instanceId).then(function (res: any) {
|
instanceDetailApi.getProcesslist(instanceId).then(function (res: any) {
|
||||||
setProcesses(res.processes || res || []);
|
setProcesses(res.processes || res || []);
|
||||||
@@ -236,28 +229,6 @@ export default function InstanceDetailPage() {
|
|||||||
</div>
|
</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' && (
|
{activeTab === 'process' && (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ const PERMISSION_CATEGORIES: PermissionCategory[] = [
|
|||||||
label: '管理权限',
|
label: '管理权限',
|
||||||
items: [
|
items: [
|
||||||
{ code: 'instance:connect', name: '连接实例' },
|
{ code: 'instance:connect', name: '连接实例' },
|
||||||
{ code: 'instance:view_slow_query', name: '查看慢查询' },
|
|
||||||
{ code: 'instance:kill_process', name: '终止进程' },
|
{ code: 'instance:kill_process', name: '终止进程' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
CheckCircle2, MinusCircle, Sparkles, ChevronDown,
|
CheckCircle2, MinusCircle, Sparkles, ChevronDown,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { permissionTreeData } from '@/lib/mock';
|
import { permissionTreeData } from '@/lib/constants';
|
||||||
|
|
||||||
/* ───────── Types ───────── */
|
/* ───────── 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 {
|
import {
|
||||||
ChevronRight, Check, AlertTriangle, Shield, Database,
|
ChevronRight, Check, AlertTriangle, Shield, Database,
|
||||||
FileText, Sparkles, RotateCcw, Loader2, Info,
|
FileText, Sparkles, RotateCcw, Loader2, Info,
|
||||||
|
ChevronDown, ChevronUp, Table2, Columns3, FolderOpen,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ticketApi } from '@/api/ticket';
|
import { ticketApi } from '@/api/ticket';
|
||||||
import { instanceApi } from '@/api/instance';
|
import { instanceApi } from '@/api/instance';
|
||||||
@@ -39,6 +40,12 @@ interface StepConfig {
|
|||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TableMeta {
|
||||||
|
name: string;
|
||||||
|
rows?: number;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Constants */
|
/* Constants */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -79,6 +86,24 @@ const PRIORITY_OPTIONS = [
|
|||||||
{ value: 'high', label: '高' },
|
{ 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 */
|
/* Helper: generate rollback SQL suggestion */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -86,7 +111,6 @@ const PRIORITY_OPTIONS = [
|
|||||||
function generateRollbackSuggestion(sql: string): string {
|
function generateRollbackSuggestion(sql: string): string {
|
||||||
const upper = sql.toUpperCase().trim();
|
const upper = sql.toUpperCase().trim();
|
||||||
|
|
||||||
// UPDATE -> generate reverse UPDATE
|
|
||||||
if (upper.startsWith('UPDATE')) {
|
if (upper.startsWith('UPDATE')) {
|
||||||
const tableMatch = sql.match(/UPDATE\s+(\w+)/i);
|
const tableMatch = sql.match(/UPDATE\s+(\w+)/i);
|
||||||
const table = tableMatch ? tableMatch[1] : 'table';
|
const table = tableMatch ? tableMatch[1] : 'table';
|
||||||
@@ -95,19 +119,16 @@ function generateRollbackSuggestion(sql: string): string {
|
|||||||
return `-- 建议回滚语句(请根据实际业务调整):\n-- UPDATE ${table} SET ... WHERE ${where || '...'};`;
|
return `-- 建议回滚语句(请根据实际业务调整):\n-- UPDATE ${table} SET ... WHERE ${where || '...'};`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// DELETE -> generate INSERT from backup
|
|
||||||
if (upper.startsWith('DELETE')) {
|
if (upper.startsWith('DELETE')) {
|
||||||
const tableMatch = sql.match(/FROM\s+(\w+)/i) || sql.match(/DELETE\s+(\w+)/i);
|
const tableMatch = sql.match(/FROM\s+(\w+)/i) || sql.match(/DELETE\s+(\w+)/i);
|
||||||
const table = tableMatch ? tableMatch[1] : 'table';
|
const table = tableMatch ? tableMatch[1] : 'table';
|
||||||
return `-- 建议回滚语句:\n-- INSERT INTO ${table} SELECT * FROM ${table}_backup WHERE ...;\n-- 或从 Binlog 恢复`;
|
return `-- 建议回滚语句:\n-- INSERT INTO ${table} SELECT * FROM ${table}_backup WHERE ...;\n-- 或从 Binlog 恢复`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ALTER -> generate reverse ALTER
|
|
||||||
if (upper.startsWith('ALTER')) {
|
if (upper.startsWith('ALTER')) {
|
||||||
return `-- DDL 回滚说明:\n-- 1. ALTER TABLE 操作无法直接回滚\n-- 2. 建议先使用 pt-online-schema-change 或 gh-ost 进行在线变更\n-- 3. 执行前请确保有完整备份`;
|
return `-- DDL 回滚说明:\n-- 1. ALTER TABLE 操作无法直接回滚\n-- 2. 建议先使用 pt-online-schema-change 或 gh-ost 进行在线变更\n-- 3. 执行前请确保有完整备份`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// INSERT -> generate DELETE
|
|
||||||
if (upper.startsWith('INSERT')) {
|
if (upper.startsWith('INSERT')) {
|
||||||
const tableMatch = sql.match(/INTO\s+(\w+)/i);
|
const tableMatch = sql.match(/INTO\s+(\w+)/i);
|
||||||
const table = tableMatch ? tableMatch[1] : 'table';
|
const table = tableMatch ? tableMatch[1] : 'table';
|
||||||
@@ -130,6 +151,7 @@ export default function TicketCreatePage() {
|
|||||||
// -- Form fields --
|
// -- Form fields --
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
const [priority, setPriority] = useState('medium');
|
const [priority, setPriority] = useState('medium');
|
||||||
|
const [dbType, setDbType] = useState('');
|
||||||
const [instanceId, setInstanceId] = useState('');
|
const [instanceId, setInstanceId] = useState('');
|
||||||
const [database, setDatabase] = useState('');
|
const [database, setDatabase] = useState('');
|
||||||
const [sqlContent, setSqlContent] = useState('');
|
const [sqlContent, setSqlContent] = useState('');
|
||||||
@@ -148,6 +170,14 @@ export default function TicketCreatePage() {
|
|||||||
// -- Audit --
|
// -- Audit --
|
||||||
const [auditResult, setAuditResult] = useState<AuditResult | null>(null);
|
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 */
|
/* Fetch instances on mount */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
@@ -163,6 +193,39 @@ export default function TicketCreatePage() {
|
|||||||
.finally(() => setLoadingInstances(false));
|
.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 */
|
/* Fetch databases when instance changes */
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
@@ -173,6 +236,7 @@ export default function TicketCreatePage() {
|
|||||||
setDatabase('');
|
setDatabase('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const inst = instances.find(i => i.id === Number(instanceId));
|
||||||
setLoadingDatabases(true);
|
setLoadingDatabases(true);
|
||||||
instanceApi.listDatabases(Number(instanceId))
|
instanceApi.listDatabases(Number(instanceId))
|
||||||
.then((res: unknown) => {
|
.then((res: unknown) => {
|
||||||
@@ -182,14 +246,80 @@ export default function TicketCreatePage() {
|
|||||||
setDatabases(Array.isArray(data) ? data as string[] : []);
|
setDatabases(Array.isArray(data) ? data as string[] : []);
|
||||||
if (Array.isArray(data) && data.length > 0) {
|
if (Array.isArray(data) && data.length > 0) {
|
||||||
setDatabase(String(data[0]));
|
setDatabase(String(data[0]));
|
||||||
|
} else if (inst?.db_type === 'redis') {
|
||||||
|
// Redis 默认 db0
|
||||||
|
setDatabase('db0');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// Fallback: no auto database list
|
|
||||||
setDatabases([]);
|
setDatabases([]);
|
||||||
|
// Redis fallback
|
||||||
|
if (inst?.db_type === 'redis') {
|
||||||
|
setDatabase('db0');
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.finally(() => setLoadingDatabases(false));
|
.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 */
|
/* SQL change callback */
|
||||||
@@ -197,7 +327,6 @@ export default function TicketCreatePage() {
|
|||||||
|
|
||||||
const handleSqlChange = useCallback((val: string) => {
|
const handleSqlChange = useCallback((val: string) => {
|
||||||
setSqlContent(val);
|
setSqlContent(val);
|
||||||
// Auto-generate rollback suggestion when SQL changes
|
|
||||||
if (val.trim().length > 10) {
|
if (val.trim().length > 10) {
|
||||||
setRollbackSql(generateRollbackSuggestion(val));
|
setRollbackSql(generateRollbackSuggestion(val));
|
||||||
}
|
}
|
||||||
@@ -255,7 +384,7 @@ export default function TicketCreatePage() {
|
|||||||
const inst = instances.find((i) => i.id === Number(instanceId));
|
const inst = instances.find((i) => i.id === Number(instanceId));
|
||||||
await ticketApi.create({
|
await ticketApi.create({
|
||||||
title: title.trim(),
|
title: title.trim(),
|
||||||
db_type: inst?.db_type || 'mysql',
|
db_type: inst?.db_type || dbType || 'mysql',
|
||||||
instance_id: Number(instanceId),
|
instance_id: Number(instanceId),
|
||||||
target_database: database,
|
target_database: database,
|
||||||
sql_content: sqlContent,
|
sql_content: sqlContent,
|
||||||
@@ -270,7 +399,7 @@ export default function TicketCreatePage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, navigate]);
|
}, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, dbType, navigate]);
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- */
|
/* ---------------------------------------------------------------- */
|
||||||
/* Derived values */
|
/* Derived values */
|
||||||
@@ -336,6 +465,106 @@ export default function TicketCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null;
|
) : 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 ========== */
|
/* ========== Step 1: Fill info ========== */
|
||||||
const renderStep1 = () => (
|
const renderStep1 = () => (
|
||||||
<div className="space-y-5 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
<div className="space-y-5 bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||||
@@ -352,7 +581,7 @@ export default function TicketCreatePage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Priority + Instance */}
|
{/* Priority + DB Type */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">优先级</label>
|
<label className="block text-sm font-medium mb-2">优先级</label>
|
||||||
@@ -369,32 +598,65 @@ export default function TicketCreatePage() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">
|
<label className="block text-sm font-medium mb-2">
|
||||||
选择实例 <span className="text-red-400">*</span>
|
数据库类型 <span className="text-red-400">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={instanceId}
|
value={dbType}
|
||||||
onChange={(e) => setInstanceId(e.target.value)}
|
onChange={(e) => setDbType(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"
|
||||||
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="">
|
{DB_TYPE_OPTIONS.map((opt) => (
|
||||||
{loadingInstances ? '加载中...' : '请选择实例'}
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
</option>
|
|
||||||
{instances.map((inst) => (
|
|
||||||
<option key={inst.id} value={String(inst.id)}>
|
|
||||||
{inst.name} ({inst.host}:{inst.port}) [{inst.db_type.toUpperCase()}]
|
|
||||||
</option>
|
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* Database */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">
|
<label className="block text-sm font-medium mb-2">
|
||||||
数据库 <span className="text-red-400">*</span>
|
数据库 <span className="text-red-400">*</span>
|
||||||
</label>
|
</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
|
<select
|
||||||
value={database}
|
value={database}
|
||||||
onChange={(e) => setDatabase(e.target.value)}
|
onChange={(e) => setDatabase(e.target.value)}
|
||||||
@@ -410,22 +672,19 @@ export default function TicketCreatePage() {
|
|||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<input
|
||||||
<input
|
value={database}
|
||||||
value={database}
|
onChange={(e) => setDatabase(e.target.value)}
|
||||||
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"
|
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="目标数据库名"
|
placeholder={instanceId ? '请输入数据库名' : '请先选择实例'}
|
||||||
/>
|
/>
|
||||||
{selectedInstance && (
|
|
||||||
<span className="text-xs text-[var(--dg-text-muted)] whitespace-nowrap">
|
|
||||||
{selectedInstance.db_type.toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Schema Browser */}
|
||||||
|
{renderSchemaBrowser()}
|
||||||
|
|
||||||
{/* SQL Editor */}
|
{/* SQL Editor */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<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">
|
<p className="text-xs text-[var(--dg-text-muted)] mt-1.5 flex items-center gap-1">
|
||||||
<Info className="w-3 h-3" />
|
<Info className="w-3 h-3" />
|
||||||
支持 Ctrl+Shift+F 格式化代码
|
提交前必须通过 SQL 审核(语法检查 + 审核规则)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -500,12 +759,12 @@ export default function TicketCreatePage() {
|
|||||||
{auditing ? (
|
{auditing ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
审核中...
|
验证审核中...
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Shield className="w-4 h-4" />
|
<Shield className="w-4 h-4" />
|
||||||
审核并继续
|
验证并审核
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -649,7 +908,7 @@ export default function TicketCreatePage() {
|
|||||||
|
|
||||||
{/* Rollback preview */}
|
{/* Rollback preview */}
|
||||||
{rollbackSql && (
|
{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">
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
<RotateCcw className="w-3.5 h-3.5 text-purple-400" />
|
<RotateCcw className="w-3.5 h-3.5 text-purple-400" />
|
||||||
<span className="text-xs text-[var(--dg-text-muted)]">回滚 SQL</span>
|
<span className="text-xs text-[var(--dg-text-muted)]">回滚 SQL</span>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
BarChart3, ChevronDown, ChevronUp, Link2,
|
BarChart3, ChevronDown, ChevronUp, Link2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ticketApi } from '@/api/ticket';
|
import { ticketApi } from '@/api/ticket';
|
||||||
import { statusMap, statusColorMap } from '@/lib/mock';
|
import { statusMap, statusColorMap } from '@/lib/constants';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import SqlEditor from '@/components/SqlEditor';
|
import SqlEditor from '@/components/SqlEditor';
|
||||||
|
|
||||||
@@ -129,14 +129,25 @@ export default function TicketDetailPage() {
|
|||||||
}
|
}
|
||||||
}, [ticketId]);
|
}, [ticketId]);
|
||||||
|
|
||||||
// Mock related tickets fetch
|
// Fetch related tickets from API
|
||||||
const fetchRelated = useCallback(async () => {
|
const fetchRelated = useCallback(async () => {
|
||||||
// In real implementation: await ticketApi.related(ticketId)
|
try {
|
||||||
setRelatedTickets([
|
const res = await ticketApi.list({ instance_id: ticket?.instance_id, limit: 5 }) as { items?: any[] };
|
||||||
{ id: ticketId - 1, title: '用户表索引优化', status: 'success', applicant_name: '张三', create_time: '2024-01-14 10:00:00' },
|
const items = (res?.items || [])
|
||||||
{ id: ticketId + 1, title: '订单数据归档', status: 'approved', applicant_name: '李四', create_time: '2024-01-15 14:00:00' },
|
.filter((t: any) => t.id !== ticketId)
|
||||||
]);
|
.slice(0, 5)
|
||||||
}, [ticketId]);
|
.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(() => {
|
useEffect(() => {
|
||||||
if (!isNaN(ticketId)) {
|
if (!isNaN(ticketId)) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
CheckSquare, Square, ArrowUpDown, FileDown, Ban,
|
CheckSquare, Square, ArrowUpDown, FileDown, Ban,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
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 { useActionPermission } from '@/hooks/useAuth';
|
||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
Loader2, Check, XCircle, Star,
|
Loader2, Check, XCircle, Star,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { dbTypeColorMap } from '@/lib/mock';
|
import { dbTypeColorMap } from '@/lib/constants';
|
||||||
import { workflowApi } from '@/api/workflow';
|
import { workflowApi } from '@/api/workflow';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|||||||
Reference in new issue
Block a user