From 7be6813b4ee94bff7e7036f02c36b5d2218b210e Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Thu, 24 Sep 2026 07:04:40 +0900 Subject: [PATCH 1/7] feat(stats): detect outages from consecutive lost probes - Record an outage when 3 or more consecutive probes are lost, with its start, end, duration and lost probe count. It starts at the first lost probe and ends at the next success; shorter runs are ordinary loss. - Buffer results for timeout + interval and apply them in send order, so a timeout reported after a later success does not split an outage. The buffer is flushed when the event channel closes. - Return deep-copied snapshots from SortBy/GetMetrics so the UI no longer reads live metrics without the lock. - Add RTT percentiles (nearest rank over the history). - Use a strict ordering for descending sorts so equal rows keep their order across refreshes. - Replace SetBeepEnabled with Options.DisableBeep for batch runs. --- internal/command/batch.go | 10 +- internal/command/mping.go | 4 +- internal/stats/history.go | 7 + internal/stats/history_test.go | 3 +- internal/stats/interface.go | 7 +- internal/stats/manager.go | 162 ++++++++++++---------- internal/stats/metric.go | 79 +++++++++++ internal/stats/outage.go | 212 +++++++++++++++++++++++++++++ internal/stats/outage_test.go | 237 +++++++++++++++++++++++++++++++++ 9 files changed, 646 insertions(+), 75 deletions(-) create mode 100644 internal/stats/outage.go create mode 100644 internal/stats/outage_test.go 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) + } +} From b99d95ee8d8b7b6f447dbc8cfca3e4bc2abd5b1b Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Thu, 24 Sep 2026 07:04:51 +0900 Subject: [PATCH 2/7] feat(ui): report outages, RTT percentiles and a history sparkline - Host details show RTT p50/p95/p99 and the recent outages with their total and longest downtime. - Table reports end with a cross-target outage timeline; json/csv gain outages[], outage_count, total_downtime_ms, longest_outage_ms and the RTT percentiles. - FormatLastFailCell adds the related outage duration to the last failure time (red while down, yellow after recovery). - FormatSparkline draws recent probes with bar height by how many ms an RTT exceeds the median, so normal jitter stays flat. --- internal/ui/shared/export.go | 99 +++++++++++++- internal/ui/shared/export_test.go | 87 +++++++++++- internal/ui/shared/formatters.go | 188 ++++++++++++++++++++++++++ internal/ui/shared/formatters_test.go | 131 ++++++++++++++++++ internal/ui/shared/table_data.go | 3 + 5 files changed, 500 insertions(+), 8 deletions(-) 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 From 1a6db93899e9516e6dfb4f2f65037ccfaafe414e Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Thu, 24 Sep 2026 07:05:04 +0900 Subject: [PATCH 3/7] feat(tui): add History column and keep the selection on its host - Insert a History sparkline column after Loss and show the outage duration in the LastFailTime cell. - Record the selected host on every selection change and restore it by name, so re-sorting keeps the same host selected. A filter that hides it shows the first row meanwhile without losing the selection. - Refresh the detail panel from the latest snapshot on every update. --- internal/ui/tui/layout.go | 9 +- internal/ui/tui/panels/host_list.go | 138 ++++++++-------- internal/ui/tui/panels/host_list_test.go | 193 +++++++++++++++++++++-- 3 files changed, 254 insertions(+), 86 deletions(-) diff --git a/internal/ui/tui/layout.go b/internal/ui/tui/layout.go index d7f3e69..6309180 100644 --- a/internal/ui/tui/layout.go +++ b/internal/ui/tui/layout.go @@ -131,9 +131,8 @@ 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) + if m, ok := l.hostList.CurrentSelectedMetric(); ok { + l.hostDetail.SetMetrics(m) } // Create horizontal layout for host list and detail @@ -168,6 +167,10 @@ func (l *LayoutManager) UpdateAll() { // Only update detail panel when it's visible if l.mode == ListWithDetail { + // Metrics are snapshots; hand the detail panel the fresh one + if m, ok := l.hostList.CurrentSelectedMetric(); ok { + l.hostDetail.SetMetrics(m) + } l.hostDetail.Update() } } 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) + } +} From 7ea8941cb3bd7cedcbe7cbef37fed8e32c06cc7c Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Thu, 24 Sep 2026 07:05:12 +0900 Subject: [PATCH 4/7] feat(trace): add an MTR-style ICMP path tracer Each round sends ICMP echo requests with TTL 1..30 and keeps per-hop loss and RTT statistics. Replies are matched by echo ID/seq, including the datagram quoted in Time Exceeded / Destination Unreachable messages (IPv4 with IHL options, IPv6). A raw socket is preferred, with a fallback to unprivileged datagram ICMP, which still receives Time Exceeded on macOS. Hop addresses are reverse-resolved in the background. Network integration tests run with -tags integration. --- internal/trace/integration_test.go | 149 ++++++++++ internal/trace/packet.go | 111 +++++++ internal/trace/packet_test.go | 124 ++++++++ internal/trace/target.go | 75 +++++ internal/trace/target_test.go | 43 +++ internal/trace/trace.go | 459 +++++++++++++++++++++++++++++ internal/trace/trace_test.go | 86 ++++++ 7 files changed, 1047 insertions(+) create mode 100644 internal/trace/integration_test.go create mode 100644 internal/trace/packet.go create mode 100644 internal/trace/packet_test.go create mode 100644 internal/trace/target.go create mode 100644 internal/trace/target_test.go create mode 100644 internal/trace/trace.go create mode 100644 internal/trace/trace_test.go diff --git a/internal/trace/integration_test.go b/internal/trace/integration_test.go new file mode 100644 index 0000000..a961afa --- /dev/null +++ b/internal/trace/integration_test.go @@ -0,0 +1,149 @@ +//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. + // Hops are probed every round (maxTTL only shrinks to the destination). + if h.Sent != res.Rounds { + t.Errorf("hop %d: sent=%d, want %d (one settled probe per round)", h.TTL, h.Sent, res.Rounds) + } + for r := 1; 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..248b3fc --- /dev/null +++ b/internal/trace/trace.go @@ -0,0 +1,459 @@ +// 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 +) + +// 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(); 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 +} + +func (t *Tracer) sendRound() error { + t.mu.Lock() + t.expire(time.Now()) + maxTTL := t.maxTTL + t.rounds++ + round := t.rounds + t.mu.Unlock() + + for ttl := 1; ttl <= maxTTL; ttl++ { + 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) + } +} + +// 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 := 0 + for i := 0; i < t.maxTTL; i++ { + if t.hops[i].recv > 0 { + last = i + 1 + } + } + 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..e0fc177 --- /dev/null +++ b/internal/trace/trace_test.go @@ -0,0 +1,86 @@ +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) + } + } +} From f91f80dec655abc4ec2f4d68bfaf220e742287bc Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Thu, 24 Sep 2026 07:05:21 +0900 Subject: [PATCH 5/7] feat(tui): add a live path view for the selected host Press p to show an MTR-style trace next to the host list. It follows the selection and traces the host part of any target (tcp://, https://, dns://, ...). The trace starts off the UI goroutine and runs at most one round per second so router ICMP rate limiting does not show up as loss. Hops still waiting for a reply show 'waiting', and loss only at intermediate hops is marked as likely rate limiting. --- internal/ui/tui/app.go | 5 + internal/ui/tui/layout.go | 90 ++++++++++------ internal/ui/tui/panels/header_footer.go | 3 +- internal/ui/tui/panels/path.go | 127 +++++++++++++++++++++++ internal/ui/tui/panels/path_test.go | 84 +++++++++++++++ internal/ui/tui/path_integration_test.go | 84 +++++++++++++++ internal/ui/tui/path_tracer.go | 102 ++++++++++++++++++ 7 files changed, 463 insertions(+), 32 deletions(-) create mode 100644 internal/ui/tui/panels/path.go create mode 100644 internal/ui/tui/panels/path_test.go create mode 100644 internal/ui/tui/path_integration_test.go create mode 100644 internal/ui/tui/path_tracer.go 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 6309180..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,45 +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 - if m, ok := l.hostList.CurrentSelectedMetric(); ok { - l.hostDetail.SetMetrics(m) +// 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) } @@ -165,13 +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 detail panel the fresh one - if m, ok := l.hostList.CurrentSelectedMetric(); ok { + // 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/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 +} From e62a83ae6f57023c4ee0b633b2966ecab549621b Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Thu, 24 Sep 2026 07:05:21 +0900 Subject: [PATCH 6/7] docs: document outage measurement, path view and History column --- README.md | 52 +++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) 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: From e5346bc8a63b883bc088461d8af3fbe7bfc615fe Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Thu, 24 Sep 2026 08:33:29 +0900 Subject: [PATCH 7/7] fix(trace): pace probes and stop probing far past the last reply Each round sent TTL 1..30 back to back, and kept doing so when the destination did not answer. Home routers took the burst for an ICMP flood and dropped ICMP for every host behind them for a while, so opening the path view on one machine made mping fail on the others. - Spread a round's probes evenly over the interval, like mtr. - While the destination is unknown, probe at most 8 TTLs past the farthest hop that has replied instead of up to 30. --- internal/trace/integration_test.go | 13 +++++--- internal/trace/trace.go | 51 ++++++++++++++++++++++++------ internal/trace/trace_test.go | 26 +++++++++++++++ 3 files changed, 77 insertions(+), 13 deletions(-) diff --git a/internal/trace/integration_test.go b/internal/trace/integration_test.go index a961afa..d97c6a7 100644 --- a/internal/trace/integration_test.go +++ b/internal/trace/integration_test.go @@ -107,11 +107,16 @@ 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. - // Hops are probed every round (maxTTL only shrinks to the destination). - if h.Sent != res.Rounds { - t.Errorf("hop %d: sent=%d, want %d (one settled probe per round)", h.TTL, h.Sent, res.Rounds) + // 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++ } - for r := 1; r <= res.Rounds; r++ { + 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) } diff --git a/internal/trace/trace.go b/internal/trace/trace.go index 248b3fc..758e817 100644 --- a/internal/trace/trace.go +++ b/internal/trace/trace.go @@ -24,6 +24,13 @@ const ( 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 @@ -248,7 +255,7 @@ func (t *Tracer) Run(ctx context.Context, rounds int) error { case <-ticker.C: } } - if err := t.sendRound(); err != nil { + if err := t.sendRound(ctx); err != nil { sendErr = err break } @@ -271,15 +278,25 @@ func (t *Tracer) drain(recvDone <-chan struct{}, err error) error { return err } -func (t *Tracer) sendRound() error { +// 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.maxTTL + 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 @@ -378,6 +395,27 @@ func (t *Tracer) handle(r reply, addr string, now time.Time) { } } +// 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 { @@ -418,12 +456,7 @@ func (t *Tracer) Snapshot() Result { t.expire(time.Now()) // Show hops up to the destination, or up to the farthest responder - last := 0 - for i := 0; i < t.maxTTL; i++ { - if t.hops[i].recv > 0 { - last = i + 1 - } - } + last := t.farthestResponderLocked() if t.reached { last = t.maxTTL } diff --git a/internal/trace/trace_test.go b/internal/trace/trace_test.go index e0fc177..e43009d 100644 --- a/internal/trace/trace_test.go +++ b/internal/trace/trace_test.go @@ -84,3 +84,29 @@ func TestHasRateLimitedHops(t *testing.T) { } } } + +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) + } +}