From 8471f368eb02cac1adc2fb4e3475c4730ebfc06a Mon Sep 17 00:00:00 2001 From: logan Date: Sun, 24 May 2026 14:08:45 +0800 Subject: [PATCH] init --- backend/app/api/v1/endpoints/audit_rule.py | 6 +- backend/app/api/v1/endpoints/db_types.py | 1 - .../app/api/v1/endpoints/instance_detail.py | 36 - backend/app/api/v1/endpoints/ldap_config.py | 10 +- backend/app/api/v1/endpoints/notification.py | 41 +- backend/app/api/v1/endpoints/slow_log.py | 103 -- backend/app/api/v1/router.py | 3 +- backend/app/main.py | 31 +- backend/app/models/slow_log.py | 21 - backend/app/models/user.py | 2 +- backend/app/services/notification_sender.py | 39 +- backend/app/services/slow_log_collector.py | 269 ----- backend/tests/conftest.py | 1 - backend/tests/test_instance_detail.py | 22 - backend/tests/test_ldap_config.py | 13 +- backend/tests/test_notification.py | 15 +- backend/tests/test_settings.py | 12 +- backend/tests/test_slow_log.py | 433 -------- frontend/src/App.tsx | 1 - frontend/src/api/instanceDetail.ts | 4 - frontend/src/api/redis.ts | 62 +- frontend/src/api/slowLog.ts | 17 - frontend/src/lib/{mock.ts => constants.ts} | 0 frontend/src/lib/queryTemplates.ts | 12 - frontend/src/pages/Dashboard.tsx | 12 +- frontend/src/pages/InstanceDetailPage.tsx | 33 +- frontend/src/pages/InstanceGroupPage.tsx | 1 - frontend/src/pages/RolesPage.tsx | 2 +- frontend/src/pages/SlowQueryPage.tsx | 967 ------------------ frontend/src/pages/TicketCreatePage.tsx | 339 +++++- frontend/src/pages/TicketDetailPage.tsx | 27 +- frontend/src/pages/TicketsPage.tsx | 2 +- frontend/src/pages/WorkflowPage.tsx | 2 +- 33 files changed, 444 insertions(+), 2095 deletions(-) delete mode 100644 backend/app/api/v1/endpoints/slow_log.py delete mode 100644 backend/app/models/slow_log.py delete mode 100644 backend/app/services/slow_log_collector.py delete mode 100644 backend/tests/test_slow_log.py delete mode 100644 frontend/src/api/slowLog.ts rename frontend/src/lib/{mock.ts => constants.ts} (100%) delete mode 100644 frontend/src/pages/SlowQueryPage.tsx diff --git a/backend/app/api/v1/endpoints/audit_rule.py b/backend/app/api/v1/endpoints/audit_rule.py index 912e440..6550b0c 100644 --- a/backend/app/api/v1/endpoints/audit_rule.py +++ b/backend/app/api/v1/endpoints/audit_rule.py @@ -23,7 +23,7 @@ async def list_audit_rules( db_type: Optional[str] = None, is_enabled: Optional[bool] = None, db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_role("admin", "dba")), + current_user: User = Depends(require_role("admin", "dba", "auditor")), ): stmt = select(AuditRule).order_by(AuditRule.id) if rule_type: @@ -62,7 +62,7 @@ async def create_audit_rule( @router.get("/stats/summary") async def rule_stats( db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_role("admin", "dba")), + current_user: User = Depends(require_role("admin", "dba", "auditor")), ): total_stmt = select(func.count(AuditRule.id)) total = (await db.execute(total_stmt)).scalar() @@ -130,7 +130,7 @@ async def seed_audit_rules( async def get_audit_rule( rule_id: int, db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_role("admin", "dba")), + current_user: User = Depends(require_role("admin", "dba", "auditor")), ): result = await db.execute(select(AuditRule).where(AuditRule.id == rule_id)) rule = result.scalar_one_or_none() diff --git a/backend/app/api/v1/endpoints/db_types.py b/backend/app/api/v1/endpoints/db_types.py index a059049..817e46e 100644 --- a/backend/app/api/v1/endpoints/db_types.py +++ b/backend/app/api/v1/endpoints/db_types.py @@ -49,7 +49,6 @@ DB_TYPE_INFO: Dict[str, Dict[str, Any]] = { "查看索引": "SHOW INDEX FROM table_name", "查看连接": "SHOW PROCESSLIST", "查看状态": "SHOW STATUS LIKE 'Threads%'", - "查看慢查询": "SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 20", }, "limitations": [ "不支持存储过程调用", diff --git a/backend/app/api/v1/endpoints/instance_detail.py b/backend/app/api/v1/endpoints/instance_detail.py index 4d478b1..39b6bc0 100644 --- a/backend/app/api/v1/endpoints/instance_detail.py +++ b/backend/app/api/v1/endpoints/instance_detail.py @@ -217,42 +217,6 @@ async def show_grants( raise HTTPException(status_code=500, detail="查询权限失败: " + str(e)) -# ---- Slow Queries ---- - -@router.get("/{instance_id}/detail/slow-queries") -async def slow_queries( - instance_id: int, - database: Optional[str] = Query(None, description="数据库名"), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(require_role("admin", "dba")), -): - instance = await _get_decrypted_instance(db, instance_id) - # P0: 权限检查 - await check_instance_access(current_user, instance, db) - if instance.db_type != "mysql": - raise HTTPException(status_code=400, detail="仅支持MySQL实例") - try: - # Try querying mysql.slow_log table first - try: - sql = ( - "SELECT start_time, user_host, query_time, lock_time, " - "rows_sent, rows_examined, sql_text " - "FROM mysql.slow_log ORDER BY start_time DESC LIMIT 100" - ) - rows = await _mysql_query(instance, sql, database=database) - return rows - except Exception: - # Fallback: show slow query variables - rows = await _mysql_query( - instance, - "SHOW VARIABLES LIKE 'slow%'", - database=database, - ) - return rows - except Exception as e: - raise HTTPException(status_code=500, detail="查询慢查询失败: " + str(e)) - - # ---- Processlist ---- @router.get("/{instance_id}/detail/processlist") diff --git a/backend/app/api/v1/endpoints/ldap_config.py b/backend/app/api/v1/endpoints/ldap_config.py index f867cab..cc8209e 100644 --- a/backend/app/api/v1/endpoints/ldap_config.py +++ b/backend/app/api/v1/endpoints/ldap_config.py @@ -100,12 +100,11 @@ async def test_ldap_connection( db: AsyncSession = Depends(get_db), current_user: User = Depends(require_role("admin")), ): - """测试 LDAP 连接 — 使用 mock 模拟""" + """测试 LDAP 连接""" ldap = await _get_existing(db) if not ldap: raise HTTPException(status_code=404, detail="LDAP 配置不存在,请先创建") - # Use mock for testing — python-ldap may not be installed try: import ldap as _ldap # noqa # Real LDAP test path @@ -131,12 +130,9 @@ async def test_ldap_connection( return {"success": True, "message": f"LDAP 连接成功,用户 {data.test_user} 验证通过"} except ImportError: - # Mock path — python-ldap not installed - host = ldap.ldap_host - port = ldap.ldap_port return { - "success": True, - "message": f"[Mock] LDAP 连接模拟成功 ({host}:{port}),用户 {data.test_user} 验证通过", + "success": False, + "message": "python-ldap 未安装,无法测试 LDAP 连接。请执行: pip install python-ldap", } except Exception as e: return {"success": False, "message": f"LDAP 连接失败: {str(e)}"} diff --git a/backend/app/api/v1/endpoints/notification.py b/backend/app/api/v1/endpoints/notification.py index 7ab9c79..6532c1b 100644 --- a/backend/app/api/v1/endpoints/notification.py +++ b/backend/app/api/v1/endpoints/notification.py @@ -124,7 +124,9 @@ async def test_notification( db: AsyncSession = Depends(get_db), current_user: User = Depends(require_role("admin")), ): - """测试发送通知 — 目前返回模拟结果""" + """测试发送通知 — 实际发送测试消息到配置的渠道。""" + from app.services.notification_sender import NotificationSender + result = await db.execute( select(NotificationChannel).where(NotificationChannel.id == channel_id) ) @@ -133,24 +135,19 @@ async def test_notification( raise HTTPException(status_code=404, detail="通知渠道不存在") config = json.loads(channel.config) if channel.config else {} - channel_type = channel.channel_type - - # Simulated test — in production this would actually send a message - if channel_type == "dingtalk": - if not config.get("webhook_url"): - return {"success": False, "message": "缺少 webhook_url 配置"} - return {"success": True, "message": f"钉钉测试消息已发送至 {config['webhook_url'][:30]}..."} - - elif channel_type == "webhook_feishu": - if not config.get("webhook_url"): - return {"success": False, "message": "缺少 webhook_url 配置"} - return {"success": True, "message": f"飞书测试消息已发送至 {config['webhook_url'][:30]}..."} - - elif channel_type == "email": - required = ["smtp_host", "smtp_port", "smtp_user", "from_addr"] - missing = [k for k in required if not config.get(k)] - if missing: - return {"success": False, "message": f"缺少必要配置: {', '.join(missing)}"} - return {"success": True, "message": f"邮件测试已发送: {config['smtp_user']} -> test@example.com"} - - return {"success": False, "message": f"不支持的通知类型: {channel_type}"} + sender = NotificationSender() + test_data = { + "title": "测试通知", + "applicant": current_user.display_name or current_user.username, + "db_type": "mysql", + "target_database": "test_db", + "sql_preview": "SELECT 1", + "priority": "normal", + "approver": "系统", + "ticket_id": "TEST", + "result": "成功", + "duration": "10", + "affected_rows": "0", + } + result = await sender.send(channel.channel_type, config, "ticket_created", test_data) + return result diff --git a/backend/app/api/v1/endpoints/slow_log.py b/backend/app/api/v1/endpoints/slow_log.py deleted file mode 100644 index e069ffd..0000000 --- a/backend/app/api/v1/endpoints/slow_log.py +++ /dev/null @@ -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], - } diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 200b230..2ee22f1 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -5,7 +5,7 @@ from app.api.v1.endpoints import ( announcement, notification, ldap_config, instance_detail, backup, redis, dictionary, instance_group, masking_rule, rollback, - workflow, impact, schema_drift, slow_log, pool, + workflow, impact, schema_drift, pool, mongo, query_history, sql_assistant, db_types, settings, ) @@ -34,7 +34,6 @@ api_router.include_router(rollback.router) api_router.include_router(workflow.router) api_router.include_router(impact.router) api_router.include_router(schema_drift.router) -api_router.include_router(slow_log.router) api_router.include_router(pool.router) api_router.include_router(query_history.router) api_router.include_router(sql_assistant.router) diff --git a/backend/app/main.py b/backend/app/main.py index d2ee7d4..645596c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -29,17 +29,27 @@ async def seed_workflows(): async def seed_data(): - """Create default admin user from environment config.""" + """Create default users with different roles.""" + from app.core.security import hash_password + + default_users = [ + {"username": "admin", "display_name": "超级管理员", "role": "admin", "password": "admin123"}, + {"username": "dba", "display_name": "张三", "role": "dba", "password": "admin123"}, + {"username": "auditor", "display_name": "老六", "role": "auditor", "password": "admin123"}, + {"username": "developer", "display_name": "路人甲", "role": "developer", "password": "admin123"}, + ] + async with async_session() as db: - result = await db.execute(select(User).where(User.username == settings.ADMIN_USERNAME)) - if not result.scalar_one_or_none(): - db.add(User( - username=settings.ADMIN_USERNAME, - display_name="管理员", - role="admin", - hashed_password=hash_password(settings.ADMIN_PASSWORD), - )) - await db.commit() + for u in default_users: + result = await db.execute(select(User).where(User.username == u["username"])) + if not result.scalar_one_or_none(): + db.add(User( + username=u["username"], + display_name=u["display_name"], + role=u["role"], + hashed_password=hash_password(u["password"]), + )) + await db.commit() @asynccontextmanager @@ -62,7 +72,6 @@ async def lifespan(app: FastAPI): import app.models.notification_template # noqa import app.models.scheduled_task # noqa import app.models.schema_revision # noqa - import app.models.slow_log # noqa import app.models.query_history # noqa await init_db() diff --git a/backend/app/models/slow_log.py b/backend/app/models/slow_log.py deleted file mode 100644 index 02daf2c..0000000 --- a/backend/app/models/slow_log.py +++ /dev/null @@ -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()) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index 951300e..402ec3c 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -13,7 +13,7 @@ class User(Base): email = Column(String(100), unique=True, nullable=True) phone = Column(String(20), nullable=True) hashed_password = Column(String(200), nullable=False) - role = Column(String(20), nullable=False, default="developer") # admin / dba / developer / guest + role = Column(String(20), nullable=False, default="developer") # admin / dba / auditor / developer / guest is_active = Column(Boolean, default=True) last_login = Column(DateTime, nullable=True) created_at = Column(DateTime, server_default=func.now()) diff --git a/backend/app/services/notification_sender.py b/backend/app/services/notification_sender.py index 7cf0f23..c708472 100644 --- a/backend/app/services/notification_sender.py +++ b/backend/app/services/notification_sender.py @@ -18,40 +18,40 @@ class NotificationSender: # 内置消息模板 TEMPLATES = { "ticket_created": { - "title": "📝 新工单待审批: {title}", - "body": "**申请人**: {applicant}\\n**数据库**: {db_type}@{target_database}\\n**SQL摘要**: {sql_preview}\\n**优先级**: {priority}", + "title": "新工单待审批: {title}", + "body": "**申请人**: {applicant}\n**数据库**: {db_type}@{target_database}\n**SQL摘要**: {sql_preview}", }, "ticket_approved": { - "title": "✅ 工单已审批通过: {title}", - "body": "**审批人**: {approver}\\n**工单ID**: {ticket_id}\\n**状态**: 已批准,可执行", + "title": "工单已审批通过: {title}", + "body": "**审批人**: {approver}\n**工单ID**: {ticket_id}\n**状态**: 已批准,可执行", }, "ticket_rejected": { - "title": "❌ 工单已被拒绝: {title}", - "body": "**审批人**: {approver}\\n**拒绝原因**: {comment}\\n**工单ID**: {ticket_id}", + "title": "工单已被拒绝: {title}", + "body": "**审批人**: {approver}\n**拒绝原因**: {comment}\n**工单ID**: {ticket_id}", }, "ticket_executed": { - "title": "🚀 工单执行完成: {title}", - "body": "**执行结果**: {result}\\n**耗时**: {duration}ms\\n**影响行数**: {affected_rows}", + "title": "工单执行完成: {title}", + "body": "**执行结果**: {result}\n**耗时**: {duration}ms\n**影响行数**: {affected_rows}", }, "ticket_failed": { - "title": "⚠️ 工单执行失败: {title}", - "body": "**错误信息**: {error}\\n**工单ID**: {ticket_id}\\n请及时处理", + "title": "工单执行失败: {title}", + "body": "**错误信息**: {error}\n**工单ID**: {ticket_id}\n请及时处理", }, "ticket_rollback": { - "title": "🔄 工单已回滚: {title}", - "body": "**回滚人**: {operator}\\n**工单ID**: {ticket_id}", + "title": "工单已回滚: {title}", + "body": "**回滚人**: {operator}\n**工单ID**: {ticket_id}", }, "backup_completed": { - "title": "💾 备份完成: {title}", - "body": "**数据库**: {database}\\n**文件大小**: {size}\\n**耗时**: {duration}s", + "title": "备份完成: {title}", + "body": "**数据库**: {database}\n**文件大小**: {size}\n**耗时**: {duration}s", }, "backup_failed": { - "title": "❌ 备份失败: {title}", - "body": "**错误信息**: {error}\\n请检查数据库连接和磁盘空间", + "title": "备份失败: {title}", + "body": "**错误信息**: {error}\n请检查数据库连接和磁盘空间", }, "schema_drift_detected": { - "title": "🔍 检测到 Schema 漂移: {instance_name}", - "body": "**数据库**: {database}\\n**发现 {drift_count} 处变更**:\\n{drift_details}", + "title": "检测到 Schema 漂移: {instance_name}", + "body": "**数据库**: {database}\n**发现 {drift_count} 处变更**:\n{drift_details}", }, } @@ -101,6 +101,9 @@ class NotificationSender: title = template["title"].format(**data) body = template["body"].format(**data) + # 钉钉 markdown 单个 \n 视为空格,需要 \n\n 才能换行 + body = body.replace("\n", "\n\n") + payload = { "msgtype": "markdown", "markdown": { diff --git a/backend/app/services/slow_log_collector.py b/backend/app/services/slow_log_collector.py deleted file mode 100644 index 708b3ca..0000000 --- a/backend/app/services/slow_log_collector.py +++ /dev/null @@ -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) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 20983b2..59f3ddd 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -17,7 +17,6 @@ from app.models.announcement import Announcement from app.models.notification import NotificationChannel from app.models.ldap_config import LdapConfig from app.models.backup import Backup -from app.models.slow_log import SlowLog from app.models.scheduled_task import ScheduledTask from app.models.schema_revision import SchemaRevision from app.models.masking_rule import MaskingRule diff --git a/backend/tests/test_instance_detail.py b/backend/tests/test_instance_detail.py index 7df0b39..c599289 100644 --- a/backend/tests/test_instance_detail.py +++ b/backend/tests/test_instance_detail.py @@ -177,28 +177,6 @@ class TestShowGrants: assert len(data["grants"]) >= 1 -class TestSlowQueries: - async def test_slow_queries_success(self, client: AsyncClient, test_instance, admin_token): - mock_conn, _ = _mock_mysql_connection([ - {"start_time": "2024-01-01 00:00:00", "user_host": "root[root]@localhost", - "query_time": "00:00:05", "lock_time": "00:00:00", "rows_sent": 1, - "rows_examined": 1000, "sql_text": "SELECT * FROM big_table"}, - ]) - - mock_aiomysql = _make_aiomysql_mock() - mock_aiomysql.connect = AsyncMock(return_value=mock_conn) - - with patch.dict(sys.modules, {"aiomysql": mock_aiomysql}): - resp = await client.get( - "/api/v1/instances/" + str(test_instance.id) + "/detail/slow-queries", - params={"database": "test_db"}, - headers=auth_header(admin_token), - ) - assert resp.status_code == 200 - data = resp.json() - assert isinstance(data, list) - - class TestProcesslist: async def test_show_processlist_success(self, client: AsyncClient, test_instance, admin_token): mock_conn, _ = _mock_mysql_connection([ diff --git a/backend/tests/test_ldap_config.py b/backend/tests/test_ldap_config.py index b171066..888d6ed 100644 --- a/backend/tests/test_ldap_config.py +++ b/backend/tests/test_ldap_config.py @@ -267,8 +267,8 @@ async def test_create_ldap_config_empty_password(client: AsyncClient, admin_toke # ──────────────────────────────────────────────────────────────────── @pytest.mark.asyncio -async def test_ldap_connection_mock_success(client: AsyncClient, admin_token: str): - """配置存在时 POST /test 在 mock 模式下应返回 success=True""" +async def test_ldap_connection_real_failure(client: AsyncClient, admin_token: str): + """配置存在时 POST /test 应尝试真实连接,测试环境无 LDAP 服务器则返回 success=False""" await client.put( "/api/v1/ldap-config", json=VALID_LDAP_PAYLOAD, @@ -282,11 +282,10 @@ async def test_ldap_connection_mock_success(client: AsyncClient, admin_token: st ) assert resp.status_code == 200 data = resp.json() - assert data["success"] is True - # mock 模式消息中包含 Mock 字样 - assert "Mock" in data["message"] - # 消息中应包含主机和测试用户信息 - assert "zhangsan" in data["message"] + # 测试环境无真实 LDAP 服务器,连接应失败 + assert data["success"] is False + # 消息中应包含连接错误信息 + assert any(kw in data["message"] for kw in ["error", "fail", "未安装", "连接失败", "LDAP"]) @pytest.mark.asyncio diff --git a/backend/tests/test_notification.py b/backend/tests/test_notification.py index 2909d02..eab9cca 100644 --- a/backend/tests/test_notification.py +++ b/backend/tests/test_notification.py @@ -1,5 +1,6 @@ """Comprehensive tests for NotificationChannel CRUD, permissions, config masking, and test-send.""" import pytest +from unittest.mock import AsyncMock, patch from httpx import AsyncClient from tests.conftest import auth_header @@ -268,7 +269,9 @@ async def test_delete_nonexistent_channel(client: AsyncClient, admin_token: str) @pytest.mark.asyncio -async def test_send_dingtalk_success(client: AsyncClient, admin_token: str): +@patch("app.services.notification_sender.NotificationSender.send", new_callable=AsyncMock) +async def test_send_dingtalk_success(mock_send, client: AsyncClient, admin_token: str): + mock_send.return_value = {"success": True, "message": "钉钉测试消息发送成功"} create = await client.post(BASE, json=DINGTALK_PAYLOAD, headers=auth_header(admin_token)) cid = create.json()["id"] @@ -297,7 +300,9 @@ async def test_send_dingtalk_missing_webhook(client: AsyncClient, admin_token: s @pytest.mark.asyncio -async def test_send_feishu_success(client: AsyncClient, admin_token: str): +@patch("app.services.notification_sender.NotificationSender.send", new_callable=AsyncMock) +async def test_send_feishu_success(mock_send, client: AsyncClient, admin_token: str): + mock_send.return_value = {"success": True, "message": "飞书测试消息发送成功"} create = await client.post(BASE, json=FEISHU_PAYLOAD, headers=auth_header(admin_token)) cid = create.json()["id"] @@ -325,7 +330,9 @@ async def test_send_feishu_missing_webhook(client: AsyncClient, admin_token: str @pytest.mark.asyncio -async def test_send_email_success(client: AsyncClient, admin_token: str): +@patch("app.services.notification_sender.NotificationSender.send", new_callable=AsyncMock) +async def test_send_email_success(mock_send, client: AsyncClient, admin_token: str): + mock_send.return_value = {"success": True, "message": "邮件测试消息发送成功"} create = await client.post(BASE, json=EMAIL_PAYLOAD, headers=auth_header(admin_token)) cid = create.json()["id"] @@ -351,9 +358,7 @@ async def test_send_email_missing_smtp_config(client: AsyncClient, admin_token: assert resp.status_code == 200 data = resp.json() assert data["success"] is False - assert "smtp_port" in data["message"] assert "smtp_user" in data["message"] - assert "from_addr" in data["message"] @pytest.mark.asyncio diff --git a/backend/tests/test_settings.py b/backend/tests/test_settings.py index fbd9d94..4764215 100644 --- a/backend/tests/test_settings.py +++ b/backend/tests/test_settings.py @@ -2,6 +2,7 @@ import json import pytest +from unittest.mock import AsyncMock, patch from httpx import AsyncClient from tests.conftest import auth_header @@ -194,13 +195,15 @@ async def test_delete_notification_channel(client: AsyncClient, admin_token: str @pytest.mark.asyncio -async def test_notification_test_endpoint(client: AsyncClient, admin_token: str): +@patch("app.services.notification_sender.NotificationSender.send", new_callable=AsyncMock) +async def test_notification_test_endpoint(mock_send, client: AsyncClient, admin_token: str): + mock_send.return_value = {"success": True, "message": "钉钉测试消息发送成功"} create = await client.post( "/api/v1/notifications", json={ "name": "钉钉测试", "channel_type": "dingtalk", - "config": {"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=test"}, + "config": {"webhook_url": "https://oapi.dingtalk.com/robot/send?access_token=***"}, "is_enabled": True, }, headers=auth_header(admin_token), @@ -365,9 +368,8 @@ async def test_ldap_test_connection(client: AsyncClient, admin_token: str): ) assert resp.status_code == 200 data = resp.json() - # Mock mode should return success - assert data["success"] is True - assert "Mock" in data["message"] + # 测试环境无真实 LDAP 服务器,应返回失败 + assert data["success"] is False @pytest.mark.asyncio diff --git a/backend/tests/test_slow_log.py b/backend/tests/test_slow_log.py deleted file mode 100644 index be0ac39..0000000 --- a/backend/tests/test_slow_log.py +++ /dev/null @@ -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 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index dbcb75f..6d5f002 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,6 @@ import MongoDBDetailPage from '@/pages/MongoDBDetailPage'; import TicketCreatePage from '@/pages/TicketCreatePage'; import TicketDetailPage from '@/pages/TicketDetailPage'; import InstanceGroupPage from '@/pages/InstanceGroupPage'; -// import SlowQueryPage from '@/pages/SlowQueryPage'; // 路由守卫:未登录跳转登录页,无权限跳转仪表盘 function RouteGuard() { diff --git a/frontend/src/api/instanceDetail.ts b/frontend/src/api/instanceDetail.ts index 6521104..d613cd8 100644 --- a/frontend/src/api/instanceDetail.ts +++ b/frontend/src/api/instanceDetail.ts @@ -21,10 +21,6 @@ export const instanceDetailApi = { getGrants: (id: number, user: string, host: string) => request.get('/instances/' + id + '/detail/grants', { params: { user: user, host: host } }), - // Slow Queries - getSlowQueries: (id: number, database?: string) => - request.get('/instances/' + id + '/detail/slow-queries', { params: database ? { database: database } : {} }), - // Processlist getProcesslist: (id: number) => request.get('/instances/' + id + '/detail/processlist'), diff --git a/frontend/src/api/redis.ts b/frontend/src/api/redis.ts index 9b16388..1dfd269 100644 --- a/frontend/src/api/redis.ts +++ b/frontend/src/api/redis.ts @@ -1,37 +1,31 @@ import request from './request'; -export var redisApi = { - getInfo: function (id: number) { - return request.get('/redis/' + id + '/info'); - }, - getDbsize: function (id: number, db: number) { - return request.get('/redis/' + id + '/dbsize', { params: { db: db } }); - }, - getKeys: function (id: number, params: Record) { - return request.get('/redis/' + id + '/keys', { params: params }); - }, - getKeyDetail: function (id: number, key: string, db: number) { - return request.get('/redis/' + id + '/keys/' + encodeURIComponent(key), { params: { db: db } }); - }, - createKey: function (id: number, data: Record) { - return request.post('/redis/' + id + '/keys', data); - }, - deleteKey: function (id: number, key: string, db: number) { - return request.delete('/redis/' + id + '/keys/' + encodeURIComponent(key), { params: { db: db } }); - }, - setExpire: function (id: number, key: string, data: Record) { - return request.put('/redis/' + id + '/keys/' + encodeURIComponent(key) + '/expire', data); - }, - flushDb: function (id: number, db: number) { - return request.delete('/redis/' + id + '/flushdb', { params: { db: db } }); - }, - executeCommand: function (id: number, data: Record) { - return request.post('/redis/' + id + '/command', data); - }, - getSlowlog: function (id: number, count: number) { - return request.get('/redis/' + id + '/slowlog', { params: { count: count } }); - }, - getClients: function (id: number) { - return request.get('/redis/' + id + '/clients'); - }, +export const redisApi = { + // Redis 信息概览 + getInfo: (instanceId: number) => + request.get('/redis/' + instanceId + '/info'), + + // DBSIZE + getDbsize: (instanceId: number, db?: number) => + request.get('/redis/' + instanceId + '/dbsize', { params: { db: db ?? 0 } }), + + // 键列表 + scanKeys: (instanceId: number, params: { db?: number; pattern?: string; cursor?: number; count?: number; type?: string }) => + request.get('/redis/' + instanceId + '/keys', { params }), + + // 键详情 + getKeyDetail: (instanceId: number, key: string, db?: number) => + request.get('/redis/' + instanceId + '/keys/' + encodeURIComponent(key), { params: { db: db ?? 0 } }), + + // 慢日志 + getSlowlog: (instanceId: number, count?: number) => + request.get('/redis/' + instanceId + '/slowlog', { params: { count: count ?? 10 } }), + + // 客户端列表 + getClients: (instanceId: number) => + request.get('/redis/' + instanceId + '/clients'), + + // 执行命令 + executeCommand: (instanceId: number, data: { db: number; command: string }) => + request.post('/redis/' + instanceId + '/command', data), }; diff --git a/frontend/src/api/slowLog.ts b/frontend/src/api/slowLog.ts deleted file mode 100644 index 51ae6a0..0000000 --- a/frontend/src/api/slowLog.ts +++ /dev/null @@ -1,17 +0,0 @@ -import request from './request'; - -export const slowLogApi = { - list: (instanceId: number) => - request.get(`/slow-logs/instances/${instanceId}`), - - collect: (instanceId: number, database: string = '', limit: number = 50) => - request.post(`/slow-logs/instances/${instanceId}/collect`, null, { - params: { database, limit }, - }), - - report: (instanceId: number) => - request.get(`/slow-logs/instances/${instanceId}/report`), - - topQueries: () => - request.get('/slow-logs/dashboard/top'), -}; diff --git a/frontend/src/lib/mock.ts b/frontend/src/lib/constants.ts similarity index 100% rename from frontend/src/lib/mock.ts rename to frontend/src/lib/constants.ts diff --git a/frontend/src/lib/queryTemplates.ts b/frontend/src/lib/queryTemplates.ts index ac4c41d..260b5b6 100644 --- a/frontend/src/lib/queryTemplates.ts +++ b/frontend/src/lib/queryTemplates.ts @@ -28,18 +28,6 @@ export const queryTemplates: QueryTemplate[] = [ }, supportedTypes: ['mysql', 'postgresql', 'mongodb'], }, - { - id: 'slow-queries', - name: '查询慢SQL', - description: '查看慢查询日志,定位执行时间较长的SQL语句', - category: '性能分析', - sql: { - mysql: 'SELECT * FROM mysql.slow_log LIMIT 20;', - postgresql: - 'SELECT * FROM pg_stat_statements ORDER BY total_time DESC LIMIT 20;', - }, - supportedTypes: ['mysql', 'postgresql'], - }, { id: 'table-size', name: '查询表大小', diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 0cef163..ae5b9dc 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -7,7 +7,7 @@ import { Filter, Eye, BarChart3, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/mock'; +import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants'; import type { DBInstance } from '@/types'; import { useTheme } from '@/hooks/useTheme'; import { useAuth } from '@/hooks/useAuth'; @@ -159,14 +159,7 @@ export default function Dashboard() { useEffect(() => { dashboardApi.getStats() .then(data => setStats(data)) - .catch(() => { - /* fallback: mock data */ - setStats({ - ticket_total: 128, ticket_pending: 23, ticket_month: 342, - ticket_executing: 3, instance_total: 11, instance_online: 8, - user_total: 7, queries_today: 15234, - }); - }) + .catch(() => setStats(null)) .finally(() => setLoading(false)); }, []); @@ -348,7 +341,6 @@ export default function Dashboard() { { label: '新建工单', icon: FileText, href: '/tickets', color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' }, { label: '新建查询', icon: Search, href: '/queries', color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' }, { label: '添加实例', icon: Database, href: '/instances', color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' }, - { label: '查看报表', icon: BarChart3, href: '/slow-query', color: 'text-orange-400', bg: 'bg-orange-400/10', border: 'border-orange-400/20' }, ]; const TrendIcon = ({ trend }: { trend: 'up' | 'down' | 'neutral' }) => { diff --git a/frontend/src/pages/InstanceDetailPage.tsx b/frontend/src/pages/InstanceDetailPage.tsx index b0f8be8..a98a31d 100644 --- a/frontend/src/pages/InstanceDetailPage.tsx +++ b/frontend/src/pages/InstanceDetailPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import { ArrowLeft, Database, Table2, Users, Clock, Cpu, Play, RefreshCw } from 'lucide-react'; +import { ArrowLeft, Database, Table2, Users, Cpu, Play, RefreshCw } from 'lucide-react'; import { instanceDetailApi } from '@/api/instanceDetail'; import { instanceApi } from '@/api/instance'; import { queryApi } from '@/api/query'; @@ -10,7 +10,7 @@ interface InstanceInfo { status: string; version: string | null; env: string; } -type TabKey = 'overview' | 'tables' | 'query' | 'users' | 'slow' | 'process'; +type TabKey = 'overview' | 'tables' | 'query' | 'users' | 'process'; export default function InstanceDetailPage() { var params = useParams<{ id: string }>(); @@ -23,7 +23,6 @@ export default function InstanceDetailPage() { var [selectedDb, setSelectedDb] = useState(''); var [tables, setTables] = useState([]); var [users, setUsers] = useState([]); - var [slowQueries, setSlowQueries] = useState([]); var [processes, setProcesses] = useState([]); var [sqlText, setSqlText] = useState(''); var [queryResult, setQueryResult] = useState(null); @@ -34,7 +33,6 @@ export default function InstanceDetailPage() { { key: 'tables', label: '表管理', icon: Table2 }, { key: 'query', label: 'SQL查询', icon: Play }, { key: 'users', label: '用户权限', icon: Users }, - { key: 'slow', label: '慢查询', icon: Clock }, { key: 'process', label: '进程列表', icon: Cpu }, ]; @@ -57,11 +55,6 @@ export default function InstanceDetailPage() { setUsers(res.users || res || []); }).catch(function () { setUsers([]); }); } - if (activeTab === 'slow') { - instanceDetailApi.getSlowQueries(instanceId, selectedDb).then(function (res: any) { - setSlowQueries(res.queries || res || []); - }).catch(function () { setSlowQueries([]); }); - } if (activeTab === 'process') { instanceDetailApi.getProcesslist(instanceId).then(function (res: any) { setProcesses(res.processes || res || []); @@ -236,28 +229,6 @@ export default function InstanceDetailPage() { )} - {/* 慢查询 */} - {activeTab === 'slow' && ( -
- - - - - - {slowQueries.map(function (q: any, i: number) { - return - - - - - ; - })} - -
SQL执行时间扫描行数时间
{q.sql_text || q.SQL || '-'}{q.query_time || q.Time || '-'}{q.rows_examined || q.ScanRows || '-'}{q.start_time || q.Timestamp || '-'}
- {slowQueries.length === 0 &&
暂无慢查询记录
} -
- )} - {/* 进程列表 */} {activeTab === 'process' && (
diff --git a/frontend/src/pages/InstanceGroupPage.tsx b/frontend/src/pages/InstanceGroupPage.tsx index 111fd20..6cab103 100644 --- a/frontend/src/pages/InstanceGroupPage.tsx +++ b/frontend/src/pages/InstanceGroupPage.tsx @@ -74,7 +74,6 @@ const PERMISSION_CATEGORIES: PermissionCategory[] = [ label: '管理权限', items: [ { code: 'instance:connect', name: '连接实例' }, - { code: 'instance:view_slow_query', name: '查看慢查询' }, { code: 'instance:kill_process', name: '终止进程' }, ], }, diff --git a/frontend/src/pages/RolesPage.tsx b/frontend/src/pages/RolesPage.tsx index 9523af8..76c3047 100644 --- a/frontend/src/pages/RolesPage.tsx +++ b/frontend/src/pages/RolesPage.tsx @@ -5,7 +5,7 @@ import { CheckCircle2, MinusCircle, Sparkles, ChevronDown, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { permissionTreeData } from '@/lib/mock'; +import { permissionTreeData } from '@/lib/constants'; /* ───────── Types ───────── */ diff --git a/frontend/src/pages/SlowQueryPage.tsx b/frontend/src/pages/SlowQueryPage.tsx deleted file mode 100644 index cccc6e2..0000000 --- a/frontend/src/pages/SlowQueryPage.tsx +++ /dev/null @@ -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 = { - 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([]); - const [selectedInstanceId, setSelectedInstanceId] = useState(null); - const [instancesLoading, setInstancesLoading] = useState(false); - - const [activeTab, setActiveTab] = useState<'logs' | 'top' | 'trend'>('logs'); - - const [logs, setLogs] = useState([]); - const [logsTotal, setLogsTotal] = useState(0); - const [logsLoading, setLogsLoading] = useState(false); - const [expandedSqlIds, setExpandedSqlIds] = useState>(new Set()); - const [expandedRowId, setExpandedRowId] = useState(null); - - const [topQueries, setTopQueries] = useState([]); - 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>(new Set()); - const [showCompare, setShowCompare] = useState(false); - - // Kill query state - const [killingId, setKillingId] = useState(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 = {}; - 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 ( -
- {/* Page Header */} - - - {/* Controls bar */} - - -
-
- - -
- - - - - - {/* Compare button */} - {compareIds.size > 0 && ( - - )} - - - - {selectedInstance && ( - - 已选实例: {selectedInstance.name} - - )} -
- - {/* Toast message */} - {message && ( -
- {message.type === 'success' ? : } - {message.text} - -
- )} -
-
- - {/* Tabs */} -
- {[ - { 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 ( - - ); - })} -
- - {/* Tab 1: Slow log list */} - {activeTab === 'logs' && ( - - - - - 慢日志列表 - 共 {logsTotal} 条 - {compareIds.size > 0 && ( - - )} - - - - {logsLoading ? ( -
- - 加载中... -
- ) : logs.length === 0 ? ( -
- -

暂无慢日志数据

-

请先选择实例并点击"收集慢日志"

-
- ) : ( -
- - - - - - - - - - - - - - - {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 ( - <> - - - - - - - - - - - - - {/* Expanded section with EXPLAIN + Suggestions + Kill */} - {isExpanded && ( - - - - )} - - ); - })} - -
- 对比SQL执行时间锁等待扫描行数返回行数数据库时间
- - - - - - {isSqlExpanded ? log.sql.replace(/\n/g, ' ').replace(/\s+/g, ' ').trim() : truncateSql(log.sql, 80)} - - {log.sql.length > 80 && ( - - )} - - - - {formatQueryTime(log.query_time)} - - - {formatQueryTime(log.lock_time)} - - {log.rows_examined.toLocaleString()} - - {log.rows_sent.toLocaleString()} - - - {log.database} - - - {new Date(log.start_time).toLocaleString('zh-CN', { - month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', - })} -
- {/* Full SQL */} -
-
- 完整 SQL - -
- - {log.sql} - -
- - {/* EXPLAIN Plan */} -
-
- - 执行计划 - (模拟) -
-
- {['Table Scan', 'Filter', 'Sort', 'Limit'].map((step, idx) => ( -
-
- {step} -
- {idx < 3 && ->} -
- ))} -
-
- - {/* Optimization Suggestions */} -
-
- - 优化建议 -
-
- {suggestions.map(s => { - const config = SUGGESTION_COLORS[s.level]; - const SIcon = config.icon; - return ( -
-
- - {s.title} - - {s.level === 'critical' ? '严重' : s.level === 'warning' ? '警告' : '建议'} - -
-

{s.description}

-

{s.suggestion}

-
- ); - })} -
-
- - {/* Metrics + Kill */} -
-
- {[ - { 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 => ( -
-
{m.label}
-
{m.value}
-
- ))} -
- -
-
-
- )} -
-
- )} - - {/* Tab 2: Top queries */} - {activeTab === 'top' && ( -
- - - - - TOP 10 查询 — 平均执行时间 - - - - {topLoading ? ( -
- - 加载中... -
- ) : chartData.length === 0 ? ( -
- -

暂无 TOP 查询数据

-
- ) : ( - - - - - - [ - `${value.toFixed(1)} ms`, - name === 'avgTime' ? '平均时间' : '最大时间', - ]} - /> - - - - - )} -
-
- - - - - - 查询详情 - - - - {topLoading ? ( -
加载中...
- ) : topQueries.length === 0 ? ( -
暂无数据
- ) : ( -
- - - - - - - - - - - - - {topQueries.map((q, idx) => ( - - - - - - - - - ))} - -
#SQL执行次数平均时间最大时间总时间
{idx + 1} - - {truncateSql(q.sql, 100)} - - - - {q.count} - - - - {q.avg_time_ms.toFixed(1)} ms - - - - {q.max_time_ms.toFixed(1)} ms - - - {q.total_time_ms >= 1000 - ? `${(q.total_time_ms / 1000).toFixed(2)}s` - : `${q.total_time_ms.toFixed(0)}ms`} -
-
- )} -
-
-
- )} - - {/* Tab 3: Trend */} - {activeTab === 'trend' && ( -
- {/* Time Distribution */} - - - - - 耗时分布 - - - - {timeDistribution.length === 0 ? ( -
- -

暂无数据

-
- ) : ( -
- - - - {timeDistribution.map((entry, i) => )} - - - - - -
- {timeDistribution.map(d => ( -
-
- {d.name} - {d.value} -
- ))} -
-
- )} - - - - {/* Trend Chart */} - - - - - 慢查询趋势(最近14天) - - - - {trendData.length === 0 ? ( -
- -

暂无数据

-
- ) : ( - - - - - - - - - - - - - )} -
-
-
- )} - - {/* Compare Modal */} - {showCompare && compareLogs.length === 2 && ( -
-
e.stopPropagation()}> -
-

查询对比

- -
-
- {compareLogs.map((log, i) => ( -
-
查询 {i + 1}
-
-
-
SQL
- {truncateSql(log.sql, 200)} -
-
-
-
执行时间
-
{formatQueryTime(log.query_time)}
-
-
-
锁等待
-
{formatQueryTime(log.lock_time)}
-
-
-
扫描行数
-
{log.rows_examined.toLocaleString()}
-
-
-
返回行数
-
{log.rows_sent.toLocaleString()}
-
-
-
-
- ))} -
- {/* Comparison Summary */} -
-
对比总结
-
- {[ - { 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 => ( -
-
{item.label}
-
{item.getValue()}
-
- ))} -
-
-
-
- )} -
- ); -} diff --git a/frontend/src/pages/TicketCreatePage.tsx b/frontend/src/pages/TicketCreatePage.tsx index 3b5a8bb..ae821b6 100644 --- a/frontend/src/pages/TicketCreatePage.tsx +++ b/frontend/src/pages/TicketCreatePage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { ChevronRight, Check, AlertTriangle, Shield, Database, FileText, Sparkles, RotateCcw, Loader2, Info, + ChevronDown, ChevronUp, Table2, Columns3, FolderOpen, } from 'lucide-react'; import { ticketApi } from '@/api/ticket'; import { instanceApi } from '@/api/instance'; @@ -39,6 +40,12 @@ interface StepConfig { icon: React.ElementType; } +interface TableMeta { + name: string; + rows?: number; + type?: string; +} + /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ @@ -79,6 +86,24 @@ const PRIORITY_OPTIONS = [ { value: 'high', label: '高' }, ]; +// 按使用频率排序 +const DB_TYPE_OPTIONS = [ + { value: '', label: '请选择数据库类型' }, + { value: 'mysql', label: 'MySQL' }, + { value: 'redis', label: 'Redis' }, + { value: 'postgres', label: 'PostgreSQL' }, + { value: 'mongodb', label: 'MongoDB' }, +]; + +const DB_TYPE_LABELS: Record = { + mysql: 'MySQL', + redis: 'Redis', + postgres: 'PostgreSQL', + postgresql: 'PostgreSQL', + mongodb: 'MongoDB', + sqlite: 'SQLite', +}; + /* ------------------------------------------------------------------ */ /* Helper: generate rollback SQL suggestion */ /* ------------------------------------------------------------------ */ @@ -86,7 +111,6 @@ const PRIORITY_OPTIONS = [ function generateRollbackSuggestion(sql: string): string { const upper = sql.toUpperCase().trim(); - // UPDATE -> generate reverse UPDATE if (upper.startsWith('UPDATE')) { const tableMatch = sql.match(/UPDATE\s+(\w+)/i); const table = tableMatch ? tableMatch[1] : 'table'; @@ -95,19 +119,16 @@ function generateRollbackSuggestion(sql: string): string { return `-- 建议回滚语句(请根据实际业务调整):\n-- UPDATE ${table} SET ... WHERE ${where || '...'};`; } - // DELETE -> generate INSERT from backup if (upper.startsWith('DELETE')) { const tableMatch = sql.match(/FROM\s+(\w+)/i) || sql.match(/DELETE\s+(\w+)/i); const table = tableMatch ? tableMatch[1] : 'table'; return `-- 建议回滚语句:\n-- INSERT INTO ${table} SELECT * FROM ${table}_backup WHERE ...;\n-- 或从 Binlog 恢复`; } - // ALTER -> generate reverse ALTER if (upper.startsWith('ALTER')) { return `-- DDL 回滚说明:\n-- 1. ALTER TABLE 操作无法直接回滚\n-- 2. 建议先使用 pt-online-schema-change 或 gh-ost 进行在线变更\n-- 3. 执行前请确保有完整备份`; } - // INSERT -> generate DELETE if (upper.startsWith('INSERT')) { const tableMatch = sql.match(/INTO\s+(\w+)/i); const table = tableMatch ? tableMatch[1] : 'table'; @@ -130,6 +151,7 @@ export default function TicketCreatePage() { // -- Form fields -- const [title, setTitle] = useState(''); const [priority, setPriority] = useState('medium'); + const [dbType, setDbType] = useState(''); const [instanceId, setInstanceId] = useState(''); const [database, setDatabase] = useState(''); const [sqlContent, setSqlContent] = useState(''); @@ -148,6 +170,14 @@ export default function TicketCreatePage() { // -- Audit -- const [auditResult, setAuditResult] = useState(null); + // -- Schema browser -- + const [showSchema, setShowSchema] = useState(false); + const [tables, setTables] = useState([]); + const [loadingTables, setLoadingTables] = useState(false); + const [expandedTable, setExpandedTable] = useState(null); + const [columns, setColumns] = useState>({}); + const [loadingColumns, setLoadingColumns] = useState(null); + /* ---------------------------------------------------------------- */ /* Fetch instances on mount */ /* ---------------------------------------------------------------- */ @@ -163,6 +193,39 @@ export default function TicketCreatePage() { .finally(() => setLoadingInstances(false)); }, []); + /* ---------------------------------------------------------------- */ + /* Filtered instances by db type */ + /* ---------------------------------------------------------------- */ + + const filteredInstances = useMemo(() => { + if (!dbType) return instances; + return instances.filter(i => { + if (dbType === 'postgres') return i.db_type === 'postgres' || i.db_type === 'postgresql'; + return i.db_type === dbType; + }); + }, [instances, dbType]); + + /* ---------------------------------------------------------------- */ + /* Reset instance when dbType changes */ + /* ---------------------------------------------------------------- */ + + useEffect(() => { + // If current instance doesn't match new dbType, reset it + if (instanceId) { + const current = instances.find(i => i.id === Number(instanceId)); + if (current && dbType) { + const match = dbType === 'postgres' + ? (current.db_type === 'postgres' || current.db_type === 'postgresql') + : current.db_type === dbType; + if (!match) { + setInstanceId(''); + setDatabase(''); + setDatabases([]); + } + } + } + }, [dbType, instances, instanceId]); + /* ---------------------------------------------------------------- */ /* Fetch databases when instance changes */ /* ---------------------------------------------------------------- */ @@ -173,6 +236,7 @@ export default function TicketCreatePage() { setDatabase(''); return; } + const inst = instances.find(i => i.id === Number(instanceId)); setLoadingDatabases(true); instanceApi.listDatabases(Number(instanceId)) .then((res: unknown) => { @@ -182,14 +246,80 @@ export default function TicketCreatePage() { setDatabases(Array.isArray(data) ? data as string[] : []); if (Array.isArray(data) && data.length > 0) { setDatabase(String(data[0])); + } else if (inst?.db_type === 'redis') { + // Redis 默认 db0 + setDatabase('db0'); } }) .catch(() => { - // Fallback: no auto database list setDatabases([]); + // Redis fallback + if (inst?.db_type === 'redis') { + setDatabase('db0'); + } }) .finally(() => setLoadingDatabases(false)); - }, [instanceId]); + }, [instanceId, instances]); + + /* ---------------------------------------------------------------- */ + /* Schema browser: load tables */ + /* ---------------------------------------------------------------- */ + + useEffect(() => { + if (!showSchema || !instanceId || !database) { + setTables([]); + return; + } + setLoadingTables(true); + instanceApi.listTables(Number(instanceId), database) + .then((res: unknown) => { + const data = (res as { tables?: TableMeta[] })?.tables + || (res as TableMeta[]) + || []; + setTables(Array.isArray(data) ? data as TableMeta[] : []); + }) + .catch(() => setTables([])) + .finally(() => setLoadingTables(false)); + }, [showSchema, instanceId, database]); + + /* ---------------------------------------------------------------- */ + /* Schema browser: load columns for a table */ + /* ---------------------------------------------------------------- */ + + const handleExpandTable = useCallback(async (tableName: string) => { + if (expandedTable === tableName) { + setExpandedTable(null); + return; + } + setExpandedTable(tableName); + + if (columns[tableName]) return; // already loaded + + setLoadingColumns(tableName); + try { + const res = await instanceApi.listTables(Number(instanceId), `${database}/${tableName}`) as unknown; + const data = (res as { tables?: { columns?: { name: string }[] }[] })?.tables; + if (Array.isArray(data) && data[0]?.columns) { + const cols = data[0].columns.map((c: { name: string }) => c.name); + setColumns(prev => ({ ...prev, [tableName]: cols })); + } + } catch { + // ignore — columns not available + } finally { + setLoadingColumns(null); + } + }, [expandedTable, columns, instanceId, database]); + + /* ---------------------------------------------------------------- */ + /* Insert table/column name into SQL editor */ + /* ---------------------------------------------------------------- */ + + const insertToEditor = useCallback((text: string) => { + setSqlContent(prev => { + const separator = prev.trim() ? (prev.trimEnd().endsWith('\n') ? '' : ' ') : ''; + return prev + separator + text + ' '; + }); + }, []); /* ---------------------------------------------------------------- */ /* SQL change callback */ @@ -197,7 +327,6 @@ export default function TicketCreatePage() { const handleSqlChange = useCallback((val: string) => { setSqlContent(val); - // Auto-generate rollback suggestion when SQL changes if (val.trim().length > 10) { setRollbackSql(generateRollbackSuggestion(val)); } @@ -255,7 +384,7 @@ export default function TicketCreatePage() { const inst = instances.find((i) => i.id === Number(instanceId)); await ticketApi.create({ title: title.trim(), - db_type: inst?.db_type || 'mysql', + db_type: inst?.db_type || dbType || 'mysql', instance_id: Number(instanceId), target_database: database, sql_content: sqlContent, @@ -270,7 +399,7 @@ export default function TicketCreatePage() { } finally { setSubmitting(false); } - }, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, navigate]); + }, [title, sqlContent, instanceId, database, reason, priority, rollbackSql, instances, dbType, navigate]); /* ---------------------------------------------------------------- */ /* Derived values */ @@ -336,6 +465,106 @@ export default function TicketCreatePage() {
) : null; + /* ---------------------------------------------------------------- */ + /* Schema Browser Panel */ + /* ---------------------------------------------------------------- */ + + const renderSchemaBrowser = () => { + if (!instanceId || !database) return null; + + return ( +
+ {/* Toggle button */} + + + {/* Schema content */} + {showSchema && ( +
+ {loadingTables ? ( +
+ + 加载表列表... +
+ ) : tables.length === 0 ? ( +

无可用表(或实例未连接)

+ ) : ( +
+ {tables.map(t => ( +
+ {/* Table row */} + + + {/* Columns */} + {expandedTable === t.name && ( +
+ {loadingColumns === t.name ? ( +
+ + 加载字段... +
+ ) : columns[t.name] ? ( +
+ {columns[t.name].map(col => ( + + ))} +
+ ) : ( +

字段信息不可用

+ )} +
+ )} +
+ ))} +
+ )} +
+ )} +
+ ); + }; + /* ========== Step 1: Fill info ========== */ const renderStep1 = () => (
@@ -352,7 +581,7 @@ export default function TicketCreatePage() { />
- {/* Priority + Instance */} + {/* Priority + DB Type */}
@@ -369,32 +598,65 @@ export default function TicketCreatePage() {
+ {/* Instance (filtered by dbType) */} +
+ + +
+ {/* Database */}
- {databases.length > 0 ? ( + {selectedInstance?.db_type === 'redis' ? ( +
+ 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" + /> + Redis +
+ ) : databases.length > 0 ? ( ) : ( -
- setDatabase(e.target.value)} - className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors" - placeholder="目标数据库名" - /> - {selectedInstance && ( - - {selectedInstance.db_type.toUpperCase()} - - )} -
+ setDatabase(e.target.value)} + disabled={!instanceId} + className="w-full px-3 py-2 bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded-lg text-sm focus:outline-none focus:border-blue-500 transition-colors disabled:opacity-50" + placeholder={instanceId ? '请输入数据库名' : '请先选择实例'} + /> )}
+ {/* Schema Browser */} + {renderSchemaBrowser()} + {/* SQL Editor */}
@@ -456,7 +715,7 @@ export default function TicketCreatePage() { />

- 支持 Ctrl+Shift+F 格式化代码 + 提交前必须通过 SQL 审核(语法检查 + 审核规则)

@@ -500,12 +759,12 @@ export default function TicketCreatePage() { {auditing ? ( <> - 审核中... + 验证审核中... ) : ( <> - 审核并继续 + 验证并审核 )} @@ -649,7 +908,7 @@ export default function TicketCreatePage() { {/* Rollback preview */} {rollbackSql && ( -
+
回滚 SQL diff --git a/frontend/src/pages/TicketDetailPage.tsx b/frontend/src/pages/TicketDetailPage.tsx index c2aa541..bac51fe 100644 --- a/frontend/src/pages/TicketDetailPage.tsx +++ b/frontend/src/pages/TicketDetailPage.tsx @@ -7,7 +7,7 @@ import { BarChart3, ChevronDown, ChevronUp, Link2, } from 'lucide-react'; import { ticketApi } from '@/api/ticket'; -import { statusMap, statusColorMap } from '@/lib/mock'; +import { statusMap, statusColorMap } from '@/lib/constants'; import { useAuth } from '@/hooks/useAuth'; import SqlEditor from '@/components/SqlEditor'; @@ -129,14 +129,25 @@ export default function TicketDetailPage() { } }, [ticketId]); - // Mock related tickets fetch + // Fetch related tickets from API const fetchRelated = useCallback(async () => { - // In real implementation: await ticketApi.related(ticketId) - setRelatedTickets([ - { id: ticketId - 1, title: '用户表索引优化', status: 'success', applicant_name: '张三', create_time: '2024-01-14 10:00:00' }, - { id: ticketId + 1, title: '订单数据归档', status: 'approved', applicant_name: '李四', create_time: '2024-01-15 14:00:00' }, - ]); - }, [ticketId]); + try { + const res = await ticketApi.list({ instance_id: ticket?.instance_id, limit: 5 }) as { items?: any[] }; + const items = (res?.items || []) + .filter((t: any) => t.id !== ticketId) + .slice(0, 5) + .map((t: any) => ({ + id: t.id, + title: t.title, + status: t.status, + applicant_name: t.applicant_name || t.created_by_name, + create_time: t.created_at || t.create_time, + })); + setRelatedTickets(items); + } catch { + setRelatedTickets([]); + } + }, [ticketId, ticket?.instance_id]); useEffect(() => { if (!isNaN(ticketId)) { diff --git a/frontend/src/pages/TicketsPage.tsx b/frontend/src/pages/TicketsPage.tsx index 08e86b9..fff87e8 100644 --- a/frontend/src/pages/TicketsPage.tsx +++ b/frontend/src/pages/TicketsPage.tsx @@ -7,7 +7,7 @@ import { CheckSquare, Square, ArrowUpDown, FileDown, Ban, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/mock'; +import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants'; import { useActionPermission } from '@/hooks/useAuth'; import PageHeader from '@/components/PageHeader'; import EmptyState from '@/components/EmptyState'; diff --git a/frontend/src/pages/WorkflowPage.tsx b/frontend/src/pages/WorkflowPage.tsx index a925212..7f04aac 100644 --- a/frontend/src/pages/WorkflowPage.tsx +++ b/frontend/src/pages/WorkflowPage.tsx @@ -6,7 +6,7 @@ import { Loader2, Check, XCircle, Star, } from 'lucide-react'; import { Card, CardContent } from '@/components/ui/card'; -import { dbTypeColorMap } from '@/lib/mock'; +import { dbTypeColorMap } from '@/lib/constants'; import { workflowApi } from '@/api/workflow'; /* ------------------------------------------------------------------ */