From 4ac2528f8e8245dfdd0b01f5d5f6f4fb5a4c8399 Mon Sep 17 00:00:00 2001 From: logan Date: Sun, 24 May 2026 01:31:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=AE=A1=E6=A0=B8=E5=BC=95=E6=93=8E?= =?UTF-8?q?=E4=BC=98=E5=8C=96=20+=20=E6=89=A7=E8=A1=8C=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端审核规则修复: - Redis SEC-001: SET/DEL等常规命令不再误报AUTH要求,改为仅对管理级命令WARNING - Redis SEC-003: 降级为INFO,排除已由CMD-001拦截的FLUSHDB/FLUSHALL - Redis key提取: 排除CONFIG/ACL等管理命令,不再把子命令参数误认为key名 - MySQL SEC-006: 新增GRANT ALL/REVOKE ALL全局权限拦截 - MySQL SEC-007: 新增LOAD DATA/LOAD XML拦截 - MySQL SEC-008: 新增SLEEP()/BENCHMARK()时间盲注拦截 - MySQL SEC-009: 新增INTO OUTFILE/DUMPFILE文件写入拦截 前端执行结果结构化: - TicketDetailPage执行结果从原始JSON改为结构化展示 - 解析JSON展示: 状态徽章+影响行数+耗时+验证状态 - Errors/Warnings逐条渲染: 红色错误框+黄色警告框+具体消息 - 删除废弃的executeStats/renderExecuteStats 其他: 审核规则6分组/慢查询移除/查询数据重命名/AuditLogs修复 测试: 1040 passed, TSC零错误 --- backend/app/api/v1/endpoints/db_types.py | 267 ++++++++++++++++++ backend/app/api/v1/router.py | 4 +- backend/app/schemas/audit_rule.py | 4 +- backend/app/services/db_connector.py | 57 ++++ backend/app/services/mysql_audit_rules.py | 102 ++++++- backend/app/services/redis_audit_rules.py | 58 ++-- backend/tests/test_db_types.py | 204 +++++++++++++ frontend/src/App.tsx | 3 +- frontend/src/api/dbTypes.ts | 51 ++++ frontend/src/api/query.ts | 9 + .../src/components/EditInstanceDialog.tsx | 186 ++++++++++++ frontend/src/lib/mock.ts | 5 +- frontend/src/pages/AuditLogsPage.tsx | 4 +- frontend/src/pages/AuditRulesPage.tsx | 43 ++- frontend/src/pages/InstancePage.tsx | 90 +++--- frontend/src/pages/QueriesPage.tsx | 2 +- frontend/src/pages/RolesPage.tsx | 2 +- frontend/src/pages/TicketDetailPage.tsx | 265 ++++++++++++----- frontend/src/pages/TicketsPage.tsx | 171 ++--------- 19 files changed, 1202 insertions(+), 325 deletions(-) create mode 100644 backend/app/api/v1/endpoints/db_types.py create mode 100644 backend/tests/test_db_types.py create mode 100644 frontend/src/api/dbTypes.ts create mode 100644 frontend/src/components/EditInstanceDialog.tsx diff --git a/backend/app/api/v1/endpoints/db_types.py b/backend/app/api/v1/endpoints/db_types.py new file mode 100644 index 0000000..a059049 --- /dev/null +++ b/backend/app/api/v1/endpoints/db_types.py @@ -0,0 +1,267 @@ +"""数据库类型说明 API — 人性化数据库能力查询。 + +提供每种数据库类型的详细信息,包括: +- 支持的操作和限制 +- 查询语法示例 +- 安全规则说明 +- 推荐使用场景 +""" +from fastapi import APIRouter, Depends, HTTPException +from typing import Dict, List, Any + +from app.core.security import get_current_user +from app.models.user import User + +router = APIRouter(prefix="/db-types", tags=["数据库类型说明"]) + + +# ── 数据库类型元数据 ── + +DB_TYPE_INFO: Dict[str, Dict[str, Any]] = { + "mysql": { + "name": "MySQL", + "icon": "/db-logos/mysql.svg", + "description": "最流行的开源关系型数据库,适合 Web 应用、OLTP 场景", + "version": "5.7+ / 8.0+", + "category": "关系型", + "supported_query_types": [ + {"type": "SELECT", "description": "数据查询", "example": "SELECT id, name FROM users WHERE status = 'active' LIMIT 100"}, + {"type": "SHOW", "description": "元数据查询", "example": "SHOW TABLES; SHOW PROCESSLIST; SHOW VARIABLES LIKE 'slow%'"}, + {"type": "DESCRIBE", "description": "表结构查看", "example": "DESCRIBE users;"}, + {"type": "EXPLAIN", "description": "执行计划分析", "example": "EXPLAIN SELECT * FROM users WHERE id = 1"}, + ], + "security_rules": [ + "仅支持 SELECT / SHOW / DESCRIBE / EXPLAIN 只读查询", + "禁止 DML (INSERT/UPDATE/DELETE) 和 DDL (CREATE/ALTER/DROP)", + "查询结果自动脱敏敏感字段", + "最大返回行数限制 (默认 1000 行)", + ], + "tips": [ + "使用 EXPLAIN 分析慢查询", + "避免 SELECT *,明确指定需要的列", + "大数据量查询使用 LIMIT 分页", + "利用索引提示 FORCE INDEX 优化查询", + ], + "common_commands": { + "查看所有表": "SHOW TABLES", + "查看表结构": "DESCRIBE table_name", + "查看建表语句": "SHOW CREATE TABLE table_name", + "查看索引": "SHOW INDEX FROM table_name", + "查看连接": "SHOW PROCESSLIST", + "查看状态": "SHOW STATUS LIKE 'Threads%'", + "查看慢查询": "SELECT * FROM mysql.slow_log ORDER BY start_time DESC LIMIT 20", + }, + "limitations": [ + "不支持存储过程调用", + "不支持临时表创建", + "不支持 LOAD DATA INFILE", + ], + }, + "postgres": { + "name": "PostgreSQL", + "icon": "/db-logos/postgres.svg", + "description": "功能最强大的开源关系型数据库,适合复杂查询、GIS、JSON 场景", + "version": "12+ / 13+ / 14+ / 15+", + "category": "关系型", + "supported_query_types": [ + {"type": "SELECT", "description": "数据查询", "example": "SELECT id, name FROM users WHERE status = 'active' LIMIT 100"}, + {"type": "EXPLAIN", "description": "执行计划分析", "example": "EXPLAIN ANALYZE SELECT * FROM users WHERE id = 1"}, + ], + "security_rules": [ + "仅支持 SELECT 只读查询", + "支持 EXPLAIN / EXPLAIN ANALYZE (只读)", + "禁止 DML (INSERT/UPDATE/DELETE) 和 DDL 操作", + "查询结果自动脱敏", + ], + "tips": [ + "使用 EXPLAIN ANALYZE 获取真实执行统计", + "利用 CTE (WITH 语句) 优化复杂查询", + "pg_stat_user_tables 查看表统计信息", + "使用 JSONB 类型和操作符处理 JSON 数据", + ], + "common_commands": { + "查看所有表": "SELECT tablename FROM pg_tables WHERE schemaname = 'public'", + "查看表大小": "SELECT pg_size_pretty(pg_total_relation_size('table_name'))", + "查看活动查询": "SELECT * FROM pg_stat_activity WHERE state = 'active'", + "查看索引使用": "SELECT * FROM pg_stat_user_indexes ORDER BY idx_scan DESC", + "查看锁等待": "SELECT * FROM pg_locks WHERE NOT granted", + "查看表统计": "SELECT * FROM pg_stat_user_tables ORDER BY n_live_tup DESC", + }, + "limitations": [ + "不支持存储过程调用", + "不支持 COPY 命令", + "不支持事务控制 (BEGIN/COMMIT)", + ], + }, + "redis": { + "name": "Redis", + "icon": "/db-logos/redis.svg", + "description": "高性能内存键值数据库,适合缓存、会话管理、排行榜场景", + "version": "6.0+ / 7.0+", + "category": "键值型", + "supported_query_types": [ + {"type": "GET", "description": "获取字符串值", "example": "GET user:1001"}, + {"type": "HGETALL", "description": "获取哈希所有字段", "example": "HGETALL user:1001"}, + {"type": "LRANGE", "description": "获取列表范围", "example": "LRANGE mylist 0 -1"}, + {"type": "SMEMBERS", "description": "获取集合所有成员", "example": "SMEMBERS myset"}, + {"type": "ZRANGE", "description": "获取有序集合范围", "example": "ZRANGE leaderboard 0 10 WITHSCORES"}, + {"type": "INFO", "description": "服务器信息", "example": "INFO server; INFO memory"}, + {"type": "SCAN", "description": "迭代键", "example": "SCAN 0 MATCH user:* COUNT 50"}, + ], + "security_rules": [ + "仅支持只读命令白名单 (40+ 命令)", + "禁止写入命令 (SET/DEL/FLUSHDB 等)", + "禁止管理命令 (SHUTDOWN/DEBUG 等)", + "KEYS 命令建议用 SCAN 替代 (生产环境安全)", + ], + "tips": [ + "使用 SCAN 代替 KEYS 避免阻塞", + "HGETALL 获取完整哈希,HGET 获取单个字段", + "ZRANGE WITHSCORES 获取分数", + "TYPE key 查看键的数据类型", + "TTL key 查看剩余过期时间", + ], + "common_commands": { + "查看键数量": "DBSIZE", + "查看服务器信息": "INFO server", + "查看内存使用": "INFO memory", + "查看键类型": "TYPE key_name", + "扫描键": "SCAN 0 MATCH pattern COUNT 50", + "获取字符串": "GET key_name", + "获取哈希": "HGETALL key_name", + "查看客户端": "CLIENT LIST", + }, + "limitations": [ + "不支持 Lua 脚本执行 (EVAL)", + "不支持事务 (MULTI/EXEC)", + "SCAN 命令不支持跨数据库", + ], + }, + "mongo": { + "name": "MongoDB", + "icon": "/db-logos/mongo.svg", + "description": "文档型 NoSQL 数据库,适合灵活 Schema、JSON 原生存储场景", + "version": "4.4+ / 5.0+ / 6.0+", + "category": "文档型", + "supported_query_types": [ + {"type": "find", "description": "查询文档", "example": "db.users.find({status: 'active'}).limit(100)"}, + {"type": "aggregate", "description": "聚合管道", "example": "db.orders.aggregate([{$match: {status: 'completed'}}, {$group: {_id: '$product', total: {$sum: '$amount'}}}])"}, + {"type": "countDocuments", "description": "计数", "example": "db.users.countDocuments({status: 'active'})"}, + {"type": "distinct", "description": "去重值", "example": "db.users.distinct('department')"}, + ], + "security_rules": [ + "仅支持 find/aggregate/countDocuments/distinct 只读操作", + "聚合管道禁止 $out 和 $merge (防止写入)", + "禁止 insert/update/delete/drop 等写操作", + "查询结果限制最大返回数量", + ], + "tips": [ + "使用投影 (projection) 只返回需要的字段", + "aggregate 支持多阶段管道处理", + "$lookup 实现类似 SQL JOIN 的关联查询", + "createIndex / getIndexes 查看索引信息", + ], + "common_commands": { + "查看集合": "show collections", + "查找文档": "db.collection.find({}).limit(20)", + "计数文档": "db.collection.countDocuments({})", + "聚合统计": "db.collection.aggregate([{$group: {_id: '$field', count: {$sum: 1}}}])", + "查看索引": "db.collection.getIndexes()", + "查看统计": "db.collection.stats()", + }, + "limitations": [ + "聚合管道不支持 $out/$merge", + "不支持 eval 命令", + "不支持创建/删除集合", + ], + }, +} + + +@router.get("") +async def list_db_types( + current_user: User = Depends(get_current_user), +): + """获取所有支持的数据库类型概览。 + + 返回每种数据库类型的基本信息,包括名称、图标、描述和类别。 + 用于前端展示数据库类型选择器或帮助信息。 + """ + result = [] + for db_type, info in DB_TYPE_INFO.items(): + result.append({ + "type": db_type, + "name": info["name"], + "icon": info["icon"], + "description": info["description"], + "category": info["category"], + "version": info["version"], + "supported_operations": len(info["supported_query_types"]), + "security_rules_count": len(info["security_rules"]), + }) + return {"items": result, "total": len(result)} + + +@router.get("/{db_type}") +async def get_db_type_detail( + db_type: str, + current_user: User = Depends(get_current_user), +): + """获取指定数据库类型的详细信息。 + + 包含: 支持的操作、查询语法示例、安全规则、使用技巧、常用命令。 + 用于查询页面的上下文帮助和语法提示。 + """ + if db_type not in DB_TYPE_INFO: + raise HTTPException( + status_code=404, + detail="不支持的数据库类型: " + db_type + "。支持: mysql, postgres, redis, mongo", + ) + + info = DB_TYPE_INFO[db_type] + return { + "type": db_type, + **info, + } + + +@router.get("/{db_type}/commands") +async def get_db_type_commands( + db_type: str, + current_user: User = Depends(get_current_user), +): + """获取指定数据库类型的常用命令列表。 + + 返回常用命令名称和示例,用于快捷查询模板。 + """ + if db_type not in DB_TYPE_INFO: + raise HTTPException( + status_code=404, + detail="不支持的数据库类型: " + db_type, + ) + + commands = DB_TYPE_INFO[db_type]["common_commands"] + result = [ + {"name": name, "command": cmd} + for name, cmd in commands.items() + ] + return {"items": result, "total": len(result)} + + +@router.get("/{db_type}/tips") +async def get_db_type_tips( + db_type: str, + current_user: User = Depends(get_current_user), +): + """获取指定数据库类型的使用技巧。 + + 用于前端展示小贴士,帮助用户更好地使用查询功能。 + """ + if db_type not in DB_TYPE_INFO: + raise HTTPException( + status_code=404, + detail="不支持的数据库类型: " + db_type, + ) + + tips = DB_TYPE_INFO[db_type]["tips"] + return {"items": tips, "total": len(tips)} diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index be7f6c7..4b99413 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -6,7 +6,7 @@ from app.api.v1.endpoints import ( instance_detail, backup, redis, dictionary, instance_group, masking_rule, rollback, workflow, impact, schema_drift, slow_log, pool, - mongo, query_history, sql_assistant, + mongo, query_history, sql_assistant, db_types, ) api_router = APIRouter(prefix="/api/v1") @@ -37,3 +37,5 @@ 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) + +api_router.include_router(db_types.router) diff --git a/backend/app/schemas/audit_rule.py b/backend/app/schemas/audit_rule.py index 4fa6710..ad572ff 100644 --- a/backend/app/schemas/audit_rule.py +++ b/backend/app/schemas/audit_rule.py @@ -6,7 +6,7 @@ from typing import Optional, Literal class AuditRuleCreate(BaseModel): name: str = Field(..., min_length=1, max_length=100) description: Optional[str] = None - rule_type: Literal["syntax", "performance", "safety", "naming", "DDL", "DML"] + rule_type: Literal["syntax", "performance", "safety", "security", "naming", "DDL", "DML", "DQL", "DCL"] severity: Literal["error", "warning", "info"] = "warning" db_type: str = "mysql" pattern: Optional[str] = None @@ -18,7 +18,7 @@ class AuditRuleCreate(BaseModel): class AuditRuleUpdate(BaseModel): name: Optional[str] = Field(default=None, min_length=1, max_length=100) description: Optional[str] = None - rule_type: Optional[Literal["syntax", "performance", "safety", "naming", "DDL", "DML"]] = None + rule_type: Optional[Literal["syntax", "performance", "safety", "security", "naming", "DDL", "DML", "DQL", "DCL"]] = None severity: Optional[Literal["error", "warning", "info"]] = None db_type: Optional[str] = None pattern: Optional[str] = None diff --git a/backend/app/services/db_connector.py b/backend/app/services/db_connector.py index d43e526..258a41e 100644 --- a/backend/app/services/db_connector.py +++ b/backend/app/services/db_connector.py @@ -5,6 +5,7 @@ Replaces mock aiomysql calls with actual driver connections. """ import asyncio import time +import asyncio import sqlite3 from typing import Optional, List, Dict, Any, Tuple @@ -61,12 +62,68 @@ class DBConnector: return await DBConnector._test_postgres(instance, start) elif instance.db_type == "sqlite": return await DBConnector._test_sqlite(instance, start) + elif instance.db_type == "redis": + return await DBConnector._test_redis(instance, start) + elif instance.db_type == "mongodb": + return await DBConnector._test_mongodb(instance, start) else: return ConnectionResult(False, f"暂不支持 {instance.db_type} 类型数据源") except Exception as e: elapsed = int((time.time() - start) * 1000) return ConnectionResult(False, f"连接失败: {str(e)}", latency_ms=elapsed) + + @staticmethod + async def _test_redis(instance: 'Instance', start: float) -> ConnectionResult: + """Test Redis connectivity using synchronous redis client in thread.""" + try: + import redis as redis_lib + + def _connect(): + client = redis_lib.Redis( + host=instance.host, + port=instance.port, + password=instance.password if instance.password and instance.password != "default" else None, + db=0, + socket_connect_timeout=5, + socket_timeout=5, + ) + info = client.info("server") + version = info.get("redis_version", "unknown") + client.close() + return version + + version = await asyncio.to_thread(_connect) + elapsed = int((time.time() - start) * 1000) + return ConnectionResult(True, f"Redis {version} 连接成功", version=version, latency_ms=elapsed) + except Exception as e: + elapsed = int((time.time() - start) * 1000) + return ConnectionResult(False, f"Redis 连接失败: {str(e)}", latency_ms=elapsed) + + @staticmethod + async def _test_mongodb(instance: 'Instance', start: float) -> ConnectionResult: + """Test MongoDB connectivity using pymongo in thread.""" + try: + from pymongo import MongoClient + + def _connect(): + if instance.username and instance.username != "default": + uri = f"mongodb://{instance.username}:{instance.password}@{instance.host}:{instance.port}/" + else: + uri = f"mongodb://{instance.host}:{instance.port}/" + client = MongoClient(uri, serverSelectionTimeoutMS=5000, connectTimeoutMS=5000) + info = client.server_info() + version = info.get("version", "unknown") + client.close() + return version + + version = await asyncio.to_thread(_connect) + elapsed = int((time.time() - start) * 1000) + return ConnectionResult(True, f"MongoDB {version} 连接成功", version=version, latency_ms=elapsed) + except Exception as e: + elapsed = int((time.time() - start) * 1000) + return ConnectionResult(False, f"MongoDB 连接失败: {str(e)}", latency_ms=elapsed) + @staticmethod async def execute_query( instance: Instance, database: Optional[str], sql: str, user_id: int diff --git a/backend/app/services/mysql_audit_rules.py b/backend/app/services/mysql_audit_rules.py index 593765c..1bf40ad 100644 --- a/backend/app/services/mysql_audit_rules.py +++ b/backend/app/services/mysql_audit_rules.py @@ -2090,12 +2090,110 @@ def check_mysql_sec_005(sql_clean: str, sql_upper: str, report: AuditReportV2, r # ═══════════════════════════════════════════════════ # 模块总结 +# ── SEC-006: 权限相关高危语句拦截 ── +@register_rule(AuditRuleV2( + code="MYSQL-SEC-006", + name="DangerousPrivilegeStatement", + category=RuleCategory.SECURITY, + severity=Severity.ERROR, + db_type="mysql", + description="拦截 GRANT ALL / REVOKE ALL 等全局权限变更操作", + config={"enabled": True}, + message_template="禁止执行 '{keyword}' 全局权限操作,请联系 DBA 通过权限管理流程申请", +)) +def check_mysql_sec_006(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: + """全局权限语句拦截。""" + # GRANT ALL / GRANT ALL PRIVILEGES + if re.search(r'\bGRANT\s+ALL\b', sql_upper): + report.findings.append(AuditFindingV2( + rule_code=rule.code, rule_name=rule.name, level=rule.severity, + message=rule.message_template.format(keyword="GRANT ALL"), + )) + # REVOKE ALL + elif re.search(r'\bREVOKE\s+ALL\b', sql_upper): + report.findings.append(AuditFindingV2( + rule_code=rule.code, rule_name=rule.name, level=rule.severity, + message=rule.message_template.format(keyword="REVOKE ALL"), + )) + + +# ── SEC-007: LOAD DATA / LOAD XML 拦截 ── +@register_rule(AuditRuleV2( + code="MYSQL-SEC-007", + name="BlockLoadData", + category=RuleCategory.SECURITY, + severity=Severity.ERROR, + db_type="mysql", + description="禁止 LOAD DATA / LOAD XML 操作,存在安全风险且可能导入大量数据", + config={"enabled": True}, + message_template="禁止使用 '{keyword}',该操作存在安全风险,请使用 INSERT 批量导入", +)) +def check_mysql_sec_007(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: + """LOAD DATA/LOAD XML 拦截。""" + if re.search(r'\bLOAD\s+DATA\b', sql_upper): + report.findings.append(AuditFindingV2( + rule_code=rule.code, rule_name=rule.name, level=rule.severity, + message=rule.message_template.format(keyword="LOAD DATA"), + )) + elif re.search(r'\bLOAD\s+XML\b', sql_upper): + report.findings.append(AuditFindingV2( + rule_code=rule.code, rule_name=rule.name, level=rule.severity, + message=rule.message_template.format(keyword="LOAD XML"), + )) + + +# ── SEC-008: SLEEP / BENCHMARK 注入拦截 ── +@register_rule(AuditRuleV2( + code="MYSQL-SEC-008", + name="BlockTimeBasedInjection", + category=RuleCategory.SECURITY, + severity=Severity.ERROR, + db_type="mysql", + description="拦截 SLEEP() / BENCHMARK() 等时间盲注函数", + config={"enabled": True}, + message_template="禁止使用 '{func}()' 函数,疑似时间盲注攻击", +)) +def check_mysql_sec_008(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: + """时间盲注函数拦截。""" + if re.search(r'\bSLEEP\s*\(', sql_upper): + report.findings.append(AuditFindingV2( + rule_code=rule.code, rule_name=rule.name, level=rule.severity, + message=rule.message_template.format(func="SLEEP"), + )) + if re.search(r'\bBENCHMARK\s*\(', sql_upper): + report.findings.append(AuditFindingV2( + rule_code=rule.code, rule_name=rule.name, level=rule.severity, + message=rule.message_template.format(func="BENCHMARK"), + )) + + +# ── SEC-009: INTO OUTFILE / INTO DUMPFILE 拦截 ── +@register_rule(AuditRuleV2( + code="MYSQL-SEC-009", + name="BlockIntoOutfile", + category=RuleCategory.SECURITY, + severity=Severity.ERROR, + db_type="mysql", + description="禁止 INTO OUTFILE / INTO DUMPFILE,防止写入 WebShell 或导出敏感数据", + config={"enabled": True}, + message_template="禁止使用 '{keyword}',该操作可写入文件系统,存在严重安全风险", +)) +def check_mysql_sec_009(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: + """INTO OUTFILE/DUMPFILE 拦截。""" + if re.search(r'\bINTO\s+(OUT|DUMP)FILE\b', sql_upper): + keyword = "INTO OUTFILE" if "OUTFILE" in sql_upper else "INTO DUMPFILE" + report.findings.append(AuditFindingV2( + rule_code=rule.code, rule_name=rule.name, level=rule.severity, + message=rule.message_template.format(keyword=keyword), + )) + + # ═══════════════════════════════════════════════════ -# 总计 63 条规则: +# 总计 67 条规则: # - DDL 规则: 36 条 (MYSQL-DDL-001 ~ MYSQL-DDL-036) # - DML 规则: 9 条 (MYSQL-DML-001 ~ MYSQL-DML-009) # - Online-DDL 规则: 3 条 (MYSQL-OSC-001 ~ MYSQL-OSC-003) # - 命名规则: 5 条 (MYSQL-NAM-001 ~ MYSQL-NAM-005) # - 性能规则: 5 条 (MYSQL-PERF-001 ~ MYSQL-PERF-005) -# - 安全规则: 5 条 (MYSQL-SEC-001 ~ MYSQL-SEC-005) +# - 安全规则: 9 条 (MYSQL-SEC-001 ~ MYSQL-SEC-009) # ═══════════════════════════════════════════════════ diff --git a/backend/app/services/redis_audit_rules.py b/backend/app/services/redis_audit_rules.py index 9150f81..47bfec7 100644 --- a/backend/app/services/redis_audit_rules.py +++ b/backend/app/services/redis_audit_rules.py @@ -98,6 +98,17 @@ def _extract_key_from_command(command: str, args: List[str]) -> Optional[str]: if command in second_key_commands and len(args) >= 1: return args[0] + # 管理命令/无 key 命令:不应提取 key + no_key_commands = { + "CONFIG", "ACL", "AUTH", "INFO", "PING", "FLUSHDB", "FLUSHALL", + "DBSIZE", "BGSAVE", "BGREWRITEAOF", "SAVE", "LASTSAVE", + "SHUTDOWN", "SLAVEOF", "REPLICAOF", "SLOWLOG", "MONITOR", + "DEBUG", "OBJECT", "CLIENT", "CLUSTER", "SENTINEL", + "MODULE", "COMMAND", "MEMORY", "LATENCY", "WAIT", + } + if command in no_key_commands: + return None + # 默认:第一个参数是 key return args[0] @@ -1610,35 +1621,28 @@ def check_redis_perf_008(sql_clean: str, sql_upper: str, report: AuditReportV2, @register_rule(AuditRuleV2( code="REDIS-SEC-001", - name="必须认证才能执行命令", + name="敏感管理命令需要二次确认", category=RuleCategory.SECURITY, - severity=Severity.ERROR, + severity=Severity.WARNING, db_type="redis", - description="非只读命令必须执行 AUTH 认证后才能执行", - config={"require_auth": True, "allowed_cmds_without_auth": ["PING", "AUTH", "ECHO", "QUIT", "HELLO"]}, - message_template="命令 '{cmd}' 需要认证后才能执行,请先执行 AUTH password 进行认证", + description="CONFIG/ACL/SHUTDOWN 等管理级命令属于高危操作,建议限制执行", + config={"admin_cmds": ["CONFIG", "ACL", "SHUTDOWN", "DEBUG", "MODULE", "REPLICAOF", "SLAVEOF", "SAVE", "BGSAVE", "BGREWRITEAOF"]}, + message_template="命令 '{cmd}' 属于 Redis 管理级操作,请确认业务必要性后再执行", )) def check_redis_sec_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """必须认证检查。 + """管理级命令检查。 - 提示执行敏感命令前需要 AUTH 认证。 + 仅对 CONFIG/ACL/SHUTDOWN 等管理级命令产生警告, + 常规数据操作命令(SET/DEL/HSET 等)不触发—— + 平台已通过实例配置管理认证,无需额外提示 AUTH。 """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() - allowed = rule.config.get("allowed_cmds_without_auth", ["PING", "AUTH", "ECHO", "QUIT", "HELLO"]) - if cmd in allowed: - return - - # 只对写入和敏感命令要求认证 - sensitive_cmds = { - "SET", "DEL", "FLUSHDB", "FLUSHALL", "CONFIG", "ACL", "RENAME", - "MIGRATE", "RESTORE", "SHUTDOWN", "DEBUG", "MODULE", "REPLICAOF", - "SLAVEOF", "SAVE", "BGSAVE", "BGREWRITEAOF", - } - if cmd in sensitive_cmds: + admin_cmds = rule.config.get("admin_cmds", []) + if cmd in admin_cmds: report.findings.append(AuditFindingV2( rule_code=rule.code, rule_name=rule.name, @@ -1696,25 +1700,27 @@ def check_redis_sec_002(sql_clean: str, sql_upper: str, report: AuditReportV2, r @register_rule(AuditRuleV2( code="REDIS-SEC-003", - name="危险命令必须被重命名", + name="危险命令重命名建议", category=RuleCategory.SECURITY, - severity=Severity.WARNING, + severity=Severity.INFO, db_type="redis", - description="建议将危险命令(FLUSHDB/FLUSHALL/CONFIG/DEBUG)通过 rename-command 重命名", - config={"dangerous_commands": ["FLUSHDB", "FLUSHALL", "CONFIG", "DEBUG", "SHUTDOWN"]}, - message_template="危险命令 '{cmd}' 应通过 rename-command 在配置中重命名,防止误操作或恶意执行", + description="建议将 FLUSHDB/FLUSHALL/DEBUG/SHUTDOWN 通过 rename-command 重命名", + config={"dangerous_commands": ["DEBUG", "SHUTDOWN"]}, + message_template="建议将 '{cmd}' 通过 rename-command 重命名,防止误操作", )) def check_redis_sec_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None: - """危险命令重命名检查。 + """危险命令重命名建议。 - 当检测到危险命令使用时,提示应在配置中重命名。 + FLUSHDB/FLUSHALL 已由 CMD-001 硬拦截,此处仅对 DEBUG/SHUTDOWN + 等管理命令给出 rename-command 配置建议,避免与 CMD-001 重复。 """ command, args = _parse_redis_command(sql_clean) if not command: return cmd = command.upper() - dangerous = rule.config.get("dangerous_commands", ["FLUSHDB", "FLUSHALL", "CONFIG", "DEBUG", "SHUTDOWN"]) + # 排除已由 CMD-001 拦截的 FLUSHDB/FLUSHALL/CONFIG + dangerous = rule.config.get("dangerous_commands", ["DEBUG", "SHUTDOWN"]) if cmd in dangerous: report.findings.append(AuditFindingV2( rule_code=rule.code, diff --git a/backend/tests/test_db_types.py b/backend/tests/test_db_types.py new file mode 100644 index 0000000..0fd3e5e --- /dev/null +++ b/backend/tests/test_db_types.py @@ -0,0 +1,204 @@ +"""数据库类型说明 API 测试 — db_types 端点。 + +覆盖: +- GET /db-types — 列表概览 +- GET /db-types/{db_type} — 详细信息 +- GET /db-types/{db_type}/commands — 常用命令 +- GET /db-types/{db_type}/tips — 使用技巧 +- 404 错误处理 +""" +import pytest +from httpx import AsyncClient, ASGITransport + +from app.main import app + + +@pytest.fixture +def auth_headers(): + """返回认证头。""" + from app.core.security import create_access_token + token = create_access_token({"sub": "1", "role": "admin"}) + return {"Authorization": "Bearer " + token} + + +@pytest.mark.asyncio +async def test_list_db_types(client, admin_token): + """测试获取所有数据库类型概览。""" + resp = await client.get("/api/v1/db-types", headers={"Authorization": "Bearer " + admin_token}) + + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert data["total"] == 4 + + types = {item["type"] for item in data["items"]} + assert types == {"mysql", "postgres", "redis", "mongo"} + + # 检查每个条目的结构 + for item in data["items"]: + assert "type" in item + assert "name" in item + assert "icon" in item + assert "description" in item + assert "category" in item + assert "version" in item + assert "supported_operations" in item + assert item["supported_operations"] > 0 + + +@pytest.mark.asyncio +async def test_get_mysql_detail(client, admin_token): + """测试获取 MySQL 详细信息。""" + resp = await client.get("/api/v1/db-types/mysql", headers={"Authorization": "Bearer " + admin_token}) + + assert resp.status_code == 200 + data = resp.json() + assert data["type"] == "mysql" + assert data["name"] == "MySQL" + assert data["category"] == "关系型" + assert len(data["supported_query_types"]) >= 4 + assert len(data["security_rules"]) >= 1 + assert len(data["tips"]) >= 1 + assert len(data["common_commands"]) >= 1 + assert len(data["limitations"]) >= 1 + + # 检查查询类型结构 + for qt in data["supported_query_types"]: + assert "type" in qt + assert "description" in qt + assert "example" in qt + + +@pytest.mark.asyncio +async def test_get_postgres_detail(client, admin_token): + """测试获取 PostgreSQL 详细信息。""" + resp = await client.get("/api/v1/db-types/postgres", headers={"Authorization": "Bearer " + admin_token}) + + assert resp.status_code == 200 + data = resp.json() + assert data["type"] == "postgres" + assert data["name"] == "PostgreSQL" + assert data["category"] == "关系型" + + +@pytest.mark.asyncio +async def test_get_redis_detail(client, admin_token): + """测试获取 Redis 详细信息。""" + resp = await client.get("/api/v1/db-types/redis", headers={"Authorization": "Bearer " + admin_token}) + + assert resp.status_code == 200 + data = resp.json() + assert data["type"] == "redis" + assert data["name"] == "Redis" + assert data["category"] == "键值型" + assert len(data["supported_query_types"]) >= 5 + + +@pytest.mark.asyncio +async def test_get_mongo_detail(client, admin_token): + """测试获取 MongoDB 详细信息。""" + resp = await client.get("/api/v1/db-types/mongo", headers={"Authorization": "Bearer " + admin_token}) + + assert resp.status_code == 200 + data = resp.json() + assert data["type"] == "mongo" + assert data["name"] == "MongoDB" + assert data["category"] == "文档型" + + +@pytest.mark.asyncio +async def test_get_unknown_db_type(client, admin_token): + """测试获取不支持的数据库类型 — 404。""" + resp = await client.get("/api/v1/db-types/oracle", headers={"Authorization": "Bearer " + admin_token}) + + assert resp.status_code == 404 + assert "不支持的数据库类型" in resp.json()["detail"] + + +@pytest.mark.asyncio +async def test_get_commands(client, admin_token): + """测试获取常用命令列表。""" + resp = await client.get("/api/v1/db-types/mysql/commands", headers={"Authorization": "Bearer " + admin_token}) + + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert data["total"] >= 5 + + for cmd in data["items"]: + assert "name" in cmd + assert "command" in cmd + + +@pytest.mark.asyncio +async def test_get_commands_redis(client, admin_token): + """测试获取 Redis 常用命令列表。""" + resp = await client.get("/api/v1/db-types/redis/commands", headers={"Authorization": "Bearer " + admin_token}) + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 5 + + cmd_names = {cmd["name"] for cmd in data["items"]} + assert "查看键数量" in cmd_names or "查看服务器信息" in cmd_names + + +@pytest.mark.asyncio +async def test_get_commands_unknown(client, admin_token): + """测试获取不支持类型的命令 — 404。""" + resp = await client.get("/api/v1/db-types/cassandra/commands", headers={"Authorization": "Bearer " + admin_token}) + + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_get_tips(client, admin_token): + """测试获取使用技巧。""" + resp = await client.get("/api/v1/db-types/mysql/tips", headers={"Authorization": "Bearer " + admin_token}) + + assert resp.status_code == 200 + data = resp.json() + assert "items" in data + assert data["total"] >= 3 + # 每条 tip 是字符串 + for tip in data["items"]: + assert isinstance(tip, str) + assert len(tip) > 0 + + +@pytest.mark.asyncio +async def test_get_tips_mongo(client, admin_token): + """测试获取 MongoDB 使用技巧。""" + resp = await client.get("/api/v1/db-types/mongo/tips", headers={"Authorization": "Bearer " + admin_token}) + + assert resp.status_code == 200 + data = resp.json() + assert data["total"] >= 2 + + +@pytest.mark.asyncio +async def test_get_tips_unknown(client, admin_token): + """测试获取不支持类型的技巧 — 404。""" + resp = await client.get("/api/v1/db-types/sqlite/tips", headers={"Authorization": "Bearer " + admin_token}) + + assert resp.status_code == 404 + + +@pytest.mark.asyncio +async def test_db_types_require_auth(client): + """测试未认证请求被拒绝。""" + resp = await client.get("/api/v1/db-types") + + assert resp.status_code in (401, 403) + + +@pytest.mark.asyncio +async def test_list_structure_consistency(client, admin_token): + """测试所有类型的列表条目结构一致。""" + resp = await client.get("/api/v1/db-types", headers={"Authorization": "Bearer " + admin_token}) + + data = resp.json() + required_fields = {"type", "name", "icon", "description", "category", "version", "supported_operations", "security_rules_count"} + for item in data["items"]: + assert required_fields.issubset(set(item.keys())), \ + "Missing fields: " + str(required_fields - set(item.keys())) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6be062d..dbcb75f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -21,7 +21,7 @@ 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'; +// import SlowQueryPage from '@/pages/SlowQueryPage'; // 路由守卫:未登录跳转登录页,无权限跳转仪表盘 function RouteGuard() { @@ -67,7 +67,6 @@ function AppRoutes() { } /> } /> } /> - } /> } /> } /> } /> diff --git a/frontend/src/api/dbTypes.ts b/frontend/src/api/dbTypes.ts new file mode 100644 index 0000000..f8143ad --- /dev/null +++ b/frontend/src/api/dbTypes.ts @@ -0,0 +1,51 @@ +import request from './request'; + +export interface DBTypeOverview { + type: string; + name: string; + icon: string; + description: string; + category: string; + version: string; + supported_operations: number; + security_rules_count: number; +} + +export interface DBTypeQueryType { + type: string; + description: string; + example: string; +} + +export interface DBTypeDetail { + type: string; + name: string; + icon: string; + description: string; + version: string; + category: string; + supported_query_types: DBTypeQueryType[]; + security_rules: string[]; + tips: string[]; + common_commands: Record; + limitations: string[]; +} + +export interface DBTypeCommand { + name: string; + command: string; +} + +export const dbTypesApi = { + list: () => + request.get('/db-types'), + + getDetail: (dbType: string) => + request.get('/db-types/' + dbType), + + getCommands: (dbType: string) => + request.get('/db-types/' + dbType + '/commands'), + + getTips: (dbType: string) => + request.get('/db-types/' + dbType + '/tips'), +}; diff --git a/frontend/src/api/query.ts b/frontend/src/api/query.ts index f6aea85..adfb6e7 100644 --- a/frontend/src/api/query.ts +++ b/frontend/src/api/query.ts @@ -3,4 +3,13 @@ import request from './request'; export const queryApi = { execute: (data: { instance_id: number; database?: string; sql: string }) => request.post('/query', data), + + formatSQL: (sql: string) => + request.post('/sql-assistant/format', { sql }), + + analyzeSQL: (sql: string, db_type: string) => + request.post('/sql-assistant/analyze', { sql, db_type }), + + getDBTypeDetail: (dbType: string) => + request.get('/db-types/' + dbType), }; diff --git a/frontend/src/components/EditInstanceDialog.tsx b/frontend/src/components/EditInstanceDialog.tsx new file mode 100644 index 0000000..9a28a7e --- /dev/null +++ b/frontend/src/components/EditInstanceDialog.tsx @@ -0,0 +1,186 @@ +import { useState, useCallback, useEffect } from 'react'; +import { X, Loader2 } from 'lucide-react'; +import { instanceApi } from '@/api/instance'; + +/* ------------------------------------------------------------------ */ +/* Types */ +/* ------------------------------------------------------------------ */ +interface InstanceData { + id: number; + name: string; + db_type: string; + host: string; + port: number; + username: string; + database: string; + env: string; + access_mode: string; +} + +interface Props { + instance: InstanceData; + open: boolean; + onClose: () => void; + onSaved: () => void; +} + +/* ------------------------------------------------------------------ */ +/* Reusable Edit Instance Dialog */ +/* ------------------------------------------------------------------ */ +export default function EditInstanceDialog({ instance, open, onClose, onSaved }: Props) { + const [name, setName] = useState(instance.name); + const [host, setHost] = useState(instance.host); + const [port, setPort] = useState(String(instance.port)); + const [username, setUsername] = useState(instance.username); + const [password, setPassword] = useState(''); + const [database, setDatabase] = useState(instance.database || ''); + const [env, setEnv] = useState(instance.env || 'development'); + const [accessMode, setAccessMode] = useState(instance.access_mode || 'readwrite'); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + /* Sync when instance changes */ + useEffect(() => { + setName(instance.name); + setHost(instance.host); + setPort(String(instance.port)); + setUsername(instance.username); + setPassword(''); + setDatabase(instance.database || ''); + setEnv(instance.env || 'development'); + setAccessMode(instance.access_mode || 'readwrite'); + setError(''); + }, [instance]); + + const handleSubmit = useCallback(async () => { + if (!name.trim()) { setError('请填写名称'); return; } + if (!host.trim()) { setError('请填写地址'); return; } + setSubmitting(true); setError(''); + try { + await instanceApi.update(instance.id, { + name: name.trim(), + host: host.trim(), + port: Number(port), + username: username.trim() || undefined, + password: password || undefined, + database: database.trim() || undefined, + env, + access_mode: accessMode, + }); + onSaved(); + } catch (e: unknown) { + const msg = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '保存失败'; + setError(msg); + } finally { + setSubmitting(false); + } + }, [name, host, port, username, password, database, env, accessMode, instance.id, onSaved]); + + if (!open) return null; + + const inputCls = 'w-full px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors'; + const labelCls = 'block text-xs font-medium text-[var(--dg-text-secondary)] mb-1'; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

