From a463a0932981d4608c1d7f52da1904cdbfe29551 Mon Sep 17 00:00:00 2001 From: Othman Hendy Suseo Date: Sat, 20 Dec 2025 13:21:12 +0000 Subject: [PATCH] refine disk information page --- internal/httpapp/api_handlers.go | 166 +++++++++++++++++++++++++++++ internal/httpapp/router_helpers.go | 10 ++ internal/zfs/service.go | 135 +++++++++++++++++++++++ web/templates/protection.html | 48 +++++++++ web/templates/storage.html | 65 +++++++++-- 5 files changed, 413 insertions(+), 11 deletions(-) diff --git a/internal/httpapp/api_handlers.go b/internal/httpapp/api_handlers.go index 29f67be..2c0a8c4 100644 --- a/internal/httpapp/api_handlers.go +++ b/internal/httpapp/api_handlers.go @@ -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 +} diff --git a/internal/httpapp/router_helpers.go b/internal/httpapp/router_helpers.go index b4f6352..480429a 100644 --- a/internal/httpapp/router_helpers.go +++ b/internal/httpapp/router_helpers.go @@ -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) }, diff --git a/internal/zfs/service.go b/internal/zfs/service.go index c3d262e..48a9bc7 100644 --- a/internal/zfs/service.go +++ b/internal/zfs/service.go @@ -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 +} diff --git a/web/templates/protection.html b/web/templates/protection.html index c7eca5c..293cb58 100644 --- a/web/templates/protection.html +++ b/web/templates/protection.html @@ -317,6 +317,9 @@ async function loadSnapshots() {
+ @@ -393,6 +396,48 @@ async function createSnapshot(e) { } } +// Restore snapshot function - must be in global scope +window.restoreSnapshot = async function(snapshotName, datasetName) { + const warning = `WARNING: This will rollback dataset "${datasetName}" to snapshot "${snapshotName}".\n\n` + + `This action will:\n` + + `- Discard all changes made after this snapshot\n` + + `- Cannot be undone\n\n` + + `Are you sure you want to continue?`; + + if (!confirm(warning)) return; + + try { + const res = await fetch(`/api/v1/snapshots/${encodeURIComponent(snapshotName)}/restore`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ force: false }) + }); + + if (res.ok) { + // Reload both snapshot lists + if (typeof loadSnapshots === 'function') loadSnapshots(); + if (typeof loadVolumeSnapshots === 'function') loadVolumeSnapshots(); + alert('Snapshot restored successfully'); + } else { + const data = await res.json(); + let errMsg = 'Failed to restore snapshot'; + 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 deleteSnapshot(name) { if (!confirm(`Are you sure you want to delete snapshot "${name}"?`)) return; @@ -613,6 +658,9 @@ async function loadVolumeSnapshots() {
+ diff --git a/web/templates/storage.html b/web/templates/storage.html index f50effa..bc07e2f 100644 --- a/web/templates/storage.html +++ b/web/templates/storage.html @@ -630,10 +630,16 @@ async function loadDisks() {
- - - - + ${isAvailable ? ` + + + + + ` : ` + + + + `}
@@ -641,6 +647,15 @@ async function loadDisks() {
${disk.name}
+ ${disk.health_status ? ` +
+ ${disk.health_status === 'healthy' ? '✓ Healthy' : disk.health_status === 'failed' ? '✗ Failed' : '? Unknown'} +
+ ` : ''}
${disk.size || 'N/A'}
@@ -680,11 +695,17 @@ async function loadDisks() {
-
- - - - +
+ ${isAvailable ? ` + + + + + ` : ` + + + + `}
@@ -711,14 +732,36 @@ async function loadDisks() { Slot: #${slotNumber}
+ ${disk.health_status ? ` +
+ Health: + ${disk.health_status === 'healthy' ? 'Healthy' : disk.health_status === 'failed' ? 'Failed' : 'Unknown'} +
+ ` : ''} + ${disk.health_temperature ? ` +
+ Temp: + ${disk.health_temperature} +
+ ` : ''} + ${disk.health_power_on_hours ? ` +
+ Power On: + ${parseInt(disk.health_power_on_hours).toLocaleString()}h +
+ ` : ''}
-