This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -124,6 +125,17 @@ func (a *App) handleGetPool(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if detail is requested
|
||||
if r.URL.Query().Get("detail") == "true" {
|
||||
detail, err := a.zfs.GetPoolDetail(name)
|
||||
if err != nil {
|
||||
writeError(w, errors.ErrNotFound("pool not found").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, detail)
|
||||
return
|
||||
}
|
||||
|
||||
pool, err := a.zfs.GetPool(name)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
@@ -133,6 +145,43 @@ func (a *App) handleGetPool(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, pool)
|
||||
}
|
||||
|
||||
func (a *App) handleAddSpareDisk(w http.ResponseWriter, r *http.Request) {
|
||||
name := pathParam(r, "/api/v1/pools/")
|
||||
name = strings.TrimSuffix(name, "/spare")
|
||||
if name == "" {
|
||||
writeError(w, errors.ErrBadRequest("pool name required"))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Disk string `json:"disk"` // Disk path like /dev/sdb or sdb
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, errors.ErrBadRequest("invalid request body"))
|
||||
return
|
||||
}
|
||||
|
||||
if req.Disk == "" {
|
||||
writeError(w, errors.ErrValidation("disk path required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure disk path starts with /dev/ if not already
|
||||
diskPath := req.Disk
|
||||
if !strings.HasPrefix(diskPath, "/dev/") {
|
||||
diskPath = "/dev/" + diskPath
|
||||
}
|
||||
|
||||
if err := a.zfs.AddSpareDisk(name, diskPath); err != nil {
|
||||
log.Printf("add spare disk error: %v", err)
|
||||
writeError(w, errors.ErrInternal("failed to add spare disk").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "spare disk added", "pool": name, "disk": diskPath})
|
||||
}
|
||||
|
||||
func (a *App) handleDeletePool(w http.ResponseWriter, r *http.Request) {
|
||||
name := pathParam(r, "/api/v1/pools/")
|
||||
if name == "" {
|
||||
@@ -753,6 +802,13 @@ func (a *App) handleDeleteSnapshotPolicy(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// SMB Share Handlers
|
||||
func (a *App) handleListSMBShares(w http.ResponseWriter, r *http.Request) {
|
||||
// Sync shares from OS (smb.conf) to store
|
||||
// This ensures shares created before service restart are visible
|
||||
if err := a.syncSMBSharesFromOS(); err != nil {
|
||||
log.Printf("warning: failed to sync SMB shares from OS: %v", err)
|
||||
// Continue anyway - return what's in store
|
||||
}
|
||||
|
||||
shares := a.smbStore.List()
|
||||
writeJSON(w, http.StatusOK, shares)
|
||||
}
|
||||
@@ -1151,6 +1207,11 @@ func (a *App) handleDeleteNFSExport(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// iSCSI Handlers
|
||||
func (a *App) handleListISCSITargets(w http.ResponseWriter, r *http.Request) {
|
||||
// Sync targets from OS before listing
|
||||
if err := a.syncISCSITargetsFromOS(); err != nil {
|
||||
log.Printf("warning: failed to sync iSCSI targets from OS: %v", err)
|
||||
}
|
||||
|
||||
targets := a.iscsiStore.List()
|
||||
writeJSON(w, http.StatusOK, targets)
|
||||
}
|
||||
@@ -1158,37 +1219,63 @@ func (a *App) handleListISCSITargets(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) handleCreateISCSITarget(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
IQN string `json:"iqn"`
|
||||
Type string `json:"type"` // "disk" or "tape" (default: "disk")
|
||||
Initiators []string `json:"initiators"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
log.Printf("create iSCSI target: invalid request body: %v", err)
|
||||
writeError(w, errors.ErrBadRequest("invalid request body").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate and set target type
|
||||
targetType := models.ISCSITargetTypeDisk // Default to disk mode
|
||||
if req.Type != "" {
|
||||
if req.Type != "disk" && req.Type != "tape" {
|
||||
writeError(w, errors.ErrValidation("invalid target type: must be 'disk' or 'tape'"))
|
||||
return
|
||||
}
|
||||
targetType = models.ISCSITargetType(req.Type)
|
||||
}
|
||||
|
||||
log.Printf("create iSCSI target: IQN=%s, Type=%s, Initiators=%v", req.IQN, targetType, req.Initiators)
|
||||
|
||||
// Validate IQN format
|
||||
if err := validation.ValidateIQN(req.IQN); err != nil {
|
||||
log.Printf("IQN validation error: %v (IQN: %s)", err, req.IQN)
|
||||
writeError(w, errors.ErrValidation(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
target, err := a.iscsiStore.Create(req.IQN, req.Initiators)
|
||||
target, err := a.iscsiStore.CreateWithType(req.IQN, targetType, req.Initiators)
|
||||
if err != nil {
|
||||
if err == storage.ErrISCSITargetExists {
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": "target with this IQN already exists"})
|
||||
log.Printf("create iSCSI target: target already exists (IQN: %s)", req.IQN)
|
||||
writeError(w, errors.ErrConflict("target with this IQN already exists"))
|
||||
return
|
||||
}
|
||||
log.Printf("create iSCSI target error: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
writeError(w, errors.ErrInternal("failed to create iSCSI target").WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("create iSCSI target: target created in store (ID: %s, IQN: %s)", target.ID, target.IQN)
|
||||
|
||||
// Apply configuration to iSCSI service
|
||||
targets := a.iscsiStore.List()
|
||||
if err := a.iscsiService.ApplyConfiguration(targets); err != nil {
|
||||
log.Printf("apply iSCSI configuration error: %v", err)
|
||||
log.Printf("create iSCSI target: apply configuration error: %v", err)
|
||||
// Don't fail the request if configuration fails - target is already in store
|
||||
// User can retry configuration later
|
||||
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
||||
"target": target,
|
||||
"warning": "target created but configuration may have failed. check logs.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("create iSCSI target: success (ID: %s, IQN: %s)", target.ID, target.IQN)
|
||||
writeJSON(w, http.StatusCreated, target)
|
||||
}
|
||||
|
||||
@@ -1325,7 +1412,10 @@ func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
|
||||
id := parts[0]
|
||||
|
||||
var req struct {
|
||||
ZVOL string `json:"zvol"`
|
||||
ZVOL string `json:"zvol"` // ZVOL name (for block backstore)
|
||||
Device string `json:"device"` // Device path (e.g., /dev/st0 for tape)
|
||||
Backstore string `json:"backstore"` // Backstore type: "block", "pscsi", "fileio" (optional, auto-detected)
|
||||
BackstoreName string `json:"backstore_name"` // Custom backstore name (optional)
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -1333,11 +1423,16 @@ func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.ZVOL == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol is required"})
|
||||
// Validate: must have either ZVOL or Device
|
||||
if req.ZVOL == "" && req.Device == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "either zvol or device is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var zvolSize uint64
|
||||
var zvolName string
|
||||
|
||||
if req.ZVOL != "" {
|
||||
// Validate ZVOL exists
|
||||
zvols, err := a.zfs.ListZVOLs("")
|
||||
if err != nil {
|
||||
@@ -1346,12 +1441,12 @@ func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var zvolSize uint64
|
||||
zvolExists := false
|
||||
for _, zvol := range zvols {
|
||||
if zvol.Name == req.ZVOL {
|
||||
zvolExists = true
|
||||
zvolSize = zvol.Size
|
||||
zvolName = zvol.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -1360,8 +1455,23 @@ func (a *App) handleAddLUN(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "zvol not found"})
|
||||
return
|
||||
}
|
||||
} else if req.Device != "" {
|
||||
// Validate device exists
|
||||
if _, err := os.Stat(req.Device); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": fmt.Sprintf("device not found: %s", req.Device)})
|
||||
return
|
||||
}
|
||||
log.Printf("stat device error: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to validate device"})
|
||||
return
|
||||
}
|
||||
// For tape devices, size is typically 0 or unknown
|
||||
zvolSize = 0
|
||||
}
|
||||
|
||||
lun, err := a.iscsiStore.AddLUN(id, req.ZVOL, zvolSize)
|
||||
// Use updated AddLUN signature that supports device and backstore
|
||||
lun, err := a.iscsiStore.AddLUNWithDevice(id, zvolName, req.Device, zvolSize, req.Backstore, req.BackstoreName)
|
||||
if err != nil {
|
||||
if err == storage.ErrISCSITargetNotFound {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "target not found"})
|
||||
@@ -1804,3 +1914,326 @@ func (a *App) syncNFSExportsFromOS() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncSMBSharesFromOS syncs SMB shares from /etc/samba/smb.conf to the store
|
||||
func (a *App) syncSMBSharesFromOS() error {
|
||||
configPath := "/etc/samba/smb.conf"
|
||||
cmd := exec.Command("sudo", "-n", "cat", configPath)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// If can't read smb.conf, that's okay - might not exist yet
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
currentShare := ""
|
||||
inShareSection := false
|
||||
sharePath := ""
|
||||
shareReadOnly := false
|
||||
shareGuestOK := false
|
||||
shareDescription := ""
|
||||
shareValidUsers := []string{}
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a share section
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
// Save previous share if exists
|
||||
if inShareSection && currentShare != "" && sharePath != "" {
|
||||
// Try to find corresponding dataset
|
||||
datasets, err := a.zfs.ListDatasets("")
|
||||
var dataset string
|
||||
if err == nil {
|
||||
for _, ds := range datasets {
|
||||
if ds.Mountpoint == sharePath {
|
||||
dataset = ds.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if share already exists
|
||||
existingShares := a.smbStore.List()
|
||||
exists := false
|
||||
for _, share := range existingShares {
|
||||
if share.Name == currentShare || share.Path == sharePath {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
_, err = a.smbStore.Create(currentShare, sharePath, dataset, shareDescription, shareReadOnly, shareGuestOK, shareValidUsers)
|
||||
if err != nil && err != storage.ErrSMBShareExists {
|
||||
log.Printf("warning: failed to sync SMB share %s: %v", currentShare, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start new share section
|
||||
shareName := strings.Trim(line, "[]")
|
||||
if shareName != "global" && shareName != "printers" && shareName != "print$" {
|
||||
currentShare = shareName
|
||||
inShareSection = true
|
||||
sharePath = ""
|
||||
shareReadOnly = false
|
||||
shareGuestOK = false
|
||||
shareDescription = ""
|
||||
shareValidUsers = []string{}
|
||||
} else {
|
||||
inShareSection = false
|
||||
currentShare = ""
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse share properties
|
||||
if inShareSection && currentShare != "" {
|
||||
if strings.HasPrefix(line, "path = ") {
|
||||
sharePath = strings.TrimSpace(strings.TrimPrefix(line, "path = "))
|
||||
} else if strings.HasPrefix(line, "read only = ") {
|
||||
value := strings.TrimSpace(strings.TrimPrefix(line, "read only = "))
|
||||
shareReadOnly = (value == "yes" || value == "true")
|
||||
} else if strings.HasPrefix(line, "guest ok = ") {
|
||||
value := strings.TrimSpace(strings.TrimPrefix(line, "guest ok = "))
|
||||
shareGuestOK = (value == "yes" || value == "true")
|
||||
} else if strings.HasPrefix(line, "comment = ") {
|
||||
shareDescription = strings.TrimSpace(strings.TrimPrefix(line, "comment = "))
|
||||
} else if strings.HasPrefix(line, "valid users = ") {
|
||||
usersStr := strings.TrimSpace(strings.TrimPrefix(line, "valid users = "))
|
||||
shareValidUsers = strings.Split(usersStr, ",")
|
||||
for i := range shareValidUsers {
|
||||
shareValidUsers[i] = strings.TrimSpace(shareValidUsers[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save last share if exists
|
||||
if inShareSection && currentShare != "" && sharePath != "" {
|
||||
datasets, err := a.zfs.ListDatasets("")
|
||||
var dataset string
|
||||
if err == nil {
|
||||
for _, ds := range datasets {
|
||||
if ds.Mountpoint == sharePath {
|
||||
dataset = ds.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
existingShares := a.smbStore.List()
|
||||
exists := false
|
||||
for _, share := range existingShares {
|
||||
if share.Name == currentShare || share.Path == sharePath {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
_, err = a.smbStore.Create(currentShare, sharePath, dataset, shareDescription, shareReadOnly, shareGuestOK, shareValidUsers)
|
||||
if err != nil && err != storage.ErrSMBShareExists {
|
||||
log.Printf("warning: failed to sync SMB share %s: %v", currentShare, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncISCSITargetsFromOS syncs iSCSI targets from targetcli to the store
|
||||
func (a *App) syncISCSITargetsFromOS() error {
|
||||
log.Printf("debug: starting syncISCSITargetsFromOS")
|
||||
// Get list of targets from targetcli
|
||||
// Set TARGETCLI_HOME and TARGETCLI_LOCK_DIR to writable directories
|
||||
// Create the directories first if they don't exist
|
||||
os.MkdirAll("/tmp/.targetcli", 0755)
|
||||
os.MkdirAll("/tmp/targetcli-run", 0755)
|
||||
// Use sudo to run as root, then set environment variables in the command
|
||||
cmd := exec.Command("sh", "-c", "sudo -n sh -c 'TARGETCLI_HOME=/tmp/.targetcli TARGETCLI_LOCK_DIR=/tmp/targetcli-run targetcli /iscsi ls'")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Log the error but don't fail - targetcli might not be configured
|
||||
log.Printf("warning: failed to list iSCSI targets from targetcli: %v (output: %s)", err, string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Printf("debug: targetcli output: %s", string(output))
|
||||
lines := strings.Split(string(output), "\n")
|
||||
var currentIQN string
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a target line (starts with "o- iqn.")
|
||||
if strings.HasPrefix(line, "o- iqn.") {
|
||||
log.Printf("debug: found target line: %s", line)
|
||||
// Extract IQN from line like "o- iqn.2025-12.com.atlas:target-1"
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
currentIQN = parts[1]
|
||||
|
||||
// Check if target already exists in store
|
||||
existingTargets := a.iscsiStore.List()
|
||||
exists := false
|
||||
for _, t := range existingTargets {
|
||||
if t.IQN == currentIQN {
|
||||
exists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !exists {
|
||||
// Try to determine target type from IQN
|
||||
targetType := models.ISCSITargetTypeDisk // Default to disk mode
|
||||
if strings.Contains(strings.ToLower(currentIQN), "tape") {
|
||||
targetType = models.ISCSITargetTypeTape
|
||||
}
|
||||
|
||||
// Create target in store
|
||||
target, err := a.iscsiStore.CreateWithType(currentIQN, targetType, []string{})
|
||||
if err != nil && err != storage.ErrISCSITargetExists {
|
||||
log.Printf("warning: failed to sync iSCSI target %s: %v", currentIQN, err)
|
||||
} else if err == nil {
|
||||
log.Printf("synced iSCSI target from OS: %s (type: %s)", currentIQN, targetType)
|
||||
|
||||
// Now try to sync LUNs for this target
|
||||
if err := a.syncLUNsFromOS(currentIQN, target.ID, targetType); err != nil {
|
||||
log.Printf("warning: failed to sync LUNs for target %s: %v", currentIQN, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncLUNsFromOS syncs LUNs for a specific target from targetcli
|
||||
func (a *App) syncLUNsFromOS(iqn, targetID string, targetType models.ISCSITargetType) error {
|
||||
// Get LUNs for this target
|
||||
// Use sudo to run as root, then set environment variables in the command
|
||||
cmd := exec.Command("sh", "-c", "sudo -n sh -c 'TARGETCLI_HOME=/tmp/.targetcli TARGETCLI_LOCK_DIR=/tmp/targetcli-run targetcli /iscsi/"+iqn+"/tpg1/luns ls'")
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// No LUNs or can't read - that's okay, log for debugging
|
||||
log.Printf("debug: failed to list LUNs for target %s: %v (output: %s)", iqn, err, string(output))
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.Split(string(output), "\n")
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "o- lun") {
|
||||
// Parse LUN line like "o- lun0 ....................................... [block/pool-test-02-vol01 (/dev/zvol/pool-test-02/vol01) (default_tg_pt_gp)]"
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 {
|
||||
// Extract LUN ID from "lun0"
|
||||
lunIDStr := strings.TrimPrefix(parts[1], "lun")
|
||||
lunID, err := strconv.Atoi(lunIDStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract backstore path and device from the line
|
||||
var backstorePath string
|
||||
var devicePath string
|
||||
var zvolName string
|
||||
|
||||
// Find the part with brackets - might span multiple parts
|
||||
fullLine := strings.Join(parts, " ")
|
||||
start := strings.Index(fullLine, "[")
|
||||
end := strings.LastIndex(fullLine, "]")
|
||||
if start >= 0 && end > start {
|
||||
content := fullLine[start+1 : end]
|
||||
// Parse content like "block/pool-test-02-vol01 (/dev/zvol/pool-test-02/vol01)"
|
||||
if strings.Contains(content, "(") {
|
||||
// Has device path
|
||||
parts2 := strings.Split(content, "(")
|
||||
if len(parts2) >= 2 {
|
||||
backstorePath = strings.TrimSpace(parts2[0])
|
||||
devicePath = strings.Trim(strings.TrimSpace(parts2[1]), "()")
|
||||
|
||||
// If device is a zvol, extract ZVOL name
|
||||
if strings.HasPrefix(devicePath, "/dev/zvol/") {
|
||||
zvolName = strings.TrimPrefix(devicePath, "/dev/zvol/")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
backstorePath = content
|
||||
}
|
||||
}
|
||||
|
||||
// Check if LUN already exists
|
||||
target, err := a.iscsiStore.Get(targetID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
lunExists := false
|
||||
for _, lun := range target.LUNs {
|
||||
if lun.ID == lunID {
|
||||
lunExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !lunExists {
|
||||
// Determine backstore type
|
||||
backstoreType := "block"
|
||||
if strings.HasPrefix(backstorePath, "pscsi/") {
|
||||
backstoreType = "pscsi"
|
||||
} else if strings.HasPrefix(backstorePath, "fileio/") {
|
||||
backstoreType = "fileio"
|
||||
}
|
||||
|
||||
// Get size if it's a ZVOL
|
||||
var size uint64
|
||||
if zvolName != "" {
|
||||
zvols, err := a.zfs.ListZVOLs("")
|
||||
if err == nil {
|
||||
for _, zvol := range zvols {
|
||||
if zvol.Name == zvolName {
|
||||
size = zvol.Size
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add LUN to store
|
||||
if targetType == models.ISCSITargetTypeTape && devicePath != "" {
|
||||
// Tape mode: use device
|
||||
_, err := a.iscsiStore.AddLUNWithDevice(targetID, "", devicePath, size, backstoreType, "")
|
||||
if err != nil && err != storage.ErrLUNExists {
|
||||
log.Printf("warning: failed to sync LUN %d for target %s: %v", lunID, iqn, err)
|
||||
}
|
||||
} else if zvolName != "" {
|
||||
// Disk mode: use ZVOL
|
||||
_, err := a.iscsiStore.AddLUNWithDevice(targetID, zvolName, "", size, backstoreType, "")
|
||||
if err != nil && err != storage.ErrLUNExists {
|
||||
log.Printf("warning: failed to sync LUN %d for target %s: %v", lunID, iqn, err)
|
||||
}
|
||||
} else if devicePath != "" {
|
||||
// Generic device
|
||||
_, err := a.iscsiStore.AddLUNWithDevice(targetID, "", devicePath, size, backstoreType, "")
|
||||
if err != nil && err != storage.ErrLUNExists {
|
||||
log.Printf("warning: failed to sync LUN %d for target %s: %v", lunID, iqn, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package httpapp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -64,6 +65,14 @@ func (a *App) handleDashboardAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Service statistics
|
||||
// Sync from OS first to ensure accurate counts
|
||||
if err := a.syncSMBSharesFromOS(); err != nil {
|
||||
log.Printf("warning: failed to sync SMB shares from OS in dashboard: %v", err)
|
||||
}
|
||||
if err := a.syncNFSExportsFromOS(); err != nil {
|
||||
log.Printf("warning: failed to sync NFS exports from OS in dashboard: %v", err)
|
||||
}
|
||||
|
||||
smbShares := a.smbStore.List()
|
||||
data.Services.SMBShares = len(smbShares)
|
||||
|
||||
|
||||
@@ -104,6 +104,15 @@ func (a *App) handlePoolOps(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasSuffix(r.URL.Path, "/spare") {
|
||||
if r.Method == http.MethodPost {
|
||||
a.handleAddSpareDisk(w, r)
|
||||
} else {
|
||||
writeError(w, errors.NewAPIError(errors.ErrCodeBadRequest, "method not allowed", http.StatusMethodNotAllowed))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
methodHandler(
|
||||
func(w http.ResponseWriter, r *http.Request) { a.handleGetPool(w, r) },
|
||||
nil,
|
||||
|
||||
@@ -24,19 +24,31 @@ type NFSExport struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// ISCSITargetType represents the type of iSCSI target
|
||||
type ISCSITargetType string
|
||||
|
||||
const (
|
||||
ISCSITargetTypeDisk ISCSITargetType = "disk" // For ZVOL/block devices
|
||||
ISCSITargetTypeTape ISCSITargetType = "tape" // For tape library passthrough
|
||||
)
|
||||
|
||||
// ISCSITarget represents an iSCSI target
|
||||
type ISCSITarget struct {
|
||||
ID string `json:"id"`
|
||||
IQN string `json:"iqn"` // iSCSI Qualified Name
|
||||
Type ISCSITargetType `json:"type"` // "disk" or "tape"
|
||||
LUNs []LUN `json:"luns"`
|
||||
Initiators []string `json:"initiators"` // ACL list
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// LUN represents a Logical Unit Number backed by a ZVOL
|
||||
// LUN represents a Logical Unit Number backed by various storage types
|
||||
type LUN struct {
|
||||
ID int `json:"id"` // LUN number
|
||||
ZVOL string `json:"zvol"` // ZVOL name
|
||||
Size uint64 `json:"size"` // bytes
|
||||
Backend string `json:"backend"` // "zvol"
|
||||
ZVOL string `json:"zvol"` // ZVOL name (for block backstore)
|
||||
Device string `json:"device"` // Device path (e.g., /dev/st0 for tape, /dev/sdX for disk)
|
||||
Size uint64 `json:"size"` // bytes (0 for unknown/tape devices)
|
||||
Backend string `json:"backend"` // "zvol", "block", "pscsi", "fileio"
|
||||
Backstore string `json:"backstore"` // Backstore type: "block", "pscsi", "fileio" (default: "block")
|
||||
BackstoreName string `json:"backstore_name"` // Name used in targetcli
|
||||
}
|
||||
|
||||
@@ -13,6 +13,37 @@ type Pool struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PoolDetail represents detailed pool information from zpool status
|
||||
type PoolDetail struct {
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"` // ONLINE, DEGRADED, FAULTED
|
||||
Status string `json:"status"` // Full status message
|
||||
VDEVs []VDEV `json:"vdevs"` // Virtual devices
|
||||
Spares []string `json:"spares"` // Spare disks
|
||||
Errors string `json:"errors"` // Error summary
|
||||
ScrubInfo string `json:"scrub_info"` // Scrub information
|
||||
}
|
||||
|
||||
// VDEV represents a virtual device in a pool
|
||||
type VDEV struct {
|
||||
Name string `json:"name"` // VDEV name or type
|
||||
Type string `json:"type"` // mirror, raidz, raidz2, etc.
|
||||
State string `json:"state"` // ONLINE, DEGRADED, etc.
|
||||
Disks []Disk `json:"disks"` // Disks in this VDEV
|
||||
Read int `json:"read"` // Read errors
|
||||
Write int `json:"write"` // Write errors
|
||||
Checksum int `json:"checksum"` // Checksum errors
|
||||
}
|
||||
|
||||
// Disk represents a disk in a VDEV
|
||||
type Disk struct {
|
||||
Name string `json:"name"` // Disk name (e.g., sdb)
|
||||
State string `json:"state"` // ONLINE, DEGRADED, FAULTED, etc.
|
||||
Read int `json:"read"` // Read errors
|
||||
Write int `json:"write"` // Write errors
|
||||
Checksum int `json:"checksum"` // Checksum errors
|
||||
}
|
||||
|
||||
// Dataset represents a ZFS filesystem
|
||||
type Dataset struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -41,6 +42,7 @@ func (s *ISCSIService) ApplyConfiguration(targets []models.ISCSITarget) error {
|
||||
// Disable target if it exists
|
||||
if err := s.disableTarget(target.IQN); err != nil {
|
||||
// Log but continue
|
||||
fmt.Printf("warning: failed to disable target %s: %v\n", target.IQN, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
@@ -49,16 +51,19 @@ func (s *ISCSIService) ApplyConfiguration(targets []models.ISCSITarget) error {
|
||||
if err := s.createTarget(target); err != nil {
|
||||
return fmt.Errorf("create target %s: %w", target.IQN, err)
|
||||
}
|
||||
fmt.Printf("iSCSI target created/verified: %s\n", target.IQN)
|
||||
|
||||
// Configure ACLs
|
||||
if err := s.configureACLs(target); err != nil {
|
||||
return fmt.Errorf("configure ACLs for %s: %w", target.IQN, err)
|
||||
}
|
||||
fmt.Printf("iSCSI ACLs configured for: %s\n", target.IQN)
|
||||
|
||||
// Configure LUNs
|
||||
if err := s.configureLUNs(target); err != nil {
|
||||
return fmt.Errorf("configure LUNs for %s: %w", target.IQN, err)
|
||||
}
|
||||
fmt.Printf("iSCSI LUNs configured for: %s\n", target.IQN)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -68,27 +73,93 @@ func (s *ISCSIService) ApplyConfiguration(targets []models.ISCSITarget) error {
|
||||
func (s *ISCSIService) createTarget(target models.ISCSITarget) error {
|
||||
// Use targetcli to create target
|
||||
// Format: targetcli /iscsi create <IQN>
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi", "create", target.IQN)
|
||||
if err := cmd.Run(); err != nil {
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi", "create", target.IQN)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
// Target might already exist, which is OK
|
||||
// Check if it actually exists
|
||||
if !s.targetExists(target.IQN) {
|
||||
return fmt.Errorf("create target failed: %w", err)
|
||||
return fmt.Errorf("create target failed: %w (output: %s)", err, string(output))
|
||||
}
|
||||
fmt.Printf("target %s already exists, continuing\n", target.IQN)
|
||||
} else {
|
||||
fmt.Printf("target %s created successfully\n", target.IQN)
|
||||
}
|
||||
|
||||
// Enable TPG1 (Target Portal Group 1)
|
||||
// Disable authentication
|
||||
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "authentication=0")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
fmt.Printf("warning: failed to set authentication=0: %v (output: %s)\n", err, string(output))
|
||||
}
|
||||
|
||||
// Enable generate_node_acls (allow all initiators if no ACLs specified)
|
||||
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "generate_node_acls=1")
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
fmt.Printf("warning: failed to set generate_node_acls=1: %v (output: %s)\n", err, string(output))
|
||||
} else {
|
||||
fmt.Printf("set generate_node_acls=1 for target %s\n", target.IQN)
|
||||
}
|
||||
|
||||
// Create portal if not exists (listen on all interfaces, port 3260)
|
||||
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/portals", "create")
|
||||
if err := cmd.Run(); err != nil {
|
||||
// Portal might already exist, which is OK
|
||||
// Check if portal exists
|
||||
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/portals", "ls")
|
||||
output, err2 := cmd.Output()
|
||||
if err2 != nil || len(strings.TrimSpace(string(output))) == 0 {
|
||||
// No portal exists, try to create with specific IP
|
||||
// Get system IP
|
||||
systemIP, _ := s.getSystemIP()
|
||||
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/portals", "create", systemIP)
|
||||
if err3 := cmd.Run(); err3 != nil {
|
||||
// Try with 0.0.0.0 (all interfaces)
|
||||
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/portals", "create", "0.0.0.0")
|
||||
if err4 := cmd.Run(); err4 != nil {
|
||||
// Log but don't fail - portal might already exist
|
||||
fmt.Printf("warning: failed to create portal: %v", err4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save configuration
|
||||
cmd = exec.Command("sudo", "-n", s.targetcliPath, "saveconfig")
|
||||
cmd.Run() // Ignore errors
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// configureACLs configures initiator ACLs for a target
|
||||
func (s *ISCSIService) configureACLs(target models.ISCSITarget) error {
|
||||
// If no initiators specified, allow all (generate_node_acls=1)
|
||||
if len(target.Initiators) == 0 {
|
||||
// Set to allow all initiators
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "generate_node_acls=1")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("set generate_node_acls: %w", err)
|
||||
}
|
||||
// Disable authentication for open access
|
||||
cmd = exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "authentication=0")
|
||||
cmd.Run() // Ignore errors
|
||||
return nil
|
||||
}
|
||||
|
||||
// If initiators specified, use ACL-based access
|
||||
// Set generate_node_acls=0 to use explicit ACLs
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1", "set", "attribute", "generate_node_acls=0")
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("set generate_node_acls=0: %w", err)
|
||||
}
|
||||
|
||||
// Get current ACLs
|
||||
currentACLs, _ := s.getACLs(target.IQN)
|
||||
|
||||
// Remove ACLs not in desired list
|
||||
for _, acl := range currentACLs {
|
||||
if !contains(target.Initiators, acl) {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/acls", "delete", acl)
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/acls", "delete", acl)
|
||||
cmd.Run() // Ignore errors
|
||||
}
|
||||
}
|
||||
@@ -96,7 +167,7 @@ func (s *ISCSIService) configureACLs(target models.ISCSITarget) error {
|
||||
// Add new ACLs
|
||||
for _, initiator := range target.Initiators {
|
||||
if !contains(currentACLs, initiator) {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/acls", "create", initiator)
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/acls", "create", initiator)
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("create ACL %s: %w", initiator, err)
|
||||
}
|
||||
@@ -114,23 +185,100 @@ func (s *ISCSIService) configureLUNs(target models.ISCSITarget) error {
|
||||
// Remove LUNs not in desired list
|
||||
for _, lun := range currentLUNs {
|
||||
if !s.hasLUN(target.LUNs, lun) {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/luns", "delete", fmt.Sprintf("lun/%d", lun))
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/luns", "delete", fmt.Sprintf("lun/%d", lun))
|
||||
cmd.Run() // Ignore errors
|
||||
}
|
||||
}
|
||||
|
||||
// Add/update LUNs
|
||||
for _, lun := range target.LUNs {
|
||||
// Create LUN mapping
|
||||
// Format: targetcli /iscsi/<IQN>/tpg1/luns create /backstores/zvol/<zvol>
|
||||
zvolPath := "/backstores/zvol/" + lun.ZVOL
|
||||
// Determine backstore type (default to block for ZVOL, pscsi for tape devices)
|
||||
backstoreType := lun.Backstore
|
||||
if backstoreType == "" {
|
||||
if lun.Device != "" {
|
||||
// If device is specified and looks like tape device, use pscsi
|
||||
if strings.HasPrefix(lun.Device, "/dev/st") || strings.HasPrefix(lun.Device, "/dev/nst") {
|
||||
backstoreType = "pscsi"
|
||||
} else {
|
||||
backstoreType = "block"
|
||||
}
|
||||
} else if lun.ZVOL != "" {
|
||||
// Default to block for ZVOL
|
||||
backstoreType = "block"
|
||||
} else {
|
||||
return fmt.Errorf("LUN must have either ZVOL or Device specified")
|
||||
}
|
||||
}
|
||||
|
||||
// First ensure the zvol backend exists
|
||||
cmd := exec.Command(s.targetcliPath, "/backstores/zvol", "create", lun.ZVOL, lun.ZVOL)
|
||||
cmd.Run() // Ignore if already exists
|
||||
// Determine backstore name
|
||||
backstoreName := lun.BackstoreName
|
||||
if backstoreName == "" {
|
||||
if lun.ZVOL != "" {
|
||||
backstoreName = strings.ReplaceAll(lun.ZVOL, "/", "-")
|
||||
} else if lun.Device != "" {
|
||||
// Use device name (e.g., st0, sdb)
|
||||
backstoreName = strings.TrimPrefix(strings.TrimPrefix(lun.Device, "/dev/"), "/dev/")
|
||||
backstoreName = strings.ReplaceAll(backstoreName, "/", "-")
|
||||
} else {
|
||||
backstoreName = fmt.Sprintf("lun-%d", lun.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Determine device path
|
||||
var devicePath string
|
||||
if lun.Device != "" {
|
||||
devicePath = lun.Device
|
||||
} else if lun.ZVOL != "" {
|
||||
devicePath = "/dev/zvol/" + lun.ZVOL
|
||||
} else {
|
||||
return fmt.Errorf("LUN must have either ZVOL or Device specified")
|
||||
}
|
||||
|
||||
backstorePath := "/backstores/" + backstoreType + "/" + backstoreName
|
||||
|
||||
// Create backstore based on type
|
||||
switch backstoreType {
|
||||
case "block":
|
||||
// Format: targetcli /backstores/block create name=<name> dev=<dev>
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/backstores/block", "create", "name="+backstoreName, "dev="+devicePath)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
if !strings.Contains(string(output), "already exists") {
|
||||
fmt.Printf("warning: failed to create block backstore %s: %v (output: %s)\n", backstoreName, err, string(output))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("created block backstore %s for %s\n", backstoreName, devicePath)
|
||||
}
|
||||
|
||||
case "pscsi":
|
||||
// Format: targetcli /backstores/pscsi create name=<name> dev=<dev>
|
||||
// pscsi is for SCSI pass-through (tape devices, etc.)
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/backstores/pscsi", "create", "name="+backstoreName, "dev="+devicePath)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
if !strings.Contains(string(output), "already exists") {
|
||||
return fmt.Errorf("failed to create pscsi backstore %s: %w (output: %s)", backstoreName, err, string(output))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("created pscsi backstore %s for %s\n", backstoreName, devicePath)
|
||||
}
|
||||
|
||||
case "fileio":
|
||||
// Format: targetcli /backstores/fileio create name=<name> file_or_dev=<path> [size=<size>]
|
||||
// fileio is for file-based storage
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/backstores/fileio", "create", "name="+backstoreName, "file_or_dev="+devicePath)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
if !strings.Contains(string(output), "already exists") {
|
||||
return fmt.Errorf("failed to create fileio backstore %s: %w (output: %s)", backstoreName, err, string(output))
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("created fileio backstore %s for %s\n", backstoreName, devicePath)
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported backstore type: %s", backstoreType)
|
||||
}
|
||||
|
||||
// Create LUN
|
||||
cmd = exec.Command(s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/luns", "create", zvolPath)
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+target.IQN+"/tpg1/luns", "create", backstorePath)
|
||||
if err := cmd.Run(); err != nil {
|
||||
// LUN might already exist
|
||||
if !s.hasLUNID(currentLUNs, lun.ID) {
|
||||
@@ -144,7 +292,7 @@ func (s *ISCSIService) configureLUNs(target models.ISCSITarget) error {
|
||||
|
||||
// Helper functions
|
||||
func (s *ISCSIService) targetExists(iqn string) bool {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi", "ls")
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi", "ls")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return false
|
||||
@@ -153,25 +301,56 @@ func (s *ISCSIService) targetExists(iqn string) bool {
|
||||
}
|
||||
|
||||
func (s *ISCSIService) getACLs(iqn string) ([]string, error) {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+iqn+"/tpg1/acls", "ls")
|
||||
_, err := cmd.Output()
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+iqn+"/tpg1/acls", "ls")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return []string{}, nil // Return empty if can't get ACLs
|
||||
}
|
||||
|
||||
// Parse output to extract ACL names
|
||||
// This is simplified - real implementation would parse targetcli output
|
||||
return []string{}, nil
|
||||
// Format: o- acls ................................................................................................ [ACLs: 1]
|
||||
// o- iqn.1994-05.com.redhat:client1 ........................................................ [Mapped LUNs: 1]
|
||||
lines := strings.Split(string(output), "\n")
|
||||
acls := []string{}
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "o- iqn.") {
|
||||
// Extract IQN from line like "o- iqn.1994-05.com.redhat:client1"
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 && strings.HasPrefix(parts[1], "iqn.") {
|
||||
acls = append(acls, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return acls, nil
|
||||
}
|
||||
|
||||
func (s *ISCSIService) getLUNs(iqn string) ([]int, error) {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+iqn+"/tpg1/luns", "ls")
|
||||
_, err := cmd.Output()
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+iqn+"/tpg1/luns", "ls")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return []int{}, nil // Return empty if can't get LUNs
|
||||
}
|
||||
|
||||
// Parse output to extract LUN IDs
|
||||
// This is simplified - real implementation would parse targetcli output
|
||||
return []int{}, nil
|
||||
// Format: o- luns ................................................................................................ [LUNs: 1]
|
||||
// o- lun0 ................................................................................ [zvol/pool-test-02/vol-1(/dev/zvol/pool-test-02/vol-1)]
|
||||
lines := strings.Split(string(output), "\n")
|
||||
luns := []int{}
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "o- lun") {
|
||||
// Extract LUN number from line like "o- lun0"
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) >= 2 && strings.HasPrefix(parts[1], "lun") {
|
||||
lunIDStr := strings.TrimPrefix(parts[1], "lun")
|
||||
if lunID, err := strconv.Atoi(lunIDStr); err == nil {
|
||||
luns = append(luns, lunID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return luns, nil
|
||||
}
|
||||
|
||||
func (s *ISCSIService) hasLUN(luns []models.LUN, id int) bool {
|
||||
@@ -193,7 +372,7 @@ func (s *ISCSIService) hasLUNID(luns []int, id int) bool {
|
||||
}
|
||||
|
||||
func (s *ISCSIService) disableTarget(iqn string) error {
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi/"+iqn+"/tpg1", "set", "attribute", "enable=0")
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi/"+iqn+"/tpg1", "set", "attribute", "enable=0")
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
@@ -326,7 +505,7 @@ func (s *ISCSIService) GetConnectionInstructions(target models.ISCSITarget, port
|
||||
// GetPortalIP attempts to detect the portal IP address
|
||||
func (s *ISCSIService) GetPortalIP() (string, error) {
|
||||
// Try to get IP from targetcli
|
||||
cmd := exec.Command(s.targetcliPath, "/iscsi", "ls")
|
||||
cmd := exec.Command("sudo", "-n", s.targetcliPath, "/iscsi", "ls")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// Fallback: try to get system IP
|
||||
|
||||
@@ -3,6 +3,7 @@ package storage
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"gitea.avt.data-center.id/othman.suseno/atlas/internal/models"
|
||||
@@ -69,6 +70,12 @@ func (s *ISCSIStore) GetByIQN(iqn string) (*models.ISCSITarget, error) {
|
||||
|
||||
// Create creates a new iSCSI target
|
||||
func (s *ISCSIStore) Create(iqn string, initiators []string) (*models.ISCSITarget, error) {
|
||||
// Default to disk mode for backward compatibility
|
||||
return s.CreateWithType(iqn, models.ISCSITargetTypeDisk, initiators)
|
||||
}
|
||||
|
||||
// CreateWithType creates a new iSCSI target with specified type
|
||||
func (s *ISCSIStore) CreateWithType(iqn string, targetType models.ISCSITargetType, initiators []string) (*models.ISCSITarget, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -85,6 +92,7 @@ func (s *ISCSIStore) Create(iqn string, initiators []string) (*models.ISCSITarge
|
||||
target := &models.ISCSITarget{
|
||||
ID: id,
|
||||
IQN: iqn,
|
||||
Type: targetType,
|
||||
LUNs: []models.LUN{},
|
||||
Initiators: initiators,
|
||||
Enabled: true,
|
||||
@@ -155,6 +163,60 @@ func (s *ISCSIStore) AddLUN(targetID string, zvol string, size uint64) (*models.
|
||||
ZVOL: zvol,
|
||||
Size: size,
|
||||
Backend: "zvol",
|
||||
Backstore: "block", // Default to block for ZVOL
|
||||
}
|
||||
|
||||
target.LUNs = append(target.LUNs, lun)
|
||||
return &lun, nil
|
||||
}
|
||||
|
||||
// AddLUNWithDevice adds a LUN to a target with support for device and backstore type
|
||||
func (s *ISCSIStore) AddLUNWithDevice(targetID string, zvol string, device string, size uint64, backstore string, backstoreName string) (*models.LUN, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
target, ok := s.targets[targetID]
|
||||
if !ok {
|
||||
return nil, ErrISCSITargetNotFound
|
||||
}
|
||||
|
||||
// Check if device/ZVOL already mapped
|
||||
for _, lun := range target.LUNs {
|
||||
if (zvol != "" && lun.ZVOL == zvol) || (device != "" && lun.Device == device) {
|
||||
return nil, ErrLUNExists
|
||||
}
|
||||
}
|
||||
|
||||
// Find next available LUN ID
|
||||
lunID := 0
|
||||
for _, lun := range target.LUNs {
|
||||
if lun.ID >= lunID {
|
||||
lunID = lun.ID + 1
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect backstore type if not specified
|
||||
if backstore == "" {
|
||||
if device != "" {
|
||||
// If device is specified and looks like tape device, use pscsi
|
||||
if strings.HasPrefix(device, "/dev/st") || strings.HasPrefix(device, "/dev/nst") {
|
||||
backstore = "pscsi"
|
||||
} else {
|
||||
backstore = "block"
|
||||
}
|
||||
} else if zvol != "" {
|
||||
backstore = "block"
|
||||
}
|
||||
}
|
||||
|
||||
lun := models.LUN{
|
||||
ID: lunID,
|
||||
ZVOL: zvol,
|
||||
Device: device,
|
||||
Size: size,
|
||||
Backend: "zvol", // Keep for backward compatibility
|
||||
Backstore: backstore,
|
||||
BackstoreName: backstoreName,
|
||||
}
|
||||
|
||||
target.LUNs = append(target.LUNs, lun)
|
||||
|
||||
@@ -19,7 +19,9 @@ var (
|
||||
shareNamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_\-\.]{0,79}$`)
|
||||
|
||||
// IQN pattern (simplified - iqn.yyyy-mm.reversed.domain:identifier)
|
||||
iqnPattern = regexp.MustCompile(`^iqn\.\d{4}-\d{2}\.[a-zA-Z0-9][a-zA-Z0-9\-\.]*:[a-zA-Z0-9][a-zA-Z0-9\-_\.]*$`)
|
||||
// Domain must have at least 2 levels (e.g., com.atlas, org.example)
|
||||
// Format: iqn.YYYY-MM.domain.subdomain:identifier
|
||||
iqnPattern = regexp.MustCompile(`^iqn\.\d{4}-\d{2}\.[a-zA-Z0-9][a-zA-Z0-9\-]*\.[a-zA-Z0-9][a-zA-Z0-9\-\.]*:[a-zA-Z0-9][a-zA-Z0-9\-_\.]*$`)
|
||||
|
||||
// Email pattern (basic)
|
||||
emailPattern = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)
|
||||
@@ -166,6 +168,8 @@ func ValidateShareName(name string) error {
|
||||
}
|
||||
|
||||
// ValidateIQN validates an iSCSI Qualified Name
|
||||
// IQN format: iqn.YYYY-MM.domain.subdomain:identifier
|
||||
// Domain must have at least 2 levels (e.g., com.atlas, org.example)
|
||||
func ValidateIQN(iqn string) error {
|
||||
if iqn == "" {
|
||||
return &ValidationError{Field: "iqn", Message: "IQN cannot be empty"}
|
||||
@@ -181,7 +185,7 @@ func ValidateIQN(iqn string) error {
|
||||
|
||||
// Basic format validation (can be more strict)
|
||||
if !iqnPattern.MatchString(iqn) {
|
||||
return &ValidationError{Field: "iqn", Message: "invalid IQN format (expected: iqn.yyyy-mm.reversed.domain:identifier)"}
|
||||
return &ValidationError{Field: "iqn", Message: "invalid IQN format (expected: iqn.YYYY-MM.domain.subdomain:identifier, domain must have at least 2 levels, e.g., com.atlas, org.example)"}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -255,6 +255,197 @@ func (s *Service) GetPool(name string) (*models.Pool, error) {
|
||||
return nil, fmt.Errorf("pool %s not found", name)
|
||||
}
|
||||
|
||||
// GetPoolDetail returns detailed pool information from zpool status
|
||||
func (s *Service) GetPoolDetail(name string) (*models.PoolDetail, error) {
|
||||
output, err := s.execCommand(s.zpoolPath, "status", name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get pool status: %w", err)
|
||||
}
|
||||
|
||||
detail := &models.PoolDetail{
|
||||
Name: name,
|
||||
VDEVs: []models.VDEV{},
|
||||
Spares: []string{},
|
||||
}
|
||||
|
||||
lines := strings.Split(output, "\n")
|
||||
var currentVDEV *models.VDEV
|
||||
inConfig := false
|
||||
inSpares := false
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
// Parse pool name and state
|
||||
if strings.HasPrefix(line, "pool:") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) > 1 {
|
||||
detail.Name = parts[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "state:") {
|
||||
parts := strings.Fields(line)
|
||||
if len(parts) > 1 {
|
||||
detail.State = parts[1]
|
||||
detail.Status = strings.Join(parts[1:], " ")
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse errors line
|
||||
if strings.HasPrefix(line, "errors:") {
|
||||
detail.Errors = strings.TrimPrefix(line, "errors:")
|
||||
detail.Errors = strings.TrimSpace(detail.Errors)
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse scrub information
|
||||
if strings.Contains(line, "scrub") {
|
||||
detail.ScrubInfo = line
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we're entering config section
|
||||
if strings.HasPrefix(line, "config:") {
|
||||
inConfig = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if we're in spares section
|
||||
if strings.Contains(line, "spares") {
|
||||
inSpares = true
|
||||
inConfig = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse VDEV and disk information
|
||||
if inConfig {
|
||||
// Check if this is a VDEV header (indented but not a disk)
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 5 {
|
||||
// Check if it's a VDEV type line (mirror, raidz, etc.)
|
||||
if fields[0] == "mirror" || strings.HasPrefix(fields[0], "raidz") || fields[0] == "log" || fields[0] == "cache" {
|
||||
// Save previous VDEV if exists
|
||||
if currentVDEV != nil {
|
||||
detail.VDEVs = append(detail.VDEVs, *currentVDEV)
|
||||
}
|
||||
// Start new VDEV
|
||||
currentVDEV = &models.VDEV{
|
||||
Name: fields[0],
|
||||
Type: fields[0],
|
||||
State: "ONLINE",
|
||||
Disks: []models.Disk{},
|
||||
}
|
||||
// Try to parse state if available
|
||||
if len(fields) > 1 {
|
||||
for _, field := range fields[1:] {
|
||||
if field == "ONLINE" || field == "DEGRADED" || field == "FAULTED" || field == "OFFLINE" {
|
||||
currentVDEV.State = field
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if it's a disk line (starts with sd, hd, nvme, etc.)
|
||||
diskName := fields[0]
|
||||
if strings.HasPrefix(diskName, "sd") || strings.HasPrefix(diskName, "hd") || strings.HasPrefix(diskName, "nvme") {
|
||||
// This is a disk
|
||||
state := "ONLINE"
|
||||
read := 0
|
||||
write := 0
|
||||
checksum := 0
|
||||
|
||||
if len(fields) > 1 {
|
||||
state = fields[1]
|
||||
}
|
||||
if len(fields) > 2 {
|
||||
if val, err := strconv.Atoi(fields[2]); err == nil {
|
||||
read = val
|
||||
}
|
||||
}
|
||||
if len(fields) > 3 {
|
||||
if val, err := strconv.Atoi(fields[3]); err == nil {
|
||||
write = val
|
||||
}
|
||||
}
|
||||
if len(fields) > 4 {
|
||||
if val, err := strconv.Atoi(fields[4]); err == nil {
|
||||
checksum = val
|
||||
}
|
||||
}
|
||||
|
||||
disk := models.Disk{
|
||||
Name: diskName,
|
||||
State: state,
|
||||
Read: read,
|
||||
Write: write,
|
||||
Checksum: checksum,
|
||||
}
|
||||
|
||||
// If we have a current VDEV, add disk to it
|
||||
if currentVDEV != nil {
|
||||
currentVDEV.Disks = append(currentVDEV.Disks, disk)
|
||||
// Update VDEV errors
|
||||
currentVDEV.Read += read
|
||||
currentVDEV.Write += write
|
||||
currentVDEV.Checksum += checksum
|
||||
} else {
|
||||
// Standalone disk, create a VDEV for it
|
||||
currentVDEV = &models.VDEV{
|
||||
Name: diskName,
|
||||
Type: "disk",
|
||||
State: state,
|
||||
Disks: []models.Disk{disk},
|
||||
Read: read,
|
||||
Write: write,
|
||||
Checksum: checksum,
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse spares section
|
||||
if inSpares {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) > 0 {
|
||||
diskName := fields[0]
|
||||
if strings.HasPrefix(diskName, "sd") || strings.HasPrefix(diskName, "hd") || strings.HasPrefix(diskName, "nvme") {
|
||||
detail.Spares = append(detail.Spares, diskName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Empty line might indicate end of section
|
||||
if line == "" && currentVDEV != nil {
|
||||
detail.VDEVs = append(detail.VDEVs, *currentVDEV)
|
||||
currentVDEV = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Save last VDEV if exists
|
||||
if currentVDEV != nil {
|
||||
detail.VDEVs = append(detail.VDEVs, *currentVDEV)
|
||||
}
|
||||
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// AddSpareDisk adds a spare disk to a pool
|
||||
func (s *Service) AddSpareDisk(poolName, diskPath string) error {
|
||||
args := []string{"add", poolName, "spare", diskPath}
|
||||
_, err := s.execCommand(s.zpoolPath, args...)
|
||||
if err != nil {
|
||||
return translateZFSError(err, "menambahkan spare disk", poolName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreatePool creates a new ZFS pool
|
||||
func (s *Service) CreatePool(name string, vdevs []string, options map[string]string) error {
|
||||
args := []string{"create"}
|
||||
@@ -1148,11 +1339,19 @@ func (s *Service) ListDisks() ([]map[string]string, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip virtual disks (ZVOLs) - these are zd* devices
|
||||
// Must check BEFORE checking dev.Type == "disk" because zd* devices
|
||||
// are reported as type "disk" by lsblk
|
||||
if strings.HasPrefix(dev.Name, "zd") {
|
||||
log.Printf("debug: skipping virtual disk %s (zd* device)", dev.Name)
|
||||
continue
|
||||
}
|
||||
|
||||
// Include all physical disks (both available and used) so we can show status
|
||||
// Only include actual disk devices (not partitions, loops, etc.)
|
||||
if dev.Type == "disk" {
|
||||
// Check if disk is used in a pool
|
||||
isUsed := usedDisks[dev.Name]
|
||||
|
||||
// Include all disks (both available and used) so we can show status
|
||||
if dev.Type == "disk" {
|
||||
disk := map[string]string{
|
||||
"name": dev.Name,
|
||||
"size": dev.Size,
|
||||
|
||||
@@ -144,7 +144,9 @@
|
||||
// Update storage stats
|
||||
document.getElementById('pool-count').textContent = data.storage.pool_count || 0;
|
||||
document.getElementById('total-capacity').textContent = formatBytes(data.storage.total_capacity || 0);
|
||||
document.getElementById('smb-shares').textContent = (data.services.smb_shares || 0) + ' / ' + (data.services.nfs_exports || 0);
|
||||
// Display total shares (SMB + NFS)
|
||||
const totalShares = (data.services.smb_shares || 0) + (data.services.nfs_exports || 0);
|
||||
document.getElementById('smb-shares').textContent = totalShares;
|
||||
document.getElementById('iscsi-targets').textContent = data.services.iscsi_targets || 0;
|
||||
|
||||
// Update service status
|
||||
|
||||
@@ -5,31 +5,71 @@
|
||||
<h1 class="text-3xl font-bold text-white mb-2">iSCSI Targets</h1>
|
||||
<p class="text-slate-400">Manage iSCSI targets and LUNs</p>
|
||||
</div>
|
||||
<button onclick="showCreateISCSIModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
|
||||
Create Target
|
||||
</div>
|
||||
|
||||
<!-- Tabs for Disk Mode and Tape Mode -->
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<div class="flex border-b border-slate-700">
|
||||
<button onclick="switchTab('disk')" id="tab-disk" class="flex-1 px-4 py-3 text-sm font-medium text-center border-b-2 border-blue-600 text-blue-400 bg-slate-800">
|
||||
Disk Mode
|
||||
</button>
|
||||
<button onclick="switchTab('tape')" id="tab-tape" class="flex-1 px-4 py-3 text-sm font-medium text-center border-b-2 border-transparent text-slate-400 hover:text-white">
|
||||
Tape Library Passthrough
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 overflow-hidden">
|
||||
<!-- Disk Mode Content -->
|
||||
<div id="content-disk" class="tab-content">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<h2 class="text-lg font-semibold text-white">iSCSI Targets</h2>
|
||||
<button onclick="loadISCSITargets()" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Disk Mode Targets</h2>
|
||||
<p class="text-xs text-slate-400 mt-1">For ZVOL and block devices</p>
|
||||
</div>
|
||||
<div id="iscsi-targets-list" class="p-4">
|
||||
<div class="flex gap-2">
|
||||
<button onclick="loadISCSITargets('disk')" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
<button onclick="showCreateISCSIModal('disk')" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium">
|
||||
Create Disk Target
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="iscsi-targets-list-disk" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tape Mode Content -->
|
||||
<div id="content-tape" class="tab-content hidden">
|
||||
<div class="p-4 border-b border-slate-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-white">Tape Library Passthrough Targets</h2>
|
||||
<p class="text-xs text-slate-400 mt-1">For tape devices (/dev/st*, /dev/nst*)</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="loadISCSITargets('tape')" class="text-sm text-slate-400 hover:text-white">Refresh</button>
|
||||
<button onclick="showCreateISCSIModal('tape')" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg text-sm font-medium">
|
||||
Create Tape Target
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="iscsi-targets-list-tape" class="p-4">
|
||||
<p class="text-slate-400 text-sm">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create iSCSI Target Modal -->
|
||||
<div id="create-iscsi-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Create iSCSI Target</h3>
|
||||
<h3 id="create-iscsi-modal-title" class="text-xl font-semibold text-white mb-4">Create iSCSI Target</h3>
|
||||
<form id="create-iscsi-form" onsubmit="createISCSITarget(event)" class="space-y-4">
|
||||
<input type="hidden" name="type" id="create-iscsi-type" value="disk">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">IQN</label>
|
||||
<input type="text" name="iqn" placeholder="iqn.2024-12.com.atlas:target1" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<p class="text-xs text-slate-400 mt-1">iSCSI Qualified Name</p>
|
||||
<input type="text" name="iqn" id="iqn-input" placeholder="iqn.2025-12.com.atlas:target-1" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<p class="text-xs text-slate-400 mt-1">iSCSI Qualified Name (format: iqn.YYYY-MM.domain.subdomain:identifier)</p>
|
||||
<p class="text-xs text-red-400 mt-1">⚠️ Important: Domain must have at least 2 levels (e.g., com.atlas, org.example)</p>
|
||||
<p class="text-xs text-slate-500 mt-1">Example: iqn.2025-12.com.atlas:target-1 (correct) | iqn.2025-12.atlas:target-1 (wrong - domain too short)</p>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('create-iscsi-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
@@ -99,24 +139,56 @@ function formatBytes(bytes) {
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
async function loadISCSITargets() {
|
||||
// Tab switching
|
||||
let currentTab = 'disk';
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
|
||||
// Update tab buttons
|
||||
document.getElementById('tab-disk').classList.toggle('border-blue-600', tab === 'disk');
|
||||
document.getElementById('tab-disk').classList.toggle('text-blue-400', tab === 'disk');
|
||||
document.getElementById('tab-disk').classList.toggle('border-transparent', tab !== 'disk');
|
||||
document.getElementById('tab-disk').classList.toggle('text-slate-400', tab !== 'disk');
|
||||
|
||||
document.getElementById('tab-tape').classList.toggle('border-green-600', tab === 'tape');
|
||||
document.getElementById('tab-tape').classList.toggle('text-green-400', tab === 'tape');
|
||||
document.getElementById('tab-tape').classList.toggle('border-transparent', tab !== 'tape');
|
||||
document.getElementById('tab-tape').classList.toggle('text-slate-400', tab !== 'tape');
|
||||
|
||||
// Update content visibility
|
||||
document.getElementById('content-disk').classList.toggle('hidden', tab !== 'disk');
|
||||
document.getElementById('content-tape').classList.toggle('hidden', tab !== 'tape');
|
||||
|
||||
// Load targets for active tab
|
||||
loadISCSITargets(tab);
|
||||
}
|
||||
|
||||
async function loadISCSITargets(type = 'disk') {
|
||||
try {
|
||||
const res = await fetch('/api/v1/iscsi/targets', { headers: getAuthHeaders() });
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
document.getElementById('iscsi-targets-list').innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load iSCSI targets'}</p>`;
|
||||
const listEl = document.getElementById(`iscsi-targets-list-${type}`);
|
||||
if (listEl) {
|
||||
listEl.innerHTML = `<p class="text-red-400 text-sm">Error: ${err.error || 'Failed to load iSCSI targets'}</p>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const targets = await res.json();
|
||||
const listEl = document.getElementById('iscsi-targets-list');
|
||||
const allTargets = await res.json();
|
||||
|
||||
if (!Array.isArray(targets)) {
|
||||
// Filter targets by type
|
||||
const targets = Array.isArray(allTargets) ? allTargets.filter(t => (t.type || 'disk') === type) : [];
|
||||
const listEl = document.getElementById(`iscsi-targets-list-${type}`);
|
||||
|
||||
if (!listEl) return;
|
||||
|
||||
if (!Array.isArray(allTargets)) {
|
||||
listEl.innerHTML = '<p class="text-red-400 text-sm">Error: Invalid response format</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (targets.length === 0) {
|
||||
listEl.innerHTML = '<p class="text-slate-400 text-sm">No iSCSI targets found</p>';
|
||||
listEl.innerHTML = `<p class="text-slate-400 text-sm">No ${type === 'disk' ? 'disk mode' : 'tape passthrough'} targets found</p>`;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -155,7 +227,7 @@ async function loadISCSITargets() {
|
||||
` : '<p class="text-sm text-slate-500 mt-2">No LUNs attached. Click "Add LUN" to bind a volume.</p>'}
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button onclick="showAddLUNModal('${target.id}')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
<button onclick="showAddLUNModal('${target.id}', '${target.type || 'disk'}')" class="px-3 py-1.5 ${target.type === 'tape' ? 'bg-green-600 hover:bg-green-700' : 'bg-blue-600 hover:bg-blue-700'} text-white rounded text-sm">
|
||||
Add LUN
|
||||
</button>
|
||||
<button onclick="showConnectionInstructions('${target.id}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
@@ -173,7 +245,39 @@ async function loadISCSITargets() {
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateISCSIModal() {
|
||||
function showCreateISCSIModal(type = 'disk') {
|
||||
// Generate default IQN with current date
|
||||
// IQN format: iqn.YYYY-MM.domain.subdomain:identifier
|
||||
// Domain must have at least 2 levels (e.g., com.atlas)
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const suffix = type === 'tape' ? 'tape' : 'target';
|
||||
const defaultIQN = `iqn.${year}-${month}.com.atlas:${suffix}-1`;
|
||||
|
||||
// Set target type
|
||||
document.getElementById('create-iscsi-type').value = type;
|
||||
document.getElementById('create-iscsi-modal-title').textContent = type === 'tape' ? 'Create Tape Library Passthrough Target' : 'Create Disk Mode Target';
|
||||
|
||||
const iqnInput = document.getElementById('iqn-input');
|
||||
if (iqnInput) {
|
||||
iqnInput.value = defaultIQN;
|
||||
// Auto-fix common mistake: replace last dot with colon if needed
|
||||
iqnInput.addEventListener('blur', function() {
|
||||
let value = this.value.trim();
|
||||
// If user typed dot before identifier, replace with colon
|
||||
// Pattern: iqn.YYYY-MM.domain.identifier -> iqn.YYYY-MM.domain:identifier
|
||||
if (value.match(/^iqn\.\d{4}-\d{2}\.[a-zA-Z0-9][a-zA-Z0-9\-\.]*\.[a-zA-Z0-9]/) && !value.includes(':')) {
|
||||
// Replace last dot with colon
|
||||
const lastDotIndex = value.lastIndexOf('.');
|
||||
if (lastDotIndex > 0) {
|
||||
value = value.substring(0, lastDotIndex) + ':' + value.substring(lastDotIndex + 1);
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('create-iscsi-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
@@ -271,24 +375,35 @@ function closeModal(modalId) {
|
||||
async function createISCSITarget(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const targetType = formData.get('type') || 'disk';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/v1/iscsi/targets', {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
iqn: formData.get('iqn')
|
||||
iqn: formData.get('iqn'),
|
||||
type: targetType
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('create-iscsi-modal');
|
||||
e.target.reset();
|
||||
loadISCSITargets();
|
||||
alert('iSCSI target created successfully');
|
||||
loadISCSITargets(targetType);
|
||||
alert(`${targetType === 'tape' ? 'Tape' : 'Disk'} target created successfully`);
|
||||
} else {
|
||||
const err = await res.json();
|
||||
alert(`Error: ${err.error || 'Failed to create iSCSI target'}`);
|
||||
const err = await res.json().catch(() => ({ error: 'Failed to parse error response' }));
|
||||
let errMsg = 'Failed to create iSCSI target';
|
||||
if (err) {
|
||||
if (err.message) {
|
||||
errMsg = err.message;
|
||||
if (err.details) errMsg += ': ' + err.details;
|
||||
} else if (err.error) {
|
||||
errMsg = err.error;
|
||||
}
|
||||
}
|
||||
alert(`Error: ${errMsg}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
@@ -299,8 +414,23 @@ async function addLUN(e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const targetId = formData.get('target_id');
|
||||
const targetType = formData.get('target_type') || 'disk';
|
||||
|
||||
// Get volume from either dropdown or manual input
|
||||
let requestBody = {};
|
||||
|
||||
if (targetType === 'tape') {
|
||||
// Tape mode: use device
|
||||
const device = document.getElementById('lun-device-input').value.trim();
|
||||
if (!device) {
|
||||
alert('Please enter a tape device path');
|
||||
return;
|
||||
}
|
||||
requestBody = {
|
||||
device: device,
|
||||
backstore: 'pscsi'
|
||||
};
|
||||
} else {
|
||||
// Disk mode: use ZVOL
|
||||
const zvolSelect = document.getElementById('lun-zvol-select').value;
|
||||
const zvolManual = document.getElementById('lun-zvol-manual').value.trim();
|
||||
const zvol = zvolSelect || zvolManual;
|
||||
@@ -309,14 +439,16 @@ async function addLUN(e) {
|
||||
alert('Please select or enter a volume name');
|
||||
return;
|
||||
}
|
||||
requestBody = {
|
||||
zvol: zvol
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/iscsi/targets/${targetId}/luns`, {
|
||||
method: 'POST',
|
||||
headers: getAuthHeaders(),
|
||||
body: JSON.stringify({
|
||||
zvol: zvol
|
||||
})
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
@@ -324,7 +456,9 @@ async function addLUN(e) {
|
||||
e.target.reset();
|
||||
document.getElementById('lun-zvol-select').innerHTML = '<option value="">Loading volumes...</option>';
|
||||
document.getElementById('lun-zvol-manual').value = '';
|
||||
loadISCSITargets();
|
||||
document.getElementById('lun-device-input').value = '';
|
||||
// Reload targets for the current tab
|
||||
loadISCSITargets(targetType);
|
||||
alert('LUN added successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
@@ -348,7 +482,9 @@ async function removeLUN(targetId, lunId) {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadISCSITargets();
|
||||
// Reload targets for current tab
|
||||
const activeTab = document.getElementById('content-disk').classList.contains('hidden') ? 'tape' : 'disk';
|
||||
loadISCSITargets(activeTab);
|
||||
alert('LUN removed successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
@@ -369,7 +505,9 @@ async function deleteISCSITarget(id) {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
loadISCSITargets();
|
||||
// Reload targets for current tab
|
||||
const activeTab = document.getElementById('content-disk').classList.contains('hidden') ? 'tape' : 'disk';
|
||||
loadISCSITargets(activeTab);
|
||||
alert('iSCSI target deleted successfully');
|
||||
} else {
|
||||
const err = await res.json();
|
||||
@@ -380,8 +518,7 @@ async function deleteISCSITarget(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// Load initial data
|
||||
loadISCSITargets();
|
||||
// Load initial data - will be called in auth check function
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
||||
@@ -233,6 +233,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Spare Disk Modal -->
|
||||
<div id="add-spare-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-4 sm:p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
<h3 class="text-xl font-semibold text-white mb-4">Add Spare Disk</h3>
|
||||
<form id="add-spare-form" onsubmit="addSpareDisk(event)" class="space-y-4">
|
||||
<input type="hidden" id="add-spare-pool-name" name="pool">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Pool Name</label>
|
||||
<input type="text" id="add-spare-pool-name-display" readonly class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm opacity-75 cursor-not-allowed">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Select Disk</label>
|
||||
<select id="add-spare-disk" name="disk" required class="w-full px-3 py-2 bg-slate-900 border border-slate-700 rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-600">
|
||||
<option value="">Loading disks...</option>
|
||||
</select>
|
||||
<p class="text-xs text-slate-400 mt-1">Only available disks are shown</p>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end">
|
||||
<button type="button" onclick="closeModal('add-spare-modal')" class="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded text-sm">
|
||||
Add Spare
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Edit Dataset Modal -->
|
||||
<div id="edit-dataset-modal" class="hidden fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div class="bg-slate-800 rounded-lg border border-slate-700 p-4 sm:p-6 max-w-md w-full max-h-[90vh] overflow-y-auto">
|
||||
@@ -435,8 +464,10 @@ async function loadPools() {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Rendering pools list...');
|
||||
listEl.innerHTML = pools.map(pool => `
|
||||
console.log('Rendering pools list...', pools);
|
||||
const html = pools.map(pool => {
|
||||
console.log('Rendering pool:', pool.name);
|
||||
return `
|
||||
<div class="border-b border-slate-700 last:border-0 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
@@ -462,8 +493,22 @@ async function loadPools() {
|
||||
<span class="text-white ml-2">${formatBytes(pool.free)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button onclick="togglePoolInfo('${pool.name}')" class="text-sm text-blue-400 hover:text-blue-300 flex items-center gap-1">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
Pool Info
|
||||
</button>
|
||||
</div>
|
||||
<div id="pool-info-${pool.name}" class="hidden mt-4 p-4 bg-slate-900 rounded border border-slate-700">
|
||||
<p class="text-slate-400 text-sm">Loading pool details...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="showAddSpareModal('${pool.name}')" class="px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white rounded text-sm">
|
||||
Add Spare
|
||||
</button>
|
||||
<button onclick="startScrub('${pool.name}')" class="px-3 py-1.5 bg-slate-700 hover:bg-slate-600 text-white rounded text-sm">
|
||||
Scrub
|
||||
</button>
|
||||
@@ -476,7 +521,14 @@ async function loadPools() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
console.log('Generated HTML contains Pool Info:', html.includes('Pool Info'));
|
||||
console.log('Generated HTML contains togglePoolInfo:', html.includes('togglePoolInfo'));
|
||||
console.log('Generated HTML length:', html.length);
|
||||
|
||||
listEl.innerHTML = html;
|
||||
console.log('Pools list rendered successfully');
|
||||
} catch (err) {
|
||||
console.error('Error in loadPools:', err);
|
||||
@@ -1069,6 +1121,197 @@ async function createZVOL(e) {
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePoolInfo(poolName) {
|
||||
const infoEl = document.getElementById(`pool-info-${poolName}`);
|
||||
if (!infoEl) return;
|
||||
|
||||
if (infoEl.classList.contains('hidden')) {
|
||||
infoEl.classList.remove('hidden');
|
||||
await loadPoolInfo(poolName);
|
||||
} else {
|
||||
infoEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPoolInfo(poolName) {
|
||||
const infoEl = document.getElementById(`pool-info-${poolName}`);
|
||||
if (!infoEl) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/pools/${poolName}?detail=true`, {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
infoEl.innerHTML = '<p class="text-red-400 text-sm">Failed to load pool details</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = await res.json();
|
||||
|
||||
let html = '<div class="space-y-4">';
|
||||
|
||||
// State and Status
|
||||
html += `<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-slate-400">State:</span>
|
||||
<span class="text-white ml-2 px-2 py-1 rounded text-xs font-medium ${
|
||||
detail.state === 'ONLINE' ? 'bg-green-900 text-green-300' :
|
||||
detail.state === 'DEGRADED' ? 'bg-yellow-900 text-yellow-300' :
|
||||
'bg-red-900 text-red-300'
|
||||
}">${detail.state || 'N/A'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-slate-400">Errors:</span>
|
||||
<span class="text-white ml-2">${detail.errors || 'No known data errors'}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// Scrub Info
|
||||
if (detail.scrub_info) {
|
||||
html += `<div class="text-sm">
|
||||
<span class="text-slate-400">Scrub:</span>
|
||||
<span class="text-white ml-2">${detail.scrub_info}</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// VDEVs
|
||||
if (detail.vdevs && detail.vdevs.length > 0) {
|
||||
html += '<div><h4 class="text-sm font-semibold text-white mb-2">Virtual Devices:</h4>';
|
||||
html += '<div class="space-y-3">';
|
||||
detail.vdevs.forEach(vdev => {
|
||||
html += `<div class="pl-4 border-l-2 border-slate-600">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="text-sm font-medium text-slate-300">${vdev.name || vdev.type || 'VDEV'}</span>
|
||||
<span class="px-2 py-0.5 rounded text-xs ${
|
||||
vdev.state === 'ONLINE' ? 'bg-green-900 text-green-300' :
|
||||
vdev.state === 'DEGRADED' ? 'bg-yellow-900 text-yellow-300' :
|
||||
'bg-red-900 text-red-300'
|
||||
}">${vdev.state}</span>
|
||||
${vdev.read > 0 || vdev.write > 0 || vdev.checksum > 0 ? `
|
||||
<span class="text-xs text-slate-400">
|
||||
(R:${vdev.read} W:${vdev.write} C:${vdev.checksum})
|
||||
</span>
|
||||
` : ''}
|
||||
</div>`;
|
||||
if (vdev.disks && vdev.disks.length > 0) {
|
||||
html += '<div class="ml-4 space-y-1">';
|
||||
vdev.disks.forEach(disk => {
|
||||
html += `<div class="text-xs flex items-center gap-2">
|
||||
<span class="text-slate-300">${disk.name}</span>
|
||||
<span class="px-1.5 py-0.5 rounded text-xs ${
|
||||
disk.state === 'ONLINE' ? 'bg-green-900 text-green-300' :
|
||||
disk.state === 'DEGRADED' ? 'bg-yellow-900 text-yellow-300' :
|
||||
'bg-red-900 text-red-300'
|
||||
}">${disk.state}</span>
|
||||
${disk.read > 0 || disk.write > 0 || disk.checksum > 0 ? `
|
||||
<span class="text-slate-500">
|
||||
(R:${disk.read} W:${disk.write} C:${disk.checksum})
|
||||
</span>
|
||||
` : ''}
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div></div>';
|
||||
}
|
||||
|
||||
// Spares
|
||||
if (detail.spares && detail.spares.length > 0) {
|
||||
html += '<div><h4 class="text-sm font-semibold text-white mb-2">Spare Disks:</h4>';
|
||||
html += '<div class="flex flex-wrap gap-2">';
|
||||
detail.spares.forEach(spare => {
|
||||
html += `<span class="px-2 py-1 bg-slate-700 text-slate-300 rounded text-xs">${spare}</span>`;
|
||||
});
|
||||
html += '</div></div>';
|
||||
} else {
|
||||
html += '<div><h4 class="text-sm font-semibold text-white mb-2">Spare Disks:</h4>';
|
||||
html += '<p class="text-slate-400 text-sm">No spare disks configured</p></div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
infoEl.innerHTML = html;
|
||||
} catch (err) {
|
||||
console.error('Error loading pool info:', err);
|
||||
infoEl.innerHTML = '<p class="text-red-400 text-sm">Error loading pool details</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function showAddSpareModal(poolName) {
|
||||
document.getElementById('add-spare-pool-name').value = poolName;
|
||||
document.getElementById('add-spare-pool-name-display').value = poolName;
|
||||
document.getElementById('add-spare-modal').classList.remove('hidden');
|
||||
loadDisksForSpare();
|
||||
}
|
||||
|
||||
async function loadDisksForSpare() {
|
||||
try {
|
||||
const res = await fetch('/api/v1/disks', {
|
||||
headers: getAuthHeaders()
|
||||
});
|
||||
if (!res.ok) return;
|
||||
|
||||
const disks = await res.json();
|
||||
const selectEl = document.getElementById('add-spare-disk');
|
||||
selectEl.innerHTML = '<option value="">Select a disk...</option>';
|
||||
|
||||
disks.forEach(disk => {
|
||||
if (disk.status === 'available') {
|
||||
const option = document.createElement('option');
|
||||
option.value = disk.name;
|
||||
option.textContent = `${disk.name} (${disk.size || 'Unknown'})`;
|
||||
selectEl.appendChild(option);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error loading disks:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function addSpareDisk(event) {
|
||||
event.preventDefault();
|
||||
const poolName = document.getElementById('add-spare-pool-name').value;
|
||||
const diskName = document.getElementById('add-spare-disk').value;
|
||||
|
||||
if (!diskName) {
|
||||
alert('Please select a disk');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/v1/pools/${poolName}/spare`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ disk: diskName })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('add-spare-modal');
|
||||
loadPools();
|
||||
alert('Spare disk added successfully');
|
||||
} else {
|
||||
const data = await res.json();
|
||||
let errMsg = 'Failed to add spare disk';
|
||||
if (data) {
|
||||
if (data.message) {
|
||||
errMsg = data.message;
|
||||
if (data.details) errMsg += ': ' + data.details;
|
||||
} else if (data.error) {
|
||||
errMsg = data.error;
|
||||
}
|
||||
}
|
||||
alert(`Error: ${errMsg}`);
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`Error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePool(name) {
|
||||
if (!confirm(`Are you sure you want to delete pool "${name}"? This will destroy all data!`)) return;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user