From 40563452166c87b6ced7185e23a09c7cd15af5a0 Mon Sep 17 00:00:00 2001 From: henrygd Date: Thu, 9 Oct 2025 13:18:10 -0400 Subject: [PATCH] add ability to set custom name for extra filesystems (#379) --- agent/agent.go | 7 +- agent/disk.go | 36 ++- agent/disk_test.go | 235 ++++++++++++++++++ internal/entities/system/system.go | 1 + .../site/src/components/charts/disk-chart.tsx | 4 +- .../site/src/components/routes/system.tsx | 2 +- 6 files changed, 274 insertions(+), 11 deletions(-) create mode 100644 agent/disk_test.go diff --git a/agent/agent.go b/agent/agent.go index 8a3cd801..4c395a70 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -152,7 +152,12 @@ func (a *Agent) gatherStats(cacheTimeMs uint16) *system.CombinedData { data.Stats.ExtraFs = make(map[string]*system.FsStats) for name, stats := range a.fsStats { if !stats.Root && stats.DiskTotal > 0 { - data.Stats.ExtraFs[name] = stats + // Use custom name if available, otherwise use device name + key := name + if stats.Name != "" { + key = stats.Name + } + data.Stats.ExtraFs[key] = stats } } slog.Debug("Extra FS", "data", data.Stats.ExtraFs) diff --git a/agent/disk.go b/agent/disk.go index e117a284..6cd5dcc4 100644 --- a/agent/disk.go +++ b/agent/disk.go @@ -13,6 +13,19 @@ import ( "github.com/shirou/gopsutil/v4/disk" ) +// parseFilesystemEntry parses a filesystem entry in the format "device__customname" +// Returns the device/filesystem part and the custom name part +func parseFilesystemEntry(entry string) (device, customName string) { + entry = strings.TrimSpace(entry) + if parts := strings.SplitN(entry, "__", 2); len(parts) == 2 { + device = strings.TrimSpace(parts[0]) + customName = strings.TrimSpace(parts[1]) + } else { + device = entry + } + return device, customName +} + // Sets up the filesystems to monitor for disk usage and I/O. func (a *Agent) initializeDiskInfo() { filesystem, _ := GetEnv("FILESYSTEM") @@ -37,7 +50,7 @@ func (a *Agent) initializeDiskInfo() { slog.Debug("Disk I/O", "diskstats", diskIoCounters) // Helper function to add a filesystem to fsStats if it doesn't exist - addFsStat := func(device, mountpoint string, root bool) { + addFsStat := func(device, mountpoint string, root bool, customName ...string) { var key string if runtime.GOOS == "windows" { key = device @@ -66,7 +79,11 @@ func (a *Agent) initializeDiskInfo() { } } } - a.fsStats[key] = &system.FsStats{Root: root, Mountpoint: mountpoint} + fsStats := &system.FsStats{Root: root, Mountpoint: mountpoint} + if len(customName) > 0 && customName[0] != "" { + fsStats.Name = customName[0] + } + a.fsStats[key] = fsStats } } @@ -86,11 +103,14 @@ func (a *Agent) initializeDiskInfo() { // Add EXTRA_FILESYSTEMS env var values to fsStats if extraFilesystems, exists := GetEnv("EXTRA_FILESYSTEMS"); exists { - for _, fs := range strings.Split(extraFilesystems, ",") { + for _, fsEntry := range strings.Split(extraFilesystems, ",") { + // Parse custom name from format: device__customname + fs, customName := parseFilesystemEntry(fsEntry) + found := false for _, p := range partitions { if strings.HasSuffix(p.Device, fs) || p.Mountpoint == fs { - addFsStat(p.Device, p.Mountpoint, false) + addFsStat(p.Device, p.Mountpoint, false, customName) found = true break } @@ -98,7 +118,7 @@ func (a *Agent) initializeDiskInfo() { // if not in partitions, test if we can get disk usage if !found { if _, err := disk.Usage(fs); err == nil { - addFsStat(filepath.Base(fs), fs, false) + addFsStat(filepath.Base(fs), fs, false, customName) } else { slog.Error("Invalid filesystem", "name", fs, "err", err) } @@ -120,7 +140,8 @@ func (a *Agent) initializeDiskInfo() { // Check if device is in /extra-filesystems if strings.HasPrefix(p.Mountpoint, efPath) { - addFsStat(p.Device, p.Mountpoint, false) + device, customName := parseFilesystemEntry(p.Mountpoint) + addFsStat(device, p.Mountpoint, false, customName) } } @@ -135,7 +156,8 @@ func (a *Agent) initializeDiskInfo() { mountpoint := filepath.Join(efPath, folder.Name()) slog.Debug("/extra-filesystems", "mountpoint", mountpoint) if !existingMountpoints[mountpoint] { - addFsStat(folder.Name(), mountpoint, false) + device, customName := parseFilesystemEntry(folder.Name()) + addFsStat(device, mountpoint, false, customName) } } } diff --git a/agent/disk_test.go b/agent/disk_test.go new file mode 100644 index 00000000..88e9b712 --- /dev/null +++ b/agent/disk_test.go @@ -0,0 +1,235 @@ +//go:build testing +// +build testing + +package agent + +import ( + "os" + "strings" + "testing" + + "github.com/henrygd/beszel/internal/entities/system" + "github.com/shirou/gopsutil/v4/disk" + "github.com/stretchr/testify/assert" +) + +func TestParseFilesystemEntry(t *testing.T) { + tests := []struct { + name string + input string + expectedFs string + expectedName string + }{ + { + name: "simple device name", + input: "sda1", + expectedFs: "sda1", + expectedName: "", + }, + { + name: "device with custom name", + input: "sda1__my-storage", + expectedFs: "sda1", + expectedName: "my-storage", + }, + { + name: "full device path with custom name", + input: "/dev/sdb1__backup-drive", + expectedFs: "/dev/sdb1", + expectedName: "backup-drive", + }, + { + name: "NVMe device with custom name", + input: "nvme0n1p2__fast-ssd", + expectedFs: "nvme0n1p2", + expectedName: "fast-ssd", + }, + { + name: "whitespace trimmed", + input: " sda2__trimmed-name ", + expectedFs: "sda2", + expectedName: "trimmed-name", + }, + { + name: "empty custom name", + input: "sda3__", + expectedFs: "sda3", + expectedName: "", + }, + { + name: "empty device name", + input: "__just-custom", + expectedFs: "", + expectedName: "just-custom", + }, + { + name: "multiple underscores in custom name", + input: "sda1__my_custom_drive", + expectedFs: "sda1", + expectedName: "my_custom_drive", + }, + { + name: "custom name with spaces", + input: "sda1__My Storage Drive", + expectedFs: "sda1", + expectedName: "My Storage Drive", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fsEntry := strings.TrimSpace(tt.input) + var fs, customName string + if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 { + fs = strings.TrimSpace(parts[0]) + customName = strings.TrimSpace(parts[1]) + } else { + fs = fsEntry + } + + assert.Equal(t, tt.expectedFs, fs) + assert.Equal(t, tt.expectedName, customName) + }) + } +} + +func TestInitializeDiskInfoWithCustomNames(t *testing.T) { + // Set up environment variables + oldEnv := os.Getenv("EXTRA_FILESYSTEMS") + defer func() { + if oldEnv != "" { + os.Setenv("EXTRA_FILESYSTEMS", oldEnv) + } else { + os.Unsetenv("EXTRA_FILESYSTEMS") + } + }() + + // Test with custom names + os.Setenv("EXTRA_FILESYSTEMS", "sda1__my-storage,/dev/sdb1__backup-drive,nvme0n1p2") + + // Mock disk partitions (we'll just test the parsing logic) + // Since the actual disk operations are system-dependent, we'll focus on the parsing + testCases := []struct { + envValue string + expectedFs []string + expectedNames map[string]string + }{ + { + envValue: "sda1__my-storage,sdb1__backup-drive", + expectedFs: []string{"sda1", "sdb1"}, + expectedNames: map[string]string{ + "sda1": "my-storage", + "sdb1": "backup-drive", + }, + }, + { + envValue: "sda1,nvme0n1p2__fast-ssd", + expectedFs: []string{"sda1", "nvme0n1p2"}, + expectedNames: map[string]string{ + "nvme0n1p2": "fast-ssd", + }, + }, + } + + for _, tc := range testCases { + t.Run("env_"+tc.envValue, func(t *testing.T) { + os.Setenv("EXTRA_FILESYSTEMS", tc.envValue) + + // Create mock partitions that would match our test cases + partitions := []disk.PartitionStat{} + for _, fs := range tc.expectedFs { + if strings.HasPrefix(fs, "/dev/") { + partitions = append(partitions, disk.PartitionStat{ + Device: fs, + Mountpoint: fs, + }) + } else { + partitions = append(partitions, disk.PartitionStat{ + Device: "/dev/" + fs, + Mountpoint: "/" + fs, + }) + } + } + + // Test the parsing logic by calling the relevant part + // We'll create a simplified version to test just the parsing + extraFilesystems := tc.envValue + for _, fsEntry := range strings.Split(extraFilesystems, ",") { + // Parse the entry + fsEntry = strings.TrimSpace(fsEntry) + var fs, customName string + if parts := strings.SplitN(fsEntry, "__", 2); len(parts) == 2 { + fs = strings.TrimSpace(parts[0]) + customName = strings.TrimSpace(parts[1]) + } else { + fs = fsEntry + } + + // Verify the device is in our expected list + assert.Contains(t, tc.expectedFs, fs, "parsed device should be in expected list") + + // Check if custom name should exist + if expectedName, exists := tc.expectedNames[fs]; exists { + assert.Equal(t, expectedName, customName, "custom name should match expected") + } else { + assert.Empty(t, customName, "custom name should be empty when not expected") + } + } + }) + } +} + +func TestFsStatsWithCustomNames(t *testing.T) { + // Test that FsStats properly stores custom names + fsStats := &system.FsStats{ + Mountpoint: "/mnt/storage", + Name: "my-custom-storage", + DiskTotal: 100.0, + DiskUsed: 50.0, + } + + assert.Equal(t, "my-custom-storage", fsStats.Name) + assert.Equal(t, "/mnt/storage", fsStats.Mountpoint) + assert.Equal(t, 100.0, fsStats.DiskTotal) + assert.Equal(t, 50.0, fsStats.DiskUsed) +} + +func TestExtraFsKeyGeneration(t *testing.T) { + // Test the logic for generating ExtraFs keys with custom names + testCases := []struct { + name string + deviceName string + customName string + expectedKey string + }{ + { + name: "with custom name", + deviceName: "sda1", + customName: "my-storage", + expectedKey: "my-storage", + }, + { + name: "without custom name", + deviceName: "sda1", + customName: "", + expectedKey: "sda1", + }, + { + name: "empty custom name falls back to device", + deviceName: "nvme0n1p2", + customName: "", + expectedKey: "nvme0n1p2", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Simulate the key generation logic from agent.go + key := tc.deviceName + if tc.customName != "" { + key = tc.customName + } + assert.Equal(t, tc.expectedKey, key) + }) + } +} diff --git a/internal/entities/system/system.go b/internal/entities/system/system.go index 5676e182..5177bbfb 100644 --- a/internal/entities/system/system.go +++ b/internal/entities/system/system.go @@ -62,6 +62,7 @@ type FsStats struct { Time time.Time `json:"-"` Root bool `json:"-"` Mountpoint string `json:"-"` + Name string `json:"-"` DiskTotal float64 `json:"d" cbor:"0,keyasint"` DiskUsed float64 `json:"du" cbor:"1,keyasint"` TotalRead uint64 `json:"-"` diff --git a/internal/site/src/components/charts/disk-chart.tsx b/internal/site/src/components/charts/disk-chart.tsx index bc7fe26c..4be78f93 100644 --- a/internal/site/src/components/charts/disk-chart.tsx +++ b/internal/site/src/components/charts/disk-chart.tsx @@ -4,7 +4,7 @@ import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { Unit } from "@/lib/enums" import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils" -import type { ChartData } from "@/types" +import type { ChartData, SystemStatsRecord } from "@/types" import { useYAxisWidth } from "./hooks" export default memo(function DiskChart({ @@ -12,7 +12,7 @@ export default memo(function DiskChart({ diskSize, chartData, }: { - dataKey: string + dataKey: string | ((data: SystemStatsRecord) => number | undefined) diskSize: number chartData: ChartData }) { diff --git a/internal/site/src/components/routes/system.tsx b/internal/site/src/components/routes/system.tsx index 6e7c9588..61db64e3 100644 --- a/internal/site/src/components/routes/system.tsx +++ b/internal/site/src/components/routes/system.tsx @@ -932,7 +932,7 @@ export default memo(function SystemDetail({ id }: { id: string }) { > stats?.efs?.[extraFsName]?.du} diskSize={systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN} />