diff --git a/backend/go.mod b/backend/go.mod index be9f4f4..70aa3e2 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -38,6 +38,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.17.6 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kr/text v0.2.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect @@ -48,6 +49,11 @@ require ( github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.12 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/arch v0.15.0 // indirect golang.org/x/net v0.50.0 // indirect diff --git a/backend/internal/handler/instance_detail.go b/backend/internal/handler/instance_detail.go index 24021de..0636f05 100644 --- a/backend/internal/handler/instance_detail.go +++ b/backend/internal/handler/instance_detail.go @@ -16,6 +16,11 @@ import ( "strconv" "encoding/json" "bytes" + "regexp" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" + "go.mongodb.org/mongo-driver/v2/mongo/readpref" ) // GET /api/v1/instances/:id/detail/databases @@ -549,13 +554,30 @@ func ExecuteQuery(c *gin.Context) { c.JSON(http.StatusOK, result) case "redis": - result, err := executeRedisQuery(&instance, input.Database, input.SQL) + result, err := executeRedisQuery(&instance, input.Database, input.SQL, input.Limit, input.Offset) costMs := time.Since(start).Milliseconds() if err != nil { c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()}) return } - saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, 1) + result["cost_ms"] = costMs + rowCount := 1 + if rowsArr, ok := result["rows"].([]interface{}); ok { + rowCount = len(rowsArr) + } + saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, rowCount) + 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 { + c.JSON(http.StatusOK, gin.H{"columns": []string{}, "rows": []interface{}{}, "total": 0, "cost_ms": costMs, "error": err.Error()}) + return + } + result["cost_ms"] = costMs + rowsInterface, _ := result["rows"].([]interface{}) + saveQueryHistory(userID, input.InstanceID, input.Database, input.SQL, costMs, len(rowsInterface)) c.JSON(http.StatusOK, result) default: @@ -639,7 +661,7 @@ func executeSQLQuery(instance *model.Instance, dbName string, sqlStr string, lim }, nil } -func executeRedisQuery(instance *model.Instance, dbSelect string, command string) (gin.H, error) { +func executeRedisQuery(instance *model.Instance, dbSelect string, command string, limit int, offset int) (gin.H, error) { password := instance.Password if strings.HasPrefix(password, "enc:") { decrypted, err := decryptPassword(strings.TrimPrefix(password, "enc:")) @@ -695,6 +717,109 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string args = append(args, p) } + // For KEYS command, use SCAN with COUNT instead for safety + pagination + if cmd == "KEYS" && strings.Contains(parts[len(parts)-1], "*") { + // Replace KEYS * with SCAN-based pagination + pattern := "*" + if len(parts) > 1 { + pattern = parts[1] + } + var allKeys []string + cursor := uint64(0) + maxScan := offset + limit + for { + keys, nextCursor, err := client.Scan(ctx, cursor, pattern, int64(maxScan-len(allKeys))).Result() + if err != nil { + return nil, fmt.Errorf("SCAN failed: %v", err) + } + allKeys = append(allKeys, keys...) + cursor = nextCursor + if cursor == 0 || len(allKeys) >= maxScan { + break + } + } + total := len(allKeys) + end := offset + limit + if end > len(allKeys) { + end = len(allKeys) + } + if offset > len(allKeys) { + offset = len(allKeys) + } + pagedKeys := allKeys[offset:end] + rows := make([]interface{}, len(pagedKeys)) + for i, k := range pagedKeys { + rows[i] = map[string]interface{}{"index": offset + i, "key": k} + } + return gin.H{ + "columns": []string{"index", "key"}, + "rows": rows, + "total": total, + "count": len(rows), + }, nil + } + + // For LRANGE, inject pagination if user didn't specify start/stop + if cmd == "LRANGE" && len(parts) < 4 { + key := "" + if len(parts) > 1 { + key = parts[1] + } + result, err := client.LRange(ctx, key, int64(offset), int64(offset+limit-1)).Result() + if err != nil { + return nil, fmt.Errorf("command failed: %v", err) + } + // Get total via LLEN + total, _ := client.LLen(ctx, key).Result() + rows := make([]interface{}, len(result)) + for i, val := range result { + rows[i] = map[string]interface{}{"index": offset + i, "value": val} + } + return gin.H{ + "columns": []string{"index", "value"}, + "rows": rows, + "total": int(total), + "count": len(rows), + }, nil + } + + // For SMEMBERS with limit, use SSCAN + if cmd == "SMEMBERS" && limit > 0 { + key := "" + if len(parts) > 1 { + key = parts[1] + } + total, _ := client.SCard(ctx, key).Result() + var members []string + cursor := uint64(0) + for len(members) < offset+limit { + batch, nextCursor, err := client.SScan(ctx, key, cursor, "", int64(offset+limit-len(members))).Result() + if err != nil { + break + } + members = append(members, batch...) + cursor = nextCursor + if cursor == 0 { + break + } + } + end := offset + limit + if end > len(members) { + end = len(members) + } + paged := members[offset:end] + rows := make([]interface{}, len(paged)) + for i, m := range paged { + rows[i] = map[string]interface{}{"index": offset + i, "member": m} + } + return gin.H{ + "columns": []string{"index", "member"}, + "rows": rows, + "total": int(total), + "count": len(rows), + }, nil + } + result, err := client.Do(ctx, args...).Result() if err != nil { return nil, fmt.Errorf("command failed: %v", err) @@ -780,9 +905,208 @@ func executeRedisQuery(instance *model.Instance, dbSelect string, command string "columns": columns, "rows": rows, "total": len(rows), + "count": len(rows), }, nil } +// executeMongoQuery — MongoDB query with pagination +// Supports: db.collection.find({...}), db.collection.aggregate([...]) +// Input format: db.collection.find({query}) or JSON array of pipeline stages +func executeMongoQuery(instance *model.Instance, dbName string, queryStr string, limit int, offset int) (gin.H, error) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Build connection URI + uri := fmt.Sprintf("mongodb://%s:%d", instance.Host, instance.Port) + if instance.Password != "" { + pwd := instance.Password + if strings.HasPrefix(pwd, "enc:") { + if dec, err := decryptPassword(strings.TrimPrefix(pwd, "enc:")); err == nil { + pwd = dec + } + } + user := instance.Username + if user == "" { + user = "root" + } + uri = fmt.Sprintf("mongodb://%s:%s@%s:%d", user, pwd, instance.Host, instance.Port) + } + + client, err := mongo.Connect(options.Client().ApplyURI(uri)) + if err != nil { + return nil, fmt.Errorf("MongoDB connection failed: %v", err) + } + defer client.Disconnect(ctx) + + if err := client.Ping(ctx, readpref.Primary()); err != nil { + return nil, fmt.Errorf("MongoDB ping failed: %v", err) + } + + // Parse query: "db.collection.find({...})" or raw JSON + collectionName, filter, pipeline, err := parseMongoQuery(queryStr) + if err != nil { + return nil, err + } + + if dbName == "" { + dbName = "test" + } + coll := client.Database(dbName).Collection(collectionName) + + var total int64 + var docs []bson.M + + if pipeline != nil { + // Aggregation — add $facet for count + data + facetStage := bson.D{ + {"$facet", bson.D{ + {"data", bson.A{ + bson.D{{"$skip", offset}}, + bson.D{{"$limit", limit}}, + }}, + {"count", bson.A{ + bson.D{{"$count", "total"}}, + }}, + }}, + } + fullPipeline := append(pipeline, facetStage) + var results []bson.M + cursor, err := coll.Aggregate(ctx, fullPipeline) + if err != nil { + return nil, fmt.Errorf("aggregation failed: %v", err) + } + defer cursor.Close(ctx) + cursor.All(ctx, &results) + + if len(results) > 0 { + if dataArr, ok := results[0]["data"].(bson.A); ok { + for _, d := range dataArr { + if m, ok := d.(bson.M); ok { + docs = append(docs, m) + } + } + } + if countArr, ok := results[0]["count"].(bson.A); ok && len(countArr) > 0 { + if m, ok := countArr[0].(bson.M); ok { + if t, ok := m["total"].(int32); ok { + total = int64(t) + } else if t, ok := m["total"].(int64); ok { + total = t + } + } + } + } + } else { + // Find + total, _ = coll.CountDocuments(ctx, filter) + opts := options.Find().SetSkip(int64(offset)).SetLimit(int64(limit)) + cursor, err := coll.Find(ctx, filter, opts) + if err != nil { + return nil, fmt.Errorf("find failed: %v", err) + } + defer cursor.Close(ctx) + cursor.All(ctx, &docs) + } + + // Convert bson.M to flat maps for table display + columnSet := make(map[string]bool) + rowMaps := make([]interface{}, 0, len(docs)) + for _, doc := range docs { + flat := flattenBSON(doc, "") + for k := range flat { + columnSet[k] = true + } + rowMaps = append(rowMaps, flat) + } + + // Stable column order + columns := make([]string, 0, len(columnSet)) + if _, hasID := columnSet["_id"]; hasID { + columns = append(columns, "_id") + } + for k := range columnSet { + if k != "_id" { + columns = append(columns, k) + } + } + + return gin.H{ + "columns": columns, + "rows": rowMaps, + "total": int(total), + "count": len(rowMaps), + }, nil +} + +// parseMongoQuery parses "db.collection.find({...})" or "db.collection.aggregate([...])" or raw JSON +func parseMongoQuery(queryStr string) (collection string, filter bson.M, pipeline bson.A, err error) { + queryStr = strings.TrimSpace(queryStr) + + // Pattern: db.collection.find({...}) + if re := regexpFromPattern(`db\.(\w+)\.find\((.+)?\)`); re != nil { + matches := re.FindStringSubmatch(queryStr) + if len(matches) >= 2 { + collection = matches[1] + if len(matches) >= 3 && strings.TrimSpace(matches[2]) != "" { + if e := json.Unmarshal([]byte(matches[2]), &filter); e != nil { + filter = bson.M{} + } + } else { + filter = bson.M{} + } + return + } + } + + // Pattern: db.collection.aggregate([...]) + if re := regexpFromPattern(`db\.(\w+)\.aggregate\((.+)\)`); re != nil { + matches := re.FindStringSubmatch(queryStr) + if len(matches) >= 3 { + collection = matches[1] + if e := json.Unmarshal([]byte(matches[2]), &pipeline); e != nil { + err = fmt.Errorf("invalid aggregation pipeline: %v", e) + return + } + return + } + } + + // Fallback: raw JSON object treated as find on all collections? No — require db.xxx format + err = fmt.Errorf("query format not supported. Use: db.collection.find({}) or db.collection.aggregate([...])") + return +} + +func regexpFromPattern(pattern string) *regexp.Regexp { + // Import regexp lazily — actually let's just import at top + // This is a simple helper + return regexp.MustCompile(pattern) +} + +func flattenBSON(doc bson.M, prefix string) map[string]interface{} { + result := make(map[string]interface{}) + for k, v := range doc { + fullKey := k + if prefix != "" { + fullKey = prefix + "." + k + } + switch child := v.(type) { + case bson.M: + for ck, cv := range flattenBSON(child, fullKey) { + result[ck] = cv + } + case bson.A: + // Serialize arrays as JSON string + b, _ := json.Marshal(child) + result[fullKey] = string(b) + default: + // Convert ObjectID to string + s := fmt.Sprintf("%v", v) + result[fullKey] = s + } + } + return result +} + // POST /api/v1/query/export — Streaming CSV export for large datasets func ExportQueryCSV(c *gin.Context) { var input struct { @@ -800,7 +1124,7 @@ func ExportQueryCSV(c *gin.Context) { return } if input.Limit <= 0 { - input.Limit = 10000 // Export max 10k rows + input.Limit = 10000 } if input.Limit > 50000 { input.Limit = 50000 @@ -812,48 +1136,47 @@ func ExportQueryCSV(c *gin.Context) { return } - if instance.DBType != "mysql" && instance.DBType != "postgresql" && instance.DBType != "postgres" && instance.DBType != "pg" { - c.JSON(http.StatusBadRequest, gin.H{"detail": "CSV export only supports MySQL/PostgreSQL"}) - return - } + // Stream CSV response headers + filename := fmt.Sprintf("query_export_%s.csv", time.Now().Format("20060102_150405")) + c.Header("Content-Type", "text/csv; charset=utf-8") + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + c.Writer.Write([]byte("\xEF\xBB\xBF")) // BOM for Excel - db, err := getDBConnection(&instance, input.Database) + switch instance.DBType { + case "mysql", "postgresql", "postgres", "pg": + exportSQLCSV(c, &instance, input.Database, input.SQL, input.Limit) + case "redis": + exportRedisCSV(c, &instance, input.Database, input.SQL, input.Limit) + case "mongodb", "mongo": + exportMongoCSV(c, &instance, input.Database, input.SQL, input.Limit) + default: + c.JSON(http.StatusBadRequest, gin.H{"detail": "CSV export not supported for " + instance.DBType}) + } +} + +func exportSQLCSV(c *gin.Context, instance *model.Instance, dbName string, sqlStr string, limit int) { + db, err := getDBConnection(instance, dbName) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "Connection failed: " + err.Error()}) + c.Writer.Write([]byte("Error: connection failed\n")) return } defer db.Close() - // Inject LIMIT if not present - upperSQL := strings.ToUpper(strings.TrimSpace(input.SQL)) + upperSQL := strings.ToUpper(strings.TrimSpace(sqlStr)) if !strings.Contains(upperSQL, " LIMIT ") { - input.SQL = fmt.Sprintf("%s LIMIT %d", input.SQL, input.Limit) + sqlStr = fmt.Sprintf("%s LIMIT %d", sqlStr, limit) } - rows, err := db.Query(input.SQL) + rows, err := db.Query(sqlStr) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"detail": "Query failed: " + err.Error()}) + c.Writer.Write([]byte("Error: query failed\n")) return } defer rows.Close() cols, _ := rows.Columns() - colNames := make([]string, len(cols)) - for i, col := range cols { - colNames[i] = col - } + c.Writer.Write([]byte(strings.Join(cols, ",") + "\n")) - // Stream CSV response - filename := fmt.Sprintf("query_export_%s.csv", time.Now().Format("20060102_150405")) - c.Header("Content-Type", "text/csv; charset=utf-8") - c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) - // BOM for Excel UTF-8 - c.Writer.Write([]byte("\xEF\xBB\xBF")) - - // Header row - c.Writer.Write([]byte(strings.Join(colNames, ",") + "\n")) - - // Data rows — write in chunks to avoid OOM batchSize := 500 batch := make([]string, 0, batchSize) rowCount := 0 @@ -874,7 +1197,6 @@ func ExportQueryCSV(c *gin.Context) { } else if val != nil { s = fmt.Sprintf("%v", val) } - // CSV escape: wrap in quotes if contains comma, quote, or newline if strings.ContainsAny(s, ",\"\n\r") { s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"` } @@ -889,13 +1211,71 @@ func ExportQueryCSV(c *gin.Context) { batch = batch[:0] } } - // Flush remaining if len(batch) > 0 { c.Writer.Write([]byte(strings.Join(batch, "\n") + "\n")) } c.Writer.(http.Flusher).Flush() + saveQueryHistory(0, int(instance.ID), dbName, sqlStr, 0, rowCount) +} - saveQueryHistory(0, input.InstanceID, input.Database, input.SQL, 0, rowCount) +func exportRedisCSV(c *gin.Context, instance *model.Instance, dbSelect string, command string, limit int) { + result, err := executeRedisQuery(instance, dbSelect, command, limit, 0) + if err != nil { + c.Writer.Write([]byte("Error: " + err.Error() + "\n")) + return + } + + columns, _ := result["columns"].([]string) + rows, _ := result["rows"].([]interface{}) + + c.Writer.Write([]byte(strings.Join(columns, ",") + "\n")) + + for _, row := range rows { + if m, ok := row.(map[string]interface{}); ok { + cells := make([]string, len(columns)) + for i, col := range columns { + s := fmt.Sprintf("%v", m[col]) + if strings.ContainsAny(s, ",\"\n\r") { + s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"` + } + cells[i] = s + } + c.Writer.Write([]byte(strings.Join(cells, ",") + "\n")) + } + } + c.Writer.(http.Flusher).Flush() + rowCount := len(rows) + saveQueryHistory(0, int(instance.ID), dbSelect, command, 0, rowCount) +} + +func exportMongoCSV(c *gin.Context, instance *model.Instance, dbName string, queryStr string, limit int) { + result, err := executeMongoQuery(instance, dbName, queryStr, limit, 0) + if err != nil { + c.Writer.Write([]byte("Error: " + err.Error() + "\n")) + return + } + + columns, _ := result["columns"].([]string) + rows, _ := result["rows"].([]interface{}) + + c.Writer.Write([]byte(strings.Join(columns, ",") + "\n")) + + for _, row := range rows { + if m, ok := row.(map[string]interface{}); ok { + cells := make([]string, len(columns)) + for i, col := range columns { + s := fmt.Sprintf("%v", m[col]) + if strings.ContainsAny(s, ",\"\n\r") { + s = `"` + strings.ReplaceAll(s, `"`, `""`) + `"` + } + cells[i] = s + } + c.Writer.Write([]byte(strings.Join(cells, ",") + "\n")) + } + } + c.Writer.(http.Flusher).Flush() + rowCount := len(rows) + saveQueryHistory(0, int(instance.ID), dbName, queryStr, 0, rowCount) } func saveQueryHistory(userID int, instanceID int, database string, sqlStr string, costMs int64, rowCount int) { diff --git a/frontend/src/pages/QueriesPage.tsx b/frontend/src/pages/QueriesPage.tsx index 656f7db..a68f288 100644 --- a/frontend/src/pages/QueriesPage.tsx +++ b/frontend/src/pages/QueriesPage.tsx @@ -494,7 +494,7 @@ export default function QueriesPage() { {result.columns?.length || 0} 列 )} - {result.rows?.length > 0 && dbType !== 'redis' && ( + {result.rows?.length > 0 && (