mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-14 08:51:49 +02:00
Compare commits
1 Commits
nvml
...
35fd12af5d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35fd12af5d |
@@ -298,13 +298,8 @@ func (gm *GPUManager) calculateGPUAverage(id string, gpu *system.GPUData, cacheK
|
|||||||
currentCount := uint32(gpu.Count)
|
currentCount := uint32(gpu.Count)
|
||||||
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
deltaCount := gm.calculateDeltaCount(currentCount, lastSnapshot)
|
||||||
|
|
||||||
// If no new data arrived
|
// If no new data arrived, use last known average
|
||||||
if deltaCount == 0 {
|
if deltaCount == 0 {
|
||||||
// If GPU appears suspended (instantaneous values are 0), return zero values
|
|
||||||
// Otherwise return last known average for temporary collection gaps
|
|
||||||
if gpu.Temperature == 0 && gpu.MemoryUsed == 0 {
|
|
||||||
return system.GPUData{Name: gpu.Name}
|
|
||||||
}
|
|
||||||
return gm.lastAvgData[id] // zero value if not found
|
return gm.lastAvgData[id] // zero value if not found
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
@@ -73,9 +75,10 @@ type nvmlCollector struct {
|
|||||||
|
|
||||||
func (c *nvmlCollector) init() error {
|
func (c *nvmlCollector) init() error {
|
||||||
slog.Info("NVML: Initializing")
|
slog.Info("NVML: Initializing")
|
||||||
libPath := getNVMLPath()
|
libPath := "libnvidia-ml.so.1"
|
||||||
|
|
||||||
lib, err := openLibrary(libPath)
|
// Check for standard locations if necessary, but purego/dlopen usually handles this
|
||||||
|
lib, err := purego.Dlopen(libPath, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load %s: %w", libPath, err)
|
return fmt.Errorf("failed to load %s: %w", libPath, err)
|
||||||
}
|
}
|
||||||
@@ -87,7 +90,8 @@ func (c *nvmlCollector) init() error {
|
|||||||
purego.RegisterLibFunc(&nvmlDeviceGetHandleByIndex, lib, "nvmlDeviceGetHandleByIndex")
|
purego.RegisterLibFunc(&nvmlDeviceGetHandleByIndex, lib, "nvmlDeviceGetHandleByIndex")
|
||||||
purego.RegisterLibFunc(&nvmlDeviceGetName, lib, "nvmlDeviceGetName")
|
purego.RegisterLibFunc(&nvmlDeviceGetName, lib, "nvmlDeviceGetName")
|
||||||
// Try to get v2 memory info, fallback to v1 if not available
|
// Try to get v2 memory info, fallback to v1 if not available
|
||||||
if hasSymbol(lib, "nvmlDeviceGetMemoryInfo_v2") {
|
_, err = purego.Dlsym(lib, "nvmlDeviceGetMemoryInfo_v2")
|
||||||
|
if err == nil {
|
||||||
c.isV2 = true
|
c.isV2 = true
|
||||||
purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo_v2")
|
purego.RegisterLibFunc(&nvmlDeviceGetMemoryInfo, lib, "nvmlDeviceGetMemoryInfo_v2")
|
||||||
} else {
|
} else {
|
||||||
@@ -143,39 +147,21 @@ func (c *nvmlCollector) collect() {
|
|||||||
defer c.gm.Unlock()
|
defer c.gm.Unlock()
|
||||||
|
|
||||||
for i, device := range c.devices {
|
for i, device := range c.devices {
|
||||||
id := fmt.Sprintf("%d", i)
|
|
||||||
bdf := c.bdfs[i]
|
bdf := c.bdfs[i]
|
||||||
|
|
||||||
// Update GPUDataMap
|
|
||||||
if _, ok := c.gm.GpuDataMap[id]; !ok {
|
|
||||||
var nameBuf [64]byte
|
|
||||||
if ret := nvmlDeviceGetName(device, &nameBuf[0], 64); ret != nvmlReturn(nvmlSuccess) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
name := string(nameBuf[:strings.Index(string(nameBuf[:]), "\x00")])
|
|
||||||
name = strings.TrimPrefix(name, "NVIDIA ")
|
|
||||||
c.gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
|
|
||||||
}
|
|
||||||
gpu := c.gm.GpuDataMap[id]
|
|
||||||
|
|
||||||
if bdf != "" && !c.isGPUActive(bdf) {
|
if bdf != "" && !c.isGPUActive(bdf) {
|
||||||
slog.Info("NVML: GPU is suspended, skipping", "bdf", bdf)
|
slog.Info("NVML: GPU is suspended, skipping", "bdf", bdf)
|
||||||
gpu.Temperature = 0
|
|
||||||
gpu.MemoryUsed = 0
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
slog.Info("NVML: Collecting data for GPU", "bdf", bdf)
|
||||||
|
|
||||||
|
id := fmt.Sprintf("%d", i)
|
||||||
|
|
||||||
// Utilization
|
// Utilization
|
||||||
var utilization nvmlUtilization
|
var utilization nvmlUtilization
|
||||||
if ret := nvmlDeviceGetUtilizationRates(device, &utilization); ret != nvmlReturn(nvmlSuccess) {
|
if ret := nvmlDeviceGetUtilizationRates(device, &utilization); ret != nvmlReturn(nvmlSuccess) {
|
||||||
slog.Info("NVML: Utilization failed (GPU likely suspended)", "bdf", bdf, "ret", ret)
|
|
||||||
gpu.Temperature = 0
|
|
||||||
gpu.MemoryUsed = 0
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("NVML: Collecting data for GPU", "bdf", bdf)
|
|
||||||
|
|
||||||
// Temperature
|
// Temperature
|
||||||
var temp uint32
|
var temp uint32
|
||||||
nvmlDeviceGetTemperature(device, 0, &temp) // 0 is NVML_TEMPERATURE_GPU
|
nvmlDeviceGetTemperature(device, 0, &temp) // 0 is NVML_TEMPERATURE_GPU
|
||||||
@@ -199,6 +185,16 @@ func (c *nvmlCollector) collect() {
|
|||||||
var power uint32
|
var power uint32
|
||||||
nvmlDeviceGetPowerUsage(device, &power)
|
nvmlDeviceGetPowerUsage(device, &power)
|
||||||
|
|
||||||
|
// Update GPUDataMap
|
||||||
|
if _, ok := c.gm.GpuDataMap[id]; !ok {
|
||||||
|
var nameBuf [64]byte
|
||||||
|
nvmlDeviceGetName(device, &nameBuf[0], 64)
|
||||||
|
name := string(nameBuf[:strings.Index(string(nameBuf[:]), "\x00")])
|
||||||
|
name = strings.TrimPrefix(name, "NVIDIA ")
|
||||||
|
c.gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu := c.gm.GpuDataMap[id]
|
||||||
gpu.Temperature = float64(temp)
|
gpu.Temperature = float64(temp)
|
||||||
gpu.MemoryUsed = float64(usedMem) / 1024 / 1024 / mebibytesInAMegabyte
|
gpu.MemoryUsed = float64(usedMem) / 1024 / 1024 / mebibytesInAMegabyte
|
||||||
gpu.MemoryTotal = float64(totalMem) / 1024 / 1024 / mebibytesInAMegabyte
|
gpu.MemoryTotal = float64(totalMem) / 1024 / 1024 / mebibytesInAMegabyte
|
||||||
@@ -208,3 +204,34 @@ func (c *nvmlCollector) collect() {
|
|||||||
slog.Info("NVML: Collected data", "gpu", gpu)
|
slog.Info("NVML: Collected data", "gpu", gpu)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *nvmlCollector) isGPUActive(bdf string) bool {
|
||||||
|
// runtime_status
|
||||||
|
statusPath := filepath.Join("/sys/bus/pci/devices", bdf, "power/runtime_status")
|
||||||
|
status, err := os.ReadFile(statusPath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("NVML: Can't read runtime_status", "bdf", bdf, "err", err)
|
||||||
|
return true // Assume active if we can't read status
|
||||||
|
}
|
||||||
|
statusStr := strings.TrimSpace(string(status))
|
||||||
|
if statusStr != "active" && statusStr != "resuming" {
|
||||||
|
slog.Info("NVML: GPU is not active", "bdf", bdf, "status", statusStr)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// power_state (D0 check)
|
||||||
|
// Find any drm card device power_state
|
||||||
|
pstatePathPattern := filepath.Join("/sys/bus/pci/devices", bdf, "drm/card*/device/power_state")
|
||||||
|
matches, _ := filepath.Glob(pstatePathPattern)
|
||||||
|
if len(matches) > 0 {
|
||||||
|
pstate, err := os.ReadFile(matches[0])
|
||||||
|
if err == nil {
|
||||||
|
pstateStr := strings.TrimSpace(string(pstate))
|
||||||
|
if pstateStr != "D0" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
//go:build linux
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/ebitengine/purego"
|
|
||||||
"golang.org/x/exp/slog"
|
|
||||||
)
|
|
||||||
|
|
||||||
func openLibrary(name string) (uintptr, error) {
|
|
||||||
return purego.Dlopen(name, purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func getNVMLPath() string {
|
|
||||||
return "libnvidia-ml.so.1"
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasSymbol(lib uintptr, symbol string) bool {
|
|
||||||
_, err := purego.Dlsym(lib, symbol)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nvmlCollector) isGPUActive(bdf string) bool {
|
|
||||||
// runtime_status
|
|
||||||
statusPath := filepath.Join("/sys/bus/pci/devices", bdf, "power/runtime_status")
|
|
||||||
status, err := os.ReadFile(statusPath)
|
|
||||||
if err != nil {
|
|
||||||
slog.Info("NVML: Can't read runtime_status", "bdf", bdf, "err", err)
|
|
||||||
return true // Assume active if we can't read status
|
|
||||||
}
|
|
||||||
statusStr := strings.TrimSpace(string(status))
|
|
||||||
if statusStr != "active" && statusStr != "resuming" {
|
|
||||||
slog.Info("NVML: GPU is not active", "bdf", bdf, "status", statusStr)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// power_state (D0 check)
|
|
||||||
// Find any drm card device power_state
|
|
||||||
pstatePathPattern := filepath.Join("/sys/bus/pci/devices", bdf, "drm/card*/device/power_state")
|
|
||||||
matches, _ := filepath.Glob(pstatePathPattern)
|
|
||||||
if len(matches) > 0 {
|
|
||||||
pstate, err := os.ReadFile(matches[0])
|
|
||||||
if err == nil {
|
|
||||||
pstateStr := strings.TrimSpace(string(pstate))
|
|
||||||
if pstateStr != "D0" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
//go:build !linux && !windows
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import "fmt"
|
|
||||||
|
|
||||||
func openLibrary(name string) (uintptr, error) {
|
|
||||||
return 0, fmt.Errorf("nvml not supported on this platform")
|
|
||||||
}
|
|
||||||
|
|
||||||
func getNVMLPath() string {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasSymbol(lib uintptr, symbol string) bool {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nvmlCollector) isGPUActive(bdf string) bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
//go:build windows
|
|
||||||
|
|
||||||
package agent
|
|
||||||
|
|
||||||
import (
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
func openLibrary(name string) (uintptr, error) {
|
|
||||||
handle, err := windows.LoadLibrary(name)
|
|
||||||
return uintptr(handle), err
|
|
||||||
}
|
|
||||||
|
|
||||||
func getNVMLPath() string {
|
|
||||||
return "nvml.dll"
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasSymbol(lib uintptr, symbol string) bool {
|
|
||||||
_, err := windows.GetProcAddress(windows.Handle(lib), symbol)
|
|
||||||
return err == nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *nvmlCollector) isGPUActive(bdf string) bool {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
2
go.mod
2
go.mod
@@ -21,7 +21,6 @@ require (
|
|||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
golang.org/x/crypto v0.45.0
|
golang.org/x/crypto v0.45.0
|
||||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
|
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39
|
||||||
golang.org/x/sys v0.38.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,6 +57,7 @@ require (
|
|||||||
golang.org/x/net v0.47.0 // indirect
|
golang.org/x/net v0.47.0 // indirect
|
||||||
golang.org/x/oauth2 v0.33.0 // indirect
|
golang.org/x/oauth2 v0.33.0 // indirect
|
||||||
golang.org/x/sync v0.18.0 // indirect
|
golang.org/x/sync v0.18.0 // indirect
|
||||||
|
golang.org/x/sys v0.38.0 // indirect
|
||||||
golang.org/x/term v0.37.0 // indirect
|
golang.org/x/term v0.37.0 // indirect
|
||||||
golang.org/x/text v0.31.0 // indirect
|
golang.org/x/text v0.31.0 // indirect
|
||||||
howett.net/plist v1.0.1 // indirect
|
howett.net/plist v1.0.1 // indirect
|
||||||
|
|||||||
@@ -93,15 +93,51 @@ export const smartColumns: ColumnDef<SmartAttribute>[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export type DiskInfo = {
|
||||||
|
id: string
|
||||||
|
system: string
|
||||||
|
device: string
|
||||||
|
model: string
|
||||||
|
capacity: string
|
||||||
|
status: string
|
||||||
|
temperature: number
|
||||||
|
deviceType: string
|
||||||
|
powerOnHours?: number
|
||||||
|
powerCycles?: number
|
||||||
|
attributes?: SmartAttribute[]
|
||||||
|
updated: string
|
||||||
|
}
|
||||||
|
|
||||||
// Function to format capacity display
|
// Function to format capacity display
|
||||||
function formatCapacity(bytes: number): string {
|
function formatCapacity(bytes: number): string {
|
||||||
const { value, unit } = formatBytes(bytes)
|
const { value, unit } = formatBytes(bytes)
|
||||||
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
return `${toFixedFloat(value, value >= 10 ? 1 : 2)} ${unit}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Function to convert SmartDeviceRecord to DiskInfo
|
||||||
|
function convertSmartDeviceRecordToDiskInfo(records: SmartDeviceRecord[]): DiskInfo[] {
|
||||||
|
const unknown = "Unknown"
|
||||||
|
return records.map((record) => ({
|
||||||
|
id: record.id,
|
||||||
|
system: record.system,
|
||||||
|
device: record.name || unknown,
|
||||||
|
model: record.model || unknown,
|
||||||
|
serialNumber: record.serial || unknown,
|
||||||
|
firmwareVersion: record.firmware || unknown,
|
||||||
|
capacity: record.capacity ? formatCapacity(record.capacity) : unknown,
|
||||||
|
status: record.state || unknown,
|
||||||
|
temperature: record.temp || 0,
|
||||||
|
deviceType: record.type || unknown,
|
||||||
|
attributes: record.attributes,
|
||||||
|
updated: record.updated,
|
||||||
|
powerOnHours: record.hours,
|
||||||
|
powerCycles: record.cycles,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
const SMART_DEVICE_FIELDS = "id,system,name,model,state,capacity,temp,type,hours,cycles,updated"
|
||||||
|
|
||||||
export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
export const columns: ColumnDef<DiskInfo>[] = [
|
||||||
{
|
{
|
||||||
id: "system",
|
id: "system",
|
||||||
accessorFn: (record) => record.system,
|
accessorFn: (record) => record.system,
|
||||||
@@ -118,12 +154,12 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "name",
|
accessorKey: "device",
|
||||||
sortingFn: (a, b) => a.original.name.localeCompare(b.original.name),
|
sortingFn: (a, b) => a.original.device.localeCompare(b.original.device),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Device`} Icon={HardDrive} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ row }) => (
|
||||||
<div className="font-medium max-w-40 truncate ms-1.5" title={getValue() as string}>
|
<div className="font-medium max-w-40 truncate ms-1.5" title={row.getValue("device")}>
|
||||||
{getValue() as string}
|
{row.getValue("device")}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -131,20 +167,19 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
|||||||
accessorKey: "model",
|
accessorKey: "model",
|
||||||
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
|
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ row }) => (
|
||||||
<div className="max-w-48 truncate ms-1.5" title={getValue() as string}>
|
<div className="max-w-48 truncate ms-1.5" title={row.getValue("model")}>
|
||||||
{getValue() as string}
|
{row.getValue("model")}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "capacity",
|
accessorKey: "capacity",
|
||||||
invertSorting: true,
|
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Capacity`} Icon={BinaryIcon} />,
|
||||||
cell: ({ getValue }) => <span className="ms-1.5">{formatCapacity(getValue() as number)}</span>,
|
cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "state",
|
accessorKey: "status",
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={Activity} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Status`} Icon={Activity} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
const status = getValue() as string
|
const status = getValue() as string
|
||||||
@@ -156,8 +191,8 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "type",
|
accessorKey: "deviceType",
|
||||||
sortingFn: (a, b) => a.original.type.localeCompare(b.original.type),
|
sortingFn: (a, b) => a.original.deviceType.localeCompare(b.original.deviceType),
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Type`} Icon={ArrowLeftRightIcon} />,
|
||||||
cell: ({ getValue }) => (
|
cell: ({ getValue }) => (
|
||||||
<div className="ms-1.5">
|
<div className="ms-1.5">
|
||||||
@@ -168,7 +203,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "hours",
|
accessorKey: "powerOnHours",
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />
|
<HeaderButton column={column} name={t({ message: "Power On", comment: "Power On Time" })} Icon={Clock} />
|
||||||
@@ -188,7 +223,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "cycles",
|
accessorKey: "powerCycles",
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
<HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />
|
<HeaderButton column={column} name={t({ message: "Cycles", comment: "Power Cycles" })} Icon={RotateCwIcon} />
|
||||||
@@ -202,7 +237,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "temp",
|
accessorKey: "temperature",
|
||||||
invertSorting: true,
|
invertSorting: true,
|
||||||
header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
|
header: ({ column }) => <HeaderButton column={column} name={t`Temp`} Icon={ThermometerIcon} />,
|
||||||
cell: ({ getValue }) => {
|
cell: ({ getValue }) => {
|
||||||
@@ -211,14 +246,14 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// accessorKey: "serial",
|
// accessorKey: "serialNumber",
|
||||||
// sortingFn: (a, b) => a.original.serial.localeCompare(b.original.serial),
|
// sortingFn: (a, b) => a.original.serialNumber.localeCompare(b.original.serialNumber),
|
||||||
// header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />,
|
// header: ({ column }) => <HeaderButton column={column} name={t`Serial Number`} Icon={HashIcon} />,
|
||||||
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
||||||
// },
|
// },
|
||||||
// {
|
// {
|
||||||
// accessorKey: "firmware",
|
// accessorKey: "firmwareVersion",
|
||||||
// sortingFn: (a, b) => a.original.firmware.localeCompare(b.original.firmware),
|
// sortingFn: (a, b) => a.original.firmwareVersion.localeCompare(b.original.firmwareVersion),
|
||||||
// header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />,
|
// header: ({ column }) => <HeaderButton column={column} name={t`Firmware`} Icon={CpuIcon} />,
|
||||||
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
// cell: ({ getValue }) => <span className="ms-1.5">{getValue() as string}</span>,
|
||||||
// },
|
// },
|
||||||
@@ -237,15 +272,7 @@ export const columns: ColumnDef<SmartDeviceRecord>[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function HeaderButton({
|
function HeaderButton({ column, name, Icon }: { column: Column<DiskInfo>; name: string; Icon: React.ElementType }) {
|
||||||
column,
|
|
||||||
name,
|
|
||||||
Icon,
|
|
||||||
}: {
|
|
||||||
column: Column<SmartDeviceRecord>
|
|
||||||
name: string
|
|
||||||
Icon: React.ElementType
|
|
||||||
}) {
|
|
||||||
const isSorted = column.getIsSorted()
|
const isSorted = column.getIsSorted()
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -263,7 +290,7 @@ function HeaderButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DisksTable({ systemId }: { systemId?: string }) {
|
export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||||
const [sorting, setSorting] = useState<SortingState>([{ id: systemId ? "name" : "system", desc: false }])
|
const [sorting, setSorting] = useState<SortingState>([{ id: systemId ? "device" : "system", desc: false }])
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
const [rowSelection, setRowSelection] = useState({})
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
const [smartDevices, setSmartDevices] = useState<SmartDeviceRecord[] | undefined>(undefined)
|
const [smartDevices, setSmartDevices] = useState<SmartDeviceRecord[] | undefined>(undefined)
|
||||||
@@ -272,95 +299,96 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null)
|
const [rowActionState, setRowActionState] = useState<{ type: "refresh" | "delete"; id: string } | null>(null)
|
||||||
const [globalFilter, setGlobalFilter] = useState("")
|
const [globalFilter, setGlobalFilter] = useState("")
|
||||||
|
|
||||||
const openSheet = (disk: SmartDeviceRecord) => {
|
const openSheet = (disk: DiskInfo) => {
|
||||||
setActiveDiskId(disk.id)
|
setActiveDiskId(disk.id)
|
||||||
setSheetOpen(true)
|
setSheetOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch smart devices
|
// Fetch smart devices from collection (without attributes to save bandwidth)
|
||||||
useEffect(() => {
|
const fetchSmartDevices = useCallback(() => {
|
||||||
const controller = new AbortController()
|
|
||||||
|
|
||||||
pb.collection<SmartDeviceRecord>("smart_devices")
|
pb.collection<SmartDeviceRecord>("smart_devices")
|
||||||
.getFullList({
|
.getFullList({
|
||||||
filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined,
|
filter: systemId ? pb.filter("system = {:system}", { system: systemId }) : undefined,
|
||||||
fields: SMART_DEVICE_FIELDS,
|
fields: SMART_DEVICE_FIELDS,
|
||||||
signal: controller.signal,
|
|
||||||
})
|
})
|
||||||
.then(setSmartDevices)
|
.then((records) => {
|
||||||
.catch((err) => {
|
setSmartDevices(records)
|
||||||
if (!err.isAbort) {
|
|
||||||
setSmartDevices([])
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
.catch(() => setSmartDevices([]))
|
||||||
return () => controller.abort()
|
|
||||||
}, [systemId])
|
}, [systemId])
|
||||||
|
|
||||||
// Subscribe to updates
|
// Fetch smart devices when component mounts or systemId changes
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSmartDevices()
|
||||||
|
}, [fetchSmartDevices])
|
||||||
|
|
||||||
|
// Subscribe to live updates so rows add/remove without manual refresh/filtering
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let unsubscribe: (() => void) | undefined
|
let unsubscribe: (() => void) | undefined
|
||||||
const pbOptions = systemId
|
const pbOptions = systemId
|
||||||
? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
|
? { fields: SMART_DEVICE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
|
||||||
: { fields: SMART_DEVICE_FIELDS }
|
: { fields: SMART_DEVICE_FIELDS }
|
||||||
|
|
||||||
; (async () => {
|
;(async () => {
|
||||||
try {
|
try {
|
||||||
unsubscribe = await pb.collection("smart_devices").subscribe(
|
unsubscribe = await pb.collection("smart_devices").subscribe(
|
||||||
"*",
|
"*",
|
||||||
(event) => {
|
(event) => {
|
||||||
const record = event.record as SmartDeviceRecord
|
const record = event.record as SmartDeviceRecord
|
||||||
setSmartDevices((currentDevices) => {
|
setSmartDevices((currentDevices) => {
|
||||||
const devices = currentDevices ?? []
|
const devices = currentDevices ?? []
|
||||||
const matchesSystemScope = !systemId || record.system === systemId
|
const matchesSystemScope = !systemId || record.system === systemId
|
||||||
|
|
||||||
if (event.action === "delete") {
|
if (event.action === "delete") {
|
||||||
return devices.filter((device) => device.id !== record.id)
|
return devices.filter((device) => device.id !== record.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!matchesSystemScope) {
|
if (!matchesSystemScope) {
|
||||||
// Record moved out of scope; ensure it disappears locally.
|
// Record moved out of scope; ensure it disappears locally.
|
||||||
return devices.filter((device) => device.id !== record.id)
|
return devices.filter((device) => device.id !== record.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingIndex = devices.findIndex((device) => device.id === record.id)
|
const existingIndex = devices.findIndex((device) => device.id === record.id)
|
||||||
if (existingIndex === -1) {
|
if (existingIndex === -1) {
|
||||||
return [record, ...devices]
|
return [record, ...devices]
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = [...devices]
|
const next = [...devices]
|
||||||
next[existingIndex] = record
|
next[existingIndex] = record
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
pbOptions
|
pbOptions
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to subscribe to SMART device updates:", error)
|
console.error("Failed to subscribe to SMART device updates:", error)
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribe?.()
|
unsubscribe?.()
|
||||||
}
|
}
|
||||||
}, [systemId])
|
}, [systemId])
|
||||||
|
|
||||||
const handleRowRefresh = useCallback(async (disk: SmartDeviceRecord) => {
|
const handleRowRefresh = useCallback(
|
||||||
if (!disk.system) return
|
async (disk: DiskInfo) => {
|
||||||
setRowActionState({ type: "refresh", id: disk.id })
|
if (!disk.system) return
|
||||||
try {
|
setRowActionState({ type: "refresh", id: disk.id })
|
||||||
await pb.send("/api/beszel/smart/refresh", {
|
try {
|
||||||
method: "POST",
|
await pb.send("/api/beszel/smart/refresh", {
|
||||||
query: { system: disk.system },
|
method: "POST",
|
||||||
})
|
query: { system: disk.system },
|
||||||
} catch (error) {
|
})
|
||||||
console.error("Failed to refresh SMART device:", error)
|
} catch (error) {
|
||||||
} finally {
|
console.error("Failed to refresh SMART device:", error)
|
||||||
setRowActionState((state) => (state?.id === disk.id ? null : state))
|
} finally {
|
||||||
}
|
setRowActionState((state) => (state?.id === disk.id ? null : state))
|
||||||
}, [])
|
}
|
||||||
|
},
|
||||||
|
[fetchSmartDevices]
|
||||||
|
)
|
||||||
|
|
||||||
const handleDeleteDevice = useCallback(async (disk: SmartDeviceRecord) => {
|
const handleDeleteDevice = useCallback(async (disk: DiskInfo) => {
|
||||||
setRowActionState({ type: "delete", id: disk.id })
|
setRowActionState({ type: "delete", id: disk.id })
|
||||||
try {
|
try {
|
||||||
await pb.collection("smart_devices").delete(disk.id)
|
await pb.collection("smart_devices").delete(disk.id)
|
||||||
@@ -372,7 +400,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const actionColumn = useMemo<ColumnDef<SmartDeviceRecord>>(
|
const actionColumn = useMemo<ColumnDef<DiskInfo>>(
|
||||||
() => ({
|
() => ({
|
||||||
id: "actions",
|
id: "actions",
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@@ -440,8 +468,13 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
return [...baseColumns, actionColumn]
|
return [...baseColumns, actionColumn]
|
||||||
}, [systemId, actionColumn])
|
}, [systemId, actionColumn])
|
||||||
|
|
||||||
|
// Convert SmartDeviceRecord to DiskInfo
|
||||||
|
const diskData = useMemo(() => {
|
||||||
|
return smartDevices ? convertSmartDeviceRecordToDiskInfo(smartDevices) : []
|
||||||
|
}, [smartDevices])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: smartDevices || ([] as SmartDeviceRecord[]),
|
data: diskData,
|
||||||
columns: tableColumns,
|
columns: tableColumns,
|
||||||
onSortingChange: setSorting,
|
onSortingChange: setSorting,
|
||||||
onColumnFiltersChange: setColumnFilters,
|
onColumnFiltersChange: setColumnFilters,
|
||||||
@@ -459,10 +492,10 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
globalFilterFn: (row, _columnId, filterValue) => {
|
globalFilterFn: (row, _columnId, filterValue) => {
|
||||||
const disk = row.original
|
const disk = row.original
|
||||||
const systemName = $allSystemsById.get()[disk.system]?.name ?? ""
|
const systemName = $allSystemsById.get()[disk.system]?.name ?? ""
|
||||||
const device = disk.name ?? ""
|
const device = disk.device ?? ""
|
||||||
const model = disk.model ?? ""
|
const model = disk.model ?? ""
|
||||||
const status = disk.state ?? ""
|
const status = disk.status ?? ""
|
||||||
const type = disk.type ?? ""
|
const type = disk.deviceType ?? ""
|
||||||
const searchString = `${systemName} ${device} ${model} ${status} ${type}`.toLowerCase()
|
const searchString = `${systemName} ${device} ${model} ${status} ${type}`.toLowerCase()
|
||||||
return (filterValue as string)
|
return (filterValue as string)
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -472,7 +505,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Hide the table on system pages if there's no data, but always show on global page
|
// Hide the table on system pages if there's no data, but always show on global page
|
||||||
if (systemId && !smartDevices?.length && !columnFilters.length) {
|
if (systemId && !diskData.length && !columnFilters.length) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user