diff --git a/beszel/internal/agent/agent.go b/beszel/internal/agent/agent.go index 80993e64..01756e19 100644 --- a/beszel/internal/agent/agent.go +++ b/beszel/internal/agent/agent.go @@ -25,6 +25,7 @@ type Agent struct { systemInfo system.Info // Host system info gpuManager *GPUManager // Manages GPU data cache *SessionCache // Cache for system stats based on primary session ID + smartManager *SmartManager // Manages SMART data } func NewAgent() *Agent { @@ -62,6 +63,12 @@ func NewAgent() *Agent { agent.gpuManager = gm } + if sm, err := NewSmartManager(); err != nil { + slog.Debug("SMART", "err", err) + } else { + agent.smartManager = sm + } + // if debugging, print stats if agent.debug { slog.Debug("Stats", "data", agent.gatherStats("")) diff --git a/beszel/internal/agent/smart.go b/beszel/internal/agent/smart.go new file mode 100644 index 00000000..ee5fa310 --- /dev/null +++ b/beszel/internal/agent/smart.go @@ -0,0 +1,304 @@ +package agent + +import ( + "beszel/internal/entities/smart" + "beszel/internal/entities/system" + "context" + "encoding/json" + "fmt" + "os/exec" + "reflect" + "sync" + "time" + + "golang.org/x/exp/slog" +) + +// SmartManager manages data collection for SMART devices +// TODO: add retry argument +// TODO: add timeout argument +type SmartManager struct { + SmartDataMap map[string]*system.SmartData + SmartDevices []*DeviceInfo + mutex sync.Mutex +} + +type scanOutput struct { + Devices []struct { + Name string `json:"name"` + Type string `json:"type"` + InfoName string `json:"info_name"` + Protocol string `json:"protocol"` + } `json:"devices"` +} + +type DeviceInfo struct { + Name string `json:"name"` + Type string `json:"type"` + InfoName string `json:"info_name"` + Protocol string `json:"protocol"` +} + +var errNoValidSmartData = fmt.Errorf("no valid GPU data found") // Error for missing data + +// Starts the SmartManager +func (sm *SmartManager) Start() { + sm.SmartDataMap = make(map[string]*system.SmartData) + for { + err := sm.ScanDevices() + if err != nil { + slog.Warn("smartctl scan failed, stopping", "err", err) + return + } + // TODO: add retry logic + for _, deviceInfo := range sm.SmartDevices { + err := sm.CollectSmart(deviceInfo) + if err != nil { + slog.Warn("smartctl collect failed, stopping", "err", err) + return + } + } + // Sleep for 10 seconds before next scan + time.Sleep(10 * time.Second) + } +} + +// GetCurrentData returns the current SMART data +func (sm *SmartManager) GetCurrentData() map[string]system.SmartData { + sm.mutex.Lock() + defer sm.mutex.Unlock() + result := make(map[string]system.SmartData) + for key, value := range sm.SmartDataMap { + result[key] = *value + } + return result +} + +// ScanDevices scans for SMART devices +// Scan devices using `smartctl --scan -j` +// If scan fails, return error +// If scan succeeds, parse the output and update the SmartDevices slice +func (sm *SmartManager) ScanDevices() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "smartctl", "--scan", "-j") + output, err := cmd.Output() + + if err != nil { + return err + } + + hasValidData := sm.parseScan(output) + if !hasValidData { + return errNoValidSmartData + } + return nil +} + +// CollectSmart collects SMART data for a device +// Collect data using `smartctl --all -j /dev/sdX` or `smartctl --all -j /dev/nvmeX` +// If collect fails, return error +// If collect succeeds, parse the output and update the SmartDataMap +func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "smartctl", "--all", "-j", deviceInfo.Name) + + output, err := cmd.Output() + + if err != nil { + return err + } + + hasValidData := false + if deviceInfo.Type == "scsi" { + // parse scsi devices + hasValidData = sm.parseSmartForScsi(output) + } else if deviceInfo.Type == "nvme" { + // parse nvme devices + hasValidData = sm.parseSmartForNvme(output) + } + + if !hasValidData { + return errNoValidSmartData + } + return nil +} + +// parseScan parses the output of smartctl --scan -j and updates the SmartDevices slice +func (sm *SmartManager) parseScan(output []byte) bool { + sm.mutex.Lock() + defer sm.mutex.Unlock() + + sm.SmartDevices = make([]*DeviceInfo, 0) + scan := &scanOutput{} + + if err := json.Unmarshal(output, scan); err != nil { + fmt.Printf("Failed to parse JSON: %v\n", err) + return false + } + + scannedDeviceNameMap := make(map[string]bool) + + for _, device := range scan.Devices { + deviceInfo := &DeviceInfo{ + Name: device.Name, + Type: device.Type, + InfoName: device.InfoName, + Protocol: device.Protocol, + } + sm.SmartDevices = append(sm.SmartDevices, deviceInfo) + scannedDeviceNameMap[device.Name] = true + } + // remove devices that are not in the scan + for key := range sm.SmartDataMap { + if _, ok := scannedDeviceNameMap[key]; !ok { + delete(sm.SmartDataMap, key) + } + } + devicesString := "" + for _, device := range sm.SmartDevices { + devicesString += device.Name + " " + } + + return true +} + +// parseSmartForScsi parses the output of smartctl --all -j /dev/sdX and updates the SmartDataMap +func (sm *SmartManager) parseSmartForScsi(output []byte) bool { + data := &smart.SmartInfoForSata{} + + if err := json.Unmarshal(output, &data); err != nil { + return false + } + + sm.mutex.Lock() + defer sm.mutex.Unlock() + + // get device name (e.g. /dev/sda) + keyName := data.SerialNumber + + // if device does not exist in SmartDataMap, initialize it + if _, ok := sm.SmartDataMap[keyName]; !ok { + sm.SmartDataMap[keyName] = &system.SmartData{} + } + + // update SmartData + smartData := sm.SmartDataMap[keyName] + smartData.ModelFamily = data.ModelFamily + smartData.ModelName = data.ModelName + smartData.SerialNumber = data.SerialNumber + smartData.FirmwareVersion = data.FirmwareVersion + smartData.Capacity = data.UserCapacity.Bytes + if data.SmartStatus.Passed { + smartData.SmartStatus = "PASSED" + } else { + smartData.SmartStatus = "FAILED" + } + smartData.DiskName = data.Device.Name + smartData.DiskType = data.Device.Type + + // update SmartAttributes + smartData.Attributes = make([]*system.SmartAttribute, 0, len(data.AtaSmartAttributes.Table)) + for _, attr := range data.AtaSmartAttributes.Table { + smartAttr := &system.SmartAttribute{ + Id: attr.ID, + Name: attr.Name, + Value: attr.Value, + Worst: attr.Worst, + Threshold: attr.Thresh, + RawValue: attr.Raw.Value, + RawString: attr.Raw.String, + Flags: attr.Flags.String, + WhenFailed: attr.WhenFailed, + } + smartData.Attributes = append(smartData.Attributes, smartAttr) + } + smartData.Temperature = data.Temperature.Current + sm.SmartDataMap[keyName] = smartData + + return true +} + +// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap +func (sm *SmartManager) parseSmartForNvme(output []byte) bool { + data := &smart.SmartInfoForNvme{} + + if err := json.Unmarshal(output, &data); err != nil { + return false + } + + sm.mutex.Lock() + defer sm.mutex.Unlock() + + // get device name (e.g. /dev/nvme0) + keyName := data.SerialNumber + + // if device does not exist in SmartDataMap, initialize it + if _, ok := sm.SmartDataMap[keyName]; !ok { + sm.SmartDataMap[keyName] = &system.SmartData{} + } + + // update SmartData + smartData := sm.SmartDataMap[keyName] + smartData.ModelName = data.ModelName + smartData.SerialNumber = data.SerialNumber + smartData.FirmwareVersion = data.FirmwareVersion + smartData.Capacity = data.UserCapacity.Bytes + if data.SmartStatus.Passed { + smartData.SmartStatus = "PASSED" + } else { + smartData.SmartStatus = "FAILED" + } + smartData.DiskName = data.Device.Name + smartData.DiskType = data.Device.Type + + v := reflect.ValueOf(data.NVMeSmartHealthInformationLog) + t := v.Type() + smartData.Attributes = make([]*system.SmartAttribute, 0, v.NumField()) + + // nvme attributes does not follow the same format as ata attributes, + // so we have to manually iterate over the fields and update SmartAttributes + for i := 0; i < v.NumField(); i++ { + field := t.Field(i) + value := v.Field(i) + key := field.Name + val := value.Interface() + // drop non int values + if _, ok := val.(int); !ok { + continue + } + smartAttr := &system.SmartAttribute{ + Name: key, + RawValue: val.(int), + } + smartData.Attributes = append(smartData.Attributes, smartAttr) + } + smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature + + sm.SmartDataMap[keyName] = smartData + + return true +} + +// detectSmartctl checks if smartctl is installed, returns an error if not +func (sm *SmartManager) detectSmartctl() error { + if _, err := exec.LookPath("smartctl"); err == nil { + return nil + } + return fmt.Errorf("no smartctl found - install smartctl") +} + +// NewGPUManager creates and initializes a new GPUManager +func NewSmartManager() (*SmartManager, error) { + var sm SmartManager + if err := sm.detectSmartctl(); err != nil { + return nil, err + } + + go sm.Start() + + return &sm, nil +} diff --git a/beszel/internal/agent/system.go b/beszel/internal/agent/system.go index 62695650..3fff16ad 100644 --- a/beszel/internal/agent/system.go +++ b/beszel/internal/agent/system.go @@ -237,6 +237,17 @@ func (a *Agent) getSystemStats() system.Stats { } } } + if a.smartManager != nil { + if smartData := a.smartManager.GetCurrentData(); len(smartData) > 0 { + systemStats.SmartData = smartData + if systemStats.Temperatures == nil { + systemStats.Temperatures = make(map[string]float64, len(a.smartManager.SmartDataMap)) + } + for key, value := range a.smartManager.SmartDataMap { + systemStats.Temperatures[key] = float64(value.Temperature) + } + } + } // update base system info a.systemInfo.Cpu = systemStats.Cpu diff --git a/beszel/internal/entities/smart/smart.go b/beszel/internal/entities/smart/smart.go new file mode 100644 index 00000000..2dcdedd8 --- /dev/null +++ b/beszel/internal/entities/smart/smart.go @@ -0,0 +1,269 @@ +package smart + +type SmartInfoForSata struct { + JSONFormatVersion []int `json:"json_format_version"` + Smartctl struct { + Version []int `json:"version"` + SvnRevision string `json:"svn_revision"` + PlatformInfo string `json:"platform_info"` + BuildInfo string `json:"build_info"` + Argv []string `json:"argv"` + ExitStatus int `json:"exit_status"` + } `json:"smartctl"` + Device struct { + Name string `json:"name"` + InfoName string `json:"info_name"` + Type string `json:"type"` + Protocol string `json:"protocol"` + } `json:"device"` + ModelFamily string `json:"model_family"` + ModelName string `json:"model_name"` + SerialNumber string `json:"serial_number"` + Wwn struct { + Naa int `json:"naa"` + Oui int `json:"oui"` + ID int `json:"id"` + } `json:"wwn"` + FirmwareVersion string `json:"firmware_version"` + UserCapacity struct { + Blocks uint64 `json:"blocks"` + Bytes uint64 `json:"bytes"` + } `json:"user_capacity"` + LogicalBlockSize int `json:"logical_block_size"` + PhysicalBlockSize int `json:"physical_block_size"` + RotationRate int `json:"rotation_rate"` + FormFactor struct { + AtaValue int `json:"ata_value"` + Name string `json:"name"` + } `json:"form_factor"` + Trim struct { + Supported bool `json:"supported"` + } `json:"trim"` + InSmartctlDatabase bool `json:"in_smartctl_database"` + AtaVersion struct { + String string `json:"string"` + MajorValue int `json:"major_value"` + MinorValue int `json:"minor_value"` + } `json:"ata_version"` + SataVersion struct { + String string `json:"string"` + Value int `json:"value"` + } `json:"sata_version"` + InterfaceSpeed struct { + Max struct { + SataValue int `json:"sata_value"` + String string `json:"string"` + UnitsPerSecond int `json:"units_per_second"` + BitsPerUnit int `json:"bits_per_unit"` + } `json:"max"` + Current struct { + SataValue int `json:"sata_value"` + String string `json:"string"` + UnitsPerSecond int `json:"units_per_second"` + BitsPerUnit int `json:"bits_per_unit"` + } `json:"current"` + } `json:"interface_speed"` + LocalTime struct { + TimeT int `json:"time_t"` + Asctime string `json:"asctime"` + } `json:"local_time"` + SmartStatus struct { + Passed bool `json:"passed"` + } `json:"smart_status"` + AtaSmartData struct { + OfflineDataCollection struct { + Status struct { + Value int `json:"value"` + String string `json:"string"` + Passed bool `json:"passed"` + } `json:"status"` + CompletionSeconds int `json:"completion_seconds"` + } `json:"offline_data_collection"` + SelfTest struct { + Status struct { + Value int `json:"value"` + String string `json:"string"` + Passed bool `json:"passed"` + } `json:"status"` + PollingMinutes struct { + Short int `json:"short"` + Extended int `json:"extended"` + } `json:"polling_minutes"` + } `json:"self_test"` + Capabilities struct { + Values []int `json:"values"` + ExecOfflineImmediateSupported bool `json:"exec_offline_immediate_supported"` + OfflineIsAbortedUponNewCmd bool `json:"offline_is_aborted_upon_new_cmd"` + OfflineSurfaceScanSupported bool `json:"offline_surface_scan_supported"` + SelfTestsSupported bool `json:"self_tests_supported"` + ConveyanceSelfTestSupported bool `json:"conveyance_self_test_supported"` + SelectiveSelfTestSupported bool `json:"selective_self_test_supported"` + AttributeAutosaveEnabled bool `json:"attribute_autosave_enabled"` + ErrorLoggingSupported bool `json:"error_logging_supported"` + GpLoggingSupported bool `json:"gp_logging_supported"` + } `json:"capabilities"` + } `json:"ata_smart_data"` + AtaSctCapabilities struct { + Value int `json:"value"` + ErrorRecoveryControlSupported bool `json:"error_recovery_control_supported"` + FeatureControlSupported bool `json:"feature_control_supported"` + DataTableSupported bool `json:"data_table_supported"` + } `json:"ata_sct_capabilities"` + AtaSmartAttributes struct { + Revision int `json:"revision"` + Table []struct { + ID int `json:"id"` + Name string `json:"name"` + Value int `json:"value"` + Worst int `json:"worst"` + Thresh int `json:"thresh"` + WhenFailed string `json:"when_failed"` + Flags struct { + Value int `json:"value"` + String string `json:"string"` + Prefailure bool `json:"prefailure"` + UpdatedOnline bool `json:"updated_online"` + Performance bool `json:"performance"` + ErrorRate bool `json:"error_rate"` + EventCount bool `json:"event_count"` + AutoKeep bool `json:"auto_keep"` + } `json:"flags"` + Raw struct { + Value int `json:"value"` + String string `json:"string"` + } `json:"raw"` + } `json:"table"` + } `json:"ata_smart_attributes"` + PowerOnTime struct { + Hours int `json:"hours"` + } `json:"power_on_time"` + PowerCycleCount int `json:"power_cycle_count"` + Temperature struct { + Current int `json:"current"` + } `json:"temperature"` + AtaSmartErrorLog struct { + Summary struct { + Revision int `json:"revision"` + Count int `json:"count"` + } `json:"summary"` + } `json:"ata_smart_error_log"` + AtaSmartSelfTestLog struct { + Standard struct { + Revision int `json:"revision"` + Count int `json:"count"` + } `json:"standard"` + } `json:"ata_smart_self_test_log"` + AtaSmartSelectiveSelfTestLog struct { + Revision int `json:"revision"` + Table []struct { + LbaMin int `json:"lba_min"` + LbaMax int `json:"lba_max"` + Status struct { + Value int `json:"value"` + String string `json:"string"` + } `json:"status"` + } `json:"table"` + Flags struct { + Value int `json:"value"` + RemainderScanEnabled bool `json:"remainder_scan_enabled"` + } `json:"flags"` + PowerUpScanResumeMinutes int `json:"power_up_scan_resume_minutes"` + } `json:"ata_smart_selective_self_test_log"` +} + + +type SmartInfoForNvme struct { + JSONFormatVersion [2]int `json:"json_format_version"` + Smartctl struct { + Version [2]int `json:"version"` + SVNRevision string `json:"svn_revision"` + PlatformInfo string `json:"platform_info"` + BuildInfo string `json:"build_info"` + Argv []string `json:"argv"` + ExitStatus int `json:"exit_status"` + } `json:"smartctl"` + Device struct { + Name string `json:"name"` + InfoName string `json:"info_name"` + Type string `json:"type"` + Protocol string `json:"protocol"` + } `json:"device"` + ModelName string `json:"model_name"` + SerialNumber string `json:"serial_number"` + FirmwareVersion string `json:"firmware_version"` + NVMePCIVendor struct { + ID int `json:"id"` + SubsystemID int `json:"subsystem_id"` + } `json:"nvme_pci_vendor"` + NVMeIEEEOUIIdentifier int `json:"nvme_ieee_oui_identifier"` + NVMeTotalCapacity int `json:"nvme_total_capacity"` + NVMeUnallocatedCapacity int `json:"nvme_unallocated_capacity"` + NVMeControllerID int `json:"nvme_controller_id"` + NVMeVersion struct { + String string `json:"string"` + Value int `json:"value"` + } `json:"nvme_version"` + NVMeNumberOfNamespaces int `json:"nvme_number_of_namespaces"` + NVMeNamespaces []struct { + ID int `json:"id"` + Size struct { + Blocks int `json:"blocks"` + Bytes int `json:"bytes"` + } `json:"size"` + Capacity struct { + Blocks int `json:"blocks"` + Bytes int `json:"bytes"` + } `json:"capacity"` + Utilization struct { + Blocks int `json:"blocks"` + Bytes int `json:"bytes"` + } `json:"utilization"` + FormattedLBASize int `json:"formatted_lba_size"` + EUI64 struct { + OUI int `json:"oui"` + ExtID int `json:"ext_id"` + } `json:"eui64"` + } `json:"nvme_namespaces"` + UserCapacity struct { + Blocks uint64 `json:"blocks"` + Bytes uint64 `json:"bytes"` + } `json:"user_capacity"` + LogicalBlockSize int `json:"logical_block_size"` + LocalTime struct { + TimeT int64 `json:"time_t"` + Asctime string `json:"asctime"` + } `json:"local_time"` + SmartStatus struct { + Passed bool `json:"passed"` + NVMe struct { + Value int `json:"value"` + } `json:"nvme"` + } `json:"smart_status"` + NVMeSmartHealthInformationLog struct { + CriticalWarning int `json:"critical_warning"` + Temperature int `json:"temperature"` + AvailableSpare int `json:"available_spare"` + AvailableSpareThreshold int `json:"available_spare_threshold"` + PercentageUsed int `json:"percentage_used"` + DataUnitsRead int `json:"data_units_read"` + DataUnitsWritten int `json:"data_units_written"` + HostReads int `json:"host_reads"` + HostWrites int `json:"host_writes"` + ControllerBusyTime int `json:"controller_busy_time"` + PowerCycles int `json:"power_cycles"` + PowerOnHours int `json:"power_on_hours"` + UnsafeShutdowns int `json:"unsafe_shutdowns"` + MediaErrors int `json:"media_errors"` + NumErrLogEntries int `json:"num_err_log_entries"` + WarningTempTime int `json:"warning_temp_time"` + CriticalCompTime int `json:"critical_comp_time"` + TemperatureSensors []int `json:"temperature_sensors"` + } `json:"nvme_smart_health_information_log"` + Temperature struct { + Current int `json:"current"` + } `json:"temperature"` + PowerCycleCount int `json:"power_cycle_count"` + PowerOnTime struct { + Hours int `json:"hours"` + } `json:"power_on_time"` +} \ No newline at end of file diff --git a/beszel/internal/entities/system/system.go b/beszel/internal/entities/system/system.go index 08c8d6b4..d3f2b99e 100644 --- a/beszel/internal/entities/system/system.go +++ b/beszel/internal/entities/system/system.go @@ -8,29 +8,30 @@ import ( ) type Stats struct { - Cpu float64 `json:"cpu"` - MaxCpu float64 `json:"cpum,omitempty"` - Mem float64 `json:"m"` - MemUsed float64 `json:"mu"` - MemPct float64 `json:"mp"` - MemBuffCache float64 `json:"mb"` - MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory - Swap float64 `json:"s,omitempty"` - SwapUsed float64 `json:"su,omitempty"` - DiskTotal float64 `json:"d"` - DiskUsed float64 `json:"du"` - DiskPct float64 `json:"dp"` - DiskReadPs float64 `json:"dr"` - DiskWritePs float64 `json:"dw"` - MaxDiskReadPs float64 `json:"drm,omitempty"` - MaxDiskWritePs float64 `json:"dwm,omitempty"` - NetworkSent float64 `json:"ns"` - NetworkRecv float64 `json:"nr"` - MaxNetworkSent float64 `json:"nsm,omitempty"` - MaxNetworkRecv float64 `json:"nrm,omitempty"` - Temperatures map[string]float64 `json:"t,omitempty"` - ExtraFs map[string]*FsStats `json:"efs,omitempty"` - GPUData map[string]GPUData `json:"g,omitempty"` + Cpu float64 `json:"cpu"` + MaxCpu float64 `json:"cpum,omitempty"` + Mem float64 `json:"m"` + MemUsed float64 `json:"mu"` + MemPct float64 `json:"mp"` + MemBuffCache float64 `json:"mb"` + MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory + Swap float64 `json:"s,omitempty"` + SwapUsed float64 `json:"su,omitempty"` + DiskTotal float64 `json:"d"` + DiskUsed float64 `json:"du"` + DiskPct float64 `json:"dp"` + DiskReadPs float64 `json:"dr"` + DiskWritePs float64 `json:"dw"` + MaxDiskReadPs float64 `json:"drm,omitempty"` + MaxDiskWritePs float64 `json:"dwm,omitempty"` + NetworkSent float64 `json:"ns"` + NetworkRecv float64 `json:"nr"` + MaxNetworkSent float64 `json:"nsm,omitempty"` + MaxNetworkRecv float64 `json:"nrm,omitempty"` + Temperatures map[string]float64 `json:"t,omitempty"` + ExtraFs map[string]*FsStats `json:"efs,omitempty"` + GPUData map[string]GPUData `json:"g,omitempty"` + SmartData map[string]SmartData `json:"sm,omitempty"` } type GPUData struct { @@ -73,6 +74,31 @@ const ( Freebsd ) +type SmartData struct { + ModelFamily string `json:"mf,omitempty"` + ModelName string `json:"mn,omitempty"` + SerialNumber string `json:"sn,omitempty"` + FirmwareVersion string `json:"fv,omitempty"` + Capacity uint64 `json:"c,omitempty"` + SmartStatus string `json:"s,omitempty"` + DiskName string `json:"dn,omitempty"` // something like /dev/sda + DiskType string `json:"dt,omitempty"` + Temperature int `json:"t,omitempty"` + Attributes []*SmartAttribute `json:"a,omitempty"` +} + +type SmartAttribute struct { + Id int `json:"id,omitempty"` + Name string `json:"n"` + Value int `json:"v,omitempty"` + Worst int `json:"w,omitempty"` + Threshold int `json:"t,omitempty"` + RawValue int `json:"rv"` + RawString string `json:"rs,omitempty"` + Flags string `json:"f,omitempty"` + WhenFailed string `json:"wf,omitempty"` +} + type Info struct { Hostname string `json:"h"` KernelVersion string `json:"k,omitempty"` diff --git a/beszel/site/src/components/routes/system.tsx b/beszel/site/src/components/routes/system.tsx index 14540cf0..34ec6445 100644 --- a/beszel/site/src/components/routes/system.tsx +++ b/beszel/site/src/components/routes/system.tsx @@ -35,10 +35,12 @@ import { Input } from "../ui/input" import { ChartAverage, ChartMax, Rows, TuxIcon, WindowsIcon, AppleIcon, FreeBsdIcon } from "../ui/icons" import { useIntersectionObserver } from "@/lib/use-intersection-observer" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs" import { timeTicks } from "d3-time" import { useLingui } from "@lingui/react/macro" import { $router, navigate } from "../router" import { getPagePath } from "@nanostores/router" +import DisksTab from "../tabs/disks-tab" const AreaChartDefault = lazy(() => import("../charts/area-chart")) const ContainerChart = lazy(() => import("../charts/container-chart")) @@ -463,6 +465,14 @@ export default function SystemDetail({ name }: { name: string }) { + {/* tabs for different views */} + + + Systems + Disks + + + {/* main charts */}
)} + + + + + +
{/* add space for tooltip if more than 12 containers */} diff --git a/beszel/site/src/components/tabs/disks-tab.tsx b/beszel/site/src/components/tabs/disks-tab.tsx new file mode 100644 index 00000000..29b3f753 --- /dev/null +++ b/beszel/site/src/components/tabs/disks-tab.tsx @@ -0,0 +1,631 @@ +"use client" + +import * as React from "react" +import { + ColumnDef, + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState, +} from "@tanstack/react-table" +import { Activity, Box, Binary, Container, ChevronDown, Clock, HardDrive, Thermometer, Tags, MoreHorizontal } from "lucide-react" + +import { Button } from "../ui/button" +import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../ui/dialog" +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "../ui/dropdown-menu" +import { Input } from "../ui/input" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table" +import { Badge } from "../ui/badge" +import { SmartData, SmartAttribute } from "@/types" + + +// Column definition for S.M.A.R.T. attributes table +export const smartColumns: ColumnDef[] = [ + { + accessorKey: "id", + header: "ID", + cell: ({ row }) => { + const id = row.getValue("id") as number | undefined + return
{id || ""}
+ }, + enableSorting: false, + }, + { + accessorKey: "n", + header: "Name", + cell: ({ row }) => ( +
{row.getValue("n")}
+ ), + enableSorting: false, + }, + { + accessorKey: "rs", + header: "Value", + cell: ({ row }) => { + // if raw string is not empty, use it, otherwise use raw value + const rawString = row.getValue("rs") as string | undefined + const rawValue = row.original.rv + const displayValue = rawString || rawValue?.toString() || "-" + return
{displayValue}
+ }, + enableSorting: false, + }, + { + accessorKey: "v", + header: "Normalized", + cell: ({ row }) => ( +
{row.getValue("v")}
+ ), + enableSorting: false, + }, + { + accessorKey: "w", + header: "Worst", + cell: ({ row }) => { + const worst = row.getValue("w") as number | undefined + return
{worst || ""}
+ }, + enableSorting: false, + }, + { + accessorKey: "t", + header: "Threshold", + cell: ({ row }) => { + const threshold = row.getValue("t") as number | undefined + return
{threshold || ""}
+ }, + enableSorting: false, + }, + { + accessorKey: "f", + header: "Flags", + cell: ({ row }) => { + const flags = row.getValue("f") as string | undefined + return
{flags || ""}
+ }, + enableSorting: false, + }, + { + accessorKey: "wf", + header: "Failing", + cell: ({ row }) => { + const whenFailed = row.getValue("wf") as string | undefined + return
{whenFailed || ""}
+ }, + enableSorting: false, + }, +] + +export type DiskInfo = { + device: string + model: string + serialNumber: string + firmwareVersion: string + capacity: string + status: string + temperature: number + deviceType: string + powerOnHours?: number + powerCycles?: number +} + +// Function to format capacity display +function formatCapacity(bytes: number): string { + const units = [ + { name: 'PB', size: 1024 ** 5 }, + { name: 'TB', size: 1024 ** 4 }, + { name: 'GB', size: 1024 ** 3 }, + { name: 'MB', size: 1024 ** 2 }, + { name: 'KB', size: 1024 ** 1 }, + { name: 'B', size: 1 } + ] + + for (const unit of units) { + if (bytes >= unit.size) { + const value = bytes / unit.size + // For bytes, don't show decimals; for other units show one decimal place + const decimals = unit.name === 'B' ? 0 : 1 + return `${value.toFixed(decimals)} ${unit.name}` + } + } + + return '0 B' +} + +// Function to convert SmartData to DiskInfo +function convertSmartDataToDiskInfo(smartDataRecord: Record): DiskInfo[] { + return Object.entries(smartDataRecord).map(([key, smartData]) => ({ + device: smartData.dn || key, + model: smartData.mn || "Unknown", + serialNumber: smartData.sn || "Unknown", + firmwareVersion: smartData.fv || "Unknown", + capacity: smartData.c ? formatCapacity(smartData.c) : "Unknown", + status: smartData.s || "Unknown", + temperature: smartData.t || 0, + deviceType: smartData.dt || "Unknown", + // These fields need to be extracted from SmartAttribute if available + powerOnHours: smartData.a?.find(attr => attr.n.toLowerCase().includes("poweronhours") || attr.n.toLowerCase().includes("power_on_hours"))?.rv, + powerCycles: smartData.a?.find(attr => attr.n.toLowerCase().includes("power") && attr.n.toLowerCase().includes("cycle"))?.rv, + })) +} + +// S.M.A.R.T. details dialog component +function SmartDialog({ disk, smartData }: { disk: DiskInfo; smartData?: SmartData }) { + const [open, setOpen] = React.useState(false) + + const smartAttributes = smartData?.a || [] + + // Find all attributes where when failed is not empty + const failedAttributes = smartAttributes.filter(attr => attr.wf && attr.wf.trim() !== '') + + const table = useReactTable({ + data: smartAttributes, + columns: smartColumns, + getCoreRowModel: getCoreRowModel(), + enableSorting: false, + }) + + return ( + + + e.preventDefault()}> + View S.M.A.R.T. + + + + + S.M.A.R.T. Details - {disk.device} + + S.M.A.R.T. attributes for {disk.model} ({disk.serialNumber}) + + + {smartData?.s && ( +
+