编辑数据源

+ +
+ + {/* Body */} +
+ {error && ( +
{error}
+ )} + + {/* Name */} +
+ + setName(e.target.value)} placeholder="实例名称" /> +
+ + {/* Host + Port */} +
+
+ + setHost(e.target.value)} placeholder="127.0.0.1" /> +
+
+ + setPort(e.target.value)} placeholder="3306" /> +
+
+ + {/* Username + Password */} +
+
+ + setUsername(e.target.value)} /> +
+
+ + setPassword(e.target.value)} placeholder="••••••" /> +
+
+ + {/* Database */} +
+ + setDatabase(e.target.value)} placeholder="可选" /> +
+ + {/* Env + Access Mode */} +
+
+ + +
+
+ + +
+
+
+ + {/* Footer */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/lib/mock.ts b/frontend/src/lib/mock.ts index b18869e..178252b 100644 --- a/frontend/src/lib/mock.ts +++ b/frontend/src/lib/mock.ts @@ -25,8 +25,7 @@ export const menuItems = [ { id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] }, { id: 'instance-groups', label: '权限管理', icon: 'Lock', section: 'permission', roles: ['admin'] }, { id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] }, - { id: 'queries', label: '查询审计', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] }, - { id: 'slow-query', label: '慢查询分析', icon: 'Timer', section: 'ticket', roles: ['admin', 'dba'] }, + { id: 'queries', label: '查询数据', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] }, { id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] }, { id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] }, { id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] }, @@ -192,7 +191,7 @@ export const permissionTreeData: PermissionNode[] = [ { id: 'ticket_execute', label: '执行工单' }, { id: 'ticket_rollback', label: '回滚工单' }, ]}, - { id: 'queries', label: '查询审计(查看日志、新建查询、导出)', children: [ + { id: 'queries', label: '查询数据(查看日志、新建查询、导出)', children: [ { id: 'query_create', label: '执行查询' }, { id: 'query_export', label: '导出结果' }, { id: 'query_view_all', label: '查看全部用户查询' }, diff --git a/frontend/src/pages/AuditLogsPage.tsx b/frontend/src/pages/AuditLogsPage.tsx index 766c01f..51ab4a1 100644 --- a/frontend/src/pages/AuditLogsPage.tsx +++ b/frontend/src/pages/AuditLogsPage.tsx @@ -493,9 +493,9 @@ export default function AuditLogsPage() {
- {log.username.charAt(0).toUpperCase()} + {(log.username || '?').charAt(0).toUpperCase()}
- {log.username} + {log.username || '-'}
diff --git a/frontend/src/pages/AuditRulesPage.tsx b/frontend/src/pages/AuditRulesPage.tsx index d75daf3..5b11f51 100644 --- a/frontend/src/pages/AuditRulesPage.tsx +++ b/frontend/src/pages/AuditRulesPage.tsx @@ -26,7 +26,7 @@ interface AuditRule { } type DbType = 'mysql' | 'postgresql' | 'redis' | 'mongodb'; -type RuleGroup = 'DDL' | 'DML' | 'DCL' | 'DQL'; +type RuleGroup = 'DDL' | 'DML' | 'DCL' | 'DQL' | 'security' | 'performance'; type SeverityFilter = 'all' | 'error' | 'warning' | 'info'; /* ------------------------------------------------------------------ */ @@ -51,6 +51,8 @@ const GROUP_CONFIG: Record = { @@ -59,34 +61,17 @@ const SEVERITY_CONFIG: Record upper.includes(kw))) return 'DDL'; - if (DML_KEYWORDS.some((kw) => upper.includes(kw))) return 'DML'; - if (DCL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DCL'; - if (DQL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DQL'; - // MongoDB security rules -> DCL, performance rules -> DQL - if (upper.includes('SECURITY') || upper.includes('安全') || upper.includes('审计') || upper.includes('注入') || upper.includes('EVAL') || upper.includes('MAPSQL_REDUCE')) return 'DCL'; - if (upper.includes('PERFORMANCE') || upper.includes('性能') || upper.includes('索引') || upper.includes('扫描') || upper.includes('分片') || upper.includes('SHARD')) return 'DQL'; - // Default to DDL for structural rules, DML for data rules - if (upper.includes('WHERE') || upper.includes('写入') || upper.includes('UPDATE') || upper.includes('DELETE')) return 'DML'; +const VALID_GROUPS: RuleGroup[] = ['DDL', 'DML', 'DCL', 'DQL', 'security', 'performance']; + +function getRuleGroup(rule: AuditRule): RuleGroup { + const rt = rule.rule_type; + if (VALID_GROUPS.includes(rt as RuleGroup)) return rt as RuleGroup; + // Fallback: map unknown types + if (rt === 'safety' || rt === 'naming' || rt === 'syntax') return 'DDL'; return 'DDL'; } @@ -99,7 +84,7 @@ function sortRules(rules: AuditRule[], sortBy: 'severity' | 'name' | 'type'): Au case 'name': return sorted.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN')); case 'type': - return sorted.sort((a, b) => getRuleGroup(a.name).localeCompare(getRuleGroup(b.name))); + return sorted.sort((a, b) => getRuleGroup(a).localeCompare(getRuleGroup(b))); default: return sorted; } @@ -239,9 +224,11 @@ export default function AuditRulesPage() { DML: [], DCL: [], DQL: [], + security: [], + performance: [], }; for (const rule of filteredRules) { - const group = getRuleGroup(rule.name); + const group = getRuleGroup(rule); groups[group].push(rule); } return groups; @@ -580,8 +567,10 @@ export default function AuditRulesPage() {
{renderRuleGroup('DDL', groupedRules.DDL)} {renderRuleGroup('DML', groupedRules.DML)} + {renderRuleGroup('security', groupedRules.security)} {renderRuleGroup('DCL', groupedRules.DCL)} {renderRuleGroup('DQL', groupedRules.DQL)} + {renderRuleGroup('performance', groupedRules.performance)}
)} diff --git a/frontend/src/pages/InstancePage.tsx b/frontend/src/pages/InstancePage.tsx index 136dde4..35c62b5 100644 --- a/frontend/src/pages/InstancePage.tsx +++ b/frontend/src/pages/InstancePage.tsx @@ -11,6 +11,7 @@ import { useActionPermission } from '@/hooks/useAuth'; import PageHeader from '@/components/PageHeader'; import EmptyState from '@/components/EmptyState'; import { instanceApi } from '@/api/instance'; +import EditInstanceDialog from '@/components/EditInstanceDialog'; /* ------------------------------------------------------------------ */ /* Types */ @@ -41,11 +42,14 @@ type ViewMode = 'grid' | 'list'; /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ +/* ------------------------------------------------------------------ */ +/* DB Brand Icons */ + const dbTypeConfig: Record = { - mysql: { icon: Database, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' }, - postgres: { icon: Server, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' }, - redis: { icon: Zap, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' }, - mongo: { icon: Leaf, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' }, + mysql: { icon: MySQLIcon, color: 'text-[#26a69a]', bg: 'bg-[rgba(0,117,143,0.15)]', label: 'MySQL' }, + postgres: { icon: PostgreSQLIcon, color: 'text-[#42a5f5]', bg: 'bg-[rgba(51,103,145,0.15)]', label: 'PostgreSQL' }, + redis: { icon: RedisIcon, color: 'text-[#ef5350]', bg: 'bg-[rgba(220,50,32,0.15)]', label: 'Redis' }, + mongo: { icon: MongoDBIcon, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' }, }; const envLabel: Record = { @@ -132,6 +136,8 @@ export default function InstancePage() { const [instances, setInstances] = useState([]); const [loading, setLoading] = useState(false); const [showCreate, setShowCreate] = useState(false); + const [showEdit, setShowEdit] = useState(false); + const [editingInstance, setEditingInstance] = useState(null); const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchDeleting, setBatchDeleting] = useState(false); @@ -242,6 +248,28 @@ export default function InstancePage() { ); } + if (showEdit && editingInstance) { + return ( + <> + { setView('grid'); setShowEdit(false); setEditingInstance(null); }} + config={dbTypeConfig[editingInstance.db_type] || dbTypeConfig.mysql} + canEdit={canEdit} + canDelete={canDelete} + onDelete={() => { handleDelete(editingInstance.id); setView('grid'); setShowEdit(false); setEditingInstance(null); }} + onEdit={() => {}} + /> + { setShowEdit(false); setEditingInstance(null); }} + onSaved={() => { setShowEdit(false); setEditingInstance(null); fetchInstances(); }} + /> + + ); + } + const selectedConfig = selectedInstance ? (dbTypeConfig[selectedInstance.db_type] || dbTypeConfig.mysql) : dbTypeConfig.mysql; @@ -255,6 +283,7 @@ export default function InstancePage() { canEdit={canEdit} canDelete={canDelete} onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }} + onEdit={() => { setEditingInstance(selectedInstance); setShowEdit(true); }} /> ); } @@ -428,7 +457,7 @@ export default function InstancePage() { ) : filtered.length === 0 ? ( ) : layoutMode === 'grid' ? ( -
+
{filtered.map(instance => { const ic = dbTypeConfig[instance.db_type] || dbTypeConfig.mysql; const IconComp = ic.icon; @@ -438,7 +467,7 @@ export default function InstancePage() { return (
{ setSelectedInstance(instance); setView('detail'); }} @@ -456,8 +485,8 @@ export default function InstancePage() {
-
- +
+
@@ -472,14 +501,14 @@ export default function InstancePage() {
-

{instance.name}

-

{instance.host}:{instance.port}

-

+

{instance.name}

+

{instance.host}:{instance.port}

+

{ic.label} {instance.version || ''}

{/* Health Score */} -
+
@@ -495,35 +524,33 @@ export default function InstancePage() {
{/* Metrics */} -
-
+
+
- {instance.connections} + {instance.connections}
- 连接 + 连接
-
+
- {instance.qps ? instance.qps.toLocaleString() : '-'} + {instance.qps ? instance.qps.toLocaleString() : '-'}
- QPS + QPS
-
+
- {instance.latency}ms + {instance.latency}ms
- 延迟 + 延迟
- {instance.description && ( -

{instance.description}

- )} -
+ +
查看详情 @@ -584,7 +611,7 @@ export default function InstancePage() { {instance.name} - + {ic.label} @@ -633,10 +660,10 @@ export default function InstancePage() { /* ------------------------------------------------------------------ */ /* InstanceDetail */ /* ------------------------------------------------------------------ */ -function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete }: { +function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete, onEdit }: { instance: InstanceItem; onBack: () => void; config: { icon: React.ElementType; color: string; bg: string }; - canEdit: boolean; canDelete: boolean; onDelete: () => void; + canEdit: boolean; canDelete: boolean; onDelete: () => void; onEdit: () => void; }) { const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null); @@ -694,7 +721,7 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete {testing ? '测试中...' : '测试连接'} {canEdit && ( - @@ -845,8 +872,7 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete ); } -/* ------------------------------------------------------------------ */ -/* DB Brand Icons */ + /* ------------------------------------------------------------------ */ function MySQLIcon({ className }: { className?: string }) { return MySQL; diff --git a/frontend/src/pages/QueriesPage.tsx b/frontend/src/pages/QueriesPage.tsx index a9eebf5..81bb049 100644 --- a/frontend/src/pages/QueriesPage.tsx +++ b/frontend/src/pages/QueriesPage.tsx @@ -170,7 +170,7 @@ export default function QueriesPage() { return (
setShowNewQuery(true), permission: 'query.create' } : undefined} /> diff --git a/frontend/src/pages/RolesPage.tsx b/frontend/src/pages/RolesPage.tsx index 52f129f..fa4a464 100644 --- a/frontend/src/pages/RolesPage.tsx +++ b/frontend/src/pages/RolesPage.tsx @@ -32,7 +32,7 @@ const PERMISSION_PRESETS: PermissionPreset[] = [ { key: 'dba', name: 'DBA 模板', - description: '数据库管理员:完整的数据源管理、工单审批、查询审计权限', + description: '数据库管理员:完整的数据源管理、工单审批、查询数据权限', icon: Database, permissions: ['dashboard', 'mysql', 'postgres', 'redis', 'mongo', 'tickets', 'ticket_create', 'ticket_approve', 'ticket_execute', 'ticket_rollback', 'queries', 'query_create', 'query_export', 'query_view_all', 'audit-rules', 'audit-logs', 'workflow'], }, diff --git a/frontend/src/pages/TicketDetailPage.tsx b/frontend/src/pages/TicketDetailPage.tsx index 9be2aa2..c2aa541 100644 --- a/frontend/src/pages/TicketDetailPage.tsx +++ b/frontend/src/pages/TicketDetailPage.tsx @@ -216,19 +216,6 @@ export default function TicketDetailPage() { /* Parse execute result for visualization */ /* ---------------------------------------------------------------- */ - const executeStats = useMemo(() => { - if (!ticket?.execute_result) return null; - // Try to extract affected rows and duration from execute result - const result = ticket.execute_result; - const affectedMatch = result.match(/(\d+)\s*(?:row|rows)?\s*(?:affected|changed)/i); - const durationMatch = result.match(/(\d+(?:\.\d+)?)\s*(?:ms|seconds?|s)/i); - return { - affectedRows: ticket.affected_rows || (affectedMatch ? parseInt(affectedMatch[1], 10) : undefined), - durationMs: ticket.execute_duration_ms || (durationMatch ? parseFloat(durationMatch[1]) : undefined), - raw: result, - }; - }, [ticket]); - /* ---------------------------------------------------------------- */ /* Loading & Error states */ /* ---------------------------------------------------------------- */ @@ -442,39 +429,6 @@ export default function TicketDetailPage() { ); }; - const renderExecuteStats = () => { - if (!executeStats) return null; - - return ( -
- {executeStats.affectedRows !== undefined && ( -
-
- - 影响行数 -
-
- {executeStats.affectedRows.toLocaleString()} -
-
- )} - {executeStats.durationMs !== undefined && ( -
-
- - 执行耗时 -
-
- {executeStats.durationMs >= 1000 - ? `${(executeStats.durationMs / 1000).toFixed(2)}s` - : `${Math.round(executeStats.durationMs)}ms`} -
-
- )} -
- ); - }; - const renderRelatedTickets = () => { if (relatedTickets.length === 0) return null; @@ -624,22 +578,90 @@ export default function TicketDetailPage() {
{/* Audit result */} - {ticket.audit_result && ( -
- -
- {ticket.audit_result} + {ticket.audit_result && (() => { + let audit: any = null; + try { audit = typeof ticket.audit_result === 'string' ? JSON.parse(ticket.audit_result) : ticket.audit_result; } catch { audit = null; } + + if (!audit || !audit.findings) { + return ( +
+

+ 审核结果 +

+

{ticket.audit_result}

+
+ ); + } + + const errorCount = audit.findings.filter((f: any) => f.level === 'error').length; + const warningCount = audit.findings.filter((f: any) => f.level === 'warning').length; + const infoCount = audit.findings.filter((f: any) => f.level === 'info').length; + const scoreColor = audit.score >= 90 ? 'text-green-400' : audit.score >= 60 ? 'text-amber-400' : 'text-red-400'; + + return ( +
+ + + {/* Summary badges */} +
+ {audit.passed ? ( + + 审核通过 + + ) : ( + + 存在阻断项 + + )} + {errorCount > 0 && {errorCount} 错误} + {warningCount > 0 && {warningCount} 警告} + {infoCount > 0 && {infoCount} 提示} +
+ + {/* Findings table */} + {showFullAudit && audit.findings.length > 0 && ( +
+ {audit.findings.map((f: any, idx: number) => { + const levelConfig: Record = { + error: { label: '错误', color: 'text-red-400 bg-red-500/15', icon: XCircle }, + warning: { label: '警告', color: 'text-amber-400 bg-amber-500/15', icon: AlertTriangle }, + info: { label: '提示', color: 'text-blue-400 bg-blue-500/15', icon: CheckCircle }, + }; + const lc = levelConfig[f.level] || levelConfig.info; + const LevelIcon = lc.icon; + return ( +
+
+ + {lc.label} + {f.rule && {f.rule}} +
+

{f.message}

+
+ ); + })} +
+ )} + + {/* Summary text */} + {!showFullAudit && audit.summary && ( +

{audit.summary}

+ )}
-
- )} + ); + })()} {/* Rollback SQL */} {ticket.rollback_sql && ( @@ -658,23 +680,112 @@ export default function TicketDetailPage() { )} {/* Execute result */} - {ticket.execute_result && ( -
-

- 执行结果 -

-
-            {ticket.execute_result}
-          
- {renderExecuteStats()} - {ticket.execute_time && ( -
- - 执行时间: {new Date(ticket.execute_time).toLocaleString()} + {ticket.execute_result && (() => { + let execResult: any = null; + try { execResult = typeof ticket.execute_result === 'string' ? JSON.parse(ticket.execute_result) : ticket.execute_result; } catch { execResult = null; } + + if (!execResult || typeof execResult !== 'object') { + return ( +
+

+ 执行结果 +

+
+                {ticket.execute_result}
+              
+ {ticket.execute_time && ( +
+ + 执行时间: {new Date(ticket.execute_time).toLocaleString()} +
+ )}
- )} -
- )} + ); + } + + const isSuccess = execResult.status === 'success'; + const StatusIcon = isSuccess ? CheckCircle : XCircle; + const statusColor = isSuccess ? 'text-green-400' : 'text-red-400'; + const statusBg = isSuccess ? 'bg-green-500/15' : 'bg-red-500/15'; + + return ( +
+

+ 执行结果 +

+ + {/* Status badge + key metrics */} +
+ + + {isSuccess ? '执行成功' : '执行失败'} + + {execResult.affected_rows !== undefined && ( + + 影响行数: {execResult.affected_rows} + + )} + {execResult.duration_ms !== undefined && ( + + 耗时: {execResult.duration_ms}ms + + )} + {execResult.validation_passed !== undefined && ( + + {execResult.validation_passed ? '✓ 验证通过' : '✗ 验证未通过'} + + )} +
+ + {/* Errors */} + {execResult.errors && execResult.errors.length > 0 && ( +
+ {execResult.errors.map((err: string, idx: number) => ( +
+
+ + 错误 {execResult.errors.length > 1 ? `#${idx + 1}` : ''} +
+

{err}

+
+ ))} +
+ )} + + {/* Warnings */} + {execResult.warnings && execResult.warnings.length > 0 && ( +
+ {execResult.warnings.map((w: string, idx: number) => ( +
+
+ + 警告 {execResult.warnings.length > 1 ? `#${idx + 1}` : ''} +
+

{w}

+
+ ))} +
+ )} + + {/* Raw output (if present) */} + {execResult.raw_output && ( +
+

原始输出

+
+                  {execResult.raw_output}
+                
+
+ )} + + {ticket.execute_time && ( +
+ + 执行时间: {new Date(ticket.execute_time).toLocaleString()} +
+ )} +
+ ); + })()} {/* Timeline */} {renderTimeline()} diff --git a/frontend/src/pages/TicketsPage.tsx b/frontend/src/pages/TicketsPage.tsx index 7922895..08e86b9 100644 --- a/frontend/src/pages/TicketsPage.tsx +++ b/frontend/src/pages/TicketsPage.tsx @@ -1,8 +1,8 @@ import { useState, useEffect, useCallback, useMemo } from 'react'; import { - Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw, - FileCode, History, Undo2, XCircle as XIcon, - Clock, AlertTriangle, AlertCircle, Info, ChevronLeft, + Plus, Search, Eye, CheckCircle, XCircle, Play, + FileCode, History, XCircle as XIcon, + Clock, AlertTriangle, ChevronLeft, Filter, X, Loader2, Users, Calendar, CheckSquare, Square, ArrowUpDown, FileDown, Ban, } from 'lucide-react'; @@ -24,9 +24,7 @@ interface TicketItem { instance_id: number; target_database: string; sql_content: string; - rollback_sql: string | null; status: string; - priority: string; applicant_id: number; applicant_name: string | null; approver_id: number | null; @@ -62,11 +60,7 @@ type DateRange = 'all' | 'today' | 'week' | 'month'; /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ -const priorityConfig: Record = { - high: { label: '高', color: 'text-red-400', bg: 'bg-red-500/15', border: 'border-red-500/30', icon: AlertCircle }, - medium: { label: '中', color: 'text-yellow-400', bg: 'bg-yellow-500/15', border: 'border-yellow-500/30', icon: AlertTriangle }, - low: { label: '低', color: 'text-blue-400', bg: 'bg-blue-500/15', border: 'border-blue-500/30', icon: Info }, -}; + const statusFlow = ['pending', 'reviewing', 'approved', 'executing', 'success']; @@ -74,14 +68,13 @@ const statusFlow = ['pending', 'reviewing', 'approved', 'executing', 'success']; /* Helpers */ /* ------------------------------------------------------------------ */ function exportTicketsToCSV(tickets: TicketItem[]): string { - const columns = ['工单号', '标题', '数据库类型', '目标数据库', '申请人', '优先级', '状态', '创建时间', '变更原因']; + const columns = ['工单号', '标题', '数据库类型', '目标数据库', '申请人', '状态', '创建时间', '变更原因']; const rows = tickets.map(t => ({ 工单号: `TK-${t.id}`, 标题: t.title, 数据库类型: t.db_type.toUpperCase(), 目标数据库: t.target_database, 申请人: t.applicant_name || '-', - 优先级: t.priority === 'high' ? '高' : t.priority === 'medium' ? '中' : '低', 状态: statusMap[t.status] || t.status, 创建时间: new Date(t.create_time).toLocaleString(), 变更原因: t.reason || '-', @@ -144,8 +137,6 @@ function TableRowSkeleton() {
-
-
@@ -163,7 +154,6 @@ export default function TicketsPage() { const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState(''); const [dbTypeFilter, setDbTypeFilter] = useState(''); - const [priorityFilter, setPriorityFilter] = useState(''); const [dateRange, setDateRange] = useState('all'); const [applicantFilter, setApplicantFilter] = useState(''); const [selectedTicket, setSelectedTicket] = useState(null); @@ -177,7 +167,7 @@ export default function TicketsPage() { const [selectedIds, setSelectedIds] = useState>(new Set()); const [batchApproving, setBatchApproving] = useState(false); const [showFilters, setShowFilters] = useState(false); - const [sortField, setSortField] = useState<'create_time' | 'priority'>('create_time'); + const [sortField, setSortField] = useState<'create_time'>('create_time'); const [sortAsc, setSortAsc] = useState(false); const fetchTickets = useCallback(async () => { @@ -223,11 +213,6 @@ export default function TicketsPage() { } /* Client-side sort */ result.sort((a, b) => { - if (sortField === 'priority') { - const order = { high: 0, medium: 1, low: 2 }; - const cmp = (order[a.priority as keyof typeof order] ?? 3) - (order[b.priority as keyof typeof order] ?? 3); - return sortAsc ? cmp : -cmp; - } return sortAsc ? new Date(a.create_time).getTime() - new Date(b.create_time).getTime() : new Date(b.create_time).getTime() - new Date(a.create_time).getTime(); @@ -310,7 +295,7 @@ export default function TicketsPage() { ); } - const hasActiveFilters = search || statusFilter || dbTypeFilter || priorityFilter || dateRange !== 'all' || applicantFilter; + const hasActiveFilters = search || statusFilter || dbTypeFilter || dateRange !== 'all' || applicantFilter; return (
@@ -401,16 +386,7 @@ export default function TicketsPage() { - +