This commit is contained in:
2026-01-06 10:20:34 -08:00
parent 0e1fc6a88d
commit a8ab26752a
15 changed files with 1806 additions and 929 deletions
+2
View File
@@ -1,2 +1,4 @@
.aider*
.env
AGENTS.md
CLAUDE.md
+32 -9
View File
@@ -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"
+137
View File
@@ -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
+20
View File
@@ -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)
}
}
+23 -1
View File
@@ -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
)
+70
View File
@@ -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=
+118
View File
@@ -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()
}
+36
View File
@@ -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")
}
}
+278
View File
@@ -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:
}
}
}
}
}
+394
View File
@@ -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...)
}
+126
View File
@@ -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)
}
})
}
}
+87
View File
@@ -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
}
}
+105
View File
@@ -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)
}
})
}
}
+378
View File
@@ -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()
}
}
}
-919
View File
@@ -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). */