This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -124,6 +125,17 @@ func (a *App) handleGetPool(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
pool, err := a.zfs.GetPool(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
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)
|
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) {
|
func (a *App) handleDeletePool(w http.ResponseWriter, r *http.Request) {
|
||||||
name := pathParam(r, "/api/v1/pools/")
|
name := pathParam(r, "/api/v1/pools/")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -753,6 +802,13 @@ func (a *App) handleDeleteSnapshotPolicy(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
// SMB Share Handlers
|
// SMB Share Handlers
|
||||||
func (a *App) handleListSMBShares(w http.ResponseWriter, r *http.Request) {
|
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()
|
shares := a.smbStore.List()
|
||||||
writeJSON(w, http.StatusOK, shares)
|
writeJSON(w, http.StatusOK, shares)
|
||||||
}
|
}
|
||||||
@@ -1151,6 +1207,11 @@ func (a *App) handleDeleteNFSExport(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// iSCSI Handlers
|
// iSCSI Handlers
|
||||||
func (a *App) handleListISCSITargets(w http.ResponseWriter, r *http.Request) {
|
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()
|
targets := a.iscsiStore.List()
|
||||||
writeJSON(w, http.StatusOK, targets)
|
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) {
|
func (a *App) handleCreateISCSITarget(w http.ResponseWriter, r *http.Request) {
|
||||||
var req struct {
|
var req struct {
|
||||||
IQN string `json:"iqn"`
|
IQN string `json:"iqn"`
|
||||||
|
Type string `json:"type"` // "disk" or "tape" (default: "disk")
|
||||||
Initiators []string `json:"initiators"`
|
Initiators []string `json:"initiators"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
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
|
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
|
// Validate IQN format
|
||||||
if err := validation.ValidateIQN(req.IQN); err != nil {
|
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()))
|
writeError(w, errors.ErrValidation(err.Error()))
|
||||||
return
|
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 != nil {
|
||||||
if err == storage.ErrISCSITargetExists {
|
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
|
return
|
||||||
}
|
}
|
||||||
log.Printf("create iSCSI target error: %v", err)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Printf("create iSCSI target: target created in store (ID: %s, IQN: %s)", target.ID, target.IQN)
|
||||||
|
|
||||||
// Apply configuration to iSCSI service
|
// Apply configuration to iSCSI service
|
||||||
targets := a.iscsiStore.List()
|
targets := a.iscsiStore.List()
|
||||||
if err := a.iscsiService.ApplyConfiguration(targets); err != nil {
|
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)
|
writeJSON(w, http.StatusCreated, target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1325,7 +1412,10 @@ func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
|
|||||||
id := parts[0]
|
id := parts[0]
|
||||||
|
|
||||||
var req struct {
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
@@ -1333,35 +1423,55 @@ func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.ZVOL == "" {
|
// Validate: must have either ZVOL or Device
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol is required"})
|
if req.ZVOL == "" && req.Device == "" {
|
||||||
return
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "either zvol or device is required"})
|
||||||
}
|
|
||||||
|
|
||||||
// Validate ZVOL exists
|
|
||||||
zvols, err := a.zfs.ListZVOLs("")
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("list zvols error: %v", err)
|
|
||||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate zvol"})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var zvolSize uint64
|
var zvolSize uint64
|
||||||
zvolExists := false
|
var zvolName string
|
||||||
for _, zvol := range zvols {
|
|
||||||
if zvol.Name == req.ZVOL {
|
if req.ZVOL != "" {
|
||||||
zvolExists = true
|
// Validate ZVOL exists
|
||||||
zvolSize = zvol.Size
|
zvols, err := a.zfs.ListZVOLs("")
|
||||||
break
|
if err != nil {
|
||||||
|
log.Printf("list zvols error: %v", err)
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate zvol"})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
zvolExists := false
|
||||||
|
for _, zvol := range zvols {
|
||||||
|
if zvol.Name == req.ZVOL {
|
||||||
|
zvolExists = true
|
||||||
|
zvolSize = zvol.Size
|
||||||
|
zvolName = zvol.Name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !zvolExists {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
if !zvolExists {
|
// Use updated AddLUN signature that supports device and backstore
|
||||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol not found"})
|
lun, err := a.iscsiStore.AddLUNWithDevice(id, zvolName, req.Device, zvolSize, req.Backstore, req.BackstoreName)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
lun, err := a.iscsiStore.AddLUN(id, req.ZVOL, zvolSize)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == storage.ErrISCSITargetNotFound {
|
if err == storage.ErrISCSITargetNotFound {
|
||||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "target not found"})
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "target not found"})
|
||||||
@@ -1804,3 +1914,326 @@ func (a *App) syncNFSExportsFromOS() error {
|
|||||||
|
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package httpapp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,6 +65,14 @@ func (a *App) handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Service statistics
|
// 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()
|
smbShares := a.smbStore.List()
|
||||||
data.Services.SMBShares = len(smbShares)
|
data.Services.SMBShares = len(smbShares)
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ func (a *App) handlePoolOps(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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(
|
methodHandler(
|
||||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetPool(w, r) },
|
func(w http.ResponseWriter, r *http.Request) { a.handleGetPool(w, r) },
|
||||||
nil,
|
nil,
|
||||||
|
|||||||
@@ -24,19 +24,31 @@ type NFSExport struct {
|
|||||||
Enabled bool `json:"enabled"`
|
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
|
// ISCSITarget represents an iSCSI target
|
||||||
type ISCSITarget struct {
|
type ISCSITarget struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
IQN string `json:"iqn"` // iSCSI Qualified Name
|
IQN string `json:"iqn"` // iSCSI Qualified Name
|
||||||
LUNs []LUN `json:"luns"`
|
Type ISCSITargetType `json:"type"` // "disk" or "tape"
|
||||||
Initiators []string `json:"initiators"` // ACL list
|
LUNs []LUN `json:"luns"`
|
||||||
Enabled bool `json:"enabled"`
|
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 {
|
type LUN struct {
|
||||||
ID int `json:"id"` // LUN number
|
ID int `json:"id"` // LUN number
|
||||||
ZVOL string `json:"zvol"` // ZVOL name
|
ZVOL string `json:"zvol"` // ZVOL name (for block backstore)
|
||||||
Size uint64 `json:"size"` // bytes
|
Device string `json:"device"` // Device path (e.g., /dev/st0 for tape, /dev/sdX for disk)
|
||||||
Backend string `json:"backend"` // "zvol"
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,37 @@ type Pool struct {
|
|||||||
CreatedAt time.Time `json:"created_at"`
|
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
|
// Dataset represents a ZFS filesystem
|
||||||
type Dataset struct {
|
type Dataset struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ func (s *ISCSIService) ApplyConfiguration(targets []models.ISCSITarget) error {
|
|||||||
// Disable target if it exists
|
// Disable target if it exists
|
||||||
if err := s.disableTarget(target.IQN); err != nil {
|
if err := s.disableTarget(target.IQN); err != nil {
|
||||||
// Log but continue
|
// Log but continue
|
||||||
|
fmt.Printf("warning: failed to disable target %s: %v\n", target.IQN, err)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -49,16 +51,19 @@ func (s *ISCSIService) ApplyConfiguration(targets []models.ISCSITarget) error {
|
|||||||
if err := s.createTarget(target); err != nil {
|
if err := s.createTarget(target); err != nil {
|
||||||
return fmt.Errorf("create target %s: %w", target.IQN, err)
|
return fmt.Errorf("create target %s: %w", target.IQN, err)
|
||||||
}
|
}
|
||||||
|
fmt.Printf("iSCSI target created/verified: %s\n", target.IQN)
|
||||||
|
|
||||||
// Configure ACLs
|
// Configure ACLs
|
||||||
if err := s.configureACLs(target); err != nil {
|
if err := s.configureACLs(target); err != nil {
|
||||||
return fmt.Errorf("configure ACLs for %s: %w", target.IQN, err)
|
return fmt.Errorf("configure ACLs for %s: %w", target.IQN, err)
|
||||||
}
|
}
|
||||||
|
fmt.Printf("iSCSI ACLs configured for: %s\n", target.IQN)
|
||||||
|
|
||||||
// Configure LUNs
|
// Configure LUNs
|
||||||
if err := s.configureLUNs(target); err != nil {
|
if err := s.configureLUNs(target); err != nil {
|
||||||
return fmt.Errorf("configure LUNs for %s: %w", target.IQN, err)
|
return fmt.Errorf("configure LUNs for %s: %w", target.IQN, err)
|
||||||
}
|
}
|
||||||
|
fmt.Printf("iSCSI LUNs configured for: %s\n", target.IQN)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -68,27 +73,93 @@ func (s *ISCSIService) ApplyConfiguration(targets []models.ISCSITarget) error {
|
|||||||
func (s *ISCSIService) createTarget(target models.ISCSITarget) error {
|
func (s *ISCSIService) createTarget(target models.ISCSITarget) error {
|
||||||
// Use targetcli to create target
|
// Use targetcli to create target
|
||||||
// Format: targetcli /iscsi create <IQN>
|
// Format: targetcli /iscsi create <IQN>
|
||||||
cmd := exec.Command(s.targetcliPath, "/iscsi", "create", target.IQN)
|
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi", "create", target.IQN)
|
||||||
if err := cmd.Run(); err != nil {
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
// Target might already exist, which is OK
|
// Target might already exist, which is OK
|
||||||
// Check if it actually exists
|
// Check if it actually exists
|
||||||
if !s.targetExists(target.IQN) {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// configureACLs configures initiator ACLs for a target
|
// configureACLs configures initiator ACLs for a target
|
||||||
func (s *ISCSIService) configureACLs(target models.ISCSITarget) error {
|
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
|
// Get current ACLs
|
||||||
currentACLs, _ := s.getACLs(target.IQN)
|
currentACLs, _ := s.getACLs(target.IQN)
|
||||||
|
|
||||||
// Remove ACLs not in desired list
|
// Remove ACLs not in desired list
|
||||||
for _, acl := range currentACLs {
|
for _, acl := range currentACLs {
|
||||||
if !contains(target.Initiators, acl) {
|
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
|
cmd.Run() // Ignore errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +167,7 @@ func (s *ISCSIService) configureACLs(target models.ISCSITarget) error {
|
|||||||
// Add new ACLs
|
// Add new ACLs
|
||||||
for _, initiator := range target.Initiators {
|
for _, initiator := range target.Initiators {
|
||||||
if !contains(currentACLs, initiator) {
|
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 {
|
if err := cmd.Run(); err != nil {
|
||||||
return fmt.Errorf("create ACL %s: %w", initiator, err)
|
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
|
// Remove LUNs not in desired list
|
||||||
for _, lun := range currentLUNs {
|
for _, lun := range currentLUNs {
|
||||||
if !s.hasLUN(target.LUNs, lun) {
|
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
|
cmd.Run() // Ignore errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add/update LUNs
|
// Add/update LUNs
|
||||||
for _, lun := range target.LUNs {
|
for _, lun := range target.LUNs {
|
||||||
// Create LUN mapping
|
// Determine backstore type (default to block for ZVOL, pscsi for tape devices)
|
||||||
// Format: targetcli /iscsi/<IQN>/tpg1/luns create /backstores/zvol/<zvol>
|
backstoreType := lun.Backstore
|
||||||
zvolPath := "/backstores/zvol/" + lun.ZVOL
|
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
|
// Determine backstore name
|
||||||
cmd := exec.Command(s.targetcliPath, "/backstores/zvol", "create", lun.ZVOL, lun.ZVOL)
|
backstoreName := lun.BackstoreName
|
||||||
cmd.Run() // Ignore if already exists
|
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
|
// 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 {
|
if err := cmd.Run(); err != nil {
|
||||||
// LUN might already exist
|
// LUN might already exist
|
||||||
if !s.hasLUNID(currentLUNs, lun.ID) {
|
if !s.hasLUNID(currentLUNs, lun.ID) {
|
||||||
@@ -144,7 +292,7 @@ func (s *ISCSIService) configureLUNs(target models.ISCSITarget) error {
|
|||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
func (s *ISCSIService) targetExists(iqn string) bool {
|
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()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
@@ -153,25 +301,56 @@ func (s *ISCSIService) targetExists(iqn string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *ISCSIService) getACLs(iqn string) ([]string, error) {
|
func (s *ISCSIService) getACLs(iqn string) ([]string, error) {
|
||||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+iqn+"/tpg1/acls", "ls")
|
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+iqn+"/tpg1/acls", "ls")
|
||||||
_, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return []string{}, nil // Return empty if can't get ACLs
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse output to extract ACL names
|
// Parse output to extract ACL names
|
||||||
// This is simplified - real implementation would parse targetcli output
|
// Format: o- acls ................................................................................................ [ACLs: 1]
|
||||||
return []string{}, nil
|
// 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) {
|
func (s *ISCSIService) getLUNs(iqn string) ([]int, error) {
|
||||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+iqn+"/tpg1/luns", "ls")
|
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+iqn+"/tpg1/luns", "ls")
|
||||||
_, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return []int{}, nil // Return empty if can't get LUNs
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse output to extract LUN IDs
|
// Parse output to extract LUN IDs
|
||||||
// This is simplified - real implementation would parse targetcli output
|
// Format: o- luns ................................................................................................ [LUNs: 1]
|
||||||
return []int{}, nil
|
// 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 {
|
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 {
|
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()
|
return cmd.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +505,7 @@ func (s *ISCSIService) GetConnectionInstructions(target models.ISCSITarget, port
|
|||||||
// GetPortalIP attempts to detect the portal IP address
|
// GetPortalIP attempts to detect the portal IP address
|
||||||
func (s *ISCSIService) GetPortalIP() (string, error) {
|
func (s *ISCSIService) GetPortalIP() (string, error) {
|
||||||
// Try to get IP from targetcli
|
// 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()
|
output, err := cmd.Output()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Fallback: try to get system IP
|
// Fallback: try to get system IP
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package storage
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
"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
|
// Create creates a new iSCSI target
|
||||||
func (s *ISCSIStore) Create(iqn string, initiators []string) (*models.ISCSITarget, error) {
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
@@ -85,6 +92,7 @@ func (s *ISCSIStore) Create(iqn string, initiators []string) (*models.ISCSITarge
|
|||||||
target := &models.ISCSITarget{
|
target := &models.ISCSITarget{
|
||||||
ID: id,
|
ID: id,
|
||||||
IQN: iqn,
|
IQN: iqn,
|
||||||
|
Type: targetType,
|
||||||
LUNs: []models.LUN{},
|
LUNs: []models.LUN{},
|
||||||
Initiators: initiators,
|
Initiators: initiators,
|
||||||
Enabled: true,
|
Enabled: true,
|
||||||
@@ -151,10 +159,64 @@ func (s *ISCSIStore) AddLUN(targetID string, zvol string, size uint64) (*models.
|
|||||||
}
|
}
|
||||||
|
|
||||||
lun := models.LUN{
|
lun := models.LUN{
|
||||||
ID: lunID,
|
ID: lunID,
|
||||||
ZVOL: zvol,
|
ZVOL: zvol,
|
||||||
Size: size,
|
Size: size,
|
||||||
Backend: "zvol",
|
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)
|
target.LUNs = append(target.LUNs, lun)
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ var (
|
|||||||
shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.]{0,79}$`)
|
shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.]{0,79}$`)
|
||||||
|
|
||||||
// IQN pattern (simplified - iqn.yyyy-mm.reversed.domain:identifier)
|
// 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)
|
// Email pattern (basic)
|
||||||
emailPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
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
|
// 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 {
|
func ValidateIQN(iqn string) error {
|
||||||
if iqn == "" {
|
if iqn == "" {
|
||||||
return &ValidationError{Field: "iqn", Message: "IQN cannot be empty"}
|
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)
|
// Basic format validation (can be more strict)
|
||||||
if !iqnPattern.MatchString(iqn) {
|
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
|
return nil
|
||||||
|
|||||||
@@ -255,6 +255,197 @@ func (s *Service) GetPool(name string) (*models.Pool, error) {
|
|||||||
return nil, fmt.Errorf("pool %s not found", name)
|
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
|
// CreatePool creates a new ZFS pool
|
||||||
func (s *Service) CreatePool(name string, vdevs []string, options map[string]string) error {
|
func (s *Service) CreatePool(name string, vdevs []string, options map[string]string) error {
|
||||||
args := []string{"create"}
|
args := []string{"create"}
|
||||||
@@ -1148,11 +1339,19 @@ func (s *Service) ListDisks() ([]map[string]string, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if disk is used in a pool
|
// Skip virtual disks (ZVOLs) - these are zd* devices
|
||||||
isUsed := usedDisks[dev.Name]
|
// 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 disks (both available and used) so we can show status
|
// 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" {
|
if dev.Type == "disk" {
|
||||||
|
// Check if disk is used in a pool
|
||||||
|
isUsed := usedDisks[dev.Name]
|
||||||
disk := map[string]string{
|
disk := map[string]string{
|
||||||
"name": dev.Name,
|
"name": dev.Name,
|
||||||
"size": dev.Size,
|
"size": dev.Size,
|
||||||
|
|||||||
@@ -144,7 +144,9 @@
|
|||||||
// Update storage stats
|
// Update storage stats
|
||||||
document.getElementById('pool-count').textContent = data.storage.pool_count || 0;
|
document.getElementById('pool-count').textContent = data.storage.pool_count || 0;
|
||||||
document.getElementById('total-capacity').textContent = formatBytes(data.storage.total_capacity || 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;
|
document.getElementById('iscsi-targets').textContent = data.services.iscsi_targets || 0;
|
||||||
|
|
||||||
// Update service status
|
// Update service status
|
||||||
|
|||||||
@@ -5,18 +5,55 @@
|
|||||||
<h1 class="text-3xl font-bold text-white mb-2">iSCSI Targets</h1>
|
<h1 class="text-3xl font-bold text-white mb-2">iSCSI Targets</h1>
|
||||||
<p class="text-slate-400">Manage iSCSI targets and LUNs</p>
|
<p class="text-slate-400">Manage iSCSI targets and LUNs</p>
|
||||||
</div>
|
</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
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Tabs for Disk Mode and Tape Mode -->
|
||||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
<div class="flex border-b border-slate-700">
|
||||||
<h2 class="text-lg font-semibold text-white">iSCSI Targets</h2>
|
<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">
|
||||||
<button onclick="loadISCSITargets()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
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>
|
||||||
<div id="iscsi-targets-list" class="p-4">
|
|
||||||
<p class="text-slate-400 text-sm">Loading...</p>
|
<!-- Disk Mode Content -->
|
||||||
|
<div id="content-disk" class="tab-content">
|
||||||
|
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,12 +61,15 @@
|
|||||||
<!-- Create iSCSI Target Modal -->
|
<!-- 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 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">
|
<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">
|
<form id="create-iscsi-form" onsubmit="createISCSITarget(event)" class="space-y-4">
|
||||||
|
<input type="hidden" name="type" id="create-iscsi-type" value="disk">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-slate-300 mb-1">IQN</label>
|
<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">
|
<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</p>
|
<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>
|
||||||
<div class="flex gap-2 justify-end">
|
<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">
|
<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];
|
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 {
|
try {
|
||||||
const res = await fetch('/api/v1/iscsi/targets', { headers: getAuthHeaders() });
|
const res = await fetch('/api/v1/iscsi/targets', { headers: getAuthHeaders() });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const targets = await res.json();
|
const allTargets = await res.json();
|
||||||
const listEl = document.getElementById('iscsi-targets-list');
|
|
||||||
|
|
||||||
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>';
|
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targets.length === 0) {
|
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;
|
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>'}
|
` : '<p class="text-sm text-slate-500 mt-2">No LUNs attached. Click "Add LUN" to bind a volume.</p>'}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 ml-4">
|
<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
|
Add LUN
|
||||||
</button>
|
</button>
|
||||||
<button onclick="showConnectionInstructions('${target.id}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
<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');
|
document.getElementById('create-iscsi-modal').classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,24 +375,35 @@ function closeModal(modalId) {
|
|||||||
async function createISCSITarget(e) {
|
async function createISCSITarget(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.target);
|
const formData = new FormData(e.target);
|
||||||
|
const targetType = formData.get('type') || 'disk';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/v1/iscsi/targets', {
|
const res = await fetch('/api/v1/iscsi/targets', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
iqn: formData.get('iqn')
|
iqn: formData.get('iqn'),
|
||||||
|
type: targetType
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
closeModal('create-iscsi-modal');
|
closeModal('create-iscsi-modal');
|
||||||
e.target.reset();
|
e.target.reset();
|
||||||
loadISCSITargets();
|
loadISCSITargets(targetType);
|
||||||
alert('iSCSI target created successfully');
|
alert(`${targetType === 'tape' ? 'Tape' : 'Disk'} target created successfully`);
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json();
|
const err = await res.json().catch(() => ({ error: 'Failed to parse error response' }));
|
||||||
alert(`Error: ${err.error || 'Failed to create iSCSI target'}`);
|
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) {
|
} catch (err) {
|
||||||
alert(`Error: ${err.message}`);
|
alert(`Error: ${err.message}`);
|
||||||
@@ -299,24 +414,41 @@ async function addLUN(e) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(e.target);
|
const formData = new FormData(e.target);
|
||||||
const targetId = formData.get('target_id');
|
const targetId = formData.get('target_id');
|
||||||
|
const targetType = formData.get('target_type') || 'disk';
|
||||||
|
|
||||||
// Get volume from either dropdown or manual input
|
let requestBody = {};
|
||||||
const zvolSelect = document.getElementById('lun-zvol-select').value;
|
|
||||||
const zvolManual = document.getElementById('lun-zvol-manual').value.trim();
|
|
||||||
const zvol = zvolSelect || zvolManual;
|
|
||||||
|
|
||||||
if (!zvol) {
|
if (targetType === 'tape') {
|
||||||
alert('Please select or enter a volume name');
|
// Tape mode: use device
|
||||||
return;
|
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;
|
||||||
|
|
||||||
|
if (!zvol) {
|
||||||
|
alert('Please select or enter a volume name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
requestBody = {
|
||||||
|
zvol: zvol
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/luns`, {
|
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/luns`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: getAuthHeaders(),
|
headers: getAuthHeaders(),
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(requestBody)
|
||||||
zvol: zvol
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -324,7 +456,9 @@ async function addLUN(e) {
|
|||||||
e.target.reset();
|
e.target.reset();
|
||||||
document.getElementById('lun-zvol-select').innerHTML = '<option value="">Loading volumes...</option>';
|
document.getElementById('lun-zvol-select').innerHTML = '<option value="">Loading volumes...</option>';
|
||||||
document.getElementById('lun-zvol-manual').value = '';
|
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');
|
alert('LUN added successfully');
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
@@ -348,7 +482,9 @@ async function removeLUN(targetId, lunId) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
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');
|
alert('LUN removed successfully');
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
@@ -369,7 +505,9 @@ async function deleteISCSITarget(id) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
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');
|
alert('iSCSI target deleted successfully');
|
||||||
} else {
|
} else {
|
||||||
const err = await res.json();
|
const err = await res.json();
|
||||||
@@ -380,8 +518,7 @@ async function deleteISCSITarget(id) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data - will be called in auth check function
|
||||||
loadISCSITargets();
|
|
||||||
</script>
|
</script>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
@@ -233,6 +233,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- 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 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">
|
<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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Rendering pools list...');
|
console.log('Rendering pools list...', pools);
|
||||||
listEl.innerHTML = pools.map(pool => `
|
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="border-b border-slate-700 last:border-0 py-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex-1">
|
<div class="flex-1">
|
||||||
@@ -462,8 +493,22 @@ async function loadPools() {
|
|||||||
<span class="text-white ml-2">${formatBytes(pool.free)}</span>
|
<span class="text-white ml-2">${formatBytes(pool.free)}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div class="flex gap-2">
|
<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">
|
<button onclick="startScrub('${pool.name}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||||
Scrub
|
Scrub
|
||||||
</button>
|
</button>
|
||||||
@@ -476,7 +521,14 @@ async function loadPools() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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');
|
console.log('Pools list rendered successfully');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error in loadPools:', 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) {
|
async function deletePool(name) {
|
||||||
if (!confirm(`Are you sure you want to delete pool "${name}"? This will destroy all data!`)) return;
|
if (!confirm(`Are you sure you want to delete pool "${name}"? This will destroy all data!`)) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user