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 @@ - -
-
-
-

- - Simple, Transparent Pricing -

-

- Choose the plan that fits your needs. Start with Community Edition for free, or unlock - advanced features with Cloud. -

-
- -
- -
-
-

Community Edition

-

- Perfect for individual developers and small projects -

-
- Free - forever -
-
- -
-
- - Unlimited mock servers -
-
- - Request logging & filtering -
-
- - Multi-user workspaces -
-
- - Docker deployment -
-
- - SQLite & PostgreSQL support -
-
- - -
- - -
- -
- - Most Popular - -
- -
-

Cloud

-

- For teams and production deployments -

-
- $0 - per month -
-
- -
-
- - Everything in Community -
-
- - -
- -
- -
- - Coming Soon - -
- -
-

Pro

-

- For enterprise and advanced use cases -

-
- Contact Us - for pricing -
-
- -
-
- - Everything in Cloud -
-
- - Advanced analytics -
- -
- - Custom domains -
- -
- - Priority support -
-
- - -
-
-
-
diff --git a/frontend/src/lib/components/landing-page/LandingPageFooter.svelte b/frontend/src/lib/components/landing-page/LandingPageFooter.svelte index 0805109b..feeb66c6 100644 --- a/frontend/src/lib/components/landing-page/LandingPageFooter.svelte +++ b/frontend/src/lib/components/landing-page/LandingPageFooter.svelte @@ -40,17 +40,6 @@ Modes -
  • - - Pricing - -
  • - diff --git a/frontend/src/lib/components/landing-page/LandingPageHeader.svelte b/frontend/src/lib/components/landing-page/LandingPageHeader.svelte index 0ed24dff..b053d901 100644 --- a/frontend/src/lib/components/landing-page/LandingPageHeader.svelte +++ b/frontend/src/lib/components/landing-page/LandingPageHeader.svelte @@ -85,14 +85,6 @@ > Modes - - Pricing - @@ -220,18 +212,6 @@ Modes - - - Pricing - - {#if !$isAuthenticated}
    diff --git a/frontend/src/lib/components/replay/ReplayEditor.svelte b/frontend/src/lib/components/replay/ReplayEditor.svelte index 0feb209e..abcc10bb 100644 --- a/frontend/src/lib/components/replay/ReplayEditor.svelte +++ b/frontend/src/lib/components/replay/ReplayEditor.svelte @@ -407,6 +407,90 @@ // Add logic to cancel the request if needed } + async function handleSaveResponse() { + if (!executionResult || !activeTab || !$selectedWorkspace || !$selectedProject) return; + + try { + // Use parent replay name automatically + const defaultName = activeTab.replay?.name ? `${activeTab.replay.name} (Response)` : "Saved Response"; + const name = defaultName; + + replayActions.setLoading('save', true); + + const payload: any = { + name: name, + protocol: activeTab.replay?.protocol || 'http', + method: activeTab.content?.method || 'GET', + url: activeTab.content?.url || '', + headers: [], + payload: activeTab.content?.body?.content || '', + config: { + auth: activeTab.content?.auth || { type: 'none', config: {} }, + settings: activeTab.content?.settings || {} + }, + is_response: true, + parent_id: activeTab.id, // Current request is the parent + response_status: executionResult.status_code, + latency_ms: executionResult.latency_ms, + response_body: executionResult.response_body, + response_meta: JSON.stringify(executionResult.response_headers || {}) + }; + + const validParams = (activeTab.content?.params || []).filter((p: any) => p.key); + let metaObj: any = {}; + try { + if (activeTab.replay?.metadata) { + metaObj = typeof activeTab.replay.metadata === 'string' + ? JSON.parse(activeTab.replay.metadata) + : { ...(activeTab.replay.metadata as any) }; + } + } catch(e) { + console.warn('Failed to parse metadata when saving', e); + } + + if (validParams.length > 0) { + metaObj.params = validParams; + } + metaObj.bodyType = activeTab.content?.body?.type || 'none'; + payload.metadata = metaObj; + + if (activeTab.content?.headers) { + payload.headers = activeTab.content.headers + .filter((h: any) => h.enabled && h.key) + .map((h: any) => ({ + key: h.key, + value: h.value || '', + description: h.description || '' + })); + } + + const res = await replayApi.createReplay( + $selectedWorkspace.id, + $selectedProject.id, + payload + ); + + const newItem = { + id: res.replay.id, + name: res.replay.name, + project_id: res.replay.project_id, + folder_id: res.replay.folder_id, + parent_id: res.replay.parent_id || null, + is_response: res.replay.is_response || false, + method: res.replay.method, + created_at: res.replay.created_at, + updated_at: res.replay.updated_at + }; + replayActions.addReplay(newItem); + toast.success('Response saved as example!'); + + } catch (err: any) { + toast.error(err.message || 'Failed to save response'); + } finally { + replayActions.setLoading('save', false); + } + } + let isEditingTitle = false; let editTitleValue = ''; @@ -731,6 +815,7 @@ {executionResult} on:toggleExpand={handleFooterToggleExpand} on:showHistory={handleFooterShowHistory} + on:saveResponse={handleSaveResponse} /> {/if}
    diff --git a/frontend/src/lib/components/replay/ReplayList.svelte b/frontend/src/lib/components/replay/ReplayList.svelte index 1e617e85..bc40306a 100644 --- a/frontend/src/lib/components/replay/ReplayList.svelte +++ b/frontend/src/lib/components/replay/ReplayList.svelte @@ -35,6 +35,8 @@ let draggedItem: any | null = $state(null); let dragOverItemId: string | null = $state(null); let dragOverPosition: 'top' | 'bottom' | 'inside' | 'root' | null = $state(null); + let dragOverDepth: number = $state(0); + let dragTargetFolderId: string | null = $state(null); let dragCounter = $state(0); let contextMenu = $state<{ isOpen: boolean; x: number; y: number; item: any | null }>({ @@ -101,7 +103,8 @@ const items = sortedReplays; const result: any[] = []; const folders = items.filter(i => i.itemType === 'folder'); - const replaysList = items.filter(i => i.itemType === 'replay'); + const requestsList = items.filter(i => i.itemType === 'replay' && !i.is_response); + const responsesList = items.filter(i => i.itemType === 'replay' && i.is_response); // Defend against circular references const processedFolderIds = new Set(); @@ -134,8 +137,8 @@ } // Add replays - const childReplays = replaysList.filter(r => { - const rFid = r.folder_id ? String(r.folder_id).trim() : null; + const childReplays = requestsList.filter(r => { + const rFid = (r as any).folder_id ? String((r as any).folder_id).trim() : null; // If parentId is null (we are currently at root), also include orphaned replays whose folder no longer exists if (parentId === null) { return rFid === null || !knownFolderIds.has(rFid); @@ -144,7 +147,15 @@ }); for (const replay of childReplays) { if (isVisible) { - result.push({ ...replay, depth, isVisible }); + const childResponses = responsesList.filter(resp => resp.parent_id === replay.id); + result.push({ ...replay, depth, isVisible, hasChildren: childResponses.length > 0 }); + + const isReplayExpanded = !collapsedFolders.has(replay.id); + for (const resp of childResponses) { + if (isReplayExpanded) { + result.push({ ...resp, depth: depth + 1, isVisible }); + } + } } } } @@ -320,19 +331,57 @@ // Determine position for visual feedback const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); - const y = e.clientY - rect.top; dragOverItemId = targetItem.id; - if (targetItem.itemType === 'folder') { - // Droppable inside folder - dragOverPosition = 'inside'; - } else { - // If dropping on a replay, determine if it's top or bottom (for reordering, which isn't fully implemented backend yet, so just fallback to same folder) - // For now, if we drop on a replay, we just move it to that replay's folder - dragOverPosition = 'inside'; + if (draggedItem.is_response) { + // Responses can only be dropped ON a parent replay item. + if (targetItem.itemType === 'replay' && !targetItem.is_response) { + dragOverPosition = 'inside'; + if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; + } else { + dragOverItemId = null; + dragOverPosition = null; + } + return; + } + + if (targetItem.is_response) { + dragOverItemId = null; + dragOverPosition = null; + return; } + // Calculate desired drop depth based on mouse X position (24px approx per depth level) + const offsetX = e.clientX - rect.left - 32; // Offset for icons/padding + const maxDepth = targetItem.itemType === 'folder' ? targetItem.depth + 1 : targetItem.depth; + dragOverDepth = Math.max(0, Math.min(maxDepth, Math.max(0, Math.floor(offsetX / 24)))); + + // Determine target folder based on computed depth + let folderLookup: Record = { 0: null }; + let currentFolderIdStr = targetItem.itemType === 'folder' ? targetItem.parent_id : targetItem.folder_id; + + let folderPath = []; + let currentF = $replayFolders.find(f => f.id === currentFolderIdStr); + while (currentF) { + folderPath.push(currentF.id); + currentF = $replayFolders.find(f => f.id === currentF?.parent_id); + } + folderPath.reverse(); + + folderPath.forEach((fid, index) => { + folderLookup[index + 1] = fid; + }); + + // If targetItem is a folder and depth matches max (child), folder_id is targetItem.id + if (targetItem.itemType === 'folder' && dragOverDepth === maxDepth) { + folderLookup[maxDepth] = targetItem.id; + } + + dragTargetFolderId = folderLookup[dragOverDepth] ?? null; + + dragOverPosition = 'inside'; + // Prevent folder dropping into itself if (draggedItem.itemType === 'folder' && draggedItem.id === targetItem.id) { dragOverItemId = null; @@ -363,6 +412,8 @@ draggedItem = null; dragOverItemId = null; dragOverPosition = null; + dragTargetFolderId = null; + dragOverDepth = 0; dragCounter = 0; } @@ -377,8 +428,32 @@ const itemId = draggedItem.id; const itemType = draggedItem.itemType; - // If dropped on a folder, target is the folder. If dropped on a replay, target is the replay's folder. - const targetId = targetItem.itemType === 'folder' ? targetItem.id : targetItem.folder_id || targetItem.parent_id; + + if (draggedItem.is_response) { + if (targetItem.itemType !== 'replay' || targetItem.is_response) return; + const targetId = targetItem.id; + + if (!$selectedWorkspace || !$selectedProject) return; + + // Optimistic UI update + replayActions.moveResponse(itemId, targetId); + + try { + await replayApi.updateReplay($selectedWorkspace.id, $selectedProject.id, itemId, { + parent_id: targetId, + update_parent_id: true + }); + } catch (error: any) { + toast.error(`Failed to move response: ${error.message || 'Unknown error'}`); + dispatch('refresh'); // Refresh on error to restore state + } + draggedItem = null; + return; + } + + if (targetItem.is_response) return; + + const targetId = dragTargetFolderId; // Prevent dropping folder into itself if (itemType === 'folder' && itemId === targetId) return; @@ -407,10 +482,9 @@ draggedItem = null; } - function handleRootDragOver(e: DragEvent) { e.preventDefault(); - if (draggedItem) { + if (draggedItem && !draggedItem.is_response) { dragOverPosition = 'root'; if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'; } @@ -429,11 +503,15 @@ } } + async function handleRootDrop(e: DragEvent) { e.preventDefault(); dragOverPosition = null; - if (!draggedItem) return; + if (!draggedItem || draggedItem.is_response) { + draggedItem = null; + return; + } const itemId = draggedItem.id; const itemType = draggedItem.itemType; @@ -575,19 +653,12 @@ -
    - {#if dragOverPosition === 'root'} -
    - Move to Top Level -
    - {/if} - {#if $filteredReplays.length === 0}
    @@ -635,7 +706,7 @@ ondragleave={(e) => handleDragLeave(e, item)} ondragend={(e) => handleDragEnd(e)} ondrop={(e) => handleDrop(e, item)} - class="group flex transition-all cursor-pointer {draggedItem?.id === item.id ? 'opacity-30' : ''} {dragOverItemId === item.id ? (item.itemType === 'folder' ? 'bg-[#ff6600]/10 ring-1 ring-inset ring-[#ff6600] z-10' : 'border-t-2 border-t-[#ff6600]') : 'border-t-2 border-t-transparent'} {$selectedReplay?.id === + class="group flex transition-all cursor-pointer {draggedItem?.id === item.id ? 'opacity-30' : ''} {dragOverItemId === item.id && (draggedItem?.is_response || (item.itemType === 'folder' && dragOverDepth === item.depth + 1)) ? 'bg-[#ff6600]/10 ring-1 ring-inset ring-[#ff6600] z-10 rounded-sm' : ''} {$selectedReplay?.id === item.id && dragOverItemId !== item.id ? 'bg-blue-500/10 border-l-[3px] border-l-[#ff6600]' : 'border-l-[3px] border-l-transparent hover:bg-gray-500/10'} {contextMenu.isOpen && contextMenu.item?.id === item.id ? 'bg-gray-500/10 relative z-40' : 'relative'} px-2" @@ -646,6 +717,10 @@ tabindex="0" onkeydown={(e) => e.key === 'Enter' && handleSelectReplay(item)} > + + {#if dragOverItemId === item.id && !(draggedItem?.is_response || (item.itemType === 'folder' && dragOverDepth === item.depth + 1))} +
    + {/if} {#if item.depth > 0} {#each Array(item.depth) as _, i} @@ -656,7 +731,7 @@
    - {#if item.itemType === 'folder'} + {#if item.itemType === 'folder' || item.hasChildren} + {:else} +
    + {/if} + + {#if item.itemType === 'folder'} + {:else if item.is_response} + {:else} {/if} diff --git a/frontend/src/lib/components/replay/ReplayManager.svelte b/frontend/src/lib/components/replay/ReplayManager.svelte index e55c3062..eb47b194 100644 --- a/frontend/src/lib/components/replay/ReplayManager.svelte +++ b/frontend/src/lib/components/replay/ReplayManager.svelte @@ -467,16 +467,66 @@ } } - function handleEditReplay(event: CustomEvent) { - const replay = event.detail; + async function handleEditReplay(event: CustomEvent) { + const listItem = event.detail; // ReplayListItem (minimal) or folder - selectedReplay.set(replay); + selectedReplay.set(listItem); activeView = 'editor'; - + + // For replay items, fetch the full data first + let fullReplay = listItem; + if (listItem.itemType !== 'folder') { + try { + if ($selectedWorkspace && $selectedProject) { + const res = await replayApi.getReplay($selectedWorkspace.id, $selectedProject.id, listItem.id); + fullReplay = { ...res.replay, itemType: 'request' }; + selectedReplay.set(fullReplay); + } + } catch (e: any) { + toast.error(e.message || 'Failed to load replay details'); + return; + } + } else { + // Fetch full folder details (includes doc) + try { + if ($selectedWorkspace && $selectedProject) { + const res = await replayApi.getFolder($selectedWorkspace.id, $selectedProject.id, listItem.id); + fullReplay = { ...res.folder, itemType: 'folder' }; + selectedReplay.set(fullReplay); + } + } catch (e: any) { + toast.error(e.message || 'Failed to load folder details'); + return; + } + } + // Check if this replay is already open in a tab - const existingTab = editorTabs.find(tab => tab.id === replay.id); + const existingTab = editorTabs.find(tab => tab.id === fullReplay.id); if (existingTab) { + // Refresh tab with newly fetched full data + existingTab.replay = fullReplay.itemType !== 'folder' ? fullReplay : existingTab.replay; + + if (fullReplay.itemType !== 'folder' && fullReplay.is_response) { + let headers = {}; + try { + headers = fullReplay.response_meta ? JSON.parse(fullReplay.response_meta) : {}; + } catch (e) {} + existingTab.executionResult = { + replay_id: fullReplay.id, + log_id: '', + status_code: fullReplay.response_status || 200, + status_text: '', + latency_ms: fullReplay.latency_ms || 0, + response_body: fullReplay.response_body || '', + response_headers: headers, + size: fullReplay.response_body?.length || 0, + error: null + }; + } + + editorTabs = [...editorTabs]; // trigger reactivity + // Switch to existing tab editorActiveTabId = existingTab.id; editorActiveTabContent = { @@ -489,17 +539,35 @@ } else { // Create new tab for this replay with full content const newTab: Tab = { - id: replay.id || `tab-${Date.now()}`, + id: fullReplay.id || `tab-${Date.now()}`, isUnsaved: false, - itemType: replay.itemType === 'folder' ? 'folder' : 'request', - folder: replay.itemType === 'folder' ? replay : undefined, - replay: replay.itemType !== 'folder' ? replay : undefined, + itemType: fullReplay.itemType === 'folder' ? 'folder' : 'request', + folder: fullReplay.itemType === 'folder' ? fullReplay : undefined, + replay: fullReplay.itemType !== 'folder' ? fullReplay : undefined, content: { ...createDefaultTabContent(), - method: replay.method || 'GET', - url: replay.url || '', + method: fullReplay.method || 'GET', + url: fullReplay.url || '', } }; + + if (fullReplay.itemType !== 'folder' && fullReplay.is_response) { + let headers = {}; + try { + headers = fullReplay.response_meta ? JSON.parse(fullReplay.response_meta) : {}; + } catch (e) {} + newTab.executionResult = { + replay_id: fullReplay.id, + log_id: '', + status_code: fullReplay.response_status || 200, + status_text: '', + latency_ms: fullReplay.latency_ms || 0, + response_body: fullReplay.response_body || '', + response_headers: headers, + size: fullReplay.response_body?.length || 0, + error: null + }; + } const activeTabIndex = editorTabs.findIndex(t => t.id === editorActiveTabId); const activeTab = activeTabIndex !== -1 ? editorTabs[activeTabIndex] : null; diff --git a/frontend/src/lib/components/replay/ReplayResponseFooter.svelte b/frontend/src/lib/components/replay/ReplayResponseFooter.svelte index e255e93f..f3985685 100644 --- a/frontend/src/lib/components/replay/ReplayResponseFooter.svelte +++ b/frontend/src/lib/components/replay/ReplayResponseFooter.svelte @@ -154,12 +154,22 @@
    Response + + + diff --git a/frontend/src/lib/stores/replay.ts b/frontend/src/lib/stores/replay.ts index 936ad1f3..819faf46 100644 --- a/frontend/src/lib/stores/replay.ts +++ b/frontend/src/lib/stores/replay.ts @@ -1,15 +1,15 @@ import { writable, derived } from 'svelte/store'; -import type { Replay, ReplayLog, ExecuteReplayResponse } from '$lib/types/Replay'; +import type { Replay, ReplayListItem, ReplayLog, ExecuteReplayResponse } from '$lib/types/Replay'; -// Main replay list store -export const replays = writable([]); +// Main replay list store — lightweight items from list API +export const replays = writable([]); // Replay folders export const replayFolders = writable([]); // Combined type for UI -export type ReplayItem = (import('$lib/types/Replay').Replay & { itemType: 'replay' }) | (import('$lib/types/Replay').ReplayFolder & { itemType: 'folder' }); +export type ReplayItem = (ReplayListItem & { itemType: 'replay' }) | (import('$lib/types/Replay').ReplayFolder & { itemType: 'folder' }); // Currently selected replay export const selectedReplay = writable(null); @@ -62,16 +62,11 @@ export const filteredReplays = derived( ]; return combined.filter(item => { - // Search by name or URL + // Search by name only (url not available in list items) const matchesSearch = !$filter.searchTerm || - item.name?.toLowerCase().includes($filter.searchTerm?.toLowerCase()) || - (item.itemType === 'replay' && item.url?.toLowerCase().includes($filter.searchTerm?.toLowerCase())); - - // Filter by protocol (only applies to replays) - const matchesProtocol = !$filter.protocol || - (item.itemType === 'replay' ? item.protocol === $filter.protocol : true); - - return matchesSearch && matchesProtocol; + item.name?.toLowerCase().includes($filter.searchTerm?.toLowerCase()); + + return matchesSearch; }); } ); @@ -115,13 +110,13 @@ export const replayActions = { })); }, - // Add a new replay to the list - addReplay: (replay: Replay) => { + // Add a new replay to the list (after create, list is refreshed — this is for optimistic updates) + addReplay: (replay: ReplayListItem) => { replays.update(list => [...list, replay]); }, - // Update an existing replay - updateReplay: (updatedReplay: Replay) => { + // Update an existing replay in the list + updateReplay: (updatedReplay: ReplayListItem) => { replays.update(list => list.map(replay => replay.id === updatedReplay.id ? updatedReplay : replay @@ -159,6 +154,12 @@ export const replayActions = { } }, + // Move a response to a new parent config + moveResponse: (itemId: string, newParentId: string | null) => { + const targetParentId = newParentId === null ? undefined : newParentId; + replays.update(list => list.map(r => r.id === itemId ? { ...r, parent_id: targetParentId } : r)); + }, + // Set execution state setExecuting: (isExecuting: boolean) => { replayExecution.update(state => ({ diff --git a/frontend/src/lib/types/Replay.ts b/frontend/src/lib/types/Replay.ts index 0c55de6f..421b34d6 100644 --- a/frontend/src/lib/types/Replay.ts +++ b/frontend/src/lib/types/Replay.ts @@ -27,19 +27,41 @@ export interface ReplayPayload { bodyType?: ReplayMetadata['bodyType']; } +// Full Replay model — returned by GET /replays/:id export interface Replay { id: string name: string - doc: string // User-defined documentation + doc: string project_id: string folder_id: any + parent_id?: string protocol: string method: string url: string - config: string // JSON string — parse with JSON.parse() - metadata: string // JSON string — parse with JSON.parse() → ReplayMetadata - headers: string // JSON string — parse with JSON.parse() → Record - payload: string // raw body content string + config: string // JSON string + metadata: string // JSON string → ReplayMetadata + headers: string // JSON string → Record + payload: string + + // Response snapshot fields (only present when is_response = true) + is_response?: boolean + response_status?: number + response_meta?: string + response_body?: string + latency_ms?: number + created_at: string + updated_at: string +} + +// Lightweight item — returned by GET /replays (list). Heavy fields excluded. +export interface ReplayListItem { + id: string + name: string + project_id: string + folder_id: string | null | undefined + parent_id: string | null + is_response: boolean + method: string created_at: string updated_at: string } @@ -69,6 +91,8 @@ export interface UpdateReplayRequest { body?: string; folder_id?: string | null; update_folder_id?: boolean; + parent_id?: string | null; + update_parent_id?: boolean; metadata?: ReplayMetadata; config?: ReplayConfig; } @@ -108,10 +132,10 @@ export interface ReplayFolder { } export interface ListReplaysResponse { - replays: Replay[]; + replays: ReplayListItem[]; folders: ReplayFolder[]; - replayCount: number; - folderCount: number; + replay_count: number; + folder_count: number; } export interface ListReplayLogsResponse {