mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-14 08:51:49 +02:00
Compare commits
5 Commits
614-smart
...
4395520a28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4395520a28 | ||
|
|
8c52f30a71 | ||
|
|
46316ebffa | ||
|
|
0b04f60b6c | ||
|
|
20b822d072 |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -39,4 +39,4 @@ jobs:
|
|||||||
version: latest
|
version: latest
|
||||||
args: release --clean
|
args: release --clean
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ type Agent struct {
|
|||||||
systemInfo system.Info // Host system info
|
systemInfo system.Info // Host system info
|
||||||
gpuManager *GPUManager // Manages GPU data
|
gpuManager *GPUManager // Manages GPU data
|
||||||
cache *SessionCache // Cache for system stats based on primary session ID
|
cache *SessionCache // Cache for system stats based on primary session ID
|
||||||
smartManager *SmartManager // Manages SMART data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAgent() *Agent {
|
func NewAgent() *Agent {
|
||||||
@@ -63,12 +62,6 @@ func NewAgent() *Agent {
|
|||||||
agent.gpuManager = gm
|
agent.gpuManager = gm
|
||||||
}
|
}
|
||||||
|
|
||||||
if sm, err := NewSmartManager(); err != nil {
|
|
||||||
slog.Debug("SMART", "err", err)
|
|
||||||
} else {
|
|
||||||
agent.smartManager = sm
|
|
||||||
}
|
|
||||||
|
|
||||||
// if debugging, print stats
|
// if debugging, print stats
|
||||||
if agent.debug {
|
if agent.debug {
|
||||||
slog.Debug("Stats", "data", agent.gatherStats(""))
|
slog.Debug("Stats", "data", agent.gatherStats(""))
|
||||||
|
|||||||
@@ -243,21 +243,26 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
|
|||||||
// copy / reset the data
|
// copy / reset the data
|
||||||
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
gpuData := make(map[string]system.GPUData, len(gm.GpuDataMap))
|
||||||
for id, gpu := range gm.GpuDataMap {
|
for id, gpu := range gm.GpuDataMap {
|
||||||
// sum the data
|
var gpuAvg system.GPUData
|
||||||
gpu.Temperature = twoDecimals(gpu.Temperature)
|
|
||||||
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
gpuAvg.Temperature = twoDecimals(gpu.Temperature)
|
||||||
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
gpuAvg.MemoryUsed = twoDecimals(gpu.MemoryUsed)
|
||||||
gpu.Usage = twoDecimals(gpu.Usage / gpu.Count)
|
gpuAvg.MemoryTotal = twoDecimals(gpu.MemoryTotal)
|
||||||
gpu.Power = twoDecimals(gpu.Power / gpu.Count)
|
|
||||||
// reset the count
|
// avoid division by zero
|
||||||
gpu.Count = 1
|
if gpu.Count > 0 {
|
||||||
// dereference to avoid overwriting anything else
|
gpuAvg.Usage = twoDecimals(gpu.Usage / gpu.Count)
|
||||||
gpuCopy := *gpu
|
gpuAvg.Power = twoDecimals(gpu.Power / gpu.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset accumulators in the original
|
||||||
|
gpu.Usage, gpu.Power, gpu.Count = 0, 0, 0
|
||||||
|
|
||||||
// append id to the name if there are multiple GPUs with the same name
|
// append id to the name if there are multiple GPUs with the same name
|
||||||
if nameCounts[gpu.Name] > 1 {
|
if nameCounts[gpu.Name] > 1 {
|
||||||
gpuCopy.Name = fmt.Sprintf("%s %s", gpu.Name, id)
|
gpuAvg.Name = fmt.Sprintf("%s %s", gpu.Name, id)
|
||||||
}
|
}
|
||||||
gpuData[id] = gpuCopy
|
gpuData[id] = gpuAvg
|
||||||
}
|
}
|
||||||
slog.Debug("GPU", "data", gpuData)
|
slog.Debug("GPU", "data", gpuData)
|
||||||
return gpuData
|
return gpuData
|
||||||
|
|||||||
@@ -279,6 +279,19 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
Count: 1,
|
Count: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "orin nano",
|
||||||
|
input: "06-18-2025 11:25:24 RAM 3452/7620MB (lfb 25x4MB) SWAP 1518/16384MB (cached 174MB) CPU [1%@1420,2%@1420,0%@1420,2%@1420,2%@729,1%@729] GR3D_FREQ 0% cpu@50.031C soc2@49.031C soc0@50C gpu@49.031C tj@50.25C soc1@50.25C VDD_IN 4824mW/4824mW VDD_CPU_GPU_CV 518mW/518mW VDD_SOC 1475mW/1475mW",
|
||||||
|
wantMetrics: &system.GPUData{
|
||||||
|
Name: "GPU",
|
||||||
|
MemoryUsed: 3452.0,
|
||||||
|
MemoryTotal: 7620.0,
|
||||||
|
Usage: 0.0,
|
||||||
|
Temperature: 50.25,
|
||||||
|
Power: 0.518,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "missing temperature",
|
name: "missing temperature",
|
||||||
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
||||||
@@ -318,44 +331,75 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetCurrentData(t *testing.T) {
|
func TestGetCurrentData(t *testing.T) {
|
||||||
gm := &GPUManager{
|
t.Run("calculates averages and resets accumulators", func(t *testing.T) {
|
||||||
GpuDataMap: map[string]*system.GPUData{
|
gm := &GPUManager{
|
||||||
"0": {
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
Name: "GPU1",
|
"0": {
|
||||||
Temperature: 50,
|
Name: "GPU1",
|
||||||
MemoryUsed: 2048,
|
Temperature: 50,
|
||||||
MemoryTotal: 4096,
|
MemoryUsed: 2048,
|
||||||
Usage: 100, // 100 over 2 counts = 50 avg
|
MemoryTotal: 4096,
|
||||||
Power: 200, // 200 over 2 counts = 100 avg
|
Usage: 100, // 100 over 2 counts = 50 avg
|
||||||
Count: 2,
|
Power: 200, // 200 over 2 counts = 100 avg
|
||||||
|
Count: 2,
|
||||||
|
},
|
||||||
|
"1": {
|
||||||
|
Name: "GPU1",
|
||||||
|
Temperature: 60,
|
||||||
|
MemoryUsed: 3072,
|
||||||
|
MemoryTotal: 8192,
|
||||||
|
Usage: 30,
|
||||||
|
Power: 60,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"1": {
|
}
|
||||||
Name: "GPU1",
|
|
||||||
Temperature: 60,
|
result := gm.GetCurrentData()
|
||||||
MemoryUsed: 3072,
|
|
||||||
MemoryTotal: 8192,
|
// Verify name disambiguation
|
||||||
Usage: 30,
|
assert.Equal(t, "GPU1 0", result["0"].Name)
|
||||||
Power: 60,
|
assert.Equal(t, "GPU1 1", result["1"].Name)
|
||||||
Count: 1,
|
|
||||||
|
// Check averaged values in the result
|
||||||
|
assert.InDelta(t, 50.0, result["0"].Usage, 0.01)
|
||||||
|
assert.InDelta(t, 100.0, result["0"].Power, 0.01)
|
||||||
|
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
|
||||||
|
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
|
||||||
|
|
||||||
|
// Verify that accumulators in the original map are reset
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count, "GPU 0 Count should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Usage, "GPU 0 Usage should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Power, "GPU 0 Power should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Count, "GPU 1 Count should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Usage, "GPU 1 Usage should be reset")
|
||||||
|
assert.Equal(t, float64(0), gm.GpuDataMap["1"].Power, "GPU 1 Power should be reset")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("handles zero count without panicking", func(t *testing.T) {
|
||||||
|
gm := &GPUManager{
|
||||||
|
GpuDataMap: map[string]*system.GPUData{
|
||||||
|
"0": {
|
||||||
|
Name: "TestGPU",
|
||||||
|
Count: 0,
|
||||||
|
Usage: 0,
|
||||||
|
Power: 0,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
}
|
||||||
}
|
|
||||||
|
|
||||||
result := gm.GetCurrentData()
|
var result map[string]system.GPUData
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
result = gm.GetCurrentData()
|
||||||
|
})
|
||||||
|
|
||||||
// Verify name disambiguation
|
// Check that usage and power are 0
|
||||||
assert.Equal(t, "GPU1 0", result["0"].Name)
|
assert.Equal(t, 0.0, result["0"].Usage)
|
||||||
assert.Equal(t, "GPU1 1", result["1"].Name)
|
assert.Equal(t, 0.0, result["0"].Power)
|
||||||
|
|
||||||
// Check averaged values
|
// Verify reset count
|
||||||
assert.InDelta(t, 50.0, result["0"].Usage, 0.01)
|
assert.Equal(t, float64(0), gm.GpuDataMap["0"].Count)
|
||||||
assert.InDelta(t, 100.0, result["0"].Power, 0.01)
|
})
|
||||||
assert.InDelta(t, 30.0, result["1"].Usage, 0.01)
|
|
||||||
assert.InDelta(t, 60.0, result["1"].Power, 0.01)
|
|
||||||
|
|
||||||
// Verify reset counts
|
|
||||||
assert.Equal(t, float64(1), gm.GpuDataMap["0"].Count)
|
|
||||||
assert.Equal(t, float64(1), gm.GpuDataMap["1"].Count)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDetectGPUs(t *testing.T) {
|
func TestDetectGPUs(t *testing.T) {
|
||||||
@@ -722,6 +766,18 @@ func TestAccumulation(t *testing.T) {
|
|||||||
assert.InDelta(t, expected.avgUsage, gpu.Usage, 0.01, "Average usage in GetCurrentData should match")
|
assert.InDelta(t, expected.avgUsage, gpu.Usage, 0.01, "Average usage in GetCurrentData should match")
|
||||||
assert.InDelta(t, expected.avgPower, gpu.Power, 0.01, "Average power in GetCurrentData should match")
|
assert.InDelta(t, expected.avgPower, gpu.Power, 0.01, "Average power in GetCurrentData should match")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verify that accumulators in the original map are reset
|
||||||
|
for id := range tt.expectedValues {
|
||||||
|
gpu, exists := gm.GpuDataMap[id]
|
||||||
|
assert.True(t, exists, "GPU with ID %s should still exist after GetCurrentData", id)
|
||||||
|
if !exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
assert.Equal(t, float64(0), gpu.Count, "Count should be reset for GPU ID %s", id)
|
||||||
|
assert.Equal(t, float64(0), gpu.Usage, "Usage should be reset for GPU ID %s", id)
|
||||||
|
assert.Equal(t, float64(0), gpu.Power, "Power should be reset for GPU ID %s", id)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -30,6 +31,9 @@ func (a *Agent) newSensorConfig() *SensorConfig {
|
|||||||
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
|
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
|
||||||
|
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
|
||||||
|
|
||||||
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
||||||
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
||||||
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
|
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
|
||||||
@@ -78,8 +82,18 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
// reset high temp
|
// reset high temp
|
||||||
a.systemInfo.DashboardTemp = 0
|
a.systemInfo.DashboardTemp = 0
|
||||||
|
|
||||||
// get sensor data
|
temps, err := a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
|
||||||
temps, _ := sensors.TemperaturesWithContext(a.sensorConfig.context)
|
if err != nil {
|
||||||
|
// retry once on panic (gopsutil/issues/1832)
|
||||||
|
temps, err = a.getTempsWithPanicRecovery(sensors.TemperaturesWithContext)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Error updating temperatures", "err", err)
|
||||||
|
if len(systemStats.Temperatures) > 0 {
|
||||||
|
systemStats.Temperatures = make(map[string]float64)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
slog.Debug("Temperature", "sensors", temps)
|
slog.Debug("Temperature", "sensors", temps)
|
||||||
|
|
||||||
// return if no sensors
|
// return if no sensors
|
||||||
@@ -107,15 +121,28 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// set dashboard temperature
|
// set dashboard temperature
|
||||||
if a.sensorConfig.primarySensor == "" {
|
switch a.sensorConfig.primarySensor {
|
||||||
|
case "":
|
||||||
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
|
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
|
||||||
} else if a.sensorConfig.primarySensor == sensorName {
|
case sensorName:
|
||||||
a.systemInfo.DashboardTemp = sensor.Temperature
|
a.systemInfo.DashboardTemp = sensor.Temperature
|
||||||
}
|
}
|
||||||
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
|
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getTempsWithPanicRecovery wraps sensors.TemperaturesWithContext to recover from panics (gopsutil/issues/1832)
|
||||||
|
func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.TemperatureStat, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = fmt.Errorf("panic: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// get sensor data (error ignored intentionally as it may be only with one sensor)
|
||||||
|
temps, _ = getTemps(a.sensorConfig.context)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
|
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
|
||||||
func isValidSensor(sensorName string, config *SensorConfig) bool {
|
func isValidSensor(sensorName string, config *SensorConfig) bool {
|
||||||
// if no sensors configured, everything is valid
|
// if no sensors configured, everything is valid
|
||||||
|
|||||||
@@ -4,11 +4,14 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
@@ -454,3 +457,97 @@ func TestScaleTemperatureLogic(t *testing.T) {
|
|||||||
result, expected)
|
result, expected)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetTempsWithPanicRecovery(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
systemInfo: system.Info{},
|
||||||
|
sensorConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
getTempsFn getTempsFn
|
||||||
|
expectError bool
|
||||||
|
errorMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "successful_function_call",
|
||||||
|
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
return []sensors.TemperatureStat{
|
||||||
|
{SensorKey: "test_sensor", Temperature: 45.0},
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "function_returns_error",
|
||||||
|
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
return []sensors.TemperatureStat{
|
||||||
|
{SensorKey: "test_sensor", Temperature: 45.0},
|
||||||
|
}, fmt.Errorf("sensor error")
|
||||||
|
},
|
||||||
|
expectError: false, // getTempsWithPanicRecovery ignores errors from the function
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "function_panics_with_string",
|
||||||
|
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
panic("test panic")
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "panic: test panic",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "function_panics_with_error",
|
||||||
|
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
panic(fmt.Errorf("panic error"))
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "panic:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "function_panics_with_index_out_of_bounds",
|
||||||
|
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
slice := []int{1, 2, 3}
|
||||||
|
_ = slice[10] // out of bounds panic
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "panic:",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "function_panics_with_any_conversion",
|
||||||
|
getTempsFn: func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
|
var i any = "string"
|
||||||
|
_ = i.(int) // type assertion panic
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
expectError: true,
|
||||||
|
errorMsg: "panic:",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var temps []sensors.TemperatureStat
|
||||||
|
var err error
|
||||||
|
|
||||||
|
// The function should not panic, regardless of what the injected function does
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
temps, err = agent.getTempsWithPanicRecovery(tt.getTempsFn)
|
||||||
|
}, "getTempsWithPanicRecovery should not panic")
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
assert.Error(t, err, "Expected an error to be returned")
|
||||||
|
if tt.errorMsg != "" {
|
||||||
|
assert.Contains(t, err.Error(), tt.errorMsg,
|
||||||
|
"Error message should contain expected text")
|
||||||
|
}
|
||||||
|
assert.Nil(t, temps, "Temps should be nil when panic occurs")
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err, "Should not return error for successful calls")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,304 +0,0 @@
|
|||||||
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,17 +237,6 @@ 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
|
// update base system info
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
|
|||||||
@@ -1,269 +0,0 @@
|
|||||||
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,30 +8,29 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
MaxCpu float64 `json:"cpum,omitempty"`
|
MaxCpu float64 `json:"cpum,omitempty"`
|
||||||
Mem float64 `json:"m"`
|
Mem float64 `json:"m"`
|
||||||
MemUsed float64 `json:"mu"`
|
MemUsed float64 `json:"mu"`
|
||||||
MemPct float64 `json:"mp"`
|
MemPct float64 `json:"mp"`
|
||||||
MemBuffCache float64 `json:"mb"`
|
MemBuffCache float64 `json:"mb"`
|
||||||
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
|
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
|
||||||
Swap float64 `json:"s,omitempty"`
|
Swap float64 `json:"s,omitempty"`
|
||||||
SwapUsed float64 `json:"su,omitempty"`
|
SwapUsed float64 `json:"su,omitempty"`
|
||||||
DiskTotal float64 `json:"d"`
|
DiskTotal float64 `json:"d"`
|
||||||
DiskUsed float64 `json:"du"`
|
DiskUsed float64 `json:"du"`
|
||||||
DiskPct float64 `json:"dp"`
|
DiskPct float64 `json:"dp"`
|
||||||
DiskReadPs float64 `json:"dr"`
|
DiskReadPs float64 `json:"dr"`
|
||||||
DiskWritePs float64 `json:"dw"`
|
DiskWritePs float64 `json:"dw"`
|
||||||
MaxDiskReadPs float64 `json:"drm,omitempty"`
|
MaxDiskReadPs float64 `json:"drm,omitempty"`
|
||||||
MaxDiskWritePs float64 `json:"dwm,omitempty"`
|
MaxDiskWritePs float64 `json:"dwm,omitempty"`
|
||||||
NetworkSent float64 `json:"ns"`
|
NetworkSent float64 `json:"ns"`
|
||||||
NetworkRecv float64 `json:"nr"`
|
NetworkRecv float64 `json:"nr"`
|
||||||
MaxNetworkSent float64 `json:"nsm,omitempty"`
|
MaxNetworkSent float64 `json:"nsm,omitempty"`
|
||||||
MaxNetworkRecv float64 `json:"nrm,omitempty"`
|
MaxNetworkRecv float64 `json:"nrm,omitempty"`
|
||||||
Temperatures map[string]float64 `json:"t,omitempty"`
|
Temperatures map[string]float64 `json:"t,omitempty"`
|
||||||
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
|
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
|
||||||
GPUData map[string]GPUData `json:"g,omitempty"`
|
GPUData map[string]GPUData `json:"g,omitempty"`
|
||||||
SmartData map[string]SmartData `json:"sm,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
@@ -74,31 +73,6 @@ const (
|
|||||||
Freebsd
|
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 {
|
type Info struct {
|
||||||
Hostname string `json:"h"`
|
Hostname string `json:"h"`
|
||||||
KernelVersion string `json:"k,omitempty"`
|
KernelVersion string `json:"k,omitempty"`
|
||||||
|
|||||||
@@ -35,12 +35,10 @@ import { Input } from "../ui/input"
|
|||||||
import { ChartAverage, ChartMax, Rows, TuxIcon, WindowsIcon, AppleIcon, FreeBsdIcon } from "../ui/icons"
|
import { ChartAverage, ChartMax, Rows, TuxIcon, WindowsIcon, AppleIcon, FreeBsdIcon } from "../ui/icons"
|
||||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
|
||||||
import { timeTicks } from "d3-time"
|
import { timeTicks } from "d3-time"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import { $router, navigate } from "../router"
|
import { $router, navigate } from "../router"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
import DisksTab from "../tabs/disks-tab"
|
|
||||||
|
|
||||||
const AreaChartDefault = lazy(() => import("../charts/area-chart"))
|
const AreaChartDefault = lazy(() => import("../charts/area-chart"))
|
||||||
const ContainerChart = lazy(() => import("../charts/container-chart"))
|
const ContainerChart = lazy(() => import("../charts/container-chart"))
|
||||||
@@ -465,14 +463,6 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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 */}
|
{/* main charts */}
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -670,12 +660,6 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="disks" className="mt-4">
|
|
||||||
<DisksTab smartData={systemStats.at(-1)?.stats.sm} />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* add space for tooltip if more than 12 containers */}
|
{/* add space for tooltip if more than 12 containers */}
|
||||||
|
|||||||
@@ -1,631 +0,0 @@
|
|||||||
"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
|
<th
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-12 px-4 text-start align-middle whitespace-nowrap font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0",
|
"h-12 px-4 text-start align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -62,7 +62,7 @@ TableHead.displayName = "TableHead"
|
|||||||
|
|
||||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<td ref={ref} className={cn("p-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0", className)} {...props} />
|
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pe-0", className)} {...props} />
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
TableCell.displayName = "TableCell"
|
TableCell.displayName = "TableCell"
|
||||||
|
|||||||
46
beszel/site/src/types.d.ts
vendored
46
beszel/site/src/types.d.ts
vendored
@@ -100,8 +100,6 @@ export interface SystemStats {
|
|||||||
efs?: Record<string, ExtraFsStats>
|
efs?: Record<string, ExtraFsStats>
|
||||||
/** GPU data */
|
/** GPU data */
|
||||||
g?: Record<string, GPUData>
|
g?: Record<string, GPUData>
|
||||||
/** SMART data */
|
|
||||||
sm?: Record<string, SmartData>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GPUData {
|
export interface GPUData {
|
||||||
@@ -210,47 +208,3 @@ interface AlertInfo {
|
|||||||
/** Single value description (when there's only one value, like status) */
|
/** Single value description (when there's only one value, like status) */
|
||||||
singleDesc?: () => string
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ function Install-BeszelAgentWithScoop {
|
|||||||
scoop bucket add beszel https://github.com/henrygd/beszel-scoops | Out-Null
|
scoop bucket add beszel https://github.com/henrygd/beszel-scoops | Out-Null
|
||||||
|
|
||||||
Write-Host "Installing / updating beszel-agent..."
|
Write-Host "Installing / updating beszel-agent..."
|
||||||
scoop install beszel-agent
|
scoop install beszel-agent | Out-Null
|
||||||
|
|
||||||
if (-not (Test-CommandExists "beszel-agent")) {
|
if (-not (Test-CommandExists "beszel-agent")) {
|
||||||
throw "Failed to install beszel-agent"
|
throw "Failed to install beszel-agent"
|
||||||
|
|||||||
@@ -316,18 +316,27 @@ fi
|
|||||||
# Create a dedicated user for the service if it doesn't exist
|
# Create a dedicated user for the service if it doesn't exist
|
||||||
if is_alpine; then
|
if is_alpine; then
|
||||||
if ! id -u beszel >/dev/null 2>&1; then
|
if ! id -u beszel >/dev/null 2>&1; then
|
||||||
|
echo "Creating a dedicated group for the Beszel Agent service..."
|
||||||
|
addgroup beszel
|
||||||
echo "Creating a dedicated user for the Beszel Agent service..."
|
echo "Creating a dedicated user for the Beszel Agent service..."
|
||||||
adduser -S -D -H -s /sbin/nologin beszel
|
adduser -S -D -H -s /sbin/nologin -G beszel beszel
|
||||||
fi
|
fi
|
||||||
# Add the user to the docker group to allow access to the Docker socket
|
# Add the user to the docker group to allow access to the Docker socket if group docker exists
|
||||||
addgroup beszel docker
|
if getent group docker; then
|
||||||
|
echo "Adding besel to docker group"
|
||||||
|
usermod -aG docker beszel
|
||||||
|
fi
|
||||||
|
|
||||||
else
|
else
|
||||||
if ! id -u beszel >/dev/null 2>&1; then
|
if ! id -u beszel >/dev/null 2>&1; then
|
||||||
echo "Creating a dedicated user for the Beszel Agent service..."
|
echo "Creating a dedicated user for the Beszel Agent service..."
|
||||||
useradd --system --home-dir /nonexistent --shell /bin/false beszel
|
useradd --system --home-dir /nonexistent --shell /bin/false beszel
|
||||||
fi
|
fi
|
||||||
# Add the user to the docker group to allow access to the Docker socket
|
# Add the user to the docker group to allow access to the Docker socket if group docker exists
|
||||||
usermod -aG docker beszel
|
if getent group docker; then
|
||||||
|
echo "Adding besel to docker group"
|
||||||
|
usermod -aG docker beszel
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Create the directory for the Beszel Agent
|
# Create the directory for the Beszel Agent
|
||||||
|
|||||||
Reference in New Issue
Block a user