diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index aee6197..cccd167 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -85,9 +85,50 @@ func main() { func seedDefaultSettings(db *gorm.DB) { defaults := []model.SystemSetting{ - // Data masking group + // ── 查询控制 ── + {Key: "query_limit_max", Value: "1000", ValueType: "number", Group: "query", Label: "LIMIT 上限", Desc: strPtr("SELECT 查询返回的最大行数,超过自动截断"), DefaultValue: strPtr("1000"), SortOrder: 1}, + {Key: "query_timeout", Value: "30", ValueType: "number", Group: "query", Label: "查询超时(秒)", Desc: strPtr("单次查询最长执行时间,超时自动终止"), DefaultValue: strPtr("30"), SortOrder: 2}, + {Key: "query_auto_limit", Value: "true", ValueType: "boolean", Group: "query", Label: "自动加 LIMIT", Desc: strPtr("用户未写 LIMIT 时自动追加,防止全表扫描"), DefaultValue: strPtr("true"), SortOrder: 3}, + {Key: "query_max_rows_examined", Value: "1000000", ValueType: "number", Group: "query", Label: "最大扫描行数", Desc: strPtr("单次查询允许扫描的最大行数"), DefaultValue: strPtr("1000000"), SortOrder: 4}, + {Key: "query_dangerous_pattern", Value: "true", ValueType: "boolean", Group: "query", Label: "危险模式检测", Desc: strPtr("检测不带 WHERE 的 DELETE/UPDATE 等高危操作"), DefaultValue: strPtr("true"), SortOrder: 5}, + + // ── 安全策略 ── + {Key: "login_max_attempts", Value: "5", ValueType: "number", Group: "security", Label: "登录失败锁定次数", Desc: strPtr("连续登录失败达到此次数后锁定账号"), DefaultValue: strPtr("5"), SortOrder: 1}, + {Key: "login_lockout_minutes", Value: "30", ValueType: "number", Group: "security", Label: "锁定时长(分钟)", Desc: strPtr("账号被锁定后的持续时长"), DefaultValue: strPtr("30"), SortOrder: 2}, + {Key: "password_min_length", Value: "8", ValueType: "number", Group: "security", Label: "密码最小长度", Desc: strPtr("用户密码的最短字符数"), DefaultValue: strPtr("8"), SortOrder: 3}, + {Key: "password_require_complex", Value: "true", ValueType: "boolean", Group: "security", Label: "密码复杂度", Desc: strPtr("要求包含大小写字母、数字和特殊字符"), DefaultValue: strPtr("true"), SortOrder: 4}, + {Key: "session_timeout_hours", Value: "24", ValueType: "number", Group: "security", Label: "会话超时(小时)", Desc: strPtr("JWT Token 有效期"), DefaultValue: strPtr("24"), SortOrder: 5}, + + // ── 数据脱敏 ── {Key: "data_masking_enabled", Value: "true", ValueType: "boolean", Group: "data_masking", Label: "启用数据脱敏", Desc: strPtr("启用后,查询结果中的敏感字段将被替换为 *"), DefaultValue: strPtr("true"), SortOrder: 1}, {Key: "data_masking_sensitive_fields", Value: "mobile,concat_mobile,email_alias,email,password,ga_key,private_key,secret,secret_key,key_snow,telephone,username,real_name,token,two_factor_secret,content,phone", ValueType: "string", Group: "data_masking", Label: "默认敏感字段列表", Desc: strPtr("全局敏感字段,逗号分隔。匹配列名(不区分大小写)"), DefaultValue: strPtr("mobile,concat_mobile,email_alias,email,password,ga_key,private_key,secret,secret_key,key_snow,telephone,username,real_name,token,two_factor_secret,content,phone"), SortOrder: 2}, + + // ── 日志清理 ── + {Key: "ticket_retention_days", Value: "180", ValueType: "number", Group: "cleanup", Label: "工单保留天数", Desc: strPtr("超过此天数的已完成/已关闭工单将被清理"), DefaultValue: strPtr("180"), SortOrder: 1}, + {Key: "audit_log_retention_days", Value: "365", ValueType: "number", Group: "cleanup", Label: "审计日志保留天数", Desc: strPtr("超过此天数的审计日志将被清理"), DefaultValue: strPtr("365"), SortOrder: 2}, + {Key: "query_history_retention_days", Value: "90", ValueType: "number", Group: "cleanup", Label: "查询历史保留天数", Desc: strPtr("超过此天数的查询历史将被清理"), DefaultValue: strPtr("90"), SortOrder: 3}, + {Key: "slow_log_retention_days", Value: "180", ValueType: "number", Group: "cleanup", Label: "慢日志保留天数", Desc: strPtr("超过此天数的慢查询日志将被清理"), DefaultValue: strPtr("180"), SortOrder: 4}, + + // ── 执行器 ── + {Key: "executor_timeout", Value: "300", ValueType: "number", Group: "executor", Label: "执行超时(秒)", Desc: strPtr("工单中 SQL 语句执行的最大等待时间"), DefaultValue: strPtr("300"), SortOrder: 1}, + {Key: "executor_batch_size", Value: "100", ValueType: "number", Group: "executor", Label: "批量执行大小", Desc: strPtr("DML 批量执行时每批的行数"), DefaultValue: strPtr("100"), SortOrder: 2}, + {Key: "executor_dry_run", Value: "true", ValueType: "boolean", Group: "executor", Label: "执行前预检查", Desc: strPtr("执行前先 EXPLAIN 验证 SQL 语法正确性"), DefaultValue: strPtr("true"), SortOrder: 3}, + {Key: "executor_backup", Value: "true", ValueType: "boolean", Group: "executor", Label: "执行前备份", Desc: strPtr("DML 执行前自动备份受影响的数据"), DefaultValue: strPtr("true"), SortOrder: 4}, + + // ── Ghost/OSC (gh-ost 在线DDL) ── + {Key: "ghost_enabled", Value: "false", ValueType: "boolean", Group: "ghost", Label: "启用 gh-ost", Desc: strPtr("启用 gh-ost 在线 DDL 变更工具"), DefaultValue: strPtr("false"), SortOrder: 1}, + {Key: "ghost_path", Value: "gh-ost", ValueType: "string", Group: "ghost", Label: "gh-ost 路径", Desc: strPtr("gh-ost 二进制文件路径"), DefaultValue: strPtr("gh-ost"), SortOrder: 2}, + {Key: "ghost_default_mode", Value: "dry_run", ValueType: "string", Group: "ghost", Label: "默认执行模式", Desc: strPtr("dry_run=预演 / execute=直接执行"), DefaultValue: strPtr("dry_run"), SortOrder: 3}, + {Key: "ghost_osc_priority", Value: "native_first", ValueType: "string", Group: "ghost", Label: "OSC 优先级", Desc: strPtr("native_first / ghost_first / ghost_only"), DefaultValue: strPtr("native_first"), SortOrder: 4}, + {Key: "ghost_env_mode", Value: "development", ValueType: "string", Group: "ghost", Label: "环境模式", Desc: strPtr("development / staging / production"), DefaultValue: strPtr("development"), SortOrder: 5}, + {Key: "ghost_cloud_type", Value: "self_hosted", ValueType: "string", Group: "ghost", Label: "部署类型", Desc: strPtr("self_hosted / rds / cloud"), DefaultValue: strPtr("self_hosted"), SortOrder: 6}, + {Key: "ghost_auto_trigger_rows", Value: "10", ValueType: "number", Group: "ghost", Label: "自动触发行数(万)", Desc: strPtr("表行数超过此阈值(万)自动推荐 OSC"), DefaultValue: strPtr("10"), SortOrder: 7}, + {Key: "ghost_auto_trigger_ddl", Value: "ADD COLUMN,MODIFY COLUMN,ADD INDEX", ValueType: "string", Group: "ghost", Label: "自动触发DDL类型", Desc: strPtr("自动推荐 OSC 的 DDL 操作类型列表"), DefaultValue: strPtr("ADD COLUMN,MODIFY COLUMN,ADD INDEX"), SortOrder: 8}, + {Key: "ghost_default_chunk_size", Value: "1000", ValueType: "number", Group: "ghost", Label: "默认 chunk 大小", Desc: strPtr("每次拷贝的行数"), DefaultValue: strPtr("1000"), SortOrder: 9}, + {Key: "ghost_max_load", Value: "Threads_running=25", ValueType: "string", Group: "ghost", Label: "最大负载阈值", Desc: strPtr("超过此负载暂停拷贝"), DefaultValue: strPtr("Threads_running=25"), SortOrder: 10}, + {Key: "ghost_critical_load", Value: "Threads_running=50", ValueType: "string", Group: "ghost", Label: "临界负载阈值", Desc: strPtr("超过此负载立即停止"), DefaultValue: strPtr("Threads_running=50"), SortOrder: 11}, + {Key: "ghost_allow_on_master", Value: "true", ValueType: "boolean", Group: "ghost", Label: "允许在主库执行", Desc: strPtr("是否允许直接在主库运行 gh-ost"), DefaultValue: strPtr("true"), SortOrder: 12}, + {Key: "ghost_max_lag_millis", Value: "1500", ValueType: "number", Group: "ghost", Label: "最大复制延迟(ms)", Desc: strPtr("从库复制延迟超过此值暂停拷贝"), DefaultValue: strPtr("1500"), SortOrder: 13}, } for _, s := range defaults { var existing model.SystemSetting diff --git a/backend/internal/handler/auth.go b/backend/internal/handler/auth.go index 5be5ea8..bb09485 100644 --- a/backend/internal/handler/auth.go +++ b/backend/internal/handler/auth.go @@ -29,6 +29,21 @@ func Login(c *gin.Context) { var user model.User err := model.DB.Where("username = ?", req.Username).First(&user).Error + // ── 登录失败锁定检查 ── + if err == nil && user.LockedUntil != nil && user.LockedUntil.After(time.Now()) { + maxAttempts := getSettingInt("login_max_attempts", 5) + lockMin := getSettingInt("login_lockout_minutes", 30) + remaining := int(user.LockedUntil.Sub(time.Now()).Minutes()) + 1 + c.JSON(http.StatusTooManyRequests, gin.H{"detail": fmt.Sprintf("账号已锁定,连续 %d 次登录失败,请 %d 分钟后重试", maxAttempts, lockMin), "retry_after_minutes": remaining}) + return + } + // Lock expired → reset + if err == nil && user.LockedUntil != nil && !user.LockedUntil.After(time.Now()) { + user.FailedLoginAttempts = 0 + user.LockedUntil = nil + model.DB.Save(&user) + } + if err == nil { // ── 本地用户存在 ── if user.IsActive != nil && !*user.IsActive { @@ -43,6 +58,8 @@ func Login(c *gin.Context) { // Local password failed — try LDAP fallback for LDAP-imported users (password == "-") _, ldapInfo, ldapErr := tryLDAPAuth(req.Username, req.Password) if ldapErr != nil || ldapInfo == nil { + // Both local and LDAP failed → record failed attempt + handleFailedLogin(&user) c.JSON(http.StatusUnauthorized, gin.H{"detail": "Invalid username or password"}) return } @@ -107,6 +124,9 @@ func Login(c *gin.Context) { } // ── 生成 Token ── + // Login success → reset failed attempts + user.FailedLoginAttempts = 0 + user.LockedUntil = nil token, err := middleware.GenerateToken(&user) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"detail": "Failed to generate token"}) @@ -339,3 +359,16 @@ func DeleteUser(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{"message": "User deleted"}) } + +// handleFailedLogin records a failed login attempt and locks the account if threshold is reached. +func handleFailedLogin(user *model.User) { + maxAttempts := getSettingInt("login_max_attempts", 5) + lockMinutes := getSettingInt("login_lockout_minutes", 30) + + user.FailedLoginAttempts++ + if user.FailedLoginAttempts >= maxAttempts { + lockedUntil := time.Now().Add(time.Duration(lockMinutes) * time.Minute) + user.LockedUntil = &lockedUntil + } + model.DB.Save(user) +} diff --git a/backend/internal/handler/connection_tester.go b/backend/internal/handler/connection_tester.go index 6720870..7d32cfb 100644 --- a/backend/internal/handler/connection_tester.go +++ b/backend/internal/handler/connection_tester.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "math" "strconv" "strings" "time" @@ -230,7 +231,7 @@ func getRedisMetrics(instance *model.Instance) map[string]interface{} { return metrics } metrics["status"] = "online" - info, _ = rdb.Info(ctx, "server", "clients", "stats").Result() + info, _ = rdb.Info(ctx, "server", "clients", "stats", "memory", "cpu").Result() // Latency from PING start := time.Now() rdb.Ping(ctx) @@ -274,6 +275,48 @@ func getRedisMetrics(instance *model.Instance) map[string]interface{} { metrics["uptime"] = fmt.Sprintf("%d 分钟", secs/60) } } + // Memory usage: used_memory / maxmemory * 100, fallback to total_system_memory + if used, ok := infoMap["used_memory"]; ok { + usedBytes, _ := strconv.ParseInt(used, 10, 64) + var memPercent int + if maxStr, ok2 := infoMap["maxmemory"]; ok2 { + maxBytes, _ := strconv.ParseInt(maxStr, 10, 64) + if maxBytes > 0 { + memPercent = int(usedBytes * 100 / maxBytes) + } + } + // Fallback: use total_system_memory if maxmemory not set (maxmemory=0) + if memPercent == 0 { + if totalStr, ok3 := infoMap["total_system_memory"]; ok3 { + totalBytes, _ := strconv.ParseInt(totalStr, 10, 64) + if totalBytes > 0 { + memPercent = int(usedBytes * 100 / totalBytes) + } + } + } + if memPercent > 0 { + metrics["memory"] = memPercent + } + metrics["used_memory_human"] = infoMap["used_memory_human"] + } + // CPU usage: (used_cpu_sys + used_cpu_user) / uptime_in_seconds * 100 + if sys, ok := infoMap["used_cpu_sys"]; ok { + if user, ok2 := infoMap["used_cpu_user"]; ok2 { + sysVal, _ := strconv.ParseFloat(sys, 64) + userVal, _ := strconv.ParseFloat(user, 64) + total := sysVal + userVal + if upSecs, ok3 := infoMap["uptime_in_seconds"]; ok3 { + upVal, _ := strconv.ParseFloat(upSecs, 64) + if upVal > 0 { + pct := int(total / upVal * 100) + if pct > 100 { + pct = 100 + } + metrics["cpu"] = pct + } + } + } + } return metrics } @@ -319,10 +362,12 @@ func getMySQLMetrics(instance *model.Instance) map[string]interface{} { metrics["latency"] = time.Since(start).Milliseconds() metrics["status"] = "online" - // Fetch STATUS variables + // Fetch STATUS + VARIABLES statusVars := map[string]string{} rows, err := db.QueryContext(ctx, "SHOW GLOBAL STATUS WHERE Variable_name IN "+ - "('Threads_connected','Queries','Uptime','Connections','Slow_queries')") + "('Threads_connected','Threads_running','Queries','Uptime','Connections','Slow_queries',"+ + "'Innodb_buffer_pool_pages_total','Innodb_buffer_pool_pages_data',"+ + "'Innodb_buffer_pool_reads','Innodb_buffer_pool_read_requests')") if err == nil { defer rows.Close() for rows.Next() { @@ -331,10 +376,33 @@ func getMySQLMetrics(instance *model.Instance) map[string]interface{} { statusVars[name] = value } } + varsMap := map[string]string{} + vrows, err := db.QueryContext(ctx, "SHOW VARIABLES WHERE Variable_name = 'max_connections'") + if err == nil { + defer vrows.Close() + for vrows.Next() { + var name, value string + vrows.Scan(&name, &value) + varsMap[name] = value + } + } if v, ok := statusVars["Threads_connected"]; ok { n, _ := strconv.Atoi(v) metrics["connections"] = n + // Connection usage as cpu indicator + if maxStr, ok2 := varsMap["max_connections"]; ok2 { + maxConn, _ := strconv.Atoi(maxStr) + if maxConn > 0 { + metrics["cpu"] = int(math.Ceil(float64(n) * 100 / float64(maxConn))) + metrics["max_connections"] = maxConn + } + } + } + // Threads running (active) + if v, ok := statusVars["Threads_running"]; ok { + n, _ := strconv.Atoi(v) + metrics["threads_running"] = n } if v, ok := statusVars["Uptime"]; ok { secs, _ := strconv.Atoi(v) @@ -356,6 +424,27 @@ func getMySQLMetrics(instance *model.Instance) map[string]interface{} { } } } + // Buffer pool usage as memory indicator + if bp, ok := statusVars["Innodb_buffer_pool_pages_total"]; ok { + if bd, ok2 := statusVars["Innodb_buffer_pool_pages_data"]; ok2 { + total, _ := strconv.ParseFloat(bp, 64) + used, _ := strconv.ParseFloat(bd, 64) + if total > 0 { + metrics["memory"] = int(used / total * 100) + } + } + } + // Slow queries count + if v, ok := statusVars["Slow_queries"]; ok { + n, _ := strconv.Atoi(v) + metrics["slow_queries"] = n + } + // Data size on disk + var dataMB float64 + db.QueryRowContext(ctx, "SELECT COALESCE(ROUND(SUM(data_length + index_length)/1024/1024, 1), 0) FROM information_schema.tables WHERE table_schema NOT IN ('mysql','information_schema','performance_schema','sys')").Scan(&dataMB) + if dataMB > 0 { + metrics["data_size_mb"] = dataMB + } // Version from DB if not already set if instance.Version == nil || *instance.Version == "" { diff --git a/backend/internal/handler/ghost.go b/backend/internal/handler/ghost.go index 9ea894f..5bc20fc 100644 --- a/backend/internal/handler/ghost.go +++ b/backend/internal/handler/ghost.go @@ -472,12 +472,70 @@ func UpdateSettings(c *gin.Context) { // GET /api/v1/settings/cleanup/preview func SettingsCleanupPreview(c *gin.Context) { - c.JSON(http.StatusOK, map[string]interface{}{}) + now := time.Now() + stats := gin.H{} + + // Tickets + ticketDays := getSettingInt("ticket_retention_days", 180) + var ticketCount int64 + model.DB.Model(&model.Ticket{}). + Where("status IN ? AND create_time < ?", []string{"success", "failed", "closed", "rejected"}, now.AddDate(0, 0, -ticketDays)). + Count(&ticketCount) + stats["tickets"] = gin.H{"days": ticketDays, "count": ticketCount} + + // Query history + queryDays := getSettingInt("query_history_retention_days", 90) + var queryCount int64 + model.DB.Model(&model.QueryHistory{}). + Where("created_at < ?", now.AddDate(0, 0, -queryDays)). + Count(&queryCount) + stats["query_history"] = gin.H{"days": queryDays, "count": queryCount} + + // Slow logs + slowDays := getSettingInt("slow_log_retention_days", 180) + var slowCount int64 + model.DB.Model(&model.SlowLog{}). + Where("created_at < ?", now.AddDate(0, 0, -slowDays)). + Count(&slowCount) + stats["slow_logs"] = gin.H{"days": slowDays, "count": slowCount} + + // Audit logs — skip if no AuditLog model; use raw SQL + auditDays := getSettingInt("audit_log_retention_days", 365) + var auditCount int64 + model.DB.Raw("SELECT COUNT(*) FROM audit_logs WHERE created_at < ?", now.AddDate(0, 0, -auditDays)).Scan(&auditCount) + stats["audit_logs"] = gin.H{"days": auditDays, "count": auditCount} + + c.JSON(http.StatusOK, stats) } // POST /api/v1/settings/cleanup/run func SettingsCleanupRun(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"message": "Cleanup done (placeholder)", "stats": map[string]interface{}{}}) + now := time.Now() + stats := gin.H{} + + // Tickets + ticketDays := getSettingInt("ticket_retention_days", 180) + ticketResult := model.DB.Where("status IN ? AND create_time < ?", + []string{"success", "failed", "closed", "rejected"}, now.AddDate(0, 0, -ticketDays)). + Delete(&model.Ticket{}) + stats["tickets_deleted"] = ticketResult.RowsAffected + + // Query history + queryDays := getSettingInt("query_history_retention_days", 90) + queryResult := model.DB.Where("created_at < ?", now.AddDate(0, 0, -queryDays)).Delete(&model.QueryHistory{}) + stats["query_history_deleted"] = queryResult.RowsAffected + + // Slow logs + slowDays := getSettingInt("slow_log_retention_days", 180) + slowResult := model.DB.Where("created_at < ?", now.AddDate(0, 0, -slowDays)).Delete(&model.SlowLog{}) + stats["slow_logs_deleted"] = slowResult.RowsAffected + + // Audit logs + auditDays := getSettingInt("audit_log_retention_days", 365) + auditResult := model.DB.Exec("DELETE FROM audit_logs WHERE created_at < ?", now.AddDate(0, 0, -auditDays)) + stats["audit_logs_deleted"] = auditResult.RowsAffected + + c.JSON(http.StatusOK, gin.H{"message": "Cleanup completed", "stats": stats}) } // GET /api/v1/notifications/channels diff --git a/backend/internal/handler/instance.go b/backend/internal/handler/instance.go index 2f261a1..4740dff 100644 --- a/backend/internal/handler/instance.go +++ b/backend/internal/handler/instance.go @@ -3,6 +3,7 @@ package handler import ( "net/http" "strconv" + "strings" "sql-platform/internal/middleware" "sql-platform/internal/model" @@ -66,6 +67,7 @@ func CreateInstance(c *gin.Context) { } // Encrypt password before storing if instance.Password != "" { + instance.Password = strings.TrimSpace(instance.Password) encPw, err := encryptPassword(instance.Password) if err == nil { instance.Password = "enc:" + encPw @@ -75,15 +77,20 @@ func CreateInstance(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"detail": err.Error()}) return } - // Auto-detect version and cluster_mode after creation + // Auto-detect version, cluster_mode and status after creation go func() { result := testDBConnectionFull(&instance) updates := map[string]interface{}{} - if result.Success && result.Version != "" { - updates["version"] = result.Version - } - if result.ClusterMode { - updates["cluster_mode"] = true + if result.Success { + updates["status"] = "connected" + if result.Version != "" { + updates["version"] = result.Version + } + if result.ClusterMode { + updates["cluster_mode"] = true + } + } else { + updates["status"] = "error" } if len(updates) > 0 { model.DB.Model(&model.Instance{}).Where("id = ?", instance.ID).Updates(updates) @@ -110,6 +117,7 @@ func UpdateInstance(c *gin.Context) { // if password is empty or masked placeholder, don't update it if pw, ok := input["password"].(string); ok { + pw = strings.TrimSpace(pw) if pw == "" || pw == "******" { delete(input, "password") } else { diff --git a/backend/internal/handler/instance_detail.go b/backend/internal/handler/instance_detail.go index 5d2020a..850cbe7 100644 --- a/backend/internal/handler/instance_detail.go +++ b/backend/internal/handler/instance_detail.go @@ -521,16 +521,33 @@ func ExecuteQuery(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"detail": "instance_id and sql are required"}) return } - // Default pagination + // Read query limits from platform settings + maxLimit := getSettingInt("query_limit_max", 1000) + autoLimit := getSetting("query_auto_limit", "true") == "true" if input.Limit <= 0 { - input.Limit = 100 + input.Limit = maxLimit + if input.Limit > 1000 { + input.Limit = 1000 + } } - if input.Limit > 1000 { - input.Limit = 1000 + if input.Limit > maxLimit { + input.Limit = maxLimit } 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 + } + } + } + _ = autoLimit var instance model.Instance if err := model.DB.First(&instance, input.InstanceID).Error; err != nil { @@ -595,6 +612,12 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim } defer db.Close() + // Apply query timeout from platform settings + timeoutSec := getSettingInt("query_timeout", 30) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutSec)*time.Second) + defer cancel() + _ = ctx + upperSQL := strings.ToUpper(strings.TrimSpace(sqlStr)) // Count total (only when no user LIMIT and offset=0) @@ -613,7 +636,7 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim if idx := strings.Index(upperCount, " GROUP BY "); idx > 0 { countSQL = countSQL[:idx] } - db.QueryRow(countSQL).Scan(&total) + db.QueryRowContext(ctx, countSQL).Scan(&total) } } } @@ -623,7 +646,7 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim sqlStr = fmt.Sprintf("%s LIMIT %d OFFSET %d", sqlStr, limit, offset) } - rows, err := db.Query(sqlStr) + rows, err := db.QueryContext(ctx, sqlStr) if err != nil { return nil, fmt.Errorf("query failed: %v", err) } diff --git a/backend/internal/handler/ticket.go b/backend/internal/handler/ticket.go index 08c7f5b..6e3c21a 100644 --- a/backend/internal/handler/ticket.go +++ b/backend/internal/handler/ticket.go @@ -549,6 +549,39 @@ func ExecuteTicket(c *gin.Context) { execErr = fmt.Sprintf("DB connection failed: %v", err) } else { defer db.Close() + // Apply executor timeout from platform settings + execTimeout := getSettingInt("executor_timeout", 300) + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(execTimeout)*time.Second) + defer cancel() + // Dry-run: EXPLAIN pre-check + if getSetting("executor_dry_run", "true") == "true" { + statements := splitSQL(ticket.SQLContent) + for _, stmt := range statements { + stmt = strings.TrimSpace(stmt) + if stmt == "" || strings.HasPrefix(strings.ToUpper(stmt), "EXPLAIN") { + continue + } + var explainRows []string + rows, err := db.QueryContext(ctx, "EXPLAIN "+stmt) + if err != nil { + execErr = fmt.Sprintf("预检查失败(EXPLAIN): %v", err) + break + } + for rows.Next() { + rows.Close() + break + } + _ = explainRows + } + if execErr != "" { + failStatus := "failed" + ticket.Status = &failStatus + ticket.ExecuteResult = &execErr + model.DB.Save(&ticket) + c.JSON(http.StatusOK, gin.H{"message": "Pre-check failed", "ticket": ticket}) + return + } + } // Split multi-statement by semicolons statements := splitSQL(ticket.SQLContent) var results []string @@ -557,7 +590,7 @@ func ExecuteTicket(c *gin.Context) { if stmt == "" { continue } - _, err := db.Exec(stmt) + _, err := db.ExecContext(ctx, stmt) if err != nil { results = append(results, fmt.Sprintf("[FAIL] %s: %v", truncateStr(stmt, 50), err)) // stop_on_error diff --git a/backend/internal/middleware/auth.go b/backend/internal/middleware/auth.go index cfe1835..3e09f1d 100644 --- a/backend/internal/middleware/auth.go +++ b/backend/internal/middleware/auth.go @@ -2,6 +2,7 @@ package middleware import ( "net/http" + "strconv" "strings" "time" @@ -12,6 +13,21 @@ import ( "github.com/golang-jwt/jwt/v5" ) +// getSessionTimeoutHours reads session_timeout_hours from system_settings, +// falls back to config/env, then default 24h. +func getSessionTimeoutHours() int { + var s model.SystemSetting + if model.DB.Where("`key` = ?", "session_timeout_hours").Take(&s).Error == nil { + if v, err := strconv.Atoi(s.Value); err == nil && v > 0 { + return v + } + } + if config.C.JWTExpire > 0 { + return config.C.JWTExpire + } + return 24 +} + type Claims struct { UserID int `json:"user_id"` Username string `json:"username"` @@ -25,7 +41,7 @@ func GenerateToken(user *model.User) (string, error) { Username: user.Username, Role: user.Role, RegisteredClaims: jwt.RegisteredClaims{ - ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(config.C.JWTExpire) * time.Hour)), + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(getSessionTimeoutHours()) * time.Hour)), IssuedAt: jwt.NewNumericDate(time.Now()), }, } diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index 0744342..6d42621 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -20,7 +20,9 @@ type User struct { Role string `gorm:"size:20;not null" json:"role"` // admin, dba, developer, auditor IsActive *bool `gorm:"default:true" json:"is_active"` Preferences *string `gorm:"type:text" json:"preferences"` // JSON: {"theme":"dark","login_style":"split"} - LastLogin *time.Time `json:"last_login"` + LastLogin *time.Time `json:"last_login"` + FailedLoginAttempts int `gorm:"default:0" json:"failed_login_attempts"` + LockedUntil *time.Time `json:"locked_until"` CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"created_at"` UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP" json:"updated_at"` } diff --git a/frontend/src/pages/InstancePage.tsx b/frontend/src/pages/InstancePage.tsx index d4a8dfd..0c8e14d 100644 --- a/frontend/src/pages/InstancePage.tsx +++ b/frontend/src/pages/InstancePage.tsx @@ -71,6 +71,7 @@ const envColor: Record = { const statusLabel: Record = { connected: { text: '在线', online: true }, online: { text: '在线', online: true }, + active: { text: '在线', online: true }, disconnected: { text: '离线', online: false }, offline: { text: '离线', online: false }, error: { text: '异常', online: false }, @@ -146,6 +147,7 @@ export default function InstancePage() { const [batchDeleting, setBatchDeleting] = useState(false); const [showClone, setShowClone] = useState(false); const [cloneSource, setCloneSource] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState<{msg: string; onConfirm: () => void} | null>(null); const typeTabs = [ { key: 'all', label: '全部', icon: LayoutGrid }, @@ -729,11 +731,11 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete }) { const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string; version?: string } | null>(null); - const [metrics, setMetrics] = useState<{ connections: number; qps: number; latency: number; uptime: string } | null>(null); + const [metrics, setMetrics] = useState<{ connections: number; qps: number; latency: number; uptime: string; cpu?: number; memory?: number; disk?: number; used_memory_human?: string; threads_running?: number; max_connections?: number; slow_queries?: number; data_size_mb?: number } | null>(null); useEffect(() => { instanceApi.metrics(instance.id).then(res => { - setMetrics({ connections: res.connections ?? 0, qps: res.qps ?? 0, latency: res.latency ?? 0, uptime: res.uptime ?? '-' }); + setMetrics({ connections: res.connections ?? 0, qps: res.qps ?? 0, latency: res.latency ?? 0, uptime: res.uptime ?? '-', cpu: res.cpu, memory: res.memory, disk: res.disk, used_memory_human: res.used_memory_human, threads_running: res.threads_running, max_connections: res.max_connections, slow_queries: res.slow_queries, data_size_mb: res.data_size_mb }); }).catch(() => {}); }, [instance.id]); @@ -860,21 +862,35 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
- {[ - { label: 'CPU 使用率', value: instance.cpu ?? 0, color: (instance.cpu ?? 0) > 80 ? 'bg-red-400' : (instance.cpu ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' }, - { label: '内存使用率', value: instance.memory ?? 0, color: (instance.memory ?? 0) > 85 ? 'bg-red-400' : (instance.memory ?? 0) > 70 ? 'bg-yellow-400' : 'bg-green-400' }, - { label: '磁盘使用率', value: instance.disk ?? 0, color: (instance.disk ?? 0) > 80 ? 'bg-red-400' : (instance.disk ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' }, - ].map(item => ( -
-
- {item.label} - {item.value}% -
-
-
-
-
- ))} + {(() => { + const isRedis = instance.db_type === 'redis'; + // Dynamic metric items based on db_type + const items = isRedis ? [ + { label: 'CPU 使用率', value: metrics?.cpu, text: undefined, color: (metrics?.cpu ?? 0) > 80 ? 'bg-red-400' : (metrics?.cpu ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' }, + { label: '内存使用率', value: metrics?.memory, text: metrics?.used_memory_human, color: (metrics?.memory ?? 0) > 85 ? 'bg-red-400' : (metrics?.memory ?? 0) > 70 ? 'bg-yellow-400' : 'bg-green-400' }, + ] : [ + { label: '连接使用率', value: metrics?.cpu, text: undefined, color: (metrics?.cpu ?? 0) > 80 ? 'bg-red-400' : (metrics?.cpu ?? 0) > 60 ? 'bg-yellow-400' : 'bg-green-400' }, + { label: 'Buffer Pool', value: metrics?.memory, text: undefined, color: (metrics?.memory ?? 0) > 85 ? 'bg-red-400' : (metrics?.memory ?? 0) > 70 ? 'bg-yellow-400' : 'bg-green-400' }, + { label: '慢查询', value: undefined, text: metrics?.slow_queries != null ? String(metrics.slow_queries) + ' 条' : undefined, color: '' }, + ]; + return items.filter(item => item.value != null || item.text != null).map(item => { + const hasPercent = item.value != null && item.value >= 0; + const pct = item.value ?? 0; + return ( +
+
+ {item.label} + {hasPercent ? `${pct}%` : (item.text || 'N/A')} +
+ {hasPercent && ( +
+
+
+ )} +
+ ); + }); + })()}
@@ -890,9 +906,9 @@ function InstanceDetail({ instance, onBack, config, canEdit, canDelete, onDelete
{[ - { label: '连接数', value: instance.connections ?? metrics?.connections ?? 0, unit: '', icon: Users, color: 'text-blue-400', bg: 'bg-blue-400/10' }, - { label: 'QPS', value: instance.qps ?? metrics?.qps ?? 0, unit: '/s', icon: Activity, color: 'text-green-400', bg: 'bg-green-400/10' }, - { label: '延迟', value: instance.latency ?? metrics?.latency ?? 0, unit: 'ms', icon: Signal, color: 'text-orange-400', bg: 'bg-orange-400/10' }, + { label: '连接数', value: metrics?.connections ?? 0, unit: '', icon: Users, color: 'text-blue-400', bg: 'bg-blue-400/10' }, + { label: 'QPS', value: metrics?.qps ?? 0, unit: '/s', icon: Activity, color: 'text-green-400', bg: 'bg-green-400/10' }, + { label: '延迟', value: metrics?.latency ?? 0, unit: 'ms', icon: Signal, color: 'text-orange-400', bg: 'bg-orange-400/10' }, { label: '运行时间', value: metrics?.uptime ?? '-', unit: '', icon: HardDrive, color: 'text-purple-400', bg: 'bg-purple-400/10' }, ].map(m => (
diff --git a/frontend/src/pages/PlatformConfigPage.tsx b/frontend/src/pages/PlatformConfigPage.tsx index 3f685ca..ff77672 100644 --- a/frontend/src/pages/PlatformConfigPage.tsx +++ b/frontend/src/pages/PlatformConfigPage.tsx @@ -80,11 +80,12 @@ function CleanupActions() { /* ── 分组元信息 ── */ const GROUP_META: Record = { - query: { label: '查询控制', icon: '🔍', desc: 'LIMIT 上限、超时、自动 LIMIT' }, - security: { label: '安全策略', icon: '🔒', desc: '登录安全、密码策略' }, + query: { label: '查询控制', icon: '🔍', desc: 'LIMIT 上限、超时、自动 LIMIT、危险检测' }, + security: { label: '安全策略', icon: '🔒', desc: '登录锁定、密码策略、会话超时' }, data_masking: { label: '数据脱敏', icon: '🛡️', desc: '控制查询结果中的敏感字段脱敏' }, - cleanup: { label: '日志清理', icon: '🧹', desc: '工单、审计日志、查询历史保留天数' }, - executor: { label: '执行器', icon: '⚙️', desc: 'SQL 执行超时' }, + cleanup: { label: '日志清理', icon: '🧹', desc: '工单、审计日志、查询历史、慢日志保留天数' }, + executor: { label: '执行器', icon: '⚙️', desc: 'SQL 执行超时、预检查、备份' }, + ghost: { label: 'Ghost/OSC', icon: '👻', desc: 'gh-ost 在线 DDL 变更工具配置' }, }; export default function PlatformConfigPage() {