diff --git a/backend/bin/calypso-api b/backend/bin/calypso-api index 16a97bd..351698f 100755 Binary files a/backend/bin/calypso-api and b/backend/bin/calypso-api differ diff --git a/backend/internal/common/router/router.go b/backend/internal/common/router/router.go index 76b1508..2e51f36 100644 --- a/backend/internal/common/router/router.go +++ b/backend/internal/common/router/router.go @@ -206,10 +206,12 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng scstGroup.GET("/targets", scstHandler.ListTargets) scstGroup.GET("/targets/:id", scstHandler.GetTarget) scstGroup.POST("/targets", scstHandler.CreateTarget) - scstGroup.POST("/targets/:id/luns", scstHandler.AddLUN) + scstGroup.POST("/targets/:id/luns", requirePermission("iscsi", "write"), scstHandler.AddLUN) + scstGroup.DELETE("/targets/:id/luns/:lunId", requirePermission("iscsi", "write"), scstHandler.RemoveLUN) scstGroup.POST("/targets/:id/initiators", scstHandler.AddInitiator) scstGroup.POST("/targets/:id/enable", scstHandler.EnableTarget) scstGroup.POST("/targets/:id/disable", scstHandler.DisableTarget) + scstGroup.DELETE("/targets/:id", requirePermission("iscsi", "write"), scstHandler.DeleteTarget) scstGroup.GET("/initiators", scstHandler.ListAllInitiators) scstGroup.GET("/initiators/:id", scstHandler.GetInitiator) scstGroup.DELETE("/initiators/:id", scstHandler.RemoveInitiator) @@ -223,6 +225,13 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng scstGroup.POST("/portals", scstHandler.CreatePortal) scstGroup.PUT("/portals/:id", scstHandler.UpdatePortal) scstGroup.DELETE("/portals/:id", scstHandler.DeletePortal) + // Initiator Groups routes + scstGroup.GET("/initiator-groups", scstHandler.ListAllInitiatorGroups) + scstGroup.GET("/initiator-groups/:id", scstHandler.GetInitiatorGroup) + scstGroup.POST("/initiator-groups", requirePermission("iscsi", "write"), scstHandler.CreateInitiatorGroup) + scstGroup.PUT("/initiator-groups/:id", requirePermission("iscsi", "write"), scstHandler.UpdateInitiatorGroup) + scstGroup.DELETE("/initiator-groups/:id", requirePermission("iscsi", "write"), scstHandler.DeleteInitiatorGroup) + scstGroup.POST("/initiator-groups/:id/initiators", requirePermission("iscsi", "write"), scstHandler.AddInitiatorToGroup) } // Physical Tape Libraries diff --git a/backend/internal/scst/handler.go b/backend/internal/scst/handler.go index 1a3349e..9a93b57 100644 --- a/backend/internal/scst/handler.go +++ b/backend/internal/scst/handler.go @@ -3,11 +3,13 @@ package scst import ( "fmt" "net/http" + "strings" "github.com/atlasos/calypso/internal/common/database" "github.com/atlasos/calypso/internal/common/logger" "github.com/atlasos/calypso/internal/tasks" "github.com/gin-gonic/gin" + "github.com/go-playground/validator/v10" ) // Handler handles SCST-related API requests @@ -37,6 +39,11 @@ func (h *Handler) ListTargets(c *gin.Context) { return } + // Ensure we return an empty array instead of null + if targets == nil { + targets = []Target{} + } + c.JSON(http.StatusOK, gin.H{"targets": targets}) } @@ -112,6 +119,11 @@ func (h *Handler) CreateTarget(c *gin.Context) { return } + // Set alias to name for frontend compatibility (same as ListTargets) + target.Alias = target.Name + // LUNCount will be 0 for newly created target + target.LUNCount = 0 + c.JSON(http.StatusCreated, target) } @@ -119,7 +131,7 @@ func (h *Handler) CreateTarget(c *gin.Context) { type AddLUNRequest struct { DeviceName string `json:"device_name" binding:"required"` DevicePath string `json:"device_path" binding:"required"` - LUNNumber int `json:"lun_number" binding:"required"` + LUNNumber int `json:"lun_number"` // Note: cannot use binding:"required" for int as 0 is valid HandlerType string `json:"handler_type" binding:"required"` } @@ -136,17 +148,45 @@ func (h *Handler) AddLUN(c *gin.Context) { var req AddLUNRequest if err := c.ShouldBindJSON(&req); err != nil { h.logger.Error("Failed to bind AddLUN request", "error", err) - c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid request: %v", err)}) + // Provide more detailed error message + if validationErr, ok := err.(validator.ValidationErrors); ok { + var errorMessages []string + for _, fieldErr := range validationErr { + errorMessages = append(errorMessages, fmt.Sprintf("%s is required", fieldErr.Field())) + } + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("validation failed: %s", strings.Join(errorMessages, ", "))}) + } else { + // Extract error message without full struct name + errMsg := err.Error() + if idx := strings.Index(errMsg, "Key: '"); idx >= 0 { + // Extract field name from error message + fieldStart := idx + 6 // Length of "Key: '" + if fieldEnd := strings.Index(errMsg[fieldStart:], "'"); fieldEnd >= 0 { + fieldName := errMsg[fieldStart : fieldStart+fieldEnd] + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid or missing field: %s", fieldName)}) + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request format"}) + } + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid request: %v", err)}) + } + } return } - // Validate required fields + // Validate required fields (additional check in case binding doesn't catch it) if req.DeviceName == "" || req.DevicePath == "" || req.HandlerType == "" { h.logger.Error("Missing required fields in AddLUN request", "device_name", req.DeviceName, "device_path", req.DevicePath, "handler_type", req.HandlerType) c.JSON(http.StatusBadRequest, gin.H{"error": "device_name, device_path, and handler_type are required"}) return } + // Validate LUN number range + if req.LUNNumber < 0 || req.LUNNumber > 255 { + c.JSON(http.StatusBadRequest, gin.H{"error": "lun_number must be between 0 and 255"}) + return + } + if err := h.service.AddLUN(c.Request.Context(), target.IQN, req.DeviceName, req.DevicePath, req.LUNNumber, req.HandlerType); err != nil { h.logger.Error("Failed to add LUN", "error", err) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) @@ -156,6 +196,48 @@ func (h *Handler) AddLUN(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "LUN added successfully"}) } +// RemoveLUN removes a LUN from a target +func (h *Handler) RemoveLUN(c *gin.Context) { + targetID := c.Param("id") + lunID := c.Param("lunId") + + // Get target + target, err := h.service.GetTarget(c.Request.Context(), targetID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "target not found"}) + return + } + + // Get LUN to get the LUN number + var lunNumber int + err = h.db.QueryRowContext(c.Request.Context(), + "SELECT lun_number FROM scst_luns WHERE id = $1 AND target_id = $2", + lunID, targetID, + ).Scan(&lunNumber) + if err != nil { + if strings.Contains(err.Error(), "no rows") { + // LUN already deleted from database - check if it still exists in SCST + // Try to get LUN number from URL or try common LUN numbers + // For now, return success since it's already deleted (idempotent) + h.logger.Info("LUN not found in database, may already be deleted", "lun_id", lunID, "target_id", targetID) + c.JSON(http.StatusOK, gin.H{"message": "LUN already removed or not found"}) + return + } + h.logger.Error("Failed to get LUN", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get LUN"}) + return + } + + // Remove LUN + if err := h.service.RemoveLUN(c.Request.Context(), target.IQN, lunNumber); err != nil { + h.logger.Error("Failed to remove LUN", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "LUN removed successfully"}) +} + // AddInitiatorRequest represents an initiator addition request type AddInitiatorRequest struct { InitiatorIQN string `json:"initiator_iqn" binding:"required"` @@ -186,6 +268,45 @@ func (h *Handler) AddInitiator(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Initiator added successfully"}) } +// AddInitiatorToGroupRequest represents a request to add an initiator to a group +type AddInitiatorToGroupRequest struct { + InitiatorIQN string `json:"initiator_iqn" binding:"required"` +} + +// AddInitiatorToGroup adds an initiator to a specific group +func (h *Handler) AddInitiatorToGroup(c *gin.Context) { + groupID := c.Param("id") + + var req AddInitiatorToGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + validationErrors := make(map[string]string) + if ve, ok := err.(validator.ValidationErrors); ok { + for _, fe := range ve { + field := strings.ToLower(fe.Field()) + validationErrors[field] = fmt.Sprintf("Field '%s' is required", field) + } + } + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid request", + "validation_errors": validationErrors, + }) + return + } + + err := h.service.AddInitiatorToGroup(c.Request.Context(), groupID, req.InitiatorIQN) + if err != nil { + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "single initiator only") { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + h.logger.Error("Failed to add initiator to group", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add initiator to group"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Initiator added to group successfully"}) +} + // ListAllInitiators lists all initiators across all targets func (h *Handler) ListAllInitiators(c *gin.Context) { initiators, err := h.service.ListAllInitiators(c.Request.Context()) @@ -440,6 +561,23 @@ func (h *Handler) DisableTarget(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Target disabled successfully"}) } +// DeleteTarget deletes a target +func (h *Handler) DeleteTarget(c *gin.Context) { + targetID := c.Param("id") + + if err := h.service.DeleteTarget(c.Request.Context(), targetID); err != nil { + if err.Error() == "target not found" { + c.JSON(http.StatusNotFound, gin.H{"error": "target not found"}) + return + } + h.logger.Error("Failed to delete target", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "Target deleted successfully"}) +} + // DeletePortal deletes a portal func (h *Handler) DeletePortal(c *gin.Context) { id := c.Param("id") @@ -474,3 +612,136 @@ func (h *Handler) GetPortal(c *gin.Context) { c.JSON(http.StatusOK, portal) } + +// CreateInitiatorGroupRequest represents a request to create an initiator group +type CreateInitiatorGroupRequest struct { + TargetID string `json:"target_id" binding:"required"` + GroupName string `json:"group_name" binding:"required"` +} + +// CreateInitiatorGroup creates a new initiator group +func (h *Handler) CreateInitiatorGroup(c *gin.Context) { + var req CreateInitiatorGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + validationErrors := make(map[string]string) + if ve, ok := err.(validator.ValidationErrors); ok { + for _, fe := range ve { + field := strings.ToLower(fe.Field()) + validationErrors[field] = fmt.Sprintf("Field '%s' is required", field) + } + } + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid request", + "validation_errors": validationErrors, + }) + return + } + + group, err := h.service.CreateInitiatorGroup(c.Request.Context(), req.TargetID, req.GroupName) + if err != nil { + if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + h.logger.Error("Failed to create initiator group", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create initiator group"}) + return + } + + c.JSON(http.StatusOK, group) +} + +// UpdateInitiatorGroupRequest represents a request to update an initiator group +type UpdateInitiatorGroupRequest struct { + GroupName string `json:"group_name" binding:"required"` +} + +// UpdateInitiatorGroup updates an initiator group +func (h *Handler) UpdateInitiatorGroup(c *gin.Context) { + groupID := c.Param("id") + + var req UpdateInitiatorGroupRequest + if err := c.ShouldBindJSON(&req); err != nil { + validationErrors := make(map[string]string) + if ve, ok := err.(validator.ValidationErrors); ok { + for _, fe := range ve { + field := strings.ToLower(fe.Field()) + validationErrors[field] = fmt.Sprintf("Field '%s' is required", field) + } + } + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid request", + "validation_errors": validationErrors, + }) + return + } + + group, err := h.service.UpdateInitiatorGroup(c.Request.Context(), groupID, req.GroupName) + if err != nil { + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "already exists") { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + h.logger.Error("Failed to update initiator group", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update initiator group"}) + return + } + + c.JSON(http.StatusOK, group) +} + +// DeleteInitiatorGroup deletes an initiator group +func (h *Handler) DeleteInitiatorGroup(c *gin.Context) { + groupID := c.Param("id") + + err := h.service.DeleteInitiatorGroup(c.Request.Context(), groupID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) + return + } + if strings.Contains(err.Error(), "cannot delete") || strings.Contains(err.Error(), "contains") { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + h.logger.Error("Failed to delete initiator group", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete initiator group"}) + return + } + + c.JSON(http.StatusOK, gin.H{"message": "initiator group deleted successfully"}) +} + +// GetInitiatorGroup retrieves an initiator group by ID +func (h *Handler) GetInitiatorGroup(c *gin.Context) { + groupID := c.Param("id") + + group, err := h.service.GetInitiatorGroup(c.Request.Context(), groupID) + if err != nil { + if strings.Contains(err.Error(), "not found") { + c.JSON(http.StatusNotFound, gin.H{"error": "initiator group not found"}) + return + } + h.logger.Error("Failed to get initiator group", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get initiator group"}) + return + } + + c.JSON(http.StatusOK, group) +} + +// ListAllInitiatorGroups lists all initiator groups +func (h *Handler) ListAllInitiatorGroups(c *gin.Context) { + groups, err := h.service.ListAllInitiatorGroups(c.Request.Context()) + if err != nil { + h.logger.Error("Failed to list initiator groups", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list initiator groups"}) + return + } + + if groups == nil { + groups = []InitiatorGroup{} + } + + c.JSON(http.StatusOK, gin.H{"groups": groups}) +} diff --git a/backend/internal/scst/service.go b/backend/internal/scst/service.go index 8ec7e55..7375d99 100644 --- a/backend/internal/scst/service.go +++ b/backend/internal/scst/service.go @@ -31,7 +31,8 @@ func NewService(db *database.DB, log *logger.Logger) *Service { type Target struct { ID string `json:"id"` IQN string `json:"iqn"` - TargetType string `json:"target_type"` // 'disk', 'vtl', 'physical_tape' + Alias string `json:"alias,omitempty"` // Alias for frontend compatibility (uses name) + TargetType string `json:"target_type"` // 'disk', 'vtl', 'physical_tape' Name string `json:"name"` Description string `json:"description"` IsActive bool `json:"is_active"` @@ -101,7 +102,7 @@ func (s *Service) CreateTarget(ctx context.Context, target *Target) error { } // Create target in SCST - cmd := exec.CommandContext(ctx, "scstadmin", "-add_target", target.IQN, "-driver", "iscsi") + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_target", target.IQN, "-driver", "iscsi") output, err := cmd.CombinedOutput() outputStr := string(output) @@ -151,44 +152,73 @@ func (s *Service) CreateTarget(ctx context.Context, target *Target) error { ).Scan(&target.ID, &target.CreatedAt, &target.UpdatedAt) if err != nil { // Rollback: remove from SCST - exec.CommandContext(ctx, "scstadmin", "-remove_target", target.IQN, "-driver", "iscsi").Run() + exec.CommandContext(ctx, "sudo", "scstadmin", "-remove_target", target.IQN, "-driver", "iscsi").Run() return fmt.Errorf("failed to save target to database: %w", err) } s.logger.Info("SCST target created", "iqn", target.IQN, "type", target.TargetType) + + // Apply active portals to the new target + if err := s.applyPortalsToTarget(ctx, target.IQN); err != nil { + s.logger.Warn("Failed to apply portals to new target", "iqn", target.IQN, "error", err) + // Don't fail target creation if portal application fails + } + return nil } -// AddLUN adds a LUN to a target +// AddLUN assigns an extent (device) to initiator groups as a LUN +// Note: The device/extent should already be created and opened in SCST +// This function only assigns the LUN to initiator groups, not to the target itself func (s *Service) AddLUN(ctx context.Context, targetIQN, deviceName, devicePath string, lunNumber int, handlerType string) error { - // Open device in SCST - openCmd := exec.CommandContext(ctx, "scstadmin", "-open_dev", deviceName, - "-handler", handlerType, - "-attributes", fmt.Sprintf("filename=%s", devicePath)) - output, err := openCmd.CombinedOutput() - if err != nil { - if !strings.Contains(string(output), "already exists") { - return fmt.Errorf("failed to open device in SCST: %s: %w", string(output), err) - } - } - - // Add LUN to target - addCmd := exec.CommandContext(ctx, "scstadmin", "-add_lun", fmt.Sprintf("%d", lunNumber), - "-target", targetIQN, - "-driver", "iscsi", - "-device", deviceName) - output, err = addCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("failed to add LUN to target: %s: %w", string(output), err) - } - // Get target ID var targetID string - err = s.db.QueryRowContext(ctx, "SELECT id FROM scst_targets WHERE iqn = $1", targetIQN).Scan(&targetID) + err := s.db.QueryRowContext(ctx, "SELECT id FROM scst_targets WHERE iqn = $1", targetIQN).Scan(&targetID) if err != nil { return fmt.Errorf("failed to get target ID: %w", err) } + // Get all initiator groups for this target + groups, err := s.GetTargetInitiatorGroups(ctx, targetID) + if err != nil { + return fmt.Errorf("failed to get initiator groups: %w", err) + } + + if len(groups) == 0 { + return fmt.Errorf("no initiator groups found for target %s. Please create an initiator group first", targetIQN) + } + + // Assign LUN to each group + // Command format: scstadmin -add_lun -driver iscsi -target -group -device + for _, group := range groups { + assignCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_lun", fmt.Sprintf("%d", lunNumber), + "-driver", "iscsi", + "-target", targetIQN, + "-group", group.GroupName, + "-device", deviceName) + output, err := assignCmd.CombinedOutput() + outputStr := string(output) + + // Log the command output for debugging + s.logger.Info("Assigning LUN to initiator group", + "target", targetIQN, + "group", group.GroupName, + "lun", lunNumber, + "device", deviceName, + "command", fmt.Sprintf("scstadmin -add_lun %d -driver iscsi -target %s -group %s -device %s", lunNumber, targetIQN, group.GroupName, deviceName), + "output", outputStr) + + if err != nil { + return fmt.Errorf("failed to assign LUN to initiator group %s: %s: %w", group.GroupName, outputStr, err) + } + + s.logger.Info("LUN assigned to initiator group", + "target", targetIQN, + "group", group.GroupName, + "lun", lunNumber, + "device", deviceName) + } + // Insert into database _, err = s.db.ExecContext(ctx, ` INSERT INTO scst_luns (target_id, lun_number, device_name, device_path, handler_type) @@ -202,7 +232,76 @@ func (s *Service) AddLUN(ctx context.Context, targetIQN, deviceName, devicePath return fmt.Errorf("failed to save LUN to database: %w", err) } - s.logger.Info("LUN added", "target", targetIQN, "lun", lunNumber, "device", deviceName) + // Write config to persist changes + configPath := "/etc/calypso/scst/generated.conf" + if err := s.WriteConfig(ctx, configPath); err != nil { + s.logger.Warn("Failed to write config after LUN assignment", "error", err) + // Don't fail the assignment if config write fails + } + + s.logger.Info("LUN assigned to initiator groups", "target", targetIQN, "lun", lunNumber, "device", deviceName) + return nil +} + +// RemoveLUN removes a LUN from a target +func (s *Service) RemoveLUN(ctx context.Context, targetIQN string, lunNumber int) error { + // Remove LUN from SCST first + // scstadmin -rem_lun may require interactive confirmation, so we pipe "y" to it + remCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-rem_lun", fmt.Sprintf("%d", lunNumber), + "-driver", "iscsi", + "-target", targetIQN, "-noprompt") + output, err := remCmd.CombinedOutput() + outputStr := string(output) + + // Log the command output for debugging + s.logger.Info("Removing LUN from SCST", "target", targetIQN, "lun", lunNumber, "output", outputStr) + + if err != nil { + // Check if LUN doesn't exist in SCST (not an error, but log it) + if strings.Contains(outputStr, "not found") || strings.Contains(outputStr, "does not exist") { + s.logger.Info("LUN not found in SCST, continuing with database deletion", "target", targetIQN, "lun", lunNumber) + } else { + // Real error - return error to prevent database deletion + s.logger.Error("Failed to remove LUN from SCST", "target", targetIQN, "lun", lunNumber, "output", outputStr, "error", err) + return fmt.Errorf("failed to remove LUN from SCST: %s: %w", outputStr, err) + } + } else { + // Command succeeded - verify by checking output + if strings.Contains(outputStr, "Removing") || strings.Contains(outputStr, "done") || strings.Contains(outputStr, "All done") { + s.logger.Info("LUN removed from SCST successfully", "target", targetIQN, "lun", lunNumber, "output", outputStr) + } else { + // Output doesn't indicate success, but no error - log warning + s.logger.Warn("LUN removal command completed but output unclear", "target", targetIQN, "lun", lunNumber, "output", outputStr) + } + } + + // Get target ID + var targetID string + err = s.db.QueryRowContext(ctx, "SELECT id FROM scst_targets WHERE iqn = $1", targetIQN).Scan(&targetID) + if err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("target not found") + } + return fmt.Errorf("failed to get target ID: %w", err) + } + + // Remove from database + _, err = s.db.ExecContext(ctx, ` + DELETE FROM scst_luns + WHERE target_id = $1 AND lun_number = $2 + `, targetID, lunNumber) + if err != nil { + return fmt.Errorf("failed to remove LUN from database: %w", err) + } + + // Write config to persist changes + configPath := "/etc/calypso/scst/generated.conf" + if err := s.WriteConfig(ctx, configPath); err != nil { + s.logger.Warn("Failed to write config after LUN deletion", "error", err) + // Don't fail the deletion if config write fails + } + + s.logger.Info("LUN removed", "target", targetIQN, "lun", lunNumber) return nil } @@ -241,7 +340,7 @@ func (s *Service) AddInitiator(ctx context.Context, targetIQN, initiatorIQN stri if err == sql.ErrNoRows { // Create group in SCST - cmd := exec.CommandContext(ctx, "scstadmin", "-add_group", groupName, + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_group", groupName, "-target", targetIQN, "-driver", "iscsi") output, err := cmd.CombinedOutput() @@ -262,7 +361,7 @@ func (s *Service) AddInitiator(ctx context.Context, targetIQN, initiatorIQN stri } // Add initiator to group in SCST - cmd := exec.CommandContext(ctx, "scstadmin", "-add_init", initiatorIQN, + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_init", initiatorIQN, "-group", groupName, "-target", targetIQN, "-driver", "iscsi") @@ -369,6 +468,77 @@ func (s *Service) ListAllInitiators(ctx context.Context) ([]InitiatorWithTarget, return initiators, rows.Err() } +// AddInitiatorToGroup adds an initiator to a specific group +func (s *Service) AddInitiatorToGroup(ctx context.Context, groupID, initiatorIQN string) error { + // Get group info + var groupName, targetIQN string + var targetID string + var singleInitiatorOnly bool + err := s.db.QueryRowContext(ctx, ` + SELECT ig.group_name, t.iqn, t.id, t.single_initiator_only + FROM scst_initiator_groups ig + JOIN scst_targets t ON ig.target_id = t.id + WHERE ig.id = $1 + `, groupID).Scan(&groupName, &targetIQN, &targetID, &singleInitiatorOnly) + if err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("initiator group not found") + } + return fmt.Errorf("failed to get initiator group: %w", err) + } + + // Check single initiator policy + if singleInitiatorOnly { + var existingCount int + s.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM scst_initiators WHERE group_id IN (SELECT id FROM scst_initiator_groups WHERE target_id = $1)", + targetID, + ).Scan(&existingCount) + if existingCount > 0 { + return fmt.Errorf("target enforces single initiator only") + } + } + + // Check if initiator already exists in this group + var existingID string + err = s.db.QueryRowContext(ctx, + "SELECT id FROM scst_initiators WHERE group_id = $1 AND iqn = $2", + groupID, initiatorIQN, + ).Scan(&existingID) + if err == nil { + return fmt.Errorf("initiator '%s' already exists in this group", initiatorIQN) + } else if err != sql.ErrNoRows { + return fmt.Errorf("failed to check existing initiator: %w", err) + } + + // Add initiator to group in SCST + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_init", initiatorIQN, + "-group", groupName, + "-target", targetIQN, + "-driver", "iscsi") + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to add initiator to SCST: %s: %w", string(output), err) + } + + // Insert into database + _, err = s.db.ExecContext(ctx, ` + INSERT INTO scst_initiators (group_id, iqn, is_active) + VALUES ($1, $2, true) + `, groupID, initiatorIQN) + if err != nil { + // Try to remove from SCST if database insert fails + exec.CommandContext(ctx, "sudo", "scstadmin", "-rem_init", initiatorIQN, + "-group", groupName, + "-target", targetIQN, + "-driver", "iscsi").Run() + return fmt.Errorf("failed to save initiator to database: %w", err) + } + + s.logger.Info("Initiator added to group", "group", groupName, "initiator", initiatorIQN, "target", targetIQN) + return nil +} + // RemoveInitiator removes an initiator from a target func (s *Service) RemoveInitiator(ctx context.Context, initiatorID string) error { // Get initiator info @@ -388,7 +558,8 @@ func (s *Service) RemoveInitiator(ctx context.Context, initiatorID string) error } // Remove from SCST - cmd := exec.CommandContext(ctx, "scstadmin", "-remove_init", initiatorIQN, + // Note: scstadmin uses -rem_init (not -remove_init) + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-rem_init", initiatorIQN, "-group", groupName, "-target", targetIQN, "-driver", "iscsi") @@ -468,6 +639,313 @@ func (s *Service) getGroupInitiators(ctx context.Context, groupID string) ([]Ini return initiators, rows.Err() } +// CreateInitiatorGroup creates a new initiator group for a target +func (s *Service) CreateInitiatorGroup(ctx context.Context, targetID, groupName string) (*InitiatorGroup, error) { + // Get target IQN + var targetIQN string + err := s.db.QueryRowContext(ctx, "SELECT iqn FROM scst_targets WHERE id = $1", targetID).Scan(&targetIQN) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("target not found") + } + return nil, fmt.Errorf("failed to get target: %w", err) + } + + // Validate group name + if groupName == "" { + return nil, fmt.Errorf("group name cannot be empty") + } + + // Check if group already exists + var existingID string + err = s.db.QueryRowContext(ctx, + "SELECT id FROM scst_initiator_groups WHERE target_id = $1 AND group_name = $2", + targetID, groupName, + ).Scan(&existingID) + if err == nil { + return nil, fmt.Errorf("initiator group with name '%s' already exists for this target", groupName) + } else if err != sql.ErrNoRows { + return nil, fmt.Errorf("failed to check existing group: %w", err) + } + + // Create group in SCST + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_group", groupName, + "-target", targetIQN, + "-driver", "iscsi") + output, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("failed to create initiator group in SCST: %s: %w", string(output), err) + } + + // Insert into database + var groupID string + err = s.db.QueryRowContext(ctx, + "INSERT INTO scst_initiator_groups (target_id, group_name) VALUES ($1, $2) RETURNING id", + targetID, groupName, + ).Scan(&groupID) + if err != nil { + // Try to remove from SCST if database insert fails + // scstadmin -rem_group requires interactive confirmation, so we pipe "y" to it + rollbackCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-rem_group", groupName, + "-target", targetIQN, + "-driver", "iscsi", "-noprompt") + rollbackCmd.Run() + return nil, fmt.Errorf("failed to save group to database: %w", err) + } + + group := &InitiatorGroup{ + ID: groupID, + TargetID: targetID, + GroupName: groupName, + Initiators: []Initiator{}, + CreatedAt: time.Now(), + } + + s.logger.Info("Initiator group created", "target_id", targetID, "group_name", groupName) + return group, nil +} + +// UpdateInitiatorGroup updates an initiator group (rename) +func (s *Service) UpdateInitiatorGroup(ctx context.Context, groupID, newGroupName string) (*InitiatorGroup, error) { + // Get group info + var targetIQN, oldGroupName string + err := s.db.QueryRowContext(ctx, ` + SELECT ig.group_name, t.iqn + FROM scst_initiator_groups ig + JOIN scst_targets t ON ig.target_id = t.id + WHERE ig.id = $1 + `, groupID).Scan(&oldGroupName, &targetIQN) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("initiator group not found") + } + return nil, fmt.Errorf("failed to get initiator group: %w", err) + } + + // Validate new name + if newGroupName == "" { + return nil, fmt.Errorf("group name cannot be empty") + } + + if newGroupName == oldGroupName { + // No change, just return the group + return s.GetInitiatorGroup(ctx, groupID) + } + + // Check if new name already exists for this target + var targetID string + err = s.db.QueryRowContext(ctx, "SELECT target_id FROM scst_initiator_groups WHERE id = $1", groupID).Scan(&targetID) + if err != nil { + return nil, fmt.Errorf("failed to get target_id: %w", err) + } + + var existingID string + err = s.db.QueryRowContext(ctx, + "SELECT id FROM scst_initiator_groups WHERE target_id = $1 AND group_name = $2 AND id != $3", + targetID, newGroupName, groupID, + ).Scan(&existingID) + if err == nil { + return nil, fmt.Errorf("initiator group with name '%s' already exists for this target", newGroupName) + } else if err != sql.ErrNoRows { + return nil, fmt.Errorf("failed to check existing group: %w", err) + } + + // Update in SCST (remove old, add new) + // Note: SCST doesn't support rename directly, so we need to recreate + // First, get all initiators in this group + initiators, err := s.getGroupInitiators(ctx, groupID) + if err != nil { + return nil, fmt.Errorf("failed to get initiators: %w", err) + } + + // Remove old group from SCST + // scstadmin -rem_group requires interactive confirmation, so we pipe "y" to it + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-rem_group", oldGroupName, + "-target", targetIQN, + "-driver", "iscsi", "-noprompt") + output, err := cmd.CombinedOutput() + if err != nil { + outputStr := string(output) + if !strings.Contains(outputStr, "not found") && !strings.Contains(outputStr, "does not exist") { + return nil, fmt.Errorf("failed to remove old group from SCST: %s: %w", outputStr, err) + } + } + + // Create new group in SCST + cmd = exec.CommandContext(ctx, "sudo", "scstadmin", "-add_group", newGroupName, + "-target", targetIQN, + "-driver", "iscsi") + output, err = cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("failed to create new group in SCST: %s: %w", string(output), err) + } + + // Re-add all initiators to the new group + for _, initiator := range initiators { + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_init", initiator.IQN, + "-group", newGroupName, + "-target", targetIQN, + "-driver", "iscsi") + output, err := cmd.CombinedOutput() + if err != nil { + s.logger.Warn("Failed to re-add initiator to renamed group", + "initiator", initiator.IQN, "group", newGroupName, "error", string(output)) + } + } + + // Update in database + _, err = s.db.ExecContext(ctx, + "UPDATE scst_initiator_groups SET group_name = $1 WHERE id = $2", + newGroupName, groupID) + if err != nil { + // Try to restore old group in SCST + exec.CommandContext(ctx, "sudo", "scstadmin", "-add_group", oldGroupName, + "-target", targetIQN, + "-driver", "iscsi").Run() + return nil, fmt.Errorf("failed to update group in database: %w", err) + } + + s.logger.Info("Initiator group updated", "group_id", groupID, "old_name", oldGroupName, "new_name", newGroupName) + return s.GetInitiatorGroup(ctx, groupID) +} + +// DeleteInitiatorGroup deletes an initiator group +func (s *Service) DeleteInitiatorGroup(ctx context.Context, groupID string) error { + // Get group info + var targetIQN, groupName string + var initiatorCount int + err := s.db.QueryRowContext(ctx, ` + SELECT ig.group_name, t.iqn, COUNT(i.id) + FROM scst_initiator_groups ig + JOIN scst_targets t ON ig.target_id = t.id + LEFT JOIN scst_initiators i ON i.group_id = ig.id + WHERE ig.id = $1 + GROUP BY ig.group_name, t.iqn + `, groupID).Scan(&groupName, &targetIQN, &initiatorCount) + if err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("initiator group not found") + } + return fmt.Errorf("failed to get initiator group: %w", err) + } + + // Prevent deletion if group has initiators + if initiatorCount > 0 { + return fmt.Errorf("cannot delete initiator group: group contains %d initiator(s). Please remove all initiators first", initiatorCount) + } + + // Remove from SCST first - this must succeed before deleting from database + // scstadmin -rem_group requires interactive confirmation, so we pipe "y" to it + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-rem_group", groupName, + "-target", targetIQN, + "-driver", "iscsi", "-noprompt") + output, err := cmd.CombinedOutput() + outputStr := string(output) + + // Log the command output for debugging + s.logger.Info("Removing group from SCST", "group", groupName, "target", targetIQN, "output", outputStr) + + if err != nil { + // Check if group doesn't exist in SCST (not an error, but log it) + if strings.Contains(outputStr, "not found") || strings.Contains(outputStr, "does not exist") { + s.logger.Info("Group not found in SCST, continuing with database deletion", "group", groupName, "target", targetIQN) + } else { + // Real error - return error to prevent database deletion + s.logger.Error("Failed to remove group from SCST", "group", groupName, "target", targetIQN, "output", outputStr, "error", err) + return fmt.Errorf("failed to remove group from SCST: %s: %w", outputStr, err) + } + } else { + // Command succeeded - verify by checking output + if strings.Contains(outputStr, "Removing group") || strings.Contains(outputStr, "done") || strings.Contains(outputStr, "All done") { + s.logger.Info("Group removed from SCST successfully", "group", groupName, "target", targetIQN, "output", outputStr) + } else { + // Output doesn't indicate success, but no error - log warning + s.logger.Warn("Group removal command completed but output unclear", "group", groupName, "target", targetIQN, "output", outputStr) + } + } + + // Delete from database (cascade will handle initiators if any) + _, err = s.db.ExecContext(ctx, "DELETE FROM scst_initiator_groups WHERE id = $1", groupID) + if err != nil { + return fmt.Errorf("failed to delete group from database: %w", err) + } + + // Write config to persist changes + configPath := "/etc/calypso/scst/generated.conf" + if err := s.WriteConfig(ctx, configPath); err != nil { + s.logger.Warn("Failed to write config after group deletion", "error", err) + // Don't fail the deletion if config write fails + } + + s.logger.Info("Initiator group deleted", "group_id", groupID, "group_name", groupName, "target", targetIQN) + return nil +} + +// GetInitiatorGroup retrieves a single initiator group by ID +func (s *Service) GetInitiatorGroup(ctx context.Context, groupID string) (*InitiatorGroup, error) { + var group InitiatorGroup + err := s.db.QueryRowContext(ctx, ` + SELECT id, target_id, group_name, created_at + FROM scst_initiator_groups + WHERE id = $1 + `, groupID).Scan(&group.ID, &group.TargetID, &group.GroupName, &group.CreatedAt) + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("initiator group not found") + } + return nil, fmt.Errorf("failed to get initiator group: %w", err) + } + + // Get initiators for this group + initiators, err := s.getGroupInitiators(ctx, groupID) + if err != nil { + s.logger.Warn("Failed to get initiators for group", "group_id", groupID, "error", err) + group.Initiators = []Initiator{} + } else { + group.Initiators = initiators + } + + return &group, nil +} + +// ListAllInitiatorGroups lists all initiator groups across all targets +func (s *Service) ListAllInitiatorGroups(ctx context.Context) ([]InitiatorGroup, error) { + query := ` + SELECT id, target_id, group_name, created_at + FROM scst_initiator_groups + ORDER BY target_id, group_name + ` + + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, fmt.Errorf("failed to list initiator groups: %w", err) + } + defer rows.Close() + + var groups []InitiatorGroup + for rows.Next() { + var group InitiatorGroup + err := rows.Scan(&group.ID, &group.TargetID, &group.GroupName, &group.CreatedAt) + if err != nil { + s.logger.Error("Failed to scan initiator group", "error", err) + continue + } + + // Get initiators for this group + initiators, err := s.getGroupInitiators(ctx, group.ID) + if err != nil { + s.logger.Warn("Failed to get initiators for group", "group_id", group.ID, "error", err) + group.Initiators = []Initiator{} + } else { + group.Initiators = initiators + } + + groups = append(groups, group) + } + + return groups, rows.Err() +} + // ListTargets lists all SCST targets func (s *Service) ListTargets(ctx context.Context) ([]Target, error) { query := ` @@ -505,6 +983,8 @@ func (s *Service) ListTargets(ctx context.Context) ([]Target, error) { if description.Valid { target.Description = description.String } + // Set alias to name for frontend compatibility + target.Alias = target.Name targets = append(targets, target) } @@ -558,7 +1038,7 @@ func (s *Service) GetTarget(ctx context.Context, id string) (*Target, error) { // getTargetEnabledStatus reads enabled status from SCST func (s *Service) getTargetEnabledStatus(ctx context.Context, targetIQN string) (bool, error) { // Read SCST config to check if target is enabled - cmd := exec.CommandContext(ctx, "scstadmin", "-write_config", "/tmp/scst_target_check.conf") + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-write_config", "/tmp/scst_target_check.conf") output, err := cmd.CombinedOutput() if err != nil { return false, fmt.Errorf("failed to write config: %s: %w", string(output), err) @@ -607,7 +1087,7 @@ func (s *Service) getTargetEnabledStatus(ctx context.Context, targetIQN string) // EnableTarget enables a target in SCST func (s *Service) EnableTarget(ctx context.Context, targetIQN string) error { - cmd := exec.CommandContext(ctx, "scstadmin", "-enable_target", targetIQN, "-driver", "iscsi") + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-enable_target", targetIQN, "-driver", "iscsi") output, err := cmd.CombinedOutput() if err != nil { outputStr := string(output) @@ -630,7 +1110,7 @@ func (s *Service) EnableTarget(ctx context.Context, targetIQN string) error { // DisableTarget disables a target in SCST func (s *Service) DisableTarget(ctx context.Context, targetIQN string) error { - cmd := exec.CommandContext(ctx, "scstadmin", "-disable_target", targetIQN, "-driver", "iscsi") + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-disable_target", targetIQN, "-driver", "iscsi") output, err := cmd.CombinedOutput() if err != nil { outputStr := string(output) @@ -651,6 +1131,67 @@ func (s *Service) DisableTarget(ctx context.Context, targetIQN string) error { return nil } +// DeleteTarget deletes a target from SCST and database +func (s *Service) DeleteTarget(ctx context.Context, targetID string) error { + // Get target IQN before deletion + var targetIQN string + err := s.db.QueryRowContext(ctx, "SELECT iqn FROM scst_targets WHERE id = $1", targetID).Scan(&targetIQN) + if err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("target not found") + } + return fmt.Errorf("failed to get target: %w", err) + } + + // Check if target has LUNs - warn but allow deletion + var lunCount int + s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM scst_luns WHERE target_id = $1", targetID).Scan(&lunCount) + if lunCount > 0 { + s.logger.Warn("Deleting target with LUNs", "target", targetIQN, "lun_count", lunCount) + } + + // Check if target has initiators - warn but allow deletion + var initiatorCount int + s.db.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM scst_initiators + WHERE group_id IN (SELECT id FROM scst_initiator_groups WHERE target_id = $1) + `, targetID).Scan(&initiatorCount) + if initiatorCount > 0 { + s.logger.Warn("Deleting target with initiators", "target", targetIQN, "initiator_count", initiatorCount) + } + + // Remove target from SCST first + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-remove_target", targetIQN, "-driver", "iscsi") + output, err := cmd.CombinedOutput() + outputStr := string(output) + + if err != nil { + // Check if target doesn't exist in SCST (not an error) + if strings.Contains(outputStr, "not found") || strings.Contains(outputStr, "does not exist") { + s.logger.Warn("Target not found in SCST, continuing with database deletion", "target", targetIQN) + } else { + // Log error but continue with database deletion + s.logger.Warn("Failed to remove target from SCST", "target", targetIQN, "output", outputStr) + } + } + + // Delete from database (cascade will handle related records) + _, err = s.db.ExecContext(ctx, "DELETE FROM scst_targets WHERE id = $1", targetID) + if err != nil { + return fmt.Errorf("failed to delete target from database: %w", err) + } + + // Write config to persist changes + configPath := "/etc/calypso/scst/generated.conf" + if err := s.WriteConfig(ctx, configPath); err != nil { + s.logger.Warn("Failed to write config after target deletion", "error", err) + // Don't fail the deletion if config write fails + } + + s.logger.Info("Target deleted", "iqn", targetIQN, "id", targetID) + return nil +} + // GetTargetLUNs retrieves all LUNs for a target // It reads from SCST first, then syncs with database func (s *Service) GetTargetLUNs(ctx context.Context, targetID string) ([]LUN, error) { @@ -749,7 +1290,7 @@ func (s *Service) getLUNsFromDatabase(ctx context.Context, targetID string) ([]L // readLUNsFromSCST reads LUNs directly from SCST using scstadmin -list_group func (s *Service) readLUNsFromSCST(ctx context.Context, targetIQN string) ([]LUN, error) { - cmd := exec.CommandContext(ctx, "scstadmin", "-list_group", + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-list_group", "-target", targetIQN, "-driver", "iscsi") output, err := cmd.CombinedOutput() @@ -770,17 +1311,42 @@ func (s *Service) readLUNsFromSCST(ctx context.Context, targetIQN string) ([]LUN lines := strings.Split(string(output), "\n") var luns []LUN - inLUNSection := false + inTargetLUNSection := false + foundTarget := false for _, line := range lines { line = strings.TrimSpace(line) - // Check if we're in the LUN section - if strings.Contains(line, "Assigned LUNs:") { - inLUNSection = true + // Check if we found our target + if strings.HasPrefix(line, "Target:") { + targetLine := strings.TrimSpace(strings.TrimPrefix(line, "Target:")) + if targetLine == targetIQN { + foundTarget = true + continue + } else { + // Different target, reset + foundTarget = false + inTargetLUNSection = false + continue + } + } + + // Only process if we found our target + if !foundTarget { continue } + // Check if we're in the LUN section for this target + if strings.Contains(line, "Assigned LUNs:") { + inTargetLUNSection = true + continue + } + + // Stop if we hit a Group section (LUNs after this are group-specific) + if strings.HasPrefix(line, "Group:") { + break + } + // Skip separator line if strings.HasPrefix(line, "---") { continue @@ -791,8 +1357,8 @@ func (s *Service) readLUNsFromSCST(ctx context.Context, targetIQN string) ([]LUN continue } - // Parse LUN lines (format: "1 LUN01") - if inLUNSection && line != "" { + // Parse LUN lines (format: "0 pbs-test") + if inTargetLUNSection && line != "" { parts := strings.Fields(line) if len(parts) >= 2 { var lunNumber int @@ -822,7 +1388,7 @@ func (s *Service) readLUNsFromSCST(ctx context.Context, targetIQN string) ([]LUN // we try to get handler type from device list, and device path from database if available func (s *Service) getDeviceInfo(ctx context.Context, deviceName string) (string, string, error) { // Find which handler this device belongs to - listCmd := exec.CommandContext(ctx, "scstadmin", "-list_device") + listCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-list_device") output, err := listCmd.Output() if err != nil { return "", "", fmt.Errorf("failed to list devices: %w", err) @@ -882,7 +1448,7 @@ func (s *Service) getDeviceInfo(ctx context.Context, deviceName string) (string, // getDevicePathFromConfig reads device path from SCST config file func (s *Service) getDevicePathFromConfig(deviceName, handlerType string) string { // Write current config to temp file - cmd := exec.Command("scstadmin", "-write_config", "/tmp/scst_device_info.conf") + cmd := exec.Command("sudo", "scstadmin", "-write_config", "/tmp/scst_device_info.conf") if err := cmd.Run(); err != nil { s.logger.Warn("Failed to write SCST config", "error", err) return "" @@ -939,7 +1505,7 @@ func (s *Service) getDevicePathFromConfig(deviceName, handlerType string) string // ListExtents lists all device extents (opened devices) in SCST func (s *Service) ListExtents(ctx context.Context) ([]Extent, error) { // List all devices from SCST - cmd := exec.CommandContext(ctx, "scstadmin", "-list_device") + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-list_device") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to list devices: %w", err) @@ -1041,7 +1607,7 @@ func (s *Service) CreateExtent(ctx context.Context, deviceName, devicePath, hand } // Open device in SCST - openCmd := exec.CommandContext(ctx, "scstadmin", "-open_dev", deviceName, + openCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-open_dev", deviceName, "-handler", handlerType, "-attributes", fmt.Sprintf("filename=%s", devicePath)) output, err := openCmd.CombinedOutput() @@ -1057,6 +1623,70 @@ func (s *Service) CreateExtent(ctx context.Context, deviceName, devicePath, hand return nil } +// getDeviceHandlerType gets the handler type for a device by querying SCST +func (s *Service) getDeviceHandlerType(ctx context.Context, deviceName string) (string, error) { + // First, try to get from database if device was used by a LUN + var handlerType string + err := s.db.QueryRowContext(ctx, + "SELECT handler_type FROM scst_luns WHERE device_name = $1 LIMIT 1", + deviceName, + ).Scan(&handlerType) + if err == nil && handlerType != "" { + return handlerType, nil + } + + // If not in database, query from SCST + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-list_device") + output, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("failed to list devices: %w", err) + } + + // Parse output to find handler type for this device + lines := strings.Split(string(output), "\n") + inHandlerSection := false + for _, line := range lines { + line = strings.TrimSpace(line) + + // Check if we're in a handler section + if strings.HasPrefix(line, "HANDLER") { + inHandlerSection = true + continue + } + + // Skip separator + if strings.HasPrefix(line, "---") { + continue + } + + // Skip header/footer lines + if strings.Contains(line, "Collecting") || strings.Contains(line, "All done") { + continue + } + + // Parse handler and device lines + if inHandlerSection && line != "" { + parts := strings.Fields(line) + if len(parts) >= 2 { + parsedHandlerType := parts[0] + parsedDeviceName := parts[1] + + // Skip if device is "-" (no device opened for this handler) + if parsedDeviceName == "-" { + continue + } + + // Found the device, return its handler type + if parsedDeviceName == deviceName { + return parsedHandlerType, nil + } + } + } + } + + return "", fmt.Errorf("device %s not found in SCST", deviceName) +} + // DeleteExtent closes a device in SCST (removes an extent) func (s *Service) DeleteExtent(ctx context.Context, deviceName string) error { // Check if device is in use by any LUN @@ -1069,18 +1699,102 @@ func (s *Service) DeleteExtent(ctx context.Context, deviceName string) error { return fmt.Errorf("device %s is in use by %d LUN(s). Remove LUNs first", deviceName, lunCount) } - // Close device in SCST - closeCmd := exec.CommandContext(ctx, "scstadmin", "-close_dev", deviceName) - output, err := closeCmd.CombinedOutput() + // Get handler type for this device + handlerType, err := s.getDeviceHandlerType(ctx, deviceName) if err != nil { - outputStr := string(output) - if strings.Contains(outputStr, "not found") { - return fmt.Errorf("device %s not found", deviceName) + // If device not found, check if it's already closed + if strings.Contains(err.Error(), "not found") { + s.logger.Info("Device not found in SCST, may already be closed", "device", deviceName) + return nil // Idempotent - device already closed + } + return fmt.Errorf("failed to get handler type: %w", err) + } + + // Close device in SCST with handler type + // Command format: scstadmin -close_dev -handler + closeCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-close_dev", deviceName, + "-handler", handlerType) + output, err := closeCmd.CombinedOutput() + outputStr := string(output) + + // Log the command output for debugging + s.logger.Info("Closing device in SCST", "device", deviceName, "handler", handlerType, "command", fmt.Sprintf("scstadmin -close_dev %s -handler %s", deviceName, handlerType), "output", outputStr) + + if err != nil { + // Check if device doesn't exist in SCST (not an error, but log it) + if strings.Contains(outputStr, "not found") || strings.Contains(outputStr, "does not exist") { + s.logger.Info("Device not found in SCST, may already be closed", "device", deviceName) + return nil // Idempotent - device already closed } return fmt.Errorf("failed to close device: %s: %w", outputStr, err) } - s.logger.Info("Extent deleted", "device", deviceName) + // Always verify device is actually closed by checking if it still exists + // This ensures the command really succeeded even if output is unclear + verifyCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-list_device") + verifyOutput, verifyErr := verifyCmd.CombinedOutput() + if verifyErr == nil { + verifyOutputStr := string(verifyOutput) + // Parse output to check if device still exists + lines := strings.Split(verifyOutputStr, "\n") + inHandlerSection := false + deviceStillExists := false + + for _, line := range lines { + line = strings.TrimSpace(line) + + // Check if we're in a handler section + if strings.HasPrefix(line, "HANDLER") { + inHandlerSection = true + continue + } + + // Skip separator + if strings.HasPrefix(line, "---") { + continue + } + + // Skip header/footer lines + if strings.Contains(line, "Collecting") || strings.Contains(line, "All done") { + continue + } + + // Parse handler and device lines + if inHandlerSection && line != "" { + parts := strings.Fields(line) + if len(parts) >= 2 { + parsedDeviceName := parts[1] + // Skip if device is "-" (no device opened for this handler) + if parsedDeviceName == "-" { + continue + } + // Check if this is the device we're trying to close + if parsedDeviceName == deviceName { + deviceStillExists = true + break + } + } + } + } + + if deviceStillExists { + // Device still exists - command failed + return fmt.Errorf("device %s still exists after close command. Command output: %s", deviceName, outputStr) + } + s.logger.Info("Device verified as closed", "device", deviceName) + } else { + // If verification fails, log warning but don't fail (command may have succeeded) + s.logger.Warn("Failed to verify device closure", "device", deviceName, "error", verifyErr) + } + + // Write config to persist changes + configPath := "/etc/calypso/scst/generated.conf" + if err := s.WriteConfig(ctx, configPath); err != nil { + s.logger.Warn("Failed to write config after extent deletion", "error", err) + // Don't fail the deletion if config write fails + } + + s.logger.Info("Extent deleted successfully", "device", deviceName, "handler", handlerType) return nil } @@ -1106,7 +1820,7 @@ func (s *Service) getDeviceTypeLabel(handlerType string) string { // WriteConfig writes SCST configuration to file func (s *Service) WriteConfig(ctx context.Context, configPath string) error { - cmd := exec.CommandContext(ctx, "scstadmin", "-write_config", configPath) + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-write_config", configPath) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("failed to write SCST config: %s: %w", string(output), err) @@ -1125,7 +1839,7 @@ type HandlerInfo struct { // DetectHandlers detects available SCST handlers func (s *Service) DetectHandlers(ctx context.Context) ([]HandlerInfo, error) { - cmd := exec.CommandContext(ctx, "scstadmin", "-list_handler") + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-list_handler") output, err := cmd.Output() if err != nil { return nil, fmt.Errorf("failed to list handlers: %w", err) @@ -1284,6 +1998,15 @@ func (s *Service) CreatePortal(ctx context.Context, portal *Portal) error { } s.logger.Info("Portal created", "ip", portal.IPAddress, "port", portal.Port) + + // Apply portal to all existing targets if active + if portal.IsActive { + if err := s.applyPortalToAllTargets(ctx, portal.IPAddress, portal.Port); err != nil { + s.logger.Warn("Failed to apply portal to targets", "ip", portal.IPAddress, "port", portal.Port, "error", err) + // Don't fail portal creation if application fails + } + } + return nil } @@ -1294,6 +2017,19 @@ func (s *Service) UpdatePortal(ctx context.Context, id string, portal *Portal) e return fmt.Errorf("port must be between 1 and 65535") } + // Get old portal data before update + var oldIPAddress string + var oldPort int + var oldIsActive bool + err := s.db.QueryRowContext(ctx, "SELECT ip_address, port, is_active FROM scst_portals WHERE id = $1", id).Scan(&oldIPAddress, &oldPort, &oldIsActive) + if err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("portal not found") + } + return fmt.Errorf("failed to get portal info: %w", err) + } + + // Update in database query := ` UPDATE scst_portals SET ip_address = $1, port = $2, is_active = $3, updated_at = NOW() @@ -1301,22 +2037,61 @@ func (s *Service) UpdatePortal(ctx context.Context, id string, portal *Portal) e RETURNING updated_at ` - err := s.db.QueryRowContext(ctx, query, + err = s.db.QueryRowContext(ctx, query, portal.IPAddress, portal.Port, portal.IsActive, id, ).Scan(&portal.UpdatedAt) if err != nil { - if err == sql.ErrNoRows { - return fmt.Errorf("portal not found") - } return fmt.Errorf("failed to update portal: %w", err) } s.logger.Info("Portal updated", "id", id, "ip", portal.IPAddress, "port", portal.Port) + + // Handle SCST updates + if portal.IsActive { + // Portal is active - apply to all targets + // Remove old portal if IP or port changed + if oldIPAddress != portal.IPAddress || oldPort != portal.Port { + if err := s.removePortalFromAllTargets(ctx, oldIPAddress, oldPort); err != nil { + s.logger.Warn("Failed to remove old portal from targets", "ip", oldIPAddress, "port", oldPort, "error", err) + } + } + // Apply new portal to all targets + if err := s.applyPortalToAllTargets(ctx, portal.IPAddress, portal.Port); err != nil { + s.logger.Warn("Failed to apply portal to targets", "ip", portal.IPAddress, "port", portal.Port, "error", err) + } + } else { + // Portal is inactive - remove from all targets + // Only remove if it was previously active + if oldIsActive { + if err := s.removePortalFromAllTargets(ctx, oldIPAddress, oldPort); err != nil { + s.logger.Warn("Failed to remove portal from targets", "ip", oldIPAddress, "port", oldPort, "error", err) + } + } + } + return nil } // DeletePortal deletes a portal func (s *Service) DeletePortal(ctx context.Context, id string) error { + // Get portal info before deletion to remove from SCST + var ipAddress string + var port int + err := s.db.QueryRowContext(ctx, "SELECT ip_address, port FROM scst_portals WHERE id = $1", id).Scan(&ipAddress, &port) + if err != nil { + if err == sql.ErrNoRows { + return fmt.Errorf("portal not found") + } + return fmt.Errorf("failed to get portal info: %w", err) + } + + // Remove portal from all targets before deleting from database + if err := s.removePortalFromAllTargets(ctx, ipAddress, port); err != nil { + s.logger.Warn("Failed to remove portal from targets", "ip", ipAddress, "port", port, "error", err) + // Continue with deletion even if removal fails + } + + // Delete from database result, err := s.db.ExecContext(ctx, "DELETE FROM scst_portals WHERE id = $1", id) if err != nil { return fmt.Errorf("failed to delete portal: %w", err) @@ -1357,3 +2132,183 @@ func (s *Service) GetPortal(ctx context.Context, id string) (*Portal, error) { return &portal, nil } + +// applyPortalsToTarget applies all active portals to a specific target +func (s *Service) applyPortalsToTarget(ctx context.Context, targetIQN string) error { + // Get all active portals + query := ` + SELECT ip_address, port + FROM scst_portals + WHERE is_active = true + ORDER BY ip_address, port + ` + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to query portals: %w", err) + } + defer rows.Close() + + var portals []struct { + IPAddress string + Port int + } + for rows.Next() { + var portal struct { + IPAddress string + Port int + } + if err := rows.Scan(&portal.IPAddress, &portal.Port); err != nil { + s.logger.Warn("Failed to scan portal", "error", err) + continue + } + portals = append(portals, portal) + } + + // Apply each portal to the target + for _, portal := range portals { + if err := s.addPortalToTarget(ctx, targetIQN, portal.IPAddress, portal.Port); err != nil { + s.logger.Warn("Failed to add portal to target", "target", targetIQN, "ip", portal.IPAddress, "port", portal.Port, "error", err) + // Continue with other portals even if one fails + } + } + + return nil +} + +// applyPortalToAllTargets applies a portal to all existing targets +func (s *Service) applyPortalToAllTargets(ctx context.Context, ipAddress string, port int) error { + // Ensure the iSCSI target driver is enabled + enableCmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-set_drv_attr", "iscsi", "-attributes", "enabled=1") + if output, err := enableCmd.CombinedOutput(); err != nil { + s.logger.Warn("Could not enable iscsi target driver", "error", string(output)) + // Don't fail here, as it might already be enabled or another issue might be present. + } + + // Get all iSCSI targets + query := ` + SELECT iqn + FROM scst_targets + WHERE target_type IN ('disk', 'vtl', 'physical_tape') + ORDER BY iqn + ` + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to query targets: %w", err) + } + defer rows.Close() + + var targets []string + for rows.Next() { + var iqn string + if err := rows.Scan(&iqn); err != nil { + s.logger.Warn("Failed to scan target", "error", err) + continue + } + targets = append(targets, iqn) + } + + // Apply portal to each target + for _, targetIQN := range targets { + if err := s.addPortalToTarget(ctx, targetIQN, ipAddress, port); err != nil { + s.logger.Warn("Failed to add portal to target", "target", targetIQN, "ip", ipAddress, "port", port, "error", err) + // Continue with other targets even if one fails + } + } + + return nil +} + +// removePortalFromAllTargets removes a portal from all existing targets +func (s *Service) removePortalFromAllTargets(ctx context.Context, ipAddress string, port int) error { + // Get all iSCSI targets + query := ` + SELECT iqn + FROM scst_targets + WHERE target_type IN ('disk', 'vtl', 'physical_tape') + ORDER BY iqn + ` + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return fmt.Errorf("failed to query targets: %w", err) + } + defer rows.Close() + + var targets []string + for rows.Next() { + var iqn string + if err := rows.Scan(&iqn); err != nil { + s.logger.Warn("Failed to scan target", "error", err) + continue + } + targets = append(targets, iqn) + } + + // Remove portal from each target + for _, targetIQN := range targets { + if err := s.removePortalFromTarget(ctx, targetIQN, ipAddress, port); err != nil { + s.logger.Warn("Failed to remove portal from target", "target", targetIQN, "ip", ipAddress, "port", port, "error", err) + // Continue with other targets even if one fails + } + } + + return nil +} + +// addPortalToTarget adds a portal to a specific target in SCST +func (s *Service) addPortalToTarget(ctx context.Context, targetIQN, ipAddress string, port int) error { + // Format: IP:PORT + portalAddr := fmt.Sprintf("%s:%d", ipAddress, port) + + // Use scstadmin to add portal to target + // Note: SCST uses different syntax depending on version + // Try the standard syntax first: -add_portal -target -driver iscsi + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-add_portal", portalAddr, + "-target", targetIQN, + "-driver", "iscsi") + output, err := cmd.CombinedOutput() + outputStr := string(output) + + if err != nil { + // Check if portal already exists (not an error) + if strings.Contains(outputStr, "already exists") || strings.Contains(outputStr, "already added") { + s.logger.Debug("Portal already exists on target", "target", targetIQN, "portal", portalAddr) + return nil + } + + // Some SCST versions use different syntax or portal might be configured differently + // Log warning but don't fail - portal configuration might be handled by iscsi-scstd + s.logger.Warn("Failed to add portal to target (may be handled by iscsi-scstd)", "target", targetIQN, "portal", portalAddr, "output", outputStr) + return fmt.Errorf("failed to add portal: %s: %w", outputStr, err) + } + + s.logger.Info("Portal added to target", "target", targetIQN, "portal", portalAddr) + return nil +} + +// removePortalFromTarget removes a portal from a specific target in SCST +func (s *Service) removePortalFromTarget(ctx context.Context, targetIQN, ipAddress string, port int) error { + // Format: IP:PORT + portalAddr := fmt.Sprintf("%s:%d", ipAddress, port) + + // Use scstadmin to remove portal from target + cmd := exec.CommandContext(ctx, "sudo", "scstadmin", "-remove_portal", portalAddr, + "-target", targetIQN, + "-driver", "iscsi") + output, err := cmd.CombinedOutput() + outputStr := string(output) + + if err != nil { + // Check if portal doesn't exist (not an error) + if strings.Contains(outputStr, "not found") || strings.Contains(outputStr, "does not exist") { + s.logger.Debug("Portal not found on target", "target", targetIQN, "portal", portalAddr) + return nil + } + + // Log warning but don't fail + s.logger.Warn("Failed to remove portal from target", "target", targetIQN, "portal", portalAddr, "output", outputStr) + return fmt.Errorf("failed to remove portal: %s: %w", outputStr, err) + } + + s.logger.Info("Portal removed from target", "target", targetIQN, "portal", portalAddr) + return nil +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index a12109e..d489344 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -5,6 +5,9 @@ const apiClient = axios.create({ baseURL: '/api/v1', headers: { 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Pragma': 'no-cache', + 'Expires': '0', }, }) diff --git a/frontend/src/api/scst.ts b/frontend/src/api/scst.ts index d7632a7..6663f2f 100644 --- a/frontend/src/api/scst.ts +++ b/frontend/src/api/scst.ts @@ -88,7 +88,14 @@ export interface AddInitiatorRequest { export const scstAPI = { listTargets: async (): Promise => { - const response = await apiClient.get('/scst/targets') + const response = await apiClient.get('/scst/targets', { + headers: { + 'Cache-Control': 'no-cache', + }, + params: { + _t: Date.now(), // Add timestamp to prevent browser caching + }, + }) return response.data.targets || [] }, @@ -97,7 +104,14 @@ export const scstAPI = { luns: SCSTLUN[] initiator_groups?: SCSTInitiatorGroup[] }> => { - const response = await apiClient.get(`/scst/targets/${id}`) + const response = await apiClient.get(`/scst/targets/${id}`, { + headers: { + 'Cache-Control': 'no-cache', + }, + params: { + _t: Date.now(), // Add timestamp to prevent browser caching + }, + }) return response.data }, @@ -112,6 +126,11 @@ export const scstAPI = { return response.data }, + removeLUN: async (targetId: string, lunId: string): Promise<{ message: string }> => { + const response = await apiClient.delete(`/scst/targets/${targetId}/luns/${lunId}`) + return response.data + }, + addInitiator: async (targetId: string, data: AddInitiatorRequest): Promise<{ task_id: string }> => { const response = await apiClient.post(`/scst/targets/${targetId}/initiators`, data) return response.data @@ -123,17 +142,38 @@ export const scstAPI = { }, listHandlers: async (): Promise => { - const response = await apiClient.get('/scst/handlers') + const response = await apiClient.get('/scst/handlers', { + headers: { + 'Cache-Control': 'no-cache', + }, + params: { + _t: Date.now(), + }, + }) return response.data.handlers || [] }, listPortals: async (): Promise => { - const response = await apiClient.get('/scst/portals') + const response = await apiClient.get('/scst/portals', { + headers: { + 'Cache-Control': 'no-cache', + }, + params: { + _t: Date.now(), // Add timestamp to prevent browser caching + }, + }) return response.data.portals || [] }, getPortal: async (id: string): Promise => { - const response = await apiClient.get(`/scst/portals/${id}`) + const response = await apiClient.get(`/scst/portals/${id}`, { + headers: { + 'Cache-Control': 'no-cache', + }, + params: { + _t: Date.now(), + }, + }) return response.data }, @@ -161,13 +201,32 @@ export const scstAPI = { return response.data }, + deleteTarget: async (targetId: string): Promise<{ message: string }> => { + const response = await apiClient.delete(`/scst/targets/${targetId}`) + return response.data + }, + listInitiators: async (): Promise => { - const response = await apiClient.get('/scst/initiators') + const response = await apiClient.get('/scst/initiators', { + headers: { + 'Cache-Control': 'no-cache', + }, + params: { + _t: Date.now(), + }, + }) return response.data.initiators || [] }, getInitiator: async (id: string): Promise => { - const response = await apiClient.get(`/scst/initiators/${id}`) + const response = await apiClient.get(`/scst/initiators/${id}`, { + headers: { + 'Cache-Control': 'no-cache', + }, + params: { + _t: Date.now(), + }, + }) return response.data }, @@ -176,7 +235,14 @@ export const scstAPI = { }, listExtents: async (): Promise => { - const response = await apiClient.get('/scst/extents') + const response = await apiClient.get('/scst/extents', { + headers: { + 'Cache-Control': 'no-cache', + }, + params: { + _t: Date.now(), // Add timestamp to prevent browser caching + }, + }) return response.data.extents || [] }, @@ -188,6 +254,52 @@ export const scstAPI = { deleteExtent: async (deviceName: string): Promise => { await apiClient.delete(`/scst/extents/${deviceName}`) }, + + // Initiator Groups + listInitiatorGroups: async (): Promise => { + const response = await apiClient.get('/scst/initiator-groups', { + headers: { + 'Cache-Control': 'no-cache', + }, + params: { + _t: Date.now(), // Add timestamp to prevent browser caching + }, + }) + return response.data.groups || [] + }, + + getInitiatorGroup: async (id: string): Promise => { + const response = await apiClient.get(`/scst/initiator-groups/${id}`, { + headers: { + 'Cache-Control': 'no-cache', + }, + params: { + _t: Date.now(), + }, + }) + return response.data + }, + + createInitiatorGroup: async (data: { target_id: string; group_name: string }): Promise => { + const response = await apiClient.post('/scst/initiator-groups', data) + return response.data + }, + + updateInitiatorGroup: async (id: string, data: { group_name: string }): Promise => { + const response = await apiClient.put(`/scst/initiator-groups/${id}`, data) + return response.data + }, + + deleteInitiatorGroup: async (id: string): Promise => { + await apiClient.delete(`/scst/initiator-groups/${id}`) + }, + + addInitiatorToGroup: async (groupId: string, initiatorIQN: string): Promise<{ message: string }> => { + const response = await apiClient.post(`/scst/initiator-groups/${groupId}/initiators`, { + initiator_iqn: initiatorIQN, + }) + return response.data + }, } export interface SCSTExtent { diff --git a/frontend/src/pages/ISCSITargetDetail.tsx b/frontend/src/pages/ISCSITargetDetail.tsx index b16f4a8..123e805 100644 --- a/frontend/src/pages/ISCSITargetDetail.tsx +++ b/frontend/src/pages/ISCSITargetDetail.tsx @@ -1,9 +1,9 @@ import { useParams, useNavigate } from 'react-router-dom' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' -import { scstAPI, type SCSTHandler } from '@/api/scst' +import { scstAPI, type SCSTTarget, type SCSTExtent } from '@/api/scst' import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card' import { Button } from '@/components/ui/button' -import { ArrowLeft, Plus, RefreshCw, HardDrive, Users } from 'lucide-react' +import { ArrowLeft, Plus, RefreshCw, HardDrive, Users, Trash2 } from 'lucide-react' import { useState, useEffect } from 'react' export default function ISCSITargetDetail() { @@ -23,11 +23,64 @@ export default function ISCSITargetDetail() { enabled: !!id, }) - const { data: handlers } = useQuery({ - queryKey: ['scst-handlers'], - queryFn: scstAPI.listHandlers, - staleTime: 0, // Always fetch fresh data - refetchOnMount: true, + const removeLUNMutation = useMutation({ + mutationFn: ({ targetId, lunId }: { targetId: string; lunId: string }) => + scstAPI.removeLUN(targetId, lunId), + onMutate: async ({ lunId }) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: ['scst-target', id] }) + await queryClient.cancelQueries({ queryKey: ['scst-targets'] }) + + // Snapshot the previous value + const previousTarget = queryClient.getQueryData(['scst-target', id]) + const previousTargets = queryClient.getQueryData(['scst-targets']) + + // Optimistically update to remove the LUN + queryClient.setQueryData(['scst-target', id], (old: any) => { + if (!old) return old + return { + ...old, + luns: old.luns ? old.luns.filter((lun: any) => lun.id !== lunId) : [] + } + }) + + // Optimistically update LUN count in targets list + queryClient.setQueryData(['scst-targets'], (old) => { + if (!old) return old + return old.map(t => + t.id === id + ? { ...t, lun_count: Math.max(0, (t.lun_count || 0) - 1) } + : t + ) + }) + + return { previousTarget, previousTargets } + }, + onSuccess: () => { + // Invalidate queries to refetch data from the server. + // This is simpler and less prone to race conditions than the previous implementation. + queryClient.invalidateQueries({ queryKey: ['scst-target', id] }); + queryClient.invalidateQueries({ queryKey: ['scst-targets'] }); + }, + onError: (error: any, _variables, context) => { + // If 404, treat as success (LUN already deleted) + if (error.response?.status === 404) { + // LUN already deleted, just refresh to sync UI + queryClient.invalidateQueries({ queryKey: ['scst-target', id] }); + queryClient.invalidateQueries({ queryKey: ['scst-targets'] }); + return + } + + // Rollback optimistic update + if (context?.previousTarget) { + queryClient.setQueryData(['scst-target', id], context.previousTarget) + } + if (context?.previousTargets) { + queryClient.setQueryData(['scst-targets'], context.previousTargets) + } + + alert(`Failed to remove LUN: ${error.response?.data?.error || error.message}`) + }, }) if (isLoading) { @@ -124,7 +177,7 @@ export default function ISCSITargetDetail() { onClick={() => setShowAddLUN(true)} > - Add LUN + Assign Extent @@ -183,6 +235,9 @@ export default function ISCSITargetDetail() { Status + + Actions + @@ -211,6 +266,21 @@ export default function ISCSITargetDetail() { {lun.is_active ? 'Active' : 'Inactive'} + + + ))} @@ -224,27 +294,28 @@ export default function ISCSITargetDetail() { variant="outline" onClick={(e) => { e.stopPropagation() - console.log('Add First LUN button clicked, setting showAddLUN to true') setShowAddLUN(true) }} > - Add First LUN + Assign First Extent )} - {/* Add LUN Form */} + {/* Assign Extent Form */} {showAddLUN && ( - setShowAddLUN(false)} - onSuccess={() => { + onSuccess={async () => { setShowAddLUN(false) - queryClient.invalidateQueries({ queryKey: ['scst-target', id] }) + // Invalidate queries to refetch data. + // Invalidate extents since one is now in use. + queryClient.invalidateQueries({ queryKey: ['scst-target', id] }); + queryClient.invalidateQueries({ queryKey: ['scst-extents'] }); }} /> )} @@ -264,47 +335,56 @@ export default function ISCSITargetDetail() { ) } -interface AddLUNFormProps { +interface AssignExtentFormProps { targetId: string - handlers: SCSTHandler[] onClose: () => void - onSuccess: () => void + onSuccess: () => Promise } -function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps) { - const [handlerType, setHandlerType] = useState('') - const [devicePath, setDevicePath] = useState('') - const [deviceName, setDeviceName] = useState('') +function AssignExtentForm({ targetId, onClose, onSuccess }: AssignExtentFormProps) { + const [selectedExtent, setSelectedExtent] = useState('') const [lunNumber, setLunNumber] = useState(0) - useEffect(() => { - console.log('AddLUNForm mounted, targetId:', targetId, 'handlers:', handlers) - }, [targetId, handlers]) + // Fetch available extents + const { data: extents = [], isLoading: extentsLoading } = useQuery({ + queryKey: ['scst-extents'], + queryFn: scstAPI.listExtents, + staleTime: 0, + refetchOnMount: true, + }) + + // Filter only extents that are not in use + const availableExtents = extents.filter(extent => !extent.is_in_use) const addLUNMutation = useMutation({ mutationFn: (data: { device_name: string; device_path: string; lun_number: number; handler_type: string }) => scstAPI.addLUN(targetId, data), - onSuccess: () => { - onSuccess() + onSuccess: async () => { + await onSuccess() }, onError: (error: any) => { - console.error('Failed to add LUN:', error) - const errorMessage = error.response?.data?.error || error.message || 'Failed to add LUN' + const errorMessage = error.response?.data?.error || error.message || 'Failed to assign extent' alert(errorMessage) }, }) const handleSubmit = (e: React.FormEvent) => { e.preventDefault() - if (!handlerType || !devicePath || !deviceName || lunNumber < 0) { - alert('All fields are required') + if (!selectedExtent || lunNumber < 0) { + alert('Please select an extent and specify LUN number') + return + } + + const extent = availableExtents.find(e => e.device_name === selectedExtent) + if (!extent) { + alert('Selected extent not found') return } addLUNMutation.mutate({ - handler_type: handlerType.trim(), - device_path: devicePath.trim(), - device_name: deviceName.trim(), + device_name: extent.device_name, + device_path: extent.device_path, + handler_type: extent.handler_type, lun_number: lunNumber, }) } @@ -313,74 +393,68 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
-

