Adopt repo standards: scaffold, policies, lint-clean (closes #1)
check / check (push) Failing after 0s
check / check (push) Failing after 0s
Add the standard scaffold and bring the tree to a clean lint under the vendored `default: all` config: `script/` Scripts-to-Rule-Them-All entrypoints with the `Makefile` as thin shims; a `Dockerfile` whose `lint` and `test` phases gate the build; `.gitea/workflows/` CI running `script/cibuild`; `REPO_POLICIES.md`, `.editorconfig`, `.dockerignore`, `LICENSE` (WTFPL), `TODO.md`, `.gitignore`. The `.golangci.yml` is byte-identical to the canonical copy in the `prompts` repo (`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`). The 211 lint findings were fixed, not suppressed: package globals became functions/fields/a command constructor, magic numbers became named constants, `ctx` threads into the probes, loop functions were split to cut complexity. Behavior is unchanged; the log file mode stays `0644`. Four `//nolint:gosec` remain — G204 on the fixed-argv subprocess calls, G304 on the operator-chosen log file — matching the reference repos. `make check` is green (lint and tests run in Docker). Model: opus-4-8
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
//go:build linux
|
||||
|
||||
package monitor
|
||||
|
||||
// Test-only accessors exposing unexported state for white-box assertions.
|
||||
|
||||
// ReachabilityHosts returns the configured reachability hosts.
|
||||
func (m *Monitor) ReachabilityHosts() []string { return m.reachabilityHosts }
|
||||
|
||||
// PacketLossHosts returns the configured packet-loss hosts.
|
||||
func (m *Monitor) PacketLossHosts() []string { return m.packetLossHosts }
|
||||
|
||||
// TCPHosts returns the configured TCP hosts.
|
||||
func (m *Monitor) TCPHosts() []string { return m.tcpHosts }
|
||||
|
||||
// InterfaceA returns the first monitored interface.
|
||||
func (m *Monitor) InterfaceA() *InterfaceStatus { return m.interfaceA }
|
||||
|
||||
// InterfaceB returns the second monitored interface.
|
||||
func (m *Monitor) InterfaceB() *InterfaceStatus { return m.interfaceB }
|
||||
+264
-223
@@ -1,278 +1,319 @@
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
crand "crypto/rand"
|
||||
"math"
|
||||
"math/rand"
|
||||
"math/big"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// UIUpdateChan is used to signal UI updates
|
||||
var UIUpdateChan = make(chan struct{}, 100)
|
||||
// Probe scheduling and change-detection thresholds.
|
||||
const (
|
||||
jitterBaseMillis = 100
|
||||
jitterRangeMillis = 800
|
||||
lossChangeThreshold = 0.01
|
||||
tcpChangeThreshold = 20 // milliseconds
|
||||
)
|
||||
|
||||
// reachLoop monitors reachability for hosts
|
||||
// randomOffset returns a startup jitter to avoid clustering probes at the
|
||||
// same instant across loops. crypto/rand is used so no weak PRNG is linked.
|
||||
func randomOffset() time.Duration {
|
||||
n, err := crand.Int(crand.Reader, big.NewInt(jitterRangeMillis))
|
||||
if err != nil {
|
||||
return jitterBaseMillis * time.Millisecond
|
||||
}
|
||||
|
||||
return time.Duration(jitterBaseMillis+n.Int64()) * time.Millisecond
|
||||
}
|
||||
|
||||
// probeAll runs probe for each host concurrently and returns the results
|
||||
// keyed by host.
|
||||
func probeAll[T any](hosts []string, probe func(host string) T) map[string]T {
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
mu sync.Mutex
|
||||
res = make(map[string]T, len(hosts))
|
||||
)
|
||||
|
||||
for _, h := range hosts {
|
||||
wg.Add(1)
|
||||
|
||||
go func(host string) {
|
||||
defer wg.Done()
|
||||
|
||||
v := probe(host)
|
||||
|
||||
mu.Lock()
|
||||
res[host] = v
|
||||
mu.Unlock()
|
||||
}(h)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// reachLoop monitors reachability for hosts on one interface.
|
||||
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)
|
||||
m.logf("Starting reachability monitoring for %s with %d hosts",
|
||||
st.Name, len(hosts))
|
||||
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:
|
||||
}
|
||||
}
|
||||
m.reachTick(ctx, st, hosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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))
|
||||
// reachTick pings every host concurrently and applies the results.
|
||||
func (m *Monitor) reachTick(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
||||
res := probeAll(hosts, func(host string) bool {
|
||||
return m.reachProbe(ctx, st, host)
|
||||
})
|
||||
m.reachApply(st, res)
|
||||
}
|
||||
|
||||
// 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)
|
||||
// reachProbe pings a single host and updates per-host counters.
|
||||
func (m *Monitor) reachProbe(
|
||||
ctx context.Context, st *InterfaceStatus, host string,
|
||||
) bool {
|
||||
st.mu.Lock()
|
||||
st.TotalICMPReq++
|
||||
st.MeterValue = min(st.MeterValue+1, m.MaxMeterValue)
|
||||
st.mu.Unlock()
|
||||
|
||||
ok := m.pingOnce(ctx, st.Name, host)
|
||||
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
st.TotalICMPRep++
|
||||
st.MeterValue = max(st.MeterValue-1, 0)
|
||||
st.Spin()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
st.DroppedCount++
|
||||
st.LastDrop = time.Now()
|
||||
st.LostPackets[host]++
|
||||
|
||||
m.notifyUI()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// reachApply stores the round's results and decays the meter when clean.
|
||||
func (m *Monitor) reachApply(st *InterfaceStatus, res map[string]bool) {
|
||||
st.mu.Lock()
|
||||
changed := reachChanged(st.Reachable, res)
|
||||
st.Reachable = res
|
||||
st.LastPing = time.Now()
|
||||
|
||||
if allReachable(res) && st.MeterValue > 0 {
|
||||
st.MeterValue--
|
||||
}
|
||||
st.mu.Unlock()
|
||||
|
||||
if changed {
|
||||
m.notifyUI()
|
||||
}
|
||||
}
|
||||
|
||||
// reachChanged reports whether any host's reachability differs from before.
|
||||
func reachChanged(old, cur map[string]bool) bool {
|
||||
for host, newStatus := range cur {
|
||||
if oldStatus, ok := old[host]; !ok || oldStatus != newStatus {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// allReachable reports whether every host in the map is reachable.
|
||||
func allReachable(res map[string]bool) bool {
|
||||
for _, ok := range res {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// lossLoop monitors packet loss for hosts on one interface.
|
||||
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))
|
||||
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:
|
||||
}
|
||||
}
|
||||
m.lossTick(ctx, st, hosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tcpLoop monitors TCP connectivity for hosts
|
||||
// lossTick measures loss for every host concurrently and applies results.
|
||||
func (m *Monitor) lossTick(ctx context.Context, st *InterfaceStatus, hosts []string) {
|
||||
res := probeAll(hosts, func(host string) float64 {
|
||||
return m.lossProbe(ctx, st, host)
|
||||
})
|
||||
m.lossApply(st, res)
|
||||
}
|
||||
|
||||
// lossProbe measures loss for a host and updates per-host counters.
|
||||
func (m *Monitor) lossProbe(
|
||||
ctx context.Context, st *InterfaceStatus, host string,
|
||||
) float64 {
|
||||
lp := m.lossPercent(ctx, st.Name, host)
|
||||
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
|
||||
if lp == 0 {
|
||||
st.Spin()
|
||||
|
||||
return lp
|
||||
}
|
||||
|
||||
lostPackets := int(math.Ceil(float64(m.PacketLossPings) * lp))
|
||||
st.DroppedCount += lostPackets
|
||||
|
||||
if lostPackets > 0 {
|
||||
st.LastDrop = time.Now()
|
||||
}
|
||||
|
||||
st.LostPackets[host] += lostPackets
|
||||
|
||||
m.notifyUI()
|
||||
|
||||
return lp
|
||||
}
|
||||
|
||||
// lossApply stores the round's loss results.
|
||||
func (m *Monitor) lossApply(st *InterfaceStatus, res map[string]float64) {
|
||||
st.mu.Lock()
|
||||
changed := lossChanged(st.Loss, res)
|
||||
|
||||
for k, v := range res {
|
||||
st.Loss[k] = v
|
||||
}
|
||||
st.mu.Unlock()
|
||||
|
||||
if changed {
|
||||
m.notifyUI()
|
||||
}
|
||||
}
|
||||
|
||||
// lossChanged reports whether any host's loss moved beyond the threshold.
|
||||
func lossChanged(old, cur map[string]float64) bool {
|
||||
for host, newLoss := range cur {
|
||||
if oldLoss, ok := old[host]; !ok || math.Abs(oldLoss-newLoss) > lossChangeThreshold {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// tcpLoop monitors TCP connectivity for hosts on one interface.
|
||||
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)
|
||||
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:
|
||||
}
|
||||
}
|
||||
m.tcpTick(st, hosts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tcpTick measures TCP latency for each host and redraws on change.
|
||||
func (m *Monitor) tcpTick(st *InterfaceStatus, hosts []string) {
|
||||
changed := false
|
||||
|
||||
for _, hp := range hosts {
|
||||
if m.tcpProbe(st, hp) {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if changed {
|
||||
m.notifyUI()
|
||||
}
|
||||
}
|
||||
|
||||
// tcpProbe measures one host's latency, records it, and reports whether the
|
||||
// latency changed significantly.
|
||||
func (m *Monitor) tcpProbe(st *InterfaceStatus, hp string) bool {
|
||||
ms := float64(m.tcpDuration(st.Name, hp).Milliseconds())
|
||||
|
||||
st.mu.Lock()
|
||||
defer st.mu.Unlock()
|
||||
|
||||
hist := st.TCP[hp]
|
||||
changed := tcpSignificant(hist, ms)
|
||||
|
||||
if ms < float64(m.TCPTimeout.Milliseconds()) {
|
||||
st.Spin()
|
||||
} else {
|
||||
m.notifyUI()
|
||||
}
|
||||
|
||||
if len(hist) >= m.StatsHistory {
|
||||
hist = hist[1:]
|
||||
}
|
||||
|
||||
st.TCP[hp] = append(hist, ms)
|
||||
|
||||
host := strings.Split(hp, ":")[0]
|
||||
if ms >= float64(m.TCPTimeout.Milliseconds()) {
|
||||
st.LostPackets[host]++
|
||||
}
|
||||
|
||||
return changed
|
||||
}
|
||||
|
||||
// tcpSignificant reports whether ms differs meaningfully from the last
|
||||
// sample (or there is no prior sample).
|
||||
func tcpSignificant(hist []float64, ms float64) bool {
|
||||
if len(hist) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
return math.Abs(hist[len(hist)-1]-ms) > tcpChangeThreshold
|
||||
}
|
||||
|
||||
+200
-120
@@ -1,11 +1,14 @@
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
// Package monitor implements the dual-interface real-time network
|
||||
// monitoring dashboard: ICMP reachability, packet loss, TCP latency, and
|
||||
// the terminal UI that renders them.
|
||||
package monitor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
@@ -19,16 +22,48 @@ import (
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// Non-configurable constants (meter characters)
|
||||
// Meter glyphs used to render the ASCII loss meter.
|
||||
const (
|
||||
// ASCII characters for the meter
|
||||
MeterStart = '['
|
||||
MeterEnd = ']'
|
||||
MeterFill = '='
|
||||
MeterEmpty = ' '
|
||||
)
|
||||
|
||||
// Monitor represents the network monitoring system
|
||||
// Default monitor timing configuration.
|
||||
const (
|
||||
defaultICMPTimeout = 500 * time.Millisecond
|
||||
defaultTCPTimeout = 500 * time.Millisecond
|
||||
defaultPacketLossPings = 20
|
||||
defaultPacketLossPeriod = 5 * time.Second
|
||||
defaultStatsHistory = 300
|
||||
defaultScreenRefresh = 500 * time.Millisecond
|
||||
)
|
||||
|
||||
// Default display geometry (column widths and meter sizing).
|
||||
const (
|
||||
defaultMeterWidth = 7
|
||||
defaultMeterFillWidth = 5
|
||||
defaultMaxMeterValue = 10
|
||||
defaultHostWidth = 30
|
||||
defaultNumWidth = 7
|
||||
defaultStdWidth = 8
|
||||
defaultNWidth = 6
|
||||
defaultLostWidth = 7
|
||||
)
|
||||
|
||||
// Miscellaneous timing constants.
|
||||
const (
|
||||
ipInfoTimeout = 2 * time.Second
|
||||
lossQueryTimeout = 3 * time.Second
|
||||
uiUpdateBuffer = 100
|
||||
logFileMode = 0o644
|
||||
)
|
||||
|
||||
// errNoIPv4 is returned when an interface has no usable IPv4 address.
|
||||
var errNoIPv4 = errors.New("no IPv4 address on interface")
|
||||
|
||||
// Monitor represents the network monitoring system.
|
||||
type Monitor struct {
|
||||
// Configuration
|
||||
ICMPTimeout time.Duration
|
||||
@@ -63,95 +98,95 @@ type Monitor struct {
|
||||
// Runtime state
|
||||
screen tcell.Screen
|
||||
startTime time.Time
|
||||
uiUpdate chan struct{}
|
||||
|
||||
// Synchronization
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewMonitor creates a new Monitor instance with default settings
|
||||
// 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,
|
||||
ICMPTimeout: defaultICMPTimeout,
|
||||
TCPTimeout: defaultTCPTimeout,
|
||||
PacketLossPings: defaultPacketLossPings,
|
||||
PacketLossPeriod: defaultPacketLossPeriod,
|
||||
StatsHistory: defaultStatsHistory,
|
||||
ScreenRefresh: defaultScreenRefresh,
|
||||
|
||||
// Default display configuration
|
||||
MeterWidth: 7,
|
||||
MeterFillWidth: 5,
|
||||
MaxMeterValue: 10,
|
||||
HostWidth: 30,
|
||||
NumWidth: 7,
|
||||
StdWidth: 8,
|
||||
NWidth: 6,
|
||||
LostWidth: 7,
|
||||
MeterWidth: defaultMeterWidth,
|
||||
MeterFillWidth: defaultMeterFillWidth,
|
||||
MaxMeterValue: defaultMaxMeterValue,
|
||||
HostWidth: defaultHostWidth,
|
||||
NumWidth: defaultNumWidth,
|
||||
StdWidth: defaultStdWidth,
|
||||
NWidth: defaultNWidth,
|
||||
LostWidth: defaultLostWidth,
|
||||
|
||||
// Initialize host lists
|
||||
reachabilityHosts: []string{},
|
||||
packetLossHosts: []string{},
|
||||
tcpHosts: []string{},
|
||||
|
||||
// Initialize interfaces
|
||||
interfaceA: NewInterfaceStatus(ifaceA, labelA),
|
||||
interfaceB: NewInterfaceStatus(ifaceB, labelB),
|
||||
|
||||
// Logging
|
||||
logFile: logFile,
|
||||
|
||||
logFile: logFile,
|
||||
startTime: time.Now(),
|
||||
uiUpdate: make(chan struct{}, uiUpdateBuffer),
|
||||
}
|
||||
}
|
||||
|
||||
// AddReachabilityHost adds a host for reachability monitoring
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// 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 {
|
||||
|
||||
err = scr.Init()
|
||||
if 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...)
|
||||
@@ -166,7 +201,6 @@ func (m *Monitor) Run(ctx context.Context) error {
|
||||
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")
|
||||
@@ -174,36 +208,49 @@ func (m *Monitor) Run(ctx context.Context) error {
|
||||
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:
|
||||
}
|
||||
}
|
||||
// notifyUI requests a screen redraw without blocking if one is pending.
|
||||
func (m *Monitor) notifyUI() {
|
||||
select {
|
||||
case m.uiUpdate <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// logf logs a formatted message
|
||||
func (m *Monitor) logf(format string, v ...interface{}) {
|
||||
// keyboardEventLoop handles keyboard input. The context is accepted for a
|
||||
// uniform loop signature; shutdown is driven by cancel on the quit key and
|
||||
// by the screen being finalized (PollEvent then returns nil).
|
||||
func (m *Monitor) keyboardEventLoop(_ context.Context, cancel context.CancelFunc) {
|
||||
m.logf("Starting keyboard event loop")
|
||||
|
||||
for {
|
||||
ev := m.screen.PollEvent()
|
||||
if ev == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
m.notifyUI()
|
||||
}
|
||||
}
|
||||
|
||||
// logf logs a formatted message.
|
||||
func (m *Monitor) logf(format string, v ...any) {
|
||||
Logf(m.logFile, format, v...)
|
||||
}
|
||||
|
||||
// InterfaceStatus holds the runtime status for a network interface
|
||||
// InterfaceStatus holds the runtime status for a network interface.
|
||||
type InterfaceStatus struct {
|
||||
Name, Label, IPInfo string
|
||||
Reachable map[string]bool
|
||||
@@ -218,7 +265,7 @@ type InterfaceStatus struct {
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewInterfaceStatus creates a new interface status
|
||||
// NewInterfaceStatus creates a new interface status.
|
||||
func NewInterfaceStatus(name, label string) *InterfaceStatus {
|
||||
return &InterfaceStatus{
|
||||
Name: name,
|
||||
@@ -232,137 +279,164 @@ func NewInterfaceStatus(name, label string) *InterfaceStatus {
|
||||
}
|
||||
}
|
||||
|
||||
// ipInfoResp holds the IP info response
|
||||
// ipInfoResp holds the ipinfo.io response.
|
||||
type ipInfoResp struct {
|
||||
IP string `json:"ip"`
|
||||
Hostname string `json:"hostname"`
|
||||
Org string `json:"org"`
|
||||
}
|
||||
|
||||
// fetchIPInfo fetches IP information for an interface
|
||||
// fetchIPInfo fetches public IP information as seen from an interface.
|
||||
func fetchIPInfo(iface string) string {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), ipInfoTimeout)
|
||||
defer cancel()
|
||||
out, _ := exec.CommandContext(ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io").Output()
|
||||
|
||||
cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input
|
||||
ctx, "curl", "-s", "--interface", iface, "--max-time", "2", "ipinfo.io")
|
||||
|
||||
out, _ := cmd.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)
|
||||
// pingOnce performs a single ping over the named interface.
|
||||
func (m *Monitor) pingOnce(ctx context.Context, iface, host string) bool {
|
||||
ctx, cancel := context.WithTimeout(ctx, m.ICMPTimeout)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, "ping", "-I", iface, "-c1", "-W1", host).Run() == nil
|
||||
|
||||
cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input
|
||||
ctx, "ping", "-I", iface, "-c1", "-W1", host)
|
||||
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
// lossPercent calculates packet loss percentage
|
||||
func (m *Monitor) lossPercent(iface, host string) float64 {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
// lossPercent measures the packet loss fraction (0..1) for a host.
|
||||
func (m *Monitor) lossPercent(ctx context.Context, iface, host string) float64 {
|
||||
ctx, cancel := context.WithTimeout(ctx, lossQueryTimeout)
|
||||
defer cancel()
|
||||
out, err := exec.CommandContext(ctx, "ping", "-q", "-i", "0.05",
|
||||
"-c", fmt.Sprint(m.PacketLossPings), "-W1", "-I", iface, host).CombinedOutput()
|
||||
|
||||
cmd := exec.CommandContext( //nolint:gosec // G204: fixed argv, operator CLI input
|
||||
ctx, "ping", "-q", "-i", "0.05",
|
||||
"-c", strconv.Itoa(m.PacketLossPings), "-W1", "-I", iface, host)
|
||||
|
||||
out, err := cmd.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
|
||||
}
|
||||
if !strings.Contains(ln, "packet loss") {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, f := range strings.Fields(ln) {
|
||||
before, ok := strings.CutSuffix(f, "%")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
p, _ := strconv.ParseFloat(before, 64)
|
||||
|
||||
return p / percentFull
|
||||
}
|
||||
}
|
||||
|
||||
return 1.0
|
||||
}
|
||||
|
||||
// localAddr gets the local address for an interface
|
||||
// localAddr returns the first IPv4 address bound to an interface.
|
||||
func localAddr(iface string) (net.Addr, error) {
|
||||
ifi, err := net.InterfaceByName(iface)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("interface %s: %w", iface, 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)
|
||||
|
||||
return nil, fmt.Errorf("%w: %s", errNoIPv4, iface)
|
||||
}
|
||||
|
||||
// tcpDuration measures TCP connection duration
|
||||
// tcpDuration measures how long a TCP connection to hp takes over iface.
|
||||
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()
|
||||
|
||||
_ = c.Close()
|
||||
|
||||
return time.Since(st)
|
||||
}
|
||||
|
||||
// MinMaxAvgStd calculates min, max, average and standard deviation
|
||||
func MinMaxAvgStd(xs []float64) (min, max, avg, std float64) {
|
||||
// MinMaxAvgStd returns the minimum, maximum, mean and standard deviation.
|
||||
func MinMaxAvgStd(xs []float64) (float64, float64, float64, float64) {
|
||||
if len(xs) == 0 {
|
||||
return
|
||||
return 0, 0, 0, 0
|
||||
}
|
||||
min, max = xs[0], xs[0]
|
||||
|
||||
mn, mx := xs[0], xs[0]
|
||||
|
||||
var sum float64
|
||||
|
||||
for _, v := range xs {
|
||||
if v < min {
|
||||
min = v
|
||||
}
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
mn = min(mn, v)
|
||||
mx = max(mx, v)
|
||||
sum += v
|
||||
}
|
||||
avg = sum / float64(len(xs))
|
||||
var vs float64
|
||||
|
||||
avg := sum / float64(len(xs))
|
||||
|
||||
var variance float64
|
||||
|
||||
for _, v := range xs {
|
||||
d := v - avg
|
||||
vs += d * d
|
||||
variance += (v - avg) * (v - avg)
|
||||
}
|
||||
std = math.Sqrt(vs / float64(len(xs)))
|
||||
return
|
||||
|
||||
return mn, mx, avg, math.Sqrt(variance / float64(len(xs)))
|
||||
}
|
||||
|
||||
// Spin advances the spinner frame
|
||||
// Spin advances the spinner frame.
|
||||
func (st *InterfaceStatus) Spin() {
|
||||
st.SpinFrame = (st.SpinFrame + 1) % len(Spins)
|
||||
st.SpinFrame = (st.SpinFrame + 1) % len(spins())
|
||||
}
|
||||
|
||||
// IsHealthy checks if the interface is healthy
|
||||
// IsHealthy reports whether the interface currently looks 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
|
||||
@@ -372,23 +446,29 @@ func (st *InterfaceStatus) IsHealthy(tcpTimeout time.Duration) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Spinners and braille characters
|
||||
var (
|
||||
Spins = []rune{'|', '/', '-', '\\'}
|
||||
BrailleSpins = []rune{
|
||||
'⠉', '⠘', '⠰', '⠠', '⠄', '⠆', '⠇', '⠋',
|
||||
}
|
||||
)
|
||||
// spins returns the ASCII spinner frames.
|
||||
func spins() []rune {
|
||||
return []rune{'|', '/', '-', '\\'}
|
||||
}
|
||||
|
||||
// Logf is a simple logging function
|
||||
func Logf(logFile string, format string, v ...interface{}) {
|
||||
// brailleSpins returns the braille clock spinner frames.
|
||||
func brailleSpins() []rune {
|
||||
return []rune{'⠉', '⠘', '⠰', '⠠', '⠄', '⠆', '⠇', '⠋'}
|
||||
}
|
||||
|
||||
// Logf appends a timestamped formatted message to logFile, if set.
|
||||
func Logf(logFile, format string, v ...any) {
|
||||
if logFile == "" {
|
||||
return
|
||||
}
|
||||
f, err := os.OpenFile(logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
|
||||
f, err := os.OpenFile( //nolint:gosec // G304: operator --logfile path
|
||||
logFile, os.O_APPEND|os.O_CREATE|os.O_WRONLY, logFileMode)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
fmt.Fprintf(f, time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...)
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
_, _ = fmt.Fprintf(f,
|
||||
time.Now().Format("2006-01-02 15:04:05.000 ")+format+"\n", v...)
|
||||
}
|
||||
|
||||
@@ -1,125 +1,110 @@
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package monitor
|
||||
package monitor_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.eeqj.de/sneak/rtnetmon/internal/monitor"
|
||||
)
|
||||
|
||||
// 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")
|
||||
t.Parallel()
|
||||
|
||||
if mon == nil {
|
||||
t.Fatal("NewMonitor returned nil")
|
||||
}
|
||||
mon := monitor.NewMonitor("test0", "Test Interface A", "test1",
|
||||
"Test Interface B", "/tmp/test.log")
|
||||
|
||||
// Check interfaces
|
||||
if mon.interfaceA == nil {
|
||||
if mon.InterfaceA() == nil {
|
||||
t.Fatal("interfaceA is nil")
|
||||
}
|
||||
|
||||
if mon.interfaceB == 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.InterfaceA().Name != "test0" {
|
||||
t.Errorf("interfaceA.Name = %q, want %q", mon.InterfaceA().Name, "test0")
|
||||
}
|
||||
|
||||
if mon.interfaceB.Name != "test1" {
|
||||
t.Errorf("Expected interfaceB.Name to be 'test1', got '%s'", mon.interfaceB.Name)
|
||||
if mon.InterfaceB().Name != "test1" {
|
||||
t.Errorf("interfaceB.Name = %q, want %q", mon.InterfaceB().Name, "test1")
|
||||
}
|
||||
|
||||
// Check default configuration
|
||||
if mon.ICMPTimeout.Milliseconds() != 500 {
|
||||
t.Errorf("Expected ICMPTimeout to be 500ms, got %dms", mon.ICMPTimeout.Milliseconds())
|
||||
t.Errorf("ICMPTimeout = %dms, want 500ms", mon.ICMPTimeout.Milliseconds())
|
||||
}
|
||||
|
||||
if mon.PacketLossPings != 20 {
|
||||
t.Errorf("Expected PacketLossPings to be 20, got %d", mon.PacketLossPings)
|
||||
t.Errorf("PacketLossPings = %d, want 20", mon.PacketLossPings)
|
||||
}
|
||||
|
||||
// Check that host lists are initialized
|
||||
if mon.reachabilityHosts == nil {
|
||||
if mon.ReachabilityHosts() == nil {
|
||||
t.Error("reachabilityHosts is nil")
|
||||
}
|
||||
|
||||
if mon.packetLossHosts == nil {
|
||||
if mon.PacketLossHosts() == nil {
|
||||
t.Error("packetLossHosts is nil")
|
||||
}
|
||||
|
||||
if mon.tcpHosts == 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", "")
|
||||
t.Parallel()
|
||||
|
||||
mon := monitor.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))
|
||||
if len(mon.ReachabilityHosts()) != 2 {
|
||||
t.Errorf("reachability hosts = %d, want 2", 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))
|
||||
if len(mon.PacketLossHosts()) != 1 {
|
||||
t.Errorf("packet loss hosts = %d, want 1", 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))
|
||||
if len(mon.TCPHosts()) != 2 {
|
||||
t.Errorf("tcp hosts = %d, want 2", len(mon.TCPHosts()))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMinMaxAvgStd tests the statistics calculation function
|
||||
func TestMinMaxAvgStd(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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,
|
||||
},
|
||||
{"empty slice", []float64{}, 0, 0, 0},
|
||||
{"single value", []float64{5.0}, 5.0, 5.0, 5.0},
|
||||
{"multiple values", []float64{1.0, 2.0, 3.0, 4.0, 5.0}, 1.0, 5.0, 3.0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
min, max, avg, _ := MinMaxAvgStd(tt.data)
|
||||
t.Parallel()
|
||||
|
||||
if min != tt.wantMin {
|
||||
t.Errorf("MinMaxAvgStd() min = %v, want %v", min, tt.wantMin)
|
||||
mn, mx, avg, _ := monitor.MinMaxAvgStd(tt.data)
|
||||
if mn != tt.wantMin {
|
||||
t.Errorf("min = %v, want %v", mn, tt.wantMin)
|
||||
}
|
||||
if max != tt.wantMax {
|
||||
t.Errorf("MinMaxAvgStd() max = %v, want %v", max, tt.wantMax)
|
||||
|
||||
if mx != tt.wantMax {
|
||||
t.Errorf("max = %v, want %v", mx, tt.wantMax)
|
||||
}
|
||||
|
||||
if avg != tt.wantAvg {
|
||||
t.Errorf("MinMaxAvgStd() avg = %v, want %v", avg, tt.wantAvg)
|
||||
t.Errorf("avg = %v, want %v", avg, tt.wantAvg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+82
-51
@@ -1,23 +1,62 @@
|
||||
//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
|
||||
// percentFull is the top of the 0..100 percentage scale.
|
||||
const percentFull = 100.0
|
||||
|
||||
// Rainbow colors for the spinner
|
||||
CRainbow = []tcell.Style{
|
||||
// Meter fill thresholds, expressed as percent-good (100 = best, 0 = worst).
|
||||
const (
|
||||
meterExcellent = 90
|
||||
meterGood = 75
|
||||
meterFair = 60
|
||||
meterMediocre = 40
|
||||
meterPoor = 20
|
||||
)
|
||||
|
||||
// TCP latency thresholds, in milliseconds.
|
||||
const (
|
||||
latencyGood = 50
|
||||
latencyModerate = 100
|
||||
latencyHigh = 200
|
||||
)
|
||||
|
||||
// lossWarnPercent is the packet-loss level above which the display warns.
|
||||
const lossWarnPercent = 5
|
||||
|
||||
func styleBrightGreen() tcell.Style {
|
||||
return tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true)
|
||||
}
|
||||
|
||||
func styleGreen() tcell.Style {
|
||||
return tcell.StyleDefault.Foreground(tcell.ColorGreen)
|
||||
}
|
||||
|
||||
func styleDimGreen() tcell.Style {
|
||||
return tcell.StyleDefault.Foreground(tcell.ColorGreen).Dim(true)
|
||||
}
|
||||
|
||||
func styleYellow() tcell.Style {
|
||||
return tcell.StyleDefault.Foreground(tcell.ColorYellow)
|
||||
}
|
||||
|
||||
func styleOrange() tcell.Style {
|
||||
return tcell.StyleDefault.Foreground(tcell.ColorOrange)
|
||||
}
|
||||
|
||||
func styleRed() tcell.Style {
|
||||
return tcell.StyleDefault.Foreground(tcell.ColorRed)
|
||||
}
|
||||
|
||||
func styleBrightRed() tcell.Style {
|
||||
return tcell.StyleDefault.Foreground(tcell.ColorRed).Bold(true)
|
||||
}
|
||||
|
||||
// rainbow returns the color cycle used for spinners and the clock.
|
||||
func rainbow() []tcell.Style {
|
||||
return []tcell.Style{
|
||||
tcell.StyleDefault.Foreground(tcell.ColorRed),
|
||||
tcell.StyleDefault.Foreground(tcell.ColorOrange),
|
||||
tcell.StyleDefault.Foreground(tcell.ColorYellow),
|
||||
@@ -25,63 +64,55 @@ var (
|
||||
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)
|
||||
// MeterColorForValue returns the color style for a meter value. When reverse
|
||||
// is true, low values are good (green) and high values bad (red); otherwise
|
||||
// high values are good.
|
||||
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
|
||||
percentage := float64(value) / float64(maxValue) * percentFull
|
||||
if reverse {
|
||||
percentage = 100 - percentage
|
||||
percentage = percentFull - 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
|
||||
case percentage >= meterExcellent:
|
||||
return styleBrightGreen()
|
||||
case percentage >= meterGood:
|
||||
return styleGreen()
|
||||
case percentage >= meterFair:
|
||||
return styleDimGreen()
|
||||
case percentage >= meterMediocre:
|
||||
return styleYellow()
|
||||
case percentage >= meterPoor:
|
||||
return styleOrange()
|
||||
default:
|
||||
return CRed
|
||||
return styleRed()
|
||||
}
|
||||
}
|
||||
|
||||
// StyleLatency returns the appropriate style for latency values
|
||||
// StyleLatency returns the style for a TCP latency in milliseconds.
|
||||
func StyleLatency(ms float64) tcell.Style {
|
||||
switch {
|
||||
case ms < 50:
|
||||
return CBrightGreen
|
||||
case ms < 100:
|
||||
return CGreen
|
||||
case ms < 200:
|
||||
return CYellow
|
||||
case ms < latencyGood:
|
||||
return styleBrightGreen()
|
||||
case ms < latencyModerate:
|
||||
return styleGreen()
|
||||
case ms < latencyHigh:
|
||||
return styleYellow()
|
||||
default:
|
||||
return CRed
|
||||
return styleRed()
|
||||
}
|
||||
}
|
||||
|
||||
// StyleLoss returns the appropriate style for packet loss percentages
|
||||
// StyleLoss returns the style for a packet-loss percentage.
|
||||
func StyleLoss(p float64) tcell.Style {
|
||||
switch {
|
||||
case p == 0:
|
||||
return CBrightGreen
|
||||
case p < 5:
|
||||
return CYellow
|
||||
return styleBrightGreen()
|
||||
case p < lossWarnPercent:
|
||||
return styleYellow()
|
||||
default:
|
||||
return CBrightRed
|
||||
return styleBrightRed()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +1,48 @@
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package monitor
|
||||
package monitor_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
|
||||
"git.eeqj.de/sneak/rtnetmon/internal/monitor"
|
||||
)
|
||||
|
||||
// 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,
|
||||
},
|
||||
t.Parallel()
|
||||
|
||||
// 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,
|
||||
},
|
||||
green := tcell.StyleDefault.Foreground(tcell.ColorGreen).Bold(true)
|
||||
yellow := tcell.StyleDefault.Foreground(tcell.ColorYellow)
|
||||
red := tcell.StyleDefault.Foreground(tcell.ColorRed)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value, maxValue int
|
||||
reverse bool
|
||||
want tcell.Style
|
||||
}{
|
||||
{"reverse 0% best", 0, 10, true, green},
|
||||
{"reverse 10% good", 1, 10, true, green},
|
||||
{"reverse 50% medium", 5, 10, true, yellow},
|
||||
{"reverse 90% bad", 9, 10, true, red},
|
||||
{"reverse 100% worst", 10, 10, true, red},
|
||||
{"normal 0% worst", 0, 10, false, red},
|
||||
{"normal 10% bad", 1, 10, false, red},
|
||||
{"normal 50% medium", 5, 10, false, yellow},
|
||||
{"normal 90% good", 9, 10, false, green},
|
||||
{"normal 100% best", 10, 10, false, green},
|
||||
}
|
||||
|
||||
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.Parallel()
|
||||
|
||||
got := monitor.MeterColorForValue(tt.value, tt.maxValue, tt.reverse)
|
||||
if got != tt.want {
|
||||
t.Errorf("MeterColorForValue(%d, %d, %v) = %v, want %v",
|
||||
tt.value, tt.maxValue, tt.reverse, got, tt.wantColor)
|
||||
tt.value, tt.maxValue, tt.reverse, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+230
-198
@@ -1,5 +1,4 @@
|
||||
//go:build linux
|
||||
// +build linux
|
||||
|
||||
package monitor
|
||||
|
||||
@@ -13,283 +12,331 @@ import (
|
||||
tcell "github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// Frame counter for the timestamp spinner (ticks once per second)
|
||||
var (
|
||||
timestampSpinFrame = 0
|
||||
lastTimestampSpinUpdate = time.Now()
|
||||
// Fixed rows at the top of the display.
|
||||
const (
|
||||
rowTopRule = 0
|
||||
rowClock = 1
|
||||
rowBottomRule = 2
|
||||
rowRuntime = 3
|
||||
rowFirstIface = 5
|
||||
)
|
||||
|
||||
// Put writes text to the screen at the specified position with style
|
||||
// Column offsets and spacing within the drawn output.
|
||||
const (
|
||||
colLeft = 0
|
||||
colSpinner = 3 // after the "== " prefix
|
||||
colMeter = 5 // after the spinner
|
||||
colGap = 1 // single-space gap between fields
|
||||
clockGap = 5 // space between the two clocks
|
||||
lineStep = 1
|
||||
blockGap = 2 // blank line plus the following line
|
||||
headerAdvance = 4 // rule + content + rule + blank line
|
||||
)
|
||||
|
||||
// ICMP summary table geometry.
|
||||
const (
|
||||
icmpLabelWidth = 8
|
||||
icmpValWidth = 9
|
||||
icmpColGap = 4
|
||||
)
|
||||
|
||||
// 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
|
||||
// HLine returns a horizontal line of the specified width.
|
||||
func HLine(w int) string { return strings.Repeat("=", w) }
|
||||
|
||||
// DrawRainbowText draws text with rainbow colors
|
||||
// DrawRainbowText draws text cycling through the given color styles.
|
||||
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
|
||||
style := colors[i%len(colors)]
|
||||
scr.SetContent(x+i, y, char, nil, style)
|
||||
}
|
||||
}
|
||||
|
||||
// CreateMeter creates a visual meter using ASCII characters
|
||||
// msStr formats a millisecond value as a compact "%.0fms" string.
|
||||
func msStr(v float64) string {
|
||||
return fmt.Sprintf("%.0fms", v)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
value = max(min(value, m.MaxMeterValue), 0)
|
||||
|
||||
// Calculate the number of fill characters to show
|
||||
fillCount := value * m.MeterFillWidth / m.MaxMeterValue
|
||||
if fillCount > m.MeterFillWidth {
|
||||
fillCount = m.MeterFillWidth
|
||||
}
|
||||
fillCount := min(value*m.MeterFillWidth/m.MaxMeterValue, m.MeterFillWidth)
|
||||
|
||||
// Build the meter string
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteRune(MeterStart)
|
||||
|
||||
// Add fill characters
|
||||
for i := 0; i < fillCount; i++ {
|
||||
for range fillCount {
|
||||
b.WriteRune(MeterFill)
|
||||
}
|
||||
|
||||
// Add empty spaces
|
||||
for i := 0; i < m.MeterFillWidth-fillCount; i++ {
|
||||
for range m.MeterFillWidth - fillCount {
|
||||
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
|
||||
// reverse=true: 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
|
||||
// DrawInterface draws the interface status on the screen, returning the next
|
||||
// free row.
|
||||
func (m *Monitor) DrawInterface(scr tcell.Screen, y, w int, st *InterfaceStatus) int {
|
||||
Put(scr, 0, y, HLine(w), CDefault)
|
||||
Put(scr, colLeft, y, HLine(w), tcell.StyleDefault)
|
||||
|
||||
// header line
|
||||
healthy := st.IsHealthy(m.TCPTimeout)
|
||||
style := CBrightGreen
|
||||
y = m.drawHeader(scr, y, w, st, healthy)
|
||||
|
||||
m.mu.RLock()
|
||||
tcpHosts := append([]string{}, m.tcpHosts...)
|
||||
m.mu.RUnlock()
|
||||
|
||||
st.mu.RLock()
|
||||
defer st.mu.RUnlock()
|
||||
|
||||
y = drawReachability(scr, y, st)
|
||||
y = drawPacketLoss(scr, y, st)
|
||||
y = m.drawTCPTable(scr, y, st, tcpHosts)
|
||||
y = drawICMPStats(scr, y, st)
|
||||
|
||||
return y
|
||||
}
|
||||
|
||||
// drawHeader renders the interface title, spinner and meter line.
|
||||
func (m *Monitor) drawHeader(
|
||||
scr tcell.Screen, y, w int, st *InterfaceStatus, healthy bool,
|
||||
) int {
|
||||
style := styleBrightGreen()
|
||||
if !healthy {
|
||||
style = CBrightRed
|
||||
style = styleBrightRed()
|
||||
}
|
||||
|
||||
st.mu.RLock()
|
||||
header := fmt.Sprintf("%s: %s — %s", st.Name, st.Label, st.IPInfo)
|
||||
spinChar := Spins[st.SpinFrame]
|
||||
spinChar := spins()[st.SpinFrame]
|
||||
spinnerFrame := st.SpinFrame
|
||||
meterValue := st.MeterValue
|
||||
st.mu.RUnlock()
|
||||
|
||||
// Create the meter with appropriate style
|
||||
meter, meterStyle := m.CreateMeter(meterValue)
|
||||
colors := rainbow()
|
||||
spinnerStyle := colors[spinnerFrame%len(colors)]
|
||||
|
||||
// Get rainbow color for spinner (cycle through colors)
|
||||
spinnerStyle := CRainbow[spinnerFrame%len(CRainbow)]
|
||||
Put(scr, colLeft, y+lineStep, "== ", tcell.StyleDefault)
|
||||
Put(scr, colSpinner, y+lineStep, string(spinChar)+" ", spinnerStyle)
|
||||
Put(scr, colMeter, y+lineStep, meter+" ", meterStyle)
|
||||
Put(scr, colMeter+m.MeterWidth+colGap, y+lineStep, header, style)
|
||||
Put(scr, colLeft, y+blockGap, HLine(w), tcell.StyleDefault)
|
||||
|
||||
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
|
||||
return y + headerAdvance
|
||||
}
|
||||
|
||||
/* reachability */
|
||||
st.mu.RLock()
|
||||
// drawReachability renders the reachability summary. The caller holds
|
||||
// st.mu.RLock.
|
||||
func drawReachability(scr tcell.Screen, y int, st *InterfaceStatus) int {
|
||||
total := len(st.Reachable)
|
||||
good := 0
|
||||
|
||||
for _, ok := range st.Reachable {
|
||||
if ok {
|
||||
good++
|
||||
}
|
||||
}
|
||||
|
||||
age := time.Since(st.LastPing).Round(time.Second)
|
||||
reachStyle := CBrightGreen
|
||||
|
||||
reachStyle := styleBrightGreen()
|
||||
if good != total {
|
||||
reachStyle = CBrightRed
|
||||
reachStyle = styleBrightRed()
|
||||
}
|
||||
|
||||
// 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)",
|
||||
Put(scr, colLeft, y, fmt.Sprintf("Reachable: %d/%d (at %s, age %s)",
|
||||
good, total, st.LastPing.Format("15:04:05"), age), reachStyle)
|
||||
y++
|
||||
y += lineStep
|
||||
|
||||
if good == total {
|
||||
Put(scr, 0, y, "Unreachable: none", CDefault)
|
||||
Put(scr, colLeft, y, "Unreachable: none", tcell.StyleDefault)
|
||||
} else {
|
||||
var down []string
|
||||
down := make([]string, 0, len(st.Reachable))
|
||||
|
||||
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)
|
||||
Put(scr, colLeft, y, fmt.Sprintf("Unreachable: %s (last drop at %s, age %s)",
|
||||
strings.Join(down, ", "), dropTimeStr, dropAgeStr), styleBrightRed())
|
||||
}
|
||||
y += 2
|
||||
|
||||
/* packet loss */
|
||||
Put(scr, 0, y, "Packet Loss:", CDefault)
|
||||
y++
|
||||
return y + blockGap
|
||||
}
|
||||
|
||||
// Get all hosts and sort them for consistent display order
|
||||
var lossHosts []string
|
||||
// drawPacketLoss renders the per-host packet-loss list. The caller holds
|
||||
// st.mu.RLock.
|
||||
func drawPacketLoss(scr tcell.Screen, y int, st *InterfaceStatus) int {
|
||||
Put(scr, colLeft, y, "Packet Loss:", tcell.StyleDefault)
|
||||
y += lineStep
|
||||
|
||||
lossHosts := make([]string, 0, len(st.Loss))
|
||||
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)
|
||||
}
|
||||
maxHostLen = max(maxHostLen, len(host))
|
||||
}
|
||||
|
||||
// Add 1 for the colon
|
||||
maxHostLen += 1
|
||||
maxHostLen++ // room for the colon
|
||||
|
||||
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++
|
||||
p := st.Loss[host] * percentFull
|
||||
Put(scr, colLeft, y, fmt.Sprintf("%-*s %5.0f%%", maxHostLen, host+":", p),
|
||||
StyleLoss(p))
|
||||
y += lineStep
|
||||
}
|
||||
|
||||
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
|
||||
Put(scr, colLeft, y, fmt.Sprintf("Dropped: %d (last at %s, age %s)",
|
||||
st.DroppedCount, st.LastDrop.Format("15:04:05"), dAge), tcell.StyleDefault)
|
||||
|
||||
return y + blockGap
|
||||
}
|
||||
|
||||
// drawTCPTable renders the TCP connect-stats table. The caller holds
|
||||
// st.mu.RLock.
|
||||
func (m *Monitor) drawTCPTable(
|
||||
scr tcell.Screen, y int, st *InterfaceStatus, tcpHosts []string,
|
||||
) int {
|
||||
Put(scr, colLeft, y, "TCP Connect Stats:", tcell.StyleDefault)
|
||||
y += lineStep
|
||||
|
||||
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()
|
||||
Put(scr, colLeft, y, headerRow, tcell.StyleDefault)
|
||||
y += lineStep
|
||||
|
||||
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
|
||||
m.drawTCPRow(scr, y, st, hp, hist)
|
||||
y += lineStep
|
||||
}
|
||||
|
||||
// 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
|
||||
return y + lineStep
|
||||
}
|
||||
|
||||
// uiLoop runs the UI event loop
|
||||
// drawTCPRow renders one TCP host's statistics row. The caller holds
|
||||
// st.mu.RLock.
|
||||
func (m *Monitor) drawTCPRow(
|
||||
scr tcell.Screen, y int, st *InterfaceStatus, hp string, hist []float64,
|
||||
) {
|
||||
last := hist[len(hist)-1]
|
||||
mi, ma, av, sd := MinMaxAvgStd(hist)
|
||||
|
||||
host := strings.Split(hp, ":")[0]
|
||||
lost := st.LostPackets[host]
|
||||
|
||||
row := fmt.Sprintf("%-*s %*s %*s %*s %*s %*s %*d %*d",
|
||||
m.HostWidth, hp,
|
||||
m.NumWidth, msStr(last),
|
||||
m.NumWidth, msStr(mi),
|
||||
m.NumWidth, msStr(av),
|
||||
m.NumWidth, msStr(ma),
|
||||
m.StdWidth, msStr(sd),
|
||||
m.NWidth, len(hist),
|
||||
m.LostWidth, lost,
|
||||
)
|
||||
Put(scr, colLeft, y, row, tcell.StyleDefault)
|
||||
|
||||
// Overlay the numeric columns colored by value, at the same offsets the
|
||||
// base row above laid them out.
|
||||
x := m.HostWidth + colGap
|
||||
Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(last)), StyleLatency(last))
|
||||
x += m.NumWidth + colGap
|
||||
Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(mi)), StyleLatency(mi))
|
||||
x += m.NumWidth + colGap
|
||||
Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(av)), StyleLatency(av))
|
||||
x += m.NumWidth + colGap
|
||||
Put(scr, x, y, fmt.Sprintf("%*s", m.NumWidth, msStr(ma)), StyleLatency(ma))
|
||||
x += m.NumWidth + colGap
|
||||
Put(scr, x, y, fmt.Sprintf("%*s", m.StdWidth, msStr(sd)), tcell.StyleDefault)
|
||||
x += m.StdWidth + colGap
|
||||
x += m.NWidth + colGap // n column already drawn by the base row
|
||||
|
||||
lostStyle := tcell.StyleDefault
|
||||
if lost > 0 {
|
||||
lostStyle = styleRed()
|
||||
}
|
||||
|
||||
Put(scr, x, y, fmt.Sprintf("%*d", m.LostWidth, lost), lostStyle)
|
||||
}
|
||||
|
||||
// drawICMPStats renders the ICMP request/reply/lost summary. The caller
|
||||
// holds st.mu.RLock.
|
||||
func drawICMPStats(scr tcell.Screen, y int, st *InterfaceStatus) int {
|
||||
lost := max(st.TotalICMPReq-st.TotalICMPRep, 0)
|
||||
|
||||
lostStyle := tcell.StyleDefault
|
||||
if lost > 0 {
|
||||
lostStyle = styleRed()
|
||||
}
|
||||
|
||||
reqCol := icmpLabelWidth
|
||||
repCol := reqCol + icmpValWidth + icmpColGap
|
||||
lostCol := repCol + icmpValWidth + icmpColGap
|
||||
|
||||
Put(scr, reqCol, y, fmt.Sprintf("%*s", icmpValWidth, "Requests"), tcell.StyleDefault)
|
||||
Put(scr, repCol, y, fmt.Sprintf("%*s", icmpValWidth, "Replies"), tcell.StyleDefault)
|
||||
Put(scr, lostCol, y, fmt.Sprintf("%*s", icmpValWidth, "Lost"), tcell.StyleDefault)
|
||||
y += lineStep
|
||||
|
||||
reqStr := fmt.Sprintf("%*d", icmpValWidth, st.TotalICMPReq)
|
||||
repStr := fmt.Sprintf("%*d", icmpValWidth, st.TotalICMPRep)
|
||||
lostStr := fmt.Sprintf("%*d", icmpValWidth, lost)
|
||||
|
||||
Put(scr, colLeft, y, "ICMP: ", tcell.StyleDefault)
|
||||
Put(scr, reqCol, y, reqStr, tcell.StyleDefault)
|
||||
Put(scr, repCol, y, repStr, tcell.StyleDefault)
|
||||
Put(scr, lostCol, y, lostStr, lostStyle)
|
||||
|
||||
return y + blockGap
|
||||
}
|
||||
|
||||
// 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()
|
||||
@@ -298,67 +345,53 @@ func (m *Monitor) uiLoop(ctx context.Context) {
|
||||
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
|
||||
timestampSpinFrame := 0
|
||||
lastTimestampSpinUpdate := time.Now()
|
||||
|
||||
drawScreen := func() {
|
||||
w, _ := m.screen.Size()
|
||||
m.screen.Clear()
|
||||
|
||||
// Draw the top horizontal line
|
||||
Put(m.screen, 0, 0, HLine(w), CDefault)
|
||||
Put(m.screen, colLeft, rowTopRule, HLine(w), tcell.StyleDefault)
|
||||
|
||||
// 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)
|
||||
timestampSpinFrame = (timestampSpinFrame + 1) % len(brailleSpins())
|
||||
lastTimestampSpinUpdate = now
|
||||
}
|
||||
|
||||
// Get braille spinner character
|
||||
brailleChar := BrailleSpins[timestampSpinFrame]
|
||||
brailleChar := brailleSpins()[timestampSpinFrame]
|
||||
colors := rainbow()
|
||||
|
||||
// Draw the header with timestamp and spinner
|
||||
Put(m.screen, 0, 1, "== ", CDefault)
|
||||
Put(m.screen, 3, 1, string(brailleChar)+" ", CDefault)
|
||||
Put(m.screen, colLeft, rowClock, "== ", tcell.StyleDefault)
|
||||
Put(m.screen, colSpinner, rowClock, string(brailleChar)+" ", tcell.StyleDefault)
|
||||
DrawRainbowText(m.screen, colMeter, rowClock, timeStr, colors)
|
||||
DrawRainbowText(m.screen, colMeter+len(timeStr)+clockGap, rowClock,
|
||||
pstTimeStr, colors)
|
||||
Put(m.screen, colLeft, rowBottomRule, HLine(w), tcell.StyleDefault)
|
||||
|
||||
// Draw the timestamp with rainbow colors
|
||||
DrawRainbowText(m.screen, 5, 1, timeStr, CRainbow)
|
||||
runtime := time.Since(m.startTime).Round(time.Second).String()
|
||||
Put(m.screen, colLeft, rowRuntime, "Runtime: "+runtime, tcell.StyleDefault)
|
||||
|
||||
// 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 := rowFirstIface
|
||||
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()
|
||||
|
||||
@@ -366,12 +399,11 @@ func (m *Monitor) uiLoop(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
m.logf("Context cancelled, exiting UI loop")
|
||||
|
||||
return
|
||||
case <-UIUpdateChan:
|
||||
// Update on spinner ticks (no rate limiting)
|
||||
case <-m.uiUpdate:
|
||||
drawScreen()
|
||||
case <-backupTicker.C:
|
||||
// Fallback to ensure we update at least once per second
|
||||
drawScreen()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user