diff --git a/README.md b/README.md index 67a564d..7b164fa 100644 --- a/README.md +++ b/README.md @@ -434,13 +434,15 @@ host = "0.0.0.0" port = 8200 interface = "" # Empty for all interfaces api_key = "your-secret-key" -disk_includes = ["/mnt/storage"] # Additional mounts to monitor +disk_includes = ["/mnt/storage"] # Hard override: include these mounts even if small, tmpfs, or bind mounts disk_excludes = ["/boot", "/tmp"] # Mounts to exclude [monitor] enabled = true ``` +`disk_includes` is a hard override. Explicitly included mounts are reported even if they would normally be skipped for being special filesystems or smaller than 1 GiB. Disk reporting also dedupes bind mounts by default; explicitly included bind mounts are kept. + ### Packet Loss Monitoring Continuous network monitoring with MTR integration and performance tracking. @@ -796,7 +798,7 @@ NETRONOME__AGENT_HOST=0.0.0.0 # Agent listen address NETRONOME__AGENT_PORT=8200 # Agent port NETRONOME__AGENT_INTERFACE= # Network interface to monitor (empty for all) NETRONOME__AGENT_API_KEY= # Agent API key for authentication -NETRONOME__AGENT_DISK_INCLUDES= # Comma-separated paths to include +NETRONOME__AGENT_DISK_INCLUDES= # Comma-separated hard override include paths NETRONOME__AGENT_DISK_EXCLUDES= # Comma-separated paths to exclude ``` diff --git a/cmd/netronome/main.go b/cmd/netronome/main.go index 2143910..ffc70f2 100644 --- a/cmd/netronome/main.go +++ b/cmd/netronome/main.go @@ -42,7 +42,7 @@ var ( rootCmd = &cobra.Command{ Use: "netronome", Short: "Netronome is a network performance testing and monitoring tool", - Long: `Netronome is a network performance testing and monitoring tool that helps you + Long: `Netronome is a network performance testing and monitoring tool that helps you track and analyze your network performance over time.`, CompletionOptions: cobra.CompletionOptions{ DisableDefaultCmd: true, @@ -118,7 +118,7 @@ func init() { agentCmd.Flags().StringP("interface", "i", "", "network interface to monitor (empty for all)") agentCmd.Flags().StringP("api-key", "k", "", "API key for authentication") agentCmd.Flags().StringP("log-level", "l", "", "log level (trace, debug, info, warn, error)") - agentCmd.Flags().StringSlice("disk-include", []string{}, "additional disk mount points to monitor (e.g., /mnt/storage)") + agentCmd.Flags().StringSlice("disk-include", []string{}, "disk mount points to force into monitoring, even if small or normally filtered (e.g., /mnt/storage)") agentCmd.Flags().StringSlice("disk-exclude", []string{}, "disk mount points to exclude from monitoring (e.g., /boot)") agentCmd.Flags().Bool("disable-system-metrics", false, "disable system metrics collection (CPU, memory, disk, temperature)") agentCmd.Flags().Bool("tailscale", false, "enable Tailscale for secure connectivity") diff --git a/internal/agent/disk_utils.go b/internal/agent/disk_utils.go index ef8321c..1132d82 100644 --- a/internal/agent/disk_utils.go +++ b/internal/agent/disk_utils.go @@ -4,14 +4,24 @@ package agent import ( + "fmt" "os" "path/filepath" "runtime" "strings" "github.com/rs/zerolog/log" + "github.com/shirou/gopsutil/v4/disk" ) +const minDiskReportSize = 1024 * 1024 * 1024 + +type diskReportEntry struct { + Partition disk.PartitionStat + Usage *disk.UsageStat + ExplicitInclude bool +} + // matchPath checks if a path matches a pattern, supporting both glob and prefix patterns func matchPath(pattern, path string) bool { // Check if pattern ends with * for prefix matching @@ -36,13 +46,12 @@ func matchPath(pattern, path string) bool { return pattern == path } -// shouldIncludeDisk determines if a disk should be included in monitoring based on filter rules -func (a *Agent) shouldIncludeDisk(mountpoint, device, fstype string) bool { +func (a *Agent) diskFilterDecision(mountpoint, device, fstype string) (include bool, explicit bool) { // First check if it's explicitly included - this takes precedence for _, pattern := range a.config.DiskIncludes { if matchPath(pattern, mountpoint) { log.Debug().Str("mount", mountpoint).Str("pattern", pattern).Msg("Disk included by pattern match") - return true + return true, true } } @@ -50,7 +59,7 @@ func (a *Agent) shouldIncludeDisk(mountpoint, device, fstype string) bool { for _, pattern := range a.config.DiskExcludes { if matchPath(pattern, mountpoint) { log.Debug().Str("mount", mountpoint).Str("pattern", pattern).Msg("Disk excluded by pattern match") - return false + return false, false } } @@ -73,11 +82,126 @@ func (a *Agent) shouldIncludeDisk(mountpoint, device, fstype string) bool { fstype == "sysfs" || fstype == "cgroup" || fstype == "cgroup2" { - return false + return false, false } // Include everything else by default - return true + return true, false +} + +func (a *Agent) buildDiskReportEntries(partitions []disk.PartitionStat, usageFn func(string) (*disk.UsageStat, error)) []diskReportEntry { + entries := make([]diskReportEntry, 0, len(partitions)) + + for _, partition := range partitions { + include, explicit := a.diskFilterDecision(partition.Mountpoint, partition.Device, partition.Fstype) + if !include { + log.Debug(). + Str("mount", partition.Mountpoint). + Str("device", partition.Device). + Str("fstype", partition.Fstype). + Msg("Skipping disk based on filter rules") + continue + } + + usage, err := usageFn(partition.Mountpoint) + if err != nil { + log.Debug().Err(err).Str("mount", partition.Mountpoint).Msg("Failed to get disk usage") + continue + } + + if !explicit && usage.Total < minDiskReportSize { + log.Debug(). + Str("mount", partition.Mountpoint). + Uint64("total", usage.Total). + Msg("Skipping small disk") + continue + } + + entries = append(entries, diskReportEntry{ + Partition: partition, + Usage: usage, + ExplicitInclude: explicit, + }) + } + + return dedupeDiskReportEntries(entries) +} + +func dedupeDiskReportEntries(entries []diskReportEntry) []diskReportEntry { + if len(entries) < 2 { + return entries + } + + grouped := make(map[string][]int, len(entries)) + for i, entry := range entries { + sig := diskReportSignature(entry) + grouped[sig] = append(grouped[sig], i) + } + + keep := make([]bool, len(entries)) + + for _, indexes := range grouped { + if len(indexes) == 1 { + keep[indexes[0]] = true + continue + } + + preferred := indexes[0] + for _, idx := range indexes[1:] { + if prefersDiskEntry(entries[idx], entries[preferred]) { + preferred = idx + } + } + + keep[preferred] = true + + for _, idx := range indexes { + if idx == preferred { + continue + } + if entries[idx].ExplicitInclude || !isBindMount(entries[idx].Partition.Opts) { + keep[idx] = true + } + } + } + + deduped := make([]diskReportEntry, 0, len(entries)) + for i, entry := range entries { + if keep[i] { + deduped = append(deduped, entry) + } + } + + return deduped +} + +func diskReportSignature(entry diskReportEntry) string { + return fmt.Sprintf("%s|%s|%d", entry.Partition.Device, entry.Partition.Fstype, entry.Usage.Total) +} + +func prefersDiskEntry(left, right diskReportEntry) bool { + leftBind := isBindMount(left.Partition.Opts) + rightBind := isBindMount(right.Partition.Opts) + + if leftBind != rightBind { + return !leftBind + } + + if len(left.Partition.Mountpoint) != len(right.Partition.Mountpoint) { + return len(left.Partition.Mountpoint) < len(right.Partition.Mountpoint) + } + + return left.Partition.Mountpoint < right.Partition.Mountpoint +} + +func isBindMount(opts []string) bool { + for _, opt := range opts { + if opt == "bind" { + return true + } + } + + return false } // getDevicePaths returns a list of device paths to check for SMART data diff --git a/internal/agent/disk_utils_test.go b/internal/agent/disk_utils_test.go new file mode 100644 index 0000000..8bbec53 --- /dev/null +++ b/internal/agent/disk_utils_test.go @@ -0,0 +1,151 @@ +// Copyright (c) 2024-2026, s0up and the autobrr contributors. +// SPDX-License-Identifier: GPL-2.0-or-later + +package agent + +import ( + "testing" + + "github.com/shirou/gopsutil/v4/disk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/autobrr/netronome/internal/config" +) + +func TestBuildDiskReportEntries(t *testing.T) { + tests := []struct { + name string + cfg config.AgentConfig + partitions []disk.PartitionStat + usageFn func(string) (*disk.UsageStat, error) + expectedMountpoints []string + expectedExplicitIncludes []bool + }{ + { + name: "explicit include bypasses size filter", + cfg: config.AgentConfig{ + DiskIncludes: []string{"/var/log"}, + }, + partitions: []disk.PartitionStat{ + { + Device: "tmpfs", + Mountpoint: "/var/log", + Fstype: "tmpfs", + }, + }, + usageFn: func(mountpoint string) (*disk.UsageStat, error) { + return &disk.UsageStat{ + Path: mountpoint, + Total: 128 * 1024 * 1024, + Used: 64 * 1024 * 1024, + Free: 64 * 1024 * 1024, + UsedPercent: 50, + }, nil + }, + expectedMountpoints: []string{"/var/log"}, + expectedExplicitIncludes: []bool{true}, + }, + { + name: "dedupes non explicit bind mounts", + cfg: config.AgentConfig{}, + partitions: []disk.PartitionStat{ + { + Device: "/dev/mmcblk0p2", + Mountpoint: "/", + Fstype: "ext4", + Opts: []string{"rw", "relatime"}, + }, + { + Device: "/dev/mmcblk0p2", + Mountpoint: "/var/hdd.log", + Fstype: "ext4", + Opts: []string{"rw", "relatime", "bind"}, + }, + }, + usageFn: func(mountpoint string) (*disk.UsageStat, error) { + return &disk.UsageStat{ + Path: mountpoint, + Total: 32 * 1024 * 1024 * 1024, + Used: 8 * 1024 * 1024 * 1024, + Free: 24 * 1024 * 1024 * 1024, + UsedPercent: 25, + }, nil + }, + expectedMountpoints: []string{"/"}, + expectedExplicitIncludes: []bool{false}, + }, + { + name: "keeps explicitly included bind mounts", + cfg: config.AgentConfig{ + DiskIncludes: []string{"/var/hdd.log"}, + }, + partitions: []disk.PartitionStat{ + { + Device: "/dev/mmcblk0p2", + Mountpoint: "/", + Fstype: "ext4", + Opts: []string{"rw", "relatime"}, + }, + { + Device: "/dev/mmcblk0p2", + Mountpoint: "/var/hdd.log", + Fstype: "ext4", + Opts: []string{"rw", "relatime", "bind"}, + }, + }, + usageFn: func(mountpoint string) (*disk.UsageStat, error) { + return &disk.UsageStat{ + Path: mountpoint, + Total: 32 * 1024 * 1024 * 1024, + Used: 8 * 1024 * 1024 * 1024, + Free: 24 * 1024 * 1024 * 1024, + UsedPercent: 25, + }, nil + }, + expectedMountpoints: []string{"/", "/var/hdd.log"}, + expectedExplicitIncludes: []bool{false, true}, + }, + { + name: "include wins over exclude precedence", + cfg: config.AgentConfig{ + DiskIncludes: []string{"/var/hdd.log"}, + DiskExcludes: []string{"/var/hdd.log"}, + }, + partitions: []disk.PartitionStat{ + { + Device: "/dev/mmcblk0p2", + Mountpoint: "/var/hdd.log", + Fstype: "ext4", + Opts: []string{"rw", "relatime", "bind"}, + }, + }, + usageFn: func(mountpoint string) (*disk.UsageStat, error) { + return &disk.UsageStat{ + Path: mountpoint, + Total: 32 * 1024 * 1024 * 1024, + Used: 8 * 1024 * 1024 * 1024, + Free: 24 * 1024 * 1024 * 1024, + UsedPercent: 25, + }, nil + }, + expectedMountpoints: []string{"/var/hdd.log"}, + expectedExplicitIncludes: []bool{true}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agent := New(&tt.cfg) + + entries := agent.buildDiskReportEntries(tt.partitions, tt.usageFn) + + require.Len(t, entries, len(tt.expectedMountpoints)) + require.Len(t, tt.expectedExplicitIncludes, len(entries)) + for i, entry := range entries { + assert.Equal(t, tt.expectedMountpoints[i], entry.Partition.Mountpoint) + assert.Equal(t, tt.expectedExplicitIncludes[i], entry.ExplicitInclude) + } + }) + } +} diff --git a/internal/agent/hardware.go b/internal/agent/hardware.go index acdd663..3b85950 100644 --- a/internal/agent/hardware.go +++ b/internal/agent/hardware.go @@ -197,32 +197,15 @@ func (a *Agent) getHardwareStats() (*HardwareStats, error) { } } - for _, partition := range partitions { - // Check if this disk should be included based on filters - if !a.shouldIncludeDisk(partition.Mountpoint, partition.Device, partition.Fstype) { - log.Debug(). - Str("mount", partition.Mountpoint). - Str("device", partition.Device). - Str("fstype", partition.Fstype). - Msg("Skipping disk based on filter rules") - continue - } - - usage, err := disk.Usage(partition.Mountpoint) + for _, entry := range a.buildDiskReportEntries(partitions, func(mountpoint string) (*disk.UsageStat, error) { + usage, err := disk.Usage(mountpoint) if err != nil { - log.Debug().Err(err).Str("mount", partition.Mountpoint).Msg("Failed to get disk usage") - continue + return nil, err } - - // Skip if disk is too small (less than 1GB) - if usage.Total < 1024*1024*1024 { - log.Debug(). - Str("mount", partition.Mountpoint). - Uint64("total", usage.Total). - Msg("Skipping small disk") - continue - } - + return usage, nil + }) { + partition := entry.Partition + usage := entry.Usage diskStat := DiskStats{ Path: partition.Mountpoint, Device: partition.Device,