This commit is contained in:
2025-05-16 04:25:04 -07:00
parent 9d2aa2b4de
commit 26e7fd997f
+159 -152
View File
@@ -1,5 +1,5 @@
// netmon real-time dual-interface network dashboard
// WTFPL 2025-05-16 sneak@sneak.berlin
// netmon dual-interface network dashboard (curses)
// WTFPL 2025-05-16 sneak@sneak.berlin
package main
import (
@@ -19,56 +19,55 @@ import (
)
//-----------------------------------------------------------------------------
// Configuration flags
// 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")
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",
"1.1.1.1,8.8.8.8,8.8.4.4,google.com,github.com,console.aws.amazon.com,"+
"console.cloud.google.com,fast.com,cloudflare.com,datavi.be",
"comma-separated hosts for reachability checks")
"1.1.1.1,8.8.8.8,8.8.4.4,google.com,github.com,"+
"console.aws.amazon.com,console.cloud.google.com,"+
"fast.com,cloudflare.com,datavi.be",
"hosts for reachability checks")
)
//-----------------------------------------------------------------------------
// Constants
//-----------------------------------------------------------------------------
const (
icmpTimeout = 500 * time.Millisecond // per-ping timeout
tcpTimeout = 500 * time.Millisecond // TCP handshake timeout
packetLossPings = 20 // pings per host for loss calc
packetLossPeriod = 5 * time.Second // how often to recalc loss
statsHistory = 300 // 5 minutes of 1 Hz samples
screenRefresh = 500 * time.Millisecond // spinner / UI cadence
icmpTimeout = 500 * time.Millisecond
tcpTimeout = 500 * time.Millisecond
packetLossPings = 20
packetLossPeriod = 5 * time.Second
statsHistory = 300 // 5 min of 1 Hz samples
screenRefresh = 500 * time.Millisecond
)
//-----------------------------------------------------------------------------
// Runtime data structures
// Data
//-----------------------------------------------------------------------------
type InterfaceStatus struct {
Name string
Label string
IPInfo string
Reachable map[string]bool // last reachability results
Loss map[string]float64// packet-loss %
TCP map[string][]float64 // rolling TCP ms samples
LastPing time.Time
mu sync.RWMutex
Name string
Label string
IPInfo string
Reachable map[string]bool
Loss map[string]float64
TCP map[string][]float64
LastPing time.Time
SpinFrame int
mu sync.RWMutex
}
//-----------------------------------------------------------------------------
// Helpers maths without external deps
// Math helpers (no heavy deps)
//-----------------------------------------------------------------------------
func minMaxAvgStd(nums []float64) (min, max, avg, std float64) {
if len(nums) == 0 {
return
@@ -85,22 +84,19 @@ func minMaxAvgStd(nums []float64) (min, max, avg, std float64) {
sum += v
}
avg = sum / float64(len(nums))
var varSum float64
var vSum float64
for _, v := range nums {
diff := v - avg
varSum += diff * diff
vSum += diff * diff
}
std = math.Sqrt(varSum / float64(len(nums)))
std = math.Sqrt(vSum / float64(len(nums)))
return
}
//-----------------------------------------------------------------------------
// External look-ups
//-----------------------------------------------------------------------------
type ipInfo struct {
IP, Hostname, Org string
}
type ipInfo struct{ IP, Hostname, Org string }
func fetchIPInfo(iface string) string {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
@@ -119,21 +115,18 @@ func fetchIPInfo(iface string) string {
}
//-----------------------------------------------------------------------------
// Ping helpers
// Ping + loss
//-----------------------------------------------------------------------------
func pingOnce(iface, host string) bool {
ctx, cancel := context.WithTimeout(context.Background(), icmpTimeout)
defer cancel()
err := exec.CommandContext(ctx,
"ping", "-I", iface, "-c1", "-W1", host).Run()
err := exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run()
return err == 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()
@@ -141,13 +134,12 @@ func lossPercent(iface, host string) float64 {
return 1.0
}
for _, line := range strings.Split(string(out), "\n") {
if !strings.Contains(line, "packet loss") {
continue
}
for _, f := range strings.Fields(line) {
if strings.HasSuffix(f, "%") {
pct, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64)
return pct / 100.0
if strings.Contains(line, "packet loss") {
for _, f := range strings.Fields(line) {
if strings.HasSuffix(f, "%") {
p, _ := strconv.ParseFloat(strings.TrimSuffix(f, "%"), 64)
return p / 100.0
}
}
}
}
@@ -155,18 +147,14 @@ func lossPercent(iface, host string) float64 {
}
//-----------------------------------------------------------------------------
// TCP helpers
// TCP timing
//-----------------------------------------------------------------------------
func localAddrFor(iface string) (net.Addr, error) {
func localAddr(iface string) (net.Addr, error) {
ni, err := net.InterfaceByName(iface)
if err != nil {
return nil, err
}
addrs, err := ni.Addrs()
if err != nil {
return nil, err
}
addrs, _ := ni.Addrs()
for _, a := range addrs {
if ipnet, ok := a.(*net.IPNet); ok && ipnet.IP.To4() != nil {
return &net.TCPAddr{IP: ipnet.IP}, nil
@@ -176,7 +164,7 @@ func localAddrFor(iface string) (net.Addr, error) {
}
func tcpDuration(iface, hostPort string) time.Duration {
la, err := localAddrFor(iface)
la, err := localAddr(iface)
if err != nil {
return tcpTimeout
}
@@ -191,36 +179,44 @@ func tcpDuration(iface, hostPort string) time.Duration {
}
//-----------------------------------------------------------------------------
// Goroutines reachability, loss, TCP stats
// Spinners
//-----------------------------------------------------------------------------
var spinner = []rune{'|', '/', '-', '\\'}
func reachabilityLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
func (st *InterfaceStatus) tickSpinner() {
st.SpinFrame = (st.SpinFrame + 1) % len(spinner)
}
//-----------------------------------------------------------------------------
// Goroutines
//-----------------------------------------------------------------------------
func reachLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
tick := time.NewTicker(time.Second)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
var wg sync.WaitGroup
results := make(map[string]bool, len(hosts))
res := make(map[string]bool, len(hosts))
mu := sync.Mutex{}
for _, h := range hosts {
wg.Add(1)
go func(host string) {
defer wg.Done()
ok := pingOnce(st.Name, host)
mu.Lock()
results[host] = ok
res[host] = ok
if ok {
st.tickSpinner()
}
mu.Unlock()
}(h)
}
wg.Wait()
st.mu.Lock()
st.Reachable = results
st.Reachable = res
st.LastPing = time.Now()
st.mu.Unlock()
}
@@ -230,30 +226,30 @@ func reachabilityLoop(ctx context.Context, st *InterfaceStatus, hosts []string)
func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
tick := time.NewTicker(packetLossPeriod)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
var wg sync.WaitGroup
results := make(map[string]float64, len(hosts))
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()
results[host] = lp
res[host] = lp
if lp < 1.0 {
st.tickSpinner()
}
mu.Unlock()
}(h)
}
wg.Wait()
st.mu.Lock()
for k, v := range results {
for k, v := range res {
st.Loss[k] = v
}
st.mu.Unlock()
@@ -264,7 +260,6 @@ func lossLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
tick := time.NewTicker(time.Second)
defer tick.Stop()
for {
select {
case <-ctx.Done():
@@ -272,11 +267,13 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
case <-tick.C:
for _, hp := range hosts {
ms := float64(tcpDuration(st.Name, hp).Milliseconds())
st.mu.Lock()
if ms < float64(tcpTimeout.Milliseconds()) {
st.tickSpinner()
}
hist := st.TCP[hp]
if len(hist) >= statsHistory {
hist = hist[1:] // drop oldest
hist = hist[1:]
}
st.TCP[hp] = append(hist, ms)
st.mu.Unlock()
@@ -286,25 +283,39 @@ func tcpLoop(ctx context.Context, st *InterfaceStatus, hosts []string) {
}
//-----------------------------------------------------------------------------
// UI
// UI helpers
//-----------------------------------------------------------------------------
var spin = []rune{'|', '/', '-', '\\'}
func writeLine(s tcell.Screen, x, y int, str string) {
func write(s tcell.Screen, x, y int, str string) {
for i, r := range str {
s.SetContent(x+i, y, r, nil, tcell.StyleDefault)
}
}
func drawSection(s tcell.Screen, x, y, w int, st *InterfaceStatus) {
func drawDivider(s tcell.Screen, y, w int) {
line := strings.Repeat("=", w)
write(s, 0, y, line)
}
func drawHeaderLine(s tcell.Screen, y, w int, spin rune, txt string) {
line := fmt.Sprintf("== %c %s", spin, txt)
if len(line) > w {
line = line[:w]
}
write(s, 0, y, line)
}
func drawInterface(s tcell.Screen, y, w int, st *InterfaceStatus) int {
// divider, header, divider
drawDivider(s, y, w)
st.mu.RLock()
spin := spinner[st.SpinFrame]
head := fmt.Sprintf("%s — %s", st.Label, st.IPInfo)
st.mu.RUnlock()
drawHeaderLine(s, y+1, w, spin, head)
drawDivider(s, y+2, w)
y += 4 // skip blank line after
// contents
st.mu.RLock()
defer st.mu.RUnlock()
// header
writeLine(s, x, y, fmt.Sprintf("%s — %s", st.Label, st.IPInfo))
// reachability
total := len(st.Reachable)
rc := 0
var down []string
@@ -316,121 +327,117 @@ func drawSection(s tcell.Screen, x, y, w int, st *InterfaceStatus) {
}
}
age := time.Since(st.LastPing).Round(time.Second)
writeLine(s, x, y+1,
fmt.Sprintf("Reachable: %d/%d (at %s, age %s)",
rc, total, st.LastPing.Format("15:04:05"), age))
write(s, 0, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)",
rc, total, st.LastPing.Format("15:04:05"), age))
y++
if len(down) > 0 {
writeLine(s, x, y+2, "Unreachable: "+strings.Join(down, ", "))
write(s, 0, y, "Unreachable: "+strings.Join(down, ", "))
} else {
writeLine(s, x, y+2, "Unreachable: none")
write(s, 0, y, "Unreachable: none")
}
// packet loss
row := y + 4
writeLine(s, x, row, "Packet Loss:")
row++
y += 2
write(s, 0, y, "Packet Loss:")
y++
for _, h := range packetLossHosts {
lp := st.Loss[h] * 100
writeLine(s, x, row, fmt.Sprintf("%-16s %.0f%%", h+":", lp))
row++
write(s, 0, y, fmt.Sprintf("%-16s %.0f%%", h+":", lp))
y++
}
// TCP stats
writeLine(s, x, row, "TCP Connect Stats:")
row++
y++
write(s, 0, y, "TCP Connect Stats:")
y++
for _, hp := range tcpTestHosts {
samples := st.TCP[hp]
if len(samples) == 0 {
continue
}
mi, ma, av, sd := minMaxAvgStd(samples)
writeLine(s, x, row,
fmt.Sprintf("%-16s %.0f/%.0f/%.0f/%.0fms",
hp, mi, av, ma, sd))
row++
write(s, 0, y, fmt.Sprintf("%-16s %.0f/%.0f/%.0f/%.0fms",
hp, mi, av, ma, sd))
y++
}
st.mu.RUnlock()
return y + 2 // leave a blank line after section
}
//-----------------------------------------------------------------------------
// UI main loop
//-----------------------------------------------------------------------------
func uiLoop(ctx context.Context, scr tcell.Screen, a, b *InterfaceStatus) {
defer scr.Fini()
tick := time.NewTicker(screenRefresh)
defer tick.Stop()
frame := 0
ticker := time.NewTicker(screenRefresh)
defer ticker.Stop()
topSpin := 0
for {
select {
case <-ctx.Done():
return
case <-tick.C:
case <-ticker.C:
w, _ := scr.Size()
scr.Clear()
w, h := scr.Size()
mid := h / 2
ts := time.Now().Format(time.RFC1123Z)
writeLine(scr, 0, 0, fmt.Sprintf("%c %s", spin[frame%len(spin)], ts))
frame++
drawSection(scr, 0, 1, w, a)
drawSection(scr, 0, mid, w, b)
// top banner
drawDivider(scr, 0, w)
drawHeaderLine(scr, 1, w, spinner[topSpin%len(spinner)],
time.Now().Format(time.RFC1123Z))
drawDivider(scr, 2, w)
topSpin++
y := 4
y = drawInterface(scr, y, w, a)
y = drawInterface(scr, y, w, b)
scr.Show()
}
}
}
//-----------------------------------------------------------------------------
// Entry
// Main
//-----------------------------------------------------------------------------
var (
reachabilityHosts []string
packetLossHosts = []string{"datavi.be", "google.com", "fast.com", "cloudflare.com", "github.com"}
tcpTestHosts = []string{"google.com:443", "github.com:443", "1.1.1.1:443", "8.8.8.8:53"}
reachHosts []string
packetLossHosts = []string{"datavi.be", "google.com", "fast.com",
"cloudflare.com", "github.com"}
tcpTestHosts = []string{"google.com:443", "github.com:443",
"1.1.1.1:443", "8.8.8.8:53"}
)
func main() {
flag.Parse()
reachabilityHosts = strings.Split(*hostCSV, ",")
reachHosts = strings.Split(*hostCSV, ",")
// prepare interface status objects
stA := &InterfaceStatus{
a := &InterfaceStatus{
Name: *ifaceA,
Label: *labelA,
Reachable: make(map[string]bool),
Loss: make(map[string]float64),
TCP: make(map[string][]float64),
Reachable: map[string]bool{},
Loss: map[string]float64{},
TCP: map[string][]float64{},
IPInfo: fetchIPInfo(*ifaceA),
}
stB := &InterfaceStatus{
b := &InterfaceStatus{
Name: *ifaceB,
Label: *labelB,
Reachable: make(map[string]bool),
Loss: make(map[string]float64),
TCP: make(map[string][]float64),
Reachable: map[string]bool{},
Loss: map[string]float64{},
TCP: map[string][]float64{},
IPInfo: fetchIPInfo(*ifaceB),
}
screen, err := tcell.NewScreen()
scr, err := tcell.NewScreen()
if err != nil {
panic(err)
}
if err := screen.Init(); err != nil {
if err := scr.Init(); err != nil {
panic(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// background jobs
go reachabilityLoop(ctx, stA, reachabilityHosts)
go reachabilityLoop(ctx, stB, reachabilityHosts)
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)
go lossLoop(ctx, stA, packetLossHosts)
go lossLoop(ctx, stB, packetLossHosts)
go tcpLoop(ctx, stA, tcpTestHosts)
go tcpLoop(ctx, stB, tcpTestHosts)
// UI (blocks)
uiLoop(ctx, screen, stA, stB)
uiLoop(ctx, scr, a, b)
}