diff --git a/CHANGELOG.md b/CHANGELOG.md
index a034818a..49c0cb14 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,24 @@
+## v3.0.1
+_Released on 2026-03-11_
+
+### New Features
+1. Implemented automatic Content-Type header inference using a new `metadata` field, and renamed request `body` to `payload` across the replay feature.
+2. Implemented a dedicated replay send options component for execution source selection and configurable localhost host replacement.
+
+### Docker Images
+Use one of these commands to pull the Docker image:
+
+```bash
+# Pull specific version
+docker pull ghcr.io/yogasw/beo-echo:3.0.1
+
+# Pull latest version
+docker pull ghcr.io/yogasw/beo-echo:latest
+```
+
+---
+*This summary was automatically generated by Gemini AI*
+
## v3.0.0
_Released on 2026-03-10_
diff --git a/VERSION b/VERSION
index 13d683cc..d9c62ed9 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-3.0.1
\ No newline at end of file
+3.0.2
\ No newline at end of file
diff --git a/backend/src/database/models.go b/backend/src/database/models.go
index ba45857e..d0d126f7 100644
--- a/backend/src/database/models.go
+++ b/backend/src/database/models.go
@@ -349,6 +349,14 @@ type Replay struct {
Headers string `gorm:"type:text" json:"headers"` // Headers as JSON string (key-value pairs)
Payload string `gorm:"type:text" json:"payload"` // Request payload/body
+ // History & Response Details
+ ParentID *string `gorm:"type:string;index" json:"parent_id"` // Optional parent replay ID (for saved responses/checkpoints)
+ IsResponse bool `gorm:"default:false" json:"is_response"` // Whether this Replay is a response
+ ResponseStatus int `json:"response_status"` // HTTP status code, or equivalent (0 if NA)
+ ResponseMeta string `gorm:"type:text" json:"response_meta"` // JSON string for protocol specific response meta (headers, trailers, etc)
+ ResponseBody string `gorm:"type:text" json:"response_body"` // Raw string/JSON. NOTE: Loaded on-demand in UI
+ LatencyMS int `json:"latency_ms"` // Execution time
+
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` // Timestamp of creation
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` // Timestamp of last update
}
diff --git a/backend/src/database/repositories/replay_repo.go b/backend/src/database/repositories/replay_repo.go
index e7547bad..b680f332 100644
--- a/backend/src/database/repositories/replay_repo.go
+++ b/backend/src/database/repositories/replay_repo.go
@@ -19,36 +19,69 @@ func NewReplayRepository(db *gorm.DB) *replayRepository {
return &replayRepository{db: db}
}
-// FindByProjectID finds all replays for a specific project
-func (r *replayRepository) FindByProjectID(ctx context.Context, projectID string) ([]database.Replay, error) {
- var replays []database.Replay
+// ReplayListRow is a minimal projection used by FindByProjectID.
+// GORM maps field names to column names automatically — add fields here when you
+// need more data in the list view without touching the query string.
+type ReplayListRow struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ ProjectID string `json:"project_id"`
+ FolderID *string `json:"folder_id"`
+ ParentID *string `json:"parent_id"`
+ IsResponse bool `json:"is_response"`
+ Method string `json:"method"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
+}
+
+// FindByProjectID finds all replays for a specific project.
+// Returns only the fields defined in ReplayListRow — no payload, headers, body, etc.
+// Full data is loaded on demand via FindByID.
+func (r *replayRepository) FindByProjectID(ctx context.Context, projectID string) ([]ReplayListRow, error) {
+ var rows []ReplayListRow
err := r.db.WithContext(ctx).
+ Model(&database.Replay{}).
Where("project_id = ?", projectID).
Order("created_at DESC").
- Find(&replays).Error
+ Scan(&rows).Error
if err != nil {
return nil, err
}
- return replays, nil
+ return rows, nil
+}
+
+// ReplayFolderListRow is a minimal projection used by FindFoldersByProjectID.
+// Add fields here when you need more folder data in the list view — GORM auto-selects
+// matching columns without changing the query string.
+type ReplayFolderListRow struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ ProjectID string `json:"project_id"`
+ ParentID *string `json:"parent_id"`
+ CreatedAt string `json:"created_at"`
+ UpdatedAt string `json:"updated_at"`
}
-// FindFoldersByProjectID finds all replay folders for a specific project
-func (r *replayRepository) FindFoldersByProjectID(ctx context.Context, projectID string) ([]database.ReplayFolder, error) {
- var folders []database.ReplayFolder
+// FindFoldersByProjectID finds all replay folders for a specific project.
+// Returns only the fields defined in ReplayFolderListRow — doc and nested relations are excluded.
+// Full folder data (including doc) is loaded on demand via a dedicated get endpoint.
+func (r *replayRepository) FindFoldersByProjectID(ctx context.Context, projectID string) ([]ReplayFolderListRow, error) {
+ var rows []ReplayFolderListRow
err := r.db.WithContext(ctx).
+ Model(&database.ReplayFolder{}).
Where("project_id = ?", projectID).
Order("name ASC").
- Find(&folders).Error
+ Scan(&rows).Error
if err != nil {
return nil, err
}
- return folders, nil
+ return rows, nil
}
// FindByID finds a replay by its ID
@@ -69,6 +102,22 @@ func (r *replayRepository) FindByID(ctx context.Context, id string) (*database.R
return &replay, nil
}
+// FindFolderByID finds a single replay folder by ID scoped to a project.
+// Returns the full database.ReplayFolder model (for updates, not list views).
+func (r *replayRepository) FindFolderByID(ctx context.Context, projectID string, folderID string) (*database.ReplayFolder, error) {
+ var folder database.ReplayFolder
+
+ err := r.db.WithContext(ctx).
+ Where("id = ? AND project_id = ?", folderID, projectID).
+ First(&folder).Error
+
+ if err != nil {
+ return nil, err
+ }
+
+ return &folder, nil
+}
+
// Create creates a new replay
func (r *replayRepository) Create(ctx context.Context, replay *database.Replay) error {
return r.db.WithContext(ctx).Create(replay).Error
@@ -135,21 +184,26 @@ func (r *replayRepository) Update(ctx context.Context, replay *database.Replay)
return r.db.WithContext(ctx).Save(replay).Error
}
-// Delete deletes a replay by ID
+// Delete deletes a replay by ID, and any children (histories)
func (r *replayRepository) Delete(ctx context.Context, id string) error {
- result := r.db.WithContext(ctx).
- Where("id = ?", id).
- Delete(&database.Replay{})
+ return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ // Delete any children checking this replay as a parent first
+ if err := tx.Where("parent_id = ?", id).Delete(&database.Replay{}).Error; err != nil {
+ return err
+ }
- if result.Error != nil {
- return result.Error
- }
+ result := tx.Where("id = ?", id).Delete(&database.Replay{})
- if result.RowsAffected == 0 {
- return errors.New("replay not found")
- }
+ if result.Error != nil {
+ return result.Error
+ }
- return nil
+ if result.RowsAffected == 0 {
+ return errors.New("replay not found")
+ }
+
+ return nil
+ })
}
// CreateRequestLog creates a new request log entry
diff --git a/backend/src/replay/handlers/create_replay.go b/backend/src/replay/handlers/create_replay.go
index cc63ebc9..e2335206 100644
--- a/backend/src/replay/handlers/create_replay.go
+++ b/backend/src/replay/handlers/create_replay.go
@@ -30,13 +30,6 @@ func (s *replayHandler) CreateReplayHandler(c *gin.Context) {
return
}
- log.Info().
- Str("project_id", projectID).
- Str("name", req.Name).
- Str("protocol", req.Protocol).
- Str("method", req.Method).
- Msg("handling create replay request")
-
replay, err := s.service.CreateReplay(c.Request.Context(), projectID, req)
if err != nil {
log.Error().
@@ -48,12 +41,6 @@ func (s *replayHandler) CreateReplayHandler(c *gin.Context) {
return
}
- log.Info().
- Str("replay_id", replay.ID).
- Str("project_id", projectID).
- Str("name", req.Name).
- Msg("successfully created replay")
-
c.JSON(http.StatusCreated, gin.H{
"replay": replay,
"message": "Replay created successfully",
diff --git a/backend/src/replay/handlers/delete_replay.go b/backend/src/replay/handlers/delete_replay.go
index da446f67..a78c05ee 100644
--- a/backend/src/replay/handlers/delete_replay.go
+++ b/backend/src/replay/handlers/delete_replay.go
@@ -22,11 +22,6 @@ func (s *replayHandler) DeleteReplayHandler(c *gin.Context) {
return
}
- log.Info().
- Str("project_id", projectID).
- Str("replay_id", replayID).
- Msg("handling delete replay request")
-
// First verify the replay belongs to the project
replay, err := s.service.GetReplay(c.Request.Context(), replayID)
if err != nil {
@@ -60,11 +55,6 @@ func (s *replayHandler) DeleteReplayHandler(c *gin.Context) {
return
}
- log.Info().
- Str("project_id", projectID).
- Str("replay_id", replayID).
- Msg("successfully deleted replay")
-
c.JSON(http.StatusOK, gin.H{
"message": "Replay deleted successfully",
})
diff --git a/backend/src/replay/handlers/get_folder.go b/backend/src/replay/handlers/get_folder.go
new file mode 100644
index 00000000..2b55b515
--- /dev/null
+++ b/backend/src/replay/handlers/get_folder.go
@@ -0,0 +1,29 @@
+package handlers
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/rs/zerolog"
+)
+
+// GetFolderHandler handles GET /projects/{projectId}/replays/folder/{folderId}
+func (s *replayHandler) GetFolderHandler(c *gin.Context) {
+ log := zerolog.Ctx(c.Request.Context())
+ projectID := c.Param("projectId")
+ folderID := c.Param("folderId")
+
+ if projectID == "" || folderID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "project ID and folder ID are required"})
+ return
+ }
+
+ folder, err := s.service.GetFolder(c.Request.Context(), projectID, folderID)
+ if err != nil {
+ log.Error().Err(err).Msg("failed to get folder")
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"folder": folder})
+}
diff --git a/backend/src/replay/handlers/get_replay.go b/backend/src/replay/handlers/get_replay.go
index b6c38e6b..f19a9cf8 100644
--- a/backend/src/replay/handlers/get_replay.go
+++ b/backend/src/replay/handlers/get_replay.go
@@ -22,11 +22,6 @@ func (s *replayHandler) GetReplayHandler(c *gin.Context) {
return
}
- log.Info().
- Str("project_id", projectID).
- Str("replay_id", replayID).
- Msg("handling get replay request")
-
replay, err := s.service.GetReplay(c.Request.Context(), replayID)
if err != nil {
log.Error().
@@ -49,12 +44,6 @@ func (s *replayHandler) GetReplayHandler(c *gin.Context) {
return
}
- log.Info().
- Str("project_id", projectID).
- Str("replay_id", replayID).
- Str("name", replay.Name).
- Msg("successfully retrieved replay")
-
c.JSON(http.StatusOK, gin.H{
"replay": replay,
})
diff --git a/backend/src/replay/handlers/get_replay_logs.go b/backend/src/replay/handlers/get_replay_logs.go
index 233a5b1b..f9c51ed1 100644
--- a/backend/src/replay/handlers/get_replay_logs.go
+++ b/backend/src/replay/handlers/get_replay_logs.go
@@ -25,16 +25,6 @@ func (s *replayHandler) GetReplayLogsHandler(c *gin.Context) {
replayID = &replayIDParam
}
- log.Info().
- Str("project_id", projectID).
- Str("replay_id", func() string {
- if replayID != nil {
- return *replayID
- }
- return "all"
- }()).
- Msg("handling get replay logs request")
-
logs, err := s.service.GetReplayLogs(c.Request.Context(), projectID, replayID)
if err != nil {
log.Error().
@@ -45,11 +35,6 @@ func (s *replayHandler) GetReplayLogsHandler(c *gin.Context) {
return
}
- log.Info().
- Str("project_id", projectID).
- Int("count", len(logs)).
- Msg("successfully retrieved replay logs")
-
c.JSON(http.StatusOK, gin.H{
"logs": logs,
"count": len(logs),
diff --git a/backend/src/replay/handlers/list_replays.go b/backend/src/replay/handlers/list_replays.go
index 55ed046f..7dd6bd4b 100644
--- a/backend/src/replay/handlers/list_replays.go
+++ b/backend/src/replay/handlers/list_replays.go
@@ -18,10 +18,6 @@ func (s *replayHandler) ListReplaysHandler(c *gin.Context) {
return
}
- log.Info().
- Str("project_id", projectID).
- Msg("handling list replays request")
-
result, err := s.service.ListReplays(c.Request.Context(), projectID)
if err != nil {
log.Error().
diff --git a/backend/src/replay/handlers/update_replay.go b/backend/src/replay/handlers/update_replay.go
index b8cfaf98..c4293c3b 100644
--- a/backend/src/replay/handlers/update_replay.go
+++ b/backend/src/replay/handlers/update_replay.go
@@ -35,11 +35,6 @@ func (s *replayHandler) UpdateReplayHandler(c *gin.Context) {
return
}
- log.Info().
- Str("project_id", projectID).
- Str("replay_id", replayID).
- Msg("handling update replay request")
-
// First verify the replay belongs to the project
existingReplay, err := s.service.GetReplay(c.Request.Context(), replayID)
if err != nil {
@@ -73,12 +68,6 @@ func (s *replayHandler) UpdateReplayHandler(c *gin.Context) {
return
}
- log.Info().
- Str("project_id", projectID).
- Str("replay_id", replayID).
- Str("name", replay.Name).
- Msg("successfully updated replay")
-
c.JSON(http.StatusOK, gin.H{
"replay": replay,
"message": "Replay updated successfully",
diff --git a/backend/src/replay/services/create_replay.go b/backend/src/replay/services/create_replay.go
index 2c433e8f..18c1c1ee 100644
--- a/backend/src/replay/services/create_replay.go
+++ b/backend/src/replay/services/create_replay.go
@@ -69,6 +69,8 @@ func (s *ReplayService) CreateReplay(ctx context.Context, projectID string, req
Name: name,
ProjectID: projectID,
FolderID: req.FolderID,
+ ParentID: req.ParentID,
+ IsResponse: req.IsResponse,
Protocol: database.ReplayProtocol(strings.ToLower(req.Protocol)),
Method: strings.ToUpper(req.Method),
Url: req.Url,
@@ -78,6 +80,19 @@ func (s *ReplayService) CreateReplay(ctx context.Context, projectID string, req
Config: string(configJSON),
}
+ if req.ResponseStatus != nil {
+ replay.ResponseStatus = *req.ResponseStatus
+ }
+ if req.ResponseMeta != nil {
+ replay.ResponseMeta = *req.ResponseMeta
+ }
+ if req.ResponseBody != nil {
+ replay.ResponseBody = *req.ResponseBody
+ }
+ if req.LatencyMS != nil {
+ replay.LatencyMS = *req.LatencyMS
+ }
+
err = s.repo.Create(ctx, replay)
if err != nil {
log.Error().
diff --git a/backend/src/replay/services/delete_replay.go b/backend/src/replay/services/delete_replay.go
index 5fbd42cf..3fd47050 100644
--- a/backend/src/replay/services/delete_replay.go
+++ b/backend/src/replay/services/delete_replay.go
@@ -11,10 +11,6 @@ import (
func (s *ReplayService) DeleteReplay(ctx context.Context, replayID string) error {
log := zerolog.Ctx(ctx)
- log.Info().
- Str("replay_id", replayID).
- Msg("deleting replay")
-
// Verify replay exists
_, err := s.repo.FindByID(ctx, replayID)
if err != nil {
@@ -34,9 +30,5 @@ func (s *ReplayService) DeleteReplay(ctx context.Context, replayID string) error
return fmt.Errorf("failed to delete replay: %w", err)
}
- log.Info().
- Str("replay_id", replayID).
- Msg("successfully deleted replay")
-
return nil
}
diff --git a/backend/src/replay/services/execute_replay.go b/backend/src/replay/services/execute_replay.go
index 24ab07fa..658b155e 100644
--- a/backend/src/replay/services/execute_replay.go
+++ b/backend/src/replay/services/execute_replay.go
@@ -16,13 +16,6 @@ import (
func (s *ReplayService) ExecuteReplay(ctx context.Context, projectID string, req models.ExecuteReplayRequest) (*models.ExecuteReplayResponse, error) {
log := zerolog.Ctx(ctx)
- log.Info().
- Str("project_id", projectID).
- Str("protocol", req.Protocol).
- Str("method", req.Method).
- Str("url", req.URL).
- Msg("executing replay request")
-
// Validate project exists
_, err := s.repo.FindProjectByID(ctx, projectID)
if err != nil {
@@ -48,14 +41,5 @@ func (s *ReplayService) ExecuteReplay(ctx context.Context, projectID string, req
return nil, err
}
- if resp.Error == "" {
- log.Info().
- Str("replay_id", resp.ReplayID).
- Str("project_id", projectID).
- Int("status_code", resp.StatusCode).
- Int("latency_ms", resp.LatencyMS).
- Msg("successfully executed replay request")
- }
-
return resp, nil
}
diff --git a/backend/src/replay/services/get_folder.go b/backend/src/replay/services/get_folder.go
new file mode 100644
index 00000000..f0ecf407
--- /dev/null
+++ b/backend/src/replay/services/get_folder.go
@@ -0,0 +1,16 @@
+package services
+
+import (
+ "beo-echo/backend/src/database"
+ "context"
+ "fmt"
+)
+
+// GetFolder retrieves a single ReplayFolder by ID scoped to the project
+func (s *ReplayService) GetFolder(ctx context.Context, projectID string, folderID string) (*database.ReplayFolder, error) {
+ folder, err := s.repo.FindFolderByID(ctx, projectID, folderID)
+ if err != nil {
+ return nil, fmt.Errorf("folder not found: %w", err)
+ }
+ return folder, nil
+}
diff --git a/backend/src/replay/services/get_replay.go b/backend/src/replay/services/get_replay.go
index 15d2a7be..50577151 100644
--- a/backend/src/replay/services/get_replay.go
+++ b/backend/src/replay/services/get_replay.go
@@ -13,10 +13,6 @@ import (
func (s *ReplayService) GetReplay(ctx context.Context, replayID string) (*database.Replay, error) {
log := zerolog.Ctx(ctx)
- log.Info().
- Str("replay_id", replayID).
- Msg("getting replay details")
-
replay, err := s.repo.FindByID(ctx, replayID)
if err != nil {
log.Error().
@@ -26,10 +22,5 @@ func (s *ReplayService) GetReplay(ctx context.Context, replayID string) (*databa
return nil, fmt.Errorf("replay not found: %w", err)
}
- log.Info().
- Str("replay_id", replayID).
- Str("name", replay.Name).
- Msg("successfully retrieved replay")
-
return replay, nil
}
diff --git a/backend/src/replay/services/get_replay_logs.go b/backend/src/replay/services/get_replay_logs.go
index daa7b201..c02dfd65 100644
--- a/backend/src/replay/services/get_replay_logs.go
+++ b/backend/src/replay/services/get_replay_logs.go
@@ -13,16 +13,6 @@ import (
func (s *ReplayService) GetReplayLogs(ctx context.Context, projectID string, replayID *string) ([]database.RequestLog, error) {
log := zerolog.Ctx(ctx)
- log.Info().
- Str("project_id", projectID).
- Str("replay_id", func() string {
- if replayID != nil {
- return *replayID
- }
- return "all"
- }()).
- Msg("getting replay execution logs")
-
// Validate project exists
_, err := s.repo.FindProjectByID(ctx, projectID)
if err != nil {
@@ -42,10 +32,5 @@ func (s *ReplayService) GetReplayLogs(ctx context.Context, projectID string, rep
return nil, fmt.Errorf("failed to get replay logs: %w", err)
}
- log.Info().
- Str("project_id", projectID).
- Int("count", len(logs)).
- Msg("successfully retrieved replay logs")
-
return logs, nil
}
diff --git a/backend/src/replay/services/list_replays.go b/backend/src/replay/services/list_replays.go
index 914ca7f8..bc159055 100644
--- a/backend/src/replay/services/list_replays.go
+++ b/backend/src/replay/services/list_replays.go
@@ -11,10 +11,6 @@ import (
func (s *ReplayService) ListReplays(ctx context.Context, projectID string) (*ListReplaysResponse, error) {
log := zerolog.Ctx(ctx)
- log.Info().
- Str("project_id", projectID).
- Msg("listing replays for project")
-
// Validate project exists
_, err := s.repo.FindProjectByID(ctx, projectID)
if err != nil {
diff --git a/backend/src/replay/services/service.go b/backend/src/replay/services/service.go
index 429910dd..0baac654 100644
--- a/backend/src/replay/services/service.go
+++ b/backend/src/replay/services/service.go
@@ -6,14 +6,16 @@ import (
"time"
"beo-echo/backend/src/database"
+ "beo-echo/backend/src/database/repositories"
)
// ReplayRepository defines data access requirements for replay operations
type ReplayRepository interface {
// Replay CRUD operations
- FindByProjectID(ctx context.Context, projectID string) ([]database.Replay, error)
- FindFoldersByProjectID(ctx context.Context, projectID string) ([]database.ReplayFolder, error)
+ FindByProjectID(ctx context.Context, projectID string) ([]repositories.ReplayListRow, error)
+ FindFoldersByProjectID(ctx context.Context, projectID string) ([]repositories.ReplayFolderListRow, error)
FindByID(ctx context.Context, id string) (*database.Replay, error)
+ FindFolderByID(ctx context.Context, projectID string, folderID string) (*database.ReplayFolder, error)
Create(ctx context.Context, replay *database.Replay) error
CreateFolder(ctx context.Context, folder *database.ReplayFolder) error
UpdateFolder(ctx context.Context, folder *database.ReplayFolder) error
@@ -62,8 +64,8 @@ type UpdateFolderRequest struct {
// ListReplaysResponse represents the response for listing replays
type ListReplaysResponse struct {
- Replays []database.Replay `json:"replays"`
- Folders []database.ReplayFolder `json:"folders"`
+ Replays []repositories.ReplayListRow `json:"replays"`
+ Folders []repositories.ReplayFolderListRow `json:"folders"`
}
// HeaderItem represents a single header with key, value, and description
@@ -78,6 +80,7 @@ type CreateReplayRequest struct {
Name string `json:"name"`
Doc string `json:"doc"`
FolderID *string `json:"folder_id"`
+ ParentID *string `json:"parent_id"` // For histories/saved responses
Protocol string `json:"protocol" binding:"required"`
Method string `json:"method" binding:"required"`
Url string `json:"url" binding:"required"`
@@ -85,6 +88,13 @@ type CreateReplayRequest struct {
Payload string `json:"payload"`
Metadata map[string]any `json:"metadata"` // Additional protocol-specific metadata
Config map[string]any `json:"config"` // Optional configuration for specific protocols
+
+ // Response fields for creating histories
+ IsResponse bool `json:"is_response"`
+ ResponseStatus *int `json:"response_status"`
+ ResponseMeta *string `json:"response_meta"`
+ ResponseBody *string `json:"response_body"`
+ LatencyMS *int `json:"latency_ms"`
}
// UpdateReplayRequest represents the request payload for updating a replay
@@ -93,6 +103,8 @@ type UpdateReplayRequest struct {
Doc *string `json:"doc"`
FolderID *string `json:"folder_id"`
UpdateFolderID bool `json:"update_folder_id"`
+ ParentID *string `json:"parent_id"`
+ UpdateParentID bool `json:"update_parent_id"`
Protocol *string `json:"protocol"`
Method *string `json:"method"`
Url *string `json:"url"`
@@ -100,5 +112,11 @@ type UpdateReplayRequest struct {
Payload *string `json:"payload"`
Metadata *map[string]any `json:"metadata"` // Additional protocol-specific metadata
Config *map[string]any `json:"config"` // Optional configuration for specific protocols
+
+ // Response fields for updating histories
+ ResponseStatus *int `json:"response_status"`
+ ResponseMeta *string `json:"response_meta"`
+ ResponseBody *string `json:"response_body"`
+ LatencyMS *int `json:"latency_ms"`
}
diff --git a/backend/src/replay/services/update_folder.go b/backend/src/replay/services/update_folder.go
index b40427d0..1900a1e6 100644
--- a/backend/src/replay/services/update_folder.go
+++ b/backend/src/replay/services/update_folder.go
@@ -2,8 +2,12 @@ package services
import (
"beo-echo/backend/src/database"
+ "beo-echo/backend/src/database/repositories"
"context"
+ "errors"
"fmt"
+
+ "gorm.io/gorm"
)
// UpdateFolder updates an existing replay folder
@@ -13,61 +17,49 @@ func (s *ReplayService) UpdateFolder(ctx context.Context, projectID string, fold
return nil, fmt.Errorf("project not found")
}
- // Verify the folder exists and belongs to the project
- folders, err := s.repo.FindFoldersByProjectID(ctx, projectID)
+ // Fetch target folder directly by ID from DB
+ targetFolder, err := s.repo.FindFolderByID(ctx, projectID, folderID)
if err != nil {
- return nil, fmt.Errorf("failed to fetch folders: %w", err)
- }
-
- var targetFolder *database.ReplayFolder
- for i := range folders {
- if folders[i].ID == folderID {
- targetFolder = &folders[i]
- break
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, fmt.Errorf("folder not found")
}
+ return nil, fmt.Errorf("failed to fetch folder: %w", err)
}
- if targetFolder == nil {
- return nil, fmt.Errorf("folder not found")
- }
-
- // Verify the new parent folder exists and is valid (not a child of itself)
+ // If a new ParentID is requested, validate it
if req.ParentID != nil {
- // Parent can't be itself
if *req.ParentID == folderID {
return nil, fmt.Errorf("invalid parent folder")
}
- // Verify parent exists
- var parentExists bool
+ // Fetch all folders for circular-reference check (lightweight projection)
+ folders, err := s.repo.FindFoldersByProjectID(ctx, projectID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to fetch folders: %w", err)
+ }
+
+ // Build lookup map
+ folderMap := make(map[string]repositories.ReplayFolderListRow, len(folders))
for _, f := range folders {
- if f.ID == *req.ParentID {
- parentExists = true
- break
- }
+ folderMap[f.ID] = f
}
- if !parentExists {
+
+ // Verify parent exists
+ if _, exists := folderMap[*req.ParentID]; !exists {
return nil, fmt.Errorf("parent folder not found")
}
- // Check for circular reference by ensuring the new parent isn't a child of this folder
- // A simple way to check is to traverse up from the new parent
+ // Check for circular reference
currentWalkID := *req.ParentID
for currentWalkID != "" {
- var currentParentID *string
- for _, f := range folders {
- if f.ID == currentWalkID {
- currentParentID = f.ParentID
- break
- }
- }
- if currentParentID == nil || *currentParentID == "" {
+ f, ok := folderMap[currentWalkID]
+ if !ok || f.ParentID == nil || *f.ParentID == "" {
break
}
- if *currentParentID == folderID {
+ if *f.ParentID == folderID {
return nil, fmt.Errorf("circular folder reference detected")
}
- currentWalkID = *currentParentID
+ currentWalkID = *f.ParentID
}
}
@@ -75,7 +67,6 @@ func (s *ReplayService) UpdateFolder(ctx context.Context, projectID string, fold
if req.Name != nil {
targetFolder.Name = *req.Name
}
-
if req.Doc != nil {
targetFolder.Doc = *req.Doc
}
diff --git a/backend/src/replay/services/update_replay.go b/backend/src/replay/services/update_replay.go
index 462769c4..c64cbc6a 100644
--- a/backend/src/replay/services/update_replay.go
+++ b/backend/src/replay/services/update_replay.go
@@ -40,6 +40,28 @@ func (s *ReplayService) UpdateReplay(ctx context.Context, replayID string, req U
replay.FolderID = req.FolderID
}
+ if req.UpdateParentID {
+ if req.ParentID != nil {
+ parentReplay, err := s.repo.FindByID(ctx, *req.ParentID)
+ if err != nil {
+ return nil, fmt.Errorf("invalid parent_id: %w", err)
+ }
+ if parentReplay.IsResponse {
+ return nil, fmt.Errorf("parent_id cannot be a response")
+ }
+ }
+ replay.ParentID = req.ParentID
+ } else if req.ParentID != nil {
+ parentReplay, err := s.repo.FindByID(ctx, *req.ParentID)
+ if err != nil {
+ return nil, fmt.Errorf("invalid parent_id: %w", err)
+ }
+ if parentReplay.IsResponse {
+ return nil, fmt.Errorf("parent_id cannot be a response")
+ }
+ replay.ParentID = req.ParentID
+ }
+
if req.Protocol != nil {
protocol := database.ReplayProtocol(strings.ToLower(*req.Protocol))
if protocol != database.ReplayProtocolHTTP {
@@ -98,6 +120,19 @@ func (s *ReplayService) UpdateReplay(ctx context.Context, replayID string, req U
replay.Config = string(configJSON)
}
+ if req.ResponseStatus != nil {
+ replay.ResponseStatus = *req.ResponseStatus
+ }
+ if req.ResponseMeta != nil {
+ replay.ResponseMeta = *req.ResponseMeta
+ }
+ if req.ResponseBody != nil {
+ replay.ResponseBody = *req.ResponseBody
+ }
+ if req.LatencyMS != nil {
+ replay.LatencyMS = *req.LatencyMS
+ }
+
// Update in database
err = s.repo.Update(ctx, replay)
if err != nil {
diff --git a/backend/src/server.go b/backend/src/server.go
index 5299b58c..0ce77680 100644
--- a/backend/src/server.go
+++ b/backend/src/server.go
@@ -284,6 +284,7 @@ func SetupRouter() *gin.Engine {
projectRoutes.GET("/replays", replayHandler.ListReplaysHandler)
projectRoutes.POST("/replays", replayHandler.CreateReplayHandler)
projectRoutes.POST("/replays/folder", replayHandler.CreateFolderHandler)
+ projectRoutes.GET("/replays/folder/:folderId", replayHandler.GetFolderHandler)
projectRoutes.PATCH("/replays/folder/:folderId", replayHandler.UpdateFolderHandler)
projectRoutes.DELETE("/replays/folder/:folderId", replayHandler.DeleteFolderEndpoint)
projectRoutes.GET("/replays/:replayId", replayHandler.GetReplayHandler)
diff --git a/frontend/src/lib/api/replayApi.ts b/frontend/src/lib/api/replayApi.ts
index 2e6be649..f6736293 100644
--- a/frontend/src/lib/api/replayApi.ts
+++ b/frontend/src/lib/api/replayApi.ts
@@ -1,6 +1,7 @@
import { apiClient } from './apiClient';
import type {
- Replay,
+ Replay,
+ ReplayListItem,
CreateReplayRequest,
UpdateReplayRequest,
ListReplaysResponse,
@@ -47,6 +48,20 @@ export class ReplayApi {
return response.data;
}
+ /**
+ * Get a specific folder with full details (including doc)
+ */
+ static async getFolder(
+ workspaceId: string,
+ projectId: string,
+ folderId: string
+ ): Promise<{ folder: import('$lib/types/Replay').ReplayFolder }> {
+ const response = await apiClient.get(
+ `/workspaces/${workspaceId}/projects/${projectId}/replays/folder/${folderId}`
+ );
+ return response.data;
+ }
+
/**
* Get a specific replay
*/
diff --git a/frontend/src/lib/components/landing-page/LandingContent.svelte b/frontend/src/lib/components/landing-page/LandingContent.svelte
index 317f2396..42c83dbf 100644
--- a/frontend/src/lib/components/landing-page/LandingContent.svelte
+++ b/frontend/src/lib/components/landing-page/LandingContent.svelte
@@ -864,171 +864,7 @@
-
-
- Choose the plan that fits your needs. Start with Community Edition for free, or unlock
- advanced features with Cloud.
-
- Perfect for individual developers and small projects
-
- For teams and production deployments
-
- For enterprise and advanced use cases
-
-
- Simple, Transparent Pricing
-
- Community Edition
- Cloud
- Pro
-