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/:id", scstHandler.GetTarget)
|
||||
scstGroup.POST("/targets", scstHandler.CreateTarget)
|
||||
scstGroup.POST("/targets/:id/luns", scstHandler.AddLUN)
|
||||
scstGroup.POST("/targets/:id/luns", requirePermission("iscsi", "write"), scstHandler.AddLUN)
|
||||
scstGroup.DELETE("/targets/:id/luns/:lunId", requirePermission("iscsi", "write"), scstHandler.RemoveLUN)
|
||||
scstGroup.POST("/targets/:id/initiators", scstHandler.AddInitiator)
|
||||
scstGroup.POST("/targets/:id/enable", scstHandler.EnableTarget)
|
||||
scstGroup.POST("/targets/:id/disable", scstHandler.DisableTarget)
|
||||
scstGroup.DELETE("/targets/:id", requirePermission("iscsi", "write"), scstHandler.DeleteTarget)
|
||||
scstGroup.GET("/initiators", scstHandler.ListAllInitiators)
|
||||
scstGroup.GET("/initiators/:id", scstHandler.GetInitiator)
|
||||
scstGroup.DELETE("/initiators/:id", scstHandler.RemoveInitiator)
|
||||
@@ -223,6 +225,13 @@ func NewRouter(cfg *config.Config, db *database.DB, log *logger.Logger) *gin.Eng
|
||||
scstGroup.POST("/portals", scstHandler.CreatePortal)
|
||||
scstGroup.PUT("/portals/:id", scstHandler.UpdatePortal)
|
||||
scstGroup.DELETE("/portals/:id", scstHandler.DeletePortal)
|
||||
// Initiator Groups routes
|
||||
scstGroup.GET("/initiator-groups", scstHandler.ListAllInitiatorGroups)
|
||||
scstGroup.GET("/initiator-groups/:id", scstHandler.GetInitiatorGroup)
|
||||
scstGroup.POST("/initiator-groups", requirePermission("iscsi", "write"), scstHandler.CreateInitiatorGroup)
|
||||
scstGroup.PUT("/initiator-groups/:id", requirePermission("iscsi", "write"), scstHandler.UpdateInitiatorGroup)
|
||||
scstGroup.DELETE("/initiator-groups/:id", requirePermission("iscsi", "write"), scstHandler.DeleteInitiatorGroup)
|
||||
scstGroup.POST("/initiator-groups/:id/initiators", requirePermission("iscsi", "write"), scstHandler.AddInitiatorToGroup)
|
||||
}
|
||||
|
||||
// Physical Tape Libraries
|
||||
|
||||
@@ -3,11 +3,13 @@ package scst
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/atlasos/calypso/internal/common/database"
|
||||
"github.com/atlasos/calypso/internal/common/logger"
|
||||
"github.com/atlasos/calypso/internal/tasks"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
// Handler handles SCST-related API requests
|
||||
@@ -37,6 +39,11 @@ func (h *Handler) ListTargets(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure we return an empty array instead of null
|
||||
if targets == nil {
|
||||
targets = []Target{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"targets": targets})
|
||||
}
|
||||
|
||||
@@ -112,6 +119,11 @@ func (h *Handler) CreateTarget(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Set alias to name for frontend compatibility (same as ListTargets)
|
||||
target.Alias = target.Name
|
||||
// LUNCount will be 0 for newly created target
|
||||
target.LUNCount = 0
|
||||
|
||||
c.JSON(http.StatusCreated, target)
|
||||
}
|
||||
|
||||
@@ -119,7 +131,7 @@ func (h *Handler) CreateTarget(c *gin.Context) {
|
||||
type AddLUNRequest struct {
|
||||
DeviceName string `json:"device_name" binding:"required"`
|
||||
DevicePath string `json:"device_path" binding:"required"`
|
||||
LUNNumber int `json:"lun_number" binding:"required"`
|
||||
LUNNumber int `json:"lun_number"` // Note: cannot use binding:"required" for int as 0 is valid
|
||||
HandlerType string `json:"handler_type" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -136,17 +148,45 @@ func (h *Handler) AddLUN(c *gin.Context) {
|
||||
var req AddLUNRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
h.logger.Error("Failed to bind AddLUN request", "error", err)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid request: %v", err)})
|
||||
// Provide more detailed error message
|
||||
if validationErr, ok := err.(validator.ValidationErrors); ok {
|
||||
var errorMessages []string
|
||||
for _, fieldErr := range validationErr {
|
||||
errorMessages = append(errorMessages, fmt.Sprintf("%s is required", fieldErr.Field()))
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("validation failed: %s", strings.Join(errorMessages, ", "))})
|
||||
} else {
|
||||
// Extract error message without full struct name
|
||||
errMsg := err.Error()
|
||||
if idx := strings.Index(errMsg, "Key: '"); idx >= 0 {
|
||||
// Extract field name from error message
|
||||
fieldStart := idx + 6 // Length of "Key: '"
|
||||
if fieldEnd := strings.Index(errMsg[fieldStart:], "'"); fieldEnd >= 0 {
|
||||
fieldName := errMsg[fieldStart : fieldStart+fieldEnd]
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid or missing field: %s", fieldName)})
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request format"})
|
||||
}
|
||||
} else {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("invalid request: %v", err)})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
// Validate required fields (additional check in case binding doesn't catch it)
|
||||
if req.DeviceName == "" || req.DevicePath == "" || req.HandlerType == "" {
|
||||
h.logger.Error("Missing required fields in AddLUN request", "device_name", req.DeviceName, "device_path", req.DevicePath, "handler_type", req.HandlerType)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "device_name, device_path, and handler_type are required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate LUN number range
|
||||
if req.LUNNumber < 0 || req.LUNNumber > 255 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "lun_number must be between 0 and 255"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.service.AddLUN(c.Request.Context(), target.IQN, req.DeviceName, req.DevicePath, req.LUNNumber, req.HandlerType); err != nil {
|
||||
h.logger.Error("Failed to add LUN", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -156,6 +196,48 @@ func (h *Handler) AddLUN(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "LUN added successfully"})
|
||||
}
|
||||
|
||||
// RemoveLUN removes a LUN from a target
|
||||
func (h *Handler) RemoveLUN(c *gin.Context) {
|
||||
targetID := c.Param("id")
|
||||
lunID := c.Param("lunId")
|
||||
|
||||
// Get target
|
||||
target, err := h.service.GetTarget(c.Request.Context(), targetID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "target not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// Get LUN to get the LUN number
|
||||
var lunNumber int
|
||||
err = h.db.QueryRowContext(c.Request.Context(),
|
||||
"SELECT lun_number FROM scst_luns WHERE id = $1 AND target_id = $2",
|
||||
lunID, targetID,
|
||||
).Scan(&lunNumber)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
// LUN already deleted from database - check if it still exists in SCST
|
||||
// Try to get LUN number from URL or try common LUN numbers
|
||||
// For now, return success since it's already deleted (idempotent)
|
||||
h.logger.Info("LUN not found in database, may already be deleted", "lun_id", lunID, "target_id", targetID)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "LUN already removed or not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to get LUN", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get LUN"})
|
||||
return
|
||||
}
|
||||
|
||||
// Remove LUN
|
||||
if err := h.service.RemoveLUN(c.Request.Context(), target.IQN, lunNumber); err != nil {
|
||||
h.logger.Error("Failed to remove LUN", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "LUN removed successfully"})
|
||||
}
|
||||
|
||||
// AddInitiatorRequest represents an initiator addition request
|
||||
type AddInitiatorRequest struct {
|
||||
InitiatorIQN string `json:"initiator_iqn" binding:"required"`
|
||||
@@ -186,6 +268,45 @@ func (h *Handler) AddInitiator(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Initiator added successfully"})
|
||||
}
|
||||
|
||||
// AddInitiatorToGroupRequest represents a request to add an initiator to a group
|
||||
type AddInitiatorToGroupRequest struct {
|
||||
InitiatorIQN string `json:"initiator_iqn" binding:"required"`
|
||||
}
|
||||
|
||||
// AddInitiatorToGroup adds an initiator to a specific group
|
||||
func (h *Handler) AddInitiatorToGroup(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
|
||||
var req AddInitiatorToGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
validationErrors := make(map[string]string)
|
||||
if ve, ok := err.(validator.ValidationErrors); ok {
|
||||
for _, fe := range ve {
|
||||
field := strings.ToLower(fe.Field())
|
||||
validationErrors[field] = fmt.Sprintf("Field '%s' is required", field)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid request",
|
||||
"validation_errors": validationErrors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
err := h.service.AddInitiatorToGroup(c.Request.Context(), groupID, req.InitiatorIQN)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "single initiator only") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to add initiator to group", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add initiator to group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Initiator added to group successfully"})
|
||||
}
|
||||
|
||||
// ListAllInitiators lists all initiators across all targets
|
||||
func (h *Handler) ListAllInitiators(c *gin.Context) {
|
||||
initiators, err := h.service.ListAllInitiators(c.Request.Context())
|
||||
@@ -440,6 +561,23 @@ func (h *Handler) DisableTarget(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Target disabled successfully"})
|
||||
}
|
||||
|
||||
// DeleteTarget deletes a target
|
||||
func (h *Handler) DeleteTarget(c *gin.Context) {
|
||||
targetID := c.Param("id")
|
||||
|
||||
if err := h.service.DeleteTarget(c.Request.Context(), targetID); err != nil {
|
||||
if err.Error() == "target not found" {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "target not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to delete target", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Target deleted successfully"})
|
||||
}
|
||||
|
||||
// DeletePortal deletes a portal
|
||||
func (h *Handler) DeletePortal(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
@@ -474,3 +612,136 @@ func (h *Handler) GetPortal(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, portal)
|
||||
}
|
||||
|
||||
// CreateInitiatorGroupRequest represents a request to create an initiator group
|
||||
type CreateInitiatorGroupRequest struct {
|
||||
TargetID string `json:"target_id" binding:"required"`
|
||||
GroupName string `json:"group_name" binding:"required"`
|
||||
}
|
||||
|
||||
// CreateInitiatorGroup creates a new initiator group
|
||||
func (h *Handler) CreateInitiatorGroup(c *gin.Context) {
|
||||
var req CreateInitiatorGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
validationErrors := make(map[string]string)
|
||||
if ve, ok := err.(validator.ValidationErrors); ok {
|
||||
for _, fe := range ve {
|
||||
field := strings.ToLower(fe.Field())
|
||||
validationErrors[field] = fmt.Sprintf("Field '%s' is required", field)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid request",
|
||||
"validation_errors": validationErrors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
group, err := h.service.CreateInitiatorGroup(c.Request.Context(), req.TargetID, req.GroupName)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") || strings.Contains(err.Error(), "not found") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to create initiator group", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create initiator group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, group)
|
||||
}
|
||||
|
||||
// UpdateInitiatorGroupRequest represents a request to update an initiator group
|
||||
type UpdateInitiatorGroupRequest struct {
|
||||
GroupName string `json:"group_name" binding:"required"`
|
||||
}
|
||||
|
||||
// UpdateInitiatorGroup updates an initiator group
|
||||
func (h *Handler) UpdateInitiatorGroup(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
|
||||
var req UpdateInitiatorGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
validationErrors := make(map[string]string)
|
||||
if ve, ok := err.(validator.ValidationErrors); ok {
|
||||
for _, fe := range ve {
|
||||
field := strings.ToLower(fe.Field())
|
||||
validationErrors[field] = fmt.Sprintf("Field '%s' is required", field)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusBadRequest, gin.H{
|
||||
"error": "invalid request",
|
||||
"validation_errors": validationErrors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
group, err := h.service.UpdateInitiatorGroup(c.Request.Context(), groupID, req.GroupName)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "already exists") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to update initiator group", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update initiator group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, group)
|
||||
}
|
||||
|
||||
// DeleteInitiatorGroup deletes an initiator group
|
||||
func (h *Handler) DeleteInitiatorGroup(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
|
||||
err := h.service.DeleteInitiatorGroup(c.Request.Context(), groupID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "cannot delete") || strings.Contains(err.Error(), "contains") {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to delete initiator group", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete initiator group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "initiator group deleted successfully"})
|
||||
}
|
||||
|
||||
// GetInitiatorGroup retrieves an initiator group by ID
|
||||
func (h *Handler) GetInitiatorGroup(c *gin.Context) {
|
||||
groupID := c.Param("id")
|
||||
|
||||
group, err := h.service.GetInitiatorGroup(c.Request.Context(), groupID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "not found") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "initiator group not found"})
|
||||
return
|
||||
}
|
||||
h.logger.Error("Failed to get initiator group", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get initiator group"})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, group)
|
||||
}
|
||||
|
||||
// ListAllInitiatorGroups lists all initiator groups
|
||||
func (h *Handler) ListAllInitiatorGroups(c *gin.Context) {
|
||||
groups, err := h.service.ListAllInitiatorGroups(c.Request.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("Failed to list initiator groups", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list initiator groups"})
|
||||
return
|
||||
}
|
||||
|
||||
if groups == nil {
|
||||
groups = []InitiatorGroup{}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"groups": groups})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,9 @@ const apiClient = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -88,7 +88,14 @@ export interface AddInitiatorRequest {
|
||||
|
||||
export const scstAPI = {
|
||||
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 || []
|
||||
},
|
||||
|
||||
@@ -97,7 +104,14 @@ export const scstAPI = {
|
||||
luns: SCSTLUN[]
|
||||
initiator_groups?: SCSTInitiatorGroup[]
|
||||
}> => {
|
||||
const response = await apiClient.get(`/scst/targets/${id}`)
|
||||
const response = await apiClient.get(`/scst/targets/${id}`, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
params: {
|
||||
_t: Date.now(), // Add timestamp to prevent browser caching
|
||||
},
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -112,6 +126,11 @@ export const scstAPI = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
removeLUN: async (targetId: string, lunId: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete(`/scst/targets/${targetId}/luns/${lunId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
addInitiator: async (targetId: string, data: AddInitiatorRequest): Promise<{ task_id: string }> => {
|
||||
const response = await apiClient.post(`/scst/targets/${targetId}/initiators`, data)
|
||||
return response.data
|
||||
@@ -123,17 +142,38 @@ export const scstAPI = {
|
||||
},
|
||||
|
||||
listHandlers: async (): Promise<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 || []
|
||||
},
|
||||
|
||||
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 || []
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
@@ -161,13 +201,32 @@ export const scstAPI = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
deleteTarget: async (targetId: string): Promise<{ message: string }> => {
|
||||
const response = await apiClient.delete(`/scst/targets/${targetId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
listInitiators: async (): Promise<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 || []
|
||||
},
|
||||
|
||||
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
|
||||
},
|
||||
|
||||
@@ -176,7 +235,14 @@ export const scstAPI = {
|
||||
},
|
||||
|
||||
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 || []
|
||||
},
|
||||
|
||||
@@ -188,6 +254,52 @@ export const scstAPI = {
|
||||
deleteExtent: async (deviceName: string): Promise<void> => {
|
||||
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 {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { scstAPI, type SCSTHandler } from '@/api/scst'
|
||||
import { scstAPI, type SCSTTarget, type SCSTExtent } from '@/api/scst'
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ArrowLeft, Plus, RefreshCw, HardDrive, Users } from 'lucide-react'
|
||||
import { ArrowLeft, Plus, RefreshCw, HardDrive, Users, Trash2 } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
export default function ISCSITargetDetail() {
|
||||
@@ -23,11 +23,64 @@ export default function ISCSITargetDetail() {
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
const { data: handlers } = useQuery<SCSTHandler[]>({
|
||||
queryKey: ['scst-handlers'],
|
||||
queryFn: scstAPI.listHandlers,
|
||||
staleTime: 0, // Always fetch fresh data
|
||||
refetchOnMount: true,
|
||||
const removeLUNMutation = useMutation({
|
||||
mutationFn: ({ targetId, lunId }: { targetId: string; lunId: string }) =>
|
||||
scstAPI.removeLUN(targetId, lunId),
|
||||
onMutate: async ({ lunId }) => {
|
||||
// Cancel any outgoing refetches
|
||||
await queryClient.cancelQueries({ queryKey: ['scst-target', id] })
|
||||
await queryClient.cancelQueries({ queryKey: ['scst-targets'] })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousTarget = queryClient.getQueryData(['scst-target', id])
|
||||
const previousTargets = queryClient.getQueryData<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) {
|
||||
@@ -124,7 +177,7 @@ export default function ISCSITargetDetail() {
|
||||
onClick={() => setShowAddLUN(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add LUN
|
||||
Assign Extent
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -153,12 +206,11 @@ export default function ISCSITargetDetail() {
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
console.log('Add LUN button clicked, setting showAddLUN to true')
|
||||
setShowAddLUN(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add LUN
|
||||
Assign Extent
|
||||
</Button>
|
||||
</div>
|
||||
</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">
|
||||
Status
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-text-secondary uppercase">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-card-dark divide-y divide-border-dark">
|
||||
@@ -211,6 +266,21 @@ export default function ISCSITargetDetail() {
|
||||
{lun.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</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>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -224,27 +294,28 @@ export default function ISCSITargetDetail() {
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
console.log('Add First LUN button clicked, setting showAddLUN to true')
|
||||
setShowAddLUN(true)
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add First LUN
|
||||
Assign First Extent
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Add LUN Form */}
|
||||
{/* Assign Extent Form */}
|
||||
{showAddLUN && (
|
||||
<AddLUNForm
|
||||
<AssignExtentForm
|
||||
targetId={target.id}
|
||||
handlers={handlers || []}
|
||||
onClose={() => setShowAddLUN(false)}
|
||||
onSuccess={() => {
|
||||
onSuccess={async () => {
|
||||
setShowAddLUN(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-target', id] })
|
||||
// Invalidate queries to refetch data.
|
||||
// Invalidate extents since one is now in use.
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-target', id] });
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-extents'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -264,47 +335,56 @@ export default function ISCSITargetDetail() {
|
||||
)
|
||||
}
|
||||
|
||||
interface AddLUNFormProps {
|
||||
interface AssignExtentFormProps {
|
||||
targetId: string
|
||||
handlers: SCSTHandler[]
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
onSuccess: () => Promise<void>
|
||||
}
|
||||
|
||||
function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps) {
|
||||
const [handlerType, setHandlerType] = useState('')
|
||||
const [devicePath, setDevicePath] = useState('')
|
||||
const [deviceName, setDeviceName] = useState('')
|
||||
function AssignExtentForm({ targetId, onClose, onSuccess }: AssignExtentFormProps) {
|
||||
const [selectedExtent, setSelectedExtent] = useState('')
|
||||
const [lunNumber, setLunNumber] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
console.log('AddLUNForm mounted, targetId:', targetId, 'handlers:', handlers)
|
||||
}, [targetId, handlers])
|
||||
// Fetch available extents
|
||||
const { data: extents = [], isLoading: extentsLoading } = useQuery<SCSTExtent[]>({
|
||||
queryKey: ['scst-extents'],
|
||||
queryFn: scstAPI.listExtents,
|
||||
staleTime: 0,
|
||||
refetchOnMount: true,
|
||||
})
|
||||
|
||||
// Filter only extents that are not in use
|
||||
const availableExtents = extents.filter(extent => !extent.is_in_use)
|
||||
|
||||
const addLUNMutation = useMutation({
|
||||
mutationFn: (data: { device_name: string; device_path: string; lun_number: number; handler_type: string }) =>
|
||||
scstAPI.addLUN(targetId, data),
|
||||
onSuccess: () => {
|
||||
onSuccess()
|
||||
onSuccess: async () => {
|
||||
await onSuccess()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
console.error('Failed to add LUN:', error)
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Failed to add LUN'
|
||||
const errorMessage = error.response?.data?.error || error.message || 'Failed to assign extent'
|
||||
alert(errorMessage)
|
||||
},
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!handlerType || !devicePath || !deviceName || lunNumber < 0) {
|
||||
alert('All fields are required')
|
||||
if (!selectedExtent || lunNumber < 0) {
|
||||
alert('Please select an extent and specify LUN number')
|
||||
return
|
||||
}
|
||||
|
||||
const extent = availableExtents.find(e => e.device_name === selectedExtent)
|
||||
if (!extent) {
|
||||
alert('Selected extent not found')
|
||||
return
|
||||
}
|
||||
|
||||
addLUNMutation.mutate({
|
||||
handler_type: handlerType.trim(),
|
||||
device_path: devicePath.trim(),
|
||||
device_name: deviceName.trim(),
|
||||
device_name: extent.device_name,
|
||||
device_path: extent.device_path,
|
||||
handler_type: extent.handler_type,
|
||||
lun_number: lunNumber,
|
||||
})
|
||||
}
|
||||
@@ -313,74 +393,68 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
||||
<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="p-6 border-b border-border-dark">
|
||||
<h2 className="text-xl font-bold text-white">Add LUN</h2>
|
||||
<p className="text-sm text-text-secondary mt-1">Bind a ZFS volume or storage device to this target</p>
|
||||
<h2 className="text-xl font-bold text-white">Assign Extent</h2>
|
||||
<p className="text-sm text-text-secondary mt-1">Assign an existing extent to this target as a LUN</p>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div>
|
||||
<label htmlFor="handlerType" className="block text-sm font-medium text-white mb-1">
|
||||
Handler Type *
|
||||
<label htmlFor="extent" className="block text-sm font-medium text-white mb-1">
|
||||
Available Extent *
|
||||
</label>
|
||||
<select
|
||||
id="handlerType"
|
||||
value={handlerType}
|
||||
onChange={(e) => setHandlerType(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 handler</option>
|
||||
{handlers.map((h) => (
|
||||
<option key={h.name} value={h.name}>
|
||||
{h.label || h.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="devicePath" className="block text-sm font-medium text-white mb-1">
|
||||
ZFS Volume Path *
|
||||
</label>
|
||||
<input
|
||||
id="devicePath"
|
||||
type="text"
|
||||
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
|
||||
/>
|
||||
{extentsLoading ? (
|
||||
<div className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-text-secondary text-sm">
|
||||
Loading extents...
|
||||
</div>
|
||||
) : availableExtents.length === 0 ? (
|
||||
<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.
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="extent"
|
||||
value={selectedExtent}
|
||||
onChange={(e) => setSelectedExtent(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 an extent...</option>
|
||||
{availableExtents.map((extent) => (
|
||||
<option key={extent.device_name} value={extent.device_name}>
|
||||
{extent.device_name} ({extent.handler_type}) - {extent.device_path}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="deviceName" className="block text-sm font-medium text-white mb-1">
|
||||
Device Name *
|
||||
</label>
|
||||
<input
|
||||
id="deviceName"
|
||||
type="text"
|
||||
value={deviceName}
|
||||
onChange={(e) => setDeviceName(e.target.value)}
|
||||
placeholder="device1"
|
||||
className="w-full px-3 py-2 bg-[#0f161d] border border-border-dark rounded-lg text-white text-sm focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-text-secondary">
|
||||
Logical name for this device in SCST (auto-filled from volume path)
|
||||
</p>
|
||||
</div>
|
||||
{selectedExtent && (
|
||||
<div className="p-4 bg-[#0f161d] border border-border-dark rounded-lg">
|
||||
<p className="text-sm text-text-secondary mb-2">Extent Details:</p>
|
||||
{(() => {
|
||||
const extent = availableExtents.find(e => e.device_name === selectedExtent)
|
||||
if (!extent) return null
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">Device Name:</span>
|
||||
<span className="text-white font-mono">{extent.device_name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-text-secondary">Handler:</span>
|
||||
<span className="text-white">{extent.handler_type}</span>
|
||||
</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>
|
||||
<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}
|
||||
onChange={(e) => setLunNumber(parseInt(e.target.value) || 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"
|
||||
required
|
||||
/>
|
||||
@@ -404,8 +479,11 @@ function AddLUNForm({ targetId, handlers, onClose, onSuccess }: AddLUNFormProps)
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={addLUNMutation.isPending}>
|
||||
{addLUNMutation.isPending ? 'Adding...' : 'Add LUN'}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={addLUNMutation.isPending || availableExtents.length === 0}
|
||||
>
|
||||
{addLUNMutation.isPending ? 'Assigning...' : 'Assign Extent'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -15,6 +15,14 @@ export default function ISCSITargets() {
|
||||
const { data: targets, isLoading } = useQuery<SCSTTarget[]>({
|
||||
queryKey: ['scst-targets'],
|
||||
queryFn: scstAPI.listTargets,
|
||||
refetchInterval: 3000, // Auto-refresh every 3 seconds
|
||||
refetchIntervalInBackground: true, // Continue refetching even when tab is in background
|
||||
refetchOnWindowFocus: true, // Refetch when window regains focus
|
||||
refetchOnMount: true, // Always refetch on mount
|
||||
refetchOnReconnect: true, // Refetch when network reconnects
|
||||
staleTime: 0, // Consider data stale immediately to ensure fresh data
|
||||
gcTime: 0, // Don't cache data (formerly cacheTime)
|
||||
structuralSharing: false, // Disable structural sharing to ensure updates are detected
|
||||
})
|
||||
|
||||
const applyConfigMutation = useMutation({
|
||||
@@ -158,6 +166,19 @@ export default function ISCSITargets() {
|
||||
<div className="absolute bottom-0 left-0 w-full h-0.5 bg-primary rounded-t-full"></div>
|
||||
)}
|
||||
</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>
|
||||
|
||||
@@ -206,6 +227,12 @@ export default function ISCSITargets() {
|
||||
target={target}
|
||||
isExpanded={expandedTarget === target.id}
|
||||
onToggle={() => setExpandedTarget(expandedTarget === target.id ? null : target.id)}
|
||||
onDelete={() => {
|
||||
// Close expanded view if this target was expanded
|
||||
if (expandedTarget === target.id) {
|
||||
setExpandedTarget(null)
|
||||
}
|
||||
}}
|
||||
isLast={index === filteredTargets.length - 1}
|
||||
/>
|
||||
))}
|
||||
@@ -244,6 +271,10 @@ export default function ISCSITargets() {
|
||||
{activeTab === 'extents' && (
|
||||
<ExtentsTab />
|
||||
)}
|
||||
|
||||
{activeTab === 'groups' && (
|
||||
<InitiatorGroupsTab />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -253,7 +284,7 @@ export default function ISCSITargets() {
|
||||
onClose={() => setShowCreateForm(false)}
|
||||
onSuccess={async () => {
|
||||
setShowCreateForm(false)
|
||||
// Invalidate and refetch to ensure fresh data
|
||||
// Force refetch targets list to ensure fresh data
|
||||
await queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
await queryClient.refetchQueries({ queryKey: ['scst-targets'] })
|
||||
}}
|
||||
@@ -267,10 +298,11 @@ interface TargetRowProps {
|
||||
target: SCSTTarget
|
||||
isExpanded: boolean
|
||||
onToggle: () => void
|
||||
onDelete?: () => void
|
||||
isLast?: boolean
|
||||
}
|
||||
|
||||
function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) {
|
||||
function TargetRow({ target, isExpanded, onToggle, onDelete }: TargetRowProps) {
|
||||
// Fetch LUNs when expanded
|
||||
const { data: targetData } = useQuery({
|
||||
queryKey: ['scst-target', target.id],
|
||||
@@ -303,6 +335,119 @@ function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) {
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => scstAPI.deleteTarget(target.id),
|
||||
onSuccess: async () => {
|
||||
// Close expanded view if this target was expanded
|
||||
if (isExpanded) {
|
||||
onToggle()
|
||||
}
|
||||
// Call onDelete callback if provided
|
||||
if (onDelete) {
|
||||
onDelete()
|
||||
}
|
||||
|
||||
// Optimistically remove target from cache immediately
|
||||
queryClient.setQueryData<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 (
|
||||
<div className={`group border-b border-border-dark ${isExpanded ? 'bg-white/[0.02]' : 'bg-transparent'}`}>
|
||||
{/* Main Row */}
|
||||
@@ -424,6 +569,18 @@ function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) {
|
||||
{lun.device_type || 'Unknown type'}
|
||||
</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>
|
||||
))
|
||||
) : (
|
||||
@@ -447,6 +604,19 @@ function TargetRow({ target, isExpanded, onToggle }: TargetRowProps) {
|
||||
>
|
||||
Edit Policy
|
||||
</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 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">
|
||||
@@ -540,6 +710,18 @@ function EditPolicyModal({ target, initiatorGroups, onClose, onSuccess }: EditPo
|
||||
},
|
||||
})
|
||||
|
||||
const removeInitiatorMutation = useMutation({
|
||||
mutationFn: (initiatorId: string) => scstAPI.removeInitiator(initiatorId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-target', target.id] })
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-initiators'] })
|
||||
onSuccess()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
alert(`Failed to remove initiator: ${error.response?.data?.error || error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
const handleAddInitiator = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!initiatorIQN.trim()) {
|
||||
@@ -620,10 +802,11 @@ function EditPolicyModal({ target, initiatorGroups, onClose, onSuccess }: EditPo
|
||||
<button
|
||||
onClick={() => {
|
||||
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"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
@@ -682,10 +865,14 @@ function CreateTargetForm({ onClose, onSuccess }: CreateTargetFormProps) {
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: scstAPI.createTarget,
|
||||
onSuccess: async () => {
|
||||
// Invalidate and refetch targets list
|
||||
onSuccess: async (newTarget) => {
|
||||
// Invalidate and refetch targets list to ensure we get the latest data
|
||||
await queryClient.invalidateQueries({ 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()
|
||||
},
|
||||
onError: (error: any) => {
|
||||
@@ -1164,19 +1351,21 @@ function InitiatorsTab() {
|
||||
{initiator.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</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 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">Target:</span>
|
||||
<span className="font-mono truncate max-w-[300px]">
|
||||
{initiator.target_name || initiator.target_iqn}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-semibold text-text-secondary/80">Target:</span>
|
||||
<span className="font-mono text-white/90 truncate max-w-[300px]" title={initiator.target_iqn}>
|
||||
{initiator.target_name || initiator.target_iqn.split(':').pop()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{initiator.group_name && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="font-medium">Group:</span>
|
||||
<span className="truncate">{initiator.group_name}</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-semibold text-text-secondary/80">Group:</span>
|
||||
<span className="font-mono text-white/90 truncate max-w-[300px]" title={initiator.group_name}>
|
||||
{initiator.group_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1230,7 +1419,15 @@ function ExtentsTab() {
|
||||
|
||||
const { data: extents = [], isLoading } = useQuery<SCSTExtent[]>({
|
||||
queryKey: ['scst-extents'],
|
||||
queryFn: scstAPI.listExtents,
|
||||
queryFn: () => scstAPI.listExtents(), // Wrap in arrow function to ensure fresh call
|
||||
refetchInterval: 3000, // Auto-refresh every 3 seconds
|
||||
refetchIntervalInBackground: true, // Continue refetching even when tab is in background
|
||||
refetchOnWindowFocus: true, // Refetch when window regains focus
|
||||
refetchOnMount: true, // Always refetch on mount
|
||||
refetchOnReconnect: true, // Refetch when network reconnects
|
||||
staleTime: 0, // Consider data stale immediately to ensure fresh data
|
||||
gcTime: 0, // Don't cache data (formerly cacheTime)
|
||||
structuralSharing: false, // Disable structural sharing to ensure updates are detected
|
||||
})
|
||||
|
||||
const { data: handlersData } = useQuery({
|
||||
@@ -1247,11 +1444,50 @@ function ExtentsTab() {
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: scstAPI.deleteExtent,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-extents'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-targets'] })
|
||||
onMutate: async (deviceName: string) => {
|
||||
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
|
||||
await queryClient.cancelQueries({ queryKey: ['scst-extents'] })
|
||||
await queryClient.cancelQueries({ queryKey: ['scst-targets'] })
|
||||
|
||||
// Snapshot the previous value
|
||||
const previousExtents = queryClient.getQueryData<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}`)
|
||||
},
|
||||
})
|
||||
@@ -1383,9 +1619,16 @@ function ExtentsTab() {
|
||||
<CreateExtentModal
|
||||
handlers={handlers}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSuccess={() => {
|
||||
onSuccess={async () => {
|
||||
setShowCreateModal(false)
|
||||
queryClient.invalidateQueries({ queryKey: ['scst-extents'] })
|
||||
// Remove queries from cache
|
||||
queryClient.removeQueries({ queryKey: ['scst-extents'] })
|
||||
// Force refetch to ensure fresh data
|
||||
await queryClient.invalidateQueries({ queryKey: ['scst-extents'] })
|
||||
await queryClient.refetchQueries({
|
||||
queryKey: ['scst-extents'],
|
||||
type: 'active'
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1393,15 +1636,16 @@ function ExtentsTab() {
|
||||
)
|
||||
}
|
||||
|
||||
function CreateExtentModal({ handlers, onClose, onSuccess }: { handlers: Array<{ name: string; label: string; description?: string }>, onClose: () => void, onSuccess: () => void }) {
|
||||
function CreateExtentModal({ handlers, onClose, onSuccess }: { handlers: Array<{ name: string; label: string; description?: string }>, onClose: () => void, onSuccess: () => Promise<void> }) {
|
||||
const [deviceName, setDeviceName] = useState('')
|
||||
const [devicePath, setDevicePath] = useState('')
|
||||
const [handlerType, setHandlerType] = useState('')
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: CreateExtentRequest) => scstAPI.createExtent(data),
|
||||
onSuccess: () => {
|
||||
onSuccess()
|
||||
onSuccess: async () => {
|
||||
// Call onSuccess callback which will handle refresh
|
||||
await onSuccess()
|
||||
alert('Extent created successfully!')
|
||||
},
|
||||
onError: (error: any) => {
|
||||
@@ -1512,3 +1756,557 @@ function CreateExtentModal({ handlers, onClose, onSuccess }: { handlers: Array<{
|
||||
</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