mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-23 13:11:49 +02:00
Compare commits
4 Commits
3eede6bead
...
1365-extra
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de1ad26fc2 | ||
|
|
a7ef1235f4 | ||
|
|
f64a361c60 | ||
|
|
aaa788bc2f |
2
.github/workflows/docker-images.yml
vendored
2
.github/workflows/docker-images.yml
vendored
@@ -33,6 +33,7 @@ jobs:
|
|||||||
password_secret: DOCKERHUB_TOKEN
|
password_secret: DOCKERHUB_TOKEN
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=edge
|
type=raw,value=edge
|
||||||
|
type=raw,value=latest
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
@@ -99,6 +100,7 @@ jobs:
|
|||||||
password_secret: GITHUB_TOKEN
|
password_secret: GITHUB_TOKEN
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=edge
|
type=raw,value=edge
|
||||||
|
type=raw,value=latest
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
|
|||||||
@@ -166,6 +166,7 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
}
|
}
|
||||||
|
|
||||||
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
data.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
|
data.Info.ExtraFsPct = make(map[string]system.ExtraFsInfo)
|
||||||
for name, stats := range a.fsStats {
|
for name, stats := range a.fsStats {
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
// Use custom name if available, otherwise use device name
|
// Use custom name if available, otherwise use device name
|
||||||
@@ -174,6 +175,11 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData {
|
|||||||
key = stats.Name
|
key = stats.Name
|
||||||
}
|
}
|
||||||
data.Stats.ExtraFs[key] = stats
|
data.Stats.ExtraFs[key] = stats
|
||||||
|
// Add percentage info to Info struct for dashboard
|
||||||
|
if stats.DiskTotal > 0 {
|
||||||
|
pct := (stats.DiskUsed / stats.DiskTotal) * 100
|
||||||
|
data.Info.ExtraFsPct[key] = system.ExtraFsInfo{DiskPct: pct}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ import (
|
|||||||
// SmartManager manages data collection for SMART devices
|
// SmartManager manages data collection for SMART devices
|
||||||
type SmartManager struct {
|
type SmartManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
SmartDataMap map[string]*smart.SmartData
|
SmartDataMap map[string]*smart.SmartData
|
||||||
SmartDevices []*DeviceInfo
|
SmartDevices []*DeviceInfo
|
||||||
refreshMutex sync.Mutex
|
refreshMutex sync.Mutex
|
||||||
lastScanTime time.Time
|
lastScanTime time.Time
|
||||||
binPath string
|
binPath string
|
||||||
|
excludedDevices map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
type scanOutput struct {
|
type scanOutput struct {
|
||||||
@@ -185,6 +186,7 @@ func (sm *SmartManager) ScanDevices(force bool) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
|
finalDevices := mergeDeviceLists(currentDevices, scannedDevices, configuredDevices)
|
||||||
|
finalDevices = sm.filterExcludedDevices(finalDevices)
|
||||||
sm.updateSmartDevices(finalDevices)
|
sm.updateSmartDevices(finalDevices)
|
||||||
|
|
||||||
if len(finalDevices) == 0 {
|
if len(finalDevices) == 0 {
|
||||||
@@ -232,6 +234,47 @@ func (sm *SmartManager) parseConfiguredDevices(config string) ([]*DeviceInfo, er
|
|||||||
return devices, nil
|
return devices, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) refreshExcludedDevices() {
|
||||||
|
rawValue, _ := GetEnv("EXCLUDE_SMART")
|
||||||
|
sm.excludedDevices = make(map[string]struct{})
|
||||||
|
|
||||||
|
for entry := range strings.SplitSeq(rawValue, ",") {
|
||||||
|
device := strings.TrimSpace(entry)
|
||||||
|
if device == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sm.excludedDevices[device] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) isExcludedDevice(deviceName string) bool {
|
||||||
|
_, exists := sm.excludedDevices[deviceName]
|
||||||
|
return exists
|
||||||
|
}
|
||||||
|
|
||||||
|
func (sm *SmartManager) filterExcludedDevices(devices []*DeviceInfo) []*DeviceInfo {
|
||||||
|
if devices == nil {
|
||||||
|
return []*DeviceInfo{}
|
||||||
|
}
|
||||||
|
|
||||||
|
excluded := sm.excludedDevices
|
||||||
|
if len(excluded) == 0 {
|
||||||
|
return devices
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]*DeviceInfo, 0, len(devices))
|
||||||
|
for _, device := range devices {
|
||||||
|
if device == nil || device.Name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, skip := excluded[device.Name]; skip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered = append(filtered, device)
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
// detectSmartOutputType inspects sections that are unique to each smartctl
|
// detectSmartOutputType inspects sections that are unique to each smartctl
|
||||||
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
|
// JSON schema (NVMe, ATA/SATA, SCSI) to determine which parser should be used
|
||||||
// when the reported device type is ambiguous or missing.
|
// when the reported device type is ambiguous or missing.
|
||||||
@@ -378,6 +421,10 @@ func (sm *SmartManager) parseSmartOutput(deviceInfo *DeviceInfo, output []byte)
|
|||||||
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
|
// Uses -n standby to avoid waking up sleeping disks, but bypasses standby mode
|
||||||
// for initial data collection when no cached data exists
|
// for initial data collection when no cached data exists
|
||||||
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||||
|
if deviceInfo != nil && sm.isExcludedDevice(deviceInfo.Name) {
|
||||||
|
return errNoValidSmartData
|
||||||
|
}
|
||||||
|
|
||||||
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
|
// slog.Info("collecting SMART data", "device", deviceInfo.Name, "type", deviceInfo.Type, "has_existing_data", sm.hasDataForDevice(deviceInfo.Name))
|
||||||
|
|
||||||
// Check if we have any existing data for this device
|
// Check if we have any existing data for this device
|
||||||
@@ -409,10 +456,10 @@ func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
|||||||
|
|
||||||
if !hasValidData {
|
if !hasValidData {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug("smartctl failed", "device", deviceInfo.Name, "err", err)
|
slog.Info("smartctl failed", "device", deviceInfo.Name, "err", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
slog.Debug("no valid SMART data found", "device", deviceInfo.Name)
|
slog.Info("no valid SMART data found", "device", deviceInfo.Name)
|
||||||
return errNoValidSmartData
|
return errNoValidSmartData
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,6 +962,7 @@ func NewSmartManager() (*SmartManager, error) {
|
|||||||
sm := &SmartManager{
|
sm := &SmartManager{
|
||||||
SmartDataMap: make(map[string]*smart.SmartData),
|
SmartDataMap: make(map[string]*smart.SmartData),
|
||||||
}
|
}
|
||||||
|
sm.refreshExcludedDevices()
|
||||||
path, err := sm.detectSmartctl()
|
path, err := sm.detectSmartctl()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Debug(err.Error())
|
slog.Debug(err.Error())
|
||||||
|
|||||||
@@ -588,3 +588,195 @@ func TestIsVirtualDeviceScsi(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRefreshExcludedDevices(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
envValue string
|
||||||
|
expectedDevs map[string]struct{}
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty env",
|
||||||
|
envValue: "",
|
||||||
|
expectedDevs: map[string]struct{}{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single device",
|
||||||
|
envValue: "/dev/sda",
|
||||||
|
expectedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple devices",
|
||||||
|
envValue: "/dev/sda,/dev/sdb,/dev/nvme0",
|
||||||
|
expectedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/sdb": {},
|
||||||
|
"/dev/nvme0": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "devices with whitespace",
|
||||||
|
envValue: " /dev/sda , /dev/sdb , /dev/nvme0 ",
|
||||||
|
expectedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/sdb": {},
|
||||||
|
"/dev/nvme0": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate devices",
|
||||||
|
envValue: "/dev/sda,/dev/sdb,/dev/sda",
|
||||||
|
expectedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/sdb": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty entries and whitespace",
|
||||||
|
envValue: "/dev/sda,, /dev/sdb , , ",
|
||||||
|
expectedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/sdb": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if tt.envValue != "" {
|
||||||
|
t.Setenv("EXCLUDE_SMART", tt.envValue)
|
||||||
|
} else {
|
||||||
|
// Ensure env var is not set for empty test
|
||||||
|
os.Unsetenv("EXCLUDE_SMART")
|
||||||
|
}
|
||||||
|
|
||||||
|
sm := &SmartManager{}
|
||||||
|
sm.refreshExcludedDevices()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedDevs, sm.excludedDevices)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsExcludedDevice(t *testing.T) {
|
||||||
|
sm := &SmartManager{
|
||||||
|
excludedDevices: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/nvme0": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
deviceName string
|
||||||
|
expectedBool bool
|
||||||
|
}{
|
||||||
|
{"excluded device sda", "/dev/sda", true},
|
||||||
|
{"excluded device nvme0", "/dev/nvme0", true},
|
||||||
|
{"non-excluded device sdb", "/dev/sdb", false},
|
||||||
|
{"non-excluded device nvme1", "/dev/nvme1", false},
|
||||||
|
{"empty device name", "", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := sm.isExcludedDevice(tt.deviceName)
|
||||||
|
assert.Equal(t, tt.expectedBool, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFilterExcludedDevices(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
excludedDevs map[string]struct{}
|
||||||
|
inputDevices []*DeviceInfo
|
||||||
|
expectedDevs []*DeviceInfo
|
||||||
|
expectedLength int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no exclusions",
|
||||||
|
excludedDevs: map[string]struct{}{},
|
||||||
|
inputDevices: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda"},
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
{Name: "/dev/nvme0"},
|
||||||
|
},
|
||||||
|
expectedDevs: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda"},
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
{Name: "/dev/nvme0"},
|
||||||
|
},
|
||||||
|
expectedLength: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "some devices excluded",
|
||||||
|
excludedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/nvme0": {},
|
||||||
|
},
|
||||||
|
inputDevices: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda"},
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
{Name: "/dev/nvme0"},
|
||||||
|
{Name: "/dev/nvme1"},
|
||||||
|
},
|
||||||
|
expectedDevs: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
{Name: "/dev/nvme1"},
|
||||||
|
},
|
||||||
|
expectedLength: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all devices excluded",
|
||||||
|
excludedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
"/dev/sdb": {},
|
||||||
|
},
|
||||||
|
inputDevices: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda"},
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
},
|
||||||
|
expectedDevs: []*DeviceInfo{},
|
||||||
|
expectedLength: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil devices",
|
||||||
|
excludedDevs: map[string]struct{}{},
|
||||||
|
inputDevices: nil,
|
||||||
|
expectedDevs: []*DeviceInfo{},
|
||||||
|
expectedLength: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "filter nil and empty name devices",
|
||||||
|
excludedDevs: map[string]struct{}{
|
||||||
|
"/dev/sda": {},
|
||||||
|
},
|
||||||
|
inputDevices: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sda"},
|
||||||
|
nil,
|
||||||
|
{Name: ""},
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
},
|
||||||
|
expectedDevs: []*DeviceInfo{
|
||||||
|
{Name: "/dev/sdb"},
|
||||||
|
},
|
||||||
|
expectedLength: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
sm := &SmartManager{
|
||||||
|
excludedDevices: tt.excludedDevs,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := sm.filterExcludedDevices(tt.inputDevices)
|
||||||
|
|
||||||
|
assert.Len(t, result, tt.expectedLength)
|
||||||
|
assert.Equal(t, tt.expectedDevs, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -40,13 +40,18 @@ type UserNotificationSettings struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertStats struct {
|
type SystemAlertStats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
Mem float64 `json:"mp"`
|
Mem float64 `json:"mp"`
|
||||||
Disk float64 `json:"dp"`
|
Disk float64 `json:"dp"`
|
||||||
NetSent float64 `json:"ns"`
|
NetSent float64 `json:"ns"`
|
||||||
NetRecv float64 `json:"nr"`
|
NetRecv float64 `json:"nr"`
|
||||||
Temperatures map[string]float32 `json:"t"`
|
GPU map[string]SystemAlertGPUData `json:"g"`
|
||||||
LoadAvg [3]float64 `json:"la"`
|
Temperatures map[string]float32 `json:"t"`
|
||||||
|
LoadAvg [3]float64 `json:"la"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SystemAlertGPUData struct {
|
||||||
|
Usage float64 `json:"u"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SystemAlertData struct {
|
type SystemAlertData struct {
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
val = data.Info.LoadAvg[2]
|
val = data.Info.LoadAvg[2]
|
||||||
unit = ""
|
unit = ""
|
||||||
|
case "GPU":
|
||||||
|
val = data.Info.GpuPct
|
||||||
}
|
}
|
||||||
|
|
||||||
triggered := alertRecord.GetBool("triggered")
|
triggered := alertRecord.GetBool("triggered")
|
||||||
@@ -206,6 +208,17 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
alert.val += stats.LoadAvg[1]
|
alert.val += stats.LoadAvg[1]
|
||||||
case "LoadAvg15":
|
case "LoadAvg15":
|
||||||
alert.val += stats.LoadAvg[2]
|
alert.val += stats.LoadAvg[2]
|
||||||
|
case "GPU":
|
||||||
|
if len(stats.GPU) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
maxUsage := 0.0
|
||||||
|
for _, gpu := range stats.GPU {
|
||||||
|
if gpu.Usage > maxUsage {
|
||||||
|
maxUsage = gpu.Usage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert.val += maxUsage
|
||||||
default:
|
default:
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -268,9 +281,9 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
alert.name = after + "m Load"
|
alert.name = after + "m Load"
|
||||||
}
|
}
|
||||||
|
|
||||||
// make title alert name lowercase if not CPU
|
// make title alert name lowercase if not CPU or GPU
|
||||||
titleAlertName := alert.name
|
titleAlertName := alert.name
|
||||||
if titleAlertName != "CPU" {
|
if titleAlertName != "CPU" && titleAlertName != "GPU" {
|
||||||
titleAlertName = strings.ToLower(titleAlertName)
|
titleAlertName = strings.ToLower(titleAlertName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,11 @@ type FsStats struct {
|
|||||||
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
|
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExtraFsInfo contains summary info for extra filesystems in the system info
|
||||||
|
type ExtraFsInfo struct {
|
||||||
|
DiskPct float64 `json:"dp" cbor:"0,keyasint"`
|
||||||
|
}
|
||||||
|
|
||||||
type NetIoStats struct {
|
type NetIoStats struct {
|
||||||
BytesRecv uint64
|
BytesRecv uint64
|
||||||
BytesSent uint64
|
BytesSent uint64
|
||||||
@@ -146,6 +151,7 @@ type Info struct {
|
|||||||
// TODO: remove load fields in future release in favor of load avg array
|
// TODO: remove load fields in future release in favor of load avg array
|
||||||
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
LoadAvg [3]float64 `json:"la,omitempty" cbor:"19,keyasint"`
|
||||||
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
ConnectionType ConnectionType `json:"ct,omitempty" cbor:"20,keyasint,omitempty,omitzero"`
|
||||||
|
ExtraFsPct map[string]ExtraFsInfo `json:"efsp,omitempty" cbor:"21,keyasint,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ func init() {
|
|||||||
"Disk",
|
"Disk",
|
||||||
"Temperature",
|
"Temperature",
|
||||||
"Bandwidth",
|
"Bandwidth",
|
||||||
|
"GPU",
|
||||||
"LoadAvg1",
|
"LoadAvg1",
|
||||||
"LoadAvg5",
|
"LoadAvg5",
|
||||||
"LoadAvg15"
|
"LoadAvg15"
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
WifiIcon,
|
WifiIcon,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { memo, useMemo, useRef, useState } from "react"
|
import { memo, useMemo, useRef, useState } from "react"
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"
|
||||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
import { ConnectionType, connectionTypeLabels, MeterState, SystemStatus } from "@/lib/enums"
|
||||||
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
import { $longestSystemNameLen, $userSettings } from "@/lib/stores"
|
||||||
@@ -153,7 +154,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
accessorFn: ({ info }) => info.dp,
|
accessorFn: ({ info }) => info.dp,
|
||||||
id: "disk",
|
id: "disk",
|
||||||
name: () => t`Disk`,
|
name: () => t`Disk`,
|
||||||
cell: TableCellWithMeter,
|
cell: DiskCellWithMultiple,
|
||||||
Icon: HardDriveIcon,
|
Icon: HardDriveIcon,
|
||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
@@ -354,6 +355,92 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DiskCellWithMultiple(info: CellContext<SystemRecord, unknown>) {
|
||||||
|
const { info: sysInfo, status } = info.row.original
|
||||||
|
const rootDiskPct = sysInfo.dp
|
||||||
|
const extraFsData = sysInfo.efsp
|
||||||
|
const extraFsCount = extraFsData ? Object.keys(extraFsData).length : 0
|
||||||
|
|
||||||
|
const threshold = getMeterState(rootDiskPct)
|
||||||
|
const meterClass = cn(
|
||||||
|
"h-full",
|
||||||
|
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
||||||
|
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||||
|
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||||
|
STATUS_COLORS.down
|
||||||
|
)
|
||||||
|
|
||||||
|
// No extra disks - show simple meter
|
||||||
|
if (extraFsCount === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 items-center tabular-nums tracking-tight w-full">
|
||||||
|
<span className="min-w-8 shrink-0">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||||
|
<span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
|
||||||
|
<span className={meterClass} style={{ width: `${rootDiskPct}%` }}></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has extra disks - show with tooltip
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div className="flex flex-col gap-0.5 w-full cursor-help relative z-10">
|
||||||
|
<div className="flex gap-2 items-center tabular-nums tracking-tight">
|
||||||
|
<span className="min-w-8 shrink-0">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||||
|
<span className="flex-1 min-w-8 grid bg-muted h-[1em] rounded-sm overflow-hidden">
|
||||||
|
<span className={meterClass} style={{ width: `${rootDiskPct}%` }}></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.7rem] text-muted-foreground ps-0.5">+{extraFsCount} more</div>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="right" className="max-w-xs">
|
||||||
|
<div className="flex flex-col gap-2 py-1">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium">
|
||||||
|
<HardDriveIcon className="size-3" />
|
||||||
|
<span>{t`All Disks`}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wider">{t`Root`}</div>
|
||||||
|
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||||
|
<span className="min-w-7">{decimalString(rootDiskPct, rootDiskPct >= 10 ? 1 : 2)}%</span>
|
||||||
|
<span className="flex-1 min-w-12 grid bg-muted/50 h-1.5 rounded-sm overflow-hidden">
|
||||||
|
<span className={meterClass} style={{ width: `${rootDiskPct}%` }}></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{extraFsData && Object.entries(extraFsData).map(([name, fs]) => {
|
||||||
|
const pct = fs.dp
|
||||||
|
const fsThreshold = getMeterState(pct)
|
||||||
|
const fsMeterClass = cn(
|
||||||
|
"h-full",
|
||||||
|
(status !== SystemStatus.Up && STATUS_COLORS.paused) ||
|
||||||
|
(fsThreshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||||
|
(fsThreshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||||
|
STATUS_COLORS.down
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div key={name} className="flex flex-col gap-0.5">
|
||||||
|
<div className="text-[0.65rem] text-muted-foreground uppercase tracking-wider truncate">{name}</div>
|
||||||
|
<div className="flex gap-2 items-center tabular-nums text-xs">
|
||||||
|
<span className="min-w-7">{decimalString(pct, pct >= 10 ? 1 : 2)}%</span>
|
||||||
|
<span className="flex-1 min-w-12 grid bg-muted/50 h-1.5 rounded-sm overflow-hidden">
|
||||||
|
<span className={fsMeterClass} style={{ width: `${pct}%` }}></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
||||||
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
|
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { CpuIcon, HardDriveIcon, HourglassIcon, MemoryStickIcon, ServerIcon, ThermometerIcon } from "lucide-react"
|
import { CpuIcon, HardDriveIcon, HourglassIcon, MemoryStickIcon, ServerIcon, ThermometerIcon } from "lucide-react"
|
||||||
import type { RecordSubscription } from "pocketbase"
|
import type { RecordSubscription } from "pocketbase"
|
||||||
import { EthernetIcon } from "@/components/ui/icons"
|
import { EthernetIcon, GpuIcon } from "@/components/ui/icons"
|
||||||
import { $alerts } from "@/lib/stores"
|
import { $alerts } from "@/lib/stores"
|
||||||
import type { AlertInfo, AlertRecord } from "@/types"
|
import type { AlertInfo, AlertRecord } from "@/types"
|
||||||
import { pb } from "./api"
|
import { pb } from "./api"
|
||||||
@@ -41,6 +41,12 @@ export const alertInfo: Record<string, AlertInfo> = {
|
|||||||
desc: () => t`Triggers when combined up/down exceeds a threshold`,
|
desc: () => t`Triggers when combined up/down exceeds a threshold`,
|
||||||
max: 125,
|
max: 125,
|
||||||
},
|
},
|
||||||
|
GPU: {
|
||||||
|
name: () => t`GPU Usage`,
|
||||||
|
unit: "%",
|
||||||
|
icon: GpuIcon,
|
||||||
|
desc: () => t`Triggers when GPU usage exceeds a threshold`,
|
||||||
|
},
|
||||||
Temperature: {
|
Temperature: {
|
||||||
name: () => t`Temperature`,
|
name: () => t`Temperature`,
|
||||||
unit: "°C",
|
unit: "°C",
|
||||||
|
|||||||
31
internal/site/src/types.d.ts
vendored
31
internal/site/src/types.d.ts
vendored
@@ -77,6 +77,13 @@ export interface SystemInfo {
|
|||||||
os?: Os
|
os?: Os
|
||||||
/** connection type */
|
/** connection type */
|
||||||
ct?: ConnectionType
|
ct?: ConnectionType
|
||||||
|
/** extra filesystem percentages */
|
||||||
|
efsp?: Record<string, ExtraFsInfo>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtraFsInfo {
|
||||||
|
/** disk percent */
|
||||||
|
dp: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SystemStats {
|
export interface SystemStats {
|
||||||
@@ -301,18 +308,18 @@ export interface ChartData {
|
|||||||
chartTime: ChartTimes
|
chartTime: ChartTimes
|
||||||
}
|
}
|
||||||
|
|
||||||
// interface AlertInfo {
|
export interface AlertInfo {
|
||||||
// name: () => string
|
name: () => string
|
||||||
// unit: string
|
unit: string
|
||||||
// icon: any
|
icon: any
|
||||||
// desc: () => string
|
desc: () => string
|
||||||
// max?: number
|
max?: number
|
||||||
// min?: number
|
min?: number
|
||||||
// step?: number
|
step?: number
|
||||||
// start?: number
|
start?: number
|
||||||
// /** 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 type AlertMap = Record<string, Map<string, AlertRecord>>
|
export type AlertMap = Record<string, Map<string, AlertRecord>>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user