From a8ab26752aae61fd76a35fe242fe98387be879be Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 6 Jan 2026 10:20:34 -0800 Subject: [PATCH] initial --- .gitignore | 2 + Makefile | 41 +- README.md | 137 +++++ cmd/rtnetmon/main.go | 20 + go.mod | 24 +- go.sum | 70 +++ internal/cli/root.go | 118 ++++ internal/cli/root_test.go | 36 ++ internal/monitor/loops.go | 278 ++++++++++ internal/monitor/monitor.go | 394 +++++++++++++ internal/monitor/monitor_test.go | 126 +++++ internal/monitor/styles.go | 87 +++ internal/monitor/styles_test.go | 105 ++++ internal/monitor/ui.go | 378 +++++++++++++ main.go | 919 ------------------------------- 15 files changed, 1806 insertions(+), 929 deletions(-) create mode 100644 README.md create mode 100644 cmd/rtnetmon/main.go create mode 100644 internal/cli/root.go create mode 100644 internal/cli/root_test.go create mode 100644 internal/monitor/loops.go create mode 100644 internal/monitor/monitor.go create mode 100644 internal/monitor/monitor_test.go create mode 100644 internal/monitor/styles.go create mode 100644 internal/monitor/styles_test.go create mode 100644 internal/monitor/ui.go delete mode 100644 main.go diff --git a/.gitignore b/.gitignore index d5ac763..5bdb44c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .aider* .env +AGENTS.md +CLAUDE.md diff --git a/Makefile b/Makefile index c1c0275..25604ad 100644 --- a/Makefile +++ b/Makefile @@ -1,17 +1,40 @@ -default: run +.PHONY: test lint fmt build run-local clean all copy run -main.go: - pbpaste > main.go +# Default target +default: test -copy: main.go - ssh root@las1stor1 -v "mkdir -p /tmp/x" - scp ./main.go root@las1stor1:/tmp/x +# Build the binary +build: + go build -o rtnetmon ./cmd/rtnetmon -run: copy - ssh -t root@las1stor1 -v "cd /tmp/x && go run ." +# Run tests +test: lint + go test -v ./... +# Run linter lint: golangci-lint run +# Format code +fmt: + go fmt ./... + +# Run the application locally +run-local: build + ./rtnetmon + +# Clean build artifacts clean: - rm main.go + rm -f rtnetmon + rm -f main.go.old + +# Build and test everything +all: fmt lint test build + +# Remote deployment targets +copy: + ssh root@las1stor1 -v "mkdir -p /tmp/x" + rsync -av --exclude='.git' --exclude='*.test' --exclude='/rtnetmon' --exclude='main.go.old' ./ root@las1stor1:/tmp/x/ + +run: copy + ssh -t root@las1stor1 -v "cd /tmp/x && go run ./cmd/rtnetmon" diff --git a/README.md b/README.md new file mode 100644 index 0000000..b8fc7ef --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# rtnetmon + +Real-time network monitoring dashboard for Linux systems with dual-interface support. + +## Overview + +rtnetmon is a terminal-based network monitoring tool that provides real-time +visibility into network health, packet loss, and latency across two network +interfaces simultaneously. It's designed for Linux systems and uses ncurses +for a clean, real-time dashboard interface. + +## Features + +- **Dual Interface Monitoring**: Monitor two network interfaces simultaneously +- **Real-time Updates**: Live dashboard with sub-second updates +- **Comprehensive Metrics**: + - ICMP reachability tests + - Packet loss percentage + - TCP connection latency + - Interface health status +- **Visual Indicators**: Color-coded status, spinners, and meters for quick status assessment + - Packet loss meter uses reverse coloring (empty/green = good, full/red = bad) +- **Detailed Logging**: Optional logging to file for debugging and analysis + +## Requirements + +- Linux operating system +- Go 1.21 or later +- Root/sudo access (for raw ICMP packets) +- `ping` command available in PATH + +## Installation + +```bash +git clone https://git.eeqj.de/sneak/rtnetmon.git +cd rtnetmon +make build +``` + +## Usage + +```bash +sudo ./rtnetmon --ifaceA eth0 --labelA "Primary WAN" --ifaceB wlan0 --labelB "Backup WiFi" +``` + +### Command Line Options + +- `--ifaceA`: Primary network interface (default: "gu0") +- `--labelA`: Label for primary interface (default: "gu LAN - VPN outbound") +- `--ifaceB`: Secondary network interface (default: "backhaul0") +- `--labelB`: Label for secondary interface (default: "Cox cable direct") +- `--hosts`: Comma-separated list of hosts to monitor +- `--logfile`: Path to log file (default: "/tmp/rtnetmon.log") + +### Keyboard Controls + +- `q` or `Ctrl+C`: Quit the application + +## Project Structure + +``` +rtnetmon/ +├── cmd/rtnetmon/ # Main entry point +│ └── main.go +├── internal/ +│ ├── cli/ # Command-line interface using Cobra +│ │ └── root.go +│ └── monitor/ # Core monitoring functionality +│ ├── monitor.go # Main monitoring types and functions +│ ├── loops.go # Monitoring loops (reachability, loss, TCP) +│ ├── styles.go # Terminal color styles +│ └── ui.go # User interface rendering +├── go.mod +├── go.sum +├── Makefile +└── README.md +``` + +## Development + +### Building + +```bash +make build # Build the binary +make test # Run tests +make lint # Run linter +make fmt # Format code +make all # Format, lint, test, and build +``` + +### Testing + +The project includes unit tests for core functionality. Run tests with: + +```bash +make test +``` + +### Using the Monitor API + +The monitor package provides an object-oriented API for programmatic use: + +```go +import "git.eeqj.de/sneak/rtnetmon/internal/monitor" + +// Create a new monitor +mon := monitor.NewMonitor("eth0", "Primary", "wlan0", "Backup", "/tmp/monitor.log") + +// Configure timing parameters (optional - defaults are sensible) +mon.ICMPTimeout = 1 * time.Second +mon.PacketLossPings = 10 +mon.PacketLossPeriod = 10 * time.Second + +// Add hosts to monitor +mon.AddReachabilityHost("8.8.8.8") +mon.AddReachabilityHost("google.com") + +mon.AddPacketLossHost("github.com") +mon.AddPacketLossHost("8.8.8.8") + +mon.AddTCPHost("google.com:443") +mon.AddTCPHost("github.com:443") + +// Run the monitor +ctx := context.Background() +if err := mon.Run(ctx); err != nil { + log.Fatal(err) +} +``` + +## License + +WTFPL - Do What The Fuck You Want To Public License + +## Author + +sneak@sneak.berlin diff --git a/cmd/rtnetmon/main.go b/cmd/rtnetmon/main.go new file mode 100644 index 0000000..3334586 --- /dev/null +++ b/cmd/rtnetmon/main.go @@ -0,0 +1,20 @@ +//go:build linux +// +build linux + +// netmon – dual-interface network dashboard (curses) +// WTFPL – 2025-05-16 sneak@sneak.berlin +package main + +import ( + "log" + "os" + + "git.eeqj.de/sneak/rtnetmon/internal/cli" +) + +func main() { + if err := cli.Execute(); err != nil { + log.Fatal(err) + os.Exit(1) + } +} diff --git a/go.mod b/go.mod index 62a20e5..88cbd4c 100644 --- a/go.mod +++ b/go.mod @@ -2,14 +2,36 @@ module git.eeqj.de/sneak/rtnetmon go 1.22.2 -require github.com/gdamore/tcell/v2 v2.8.1 +require ( + github.com/gdamore/tcell/v2 v2.8.1 + github.com/spf13/cobra v1.8.0 + github.com/spf13/viper v1.18.2 +) require ( + github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gdamore/encoding v1.0.1 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/rivo/uniseg v0.4.3 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/term v0.28.0 // indirect golang.org/x/text v0.21.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2535246..ea7093a 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,83 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= +github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= +golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -75,3 +137,11 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/cli/root.go b/internal/cli/root.go new file mode 100644 index 0000000..bd45ef9 --- /dev/null +++ b/internal/cli/root.go @@ -0,0 +1,118 @@ +//go:build linux +// +build linux + +package cli + +import ( + "context" + "os" + "os/signal" + "syscall" + + "github.com/spf13/cobra" + "github.com/spf13/viper" + + "git.eeqj.de/sneak/rtnetmon/internal/monitor" +) + +// Config holds the application configuration +type Config struct { + IfaceA string + LabelA string + IfaceB string + LabelB string + Hosts []string + LogFile string +} + +var ( + cfg Config + rootCmd = &cobra.Command{ + Use: "rtnetmon", + Short: "Real-time network monitoring dashboard", + Long: `rtnetmon is a dual-interface network monitoring dashboard that provides +real-time visibility into network health, packet loss, and latency.`, + RunE: runMonitor, + } +) + +// Default hosts for monitoring +var ( + defaultReachabilityHosts = []string{ + "8.8.8.8", "8.8.4.4", "google.com", "github.com", + "console.aws.amazon.com", "console.cloud.google.com", + "fast.com", "datavi.be", "captive.apple.com", + } + + defaultPacketLossHosts = []string{ + "github.com", "google.com", "8.8.8.8", "captive.apple.com", "62.115.190.68", + } + + defaultTCPHosts = []string{ + "datavi.be:443", "fast.com:443", + "console.aws.amazon.com:443", "console.cloud.google.com:443", + "captive.apple.com:80", "google.com:443", + } +) + +func init() { + // Define flags + rootCmd.Flags().StringVar(&cfg.IfaceA, "ifaceA", "gu0", "primary network interface") + rootCmd.Flags().StringVar(&cfg.LabelA, "labelA", "gu LAN - VPN outbound", "label for ifaceA") + rootCmd.Flags().StringVar(&cfg.IfaceB, "ifaceB", "backhaul0", "secondary network interface") + rootCmd.Flags().StringVar(&cfg.LabelB, "labelB", "Cox cable direct", "label for ifaceB") + rootCmd.Flags().StringSliceVar(&cfg.Hosts, "hosts", defaultReachabilityHosts, "comma-separated reachability hosts") + rootCmd.Flags().StringVar(&cfg.LogFile, "logfile", "/tmp/rtnetmon.log", "path to log file") + + // Bind flags to viper + _ = viper.BindPFlag("ifaceA", rootCmd.Flags().Lookup("ifaceA")) + _ = viper.BindPFlag("labelA", rootCmd.Flags().Lookup("labelA")) + _ = viper.BindPFlag("ifaceB", rootCmd.Flags().Lookup("ifaceB")) + _ = viper.BindPFlag("labelB", rootCmd.Flags().Lookup("labelB")) + _ = viper.BindPFlag("hosts", rootCmd.Flags().Lookup("hosts")) + _ = viper.BindPFlag("logfile", rootCmd.Flags().Lookup("logfile")) +} + +func runMonitor(cmd *cobra.Command, args []string) error { + monitor.Logf(cfg.LogFile, "Starting rtnetmon") + monitor.Logf(cfg.LogFile, "Monitoring interfaces %s and %s", cfg.IfaceA, cfg.IfaceB) + + // Create the monitor + mon := monitor.NewMonitor(cfg.IfaceA, cfg.LabelA, cfg.IfaceB, cfg.LabelB, cfg.LogFile) + + // Add reachability hosts + for _, host := range cfg.Hosts { + mon.AddReachabilityHost(host) + } + + // Add packet loss hosts + for _, host := range defaultPacketLossHosts { + mon.AddPacketLossHost(host) + } + + // Add TCP hosts + for _, host := range defaultTCPHosts { + mon.AddTCPHost(host) + } + + // Create context with signal handling + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Handle signals + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt, syscall.SIGTERM) + go func() { + s := <-sig + monitor.Logf(cfg.LogFile, "Signal received: %v", s) + cancel() + }() + + // Run the monitor + return mon.Run(ctx) +} + +// Execute runs the root command +func Execute() error { + return rootCmd.Execute() +} diff --git a/internal/cli/root_test.go b/internal/cli/root_test.go new file mode 100644 index 0000000..6f0d373 --- /dev/null +++ b/internal/cli/root_test.go @@ -0,0 +1,36 @@ +//go:build linux +// +build linux + +package cli + +import ( + "testing" +) + +// TestExecute tests that the CLI can be initialized +func TestExecute(t *testing.T) { + // This is a simple compilation test to ensure the CLI package compiles + // We can't easily test the full Execute() function as it starts the UI + + // Test that rootCmd is properly initialized + if rootCmd == nil { + t.Fatal("rootCmd is nil") + } + + if rootCmd.Use != "rtnetmon" { + t.Errorf("Expected rootCmd.Use to be 'rtnetmon', got '%s'", rootCmd.Use) + } + + // Test that default configuration is set + if len(defaultReachabilityHosts) == 0 { + t.Error("defaultReachabilityHosts is empty") + } + + if len(defaultPacketLossHosts) == 0 { + t.Error("defaultPacketLossHosts is empty") + } + + if len(defaultTCPHosts) == 0 { + t.Error("defaultTCPHosts is empty") + } +} diff --git a/internal/monitor/loops.go b/internal/monitor/loops.go new file mode 100644 index 0000000..d624c11 --- /dev/null +++ b/internal/monitor/loops.go @@ -0,0 +1,278 @@ +//go:build linux +// +build linux + +package monitor + +import ( + "context" + "math" + "math/rand" + "strings" + "sync" + "time" +) + +// UIUpdateChan is used to signal UI updates +var UIUpdateChan = make(chan struct{}, 100) + +// reachLoop monitors reachability for hosts +func (m *Monitor) reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { + m.logf("Starting reachability monitoring for %s with %d hosts", st.Name, len(hosts)) + + // Add random offset to avoid clustering at 1-second intervals + randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond + m.logf("Reachability monitoring for %s will start after %v offset", st.Name, randomOffset) + time.Sleep(randomOffset) + + tk := time.NewTicker(time.Second) + defer tk.Stop() + for { + select { + case <-ctx.Done(): + m.logf("Stopping reachability monitoring for %s", st.Name) + return + case <-tk.C: + var wg sync.WaitGroup + res := make(map[string]bool, len(hosts)) + mu := sync.Mutex{} + for _, h := range hosts { + wg.Add(1) + go func(host string) { + defer wg.Done() + st.mu.Lock() + st.TotalICMPReq++ + // Increase meter value when a packet is sent + st.MeterValue++ + if st.MeterValue > m.MaxMeterValue { + st.MeterValue = m.MaxMeterValue + } + st.mu.Unlock() + + ok := m.pingOnce(st.Name, host) + mu.Lock() + res[host] = ok + mu.Unlock() + + st.mu.Lock() + if ok { + st.TotalICMPRep++ + // Decrease meter value when a packet is successfully received + st.MeterValue-- + if st.MeterValue < 0 { + st.MeterValue = 0 + } + // Only update spinner when packets are successfully received + st.Spin() + } else { + st.DroppedCount++ + st.LastDrop = time.Now() + + // Track lost packets per host + st.LostPackets[host]++ + + // Trigger UI update on ping failure + select { + case UIUpdateChan <- struct{}{}: + default: + } + } + st.mu.Unlock() + }(h) + } + wg.Wait() + + // Check if reachability status changed + statusChanged := false + st.mu.Lock() + for host, newStatus := range res { + if oldStatus, ok := st.Reachable[host]; !ok || oldStatus != newStatus { + statusChanged = true + break + } + } + + st.Reachable = res + st.LastPing = time.Now() + + // Check if all hosts are reachable + allReachable := true + for _, ok := range res { + if !ok { + allReachable = false + break + } + } + + // If all hosts are reachable, gradually decay the meter value + if allReachable && st.MeterValue > 0 { + st.MeterValue-- + } + + st.mu.Unlock() + + // Always trigger UI update when reachability status changes + if statusChanged { + select { + case UIUpdateChan <- struct{}{}: + default: + } + } + } + } +} + +// lossLoop monitors packet loss for hosts +func (m *Monitor) lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { + m.logf("Starting packet loss monitoring for %s with %d hosts", st.Name, len(hosts)) + + // Add random offset to avoid clustering at periodic intervals + randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond + m.logf("Packet loss monitoring for %s will start after %v offset", st.Name, randomOffset) + time.Sleep(randomOffset) + + tk := time.NewTicker(m.PacketLossPeriod) + defer tk.Stop() + for { + select { + case <-ctx.Done(): + m.logf("Stopping packet loss monitoring for %s", st.Name) + return + case <-tk.C: + var wg sync.WaitGroup + res := make(map[string]float64, len(hosts)) + mu := sync.Mutex{} + for _, h := range hosts { + wg.Add(1) + go func(host string) { + defer wg.Done() + lp := m.lossPercent(st.Name, host) + mu.Lock() + res[host] = lp + mu.Unlock() + + st.mu.Lock() + if lp == 0 { + // Only update spinner when there's 0% packet loss + st.Spin() + } else { + // Calculate approximate number of lost packets based on loss percentage + lostPackets := int(math.Ceil(float64(m.PacketLossPings) * lp)) + + // Update dropped count with the number of lost packets + st.DroppedCount += lostPackets + + // Update last drop time if packets were lost + if lostPackets > 0 { + st.LastDrop = time.Now() + } + + // Track lost packets per host + st.LostPackets[host] += lostPackets + + // Trigger UI update on packet loss + select { + case UIUpdateChan <- struct{}{}: + default: + } + } + st.mu.Unlock() + }(h) + } + wg.Wait() + + // Check if loss status changed + statusChanged := false + st.mu.Lock() + for host, newLoss := range res { + if oldLoss, ok := st.Loss[host]; !ok || math.Abs(oldLoss-newLoss) > 0.01 { + statusChanged = true + break + } + } + + for k, v := range res { + st.Loss[k] = v + } + st.mu.Unlock() + + // Always trigger UI update when loss status changes + if statusChanged { + select { + case UIUpdateChan <- struct{}{}: + default: + } + } + } + } +} + +// tcpLoop monitors TCP connectivity for hosts +func (m *Monitor) tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { + m.logf("Starting TCP monitoring for %s with %d hosts", st.Name, len(hosts)) + + // Add random offset to avoid clustering at 1-second intervals + randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond + m.logf("TCP monitoring for %s will start after %v offset", st.Name, randomOffset) + time.Sleep(randomOffset) + + tk := time.NewTicker(time.Second) + defer tk.Stop() + for { + select { + case <-ctx.Done(): + m.logf("Stopping TCP monitoring for %s", st.Name) + return + case <-tk.C: + statusChanged := false + + for _, hp := range hosts { + ms := float64(m.tcpDuration(st.Name, hp).Milliseconds()) + st.mu.Lock() + + // Check if TCP latency significantly changed + hist := st.TCP[hp] + if len(hist) > 0 { + lastMs := hist[len(hist)-1] + if math.Abs(lastMs-ms) > 20 { // 20ms threshold for significant change + statusChanged = true + } + } else { + // First measurement + statusChanged = true + } + + if ms < float64(m.TCPTimeout.Milliseconds()) { + // Only update spinner on successful TCP connections + st.Spin() + } else { + // Trigger UI update on TCP timeout + select { + case UIUpdateChan <- struct{}{}: + default: + } + } + + if len(hist) >= m.StatsHistory { + hist = hist[1:] + } + st.TCP[hp] = append(hist, ms) + + // Update lost packets for the host (without port) + hostName := strings.Split(hp, ":")[0] + if ms >= float64(m.TCPTimeout.Milliseconds()) { + st.LostPackets[hostName]++ + } + + st.mu.Unlock() + } + + // Always trigger UI update when TCP status changes significantly + if statusChanged { + select { + case UIUpdateChan <- struct{}{}: + default: + } + } + } + } +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go new file mode 100644 index 0000000..2f97fff --- /dev/null +++ b/internal/monitor/monitor.go @@ -0,0 +1,394 @@ +//go:build linux +// +build linux + +package monitor + +import ( + "context" + "encoding/json" + "fmt" + "math" + "net" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + tcell "github.com/gdamore/tcell/v2" +) + +// Non-configurable constants (meter characters) +const ( + // ASCII characters for the meter + MeterStart = '[' + MeterEnd = ']' + MeterFill = '=' + MeterEmpty = ' ' +) + +// Monitor represents the network monitoring system +type Monitor struct { + // Configuration + ICMPTimeout time.Duration + TCPTimeout time.Duration + PacketLossPings int + PacketLossPeriod time.Duration + StatsHistory int + ScreenRefresh time.Duration + MeterWidth int + MeterFillWidth int + MaxMeterValue int + + // Column widths + HostWidth int + NumWidth int + StdWidth int + NWidth int + LostWidth int + + // Host lists + reachabilityHosts []string + packetLossHosts []string + tcpHosts []string + + // Interfaces + interfaceA *InterfaceStatus + interfaceB *InterfaceStatus + + // Logging + logFile string + + // Runtime state + screen tcell.Screen + startTime time.Time + + // Synchronization + mu sync.RWMutex +} + +// NewMonitor creates a new Monitor instance with default settings +func NewMonitor(ifaceA, labelA, ifaceB, labelB, logFile string) *Monitor { + return &Monitor{ + // Default timing configuration + ICMPTimeout: 500 * time.Millisecond, + TCPTimeout: 500 * time.Millisecond, + PacketLossPings: 20, + PacketLossPeriod: 5 * time.Second, + StatsHistory: 300, + ScreenRefresh: 500 * time.Millisecond, + + // Default display configuration + MeterWidth: 7, + MeterFillWidth: 5, + MaxMeterValue: 10, + HostWidth: 30, + NumWidth: 7, + StdWidth: 8, + NWidth: 6, + LostWidth: 7, + + // Initialize host lists + reachabilityHosts: []string{}, + packetLossHosts: []string{}, + tcpHosts: []string{}, + + // Initialize interfaces + interfaceA: NewInterfaceStatus(ifaceA, labelA), + interfaceB: NewInterfaceStatus(ifaceB, labelB), + + // Logging + logFile: logFile, + + startTime: time.Now(), + } +} + +// AddReachabilityHost adds a host for reachability monitoring +func (m *Monitor) AddReachabilityHost(host string) { + m.mu.Lock() + defer m.mu.Unlock() + m.reachabilityHosts = append(m.reachabilityHosts, host) +} + +// AddPacketLossHost adds a host for packet loss monitoring +func (m *Monitor) AddPacketLossHost(host string) { + m.mu.Lock() + defer m.mu.Unlock() + m.packetLossHosts = append(m.packetLossHosts, host) +} + +// AddTCPHost adds a host:port for TCP connectivity monitoring +func (m *Monitor) AddTCPHost(hostPort string) { + m.mu.Lock() + defer m.mu.Unlock() + m.tcpHosts = append(m.tcpHosts, hostPort) +} + +// Run starts the monitoring system +func (m *Monitor) Run(ctx context.Context) error { + m.logf("Starting monitor run") + + // Initialize screen + m.logf("Initializing screen") + scr, err := tcell.NewScreen() + if err != nil { + m.logf("Error creating screen: %v", err) + return fmt.Errorf("error creating screen: %w", err) + } + if err = scr.Init(); err != nil { + m.logf("Error initializing screen: %v", err) + return fmt.Errorf("error initializing screen: %w", err) + } + m.screen = scr + m.logf("Screen initialized") + + // Create cancellable context + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + // Handle keyboard events + go m.keyboardEventLoop(ctx, cancel) + + // Start monitoring goroutines + m.logf("Starting monitoring goroutines") + m.mu.RLock() + reachHosts := append([]string{}, m.reachabilityHosts...) + lossHosts := append([]string{}, m.packetLossHosts...) + tcpHosts := append([]string{}, m.tcpHosts...) + m.mu.RUnlock() + + go m.reachLoop(ctx, m.interfaceA, reachHosts) + go m.reachLoop(ctx, m.interfaceB, reachHosts) + go m.lossLoop(ctx, m.interfaceA, lossHosts) + go m.lossLoop(ctx, m.interfaceB, lossHosts) + go m.tcpLoop(ctx, m.interfaceA, tcpHosts) + go m.tcpLoop(ctx, m.interfaceB, tcpHosts) + + // Run UI loop + m.logf("Starting UI loop") + m.uiLoop(ctx) + m.logf("UI loop exited, monitor ending") + + return nil +} + +// keyboardEventLoop handles keyboard input +func (m *Monitor) keyboardEventLoop(ctx context.Context, cancel context.CancelFunc) { + m.logf("Starting keyboard event loop") + for { + if ev := m.screen.PollEvent(); ev != nil { + m.logf("Event received: %T", ev) + if ke, ok := ev.(*tcell.EventKey); ok { + m.logf("Key event: %v, rune: %c", ke.Key(), ke.Rune()) + if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' { + m.logf("Quit key detected") + cancel() + return + } + } + + // Trigger UI update on any event + select { + case UIUpdateChan <- struct{}{}: + default: + } + } + } +} + +// logf logs a formatted message +func (m *Monitor) logf(format string, v ...interface{}) { + Logf(m.logFile, format, v...) +} + +// InterfaceStatus holds the runtime status for a network interface +type InterfaceStatus struct { + Name, Label, IPInfo string + Reachable map[string]bool + Loss map[string]float64 + TCP map[string][]float64 + TotalICMPReq, TotalICMPRep int + DroppedCount int + LastDrop, LastPing time.Time + SpinFrame int + MeterValue int + LostPackets map[string]int + mu sync.RWMutex +} + +// NewInterfaceStatus creates a new interface status +func NewInterfaceStatus(name, label string) *InterfaceStatus { + return &InterfaceStatus{ + Name: name, + Label: label, + IPInfo: fetchIPInfo(name), + Reachable: map[string]bool{}, + Loss: map[string]float64{}, + TCP: map[string][]float64{}, + MeterValue: 0, + LostPackets: map[string]int{}, + } +} + +// ipInfoResp holds the IP info response +type ipInfoResp struct { + IP string `json:"ip"` + Hostname string `json:"hostname"` + Org string `json:"org"` +} + +// fetchIPInfo fetches IP information for an interface +func fetchIPInfo(iface string) string { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + out, _ := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output() + var r ipInfoResp + _ = json.Unmarshal(out, &r) + if r.IP == "" { + return "(ipinfo error)" + } + return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org) +} + +// pingOnce performs a single ping +func (m *Monitor) pingOnce(iface, host string) bool { + ctx, cancel := context.WithTimeout(context.Background(), m.ICMPTimeout) + defer cancel() + return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil +} + +// lossPercent calculates packet loss percentage +func (m *Monitor) lossPercent(iface, host string) float64 { + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, "ping", "-q", "-i", "0.05", + "-c", fmt.Sprint(m.PacketLossPings), "-W1", "-I", iface, host).CombinedOutput() + if err != nil { + return 1.0 + } + for _, ln := range strings.Split(string(out), "\n") { + if strings.Contains(ln, "packet loss") { + for _, f := range strings.Fields(ln) { + if strings.HasSuffix(f, "%") { + p, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64) + return p / 100.0 + } + } + } + } + return 1.0 +} + +// localAddr gets the local address for an interface +func localAddr(iface string) (net.Addr, error) { + ifi, err := net.InterfaceByName(iface) + if err != nil { + return nil, err + } + add, _ := ifi.Addrs() + for _, a := range add { + if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { + return &net.TCPAddr{IP: ipnet.IP}, nil + } + } + return nil, fmt.Errorf("no IPv4 on %s", iface) +} + +// tcpDuration measures TCP connection duration +func (m *Monitor) tcpDuration(iface, hp string) time.Duration { + la, err := localAddr(iface) + if err != nil { + return m.TCPTimeout + } + d := net.Dialer{Timeout: m.TCPTimeout, LocalAddr: la} + st := time.Now() + c, err := d.Dial("tcp", hp) + if err != nil { + return m.TCPTimeout + } + c.Close() + return time.Since(st) +} + +// MinMaxAvgStd calculates min, max, average and standard deviation +func MinMaxAvgStd(xs []float64) (min, max, avg, std float64) { + if len(xs) == 0 { + return + } + min, max = xs[0], xs[0] + var sum float64 + for _, v := range xs { + if v < min { + min = v + } + if v > max { + max = v + } + sum += v + } + avg = sum / float64(len(xs)) + var vs float64 + for _, v := range xs { + d := v - avg + vs += d * d + } + std = math.Sqrt(vs / float64(len(xs))) + return +} + +// Spin advances the spinner frame +func (st *InterfaceStatus) Spin() { + st.SpinFrame = (st.SpinFrame + 1) % len(Spins) +} + +// IsHealthy checks if the interface is healthy +func (st *InterfaceStatus) IsHealthy(tcpTimeout time.Duration) bool { + st.mu.RLock() + defer st.mu.RUnlock() + + // Check if there are any currently unreachable hosts + for _, ok := range st.Reachable { + if !ok { + return false + } + } + + // Check if there's any current packet loss + for _, lp := range st.Loss { + if lp > 0 { + return false + } + } + + // Check if there are any TCP timeouts + for _, hist := range st.TCP { + if len(hist) == 0 || hist[len(hist)-1] >= float64(tcpTimeout.Milliseconds()) { + return false + } + } + + return true +} + +// Spinners and braille characters +var ( + Spins = []rune{'|', '/', '-', '\\'} + BrailleSpins = []rune{ + '⠉', '⠘', '⠰', '⠠', '⠄', '⠆', '⠇', '⠋', + } +) + +// Logf is a simple logging function +func Logf(logFile string, format string, v ...interface{}) { + if logFile == "" { + return + } + f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + fmt.Fprintf(f, time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...) +} diff --git a/internal/monitor/monitor_test.go b/internal/monitor/monitor_test.go new file mode 100644 index 0000000..0c6cc8f --- /dev/null +++ b/internal/monitor/monitor_test.go @@ -0,0 +1,126 @@ +//go:build linux +// +build linux + +package monitor + +import ( + "testing" +) + +// TestNewMonitor tests the creation of a new Monitor +func TestNewMonitor(t *testing.T) { + // Test that we can create a monitor without errors + mon := NewMonitor("test0", "Test Interface A", "test1", "Test Interface B", "/tmp/test.log") + + if mon == nil { + t.Fatal("NewMonitor returned nil") + } + + // Check interfaces + if mon.interfaceA == nil { + t.Fatal("interfaceA is nil") + } + + if mon.interfaceB == nil { + t.Fatal("interfaceB is nil") + } + + if mon.interfaceA.Name != "test0" { + t.Errorf("Expected interfaceA.Name to be 'test0', got '%s'", mon.interfaceA.Name) + } + + if mon.interfaceB.Name != "test1" { + t.Errorf("Expected interfaceB.Name to be 'test1', got '%s'", mon.interfaceB.Name) + } + + // Check default configuration + if mon.ICMPTimeout.Milliseconds() != 500 { + t.Errorf("Expected ICMPTimeout to be 500ms, got %dms", mon.ICMPTimeout.Milliseconds()) + } + + if mon.PacketLossPings != 20 { + t.Errorf("Expected PacketLossPings to be 20, got %d", mon.PacketLossPings) + } + + // Check that host lists are initialized + if mon.reachabilityHosts == nil { + t.Error("reachabilityHosts is nil") + } + + if mon.packetLossHosts == nil { + t.Error("packetLossHosts is nil") + } + + if mon.tcpHosts == nil { + t.Error("tcpHosts is nil") + } +} + +// TestAddHosts tests adding hosts to the monitor +func TestAddHosts(t *testing.T) { + mon := NewMonitor("test0", "Test A", "test1", "Test B", "") + + // Test adding reachability hosts + mon.AddReachabilityHost("8.8.8.8") + mon.AddReachabilityHost("google.com") + + if len(mon.reachabilityHosts) != 2 { + t.Errorf("Expected 2 reachability hosts, got %d", len(mon.reachabilityHosts)) + } + + // Test adding packet loss hosts + mon.AddPacketLossHost("github.com") + + if len(mon.packetLossHosts) != 1 { + t.Errorf("Expected 1 packet loss host, got %d", len(mon.packetLossHosts)) + } + + // Test adding TCP hosts + mon.AddTCPHost("google.com:443") + mon.AddTCPHost("github.com:443") + + if len(mon.tcpHosts) != 2 { + t.Errorf("Expected 2 TCP hosts, got %d", len(mon.tcpHosts)) + } +} + +// TestMinMaxAvgStd tests the statistics calculation function +func TestMinMaxAvgStd(t *testing.T) { + tests := []struct { + name string + data []float64 + wantMin, wantMax, wantAvg float64 + }{ + { + name: "empty slice", + data: []float64{}, + wantMin: 0, wantMax: 0, wantAvg: 0, + }, + { + name: "single value", + data: []float64{5.0}, + wantMin: 5.0, wantMax: 5.0, wantAvg: 5.0, + }, + { + name: "multiple values", + data: []float64{1.0, 2.0, 3.0, 4.0, 5.0}, + wantMin: 1.0, wantMax: 5.0, wantAvg: 3.0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + min, max, avg, _ := MinMaxAvgStd(tt.data) + + if min != tt.wantMin { + t.Errorf("MinMaxAvgStd() min = %v, want %v", min, tt.wantMin) + } + if max != tt.wantMax { + t.Errorf("MinMaxAvgStd() max = %v, want %v", max, tt.wantMax) + } + if avg != tt.wantAvg { + t.Errorf("MinMaxAvgStd() avg = %v, want %v", avg, tt.wantAvg) + } + }) + } +} diff --git a/internal/monitor/styles.go b/internal/monitor/styles.go new file mode 100644 index 0000000..6417cf9 --- /dev/null +++ b/internal/monitor/styles.go @@ -0,0 +1,87 @@ +//go:build linux +// +build linux + +package monitor + +import tcell "github.com/gdamore/tcell/v2" + +// Color styles +var ( + CBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) + CGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen) + CDimGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Dim(true) + CYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow) + COrange = tcell.StyleDefault.Foreground(tcell.ColorOrange) + CRed = tcell.StyleDefault.Foreground(tcell.ColorRed) + CBrightRed = tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true) + CDefault = tcell.StyleDefault + + // Rainbow colors for the spinner + CRainbow = []tcell.Style{ + tcell.StyleDefault.Foreground(tcell.ColorRed), + tcell.StyleDefault.Foreground(tcell.ColorOrange), + tcell.StyleDefault.Foreground(tcell.ColorYellow), + tcell.StyleDefault.Foreground(tcell.ColorGreen), + tcell.StyleDefault.Foreground(tcell.ColorBlue), + tcell.StyleDefault.Foreground(tcell.ColorPurple), + } +) + +// MeterColorForValue returns the appropriate color style for a meter value +// value: current value of the meter +// maxValue: maximum value of the meter +// reverse: if true, low values are good (green) and high values are bad (red) +// +// if false, high values are good (green) and low values are bad (red) +func MeterColorForValue(value, maxValue int, reverse bool) tcell.Style { + // Calculate percentage + percentage := float64(value) / float64(maxValue) * 100 + + // For reverse mode (low is good), invert the percentage + if reverse { + percentage = 100 - percentage + } + + // Return color based on percentage (after any reversal) + // Now high percentage always means "good" and low percentage means "bad" + switch { + case percentage >= 90: + return CBrightGreen + case percentage >= 75: + return CGreen + case percentage >= 60: + return CDimGreen + case percentage >= 40: + return CYellow + case percentage >= 20: + return COrange + default: + return CRed + } +} + +// StyleLatency returns the appropriate style for latency values +func StyleLatency(ms float64) tcell.Style { + switch { + case ms < 50: + return CBrightGreen + case ms < 100: + return CGreen + case ms < 200: + return CYellow + default: + return CRed + } +} + +// StyleLoss returns the appropriate style for packet loss percentages +func StyleLoss(p float64) tcell.Style { + switch { + case p == 0: + return CBrightGreen + case p < 5: + return CYellow + default: + return CBrightRed + } +} diff --git a/internal/monitor/styles_test.go b/internal/monitor/styles_test.go new file mode 100644 index 0000000..325082a --- /dev/null +++ b/internal/monitor/styles_test.go @@ -0,0 +1,105 @@ +//go:build linux +// +build linux + +package monitor + +import ( + "testing" + + tcell "github.com/gdamore/tcell/v2" +) + +// TestMeterColorForValue tests the MeterColorForValue function +func TestMeterColorForValue(t *testing.T) { + tests := []struct { + name string + value int + maxValue int + reverse bool + wantColor tcell.Style + }{ + // Reverse mode tests (low is good, high is bad) + { + name: "reverse mode: 0% (best)", + value: 0, + maxValue: 10, + reverse: true, + wantColor: CBrightGreen, + }, + { + name: "reverse mode: 10% (good)", + value: 1, + maxValue: 10, + reverse: true, + wantColor: CBrightGreen, + }, + { + name: "reverse mode: 50% (medium)", + value: 5, + maxValue: 10, + reverse: true, + wantColor: CYellow, + }, + { + name: "reverse mode: 90% (bad)", + value: 9, + maxValue: 10, + reverse: true, + wantColor: CRed, + }, + { + name: "reverse mode: 100% (worst)", + value: 10, + maxValue: 10, + reverse: true, + wantColor: CRed, + }, + + // Normal mode tests (high is good, low is bad) + { + name: "normal mode: 0% (worst)", + value: 0, + maxValue: 10, + reverse: false, + wantColor: CRed, + }, + { + name: "normal mode: 10% (bad)", + value: 1, + maxValue: 10, + reverse: false, + wantColor: CRed, + }, + { + name: "normal mode: 50% (medium)", + value: 5, + maxValue: 10, + reverse: false, + wantColor: CYellow, + }, + { + name: "normal mode: 90% (good)", + value: 9, + maxValue: 10, + reverse: false, + wantColor: CBrightGreen, + }, + { + name: "normal mode: 100% (best)", + value: 10, + maxValue: 10, + reverse: false, + wantColor: CBrightGreen, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := MeterColorForValue(tt.value, tt.maxValue, tt.reverse) + if got != tt.wantColor { + t.Errorf("MeterColorForValue(%d, %d, %v) = %v, want %v", + tt.value, tt.maxValue, tt.reverse, got, tt.wantColor) + } + }) + } +} diff --git a/internal/monitor/ui.go b/internal/monitor/ui.go new file mode 100644 index 0000000..2734f8b --- /dev/null +++ b/internal/monitor/ui.go @@ -0,0 +1,378 @@ +//go:build linux +// +build linux + +package monitor + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + tcell "github.com/gdamore/tcell/v2" +) + +// Frame counter for the timestamp spinner (ticks once per second) +var ( + timestampSpinFrame = 0 + lastTimestampSpinUpdate = time.Now() +) + +// Put writes text to the screen at the specified position with style +func Put(scr tcell.Screen, x, y int, txt string, st tcell.Style) { + for i, r := range txt { + scr.SetContent(x+i, y, r, nil, st) + } +} + +// HLine creates a horizontal line of the specified width +func HLine(w int) string { return strings.Repeat("=", w) } + +// DrawRainbowText draws text with rainbow colors +func DrawRainbowText(scr tcell.Screen, x, y int, text string, colors []tcell.Style) { + for i, char := range text { + // Cycle through the rainbow colors + colorIndex := i % len(colors) + style := colors[colorIndex] + // Draw the character with the current rainbow color + scr.SetContent(x+i, y, char, nil, style) + } +} + +// CreateMeter creates a visual meter using ASCII characters +func (m *Monitor) CreateMeter(value int) (string, tcell.Style) { + if value > m.MaxMeterValue { + value = m.MaxMeterValue + } + if value < 0 { + value = 0 + } + + // Calculate the number of fill characters to show + fillCount := value * m.MeterFillWidth / m.MaxMeterValue + if fillCount > m.MeterFillWidth { + fillCount = m.MeterFillWidth + } + + // Build the meter string + var b strings.Builder + b.WriteRune(MeterStart) + + // Add fill characters + for i := 0; i < fillCount; i++ { + b.WriteRune(MeterFill) + } + + // Add empty spaces + for i := 0; i < m.MeterFillWidth-fillCount; i++ { + b.WriteRune(MeterEmpty) + } + + b.WriteRune(MeterEnd) + + // Get the appropriate style for this meter value + // Using reverse=true because for packet loss, low values are good + style := MeterColorForValue(value, m.MaxMeterValue, true) + + return b.String(), style +} + +// DrawInterface draws the interface status on the screen +func (m *Monitor) DrawInterface(scr tcell.Screen, y, w int, st *InterfaceStatus) int { + Put(scr, 0, y, HLine(w), CDefault) + + // header line + healthy := st.IsHealthy(m.TCPTimeout) + style := CBrightGreen + if !healthy { + style = CBrightRed + } + st.mu.RLock() + header := fmt.Sprintf("%s: %s — %s", st.Name, st.Label, st.IPInfo) + spinChar := Spins[st.SpinFrame] + spinnerFrame := st.SpinFrame + meterValue := st.MeterValue + st.mu.RUnlock() + + // Create the meter with appropriate style + meter, meterStyle := m.CreateMeter(meterValue) + + // Get rainbow color for spinner (cycle through colors) + spinnerStyle := CRainbow[spinnerFrame%len(CRainbow)] + + Put(scr, 0, y+1, "== ", CDefault) + Put(scr, 3, y+1, string(spinChar)+" ", spinnerStyle) // Colorful spinner + Put(scr, 5, y+1, meter+" ", meterStyle) // Colored meter + Put(scr, 5+m.MeterWidth+1, y+1, header, style) // Move the header after the meter + Put(scr, 0, y+2, HLine(w), CDefault) + y += 4 + + /* reachability */ + st.mu.RLock() + total := len(st.Reachable) + good := 0 + for _, ok := range st.Reachable { + if ok { + good++ + } + } + age := time.Since(st.LastPing).Round(time.Second) + reachStyle := CBrightGreen + if good != total { + reachStyle = CBrightRed + } + + // Get last drop time and age + dropTimeStr := "never" + dropAgeStr := "N/A" + if !st.LastDrop.IsZero() { + dropTimeStr = st.LastDrop.Format("15:04:05") + dropAgeStr = time.Since(st.LastDrop).Round(time.Second).String() + } + + // Simplified reachability line without drop info + Put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", + good, total, st.LastPing.Format("15:04:05"), age), reachStyle) + y++ + + if good == total { + Put(scr, 0, y, "Unreachable: none", CDefault) + } else { + var down []string + for h, ok := range st.Reachable { + if !ok { + down = append(down, h) + } + } + // Sort for consistent display + sort.Strings(down) + // Add drop info to the end of the unreachable line + Put(scr, 0, y, fmt.Sprintf("Unreachable: %s (last drop at %s, age %s)", + strings.Join(down, ", "), dropTimeStr, dropAgeStr), CBrightRed) + } + y += 2 + + /* packet loss */ + Put(scr, 0, y, "Packet Loss:", CDefault) + y++ + + // Get all hosts and sort them for consistent display order + var lossHosts []string + for host := range st.Loss { + lossHosts = append(lossHosts, host) + } + sort.Strings(lossHosts) + + // Find the maximum length of host names for alignment + maxHostLen := 0 + for _, host := range lossHosts { + if len(host) > maxHostLen { + maxHostLen = len(host) + } + } + + // Add 1 for the colon + maxHostLen += 1 + + for _, host := range lossHosts { + p := st.Loss[host] * 100 + // Use the maxHostLen for consistent alignment + Put(scr, 0, y, fmt.Sprintf("%-*s %5.0f%%", maxHostLen, host+":", p), StyleLoss(p)) + y++ + } + dAge := "N/A" + if !st.LastDrop.IsZero() { + dAge = time.Since(st.LastDrop).Round(time.Second).String() + } + Put(scr, 0, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", + st.DroppedCount, st.LastDrop.Format("15:04:05"), dAge), CDefault) + y += 2 + + /* TCP table */ + Put(scr, 0, y, "TCP Connect Stats:", CDefault) + y++ + // header row + headerRow := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*s %*s", + m.HostWidth, "Host", m.NumWidth, "last", m.NumWidth, "min", m.NumWidth, "avg", + m.NumWidth, "max", m.StdWidth, "stddev", m.NWidth, "n", m.LostWidth, "lost") + Put(scr, 0, y, headerRow, CDefault) + y++ + + m.mu.RLock() + tcpHosts := append([]string{}, m.tcpHosts...) + m.mu.RUnlock() + + for _, hp := range tcpHosts { + hist := st.TCP[hp] + if len(hist) == 0 { + continue + } + last := hist[len(hist)-1] + mi, ma, av, sd := MinMaxAvgStd(hist) + + // Get host name without port for lost packets lookup + hostName := strings.Split(hp, ":")[0] + lost := st.LostPackets[hostName] + + // Add lost column + row := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*d %*d", + m.HostWidth, hp, + m.NumWidth, fmt.Sprintf("%.0fms", last), + m.NumWidth, fmt.Sprintf("%.0fms", mi), + m.NumWidth, fmt.Sprintf("%.0fms", av), + m.NumWidth, fmt.Sprintf("%.0fms", ma), + m.StdWidth, fmt.Sprintf("%.0fms", sd), + m.NWidth, len(hist), + m.LostWidth, lost, + ) + Put(scr, 0, y, row, CDefault) + // colourise individual numbers + Put(scr, m.HostWidth+1, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", last)), StyleLatency(last)) + Put(scr, m.HostWidth+1+m.NumWidth+1, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", mi)), StyleLatency(mi)) + Put(scr, m.HostWidth+1+m.NumWidth*2+2, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", av)), StyleLatency(av)) + Put(scr, m.HostWidth+1+m.NumWidth*3+3, y, fmt.Sprintf("%*s", m.NumWidth, fmt.Sprintf("%.0fms", ma)), StyleLatency(ma)) + Put(scr, m.HostWidth+1+m.NumWidth*4+4, y, fmt.Sprintf("%*s", m.StdWidth, fmt.Sprintf("%.0fms", sd)), CDefault) + // Colorize the lost packets column + lostStyle := CDefault + if lost > 0 { + lostStyle = CRed + } + Put(scr, m.HostWidth+1+m.NumWidth*4+4+m.StdWidth+1+m.NWidth+1, y, fmt.Sprintf("%*d", m.LostWidth, lost), lostStyle) + y++ + } + y++ + + /* ICMP stats - combined into a single line with headers */ + // Calculate total lost packets + lost := st.TotalICMPReq - st.TotalICMPRep + if lost < 0 { + lost = 0 + } + + // Create styles based on values + lostStyle := CDefault + if lost > 0 { + lostStyle = CRed + } + + // Define column widths and positions + const valWidth = 9 + + // Format the values with right alignment + reqVal := fmt.Sprintf("%9d", st.TotalICMPReq) + repVal := fmt.Sprintf("%9d", st.TotalICMPRep) + lostVal := fmt.Sprintf("%9d", lost) + + // Header texts with the same width as values for right alignment + reqHeader := fmt.Sprintf("%9s", "Requests") + repHeader := fmt.Sprintf("%9s", "Replies") + lostHeader := fmt.Sprintf("%9s", "Lost") + + // Draw the header line + Put(scr, 0, y, " ", CDefault) + Put(scr, 8, y, reqHeader, CDefault) + Put(scr, 8+valWidth+4, y, repHeader, CDefault) + Put(scr, 8+2*(valWidth+4), y, lostHeader, CDefault) + y++ + + // Draw the values line + Put(scr, 0, y, "ICMP: ", CDefault) + Put(scr, 8, y, reqVal, CDefault) + Put(scr, 8+valWidth+4, y, repVal, CDefault) + Put(scr, 8+2*(valWidth+4), y, lostVal, lostStyle) + + y += 2 + st.mu.RUnlock() + return y +} + +// uiLoop runs the UI event loop +func (m *Monitor) uiLoop(ctx context.Context) { + m.logf("UI loop started") + defer func() { + m.logf("UI loop cleanup") + m.screen.Clear() + m.screen.ShowCursor(0, 0) + m.screen.Fini() + m.logf("Screen finalized") + }() + + // Load PST location + pstLoc, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + m.logf("Error loading PST location: %v", err) + pstLoc = time.UTC + } + + // Function to draw the screen + drawScreen := func() { + w, _ := m.screen.Size() + m.screen.Clear() + + // Draw the top horizontal line + Put(m.screen, 0, 0, HLine(w), CDefault) + + // Get current time and format it + now := time.Now() + timeStr := now.Format(time.RFC1123Z) + + // Format time in PST + pstTimeStr := now.In(pstLoc).Format(time.RFC1123Z) + + // Update the timestamp spinner once per second + if time.Since(lastTimestampSpinUpdate) >= time.Second { + timestampSpinFrame = (timestampSpinFrame + 1) % len(BrailleSpins) + lastTimestampSpinUpdate = now + } + + // Get braille spinner character + brailleChar := BrailleSpins[timestampSpinFrame] + + // Draw the header with timestamp and spinner + Put(m.screen, 0, 1, "== ", CDefault) + Put(m.screen, 3, 1, string(brailleChar)+" ", CDefault) + + // Draw the timestamp with rainbow colors + DrawRainbowText(m.screen, 5, 1, timeStr, CRainbow) + + // Add 5 space gap and PST time with rainbow colors + DrawRainbowText(m.screen, 5+len(timeStr)+5, 1, pstTimeStr, CRainbow) + + // Draw the bottom horizontal line + Put(m.screen, 0, 2, HLine(w), CDefault) + + // Draw the runtime + Put(m.screen, 0, 3, "Runtime: "+time.Since(m.startTime).Round(time.Second).String(), CDefault) + + // Draw interfaces + y := 5 + y = m.DrawInterface(m.screen, y, w, m.interfaceA) + _ = m.DrawInterface(m.screen, y, w, m.interfaceB) + + // Show the screen + m.screen.Show() + } + + // Initial draw + drawScreen() + + // Even without a ticker, ensure we update at least every second + // This is a backup in case there are no spinner updates + backupTicker := time.NewTicker(time.Second) + defer backupTicker.Stop() + + for { + select { + case <-ctx.Done(): + m.logf("Context cancelled, exiting UI loop") + return + case <-UIUpdateChan: + // Update on spinner ticks (no rate limiting) + drawScreen() + case <-backupTicker.C: + // Fallback to ensure we update at least once per second + drawScreen() + } + } +} diff --git a/main.go b/main.go deleted file mode 100644 index 43cb14b..0000000 --- a/main.go +++ /dev/null @@ -1,919 +0,0 @@ -//go:build linux -// +build linux - -// netmon – dual-interface network dashboard (curses) -// WTFPL – 2025-05-16 sneak@sneak.berlin -package main - -import ( - "context" - "encoding/json" - "flag" - "fmt" - "math" - "math/rand" - "net" - "os" - "os/exec" - "os/signal" - "strconv" - "strings" - "sync" - "syscall" - "time" - - tcell "github.com/gdamore/tcell/v2" -) - -/*────────────────── CLI flags ──────────────────*/ - -var ( - ifaceA = flag.String("ifaceA", "gu0", "primary network interface") - labelA = flag.String("labelA", "gu LAN - VPN outbound", "label for ifaceA") - ifaceB = flag.String("ifaceB", "backhaul0", "secondary network interface") - labelB = flag.String("labelB", "Cox cable direct", "label for ifaceB") - - hostCSV = flag.String("hosts", - "8.8.8.8,8.8.4.4,google.com,github.com,"+ - "console.aws.amazon.com,console.cloud.google.com,"+ - "fast.com,datavi.be,captive.apple.com", - "comma-separated reachability hosts") - - logFile = flag.String("logfile", "/tmp/rtnetmon.log", "path to log file") -) - -/*────────────────── constants ──────────────────*/ - -const ( - icmpTimeout = 500 * time.Millisecond - tcpTimeout = 500 * time.Millisecond - packetLossPings = 20 - packetLossPeriod = 5 * time.Second - statsHistory = 300 - screenRefresh = 500 * time.Millisecond // Still used as backup refresh rate - - // ASCII characters for the meter - meterStart = '[' - meterEnd = ']' - meterFill = '=' - meterEmpty = ' ' - meterWidth = 7 // Fixed width of the meter (including brackets) - meterFillWidth = 5 // Width of the fillable area (meterWidth - 2 for brackets) - maxMeterValue = 10 // Maximum value for the meter - - hostW = 30 // Increased width for host column - numW = 7 - stdW = 8 - nW = 6 - lostW = 7 // Width for the lost column -) - -/*────────────────── runtime struct ─────────────*/ - -type InterfaceStatus struct { - Name, Label, IPInfo string - Reachable map[string]bool - Loss map[string]float64 - TCP map[string][]float64 - TotalICMPReq, TotalICMPRep int - DroppedCount int - LastDrop, LastPing time.Time - SpinFrame int - MeterValue int // Current value for the packet loss meter - LostPackets map[string]int // Track lost packets per host - mu sync.RWMutex -} - -/*────────────────── colour styles ──────────────*/ - -var ( - cBrightGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true) - cGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen) - cDimGreen = tcell.StyleDefault.Foreground(tcell.ColorGreen).Dim(true) - cYellow = tcell.StyleDefault.Foreground(tcell.ColorYellow) - cOrange = tcell.StyleDefault.Foreground(tcell.ColorOrange) - cRed = tcell.StyleDefault.Foreground(tcell.ColorRed) - cBrightRed = tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true) - cDefault = tcell.StyleDefault - - // Rainbow colors for the spinner - cRainbow = []tcell.Style{ - tcell.StyleDefault.Foreground(tcell.ColorRed), - tcell.StyleDefault.Foreground(tcell.ColorOrange), - tcell.StyleDefault.Foreground(tcell.ColorYellow), - tcell.StyleDefault.Foreground(tcell.ColorGreen), - tcell.StyleDefault.Foreground(tcell.ColorBlue), - tcell.StyleDefault.Foreground(tcell.ColorPurple), - } -) - -// Get the appropriate meter style based on the value -func getMeterStyle(value int) tcell.Style { - switch { - case value <= 0: - return cBrightGreen - case value == 1: - return cGreen - case value == 2: - return cDimGreen - case value == 3: - return cYellow - case value == 4: - return cOrange - default: - return cRed - } -} - -func styleLatency(ms float64) tcell.Style { - switch { - case ms < 50: - return cBrightGreen - case ms < 100: - return cGreen - case ms < 200: - return cYellow - default: - return cRed - } -} -func styleLoss(p float64) tcell.Style { - switch { - case p == 0: - return cBrightGreen - case p < 5: - return cYellow - default: - return cBrightRed - } -} - -/*────────────────── math helpers ───────────────*/ - -func minMaxAvgStd(xs []float64) (min, max, avg, std float64) { - if len(xs) == 0 { - return - } - min, max = xs[0], xs[0] - var sum float64 - for _, v := range xs { - if v < min { - min = v - } - if v > max { - max = v - } - sum += v - } - avg = sum / float64(len(xs)) - var vs float64 - for _, v := range xs { - d := v - avg - vs += d * d - } - std = math.Sqrt(vs / float64(len(xs))) - return -} - -/*────────────────── external lookup ────────────*/ - -type ipInfoResp struct{ IP, Hostname, Org string } - -func fetchIPInfo(iface string) string { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - out, _ := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output() - var r ipInfoResp - _ = json.Unmarshal(out, &r) - if r.IP == "" { - return "(ipinfo error)" - } - return fmt.Sprintf("%s [%s] %s", r.IP, r.Hostname, r.Org) -} - -/*────────────────── ICMP helpers ───────────────*/ - -func pingOnce(iface, host string) bool { - ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout) - defer cancel() - return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil -} -func lossPercent(iface, host string) float64 { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() - out, err := exec.CommandContext(ctx, "ping", "-q", "-i", "0.05", - "-c", fmt.Sprint(packetLossPings), "-W1", "-I", iface, host).CombinedOutput() - if err != nil { - return 1.0 - } - for _, ln := range strings.Split(string(out), "\n") { - if strings.Contains(ln, "packet loss") { - for _, f := range strings.Fields(ln) { - if strings.HasSuffix(f, "%") { - p, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64) - return p / 100.0 - } - } - } - } - return 1.0 -} - -/*────────────────── TCP helpers ───────────────*/ - -func localAddr(iface string) (net.Addr, error) { - ifi, err := net.InterfaceByName(iface) - if err != nil { - return nil, err - } - add, _ := ifi.Addrs() - for _, a := range add { - if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil { - return &net.TCPAddr{IP: ipnet.IP}, nil - } - } - return nil, fmt.Errorf("no IPv4 on %s", iface) -} -func tcpDuration(iface, hp string) time.Duration { - la, err := localAddr(iface) - if err != nil { - return tcpTimeout - } - d := net.Dialer{Timeout: tcpTimeout, LocalAddr: la} - st := time.Now() - c, err := d.Dial("tcp", hp) - if err != nil { - return tcpTimeout - } - c.Close() - return time.Since(st) -} - -/*────────────────── spinner ────────────────────*/ - -var spins = []rune{'|', '/', '-', '\\'} - -// Update to add a channel for signaling UI updates -var uiUpdateChan = make(chan struct{}, 100) // Buffered channel to avoid blocking - -// Advances the spinner frame and signals a UI update -// This is called only when packets are successfully received, -// so the spinner only moves when there's actual network activity -func (st *InterfaceStatus) spin() { - st.SpinFrame = (st.SpinFrame + 1) % len(spins) - // Signal UI update after spinner changes - select { - case uiUpdateChan <- struct{}{}: - default: - // Non-blocking send - if channel is full, just continue - } -} - -/*────────────────── screen helpers ─────────────*/ - -func put(scr tcell.Screen, x, y int, txt string, st tcell.Style) { - for i, r := range txt { - scr.SetContent(x+i, y, r, nil, st) - } -} -func hline(w int) string { return strings.Repeat("=", w) } - -/*────────────────── UI drawing ─────────────────*/ - -func ifaceHealthy(st *InterfaceStatus) bool { - st.mu.RLock() - defer st.mu.RUnlock() - if st.DroppedCount > 0 { - return false - } - for _, ok := range st.Reachable { - if !ok { - return false - } - } - for _, lp := range st.Loss { - if lp > 0 { - return false - } - } - for _, hp := range tcpTestHosts { - h := st.TCP[hp] - if len(h) == 0 || h[len(h)-1] >= float64(tcpTimeout.Milliseconds()) { - return false - } - } - return true -} - -func drawIface(scr tcell.Screen, y, w int, st *InterfaceStatus) int { - put(scr, 0, y, hline(w), cDefault) - - // header line - healthy := ifaceHealthy(st) - style := cBrightGreen - if !healthy { - style = cBrightRed - } - st.mu.RLock() - header := fmt.Sprintf("%s: %s — %s", st.Name, st.Label, st.IPInfo) - spinChar := spins[st.SpinFrame] - spinnerFrame := st.SpinFrame - meterValue := st.MeterValue - st.mu.RUnlock() - - // Create the meter with appropriate style - meter, meterStyle := createMeter(meterValue, meterWidth) - - // Get rainbow color for spinner (cycle through colors) - spinnerStyle := cRainbow[spinnerFrame%len(cRainbow)] - - put(scr, 0, y+1, "== ", cDefault) - put(scr, 3, y+1, string(spinChar)+" ", spinnerStyle) // Colorful spinner - put(scr, 5, y+1, meter+" ", meterStyle) // Colored meter - put(scr, 5+meterWidth+1, y+1, header, style) // Move the header after the meter - put(scr, 0, y+2, hline(w), cDefault) - y += 4 - - /* reachability */ - st.mu.RLock() - total := len(st.Reachable) - good := 0 - for _, ok := range st.Reachable { - if ok { - good++ - } - } - age := time.Since(st.LastPing).Round(time.Second) - reachStyle := cBrightGreen - if good != total { - reachStyle = cBrightRed - } - put(scr, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)", - good, total, st.LastPing.Format("15:04:05"), age), reachStyle) - y++ - if good == total { - put(scr, 0, y, "Unreachable: none", cDefault) - } else { - var down []string - for h, ok := range st.Reachable { - if !ok { - down = append(down, h) - } - } - put(scr, 0, y, "Unreachable: "+strings.Join(down, ", "), cBrightRed) - } - y += 2 - - /* packet loss */ - put(scr, 0, y, "Packet Loss:", cDefault) - y++ - for _, h := range packetLossHosts { - p := st.Loss[h] * 100 - put(scr, 0, y, fmt.Sprintf("%-16s %5.0f%%", h+":", p), styleLoss(p)) - y++ - } - dAge := "N/A" - if !st.LastDrop.IsZero() { - dAge = time.Since(st.LastDrop).Round(time.Second).String() - } - put(scr, 0, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)", - st.DroppedCount, st.LastDrop.Format("15:04:05"), dAge), cDefault) - y += 2 - - /* TCP table */ - put(scr, 0, y, "TCP Connect Stats:", cDefault) - y++ - // header row - headerRow := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*s %*s", - hostW, "Host", numW, "last", numW, "min", numW, "avg", - numW, "max", stdW, "stddev", nW, "n", lostW, "lost") - put(scr, 0, y, headerRow, cDefault) - y++ - - for _, hp := range tcpTestHosts { - hist := st.TCP[hp] - if len(hist) == 0 { - continue - } - last := hist[len(hist)-1] - mi, ma, av, sd := minMaxAvgStd(hist) - - // Get host name without port for lost packets lookup - hostName := strings.Split(hp, ":")[0] - lost := st.LostPackets[hostName] - - // Add lost column - row := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*d %*d", - hostW, hp, - numW, fmt.Sprintf("%.0fms", last), - numW, fmt.Sprintf("%.0fms", mi), - numW, fmt.Sprintf("%.0fms", av), - numW, fmt.Sprintf("%.0fms", ma), - stdW, fmt.Sprintf("%.0fms", sd), - nW, len(hist), - lostW, lost, - ) - put(scr, 0, y, row, cDefault) - // colourise individual numbers - put(scr, hostW+1, y, fmt.Sprintf("%*s", numW, fmt.Sprintf("%.0fms", last)), styleLatency(last)) - put(scr, hostW+1+numW+1, y, fmt.Sprintf("%*s", numW, fmt.Sprintf("%.0fms", mi)), styleLatency(mi)) - put(scr, hostW+1+numW*2+2, y, fmt.Sprintf("%*s", numW, fmt.Sprintf("%.0fms", av)), styleLatency(av)) - put(scr, hostW+1+numW*3+3, y, fmt.Sprintf("%*s", numW, fmt.Sprintf("%.0fms", ma)), styleLatency(ma)) - put(scr, hostW+1+numW*4+4, y, fmt.Sprintf("%*s", stdW, fmt.Sprintf("%.0fms", sd)), cDefault) - // Colorize the lost packets column - lostStyle := cDefault - if lost > 0 { - lostStyle = cRed - } - put(scr, hostW+1+numW*4+4+stdW+1+nW+1, y, fmt.Sprintf("%*d", lostW, lost), lostStyle) - y++ - } - y++ - - /* totals */ - put(scr, 0, y, fmt.Sprintf("Total ICMP Requests: %d", st.TotalICMPReq), cDefault) - y++ - put(scr, 0, y, fmt.Sprintf("Total ICMP Replies: %d", st.TotalICMPRep), cDefault) - y++ - // Add a line showing lost packets - lost := st.TotalICMPReq - st.TotalICMPRep - lostStyle := cDefault - if lost > 0 { - lostStyle = cRed - } - put(scr, 0, y, fmt.Sprintf("Total ICMP Lost: %d", lost), lostStyle) - y += 2 - st.mu.RUnlock() - return y -} - -// Create a visual meter using ASCII characters -// Returns the meter string and the appropriate style -func createMeter(value, width int) (string, tcell.Style) { - if value > maxMeterValue { - value = maxMeterValue - } - if value < 0 { - value = 0 - } - - // Calculate the number of fill characters to show - // Map value (0-maxMeterValue) to fill width (0-meterFillWidth) - fillCount := value * meterFillWidth / maxMeterValue - if fillCount > meterFillWidth { - fillCount = meterFillWidth - } - - // Build the meter string - var b strings.Builder - - // Add opening bracket - b.WriteRune(meterStart) - - // Add fill characters - for i := 0; i < fillCount; i++ { - b.WriteRune(meterFill) - } - - // Add empty spaces - for i := 0; i < meterFillWidth-fillCount; i++ { - b.WriteRune(meterEmpty) - } - - // Add closing bracket - b.WriteRune(meterEnd) - - // Get the appropriate style for this meter value - style := getMeterStyle(value) - - return b.String(), style -} - -/*────────────────── goroutine loops ─────────────*/ - -func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { - logf("Starting reachability monitoring for %s with %d hosts", st.Name, len(hosts)) - - // Add random offset to avoid clustering at 1-second intervals - randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond - logf("Reachability monitoring for %s will start after %v offset", st.Name, randomOffset) - time.Sleep(randomOffset) - - tk := time.NewTicker(time.Second) - defer tk.Stop() - for { - select { - case <-ctx.Done(): - logf("Stopping reachability monitoring for %s", st.Name) - return - case <-tk.C: - // logf("Checking reachability for %s", st.Name) // Too verbose - var wg sync.WaitGroup - res := make(map[string]bool, len(hosts)) - mu := sync.Mutex{} - for _, h := range hosts { - wg.Add(1) - go func(host string) { - defer wg.Done() - st.mu.Lock() - st.TotalICMPReq++ - // Increase meter value when a packet is sent - st.MeterValue++ - if st.MeterValue > maxMeterValue { - st.MeterValue = maxMeterValue - } - st.mu.Unlock() - - ok := pingOnce(st.Name, host) - mu.Lock() - res[host] = ok - mu.Unlock() - - st.mu.Lock() - if ok { - st.TotalICMPRep++ - // Decrease meter value when a packet is successfully received - st.MeterValue-- - if st.MeterValue < 0 { - st.MeterValue = 0 - } - // Only update spinner when packets are successfully received - st.spin() - } else { - st.DroppedCount++ - st.LastDrop = time.Now() - - // Track lost packets per host - st.LostPackets[host]++ - - // For failed pings, we don't decrease the meter value - // Trigger UI update on ping failure - select { - case uiUpdateChan <- struct{}{}: - default: - } - } - st.mu.Unlock() - }(h) - } - wg.Wait() - - // Check if reachability status changed - statusChanged := false - st.mu.Lock() - for host, newStatus := range res { - if oldStatus, ok := st.Reachable[host]; !ok || oldStatus != newStatus { - statusChanged = true - break - } - } - - st.Reachable = res - st.LastPing = time.Now() - - // Reset DroppedCount if all hosts are reachable - allReachable := true - for _, ok := range res { - if !ok { - allReachable = false - break - } - } - if allReachable { - st.DroppedCount = 0 - // If all hosts are reachable, gradually decay the meter value - if st.MeterValue > 0 { - st.MeterValue-- - } - } - st.mu.Unlock() - - // Always trigger UI update when reachability status changes - if statusChanged { - select { - case uiUpdateChan <- struct{}{}: - default: - } - } - } - } -} - -func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { - logf("Starting packet loss monitoring for %s with %d hosts", st.Name, len(hosts)) - - // Add random offset to avoid clustering at periodic intervals - randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond - logf("Packet loss monitoring for %s will start after %v offset", st.Name, randomOffset) - time.Sleep(randomOffset) - - tk := time.NewTicker(packetLossPeriod) - defer tk.Stop() - for { - select { - case <-ctx.Done(): - logf("Stopping packet loss monitoring for %s", st.Name) - return - case <-tk.C: - // logf("Checking packet loss for %s", st.Name) // Too verbose - var wg sync.WaitGroup - res := make(map[string]float64, len(hosts)) - mu := sync.Mutex{} - for _, h := range hosts { - wg.Add(1) - go func(host string) { - defer wg.Done() - lp := lossPercent(st.Name, host) - mu.Lock() - res[host] = lp - mu.Unlock() - - st.mu.Lock() - if lp == 0 { - // Only update spinner when there's 0% packet loss (successful receipt) - st.spin() - } else { - // Trigger UI update on packet loss - select { - case uiUpdateChan <- struct{}{}: - default: - } - } - st.mu.Unlock() - }(h) - } - wg.Wait() - - // Check if loss status changed - statusChanged := false - st.mu.Lock() - for host, newLoss := range res { - if oldLoss, ok := st.Loss[host]; !ok || math.Abs(oldLoss-newLoss) > 0.01 { - statusChanged = true - break - } - } - - for k, v := range res { - st.Loss[k] = v - } - st.mu.Unlock() - - // Always trigger UI update when loss status changes - if statusChanged { - select { - case uiUpdateChan <- struct{}{}: - default: - } - } - } - } -} - -func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) { - logf("Starting TCP monitoring for %s with %d hosts", st.Name, len(hosts)) - - // Add random offset to avoid clustering at 1-second intervals - randomOffset := time.Duration(100+rand.Intn(800)) * time.Millisecond - logf("TCP monitoring for %s will start after %v offset", st.Name, randomOffset) - time.Sleep(randomOffset) - - tk := time.NewTicker(time.Second) - defer tk.Stop() - for { - select { - case <-ctx.Done(): - logf("Stopping TCP monitoring for %s", st.Name) - return - case <-tk.C: - // logf("Checking TCP for %s", st.Name) // Too verbose - statusChanged := false - - for _, hp := range hosts { - ms := float64(tcpDuration(st.Name, hp).Milliseconds()) - st.mu.Lock() - - // Check if TCP latency significantly changed - hist := st.TCP[hp] - if len(hist) > 0 { - lastMs := hist[len(hist)-1] - if math.Abs(lastMs-ms) > 20 { // 20ms threshold for significant change - statusChanged = true - } - } else { - // First measurement - statusChanged = true - } - - if ms < float64(tcpTimeout.Milliseconds()) { - // Only update spinner on successful TCP connections - st.spin() - } else { - // Trigger UI update on TCP timeout - select { - case uiUpdateChan <- struct{}{}: - default: - } - } - - if len(hist) >= statsHistory { - hist = hist[1:] - } - st.TCP[hp] = append(hist, ms) - st.mu.Unlock() - } - - // Always trigger UI update when TCP status changes significantly - if statusChanged { - select { - case uiUpdateChan <- struct{}{}: - default: - } - } - } - } -} - -/*────────────────── UI loop ─────────────────────*/ - -func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus, start time.Time) { - logf("UI loop started") - defer func() { - logf("UI loop cleanup") - scr.Clear() - scr.ShowCursor(0, 0) - scr.Fini() - logf("Screen finalized") - }() - - // Remove the ticker as we'll update on spinner ticks - // tk := time.NewTicker(screenRefresh) - // defer tk.Stop() - spin := 0 - - // Function to draw the screen - drawScreen := func() { - w, _ := scr.Size() - scr.Clear() - put(scr, 0, 0, hline(w), cDefault) - put(scr, 0, 1, fmt.Sprintf("== %c %s", spins[spin%len(spins)], time.Now().Format(time.RFC1123Z)), cDefault) - put(scr, 0, 2, hline(w), cDefault) - spin++ - put(scr, 0, 3, "Runtime: "+time.Since(start).Round(time.Second).String(), cDefault) - y := 5 - y = drawIface(scr, y, w, a) - _ = drawIface(scr, y, w, b) - scr.Show() - } - - // Initial draw - drawScreen() - - // Even without a ticker, ensure we update at least every second - // This is a backup in case there are no spinner updates - backupTicker := time.NewTicker(time.Second) - defer backupTicker.Stop() - - for { - select { - case <-ctx.Done(): - logf("Context cancelled, exiting UI loop") - return - case <-uiUpdateChan: - // Update on spinner ticks (no rate limiting) - drawScreen() - case <-backupTicker.C: - // Fallback to ensure we update at least once per second - drawScreen() - } - } -} - -/*────────────────── main ─────────────────────────*/ - -var ( - reachHosts []string - packetLossHosts = []string{"github.com", "google.com", "8.8.8.8", "captive.apple.com"} - tcpTestHosts = []string{ - "datavi.be:443", "fast.com:443", - "console.aws.amazon.com:443", "console.cloud.google.com:443", - } -) - -// Simple logging function -func logf(format string, v ...interface{}) { - if logFile == nil || *logFile == "" { - return - } - f, err := os.OpenFile(*logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return // silently fail if we can't log - } - defer f.Close() - fmt.Fprintf(f, time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...) -} - -func main() { - flag.Parse() - logf("Starting rtnetmon") - - // Seed the random number generator - rand.Seed(time.Now().UnixNano()) - logf("Random number generator seeded") - - reachHosts = strings.Split(*hostCSV, ",") - logf("Monitoring interfaces %s and %s", *ifaceA, *ifaceB) - - // Initialize UI update channel - uiUpdateChan = make(chan struct{}, 100) - logf("UI update channel initialized with buffer size 100") - - newStatus := func(name, label string) *InterfaceStatus { - logf("Initializing status for interface %s", name) - return &InterfaceStatus{ - Name: name, - Label: label, - IPInfo: fetchIPInfo(name), - Reachable: map[string]bool{}, - Loss: map[string]float64{}, - TCP: map[string][]float64{}, - MeterValue: 0, // Initialize meter value to 0 - LostPackets: map[string]int{}, // Initialize lost packets map - } - } - a, b := newStatus(*ifaceA, *labelA), newStatus(*ifaceB, *labelB) - - logf("Initializing screen") - scr, err := tcell.NewScreen() - if err != nil { - logf("Error creating screen: %v", err) - panic(err) - } - if err = scr.Init(); err != nil { - logf("Error initializing screen: %v", err) - panic(err) - } - logf("Screen initialized") - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - sig := make(chan os.Signal, 1) - signal.Notify(sig, os.Interrupt, syscall.SIGTERM) - go func() { - s := <-sig - logf("Signal received: %v", s) - cancel() - }() - - // Event polling loop with logging - go func() { - logf("Starting keyboard event loop") - for { - if ev := scr.PollEvent(); ev != nil { - logf("Event received: %T", ev) - if ke, ok := ev.(*tcell.EventKey); ok { - logf("Key event: %v, rune: %c", ke.Key(), ke.Rune()) - if ke.Key() == tcell.KeyCtrlC || ke.Rune() == 'q' { - logf("Quit key detected") - cancel() - return - } - } - - // Trigger UI update on any event - select { - case uiUpdateChan <- struct{}{}: - default: - } - } - } - }() - - // Start monitoring goroutines - logf("Starting monitoring goroutines") - go reachLoop(ctx, a, reachHosts) - go reachLoop(ctx, b, reachHosts) - go lossLoop(ctx, a, packetLossHosts) - go lossLoop(ctx, b, packetLossHosts) - go tcpLoop(ctx, a, tcpTestHosts) - go tcpLoop(ctx, b, tcpTestHosts) - - logf("Starting UI loop") - uiLoop(ctx, scr, a, b, time.Now()) - logf("UI loop exited, program ending") -} - -/*────────────────── Extra packet-loss hosts ───── - -To widen geographic and CDN coverage you could add: - - facebook.com - microsoft.com - apple.com - twitter.com - akamai.com - -These are large anycast/CDN endpoints that tend to reveal regional -network quirks. Add them to `packetLossHosts` (and `reachHosts` -if you also want individual pings every second). */