refine disk information page
Some checks failed
CI / test-build (push) Has been cancelled

This commit is contained in:
2025-12-20 13:21:12 +00:00
parent 45aaec9e47
commit a463a09329
5 changed files with 413 additions and 11 deletions

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"log"
"net/http"
"net/url"
"os/exec"
"strconv"
"strings"
"time"
@@ -611,6 +613,54 @@ func (a *App) handleDeleteSnapshot(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"message": "snapshot destroyed", "name": name})
}
func (a *App) handleRestoreSnapshot(w http.ResponseWriter, r *http.Request) {
// Extract snapshot name from path like /api/v1/snapshots/pool@snapshot/restore
path := strings.TrimPrefix(r.URL.Path, "/api/v1/snapshots/")
path = strings.TrimSuffix(path, "/restore")
// URL decode the snapshot name
snapshotName, err := url.QueryUnescape(path)
if err != nil {
writeError(w, errors.ErrBadRequest("invalid snapshot name"))
return
}
if snapshotName == "" {
writeError(w, errors.ErrBadRequest("snapshot name required"))
return
}
var req struct {
Force bool `json:"force,omitempty"` // Force rollback (recursive for child datasets)
}
if r.Body != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
// If body is empty or invalid, use defaults
req.Force = false
}
}
// Validate snapshot exists
_, err = a.zfs.GetSnapshot(snapshotName)
if err != nil {
writeError(w, errors.ErrNotFound(fmt.Sprintf("snapshot '%s' not found", snapshotName)))
return
}
// Restore snapshot
if err := a.zfs.RestoreSnapshot(snapshotName, req.Force); err != nil {
log.Printf("restore snapshot error: %v", err)
writeError(w, errors.ErrInternal("failed to restore snapshot").WithDetails(err.Error()))
return
}
writeJSON(w, http.StatusOK, map[string]string{
"message": "snapshot restored successfully",
"name": snapshotName,
})
}
// Snapshot Policy Handlers
func (a *App) handleListSnapshotPolicies(w http.ResponseWriter, r *http.Request) {
dataset := r.URL.Query().Get("dataset")
@@ -875,6 +925,13 @@ func (a *App) handleDeleteSMBShare(w http.ResponseWriter, r *http.Request) {
// NFS Export Handlers
func (a *App) handleListNFSExports(w http.ResponseWriter, r *http.Request) {
// Sync exports from OS (ZFS sharenfs) to store
// This ensures exports created before service restart are visible
if err := a.syncNFSExportsFromOS(); err != nil {
log.Printf("warning: failed to sync NFS exports from OS: %v", err)
// Continue anyway - return what's in store
}
exports := a.nfsStore.List()
writeJSON(w, http.StatusOK, exports)
}
@@ -1638,3 +1695,112 @@ func (a *App) handleListAuditLogs(w http.ResponseWriter, r *http.Request) {
logs := a.auditStore.List(actor, action, resource, limit)
writeJSON(w, http.StatusOK, logs)
}
// syncNFSExportsFromOS syncs NFS exports from ZFS sharenfs properties to the store
func (a *App) syncNFSExportsFromOS() error {
// Get all datasets
datasets, err := a.zfs.ListDatasets("")
if err != nil {
return fmt.Errorf("list datasets: %w", err)
}
// Find datasets with sharenfs property set
for _, ds := range datasets {
// Get sharenfs property
zfsPath := "zfs"
if path, err := exec.LookPath("zfs"); err == nil {
zfsPath = path
}
cmd := exec.Command("sudo", "-n", zfsPath, "get", "-H", "-o", "value", "sharenfs", ds.Name)
output, err := cmd.Output()
if err != nil {
continue // Skip if can't get property
}
sharenfsValue := strings.TrimSpace(string(output))
if sharenfsValue == "off" || sharenfsValue == "-" || sharenfsValue == "" {
continue // Not shared
}
// Check if export already exists in store
existingExports := a.nfsStore.List()
exists := false
for _, exp := range existingExports {
if exp.Dataset == ds.Name || exp.Path == ds.Mountpoint {
exists = true
break
}
}
if exists {
continue // Already in store
}
// Parse sharenfs value to extract configuration
// Format examples:
// - "rw=*,no_root_squash"
// - "ro=*,root_squash"
// - "rw=10.0.0.0/8,ro=192.168.1.0/24,root_squash"
readOnly := false
rootSquash := true // Default
clients := []string{"*"} // Default to all
// Parse sharenfs value
parts := strings.Split(sharenfsValue, ",")
clientSpecs := []string{}
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "root_squash" {
rootSquash = true
} else if part == "no_root_squash" {
rootSquash = false
} else if strings.HasPrefix(part, "ro=") {
client := strings.TrimPrefix(part, "ro=")
if client == "*" {
readOnly = true
clients = []string{"*"}
} else {
clientSpecs = append(clientSpecs, client)
readOnly = true // At least one client is read-only
}
} else if strings.HasPrefix(part, "rw=") {
client := strings.TrimPrefix(part, "rw=")
if client == "*" {
readOnly = false
clients = []string{"*"}
} else {
clientSpecs = append(clientSpecs, client)
}
} else if part == "ro" {
readOnly = true
clients = []string{"*"}
} else if part == "rw" {
readOnly = false
clients = []string{"*"}
}
}
// If we found specific clients, use them
if len(clientSpecs) > 0 {
clients = clientSpecs
}
// Use mountpoint as path, or default path
path := ds.Mountpoint
if path == "" || path == "none" {
// Generate default path
parts := strings.Split(ds.Name, "/")
datasetName := parts[len(parts)-1]
path = "/storage/datasets/" + datasetName
}
// Create export in store (ignore error if already exists)
_, err = a.nfsStore.Create(path, ds.Name, clients, readOnly, rootSquash)
if err != nil && err != storage.ErrNFSExportExists {
log.Printf("warning: failed to sync export for dataset %s: %v", ds.Name, err)
}
}
return nil
}

View File

@@ -137,6 +137,16 @@ func (a *App) handleZVOLOps(w http.ResponseWriter, r *http.Request) {
// handleSnapshotOps routes snapshot operations by method
func (a *App) handleSnapshotOps(w http.ResponseWriter, r *http.Request) {
// Check if it's a restore operation
if strings.HasSuffix(r.URL.Path, "/restore") {
if r.Method == http.MethodPost {
a.handleRestoreSnapshot(w, r)
} else {
writeError(w, errors.ErrBadRequest("method not allowed"))
}
return
}
methodHandler(
func(w http.ResponseWriter, r *http.Request) { a.handleGetSnapshot(w, r) },
func(w http.ResponseWriter, r *http.Request) { a.handleCreateSnapshot(w, r) },

View File

@@ -1167,6 +1167,16 @@ func (s *Service) ListDisks() ([]map[string]string, error) {
disk["status"] = "unavailable"
}
// Get SMART health info
healthInfo := s.getDiskHealth(dev.Name)
if healthInfo != nil {
disk["health_status"] = healthInfo["status"]
disk["health_temperature"] = healthInfo["temperature"]
disk["health_power_on_hours"] = healthInfo["power_on_hours"]
disk["health_reallocated_sectors"] = healthInfo["reallocated_sectors"]
disk["health_pending_sectors"] = healthInfo["pending_sectors"]
}
disks = append(disks, disk)
}
}
@@ -1174,6 +1184,116 @@ func (s *Service) ListDisks() ([]map[string]string, error) {
return disks, nil
}
// getDiskHealth retrieves SMART health information for a disk
func (s *Service) getDiskHealth(diskName string) map[string]string {
health := make(map[string]string)
diskPath := "/dev/" + diskName
// Check if smartctl is available
smartctlPath := "smartctl"
if path, err := exec.LookPath("smartctl"); err != nil {
// smartctl not available, skip health check
return nil
} else {
smartctlPath = path
}
// Get overall health status
cmd := exec.Command("sudo", "-n", smartctlPath, "-H", diskPath)
output, err := cmd.Output()
if err != nil {
// Check if it's because SMART is not supported (common for virtual disks)
// If exit code indicates unsupported, return nil (don't show health info)
if exitError, ok := err.(*exec.ExitError); ok {
exitCode := exitError.ExitCode()
// Exit code 2 usually means "SMART not supported" or "device doesn't support SMART"
if exitCode == 2 {
return nil // Don't show health for unsupported devices
}
}
// For other errors, also return nil (don't show unknown)
return nil
}
outputStr := string(output)
// Check if SMART is unsupported (common messages)
if strings.Contains(outputStr, "SMART support is: Unavailable") ||
strings.Contains(outputStr, "Device does not support SMART") ||
strings.Contains(outputStr, "SMART not supported") ||
strings.Contains(outputStr, "Unable to detect device type") ||
strings.Contains(outputStr, "SMART support is: Disabled") {
return nil // Don't show health for unsupported devices
}
// Parse health status - multiple possible formats:
// - "SMART overall-health self-assessment test result: PASSED" or "FAILED"
// - "SMART Health Status: OK"
// - "SMART Status: OK" or "SMART Status: FAILED"
if strings.Contains(outputStr, "PASSED") ||
strings.Contains(outputStr, "SMART Health Status: OK") ||
strings.Contains(outputStr, "SMART Status: OK") {
health["status"] = "healthy"
} else if strings.Contains(outputStr, "FAILED") ||
strings.Contains(outputStr, "SMART Status: FAILED") {
health["status"] = "failed"
} else {
// If we can't determine status but SMART is supported, return nil instead of unknown
// This avoids showing "Unknown" for virtual disks or devices with unclear status
return nil
}
// Get detailed SMART attributes
cmd = exec.Command("sudo", "-n", smartctlPath, "-A", diskPath)
attrOutput, err := cmd.Output()
if err != nil {
// Return what we have
return health
}
attrStr := string(attrOutput)
lines := strings.Split(attrStr, "\n")
// Parse key attributes
for _, line := range lines {
fields := strings.Fields(line)
if len(fields) < 10 {
continue
}
// ID 194: Temperature_Celsius
if strings.Contains(line, "194") && strings.Contains(line, "Temperature") {
if len(fields) >= 9 {
health["temperature"] = fields[9] + "°C"
}
}
// ID 9: Power_On_Hours
if strings.Contains(line, "9") && (strings.Contains(line, "Power_On") || strings.Contains(line, "Power-on")) {
if len(fields) >= 9 {
hours := fields[9]
health["power_on_hours"] = hours
}
}
// ID 5: Reallocated_Sector_Ct
if strings.Contains(line, "5") && strings.Contains(line, "Reallocated") {
if len(fields) >= 9 {
health["reallocated_sectors"] = fields[9]
}
}
// ID 197: Current_Pending_Sector
if strings.Contains(line, "197") && strings.Contains(line, "Pending") {
if len(fields) >= 9 {
health["pending_sectors"] = fields[9]
}
}
}
return health
}
// parseSize converts human-readable size to bytes
func parseSize(s string) (uint64, error) {
s = strings.TrimSpace(s)
@@ -1328,3 +1448,18 @@ func (s *Service) GetSnapshot(name string) (*models.Snapshot, error) {
return nil, fmt.Errorf("snapshot %s not found", name)
}
// RestoreSnapshot rolls back a dataset to a snapshot
func (s *Service) RestoreSnapshot(snapshotName string, force bool) error {
args := []string{"rollback"}
if force {
args = append(args, "-r") // Recursive rollback for child datasets
}
args = append(args, snapshotName)
_, err := s.execCommand(s.zfsPath, args...)
if err != nil {
return translateZFSError(err, "merestore snapshot", snapshotName)
}
return nil
}