mirror of
https://github.com/henrygd/beszel.git
synced 2026-08-07 19:11:49 +02:00
Compare commits
8 Commits
ae84919c39
...
614-smart
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d5ec267d | ||
|
|
ca7642cc91 | ||
|
|
68009c85a5 | ||
|
|
1c7c64c4aa | ||
|
|
b05966d30b | ||
|
|
ea90f6a596 | ||
|
|
f1e43b2593 | ||
|
|
748d18321d |
@@ -38,6 +38,7 @@ builds:
|
||||
- mips64
|
||||
- riscv64
|
||||
- mipsle
|
||||
- ppc64le
|
||||
ignore:
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
|
||||
@@ -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(""))
|
||||
|
||||
@@ -89,6 +89,10 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
||||
|
||||
systemStats.Temperatures = make(map[string]float64, len(temps))
|
||||
for i, sensor := range temps {
|
||||
// scale temperature
|
||||
if sensor.Temperature != 0 && sensor.Temperature < 1 {
|
||||
sensor.Temperature = scaleTemperature(sensor.Temperature)
|
||||
}
|
||||
// skip if temperature is unreasonable
|
||||
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
||||
continue
|
||||
@@ -141,3 +145,19 @@ func isValidSensor(sensorName string, config *SensorConfig) bool {
|
||||
|
||||
return config.isBlacklist
|
||||
}
|
||||
|
||||
// scaleTemperature scales temperatures in fractional values to reasonable Celsius values
|
||||
func scaleTemperature(temp float64) float64 {
|
||||
if temp > 1 {
|
||||
return temp
|
||||
}
|
||||
scaled100 := temp * 100
|
||||
scaled1000 := temp * 1000
|
||||
|
||||
if scaled100 >= 15 && scaled100 <= 95 {
|
||||
return scaled100
|
||||
} else if scaled1000 >= 15 && scaled1000 <= 95 {
|
||||
return scaled1000
|
||||
}
|
||||
return scaled100
|
||||
}
|
||||
|
||||
@@ -372,3 +372,85 @@ func TestNewSensorConfig(t *testing.T) {
|
||||
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
|
||||
assert.Equal(t, "/test/path", sysPath)
|
||||
}
|
||||
|
||||
func TestScaleTemperature(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input float64
|
||||
expected float64
|
||||
desc string
|
||||
}{
|
||||
// Normal temperatures (no scaling needed)
|
||||
{"normal_cpu_temp", 45.0, 45.0, "Normal CPU temperature"},
|
||||
{"normal_room_temp", 25.0, 25.0, "Normal room temperature"},
|
||||
{"high_cpu_temp", 85.0, 85.0, "High CPU temperature"},
|
||||
// Zero temperature
|
||||
{"zero_temp", 0.0, 0.0, "Zero temperature"},
|
||||
// Fractional values that should use 100x scaling
|
||||
{"fractional_45c", 0.45, 45.0, "0.45 should become 45°C (100x)"},
|
||||
{"fractional_25c", 0.25, 25.0, "0.25 should become 25°C (100x)"},
|
||||
{"fractional_60c", 0.60, 60.0, "0.60 should become 60°C (100x)"},
|
||||
{"fractional_75c", 0.75, 75.0, "0.75 should become 75°C (100x)"},
|
||||
{"fractional_30c", 0.30, 30.0, "0.30 should become 30°C (100x)"},
|
||||
// Fractional values that should use 1000x scaling
|
||||
{"millifractional_45c", 0.045, 45.0, "0.045 should become 45°C (1000x)"},
|
||||
{"millifractional_25c", 0.025, 25.0, "0.025 should become 25°C (1000x)"},
|
||||
{"millifractional_60c", 0.060, 60.0, "0.060 should become 60°C (1000x)"},
|
||||
{"millifractional_75c", 0.075, 75.0, "0.075 should become 75°C (1000x)"},
|
||||
{"millifractional_35c", 0.035, 35.0, "0.035 should become 35°C (1000x)"},
|
||||
// Edge cases - values outside reasonable range
|
||||
{"very_low_fractional", 0.01, 1.0, "0.01 should default to 100x scaling (1°C)"},
|
||||
{"very_high_fractional", 0.99, 99.0, "0.99 should default to 100x scaling (99°C)"},
|
||||
{"extremely_low", 0.001, 0.1, "0.001 should default to 100x scaling (0.1°C)"},
|
||||
// Boundary cases around the reasonable range (15-95°C)
|
||||
{"boundary_low_100x", 0.15, 15.0, "0.15 should use 100x scaling (15°C)"},
|
||||
{"boundary_high_100x", 0.95, 95.0, "0.95 should use 100x scaling (95°C)"},
|
||||
{"boundary_low_1000x", 0.015, 15.0, "0.015 should use 1000x scaling (15°C)"},
|
||||
{"boundary_high_1000x", 0.095, 95.0, "0.095 should use 1000x scaling (95°C)"},
|
||||
// Values just outside reasonable range
|
||||
{"just_below_range_100x", 0.14, 14.0, "0.14 should default to 100x (14°C)"},
|
||||
{"just_above_range_100x", 0.96, 96.0, "0.96 should default to 100x (96°C)"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := scaleTemperature(tt.input)
|
||||
assert.InDelta(t, tt.expected, result, 0.001,
|
||||
"scaleTemperature(%v) = %v, expected %v (%s)",
|
||||
tt.input, result, tt.expected, tt.desc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScaleTemperatureLogic(t *testing.T) {
|
||||
// Test the logic flow for ambiguous cases
|
||||
t.Run("prefers_100x_when_both_valid", func(t *testing.T) {
|
||||
// 0.5 could be 50°C (100x) or 500°C (1000x)
|
||||
// Should prefer 100x since it's tried first and is in range
|
||||
result := scaleTemperature(0.5)
|
||||
expected := 50.0
|
||||
assert.InDelta(t, expected, result, 0.001,
|
||||
"scaleTemperature(0.5) = %v, expected %v (should prefer 100x scaling)",
|
||||
result, expected)
|
||||
})
|
||||
|
||||
t.Run("uses_1000x_when_100x_too_low", func(t *testing.T) {
|
||||
// 0.05 -> 5°C (100x, too low) or 50°C (1000x, in range)
|
||||
// Should use 1000x since 100x is below reasonable range
|
||||
result := scaleTemperature(0.05)
|
||||
expected := 50.0
|
||||
assert.InDelta(t, expected, result, 0.001,
|
||||
"scaleTemperature(0.05) = %v, expected %v (should use 1000x scaling)",
|
||||
result, expected)
|
||||
})
|
||||
|
||||
t.Run("defaults_to_100x_when_both_invalid", func(t *testing.T) {
|
||||
// 0.005 -> 0.5°C (100x, too low) or 5°C (1000x, too low)
|
||||
// Should default to 100x scaling
|
||||
result := scaleTemperature(0.005)
|
||||
expected := 0.5
|
||||
assert.InDelta(t, expected, result, 0.001,
|
||||
"scaleTemperature(0.005) = %v, expected %v (should default to 100x)",
|
||||
result, expected)
|
||||
})
|
||||
}
|
||||
|
||||
304
beszel/internal/agent/smart.go
Normal file
304
beszel/internal/agent/smart.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
269
beszel/internal/entities/smart/smart.go
Normal file
269
beszel/internal/entities/smart/smart.go
Normal file
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 }) {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* tabs for different views */}
|
||||
<Tabs defaultValue="systems" className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="systems">Systems</TabsTrigger>
|
||||
<TabsTrigger value="disks">Disks</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="systems" className="mt-4">
|
||||
{/* main charts */}
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
<ChartCard
|
||||
@@ -660,6 +670,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="disks" className="mt-4">
|
||||
<DisksTab smartData={systemStats.at(-1)?.stats.sm} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* add space for tooltip if more than 12 containers */}
|
||||
|
||||
631
beszel/site/src/components/tabs/disks-tab.tsx
Normal file
631
beszel/site/src/components/tabs/disks-tab.tsx
Normal file
@@ -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<SmartAttribute>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
cell: ({ row }) => {
|
||||
const id = row.getValue("id") as number | undefined
|
||||
return <div className="font-medium">{id || ""}</div>
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "n",
|
||||
header: "Name",
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.getValue("n")}</div>
|
||||
),
|
||||
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 <div className="font-mono text-sm">{displayValue}</div>
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "v",
|
||||
header: "Normalized",
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.getValue("v")}</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "w",
|
||||
header: "Worst",
|
||||
cell: ({ row }) => {
|
||||
const worst = row.getValue("w") as number | undefined
|
||||
return <div>{worst || ""}</div>
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "t",
|
||||
header: "Threshold",
|
||||
cell: ({ row }) => {
|
||||
const threshold = row.getValue("t") as number | undefined
|
||||
return <div>{threshold || ""}</div>
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "f",
|
||||
header: "Flags",
|
||||
cell: ({ row }) => {
|
||||
const flags = row.getValue("f") as string | undefined
|
||||
return <div className="font-mono text-sm">{flags || ""}</div>
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "wf",
|
||||
header: "Failing",
|
||||
cell: ({ row }) => {
|
||||
const whenFailed = row.getValue("wf") as string | undefined
|
||||
return <div className="font-mono text-sm">{whenFailed || ""}</div>
|
||||
},
|
||||
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<string, SmartData>): 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 (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||
View S.M.A.R.T.
|
||||
</DropdownMenuItem>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>S.M.A.R.T. Details - {disk.device}</DialogTitle>
|
||||
<DialogDescription>
|
||||
S.M.A.R.T. attributes for {disk.model} ({disk.serialNumber})
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{smartData?.s && (
|
||||
<div className={`p-4 rounded-md ${
|
||||
smartData.s === "PASSED"
|
||||
? "bg-green-100 dark:bg-green-900 border border-green-200 dark:border-green-800"
|
||||
: "bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-800"
|
||||
}`}>
|
||||
<h4 className={`font-semibold ${
|
||||
smartData.s === "PASSED"
|
||||
? "text-green-800 dark:text-green-200"
|
||||
: "text-red-800 dark:text-red-200"
|
||||
}`}>
|
||||
S.M.A.R.T. Self-Test: {smartData.s}
|
||||
</h4>
|
||||
{failedAttributes.length > 0 && (
|
||||
<p className="mt-2 text-red-800 dark:text-red-200">
|
||||
Failed Attributes: {failedAttributes.map(attr => attr.n).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{smartAttributes.length > 0 ? (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
// Check if the attribute is failed
|
||||
const isFailedAttribute = row.original.wf && row.original.wf.trim() !== '';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
className={isFailedAttribute ? "text-red-600 dark:text-red-400" : ""}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No S.M.A.R.T. attributes available for this device.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<DiskInfo>[] = [
|
||||
{
|
||||
accessorKey: "device",
|
||||
header: () => (
|
||||
<div className="flex items-center">
|
||||
<HardDrive className="mr-2 h-4 w-4" />
|
||||
Device
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.getValue("device")}</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "model",
|
||||
header: () => (
|
||||
<div className="flex items-center">
|
||||
<Box className="mr-2 h-4 w-4" />
|
||||
Model
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="max-w-[200px] truncate" title={row.getValue("model")}>
|
||||
{row.getValue("model")}
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "capacity",
|
||||
header: () => (
|
||||
<div className="flex items-center">
|
||||
<Container className="mr-2 h-4 w-4" />
|
||||
Capacity
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.getValue("capacity")}</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "temperature",
|
||||
header: () => (
|
||||
<div className="flex items-center">
|
||||
<Thermometer className="mr-2 h-4 w-4" />
|
||||
Temp.
|
||||
</div>
|
||||
),
|
||||
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 (
|
||||
<Badge variant={getTemperatureColor(temp)}>
|
||||
{temp}°C
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "status",
|
||||
header: () => (
|
||||
<div className="flex items-center">
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
Status
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.getValue("status") as string
|
||||
return (
|
||||
<Badge
|
||||
variant={status === "PASSED" ? "default" : "destructive"}
|
||||
className={status === "PASSED" ? "bg-green-500 hover:bg-green-600 text-white" : ""}
|
||||
>
|
||||
{status}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "deviceType",
|
||||
header: () => (
|
||||
<div className="flex items-center">
|
||||
<Tags className="mr-2 h-4 w-4" />
|
||||
Type
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="uppercase">
|
||||
{row.getValue("deviceType")}
|
||||
</Badge>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "powerOnHours",
|
||||
header: () => (
|
||||
<div className="flex items-center">
|
||||
<Clock className="mr-2 h-4 w-4" />
|
||||
Power On Time
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const hours = row.getValue("powerOnHours") as number | undefined
|
||||
if (!hours && hours !== 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
N/A
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const days = Math.floor(hours / 24)
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div>{hours.toLocaleString()} hours</div>
|
||||
<div className="text-muted-foreground text-xs">{days} days</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "serialNumber",
|
||||
header: () => (
|
||||
<div className="flex items-center">
|
||||
<Binary className="mr-2 h-4 w-4" />
|
||||
Serial Number
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="font-mono text-sm">{row.getValue("serialNumber")}</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
cell: () => null, // This will be overwritten by columnsWithSmartData
|
||||
},
|
||||
]
|
||||
|
||||
export default function DisksTab({ smartData }: { smartData?: Record<string, SmartData> }) {
|
||||
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||
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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<SmartDialog disk={disk} smartData={diskSmartData} />
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(disk.device)}
|
||||
>
|
||||
Copy device path
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigator.clipboard.writeText(disk.serialNumber)}
|
||||
>
|
||||
Copy serial number
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
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 (
|
||||
<div>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Disk Information</CardTitle>
|
||||
<CardDescription>Disk information and S.M.A.R.T. data</CardDescription>
|
||||
</CardHeader>
|
||||
<div className="px-6 pb-6">
|
||||
<div className="w-full">
|
||||
<div className="flex items-center py-4">
|
||||
<Input
|
||||
placeholder="Filter devices..."
|
||||
value={(table.getColumn("device")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("device")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="ml-auto">
|
||||
Columns <ChevronDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="rounded-md border grid">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={columns.length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
{smartData ? "No disk data available." : "Loading disk data..."}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex items-center justify-end space-x-2 py-4">
|
||||
<div className="text-muted-foreground flex-1 text-sm">
|
||||
{table.getFilteredRowModel().rows.length} disk device(s)
|
||||
</div>
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -51,7 +51,7 @@ const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-start align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0",
|
||||
"h-12 px-4 text-start align-middle whitespace-nowrap font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -62,7 +62,7 @@ TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pe-0", className)} {...props} />
|
||||
<td ref={ref} className={cn("p-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0", className)} {...props} />
|
||||
)
|
||||
)
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
46
beszel/site/src/types.d.ts
vendored
46
beszel/site/src/types.d.ts
vendored
@@ -100,6 +100,8 @@ export interface SystemStats {
|
||||
efs?: Record<string, ExtraFsStats>
|
||||
/** GPU data */
|
||||
g?: Record<string, GPUData>
|
||||
/** SMART data */
|
||||
sm?: Record<string, SmartData>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
18
readme.md
18
readme.md
@@ -14,13 +14,13 @@ It has a friendly web interface, simple configuration, and is ready to use out o
|
||||
## Features
|
||||
|
||||
- **Lightweight**: Smaller and less resource-intensive than leading solutions.
|
||||
- **Simple**: Easy setup, no need for public internet exposure.
|
||||
- **Simple**: Easy setup with little manual configuration required.
|
||||
- **Docker stats**: Tracks CPU, memory, and network usage history for each container.
|
||||
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, and status.
|
||||
- **Multi-user**: Users manage their own systems. Admins can share systems across users.
|
||||
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
|
||||
- **Automatic backups**: Save and restore data from disk or S3-compatible storage.
|
||||
- **REST API**: Use or update your data in your own scripts and applications.
|
||||
- **Automatic backups**: Save to and restore from disk or S3-compatible storage.
|
||||
<!-- - **REST API**: Use or update your data in your own scripts and applications. -->
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -49,6 +49,18 @@ The [quick start guide](https://beszel.dev/guide/getting-started) and other docu
|
||||
- **Temperature** - Host system sensors.
|
||||
- **GPU usage / temperature / power draw** - Nvidia and AMD only. Must use binary agent.
|
||||
|
||||
## Help and discussion
|
||||
|
||||
Please search existing issues and discussions before opening a new one. I try my best to respond, but may not always have time to do so.
|
||||
|
||||
#### Bug reports and feature requests
|
||||
|
||||
Bug reports and detailed feature requests should be posted on [GitHub issues](https://github.com/henrygd/beszel/issues).
|
||||
|
||||
#### Support and general discussion
|
||||
|
||||
Support requests and general discussion can be posted on [GitHub discussions](https://github.com/henrygd/beszel/discussions) or the community-run [Matrix room](https://matrix.to/#/#beszel:matrix.org): `#beszel:matrix.org`.
|
||||
|
||||
## License
|
||||
|
||||
Beszel is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
|
||||
|
||||
@@ -31,6 +31,12 @@ if ! getent passwd "$SERVICE_USER" >/dev/null; then
|
||||
--gecos "System user for $SERVICE"
|
||||
fi
|
||||
|
||||
# Enable docker
|
||||
if ! getent group docker | grep -q "$SERVICE_USER"; then
|
||||
echo "Adding $SERVICE_USER to docker group"
|
||||
usermod -aG docker "$SERVICE_USER"
|
||||
fi
|
||||
|
||||
# Create config file if it doesn't already exist
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
touch "$CONFIG_FILE"
|
||||
|
||||
@@ -3,7 +3,8 @@ param (
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Key,
|
||||
[int]$Port = 45876,
|
||||
[string]$AgentPath = ""
|
||||
[string]$AgentPath = "",
|
||||
[string]$NSSMPath = ""
|
||||
)
|
||||
|
||||
# Check if key is provided or empty
|
||||
@@ -32,6 +33,78 @@ function Test-CommandExists {
|
||||
return (Get-Command $Command -ErrorAction SilentlyContinue)
|
||||
}
|
||||
|
||||
# Function to find beszel-agent in common installation locations
|
||||
function Find-BeszelAgent {
|
||||
# First check if it's in PATH
|
||||
$agentCmd = Get-Command "beszel-agent" -ErrorAction SilentlyContinue
|
||||
if ($agentCmd) {
|
||||
return $agentCmd.Source
|
||||
}
|
||||
|
||||
# Common installation paths to check
|
||||
$commonPaths = @(
|
||||
"$env:USERPROFILE\scoop\apps\beszel-agent\current\beszel-agent.exe",
|
||||
"$env:ProgramData\scoop\apps\beszel-agent\current\beszel-agent.exe",
|
||||
"$env:LOCALAPPDATA\Microsoft\WinGet\Packages\henrygd.beszel-agent*\beszel-agent.exe",
|
||||
"$env:ProgramFiles\WinGet\Packages\henrygd.beszel-agent*\beszel-agent.exe",
|
||||
"${env:ProgramFiles(x86)}\WinGet\Packages\henrygd.beszel-agent*\beszel-agent.exe",
|
||||
"$env:ProgramFiles\beszel-agent\beszel-agent.exe",
|
||||
"$env:ProgramFiles(x86)\beszel-agent\beszel-agent.exe",
|
||||
"$env:SystemDrive\Users\*\scoop\apps\beszel-agent\current\beszel-agent.exe"
|
||||
)
|
||||
|
||||
foreach ($path in $commonPaths) {
|
||||
# Handle wildcard paths
|
||||
if ($path.Contains("*")) {
|
||||
$foundPaths = Get-ChildItem -Path $path -ErrorAction SilentlyContinue
|
||||
if ($foundPaths) {
|
||||
return $foundPaths[0].FullName
|
||||
}
|
||||
} else {
|
||||
if (Test-Path $path) {
|
||||
return $path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
# Function to find NSSM in common installation locations
|
||||
function Find-NSSM {
|
||||
# First check if it's in PATH
|
||||
$nssmCmd = Get-Command "nssm" -ErrorAction SilentlyContinue
|
||||
if ($nssmCmd) {
|
||||
return $nssmCmd.Source
|
||||
}
|
||||
|
||||
# Common installation paths to check
|
||||
$commonPaths = @(
|
||||
"$env:USERPROFILE\scoop\apps\nssm\current\nssm.exe",
|
||||
"$env:ProgramData\scoop\apps\nssm\current\nssm.exe",
|
||||
"$env:LOCALAPPDATA\Microsoft\WinGet\Packages\NSSM.NSSM*\nssm.exe",
|
||||
"$env:ProgramFiles\WinGet\Packages\NSSM.NSSM*\nssm.exe",
|
||||
"${env:ProgramFiles(x86)}\WinGet\Packages\NSSM.NSSM*\nssm.exe",
|
||||
"$env:SystemDrive\Users\*\scoop\apps\nssm\current\nssm.exe"
|
||||
)
|
||||
|
||||
foreach ($path in $commonPaths) {
|
||||
# Handle wildcard paths
|
||||
if ($path.Contains("*")) {
|
||||
$foundPaths = Get-ChildItem -Path $path -ErrorAction SilentlyContinue
|
||||
if ($foundPaths) {
|
||||
return $foundPaths[0].FullName
|
||||
}
|
||||
} else {
|
||||
if (Test-Path $path) {
|
||||
return $path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $null
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Installation Methods
|
||||
@@ -39,11 +112,17 @@ function Test-CommandExists {
|
||||
# Function to install Scoop
|
||||
function Install-Scoop {
|
||||
Write-Host "Installing Scoop..."
|
||||
|
||||
# Check if running as admin - Scoop should not be installed as admin
|
||||
if (Test-Admin) {
|
||||
throw "Scoop cannot be installed with administrator privileges. Please run this script as a regular user first to install Scoop and beszel-agent, then run as admin to configure the service."
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
|
||||
|
||||
if (-not (Test-CommandExists "scoop")) {
|
||||
throw "Failed to install Scoop"
|
||||
throw "Failed to install Scoop - command not available after installation"
|
||||
}
|
||||
Write-Host "Scoop installed successfully."
|
||||
}
|
||||
@@ -102,8 +181,8 @@ function Install-BeszelAgentWithScoop {
|
||||
Write-Host "Adding beszel bucket..."
|
||||
scoop bucket add beszel https://github.com/henrygd/beszel-scoops | Out-Null
|
||||
|
||||
Write-Host "Installing beszel-agent..."
|
||||
scoop install beszel-agent | Out-Null
|
||||
Write-Host "Installing / updating beszel-agent..."
|
||||
scoop install beszel-agent
|
||||
|
||||
if (-not (Test-CommandExists "beszel-agent")) {
|
||||
throw "Failed to install beszel-agent"
|
||||
@@ -114,8 +193,29 @@ function Install-BeszelAgentWithScoop {
|
||||
|
||||
# Function to install beszel-agent with WinGet
|
||||
function Install-BeszelAgentWithWinGet {
|
||||
Write-Host "Installing beszel-agent..."
|
||||
winget install --exact --id henrygd.beszel-agent --accept-source-agreements --accept-package-agreements | Out-Null
|
||||
Write-Host "Installing / updating beszel-agent..."
|
||||
|
||||
# Temporarily change ErrorActionPreference to allow WinGet to complete and show output
|
||||
$originalErrorActionPreference = $ErrorActionPreference
|
||||
$ErrorActionPreference = "Continue"
|
||||
|
||||
# Use call operator (&) and capture exit code properly
|
||||
& winget install --exact --id henrygd.beszel-agent --accept-source-agreements --accept-package-agreements | Out-Null
|
||||
$wingetExitCode = $LASTEXITCODE
|
||||
|
||||
# Restore original ErrorActionPreference
|
||||
$ErrorActionPreference = $originalErrorActionPreference
|
||||
|
||||
# WinGet exit codes:
|
||||
# 0 = Success
|
||||
# -1978335212 (0x8A150014) = No applicable upgrade found (package is up to date)
|
||||
# -1978335189 (0x8A15002B) = Another "no upgrade needed" variant
|
||||
# Other codes indicate actual errors
|
||||
if ($wingetExitCode -eq -1978335212 -or $wingetExitCode -eq -1978335189) {
|
||||
Write-Host "Package is already up to date." -ForegroundColor Green
|
||||
} elseif ($wingetExitCode -ne 0) {
|
||||
Write-Host "WinGet exit code: $wingetExitCode" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Refresh PATH environment variable to make beszel-agent available in current session
|
||||
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||
@@ -123,7 +223,6 @@ function Install-BeszelAgentWithWinGet {
|
||||
# Find the path to the beszel-agent executable
|
||||
$agentPath = (Get-Command beszel-agent -ErrorAction SilentlyContinue).Source
|
||||
|
||||
|
||||
if (-not $agentPath) {
|
||||
throw "Could not find beszel-agent executable path after installation"
|
||||
}
|
||||
@@ -204,31 +303,41 @@ function Install-NSSMService {
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$Key,
|
||||
[Parameter(Mandatory=$true)]
|
||||
[int]$Port
|
||||
[int]$Port,
|
||||
[string]$NSSMPath = ""
|
||||
)
|
||||
|
||||
Write-Host "Installing beszel-agent service..."
|
||||
|
||||
# Determine the NSSM executable to use
|
||||
$nssmCommand = "nssm"
|
||||
if ($NSSMPath -and (Test-Path $NSSMPath)) {
|
||||
$nssmCommand = $NSSMPath
|
||||
Write-Host "Using NSSM from: $NSSMPath"
|
||||
} elseif (-not (Test-CommandExists "nssm")) {
|
||||
throw "NSSM is not available in PATH and no valid NSSMPath was provided"
|
||||
}
|
||||
|
||||
# Check if service already exists
|
||||
$existingService = Get-Service -Name "beszel-agent" -ErrorAction SilentlyContinue
|
||||
if ($existingService) {
|
||||
Write-Host "Service already exists. Stopping and removing existing service..."
|
||||
try {
|
||||
nssm stop beszel-agent
|
||||
nssm remove beszel-agent confirm
|
||||
& $nssmCommand stop beszel-agent
|
||||
& $nssmCommand remove beszel-agent confirm
|
||||
} catch {
|
||||
Write-Host "Warning: Failed to remove existing service: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
nssm install beszel-agent $AgentPath
|
||||
& $nssmCommand install beszel-agent $AgentPath
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Failed to install beszel-agent service"
|
||||
}
|
||||
|
||||
Write-Host "Configuring service environment variables..."
|
||||
nssm set beszel-agent AppEnvironmentExtra "+KEY=$Key"
|
||||
nssm set beszel-agent AppEnvironmentExtra "+PORT=$Port"
|
||||
& $nssmCommand set beszel-agent AppEnvironmentExtra "+KEY=$Key"
|
||||
& $nssmCommand set beszel-agent AppEnvironmentExtra "+PORT=$Port"
|
||||
|
||||
# Configure log files
|
||||
$logDir = "$env:ProgramData\beszel-agent\logs"
|
||||
@@ -236,8 +345,8 @@ function Install-NSSMService {
|
||||
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
||||
}
|
||||
$logFile = "$logDir\beszel-agent.log"
|
||||
nssm set beszel-agent AppStdout $logFile
|
||||
nssm set beszel-agent AppStderr $logFile
|
||||
& $nssmCommand set beszel-agent AppStdout $logFile
|
||||
& $nssmCommand set beszel-agent AppStderr $logFile
|
||||
}
|
||||
|
||||
# Function to configure firewall rules
|
||||
@@ -275,8 +384,21 @@ function Configure-Firewall {
|
||||
|
||||
# Function to start and monitor the service
|
||||
function Start-BeszelAgentService {
|
||||
param (
|
||||
[string]$NSSMPath = ""
|
||||
)
|
||||
|
||||
Write-Host "Starting beszel-agent service..."
|
||||
nssm start beszel-agent
|
||||
|
||||
# Determine the NSSM executable to use
|
||||
$nssmCommand = "nssm"
|
||||
if ($NSSMPath -and (Test-Path $NSSMPath)) {
|
||||
$nssmCommand = $NSSMPath
|
||||
} elseif (-not (Test-CommandExists "nssm")) {
|
||||
throw "NSSM is not available in PATH and no valid NSSMPath was provided"
|
||||
}
|
||||
|
||||
& $nssmCommand start beszel-agent
|
||||
$startResult = $LASTEXITCODE
|
||||
|
||||
# Only enter the status check loop if the NSSM start command failed
|
||||
@@ -293,7 +415,7 @@ function Start-BeszelAgentService {
|
||||
Start-Sleep -Seconds 1
|
||||
$elapsedTime += 1
|
||||
|
||||
$serviceStatus = nssm status beszel-agent
|
||||
$serviceStatus = & $nssmCommand status beszel-agent
|
||||
|
||||
if ($serviceStatus -eq "SERVICE_RUNNING") {
|
||||
$serviceStarted = $true
|
||||
@@ -331,13 +453,15 @@ try {
|
||||
if (-not $AgentPath) {
|
||||
# Check for problematic case: running as admin and need Scoop
|
||||
if ($isAdmin -and -not (Test-CommandExists "scoop") -and -not (Test-CommandExists "winget")) {
|
||||
Write-Host "ERROR: You're running as administrator but need to install Scoop." -ForegroundColor Red
|
||||
Write-Host "ERROR: You're running as administrator but neither Scoop nor WinGet is available." -ForegroundColor Red
|
||||
Write-Host "Scoop should be installed without admin privileges." -ForegroundColor Red
|
||||
Write-Host "Please run this script again without administrator privileges." -ForegroundColor Red
|
||||
Write-Host ""
|
||||
Write-Host "Please either:" -ForegroundColor Yellow
|
||||
Write-Host "1. Run this script again without administrator privileges" -ForegroundColor Yellow
|
||||
Write-Host "2. Install WinGet and run this script again" -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Installing beszel-agent..."
|
||||
if (Test-CommandExists "scoop") {
|
||||
Write-Host "Using Scoop for installation..."
|
||||
$AgentPath = Install-WithScoop -Key $Key -Port $Port
|
||||
@@ -356,27 +480,84 @@ try {
|
||||
throw "Could not find beszel-agent executable. Make sure it was properly installed."
|
||||
}
|
||||
|
||||
# Find NSSM path if not already provided
|
||||
if (-not $NSSMPath) {
|
||||
$NSSMPath = Find-NSSM
|
||||
|
||||
if (-not $NSSMPath -and (Test-CommandExists "nssm")) {
|
||||
$NSSMPath = (Get-Command "nssm" -ErrorAction SilentlyContinue).Source
|
||||
}
|
||||
|
||||
# If we still don't have NSSM, try to install it if we have package managers
|
||||
if (-not $NSSMPath) {
|
||||
if (Test-CommandExists "winget") {
|
||||
Write-Host "NSSM not found. Attempting to install via WinGet..."
|
||||
try {
|
||||
Install-NSSM -Method "WinGet"
|
||||
$NSSMPath = Find-NSSM
|
||||
if (-not $NSSMPath -and (Test-CommandExists "nssm")) {
|
||||
$NSSMPath = (Get-Command "nssm" -ErrorAction SilentlyContinue).Source
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Failed to install NSSM via WinGet: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
} elseif (Test-CommandExists "scoop") {
|
||||
Write-Host "NSSM not found. Attempting to install via Scoop..."
|
||||
try {
|
||||
Install-NSSM -Method "Scoop"
|
||||
$NSSMPath = Find-NSSM
|
||||
if (-not $NSSMPath -and (Test-CommandExists "nssm")) {
|
||||
$NSSMPath = (Get-Command "nssm" -ErrorAction SilentlyContinue).Source
|
||||
}
|
||||
} catch {
|
||||
Write-Host "Failed to install NSSM via Scoop: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||
}
|
||||
}
|
||||
|
||||
# Final check - if we still don't have NSSM and we're admin, we have a problem
|
||||
if (-not $NSSMPath -and ($isAdmin -or $Elevated)) {
|
||||
throw "NSSM is required for service installation but was not found and could not be installed. Please install NSSM manually or run as a regular user to install it."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Second: If we need admin rights for service installation and we don't have them, relaunch
|
||||
if (-not $isAdmin -and -not $Elevated) {
|
||||
Write-Host "Admin privileges required for service installation. Relaunching as admin..." -ForegroundColor Yellow
|
||||
Write-Host "Check service status with 'nssm status beszel-agent'"
|
||||
Write-Host "Edit service configuration with 'nssm edit beszel-agent'"
|
||||
|
||||
# Prepare arguments for the elevated script
|
||||
$argumentList = @(
|
||||
"-ExecutionPolicy", "Bypass",
|
||||
"-File", "`"$PSCommandPath`"",
|
||||
"-Elevated",
|
||||
"-Key", "`"$Key`"",
|
||||
"-Port", $Port,
|
||||
"-AgentPath", "`"$AgentPath`""
|
||||
)
|
||||
|
||||
# Add NSSMPath if we found it
|
||||
if ($NSSMPath) {
|
||||
$argumentList += "-NSSMPath"
|
||||
$argumentList += "`"$NSSMPath`""
|
||||
}
|
||||
|
||||
# Relaunch the script with the -Elevated switch and pass parameters
|
||||
Start-Process powershell.exe -Verb RunAs -ArgumentList "-File `"$PSCommandPath`" -Elevated -Key `"$Key`" -Port $Port -AgentPath `"$AgentPath`""
|
||||
Start-Process powershell.exe -Verb RunAs -ArgumentList $argumentList
|
||||
exit
|
||||
}
|
||||
|
||||
# Third: If we have admin rights, install service and configure firewall
|
||||
if ($isAdmin -or $Elevated) {
|
||||
# Install the service
|
||||
Install-NSSMService -AgentPath $AgentPath -Key $Key -Port $Port
|
||||
Install-NSSMService -AgentPath $AgentPath -Key $Key -Port $Port -NSSMPath $NSSMPath
|
||||
|
||||
# Configure firewall
|
||||
Configure-Firewall -Port $Port
|
||||
|
||||
# Start the service
|
||||
Start-BeszelAgentService
|
||||
Start-BeszelAgentService -NSSMPath $NSSMPath
|
||||
|
||||
# Pause to see results if this is an elevated window
|
||||
if ($Elevated) {
|
||||
|
||||
@@ -317,14 +317,14 @@ fi
|
||||
if is_alpine; then
|
||||
if ! id -u beszel >/dev/null 2>&1; then
|
||||
echo "Creating a dedicated user for the Beszel Agent service..."
|
||||
adduser -D -H -s /sbin/nologin beszel
|
||||
adduser -S -D -H -s /sbin/nologin beszel
|
||||
fi
|
||||
# Add the user to the docker group to allow access to the Docker socket
|
||||
addgroup beszel docker
|
||||
else
|
||||
if ! id -u beszel >/dev/null 2>&1; then
|
||||
echo "Creating a dedicated user for the Beszel Agent service..."
|
||||
useradd -M -s /bin/false beszel
|
||||
useradd --system --home-dir /nonexistent --shell /bin/false beszel
|
||||
fi
|
||||
# Add the user to the docker group to allow access to the Docker socket
|
||||
usermod -aG docker beszel
|
||||
|
||||
Reference in New Issue
Block a user