iscsi still failing to save current attribute, check on disable and enable portal/iscsi targets
This commit is contained in:
Binary file not shown.
@@ -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", scstHandler.ListTargets)
|
||||||
scstGroup.GET("/targets/:id", scstHandler.GetTarget)
|
scstGroup.GET("/targets/:id", scstHandler.GetTarget)
|
||||||
scstGroup.POST("/targets", scstHandler.CreateTarget)
|
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/initiators", scstHandler.AddInitiator)
|
||||||
scstGroup.POST("/targets/:id/enable", scstHandler.EnableTarget)
|
scstGroup.POST("/targets/:id/enable", scstHandler.EnableTarget)
|
||||||
scstGroup.POST("/targets/:id/disable", scstHandler.DisableTarget)
|
scstGroup.POST("/targets/:id/disable", scstHandler.DisableTarget)
|
||||||
|
scstGroup.DELETE("/targets/:id", requirePermission("iscsi", "write"), scstHandler.DeleteTarget)
|
||||||
scstGroup.GET("/initiators", scstHandler.ListAllInitiators)
|
scstGroup.GET("/initiators", scstHandler.ListAllInitiators)
|
||||||
scstGroup.GET("/initiators/:id", scstHandler.GetInitiator)
|
scstGroup.GET("/initiators/:id", scstHandler.GetInitiator)
|
||||||
scstGroup.DELETE("/initiators/:id", scstHandler.RemoveInitiator)
|
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.POST("/portals", scstHandler.CreatePortal)
|
||||||
scstGroup.PUT("/portals/:id", scstHandler.UpdatePortal)
|
scstGroup.PUT("/portals/:id", scstHandler.UpdatePortal)
|
||||||
scstGroup.DELETE("/portals/:id", scstHandler.DeletePortal)
|
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
|
// Physical Tape Libraries
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package scst
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/atlasos/calypso/internal/common/database"
|
"github.com/atlasos/calypso/internal/common/database"
|
||||||
"github.com/atlasos/calypso/internal/common/logger"
|
"github.com/atlasos/calypso/internal/common/logger"
|
||||||
"github.com/atlasos/calypso/internal/tasks"
|
"github.com/atlasos/calypso/internal/tasks"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler handles SCST-related API requests
|
// Handler handles SCST-related API requests
|
||||||
@@ -37,6 +39,11 @@ func (h *Handler) ListTargets(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure we return an empty array instead of null
|
||||||
|
if targets == nil {
|
||||||
|
targets = []Target{}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"targets": targets})
|
c.JSON(http.StatusOK, gin.H{"targets": targets})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +119,11 @@ func (h *Handler) CreateTarget(c *gin.Context) {
|
|||||||
return
|
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)
|
c.JSON(http.StatusCreated, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +131,7 @@ func (h *Handler) CreateTarget(c *gin.Context) {
|
|||||||
type AddLUNRequest struct {
|
type AddLUNRequest struct {
|
||||||
DeviceName string `json:"device_name" binding:"required"`
|
DeviceName string `json:"device_name" binding:"required"`
|
||||||
DevicePath string `json:"device_path" 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"`
|
HandlerType string `json:"handler_type" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,17 +148,45 @@ func (h *Handler) AddLUN(c *gin.Context) {
|
|||||||
var req AddLUNRequest
|
var req AddLUNRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
h.logger.Error("Failed to bind AddLUN request", "error", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields (additional check in case binding doesn't catch it)
|
||||||
if req.DeviceName == "" || req.DevicePath == "" || req.HandlerType == "" {
|
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)
|
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"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "device_name, device_path, and handler_type are required"})
|
||||||
return
|
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 {
|
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)
|
h.logger.Error("Failed to add LUN", "error", err)
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
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"})
|
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
|
// AddInitiatorRequest represents an initiator addition request
|
||||||
type AddInitiatorRequest struct {
|
type AddInitiatorRequest struct {
|
||||||
InitiatorIQN string `json:"initiator_iqn" binding:"required"`
|
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"})
|
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
|
// ListAllInitiators lists all initiators across all targets
|
||||||
func (h *Handler) ListAllInitiators(c *gin.Context) {
|
func (h *Handler) ListAllInitiators(c *gin.Context) {
|
||||||
initiators, err := h.service.ListAllInitiators(c.Request.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"})
|
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
|
// DeletePortal deletes a portal
|
||||||
func (h *Handler) DeletePortal(c *gin.Context) {
|
func (h *Handler) DeletePortal(c *gin.Context) {
|
||||||
id := c.Param("id")
|
id := c.Param("id")
|
||||||
@@ -474,3 +612,136 @@ func (h *Handler) GetPortal(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, portal)
|
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})
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@ const apiClient = axios.create({
|
|||||||
baseURL: '/api/v1',
|
baseURL: '/api/v1',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||||
|
'Pragma': 'no-cache',
|
||||||
|
'Expires': '0',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,14 @@ export interface AddInitiatorRequest {
|
|||||||
|
|
||||||
export const scstAPI = {
|
export const scstAPI = {
|
||||||
listTargets: async (): Promise<SCSTTarget[]> => {
|
listTargets: async (): Promise<SCSTTarget[]> => {
|
||||||
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 || []
|
return response.data.targets || []
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -97,7 +104,14 @@ export const scstAPI = {
|
|||||||
luns: SCSTLUN[]
|
luns: SCSTLUN[]
|
||||||
initiator_groups?: SCSTInitiatorGroup[]
|
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
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -112,6 +126,11 @@ export const scstAPI = {
|
|||||||
return response.data
|
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 }> => {
|
addInitiator: async (targetId: string, data: AddInitiatorRequest): Promise<{ task_id: string }> => {
|
||||||
const response = await apiClient.post(`/scst/targets/${targetId}/initiators`, data)
|
const response = await apiClient.post(`/scst/targets/${targetId}/initiators`, data)
|
||||||
return response.data
|
return response.data
|
||||||
@@ -123,17 +142,38 @@ export const scstAPI = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
listHandlers: async (): Promise<SCSTHandler[]> => {
|
listHandlers: async (): Promise<SCSTHandler[]> => {
|
||||||
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 || []
|
return response.data.handlers || []
|
||||||
},
|
},
|
||||||
|
|
||||||
listPortals: async (): Promise<SCSTPortal[]> => {
|
listPortals: async (): Promise<SCSTPortal[]> => {
|
||||||
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 || []
|
return response.data.portals || []
|
||||||
},
|
},
|
||||||
|
|
||||||
getPortal: async (id: string): Promise<SCSTPortal> => {
|
getPortal: async (id: string): Promise<SCSTPortal> => {
|
||||||
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
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -161,13 +201,32 @@ export const scstAPI = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
deleteTarget: async (targetId: string): Promise<{ message: string }> => {
|
||||||
|
const response = await apiClient.delete(`/scst/targets/${targetId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
listInitiators: async (): Promise<SCSTInitiator[]> => {
|
listInitiators: async (): Promise<SCSTInitiator[]> => {
|
||||||
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 || []
|
return response.data.initiators || []
|
||||||
},
|
},
|
||||||
|
|
||||||
getInitiator: async (id: string): Promise<SCSTInitiator> => {
|
getInitiator: async (id: string): Promise<SCSTInitiator> => {
|
||||||
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
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -176,7 +235,14 @@ export const scstAPI = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
listExtents: async (): Promise<SCSTExtent[]> => {
|
listExtents: async (): Promise<SCSTExtent[]> => {
|
||||||
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 || []
|
return response.data.extents || []
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -188,6 +254,52 @@ export const scstAPI = {
|
|||||||
deleteExtent: async (deviceName: string): Promise<void> => {
|
deleteExtent: async (deviceName: string): Promise<void> => {
|
||||||
await apiClient.delete(`/scst/extents/${deviceName}`)
|
await apiClient.delete(`/scst/extents/${deviceName}`)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Initiator Groups
|
||||||
|
listInitiatorGroups: async (): Promise<SCSTInitiatorGroup[]> => {
|
||||||
|
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<SCSTInitiatorGroup> => {
|
||||||
|
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<SCSTInitiatorGroup> => {
|
||||||
|
const response = await apiClient.post('/scst/initiator-groups', data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
updateInitiatorGroup: async (id: string, data: { group_name: string }): Promise<SCSTInitiatorGroup> => {
|
||||||
|
const response = await apiClient.put(`/scst/initiator-groups/${id}`, data)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteInitiatorGroup: async (id: string): Promise<void> => {
|
||||||
|
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 {
|
export interface SCSTExtent {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate } from 'react-router-dom'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||||
import { Button } from '@/components/ui/button'
|
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'
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
export default function ISCSITargetDetail() {
|
export default function ISCSITargetDetail() {
|
||||||
@@ -23,11 +23,64 @@ export default function ISCSITargetDetail() {
|
|||||||
enabled: !!id,
|
enabled: !!id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: handlers } = useQuery<SCSTHandler[]>({
|
const removeLUNMutation = useMutation({
|
||||||
queryKey: ['scst-handlers'],
|
mutationFn: ({ targetId, lunId }: { targetId: string; lunId: string }) =>
|
||||||
queryFn: scstAPI.listHandlers,
|
scstAPI.removeLUN(targetId, lunId),
|
||||||
staleTime: 0, // Always fetch fresh data
|
onMutate: async ({ lunId }) => {
|
||||||
refetchOnMount: true,
|
// 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<SCSTTarget[]>(['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<SCSTTarget[]>(['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<SCSTTarget[]>(['scst-targets'], context.previousTargets)
|
||||||
|
}
|
||||||
|
|
||||||
|
alert(`Failed to remove LUN: ${error.response?.data?.error || error.message}`)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -124,7 +177,7 @@ export default function ISCSITargetDetail() {
|
|||||||
onClick={() => setShowAddLUN(true)}
|
onClick={() => setShowAddLUN(true)}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add LUN
|
Assign Extent
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -153,12 +206,11 @@ export default function ISCSITargetDetail() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
console.log('Add LUN button clicked, setting showAddLUN to true')
|
|
||||||
setShowAddLUN(true)
|
setShowAddLUN(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add LUN
|
Assign Extent
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -183,6 +235,9 @@ export default function ISCSITargetDetail() {
|
|||||||
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
<th className="px-6 py-3 text-left text-xs font-medium text-text-secondary uppercase">
|
||||||
Status
|
Status
|
||||||
</th>
|
</th>
|
||||||
|
<th className="px-6 py-3 text-right text-xs font-medium text-text-secondary uppercase">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-card-dark divide-y divide-border-dark">
|
<tbody className="bg-card-dark divide-y divide-border-dark">
|
||||||
@@ -211,6 +266,21 @@ export default function ISCSITargetDetail() {
|
|||||||
{lun.is_active ? 'Active' : 'Inactive'}
|
{lun.is_active ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (confirm(`Remove LUN ${lun.lun_number} from this target?`)) {
|
||||||
|
removeLUNMutation.mutate({ targetId: target.id, lunId: lun.id })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={removeLUNMutation.isPending}
|
||||||
|
className="p-1.5 hover:bg-red-500/10 rounded text-text-secondary hover:text-red-400 transition-colors disabled:opacity-50"
|
||||||
|
title="Remove LUN"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -224,27 +294,28 @@ export default function ISCSITargetDetail() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
console.log('Add First LUN button clicked, setting showAddLUN to true')
|
|
||||||
setShowAddLUN(true)
|
setShowAddLUN(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
Add First LUN
|
Assign First Extent
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Add LUN Form */}
|
{/* Assign Extent Form */}
|
||||||
{showAddLUN && (
|
{showAddLUN && (
|
||||||
<AddLUNForm
|
<AssignExtentForm
|
||||||
targetId={target.id}
|
targetId={target.id}
|
||||||
handlers={handlers || []}
|
|
||||||
onClose={() => setShowAddLUN(false)}
|
onClose={() => setShowAddLUN(false)}
|
||||||
onSuccess={() => {
|
onSuccess={async () => {
|
||||||
setShowAddLUN(false)
|
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
|
targetId: string
|
||||||
handlers: SCSTHandler[]
|
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSuccess: () => void
|
onSuccess: () => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps) {
|
function AssignExtentForm({ targetId, onClose, onSuccess }: AssignExtentFormProps) {
|
||||||
const [handlerType, setHandlerType] = useState('')
|
const [selectedExtent, setSelectedExtent] = useState('')
|
||||||
const [devicePath, setDevicePath] = useState('')
|
|
||||||
const [deviceName, setDeviceName] = useState('')
|
|
||||||
const [lunNumber, setLunNumber] = useState(0)
|
const [lunNumber, setLunNumber] = useState(0)
|
||||||
|
|
||||||
useEffect(() => {
|
// Fetch available extents
|
||||||
console.log('AddLUNForm mounted, targetId:', targetId, 'handlers:', handlers)
|
const { data: extents = [], isLoading: extentsLoading } = useQuery<SCSTExtent[]>({
|
||||||
}, [targetId, handlers])
|
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({
|
const addLUNMutation = useMutation({
|
||||||
mutationFn: (data: { device_name: string; device_path: string; lun_number: number; handler_type: string }) =>
|
mutationFn: (data: { device_name: string; device_path: string; lun_number: number; handler_type: string }) =>
|
||||||
scstAPI.addLUN(targetId, data),
|
scstAPI.addLUN(targetId, data),
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
onSuccess()
|
await onSuccess()
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
console.error('Failed to add LUN:', error)
|
const errorMessage = error.response?.data?.error || error.message || 'Failed to assign extent'
|
||||||
const errorMessage = error.response?.data?.error || error.message || 'Failed to add LUN'
|
|
||||||
alert(errorMessage)
|
alert(errorMessage)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!handlerType || !devicePath || !deviceName || lunNumber < 0) {
|
if (!selectedExtent || lunNumber < 0) {
|
||||||
alert('All fields are required')
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
addLUNMutation.mutate({
|
addLUNMutation.mutate({
|
||||||
handler_type: handlerType.trim(),
|
device_name: extent.device_name,
|
||||||
device_path: devicePath.trim(),
|
device_path: extent.device_path,
|
||||||
device_name: deviceName.trim(),
|
handler_type: extent.handler_type,
|
||||||
lun_number: lunNumber,
|
lun_number: lunNumber,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -313,74 +393,68 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
|||||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||||
<div className="bg-card-dark border border-border-dark rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
<div className="bg-card-dark border border-border-dark rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
<div className="p-6 border-b border-border-dark">
|
<div className="p-6 border-b border-border-dark">
|
||||||
<h2 className="text-xl font-bold text-white">Add LUN</h2>
|
<h2 className="text-xl font-bold text-white">Assign Extent</h2>
|
||||||
<p className="text-sm text-text-secondary mt-1">Bind a ZFS volume or storage device to this target</p>
|
<p className="text-sm text-text-secondary mt-1">Assign an existing extent to this target as a LUN</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="handlerType" className="block text-sm font-medium text-white mb-1">
|
<label htmlFor="extent" className="block text-sm font-medium text-white mb-1">
|
||||||
Handler Type *
|
Available Extent *
|
||||||
</label>
|
</label>
|
||||||
<select
|
{extentsLoading ? (
|
||||||
id="handlerType"
|
<div className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-text-secondary text-sm">
|
||||||
value={handlerType}
|
Loading extents...
|
||||||
onChange={(e) => setHandlerType(e.target.value)}
|
</div>
|
||||||
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"
|
) : availableExtents.length === 0 ? (
|
||||||
required
|
<div className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-text-secondary text-sm">
|
||||||
>
|
No available extents. Please create an extent first in the Extents tab.
|
||||||
<option value="">Select a handler</option>
|
</div>
|
||||||
{handlers.map((h) => (
|
) : (
|
||||||
<option key={h.name} value={h.name}>
|
<select
|
||||||
{h.label || h.name}
|
id="extent"
|
||||||
</option>
|
value={selectedExtent}
|
||||||
))}
|
onChange={(e) => setSelectedExtent(e.target.value)}
|
||||||
</select>
|
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"
|
||||||
</div>
|
required
|
||||||
|
>
|
||||||
<div>
|
<option value="">Select an extent...</option>
|
||||||
<label htmlFor="devicePath" className="block text-sm font-medium text-white mb-1">
|
{availableExtents.map((extent) => (
|
||||||
ZFS Volume Path *
|
<option key={extent.device_name} value={extent.device_name}>
|
||||||
</label>
|
{extent.device_name} ({extent.handler_type}) - {extent.device_path}
|
||||||
<input
|
</option>
|
||||||
id="devicePath"
|
))}
|
||||||
type="text"
|
</select>
|
||||||
value={devicePath}
|
)}
|
||||||
onChange={(e) => {
|
|
||||||
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
|
|
||||||
/>
|
|
||||||
<p className="mt-1 text-xs text-text-secondary">
|
<p className="mt-1 text-xs text-text-secondary">
|
||||||
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
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{selectedExtent && (
|
||||||
<label htmlFor="deviceName" className="block text-sm font-medium text-white mb-1">
|
<div className="p-4 bg-[#0f161d] border border-border-dark rounded-lg">
|
||||||
Device Name *
|
<p className="text-sm text-text-secondary mb-2">Extent Details:</p>
|
||||||
</label>
|
{(() => {
|
||||||
<input
|
const extent = availableExtents.find(e => e.device_name === selectedExtent)
|
||||||
id="deviceName"
|
if (!extent) return null
|
||||||
type="text"
|
return (
|
||||||
value={deviceName}
|
<div className="space-y-1 text-sm">
|
||||||
onChange={(e) => setDeviceName(e.target.value)}
|
<div className="flex justify-between">
|
||||||
placeholder="device1"
|
<span className="text-text-secondary">Device Name:</span>
|
||||||
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"
|
<span className="text-white font-mono">{extent.device_name}</span>
|
||||||
required
|
</div>
|
||||||
/>
|
<div className="flex justify-between">
|
||||||
<p className="mt-1 text-xs text-text-secondary">
|
<span className="text-text-secondary">Handler:</span>
|
||||||
Logical name for this device in SCST (auto-filled from volume path)
|
<span className="text-white">{extent.handler_type}</span>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<div className="flex justify-between">
|
||||||
|
<span className="text-text-secondary">Path:</span>
|
||||||
|
<span className="text-white font-mono text-xs">{extent.device_path}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="lunNumber" className="block text-sm font-medium text-white mb-1">
|
<label htmlFor="lunNumber" className="block text-sm font-medium text-white mb-1">
|
||||||
@@ -392,6 +466,7 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
|||||||
value={lunNumber}
|
value={lunNumber}
|
||||||
onChange={(e) => setLunNumber(parseInt(e.target.value) || 0)}
|
onChange={(e) => setLunNumber(parseInt(e.target.value) || 0)}
|
||||||
min="0"
|
min="0"
|
||||||
|
max="255"
|
||||||
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"
|
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
|
required
|
||||||
/>
|
/>
|
||||||
@@ -404,8 +479,11 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
|||||||
<Button type="button" variant="outline" onClick={onClose}>
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={addLUNMutation.isPending}>
|
<Button
|
||||||
{addLUNMutation.isPending ? 'Adding...' : 'Add LUN'}
|
type="submit"
|
||||||
|
disabled={addLUNMutation.isPending || availableExtents.length === 0}
|
||||||
|
>
|
||||||
|
{addLUNMutation.isPending ? 'Assigning...' : 'Assign Extent'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -15,6 +15,14 @@ export default function ISCSITargets() {
|
|||||||
const { data: targets, isLoading } = useQuery<SCSTTarget[]>({
|
const { data: targets, isLoading } = useQuery<SCSTTarget[]>({
|
||||||
queryKey: ['scst-targets'],
|
queryKey: ['scst-targets'],
|
||||||
queryFn: scstAPI.listTargets,
|
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({
|
const applyConfigMutation = useMutation({
|
||||||
@@ -158,6 +166,19 @@ export default function ISCSITargets() {
|
|||||||
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
|
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('groups')}
|
||||||
|
className={`relative py-4 text-sm tracking-wide transition-colors ${
|
||||||
|
activeTab === 'groups'
|
||||||
|
? 'text-primary font-bold'
|
||||||
|
: 'text-text-secondary hover:text-white font-medium'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Groups
|
||||||
|
{activeTab === 'groups' && (
|
||||||
|
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -206,6 +227,12 @@ export default function ISCSITargets() {
|
|||||||
target={target}
|
target={target}
|
||||||
isExpanded={expandedTarget === target.id}
|
isExpanded={expandedTarget === target.id}
|
||||||
onToggle={() => setExpandedTarget(expandedTarget === target.id ? null : 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}
|
isLast={index === filteredTargets.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -244,6 +271,10 @@ export default function ISCSITargets() {
|
|||||||
{activeTab === 'extents' && (
|
{activeTab === 'extents' && (
|
||||||
<ExtentsTab />
|
<ExtentsTab />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'groups' && (
|
||||||
|
<InitiatorGroupsTab />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -253,7 +284,7 @@ export default function ISCSITargets() {
|
|||||||
onClose={() => setShowCreateForm(false)}
|
onClose={() => setShowCreateForm(false)}
|
||||||
onSuccess={async () => {
|
onSuccess={async () => {
|
||||||
setShowCreateForm(false)
|
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.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||||
await queryClient.refetchQueries({ queryKey: ['scst-targets'] })
|
await queryClient.refetchQueries({ queryKey: ['scst-targets'] })
|
||||||
}}
|
}}
|
||||||
@@ -267,10 +298,11 @@ interface TargetRowProps {
|
|||||||
target: SCSTTarget
|
target: SCSTTarget
|
||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
onToggle: () => void
|
onToggle: () => void
|
||||||
|
onDelete?: () => void
|
||||||
isLast?: boolean
|
isLast?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) {
|
function TargetRow({ target, isExpanded, onToggle, onDelete }: TargetRowProps) {
|
||||||
// Fetch LUNs when expanded
|
// Fetch LUNs when expanded
|
||||||
const { data: targetData } = useQuery({
|
const { data: targetData } = useQuery({
|
||||||
queryKey: ['scst-target', target.id],
|
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<SCSTTarget[]>(['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<SCSTTarget[]>(['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<SCSTTarget[]>(['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<SCSTTarget[]>(['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 (
|
return (
|
||||||
<div className={`group border-b border-border-dark ${isExpanded ? 'bg-white/[0.02]' : 'bg-transparent'}`}>
|
<div className={`group border-b border-border-dark ${isExpanded ? 'bg-white/[0.02]' : 'bg-transparent'}`}>
|
||||||
{/* Main Row */}
|
{/* Main Row */}
|
||||||
@@ -424,6 +569,18 @@ function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) {
|
|||||||
{lun.device_type || 'Unknown type'}
|
{lun.device_type || 'Unknown type'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remove LUN ${lun.lun_number} from this target?`)) {
|
||||||
|
removeLUNMutation.mutate({ targetId: target.id, lunId: lun.id })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={removeLUNMutation.isPending}
|
||||||
|
className="p-1.5 hover:bg-red-500/10 rounded text-text-secondary hover:text-red-400 transition-colors disabled:opacity-50"
|
||||||
|
title="Remove LUN"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
@@ -447,6 +604,19 @@ function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) {
|
|||||||
>
|
>
|
||||||
Edit Policy
|
Edit Policy
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (confirm(`Delete target "${target.alias || target.iqn}"? This will remove the target from SCST and all associated LUNs and initiators. This action cannot be undone.`)) {
|
||||||
|
deleteMutation.mutate()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="px-3 py-1.5 text-xs bg-red-500/20 text-red-400 hover:bg-red-500/30 rounded border border-red-500/20 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
Delete Target
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2 h-full">
|
<div className="flex flex-col gap-2 h-full">
|
||||||
<div className="p-3 rounded bg-card-dark border border-border-dark flex flex-col gap-2">
|
<div className="p-3 rounded bg-card-dark border border-border-dark flex flex-col gap-2">
|
||||||
@@ -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) => {
|
const handleAddInitiator = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!initiatorIQN.trim()) {
|
if (!initiatorIQN.trim()) {
|
||||||
@@ -620,10 +802,11 @@ function EditPolicyModal({ target, initiatorGroups, onClose, onSuccess }: EditPo
|
|||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (confirm(`Remove initiator ${initiator.iqn}?`)) {
|
if (confirm(`Remove initiator ${initiator.iqn}?`)) {
|
||||||
alert('Remove initiator functionality coming soon')
|
removeInitiatorMutation.mutate(initiator.id)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-2 hover:bg-red-500/10 rounded-lg text-text-secondary hover:text-red-400 transition-colors"
|
disabled={removeInitiatorMutation.isPending}
|
||||||
|
className="p-2 hover:bg-red-500/10 rounded-lg text-text-secondary hover:text-red-400 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
title="Remove initiator"
|
title="Remove initiator"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
@@ -682,10 +865,14 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
|
|||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: scstAPI.createTarget,
|
mutationFn: scstAPI.createTarget,
|
||||||
onSuccess: async () => {
|
onSuccess: async (newTarget) => {
|
||||||
// Invalidate and refetch targets list
|
// Invalidate and refetch targets list to ensure we get the latest data
|
||||||
await queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
await queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||||
await queryClient.refetchQueries({ queryKey: ['scst-targets'] })
|
await queryClient.refetchQueries({ queryKey: ['scst-targets'] })
|
||||||
|
// Also invalidate the specific target query if it exists
|
||||||
|
if (newTarget?.id) {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['scst-target', newTarget.id] })
|
||||||
|
}
|
||||||
onSuccess()
|
onSuccess()
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -1164,19 +1351,21 @@ function InitiatorsTab() {
|
|||||||
{initiator.is_active ? 'Active' : 'Inactive'}
|
{initiator.is_active ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 text-xs text-text-secondary">
|
<div className="flex items-center gap-4 text-xs text-text-secondary flex-wrap mt-1">
|
||||||
{initiator.target_iqn && (
|
{initiator.target_iqn && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-medium">Target:</span>
|
<span className="font-semibold text-text-secondary/80">Target:</span>
|
||||||
<span className="font-mono truncate max-w-[300px]">
|
<span className="font-mono text-white/90 truncate max-w-[300px]" title={initiator.target_iqn}>
|
||||||
{initiator.target_name || initiator.target_iqn}
|
{initiator.target_name || initiator.target_iqn.split(':').pop()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{initiator.group_name && (
|
{initiator.group_name && (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="font-medium">Group:</span>
|
<span className="font-semibold text-text-secondary/80">Group:</span>
|
||||||
<span className="truncate">{initiator.group_name}</span>
|
<span className="font-mono text-white/90 truncate max-w-[300px]" title={initiator.group_name}>
|
||||||
|
{initiator.group_name}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1230,7 +1419,15 @@ function ExtentsTab() {
|
|||||||
|
|
||||||
const { data: extents = [], isLoading } = useQuery<SCSTExtent[]>({
|
const { data: extents = [], isLoading } = useQuery<SCSTExtent[]>({
|
||||||
queryKey: ['scst-extents'],
|
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({
|
const { data: handlersData } = useQuery({
|
||||||
@@ -1247,11 +1444,50 @@ function ExtentsTab() {
|
|||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: scstAPI.deleteExtent,
|
mutationFn: scstAPI.deleteExtent,
|
||||||
onSuccess: () => {
|
onMutate: async (deviceName: string) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['scst-extents'] })
|
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||||
queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
await queryClient.cancelQueries({ queryKey: ['scst-extents'] })
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['scst-targets'] })
|
||||||
|
|
||||||
|
// Snapshot the previous value
|
||||||
|
const previousExtents = queryClient.getQueryData<SCSTExtent[]>(['scst-extents'])
|
||||||
|
|
||||||
|
// Optimistically update to remove the extent
|
||||||
|
queryClient.setQueryData<SCSTExtent[]>(['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<SCSTExtent[]>(['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}`)
|
alert(`Failed to delete extent: ${error.response?.data?.error || error.message}`)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1383,9 +1619,16 @@ function ExtentsTab() {
|
|||||||
<CreateExtentModal
|
<CreateExtentModal
|
||||||
handlers={handlers}
|
handlers={handlers}
|
||||||
onClose={() => setShowCreateModal(false)}
|
onClose={() => setShowCreateModal(false)}
|
||||||
onSuccess={() => {
|
onSuccess={async () => {
|
||||||
setShowCreateModal(false)
|
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<void> }) {
|
||||||
const [deviceName, setDeviceName] = useState('')
|
const [deviceName, setDeviceName] = useState('')
|
||||||
const [devicePath, setDevicePath] = useState('')
|
const [devicePath, setDevicePath] = useState('')
|
||||||
const [handlerType, setHandlerType] = useState('')
|
const [handlerType, setHandlerType] = useState('')
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: CreateExtentRequest) => scstAPI.createExtent(data),
|
mutationFn: (data: CreateExtentRequest) => scstAPI.createExtent(data),
|
||||||
onSuccess: () => {
|
onSuccess: async () => {
|
||||||
onSuccess()
|
// Call onSuccess callback which will handle refresh
|
||||||
|
await onSuccess()
|
||||||
alert('Extent created successfully!')
|
alert('Extent created successfully!')
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
@@ -1512,3 +1756,557 @@ function CreateExtentModal({ handlers, onClose, onSuccess }: { handlers: Array<{
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function InitiatorGroupsTab() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
|
const [editingGroup, setEditingGroup] = useState<SCSTInitiatorGroup | null>(null)
|
||||||
|
const [expandedGroup, setExpandedGroup] = useState<string | null>(null)
|
||||||
|
const [showAddInitiatorModal, setShowAddInitiatorModal] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const { data: groups = [], isLoading } = useQuery<SCSTInitiatorGroup[]>({
|
||||||
|
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<SCSTTarget[]>({
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-white text-2xl font-bold">iSCSI Initiator Groups</h2>
|
||||||
|
<p className="text-text-secondary text-sm mt-1">Manage initiator access control groups</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowCreateModal(true)}>
|
||||||
|
<Plus size={16} className="mr-2" />
|
||||||
|
Create Group
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toolbar */}
|
||||||
|
<div className="p-4 flex items-center justify-between gap-4 border-b border-border-dark/50 bg-[#141d26]">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-secondary" size={20} />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search groups by name or target..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Groups List */}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-8 text-center text-text-secondary">Loading groups...</div>
|
||||||
|
) : filteredGroups.length > 0 ? (
|
||||||
|
<div className="bg-[#141d26] border border-border-dark rounded-lg overflow-hidden">
|
||||||
|
<div className="divide-y divide-border-dark">
|
||||||
|
{filteredGroups.map((group) => {
|
||||||
|
const target = targets.find(t => t.id === group.target_id)
|
||||||
|
const isExpanded = expandedGroup === group.id
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
className="border-b border-border-dark last:border-b-0"
|
||||||
|
>
|
||||||
|
<div className="p-4 hover:bg-white/5 transition-colors">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedGroup(isExpanded ? null : group.id)}
|
||||||
|
className="p-2 rounded-md bg-primary/10 text-primary hover:bg-primary/20 transition-colors"
|
||||||
|
>
|
||||||
|
{isExpanded ? <ChevronDown size={20} /> : <ChevronRight size={20} />}
|
||||||
|
</button>
|
||||||
|
<div className="p-2 rounded-md bg-primary/10 text-primary">
|
||||||
|
<Network size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3 mb-1">
|
||||||
|
<span className="text-white font-mono text-sm font-medium">
|
||||||
|
{group.group_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 text-xs text-text-secondary">
|
||||||
|
{target && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-medium">Target:</span>
|
||||||
|
<span className="font-mono truncate max-w-[300px]" title={target.iqn}>
|
||||||
|
{target.alias || target.iqn.split(':').pop()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-medium">Initiators:</span>
|
||||||
|
<span className="text-white/90">
|
||||||
|
{group.initiators?.length || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="font-medium">Created:</span>
|
||||||
|
<span>{new Date(group.created_at).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingGroup(group)}
|
||||||
|
className="p-2 hover:bg-white/10 rounded-lg text-text-secondary hover:text-white transition-colors"
|
||||||
|
title="Edit group name"
|
||||||
|
>
|
||||||
|
<Settings size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(group)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
className="p-2 hover:bg-red-500/10 rounded-lg text-text-secondary hover:text-red-400 transition-colors disabled:opacity-50"
|
||||||
|
title="Delete group"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded view with initiators list */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="px-4 pb-4 bg-[#0f161d] border-t border-border-dark">
|
||||||
|
<div className="flex items-center justify-between mb-3 mt-3">
|
||||||
|
<h4 className="text-white text-sm font-semibold">Group Members</h4>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAddInitiatorModal(group.id)}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Plus size={14} className="mr-1" />
|
||||||
|
Add Initiator
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{group.initiators && group.initiators.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{group.initiators.map((initiator) => (
|
||||||
|
<div
|
||||||
|
key={initiator.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-[#141d26] border border-border-dark rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className="p-1.5 rounded bg-primary/10 text-primary">
|
||||||
|
<Network size={14} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white font-mono text-xs truncate">
|
||||||
|
{initiator.iqn}
|
||||||
|
</span>
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ${
|
||||||
|
initiator.is_active
|
||||||
|
? 'bg-green-500/20 text-green-400'
|
||||||
|
: 'bg-red-500/20 text-red-400'
|
||||||
|
}`}>
|
||||||
|
{initiator.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(initiator.iqn)
|
||||||
|
}}
|
||||||
|
className="p-1.5 hover:bg-white/10 rounded text-text-secondary hover:text-white transition-colors"
|
||||||
|
title="Copy IQN"
|
||||||
|
>
|
||||||
|
<Copy size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remove initiator "${initiator.iqn}" from this group?`)) {
|
||||||
|
removeInitiatorMutation.mutate(initiator.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={removeInitiatorMutation.isPending}
|
||||||
|
className="p-1.5 hover:bg-red-500/10 rounded text-text-secondary hover:text-red-400 transition-colors disabled:opacity-50"
|
||||||
|
title="Remove initiator"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-6 text-center border border-border-dark rounded-lg bg-[#141d26]">
|
||||||
|
<p className="text-text-secondary text-sm mb-2">No initiators in this group</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowAddInitiatorModal(group.id)}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<Plus size={14} className="mr-1" />
|
||||||
|
Add First Initiator
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center">
|
||||||
|
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-border-dark/50 mb-4">
|
||||||
|
<Network className="text-text-secondary" size={32} />
|
||||||
|
</div>
|
||||||
|
<p className="text-white font-medium mb-1">No groups found</p>
|
||||||
|
<p className="text-text-secondary text-sm">
|
||||||
|
{searchQuery
|
||||||
|
? 'Try adjusting your search criteria'
|
||||||
|
: 'Create an initiator group to organize initiators by access control'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Group Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<CreateGroupModal
|
||||||
|
targets={targets}
|
||||||
|
onClose={() => setShowCreateModal(false)}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
onSubmit={(data) => createMutation.mutate(data)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Edit Group Modal */}
|
||||||
|
{editingGroup && (
|
||||||
|
<EditGroupModal
|
||||||
|
group={editingGroup}
|
||||||
|
onClose={() => setEditingGroup(null)}
|
||||||
|
isLoading={updateMutation.isPending}
|
||||||
|
onSubmit={(data) => updateMutation.mutate({ id: editingGroup.id, data })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Initiator Modal */}
|
||||||
|
{showAddInitiatorModal && (
|
||||||
|
<AddInitiatorToGroupModal
|
||||||
|
groupName={groups.find(g => g.id === showAddInitiatorModal)?.group_name || ''}
|
||||||
|
onClose={() => setShowAddInitiatorModal(null)}
|
||||||
|
isLoading={addInitiatorMutation.isPending}
|
||||||
|
onSubmit={(initiatorIQN) => addInitiatorMutation.mutate({ groupId: showAddInitiatorModal, initiatorIQN })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-[#141d26] border border-border-dark rounded-lg p-6 w-full max-w-md">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-white text-lg font-bold">Create Initiator Group</h3>
|
||||||
|
<button onClick={onClose} className="text-text-secondary hover:text-white">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Target *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={targetId}
|
||||||
|
onChange={(e) => setTargetId(e.target.value)}
|
||||||
|
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
|
||||||
|
>
|
||||||
|
<option value="">Select a target</option>
|
||||||
|
{targets.map(target => (
|
||||||
|
<option key={target.id} value={target.id} className="bg-[#0f161d] text-white">
|
||||||
|
{target.alias || target.iqn.split(':').pop()} ({target.iqn})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Group Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={groupName}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-secondary mt-1">
|
||||||
|
Group name will be used as ACL group name in SCST
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3 mt-6">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Creating...' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-[#141d26] border border-border-dark rounded-lg p-6 w-full max-w-md">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-white text-lg font-bold">Edit Initiator Group</h3>
|
||||||
|
<button onClick={onClose} className="text-text-secondary hover:text-white">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Group Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={groupName}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-secondary mt-1">
|
||||||
|
Changing the group name will recreate it in SCST
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3 mt-6">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Updating...' : 'Update'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-[#141d26] border border-border-dark rounded-lg p-6 w-full max-w-md">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h3 className="text-white text-lg font-bold">Add Initiator to Group</h3>
|
||||||
|
<button onClick={onClose} className="text-text-secondary hover:text-white">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Group
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={groupName}
|
||||||
|
disabled
|
||||||
|
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white/60 text-sm cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-secondary mb-2">
|
||||||
|
Initiator IQN *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={initiatorIQN}
|
||||||
|
onChange={(e) => 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
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-text-secondary mt-1">
|
||||||
|
Enter the IQN of the initiator to add to this group
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end gap-3 mt-6">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose} disabled={isLoading}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? 'Adding...' : 'Add Initiator'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user