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:
1 parent
9f56e866f2
commit
4ac2528f8e
19 files changed
+1202
-325
No files matched your search
@@ -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)}
|
||||||
@@ -6,7 +6,7 @@ from app.api.v1.endpoints import (
|
|||||||
instance_detail, backup, redis, dictionary,
|
instance_detail, backup, redis, dictionary,
|
||||||
instance_group, masking_rule, rollback,
|
instance_group, masking_rule, rollback,
|
||||||
workflow, impact, schema_drift, slow_log, pool,
|
workflow, impact, schema_drift, slow_log, pool,
|
||||||
mongo, query_history, sql_assistant,
|
mongo, query_history, sql_assistant, db_types,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter(prefix="/api/v1")
|
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(pool.router)
|
||||||
api_router.include_router(query_history.router)
|
api_router.include_router(query_history.router)
|
||||||
api_router.include_router(sql_assistant.router)
|
api_router.include_router(sql_assistant.router)
|
||||||
|
|
||||||
|
api_router.include_router(db_types.router)
|
||||||
@@ -6,7 +6,7 @@ from typing import Optional, Literal
|
|||||||
class AuditRuleCreate(BaseModel):
|
class AuditRuleCreate(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=100)
|
name: str = Field(..., min_length=1, max_length=100)
|
||||||
description: Optional[str] = None
|
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"
|
severity: Literal["error", "warning", "info"] = "warning"
|
||||||
db_type: str = "mysql"
|
db_type: str = "mysql"
|
||||||
pattern: Optional[str] = None
|
pattern: Optional[str] = None
|
||||||
@@ -18,7 +18,7 @@ class AuditRuleCreate(BaseModel):
|
|||||||
class AuditRuleUpdate(BaseModel):
|
class AuditRuleUpdate(BaseModel):
|
||||||
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
name: Optional[str] = Field(default=None, min_length=1, max_length=100)
|
||||||
description: Optional[str] = None
|
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
|
severity: Optional[Literal["error", "warning", "info"]] = None
|
||||||
db_type: Optional[str] = None
|
db_type: Optional[str] = None
|
||||||
pattern: Optional[str] = None
|
pattern: Optional[str] = None
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ Replaces mock aiomysql calls with actual driver connections.
|
|||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
import asyncio
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from typing import Optional, List, Dict, Any, Tuple
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
|
|
||||||
@@ -61,12 +62,68 @@ class DBConnector:
|
|||||||
return await DBConnector._test_postgres(instance, start)
|
return await DBConnector._test_postgres(instance, start)
|
||||||
elif instance.db_type == "sqlite":
|
elif instance.db_type == "sqlite":
|
||||||
return await DBConnector._test_sqlite(instance, start)
|
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:
|
else:
|
||||||
return ConnectionResult(False, f"暂不支持 {instance.db_type} 类型数据源")
|
return ConnectionResult(False, f"暂不支持 {instance.db_type} 类型数据源")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
elapsed = int((time.time() - start) * 1000)
|
elapsed = int((time.time() - start) * 1000)
|
||||||
return ConnectionResult(False, f"连接失败: {str(e)}", latency_ms=elapsed)
|
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
|
@staticmethod
|
||||||
async def execute_query(
|
async def execute_query(
|
||||||
instance: Instance, database: Optional[str], sql: str, user_id: int
|
instance: Instance, database: Optional[str], sql: str, user_id: int
|
||||||
|
|||||||
@@ -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)
|
# - DDL 规则: 36 条 (MYSQL-DDL-001 ~ MYSQL-DDL-036)
|
||||||
# - DML 规则: 9 条 (MYSQL-DML-001 ~ MYSQL-DML-009)
|
# - DML 规则: 9 条 (MYSQL-DML-001 ~ MYSQL-DML-009)
|
||||||
# - Online-DDL 规则: 3 条 (MYSQL-OSC-001 ~ MYSQL-OSC-003)
|
# - Online-DDL 规则: 3 条 (MYSQL-OSC-001 ~ MYSQL-OSC-003)
|
||||||
# - 命名规则: 5 条 (MYSQL-NAM-001 ~ MYSQL-NAM-005)
|
# - 命名规则: 5 条 (MYSQL-NAM-001 ~ MYSQL-NAM-005)
|
||||||
# - 性能规则: 5 条 (MYSQL-PERF-001 ~ MYSQL-PERF-005)
|
# - 性能规则: 5 条 (MYSQL-PERF-001 ~ MYSQL-PERF-005)
|
||||||
# - 安全规则: 5 条 (MYSQL-SEC-001 ~ MYSQL-SEC-005)
|
# - 安全规则: 9 条 (MYSQL-SEC-001 ~ MYSQL-SEC-009)
|
||||||
# ═══════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════
|
||||||
@@ -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:
|
if command in second_key_commands and len(args) >= 1:
|
||||||
return args[0]
|
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
|
# 默认:第一个参数是 key
|
||||||
return args[0]
|
return args[0]
|
||||||
|
|
||||||
@@ -1610,35 +1621,28 @@ def check_redis_perf_008(sql_clean: str, sql_upper: str, report: AuditReportV2,
|
|||||||
|
|
||||||
@register_rule(AuditRuleV2(
|
@register_rule(AuditRuleV2(
|
||||||
code="REDIS-SEC-001",
|
code="REDIS-SEC-001",
|
||||||
name="必须认证才能执行命令",
|
name="敏感管理命令需要二次确认",
|
||||||
category=RuleCategory.SECURITY,
|
category=RuleCategory.SECURITY,
|
||||||
severity=Severity.ERROR,
|
severity=Severity.WARNING,
|
||||||
db_type="redis",
|
db_type="redis",
|
||||||
description="非只读命令必须执行 AUTH 认证后才能执行",
|
description="CONFIG/ACL/SHUTDOWN 等管理级命令属于高危操作,建议限制执行",
|
||||||
config={"require_auth": True, "allowed_cmds_without_auth": ["PING", "AUTH", "ECHO", "QUIT", "HELLO"]},
|
config={"admin_cmds": ["CONFIG", "ACL", "SHUTDOWN", "DEBUG", "MODULE", "REPLICAOF", "SLAVEOF", "SAVE", "BGSAVE", "BGREWRITEAOF"]},
|
||||||
message_template="命令 '{cmd}' 需要认证后才能执行,请先执行 AUTH password 进行认证",
|
message_template="命令 '{cmd}' 属于 Redis 管理级操作,请确认业务必要性后再执行",
|
||||||
))
|
))
|
||||||
def check_redis_sec_001(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
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)
|
command, args = _parse_redis_command(sql_clean)
|
||||||
if not command:
|
if not command:
|
||||||
return
|
return
|
||||||
cmd = command.upper()
|
cmd = command.upper()
|
||||||
|
|
||||||
allowed = rule.config.get("allowed_cmds_without_auth", ["PING", "AUTH", "ECHO", "QUIT", "HELLO"])
|
admin_cmds = rule.config.get("admin_cmds", [])
|
||||||
if cmd in allowed:
|
if cmd in admin_cmds:
|
||||||
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:
|
|
||||||
report.findings.append(AuditFindingV2(
|
report.findings.append(AuditFindingV2(
|
||||||
rule_code=rule.code,
|
rule_code=rule.code,
|
||||||
rule_name=rule.name,
|
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(
|
@register_rule(AuditRuleV2(
|
||||||
code="REDIS-SEC-003",
|
code="REDIS-SEC-003",
|
||||||
name="危险命令必须被重命名",
|
name="危险命令重命名建议",
|
||||||
category=RuleCategory.SECURITY,
|
category=RuleCategory.SECURITY,
|
||||||
severity=Severity.WARNING,
|
severity=Severity.INFO,
|
||||||
db_type="redis",
|
db_type="redis",
|
||||||
description="建议将危险命令(FLUSHDB/FLUSHALL/CONFIG/DEBUG)通过 rename-command 重命名",
|
description="建议将 FLUSHDB/FLUSHALL/DEBUG/SHUTDOWN 通过 rename-command 重命名",
|
||||||
config={"dangerous_commands": ["FLUSHDB", "FLUSHALL", "CONFIG", "DEBUG", "SHUTDOWN"]},
|
config={"dangerous_commands": ["DEBUG", "SHUTDOWN"]},
|
||||||
message_template="危险命令 '{cmd}' 应通过 rename-command 在配置中重命名,防止误操作或恶意执行",
|
message_template="建议将 '{cmd}' 通过 rename-command 重命名,防止误操作",
|
||||||
))
|
))
|
||||||
def check_redis_sec_003(sql_clean: str, sql_upper: str, report: AuditReportV2, rule: AuditRuleV2) -> None:
|
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)
|
command, args = _parse_redis_command(sql_clean)
|
||||||
if not command:
|
if not command:
|
||||||
return
|
return
|
||||||
cmd = command.upper()
|
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:
|
if cmd in dangerous:
|
||||||
report.findings.append(AuditFindingV2(
|
report.findings.append(AuditFindingV2(
|
||||||
rule_code=rule.code,
|
rule_code=rule.code,
|
||||||
|
|||||||
@@ -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()))
|
||||||
@@ -21,7 +21,7 @@ import MongoDBDetailPage from '@/pages/MongoDBDetailPage';
|
|||||||
import TicketCreatePage from '@/pages/TicketCreatePage';
|
import TicketCreatePage from '@/pages/TicketCreatePage';
|
||||||
import TicketDetailPage from '@/pages/TicketDetailPage';
|
import TicketDetailPage from '@/pages/TicketDetailPage';
|
||||||
import InstanceGroupPage from '@/pages/InstanceGroupPage';
|
import InstanceGroupPage from '@/pages/InstanceGroupPage';
|
||||||
import SlowQueryPage from '@/pages/SlowQueryPage';
|
// import SlowQueryPage from '@/pages/SlowQueryPage';
|
||||||
|
|
||||||
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
// 路由守卫:未登录跳转登录页,无权限跳转仪表盘
|
||||||
function RouteGuard() {
|
function RouteGuard() {
|
||||||
@@ -67,7 +67,6 @@ function AppRoutes() {
|
|||||||
<Route path="tickets/create" element={<TicketCreatePage />} />
|
<Route path="tickets/create" element={<TicketCreatePage />} />
|
||||||
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
<Route path="tickets/:id" element={<TicketDetailPage />} />
|
||||||
<Route path="queries" element={<QueriesPage />} />
|
<Route path="queries" element={<QueriesPage />} />
|
||||||
<Route path="slow-query" element={<SlowQueryPage />} />
|
|
||||||
<Route path="roles" element={<RolesPage />} />
|
<Route path="roles" element={<RolesPage />} />
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="audit-rules" element={<AuditRulesPage />} />
|
<Route path="audit-rules" element={<AuditRulesPage />} />
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
};
|
||||||
@@ -3,4 +3,13 @@ import request from './request';
|
|||||||
export const queryApi = {
|
export const queryApi = {
|
||||||
execute: (data: { instance_id: number; database?: string; sql: string }) =>
|
execute: (data: { instance_id: number; database?: string; sql: string }) =>
|
||||||
request.post<any, any>('/query', data),
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,8 +25,7 @@ export const menuItems = [
|
|||||||
{ id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] },
|
{ id: 'instances', label: '数据源', icon: 'Database', section: 'datasource', roles: ['admin', 'dba'] },
|
||||||
{ id: 'instance-groups', label: '权限管理', icon: 'Lock', section: 'permission', roles: ['admin'] },
|
{ id: 'instance-groups', label: '权限管理', icon: 'Lock', section: 'permission', roles: ['admin'] },
|
||||||
{ id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] },
|
{ id: 'tickets', label: '数据变更', icon: 'ClipboardList', section: 'ticket', roles: ['admin', 'dba', 'developer'] },
|
||||||
{ id: 'queries', label: '查询审计', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] },
|
{ id: 'queries', label: '查询数据', icon: 'Search', section: 'ticket', roles: ['admin', 'dba', 'developer', 'guest'] },
|
||||||
{ id: 'slow-query', label: '慢查询分析', icon: 'Timer', section: 'ticket', roles: ['admin', 'dba'] },
|
|
||||||
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
|
{ id: 'roles', label: '角色权限', icon: 'UserCog', section: 'permission', roles: ['admin'] },
|
||||||
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] },
|
{ id: 'users', label: '用户管理', icon: 'Users', section: 'permission', roles: ['admin'] },
|
||||||
{ id: 'audit-logs', label: '审计日志', icon: 'FileText', section: 'system', roles: ['admin', 'dba'] },
|
{ 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_execute', label: '执行工单' },
|
||||||
{ id: 'ticket_rollback', label: '回滚工单' },
|
{ id: 'ticket_rollback', label: '回滚工单' },
|
||||||
]},
|
]},
|
||||||
{ id: 'queries', label: '查询审计(查看日志、新建查询、导出)', children: [
|
{ id: 'queries', label: '查询数据(查看日志、新建查询、导出)', children: [
|
||||||
{ id: 'query_create', label: '执行查询' },
|
{ id: 'query_create', label: '执行查询' },
|
||||||
{ id: 'query_export', label: '导出结果' },
|
{ id: 'query_export', label: '导出结果' },
|
||||||
{ id: 'query_view_all', label: '查看全部用户查询' },
|
{ id: 'query_view_all', label: '查看全部用户查询' },
|
||||||
|
|||||||
@@ -493,9 +493,9 @@ export default function AuditLogsPage() {
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-2">
|
<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">
|
<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>
|
</div>
|
||||||
<span className="text-[var(--dg-text-primary)]">{log.username}</span>
|
<span className="text-[var(--dg-text-primary)]">{log.username || '-'}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ interface AuditRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DbType = 'mysql' | 'postgresql' | 'redis' | 'mongodb';
|
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';
|
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' },
|
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' },
|
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' },
|
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 }> = {
|
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' },
|
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 */
|
/* Helpers */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
function getRuleGroup(name: string): RuleGroup {
|
const VALID_GROUPS: RuleGroup[] = ['DDL', 'DML', 'DCL', 'DQL', 'security', 'performance'];
|
||||||
const upper = name.toUpperCase();
|
|
||||||
if (DDL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DDL';
|
function getRuleGroup(rule: AuditRule): RuleGroup {
|
||||||
if (DML_KEYWORDS.some((kw) => upper.includes(kw))) return 'DML';
|
const rt = rule.rule_type;
|
||||||
if (DCL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DCL';
|
if (VALID_GROUPS.includes(rt as RuleGroup)) return rt as RuleGroup;
|
||||||
if (DQL_KEYWORDS.some((kw) => upper.includes(kw))) return 'DQL';
|
// Fallback: map unknown types
|
||||||
// MongoDB security rules -> DCL, performance rules -> DQL
|
if (rt === 'safety' || rt === 'naming' || rt === 'syntax') return 'DDL';
|
||||||
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';
|
|
||||||
return 'DDL';
|
return 'DDL';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +84,7 @@ function sortRules(rules: AuditRule[], sortBy: 'severity' | 'name' | 'type'): Au
|
|||||||
case 'name':
|
case 'name':
|
||||||
return sorted.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
return sorted.sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
||||||
case 'type':
|
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:
|
default:
|
||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
@@ -239,9 +224,11 @@ export default function AuditRulesPage() {
|
|||||||
DML: [],
|
DML: [],
|
||||||
DCL: [],
|
DCL: [],
|
||||||
DQL: [],
|
DQL: [],
|
||||||
|
security: [],
|
||||||
|
performance: [],
|
||||||
};
|
};
|
||||||
for (const rule of filteredRules) {
|
for (const rule of filteredRules) {
|
||||||
const group = getRuleGroup(rule.name);
|
const group = getRuleGroup(rule);
|
||||||
groups[group].push(rule);
|
groups[group].push(rule);
|
||||||
}
|
}
|
||||||
return groups;
|
return groups;
|
||||||
@@ -580,8 +567,10 @@ export default function AuditRulesPage() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{renderRuleGroup('DDL', groupedRules.DDL)}
|
{renderRuleGroup('DDL', groupedRules.DDL)}
|
||||||
{renderRuleGroup('DML', groupedRules.DML)}
|
{renderRuleGroup('DML', groupedRules.DML)}
|
||||||
|
{renderRuleGroup('security', groupedRules.security)}
|
||||||
{renderRuleGroup('DCL', groupedRules.DCL)}
|
{renderRuleGroup('DCL', groupedRules.DCL)}
|
||||||
{renderRuleGroup('DQL', groupedRules.DQL)}
|
{renderRuleGroup('DQL', groupedRules.DQL)}
|
||||||
|
{renderRuleGroup('performance', groupedRules.performance)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useActionPermission } from '@/hooks/useAuth';
|
|||||||
import PageHeader from '@/components/PageHeader';
|
import PageHeader from '@/components/PageHeader';
|
||||||
import EmptyState from '@/components/EmptyState';
|
import EmptyState from '@/components/EmptyState';
|
||||||
import { instanceApi } from '@/api/instance';
|
import { instanceApi } from '@/api/instance';
|
||||||
|
import EditInstanceDialog from '@/components/EditInstanceDialog';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Types */
|
/* Types */
|
||||||
@@ -41,11 +42,14 @@ type ViewMode = 'grid' | 'list';
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Constants */
|
/* Constants */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* DB Brand Icons */
|
||||||
|
|
||||||
const dbTypeConfig: Record<string, { icon: React.ElementType; color: string; bg: string; label: string }> = {
|
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' },
|
mysql: { icon: MySQLIcon, 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' },
|
postgres: { icon: PostgreSQLIcon, 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' },
|
redis: { icon: RedisIcon, 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' },
|
mongo: { icon: MongoDBIcon, color: 'text-[#66bb6a]', bg: 'bg-[rgba(71,162,72,0.15)]', label: 'MongoDB' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const envLabel: Record<string, string> = {
|
const envLabel: Record<string, string> = {
|
||||||
@@ -132,6 +136,8 @@ export default function InstancePage() {
|
|||||||
const [instances, setInstances] = useState<InstanceItem[]>([]);
|
const [instances, setInstances] = useState<InstanceItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showCreate, setShowCreate] = 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 [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
const [batchDeleting, setBatchDeleting] = useState(false);
|
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
|
const selectedConfig = selectedInstance
|
||||||
? (dbTypeConfig[selectedInstance.db_type] || dbTypeConfig.mysql)
|
? (dbTypeConfig[selectedInstance.db_type] || dbTypeConfig.mysql)
|
||||||
: dbTypeConfig.mysql;
|
: dbTypeConfig.mysql;
|
||||||
@@ -255,6 +283,7 @@ export default function InstancePage() {
|
|||||||
canEdit={canEdit}
|
canEdit={canEdit}
|
||||||
canDelete={canDelete}
|
canDelete={canDelete}
|
||||||
onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }}
|
onDelete={() => { handleDelete(selectedInstance.id); setView('grid'); }}
|
||||||
|
onEdit={() => { setEditingInstance(selectedInstance); setShowEdit(true); }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -428,7 +457,7 @@ export default function InstancePage() {
|
|||||||
) : filtered.length === 0 ? (
|
) : filtered.length === 0 ? (
|
||||||
<EmptyState type="search" />
|
<EmptyState type="search" />
|
||||||
) : layoutMode === 'grid' ? (
|
) : 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 => {
|
{filtered.map(instance => {
|
||||||
const ic = dbTypeConfig[instance.db_type] || dbTypeConfig.mysql;
|
const ic = dbTypeConfig[instance.db_type] || dbTypeConfig.mysql;
|
||||||
const IconComp = ic.icon;
|
const IconComp = ic.icon;
|
||||||
@@ -438,7 +467,7 @@ export default function InstancePage() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={instance.id}
|
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' : ''
|
isSelected ? 'ring-2 ring-blue-500/50' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => { setSelectedInstance(instance); setView('detail'); }}
|
onClick={() => { setSelectedInstance(instance); setView('detail'); }}
|
||||||
@@ -456,8 +485,8 @@ export default function InstancePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start justify-between mb-4 pl-6">
|
<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`}>
|
<div className={`w-9 h-9 rounded-lg ${ic.bg} flex items-center justify-center`}>
|
||||||
<IconComp className={`w-6 h-6 ${ic.color}`} />
|
<IconComp className="w-5 h-5 object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<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)]'}`}>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 className="text-base font-semibold mb-1 truncate">{instance.name}</h3>
|
<h3 className="text-sm font-semibold mb-0.5 truncate">{instance.name}</h3>
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] mb-1">{instance.host}:{instance.port}</p>
|
<p className="text-[11px] text-[var(--dg-text-muted)] mb-0.5">{instance.host}:{instance.port}</p>
|
||||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3">
|
<p className="text-[11px] text-[var(--dg-text-muted)] mb-2">
|
||||||
{ic.label} {instance.version || ''}
|
{ic.label} {instance.version || ''}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Health Score */}
|
{/* 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)}`} />
|
<Gauge className={`w-4 h-4 ${healthColor(health)}`} />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -495,35 +524,33 @@ export default function InstancePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metrics */}
|
{/* Metrics */}
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
<div className="grid grid-cols-3 gap-1.5 text-center">
|
||||||
<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">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<Users className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
<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>
|
</div>
|
||||||
<span className="text-[10px] text-[var(--dg-text-muted)]">连接</span>
|
<span className="text-[9px] text-[var(--dg-text-muted)]">连接</span>
|
||||||
</div>
|
</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">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<Activity className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
<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>
|
</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>
|
||||||
<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">
|
<div className="flex items-center justify-center gap-1">
|
||||||
<Signal className="w-3 h-3 text-[var(--dg-text-muted)]" />
|
<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>
|
</div>
|
||||||
<span className="text-[10px] text-[var(--dg-text-muted)]">延迟</span>
|
<span className="text-[9px] text-[var(--dg-text-muted)]">延迟</span>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<span className="text-[11px] text-blue-400 flex items-center gap-1">
|
||||||
查看详情
|
查看详情
|
||||||
<ChevronRight className="w-3 h-3" />
|
<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 font-medium">{instance.name}</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className={`flex items-center gap-1.5 ${ic.color}`}>
|
<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 className="text-xs">{ic.label}</span>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -633,10 +660,10 @@ export default function InstancePage() {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* InstanceDetail */
|
/* InstanceDetail */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete }: {
|
function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete, onEdit }: {
|
||||||
instance: InstanceItem; onBack: () => void;
|
instance: InstanceItem; onBack: () => void;
|
||||||
config: { icon: React.ElementType; color: string; bg: string };
|
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 [testing, setTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null);
|
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 ? '测试中...' : '测试连接'}
|
{testing ? '测试中...' : '测试连接'}
|
||||||
</button>
|
</button>
|
||||||
{canEdit && (
|
{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" />
|
<Edit2 className="w-4 h-4" />
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
@@ -845,8 +872,7 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
|
||||||
/* DB Brand Icons */
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
function MySQLIcon({ className }: { className?: string }) {
|
function MySQLIcon({ className }: { className?: string }) {
|
||||||
return <img src="/db-logos/mysql.svg" alt="MySQL" className={className} />;
|
return <img src="/db-logos/mysql.svg" alt="MySQL" className={className} />;
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ export default function QueriesPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="查询审计"
|
title="查询数据"
|
||||||
description="在线查询工具,支持 MySQL / PostgreSQL / Redis / MongoDB 多类型数据源"
|
description="在线查询工具,支持 MySQL / PostgreSQL / Redis / MongoDB 多类型数据源"
|
||||||
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
|
action={canCreate ? { label: '新建查询', icon: Plus, onClick: () => setShowNewQuery(true), permission: 'query.create' } : undefined}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const PERMISSION_PRESETS: PermissionPreset[] = [
|
|||||||
{
|
{
|
||||||
key: 'dba',
|
key: 'dba',
|
||||||
name: 'DBA 模板',
|
name: 'DBA 模板',
|
||||||
description: '数据库管理员:完整的数据源管理、工单审批、查询审计权限',
|
description: '数据库管理员:完整的数据源管理、工单审批、查询数据权限',
|
||||||
icon: Database,
|
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'],
|
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'],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -216,19 +216,6 @@ export default function TicketDetailPage() {
|
|||||||
/* Parse execute result for visualization */
|
/* 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 */
|
/* 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 = () => {
|
const renderRelatedTickets = () => {
|
||||||
if (relatedTickets.length === 0) return null;
|
if (relatedTickets.length === 0) return null;
|
||||||
|
|
||||||
@@ -624,22 +578,90 @@ export default function TicketDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audit result */}
|
{/* Audit result */}
|
||||||
{ticket.audit_result && (
|
{ticket.audit_result && (() => {
|
||||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
let audit: any = null;
|
||||||
<button
|
try { audit = typeof ticket.audit_result === 'string' ? JSON.parse(ticket.audit_result) : ticket.audit_result; } catch { audit = null; }
|
||||||
onClick={() => setShowFullAudit(!showFullAudit)}
|
|
||||||
className="flex items-center justify-between w-full text-left"
|
if (!audit || !audit.findings) {
|
||||||
>
|
return (
|
||||||
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||||
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
|
<h3 className="text-[15px] font-semibold flex items-center gap-2">
|
||||||
</h3>
|
<Shield className="w-4 h-4 text-blue-400" /> 审核结果
|
||||||
{showFullAudit ? <ChevronUp className="w-4 h-4 text-[var(--dg-text-muted)]" /> : <ChevronDown className="w-4 h-4 text-[var(--dg-text-muted)]" />}
|
</h3>
|
||||||
</button>
|
<p className="mt-2 text-sm text-[var(--dg-text-secondary)]">{ticket.audit_result}</p>
|
||||||
<div className={`mt-3 text-sm text-[var(--dg-text-secondary)] ${showFullAudit ? '' : 'line-clamp-3'}`}>
|
</div>
|
||||||
{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 (
|
||||||
|
<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>
|
||||||
</div>
|
);
|
||||||
)}
|
})()}
|
||||||
|
|
||||||
{/* Rollback SQL */}
|
{/* Rollback SQL */}
|
||||||
{ticket.rollback_sql && (
|
{ticket.rollback_sql && (
|
||||||
@@ -658,23 +680,112 @@ export default function TicketDetailPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Execute result */}
|
{/* Execute result */}
|
||||||
{ticket.execute_result && (
|
{ticket.execute_result && (() => {
|
||||||
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
let execResult: any = null;
|
||||||
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
|
try { execResult = typeof ticket.execute_result === 'string' ? JSON.parse(ticket.execute_result) : ticket.execute_result; } catch { execResult = null; }
|
||||||
<Play className="w-4 h-4 text-purple-400" /> 执行结果
|
|
||||||
</h3>
|
if (!execResult || typeof execResult !== 'object') {
|
||||||
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
|
return (
|
||||||
{ticket.execute_result}
|
<div className="bg-[var(--dg-bg-secondary)] rounded-lg p-5">
|
||||||
</pre>
|
<h3 className="text-[15px] font-semibold flex items-center gap-2 border-b border-[var(--dg-border)] pb-3 mb-3">
|
||||||
{renderExecuteStats()}
|
<Play className="w-4 h-4 text-purple-400" /> 执行结果
|
||||||
{ticket.execute_time && (
|
</h3>
|
||||||
<div className="text-xs text-[var(--dg-text-muted)] mt-2 flex items-center gap-1">
|
<pre className="p-3 bg-[var(--dg-bg-primary)] rounded-lg text-xs font-mono whitespace-pre-wrap overflow-x-auto">
|
||||||
<Clock className="w-3 h-3" />
|
{ticket.execute_result}
|
||||||
执行时间: {new Date(ticket.execute_time).toLocaleString()}
|
</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>
|
||||||
)}
|
);
|
||||||
</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 */}
|
{/* Timeline */}
|
||||||
{renderTimeline()}
|
{renderTimeline()}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Plus, Search, Eye, CheckCircle, XCircle, Play, RotateCcw,
|
Plus, Search, Eye, CheckCircle, XCircle, Play,
|
||||||
FileCode, History, Undo2, XCircle as XIcon,
|
FileCode, History, XCircle as XIcon,
|
||||||
Clock, AlertTriangle, AlertCircle, Info, ChevronLeft,
|
Clock, AlertTriangle, ChevronLeft,
|
||||||
Filter, X, Loader2, Users, Calendar,
|
Filter, X, Loader2, Users, Calendar,
|
||||||
CheckSquare, Square, ArrowUpDown, FileDown, Ban,
|
CheckSquare, Square, ArrowUpDown, FileDown, Ban,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -24,9 +24,7 @@ interface TicketItem {
|
|||||||
instance_id: number;
|
instance_id: number;
|
||||||
target_database: string;
|
target_database: string;
|
||||||
sql_content: string;
|
sql_content: string;
|
||||||
rollback_sql: string | null;
|
|
||||||
status: string;
|
status: string;
|
||||||
priority: string;
|
|
||||||
applicant_id: number;
|
applicant_id: number;
|
||||||
applicant_name: string | null;
|
applicant_name: string | null;
|
||||||
approver_id: number | null;
|
approver_id: number | null;
|
||||||
@@ -62,11 +60,7 @@ type DateRange = 'all' | 'today' | 'week' | 'month';
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Constants */
|
/* 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'];
|
const statusFlow = ['pending', 'reviewing', 'approved', 'executing', 'success'];
|
||||||
|
|
||||||
@@ -74,14 +68,13 @@ const statusFlow = ['pending', 'reviewing', 'approved', 'executing', 'success'];
|
|||||||
/* Helpers */
|
/* Helpers */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
function exportTicketsToCSV(tickets: TicketItem[]): string {
|
function exportTicketsToCSV(tickets: TicketItem[]): string {
|
||||||
const columns = ['工单号', '标题', '数据库类型', '目标数据库', '申请人', '优先级', '状态', '创建时间', '变更原因'];
|
const columns = ['工单号', '标题', '数据库类型', '目标数据库', '申请人', '状态', '创建时间', '变更原因'];
|
||||||
const rows = tickets.map(t => ({
|
const rows = tickets.map(t => ({
|
||||||
工单号: `TK-${t.id}`,
|
工单号: `TK-${t.id}`,
|
||||||
标题: t.title,
|
标题: t.title,
|
||||||
数据库类型: t.db_type.toUpperCase(),
|
数据库类型: t.db_type.toUpperCase(),
|
||||||
目标数据库: t.target_database,
|
目标数据库: t.target_database,
|
||||||
申请人: t.applicant_name || '-',
|
申请人: t.applicant_name || '-',
|
||||||
优先级: t.priority === 'high' ? '高' : t.priority === 'medium' ? '中' : '低',
|
|
||||||
状态: statusMap[t.status] || t.status,
|
状态: statusMap[t.status] || t.status,
|
||||||
创建时间: new Date(t.create_time).toLocaleString(),
|
创建时间: new Date(t.create_time).toLocaleString(),
|
||||||
变更原因: t.reason || '-',
|
变更原因: 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-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-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-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>
|
<td className="px-5 py-3.5"><div className="h-4 w-12 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" /></td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -163,7 +154,6 @@ export default function TicketsPage() {
|
|||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('');
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
const [dbTypeFilter, setDbTypeFilter] = useState('');
|
const [dbTypeFilter, setDbTypeFilter] = useState('');
|
||||||
const [priorityFilter, setPriorityFilter] = useState('');
|
|
||||||
const [dateRange, setDateRange] = useState<DateRange>('all');
|
const [dateRange, setDateRange] = useState<DateRange>('all');
|
||||||
const [applicantFilter, setApplicantFilter] = useState('');
|
const [applicantFilter, setApplicantFilter] = useState('');
|
||||||
const [selectedTicket, setSelectedTicket] = useState<TicketItem | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<TicketItem | null>(null);
|
||||||
@@ -177,7 +167,7 @@ export default function TicketsPage() {
|
|||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
const [batchApproving, setBatchApproving] = useState(false);
|
const [batchApproving, setBatchApproving] = useState(false);
|
||||||
const [showFilters, setShowFilters] = 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 [sortAsc, setSortAsc] = useState(false);
|
||||||
|
|
||||||
const fetchTickets = useCallback(async () => {
|
const fetchTickets = useCallback(async () => {
|
||||||
@@ -223,11 +213,6 @@ export default function TicketsPage() {
|
|||||||
}
|
}
|
||||||
/* Client-side sort */
|
/* Client-side sort */
|
||||||
result.sort((a, b) => {
|
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
|
return sortAsc
|
||||||
? new Date(a.create_time).getTime() - new Date(b.create_time).getTime()
|
? new Date(a.create_time).getTime() - new Date(b.create_time).getTime()
|
||||||
: new Date(b.create_time).getTime() - new Date(a.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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -401,16 +386,7 @@ export default function TicketsPage() {
|
|||||||
<option value="postgres">PostgreSQL</option>
|
<option value="postgres">PostgreSQL</option>
|
||||||
<option value="redis">Redis</option>
|
<option value="redis">Redis</option>
|
||||||
</select>
|
</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
|
<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"
|
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}
|
value={dateRange}
|
||||||
@@ -446,12 +422,7 @@ export default function TicketsPage() {
|
|||||||
<button onClick={() => setDbTypeFilter('')}><X className="w-3 h-3" /></button>
|
<button onClick={() => setDbTypeFilter('')}><X className="w-3 h-3" /></button>
|
||||||
</span>
|
</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' && (
|
{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">
|
<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" />
|
<Calendar className="w-3 h-3" />
|
||||||
@@ -460,7 +431,7 @@ export default function TicketsPage() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<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"
|
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>
|
|
||||||
<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">数据库</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">状态</th>
|
||||||
<th className="text-left px-4 py-3 font-semibold">
|
<th className="text-left px-4 py-3 font-semibold">
|
||||||
<button
|
<button
|
||||||
@@ -570,8 +532,6 @@ export default function TicketsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{clientFilteredTickets.map(ticket => {
|
{clientFilteredTickets.map(ticket => {
|
||||||
const pConfig = priorityConfig[ticket.priority] || priorityConfig.medium;
|
|
||||||
const PIcon = pConfig.icon;
|
|
||||||
const isSelected = selectedIds.has(ticket.id);
|
const isSelected = selectedIds.has(ticket.id);
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -599,12 +559,7 @@ export default function TicketsPage() {
|
|||||||
{ticket.applicant_name || '-'}
|
{ticket.applicant_name || '-'}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">
|
<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={`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' : ''}`} />
|
<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 [instanceId, setInstanceId] = useState('');
|
||||||
const [targetDatabase, setTargetDatabase] = useState('');
|
const [targetDatabase, setTargetDatabase] = useState('');
|
||||||
const [sqlContent, setSqlContent] = useState('');
|
const [sqlContent, setSqlContent] = useState('');
|
||||||
const [rollbackSql, setRollbackSql] = useState('');
|
|
||||||
const [reason, setReason] = 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 [submitting, setSubmitting] = useState(false);
|
||||||
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number }>>([]);
|
const [instances, setInstances] = useState<Array<{ id: number; name: string; host: string; port: number }>>([]);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [charCount, setCharCount] = useState({ change: 0, rollback: 0 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
instanceApi.list().then(res => {
|
instanceApi.list().then(res => {
|
||||||
@@ -718,9 +669,7 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
|
|||||||
instance_id: Number(instanceId),
|
instance_id: Number(instanceId),
|
||||||
target_database: targetDatabase,
|
target_database: targetDatabase,
|
||||||
sql_content: sqlContent,
|
sql_content: sqlContent,
|
||||||
rollback_sql: rollbackSql || undefined,
|
|
||||||
reason,
|
reason,
|
||||||
priority,
|
|
||||||
});
|
});
|
||||||
onCreated();
|
onCreated();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
@@ -729,25 +678,11 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
|
|||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
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 (
|
return (
|
||||||
<div className="space-y-5 max-w-5xl">
|
<div className="space-y-5 max-w-5xl">
|
||||||
@@ -807,62 +742,17 @@ function NewTicketForm({ onCancel, onCreated }: { onCancel: () => void; onCreate
|
|||||||
</div>
|
</div>
|
||||||
</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 */}
|
{/* SQL Editor */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">SQL 语句 <span className="text-red-400">*</span></label>
|
<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="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
|
<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)]"
|
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"}
|
placeholder="UPDATE users SET status = 'active' WHERE id = 1;"
|
||||||
value={currentSql}
|
value={sqlContent}
|
||||||
onChange={handleSqlChange}
|
onChange={e => setSqlContent(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -914,7 +804,6 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
|
|||||||
}) {
|
}) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const pConfig = priorityConfig[ticket.priority] || priorityConfig.medium;
|
|
||||||
|
|
||||||
const handleReview = useCallback(async (action: 'approve' | 'reject') => {
|
const handleReview = useCallback(async (action: 'approve' | 'reject') => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -956,10 +845,7 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
|
|||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold flex items-center gap-3 flex-wrap">
|
<h1 className="text-xl font-semibold flex items-center gap-3 flex-wrap">
|
||||||
{ticket.title}
|
{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] || ''}`}>
|
<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}
|
{statusMap[ticket.status] || ticket.status}
|
||||||
</span>
|
</span>
|
||||||
@@ -1061,7 +947,7 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
|
|||||||
{ label: '目标数据库', value: ticket.target_database },
|
{ label: '目标数据库', value: ticket.target_database },
|
||||||
{ label: '申请人', value: ticket.applicant_name || '-' },
|
{ label: '申请人', value: ticket.applicant_name || '-' },
|
||||||
{ label: '审批人', value: ticket.approver_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: new Date(ticket.create_time).toLocaleString() },
|
||||||
{ label: '变更原因', value: ticket.reason || '-' },
|
{ label: '变更原因', value: ticket.reason || '-' },
|
||||||
].map((item, idx) => (
|
].map((item, idx) => (
|
||||||
@@ -1087,20 +973,7 @@ function TicketDetail({ ticket, onBack, canApprove, canExecute, onActionDone }:
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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 */}
|
{/* Audit Record */}
|
||||||
{ticket.audit_result && (
|
{ticket.audit_result && (
|
||||||
|
|||||||
Reference in new issue
Block a user