+ S.M.A.R.T. Self-Test: {smartData.s} +

+ {failedAttributes.length > 0 && ( +

+ Failed Attributes: {failedAttributes.map(attr => attr.n).join(", ")} +

+ )} +
+ )} +
+ {smartAttributes.length > 0 ? ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => { + // Check if the attribute is failed + const isFailedAttribute = row.original.wf && row.original.wf.trim() !== ''; + + return ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + ); + })} + +
+
+ ) : ( +
+ No S.M.A.R.T. attributes available for this device. +
+ )} +
+
+
+ ) +} + +export const columns: ColumnDef[] = [ + { + accessorKey: "device", + header: () => ( +
+ + Device +
+ ), + cell: ({ row }) => ( +
{row.getValue("device")}
+ ), + enableSorting: false, + }, + { + accessorKey: "model", + header: () => ( +
+ + Model +
+ ), + cell: ({ row }) => ( +
+ {row.getValue("model")} +
+ ), + enableSorting: false, + }, + { + accessorKey: "capacity", + header: () => ( +
+ + Capacity +
+ ), + cell: ({ row }) => ( +
{row.getValue("capacity")}
+ ), + enableSorting: false, + }, + { + accessorKey: "temperature", + header: () => ( +
+ + Temp. +
+ ), + cell: ({ row }) => { + const temp = row.getValue("temperature") as number + const getTemperatureColor = (temp: number) => { + if (temp >= 60) return "destructive" + if (temp >= 45) return "secondary" + return "default" + } + return ( + + {temp}°C + + ) + }, + enableSorting: false, + }, + { + accessorKey: "status", + header: () => ( +
+ + Status +
+ ), + cell: ({ row }) => { + const status = row.getValue("status") as string + return ( + + {status} + + ) + }, + enableSorting: false, + }, + { + accessorKey: "deviceType", + header: () => ( +
+ + Type +
+ ), + cell: ({ row }) => ( + + {row.getValue("deviceType")} + + ), + enableSorting: false, + }, + { + accessorKey: "powerOnHours", + header: () => ( +
+ + Power On Time +
+ ), + cell: ({ row }) => { + const hours = row.getValue("powerOnHours") as number | undefined + if (!hours && hours !== 0) { + return ( +
+ N/A +
+ ) + } + const days = Math.floor(hours / 24) + return ( +
+
{hours.toLocaleString()} hours
+
{days} days
+
+ ) + }, + enableSorting: false, + }, + { + accessorKey: "serialNumber", + header: () => ( +
+ + Serial Number +
+ ), + cell: ({ row }) => ( +
{row.getValue("serialNumber")}
+ ), + enableSorting: false, + }, + { + id: "actions", + enableHiding: false, + cell: () => null, // This will be overwritten by columnsWithSmartData + }, +] + +export default function DisksTab({ smartData }: { smartData?: Record }) { + const [sorting, setSorting] = React.useState([]) + const [columnFilters, setColumnFilters] = React.useState([]) + const [columnVisibility, setColumnVisibility] = React.useState({}) + const [rowSelection, setRowSelection] = React.useState({}) + + // Convert SmartData to DiskInfo, if no data use empty array + const diskData = React.useMemo(() => { + return smartData ? convertSmartDataToDiskInfo(smartData) : [] + }, [smartData]) + + // Create column definitions with SmartData + const columnsWithSmartData = React.useMemo(() => { + return columns.map(column => { + if (column.id === "actions") { + return { + ...column, + cell: ({ row }: { row: any }) => { + const disk = row.original as DiskInfo + // Find the corresponding SmartData + const diskSmartData = smartData ? Object.values(smartData).find( + sd => sd.dn === disk.device || sd.mn === disk.model + ) : undefined + + return ( + + + + + + Actions + + + navigator.clipboard.writeText(disk.device)} + > + Copy device path + + navigator.clipboard.writeText(disk.serialNumber)} + > + Copy serial number + + + + ) + } + } + } + return column + }) + }, [smartData]) + + const table = useReactTable({ + data: diskData, + columns: columnsWithSmartData, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }) + + return ( +
+ + + Disk Information + Disk information and S.M.A.R.T. data + +
+
+
+ + table.getColumn("device")?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> + + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + return ( + + column.toggleVisibility(!!value) + } + > + {column.id} + + ) + })} + + +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {smartData ? "No disk data available." : "Loading disk data..."} + + + )} + +
+
+
+
+ {table.getFilteredRowModel().rows.length} disk device(s) +
+
+ + +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/beszel/site/src/components/ui/table.tsx b/beszel/site/src/components/ui/table.tsx index ca157f65..4e18154f 100644 --- a/beszel/site/src/components/ui/table.tsx +++ b/beszel/site/src/components/ui/table.tsx @@ -51,7 +51,7 @@ const TableHead = React.forwardRef>( ({ className, ...props }, ref) => ( - + ) ) TableCell.displayName = "TableCell" diff --git a/beszel/site/src/types.d.ts b/beszel/site/src/types.d.ts index d6095e4d..e5ee6a1f 100644 --- a/beszel/site/src/types.d.ts +++ b/beszel/site/src/types.d.ts @@ -100,6 +100,8 @@ export interface SystemStats { efs?: Record /** GPU data */ g?: Record + /** SMART data */ + sm?: Record } export interface GPUData { @@ -208,3 +210,47 @@ interface AlertInfo { /** Single value description (when there's only one value, like status) */ singleDesc?: () => string } + +export interface SmartData { + /** model family */ + mf?: string + /** model name */ + mn?: string + /** serial number */ + sn?: string + /** firmware version */ + fv?: string + /** capacity */ + c?: number + /** smart status */ + s?: string + /** disk name (like /dev/sda) */ + dn?: string + /** disk type */ + dt?: string + /** temperature */ + t?: number + /** attributes */ + a?: SmartAttribute[] +} + +export interface SmartAttribute { + /** id */ + id?: number + /** name */ + n: string + /** value */ + v: number + /** worst */ + w?: number + /** threshold */ + t?: number + /** raw value */ + rv?: number + /** raw string */ + rs?: string + /** flags */ + f?: string + /** when failed */ + wf?: string +}