diff --git a/backend/Dockerfile b/backend/Dockerfile index 44026c6..dab576c 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,9 +1,8 @@ FROM golang:1.26-bookworm AS build WORKDIR /app -COPY go.mod go.sum ./ -RUN go mod download COPY . . +RUN go mod tidy RUN CGO_ENABLED=1 GOOS=linux go build -o opsxsql ./cmd/server/ FROM debian:bookworm-slim diff --git a/backend/internal/handler/audit_log.go b/backend/internal/handler/audit_log.go index 94396b3..54976e5 100644 --- a/backend/internal/handler/audit_log.go +++ b/backend/internal/handler/audit_log.go @@ -13,20 +13,34 @@ import ( func ListAuditLogs(c *gin.Context) { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) - if page < 1 { page = 1 } - if pageSize < 1 || pageSize > 100 { pageSize = 20 } + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } var total int64 - model.DB.Model(&model.AuditLog{}).Count(&total) + query := model.DB.Model(&model.AuditLog{}) + if action := c.Query("action"); action != "" { + query = query.Where("action = ?", action) + } + if targetType := c.Query("target_type"); targetType != "" { + query = query.Where("target_type = ?", targetType) + } + if resultStatus := c.Query("result_status"); resultStatus != "" { + query = query.Where("result_status = ?", resultStatus) + } + query.Count(&total) var logs []model.AuditLog offset := (page - 1) * pageSize - model.DB.Order("id DESC").Offset(offset).Limit(pageSize).Find(&logs) + query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&logs) c.JSON(http.StatusOK, gin.H{ - "items": logs, - "total": total, - "page": page, + "items": logs, + "total": total, + "page": page, "page_size": pageSize, }) } diff --git a/backend/internal/handler/instance.go b/backend/internal/handler/instance.go index 77afd56..b12c710 100644 --- a/backend/internal/handler/instance.go +++ b/backend/internal/handler/instance.go @@ -159,17 +159,20 @@ func TestConnection(c *gin.Context) { } result := testDBConnectionFull(&instance) - // Update version & cluster_mode in DB if detected + // Keep the persisted list status in sync with an explicit connection test. updates := map[string]interface{}{} + if result.Success { + updates["status"] = "connected" + } else { + updates["status"] = "error" + } if result.Success && result.Version != "" { updates["version"] = result.Version } if instance.DBType == "redis" { updates["cluster_mode"] = result.ClusterMode } - if len(updates) > 0 { - model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates) - } + model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates) if result.Success { c.JSON(http.StatusOK, gin.H{"success": true, "message": result.Message, "version": result.Version, "cluster_mode": result.ClusterMode}) } else { diff --git a/backend/internal/handler/instance_detail.go b/backend/internal/handler/instance_detail.go index ea91eac..9443686 100644 --- a/backend/internal/handler/instance_detail.go +++ b/backend/internal/handler/instance_detail.go @@ -505,7 +505,7 @@ func DetailListUsers(c *gin.Context) { // POST /api/v1/query/execute func ExecuteQuery(c *gin.Context) { - userID, _, _ := middleware.GetCurrentUser(c) + userID, username, _ := middleware.GetCurrentUser(c) var input struct { InstanceID int `json:"instance_id"` Database string `json:"database"` @@ -535,17 +535,6 @@ func ExecuteQuery(c *gin.Context) { if input.Offset < 0 { input.Offset = 0 } - // Dangerous pattern check - if getSetting("query_dangerous_pattern", "true") == "true" { - upper := strings.ToUpper(input.SQL) - for _, pat := range []string{"DELETE FROM", "UPDATE ", "DROP ", "TRUNCATE", "ALTER ", "RENAME "} { - if strings.Contains(upper, pat) && !strings.Contains(upper, " WHERE ") && !strings.Contains(upper, " LIMIT ") { - c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": 0, - "error": "危险操作检测: " + pat + " 缺少 WHERE/LIMIT 条件,请在工单中提交"}) - return - } - } - } var instance model.Instance if err := model.DB.First(&instance, input.InstanceID).Error; err != nil { @@ -553,6 +542,19 @@ func ExecuteQuery(c *gin.Context) { return } + // Dangerous pattern check + if getSetting("query_dangerous_pattern", "true") == "true" { + upper := strings.ToUpper(input.SQL) + for _, pat := range []string{"DELETE FROM", "UPDATE ", "DROP ", "TRUNCATE", "ALTER ", "RENAME "} { + if strings.Contains(upper, pat) && !strings.Contains(upper, " WHERE ") && !strings.Contains(upper, " LIMIT ") { + saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, 0, 0, "failed", "危险操作检测: "+pat+" 缺少 WHERE/LIMIT 条件,请在工单中提交", true) + c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": 0, + "error": "危险操作检测: " + pat + " 缺少 WHERE/LIMIT 条件,请在工单中提交"}) + return + } + } + } + start := time.Now() switch instance.DBType { @@ -560,6 +562,7 @@ func ExecuteQuery(c *gin.Context) { result, err := executeSQLQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset) costMs := time.Since(start).Milliseconds() if err != nil { + saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, 0, "failed", err.Error(), false) c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()}) return } @@ -567,12 +570,14 @@ func ExecuteQuery(c *gin.Context) { rowsInterface, _ := result["rows"].([]interface{}) applyDataMasking(result, &instance) saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, len(rowsInterface)) + saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, len(rowsInterface), "success", "", false) c.JSON(http.StatusOK, result) case "redis": result, err := executeRedisQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset) costMs := time.Since(start).Milliseconds() if err != nil { + saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, 0, "failed", err.Error(), false) c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()}) return } @@ -583,12 +588,14 @@ func ExecuteQuery(c *gin.Context) { } applyDataMasking(result, &instance) saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, rowCount) + saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, rowCount, "success", "", false) c.JSON(http.StatusOK, result) case "mongodb", "mongo": result, err := executeMongoQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset) costMs := time.Since(start).Milliseconds() if err != nil { + saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, 0, "failed", err.Error(), false) c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()}) return } @@ -596,6 +603,7 @@ func ExecuteQuery(c *gin.Context) { rowsInterface, _ := result["rows"].([]interface{}) applyDataMasking(result, &instance) saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, len(rowsInterface)) + saveQueryAuditLog(c, userID, username, &instance, input.Database, input.SQL, costMs, len(rowsInterface), "success", "", false) c.JSON(http.StatusOK, result) default: @@ -1594,6 +1602,45 @@ func saveQueryHistory(userID int, instanceID int, database string, sqlStr string model.DB.Create(&history) } +func saveQueryAuditLog(c *gin.Context, userID int, username string, instance *model.Instance, database string, sqlStr string, costMs int64, rowCount int, status string, errMsg string, isRisky bool) { + targetID := instance.ID + cost := int(costMs) + rows := rowCount + action := "query" + if status != "success" { + action = "query_failed" + } + + detail := fmt.Sprintf("数据查询: %s/%s, 返回 %d 行, 耗时 %dms", instance.Name, database, rowCount, costMs) + if errMsg != "" { + detail = fmt.Sprintf("数据查询失败: %s/%s, %s", instance.Name, database, errMsg) + } + + auditLog := model.AuditLog{ + UserID: userID, + Username: &username, + Action: action, + TargetType: "query", + TargetID: &targetID, + Detail: &detail, + IPAddress: auditStringPtr(c.ClientIP()), + DBType: &instance.DBType, + DatabaseName: &database, + InstanceName: &instance.Name, + ExecutionTimeMs: &cost, + RowsAffected: &rows, + ResultStatus: &status, + QueryContent: &sqlStr, + UserAgent: auditStringPtr(c.GetHeader("User-Agent")), + IsRisky: &isRisky, + } + model.DB.Create(&auditLog) +} + +func auditStringPtr(value string) *string { + return &value +} + // applyDataMasking masks sensitive fields in query results with "*". // It reads global settings (data_masking_enabled + sensitive fields) and // merges with instance-level mask_fields. diff --git a/backend/internal/handler/redis.go b/backend/internal/handler/redis.go index bbbd007..7668169 100644 --- a/backend/internal/handler/redis.go +++ b/backend/internal/handler/redis.go @@ -484,9 +484,20 @@ func RedisDbsize(c *gin.Context) { func RedisScanKeys(c *gin.Context) { id := c.Param("id") dbParam := c.Query("db") - rawPattern := strings.TrimSpace(c.DefaultQuery("pattern", "*")) + type KeyInfo struct { + Key string `json:"key"` + Type string `json:"type"` + TTL int64 `json:"ttl"` + } + + rawPattern := strings.TrimSpace(c.Query("pattern")) if rawPattern == "" { - rawPattern = "*" + c.JSON(http.StatusOK, gin.H{ + "keys": []KeyInfo{}, + "cursor": 0, + "total": 0, + }) + return } pattern := rawPattern hasWildcard := strings.ContainsAny(pattern, "*?[") @@ -512,13 +523,6 @@ func RedisScanKeys(c *gin.Context) { ctx := context.Background() - // Get types for each key - type KeyInfo struct { - Key string `json:"key"` - Type string `json:"type"` - TTL int64 `json:"ttl"` - } - // Redis Cluster 精确 key 查询应优先走槽位路由直查。全量 SCAN 需要访问各 master, // 在 NAT/代理暴露单入口时容易受节点通告地址影响。 if !hasWildcard && pattern != "*" { diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 0146993..21b5a10 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,6 +4,10 @@ server { root /usr/share/nginx/html; index index.html; + location /assets/ { + try_files $uri =404; + } + location / { try_files $uri $uri/ /index.html; } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 36d0ec8..19ed853 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -35,18 +35,19 @@ function PageFallback() { } function RouteGuard() { - const { isLoggedIn, hasPermission } = useAuth(); + const { isLoggedIn, hasPermission, visibleMenuItems } = useAuth(); const location = useLocation(); const path = location.pathname.split('/')[1] || 'dashboard'; const permissionPath = ({ redis: 'instances', mongo: 'instances' } as Record)[path] || path; + const fallbackPath = '/' + (visibleMenuItems[0]?.id || 'queries'); if (!isLoggedIn) { return ; } // 检查当前路径是否有权限访问 - if (permissionPath !== 'dashboard' && !hasPermission(permissionPath)) { - return ; + if (!hasPermission(permissionPath)) { + return ; } return ; @@ -54,9 +55,9 @@ function RouteGuard() { // 登录页守卫:已登录用户自动跳转到仪表盘 function LoginGuard() { - const { isLoggedIn } = useAuth(); + const { isLoggedIn, visibleMenuItems } = useAuth(); if (isLoggedIn) { - return ; + return ; } return ; } @@ -68,7 +69,7 @@ function AppRoutes() { } /> }> }> - } /> + } /> } /> } /> } /> @@ -88,7 +89,7 @@ function AppRoutes() { } /> } /> } /> - } /> + } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 773d4ce..c39fcbb 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -55,7 +55,7 @@ export default function Layout() { return (
{/* 侧边栏 — z-[200] 永远在弹框之上 */} -