fixing UI and iscsi sync
Some checks failed
CI / test-build (push) Has been cancelled

This commit is contained in:
2025-12-20 19:16:50 +00:00
parent 2bb892dfdc
commit 6202ef8e83
12 changed files with 1436 additions and 116 deletions

View File

@@ -6,6 +6,7 @@ import (
"log"
"net/http"
"net/url"
"os"
"os/exec"
"strconv"
"strings"
@@ -124,6 +125,17 @@ func (a *App) handleGetPool(w http.ResponseWriter, r *http.Request) {
return
}
// Check if detail is requested
if r.URL.Query().Get("detail") == "true" {
detail, err := a.zfs.GetPoolDetail(name)
if err != nil {
writeError(w, errors.ErrNotFound("pool not found").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, detail)
return
}
pool, err := a.zfs.GetPool(name)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
@@ -133,6 +145,43 @@ func (a *App) handleGetPool(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, pool)
}
func (a *App) handleAddSpareDisk(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
name = strings.TrimSuffix(name, "/spare")
if name == "" {
writeError(w, errors.ErrBadRequest("pool name required"))
return
}
var req struct {
Disk string `json:"disk"` // Disk path like /dev/sdb or sdb
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, errors.ErrBadRequest("invalid request body"))
return
}
if req.Disk == "" {
writeError(w, errors.ErrValidation("disk path required"))
return
}
// Ensure disk path starts with /dev/ if not already
diskPath := req.Disk
if !strings.HasPrefix(diskPath, "/dev/") {
diskPath = "/dev/" + diskPath
}
if err := a.zfs.AddSpareDisk(name, diskPath); err != nil {
log.Printf("add spare disk error: %v", err)
writeError(w, errors.ErrInternal("failed to add spare disk").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{"message": "spare disk added", "pool": name, "disk": diskPath})
}
func (a *App) handleDeletePool(w http.ResponseWriter, r *http.Request) {
name := pathParam(r, "/api/v1/pools/")
if name == "" {
@@ -753,6 +802,13 @@ func (a *App) handleDeleteSnapshotPolicy(w http.ResponseWriter, r *http.Request)
// SMB Share Handlers
func (a *App) handleListSMBShares(w http.ResponseWriter, r *http.Request) {
// Sync shares from OS (smb.conf) to store
// This ensures shares created before service restart are visible
if err := a.syncSMBSharesFromOS(); err != nil {
log.Printf("warning: failed to sync SMB shares from OS: %v", err)
// Continue anyway - return what's in store
}
shares := a.smbStore.List()
writeJSON(w, http.StatusOK, shares)
}
@@ -1151,6 +1207,11 @@ func (a *App) handleDeleteNFSExport(w http.ResponseWriter, r *http.Request) {
// iSCSI Handlers
func (a *App) handleListISCSITargets(w http.ResponseWriter, r *http.Request) {
// Sync targets from OS before listing
if err := a.syncISCSITargetsFromOS(); err != nil {
log.Printf("warning: failed to sync iSCSI targets from OS: %v", err)
}
targets := a.iscsiStore.List()
writeJSON(w, http.StatusOK, targets)
}
@@ -1158,37 +1219,63 @@ func (a *App) handleListISCSITargets(w http.ResponseWriter, r *http.Request) {
func (a *App) handleCreateISCSITarget(w http.ResponseWriter, r *http.Request) {
var req struct {
IQN string `json:"iqn"`
Type string `json:"type"` // "disk" or "tape" (default: "disk")
Initiators []string `json:"initiators"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
log.Printf("create iSCSI target: invalid request body: %v", err)
writeError(w, errors.ErrBadRequest("invalid request body").WithDetails(err.Error()))
return
}
// Validate and set target type
targetType := models.ISCSITargetTypeDisk // Default to disk mode
if req.Type != "" {
if req.Type != "disk" && req.Type != "tape" {
writeError(w, errors.ErrValidation("invalid target type: must be 'disk' or 'tape'"))
return
}
targetType = models.ISCSITargetType(req.Type)
}
log.Printf("create iSCSI target: IQN=%s, Type=%s, Initiators=%v", req.IQN, targetType, req.Initiators)
// Validate IQN format
if err := validation.ValidateIQN(req.IQN); err != nil {
log.Printf("IQN validation error: %v (IQN: %s)", err, req.IQN)
writeError(w, errors.ErrValidation(err.Error()))
return
}
target, err := a.iscsiStore.Create(req.IQN, req.Initiators)
target, err := a.iscsiStore.CreateWithType(req.IQN, targetType, req.Initiators)
if err != nil {
if err == storage.ErrISCSITargetExists {
writeJSON(w, http.StatusConflict, map[string]string{"error": "target with this IQN already exists"})
log.Printf("create iSCSI target: target already exists (IQN: %s)", req.IQN)
writeError(w, errors.ErrConflict("target with this IQN already exists"))
return
}
log.Printf("create iSCSI target error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
writeError(w, errors.ErrInternal("failed to create iSCSI target").WithDetails(err.Error()))
return
}
log.Printf("create iSCSI target: target created in store (ID: %s, IQN: %s)", target.ID, target.IQN)
// Apply configuration to iSCSI service
targets := a.iscsiStore.List()
if err := a.iscsiService.ApplyConfiguration(targets); err != nil {
log.Printf("apply iSCSI configuration error: %v", err)
log.Printf("create iSCSI target: apply configuration error: %v", err)
// Don't fail the request if configuration fails - target is already in store
// User can retry configuration later
writeJSON(w, http.StatusCreated, map[string]interface{}{
"target": target,
"warning": "target created but configuration may have failed. check logs.",
})
return
}
log.Printf("create iSCSI target: success (ID: %s, IQN: %s)", target.ID, target.IQN)
writeJSON(w, http.StatusCreated, target)
}
@@ -1325,7 +1412,10 @@ func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
id := parts[0]
var req struct {
ZVOL string `json:"zvol"`
ZVOL string `json:"zvol"` // ZVOL name (for block backstore)
Device string `json:"device"` // Device path (e.g., /dev/st0 for tape)
Backstore string `json:"backstore"` // Backstore type: "block", "pscsi", "fileio" (optional, auto-detected)
BackstoreName string `json:"backstore_name"` // Custom backstore name (optional)
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -1333,11 +1423,16 @@ func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
return
}
if req.ZVOL == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol is required"})
// Validate: must have either ZVOL or Device
if req.ZVOL == "" && req.Device == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "either zvol or device is required"})
return
}
var zvolSize uint64
var zvolName string
if req.ZVOL != "" {
// Validate ZVOL exists
zvols, err := a.zfs.ListZVOLs("")
if err != nil {
@@ -1346,12 +1441,12 @@ func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
return
}
var zvolSize uint64
zvolExists := false
for _, zvol := range zvols {
if zvol.Name == req.ZVOL {
zvolExists = true
zvolSize = zvol.Size
zvolName = zvol.Name
break
}
}
@@ -1360,8 +1455,23 @@ func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol not found"})
return
}
} else if req.Device != "" {
// Validate device exists
if _, err := os.Stat(req.Device); err != nil {
if os.IsNotExist(err) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("device not found: %s", req.Device)})
return
}
log.Printf("stat device error: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate device"})
return
}
// For tape devices, size is typically 0 or unknown
zvolSize = 0
}
lun, err := a.iscsiStore.AddLUN(id, req.ZVOL, zvolSize)
// Use updated AddLUN signature that supports device and backstore
lun, err := a.iscsiStore.AddLUNWithDevice(id, zvolName, req.Device, zvolSize, req.Backstore, req.BackstoreName)
if err != nil {
if err == storage.ErrISCSITargetNotFound {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "target not found"})
@@ -1804,3 +1914,326 @@ func (a *App) syncNFSExportsFromOS() error {
return nil
}
// syncSMBSharesFromOS syncs SMB shares from /etc/samba/smb.conf to the store
func (a *App) syncSMBSharesFromOS() error {
configPath := "/etc/samba/smb.conf"
cmd := exec.Command("sudo", "-n", "cat", configPath)
output, err := cmd.Output()
if err != nil {
// If can't read smb.conf, that's okay - might not exist yet
return nil
}
lines := strings.Split(string(output), "\n")
currentShare := ""
inShareSection := false
sharePath := ""
shareReadOnly := false
shareGuestOK := false
shareDescription := ""
shareValidUsers := []string{}
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
// Check if this is a share section
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
// Save previous share if exists
if inShareSection && currentShare != "" && sharePath != "" {
// Try to find corresponding dataset
datasets, err := a.zfs.ListDatasets("")
var dataset string
if err == nil {
for _, ds := range datasets {
if ds.Mountpoint == sharePath {
dataset = ds.Name
break
}
}
}
// Check if share already exists
existingShares := a.smbStore.List()
exists := false
for _, share := range existingShares {
if share.Name == currentShare || share.Path == sharePath {
exists = true
break
}
}
if !exists {
_, err = a.smbStore.Create(currentShare, sharePath, dataset, shareDescription, shareReadOnly, shareGuestOK, shareValidUsers)
if err != nil && err != storage.ErrSMBShareExists {
log.Printf("warning: failed to sync SMB share %s: %v", currentShare, err)
}
}
}
// Start new share section
shareName := strings.Trim(line, "[]")
if shareName != "global" && shareName != "printers" && shareName != "print$" {
currentShare = shareName
inShareSection = true
sharePath = ""
shareReadOnly = false
shareGuestOK = false
shareDescription = ""
shareValidUsers = []string{}
} else {
inShareSection = false
currentShare = ""
}
continue
}
// Parse share properties
if inShareSection && currentShare != "" {
if strings.HasPrefix(line, "path = ") {
sharePath = strings.TrimSpace(strings.TrimPrefix(line, "path = "))
} else if strings.HasPrefix(line, "read only = ") {
value := strings.TrimSpace(strings.TrimPrefix(line, "read only = "))
shareReadOnly = (value == "yes" || value == "true")
} else if strings.HasPrefix(line, "guest ok = ") {
value := strings.TrimSpace(strings.TrimPrefix(line, "guest ok = "))
shareGuestOK = (value == "yes" || value == "true")
} else if strings.HasPrefix(line, "comment = ") {
shareDescription = strings.TrimSpace(strings.TrimPrefix(line, "comment = "))
} else if strings.HasPrefix(line, "valid users = ") {
usersStr := strings.TrimSpace(strings.TrimPrefix(line, "valid users = "))
shareValidUsers = strings.Split(usersStr, ",")
for i := range shareValidUsers {
shareValidUsers[i] = strings.TrimSpace(shareValidUsers[i])
}
}
}
}
// Save last share if exists
if inShareSection && currentShare != "" && sharePath != "" {
datasets, err := a.zfs.ListDatasets("")
var dataset string
if err == nil {
for _, ds := range datasets {
if ds.Mountpoint == sharePath {
dataset = ds.Name
break
}
}
}
existingShares := a.smbStore.List()
exists := false
for _, share := range existingShares {
if share.Name == currentShare || share.Path == sharePath {
exists = true
break
}
}
if !exists {
_, err = a.smbStore.Create(currentShare, sharePath, dataset, shareDescription, shareReadOnly, shareGuestOK, shareValidUsers)
if err != nil && err != storage.ErrSMBShareExists {
log.Printf("warning: failed to sync SMB share %s: %v", currentShare, err)
}
}
}
return nil
}
// syncISCSITargetsFromOS syncs iSCSI targets from targetcli to the store
func (a *App) syncISCSITargetsFromOS() error {
log.Printf("debug: starting syncISCSITargetsFromOS")
// Get list of targets from targetcli
// Set TARGETCLI_HOME and TARGETCLI_LOCK_DIR to writable directories
// Create the directories first if they don't exist
os.MkdirAll("/tmp/.targetcli", 0755)
os.MkdirAll("/tmp/targetcli-run", 0755)
// Use sudo to run as root, then set environment variables in the command
cmd := exec.Command("sh", "-c", "sudo -n sh -c 'TARGETCLI_HOME=/tmp/.targetcli TARGETCLI_LOCK_DIR=/tmp/targetcli-run targetcli /iscsi ls'")
output, err := cmd.CombinedOutput()
if err != nil {
// Log the error but don't fail - targetcli might not be configured
log.Printf("warning: failed to list iSCSI targets from targetcli: %v (output: %s)", err, string(output))
return nil
}
log.Printf("debug: targetcli output: %s", string(output))
lines := strings.Split(string(output), "\n")
var currentIQN string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Check if this is a target line (starts with "o- iqn.")
if strings.HasPrefix(line, "o- iqn.") {
log.Printf("debug: found target line: %s", line)
// Extract IQN from line like "o- iqn.2025-12.com.atlas:target-1"
parts := strings.Fields(line)
if len(parts) >= 2 {
currentIQN = parts[1]
// Check if target already exists in store
existingTargets := a.iscsiStore.List()
exists := false
for _, t := range existingTargets {
if t.IQN == currentIQN {
exists = true
break
}
}
if !exists {
// Try to determine target type from IQN
targetType := models.ISCSITargetTypeDisk // Default to disk mode
if strings.Contains(strings.ToLower(currentIQN), "tape") {
targetType = models.ISCSITargetTypeTape
}
// Create target in store
target, err := a.iscsiStore.CreateWithType(currentIQN, targetType, []string{})
if err != nil && err != storage.ErrISCSITargetExists {
log.Printf("warning: failed to sync iSCSI target %s: %v", currentIQN, err)
} else if err == nil {
log.Printf("synced iSCSI target from OS: %s (type: %s)", currentIQN, targetType)
// Now try to sync LUNs for this target
if err := a.syncLUNsFromOS(currentIQN, target.ID, targetType); err != nil {
log.Printf("warning: failed to sync LUNs for target %s: %v", currentIQN, err)
}
}
}
}
}
}
return nil
}
// syncLUNsFromOS syncs LUNs for a specific target from targetcli
func (a *App) syncLUNsFromOS(iqn, targetID string, targetType models.ISCSITargetType) error {
// Get LUNs for this target
// Use sudo to run as root, then set environment variables in the command
cmd := exec.Command("sh", "-c", "sudo -n sh -c 'TARGETCLI_HOME=/tmp/.targetcli TARGETCLI_LOCK_DIR=/tmp/targetcli-run targetcli /iscsi/"+iqn+"/tpg1/luns ls'")
output, err := cmd.CombinedOutput()
if err != nil {
// No LUNs or can't read - that's okay, log for debugging
log.Printf("debug: failed to list LUNs for target %s: %v (output: %s)", iqn, err, string(output))
return nil
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "o- lun") {
// Parse LUN line like "o- lun0 ....................................... [block/pool-test-02-vol01 (/dev/zvol/pool-test-02/vol01) (default_tg_pt_gp)]"
parts := strings.Fields(line)
if len(parts) >= 2 {
// Extract LUN ID from "lun0"
lunIDStr := strings.TrimPrefix(parts[1], "lun")
lunID, err := strconv.Atoi(lunIDStr)
if err != nil {
continue
}
// Extract backstore path and device from the line
var backstorePath string
var devicePath string
var zvolName string
// Find the part with brackets - might span multiple parts
fullLine := strings.Join(parts, " ")
start := strings.Index(fullLine, "[")
end := strings.LastIndex(fullLine, "]")
if start >= 0 && end > start {
content := fullLine[start+1 : end]
// Parse content like "block/pool-test-02-vol01 (/dev/zvol/pool-test-02/vol01)"
if strings.Contains(content, "(") {
// Has device path
parts2 := strings.Split(content, "(")
if len(parts2) >= 2 {
backstorePath = strings.TrimSpace(parts2[0])
devicePath = strings.Trim(strings.TrimSpace(parts2[1]), "()")
// If device is a zvol, extract ZVOL name
if strings.HasPrefix(devicePath, "/dev/zvol/") {
zvolName = strings.TrimPrefix(devicePath, "/dev/zvol/")
}
}
} else {
backstorePath = content
}
}
// Check if LUN already exists
target, err := a.iscsiStore.Get(targetID)
if err != nil {
continue
}
lunExists := false
for _, lun := range target.LUNs {
if lun.ID == lunID {
lunExists = true
break
}
}
if !lunExists {
// Determine backstore type
backstoreType := "block"
if strings.HasPrefix(backstorePath, "pscsi/") {
backstoreType = "pscsi"
} else if strings.HasPrefix(backstorePath, "fileio/") {
backstoreType = "fileio"
}
// Get size if it's a ZVOL
var size uint64
if zvolName != "" {
zvols, err := a.zfs.ListZVOLs("")
if err == nil {
for _, zvol := range zvols {
if zvol.Name == zvolName {
size = zvol.Size
break
}
}
}
}
// Add LUN to store
if targetType == models.ISCSITargetTypeTape && devicePath != "" {
// Tape mode: use device
_, err := a.iscsiStore.AddLUNWithDevice(targetID, "", devicePath, size, backstoreType, "")
if err != nil && err != storage.ErrLUNExists {
log.Printf("warning: failed to sync LUN %d for target %s: %v", lunID, iqn, err)
}
} else if zvolName != "" {
// Disk mode: use ZVOL
_, err := a.iscsiStore.AddLUNWithDevice(targetID, zvolName, "", size, backstoreType, "")
if err != nil && err != storage.ErrLUNExists {
log.Printf("warning: failed to sync LUN %d for target %s: %v", lunID, iqn, err)
}
} else if devicePath != "" {
// Generic device
_, err := a.iscsiStore.AddLUNWithDevice(targetID, "", devicePath, size, backstoreType, "")
if err != nil && err != storage.ErrLUNExists {
log.Printf("warning: failed to sync LUN %d for target %s: %v", lunID, iqn, err)
}
}
}
}
}
}
return nil
}

View File

@@ -2,6 +2,7 @@ package httpapp
import (
"fmt"
"log"
"net/http"
)
@@ -64,6 +65,14 @@ func (a *App) handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
}
// Service statistics
// Sync from OS first to ensure accurate counts
if err := a.syncSMBSharesFromOS(); err != nil {
log.Printf("warning: failed to sync SMB shares from OS in dashboard: %v", err)
}
if err := a.syncNFSExportsFromOS(); err != nil {
log.Printf("warning: failed to sync NFS exports from OS in dashboard: %v", err)
}
smbShares := a.smbStore.List()
data.Services.SMBShares = len(smbShares)

View File

@@ -104,6 +104,15 @@ func (a *App) handlePoolOps(w http.ResponseWriter, r *http.Request) {
return
}
if strings.HasSuffix(r.URL.Path, "/spare") {
if r.Method == http.MethodPost {
a.handleAddSpareDisk(w, r)
} else {
writeError(w, errors.NewAPIError(errors.ErrCodeBadRequest, "method not allowed", http.StatusMethodNotAllowed))
}
return
}
methodHandler(
func(w http.ResponseWriter, r *http.Request) { a.handleGetPool(w, r) },
nil,

View File

@@ -24,19 +24,31 @@ type NFSExport struct {
Enabled bool `json:"enabled"`
}
// ISCSITargetType represents the type of iSCSI target
type ISCSITargetType string
const (
ISCSITargetTypeDisk ISCSITargetType = "disk" // For ZVOL/block devices
ISCSITargetTypeTape ISCSITargetType = "tape" // For tape library passthrough
)
// ISCSITarget represents an iSCSI target
type ISCSITarget struct {
ID string `json:"id"`
IQN string `json:"iqn"` // iSCSI Qualified Name
Type ISCSITargetType `json:"type"` // "disk" or "tape"
LUNs []LUN `json:"luns"`
Initiators []string `json:"initiators"` // ACL list
Enabled bool `json:"enabled"`
}
// LUN represents a Logical Unit Number backed by a ZVOL
// LUN represents a Logical Unit Number backed by various storage types
type LUN struct {
ID int `json:"id"` // LUN number
ZVOL string `json:"zvol"` // ZVOL name
Size uint64 `json:"size"` // bytes
Backend string `json:"backend"` // "zvol"
ZVOL string `json:"zvol"` // ZVOL name (for block backstore)
Device string `json:"device"` // Device path (e.g., /dev/st0 for tape, /dev/sdX for disk)
Size uint64 `json:"size"` // bytes (0 for unknown/tape devices)
Backend string `json:"backend"` // "zvol", "block", "pscsi", "fileio"
Backstore string `json:"backstore"` // Backstore type: "block", "pscsi", "fileio" (default: "block")
BackstoreName string `json:"backstore_name"` // Name used in targetcli
}

View File

@@ -13,6 +13,37 @@ type Pool struct {
CreatedAt time.Time `json:"created_at"`
}
// PoolDetail represents detailed pool information from zpool status
type PoolDetail struct {
Name string `json:"name"`
State string `json:"state"` // ONLINE, DEGRADED, FAULTED
Status string `json:"status"` // Full status message
VDEVs []VDEV `json:"vdevs"` // Virtual devices
Spares []string `json:"spares"` // Spare disks
Errors string `json:"errors"` // Error summary
ScrubInfo string `json:"scrub_info"` // Scrub information
}
// VDEV represents a virtual device in a pool
type VDEV struct {
Name string `json:"name"` // VDEV name or type
Type string `json:"type"` // mirror, raidz, raidz2, etc.
State string `json:"state"` // ONLINE, DEGRADED, etc.
Disks []Disk `json:"disks"` // Disks in this VDEV
Read int `json:"read"` // Read errors
Write int `json:"write"` // Write errors
Checksum int `json:"checksum"` // Checksum errors
}
// Disk represents a disk in a VDEV
type Disk struct {
Name string `json:"name"` // Disk name (e.g., sdb)
State string `json:"state"` // ONLINE, DEGRADED, FAULTED, etc.
Read int `json:"read"` // Read errors
Write int `json:"write"` // Write errors
Checksum int `json:"checksum"` // Checksum errors
}
// Dataset represents a ZFS filesystem
type Dataset struct {
Name string `json:"name"`

View File

@@ -3,6 +3,7 @@ package services
import (
"fmt"
"os/exec"
"strconv"
"strings"
"sync"
@@ -41,6 +42,7 @@ func (s *ISCSIService) ApplyConfiguration(targets []models.ISCSITarget) error {
// Disable target if it exists
if err := s.disableTarget(target.IQN); err != nil {
// Log but continue
fmt.Printf("warning: failed to disable target %s: %v\n", target.IQN, err)
}
continue
}
@@ -49,16 +51,19 @@ func (s *ISCSIService) ApplyConfiguration(targets []models.ISCSITarget) error {
if err := s.createTarget(target); err != nil {
return fmt.Errorf("create target %s: %w", target.IQN, err)
}
fmt.Printf("iSCSI target created/verified: %s\n", target.IQN)
// Configure ACLs
if err := s.configureACLs(target); err != nil {
return fmt.Errorf("configure ACLs for %s: %w", target.IQN, err)
}
fmt.Printf("iSCSI ACLs configured for: %s\n", target.IQN)
// Configure LUNs
if err := s.configureLUNs(target); err != nil {
return fmt.Errorf("configure LUNs for %s: %w", target.IQN, err)
}
fmt.Printf("iSCSI LUNs configured for: %s\n", target.IQN)
}
return nil
@@ -68,27 +73,93 @@ func (s *ISCSIService) ApplyConfiguration(targets []models.ISCSITarget) error {
func (s *ISCSIService) createTarget(target models.ISCSITarget) error {
// Use targetcli to create target
// Format: targetcli /iscsi create <IQN>
cmd := exec.Command(s.targetcliPath, "/iscsi", "create", target.IQN)
if err := cmd.Run(); err != nil {
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi", "create", target.IQN)
output, err := cmd.CombinedOutput()
if err != nil {
// Target might already exist, which is OK
// Check if it actually exists
if !s.targetExists(target.IQN) {
return fmt.Errorf("create target failed: %w", err)
return fmt.Errorf("create target failed: %w (output: %s)", err, string(output))
}
fmt.Printf("target %s already exists, continuing\n", target.IQN)
} else {
fmt.Printf("target %s created successfully\n", target.IQN)
}
// Enable TPG1 (Target Portal Group 1)
// Disable authentication
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "authentication=0")
if output, err := cmd.CombinedOutput(); err != nil {
fmt.Printf("warning: failed to set authentication=0: %v (output: %s)\n", err, string(output))
}
// Enable generate_node_acls (allow all initiators if no ACLs specified)
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "generate_node_acls=1")
if output, err := cmd.CombinedOutput(); err != nil {
fmt.Printf("warning: failed to set generate_node_acls=1: %v (output: %s)\n", err, string(output))
} else {
fmt.Printf("set generate_node_acls=1 for target %s\n", target.IQN)
}
// Create portal if not exists (listen on all interfaces, port 3260)
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/portals", "create")
if err := cmd.Run(); err != nil {
// Portal might already exist, which is OK
// Check if portal exists
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/portals", "ls")
output, err2 := cmd.Output()
if err2 != nil || len(strings.TrimSpace(string(output))) == 0 {
// No portal exists, try to create with specific IP
// Get system IP
systemIP, _ := s.getSystemIP()
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/portals", "create", systemIP)
if err3 := cmd.Run(); err3 != nil {
// Try with 0.0.0.0 (all interfaces)
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/portals", "create", "0.0.0.0")
if err4 := cmd.Run(); err4 != nil {
// Log but don't fail - portal might already exist
fmt.Printf("warning: failed to create portal: %v", err4)
}
}
}
}
// Save configuration
cmd = exec.Command("sudo", "-n", s.targetcliPath, "saveconfig")
cmd.Run() // Ignore errors
return nil
}
// configureACLs configures initiator ACLs for a target
func (s *ISCSIService) configureACLs(target models.ISCSITarget) error {
// If no initiators specified, allow all (generate_node_acls=1)
if len(target.Initiators) == 0 {
// Set to allow all initiators
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "generate_node_acls=1")
if err := cmd.Run(); err != nil {
return fmt.Errorf("set generate_node_acls: %w", err)
}
// Disable authentication for open access
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "authentication=0")
cmd.Run() // Ignore errors
return nil
}
// If initiators specified, use ACL-based access
// Set generate_node_acls=0 to use explicit ACLs
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "generate_node_acls=0")
if err := cmd.Run(); err != nil {
return fmt.Errorf("set generate_node_acls=0: %w", err)
}
// Get current ACLs
currentACLs, _ := s.getACLs(target.IQN)
// Remove ACLs not in desired list
for _, acl := range currentACLs {
if !contains(target.Initiators, acl) {
cmd := exec.Command(s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/acls", "delete", acl)
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/acls", "delete", acl)
cmd.Run() // Ignore errors
}
}
@@ -96,7 +167,7 @@ func (s *ISCSIService) configureACLs(target models.ISCSITarget) error {
// Add new ACLs
for _, initiator := range target.Initiators {
if !contains(currentACLs, initiator) {
cmd := exec.Command(s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/acls", "create", initiator)
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/acls", "create", initiator)
if err := cmd.Run(); err != nil {
return fmt.Errorf("create ACL %s: %w", initiator, err)
}
@@ -114,23 +185,100 @@ func (s *ISCSIService) configureLUNs(target models.ISCSITarget) error {
// Remove LUNs not in desired list
for _, lun := range currentLUNs {
if !s.hasLUN(target.LUNs, lun) {
cmd := exec.Command(s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/luns", "delete", fmt.Sprintf("lun/%d", lun))
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/luns", "delete", fmt.Sprintf("lun/%d", lun))
cmd.Run() // Ignore errors
}
}
// Add/update LUNs
for _, lun := range target.LUNs {
// Create LUN mapping
// Format: targetcli /iscsi/<IQN>/tpg1/luns create /backstores/zvol/<zvol>
zvolPath := "/backstores/zvol/" + lun.ZVOL
// Determine backstore type (default to block for ZVOL, pscsi for tape devices)
backstoreType := lun.Backstore
if backstoreType == "" {
if lun.Device != "" {
// If device is specified and looks like tape device, use pscsi
if strings.HasPrefix(lun.Device, "/dev/st") || strings.HasPrefix(lun.Device, "/dev/nst") {
backstoreType = "pscsi"
} else {
backstoreType = "block"
}
} else if lun.ZVOL != "" {
// Default to block for ZVOL
backstoreType = "block"
} else {
return fmt.Errorf("LUN must have either ZVOL or Device specified")
}
}
// First ensure the zvol backend exists
cmd := exec.Command(s.targetcliPath, "/backstores/zvol", "create", lun.ZVOL, lun.ZVOL)
cmd.Run() // Ignore if already exists
// Determine backstore name
backstoreName := lun.BackstoreName
if backstoreName == "" {
if lun.ZVOL != "" {
backstoreName = strings.ReplaceAll(lun.ZVOL, "/", "-")
} else if lun.Device != "" {
// Use device name (e.g., st0, sdb)
backstoreName = strings.TrimPrefix(strings.TrimPrefix(lun.Device, "/dev/"), "/dev/")
backstoreName = strings.ReplaceAll(backstoreName, "/", "-")
} else {
backstoreName = fmt.Sprintf("lun-%d", lun.ID)
}
}
// Determine device path
var devicePath string
if lun.Device != "" {
devicePath = lun.Device
} else if lun.ZVOL != "" {
devicePath = "/dev/zvol/" + lun.ZVOL
} else {
return fmt.Errorf("LUN must have either ZVOL or Device specified")
}
backstorePath := "/backstores/" + backstoreType + "/" + backstoreName
// Create backstore based on type
switch backstoreType {
case "block":
// Format: targetcli /backstores/block create name=<name> dev=<dev>
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/backstores/block", "create", "name="+backstoreName, "dev="+devicePath)
if output, err := cmd.CombinedOutput(); err != nil {
if !strings.Contains(string(output), "already exists") {
fmt.Printf("warning: failed to create block backstore %s: %v (output: %s)\n", backstoreName, err, string(output))
}
} else {
fmt.Printf("created block backstore %s for %s\n", backstoreName, devicePath)
}
case "pscsi":
// Format: targetcli /backstores/pscsi create name=<name> dev=<dev>
// pscsi is for SCSI pass-through (tape devices, etc.)
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/backstores/pscsi", "create", "name="+backstoreName, "dev="+devicePath)
if output, err := cmd.CombinedOutput(); err != nil {
if !strings.Contains(string(output), "already exists") {
return fmt.Errorf("failed to create pscsi backstore %s: %w (output: %s)", backstoreName, err, string(output))
}
} else {
fmt.Printf("created pscsi backstore %s for %s\n", backstoreName, devicePath)
}
case "fileio":
// Format: targetcli /backstores/fileio create name=<name> file_or_dev=<path> [size=<size>]
// fileio is for file-based storage
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/backstores/fileio", "create", "name="+backstoreName, "file_or_dev="+devicePath)
if output, err := cmd.CombinedOutput(); err != nil {
if !strings.Contains(string(output), "already exists") {
return fmt.Errorf("failed to create fileio backstore %s: %w (output: %s)", backstoreName, err, string(output))
}
} else {
fmt.Printf("created fileio backstore %s for %s\n", backstoreName, devicePath)
}
default:
return fmt.Errorf("unsupported backstore type: %s", backstoreType)
}
// Create LUN
cmd = exec.Command(s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/luns", "create", zvolPath)
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/luns", "create", backstorePath)
if err := cmd.Run(); err != nil {
// LUN might already exist
if !s.hasLUNID(currentLUNs, lun.ID) {
@@ -144,7 +292,7 @@ func (s *ISCSIService) configureLUNs(target models.ISCSITarget) error {
// Helper functions
func (s *ISCSIService) targetExists(iqn string) bool {
cmd := exec.Command(s.targetcliPath, "/iscsi", "ls")
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi", "ls")
output, err := cmd.Output()
if err != nil {
return false
@@ -153,25 +301,56 @@ func (s *ISCSIService) targetExists(iqn string) bool {
}
func (s *ISCSIService) getACLs(iqn string) ([]string, error) {
cmd := exec.Command(s.targetcliPath, "/iscsi/"+iqn+"/tpg1/acls", "ls")
_, err := cmd.Output()
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+iqn+"/tpg1/acls", "ls")
output, err := cmd.Output()
if err != nil {
return nil, err
return []string{}, nil // Return empty if can't get ACLs
}
// Parse output to extract ACL names
// This is simplified - real implementation would parse targetcli output
return []string{}, nil
// Format: o- acls ................................................................................................ [ACLs: 1]
// o- iqn.1994-05.com.redhat:client1 ........................................................ [Mapped LUNs: 1]
lines := strings.Split(string(output), "\n")
acls := []string{}
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "o- iqn.") {
// Extract IQN from line like "o- iqn.1994-05.com.redhat:client1"
parts := strings.Fields(line)
if len(parts) >= 2 && strings.HasPrefix(parts[1], "iqn.") {
acls = append(acls, parts[1])
}
}
}
return acls, nil
}
func (s *ISCSIService) getLUNs(iqn string) ([]int, error) {
cmd := exec.Command(s.targetcliPath, "/iscsi/"+iqn+"/tpg1/luns", "ls")
_, err := cmd.Output()
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+iqn+"/tpg1/luns", "ls")
output, err := cmd.Output()
if err != nil {
return nil, err
return []int{}, nil // Return empty if can't get LUNs
}
// Parse output to extract LUN IDs
// This is simplified - real implementation would parse targetcli output
return []int{}, nil
// Format: o- luns ................................................................................................ [LUNs: 1]
// o- lun0 ................................................................................ [zvol/pool-test-02/vol-1(/dev/zvol/pool-test-02/vol-1)]
lines := strings.Split(string(output), "\n")
luns := []int{}
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "o- lun") {
// Extract LUN number from line like "o- lun0"
parts := strings.Fields(line)
if len(parts) >= 2 && strings.HasPrefix(parts[1], "lun") {
lunIDStr := strings.TrimPrefix(parts[1], "lun")
if lunID, err := strconv.Atoi(lunIDStr); err == nil {
luns = append(luns, lunID)
}
}
}
}
return luns, nil
}
func (s *ISCSIService) hasLUN(luns []models.LUN, id int) bool {
@@ -193,7 +372,7 @@ func (s *ISCSIService) hasLUNID(luns []int, id int) bool {
}
func (s *ISCSIService) disableTarget(iqn string) error {
cmd := exec.Command(s.targetcliPath, "/iscsi/"+iqn+"/tpg1", "set", "attribute", "enable=0")
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+iqn+"/tpg1", "set", "attribute", "enable=0")
return cmd.Run()
}
@@ -326,7 +505,7 @@ func (s *ISCSIService) GetConnectionInstructions(target models.ISCSITarget, port
// GetPortalIP attempts to detect the portal IP address
func (s *ISCSIService) GetPortalIP() (string, error) {
// Try to get IP from targetcli
cmd := exec.Command(s.targetcliPath, "/iscsi", "ls")
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi", "ls")
output, err := cmd.Output()
if err != nil {
// Fallback: try to get system IP

View File

@@ -3,6 +3,7 @@ package storage
import (
"errors"
"fmt"
"strings"
"sync"
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
@@ -69,6 +70,12 @@ func (s *ISCSIStore) GetByIQN(iqn string) (*models.ISCSITarget, error) {
// Create creates a new iSCSI target
func (s *ISCSIStore) Create(iqn string, initiators []string) (*models.ISCSITarget, error) {
// Default to disk mode for backward compatibility
return s.CreateWithType(iqn, models.ISCSITargetTypeDisk, initiators)
}
// CreateWithType creates a new iSCSI target with specified type
func (s *ISCSIStore) CreateWithType(iqn string, targetType models.ISCSITargetType, initiators []string) (*models.ISCSITarget, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -85,6 +92,7 @@ func (s *ISCSIStore) Create(iqn string, initiators []string) (*models.ISCSITarge
target := &models.ISCSITarget{
ID: id,
IQN: iqn,
Type: targetType,
LUNs: []models.LUN{},
Initiators: initiators,
Enabled: true,
@@ -155,6 +163,60 @@ func (s *ISCSIStore) AddLUN(targetID string, zvol string, size uint64) (*models.
ZVOL: zvol,
Size: size,
Backend: "zvol",
Backstore: "block", // Default to block for ZVOL
}
target.LUNs = append(target.LUNs, lun)
return &lun, nil
}
// AddLUNWithDevice adds a LUN to a target with support for device and backstore type
func (s *ISCSIStore) AddLUNWithDevice(targetID string, zvol string, device string, size uint64, backstore string, backstoreName string) (*models.LUN, error) {
s.mu.Lock()
defer s.mu.Unlock()
target, ok := s.targets[targetID]
if !ok {
return nil, ErrISCSITargetNotFound
}
// Check if device/ZVOL already mapped
for _, lun := range target.LUNs {
if (zvol != "" && lun.ZVOL == zvol) || (device != "" && lun.Device == device) {
return nil, ErrLUNExists
}
}
// Find next available LUN ID
lunID := 0
for _, lun := range target.LUNs {
if lun.ID >= lunID {
lunID = lun.ID + 1
}
}
// Auto-detect backstore type if not specified
if backstore == "" {
if device != "" {
// If device is specified and looks like tape device, use pscsi
if strings.HasPrefix(device, "/dev/st") || strings.HasPrefix(device, "/dev/nst") {
backstore = "pscsi"
} else {
backstore = "block"
}
} else if zvol != "" {
backstore = "block"
}
}
lun := models.LUN{
ID: lunID,
ZVOL: zvol,
Device: device,
Size: size,
Backend: "zvol", // Keep for backward compatibility
Backstore: backstore,
BackstoreName: backstoreName,
}
target.LUNs = append(target.LUNs, lun)

View File

@@ -19,7 +19,9 @@ var (
shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.]{0,79}$`)
// IQN pattern (simplified - iqn.yyyy-mm.reversed.domain:identifier)
iqnPattern = regexp.MustCompile(`^iqn\.\d{4}-\d{2}\.[a-zA-Z0-9][a-zA-Z0-9\-\.]*:[a-zA-Z0-9][a-zA-Z0-9\-_\.]*$`)
// Domain must have at least 2 levels (e.g., com.atlas, org.example)
// Format: iqn.YYYY-MM.domain.subdomain:identifier
iqnPattern = regexp.MustCompile(`^iqn\.\d{4}-\d{2}\.[a-zA-Z0-9][a-zA-Z0-9\-]*\.[a-zA-Z0-9][a-zA-Z0-9\-\.]*:[a-zA-Z0-9][a-zA-Z0-9\-_\.]*$`)
// Email pattern (basic)
emailPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
@@ -166,6 +168,8 @@ func ValidateShareName(name string) error {
}
// ValidateIQN validates an iSCSI Qualified Name
// IQN format: iqn.YYYY-MM.domain.subdomain:identifier
// Domain must have at least 2 levels (e.g., com.atlas, org.example)
func ValidateIQN(iqn string) error {
if iqn == "" {
return &ValidationError{Field: "iqn", Message: "IQN cannot be empty"}
@@ -181,7 +185,7 @@ func ValidateIQN(iqn string) error {
// Basic format validation (can be more strict)
if !iqnPattern.MatchString(iqn) {
return &ValidationError{Field: "iqn", Message: "invalid IQN format (expected: iqn.yyyy-mm.reversed.domain:identifier)"}
return &ValidationError{Field: "iqn", Message: "invalid IQN format (expected: iqn.YYYY-MM.domain.subdomain:identifier, domain must have at least 2 levels, e.g., com.atlas, org.example)"}
}
return nil

View File

@@ -255,6 +255,197 @@ func (s *Service) GetPool(name string) (*models.Pool, error) {
return nil, fmt.Errorf("pool %s not found", name)
}
// GetPoolDetail returns detailed pool information from zpool status
func (s *Service) GetPoolDetail(name string) (*models.PoolDetail, error) {
output, err := s.execCommand(s.zpoolPath, "status", name)
if err != nil {
return nil, fmt.Errorf("failed to get pool status: %w", err)
}
detail := &models.PoolDetail{
Name: name,
VDEVs: []models.VDEV{},
Spares: []string{},
}
lines := strings.Split(output, "\n")
var currentVDEV *models.VDEV
inConfig := false
inSpares := false
for _, line := range lines {
line = strings.TrimSpace(line)
// Parse pool name and state
if strings.HasPrefix(line, "pool:") {
parts := strings.Fields(line)
if len(parts) > 1 {
detail.Name = parts[1]
}
continue
}
if strings.HasPrefix(line, "state:") {
parts := strings.Fields(line)
if len(parts) > 1 {
detail.State = parts[1]
detail.Status = strings.Join(parts[1:], " ")
}
continue
}
// Parse errors line
if strings.HasPrefix(line, "errors:") {
detail.Errors = strings.TrimPrefix(line, "errors:")
detail.Errors = strings.TrimSpace(detail.Errors)
continue
}
// Parse scrub information
if strings.Contains(line, "scrub") {
detail.ScrubInfo = line
continue
}
// Check if we're entering config section
if strings.HasPrefix(line, "config:") {
inConfig = true
continue
}
// Check if we're in spares section
if strings.Contains(line, "spares") {
inSpares = true
inConfig = false
continue
}
// Parse VDEV and disk information
if inConfig {
// Check if this is a VDEV header (indented but not a disk)
fields := strings.Fields(line)
if len(fields) >= 5 {
// Check if it's a VDEV type line (mirror, raidz, etc.)
if fields[0] == "mirror" || strings.HasPrefix(fields[0], "raidz") || fields[0] == "log" || fields[0] == "cache" {
// Save previous VDEV if exists
if currentVDEV != nil {
detail.VDEVs = append(detail.VDEVs, *currentVDEV)
}
// Start new VDEV
currentVDEV = &models.VDEV{
Name: fields[0],
Type: fields[0],
State: "ONLINE",
Disks: []models.Disk{},
}
// Try to parse state if available
if len(fields) > 1 {
for _, field := range fields[1:] {
if field == "ONLINE" || field == "DEGRADED" || field == "FAULTED" || field == "OFFLINE" {
currentVDEV.State = field
break
}
}
}
continue
}
// Check if it's a disk line (starts with sd, hd, nvme, etc.)
diskName := fields[0]
if strings.HasPrefix(diskName, "sd") || strings.HasPrefix(diskName, "hd") || strings.HasPrefix(diskName, "nvme") {
// This is a disk
state := "ONLINE"
read := 0
write := 0
checksum := 0
if len(fields) > 1 {
state = fields[1]
}
if len(fields) > 2 {
if val, err := strconv.Atoi(fields[2]); err == nil {
read = val
}
}
if len(fields) > 3 {
if val, err := strconv.Atoi(fields[3]); err == nil {
write = val
}
}
if len(fields) > 4 {
if val, err := strconv.Atoi(fields[4]); err == nil {
checksum = val
}
}
disk := models.Disk{
Name: diskName,
State: state,
Read: read,
Write: write,
Checksum: checksum,
}
// If we have a current VDEV, add disk to it
if currentVDEV != nil {
currentVDEV.Disks = append(currentVDEV.Disks, disk)
// Update VDEV errors
currentVDEV.Read += read
currentVDEV.Write += write
currentVDEV.Checksum += checksum
} else {
// Standalone disk, create a VDEV for it
currentVDEV = &models.VDEV{
Name: diskName,
Type: "disk",
State: state,
Disks: []models.Disk{disk},
Read: read,
Write: write,
Checksum: checksum,
}
}
continue
}
}
}
// Parse spares section
if inSpares {
fields := strings.Fields(line)
if len(fields) > 0 {
diskName := fields[0]
if strings.HasPrefix(diskName, "sd") || strings.HasPrefix(diskName, "hd") || strings.HasPrefix(diskName, "nvme") {
detail.Spares = append(detail.Spares, diskName)
}
}
}
// Empty line might indicate end of section
if line == "" && currentVDEV != nil {
detail.VDEVs = append(detail.VDEVs, *currentVDEV)
currentVDEV = nil
}
}
// Save last VDEV if exists
if currentVDEV != nil {
detail.VDEVs = append(detail.VDEVs, *currentVDEV)
}
return detail, nil
}
// AddSpareDisk adds a spare disk to a pool
func (s *Service) AddSpareDisk(poolName, diskPath string) error {
args := []string{"add", poolName, "spare", diskPath}
_, err := s.execCommand(s.zpoolPath, args...)
if err != nil {
return translateZFSError(err, "menambahkan spare disk", poolName)
}
return nil
}
// CreatePool creates a new ZFS pool
func (s *Service) CreatePool(name string, vdevs []string, options map[string]string) error {
args := []string{"create"}
@@ -1148,11 +1339,19 @@ func (s *Service) ListDisks() ([]map[string]string, error) {
continue
}
// Skip virtual disks (ZVOLs) - these are zd* devices
// Must check BEFORE checking dev.Type == "disk" because zd* devices
// are reported as type "disk" by lsblk
if strings.HasPrefix(dev.Name, "zd") {
log.Printf("debug: skipping virtual disk %s (zd* device)", dev.Name)
continue
}
// Include all physical disks (both available and used) so we can show status
// Only include actual disk devices (not partitions, loops, etc.)
if dev.Type == "disk" {
// Check if disk is used in a pool
isUsed := usedDisks[dev.Name]
// Include all disks (both available and used) so we can show status
if dev.Type == "disk" {
disk := map[string]string{
"name": dev.Name,
"size": dev.Size,

View File

@@ -144,7 +144,9 @@
// Update storage stats
document.getElementById('pool-count').textContent = data.storage.pool_count || 0;
document.getElementById('total-capacity').textContent = formatBytes(data.storage.total_capacity || 0);
document.getElementById('smb-shares').textContent = (data.services.smb_shares || 0) + ' / ' + (data.services.nfs_exports || 0);
// Display total shares (SMB + NFS)
const totalShares = (data.services.smb_shares || 0) + (data.services.nfs_exports || 0);
document.getElementById('smb-shares').textContent = totalShares;
document.getElementById('iscsi-targets').textContent = data.services.iscsi_targets || 0;
// Update service status

View File

@@ -5,31 +5,71 @@
<h1 class="text-3xl font-bold text-white mb-2">iSCSI Targets</h1>
<p class="text-slate-400">Manage iSCSI targets and LUNs</p>
</div>
<button onclick="showCreateISCSIModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
Create Target
</div>
<!-- Tabs for Disk Mode and Tape Mode -->
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
<div class="flex border-b border-slate-700">
<button onclick="switchTab('disk')" id="tab-disk" class="flex-1 px-4 py-3 text-sm font-medium text-center border-b-2 border-blue-600 text-blue-400 bg-slate-800">
Disk Mode
</button>
<button onclick="switchTab('tape')" id="tab-tape" class="flex-1 px-4 py-3 text-sm font-medium text-center border-b-2 border-transparent text-slate-400 hover:text-white">
Tape Library Passthrough
</button>
</div>
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
<!-- Disk Mode Content -->
<div id="content-disk" class="tab-content">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<h2 class="text-lg font-semibold text-white">iSCSI Targets</h2>
<button onclick="loadISCSITargets()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
<div>
<h2 class="text-lg font-semibold text-white">Disk Mode Targets</h2>
<p class="text-xs text-slate-400 mt-1">For ZVOL and block devices</p>
</div>
<div id="iscsi-targets-list" class="p-4">
<div class="flex gap-2">
<button onclick="loadISCSITargets('disk')" class="text-sm text-slate-400 hover:text-white">Refresh</button>
<button onclick="showCreateISCSIModal('disk')" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
Create Disk Target
</button>
</div>
</div>
<div id="iscsi-targets-list-disk" class="p-4">
<p class="text-slate-400 text-sm">Loading...</p>
</div>
</div>
<!-- Tape Mode Content -->
<div id="content-tape" class="tab-content hidden">
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
<div>
<h2 class="text-lg font-semibold text-white">Tape Library Passthrough Targets</h2>
<p class="text-xs text-slate-400 mt-1">For tape devices (/dev/st*, /dev/nst*)</p>
</div>
<div class="flex gap-2">
<button onclick="loadISCSITargets('tape')" class="text-sm text-slate-400 hover:text-white">Refresh</button>
<button onclick="showCreateISCSIModal('tape')" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium">
Create Tape Target
</button>
</div>
</div>
<div id="iscsi-targets-list-tape" class="p-4">
<p class="text-slate-400 text-sm">Loading...</p>
</div>
</div>
</div>
</div>
<!-- Create iSCSI Target Modal -->
<div id="create-iscsi-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
<h3 class="text-xl font-semibold text-white mb-4">Create iSCSI Target</h3>
<h3 id="create-iscsi-modal-title" class="text-xl font-semibold text-white mb-4">Create iSCSI Target</h3>
<form id="create-iscsi-form" onsubmit="createISCSITarget(event)" class="space-y-4">
<input type="hidden" name="type" id="create-iscsi-type" value="disk">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">IQN</label>
<input type="text" name="iqn" placeholder="iqn.2024-12.com.atlas:target1" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
<p class="text-xs text-slate-400 mt-1">iSCSI Qualified Name</p>
<input type="text" name="iqn" id="iqn-input" placeholder="iqn.2025-12.com.atlas:target-1" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
<p class="text-xs text-slate-400 mt-1">iSCSI Qualified Name (format: iqn.YYYY-MM.domain.subdomain:identifier)</p>
<p class="text-xs text-red-400 mt-1">⚠️ Important: Domain must have at least 2 levels (e.g., com.atlas, org.example)</p>
<p class="text-xs text-slate-500 mt-1">Example: iqn.2025-12.com.atlas:target-1 (correct) | iqn.2025-12.atlas:target-1 (wrong - domain too short)</p>
</div>
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeModal('create-iscsi-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
@@ -99,24 +139,56 @@ function formatBytes(bytes) {
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
async function loadISCSITargets() {
// Tab switching
let currentTab = 'disk';
function switchTab(tab) {
currentTab = tab;
// Update tab buttons
document.getElementById('tab-disk').classList.toggle('border-blue-600', tab === 'disk');
document.getElementById('tab-disk').classList.toggle('text-blue-400', tab === 'disk');
document.getElementById('tab-disk').classList.toggle('border-transparent', tab !== 'disk');
document.getElementById('tab-disk').classList.toggle('text-slate-400', tab !== 'disk');
document.getElementById('tab-tape').classList.toggle('border-green-600', tab === 'tape');
document.getElementById('tab-tape').classList.toggle('text-green-400', tab === 'tape');
document.getElementById('tab-tape').classList.toggle('border-transparent', tab !== 'tape');
document.getElementById('tab-tape').classList.toggle('text-slate-400', tab !== 'tape');
// Update content visibility
document.getElementById('content-disk').classList.toggle('hidden', tab !== 'disk');
document.getElementById('content-tape').classList.toggle('hidden', tab !== 'tape');
// Load targets for active tab
loadISCSITargets(tab);
}
async function loadISCSITargets(type = 'disk') {
try {
const res = await fetch('/api/v1/iscsi/targets', { headers: getAuthHeaders() });
if (!res.ok) {
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
document.getElementById('iscsi-targets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load iSCSI targets'}</p>`;
const listEl = document.getElementById(`iscsi-targets-list-${type}`);
if (listEl) {
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load iSCSI targets'}</p>`;
}
return;
}
const targets = await res.json();
const listEl = document.getElementById('iscsi-targets-list');
const allTargets = await res.json();
if (!Array.isArray(targets)) {
// Filter targets by type
const targets = Array.isArray(allTargets) ? allTargets.filter(t => (t.type || 'disk') === type) : [];
const listEl = document.getElementById(`iscsi-targets-list-${type}`);
if (!listEl) return;
if (!Array.isArray(allTargets)) {
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
return;
}
if (targets.length === 0) {
listEl.innerHTML = '<p class="text-slate-400 text-sm">No iSCSI targets found</p>';
listEl.innerHTML = `<p class="text-slate-400 text-sm">No ${type === 'disk' ? 'disk mode' : 'tape passthrough'} targets found</p>`;
return;
}
@@ -155,7 +227,7 @@ async function loadISCSITargets() {
` : '<p class="text-sm text-slate-500 mt-2">No LUNs attached. Click "Add LUN" to bind a volume.</p>'}
</div>
<div class="flex gap-2 ml-4">
<button onclick="showAddLUNModal('${target.id}')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
<button onclick="showAddLUNModal('${target.id}', '${target.type || 'disk'}')" class="px-3 py-1.5 ${target.type === 'tape' ? 'bg-green-600 hover:bg-green-700' : 'bg-blue-600 hover:bg-blue-700'} text-white rounded text-sm">
Add LUN
</button>
<button onclick="showConnectionInstructions('${target.id}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
@@ -173,7 +245,39 @@ async function loadISCSITargets() {
}
}
function showCreateISCSIModal() {
function showCreateISCSIModal(type = 'disk') {
// Generate default IQN with current date
// IQN format: iqn.YYYY-MM.domain.subdomain:identifier
// Domain must have at least 2 levels (e.g., com.atlas)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const suffix = type === 'tape' ? 'tape' : 'target';
const defaultIQN = `iqn.${year}-${month}.com.atlas:${suffix}-1`;
// Set target type
document.getElementById('create-iscsi-type').value = type;
document.getElementById('create-iscsi-modal-title').textContent = type === 'tape' ? 'Create Tape Library Passthrough Target' : 'Create Disk Mode Target';
const iqnInput = document.getElementById('iqn-input');
if (iqnInput) {
iqnInput.value = defaultIQN;
// Auto-fix common mistake: replace last dot with colon if needed
iqnInput.addEventListener('blur', function() {
let value = this.value.trim();
// If user typed dot before identifier, replace with colon
// Pattern: iqn.YYYY-MM.domain.identifier -> iqn.YYYY-MM.domain:identifier
if (value.match(/^iqn\.\d{4}-\d{2}\.[a-zA-Z0-9][a-zA-Z0-9\-\.]*\.[a-zA-Z0-9]/) && !value.includes(':')) {
// Replace last dot with colon
const lastDotIndex = value.lastIndexOf('.');
if (lastDotIndex > 0) {
value = value.substring(0, lastDotIndex) + ':' + value.substring(lastDotIndex + 1);
this.value = value;
}
}
});
}
document.getElementById('create-iscsi-modal').classList.remove('hidden');
}
@@ -271,24 +375,35 @@ function closeModal(modalId) {
async function createISCSITarget(e) {
e.preventDefault();
const formData = new FormData(e.target);
const targetType = formData.get('type') || 'disk';
try {
const res = await fetch('/api/v1/iscsi/targets', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
iqn: formData.get('iqn')
iqn: formData.get('iqn'),
type: targetType
})
});
if (res.ok) {
closeModal('create-iscsi-modal');
e.target.reset();
loadISCSITargets();
alert('iSCSI target created successfully');
loadISCSITargets(targetType);
alert(`${targetType === 'tape' ? 'Tape' : 'Disk'} target created successfully`);
} else {
const err = await res.json();
alert(`Error: ${err.error || 'Failed to create iSCSI target'}`);
const err = await res.json().catch(() => ({ error: 'Failed to parse error response' }));
let errMsg = 'Failed to create iSCSI target';
if (err) {
if (err.message) {
errMsg = err.message;
if (err.details) errMsg += ': ' + err.details;
} else if (err.error) {
errMsg = err.error;
}
}
alert(`Error: ${errMsg}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
@@ -299,8 +414,23 @@ async function addLUN(e) {
e.preventDefault();
const formData = new FormData(e.target);
const targetId = formData.get('target_id');
const targetType = formData.get('target_type') || 'disk';
// Get volume from either dropdown or manual input
let requestBody = {};
if (targetType === 'tape') {
// Tape mode: use device
const device = document.getElementById('lun-device-input').value.trim();
if (!device) {
alert('Please enter a tape device path');
return;
}
requestBody = {
device: device,
backstore: 'pscsi'
};
} else {
// Disk mode: use ZVOL
const zvolSelect = document.getElementById('lun-zvol-select').value;
const zvolManual = document.getElementById('lun-zvol-manual').value.trim();
const zvol = zvolSelect || zvolManual;
@@ -309,14 +439,16 @@ async function addLUN(e) {
alert('Please select or enter a volume name');
return;
}
requestBody = {
zvol: zvol
};
}
try {
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/luns`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
zvol: zvol
})
body: JSON.stringify(requestBody)
});
if (res.ok) {
@@ -324,7 +456,9 @@ async function addLUN(e) {
e.target.reset();
document.getElementById('lun-zvol-select').innerHTML = '<option value="">Loading volumes...</option>';
document.getElementById('lun-zvol-manual').value = '';
loadISCSITargets();
document.getElementById('lun-device-input').value = '';
// Reload targets for the current tab
loadISCSITargets(targetType);
alert('LUN added successfully');
} else {
const err = await res.json();
@@ -348,7 +482,9 @@ async function removeLUN(targetId, lunId) {
});
if (res.ok) {
loadISCSITargets();
// Reload targets for current tab
const activeTab = document.getElementById('content-disk').classList.contains('hidden') ? 'tape' : 'disk';
loadISCSITargets(activeTab);
alert('LUN removed successfully');
} else {
const err = await res.json();
@@ -369,7 +505,9 @@ async function deleteISCSITarget(id) {
});
if (res.ok) {
loadISCSITargets();
// Reload targets for current tab
const activeTab = document.getElementById('content-disk').classList.contains('hidden') ? 'tape' : 'disk';
loadISCSITargets(activeTab);
alert('iSCSI target deleted successfully');
} else {
const err = await res.json();
@@ -380,8 +518,7 @@ async function deleteISCSITarget(id) {
}
}
// Load initial data
loadISCSITargets();
// Load initial data - will be called in auth check function
</script>
{{end}}

View File

@@ -233,6 +233,35 @@
</div>
</div>
<!-- Add Spare Disk Modal -->
<div id="add-spare-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-slate-800 rounded-lg border border-slate-700 p-4 sm:p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
<h3 class="text-xl font-semibold text-white mb-4">Add Spare Disk</h3>
<form id="add-spare-form" onsubmit="addSpareDisk(event)" class="space-y-4">
<input type="hidden" id="add-spare-pool-name" name="pool">
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Pool Name</label>
<input type="text" id="add-spare-pool-name-display" readonly class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm opacity-75 cursor-not-allowed">
</div>
<div>
<label class="block text-sm font-medium text-slate-300 mb-1">Select Disk</label>
<select id="add-spare-disk" name="disk" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
<option value="">Loading disks...</option>
</select>
<p class="text-xs text-slate-400 mt-1">Only available disks are shown</p>
</div>
<div class="flex gap-2 justify-end">
<button type="button" onclick="closeModal('add-spare-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
Cancel
</button>
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded text-sm">
Add Spare
</button>
</div>
</form>
</div>
</div>
<!-- Edit Dataset Modal -->
<div id="edit-dataset-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div class="bg-slate-800 rounded-lg border border-slate-700 p-4 sm:p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
@@ -435,8 +464,10 @@ async function loadPools() {
return;
}
console.log('Rendering pools list...');
listEl.innerHTML = pools.map(pool => `
console.log('Rendering pools list...', pools);
const html = pools.map(pool => {
console.log('Rendering pool:', pool.name);
return `
<div class="border-b border-slate-700 last:border-0 py-4">
<div class="flex items-center justify-between">
<div class="flex-1">
@@ -462,8 +493,22 @@ async function loadPools() {
<span class="text-white ml-2">${formatBytes(pool.free)}</span>
</div>
</div>
<div class="mt-3">
<button onclick="togglePoolInfo('${pool.name}')" class="text-sm text-blue-400 hover:text-blue-300 flex items-center gap-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
Pool Info
</button>
</div>
<div id="pool-info-${pool.name}" class="hidden mt-4 p-4 bg-slate-900 rounded border border-slate-700">
<p class="text-slate-400 text-sm">Loading pool details...</p>
</div>
</div>
<div class="flex gap-2">
<button onclick="showAddSpareModal('${pool.name}')" class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded text-sm">
Add Spare
</button>
<button onclick="startScrub('${pool.name}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
Scrub
</button>
@@ -476,7 +521,14 @@ async function loadPools() {
</div>
</div>
</div>
`).join('');
`;
}).join('');
console.log('Generated HTML contains Pool Info:', html.includes('Pool Info'));
console.log('Generated HTML contains togglePoolInfo:', html.includes('togglePoolInfo'));
console.log('Generated HTML length:', html.length);
listEl.innerHTML = html;
console.log('Pools list rendered successfully');
} catch (err) {
console.error('Error in loadPools:', err);
@@ -1069,6 +1121,197 @@ async function createZVOL(e) {
}
}
async function togglePoolInfo(poolName) {
const infoEl = document.getElementById(`pool-info-${poolName}`);
if (!infoEl) return;
if (infoEl.classList.contains('hidden')) {
infoEl.classList.remove('hidden');
await loadPoolInfo(poolName);
} else {
infoEl.classList.add('hidden');
}
}
async function loadPoolInfo(poolName) {
const infoEl = document.getElementById(`pool-info-${poolName}`);
if (!infoEl) return;
try {
const res = await fetch(`/api/v1/pools/${poolName}?detail=true`, {
headers: getAuthHeaders()
});
if (!res.ok) {
infoEl.innerHTML = '<p class="text-red-400 text-sm">Failed to load pool details</p>';
return;
}
const detail = await res.json();
let html = '<div class="space-y-4">';
// State and Status
html += `<div class="grid grid-cols-2 gap-4 text-sm">
<div>
<span class="text-slate-400">State:</span>
<span class="text-white ml-2 px-2 py-1 rounded text-xs font-medium ${
detail.state === 'ONLINE' ? 'bg-green-900 text-green-300' :
detail.state === 'DEGRADED' ? 'bg-yellow-900 text-yellow-300' :
'bg-red-900 text-red-300'
}">${detail.state || 'N/A'}</span>
</div>
<div>
<span class="text-slate-400">Errors:</span>
<span class="text-white ml-2">${detail.errors || 'No known data errors'}</span>
</div>
</div>`;
// Scrub Info
if (detail.scrub_info) {
html += `<div class="text-sm">
<span class="text-slate-400">Scrub:</span>
<span class="text-white ml-2">${detail.scrub_info}</span>
</div>`;
}
// VDEVs
if (detail.vdevs && detail.vdevs.length > 0) {
html += '<div><h4 class="text-sm font-semibold text-white mb-2">Virtual Devices:</h4>';
html += '<div class="space-y-3">';
detail.vdevs.forEach(vdev => {
html += `<div class="pl-4 border-l-2 border-slate-600">
<div class="flex items-center gap-2 mb-1">
<span class="text-sm font-medium text-slate-300">${vdev.name || vdev.type || 'VDEV'}</span>
<span class="px-2 py-0.5 rounded text-xs ${
vdev.state === 'ONLINE' ? 'bg-green-900 text-green-300' :
vdev.state === 'DEGRADED' ? 'bg-yellow-900 text-yellow-300' :
'bg-red-900 text-red-300'
}">${vdev.state}</span>
${vdev.read > 0 || vdev.write > 0 || vdev.checksum > 0 ? `
<span class="text-xs text-slate-400">
(R:${vdev.read} W:${vdev.write} C:${vdev.checksum})
</span>
` : ''}
</div>`;
if (vdev.disks && vdev.disks.length > 0) {
html += '<div class="ml-4 space-y-1">';
vdev.disks.forEach(disk => {
html += `<div class="text-xs flex items-center gap-2">
<span class="text-slate-300">${disk.name}</span>
<span class="px-1.5 py-0.5 rounded text-xs ${
disk.state === 'ONLINE' ? 'bg-green-900 text-green-300' :
disk.state === 'DEGRADED' ? 'bg-yellow-900 text-yellow-300' :
'bg-red-900 text-red-300'
}">${disk.state}</span>
${disk.read > 0 || disk.write > 0 || disk.checksum > 0 ? `
<span class="text-slate-500">
(R:${disk.read} W:${disk.write} C:${disk.checksum})
</span>
` : ''}
</div>`;
});
html += '</div>';
}
html += '</div>';
});
html += '</div></div>';
}
// Spares
if (detail.spares && detail.spares.length > 0) {
html += '<div><h4 class="text-sm font-semibold text-white mb-2">Spare Disks:</h4>';
html += '<div class="flex flex-wrap gap-2">';
detail.spares.forEach(spare => {
html += `<span class="px-2 py-1 bg-slate-700 text-slate-300 rounded text-xs">${spare}</span>`;
});
html += '</div></div>';
} else {
html += '<div><h4 class="text-sm font-semibold text-white mb-2">Spare Disks:</h4>';
html += '<p class="text-slate-400 text-sm">No spare disks configured</p></div>';
}
html += '</div>';
infoEl.innerHTML = html;
} catch (err) {
console.error('Error loading pool info:', err);
infoEl.innerHTML = '<p class="text-red-400 text-sm">Error loading pool details</p>';
}
}
function showAddSpareModal(poolName) {
document.getElementById('add-spare-pool-name').value = poolName;
document.getElementById('add-spare-pool-name-display').value = poolName;
document.getElementById('add-spare-modal').classList.remove('hidden');
loadDisksForSpare();
}
async function loadDisksForSpare() {
try {
const res = await fetch('/api/v1/disks', {
headers: getAuthHeaders()
});
if (!res.ok) return;
const disks = await res.json();
const selectEl = document.getElementById('add-spare-disk');
selectEl.innerHTML = '<option value="">Select a disk...</option>';
disks.forEach(disk => {
if (disk.status === 'available') {
const option = document.createElement('option');
option.value = disk.name;
option.textContent = `${disk.name} (${disk.size || 'Unknown'})`;
selectEl.appendChild(option);
}
});
} catch (err) {
console.error('Error loading disks:', err);
}
}
async function addSpareDisk(event) {
event.preventDefault();
const poolName = document.getElementById('add-spare-pool-name').value;
const diskName = document.getElementById('add-spare-disk').value;
if (!diskName) {
alert('Please select a disk');
return;
}
try {
const res = await fetch(`/api/v1/pools/${poolName}/spare`, {
method: 'POST',
headers: {
...getAuthHeaders(),
'Content-Type': 'application/json'
},
body: JSON.stringify({ disk: diskName })
});
if (res.ok) {
closeModal('add-spare-modal');
loadPools();
alert('Spare disk added successfully');
} else {
const data = await res.json();
let errMsg = 'Failed to add spare disk';
if (data) {
if (data.message) {
errMsg = data.message;
if (data.details) errMsg += ': ' + data.details;
} else if (data.error) {
errMsg = data.error;
}
}
alert(`Error: ${errMsg}`);
}
} catch (err) {
alert(`Error: ${err.message}`);
}
}
async function deletePool(name) {
if (!confirm(`Are you sure you want to delete pool "${name}"? This will destroy all data!`)) return;