iscsi still failing to save current attribute, check on disable and enable portal/iscsi targets

This commit is contained in:
Warp Agent
2026-01-02 03:49:06 +07:00
parent a558c97088
commit 7543b3a850
8 changed files with 2417 additions and 191 deletions

Binary file not shown.

View File

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

View File

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

View File

@@ -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',
},
})

View File

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

View File

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

View File

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