diff --git a/README.md b/README.md index c51946e..008aff8 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,10 @@ mping - ๐Ÿ“Š **Real-time statistics**: Live success rates, response times, and packet loss - ๐Ÿ–ฅ๏ธ **Interactive TUI**: Clean terminal interface with sortable results - โšก **High performance**: Concurrent probing with configurable intervals -- ๐Ÿ“ **Batch mode**: Non-interactive mode for scripting and automation +- ๐Ÿ“ **Batch mode**: Non-interactive mode for scripting and automation, with JSON/CSV output and exit codes +- โฑ๏ธ **Outage measurement**: Records every outage with start/end time and duration โ€” ideal for failover and maintenance tests +- ๐Ÿ›ฃ๏ธ **Path trace (MTR)**: Press `p` in the TUI to see per-hop loss and latency to the selected host +- ๐Ÿ“ˆ **History at a glance**: Sparkline of recent probes and RTT p50/p95/p99 per host ## Supported Protocols @@ -108,6 +111,52 @@ Batch mode details: - Progress dots are written to stderr only when it is a terminal, so stdout stays clean for pipes. The failure beep is disabled in batch mode. - `-I, --interface` selects the source interface, same as the interactive mode. +## Outage Measurement + +mping turns consecutive failures into **outages** and records when each one started and ended. This answers the question that matters during failover tests and maintenance windows: *how long was it down?* + +- An outage starts when **3 or more consecutive probes** are lost (shorter runs count as isolated packet loss) and ends at the next successful probe. Its start is the send time of the first lost probe. +- Duration is `lost probes ร— interval`, so use a short interval (e.g. `-i 100`) for sub-second precision. +- The host list's **LastFailTime** column also shows the outage duration: `16:44:20 (DOWN 5.20s)` (red, counting up) while a host is down, and `16:44:15 (0.50s)` (yellow) once it has recovered. +- The TUI host detail panel (`v`) shows the outage count, total downtime, the longest outage, and the most recent outages. +- When you quit the TUI (and in `mping batch`), a timeline of all outages across targets is printed: + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Outages (2) โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ START โ”‚ END โ”‚ DURATION โ”‚ LOST โ”‚ HOST โ”‚ LAST ERROR โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ 14:03:05.120 โ”‚ 14:03:41.870 โ”‚ 36.750s โ”‚ 368 โ”‚ core-sw1(10.0.0.1) โ”‚ timeout โ”‚ +โ”‚ 14:03:06.002 โ”‚ (ongoing) โ”‚ 1.010s โ”‚ 10 โ”‚ 10.0.0.254 โ”‚ timeout โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +- `mping batch -o json` includes `outages[]`, `outage_count`, `total_downtime_ms`, and `longest_outage_ms` per target. +- Probe results can arrive out of order (a timeout is reported after a later probe already succeeded), so results are applied in send order after `timeout + interval`. Outages therefore appear in the TUI with that delay. + +## Path Trace (MTR) + +Press `p` in the TUI to show a live, MTR-style trace of the path to the selected host. The trace follows the selection. Any target works โ€” for `tcp://`, `https://`, `dns://`, โ€ฆ the host part is traced. + +Notes: +- Loss at an intermediate hop that does not continue to the destination is usually ICMP rate limiting on that router, not real loss; the path view points this out. +- Raw sockets are used when available (root / `cap_net_raw`). Otherwise mping falls back to unprivileged ICMP sockets: on macOS this works fully, on Linux only the destination may be visible. +- In the TUI, path probing runs at most once per second so that router rate limiting does not show up as fake loss. + +## History and Percentiles + +The host list has a **History** column with the last 20 probes (oldest on the left). The bar height shows how much slower than usual a probe was: the RTT increase over the host's median, on a fixed millisecond scale. So +20ms looks the same on a 10ms LAN host and on a 150ms overseas host. + +| Mark | Meaning | +|------|---------| +| green `โ–` | within +5ms of the median | +| green `โ–‚โ–ƒโ–„โ–…` | +5ms / +10ms / +20ms / +50ms slower | +| yellow `โ–†โ–‡โ–ˆ` | +100ms / +200ms / +500ms slower | +| red `ร—` | no reply (timeout / connection failure) | + +Latency spikes and loss bursts are visible at a glance. The host details and JSON/CSV output include RTT p50/p95/p99 over the last 100 probes. + ## Target Expansion mping supports powerful target expansion syntax for monitoring large numbers of hosts efficiently: @@ -270,6 +319,7 @@ mping tcp://google.com:443 https://google.com ntp://time.google.com 8.8.8.8 Available Commands: batch Disables TUI and performs probing for a set number of iterations + config management config help Help about any command Flags: diff --git a/internal/command/batch.go b/internal/command/batch.go index 94aa3ef..4eec00a 100644 --- a/internal/command/batch.go +++ b/internal/command/batch.go @@ -98,10 +98,12 @@ mping batch --max-loss 20 -f hosts.txt || echo "some targets are unhealthy"`, // Create ProbeManager and MetricsManager probeManager := prober.NewProbeManager(cfg.Prober, cfg.Default) - metricsManager := stats.NewMetricsManager() - // The terminal bell would corrupt machine-readable output and is - // pointless in non-interactive runs. - metricsManager.SetBeepEnabled(false) + metricsManager := stats.NewMetricsManagerWithOptions(stats.Options{ + SettleTime: stats.SettleTimeFor(_interval, _timeout), + // The terminal bell would corrupt machine-readable output and + // is pointless in non-interactive runs. + DisableBeep: true, + }) // Add targets err = probeManager.AddTargets(hosts...) diff --git a/internal/command/mping.go b/internal/command/mping.go index 3fdbb95..f129ec7 100644 --- a/internal/command/mping.go +++ b/internal/command/mping.go @@ -78,7 +78,9 @@ mping dns://8.8.8.8/google.com`, // Create ProbeManager and MetricsManager probeManager := prober.NewProbeManager(cfg.Prober, cfg.Default) - metricsManager := stats.NewMetricsManager() + metricsManager := stats.NewMetricsManagerWithOptions(stats.Options{ + SettleTime: stats.SettleTimeFor(_interval, _timeout), + }) // Add targets err = probeManager.AddTargets(hosts...) diff --git a/internal/stats/history.go b/internal/stats/history.go index de51abd..73d75bd 100644 --- a/internal/stats/history.go +++ b/internal/stats/history.go @@ -145,6 +145,13 @@ func (th *TargetHistory) GetSuccessRateInPeriod(duration time.Duration) float64 return float64(successCount) / float64(len(entries)) * 100.0 } +// clone returns a deep copy of the history +func (th *TargetHistory) clone() *TargetHistory { + c := *th + c.entries = append([]HistoryEntry(nil), th.entries...) + return &c +} + // Clear clears the history func (th *TargetHistory) Clear() { th.index = 0 diff --git a/internal/stats/history_test.go b/internal/stats/history_test.go index b0dc1e2..4bc0fdf 100644 --- a/internal/stats/history_test.go +++ b/internal/stats/history_test.go @@ -130,7 +130,8 @@ func TestMetricsWithHistory(t *testing.T) { // Record failure mm.Failed(host, time.Now(), "timeout") - // Test consecutive failures + // GetMetrics returns a snapshot, so fetch again to observe the failure + metrics = mm.GetMetrics(host) if metrics.GetConsecutiveFailures() != 1 { t.Errorf("Expected 1 consecutive failure, got %d", metrics.GetConsecutiveFailures()) } diff --git a/internal/stats/interface.go b/internal/stats/interface.go index 269dbeb..f4c9310 100644 --- a/internal/stats/interface.go +++ b/internal/stats/interface.go @@ -26,6 +26,12 @@ type Metrics interface { GetConsecutiveFailures() int GetConsecutiveSuccesses() int GetSuccessRateInPeriod(duration time.Duration) float64 + GetRTTPercentiles(ps ...float64) []time.Duration + + // Outage tracking + GetOutages() []Outage + GetLastOutage() (Outage, bool) + GetOutageSummary() OutageSummary } // MetricsProvider provides external API for metrics access @@ -37,7 +43,6 @@ type MetricsProvider interface { type MetricsSystemManager interface { ResetAllMetrics() ToggleBeep() - SetBeepEnabled(enabled bool) IsBeepEnabled() bool } diff --git a/internal/stats/manager.go b/internal/stats/manager.go index 8061b5c..b21a0ea 100644 --- a/internal/stats/manager.go +++ b/internal/stats/manager.go @@ -16,22 +16,51 @@ const ( type metricsManager struct { metrics map[string]*metrics historySize int // Number of history entries to keep + settle time.Duration beeper *beep.Beeper mu sync.Mutex } +// Options configures a MetricsManager +type Options struct { + HistorySize int + // SettleTime is how long results are buffered before outage detection so + // that out-of-order results can be applied in send order. It should cover + // the latest a result can be reported: timeout plus one interval (ICMP + // timeouts are checked on the interval ticker). + SettleTime time.Duration + // DisableBeep turns off the failure beep, e.g. for non-interactive runs + DisableBeep bool +} + +// SettleTimeFor returns the recommended SettleTime for the probe timing +func SettleTimeFor(interval, timeout time.Duration) time.Duration { + return interval + timeout + 100*time.Millisecond +} + // Create a new MetricsManager func NewMetricsManager() MetricsManager { - return NewMetricsManagerWithHistorySize(DefaultHistorySize) + return NewMetricsManagerWithOptions(Options{}) } -// Create MetricsManager with specified history size -func NewMetricsManagerWithHistorySize(historySize int) MetricsManager { - return &metricsManager{ +// NewMetricsManagerWithOptions creates a MetricsManager with custom options +func NewMetricsManagerWithOptions(opts Options) MetricsManager { + if opts.HistorySize <= 0 { + opts.HistorySize = DefaultHistorySize + } + mm := &metricsManager{ metrics: make(map[string]*metrics), - historySize: historySize, - beeper: beep.NewBeeper(), + historySize: opts.HistorySize, + settle: opts.SettleTime, + } + if !opts.DisableBeep { + mm.beeper = beep.NewBeeper() } + return mm +} + +func (mm *metricsManager) newMetrics(name string) *metrics { + return newMetrics(name, mm.historySize, mm.settle) } func (mm *metricsManager) Register(target, name string) { @@ -42,10 +71,7 @@ func (mm *metricsManager) Register(target, name string) { if ok && v.Name != target { return } - mm.metrics[target] = &metrics{ - Name: name, - history: NewTargetHistory(mm.historySize), - } + mm.metrics[target] = mm.newMetrics(name) } // ๆŒ‡ๅฎšใ•ใ‚ŒใŸใƒ›ใ‚นใƒˆใฎMetricsใ‚’ๅ–ๅพ—๏ผˆๅ†…้ƒจ็”จ๏ผ‰ @@ -55,10 +81,7 @@ func (mm *metricsManager) getMetrics(host string) *metrics { m, ok := mm.metrics[host] if !ok { - m = &metrics{ - Name: host, - history: NewTargetHistory(mm.historySize), - } + m = mm.newMetrics(host) // Register the new metrics for the host mm.metrics[host] = m } @@ -66,7 +89,10 @@ func (mm *metricsManager) getMetrics(host string) *metrics { } func (mm *metricsManager) GetMetrics(host string) Metrics { - return mm.getMetrics(host) + m := mm.getMetrics(host) + mm.mu.Lock() + defer mm.mu.Unlock() + return m.settledSnapshot(time.Now()) } func (mm *metricsManager) ResetAllMetrics() { @@ -97,6 +123,7 @@ func (mm *metricsManager) SuccessWithDetails(host string, rtt time.Duration, sen Details: details, }) } + m.outage.add(sentTime, true, "", time.Now()) mm.mu.Unlock() } @@ -114,6 +141,7 @@ func (mm *metricsManager) Failed(host string, sentTime time.Time, msg string) { Error: msg, }) } + m.outage.add(sentTime, false, msg, time.Now()) mm.mu.Unlock() // Play beep sound on failure (non-blocking) @@ -134,6 +162,9 @@ func (mm *metricsManager) Subscribe(res <-chan *prober.Event) <-chan struct{} { done := make(chan struct{}) go func() { defer close(done) + // Once the channel is closed no more results can arrive, so every + // buffered result can be applied to outage detection. + defer mm.flushOutages() for r := range res { switch r.Result { case prober.REGISTER: @@ -158,20 +189,27 @@ func (mm *metricsManager) autoRegister(key, displayName string) { defer mm.mu.Unlock() if _, exists := mm.metrics[key]; !exists { - mm.metrics[key] = &metrics{ - Name: displayName, - history: NewTargetHistory(mm.historySize), - } + mm.metrics[key] = mm.newMetrics(displayName) } } -// SortBy sorts metrics by specified key and returns Metrics slice +func (mm *metricsManager) flushOutages() { + mm.mu.Lock() + defer mm.mu.Unlock() + for _, m := range mm.metrics { + m.outage.flushAll() + } +} + +// SortBy sorts metrics by specified key and returns Metrics slice. +// The returned values are snapshots, safe to read while probing continues. func (mm *metricsManager) SortBy(k Key, ascending bool) []Metrics { mm.mu.Lock() defer mm.mu.Unlock() + now := time.Now() var res []Metrics for _, m := range mm.metrics { - res = append(res, m) + res = append(res, m.settledSnapshot(now)) } if k != Host { @@ -179,49 +217,46 @@ func (mm *metricsManager) SortBy(k Key, ascending bool) []Metrics { return res[i].GetName() < res[j].GetName() }) } - sort.SliceStable(res, func(i, j int) bool { - mi := res[i] - mj := res[j] - var result bool + // less must be a strict ordering: descending order swaps the operands + // instead of negating the result, which would make equal elements + // "less" than each other and reorder them on every call. + less := func(mi, mj Metrics) bool { switch k { case Host: - result = mi.GetName() < mj.GetName() + return mi.GetName() < mj.GetName() case Sent: - result = mi.GetTotal() < mj.GetTotal() + return mi.GetTotal() < mj.GetTotal() case Success: - result = mi.GetSuccessful() < mj.GetSuccessful() + return mi.GetSuccessful() < mj.GetSuccessful() case Loss: - result = mi.GetLoss() < mj.GetLoss() + return mi.GetLoss() < mj.GetLoss() case Fail: - result = mi.GetFailed() < mj.GetFailed() - case Last: - result = rejectLessAscending(mi.GetLastRTT(), mj.GetLastRTT()) - case Avg: - result = rejectLessAscending(mi.GetAverageRTT(), mj.GetAverageRTT()) - case Best: - result = rejectLessAscending(mi.GetMinimumRTT(), mj.GetMinimumRTT()) - case Worst: - result = rejectLessAscending(mi.GetMaximumRTT(), mj.GetMaximumRTT()) + return mi.GetFailed() < mj.GetFailed() + case Last, Avg, Best, Worst: + return rttFor(k, mi) < rttFor(k, mj) case LastSuccTime: - result = mi.GetLastSuccTime().Before(mj.GetLastSuccTime()) + return mi.GetLastSuccTime().Before(mj.GetLastSuccTime()) case LastFailTime: - result = mi.GetLastFailTime().Before(mj.GetLastFailTime()) - default: - return false + return mi.GetLastFailTime().Before(mj.GetLastFailTime()) + } + return false + } + sort.SliceStable(res, func(i, j int) bool { + // Unmeasured RTTs (zero) always go last, whatever the direction + if zi, zj := rttFor(k, res[i]) == 0, rttFor(k, res[j]) == 0; zi != zj { + return zj } - if ascending { - return result - } else { - return !result + return less(res[i], res[j]) } + return less(res[j], res[i]) }) return res } // GetMetricsAsReader retrieves as Metrics interface func (mm *metricsManager) GetMetricsAsReader(target string) Metrics { - return mm.getMetrics(target) + return mm.GetMetrics(target) } // ToggleBeep toggles beep sound on/off @@ -231,18 +266,6 @@ func (mm *metricsManager) ToggleBeep() { } } -// SetBeepEnabled turns beep sound on or off -func (mm *metricsManager) SetBeepEnabled(enabled bool) { - if mm.beeper == nil { - return - } - if enabled { - mm.beeper.Enable() - } else { - mm.beeper.Disable() - } -} - // IsBeepEnabled returns current beep sound state func (mm *metricsManager) IsBeepEnabled() bool { if mm.beeper != nil { @@ -251,14 +274,17 @@ func (mm *metricsManager) IsBeepEnabled() bool { return false } -// rejectLessAscending is RTT comparison function for ascending sort -// Zero values (unmeasured) are always placed at the end -func rejectLessAscending(i, j time.Duration) bool { - if i == 0 { - return false // If i is 0, put j first - } - if j == 0 { - return true // If j is 0, put i first +// rttFor returns the RTT a sort key refers to, or -1 for non-RTT keys +func rttFor(k Key, m Metrics) time.Duration { + switch k { + case Last: + return m.GetLastRTT() + case Avg: + return m.GetAverageRTT() + case Best: + return m.GetMinimumRTT() + case Worst: + return m.GetMaximumRTT() } - return i < j // If both are non-zero, put the smaller one first + return -1 } diff --git a/internal/stats/metric.go b/internal/stats/metric.go index 7720c21..0c44828 100644 --- a/internal/stats/metric.go +++ b/internal/stats/metric.go @@ -2,13 +2,19 @@ package stats import ( "math" + "slices" "time" ) func NewMetrics(name string, historySize int) Metrics { + return newMetrics(name, historySize, 0) +} + +func newMetrics(name string, historySize int, settle time.Duration) *metrics { return &metrics{ Name: name, history: NewTargetHistory(historySize), + outage: newOutageTracker(settle), } } @@ -28,6 +34,7 @@ func NewMetricsForTest(name string, historySize, total, success, failed int, los LastFailTime: lastFailTime, LastFailDetail: lastFailDetail, history: NewTargetHistory(historySize), + outage: newOutageTracker(0), } } @@ -47,6 +54,7 @@ type metrics struct { LastSuccTime time.Time LastFailDetail string history *TargetHistory // ๅฑฅๆญดๆƒ…ๅ ฑ + outage *outageTracker } func (m *metrics) Success(rtt time.Duration, sentTime time.Time) { @@ -97,6 +105,30 @@ func (m *metrics) Reset() { if m.history != nil { m.history.Clear() } + if m.outage != nil { + m.outage.reset() + } +} + +// settledSnapshot applies outage results that have settled by now and returns +// a snapshot, so every read path sees outages up to date. +func (m *metrics) settledSnapshot(now time.Time) *metrics { + if m.outage != nil { + m.outage.flush(now) + } + return m.snapshot() +} + +// snapshot returns a deep copy that can be read without holding the manager lock +func (m *metrics) snapshot() *metrics { + c := *m + if m.history != nil { + c.history = m.history.clone() + } + if m.outage != nil { + c.outage = m.outage.clone() + } + return &c } // Implementation of MetricsReader interface @@ -185,6 +217,53 @@ func (m *metrics) GetConsecutiveSuccesses() int { return m.history.GetConsecutiveSuccesses() } +func (m *metrics) GetOutages() []Outage { + if m.outage == nil { + return []Outage{} + } + return m.outage.outages() +} + +// GetLastOutage returns the ongoing outage, or else the most recent one +func (m *metrics) GetLastOutage() (Outage, bool) { + if m.outage == nil { + return Outage{}, false + } + return m.outage.last() +} + +func (m *metrics) GetOutageSummary() OutageSummary { + if m.outage == nil { + return OutageSummary{} + } + return m.outage.summary(time.Now()) +} + +// GetRTTPercentiles returns RTT percentiles (0-100, nearest-rank method) +// over the successful probes kept in history. +func (m *metrics) GetRTTPercentiles(ps ...float64) []time.Duration { + res := make([]time.Duration, len(ps)) + if m.history == nil { + return res + } + var rtts []time.Duration + for _, e := range m.history.GetRecentEntries(m.history.count) { + if e.Success { + rtts = append(rtts, e.RTT) + } + } + if len(rtts) == 0 { + return res + } + slices.Sort(rtts) + for i, p := range ps { + rank := int(math.Ceil(p / 100 * float64(len(rtts)))) + rank = min(max(rank, 1), len(rtts)) + res[i] = rtts[rank-1] + } + return res +} + func (m *metrics) GetSuccessRateInPeriod(duration time.Duration) float64 { if m.history == nil { return 0.0 diff --git a/internal/stats/outage.go b/internal/stats/outage.go new file mode 100644 index 0000000..cd7e4d9 --- /dev/null +++ b/internal/stats/outage.go @@ -0,0 +1,212 @@ +package stats + +import ( + "sort" + "time" +) + +const ( + // maxStoredOutages bounds memory for long-running, flapping targets. + // Aggregates (count, total, longest) keep counting beyond this limit. + maxStoredOutages = 1000 + + // outageMinLostProbes is the number of consecutive lost probes that make + // an outage. Shorter runs are treated as isolated packet loss. + outageMinLostProbes = 3 +) + +// Outage is a period during which a target was unreachable. +// +// outageMinLostProbes consecutive lost probes make an outage and the next +// successful probe ends it. Start is the send time of the first lost probe of +// the run and End is the send time of that successful probe, so Duration is +// "lost probes ร— interval" with a precision of one interval. +type Outage struct { + Start time.Time + End time.Time // zero while the outage is ongoing + LostProbes int + LastError string +} + +// Ongoing reports whether the outage has not recovered yet +func (o Outage) Ongoing() bool { + return o.End.IsZero() +} + +// Duration returns the outage length; ongoing outages are measured up to now +func (o Outage) Duration(now time.Time) time.Duration { + if o.Ongoing() { + return now.Sub(o.Start) + } + return o.End.Sub(o.Start) +} + +// OutageSummary aggregates all outages of a target +type OutageSummary struct { + Count int + Total time.Duration + Longest time.Duration + Ongoing bool +} + +// probeResult is a settled or pending probe outcome used for ordering +type probeResult struct { + sentTime time.Time + success bool + err string +} + +// outageTracker detects outages for one target. +// +// Probe results can arrive out of send order (e.g. a timeout is reported after +// a later probe already succeeded), so results are held for `settle` and +// applied in send-time order once no earlier result can still arrive. +type outageTracker struct { + settle time.Duration + + pending []probeResult // sorted by sentTime + applied time.Time // sentTime of the newest applied result + + lostRun []probeResult // consecutive failures not yet making an outage + + current *Outage + closed []Outage + + count int + total time.Duration + longest time.Duration +} + +func newOutageTracker(settle time.Duration) *outageTracker { + return &outageTracker{settle: settle} +} + +// add records a probe result and applies every result that has settled +func (t *outageTracker) add(sentTime time.Time, success bool, err string, now time.Time) { + if sentTime.IsZero() { + sentTime = now + } + r := probeResult{sentTime: sentTime, success: success, err: err} + i := sort.Search(len(t.pending), func(i int) bool { + return t.pending[i].sentTime.After(sentTime) + }) + t.pending = append(t.pending, probeResult{}) + copy(t.pending[i+1:], t.pending[i:]) + t.pending[i] = r + t.flush(now) +} + +// flush applies pending results sent before now-settle +func (t *outageTracker) flush(now time.Time) { + t.flushUntil(now.Add(-t.settle)) +} + +// flushAll applies every pending result (used when no more results can arrive) +func (t *outageTracker) flushAll() { + t.flushUntil(time.Time{}) +} + +func (t *outageTracker) flushUntil(deadline time.Time) { + n := 0 + for _, r := range t.pending { + if !deadline.IsZero() && r.sentTime.After(deadline) { + break + } + // A result that arrives after newer ones were applied can't be placed + // in order anymore; drop it from outage detection only. + if !r.sentTime.Before(t.applied) { + t.apply(r) + t.applied = r.sentTime + } + n++ + } + t.pending = t.pending[n:] +} + +func (t *outageTracker) apply(r probeResult) { + switch { + case r.success: + t.lostRun = t.lostRun[:0] + if t.current != nil { + t.closeCurrent(r.sentTime) + } + case t.current != nil: + t.current.LostProbes++ + t.current.LastError = r.err + default: + t.lostRun = append(t.lostRun, r) + if len(t.lostRun) >= outageMinLostProbes { + t.current = &Outage{Start: t.lostRun[0].sentTime, LostProbes: len(t.lostRun), LastError: r.err} + t.count++ + t.lostRun = t.lostRun[:0] + } + } +} + +func (t *outageTracker) closeCurrent(end time.Time) { + o := *t.current + o.End = end + d := o.Duration(end) + t.total += d + if d > t.longest { + t.longest = d + } + t.closed = append(t.closed, o) + if len(t.closed) > maxStoredOutages { + t.closed = append([]Outage(nil), t.closed[len(t.closed)-maxStoredOutages:]...) + } + t.current = nil +} + +// outages returns a copy of closed outages plus the ongoing one, oldest first +func (t *outageTracker) outages() []Outage { + res := make([]Outage, 0, len(t.closed)+1) + res = append(res, t.closed...) + if t.current != nil { + res = append(res, *t.current) + } + return res +} + +// last returns the ongoing outage, or else the most recent closed one +func (t *outageTracker) last() (Outage, bool) { + if t.current != nil { + return *t.current, true + } + if len(t.closed) > 0 { + return t.closed[len(t.closed)-1], true + } + return Outage{}, false +} + +// summary aggregates outages; an ongoing outage is measured up to now +func (t *outageTracker) summary(now time.Time) OutageSummary { + s := OutageSummary{Count: t.count, Total: t.total, Longest: t.longest} + if t.current != nil { + d := t.current.Duration(now) + s.Total += d + if d > s.Longest { + s.Longest = d + } + s.Ongoing = true + } + return s +} + +func (t *outageTracker) clone() *outageTracker { + c := *t + c.pending = append([]probeResult(nil), t.pending...) + c.lostRun = append([]probeResult(nil), t.lostRun...) + // closed is append-only and trimming reallocates it, so the clone can + // share the backing array as long as its capacity is capped. + c.closed = t.closed[:len(t.closed):len(t.closed)] + if t.current != nil { + cur := *t.current + c.current = &cur + } + return &c +} + +func (t *outageTracker) reset() { + *t = *newOutageTracker(t.settle) +} diff --git a/internal/stats/outage_test.go b/internal/stats/outage_test.go new file mode 100644 index 0000000..1899cdf --- /dev/null +++ b/internal/stats/outage_test.go @@ -0,0 +1,237 @@ +package stats + +import ( + "strings" + "testing" + "time" + + "github.com/servak/mping/internal/prober" +) + +var t0 = time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + +// at returns t0 + n seconds (one probe per second) +func at(n int) time.Time { return t0.Add(time.Duration(n) * time.Second) } + +// feed applies results in order; 'S' = success, 'F' = failure, one per second +func feed(tr *outageTracker, pattern string) { + for i, c := range pattern { + tr.add(at(i), c == 'S', "timeout", at(i)) + } +} + +func TestOutageTracker(t *testing.T) { + tests := []struct { + name string + pattern string + want []Outage + wantCount int + }{ + { + name: "no failures", + pattern: "SSSS", + }, + { + name: "loss shorter than the threshold is not an outage", + pattern: "SFFSFS", + }, + { + name: "single outage", + pattern: "SFFFS", + want: []Outage{{Start: at(1), End: at(4), LostProbes: 3}}, + wantCount: 1, + }, + { + name: "two outages", + pattern: "FFFSSFFFFS", + want: []Outage{ + {Start: at(0), End: at(3), LostProbes: 3}, + {Start: at(5), End: at(9), LostProbes: 4}, + }, + wantCount: 2, + }, + { + name: "a success restarts the count", + pattern: "FFSFFF", + want: []Outage{{Start: at(3), LostProbes: 3}}, + wantCount: 1, + }, + { + name: "ongoing outage", + pattern: "SSFFF", + want: []Outage{{Start: at(2), LostProbes: 3}}, + wantCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tr := newOutageTracker(0) + feed(tr, tt.pattern) + got := tr.outages() + if len(got) != len(tt.want) { + t.Fatalf("got %d outages %+v, want %d", len(got), got, len(tt.want)) + } + for i := range got { + g, w := got[i], tt.want[i] + if !g.Start.Equal(w.Start) || !g.End.Equal(w.End) || g.LostProbes != w.LostProbes { + t.Errorf("outage[%d] = {%v %v %d}, want {%v %v %d}", + i, g.Start, g.End, g.LostProbes, w.Start, w.End, w.LostProbes) + } + } + if s := tr.summary(at(10)); s.Count != tt.wantCount { + t.Errorf("summary count = %d, want %d", s.Count, tt.wantCount) + } + }) + } +} + +func TestOutageTrackerSummary(t *testing.T) { + tr := newOutageTracker(0) + feed(tr, "SFFFSFFFFSFFF") // outages: 3s, 4s, ongoing from t10 + + s := tr.summary(at(13)) // ongoing one lasts 3s at t13 + if s.Count != 3 || !s.Ongoing { + t.Fatalf("summary = %+v", s) + } + if s.Total != 10*time.Second { + t.Errorf("total = %v, want 10s", s.Total) + } + if s.Longest != 4*time.Second { + t.Errorf("longest = %v, want 4s", s.Longest) + } + if s := tr.summary(at(18)); s.Longest != 8*time.Second { + t.Errorf("ongoing outage should become longest: %v", s.Longest) + } +} + +func TestOutageTrackerReordersResults(t *testing.T) { + // TCP/HTTP probes run concurrently: probes #1-#3 time out after probe + // #4 already succeeded, so results arrive out of send order. + settle := 3 * time.Second + tr := newOutageTracker(settle) + + tr.add(at(0), true, "", at(0)) + tr.add(at(4), true, "", at(4)) // arrives before the timeouts + tr.add(at(1), false, "timeout", at(4)) + tr.add(at(2), false, "timeout", at(4)) + tr.add(at(3), false, "timeout", at(4)) + tr.add(at(5), true, "", at(5)) + if got := tr.outages(); len(got) != 0 { + t.Fatalf("results must not be applied before they settle, got %+v", got) + } + + tr.flush(at(10)) + got := tr.outages() + if len(got) != 1 || !got[0].Start.Equal(at(1)) || !got[0].End.Equal(at(4)) { + t.Fatalf("want one outage [t1, t4), got %+v", got) + } +} + +func TestOutageTrackerDropsResultsOlderThanApplied(t *testing.T) { + tr := newOutageTracker(0) + tr.add(at(2), true, "", at(2)) + tr.add(at(1), false, "late", at(3)) // older than what was already applied + if got := tr.outages(); len(got) != 0 { + t.Errorf("late result must not create an outage, got %+v", got) + } +} + +func TestOutageTrackerReset(t *testing.T) { + tr := newOutageTracker(0) + feed(tr, "SFFF") + tr.reset() + if s := tr.summary(at(5)); s.Count != 0 || s.Ongoing || len(tr.outages()) != 0 { + t.Errorf("reset should clear state, got %+v", s) + } +} + +func TestMetricsManagerFlushesOutagesOnClose(t *testing.T) { + // A large settle window keeps results buffered until the channel closes + mm := NewMetricsManagerWithOptions(Options{SettleTime: time.Hour}) + mm.ToggleBeep() + events := make(chan *prober.Event, 10) + done := mm.Subscribe(events) + + events <- &prober.Event{Key: "h", DisplayName: "h", Result: prober.REGISTER} + events <- &prober.Event{Key: "h", Result: prober.SUCCESS, SentTime: at(0)} + for i := 1; i <= 3; i++ { + events <- &prober.Event{Key: "h", Result: prober.TIMEOUT, SentTime: at(i), Message: "timeout"} + } + events <- &prober.Event{Key: "h", Result: prober.SUCCESS, SentTime: at(4)} + close(events) + <-done + + ms := mm.SortBy(Host, true) + if len(ms) != 1 { + t.Fatalf("expected 1 target, got %d", len(ms)) + } + got := ms[0].GetOutages() + if len(got) != 1 || got[0].Duration(at(9)) != 3*time.Second { + t.Fatalf("want one 3s outage, got %+v", got) + } +} + +func TestSortByReturnsSnapshots(t *testing.T) { + mm := NewMetricsManager().(*metricsManager) + mm.ToggleBeep() + mm.Register("h", "h") + mm.Failed("h", at(0), "timeout") + + snap := mm.SortBy(Host, true)[0] + mm.SuccessWithDetails("h", time.Millisecond, at(1), nil) + + if snap.GetSuccessful() != 0 || len(snap.GetRecentHistory(10)) != 1 { + t.Errorf("snapshot must not observe later updates") + } +} + +func TestRTTPercentiles(t *testing.T) { + m := newMetrics("h", 200, 0) + for i := 1; i <= 100; i++ { + rtt := time.Duration(i) * time.Millisecond + m.Success(rtt, at(i)) + m.history.AddEntry(HistoryEntry{Timestamp: at(i), RTT: rtt, Success: true}) + } + m.history.AddEntry(HistoryEntry{Timestamp: at(101), Success: false}) // ignored + + got := m.GetRTTPercentiles(50, 95, 99, 100) + want := []time.Duration{50 * time.Millisecond, 95 * time.Millisecond, 99 * time.Millisecond, 100 * time.Millisecond} + for i := range want { + if got[i] != want[i] { + t.Errorf("p%d: got %v, want %v", []int{50, 95, 99, 100}[i], got[i], want[i]) + } + } + + empty := newMetrics("e", 10, 0) + if p := empty.GetRTTPercentiles(95); p[0] != 0 { + t.Errorf("no samples should give 0, got %v", p[0]) + } +} + +func TestSortByDescendingIsStableForTies(t *testing.T) { + mm := NewMetricsManager().(*metricsManager) + mm.ToggleBeep() + for _, h := range []string{"c", "a", "d", "b"} { + mm.Register(h, h) + } + mm.Failed("d", at(0), "timeout") + mm.SuccessWithDetails("a", 5*time.Millisecond, at(0), nil) + + names := func(ms []Metrics) (s []string) { + for _, m := range ms { + s = append(s, m.GetName()) + } + return + } + // Ties keep host name order in both directions, on every call + for i := 0; i < 20; i++ { + if got := names(mm.SortBy(Fail, false)); strings.Join(got, ",") != "d,a,b,c" { + t.Fatalf("Fail desc = %v, want [d a b c]", got) + } + } + // Unmeasured RTT (zero) stays at the end even when descending + if got := names(mm.SortBy(Avg, false)); got[0] != "a" { + t.Errorf("Avg desc = %v, want measured host first", got) + } +} diff --git a/internal/trace/integration_test.go b/internal/trace/integration_test.go new file mode 100644 index 0000000..d97c6a7 --- /dev/null +++ b/internal/trace/integration_test.go @@ -0,0 +1,154 @@ +//go:build integration + +package trace + +// Integration tests against the real network. Run with: +// +// go test -tags integration -run Integration -v ./internal/trace/ +// +// Environment: +// +// MPING_IT_TARGETS comma separated targets (default 8.8.8.8,1.1.1.1,192.168.1.1) +// MPING_IT_ROUNDS rounds per target (default 20) +// MPING_IT_INTERVAL interval between rounds (default 1s, same as the TUI) + +import ( + "context" + "fmt" + "os" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +func envOr(key, def string) string { + if v := os.Getenv(key); v != "" { + return v + } + return def +} + +type probeKey struct{ round, ttl int } + +// traceLog collects per-probe events of one tracer +type traceLog struct { + mu sync.Mutex + results map[probeKey][]probeEvent + late int +} + +func (l *traceLog) record(e probeEvent) { + l.mu.Lock() + defer l.mu.Unlock() + if e.outcome == outcomeLate { + l.late++ + return + } + k := probeKey{e.round, e.ttl} + l.results[k] = append(l.results[k], e) +} + +func TestIntegrationTrace(t *testing.T) { + targets := strings.Split(envOr("MPING_IT_TARGETS", "8.8.8.8,1.1.1.1,192.168.1.1"), ",") + rounds, _ := strconv.Atoi(envOr("MPING_IT_ROUNDS", "20")) + interval, _ := time.ParseDuration(envOr("MPING_IT_INTERVAL", "1s")) + + for _, target := range targets { + t.Run(target, func(t *testing.T) { + t.Parallel() + tr, err := New(target, Config{Interval: interval, Timeout: time.Second, NoDNS: true}) + if err != nil { + t.Skipf("cannot trace: %v", err) + } + log := &traceLog{results: map[probeKey][]probeEvent{}} + tr.onEvent = log.record + + if err := tr.Run(context.Background(), rounds); err != nil { + t.Fatalf("Run() error = %v", err) + } + res := tr.Snapshot() + t.Logf("privileged=%v reached=%v rounds=%d hops=%d late=%d", + res.Privileged, res.Reached, res.Rounds, len(res.Hops), log.late) + t.Log("\n" + renderGrid(res, log)) + + checkInvariants(t, res, log) + }) + } +} + +// renderGrid shows every probe: '.' reply, 'x' lost, '?' never resolved, +// '!' resolved more than once. Rows are hops, columns are rounds. +func renderGrid(res Result, log *traceLog) string { + var sb strings.Builder + fmt.Fprintf(&sb, "hop %-16s %s\n", "addr", "rounds 1..") + for _, h := range res.Hops { + fmt.Fprintf(&sb, "%3d %-16s ", h.TTL, h.Addr) + for r := 1; r <= res.Rounds; r++ { + evs := log.results[probeKey{r, h.TTL}] + switch { + case len(evs) == 0: + sb.WriteByte('?') + case len(evs) > 1: + sb.WriteByte('!') + case evs[0].outcome == outcomeReply: + sb.WriteByte('.') + default: + sb.WriteByte('x') + } + } + fmt.Fprintf(&sb, " loss=%5.1f%% sent=%d recv=%d\n", h.Loss(), h.Sent, h.Recv) + } + return sb.String() +} + +func checkInvariants(t *testing.T, res Result, log *traceLog) { + t.Helper() + for _, h := range res.Hops { + // Every probe sent to a displayed hop must be resolved exactly once. + // A hop is probed every round from the first round that reached it + // (the probe limit grows as farther hops reply). + first := 1 + for first <= res.Rounds && len(log.results[probeKey{first, h.TTL}]) == 0 { + first++ + } + if want := res.Rounds - first + 1; h.Sent != want { + t.Errorf("hop %d: sent=%d, want %d (one settled probe per round from round %d)", h.TTL, h.Sent, want, first) + } + for r := first; r <= res.Rounds; r++ { + if n := len(log.results[probeKey{r, h.TTL}]); n != 1 { + t.Errorf("hop %d round %d: resolved %d times, want 1", h.TTL, r, n) + } + } + // A hop that answered and then never again is suspicious: either the + // path changed or replies are being mismatched/expired. + lastReply, firstReply := 0, 0 + for r := 1; r <= res.Rounds; r++ { + if evs := log.results[probeKey{r, h.TTL}]; len(evs) == 1 && evs[0].outcome == outcomeReply { + if firstReply == 0 { + firstReply = r + } + lastReply = r + } + } + if firstReply > 0 && res.Rounds-lastReply >= 5 { + // Routers rate-limit Time Exceeded (verified against the system + // traceroute), so this is only a problem if the loss also shows + // at the destination. + msg := fmt.Sprintf("hop %d answered in rounds %d..%d then stayed silent for %d rounds", + h.TTL, firstReply, lastReply, res.Rounds-lastReply) + if res.HasRateLimitedHops() { + t.Log(msg + " (router ICMP rate limiting: destination is clean)") + } else { + t.Error(msg) + } + } + } + if log.late > 0 { + t.Errorf("%d replies arrived after their probe was counted as lost", log.late) + } + if !res.Reached { + t.Errorf("destination never replied") + } +} diff --git a/internal/trace/packet.go b/internal/trace/packet.go new file mode 100644 index 0000000..21a1159 --- /dev/null +++ b/internal/trace/packet.go @@ -0,0 +1,111 @@ +package trace + +import ( + "encoding/binary" + "errors" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +const ( + protocolICMP = 1 + protocolICMPv6 = 58 + + ipv4MinHeaderLen = 20 + ipv6HeaderLen = 40 + icmpEchoHdrLen = 8 +) + +// replyKind classifies a received ICMP message relevant to tracing +type replyKind int + +const ( + kindEchoReply replyKind = iota + 1 // destination answered + kindTimeExceed // an intermediate router answered + kindUnreachable // a router reported the destination unreachable +) + +// reply is a parsed ICMP message matched back to the echo request we sent. +type reply struct { + kind replyKind + id uint16 + seq uint16 +} + +var errNotOurs = errors.New("not a reply to an echo request") + +// parseReply parses an ICMP message (without the outer IP header). For +// error messages (Time Exceeded / Destination Unreachable) the echo ID and +// sequence are taken from the quoted original datagram. +func parseReply(v6 bool, b []byte) (reply, error) { + proto := protocolICMP + if v6 { + proto = protocolICMPv6 + } + m, err := icmp.ParseMessage(proto, b) + if err != nil { + return reply{}, err + } + + switch m.Type { + case ipv4.ICMPTypeEchoReply, ipv6.ICMPTypeEchoReply: + echo, ok := m.Body.(*icmp.Echo) + if !ok { + return reply{}, errNotOurs + } + return reply{kind: kindEchoReply, id: uint16(echo.ID), seq: uint16(echo.Seq)}, nil + case ipv4.ICMPTypeTimeExceeded, ipv6.ICMPTypeTimeExceeded: + body, ok := m.Body.(*icmp.TimeExceeded) + if !ok { + return reply{}, errNotOurs + } + return parseQuoted(v6, body.Data, kindTimeExceed) + case ipv4.ICMPTypeDestinationUnreachable, ipv6.ICMPTypeDestinationUnreachable: + body, ok := m.Body.(*icmp.DstUnreach) + if !ok { + return reply{}, errNotOurs + } + return parseQuoted(v6, body.Data, kindUnreachable) + } + return reply{}, errNotOurs +} + +// parseQuoted extracts the echo ID/seq from the original datagram quoted in +// an ICMP error: the original IP header followed by at least 8 bytes of the +// original ICMP echo request. +func parseQuoted(v6 bool, data []byte, kind replyKind) (reply, error) { + var hdrLen int + if v6 { + // Extension headers are not used for our probes + if len(data) < ipv6HeaderLen || data[6] != protocolICMPv6 { + return reply{}, errNotOurs + } + hdrLen = ipv6HeaderLen + } else { + if len(data) < ipv4MinHeaderLen || data[9] != protocolICMP { + return reply{}, errNotOurs + } + hdrLen = int(data[0]&0x0f) * 4 + if hdrLen < ipv4MinHeaderLen { + return reply{}, errNotOurs + } + } + if len(data) < hdrLen+icmpEchoHdrLen { + return reply{}, errNotOurs + } + inner := data[hdrLen:] + echoType := byte(ipv4.ICMPTypeEcho) + if v6 { + echoType = byte(ipv6.ICMPTypeEchoRequest) + } + if inner[0] != echoType { + return reply{}, errNotOurs + } + return reply{ + kind: kind, + id: binary.BigEndian.Uint16(inner[4:6]), + seq: binary.BigEndian.Uint16(inner[6:8]), + }, nil +} diff --git a/internal/trace/packet_test.go b/internal/trace/packet_test.go new file mode 100644 index 0000000..ebdee61 --- /dev/null +++ b/internal/trace/packet_test.go @@ -0,0 +1,124 @@ +package trace + +import ( + "testing" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +func echoRequest(t *testing.T, v6 bool, id, seq int) []byte { + t.Helper() + m := icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: id, Seq: seq, Data: []byte("x")}} + if v6 { + m.Type = ipv6.ICMPTypeEchoRequest + } + b, err := m.Marshal(nil) + if err != nil { + t.Fatal(err) + } + return b +} + +// quoted builds the original datagram quoted inside an ICMP error +func quoted(t *testing.T, v6 bool, ihl int, id, seq int) []byte { + t.Helper() + echo := echoRequest(t, v6, id, seq) + if v6 { + hdr := make([]byte, ipv6HeaderLen) + hdr[0] = 0x60 + hdr[6] = protocolICMPv6 + return append(hdr, echo[:8]...) + } + hdr := make([]byte, ihl) + hdr[0] = 0x40 | byte(ihl/4) + hdr[9] = protocolICMP + return append(hdr, echo[:8]...) +} + +func marshal(t *testing.T, m icmp.Message) []byte { + t.Helper() + b, err := m.Marshal(nil) + if err != nil { + t.Fatal(err) + } + return b +} + +func TestParseReply(t *testing.T) { + tests := []struct { + name string + v6 bool + msg []byte + want reply + }{ + { + name: "v4 echo reply", + msg: marshal(t, icmp.Message{Type: ipv4.ICMPTypeEchoReply, Body: &icmp.Echo{ID: 0x1234, Seq: 7}}), + want: reply{kind: kindEchoReply, id: 0x1234, seq: 7}, + }, + { + name: "v4 time exceeded", + msg: marshal(t, icmp.Message{Type: ipv4.ICMPTypeTimeExceeded, Body: &icmp.TimeExceeded{Data: quoted(t, false, 20, 0x1234, 3)}}), + want: reply{kind: kindTimeExceed, id: 0x1234, seq: 3}, + }, + { + name: "v4 time exceeded with IP options (IHL=6)", + msg: marshal(t, icmp.Message{Type: ipv4.ICMPTypeTimeExceeded, Body: &icmp.TimeExceeded{Data: quoted(t, false, 24, 0xbeef, 65535)}}), + want: reply{kind: kindTimeExceed, id: 0xbeef, seq: 65535}, + }, + { + name: "v4 destination unreachable", + msg: marshal(t, icmp.Message{Type: ipv4.ICMPTypeDestinationUnreachable, Code: 1, Body: &icmp.DstUnreach{Data: quoted(t, false, 20, 1, 9)}}), + want: reply{kind: kindUnreachable, id: 1, seq: 9}, + }, + { + name: "v6 echo reply", + v6: true, + msg: marshal(t, icmp.Message{Type: ipv6.ICMPTypeEchoReply, Body: &icmp.Echo{ID: 42, Seq: 5}}), + want: reply{kind: kindEchoReply, id: 42, seq: 5}, + }, + { + name: "v6 time exceeded", + v6: true, + msg: marshal(t, icmp.Message{Type: ipv6.ICMPTypeTimeExceeded, Body: &icmp.TimeExceeded{Data: quoted(t, true, 0, 42, 11)}}), + want: reply{kind: kindTimeExceed, id: 42, seq: 11}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseReply(tt.v6, tt.msg) + if err != nil { + t.Fatalf("parseReply() error = %v", err) + } + if got != tt.want { + t.Errorf("parseReply() = %+v, want %+v", got, tt.want) + } + }) + } +} + +func TestParseReplyRejects(t *testing.T) { + // Quoted datagram is a UDP packet (e.g. someone else's traceroute) + udp := quoted(t, false, 20, 1, 1) + udp[9] = 17 + // Quoted echo *reply* instead of request + notRequest := quoted(t, false, 20, 1, 1) + notRequest[20] = byte(ipv4.ICMPTypeEchoReply) + + tests := map[string][]byte{ + "echo request": echoRequest(t, false, 1, 1), + "quoted udp": marshal(t, icmp.Message{Type: ipv4.ICMPTypeTimeExceeded, Body: &icmp.TimeExceeded{Data: udp}}), + "quoted not echo": marshal(t, icmp.Message{Type: ipv4.ICMPTypeTimeExceeded, Body: &icmp.TimeExceeded{Data: notRequest}}), + "truncated quote": marshal(t, icmp.Message{Type: ipv4.ICMPTypeTimeExceeded, Body: &icmp.TimeExceeded{Data: quoted(t, false, 20, 1, 1)[:24]}}), + "garbage": {0xff}, + } + for name, msg := range tests { + t.Run(name, func(t *testing.T) { + if got, err := parseReply(false, msg); err == nil { + t.Errorf("expected error, got %+v", got) + } + }) + } +} diff --git a/internal/trace/target.go b/internal/trace/target.go new file mode 100644 index 0000000..b237179 --- /dev/null +++ b/internal/trace/target.go @@ -0,0 +1,75 @@ +package trace + +import ( + "fmt" + "net" + "net/url" + "strings" +) + +// HostFromTarget extracts the host to trace from an mping target or display +// name. It understands: +// +// 8.8.8.8, example.com plain hosts +// example.com(93.184.216.34) ICMP display names (the IP is used) +// icmpv6://example.com forces IPv6 +// tcp://host:443, https://host/p URL-style targets of any prober +// dns://8.8.8.8/example.com/A the DNS server is traced +// +// preferV6 reports whether the target explicitly asks for IPv6. +func HostFromTarget(target string) (host string, preferV6 bool) { + target = strings.TrimSpace(target) + + // ICMP display names: "name(ip)" + if open := strings.LastIndex(target, "("); open > 0 && strings.HasSuffix(target, ")") { + if ip := net.ParseIP(target[open+1 : len(target)-1]); ip != nil { + return ip.String(), ip.To4() == nil + } + } + + if scheme, rest, ok := strings.Cut(target, "://"); ok { + preferV6 = scheme == "icmpv6" + if u, err := url.Parse(target); err == nil && u.Hostname() != "" { + return u.Hostname(), preferV6 + } + // dns:///example.com has an empty host; fall back to the raw rest + rest = strings.TrimPrefix(rest, "/") + host, _, _ = strings.Cut(rest, "/") + return stripPort(host), preferV6 + } + + // Legacy "icmpv4:host" / "icmpv6:host" (but not bare IPv6 addresses) + if net.ParseIP(target) == nil { + if scheme, rest, ok := strings.Cut(target, ":"); ok && strings.HasPrefix(scheme, "icmp") { + return rest, scheme == "icmpv6" + } + } + return stripPort(target), false +} + +func stripPort(hostport string) string { + if h, _, err := net.SplitHostPort(hostport); err == nil { + return h + } + return strings.Trim(hostport, "[]") +} + +// Resolve resolves host to an IP address, preferring IPv4 unless preferV6. +func Resolve(host string, preferV6 bool) (net.IP, error) { + if ip := net.ParseIP(host); ip != nil { + return ip, nil + } + order := []string{"ip4", "ip6"} + if preferV6 { + order = []string{"ip6", "ip4"} + } + var lastErr error + for _, network := range order { + addr, err := net.ResolveIPAddr(network, host) + if err == nil { + return addr.IP, nil + } + lastErr = err + } + return nil, fmt.Errorf("failed to resolve %q: %w", host, lastErr) +} diff --git a/internal/trace/target_test.go b/internal/trace/target_test.go new file mode 100644 index 0000000..145e67b --- /dev/null +++ b/internal/trace/target_test.go @@ -0,0 +1,43 @@ +package trace + +import "testing" + +func TestHostFromTarget(t *testing.T) { + tests := []struct { + in string + host string + preferV6 bool + }{ + {"8.8.8.8", "8.8.8.8", false}, + {"example.com", "example.com", false}, + {"example.com(93.184.216.34)", "93.184.216.34", false}, + {"example.com(2606:2800:220:1::1)", "2606:2800:220:1::1", true}, + {"icmpv4://example.com", "example.com", false}, + {"icmpv6://example.com", "example.com", true}, + {"icmpv6:example.com", "example.com", true}, + {"tcp://example.com:443", "example.com", false}, + {"tcp://[2001:db8::1]:443", "2001:db8::1", false}, + {"https://example.com/health?x=1", "example.com", false}, + {"http://127.0.0.1:8080/", "127.0.0.1", false}, + {"dns://8.8.8.8/example.com/A", "8.8.8.8", false}, + {"dns://1.1.1.1:5353/example.com", "1.1.1.1", false}, + {"ntp://pool.ntp.org", "pool.ntp.org", false}, + {"2001:db8::1", "2001:db8::1", false}, + {"my-ping://10.0.0.1", "10.0.0.1", false}, + } + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + host, v6 := HostFromTarget(tt.in) + if host != tt.host || v6 != tt.preferV6 { + t.Errorf("HostFromTarget(%q) = (%q, %v), want (%q, %v)", tt.in, host, v6, tt.host, tt.preferV6) + } + }) + } +} + +func TestResolveLiteral(t *testing.T) { + ip, err := Resolve("192.0.2.1", true) + if err != nil || ip.String() != "192.0.2.1" { + t.Errorf("Resolve literal = %v, %v", ip, err) + } +} diff --git a/internal/trace/trace.go b/internal/trace/trace.go new file mode 100644 index 0000000..758e817 --- /dev/null +++ b/internal/trace/trace.go @@ -0,0 +1,492 @@ +// Package trace implements an MTR-style path tracer: it repeatedly sends ICMP +// echo requests with increasing TTL and keeps per-hop loss and RTT statistics. +package trace + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "runtime" + "sync" + "sync/atomic" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +const ( + DefaultInterval = time.Second + DefaultTimeout = time.Second + + maxHops = 30 + maxPacketSize = 1500 + + // maxUnknownHops is how many TTLs past the farthest hop that has replied + // are probed while the destination is unknown. Home routers treat a + // burst of probes up to maxHops every round as an ICMP flood and drop + // ICMP for every host behind them. mtr stops after 5 unknown hops; this + // allows more because many routers only answer occasionally. + maxUnknownHops = 8 +) + +// idCounter makes echo IDs unique per tracer within the process, and distinct +// from the ICMP prober which uses pid&0xffff. +var idCounter atomic.Uint32 + +// Config configures a Tracer +type Config struct { + Interval time.Duration // time between rounds + Timeout time.Duration // a probe without reply after this is lost + NoDNS bool // skip reverse DNS lookups of hop addresses +} + +func (c Config) withDefaults() Config { + if c.Interval <= 0 { + c.Interval = DefaultInterval + } + if c.Timeout <= 0 { + c.Timeout = DefaultTimeout + } + return c +} + +// Hop is a snapshot of statistics for one TTL +type Hop struct { + TTL int + Addr string // most recent responder ("" if none yet) + Name string // reverse DNS name of Addr, if resolved + Others int // number of other responders seen (ECMP / path changes) + Sent int // probes that have been answered or timed out + Recv int + Last time.Duration + Avg time.Duration + Best time.Duration + Worst time.Duration +} + +// Loss returns the loss percentage of settled probes +func (h Hop) Loss() float64 { + if h.Sent == 0 { + return 0 + } + return float64(h.Sent-h.Recv) / float64(h.Sent) * 100 +} + +// Result is a snapshot of a trace +type Result struct { + Dst net.IP + Rounds int + Hops []Hop + Reached bool // the destination (or an unreachable report) ended the path + Privileged bool // raw socket in use; false means datagram ICMP fallback +} + +// HasRateLimitedHops reports whether an intermediate hop shows loss while the +// destination does not. That is almost always ICMP rate limiting on the +// router (Time Exceeded generation is low priority), not real packet loss. +func (r Result) HasRateLimitedHops() bool { + if !r.Reached || len(r.Hops) < 2 { + return false + } + final := r.Hops[len(r.Hops)-1] + if final.Sent == 0 || final.Loss() > 0 { + return false + } + for _, h := range r.Hops[:len(r.Hops)-1] { + if h.Recv > 0 && h.Loss() > 0 { + return true + } + } + return false +} + +type hopStats struct { + sent, recv int + last time.Duration + best time.Duration + worst time.Duration + sum float64 + addr string + addrs map[string]struct{} +} + +func (h *hopStats) record(addr string, rtt time.Duration) { + h.recv++ + h.last = rtt + if h.best == 0 || rtt < h.best { + h.best = rtt + } + if rtt > h.worst { + h.worst = rtt + } + h.sum += float64(rtt) + h.addr = addr + if h.addrs == nil { + h.addrs = map[string]struct{}{} + } + h.addrs[addr] = struct{}{} +} + +type inflight struct { + ttl int + round int + sent time.Time +} + +// probeEvent describes the outcome of a single probe. It is only used by +// tests (via Tracer.onEvent) to verify per-probe accounting. +type probeEvent struct { + round, ttl int + addr string + rtt time.Duration + outcome probeOutcome +} + +type probeOutcome int + +const ( + outcomeReply probeOutcome = iota + 1 + outcomeLost // expired without reply + outcomeLate // reply arrived after the probe was counted as lost +) + +// Tracer traces the path to a destination +type Tracer struct { + cfg Config + dst net.IP + v6 bool + privileged bool + conn *icmp.PacketConn + dstAddr net.Addr + id uint16 + checkID bool + + writeMu sync.Mutex // TTL is a socket option, so set+write must be atomic + + mu sync.Mutex + seq uint16 + pending map[uint16]inflight + hops []hopStats // index = ttl-1 + maxTTL int // shrinks to the destination's distance once known + reached bool + rounds int + names map[string]string // reverse DNS cache ("" = lookup in progress/failed) + + onEvent func(probeEvent) // test hook, called with mu held + + closeOnce sync.Once +} + +// New opens an ICMP socket for tracing target. It prefers a raw socket and +// falls back to an unprivileged datagram ICMP socket; on macOS the fallback +// still receives Time Exceeded messages, on Linux only the destination may +// be visible without privileges. +func New(target string, cfg Config) (*Tracer, error) { + cfg = cfg.withDefaults() + host, preferV6 := HostFromTarget(target) + dst, err := Resolve(host, preferV6) + if err != nil { + return nil, err + } + v6 := dst.To4() == nil + + t := &Tracer{ + cfg: cfg, + dst: dst, + v6: v6, + id: uint16(os.Getpid() + 1 + int(idCounter.Add(1))), + pending: map[uint16]inflight{}, + hops: make([]hopStats, maxHops), + maxTTL: maxHops, + names: map[string]string{}, + } + + rawNet, dgramNet, laddr := "ip4:icmp", "udp4", "0.0.0.0" + if v6 { + rawNet, dgramNet, laddr = "ip6:ipv6-icmp", "udp6", "::" + } + if c, err := icmp.ListenPacket(rawNet, laddr); err == nil { + t.conn, t.privileged = c, true + t.dstAddr = &net.IPAddr{IP: dst} + t.checkID = true + } else if c, err2 := icmp.ListenPacket(dgramNet, laddr); err2 == nil { + t.conn = c + t.dstAddr = &net.UDPAddr{IP: dst} + // Linux rewrites the echo ID of datagram ICMP sockets and demuxes + // replies per socket, so only check the ID elsewhere (e.g. macOS). + t.checkID = runtime.GOOS != "linux" + } else { + return nil, fmt.Errorf("cannot open ICMP socket (%v; %v): run as root or grant cap_net_raw", err, err2) + } + return t, nil +} + +// Close releases the socket +func (t *Tracer) Close() error { + var err error + t.closeOnce.Do(func() { err = t.conn.Close() }) + return err +} + +// Run sends `rounds` rounds of probes (forever when rounds <= 0) until ctx +// is done, then waits up to Timeout for outstanding replies. Run closes the +// tracer when it returns. +func (t *Tracer) Run(ctx context.Context, rounds int) error { + defer t.Close() + + recvDone := make(chan struct{}) + go func() { + defer close(recvDone) + t.receive() + }() + + ticker := time.NewTicker(t.cfg.Interval) + defer ticker.Stop() + var sendErr error + for i := 0; rounds <= 0 || i < rounds; i++ { + if i > 0 { + select { + case <-ctx.Done(): + return t.drain(recvDone, nil) + case <-ticker.C: + } + } + if err := t.sendRound(ctx); err != nil { + sendErr = err + break + } + } + + // Wait for replies to the last round + select { + case <-ctx.Done(): + case <-time.After(t.cfg.Timeout): + } + return t.drain(recvDone, sendErr) +} + +func (t *Tracer) drain(recvDone <-chan struct{}, err error) error { + t.Close() + <-recvDone + t.mu.Lock() + t.expire(time.Now().Add(t.cfg.Timeout)) // everything unanswered is lost + t.mu.Unlock() + return err +} + +// sendRound probes TTL 1..probeLimit, spread evenly over the interval (like +// mtr) so that a round never reaches routers as a burst. +func (t *Tracer) sendRound(ctx context.Context) error { + t.mu.Lock() + t.expire(time.Now()) + maxTTL := t.probeLimitLocked() + t.rounds++ + round := t.rounds + t.mu.Unlock() + + gap := t.cfg.Interval / time.Duration(maxTTL) + for ttl := 1; ttl <= maxTTL; ttl++ { + if ttl > 1 { + select { + case <-ctx.Done(): + return nil + case <-time.After(gap): + } + } + t.mu.Lock() + t.seq++ + seq := t.seq + t.mu.Unlock() + + msg := icmp.Message{Body: &icmp.Echo{ID: int(t.id), Seq: int(seq), Data: []byte("mping-trace")}} + if t.v6 { + msg.Type = ipv6.ICMPTypeEchoRequest + } else { + msg.Type = ipv4.ICMPTypeEcho + } + b, err := msg.Marshal(nil) + if err != nil { + return err + } + + t.writeMu.Lock() + if t.v6 { + err = t.conn.IPv6PacketConn().SetHopLimit(ttl) + } else { + err = t.conn.IPv4PacketConn().SetTTL(ttl) + } + if err == nil { + t.mu.Lock() + t.pending[seq] = inflight{ttl: ttl, round: round, sent: time.Now()} + t.mu.Unlock() + _, err = t.conn.WriteTo(b, t.dstAddr) + } + t.writeMu.Unlock() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return nil + } + return fmt.Errorf("send ttl=%d: %w", ttl, err) + } + } + return nil +} + +func (t *Tracer) receive() { + buf := make([]byte, maxPacketSize) + for { + n, peer, err := t.conn.ReadFrom(buf) + if err != nil { + return // closed + } + now := time.Now() + r, err := parseReply(t.v6, buf[:n]) + if err != nil { + continue + } + if t.checkID && r.id != t.id { + continue // another process or tracer + } + t.handle(r, peerIP(peer), now) + } +} + +func peerIP(a net.Addr) string { + switch v := a.(type) { + case *net.IPAddr: + return v.IP.String() + case *net.UDPAddr: + return v.IP.String() + } + return a.String() +} + +func (t *Tracer) handle(r reply, addr string, now time.Time) { + t.mu.Lock() + defer t.mu.Unlock() + p, ok := t.pending[r.seq] + if !ok { + if t.onEvent != nil { + t.onEvent(probeEvent{addr: addr, outcome: outcomeLate}) + } + return // late (already counted as lost) or unknown + } + delete(t.pending, r.seq) + + h := &t.hops[p.ttl-1] + h.sent++ + h.record(addr, now.Sub(p.sent)) + if t.onEvent != nil { + t.onEvent(probeEvent{round: p.round, ttl: p.ttl, addr: addr, rtt: now.Sub(p.sent), outcome: outcomeReply}) + } + + if r.kind == kindEchoReply || r.kind == kindUnreachable { + t.reached = true + if p.ttl < t.maxTTL { + t.maxTTL = p.ttl + } + } + if !t.cfg.NoDNS { + t.lookupLocked(addr) + } +} + +// farthestResponderLocked returns the highest TTL that has replied (0 if none) +func (t *Tracer) farthestResponderLocked() int { + last := 0 + for i := 0; i < t.maxTTL; i++ { + if t.hops[i].recv > 0 { + last = i + 1 + } + } + return last +} + +// probeLimitLocked returns the highest TTL to probe this round: the +// destination once it is known, otherwise maxUnknownHops past the farthest +// hop that has replied. +func (t *Tracer) probeLimitLocked() int { + if t.reached { + return t.maxTTL + } + return min(t.maxTTL, t.farthestResponderLocked()+maxUnknownHops) +} + +// expire counts pending probes sent before now-Timeout as lost +func (t *Tracer) expire(now time.Time) { + for seq, p := range t.pending { + if now.Sub(p.sent) >= t.cfg.Timeout { + t.hops[p.ttl-1].sent++ + delete(t.pending, seq) + if t.onEvent != nil { + t.onEvent(probeEvent{round: p.round, ttl: p.ttl, outcome: outcomeLost}) + } + } + } +} + +func (t *Tracer) lookupLocked(addr string) { + if _, ok := t.names[addr]; ok { + return + } + t.names[addr] = "" + go func() { + names, err := net.LookupAddr(addr) + if err != nil || len(names) == 0 { + return + } + name := names[0] + if len(name) > 1 && name[len(name)-1] == '.' { + name = name[:len(name)-1] + } + t.mu.Lock() + t.names[addr] = name + t.mu.Unlock() + }() +} + +// Snapshot returns the current statistics +func (t *Tracer) Snapshot() Result { + t.mu.Lock() + defer t.mu.Unlock() + t.expire(time.Now()) + + // Show hops up to the destination, or up to the farthest responder + last := t.farthestResponderLocked() + if t.reached { + last = t.maxTTL + } + + res := Result{ + Dst: t.dst, + Rounds: t.rounds, + Reached: t.reached, + Privileged: t.privileged, + Hops: make([]Hop, 0, last), + } + for i := 0; i < last; i++ { + h := t.hops[i] + hop := Hop{ + TTL: i + 1, + Addr: h.addr, + Name: t.names[h.addr], + Sent: h.sent, + Recv: h.recv, + Last: h.last, + Best: h.best, + Worst: h.worst, + } + if len(h.addrs) > 1 { + hop.Others = len(h.addrs) - 1 + } + if h.recv > 0 { + hop.Avg = time.Duration(h.sum / float64(h.recv)) + } + res.Hops = append(res.Hops, hop) + } + return res +} diff --git a/internal/trace/trace_test.go b/internal/trace/trace_test.go new file mode 100644 index 0000000..e43009d --- /dev/null +++ b/internal/trace/trace_test.go @@ -0,0 +1,112 @@ +package trace + +import ( + "context" + "testing" + "time" +) + +func TestTraceLoopback(t *testing.T) { + tr, err := New("127.0.0.1", Config{Interval: 50 * time.Millisecond, Timeout: 300 * time.Millisecond, NoDNS: true}) + if err != nil { + t.Skipf("ICMP socket unavailable in this environment: %v", err) + } + if err := tr.Run(context.Background(), 3); err != nil { + t.Fatalf("Run() error = %v", err) + } + + res := tr.Snapshot() + if !res.Reached { + t.Skip("no echo reply from loopback (ICMP may be filtered here)") + } + if res.Rounds != 3 { + t.Errorf("rounds = %d, want 3", res.Rounds) + } + if len(res.Hops) != 1 { + t.Fatalf("loopback should be a single hop, got %+v", res.Hops) + } + h := res.Hops[0] + if h.Addr != "127.0.0.1" || h.Recv == 0 || h.Sent < h.Recv || h.Best <= 0 || h.Best > h.Worst { + t.Errorf("unexpected hop: %+v", h) + } +} + +func TestTraceRunStopsOnCancel(t *testing.T) { + tr, err := New("127.0.0.1", Config{Interval: time.Hour, Timeout: time.Hour, NoDNS: true}) + if err != nil { + t.Skipf("ICMP socket unavailable in this environment: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- tr.Run(ctx, 0) }() + time.Sleep(50 * time.Millisecond) + cancel() + select { + case err := <-done: + if err != nil { + t.Errorf("Run() error = %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("Run did not stop after cancel") + } +} + +func TestHopLoss(t *testing.T) { + if l := (Hop{Sent: 4, Recv: 3}).Loss(); l != 25 { + t.Errorf("Loss() = %v, want 25", l) + } + if l := (Hop{}).Loss(); l != 0 { + t.Errorf("Loss() with no probes = %v, want 0", l) + } +} + +func TestHasRateLimitedHops(t *testing.T) { + mk := func(reached bool, hops ...Hop) Result { return Result{Reached: reached, Hops: hops} } + lossy := Hop{Addr: "a", Sent: 4, Recv: 2} + silent := Hop{Sent: 4} + clean := Hop{Addr: "d", Sent: 4, Recv: 4} + lossyDst := Hop{Addr: "d", Sent: 4, Recv: 3} + + tests := []struct { + name string + res Result + want bool + }{ + {"intermediate loss, clean destination", mk(true, lossy, clean), true}, + {"destination also lossy (real loss)", mk(true, lossy, lossyDst), false}, + {"silent hop only (no ICMP at all)", mk(true, silent, clean), false}, + {"destination not reached", mk(false, lossy, clean), false}, + {"single hop", mk(true, clean), false}, + } + for _, tt := range tests { + if got := tt.res.HasRateLimitedHops(); got != tt.want { + t.Errorf("%s: got %v, want %v", tt.name, got, tt.want) + } + } +} + +func TestProbeLimit(t *testing.T) { + withReplies := func(ttls ...int) *Tracer { + tr := &Tracer{hops: make([]hopStats, maxHops), maxTTL: maxHops} + for _, ttl := range ttls { + tr.hops[ttl-1].recv = 1 + } + return tr + } + + if got := withReplies().probeLimitLocked(); got != maxUnknownHops { + t.Errorf("no replies yet: limit = %d, want %d", got, maxUnknownHops) + } + if got := withReplies(1, 7).probeLimitLocked(); got != 7+maxUnknownHops { + t.Errorf("farthest reply at 7: limit = %d, want %d", got, 7+maxUnknownHops) + } + if got := withReplies(25).probeLimitLocked(); got != maxHops { + t.Errorf("limit must not exceed maxHops, got %d", got) + } + + reached := withReplies(1, 3) + reached.reached, reached.maxTTL = true, 12 + if got := reached.probeLimitLocked(); got != 12 { + t.Errorf("destination at 12: limit = %d, want 12", got) + } +} diff --git a/internal/ui/shared/export.go b/internal/ui/shared/export.go index b735dc0..1d0e987 100644 --- a/internal/ui/shared/export.go +++ b/internal/ui/shared/export.go @@ -7,11 +7,13 @@ import ( "io" "math" "slices" + "sort" "strconv" "strings" "time" "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" "github.com/servak/mping/internal/stats" ) @@ -48,16 +50,36 @@ type TargetReport struct { MinRTTMs float64 `json:"min_rtt_ms"` MaxRTTMs float64 `json:"max_rtt_ms"` JitterMs float64 `json:"jitter_ms"` + P50RTTMs float64 `json:"p50_rtt_ms"` + P95RTTMs float64 `json:"p95_rtt_ms"` + P99RTTMs float64 `json:"p99_rtt_ms"` LastSuccessAt *time.Time `json:"last_success_at,omitempty"` LastFailAt *time.Time `json:"last_fail_at,omitempty"` LastFailReason string `json:"last_fail_reason,omitempty"` + + OutageCount int `json:"outage_count"` + TotalDowntimeMs float64 `json:"total_downtime_ms"` + LongestOutageMs float64 `json:"longest_outage_ms"` + Outages []OutageReport `json:"outages"` +} + +// OutageReport is the machine-readable representation of an outage +type OutageReport struct { + Start time.Time `json:"start"` + End *time.Time `json:"end,omitempty"` // omitted while ongoing + DurationMs float64 `json:"duration_ms"` + LostProbes int `json:"lost_probes"` + Ongoing bool `json:"ongoing"` + LastError string `json:"last_error,omitempty"` } // csvHeader lists CSV columns; it must stay in sync with TargetReport.csvRecord var csvHeader = []string{ "host", "sent", "success", "fail", "loss_percent", "last_rtt_ms", "avg_rtt_ms", "min_rtt_ms", "max_rtt_ms", "jitter_ms", + "p50_rtt_ms", "p95_rtt_ms", "p99_rtt_ms", "last_success_at", "last_fail_at", "last_fail_reason", + "outage_count", "total_downtime_ms", "longest_outage_ms", } // csvRecord renders the report as a CSV row matching csvHeader @@ -73,14 +95,34 @@ func (r TargetReport) csvRecord() []string { formatFloat(r.MinRTTMs), formatFloat(r.MaxRTTMs), formatFloat(r.JitterMs), + formatFloat(r.P50RTTMs), + formatFloat(r.P95RTTMs), + formatFloat(r.P99RTTMs), formatRFC3339(r.LastSuccessAt), formatRFC3339(r.LastFailAt), r.LastFailReason, + strconv.Itoa(r.OutageCount), + formatFloat(r.TotalDowntimeMs), + formatFloat(r.LongestOutageMs), } } // NewTargetReport converts metrics into a TargetReport func NewTargetReport(m stats.Metrics) TargetReport { + now := time.Now() + pct := m.GetRTTPercentiles(50, 95, 99) + summary := m.GetOutageSummary() + outages := make([]OutageReport, 0) + for _, o := range m.GetOutages() { + outages = append(outages, OutageReport{ + Start: o.Start, + End: timePtr(o.End), + DurationMs: durationToMs(o.Duration(now)), + LostProbes: o.LostProbes, + Ongoing: o.Ongoing(), + LastError: o.LastError, + }) + } return TargetReport{ Host: m.GetName(), Sent: m.GetTotal(), @@ -92,9 +134,17 @@ func NewTargetReport(m stats.Metrics) TargetReport { MinRTTMs: durationToMs(m.GetMinimumRTT()), MaxRTTMs: durationToMs(m.GetMaximumRTT()), JitterMs: durationToMs(m.GetJitter()), + P50RTTMs: durationToMs(pct[0]), + P95RTTMs: durationToMs(pct[1]), + P99RTTMs: durationToMs(pct[2]), LastSuccessAt: timePtr(m.GetLastSuccTime()), LastFailAt: timePtr(m.GetLastFailTime()), LastFailReason: m.GetLastFailDetail(), + + OutageCount: summary.Count, + TotalDowntimeMs: durationToMs(summary.Total), + LongestOutageMs: durationToMs(summary.Longest), + Outages: outages, } } @@ -104,8 +154,10 @@ func WriteReport(w io.Writer, format string, metrics []stats.Metrics, sortKey st case FormatTable: t := NewTableData(metrics, sortKey, ascending).ToGoPrettyTable() t.SetStyle(table.StyleLight) - _, err := fmt.Fprintln(w, t.Render()) - return err + if _, err := fmt.Fprintln(w, t.Render()); err != nil { + return err + } + return WriteOutageTimeline(w, metrics) case FormatJSON: return writeJSON(w, metrics) case FormatCSV: @@ -139,6 +191,49 @@ func writeCSV(w io.Writer, metrics []stats.Metrics) error { return cw.Error() } +// WriteOutageTimeline renders every outage across all targets in start-time +// order. Nothing is written when no outage occurred. +func WriteOutageTimeline(w io.Writer, metrics []stats.Metrics) error { + type row struct { + host string + o stats.Outage + } + var rows []row + for _, m := range metrics { + for _, o := range m.GetOutages() { + rows = append(rows, row{host: m.GetName(), o: o}) + } + } + if len(rows) == 0 { + return nil + } + sort.SliceStable(rows, func(i, j int) bool { + return rows[i].o.Start.Before(rows[j].o.Start) + }) + + now := time.Now() + t := table.NewWriter() + t.SetStyle(table.StyleLight) + t.SetTitle(fmt.Sprintf("Outages (%d)", len(rows))) + t.AppendHeader(table.Row{"Start", "End", "Duration", "Lost", "Host", "Last Error"}) + for _, r := range rows { + end := "(ongoing)" + if !r.o.Ongoing() { + end = FormatClock(r.o.End) + } + t.AppendRow(table.Row{ + FormatClock(r.o.Start), end, FormatOutageDuration(r.o.Duration(now)), + r.o.LostProbes, r.host, r.o.LastError, + }) + } + t.SetColumnConfigs([]table.ColumnConfig{ + {Number: 3, Align: text.AlignRight}, + {Number: 4, Align: text.AlignRight}, + }) + _, err := fmt.Fprintln(w, t.Render()) + return err +} + func durationToMs(d time.Duration) float64 { // Round to microsecond precision to keep output readable return float64(d.Microseconds()) / 1000 diff --git a/internal/ui/shared/export_test.go b/internal/ui/shared/export_test.go index da6b62a..bdee45b 100644 --- a/internal/ui/shared/export_test.go +++ b/internal/ui/shared/export_test.go @@ -8,6 +8,7 @@ import ( "testing" "time" + "github.com/servak/mping/internal/prober" "github.com/servak/mping/internal/stats" ) @@ -66,15 +67,89 @@ func TestWriteReportCSV(t *testing.T) { if len(records) != 3 { t.Fatalf("expected header + 2 rows, got %d", len(records)) } - if records[0][0] != "host" || records[0][9] != "jitter_ms" { - t.Errorf("unexpected header: %v", records[0]) + col := map[string]int{} + for i, h := range records[0] { + col[h] = i } - if records[1][6] != "10" || records[1][10] != "2026-01-02T03:04:05Z" || records[1][11] != "" { - t.Errorf("unexpected healthy row: %v", records[1]) + for _, h := range []string{"host", "jitter_ms", "p95_rtt_ms", "outage_count", "last_fail_reason"} { + if _, ok := col[h]; !ok { + t.Errorf("header missing %q: %v", h, records[0]) + } + } + healthy := records[1] + if healthy[col["avg_rtt_ms"]] != "10" || healthy[col["last_success_at"]] != "2026-01-02T03:04:05Z" || healthy[col["last_fail_at"]] != "" { + t.Errorf("unexpected healthy row: %v", healthy) } // Fail reason containing a comma must round-trip intact - if records[2][12] != "timeout, no reply" { - t.Errorf("fail reason = %q", records[2][12]) + if got := records[2][col["last_fail_reason"]]; got != "timeout, no reply" { + t.Errorf("fail reason = %q", got) + } +} + +// metricsWithOutage builds metrics through the public event API: +// ok at t+0s, fail at t+1s..t+3s, ok at t+4s, fail at t+5s..t+7s (ongoing). +func metricsWithOutage(t *testing.T) []stats.Metrics { + t.Helper() + base := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + mm := stats.NewMetricsManager() + mm.ToggleBeep() + ch := make(chan *prober.Event, 16) + done := mm.Subscribe(ch) + ch <- &prober.Event{Key: "h", DisplayName: "flappy.example.com", Result: prober.REGISTER} + for i, ok := range []bool{true, false, false, false, true, false, false, false} { + ev := &prober.Event{Key: "h", SentTime: base.Add(time.Duration(i) * time.Second), Rtt: time.Millisecond} + if ok { + ev.Result = prober.SUCCESS + } else { + ev.Result, ev.Message = prober.TIMEOUT, "timeout" + } + ch <- ev + } + close(ch) + <-done + return mm.SortBy(stats.Host, true) +} + +func TestWriteReportJSONOutages(t *testing.T) { + var buf bytes.Buffer + if err := WriteReport(&buf, FormatJSON, metricsWithOutage(t), stats.Host, true); err != nil { + t.Fatal(err) + } + var got []TargetReport + if err := json.Unmarshal(buf.Bytes(), &got); err != nil { + t.Fatal(err) + } + r := got[0] + if r.OutageCount != 2 || len(r.Outages) != 2 { + t.Fatalf("want 2 outages, got %+v", r) + } + first, second := r.Outages[0], r.Outages[1] + if first.DurationMs != 3000 || first.LostProbes != 3 || first.Ongoing || first.End == nil { + t.Errorf("first outage = %+v", first) + } + if !second.Ongoing || second.End != nil { + t.Errorf("second outage should be ongoing: %+v", second) + } +} + +func TestWriteReportTableOutageTimeline(t *testing.T) { + var buf bytes.Buffer + if err := WriteReport(&buf, FormatTable, metricsWithOutage(t), stats.Host, true); err != nil { + t.Fatal(err) + } + out := buf.String() + for _, want := range []string{"Outages (2)", "03:04:06.000", "03:04:09.000", "3.000s", "(ongoing)"} { + if !strings.Contains(out, want) { + t.Errorf("timeline missing %q:\n%s", want, out) + } + } + + buf.Reset() + if err := WriteReport(&buf, FormatTable, exportTestMetrics(), stats.Host, true); err != nil { + t.Fatal(err) + } + if strings.Contains(buf.String(), "Outages") { + t.Error("timeline must be omitted when there are no outages") } } diff --git a/internal/ui/shared/formatters.go b/internal/ui/shared/formatters.go index ec05cd6..3264074 100644 --- a/internal/ui/shared/formatters.go +++ b/internal/ui/shared/formatters.go @@ -2,6 +2,7 @@ package shared import ( "fmt" + "slices" "strings" "time" @@ -28,6 +29,19 @@ func TimeFormater(t time.Time) string { return t.Format("15:04:05") } +// FormatClock formats a time with millisecond precision for outage timelines +func FormatClock(t time.Time) string { + return t.Format("15:04:05.000") +} + +// FormatOutageDuration formats an outage length with millisecond precision +func FormatOutageDuration(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%.3fs", d.Seconds()) + } + return d.Round(time.Millisecond).String() +} + // FormatHostDetail generates detailed information for a host func FormatHostDetail(metric stats.Metrics, theme *Theme) string { // Color-coded basic statistics @@ -49,6 +63,8 @@ func FormatHostDetail(metric stats.Metrics, theme *Theme) string { failColor = theme.Error } + pct := metric.GetRTTPercentiles(50, 95, 99) + basicInfo := fmt.Sprintf(`[%s]Total Probes:[%s] %d [%s]Successful:[%s] %d [%s]Failed:[%s] %d @@ -58,6 +74,7 @@ func FormatHostDetail(metric stats.Metrics, theme *Theme) string { [%s]Minimum RTT:[%s] %s [%s]Maximum RTT:[%s] %s [%s]Jitter (stddev):[%s] %s +[%s]RTT p50/p95/p99:[%s] %s / %s / %s [%s]Last Success:[%s] %s [%s]Last Failure:[%s] %s [%s]Last Error:[%s] %s`, @@ -70,11 +87,14 @@ func FormatHostDetail(metric stats.Metrics, theme *Theme) string { theme.Accent, theme.Primary, DurationFormater(metric.GetMinimumRTT()), theme.Accent, theme.Primary, DurationFormater(metric.GetMaximumRTT()), theme.Accent, theme.Primary, DurationFormater(metric.GetJitter()), + theme.Accent, theme.Primary, DurationFormater(pct[0]), DurationFormater(pct[1]), DurationFormater(pct[2]), theme.Accent, theme.Primary, TimeFormater(metric.GetLastSuccTime()), theme.Accent, theme.Primary, TimeFormater(metric.GetLastFailTime()), theme.Accent, theme.Primary, metric.GetLastFailDetail(), ) + basicInfo += "\n\n" + FormatOutages(metric, theme) + // Add history section historySection := FormatHistory(metric, theme) if historySection != "" { @@ -84,6 +104,47 @@ func FormatHostDetail(metric stats.Metrics, theme *Theme) string { return basicInfo } +// maxDetailOutages is the number of recent outages listed in host details +const maxDetailOutages = 5 + +// FormatOutages generates the outage section for a host +func FormatOutages(metric stats.Metrics, theme *Theme) string { + summary := metric.GetOutageSummary() + if summary.Count == 0 { + return fmt.Sprintf("[%s]Outages:[%s] none", theme.Accent, theme.Primary) + } + + var sb strings.Builder + state := "" + if summary.Ongoing { + state = fmt.Sprintf(" [%s]DOWN[%s]", theme.Error, theme.Primary) + } + fmt.Fprintf(&sb, "[%s]Outages:[%s] %d (total %s, longest %s)%s\n", + theme.Accent, theme.Primary, summary.Count, + FormatOutageDuration(summary.Total), FormatOutageDuration(summary.Longest), state) + fmt.Fprintf(&sb, "[%s]Start End Duration Lost[%s]\n", theme.Accent, theme.Primary) + + now := time.Now() + outages := metric.GetOutages() + for i := len(outages) - 1; i >= 0 && i >= len(outages)-maxDetailOutages; i-- { + o := outages[i] + end := "(ongoing) " + color := theme.Primary + if o.Ongoing() { + color = theme.Error + } else { + end = FormatClock(o.End) + } + fmt.Fprintf(&sb, "[%s]%s[%s] [%s]%s %9s %5d[%s]\n", + theme.Timestamp, FormatClock(o.Start), theme.Primary, + color, end, FormatOutageDuration(o.Duration(now)), o.LostProbes, theme.Primary) + } + if len(outages) > maxDetailOutages { + fmt.Fprintf(&sb, "[%s]... %d more[%s]\n", theme.Secondary, len(outages)-maxDetailOutages, theme.Primary) + } + return strings.TrimRight(sb.String(), "\n") +} + // FormatHistory generates history section for a host func FormatHistory(metric stats.Metrics, theme *Theme) string { history := metric.GetRecentHistory(10) @@ -216,3 +277,130 @@ func extractDNSAnswer(record string) string { return answer } + +// FormatLastFailCell renders the LastFailTime cell for the host list with the +// duration of the outage the last failure belongs to (tview color tags): +// +// "16:44:20 (DOWN 5.20s)" red, while the outage is ongoing +// "16:44:15 (0.50s)" yellow, after it recovered +// "16:44:15" a failure not yet applied to outage detection +// "-" no failure +func FormatLastFailCell(m stats.Metrics, now time.Time, theme *Theme) string { + lastFail := m.GetLastFailTime() + if lastFail.IsZero() { + return "-" + } + clock := TimeFormater(lastFail) + last, ok := m.GetLastOutage() + if !ok { + return clock + } + switch { + case last.Ongoing(): + return fmt.Sprintf("[%s]%s (DOWN %s)[-]", theme.Error, clock, FormatShortDuration(last.Duration(now))) + case !lastFail.Before(last.Start) && lastFail.Before(last.End): + return fmt.Sprintf("[%s]%s (%s)[-]", theme.Warning, clock, FormatShortDuration(last.Duration(now))) + } + return clock +} + +// FormatShortDuration formats a duration compactly for table cells +func FormatShortDuration(d time.Duration) string { + switch { + case d < 10*time.Second: + return fmt.Sprintf("%.2fs", d.Seconds()) + case d < time.Minute: + return fmt.Sprintf("%.1fs", d.Seconds()) + case d < time.Hour: + return fmt.Sprintf("%dm%02ds", int(d.Minutes()), int(d.Seconds())%60) + } + return fmt.Sprintf("%dh%02dm", int(d.Hours()), int(d.Minutes())%60) +} + +// HistoryWidth is the number of recent probes shown in the host list +const HistoryWidth = 20 + +var sparkLevels = []rune("โ–โ–‚โ–ƒโ–„โ–…โ–†โ–‡โ–ˆ") + +// FormatSparkline renders the newest `width` probe results (history is newest +// first, as returned by GetRecentHistory) as an oldest-to-newest strip with +// tview color tags. Bar height shows how many milliseconds an RTT exceeds the +// median of the whole given history (see sparkLevel), so a host's normal +// jitter stays flat regardless of its baseline; failures are shown as 'ร—'. +func FormatSparkline(history []stats.HistoryEntry, width int, theme *Theme) string { + if width <= 0 { + return "" + } + + var rtts []time.Duration + for _, e := range history { + if e.Success { + rtts = append(rtts, e.RTT) + } + } + var median time.Duration + if len(rtts) > 0 { + slices.Sort(rtts) + median = rtts[(len(rtts)-1)/2] + } + if len(history) > width { + history = history[:width] + } + + var sb strings.Builder + sb.WriteString(strings.Repeat(" ", width-len(history))) + lastColor := "" + setColor := func(c string) { + if c != lastColor { + fmt.Fprintf(&sb, "[%s]", c) + lastColor = c + } + } + for i := len(history) - 1; i >= 0; i-- { + e := history[i] + if !e.Success { + setColor(theme.Error) + sb.WriteRune('ร—') + continue + } + level := sparkLevel(e.RTT, median) + if level >= sparkWarnLevel { + setColor(theme.Warning) + } else { + setColor(theme.Success) + } + sb.WriteRune(sparkLevels[level]) + } + if lastColor != "" { + sb.WriteString("[-]") + } + return sb.String() +} + +// sparkThresholds are the RTT increases over the median at which the bar +// grows one level (index+1). An absolute scale keeps small changes on fast +// links (10ms -> 30ms) calm, while treating +20ms the same on any baseline. +var sparkThresholds = []time.Duration{ + 5 * time.Millisecond, + 10 * time.Millisecond, + 20 * time.Millisecond, + 50 * time.Millisecond, + 100 * time.Millisecond, // sparkWarnLevel + 200 * time.Millisecond, + 500 * time.Millisecond, +} + +// sparkWarnLevel is the first level drawn in the warning color (+100ms) +const sparkWarnLevel = 5 + +// sparkLevel maps the RTT increase over the median to a bar level +func sparkLevel(rtt, median time.Duration) int { + excess := rtt - median + level := 0 + for i, th := range sparkThresholds { + if excess >= th { + level = i + 1 + } + } + return level +} diff --git a/internal/ui/shared/formatters_test.go b/internal/ui/shared/formatters_test.go index 1e24ca6..411df25 100644 --- a/internal/ui/shared/formatters_test.go +++ b/internal/ui/shared/formatters_test.go @@ -1,9 +1,11 @@ package shared import ( + "strings" "testing" "time" + "github.com/servak/mping/internal/prober" "github.com/servak/mping/internal/stats" ) @@ -212,3 +214,132 @@ func findSubstring(s, substr string) bool { } return false } + +func TestFormatSparkline(t *testing.T) { + theme := &Theme{Success: "green", Warning: "yellow", Error: "red"} + ms := func(n int) time.Duration { return time.Duration(n) * time.Millisecond } + // GetRecentHistory order: newest first + history := []stats.HistoryEntry{ + {Success: true, RTT: ms(40)}, // newest: +30ms over the 10ms median + {Success: false}, + {Success: true, RTT: ms(10)}, + {Success: true, RTT: ms(11)}, // normal jitter stays at the lowest bar + {Success: true, RTT: ms(10)}, // oldest + } + + got := FormatSparkline(history, 8, theme) + want := " [green]โ–โ–โ–[red]ร—[green]โ–„[-]" + if got != want { + t.Errorf("FormatSparkline() = %q, want %q", got, want) + } + + // Truncated to width, keeping the newest entries + if got := FormatSparkline(history, 2, theme); got != "[red]ร—[green]โ–„[-]" { + t.Errorf("truncated sparkline = %q", got) + } + if got := FormatSparkline(nil, 3, theme); got != " " { + t.Errorf("empty history should be blank padding, got %q", got) + } +} + +func TestSparkLevel(t *testing.T) { + ms := func(n int) time.Duration { return time.Duration(n) * time.Millisecond } + tests := []struct { + name string + rtt, median time.Duration + want int + }{ + {"faster than usual", ms(8), ms(10), 0}, + {"normal jitter", ms(14), ms(10), 0}, + {"+5ms boundary", ms(15), ms(10), 1}, + {"10ms -> 30ms is only +20ms", ms(30), ms(10), 3}, + {"same +20ms on a 150ms baseline", ms(170), ms(150), 3}, + {"+99ms stays below warning", ms(249), ms(150), 4}, + {"+100ms boundary is warning", ms(250), ms(150), sparkWarnLevel}, + {"150ms -> 400ms", ms(400), ms(150), 6}, + {"+500ms and beyond is the top bar", ms(2000), ms(10), 7}, + } + for _, tt := range tests { + if got := sparkLevel(tt.rtt, tt.median); got != tt.want { + t.Errorf("%s: sparkLevel(%v, %v) = %d, want %d", tt.name, tt.rtt, tt.median, got, tt.want) + } + } +} + +func TestFormatSparklineWarnsOnLargeIncrease(t *testing.T) { + theme := &Theme{Success: "green", Warning: "yellow", Error: "red"} + ms := func(n int) time.Duration { return time.Duration(n) * time.Millisecond } + history := []stats.HistoryEntry{ // newest first + {Success: true, RTT: ms(400)}, + {Success: true, RTT: ms(150)}, + {Success: true, RTT: ms(150)}, + } + if got := FormatSparkline(history, 3, theme); got != "[green]โ–โ–[yellow]โ–‡[-]" { + t.Errorf("FormatSparkline() = %q", got) + } +} + +func TestFormatOutages(t *testing.T) { + theme := PredefinedThemes["dark"] + none := stats.NewMetricsForTest("h", 10, 0, 0, 0, 0, 0, 0, 0, 0, 0, time.Time{}, time.Time{}, "") + if got := FormatOutages(none, &theme); !strings.Contains(got, "none") { + t.Errorf("expected 'none', got %q", got) + } +} + +func TestFormatLastFailCell(t *testing.T) { + theme := &Theme{Warning: "yellow", Error: "red"} + base := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC) + + // build feeds one probe per second: 'S' success, 'F' failure + build := func(pattern string) stats.Metrics { + mm := stats.NewMetricsManager() + mm.ToggleBeep() + ch := make(chan *prober.Event, len(pattern)+1) + ch <- &prober.Event{Key: "h", DisplayName: "h", Result: prober.REGISTER} + for i, c := range pattern { + ev := &prober.Event{Key: "h", SentTime: base.Add(time.Duration(i) * time.Second)} + if c == 'S' { + ev.Result = prober.SUCCESS + } else { + ev.Result, ev.Message = prober.TIMEOUT, "timeout" + } + ch <- ev + } + close(ch) + <-mm.Subscribe(ch) + return mm.SortBy(stats.Host, true)[0] + } + + now := base.Add(20 * time.Second) + tests := []struct { + name string + pattern string + want string + }{ + {"no failure", "SSS", "-"}, + {"recovered outage", "SFFFS", "[yellow]10:00:03 (3.00s)[-]"}, + {"ongoing outage", "SSFFF", "[red]10:00:04 (DOWN 18.0s)[-]"}, + {"loss shorter than an outage", "SSFS", "10:00:02"}, + {"isolated failure after an outage", "SFFFSSFS", "10:00:06"}, + } + for _, tt := range tests { + if got := FormatLastFailCell(build(tt.pattern), now, theme); got != tt.want { + t.Errorf("%s: FormatLastFailCell(%s) = %q, want %q", tt.name, tt.pattern, got, tt.want) + } + } +} + +func TestFormatShortDuration(t *testing.T) { + tests := map[time.Duration]string{ + 1600 * time.Millisecond: "1.60s", + 36700 * time.Millisecond: "36.7s", + 2*time.Minute + 3*time.Second: "2m03s", + 3*time.Hour + 5*time.Minute: "3h05m", + } + for d, want := range tests { + if got := FormatShortDuration(d); got != want { + t.Errorf("FormatShortDuration(%v) = %q, want %q", d, got, want) + } + } +} diff --git a/internal/ui/shared/table_data.go b/internal/ui/shared/table_data.go index c0220f1..457e411 100644 --- a/internal/ui/shared/table_data.go +++ b/internal/ui/shared/table_data.go @@ -11,6 +11,9 @@ import ( "github.com/servak/mping/internal/stats" ) +// ColumnLastFailTime is the index of the LastFailTime column in TableData +const ColumnLastFailTime = 10 + // TableData represents table data optimized for tview.Table with go-pretty fallback type TableData struct { Headers []string diff --git a/internal/ui/tui/app.go b/internal/ui/tui/app.go index 7e1dffe..0468665 100644 --- a/internal/ui/tui/app.go +++ b/internal/ui/tui/app.go @@ -60,6 +60,7 @@ func NewTUIApp(mm stats.MetricsManager, cfg *shared.Config, interval, timeout ti // Run starts the application func (a *TUIApp) Run() error { a.app.SetRoot(a.layout.GetRoot(), true).SetFocus(a.layout.GetRoot()) + defer a.layout.StopPathTrace() return a.app.Run() } @@ -139,6 +140,9 @@ func (a *TUIApp) setupKeyBindings() { case 'v': a.toggleDetailView() return nil + case 'p': + a.layout.TogglePathView() + return nil case 's': a.nextSort() return nil @@ -189,6 +193,7 @@ NAVIGATION: r Reverse sort order R Reset all metrics v Toggle detail view + p Toggle path (MTR) view / Filter hosts t Cycle theme b Toggle beep sound diff --git a/internal/ui/tui/layout.go b/internal/ui/tui/layout.go index d7f3e69..a65cb9b 100644 --- a/internal/ui/tui/layout.go +++ b/internal/ui/tui/layout.go @@ -18,6 +18,7 @@ type DisplayMode int const ( ListOnly DisplayMode = iota ListWithDetail + ListWithPath ) // LayoutManager manages screen layout and panel arrangement @@ -31,6 +32,10 @@ type LayoutManager struct { hostList *panels.HostListPanel footer *panels.FooterPanel hostDetail *panels.HostDetailPanel + path *panels.PathPanel + + // Background path trace for the selected host (ListWithPath mode) + pathTracer *pathTracer // Filter input filterInput *tview.InputField @@ -68,6 +73,8 @@ func (l *LayoutManager) setupPanels(uiState *state.UIState, mm stats.MetricsProv l.hostList = panels.NewHostListPanel(uiState, mm, config) l.footer = panels.NewFooterPanel(config) l.hostDetail = panels.NewHostDetailPanel(config) + l.path = panels.NewPathPanel(config) + l.pathTracer = newPathTracer(interval, timeout) // Setup filter input with theme-aware colors theme := config.GetTheme() @@ -117,46 +124,58 @@ func (l *LayoutManager) SetSelectedMetrics(metrics stats.Metrics) { l.hostDetail.SetMetrics(metrics) } -// ToggleDetailView switches between single and dual pane layout +// ToggleDetailView switches between single pane and list+detail layout func (l *LayoutManager) ToggleDetailView() { - if l.mode == ListOnly { - l.showDetailView() + if l.mode == ListWithDetail { + l.setMode(ListOnly) } else { - l.hideDetailView() + l.setMode(ListWithDetail) } } -// showDetailView switches to dual pane layout -func (l *LayoutManager) showDetailView() { - l.mode = ListWithDetail - - // Get currently selected metrics and set them in the detail panel - selectedMetrics := l.hostList.GetSelectedMetrics() - if selectedMetrics != nil { - l.hostDetail.SetMetrics(selectedMetrics) +// TogglePathView switches between single pane and list+path layout +func (l *LayoutManager) TogglePathView() { + if l.mode == ListWithPath { + l.setMode(ListOnly) + } else { + l.setMode(ListWithPath) } +} - // Create horizontal layout for host list and detail - mainContent := tview.NewFlex(). - SetDirection(tview.FlexColumn). - AddItem(l.hostList.GetView(), 0, 2, true). // Host list takes 2/3 - AddItem(l.hostDetail.GetView(), 0, 1, false) // Detail takes 1/3 - - // Rebuild root layout with dual pane - l.root.Clear() - l.root.AddItem(l.header.GetView(), 1, 0, false). - AddItem(mainContent, 0, 1, true). - AddItem(l.footer.GetView(), 1, 0, false) +// StopPathTrace stops any background path trace +func (l *LayoutManager) StopPathTrace() { + l.pathTracer.Stop() } -// hideDetailView switches to single pane layout -func (l *LayoutManager) hideDetailView() { - l.mode = ListOnly +// setMode rebuilds the root layout for the given display mode +func (l *LayoutManager) setMode(mode DisplayMode) { + l.mode = mode + if mode != ListWithPath { + l.pathTracer.Stop() + } + + content := l.hostList.GetView() + switch mode { + case ListWithDetail: + // Get currently selected metrics and set them in the detail panel + if m, ok := l.hostList.CurrentSelectedMetric(); ok { + l.hostDetail.SetMetrics(m) + } + content = tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(l.hostList.GetView(), 0, 2, true). // Host list takes 2/3 + AddItem(l.hostDetail.GetView(), 0, 1, false) // Detail takes 1/3 + case ListWithPath: + // The hop table needs more width than host details + content = tview.NewFlex(). + SetDirection(tview.FlexColumn). + AddItem(l.hostList.GetView(), 0, 1, true). + AddItem(l.path.GetView(), 0, 1, false) + } - // Rebuild root layout with single pane l.root.Clear() l.root.AddItem(l.header.GetView(), 1, 0, false). - AddItem(l.hostList.GetView(), 0, 1, true). + AddItem(content, 0, 1, true). AddItem(l.footer.GetView(), 1, 0, false) } @@ -166,9 +185,21 @@ func (l *LayoutManager) UpdateAll() { l.footer.Update() l.hostList.Update() - // Only update detail panel when it's visible - if l.mode == ListWithDetail { + // Metrics are snapshots; hand the visible side panel the fresh one + m, ok := l.hostList.CurrentSelectedMetric() + switch l.mode { + case ListWithDetail: + if ok { + l.hostDetail.SetMetrics(m) + } l.hostDetail.Update() + case ListWithPath: + target := "" + if ok { + target = m.GetName() + } + l.pathTracer.Follow(target) + l.path.Update(l.pathTracer.State()) } } diff --git a/internal/ui/tui/panels/header_footer.go b/internal/ui/tui/panels/header_footer.go index 168de47..c526a01 100644 --- a/internal/ui/tui/panels/header_footer.go +++ b/internal/ui/tui/panels/header_footer.go @@ -130,8 +130,9 @@ func (f *FooterPanel) generateFooterContent() string { filterText := fmt.Sprintf("[%s]/:filter[-]", theme.Secondary) themeText := fmt.Sprintf("[%s]t:theme[-]", theme.Secondary) beepText := fmt.Sprintf("[%s]b:beep[-]", theme.Secondary) + viewText := fmt.Sprintf("[%s]v:detail p:path[-]", theme.Secondary) moveText := fmt.Sprintf("[%s]j/k/g/G/u/d:move[-]", theme.Secondary) - return fmt.Sprintf("%s %s %s %s %s %s %s %s %s", helpText, quitText, sortText, reverseText, resetText, filterText, themeText, beepText, moveText) + return fmt.Sprintf("%s %s %s %s %s %s %s %s %s %s", helpText, quitText, viewText, sortText, reverseText, resetText, filterText, themeText, beepText, moveText) } // GetView returns the underlying tview component diff --git a/internal/ui/tui/panels/host_list.go b/internal/ui/tui/panels/host_list.go index 2f740db..17f8790 100644 --- a/internal/ui/tui/panels/host_list.go +++ b/internal/ui/tui/panels/host_list.go @@ -2,6 +2,8 @@ package panels import ( "fmt" + "slices" + "time" "github.com/gdamore/tcell/v2" "github.com/rivo/tview" @@ -20,6 +22,8 @@ type HostListPanel struct { mm stats.MetricsProvider config *shared.Config onSelectionChange func(metrics stats.Metrics) // Callback when selection changes + lastTableData *shared.TableData // Data rendered by the last Update + keepSelectedHost bool // Select() moves the cursor without changing the selected host } type HostListParams interface { @@ -45,6 +49,11 @@ func NewHostListPanel(state HostListParams, mm stats.MetricsProvider, config *sh mm: mm, config: config, } + // Every selection change (keys, mouse clicks, programmatic Select) goes + // through here so the selection follows the host, not the row index. + table.SetSelectionChangedFunc(func(row, _ int) { + panel.onTableSelectionChanged(row) + }) return panel } @@ -78,6 +87,7 @@ func (h *HostListPanel) Update() { // Use TableData's logic but populate our existing table h.populateTableFromData(tableData) + h.lastTableData = tableData // Restore selection if specified selectedHost := h.renderState.GetSelectedHost() @@ -97,21 +107,34 @@ func (h *HostListPanel) getFilteredMetrics() []stats.Metrics { return shared.FilterMetrics(metrics, h.renderState.GetFilter()) } -// updateSelectedHost updates the selection state based on current table selection -func (h *HostListPanel) updateSelectedHost() { - metrics := h.getFilteredMetrics() - tableData := shared.NewTableData(metrics, h.renderState.GetSortKey(), h.renderState.IsAscending()) - selectedHost := h.GetSelectedHost(tableData) - - // Only update if the selection actually changed to avoid loops - if h.selectionState.GetSelectedHost() != selectedHost { - h.selectionState.SetSelectedHost(selectedHost) +// onTableSelectionChanged records the host at the selected row as the +// selection, so later updates keep selecting that host even when sorting +// moves it to another row. +func (h *HostListPanel) onTableSelectionChanged(row int) { + if h.keepSelectedHost || h.lastTableData == nil { + return + } + metric, ok := h.lastTableData.GetMetricAtRow(row - 1) // Subtract 1 for header + if !ok { + return + } + if h.selectionState.GetSelectedHost() == metric.GetName() { + return + } + h.selectionState.SetSelectedHost(metric.GetName()) + if h.onSelectionChange != nil { + h.onSelectionChange(metric) + } +} - // Call the callback to update detail panel with metrics object - if metric, ok := h.GetSelectedMetric(tableData); ok && h.onSelectionChange != nil { - h.onSelectionChange(metric) - } +// CurrentSelectedMetric returns the selected host's metrics from the last +// Update. Metrics are snapshots, so this is how other panels get fresh data +// without querying the MetricsProvider again. +func (h *HostListPanel) CurrentSelectedMetric() (stats.Metrics, bool) { + if h.lastTableData == nil { + return nil, false } + return h.GetSelectedMetric(h.lastTableData) } // GetView returns the underlying tview component @@ -128,14 +151,6 @@ func (h *HostListPanel) GetSelectedMetric(tableData *shared.TableData) (stats.Me return tableData.GetMetricAtRow(row - 1) // Subtract 1 for header } -// GetSelectedHost returns the name of the currently selected host -func (h *HostListPanel) GetSelectedHost(tableData *shared.TableData) string { - if metric, ok := h.GetSelectedMetric(tableData); ok { - return metric.GetName() - } - return "" -} - // SetSelectedFunc sets the function to call when a row is selected func (h *HostListPanel) SetSelectedFunc(fn func(row, col int)) { h.table.SetSelectedFunc(fn) @@ -151,8 +166,6 @@ func (h *HostListPanel) ScrollDown() { row, _ := h.table.GetSelection() if row+1 < h.table.GetRowCount() { h.table.Select(row+1, 0) - // Update selection state directly - h.updateSelectedHost() } } @@ -160,23 +173,17 @@ func (h *HostListPanel) ScrollUp() { row, _ := h.table.GetSelection() if row > 1 { // Don't go above first data row (row 0 is header) h.table.Select(row-1, 0) - // Update selection state directly - h.updateSelectedHost() } } func (h *HostListPanel) ScrollToTop() { h.table.Select(1, 0) // Select first data row (row 0 is header) - // Update selection state directly - h.updateSelectedHost() } func (h *HostListPanel) ScrollToBottom() { rowCount := h.table.GetRowCount() if rowCount > 1 { h.table.Select(rowCount-1, 0) - // Update selection state directly - h.updateSelectedHost() } } @@ -190,8 +197,6 @@ func (h *HostListPanel) PageDown() { newRow = rowCount - 1 } h.table.Select(newRow, 0) - // Update selection state directly - h.updateSelectedHost() } func (h *HostListPanel) PageUp() { @@ -203,11 +208,14 @@ func (h *HostListPanel) PageUp() { newRow = 1 } h.table.Select(newRow, 0) - // Update selection state directly - h.updateSelectedHost() } -// populateTableFromData populates our table using TableData content +// historyColumn is where the TUI-only History column is inserted (after Loss) +const historyColumn = 5 + +// populateTableFromData populates our table using TableData content. +// A TUI-only History sparkline column is inserted after Loss, and the +// LastFailTime cell also shows the duration of the related outage. func (h *HostListPanel) populateTableFromData(tableData *shared.TableData) { // Define alignment for each column (same as in shared/table_data.go) alignments := []int{ @@ -216,6 +224,7 @@ func (h *HostListPanel) populateTableFromData(tableData *shared.TableData) { tview.AlignRight, // Succ tview.AlignRight, // Fail tview.AlignRight, // Loss + tview.AlignLeft, // History tview.AlignRight, // Last tview.AlignRight, // Avg tview.AlignRight, // Best @@ -228,36 +237,38 @@ func (h *HostListPanel) populateTableFromData(tableData *shared.TableData) { // Get theme for theme-aware colors theme := h.config.GetTheme() - // Set headers - for col, header := range tableData.Headers { + setCell := func(row, col int, text, color string, header bool) { alignment := tview.AlignLeft if col < len(alignments) { alignment = alignments[col] } - - h.table.SetCell(0, col, &tview.TableCell{ - Text: " " + header + " ", - Color: tcell.GetColor(theme.TableHeader), + h.table.SetCell(row, col, &tview.TableCell{ + Text: " " + text + " ", + Color: tcell.GetColor(color), BackgroundColor: tcell.GetColor(theme.Background), Align: alignment, - NotSelectable: true, + NotSelectable: header, }) } + // Set headers + headers := slices.Insert(slices.Clone(tableData.Headers), historyColumn, "History") + for col, header := range headers { + setCell(0, col, header, theme.TableHeader, true) + } + // Set data rows + now := time.Now() for row, rowData := range tableData.Rows { - for col, cellData := range rowData { - alignment := tview.AlignLeft - if col < len(alignments) { - alignment = alignments[col] - } - - h.table.SetCell(row+1, col, &tview.TableCell{ - Text: " " + cellData + " ", - Color: tcell.GetColor(theme.Primary), - BackgroundColor: tcell.GetColor(theme.Background), - Align: alignment, - }) + cells := slices.Clone(rowData) + sparkline := "" + if m, ok := tableData.GetMetricAtRow(row); ok { + sparkline = shared.FormatSparkline(m.GetRecentHistory(stats.DefaultHistorySize), shared.HistoryWidth, theme) + cells[shared.ColumnLastFailTime] = shared.FormatLastFailCell(m, now, theme) + } + cells = slices.Insert(cells, historyColumn, sparkline) + for col, cellData := range cells { + setCell(row+1, col, cellData, theme.Primary, false) } } } @@ -271,24 +282,11 @@ func (h *HostListPanel) restoreSelection(tableData *shared.TableData, selectedHo } } - // If host not found, select first row + // If host not found (e.g. hidden by a filter), show the first row but + // keep the selected host so it is selected again once it reappears if h.table.GetRowCount() > 1 { + h.keepSelectedHost = true h.table.Select(1, 0) + h.keepSelectedHost = false } } - -// GetSelectedMetrics returns the currently selected metrics -func (h *HostListPanel) GetSelectedMetrics() stats.Metrics { - metrics := h.getFilteredMetrics() - if len(metrics) == 0 { - return nil - } - - row, _ := h.table.GetSelection() - // row 0 is header, so data starts from row 1 - if row >= 1 && row-1 < len(metrics) { - return metrics[row-1] - } - - return nil -} diff --git a/internal/ui/tui/panels/host_list_test.go b/internal/ui/tui/panels/host_list_test.go index a056d3f..6ff337e 100644 --- a/internal/ui/tui/panels/host_list_test.go +++ b/internal/ui/tui/panels/host_list_test.go @@ -2,10 +2,14 @@ package panels import ( "fmt" + "strings" "testing" + "time" + "github.com/servak/mping/internal/prober" "github.com/servak/mping/internal/stats" "github.com/servak/mping/internal/ui/shared" + "github.com/servak/mping/internal/ui/tui/state" ) // mockState implements the required interfaces for testing @@ -131,23 +135,85 @@ func TestHostListPanelUpdateWithFilter(t *testing.T) { panel.Update() } -func TestHostListPanelUpdateSelectedHost(t *testing.T) { +// feedEvents sends events through the public API and waits until processed +func feedEvents(t *testing.T, mm stats.MetricsManager, events ...*prober.Event) { + t.Helper() + ch := make(chan *prober.Event, len(events)) + for _, e := range events { + ch <- e + } + close(ch) + <-mm.Subscribe(ch) +} + +func selectedName(t *testing.T, p *HostListPanel) string { + t.Helper() + m, ok := p.CurrentSelectedMetric() + if !ok { + t.Fatal("no host selected") + } + return m.GetName() +} + +// Regression: with no explicit selection the panel used to always select +// row 1, so a sort change (e.g. another host failing once) silently moved +// the selection - and restarted the path trace - to a different host. +func TestHostListPanelSelectionFollowsHostAcrossSortChanges(t *testing.T) { mm := stats.NewMetricsManager() - state := newMockState() - config := shared.DefaultConfig() - panel := NewHostListPanel(state, mm, config) + mm.ToggleBeep() + st := state.NewUIState() // default sort: Fail, descending + panel := NewHostListPanel(st, mm, shared.DefaultConfig()) + + now := time.Now() + var events []*prober.Event + for _, h := range []string{"8.8.8.8", "1.1.1.1", "192.168.1.1"} { + events = append(events, + &prober.Event{Key: h, DisplayName: h, Result: prober.REGISTER}, + &prober.Event{Key: h, Result: prober.SUCCESS, SentTime: now, Rtt: time.Millisecond}) + } + feedEvents(t, mm, events...) - // Register test metrics - mm.Register("test.com", "test.com") + panel.Update() + if got := selectedName(t, panel); got != "1.1.1.1" { + t.Fatalf("initial selection = %s, want 1.1.1.1 (first by name)", got) + } + if got := st.GetSelectedHost(); got != "1.1.1.1" { + t.Fatalf("default selection must be recorded in state, got %q", got) + } - // Test updateSelectedHost doesn't panic - defer func() { - if r := recover(); r != nil { - t.Errorf("updateSelectedHost() panicked: %v", r) - } - }() + // 8.8.8.8 times out once and jumps to the top of the Fail-sorted list + feedEvents(t, mm, &prober.Event{Key: "8.8.8.8", Result: prober.TIMEOUT, SentTime: now, Message: "timeout"}) + panel.Update() + if got := selectedName(t, panel); got != "1.1.1.1" { + t.Errorf("selection moved to %s after a sort change, want it to stay on 1.1.1.1", got) + } + if row, _ := panel.table.GetSelection(); row == 1 { + t.Errorf("1.1.1.1 should no longer be on row 1 (8.8.8.8 sorts first)") + } +} - panel.updateSelectedHost() +// Mouse clicks select rows through Table.Select; the selection must stick +// on the next Update instead of snapping back. +func TestHostListPanelClickSelectionSticks(t *testing.T) { + mm := stats.NewMetricsManager() + st := state.NewUIState() + panel := NewHostListPanel(st, mm, shared.DefaultConfig()) + mm.Register("a.example", "a.example") + mm.Register("b.example", "b.example") + + var notified string + panel.SetSelectionChangeCallback(func(m stats.Metrics) { notified = m.GetName() }) + + panel.Update() + panel.table.Select(2, 0) // what tview does on a mouse click + panel.Update() + + if got := selectedName(t, panel); got != "b.example" { + t.Errorf("selection after click = %s, want b.example", got) + } + if notified != "b.example" { + t.Errorf("selection change callback got %q, want b.example", notified) + } } func TestHostListPanelScrollDown(t *testing.T) { @@ -304,3 +370,104 @@ func TestHostListPanelRestoreSelection(t *testing.T) { panel.restoreSelection(tableData, "google.com") panel.restoreSelection(tableData, "nonexistent.com") } + +func TestHostListPanelCurrentSelectedMetricIsFresh(t *testing.T) { + mm := stats.NewMetricsManager() + mm.ToggleBeep() + events := make(chan *prober.Event, 10) + done := mm.Subscribe(events) + events <- &prober.Event{Key: "h", DisplayName: "host", Result: prober.REGISTER} + + panel := NewHostListPanel(state.NewUIState(), mm, shared.DefaultConfig()) + waitFor := func(total int) { + t.Helper() + for i := 0; i < 100; i++ { + panel.Update() + if m, ok := panel.CurrentSelectedMetric(); ok && m.GetTotal() == total { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("selected metric never reached total=%d", total) + } + + waitFor(0) + events <- &prober.Event{Key: "h", Result: prober.SENT} + waitFor(1) // a later Update must expose the new data, not the old snapshot + close(events) + <-done +} + +func TestHostListPanelLastFailTimeShowsOutage(t *testing.T) { + mm := stats.NewMetricsManager() + mm.ToggleBeep() + panel := NewHostListPanel(state.NewUIState(), mm, shared.DefaultConfig()) + + now := time.Now() + feedEvents(t, mm, + &prober.Event{Key: "down", DisplayName: "down.example", Result: prober.REGISTER}, + &prober.Event{Key: "up", DisplayName: "up.example", Result: prober.REGISTER}, + &prober.Event{Key: "down", Result: prober.SUCCESS, SentTime: now.Add(-3 * time.Second)}, + &prober.Event{Key: "down", Result: prober.TIMEOUT, SentTime: now.Add(-2 * time.Second), Message: "timeout"}, + &prober.Event{Key: "down", Result: prober.TIMEOUT, SentTime: now.Add(-1 * time.Second), Message: "timeout"}, + &prober.Event{Key: "down", Result: prober.TIMEOUT, SentTime: now, Message: "timeout"}, + &prober.Event{Key: "up", Result: prober.SUCCESS, SentTime: now}, + ) + panel.Update() + + col := shared.ColumnLastFailTime + 1 // shifted by the History column + if got := panel.table.GetCell(0, col).Text; !strings.Contains(got, "LastFailTime") { + t.Fatalf("header at column %d = %q, want LastFailTime", col, got) + } + for row := 0; row < panel.table.GetRowCount(); row++ { + for c := 0; c < panel.table.GetColumnCount(); c++ { + if strings.Contains(panel.table.GetCell(row, c).Text, "Outage") { + t.Errorf("there must be no separate Outage column (row %d col %d)", row, c) + } + } + } + cells := map[string]string{} + for row := 1; row < panel.table.GetRowCount(); row++ { + name := strings.TrimSpace(panel.table.GetCell(row, 0).Text) + cells[name] = panel.table.GetCell(row, col).Text + } + if !strings.Contains(cells["down.example"], "(DOWN 2.") { + t.Errorf("down host LastFailTime = %q, want time with (DOWN ~2s)", cells["down.example"]) + } + if strings.TrimSpace(cells["up.example"]) != "-" { + t.Errorf("healthy host LastFailTime = %q, want -", cells["up.example"]) + } +} + +// A filter that hides the selected host shows the first row meanwhile, but +// the selection must return to the host once the filter is cleared. +func TestHostListPanelSelectionSurvivesFilter(t *testing.T) { + mm := stats.NewMetricsManager() + mm.ToggleBeep() + st := state.NewUIState() + panel := NewHostListPanel(st, mm, shared.DefaultConfig()) + + now := time.Now() + var events []*prober.Event + for _, h := range []string{"8.8.8.8", "1.1.1.1", "192.168.1.1"} { + events = append(events, + &prober.Event{Key: h, DisplayName: h, Result: prober.REGISTER}, + &prober.Event{Key: h, Result: prober.SUCCESS, SentTime: now, Rtt: time.Millisecond}) + } + feedEvents(t, mm, events...) + + st.SetSelectedHost("192.168.1.1") + panel.Update() + + st.SetFilter("8.8") + panel.Update() + if got := selectedName(t, panel); got != "8.8.8.8" { + t.Fatalf("filtered selection = %s, want 8.8.8.8 (only row)", got) + } + + st.SetFilter("") + panel.Update() + if got := selectedName(t, panel); got != "192.168.1.1" { + t.Errorf("selection after clearing the filter = %s, want 192.168.1.1", got) + } +} diff --git a/internal/ui/tui/panels/path.go b/internal/ui/tui/panels/path.go new file mode 100644 index 0000000..d577e62 --- /dev/null +++ b/internal/ui/tui/panels/path.go @@ -0,0 +1,127 @@ +package panels + +import ( + "fmt" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/servak/mping/internal/trace" + "github.com/servak/mping/internal/ui/shared" +) + +const maxPathHostWidth = 40 + +// PathPanel shows an MTR-style live path trace for the selected host +type PathPanel struct { + view *tview.TextView + container *tview.Flex + config *shared.Config +} + +// NewPathPanel creates a new PathPanel +func NewPathPanel(config *shared.Config) *PathPanel { + view := tview.NewTextView().SetDynamicColors(true).SetScrollable(true) + container := tview.NewFlex().SetDirection(tview.FlexRow).AddItem(view, 0, 1, false) + return &PathPanel{view: view, container: container, config: config} +} + +// GetView returns the underlying tview component +func (p *PathPanel) GetView() tview.Primitive { + return p.container +} + +// Update renders the trace state +func (p *PathPanel) Update(target string, res *trace.Result, err error) { + theme := p.config.GetTheme() + p.container. + SetBorder(true). + SetTitle(fmt.Sprintf(" [%s]Path ", theme.Primary)). + SetBackgroundColor(tcell.GetColor(theme.Background)). + SetBorderColor(tcell.GetColor(theme.Primary)) + p.view.SetBackgroundColor(tcell.GetColor(theme.Background)) + p.view.SetText(FormatPath(target, res, err, theme)) +} + +// FormatPath renders a trace snapshot with tview color tags +func FormatPath(target string, res *trace.Result, err error, theme *shared.Theme) string { + switch { + case target == "": + return "Select a host to trace its path" + case err != nil: + return fmt.Sprintf("[%s]Path trace to %s unavailable:[%s]\n%s", + theme.Error, tview.Escape(target), theme.Primary, tview.Escape(err.Error())) + case res == nil: + return fmt.Sprintf("Starting path trace to %s ...", tview.Escape(target)) + } + + var sb strings.Builder + title := tview.Escape(target) + if dst := res.Dst.String(); !strings.Contains(target, dst) { + title += " (" + dst + ")" + } + fmt.Fprintf(&sb, "[%s]%s[%s] ยท %d rounds\n", theme.Accent, title, theme.Primary, res.Rounds) + + hosts := make([]string, len(res.Hops)) + width := len("Host") + for i, h := range res.Hops { + hosts[i] = pathHopHost(h) + width = max(width, min(len(hosts[i]), maxPathHostWidth)) + } + + fmt.Fprintf(&sb, "[%s]%3s %-*s %6s %4s %6s %6s %6s %6s[%s]\n", + theme.TableHeader, "Hop", width, "Host", "Loss", "Snt", "Last", "Avg", "Best", "Wrst", theme.Primary) + df := shared.DurationFormater + rateLimited := res.HasRateLimitedHops() + for i, h := range res.Hops { + host := hosts[i] + if len(host) > width { + host = host[:width-1] + "โ€ฆ" + } + loss := h.Loss() + lossColor := theme.Primary + switch { + case h.Sent > 0 && h.Recv == 0: + lossColor = theme.Secondary + case loss > 0 && rateLimited: + // Loss that does not reach the destination is router ICMP + // rate limiting; don't make it look like a problem + lossColor = theme.Secondary + case loss > 10: + lossColor = theme.Error + case loss > 0: + lossColor = theme.Warning + } + fmt.Fprintf(&sb, "%3d %-*s [%s]%5.1f%%[%s] %4d %6s %6s %6s %6s\n", + h.TTL, width, tview.Escape(host), lossColor, loss, theme.Primary, h.Sent, + df(h.Last), df(h.Avg), df(h.Best), df(h.Worst)) + } + if !res.Reached && res.Rounds > 0 { + fmt.Fprintf(&sb, "[%s]destination has not replied yet[%s]\n", theme.Secondary, theme.Primary) + } + if rateLimited { + fmt.Fprintf(&sb, "[%s]loss only at intermediate hops is usually router ICMP rate limiting[%s]\n", theme.Secondary, theme.Primary) + } + if !res.Privileged { + fmt.Fprintf(&sb, "[%s]unprivileged ICMP: intermediate hops may be missing on Linux[%s]\n", theme.Secondary, theme.Primary) + } + return strings.TrimRight(sb.String(), "\n") +} + +func pathHopHost(h trace.Hop) string { + if h.Addr == "" { + if h.Sent == 0 { + return "waiting" // first probe still in flight + } + return "???" // no reply yet, at least one probe timed out + } + s := h.Addr + if h.Name != "" { + s = h.Name + } + if h.Others > 0 { + s += fmt.Sprintf(" +%d", h.Others) + } + return s +} diff --git a/internal/ui/tui/panels/path_test.go b/internal/ui/tui/panels/path_test.go new file mode 100644 index 0000000..9198a6b --- /dev/null +++ b/internal/ui/tui/panels/path_test.go @@ -0,0 +1,84 @@ +package panels + +import ( + "errors" + "net" + "strings" + "testing" + "time" + + "github.com/servak/mping/internal/trace" + "github.com/servak/mping/internal/ui/shared" +) + +func TestFormatPath(t *testing.T) { + theme := shared.PredefinedThemes["dark"] + + if got := FormatPath("", nil, nil, &theme); !strings.Contains(got, "Select a host") { + t.Errorf("no target: %q", got) + } + if got := FormatPath("h", nil, errors.New("permission denied"), &theme); !strings.Contains(got, "permission denied") { + t.Errorf("error state: %q", got) + } + if got := FormatPath("h", nil, nil, &theme); !strings.Contains(got, "Starting") { + t.Errorf("starting state: %q", got) + } + + res := &trace.Result{ + Dst: net.ParseIP("192.0.2.10"), Rounds: 3, Reached: true, Privileged: true, + Hops: []trace.Hop{ + {TTL: 1, Addr: "192.0.2.1", Name: strings.Repeat("very-long-router-name.", 4), Sent: 3, Recv: 3, Avg: time.Millisecond}, + {TTL: 2, Sent: 3}, + {TTL: 3, Addr: "192.0.2.10", Others: 2, Sent: 3, Recv: 2, Avg: 3 * time.Millisecond}, + }, + } + got := FormatPath("tcp://[example]:443", res, nil, &theme) + for _, want := range []string{"tcp://[example[]:443 (192.0.2.10)", "ยท 3 rounds", "???", "192.0.2.10 +2", "33.3%", "โ€ฆ"} { + if !strings.Contains(got, want) { + t.Errorf("FormatPath missing %q:\n%s", want, got) + } + } +} + +func TestFormatPathTitleAndRateLimitNote(t *testing.T) { + theme := shared.PredefinedThemes["dark"] + res := &trace.Result{ + Dst: net.ParseIP("192.0.2.10"), Rounds: 2, Reached: true, Privileged: true, + Hops: []trace.Hop{ + {TTL: 1, Addr: "192.0.2.1", Sent: 4, Recv: 1}, + {TTL: 2, Addr: "192.0.2.10", Sent: 4, Recv: 4}, + }, + } + got := FormatPath("example.com(192.0.2.10)", res, nil, &theme) + if strings.Count(got, "192.0.2.10") != 2 { // title once + hop 2 + t.Errorf("destination IP should not be repeated in the title:\n%s", got) + } + if !strings.Contains(got, "rate limiting") { + t.Errorf("expected rate limiting note:\n%s", got) + } +} + +func TestFormatPathWaitingAndRateLimitColors(t *testing.T) { + theme := shared.PredefinedThemes["dark"] + res := &trace.Result{ + Dst: net.ParseIP("192.0.2.10"), Rounds: 5, Reached: true, Privileged: true, + Hops: []trace.Hop{ + {TTL: 1, Sent: 0}, // probe in flight + {TTL: 2, Addr: "192.0.2.2", Sent: 5, Recv: 1}, // rate limited router + {TTL: 3, Addr: "192.0.2.10", Sent: 5, Recv: 5}, // clean destination + }, + } + got := FormatPath("192.0.2.10", res, nil, &theme) + if !strings.Contains(got, "waiting") { + t.Errorf("in-flight hop should say waiting:\n%s", got) + } + if strings.Contains(got, "["+theme.Error+"]") { + t.Errorf("rate limited hop must not use the error color:\n%s", got) + } + + // Loss that continues to the destination is real and stays red + res.Hops[2].Recv = 3 + if got := FormatPath("192.0.2.10", res, nil, &theme); !strings.Contains(got, "["+theme.Error+"]") { + t.Errorf("real loss should use the error color:\n%s", got) + } +} diff --git a/internal/ui/tui/path_integration_test.go b/internal/ui/tui/path_integration_test.go new file mode 100644 index 0000000..2790438 --- /dev/null +++ b/internal/ui/tui/path_integration_test.go @@ -0,0 +1,84 @@ +//go:build integration + +package tui + +// Drives the real layout + path tracer while the host list is re-sorted, +// reproducing the "path view keeps resetting" report. Run with: +// +// go test -tags integration -run Integration -v ./internal/ui/tui/ +// +// MPING_IT_DURATION controls how long each scenario samples (default 12s). + +import ( + "os" + "testing" + "time" + + "github.com/servak/mping/internal/prober" + "github.com/servak/mping/internal/stats" + "github.com/servak/mping/internal/ui/shared" + "github.com/servak/mping/internal/ui/tui/state" +) + +func TestIntegrationPathViewFollowsSelection(t *testing.T) { + duration := 12 * time.Second + if v, err := time.ParseDuration(os.Getenv("MPING_IT_DURATION")); err == nil { + duration = v + } + + mm := stats.NewMetricsManager() + mm.ToggleBeep() + events := make(chan *prober.Event, 1000) + done := mm.Subscribe(events) + defer func() { close(events); <-done }() + + hosts := []string{"8.8.8.8", "1.1.1.1", "192.168.1.1"} + for _, h := range hosts { + events <- &prober.Event{Key: h, DisplayName: h, Result: prober.REGISTER} + } + + uiState := state.NewUIState() + layout := NewLayoutManager(uiState, mm, shared.DefaultConfig(), 500*time.Millisecond, time.Second) + defer layout.StopPathTrace() + layout.UpdateAll() + layout.TogglePathView() + + start := time.Now() + failed := false + lastRounds := 0 + for time.Since(start) < duration { + now := time.Now() + for _, h := range hosts { + events <- &prober.Event{Key: h, Result: prober.SUCCESS, SentTime: now, Rtt: time.Millisecond} + } + // Like the report: 8.8.8.8 times out once and jumps to the top + if !failed && time.Since(start) > duration/3 { + events <- &prober.Event{Key: "8.8.8.8", Result: prober.TIMEOUT, SentTime: now, Message: "timeout"} + failed = true + } + time.Sleep(250 * time.Millisecond) // TUI refresh period + + layout.UpdateAll() + target, res, err := layout.pathTracer.State() + if err != nil { + t.Skipf("path tracing unavailable: %v", err) + } + if target != "1.1.1.1" { + t.Fatalf("path target switched to %q at %v, want it to stay on 1.1.1.1", + target, time.Since(start).Round(time.Millisecond)) + } + if res == nil { + continue + } + if res.Rounds < lastRounds { + t.Fatalf("rounds went back from %d to %d: the trace was restarted", lastRounds, res.Rounds) + } + lastRounds = res.Rounds + } + + _, res, _ := layout.pathTracer.State() + if res == nil || res.Rounds < int(duration/time.Second)-2 { + t.Fatalf("trace made too little progress: %+v", res) + } + t.Logf("path to 1.1.1.1 kept for %v: %d rounds, %d hops, reached=%v", duration, res.Rounds, len(res.Hops), res.Reached) +} diff --git a/internal/ui/tui/path_tracer.go b/internal/ui/tui/path_tracer.go new file mode 100644 index 0000000..480ce26 --- /dev/null +++ b/internal/ui/tui/path_tracer.go @@ -0,0 +1,102 @@ +package tui + +import ( + "context" + "sync" + "time" + + "github.com/servak/mping/internal/trace" +) + +// minPathInterval keeps path tracing polite: routers rate-limit ICMP Time +// Exceeded generation, and probing them faster shows fake loss. +const minPathInterval = time.Second + +// pathTracer runs a background trace for the host selected in the TUI and +// restarts it when the selection changes. +type pathTracer struct { + cfg trace.Config + + mu sync.Mutex + target string + tracer *trace.Tracer + err error + cancel context.CancelFunc +} + +func newPathTracer(interval, timeout time.Duration) *pathTracer { + return &pathTracer{cfg: trace.Config{ + Interval: max(interval, minPathInterval), + Timeout: max(timeout, minPathInterval), + }} +} + +// Follow traces target, restarting the trace if the target changed. +// Socket setup and name resolution happen off the UI goroutine. +func (p *pathTracer) Follow(target string) { + p.mu.Lock() + defer p.mu.Unlock() + if target == p.target { + return + } + p.stopLocked() + p.target = target + if target == "" { + return + } + + ctx, cancel := context.WithCancel(context.Background()) + p.cancel = cancel + go func() { + tr, err := trace.New(target, p.cfg) + p.mu.Lock() + if ctx.Err() != nil { + p.mu.Unlock() + if tr != nil { + tr.Close() + } + return + } + p.tracer, p.err = tr, err + p.mu.Unlock() + if err != nil { + return + } + if err := tr.Run(ctx, 0); err != nil { + p.mu.Lock() + if ctx.Err() == nil { + p.err = err + } + p.mu.Unlock() + } + }() +} + +// Stop stops tracing +func (p *pathTracer) Stop() { + p.mu.Lock() + defer p.mu.Unlock() + p.stopLocked() + p.target = "" +} + +func (p *pathTracer) stopLocked() { + if p.cancel != nil { + p.cancel() + p.cancel = nil + } + p.tracer = nil + p.err = nil +} + +// State returns the current target, trace snapshot (nil while starting) and error +func (p *pathTracer) State() (string, *trace.Result, error) { + p.mu.Lock() + target, tr, err := p.target, p.tracer, p.err + p.mu.Unlock() + if tr == nil { + return target, nil, err + } + res := tr.Snapshot() + return target, &res, err +}