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

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