initial
This commit is contained in:
@@ -1,2 +1,4 @@
|
|||||||
.aider*
|
.aider*
|
||||||
.env
|
.env
|
||||||
|
AGENTS.md
|
||||||
|
CLAUDE.md
|
||||||
|
|||||||
@@ -1,17 +1,40 @@
|
|||||||
default: run
|
.PHONY: test lint fmt build run-local clean all copy run
|
||||||
|
|
||||||
main.go:
|
# Default target
|
||||||
pbpaste > main.go
|
default: test
|
||||||
|
|
||||||
copy: main.go
|
# Build the binary
|
||||||
ssh root@las1stor1 -v "mkdir -p /tmp/x"
|
build:
|
||||||
scp ./main.go root@las1stor1:/tmp/x
|
go build -o rtnetmon ./cmd/rtnetmon
|
||||||
|
|
||||||
run: copy
|
# Run tests
|
||||||
ssh -t root@las1stor1 -v "cd /tmp/x && go run ."
|
test: lint
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
# Run linter
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run
|
golangci-lint run
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
fmt:
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
# Run the application locally
|
||||||
|
run-local: build
|
||||||
|
./rtnetmon
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
clean:
|
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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,36 @@ module git.eeqj.de/sneak/rtnetmon
|
|||||||
|
|
||||||
go 1.22.2
|
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 (
|
require (
|
||||||
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // 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/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/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/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/sys v0.29.0 // indirect
|
||||||
golang.org/x/term v0.28.0 // indirect
|
golang.org/x/term v0.28.0 // indirect
|
||||||
golang.org/x/text v0.21.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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
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 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
|
||||||
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
|
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/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 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
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 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
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.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
|
github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw=
|
||||||
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
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=
|
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-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.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.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.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
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.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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.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.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
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=
|
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=
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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). */
|
|
||||||
Reference in New Issue
Block a user