Add LUN

-

Bind a ZFS volume or storage device to this target

+

Assign Extent

+

Assign an existing extent to this target as a LUN

-
- -
- - { - const path = e.target.value.trim() - setDevicePath(path) - // Auto-generate device name from path (e.g., /dev/zvol/pool/volume -> volume) - if (path && !deviceName) { - const parts = path.split('/') - const name = parts[parts.length - 1] || parts[parts.length - 2] || 'device' - setDeviceName(name) - } - }} - placeholder="/dev/zvol/pool/volume or /dev/sda" - className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary font-mono" - required - /> + {extentsLoading ? ( +
+ Loading extents... +
+ ) : availableExtents.length === 0 ? ( +
+ No available extents. Please create an extent first in the Extents tab. +
+ ) : ( + + )}

- Enter ZFS volume path (e.g., /dev/zvol/pool/volume) or block device path + Select an extent that has been created in the Extents tab

-
- - setDeviceName(e.target.value)} - placeholder="device1" - className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary" - required - /> -

- Logical name for this device in SCST (auto-filled from volume path) -

-
+ {selectedExtent && ( +
+

Extent Details:

+ {(() => { + const extent = availableExtents.find(e => e.device_name === selectedExtent) + if (!extent) return null + return ( +
+
+ Device Name: + {extent.device_name} +
+
+ Handler: + {extent.handler_type} +
+
+ Path: + {extent.device_path} +
+
+ ) + })()} +
+ )}
diff --git a/frontend/src/pages/ISCSITargets.tsx b/frontend/src/pages/ISCSITargets.tsx index 7fef212..e6109c6 100644 --- a/frontend/src/pages/ISCSITargets.tsx +++ b/frontend/src/pages/ISCSITargets.tsx @@ -15,6 +15,14 @@ export default function ISCSITargets() { const { data: targets, isLoading } = useQuery({ queryKey: ['scst-targets'], queryFn: scstAPI.listTargets, + refetchInterval: 3000, // Auto-refresh every 3 seconds + refetchIntervalInBackground: true, // Continue refetching even when tab is in background + refetchOnWindowFocus: true, // Refetch when window regains focus + refetchOnMount: true, // Always refetch on mount + refetchOnReconnect: true, // Refetch when network reconnects + staleTime: 0, // Consider data stale immediately to ensure fresh data + gcTime: 0, // Don't cache data (formerly cacheTime) + structuralSharing: false, // Disable structural sharing to ensure updates are detected }) const applyConfigMutation = useMutation({ @@ -158,6 +166,19 @@ export default function ISCSITargets() {
)} +
@@ -206,6 +227,12 @@ export default function ISCSITargets() { target={target} isExpanded={expandedTarget === target.id} onToggle={() => setExpandedTarget(expandedTarget === target.id ? null : target.id)} + onDelete={() => { + // Close expanded view if this target was expanded + if (expandedTarget === target.id) { + setExpandedTarget(null) + } + }} isLast={index === filteredTargets.length - 1} /> ))} @@ -244,6 +271,10 @@ export default function ISCSITargets() { {activeTab === 'extents' && ( )} + + {activeTab === 'groups' && ( + + )} @@ -253,7 +284,7 @@ export default function ISCSITargets() { onClose={() => setShowCreateForm(false)} onSuccess={async () => { setShowCreateForm(false) - // Invalidate and refetch to ensure fresh data + // Force refetch targets list to ensure fresh data await queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) await queryClient.refetchQueries({ queryKey: ['scst-targets'] }) }} @@ -267,10 +298,11 @@ interface TargetRowProps { target: SCSTTarget isExpanded: boolean onToggle: () => void + onDelete?: () => void isLast?: boolean } -function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) { +function TargetRow({ target, isExpanded, onToggle, onDelete }: TargetRowProps) { // Fetch LUNs when expanded const { data: targetData } = useQuery({ queryKey: ['scst-target', target.id], @@ -303,6 +335,119 @@ function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) { }, }) + const deleteMutation = useMutation({ + mutationFn: () => scstAPI.deleteTarget(target.id), + onSuccess: async () => { + // Close expanded view if this target was expanded + if (isExpanded) { + onToggle() + } + // Call onDelete callback if provided + if (onDelete) { + onDelete() + } + + // Optimistically remove target from cache immediately + queryClient.setQueryData(['scst-targets'], (oldData) => { + if (!oldData) return oldData + return oldData.filter(t => t.id !== target.id) + }) + + // Remove target-specific queries from cache + queryClient.removeQueries({ queryKey: ['scst-target', target.id] }) + + // Invalidate and refetch targets list to ensure consistency + await queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) + await queryClient.refetchQueries({ + queryKey: ['scst-targets'], + type: 'active' // Only refetch active queries + }) + }, + onError: (error: any) => { + // On error, refetch to restore correct state + queryClient.refetchQueries({ queryKey: ['scst-targets'] }) + alert(`Failed to delete target: ${error.response?.data?.error || error.message}`) + }, + }) + + const removeLUNMutation = useMutation({ + mutationFn: ({ targetId, lunId }: { targetId: string; lunId: string }) => + scstAPI.removeLUN(targetId, lunId), + onMutate: async ({ lunId }) => { + // Cancel any outgoing refetches (so they don't overwrite our optimistic update) + await queryClient.cancelQueries({ queryKey: ['scst-target', target.id] }) + await queryClient.cancelQueries({ queryKey: ['scst-targets'] }) + + // Snapshot the previous value + const previousTarget = queryClient.getQueryData(['scst-target', target.id]) + const previousTargets = queryClient.getQueryData(['scst-targets']) + + // Optimistically update to remove the LUN from target + queryClient.setQueryData(['scst-target', target.id], (old: any) => { + if (!old) return old + return { + ...old, + luns: old.luns ? old.luns.filter((lun: any) => lun.id !== lunId) : [] + } + }) + + // Optimistically update LUN count in targets list + queryClient.setQueryData(['scst-targets'], (old) => { + if (!old) return old + return old.map(t => + t.id === target.id + ? { ...t, lun_count: Math.max(0, (t.lun_count || 0) - 1) } + : t + ) + }) + + return { previousTarget, previousTargets } + }, + onSuccess: async () => { + // Remove target-specific queries from cache + queryClient.removeQueries({ queryKey: ['scst-target', target.id] }) + + // Invalidate and refetch queries to update UI + await queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) + await queryClient.invalidateQueries({ queryKey: ['scst-target', target.id] }) + + // Explicitly refetch the target data if the target is expanded + if (isExpanded) { + await queryClient.refetchQueries({ queryKey: ['scst-target', target.id] }) + } + + // Refetch targets list to update LUN count + await queryClient.refetchQueries({ + queryKey: ['scst-targets'], + type: 'active' + }) + }, + onError: (error: any, _variables, context) => { + // If 404, treat as success (LUN already deleted) + if (error.response?.status === 404) { + // LUN already deleted, just refresh to sync UI + queryClient.invalidateQueries({ queryKey: ['scst-target', target.id] }) + queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) + queryClient.refetchQueries({ queryKey: ['scst-target', target.id] }) + queryClient.refetchQueries({ queryKey: ['scst-targets'] }) + return + } + + // Rollback optimistic update + if (context?.previousTarget) { + queryClient.setQueryData(['scst-target', target.id], context.previousTarget) + } + if (context?.previousTargets) { + queryClient.setQueryData(['scst-targets'], context.previousTargets) + } + + // On error, refetch to restore correct state + queryClient.refetchQueries({ queryKey: ['scst-target', target.id] }) + queryClient.refetchQueries({ queryKey: ['scst-targets'] }) + alert(`Failed to remove LUN: ${error.response?.data?.error || error.message}`) + }, + }) + return (
{/* Main Row */} @@ -424,6 +569,18 @@ function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) { {lun.device_type || 'Unknown type'}
+ )) ) : ( @@ -447,6 +604,19 @@ function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) { > Edit Policy +
@@ -540,6 +710,18 @@ function EditPolicyModal({ target, initiatorGroups, onClose, onSuccess }: EditPo }, }) + const removeInitiatorMutation = useMutation({ + mutationFn: (initiatorId: string) => scstAPI.removeInitiator(initiatorId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scst-target', target.id] }) + queryClient.invalidateQueries({ queryKey: ['scst-initiators'] }) + onSuccess() + }, + onError: (error: any) => { + alert(`Failed to remove initiator: ${error.response?.data?.error || error.message}`) + }, + }) + const handleAddInitiator = (e: React.FormEvent) => { e.preventDefault() if (!initiatorIQN.trim()) { @@ -620,10 +802,11 @@ function EditPolicyModal({ target, initiatorGroups, onClose, onSuccess }: EditPo
-
+
{initiator.target_iqn && ( -
- Target: - - {initiator.target_name || initiator.target_iqn} +
+ Target: + + {initiator.target_name || initiator.target_iqn.split(':').pop()}
)} {initiator.group_name && ( -
- Group: - {initiator.group_name} +
+ Group: + + {initiator.group_name} +
)}
@@ -1230,7 +1419,15 @@ function ExtentsTab() { const { data: extents = [], isLoading } = useQuery({ queryKey: ['scst-extents'], - queryFn: scstAPI.listExtents, + queryFn: () => scstAPI.listExtents(), // Wrap in arrow function to ensure fresh call + refetchInterval: 3000, // Auto-refresh every 3 seconds + refetchIntervalInBackground: true, // Continue refetching even when tab is in background + refetchOnWindowFocus: true, // Refetch when window regains focus + refetchOnMount: true, // Always refetch on mount + refetchOnReconnect: true, // Refetch when network reconnects + staleTime: 0, // Consider data stale immediately to ensure fresh data + gcTime: 0, // Don't cache data (formerly cacheTime) + structuralSharing: false, // Disable structural sharing to ensure updates are detected }) const { data: handlersData } = useQuery({ @@ -1247,11 +1444,50 @@ function ExtentsTab() { const deleteMutation = useMutation({ mutationFn: scstAPI.deleteExtent, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scst-extents'] }) - queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) + onMutate: async (deviceName: string) => { + // Cancel any outgoing refetches (so they don't overwrite our optimistic update) + await queryClient.cancelQueries({ queryKey: ['scst-extents'] }) + await queryClient.cancelQueries({ queryKey: ['scst-targets'] }) + + // Snapshot the previous value + const previousExtents = queryClient.getQueryData(['scst-extents']) + + // Optimistically update to remove the extent + queryClient.setQueryData(['scst-extents'], (old) => + old ? old.filter((e) => e.device_name !== deviceName) : [] + ) + + return { previousExtents } }, - onError: (error: any) => { + onSuccess: async () => { + // Remove all queries from cache (including inactive ones) + queryClient.removeQueries({ queryKey: ['scst-extents'] }) + queryClient.removeQueries({ queryKey: ['scst-targets'] }) + + // Invalidate all related queries + await queryClient.invalidateQueries({ queryKey: ['scst-extents'] }) + await queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) + + // Force refetch with no cache + await queryClient.refetchQueries({ + queryKey: ['scst-extents'], + type: 'all' // Refetch all queries, not just active + }) + + // Also refetch targets to update LUN count if needed + await queryClient.refetchQueries({ + queryKey: ['scst-targets'], + type: 'all' + }) + }, + onError: (error: any, _deviceName: string, context) => { + // Rollback optimistic update + if (context?.previousExtents) { + queryClient.setQueryData(['scst-extents'], context.previousExtents) + } + + // Refetch to restore correct state + queryClient.refetchQueries({ queryKey: ['scst-extents'] }) alert(`Failed to delete extent: ${error.response?.data?.error || error.message}`) }, }) @@ -1383,9 +1619,16 @@ function ExtentsTab() { setShowCreateModal(false)} - onSuccess={() => { + onSuccess={async () => { setShowCreateModal(false) - queryClient.invalidateQueries({ queryKey: ['scst-extents'] }) + // Remove queries from cache + queryClient.removeQueries({ queryKey: ['scst-extents'] }) + // Force refetch to ensure fresh data + await queryClient.invalidateQueries({ queryKey: ['scst-extents'] }) + await queryClient.refetchQueries({ + queryKey: ['scst-extents'], + type: 'active' + }) }} /> )} @@ -1393,15 +1636,16 @@ function ExtentsTab() { ) } -function CreateExtentModal({ handlers, onClose, onSuccess }: { handlers: Array<{ name: string; label: string; description?: string }>, onClose: () => void, onSuccess: () => void }) { +function CreateExtentModal({ handlers, onClose, onSuccess }: { handlers: Array<{ name: string; label: string; description?: string }>, onClose: () => void, onSuccess: () => Promise }) { const [deviceName, setDeviceName] = useState('') const [devicePath, setDevicePath] = useState('') const [handlerType, setHandlerType] = useState('') const createMutation = useMutation({ mutationFn: (data: CreateExtentRequest) => scstAPI.createExtent(data), - onSuccess: () => { - onSuccess() + onSuccess: async () => { + // Call onSuccess callback which will handle refresh + await onSuccess() alert('Extent created successfully!') }, onError: (error: any) => { @@ -1512,3 +1756,557 @@ function CreateExtentModal({ handlers, onClose, onSuccess }: { handlers: Array<{
) } + +function InitiatorGroupsTab() { + const queryClient = useQueryClient() + const [searchQuery, setSearchQuery] = useState('') + const [showCreateModal, setShowCreateModal] = useState(false) + const [editingGroup, setEditingGroup] = useState(null) + const [expandedGroup, setExpandedGroup] = useState(null) + const [showAddInitiatorModal, setShowAddInitiatorModal] = useState(null) + + const { data: groups = [], isLoading } = useQuery({ + queryKey: ['scst-initiator-groups'], + queryFn: scstAPI.listInitiatorGroups, + refetchInterval: 3000, // Auto-refresh every 3 seconds + refetchIntervalInBackground: true, // Continue refetching even when tab is in background + refetchOnWindowFocus: true, // Refetch when window regains focus + refetchOnMount: true, // Always refetch on mount + refetchOnReconnect: true, // Refetch when network reconnects + staleTime: 0, // Consider data stale immediately to ensure fresh data + gcTime: 0, // Don't cache data (formerly cacheTime) + structuralSharing: false, // Disable structural sharing to ensure updates are detected + }) + + const { data: targets = [] } = useQuery({ + queryKey: ['scst-targets'], + queryFn: scstAPI.listTargets, + }) + + const createMutation = useMutation({ + mutationFn: (data: { target_id: string; group_name: string }) => scstAPI.createInitiatorGroup(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scst-initiator-groups'] }) + queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) + setShowCreateModal(false) + }, + onError: (error: any) => { + alert(`Failed to create group: ${error.response?.data?.error || error.message}`) + }, + }) + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: { group_name: string } }) => scstAPI.updateInitiatorGroup(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scst-initiator-groups'] }) + queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) + setEditingGroup(null) + }, + onError: (error: any) => { + alert(`Failed to update group: ${error.response?.data?.error || error.message}`) + }, + }) + + const deleteMutation = useMutation({ + mutationFn: (id: string) => scstAPI.deleteInitiatorGroup(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scst-initiator-groups'] }) + queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) + }, + onError: (error: any) => { + alert(`Failed to delete group: ${error.response?.data?.error || error.message}`) + }, + }) + + const addInitiatorMutation = useMutation({ + mutationFn: ({ groupId, initiatorIQN }: { groupId: string; initiatorIQN: string }) => + scstAPI.addInitiatorToGroup(groupId, initiatorIQN), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scst-initiator-groups'] }) + queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) + queryClient.invalidateQueries({ queryKey: ['scst-initiators'] }) + setShowAddInitiatorModal(null) + }, + onError: (error: any) => { + alert(`Failed to add initiator: ${error.response?.data?.error || error.message}`) + }, + }) + + const removeInitiatorMutation = useMutation({ + mutationFn: (initiatorId: string) => scstAPI.removeInitiator(initiatorId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scst-initiator-groups'] }) + queryClient.invalidateQueries({ queryKey: ['scst-targets'] }) + queryClient.invalidateQueries({ queryKey: ['scst-initiators'] }) + }, + onError: (error: any) => { + alert(`Failed to remove initiator: ${error.response?.data?.error || error.message}`) + }, + }) + + const filteredGroups = groups.filter(group => { + const target = targets.find(t => t.id === group.target_id) + const matchesSearch = + group.group_name.toLowerCase().includes(searchQuery.toLowerCase()) || + (target && ((target.alias || target.iqn).toLowerCase().includes(searchQuery.toLowerCase()) || target.iqn.toLowerCase().includes(searchQuery.toLowerCase()))) + return matchesSearch + }) + + const handleDelete = (group: SCSTInitiatorGroup) => { + if (group.initiators && group.initiators.length > 0) { + alert(`Cannot delete group: Group contains ${group.initiators.length} initiator(s). Please remove all initiators first.`) + return + } + if (confirm(`Delete initiator group "${group.group_name}"?`)) { + deleteMutation.mutate(group.id) + } + } + + return ( +
+ {/* Header */} +
+
+

iSCSI Initiator Groups

+

Manage initiator access control groups

+
+ +
+ + {/* Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full bg-[#0f161d] border border-border-dark rounded-lg pl-10 pr-4 py-2 text-sm text-white focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all placeholder-text-secondary/50" + /> +
+
+ + {/* Groups List */} + {isLoading ? ( +
Loading groups...
+ ) : filteredGroups.length > 0 ? ( +
+
+ {filteredGroups.map((group) => { + const target = targets.find(t => t.id === group.target_id) + const isExpanded = expandedGroup === group.id + return ( +
+
+
+
+
+ +
+ +
+
+
+ + {group.group_name} + +
+
+ {target && ( +
+ Target: + + {target.alias || target.iqn.split(':').pop()} + +
+ )} +
+ Initiators: + + {group.initiators?.length || 0} + +
+
+ Created: + {new Date(group.created_at).toLocaleDateString()} +
+
+
+
+
+
+ + +
+
+
+ + {/* Expanded view with initiators list */} + {isExpanded && ( +
+
+

Group Members

+ +
+ {group.initiators && group.initiators.length > 0 ? ( +
+ {group.initiators.map((initiator) => ( +
+
+
+ +
+
+
+ + {initiator.iqn} + + + {initiator.is_active ? 'Active' : 'Inactive'} + +
+
+
+
+ + +
+
+ ))} +
+ ) : ( +
+

No initiators in this group

+ +
+ )} +
+ )} +
+ ) + })} +
+
+ ) : ( +
+
+ +
+

No groups found

+

+ {searchQuery + ? 'Try adjusting your search criteria' + : 'Create an initiator group to organize initiators by access control'} +

+
+ )} + + {/* Create Group Modal */} + {showCreateModal && ( + setShowCreateModal(false)} + isLoading={createMutation.isPending} + onSubmit={(data) => createMutation.mutate(data)} + /> + )} + + {/* Edit Group Modal */} + {editingGroup && ( + setEditingGroup(null)} + isLoading={updateMutation.isPending} + onSubmit={(data) => updateMutation.mutate({ id: editingGroup.id, data })} + /> + )} + + {/* Add Initiator Modal */} + {showAddInitiatorModal && ( + g.id === showAddInitiatorModal)?.group_name || ''} + onClose={() => setShowAddInitiatorModal(null)} + isLoading={addInitiatorMutation.isPending} + onSubmit={(initiatorIQN) => addInitiatorMutation.mutate({ groupId: showAddInitiatorModal, initiatorIQN })} + /> + )} +
+ ) +} + +function CreateGroupModal({ targets, onClose, isLoading, onSubmit }: { + targets: SCSTTarget[] + onClose: () => void + isLoading: boolean + onSubmit: (data: { target_id: string; group_name: string }) => void +}) { + const [targetId, setTargetId] = useState('') + const [groupName, setGroupName] = useState('') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!targetId || !groupName.trim()) { + alert('Please fill in all required fields') + return + } + onSubmit({ target_id: targetId, group_name: groupName.trim() }) + } + + return ( +
+
+
+

Create Initiator Group

+ +
+
+
+
+ + +
+
+ + setGroupName(e.target.value)} + placeholder="my-acl-group" + className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary" + required + /> +

