This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -317,6 +317,9 @@ async function loadSnapshots() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button onclick="restoreSnapshot('${snap.name}', '${snap.dataset}')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Restore
|
||||
</button>
|
||||
<button onclick="deleteSnapshot('${snap.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
@@ -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() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-4">
|
||||
<button onclick="restoreSnapshot('${snap.name}', '${snap.dataset}')" class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm">
|
||||
Restore
|
||||
</button>
|
||||
<button onclick="deleteSnapshot('${snap.name}')" class="px-3 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded text-sm">
|
||||
Delete
|
||||
</button>
|
||||
|
||||
@@ -630,10 +630,16 @@ async function loadDisks() {
|
||||
<div class="flex flex-col items-center justify-center pt-2">
|
||||
<div class="w-20 h-20 rounded-xl bg-gradient-to-br ${isAvailable ? 'from-slate-600 to-slate-700' : 'from-slate-700 to-slate-800'} flex items-center justify-center mb-3 border-2 border-slate-500/50 ${isAvailable ? 'group-hover:border-blue-500/70 group-hover:from-blue-600/30 group-hover:to-slate-700' : ''} transition-all shadow-inner">
|
||||
<!-- Disk Icon -->
|
||||
<svg class="w-12 h-12 ${isAvailable ? 'text-slate-200 group-hover:text-blue-400' : 'text-slate-400'} transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M4 3a2 2 0 100 4h12a2 2 0 100-4H4z"></path>
|
||||
<path fill-rule="evenodd" d="M3 8h14v7a2 2 0 01-2 2H5a2 2 0 01-2-2V8zm5 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
${isAvailable ? `
|
||||
<svg class="w-12 h-12 text-slate-200 group-hover:text-blue-400 transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M4 3a2 2 0 100 4h12a2 2 0 100-4H4z"></path>
|
||||
<path fill-rule="evenodd" d="M3 8h14v7a2 2 0 01-2 2H5a2 2 0 01-2-2V8zm5 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
` : `
|
||||
<svg class="w-12 h-12 text-red-400 transition-colors" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M13.477 14.89A6 6 0 015.11 6.524l8.367 8.368zm1.414-1.414L6.524 5.11a6 6 0 018.367 8.367zM18 10a8 8 0 11-16 0 8 8 0 0116 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Device Name -->
|
||||
@@ -641,6 +647,15 @@ async function loadDisks() {
|
||||
<div class="${isAvailable ? 'text-slate-100 group-hover:text-blue-400' : 'text-slate-400'} font-bold text-base mb-1 transition-colors font-mono">
|
||||
${disk.name}
|
||||
</div>
|
||||
${disk.health_status ? `
|
||||
<div class="mb-1">
|
||||
<span class="px-1.5 py-0.5 rounded text-xs font-medium ${
|
||||
disk.health_status === 'healthy' ? 'bg-green-600 text-white' :
|
||||
disk.health_status === 'failed' ? 'bg-red-600 text-white' :
|
||||
'bg-yellow-600 text-white'
|
||||
}">${disk.health_status === 'healthy' ? '✓ Healthy' : disk.health_status === 'failed' ? '✗ Failed' : '? Unknown'}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="${isAvailable ? 'text-slate-300' : 'text-slate-500'} text-xs font-semibold mb-1">
|
||||
${disk.size || 'N/A'}
|
||||
</div>
|
||||
@@ -680,11 +695,17 @@ async function loadDisks() {
|
||||
</div>
|
||||
|
||||
<!-- Disk Icon -->
|
||||
<div class="w-12 h-12 rounded-lg bg-slate-700 border border-slate-600 flex items-center justify-center flex-shrink-0">
|
||||
<svg class="w-6 h-6 text-slate-300" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M4 3a2 2 0 100 4h12a2 2 0 100-4H4z"></path>
|
||||
<path fill-rule="evenodd" d="M3 8h14v7a2 2 0 01-2 2H5a2 2 0 01-2-2V8zm5 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<div class="w-12 h-12 rounded-lg ${isAvailable ? 'bg-slate-700 border-slate-600' : 'bg-slate-800 border-slate-700 opacity-60'} border flex items-center justify-center flex-shrink-0">
|
||||
${isAvailable ? `
|
||||
<svg class="w-6 h-6 text-slate-300" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M4 3a2 2 0 100 4h12a2 2 0 100-4H4z"></path>
|
||||
<path fill-rule="evenodd" d="M3 8h14v7a2 2 0 01-2 2H5a2 2 0 01-2-2V8zm5 3a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
` : `
|
||||
<svg class="w-6 h-6 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M13.477 14.89A6 6 0 015.11 6.524l8.367 8.368zm1.414-1.414L6.524 5.11a6 6 0 018.367 8.367zM18 10a8 8 0 11-16 0 8 8 0 0116 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Disk Info -->
|
||||
@@ -711,14 +732,36 @@ async function loadDisks() {
|
||||
<span class="text-slate-400">Slot:</span>
|
||||
<span class="text-white ml-2 font-semibold">#${slotNumber}</span>
|
||||
</div>
|
||||
${disk.health_status ? `
|
||||
<div>
|
||||
<span class="text-slate-400">Health:</span>
|
||||
<span class="ml-2 px-2 py-0.5 rounded text-xs font-medium ${
|
||||
disk.health_status === 'healthy' ? 'bg-green-600 text-white' :
|
||||
disk.health_status === 'failed' ? 'bg-red-600 text-white' :
|
||||
'bg-yellow-600 text-white'
|
||||
}">${disk.health_status === 'healthy' ? 'Healthy' : disk.health_status === 'failed' ? 'Failed' : 'Unknown'}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${disk.health_temperature ? `
|
||||
<div>
|
||||
<span class="text-slate-400">Temp:</span>
|
||||
<span class="text-white ml-2">${disk.health_temperature}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${disk.health_power_on_hours ? `
|
||||
<div>
|
||||
<span class="text-slate-400">Power On:</span>
|
||||
<span class="text-white ml-2">${parseInt(disk.health_power_on_hours).toLocaleString()}h</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex gap-2">
|
||||
<button onclick="useDiskForPool('${diskPath}')"
|
||||
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-700 text-white rounded text-sm transition-colors">
|
||||
<button ${isAvailable ? `onclick="useDiskForPool('${diskPath}')"` : 'disabled'}
|
||||
class="px-3 py-1.5 ${isAvailable ? 'bg-blue-600 hover:bg-blue-700 text-white cursor-pointer' : 'bg-slate-600 text-slate-400 cursor-not-allowed'} rounded text-sm transition-colors">
|
||||
Use for Pool
|
||||
</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user