fix: 审核引擎优化 + 执行结果结构化展示

后端审核规则修复:
- 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零错误
This commit is contained in:
logan committed 2026-05-24 01:31:33 +08:00
1 parent 9f56e866f2
commit 4ac2528f8e
19 files changed
+1202 -325

No files matched your search

+267
View File
@@ -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)}
+3 -1
View File
@@ -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)
+2 -2
View File
@@ -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
+57
View File
@@ -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
+100 -2
View File
@@ -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)
# ═══════════════════════════════════════════════════
+32 -26
View File
@@ -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,
+204
View File
@@ -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()))
+1 -2
View File
@@ -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() {
<Route path="tickets/create" element={<TicketCreatePage />} />
<Route path="tickets/:id" element={<TicketDetailPage />} />
<Route path="queries" element={<QueriesPage />} />
<Route path="slow-query" element={<SlowQueryPage />} />
<Route path="roles" element={<RolesPage />} />
<Route path="users" element={<UsersPage />} />
<Route path="audit-rules" element={<AuditRulesPage />} />
+51
View File
@@ -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<string, string>;
limitations: string[];
}
export interface DBTypeCommand {
name: string;
command: string;
}
export const dbTypesApi = {
list: () =>
request.get<any, { items: DBTypeOverview[]; total: number }>('/db-types'),
getDetail: (dbType: string) =>
request.get<any, DBTypeDetail>('/db-types/' + dbType),
getCommands: (dbType: string) =>
request.get<any, { items: DBTypeCommand[]; total: number }>('/db-types/' + dbType + '/commands'),
getTips: (dbType: string) =>
request.get<any, { items: string[]; total: number }>('/db-types/' + dbType + '/tips'),
};
+9
View File
@@ -3,4 +3,13 @@ import request from './request';
export const queryApi = {
execute: (data: { instance_id: number; database?: string; sql: string }) =>
request.post<any, any>('/query', data),
formatSQL: (sql: string) =>
request.post<any, any>('/sql-assistant/format', { sql }),
analyzeSQL: (sql: string, db_type: string) =>
request.post<any, any>('/sql-assistant/analyze', { sql, db_type }),
getDBTypeDetail: (dbType: string) =>
request.get<any, any>('/db-types/' + dbType),
};
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div
className="bg-[var(--dg-bg-secondary)] rounded-xl shadow-2xl w-full max-w-lg max-h-[85vh] overflow-y-auto"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-5 py-4">
<h2 className="text-base font-semibold">编辑数据源</h2>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
{/* Body */}
<div className="px-5 pb-5 space-y-4">
{error && (
<div className="px-3 py-2 text-sm text-red-400 bg-red-500/10 rounded-lg">{error}</div>
)}
{/* Name */}
<div>
<label className={labelCls}>名称</label>
<input className={inputCls} value={name} onChange={e => setName(e.target.value)} placeholder="实例名称" />
</div>
{/* Host + Port */}
<div className="grid grid-cols-3 gap-3">
<div className="col-span-2">
<label className={labelCls}>地址</label>
<input className={inputCls} value={host} onChange={e => setHost(e.target.value)} placeholder="127.0.0.1" />
</div>
<div>
<label className={labelCls}>端口</label>
<input className={inputCls} value={port} onChange={e => setPort(e.target.value)} placeholder="3306" />
</div>
</div>
{/* Username + Password */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>用户名</label>
<input className={inputCls} value={username} onChange={e => setUsername(e.target.value)} />
</div>
<div>
<label className={labelCls}>密码(留空不修改)</label>
<input className={inputCls} type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="••••••" />
</div>
</div>
{/* Database */}
<div>
<label className={labelCls}>默认数据库</label>
<input className={inputCls} value={database} onChange={e => setDatabase(e.target.value)} placeholder="可选" />
</div>
{/* Env + Access Mode */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className={labelCls}>环境</label>
<select className={inputCls} value={env} onChange={e => setEnv(e.target.value)}>
<option value="development">开发</option>
<option value="testing">测试</option>
<option value="staging">预发</option>
<option value="production">生产</option>
<option value="uat">UAT</option>
</select>
</div>
<div>
<label className={labelCls}>访问模式</label>
<select className={inputCls} value={accessMode} onChange={e => setAccessMode(e.target.value)}>
<option value="readwrite">读写</option>
<option value="read">只读</option>
<option value="write">只写</option>
</select>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-4">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] rounded-lg transition-colors"
>
取消
</button>
<button
onClick={handleSubmit}
disabled={submitting}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-500 hover:bg-blue-600 disabled:opacity-50 rounded-lg transition-colors"
>
{submitting && <Loader2 className="w-4 h-4 animate-spin" />}
保存
</button>
</div>
</div>
</div>
);
}
+2 -3
View File
@@ -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: '查看全部用户查询' },
+2 -2
View File
@@ -493,9 +493,9 @@ export default function AuditLogsPage() {
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded-full bg-blue-500/15 text-blue-400 flex items-center justify-center text-xs font-medium">
{log.username.charAt(0).toUpperCase()}
{(log.username || '?').charAt(0).toUpperCase()}
</div>
<span className="text-[var(--dg-text-primary)]">{log.username}</span>
<span className="text-[var(--dg-text-primary)]">{log.username || '-'}</span>
</div>
</td>
<td className="px-4 py-3">
+16 -27
View File
@@ -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<RuleGroup, { label: string; icon: React.ElementType;
DML: { label: 'DML 规则', icon: Shield, color: 'text-blue-400 bg-blue-500/15' },
DCL: { label: 'DCL 规则', icon: Shield, color: 'text-purple-400 bg-purple-500/15' },
DQL: { label: 'DQL 规则', icon: FileSearch, color: 'text-green-400 bg-green-500/15' },
security: { label: '安全规则', icon: AlertTriangle, color: 'text-red-400 bg-red-500/15' },
performance: { label: '性能规则', icon: Info, color: 'text-cyan-400 bg-cyan-500/15' },
};
const SEVERITY_CONFIG: Record<string, { label: string; color: string; dot: string }> = {
@@ -59,34 +61,17 @@ const SEVERITY_CONFIG: Record<string, { label: string; color: string; dot: strin
info: { label: '提示', color: 'text-blue-400', dot: 'bg-blue-400' },
};
const DDL_KEYWORDS = [
'DDL', 'CREATE', 'ALTER', 'DROP', 'TRUNCATE', 'RENAME',
'TABLE', 'INDEX', 'KEY', 'COLUMN', 'PARTITION', 'VIEW',
'DATABASE', 'SCHEMA', 'SEQUENCE', '集合', 'COLLECTION',
' Validator', 'CAPPED', 'SCHEMA',
];
const DML_KEYWORDS = ['DML', 'INSERT', 'UPDATE', 'DELETE', 'REPLACE', 'MERGE', 'BULK', '写入', 'UNSET'];
const DCL_KEYWORDS = ['DCL', 'GRANT', 'REVOKE', 'DENY'];
const DQL_KEYWORDS = ['DQL', 'SELECT', 'EXPLAIN', 'DESCRIBE', 'SHOW', 'FIND', '查询', '聚合', 'AGGREGAT', 'PROJECTION', 'FILTER', 'REGEX', '$WHERE'];
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getRuleGroup(name: string): RuleGroup {
const upper = name.toUpperCase();
if (DDL_KEYWORDS.some((kw) => 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() {
<div className="space-y-6">
{renderRuleGroup('DDL', groupedRules.DDL)}
{renderRuleGroup('DML', groupedRules.DML)}
{renderRuleGroup('security', groupedRules.security)}
{renderRuleGroup('DCL', groupedRules.DCL)}
{renderRuleGroup('DQL', groupedRules.DQL)}
{renderRuleGroup('performance', groupedRules.performance)}
</div>
)}
</div>
+58 -32
View File
@@ -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<string, { icon: React.ElementType; color: string; bg: string; label: string }> = {
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<string, string> = {
@@ -132,6 +136,8 @@ export default function InstancePage() {
const [instances, setInstances] = useState<InstanceItem[]>([]);
const [loading, setLoading] = useState(false);
const [showCreate, setShowCreate] = useState(false);
const [showEdit, setShowEdit] = useState(false);
const [editingInstance, setEditingInstance] = useState<InstanceItem | null>(null);
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
const [batchDeleting, setBatchDeleting] = useState(false);
@@ -242,6 +248,28 @@ export default function InstancePage() {
);
}
if (showEdit && editingInstance) {
return (
<>
<InstanceDetail
instance={editingInstance}
onBack={() => { 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={() => {}}
/>
<EditInstanceDialog
instance={editingInstance}
open={showEdit}
onClose={() => { 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 ? (
<EmptyState type="search" />
) : layoutMode === 'grid' ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{filtered.map(instance => {
const ic = dbTypeConfig[instance.db_type] || dbTypeConfig.mysql;
const IconComp = ic.icon;
@@ -438,7 +467,7 @@ export default function InstancePage() {
return (
<div
key={instance.id}
className={`dg-card p-5 cursor-pointer dg-card-hover group relative transition-all ${
className={`dg-card p-3.5 cursor-pointer dg-card-hover group relative transition-all ${
isSelected ? 'ring-2 ring-blue-500/50' : ''
}`}
onClick={() => { setSelectedInstance(instance); setView('detail'); }}
@@ -456,8 +485,8 @@ export default function InstancePage() {
</div>
<div className="flex items-start justify-between mb-4 pl-6">
<div className={`w-11 h-11 rounded-xl ${ic.bg} flex items-center justify-center`}>
<IconComp className={`w-6 h-6 ${ic.color}`} />
<div className={`w-9 h-9 rounded-lg ${ic.bg} flex items-center justify-center`}>
<IconComp className="w-5 h-5 object-contain" />
</div>
<div className="flex items-center gap-2">
<span className={`text-[11px] px-2 py-0.5 rounded-md border font-medium ${envColor[instance.env] || 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] border-[var(--dg-border)]'}`}>
@@ -472,14 +501,14 @@ export default function InstancePage() {
</div>
</div>
<h3 className="text-base font-semibold mb-1 truncate">{instance.name}</h3>
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{instance.host}:{instance.port}</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-3">
<h3 className="text-sm font-semibold mb-0.5 truncate">{instance.name}</h3>
<p className="text-[11px] text-[var(--dg-text-muted)] mb-0.5">{instance.host}:{instance.port}</p>
<p className="text-[11px] text-[var(--dg-text-muted)] mb-2">
{ic.label} {instance.version || ''}
</p>
{/* Health Score */}
<div className="flex items-center gap-2 mb-3 p-2 rounded-lg bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center gap-2 mb-2 p-1.5 rounded-lg bg-[var(--dg-bg-tertiary)]">
<Gauge className={`w-4 h-4 ${healthColor(health)}`} />
<div className="flex-1">
<div className="flex items-center justify-between">
@@ -495,35 +524,33 @@ export default function InstancePage() {
</div>
{/* Metrics */}
<div className="grid grid-cols-3 gap-2 text-center">
<div className="p-1.5 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="grid grid-cols-3 gap-1.5 text-center">
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center justify-center gap-1">
<Users className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="text-xs font-semibold">{instance.connections}</span>
<span className="text-[11px] font-semibold">{instance.connections}</span>
</div>
<span className="text-[10px] text-[var(--dg-text-muted)]">连接</span>
<span className="text-[9px] text-[var(--dg-text-muted)]">连接</span>
</div>
<div className="p-1.5 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center justify-center gap-1">
<Activity className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="text-xs font-semibold">{instance.qps ? instance.qps.toLocaleString() : '-'}</span>
<span className="text-[11px] font-semibold">{instance.qps ? instance.qps.toLocaleString() : '-'}</span>
</div>
<span className="text-[10px] text-[var(--dg-text-muted)]">QPS</span>
<span className="text-[9px] text-[var(--dg-text-muted)]">QPS</span>
</div>
<div className="p-1.5 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="p-1 rounded-md bg-[var(--dg-bg-tertiary)]">
<div className="flex items-center justify-center gap-1">
<Signal className="w-3 h-3 text-[var(--dg-text-muted)]" />
<span className="text-xs font-semibold">{instance.latency}ms</span>
<span className="text-[11px] font-semibold">{instance.latency}ms</span>
</div>
<span className="text-[10px] text-[var(--dg-text-muted)]">延迟</span>
<span className="text-[9px] text-[var(--dg-text-muted)]">延迟</span>
</div>
</div>
{instance.description && (
<p className="text-xs text-[var(--dg-text-secondary)] line-clamp-2 mt-3">{instance.description}</p>
)}
<div className="mt-3 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="mt-2 opacity-0 group-hover:opacity-100 transition-opacity">
<span className="text-[11px] text-blue-400 flex items-center gap-1">
查看详情
<ChevronRight className="w-3 h-3" />
@@ -584,7 +611,7 @@ export default function InstancePage() {
<td className="px-4 py-3 font-medium">{instance.name}</td>
<td className="px-4 py-3">
<span className={`flex items-center gap-1.5 ${ic.color}`}>
<IconComp className="w-4 h-4" />
<IconComp className="w-4 h-4 object-contain" />
<span className="text-xs">{ic.label}</span>
</span>
</td>
@@ -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 ? '测试中...' : '测试连接'}
</button>
{canEdit && (
<button className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
<button onClick={onEdit} className="flex items-center gap-2 px-4 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-all">
<Edit2 className="w-4 h-4" />
编辑
</button>
@@ -845,8 +872,7 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
);
}
/* ------------------------------------------------------------------ */
/* DB Brand Icons */
/* ------------------------------------------------------------------ */
function MySQLIcon({ className }: { className?: string }) {
return <img src="/db-logos/mysql.svg" alt="MySQL" className={className} />;
+1 -1
View File
@@ -170,7 +170,7 @@ export default function QueriesPage() {
return (
<div className="space-y-5">
<PageHeader
title="查询审计"
title="查询数据"
description="在线查询工具,支持 MySQL / PostgreSQL / Redis / MongoDB 多类型数据源"
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
/>
+1 -1
View File
@@ -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'],
},
+188 -77
View File
@@ -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 (
<div className="mt-3 grid grid-cols-2 gap-3">
{executeStats.affectedRows !== undefined && (
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] mb-1">
<BarChart3 className="w-3.5 h-3.5" />
影响行数
</div>
<div className="text-xl font-semibold text-[var(--dg-text-primary)]">
{executeStats.affectedRows.toLocaleString()}
</div>
</div>
)}
{executeStats.durationMs !== undefined && (
<div className="p-3 bg-[var(--dg-bg-primary)] rounded-lg border border-[var(--dg-border)]">
<div className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] mb-1">
<Clock className="w-3.5 h-3.5" />
执行耗时
</div>
<div className="text-xl font-semibold text-[var(--dg-text-primary)]">
{executeStats.durationMs >= 1000
? `${(executeStats.durationMs / 1000).toFixed(2)}s`
: `${Math.round(executeStats.durationMs)}ms`}
</div>
</div>
)}
</div>
);
};
const renderRelatedTickets = () => {
if (relatedTickets.length === 0) return null;
@@ -624,22 +578,90 @@ export default function TicketDetailPage() {
</div>
{/* Audit result */}
{ticket.audit_result && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<button
onClick={() => setShowFullAudit(!showFullAudit)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-[15px] font-semibold flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
</h3>
{showFullAudit ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
</button>
<div className={`mt-3 text-sm text-[var(--dg-text-secondary)] ${showFullAudit ? '' : 'line-clamp-3'}`}>
{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 (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
</h3>
<p className="mt-2 text-sm text-[var(--dg-text-secondary)]">{ticket.audit_result}</p>
</div>
);
}
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 (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<button
onClick={() => setShowFullAudit(!showFullAudit)}
className="flex items-center justify-between w-full text-left"
>
<h3 className="text-[15px] font-semibold flex items-center gap-2">
<Shield className="w-4 h-4 text-blue-400" /> SQL 审核结果
</h3>
<div className="flex items-center gap-3">
<span className={`text-xl font-bold ${scoreColor}`}>{audit.score ?? '-'}</span>
<span className="text-xs text-[var(--dg-text-muted)]">分</span>
{showFullAudit ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
</div>
</button>
{/* Summary badges */}
<div className="mt-3 flex items-center gap-2 flex-wrap">
{audit.passed ? (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium bg-green-500/15 text-green-400">
<CheckCircle className="w-3 h-3" /> 审核通过
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded text-xs font-medium bg-red-500/15 text-red-400">
<XCircle className="w-3 h-3" /> 存在阻断项
</span>
)}
{errorCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-red-500/15 text-red-400">{errorCount} 错误</span>}
{warningCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-amber-500/15 text-amber-400">{warningCount} 警告</span>}
{infoCount > 0 && <span className="px-2 py-0.5 rounded text-xs bg-blue-500/15 text-blue-400">{infoCount} 提示</span>}
</div>
{/* Findings table */}
{showFullAudit && audit.findings.length > 0 && (
<div className="mt-4 space-y-2">
{audit.findings.map((f: any, idx: number) => {
const levelConfig: Record<string, { label: string; color: string; icon: any }> = {
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 (
<div key={idx} className={`p-3 rounded-lg border border-[var(--dg-border)] ${f.level === 'error' ? 'border-red-500/20' : ''}`}>
<div className="flex items-center gap-2 mb-1">
<LevelIcon className={`w-3.5 h-3.5 ${lc.color.split(' ')[0]}`} />
<span className={`px-1.5 py-0.5 rounded text-xs font-medium ${lc.color}`}>{lc.label}</span>
{f.rule && <span className="text-xs text-[var(--dg-text-muted)] font-mono">{f.rule}</span>}
</div>
<p className="text-sm text-[var(--dg-text-primary)] pl-6">{f.message}</p>
</div>
);
})}
</div>
)}
{/* Summary text */}
{!showFullAudit && audit.summary && (
<p className="mt-2 text-xs text-[var(--dg-text-muted)]">{audit.summary}</p>
)}
</div>
</div>
)}
);
})()}
{/* Rollback SQL */}
{ticket.rollback_sql && (
@@ -658,23 +680,112 @@ export default function TicketDetailPage() {
)}
{/* Execute result */}
{ticket.execute_result && (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
</h3>
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{ticket.execute_result}
</pre>
{renderExecuteStats()}
{ticket.execute_time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
执行时间: {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 (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
</h3>
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
{ticket.execute_result}
</pre>
{ticket.execute_time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-2 flex items-center gap-1">
<Clock className="w-3 h-3" />
执行时间: {new Date(ticket.execute_time).toLocaleString()}
</div>
)}
</div>
)}
</div>
)}
);
}
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 (
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
<Play className="w-4 h-4 text-purple-400" /> 执行结果
</h3>
{/* Status badge + key metrics */}
<div className="flex items-center gap-3 flex-wrap mb-3">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded text-xs font-medium ${statusBg} ${statusColor}`}>
<StatusIcon className="w-3.5 h-3.5" />
{isSuccess ? '执行成功' : '执行失败'}
</span>
{execResult.affected_rows !== undefined && (
<span className="px-2.5 py-1 rounded text-xs bg-[var(--dg-bg-tertiary)]">
影响行数: <span className="font-medium">{execResult.affected_rows}</span>
</span>
)}
{execResult.duration_ms !== undefined && (
<span className="px-2.5 py-1 rounded text-xs bg-[var(--dg-bg-tertiary)]">
耗时: <span className="font-medium">{execResult.duration_ms}ms</span>
</span>
)}
{execResult.validation_passed !== undefined && (
<span className={`px-2.5 py-1 rounded text-xs ${execResult.validation_passed ? 'text-green-400 bg-green-500/10' : 'text-amber-400 bg-amber-500/10'}`}>
{execResult.validation_passed ? '✓ 验证通过' : '✗ 验证未通过'}
</span>
)}
</div>
{/* Errors */}
{execResult.errors && execResult.errors.length > 0 && (
<div className="space-y-2 mb-3">
{execResult.errors.map((err: string, idx: number) => (
<div key={idx} className="p-3 rounded-lg bg-red-500/10 border border-red-500/20">
<div className="flex items-center gap-1.5 mb-1">
<XCircle className="w-3.5 h-3.5 text-red-400" />
<span className="text-xs font-medium text-red-400">错误 {execResult.errors.length > 1 ? `#${idx + 1}` : ''}</span>
</div>
<p className="text-sm text-red-300 pl-5">{err}</p>
</div>
))}
</div>
)}
{/* Warnings */}
{execResult.warnings && execResult.warnings.length > 0 && (
<div className="space-y-2 mb-3">
{execResult.warnings.map((w: string, idx: number) => (
<div key={idx} className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
<div className="flex items-center gap-1.5 mb-1">
<AlertTriangle className="w-3.5 h-3.5 text-amber-400" />
<span className="text-xs font-medium text-amber-400">警告 {execResult.warnings.length > 1 ? `#${idx + 1}` : ''}</span>
</div>
<p className="text-sm text-amber-300 pl-5">{w}</p>
</div>
))}
</div>
)}
{/* Raw output (if present) */}
{execResult.raw_output && (
<div className="mt-3">
<p className="text-xs text-[var(--dg-text-muted)] mb-1">原始输出</p>
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto border border-[var(--dg-border-light)]">
{execResult.raw_output}
</pre>
</div>
)}
{ticket.execute_time && (
<div className="text-xs text-[var(--dg-text-muted)] mt-3 flex items-center gap-1">
<Clock className="w-3 h-3" />
执行时间: {new Date(ticket.execute_time).toLocaleString()}
</div>
)}
</div>
);
})()}
{/* Timeline */}
{renderTimeline()}
+22 -149
View File
@@ -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<string, { label: string; color: string; bg: string; border: string; icon: React.ElementType }> = {
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() {
<td className="px-5 py-3.5"><div className="h-4 w-32 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-5 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-5 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-5 py-3.5"><div className="h-4 w-10 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-5 py-3.5"><div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-5 py-3.5"><div className="h-4 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
<td className="px-5 py-3.5"><div className="h-4 w-12 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
</tr>
@@ -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<DateRange>('all');
const [applicantFilter, setApplicantFilter] = useState('');
const [selectedTicket, setSelectedTicket] = useState<TicketItem | null>(null);
@@ -177,7 +167,7 @@ export default function TicketsPage() {
const [selectedIds, setSelectedIds] = useState<Set<number>>(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 (
<div className="space-y-5">
@@ -401,16 +386,7 @@ export default function TicketsPage() {
<option value="postgres">PostgreSQL</option>
<option value="redis">Redis</option>
</select>
<select
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
value={priorityFilter}
onChange={e => { setPriorityFilter(e.target.value); setPage(1); }}
>
<option value="">全部优先级</option>
<option value="high">高</option>
<option value="medium">中</option>
<option value="low">低</option>
</select>
<select
className="px-3 py-2.5 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-lg text-sm focus:border-blue-400 focus:outline-none transition-colors"
value={dateRange}
@@ -446,12 +422,7 @@ export default function TicketsPage() {
<button onClick={() => setDbTypeFilter('')}><X className="w-3 h-3" /></button>
</span>
)}
{priorityFilter && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md bg-blue-500/10 text-blue-400">
优先级: {priorityConfig[priorityFilter]?.label || priorityFilter}
<button onClick={() => setPriorityFilter('')}><X className="w-3 h-3" /></button>
</span>
)}
{dateRange !== 'all' && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-md bg-blue-500/10 text-blue-400">
<Calendar className="w-3 h-3" />
@@ -460,7 +431,7 @@ export default function TicketsPage() {
</span>
)}
<button
onClick={() => { setSearch(''); setStatusFilter(''); setDbTypeFilter(''); setPriorityFilter(''); setDateRange('all'); setApplicantFilter(''); setPage(1); }}
onClick={() => { setSearch(''); setStatusFilter(''); setDbTypeFilter(''); setDateRange('all'); setApplicantFilter(''); setPage(1); }}
className="text-xs text-red-400 hover:text-red-300 ml-auto"
>
清除全部
@@ -510,7 +481,6 @@ export default function TicketsPage() {
<th className="text-left px-5 py-3 font-semibold">标题</th>
<th className="text-left px-5 py-3 font-semibold">数据库</th>
<th className="text-left px-5 py-3 font-semibold">申请人</th>
<th className="text-left px-5 py-3 font-semibold">优先级</th>
<th className="text-left px-5 py-3 font-semibold">状态</th>
<th className="text-left px-5 py-3 font-semibold">创建时间</th>
<th className="text-left px-5 py-3 font-semibold">操作</th>
@@ -546,15 +516,7 @@ export default function TicketsPage() {
<th className="text-left px-4 py-3 font-semibold">标题</th>
<th className="text-left px-4 py-3 font-semibold">数据库</th>
<th className="text-left px-4 py-3 font-semibold">申请人</th>
<th className="text-left px-4 py-3 font-semibold">
<button
onClick={() => { setSortField('priority'); setSortAsc(!sortAsc); }}
className="flex items-center gap-1 hover:text-blue-400 transition-colors"
>
优先级
<ArrowUpDown className="w-3 h-3" />
</button>
</th>
<th className="text-left px-4 py-3 font-semibold">状态</th>
<th className="text-left px-4 py-3 font-semibold">
<button
@@ -570,8 +532,6 @@ export default function TicketsPage() {
</thead>
<tbody>
{clientFilteredTickets.map(ticket => {
const pConfig = priorityConfig[ticket.priority] || priorityConfig.medium;
const PIcon = pConfig.icon;
const isSelected = selectedIds.has(ticket.id);
return (
<tr
@@ -599,12 +559,7 @@ export default function TicketsPage() {
{ticket.applicant_name || '-'}
</div>
</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium border ${pConfig.bg} ${pConfig.color} ${pConfig.border}`}>
<PIcon className="w-3 h-3" />
{pConfig.label}
</span>
</td>
<td className="px-4 py-3.5">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' || ticket.status === 'executing' ? 'animate-pulse' : ''}`} />
@@ -688,14 +643,10 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
const [instanceId, setInstanceId] = useState('');
const [targetDatabase, setTargetDatabase] = useState('');
const [sqlContent, setSqlContent] = useState('');
const [rollbackSql, setRollbackSql] = useState('');
const [reason, setReason] = useState('');
const [priority, setPriority] = useState<'high' | 'medium' | 'low'>('medium');
const [sqlTab, setSqlTab] = useState<'change' | 'rollback'>('change');
const [submitting, setSubmitting] = useState(false);
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number }>>([]);
const [error, setError] = useState('');
const [charCount, setCharCount] = useState({ change: 0, rollback: 0 });
useEffect(() => {
instanceApi.list().then(res => {
@@ -718,9 +669,7 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
instance_id: Number(instanceId),
target_database: targetDatabase,
sql_content: sqlContent,
rollback_sql: rollbackSql || undefined,
reason,
priority,
});
onCreated();
} catch (e: unknown) {
@@ -729,25 +678,11 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
} finally {
setSubmitting(false);
}
}, [title, dbType, instanceId, targetDatabase, sqlContent, rollbackSql, reason, priority, onCreated]);
}, [title, dbType, instanceId, targetDatabase, sqlContent, reason, onCreated]);
const handleSqlChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
if (sqlTab === 'change') {
setSqlContent(val);
setCharCount(prev => ({ ...prev, change: val.length }));
} else {
setRollbackSql(val);
setCharCount(prev => ({ ...prev, rollback: val.length }));
}
}, [sqlTab]);
const currentSql = sqlTab === 'change' ? sqlContent : rollbackSql;
const priorityOptions: Array<{ value: 'high' | 'medium' | 'low'; label: string }> = [
{ value: 'high', label: '高' },
{ value: 'medium', label: '中' },
{ value: 'low', label: '低' },
];
return (
<div className="space-y-5 max-w-5xl">
@@ -807,62 +742,17 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
</div>
</div>
{/* Priority */}
<div>
<label className="block text-sm font-medium mb-2">优先级</label>
<div className="flex gap-3">
{priorityOptions.map(p => {
const pConf = priorityConfig[p.value];
return (
<button
key={p.value}
onClick={() => setPriority(p.value)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm border transition-all font-medium ${
priority === p.value
? `${pConf.bg} ${pConf.color} ${pConf.border}`
: 'bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:border-[var(--dg-text-muted)]'
}`}
>
<pConf.icon className="w-4 h-4" />
{p.label}
</button>
);
})}
</div>
</div>
{/* SQL Editor */}
<div>
<label className="block text-sm font-medium mb-2">SQL 语句 <span className="text-red-400">*</span></label>
<div className="dg-card overflow-hidden">
<div className="flex items-center gap-1 p-2 bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<button
onClick={() => setSqlTab('change')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-all ${
sqlTab === 'change' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<FileCode className="w-3.5 h-3.5" />
变更 SQL
</button>
<button
onClick={() => setSqlTab('rollback')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs transition-all ${
sqlTab === 'rollback' ? 'bg-blue-500/15 text-blue-400 border border-blue-500/30' : 'text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
<RotateCcw className="w-3.5 h-3.5" />
回滚 SQL
</button>
<span className="ml-auto text-xs text-[var(--dg-text-muted)]">
{charCount[sqlTab]} 字符
</span>
</div>
<textarea
className="w-full min-h-[200px] p-4 bg-[var(--dg-bg-primary)] text-sm font-mono text-[var(--dg-text-primary)] resize-y border-none outline-none placeholder:text-[var(--dg-text-muted)]"
placeholder={sqlTab === 'change' ? "UPDATE users SET status = 'active' WHERE id = 1;" : "-- 请输入回滚 SQL"}
value={currentSql}
onChange={handleSqlChange}
placeholder="UPDATE users SET status = 'active' WHERE id = 1;"
value={sqlContent}
onChange={e => setSqlContent(e.target.value)}
/>
</div>
</div>
@@ -914,7 +804,6 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
}) {
const [loading, setLoading] = useState(false);
const pConfig = priorityConfig[ticket.priority] || priorityConfig.medium;
const handleReview = useCallback(async (action: 'approve' | 'reject') => {
setLoading(true);
@@ -956,10 +845,7 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
<div>
<h1 className="text-xl font-semibold flex items-center gap-3 flex-wrap">
{ticket.title}
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${pConfig.bg} ${pConfig.color} ${pConfig.border}`}>
<pConfig.icon className="w-3 h-3" />
{pConfig.label}优先级
</span>
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
{statusMap[ticket.status] || ticket.status}
</span>
@@ -1061,7 +947,7 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
{ label: '目标数据库', value: ticket.target_database },
{ label: '申请人', value: ticket.applicant_name || '-' },
{ label: '审批人', value: ticket.approver_name || '-' },
{ label: '优先级', value: <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium border ${pConfig.bg} ${pConfig.color} ${pConfig.border}`}><pConfig.icon className="w-3 h-3" />{pConfig.label}</span> },
{ label: '创建时间', value: new Date(ticket.create_time).toLocaleString() },
{ label: '变更原因', value: ticket.reason || '-' },
].map((item, idx) => (
@@ -1087,20 +973,7 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
</Card>
</div>
{/* Rollback SQL */}
{ticket.rollback_sql && (
<Card className="dg-card">
<CardHeader className="border-b border-[var(--dg-border)]">
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
<Undo2 className="w-4 h-4 text-purple-400" />
回滚 SQL
</CardTitle>
</CardHeader>
<CardContent className="p-0">
<pre className="p-5 text-xs font-mono text-[var(--dg-text-primary)] bg-[var(--dg-bg-primary)] overflow-x-auto whitespace-pre-wrap max-h-[300px] overflow-y-auto">{ticket.rollback_sql}</pre>
</CardContent>
</Card>
)}
{/* Audit Record */}
{ticket.audit_result && (