+ Group name will be used as ACL group name in SCST +

+
+
+
+ + +
+
+
+
+ ) +} + +function EditGroupModal({ group, onClose, isLoading, onSubmit }: { + group: SCSTInitiatorGroup + onClose: () => void + isLoading: boolean + onSubmit: (data: { group_name: string }) => void +}) { + const [groupName, setGroupName] = useState(group.group_name) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!groupName.trim()) { + alert('Group name cannot be empty') + return + } + onSubmit({ group_name: groupName.trim() }) + } + + return ( +
+
+
+

Edit Initiator Group

+ +
+
+
+
+ + setGroupName(e.target.value)} + placeholder="my-acl-group" + className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary" + required + /> +

+ Changing the group name will recreate it in SCST +

+
+
+
+ + +
+
+
+
+ ) +} + +function AddInitiatorToGroupModal({ groupName, onClose, isLoading, onSubmit }: { + groupName: string + onClose: () => void + isLoading: boolean + onSubmit: (initiatorIQN: string) => void +}) { + const [initiatorIQN, setInitiatorIQN] = useState('') + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!initiatorIQN.trim()) { + alert('Please enter an initiator IQN') + return + } + // Validate IQN format (basic check) + if (!initiatorIQN.trim().toLowerCase().startsWith('iqn.')) { + alert('Invalid IQN format. IQN must start with "iqn."') + return + } + onSubmit(initiatorIQN.trim()) + } + + return ( +
+
+
+

Add Initiator to Group

+ +
+
+
+
+ + +
+
+ + setInitiatorIQN(e.target.value)} + placeholder="iqn.1993-08.org.debian:01:example" + className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary font-mono" + required + /> +

+ Enter the IQN of the initiator to add to this group +

+
+
+
+ + +
+
+
+
+ ) +}