From 3713754dad13e109cf0c203496b121c381833516 Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Mon, 7 Jul 2025 22:51:31 +0900 Subject: [PATCH 01/13] feat: Add comprehensive probe history tracking with interface-based architecture - Implement ring buffer-based history management for each target - Add detailed probe information collection for all probe types (ICMP, HTTP, DNS, NTP) - Refactor stats module to use interface-based design for better abstraction - Enhance probe details with protocol-specific information: * ICMP: sequence, TTL, data size * HTTP: status code, response size, headers, redirects * DNS: response code, answer count, server details * NTP: stratum, time offset, precision * TCP: connection-only verification (no additional details) - Add MetricsReader interface to decouple UI from internal implementation - Extend history functionality with consecutive failure/success tracking - Add success rate calculation within specified time periods - Update TUI components to use interface-based metrics access - Maintain backward compatibility for existing functionality This enhancement enables advanced monitoring capabilities including trend analysis, detailed probe diagnostics, and improved failure pattern detection. --- internal/command/batch.go | 2 +- internal/command/mping.go | 2 +- internal/prober/details.go | 45 ++++++++ internal/prober/dns.go | 25 +++- internal/prober/http.go | 32 +++++ internal/prober/icmp.go | 13 +++ internal/prober/ntp.go | 27 ++++- internal/prober/prober.go | 1 + internal/prober/tcp.go | 3 + internal/stats/history.go | 152 ++++++++++++++++++++++++ internal/stats/history_test.go | 173 ++++++++++++++++++++++++++++ internal/stats/interface.go | 61 ++++++++++ internal/stats/manager.go | 167 ++++++++++++++++++++++++--- internal/stats/metric.go | 89 ++++++++++++++ internal/ui/shared/filters.go | 6 +- internal/ui/shared/formatters.go | 26 ++--- internal/ui/shared/table_data.go | 32 ++--- internal/ui/tui/app.go | 6 +- internal/ui/tui/panels/host_list.go | 12 +- 19 files changed, 808 insertions(+), 66 deletions(-) create mode 100644 internal/prober/details.go create mode 100644 internal/stats/history.go create mode 100644 internal/stats/history_test.go create mode 100644 internal/stats/interface.go diff --git a/internal/command/batch.go b/internal/command/batch.go index 82fe3b6..1db7043 100644 --- a/internal/command/batch.go +++ b/internal/command/batch.go @@ -99,7 +99,7 @@ mping batch dns://8.8.8.8/google.com`, // Stop probing probeManager.Stop() cmd.Print("\r") - metrics := metricsManager.SortBy(stats.Success, true) + metrics := metricsManager.SortByWithReader(stats.Success, true) tableData := shared.NewTableData(metrics, stats.Success, true) t := tableData.ToGoPrettyTable() t.SetStyle(table.StyleLight) diff --git a/internal/command/mping.go b/internal/command/mping.go index 56ebb49..f0829fd 100644 --- a/internal/command/mping.go +++ b/internal/command/mping.go @@ -104,7 +104,7 @@ mping dns://8.8.8.8/google.com`, probeManager.Stop() // Final results - metrics := metricsManager.SortBy(stats.Success, true) + metrics := metricsManager.SortByWithReader(stats.Success, true) tableData := shared.NewTableData(metrics, stats.Success, true) t := tableData.ToGoPrettyTable() t.SetStyle(table.StyleLight) diff --git a/internal/prober/details.go b/internal/prober/details.go new file mode 100644 index 0000000..79beaed --- /dev/null +++ b/internal/prober/details.go @@ -0,0 +1,45 @@ +package prober + +// プローブ詳細情報(TargetIPを削除、TCPDetailsも削除) +type ProbeDetails struct { + ProbeType string `json:"probe_type"` + + // 型別詳細(どれか一つのみ使用) + ICMP *ICMPDetails `json:"icmp,omitempty"` + HTTP *HTTPDetails `json:"http,omitempty"` + DNS *DNSDetails `json:"dns,omitempty"` + NTP *NTPDetails `json:"ntp,omitempty"` + // TCP は詳細情報なし(接続可否のみのため) +} + +type ICMPDetails struct { + Sequence int `json:"sequence"` + TTL int `json:"ttl"` + DataSize int `json:"data_size"` +} + +type HTTPDetails struct { + StatusCode int `json:"status_code"` + ResponseSize int64 `json:"response_size"` + Headers map[string]string `json:"headers,omitempty"` + Redirects []string `json:"redirects,omitempty"` +} + +type DNSDetails struct { + Server string `json:"server"` + Port int `json:"port"` + Domain string `json:"domain"` + RecordType string `json:"record_type"` + ResponseCode int `json:"response_code"` + AnswerCount int `json:"answer_count"` + Answers []string `json:"answers,omitempty"` + UseTCP bool `json:"use_tcp"` +} + +type NTPDetails struct { + Server string `json:"server"` + Port int `json:"port"` + Stratum int `json:"stratum"` + Offset int64 `json:"offset_microseconds"` // マイクロ秒単位 + Precision int `json:"precision"` +} \ No newline at end of file diff --git a/internal/prober/dns.go b/internal/prober/dns.go index b42cb0e..7b3d3e9 100644 --- a/internal/prober/dns.go +++ b/internal/prober/dns.go @@ -247,7 +247,7 @@ func (p *DNSProber) sendProbe(result chan *Event, target *DNSTarget, timeout tim } // Success - p.success(result, target, now, rtt) + p.success(result, target, now, rtt, r) } func (p *DNSProber) sent(result chan *Event, target *DNSTarget, sentTime time.Time) { @@ -261,7 +261,27 @@ func (p *DNSProber) sent(result chan *Event, target *DNSTarget, sentTime time.Ti } } -func (p *DNSProber) success(result chan *Event, target *DNSTarget, sentTime time.Time, rtt time.Duration) { +func (p *DNSProber) success(result chan *Event, target *DNSTarget, sentTime time.Time, rtt time.Duration, resp *dns.Msg) { + // DNS詳細情報を作成 + var answers []string + for _, ans := range resp.Answer { + answers = append(answers, ans.String()) + } + + details := &ProbeDetails{ + ProbeType: "dns", + DNS: &DNSDetails{ + Server: target.Server, + Port: target.Port, + Domain: target.Domain, + RecordType: target.RecordType, + ResponseCode: resp.Rcode, + AnswerCount: len(resp.Answer), + Answers: answers, + UseTCP: target.UseTCP, + }, + } + result <- &Event{ Key: target.OriginalTarget, DisplayName: target.OriginalTarget, @@ -269,6 +289,7 @@ func (p *DNSProber) success(result chan *Event, target *DNSTarget, sentTime time SentTime: sentTime, Rtt: rtt, Message: "", + Details: details, } } diff --git a/internal/prober/http.go b/internal/prober/http.go index 3029f77..17cada9 100644 --- a/internal/prober/http.go +++ b/internal/prober/http.go @@ -12,6 +12,7 @@ import ( "strings" "sync" "time" + ) const ( @@ -181,12 +182,43 @@ func (p *HTTPProber) probe(r chan *Event, target string) { } else if p.config.ExpectBody != "" && p.config.ExpectBody != strings.TrimRight(string(body), "\n") { p.failed(r, target, now, errors.New("invalid body")) } else { + // HTTP詳細情報を作成 + headers := make(map[string]string) + for key, values := range resp.Header { + if len(values) > 0 { + headers[key] = values[0] // 最初の値のみを取得 + } + } + + var redirects []string + if resp.Request.URL.String() != target { + redirects = append(redirects, resp.Request.URL.String()) + } + + var probeType string + if strings.HasPrefix(target, "https://") { + probeType = "https" + } else { + probeType = "http" + } + + details := &ProbeDetails{ + ProbeType: probeType, + HTTP: &HTTPDetails{ + StatusCode: resp.StatusCode, + ResponseSize: int64(len(body)), + Headers: headers, + Redirects: redirects, + }, + } + r <- &Event{ Key: target, DisplayName: target, Result: SUCCESS, SentTime: now, Rtt: time.Since(now), + Details: details, } } } diff --git a/internal/prober/icmp.go b/internal/prober/icmp.go index 258c463..f31a86a 100644 --- a/internal/prober/icmp.go +++ b/internal/prober/icmp.go @@ -12,6 +12,7 @@ import ( "golang.org/x/net/icmp" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" + ) const ( @@ -187,12 +188,24 @@ func (p *ICMPProber) success(r chan *Event, runCnt int, addr string) { table[addr] = true elapse := time.Since(k.sentTime) key, displayName := p.getTargetInfo(addr) + + // ICMP詳細情報を作成 + details := &ProbeDetails{ + ProbeType: string(p.version), + ICMP: &ICMPDetails{ + Sequence: runCnt, + TTL: 0, // TODO: IPヘッダーから取得 + DataSize: len(p.body) + 8, // ICMPヘッダー(8bytes) + データ + }, + } + r <- &Event{ Key: key, DisplayName: displayName, Result: SUCCESS, SentTime: k.sentTime, Rtt: elapse, + Details: details, } return } diff --git a/internal/prober/ntp.go b/internal/prober/ntp.go index 5945b8d..a8dc8f7 100644 --- a/internal/prober/ntp.go +++ b/internal/prober/ntp.go @@ -7,6 +7,7 @@ import ( "strings" "sync" "time" + ) const ( @@ -235,7 +236,7 @@ func (p *NTPProber) sendProbe(result chan *Event, serverAddr string, timeout tim } // Success - p.success(result, serverAddr, displayName, now, rtt) + p.success(result, serverAddr, displayName, now, rtt, &resp, offset) } func (p *NTPProber) sent(result chan *Event, serverAddr, displayName string, sentTime time.Time) { @@ -249,7 +250,28 @@ func (p *NTPProber) sent(result chan *Event, serverAddr, displayName string, sen } } -func (p *NTPProber) success(result chan *Event, serverAddr, displayName string, sentTime time.Time, rtt time.Duration) { +func (p *NTPProber) success(result chan *Event, serverAddr, displayName string, sentTime time.Time, rtt time.Duration, resp *ntpPacket, offset time.Duration) { + // NTP詳細情報を作成 + // ポートを抽出(serverAddrは "host:port" 形式) + _, portStr, _ := net.SplitHostPort(serverAddr) + port := 123 // デフォルトNTPポート + if portStr != "" { + if p, err := net.LookupPort("udp", portStr); err == nil { + port = p + } + } + + details := &ProbeDetails{ + ProbeType: "ntp", + NTP: &NTPDetails{ + Server: displayName, + Port: port, + Stratum: int(resp.Stratum), + Offset: offset.Microseconds(), + Precision: int(resp.Precision), + }, + } + result <- &Event{ Key: serverAddr, DisplayName: displayName, @@ -257,6 +279,7 @@ func (p *NTPProber) success(result chan *Event, serverAddr, displayName string, SentTime: sentTime, Rtt: rtt, Message: "", + Details: details, } } diff --git a/internal/prober/prober.go b/internal/prober/prober.go index 3dcce98..aaebe37 100644 --- a/internal/prober/prober.go +++ b/internal/prober/prober.go @@ -30,6 +30,7 @@ type Event struct { SentTime time.Time Rtt time.Duration Message string + Details *ProbeDetails // 追加:詳細情報 } type Prober interface { diff --git a/internal/prober/tcp.go b/internal/prober/tcp.go index 0864a49..dcfacfc 100644 --- a/internal/prober/tcp.go +++ b/internal/prober/tcp.go @@ -191,6 +191,8 @@ func (p *TCPProber) sent(result chan *Event, target string, sentTime time.Time) func (p *TCPProber) success(result chan *Event, target string, sentTime time.Time, rtt time.Duration) { displayName := p.targets[target] // Get displayName from targets map + + // TCP は接続可否のみの確認なので詳細情報なし result <- &Event{ Key: target, DisplayName: displayName, @@ -198,6 +200,7 @@ func (p *TCPProber) success(result chan *Event, target string, sentTime time.Tim SentTime: sentTime, Rtt: rtt, Message: "", + Details: nil, // TCP は詳細情報なし } } diff --git a/internal/stats/history.go b/internal/stats/history.go new file mode 100644 index 0000000..a816aac --- /dev/null +++ b/internal/stats/history.go @@ -0,0 +1,152 @@ +package stats + +import ( + "time" + + "github.com/servak/mping/internal/prober" +) + +// 履歴エントリ +type HistoryEntry struct { + Timestamp time.Time `json:"timestamp"` + RTT time.Duration `json:"rtt"` + Success bool `json:"success"` + Error string `json:"error,omitempty"` + Details *prober.ProbeDetails `json:"details,omitempty"` +} + +// 各ターゲットの履歴を管理する構造体(リングバッファ) +type TargetHistory struct { + entries []HistoryEntry + size int // リングバッファサイズ + index int // 現在の書き込み位置 + count int // 実際のエントリ数 +} + +// NewTargetHistory は新しいTargetHistoryを作成 +func NewTargetHistory(size int) *TargetHistory { + return &TargetHistory{ + entries: make([]HistoryEntry, size), + size: size, + index: 0, + count: 0, + } +} + +// AddEntry は新しい履歴エントリを追加 +func (th *TargetHistory) AddEntry(entry HistoryEntry) { + th.entries[th.index] = entry + th.index = (th.index + 1) % th.size + if th.count < th.size { + th.count++ + } +} + +// GetRecentEntries は最新のn件のエントリを取得(新しい順) +func (th *TargetHistory) GetRecentEntries(n int) []HistoryEntry { + if n <= 0 || th.count == 0 { + return []HistoryEntry{} + } + + if n > th.count { + n = th.count + } + + result := make([]HistoryEntry, n) + for i := 0; i < n; i++ { + // 最新から順に取得 + pos := (th.index - 1 - i + th.size) % th.size + result[i] = th.entries[pos] + } + + return result +} + +// GetEntriesSince は指定時刻以降のエントリを取得 +func (th *TargetHistory) GetEntriesSince(since time.Time) []HistoryEntry { + if th.count == 0 { + return []HistoryEntry{} + } + + var result []HistoryEntry + for i := 0; i < th.count; i++ { + pos := (th.index - 1 - i + th.size) % th.size + entry := th.entries[pos] + if entry.Timestamp.After(since) || entry.Timestamp.Equal(since) { + result = append(result, entry) + } else { + break // 古いエントリに到達したので終了 + } + } + + return result +} + +// GetConsecutiveFailures は連続失敗回数を取得 +func (th *TargetHistory) GetConsecutiveFailures() int { + if th.count == 0 { + return 0 + } + + count := 0 + for i := 0; i < th.count; i++ { + pos := (th.index - 1 - i + th.size) % th.size + entry := th.entries[pos] + if !entry.Success { + count++ + } else { + break + } + } + + return count +} + +// GetConsecutiveSuccesses は連続成功回数を取得 +func (th *TargetHistory) GetConsecutiveSuccesses() int { + if th.count == 0 { + return 0 + } + + count := 0 + for i := 0; i < th.count; i++ { + pos := (th.index - 1 - i + th.size) % th.size + entry := th.entries[pos] + if entry.Success { + count++ + } else { + break + } + } + + return count +} + +// GetSuccessRateInPeriod は指定期間内の成功率を取得 +func (th *TargetHistory) GetSuccessRateInPeriod(duration time.Duration) float64 { + if th.count == 0 { + return 0.0 + } + + since := time.Now().Add(-duration) + entries := th.GetEntriesSince(since) + + if len(entries) == 0 { + return 0.0 + } + + successCount := 0 + for _, entry := range entries { + if entry.Success { + successCount++ + } + } + + return float64(successCount) / float64(len(entries)) * 100.0 +} + +// Clear は履歴をクリア +func (th *TargetHistory) Clear() { + th.index = 0 + th.count = 0 +} \ No newline at end of file diff --git a/internal/stats/history_test.go b/internal/stats/history_test.go new file mode 100644 index 0000000..c2a3118 --- /dev/null +++ b/internal/stats/history_test.go @@ -0,0 +1,173 @@ +package stats + +import ( + "testing" + "time" + + "github.com/servak/mping/internal/prober" +) + +func TestTargetHistory(t *testing.T) { + th := NewTargetHistory(3) + + // 初期状態のテスト + if th.GetConsecutiveFailures() != 0 { + t.Errorf("Expected 0 consecutive failures, got %d", th.GetConsecutiveFailures()) + } + if th.GetConsecutiveSuccesses() != 0 { + t.Errorf("Expected 0 consecutive successes, got %d", th.GetConsecutiveSuccesses()) + } + + now := time.Now() + + // 成功エントリを追加 + th.AddEntry(HistoryEntry{ + Timestamp: now, + RTT: 100 * time.Millisecond, + Success: true, + Details: &prober.ProbeDetails{ + ProbeType: "icmp", + ICMP: &prober.ICMPDetails{ + Sequence: 1, + TTL: 64, + DataSize: 64, + }, + }, + }) + + // 連続成功数をテスト + if th.GetConsecutiveSuccesses() != 1 { + t.Errorf("Expected 1 consecutive success, got %d", th.GetConsecutiveSuccesses()) + } + + // 失敗エントリを追加 + th.AddEntry(HistoryEntry{ + Timestamp: now.Add(time.Second), + RTT: 0, + Success: false, + Error: "timeout", + }) + + // 連続失敗数をテスト + if th.GetConsecutiveFailures() != 1 { + t.Errorf("Expected 1 consecutive failure, got %d", th.GetConsecutiveFailures()) + } + if th.GetConsecutiveSuccesses() != 0 { + t.Errorf("Expected 0 consecutive successes, got %d", th.GetConsecutiveSuccesses()) + } + + // 最新エントリを取得 + recent := th.GetRecentEntries(2) + if len(recent) != 2 { + t.Errorf("Expected 2 recent entries, got %d", len(recent)) + } + if recent[0].Success != false { + t.Errorf("Expected first recent entry to be failure, got success") + } + if recent[1].Success != true { + t.Errorf("Expected second recent entry to be success, got failure") + } + + // リングバッファの動作をテスト + th.AddEntry(HistoryEntry{ + Timestamp: now.Add(2 * time.Second), + RTT: 200 * time.Millisecond, + Success: true, + }) + th.AddEntry(HistoryEntry{ + Timestamp: now.Add(3 * time.Second), + RTT: 300 * time.Millisecond, + Success: true, + }) + + // 最大サイズを超えたエントリを取得 + allRecent := th.GetRecentEntries(5) + if len(allRecent) != 3 { + t.Errorf("Expected 3 recent entries (max size), got %d", len(allRecent)) + } +} + +func TestMetricsWithHistory(t *testing.T) { + mm := NewMetricsManager() + host := "example.com" + + // 成功を記録 + details := &prober.ProbeDetails{ + ProbeType: "icmp", + ICMP: &prober.ICMPDetails{ + Sequence: 1, + TTL: 64, + DataSize: 64, + }, + } + mm.SuccessWithDetails(host, 50*time.Millisecond, time.Now(), details) + + // メトリクスを取得 + metrics := mm.GetMetrics(host) + if metrics.GetTotal() != 0 { + t.Errorf("Expected total to be 0 (Sent not called), got %d", metrics.GetTotal()) + } + if metrics.GetSuccessful() != 1 { + t.Errorf("Expected successful to be 1, got %d", metrics.GetSuccessful()) + } + + // 履歴を取得 + history := metrics.GetRecentHistory(1) + if len(history) != 1 { + t.Errorf("Expected 1 history entry, got %d", len(history)) + } + if !history[0].Success { + t.Errorf("Expected history entry to be success") + } + if history[0].Details == nil { + t.Errorf("Expected history entry to have details") + } + if history[0].Details.ProbeType != "icmp" { + t.Errorf("Expected probe type to be 'icmp', got %s", history[0].Details.ProbeType) + } + + // 失敗を記録 + mm.Failed(host, time.Now(), "timeout") + + // 連続失敗数をテスト + if metrics.GetConsecutiveFailures() != 1 { + t.Errorf("Expected 1 consecutive failure, got %d", metrics.GetConsecutiveFailures()) + } +} + +func TestSuccessRateInPeriod(t *testing.T) { + th := NewTargetHistory(10) + now := time.Now() + + // 現在時刻に近い時刻でテストデータを作成 + // 5つの成功を追加 + for i := 0; i < 5; i++ { + th.AddEntry(HistoryEntry{ + Timestamp: now.Add(time.Duration(-i-10) * time.Second), + RTT: 100 * time.Millisecond, + Success: true, + }) + } + + // 5つの失敗を追加 + for i := 0; i < 5; i++ { + th.AddEntry(HistoryEntry{ + Timestamp: now.Add(time.Duration(-i-1) * time.Second), + RTT: 0, + Success: false, + Error: "timeout", + }) + } + + // 全期間の成功率(50%) + rate := th.GetSuccessRateInPeriod(time.Hour) + if rate != 50.0 { + t.Errorf("Expected 50%% success rate, got %f%%", rate) + } + + // 直近6秒間の成功率(最新の失敗エントリのみ) + recentRate := th.GetSuccessRateInPeriod(6 * time.Second) + if recentRate != 0.0 { + t.Errorf("Expected 0%% success rate for recent period, got %f%%", recentRate) + } +} \ No newline at end of file diff --git a/internal/stats/interface.go b/internal/stats/interface.go new file mode 100644 index 0000000..6d98e66 --- /dev/null +++ b/internal/stats/interface.go @@ -0,0 +1,61 @@ +package stats + +import ( + "time" + + "github.com/servak/mping/internal/prober" +) + +// ターゲット別のメトリクス読み取り用インターフェース +type MetricsReader interface { + // 基本統計情報 + GetName() string + GetTotal() int + GetSuccessful() int + GetFailed() int + GetLoss() float64 + GetLastRTT() time.Duration + GetAverageRTT() time.Duration + GetMinimumRTT() time.Duration + GetMaximumRTT() time.Duration + GetLastSuccTime() time.Time + GetLastFailTime() time.Time + GetLastFailDetail() string + + // 履歴情報 + GetRecentHistory(n int) []HistoryEntry + GetHistorySince(since time.Time) []HistoryEntry + GetConsecutiveFailures() int + GetConsecutiveSuccesses() int + GetSuccessRateInPeriod(duration time.Duration) float64 +} + +// メトリクス管理全体のインターフェース +type MetricsManagerInterface interface { + // 基本操作 + GetMetrics(target string) MetricsReader + GetAllTargets() []string + ResetAllMetrics() + + // 統計情報登録 + Success(target string, rtt time.Duration, sentTime time.Time, details *prober.ProbeDetails) + Failed(target string, sentTime time.Time, msg string) + Sent(target string) + + // 履歴機能 + GetTargetHistory(target string, n int) []HistoryEntry + GetAllTargetsRecentHistory(n int) map[string][]HistoryEntry + + // ソート機能 + SortBy(k Key, ascending bool) []MetricsReader +} + +// 履歴専用インターフェース +type HistoryManagerInterface interface { + AddSuccessEntry(target string, timestamp time.Time, rtt time.Duration, details *prober.ProbeDetails) + AddFailureEntry(target string, timestamp time.Time, error string) + GetHistory(target string, n int) []HistoryEntry + GetHistorySince(target string, since time.Time) []HistoryEntry + ClearHistory(target string) + ClearAllHistory() +} \ No newline at end of file diff --git a/internal/stats/manager.go b/internal/stats/manager.go index cf97729..5c59d2a 100644 --- a/internal/stats/manager.go +++ b/internal/stats/manager.go @@ -8,16 +8,26 @@ import ( "github.com/servak/mping/internal/prober" ) +const ( + DefaultHistorySize = 100 // デフォルトの履歴保持数 +) + type MetricsManager struct { - metrics map[string]*Metrics - mu sync.Mutex + metrics map[string]*Metrics + historySize int // 履歴保持数 + mu sync.Mutex } // 新しいMetricsManagerを生成 func NewMetricsManager() *MetricsManager { - metrics := make(map[string]*Metrics) + return NewMetricsManagerWithHistorySize(DefaultHistorySize) +} + +// 履歴サイズを指定してMetricsManagerを生成 +func NewMetricsManagerWithHistorySize(historySize int) *MetricsManager { return &MetricsManager{ - metrics: metrics, + metrics: make(map[string]*Metrics), + historySize: historySize, } } @@ -27,7 +37,8 @@ func (mm *MetricsManager) Register(target, name string) { return } mm.metrics[target] = &Metrics{ - Name: name, + Name: name, + history: NewTargetHistory(mm.historySize), } } @@ -39,7 +50,8 @@ func (mm *MetricsManager) GetMetrics(host string) *Metrics { m, ok := mm.metrics[host] if !ok { m = &Metrics{ - Name: host, + Name: host, + history: NewTargetHistory(mm.historySize), } mm.metrics[host] = m } @@ -58,10 +70,23 @@ func (mm *MetricsManager) ResetAllMetrics() { // ホストに対する成功を登録 func (mm *MetricsManager) Success(host string, rtt time.Duration, sentTime time.Time) { + mm.SuccessWithDetails(host, rtt, sentTime, nil) +} + +// ホストに対する成功を詳細情報付きで登録 +func (mm *MetricsManager) SuccessWithDetails(host string, rtt time.Duration, sentTime time.Time, details *prober.ProbeDetails) { m := mm.GetMetrics(host) mm.mu.Lock() m.Success(rtt, sentTime) + if m.history != nil { + m.history.AddEntry(HistoryEntry{ + Timestamp: sentTime, + RTT: rtt, + Success: true, + Details: details, + }) + } mm.mu.Unlock() } @@ -71,6 +96,14 @@ func (mm *MetricsManager) Failed(host string, sentTime time.Time, msg string) { mm.mu.Lock() m.Fail(sentTime, msg) + if m.history != nil { + m.history.AddEntry(HistoryEntry{ + Timestamp: sentTime, + RTT: 0, + Success: false, + Error: msg, + }) + } mm.mu.Unlock() } @@ -91,7 +124,7 @@ func (mm *MetricsManager) Subscribe(res <-chan *prober.Event) { case prober.SENT: mm.Sent(r.Key) case prober.SUCCESS: - mm.Success(r.Key, r.Rtt, r.SentTime) + mm.SuccessWithDetails(r.Key, r.Rtt, r.SentTime, r.Details) case prober.TIMEOUT: mm.Failed(r.Key, r.SentTime, r.Message) case prober.FAILED: @@ -108,7 +141,8 @@ func (mm *MetricsManager) autoRegister(key, displayName string) { if _, exists := mm.metrics[key]; !exists { mm.metrics[key] = &Metrics{ - Name: displayName, + Name: displayName, + history: NewTargetHistory(mm.historySize), } } } @@ -133,30 +167,29 @@ func (mm *MetricsManager) SortBy(k Key, ascending bool) []Metrics { case Host: result = res[i].Name < res[j].Name case Sent: - result = mi.Total < mj.Total // 昇順:小さい値が先 + result = mi.Total < mj.Total case Success: - result = mi.Successful < mj.Successful // 昇順:小さい値が先 + result = mi.Successful < mj.Successful case Loss: - result = mi.Loss < mj.Loss // 昇順:小さい値が先 + result = mi.Loss < mj.Loss case Fail: - result = mi.Failed < mj.Failed // 昇順:小さい値が先 + result = mi.Failed < mj.Failed case Last: - result = rejectLessAscending(mi.LastRTT, mj.LastRTT) // 昇順対応 + result = rejectLessAscending(mi.LastRTT, mj.LastRTT) case Avg: - result = rejectLessAscending(mi.AverageRTT, mj.AverageRTT) // 昇順対応 + result = rejectLessAscending(mi.AverageRTT, mj.AverageRTT) case Best: - result = rejectLessAscending(mi.MinimumRTT, mj.MinimumRTT) // 昇順対応 + result = rejectLessAscending(mi.MinimumRTT, mj.MinimumRTT) case Worst: - result = rejectLessAscending(mi.MaximumRTT, mj.MaximumRTT) // 昇順対応 + result = rejectLessAscending(mi.MaximumRTT, mj.MaximumRTT) case LastSuccTime: - result = mi.LastSuccTime.Before(mj.LastSuccTime) // 昇順:古い時刻が先 + result = mi.LastSuccTime.Before(mj.LastSuccTime) case LastFailTime: - result = mi.LastFailTime.Before(mj.LastFailTime) // 昇順:古い時刻が先 + result = mi.LastFailTime.Before(mj.LastFailTime) default: return false } - // ascending=falseの場合は結果を反転 if ascending { return result } else { @@ -166,6 +199,102 @@ func (mm *MetricsManager) SortBy(k Key, ascending bool) []Metrics { return res } +// SortByWithReader は MetricsReader インターフェースを使用するバージョン +func (mm *MetricsManager) SortByWithReader(k Key, ascending bool) []MetricsReader { + mm.mu.Lock() + var res []MetricsReader + for _, m := range mm.metrics { + res = append(res, m) + } + mm.mu.Unlock() + + if k != Host { + sort.SliceStable(res, func(i, j int) bool { + return res[i].GetName() < res[j].GetName() + }) + } + sort.SliceStable(res, func(i, j int) bool { + mi := res[i] + mj := res[j] + var result bool + switch k { + case Host: + result = mi.GetName() < mj.GetName() + case Sent: + result = mi.GetTotal() < mj.GetTotal() + case Success: + result = mi.GetSuccessful() < mj.GetSuccessful() + case Loss: + result = 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()) + case LastSuccTime: + result = mi.GetLastSuccTime().Before(mj.GetLastSuccTime()) + case LastFailTime: + result = mi.GetLastFailTime().Before(mj.GetLastFailTime()) + default: + return false + } + + if ascending { + return result + } else { + return !result + } + }) + return res +} + +// GetAllTargets は全ターゲットのリストを取得 +func (mm *MetricsManager) GetAllTargets() []string { + mm.mu.Lock() + defer mm.mu.Unlock() + + var targets []string + for target := range mm.metrics { + targets = append(targets, target) + } + return targets +} + +// GetTargetHistory は指定ターゲットの履歴を取得 +func (mm *MetricsManager) GetTargetHistory(target string, n int) []HistoryEntry { + mm.mu.Lock() + defer mm.mu.Unlock() + + if m, exists := mm.metrics[target]; exists && m.history != nil { + return m.history.GetRecentEntries(n) + } + return []HistoryEntry{} +} + +// GetAllTargetsRecentHistory は全ターゲットの最新履歴を取得 +func (mm *MetricsManager) GetAllTargetsRecentHistory(n int) map[string][]HistoryEntry { + mm.mu.Lock() + defer mm.mu.Unlock() + + result := make(map[string][]HistoryEntry) + for target, m := range mm.metrics { + if m.history != nil { + result[target] = m.history.GetRecentEntries(n) + } + } + return result +} + +// GetMetricsAsReader は MetricsReader インターフェースとして取得 +func (mm *MetricsManager) GetMetricsAsReader(target string) MetricsReader { + return mm.GetMetrics(target) +} + // rejectLessAscending は昇順ソート用のRTT比較関数 // 0値(未測定)は常に後ろに配置される func rejectLessAscending(i, j time.Duration) bool { diff --git a/internal/stats/metric.go b/internal/stats/metric.go index 3dbee1a..fb71493 100644 --- a/internal/stats/metric.go +++ b/internal/stats/metric.go @@ -18,6 +18,7 @@ type Metrics struct { LastFailTime time.Time LastSuccTime time.Time LastFailDetail string + history *TargetHistory // 履歴情報 } func (m *Metrics) Success(rtt time.Duration, sentTime time.Time) { @@ -63,4 +64,92 @@ func (m *Metrics) Reset() { m.LastFailTime = time.Time{} m.LastSuccTime = time.Time{} m.LastFailDetail = "" + if m.history != nil { + m.history.Clear() + } +} + +// MetricsReader インターフェースの実装 + +func (m *Metrics) GetName() string { + return m.Name +} + +func (m *Metrics) GetTotal() int { + return m.Total +} + +func (m *Metrics) GetSuccessful() int { + return m.Successful +} + +func (m *Metrics) GetFailed() int { + return m.Failed +} + +func (m *Metrics) GetLoss() float64 { + return m.Loss +} + +func (m *Metrics) GetLastRTT() time.Duration { + return m.LastRTT +} + +func (m *Metrics) GetAverageRTT() time.Duration { + return m.AverageRTT +} + +func (m *Metrics) GetMinimumRTT() time.Duration { + return m.MinimumRTT +} + +func (m *Metrics) GetMaximumRTT() time.Duration { + return m.MaximumRTT +} + +func (m *Metrics) GetLastSuccTime() time.Time { + return m.LastSuccTime +} + +func (m *Metrics) GetLastFailTime() time.Time { + return m.LastFailTime +} + +func (m *Metrics) GetLastFailDetail() string { + return m.LastFailDetail +} + +func (m *Metrics) GetRecentHistory(n int) []HistoryEntry { + if m.history == nil { + return []HistoryEntry{} + } + return m.history.GetRecentEntries(n) +} + +func (m *Metrics) GetHistorySince(since time.Time) []HistoryEntry { + if m.history == nil { + return []HistoryEntry{} + } + return m.history.GetEntriesSince(since) +} + +func (m *Metrics) GetConsecutiveFailures() int { + if m.history == nil { + return 0 + } + return m.history.GetConsecutiveFailures() +} + +func (m *Metrics) GetConsecutiveSuccesses() int { + if m.history == nil { + return 0 + } + return m.history.GetConsecutiveSuccesses() +} + +func (m *Metrics) GetSuccessRateInPeriod(duration time.Duration) float64 { + if m.history == nil { + return 0.0 + } + return m.history.GetSuccessRateInPeriod(duration) } diff --git a/internal/ui/shared/filters.go b/internal/ui/shared/filters.go index 2c66bf7..9246884 100644 --- a/internal/ui/shared/filters.go +++ b/internal/ui/shared/filters.go @@ -7,15 +7,15 @@ import ( ) // FilterMetrics filters metrics based on filter text -func FilterMetrics(metrics []stats.Metrics, filterText string) []stats.Metrics { +func FilterMetrics(metrics []stats.MetricsReader, filterText string) []stats.MetricsReader { if filterText == "" { return metrics } - filtered := []stats.Metrics{} + filtered := []stats.MetricsReader{} filterLower := strings.ToLower(filterText) for _, m := range metrics { - if strings.Contains(strings.ToLower(m.Name), filterLower) { + if strings.Contains(strings.ToLower(m.GetName()), filterLower) { filtered = append(filtered, m) } } diff --git a/internal/ui/shared/formatters.go b/internal/ui/shared/formatters.go index 9384140..924cb05 100644 --- a/internal/ui/shared/formatters.go +++ b/internal/ui/shared/formatters.go @@ -27,7 +27,7 @@ func TimeFormater(t time.Time) string { } // FormatHostDetail generates detailed information for a host -func FormatHostDetail(metric stats.Metrics) string { +func FormatHostDetail(metric stats.MetricsReader) string { return fmt.Sprintf(`Host Details: %s Total Probes: %d @@ -41,17 +41,17 @@ Maximum RTT: %s Last Success: %s Last Failure: %s Last Error: %s`, - metric.Name, - metric.Total, - metric.Successful, - metric.Failed, - metric.Loss, - DurationFormater(metric.LastRTT), - DurationFormater(metric.AverageRTT), - DurationFormater(metric.MinimumRTT), - DurationFormater(metric.MaximumRTT), - TimeFormater(metric.LastSuccTime), - TimeFormater(metric.LastFailTime), - metric.LastFailDetail, + metric.GetName(), + metric.GetTotal(), + metric.GetSuccessful(), + metric.GetFailed(), + metric.GetLoss(), + DurationFormater(metric.GetLastRTT()), + DurationFormater(metric.GetAverageRTT()), + DurationFormater(metric.GetMinimumRTT()), + DurationFormater(metric.GetMaximumRTT()), + TimeFormater(metric.GetLastSuccTime()), + TimeFormater(metric.GetLastFailTime()), + metric.GetLastFailDetail(), ) } \ No newline at end of file diff --git a/internal/ui/shared/table_data.go b/internal/ui/shared/table_data.go index 971c9b2..0dfb224 100644 --- a/internal/ui/shared/table_data.go +++ b/internal/ui/shared/table_data.go @@ -14,11 +14,11 @@ import ( type TableData struct { Headers []string Rows [][]string - Metrics []stats.Metrics // Keep reference for interactive row selection + Metrics []stats.MetricsReader // Keep reference for interactive row selection } // NewTableData creates TableData from metrics -func NewTableData(metrics []stats.Metrics, sortKey stats.Key, ascending bool) *TableData { +func NewTableData(metrics []stats.MetricsReader, sortKey stats.Key, ascending bool) *TableData { // Generate headers with sort arrows headers := []string{ headerWithArrow("Host", stats.Host, sortKey, ascending), @@ -42,18 +42,18 @@ func NewTableData(metrics []stats.Metrics, sortKey stats.Key, ascending bool) *T for i, m := range metrics { rows[i] = []string{ - m.Name, - fmt.Sprintf("%d", m.Total), - fmt.Sprintf("%d", m.Successful), - fmt.Sprintf("%d", m.Failed), - fmt.Sprintf("%5.1f%%", m.Loss), - df(m.LastRTT), - df(m.AverageRTT), - df(m.MinimumRTT), - df(m.MaximumRTT), - tf(m.LastSuccTime), - tf(m.LastFailTime), - m.LastFailDetail, + m.GetName(), + fmt.Sprintf("%d", m.GetTotal()), + fmt.Sprintf("%d", m.GetSuccessful()), + fmt.Sprintf("%d", m.GetFailed()), + fmt.Sprintf("%5.1f%%", m.GetLoss()), + df(m.GetLastRTT()), + df(m.GetAverageRTT()), + df(m.GetMinimumRTT()), + df(m.GetMaximumRTT()), + tf(m.GetLastSuccTime()), + tf(m.GetLastFailTime()), + m.GetLastFailDetail(), } } @@ -149,9 +149,9 @@ func (td *TableData) ToTviewTable() *tview.Table { } // GetMetricAtRow returns the metric for a given row index -func (td *TableData) GetMetricAtRow(row int) (stats.Metrics, bool) { +func (td *TableData) GetMetricAtRow(row int) (stats.MetricsReader, bool) { if row < 0 || row >= len(td.Metrics) { - return stats.Metrics{}, false + return nil, false } return td.Metrics[row], true } diff --git a/internal/ui/tui/app.go b/internal/ui/tui/app.go index 54ad2a1..21b0511 100644 --- a/internal/ui/tui/app.go +++ b/internal/ui/tui/app.go @@ -282,7 +282,7 @@ func (a *TUIApp) handleRowSelection(row, col int) { } // showHostDetails displays detailed information for a selected host -func (a *TUIApp) showHostDetails(metric stats.Metrics) { +func (a *TUIApp) showHostDetails(metric stats.MetricsReader) { detailText := shared.FormatHostDetail(metric) // Create and show modal @@ -299,8 +299,8 @@ func (a *TUIApp) showHostDetails(metric stats.Metrics) { } // getFilteredMetrics returns filtered metrics based on current state -func (a *TUIApp) getFilteredMetrics() []stats.Metrics { - metrics := a.mm.SortBy(a.state.GetSortKey(), a.state.IsAscending()) +func (a *TUIApp) getFilteredMetrics() []stats.MetricsReader { + metrics := a.mm.SortByWithReader(a.state.GetSortKey(), a.state.IsAscending()) return shared.FilterMetrics(metrics, a.state.GetFilter()) } diff --git a/internal/ui/tui/panels/host_list.go b/internal/ui/tui/panels/host_list.go index 3ffd54f..7d4f9b1 100644 --- a/internal/ui/tui/panels/host_list.go +++ b/internal/ui/tui/panels/host_list.go @@ -72,8 +72,8 @@ func (h *HostListPanel) Update() { } // getFilteredMetrics returns filtered metrics based on current state -func (h *HostListPanel) getFilteredMetrics() []stats.Metrics { - metrics := h.mm.SortBy(h.renderState.GetSortKey(), h.renderState.IsAscending()) +func (h *HostListPanel) getFilteredMetrics() []stats.MetricsReader { + metrics := h.mm.SortByWithReader(h.renderState.GetSortKey(), h.renderState.IsAscending()) return shared.FilterMetrics(metrics, h.renderState.GetFilter()) } @@ -91,10 +91,10 @@ func (h *HostListPanel) GetView() *tview.Table { } // GetSelectedMetric returns the currently selected metric -func (h *HostListPanel) GetSelectedMetric(tableData *shared.TableData) (stats.Metrics, bool) { +func (h *HostListPanel) GetSelectedMetric(tableData *shared.TableData) (stats.MetricsReader, bool) { row, _ := h.table.GetSelection() if row <= 0 { - return stats.Metrics{}, false + return nil, false } return tableData.GetMetricAtRow(row - 1) // Subtract 1 for header } @@ -102,7 +102,7 @@ func (h *HostListPanel) GetSelectedMetric(tableData *shared.TableData) (stats.Me // 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.Name + return metric.GetName() } return "" } @@ -225,7 +225,7 @@ func (h *HostListPanel) populateTableFromData(tableData *shared.TableData) { // restoreSelection finds and selects the row containing the specified host func (h *HostListPanel) restoreSelection(tableData *shared.TableData, selectedHost string) { for i, metric := range tableData.Metrics { - if metric.Name == selectedHost { + if metric.GetName() == selectedHost { h.table.Select(i+1, 0) // +1 because row 0 is header return } From b8fad6354459d240f812f0c9485fa01918d91424 Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Mon, 7 Jul 2025 23:12:51 +0900 Subject: [PATCH 02/13] feat: Add toggle-based detail view with comprehensive history display - Add 'v' key toggle for showing/hiding detailed host information panel - Implement 2-pane layout: host list (left) + detail panel (right) - Display probe history (last 10 entries) with timestamps and probe-specific details - Show basic statistics: RTT metrics, success/failure counts, packet loss - Support all probe types: ICMP (seq/ttl/size), HTTP (status/size), DNS (code/answers), NTP (stratum/offset), TCP (connection) - Fix TCP detail display issue by passing metrics objects directly instead of hostname strings - Default to single-pane view for simplicity, toggle detail view on demand --- internal/ui/shared/formatters.go | 77 ++++++++++++++++++++++++++- internal/ui/tui/app.go | 34 ++++++------ internal/ui/tui/layout.go | 70 +++++++++++++++++++++++- internal/ui/tui/panels/host_detail.go | 51 ++++++++++++++---- internal/ui/tui/panels/host_list.go | 11 ++++ 5 files changed, 212 insertions(+), 31 deletions(-) diff --git a/internal/ui/shared/formatters.go b/internal/ui/shared/formatters.go index 924cb05..af565fa 100644 --- a/internal/ui/shared/formatters.go +++ b/internal/ui/shared/formatters.go @@ -2,8 +2,10 @@ package shared import ( "fmt" + "strings" "time" + "github.com/servak/mping/internal/prober" "github.com/servak/mping/internal/stats" ) @@ -28,7 +30,7 @@ func TimeFormater(t time.Time) string { // FormatHostDetail generates detailed information for a host func FormatHostDetail(metric stats.MetricsReader) string { - return fmt.Sprintf(`Host Details: %s + basicInfo := fmt.Sprintf(`Host Details: %s Total Probes: %d Successful: %d @@ -54,4 +56,77 @@ Last Error: %s`, TimeFormater(metric.GetLastFailTime()), metric.GetLastFailDetail(), ) + + // Add history section + historySection := FormatHistory(metric) + if historySection != "" { + basicInfo += "\n\n" + historySection + } + + return basicInfo +} + +// FormatHistory generates history section for a host +func FormatHistory(metric stats.MetricsReader) string { + history := metric.GetRecentHistory(10) + if len(history) == 0 { + return "" + } + + var sb strings.Builder + sb.WriteString("Recent History (last 10 entries):\n") + sb.WriteString("Time Status RTT Details\n") + sb.WriteString("-------- ------ ------- --------\n") + + for _, entry := range history { + status := "OK" + if !entry.Success { + status = "FAIL" + } + + details := formatProbeDetails(entry.Details) + sb.WriteString(fmt.Sprintf("%-8s %-6s %-7s %s\n", + entry.Timestamp.Format("15:04:05"), + status, + DurationFormater(entry.RTT), + details, + )) + } + + return sb.String() +} + +// formatProbeDetails formats probe-specific details +func formatProbeDetails(details *prober.ProbeDetails) string { + if details == nil { + return "" + } + + switch details.ProbeType { + case "icmp": + if details.ICMP != nil { + return fmt.Sprintf("seq=%d ttl=%d size=%d", + details.ICMP.Sequence, details.ICMP.TTL, details.ICMP.DataSize) + } + case "http", "https": + if details.HTTP != nil { + return fmt.Sprintf("status=%d size=%d", + details.HTTP.StatusCode, details.HTTP.ResponseSize) + } + case "dns": + if details.DNS != nil { + return fmt.Sprintf("code=%d answers=%d server=%s", + details.DNS.ResponseCode, details.DNS.AnswerCount, details.DNS.Server) + } + case "ntp": + if details.NTP != nil { + offset := time.Duration(details.NTP.Offset) * time.Microsecond + return fmt.Sprintf("stratum=%d offset=%s", + details.NTP.Stratum, DurationFormater(offset)) + } + case "tcp": + return "connection" + } + + return "" } \ No newline at end of file diff --git a/internal/ui/tui/app.go b/internal/ui/tui/app.go index 21b0511..a520b3d 100644 --- a/internal/ui/tui/app.go +++ b/internal/ui/tui/app.go @@ -88,6 +88,11 @@ func (a *TUIApp) setupCallbacks() { // Set row selection callback a.layout.GetHostListPanel().SetSelectedFunc(a.handleRowSelection) + + // Set selection change callback for detail panel updates + a.layout.GetHostListPanel().SetSelectionChangeCallback(func(metrics stats.MetricsReader) { + a.layout.SetSelectedMetrics(metrics) + }) } // setupKeyBindings configures key bindings @@ -108,6 +113,7 @@ func (a *TUIApp) setupKeyBindings() { return event } + // When filter input is visible, let it handle its own keys if a.layout.IsFilterShown() { return event @@ -130,6 +136,9 @@ func (a *TUIApp) setupKeyBindings() { case 'h': a.showHelp() return nil + case 'v': + a.toggleDetailView() + return nil case 's': a.nextSort() return nil @@ -173,6 +182,7 @@ NAVIGATION: S Previous sort key r Reverse sort order R Reset all metrics + v Toggle detail view / Filter hosts h Show/hide this help q, Ctrl+C Quit application @@ -232,6 +242,11 @@ func (a *TUIApp) clearFilter() { a.state.ClearFilter() } +// View toggle methods +func (a *TUIApp) toggleDetailView() { + a.layout.ToggleDetailView() +} + func (a *TUIApp) handleFilterDone(key tcell.Key) { switch key { case tcell.KeyEnter: @@ -277,26 +292,11 @@ func (a *TUIApp) handleRowSelection(row, col int) { // Convert table row to data row (subtract 1 for header) dataRow := row - 1 if metric, ok := tableData.GetMetricAtRow(dataRow); ok { - a.showHostDetails(metric) + // Update detail panel instead of showing modal + a.layout.SetSelectedMetrics(metric) } } -// showHostDetails displays detailed information for a selected host -func (a *TUIApp) showHostDetails(metric stats.MetricsReader) { - detailText := shared.FormatHostDetail(metric) - - // Create and show modal - modal := tview.NewModal(). - SetText(detailText). - AddButtons([]string{"Close"}). - SetDoneFunc(func(buttonIndex int, buttonLabel string) { - // Remove modal and restore focus - a.layout.RemoveModal("details") - }) - - a.layout.AddModal("details", modal) - a.layout.ShowPage("details") -} // getFilteredMetrics returns filtered metrics based on current state func (a *TUIApp) getFilteredMetrics() []stats.MetricsReader { diff --git a/internal/ui/tui/layout.go b/internal/ui/tui/layout.go index 99cb3bb..6022493 100644 --- a/internal/ui/tui/layout.go +++ b/internal/ui/tui/layout.go @@ -30,7 +30,7 @@ type LayoutManager struct { header *panels.HeaderPanel hostList *panels.HostListPanel footer *panels.FooterPanel - _ *panels.HostDetailPanel + hostDetail *panels.HostDetailPanel // Filter input filterInput *tview.InputField @@ -61,7 +61,7 @@ func (l *LayoutManager) setupPanels(uiState *state.UIState, mm *stats.MetricsMan l.header = panels.NewHeaderPanel(uiState, config, interval, timeout) l.hostList = panels.NewHostListPanel(uiState, mm) l.footer = panels.NewFooterPanel(config) - // hostDetail will be created on demand + l.hostDetail = panels.NewHostDetailPanel(mm) // Setup filter input l.filterInput = tview.NewInputField(). @@ -72,6 +72,7 @@ func (l *LayoutManager) setupPanels(uiState *state.UIState, mm *stats.MetricsMan // setupLayout configures the main layout structure func (l *LayoutManager) setupLayout() { + // Start with single pane layout (host list only) l.root = tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(l.header.GetView(), 1, 0, false). @@ -95,11 +96,68 @@ func (l *LayoutManager) GetHostListPanel() *panels.HostListPanel { return l.hostList } +// GetHostDetailPanel returns the host detail panel +func (l *LayoutManager) GetHostDetailPanel() *panels.HostDetailPanel { + return l.hostDetail +} + +// SetSelectedHost updates the detail panel with the selected host +func (l *LayoutManager) SetSelectedHost(hostname string) { + l.hostDetail.SetHost(hostname) +} + +// SetSelectedMetrics updates the detail panel with the selected metrics +func (l *LayoutManager) SetSelectedMetrics(metrics stats.MetricsReader) { + l.hostDetail.SetMetrics(metrics) +} + +// ToggleDetailView switches between single and dual pane layout +func (l *LayoutManager) ToggleDetailView() { + if l.mode == ListOnly { + l.showDetailView() + } else { + l.hideDetailView() + } +} + +// showDetailView switches to dual pane layout +func (l *LayoutManager) showDetailView() { + l.mode = ListWithDetail + + // 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) +} + +// hideDetailView switches to single pane layout +func (l *LayoutManager) hideDetailView() { + l.mode = ListOnly + + // 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(l.footer.GetView(), 1, 0, false) +} + // UpdateAll refreshes all panels func (l *LayoutManager) UpdateAll() { l.header.Update() l.footer.Update() l.hostList.Update() + + // Only update detail panel when it's visible + if l.mode == ListWithDetail { + l.hostDetail.Update() + } } // HandleKeyEvent handles key events for navigation @@ -208,6 +266,14 @@ func (l *LayoutManager) RemoveModal(name string) { l.pages.RemovePage(name) } +func (l *LayoutManager) AddPage(name string, page tview.Primitive, resize, visible bool) { + l.pages.AddPage(name, page, resize, visible) +} + +func (l *LayoutManager) RemovePage(name string) { + l.pages.RemovePage(name) +} + func (l *LayoutManager) ShowPage(name string) { l.pages.ShowPage(name) } diff --git a/internal/ui/tui/panels/host_detail.go b/internal/ui/tui/panels/host_detail.go index 338b60d..9a05104 100644 --- a/internal/ui/tui/panels/host_detail.go +++ b/internal/ui/tui/panels/host_detail.go @@ -2,36 +2,61 @@ package panels import ( "github.com/rivo/tview" + + "github.com/servak/mping/internal/stats" + "github.com/servak/mping/internal/ui/shared" ) -// HostDetailPanel manages host detail display (future feature) +// HostDetailPanel manages host detail display type HostDetailPanel struct { - view *tview.TextView + view *tview.TextView + currentHost string + currentMetrics stats.MetricsReader + mm *stats.MetricsManager } // NewHostDetailPanel creates a new HostDetailPanel -func NewHostDetailPanel() *HostDetailPanel { +func NewHostDetailPanel(mm *stats.MetricsManager) *HostDetailPanel { view := tview.NewTextView() view.SetDynamicColors(true). SetScrollable(true). SetBorder(true). - SetTitle("Host Details") + SetTitle(" Host Details "). + SetTitleAlign(tview.AlignCenter) return &HostDetailPanel{ view: view, + mm: mm, } } -// Update refreshes host detail display (future feature) +// Update refreshes host detail display for current host func (h *HostDetailPanel) Update() { - // This will be implemented when we add the side panel feature - // For now, it's just a placeholder - content := `Host Details Panel -(Future feature - will show detailed metrics for selected host)` - + if h.currentMetrics == nil { + h.view.SetText("Select a host to view details") + return + } + + // Format and display the host details with history + content := shared.FormatHostDetail(h.currentMetrics) h.view.SetText(content) } +// SetHost sets the current host to display details for +func (h *HostDetailPanel) SetHost(hostname string) { + h.currentHost = hostname + h.view.SetTitle(" Host Details: " + hostname + " ") +} + +// SetMetrics sets the current metrics object directly +func (h *HostDetailPanel) SetMetrics(metrics stats.MetricsReader) { + h.currentMetrics = metrics + if metrics != nil { + h.currentHost = metrics.GetName() + h.view.SetTitle(" Host Details: " + h.currentHost + " ") + } +} + // GetView returns the underlying tview component func (h *HostDetailPanel) GetView() *tview.TextView { return h.view @@ -39,5 +64,9 @@ func (h *HostDetailPanel) GetView() *tview.TextView { // SetVisible controls whether the detail panel is visible func (h *HostDetailPanel) SetVisible(visible bool) { - // Implementation for future layout mode switching + if visible { + h.view.SetBorder(true) + } else { + h.view.SetBorder(false) + } } \ No newline at end of file diff --git a/internal/ui/tui/panels/host_list.go b/internal/ui/tui/panels/host_list.go index 7d4f9b1..6b32180 100644 --- a/internal/ui/tui/panels/host_list.go +++ b/internal/ui/tui/panels/host_list.go @@ -15,6 +15,7 @@ type HostListPanel struct { renderState state.RenderState selectionState state.SelectionState mm *stats.MetricsManager + onSelectionChange func(metrics stats.MetricsReader) // Callback when selection changes } type HostListParams interface { @@ -83,6 +84,11 @@ func (h *HostListPanel) updateSelectedHost() { tableData := shared.NewTableData(metrics, h.renderState.GetSortKey(), h.renderState.IsAscending()) selectedHost := h.GetSelectedHost(tableData) h.selectionState.SetSelectedHost(selectedHost) + + // Call the callback to update detail panel with metrics object + if metric, ok := h.GetSelectedMetric(tableData); ok && h.onSelectionChange != nil { + h.onSelectionChange(metric) + } } // GetView returns the underlying tview component @@ -112,6 +118,11 @@ func (h *HostListPanel) SetSelectedFunc(fn func(row, col int)) { h.table.SetSelectedFunc(fn) } +// SetSelectionChangeCallback sets the callback for when selection changes +func (h *HostListPanel) SetSelectionChangeCallback(fn func(metrics stats.MetricsReader)) { + h.onSelectionChange = fn +} + // Navigation methods func (h *HostListPanel) ScrollDown() { row, _ := h.table.GetSelection() From 1daf8ec9a191fb9940a6916b7875da5391e25729 Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Mon, 7 Jul 2025 23:21:50 +0900 Subject: [PATCH 03/13] refactor: Improve detail view formatting and error display - Remove redundant host name from detail view content (already shown in title bar) - Display error messages in history Details column for failed probes - Fix ICMP details to show only implemented fields (seq, size) instead of unimplemented TTL - Add fallback probe type labels when detailed information is unavailable - Simplify DNS details display by removing server information for cleaner layout - Enhance history readability with context-aware Details column (errors vs probe specifics) --- internal/ui/shared/formatters.go | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/internal/ui/shared/formatters.go b/internal/ui/shared/formatters.go index af565fa..a66851b 100644 --- a/internal/ui/shared/formatters.go +++ b/internal/ui/shared/formatters.go @@ -30,9 +30,7 @@ func TimeFormater(t time.Time) string { // FormatHostDetail generates detailed information for a host func FormatHostDetail(metric stats.MetricsReader) string { - basicInfo := fmt.Sprintf(`Host Details: %s - -Total Probes: %d + basicInfo := fmt.Sprintf(`Total Probes: %d Successful: %d Failed: %d Loss Rate: %.1f%% @@ -43,7 +41,6 @@ Maximum RTT: %s Last Success: %s Last Failure: %s Last Error: %s`, - metric.GetName(), metric.GetTotal(), metric.GetSuccessful(), metric.GetFailed(), @@ -80,11 +77,19 @@ func FormatHistory(metric stats.MetricsReader) string { for _, entry := range history { status := "OK" + details := "" + if !entry.Success { status = "FAIL" + // Show error message for failed entries + if entry.Error != "" { + details = entry.Error + } + } else { + // Show probe-specific details for successful entries + details = formatProbeDetails(entry.Details) } - details := formatProbeDetails(entry.Details) sb.WriteString(fmt.Sprintf("%-8s %-6s %-7s %s\n", entry.Timestamp.Format("15:04:05"), status, @@ -103,27 +108,32 @@ func formatProbeDetails(details *prober.ProbeDetails) string { } switch details.ProbeType { - case "icmp": + case "icmp", "icmpv4", "icmpv6": if details.ICMP != nil { - return fmt.Sprintf("seq=%d ttl=%d size=%d", - details.ICMP.Sequence, details.ICMP.TTL, details.ICMP.DataSize) + // Only show sequence and size for now (TTL is not properly implemented) + return fmt.Sprintf("seq=%d size=%d", + details.ICMP.Sequence, details.ICMP.DataSize) } + return "icmp ping" case "http", "https": if details.HTTP != nil { return fmt.Sprintf("status=%d size=%d", details.HTTP.StatusCode, details.HTTP.ResponseSize) } + return "http probe" case "dns": if details.DNS != nil { - return fmt.Sprintf("code=%d answers=%d server=%s", - details.DNS.ResponseCode, details.DNS.AnswerCount, details.DNS.Server) + return fmt.Sprintf("code=%d answers=%d", + details.DNS.ResponseCode, details.DNS.AnswerCount) } + return "dns query" case "ntp": if details.NTP != nil { offset := time.Duration(details.NTP.Offset) * time.Microsecond return fmt.Sprintf("stratum=%d offset=%s", details.NTP.Stratum, DurationFormater(offset)) } + return "ntp sync" case "tcp": return "connection" } From 397b257811d316a6cf80d21b1a6333090802113e Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Mon, 7 Jul 2025 23:49:52 +0900 Subject: [PATCH 04/13] fix: Resolve filter infinite loop and enhance DNS details display - Fix infinite loop when applying filters by removing immediate Update() call - Add selection change detection to prevent redundant callback executions - Simplify DNS details format by removing redundant domain/server information - Add first DNS answer value display with configurable truncation (35 chars) - Improve protocol indication (TCP only when used, UDP implied by absence) - Extract DNS answer parsing logic into dedicated function for reusability --- internal/ui/shared/formatters.go | 46 +++++++++++++++++++++++++++-- internal/ui/tui/app.go | 3 +- internal/ui/tui/panels/host_list.go | 12 +++++--- 3 files changed, 54 insertions(+), 7 deletions(-) diff --git a/internal/ui/shared/formatters.go b/internal/ui/shared/formatters.go index a66851b..2e0b6e2 100644 --- a/internal/ui/shared/formatters.go +++ b/internal/ui/shared/formatters.go @@ -123,8 +123,24 @@ func formatProbeDetails(details *prober.ProbeDetails) string { return "http probe" case "dns": if details.DNS != nil { - return fmt.Sprintf("code=%d answers=%d", - details.DNS.ResponseCode, details.DNS.AnswerCount) + proto := "" + if details.DNS.UseTCP { + proto = "tcp " + } + + // Show just the essential info: protocol, response code, answer count, and first answer + baseInfo := fmt.Sprintf("%scode=%d ans=%d", + proto, details.DNS.ResponseCode, details.DNS.AnswerCount) + + // Add first answer if available + if len(details.DNS.Answers) > 0 { + firstAnswer := extractDNSAnswer(details.DNS.Answers[0]) + if firstAnswer != "" { + baseInfo += " " + firstAnswer + } + } + + return baseInfo } return "dns query" case "ntp": @@ -139,4 +155,30 @@ func formatProbeDetails(details *prober.ProbeDetails) string { } return "" +} + +// extractDNSAnswer extracts the answer value from DNS record string +// Example: "google.com. 300 IN A 142.250.196.14" -> "142.250.196.14" +func extractDNSAnswer(record string) string { + if record == "" { + return "" + } + + // Split by whitespace and get the last part (the answer value) + parts := strings.Fields(record) + if len(parts) == 0 { + return "" + } + + // The last part is usually the answer value + answer := parts[len(parts)-1] + + // Truncate very long answers (like long TXT records) + // TODO: Make this configurable in the future + maxAnswerLength := 35 + if len(answer) > maxAnswerLength { + answer = answer[:maxAnswerLength-3] + "..." + } + + return answer } \ No newline at end of file diff --git a/internal/ui/tui/app.go b/internal/ui/tui/app.go index a520b3d..40e78d2 100644 --- a/internal/ui/tui/app.go +++ b/internal/ui/tui/app.go @@ -254,7 +254,8 @@ func (a *TUIApp) handleFilterDone(key tcell.Key) { filterText := a.layout.GetFilterText() a.state.SetFilter(filterText) a.layout.HideFilterInput() - a.Update() + // Don't call a.Update() here - it causes infinite loop + // The regular update cycle will handle the refresh a.layout.RestoreFocus() case tcell.KeyEscape: // Cancel filter input diff --git a/internal/ui/tui/panels/host_list.go b/internal/ui/tui/panels/host_list.go index 6b32180..53acf51 100644 --- a/internal/ui/tui/panels/host_list.go +++ b/internal/ui/tui/panels/host_list.go @@ -83,11 +83,15 @@ func (h *HostListPanel) updateSelectedHost() { metrics := h.getFilteredMetrics() tableData := shared.NewTableData(metrics, h.renderState.GetSortKey(), h.renderState.IsAscending()) selectedHost := h.GetSelectedHost(tableData) - h.selectionState.SetSelectedHost(selectedHost) - // Call the callback to update detail panel with metrics object - if metric, ok := h.GetSelectedMetric(tableData); ok && h.onSelectionChange != nil { - h.onSelectionChange(metric) + // Only update if the selection actually changed to avoid loops + if h.selectionState.GetSelectedHost() != selectedHost { + h.selectionState.SetSelectedHost(selectedHost) + + // Call the callback to update detail panel with metrics object + if metric, ok := h.GetSelectedMetric(tableData); ok && h.onSelectionChange != nil { + h.onSelectionChange(metric) + } } } From c62dac5b9b662b891f6d4cc96378518481c6fbc9 Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Mon, 7 Jul 2025 23:53:51 +0900 Subject: [PATCH 05/13] feat: Add comprehensive color coding to detail view - Color-code basic statistics with status-aware colors: * Success count: green (with data) / red (zero) * Failed count: red (with failures) / white (zero) * Loss rate: green (<10%) / yellow (10-50%) / red (>50%) * Labels: consistent cyan coloring - Enhance history section with visual hierarchy: * Section titles in yellow for prominence * Headers in cyan for structure * Timestamps in gray for subtlety * Status indicators: green (OK) / red (FAIL) * Error messages highlighted in red - Improve overall readability and instant problem identification --- internal/ui/shared/formatters.go | 61 +++++++++++++++++++++----------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/internal/ui/shared/formatters.go b/internal/ui/shared/formatters.go index 2e0b6e2..ae77164 100644 --- a/internal/ui/shared/formatters.go +++ b/internal/ui/shared/formatters.go @@ -30,21 +30,40 @@ func TimeFormater(t time.Time) string { // FormatHostDetail generates detailed information for a host func FormatHostDetail(metric stats.MetricsReader) string { - basicInfo := fmt.Sprintf(`Total Probes: %d -Successful: %d -Failed: %d -Loss Rate: %.1f%% -Last RTT: %s -Average RTT: %s -Minimum RTT: %s -Maximum RTT: %s -Last Success: %s -Last Failure: %s -Last Error: %s`, + // Color-coded basic statistics + lossRate := metric.GetLoss() + lossColor := "green" + if lossRate > 50 { + lossColor = "red" + } else if lossRate > 10 { + lossColor = "yellow" + } + + successColor := "green" + if metric.GetSuccessful() == 0 { + successColor = "red" + } + + failColor := "white" + if metric.GetFailed() > 0 { + failColor = "red" + } + + basicInfo := fmt.Sprintf(`[cyan]Total Probes:[white] %d +[%s]Successful:[white] %d +[%s]Failed:[white] %d +[cyan]Loss Rate:[white] [%s]%.1f%%[white] +[cyan]Last RTT:[white] %s +[cyan]Average RTT:[white] %s +[cyan]Minimum RTT:[white] %s +[cyan]Maximum RTT:[white] %s +[cyan]Last Success:[white] %s +[cyan]Last Failure:[white] %s +[cyan]Last Error:[white] %s`, metric.GetTotal(), - metric.GetSuccessful(), - metric.GetFailed(), - metric.GetLoss(), + successColor, metric.GetSuccessful(), + failColor, metric.GetFailed(), + lossColor, lossRate, DurationFormater(metric.GetLastRTT()), DurationFormater(metric.GetAverageRTT()), DurationFormater(metric.GetMinimumRTT()), @@ -71,28 +90,30 @@ func FormatHistory(metric stats.MetricsReader) string { } var sb strings.Builder - sb.WriteString("Recent History (last 10 entries):\n") - sb.WriteString("Time Status RTT Details\n") - sb.WriteString("-------- ------ ------- --------\n") + sb.WriteString("\n[yellow]Recent History (last 10 entries):[white]\n") + sb.WriteString("[cyan]Time Status RTT Details[white]\n") + sb.WriteString("[gray]-------- ------ ------- --------[white]\n") for _, entry := range history { + statusColor := "green" status := "OK" details := "" if !entry.Success { status = "FAIL" + statusColor = "red" // Show error message for failed entries if entry.Error != "" { - details = entry.Error + details = fmt.Sprintf("[red]%s[white]", entry.Error) } } else { // Show probe-specific details for successful entries details = formatProbeDetails(entry.Details) } - sb.WriteString(fmt.Sprintf("%-8s %-6s %-7s %s\n", + sb.WriteString(fmt.Sprintf("[gray]%-8s[white] [%s]%-6s[white] %-7s %s\n", entry.Timestamp.Format("15:04:05"), - status, + statusColor, status, DurationFormater(entry.RTT), details, )) From 382fb5ab2229de5d0e21661333122485a8d8d6b8 Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Tue, 8 Jul 2025 00:29:17 +0900 Subject: [PATCH 06/13] fix: Change default sort key from Success to Fail in table views - Updated metrics sorting logic to use `stats.Fail` instead of `stats.Success` to highlight failing endpoints by default. - Updated UI state default `sortKey` to `stats.Fail` and changed `ascending` to `false` to show most problematic rows at the top. - Adjusted `ToGoPrettyTable` to handle wide character rendering correctly by disabling East Asian width override with `text.OverrideRuneWidthEastAsianWidth(false)`. - Minor formatting improvements in `ToTviewTable()` for consistent code style. --- internal/command/mping.go | 4 ++-- internal/ui/shared/table_data.go | 14 ++++++++------ internal/ui/tui/state/state.go | 4 ++-- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/internal/command/mping.go b/internal/command/mping.go index f0829fd..ba3a8ae 100644 --- a/internal/command/mping.go +++ b/internal/command/mping.go @@ -104,8 +104,8 @@ mping dns://8.8.8.8/google.com`, probeManager.Stop() // Final results - metrics := metricsManager.SortByWithReader(stats.Success, true) - tableData := shared.NewTableData(metrics, stats.Success, true) + metrics := metricsManager.SortByWithReader(stats.Fail, false) + tableData := shared.NewTableData(metrics, stats.Fail, false) t := tableData.ToGoPrettyTable() t.SetStyle(table.StyleLight) cmd.Println(t.Render()) diff --git a/internal/ui/shared/table_data.go b/internal/ui/shared/table_data.go index 0dfb224..1284f1e 100644 --- a/internal/ui/shared/table_data.go +++ b/internal/ui/shared/table_data.go @@ -5,6 +5,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" "github.com/rivo/tview" "github.com/servak/mping/internal/stats" @@ -66,6 +67,7 @@ func NewTableData(metrics []stats.MetricsReader, sortKey stats.Key, ascending bo // ToGoPrettyTable converts to go-pretty table format for final output only func (td *TableData) ToGoPrettyTable() table.Writer { + text.OverrideRuneWidthEastAsianWidth(false) t := table.NewWriter() // Convert headers to interface{} slice @@ -92,11 +94,11 @@ func (td *TableData) ToTviewTable() *tview.Table { t := tview.NewTable(). SetFixed(1, 0). SetSelectable(true, false). - SetBorders(false). // Disable all borders - SetSeparator(' '). // Use space separator instead of lines + SetBorders(false). // Disable all borders + SetSeparator(' '). // Use space separator instead of lines SetSelectedStyle(tcell.StyleDefault. Background(tcell.ColorDarkBlue). - Foreground(tcell.ColorWhite)) // Pattern 1: DarkBlue + White - k9s style + Foreground(tcell.ColorWhite)) // Pattern 1: DarkBlue + White - k9s style // Define alignment for each column alignments := []int{ @@ -120,7 +122,7 @@ func (td *TableData) ToTviewTable() *tview.Table { if col < len(alignments) { alignment = alignments[col] } - + t.SetCell(0, col, &tview.TableCell{ Text: " " + header + " ", Color: tcell.ColorYellow, @@ -136,7 +138,7 @@ func (td *TableData) ToTviewTable() *tview.Table { if col < len(alignments) { alignment = alignments[col] } - + t.SetCell(row+1, col, &tview.TableCell{ Text: " " + cellData + " ", Color: tcell.ColorWhite, @@ -166,4 +168,4 @@ func headerWithArrow(header string, key stats.Key, sortKey stats.Key, ascending } } return header -} \ No newline at end of file +} diff --git a/internal/ui/tui/state/state.go b/internal/ui/tui/state/state.go index 141779b..be8d24f 100644 --- a/internal/ui/tui/state/state.go +++ b/internal/ui/tui/state/state.go @@ -18,7 +18,7 @@ type UIState struct { // NewUIState creates a new UIState with defaults func NewUIState() *UIState { return &UIState{ - sortKey: stats.Success, + sortKey: stats.Fail, ascending: false, // Default to descending filterText: "", selectedHost: "", @@ -80,4 +80,4 @@ func (s *UIState) ClearFilter() { s.mu.Lock() defer s.mu.Unlock() s.filterText = "" -} \ No newline at end of file +} From 04346f56457dfb7fb71f9f5455917e561c222a39 Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Tue, 8 Jul 2025 07:24:43 +0900 Subject: [PATCH 07/13] fix: Resolve lint and test issues for MetricsReader interface - Fix type conversion issues in test files to use MetricsReader interface - Update test expectations to match color-coded formatter output - Fix default sort key test to use Fail instead of Success - Ensure all tests pass with new interface implementation --- internal/ui/shared/filters_test.go | 42 +++++++++---------- internal/ui/shared/formatters_test.go | 52 ++++++++++++------------ internal/ui/tui/panels/host_list_test.go | 6 +-- internal/ui/tui/state/state_test.go | 4 +- 4 files changed, 52 insertions(+), 52 deletions(-) diff --git a/internal/ui/shared/filters_test.go b/internal/ui/shared/filters_test.go index ffea9e5..94c7ed3 100644 --- a/internal/ui/shared/filters_test.go +++ b/internal/ui/shared/filters_test.go @@ -9,29 +9,29 @@ import ( func TestFilterMetrics(t *testing.T) { // Create test metrics - metrics := []stats.Metrics{ - { + metrics := []stats.MetricsReader{ + &stats.Metrics{ Name: "google.com", Total: 100, Successful: 95, Failed: 5, LastSuccTime: time.Now(), }, - { + &stats.Metrics{ Name: "yahoo.com", Total: 50, Successful: 48, Failed: 2, LastSuccTime: time.Now(), }, - { + &stats.Metrics{ Name: "example.org", Total: 25, Successful: 25, Failed: 0, LastSuccTime: time.Now(), }, - { + &stats.Metrics{ Name: "test.net", Total: 10, Successful: 8, @@ -108,7 +108,7 @@ func TestFilterMetrics(t *testing.T) { // Check that all expected names are present resultNames := make(map[string]bool) for _, metric := range result { - resultNames[metric.Name] = true + resultNames[metric.GetName()] = true } for _, expectedName := range tt.expectedNames { @@ -121,7 +121,7 @@ func TestFilterMetrics(t *testing.T) { if len(resultNames) != len(tt.expectedNames) { actualNames := make([]string, 0, len(result)) for _, metric := range result { - actualNames = append(actualNames, metric.Name) + actualNames = append(actualNames, metric.GetName()) } t.Errorf("FilterMetrics() returned unexpected metrics. Got: %v, Want: %v", actualNames, tt.expectedNames) } @@ -130,24 +130,24 @@ func TestFilterMetrics(t *testing.T) { } func TestFilterMetricsPreservesOrder(t *testing.T) { - metrics := []stats.Metrics{ - {Name: "alpha.com"}, - {Name: "beta.com"}, - {Name: "gamma.com"}, + metrics := []stats.MetricsReader{ + &stats.Metrics{Name: "alpha.com"}, + &stats.Metrics{Name: "beta.com"}, + &stats.Metrics{Name: "gamma.com"}, } result := FilterMetrics(metrics, ".com") expectedOrder := []string{"alpha.com", "beta.com", "gamma.com"} for i, metric := range result { - if metric.Name != expectedOrder[i] { - t.Errorf("FilterMetrics() changed order. Got %s at position %d, want %s", metric.Name, i, expectedOrder[i]) + if metric.GetName() != expectedOrder[i] { + t.Errorf("FilterMetrics() changed order. Got %s at position %d, want %s", metric.GetName(), i, expectedOrder[i]) } } } func TestFilterMetricsWithEmptyMetrics(t *testing.T) { - var metrics []stats.Metrics + var metrics []stats.MetricsReader result := FilterMetrics(metrics, "test") @@ -157,14 +157,14 @@ func TestFilterMetricsWithEmptyMetrics(t *testing.T) { } func TestFilterMetricsDoesNotModifyOriginal(t *testing.T) { - original := []stats.Metrics{ - {Name: "test1.com"}, - {Name: "test2.com"}, - {Name: "example.org"}, + original := []stats.MetricsReader{ + &stats.Metrics{Name: "test1.com"}, + &stats.Metrics{Name: "test2.com"}, + &stats.Metrics{Name: "example.org"}, } // Create a copy to compare later - originalCopy := make([]stats.Metrics, len(original)) + originalCopy := make([]stats.MetricsReader, len(original)) copy(originalCopy, original) // Filter metrics @@ -176,8 +176,8 @@ func TestFilterMetricsDoesNotModifyOriginal(t *testing.T) { } for i, metric := range original { - if metric.Name != originalCopy[i].Name { - t.Errorf("FilterMetrics() modified original slice at index %d: got %s, want %s", i, metric.Name, originalCopy[i].Name) + if metric.GetName() != originalCopy[i].GetName() { + t.Errorf("FilterMetrics() modified original slice at index %d: got %s, want %s", i, metric.GetName(), originalCopy[i].GetName()) } } } \ No newline at end of file diff --git a/internal/ui/shared/formatters_test.go b/internal/ui/shared/formatters_test.go index 4745ce7..de68ac5 100644 --- a/internal/ui/shared/formatters_test.go +++ b/internal/ui/shared/formatters_test.go @@ -96,7 +96,7 @@ func TestTimeFormater(t *testing.T) { func TestFormatHostDetail(t *testing.T) { testTime := time.Date(2024, 1, 1, 15, 30, 45, 0, time.UTC) - metric := stats.Metrics{ + metric := &stats.Metrics{ Name: "example.com", Total: 100, Successful: 95, @@ -114,18 +114,18 @@ func TestFormatHostDetail(t *testing.T) { result := FormatHostDetail(metric) expectedContents := []string{ - "Host Details: example.com", - "Total Probes: 100", - "Successful: 95", - "Failed: 5", - "Loss Rate: 5.0%", - "Last RTT: 25ms", - "Average RTT: 30ms", - "Minimum RTT: 20ms", - "Maximum RTT: 40ms", - "Last Success: 15:30:45", - "Last Failure: 15:30:46", - "Last Error: timeout", + "Total Probes:[white] 100", + "Successful:[white] 95", + "Failed:[white] 5", + "Loss Rate:[white]", + "5.0%", + "Last RTT:[white] 25ms", + "Average RTT:[white] 30ms", + "Minimum RTT:[white] 20ms", + "Maximum RTT:[white] 40ms", + "Last Success:[white] 15:30:45", + "Last Failure:[white] 15:30:46", + "Last Error:[white] timeout", } for _, expected := range expectedContents { @@ -136,7 +136,7 @@ func TestFormatHostDetail(t *testing.T) { } func TestFormatHostDetailWithZeroValues(t *testing.T) { - metric := stats.Metrics{ + metric := &stats.Metrics{ Name: "test.com", Total: 0, Successful: 0, @@ -154,18 +154,18 @@ func TestFormatHostDetailWithZeroValues(t *testing.T) { result := FormatHostDetail(metric) expectedContents := []string{ - "Host Details: test.com", - "Total Probes: 0", - "Successful: 0", - "Failed: 0", - "Loss Rate: 0.0%", - "Last RTT: -", - "Average RTT: -", - "Minimum RTT: -", - "Maximum RTT: -", - "Last Success: -", - "Last Failure: -", - "Last Error: ", + "Total Probes:[white] 0", + "Successful:[white] 0", + "Failed:[white] 0", + "Loss Rate:[white]", + "0.0%", + "Last RTT:[white] -", + "Average RTT:[white] -", + "Minimum RTT:[white] -", + "Maximum RTT:[white] -", + "Last Success:[white] -", + "Last Failure:[white] -", + "Last Error:[white] ", } for _, expected := range expectedContents { diff --git a/internal/ui/tui/panels/host_list_test.go b/internal/ui/tui/panels/host_list_test.go index 8672d47..239c144 100644 --- a/internal/ui/tui/panels/host_list_test.go +++ b/internal/ui/tui/panels/host_list_test.go @@ -276,9 +276,9 @@ func TestHostListPanelRestoreSelection(t *testing.T) { mm.Register("google.com", "google.com") mm.Register("example.com", "example.com") - metrics := []stats.Metrics{ - {Name: "google.com"}, - {Name: "example.com"}, + metrics := []stats.MetricsReader{ + &stats.Metrics{Name: "google.com"}, + &stats.Metrics{Name: "example.com"}, } tableData := shared.NewTableData(metrics, stats.Success, false) diff --git a/internal/ui/tui/state/state_test.go b/internal/ui/tui/state/state_test.go index 50757d2..d7a6af5 100644 --- a/internal/ui/tui/state/state_test.go +++ b/internal/ui/tui/state/state_test.go @@ -16,8 +16,8 @@ func TestNewUIState(t *testing.T) { } // Test default values - if state.GetSortKey() != stats.Success { - t.Errorf("Expected default sort key to be %v, got %v", stats.Success, state.GetSortKey()) + if state.GetSortKey() != stats.Fail { + t.Errorf("Expected default sort key to be %v, got %v", stats.Fail, state.GetSortKey()) } if state.IsAscending() { From 64cf1ad8a83126533732d14c71bdbc92562d8ffa Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Tue, 8 Jul 2025 08:49:15 +0900 Subject: [PATCH 08/13] refactor: Convert Japanese comments to English throughout codebase - Replace all Japanese comments with clear English equivalents - Update comments in prober package (details.go, dns.go, http.go, icmp.go, ntp.go, tcp.go) - Update comments in stats package (history.go, interface.go, manager.go, metric.go, history_test.go) - Maintain original meaning and context in all translations - Fix minor ICMP variable naming issues discovered during translation - All functionality remains unchanged, only comment language updated --- internal/prober/details.go | 8 ++++---- internal/prober/dns.go | 2 +- internal/prober/http.go | 4 ++-- internal/prober/icmp.go | 17 ++++++++--------- internal/prober/ntp.go | 6 +++--- internal/prober/prober.go | 2 +- internal/prober/tcp.go | 4 ++-- internal/stats/history.go | 24 ++++++++++++------------ internal/stats/history_test.go | 32 ++++++++++++++++---------------- internal/stats/interface.go | 18 +++++++++--------- internal/stats/manager.go | 34 +++++++++++++++++----------------- internal/stats/metric.go | 2 +- 12 files changed, 76 insertions(+), 77 deletions(-) diff --git a/internal/prober/details.go b/internal/prober/details.go index 79beaed..b97f9a0 100644 --- a/internal/prober/details.go +++ b/internal/prober/details.go @@ -1,15 +1,15 @@ package prober -// プローブ詳細情報(TargetIPを削除、TCPDetailsも削除) +// Probe detail information (TargetIP and TCPDetails removed) type ProbeDetails struct { ProbeType string `json:"probe_type"` - // 型別詳細(どれか一つのみ使用) + // Type-specific details (only one should be used) ICMP *ICMPDetails `json:"icmp,omitempty"` HTTP *HTTPDetails `json:"http,omitempty"` DNS *DNSDetails `json:"dns,omitempty"` NTP *NTPDetails `json:"ntp,omitempty"` - // TCP は詳細情報なし(接続可否のみのため) + // TCP has no detailed information (only connection availability) } type ICMPDetails struct { @@ -40,6 +40,6 @@ type NTPDetails struct { Server string `json:"server"` Port int `json:"port"` Stratum int `json:"stratum"` - Offset int64 `json:"offset_microseconds"` // マイクロ秒単位 + Offset int64 `json:"offset_microseconds"` // In microseconds Precision int `json:"precision"` } \ No newline at end of file diff --git a/internal/prober/dns.go b/internal/prober/dns.go index 7b3d3e9..679b1d7 100644 --- a/internal/prober/dns.go +++ b/internal/prober/dns.go @@ -262,7 +262,7 @@ func (p *DNSProber) sent(result chan *Event, target *DNSTarget, sentTime time.Ti } func (p *DNSProber) success(result chan *Event, target *DNSTarget, sentTime time.Time, rtt time.Duration, resp *dns.Msg) { - // DNS詳細情報を作成 + // Create DNS detail information var answers []string for _, ans := range resp.Answer { answers = append(answers, ans.String()) diff --git a/internal/prober/http.go b/internal/prober/http.go index 17cada9..57dbc0c 100644 --- a/internal/prober/http.go +++ b/internal/prober/http.go @@ -182,11 +182,11 @@ func (p *HTTPProber) probe(r chan *Event, target string) { } else if p.config.ExpectBody != "" && p.config.ExpectBody != strings.TrimRight(string(body), "\n") { p.failed(r, target, now, errors.New("invalid body")) } else { - // HTTP詳細情報を作成 + // Create HTTP detail information headers := make(map[string]string) for key, values := range resp.Header { if len(values) > 0 { - headers[key] = values[0] // 最初の値のみを取得 + headers[key] = values[0] // Get only the first value } } diff --git a/internal/prober/icmp.go b/internal/prober/icmp.go index f31a86a..44121ee 100644 --- a/internal/prober/icmp.go +++ b/internal/prober/icmp.go @@ -12,7 +12,6 @@ import ( "golang.org/x/net/icmp" "golang.org/x/net/ipv4" "golang.org/x/net/ipv6" - ) const ( @@ -172,7 +171,7 @@ func (p *ICMPProber) sent(r chan *Event, addr string) { } } -func (p *ICMPProber) success(r chan *Event, runCnt int, addr string) { +func (p *ICMPProber) success(r chan *Event, runCnt int, addr string, payload icmp.Message) { p.mu.Lock() defer p.mu.Unlock() for k, table := range p.tables { @@ -188,17 +187,17 @@ func (p *ICMPProber) success(r chan *Event, runCnt int, addr string) { table[addr] = true elapse := time.Since(k.sentTime) key, displayName := p.getTargetInfo(addr) - - // ICMP詳細情報を作成 + + // Create ICMP detail information details := &ProbeDetails{ ProbeType: string(p.version), ICMP: &ICMPDetails{ Sequence: runCnt, - TTL: 0, // TODO: IPヘッダーから取得 - DataSize: len(p.body) + 8, // ICMPヘッダー(8bytes) + データ + TTL: 0, // TODO: Extract from IP header + DataSize: len(p.body) + 8, // ICMP header (8 bytes) + data }, } - + r <- &Event{ Key: key, DisplayName: displayName, @@ -313,7 +312,7 @@ func (p *ICMPProber) probe(r chan *Event) { func (p *ICMPProber) recvPkts(r chan *Event) { pktbuf := make([]byte, maxPacketSize) for { - n, ip, err := p.c.ReadFrom(pktbuf) + n, addr, err := p.c.ReadFrom(pktbuf) if err != nil { fmt.Printf("Error reading ICMP packet: %s\n", err) os.Exit(1) @@ -336,7 +335,7 @@ func (p *ICMPProber) recvPkts(r chan *Event) { if rm.Code == 0 { switch rm.Type { case ipv4.ICMPTypeEchoReply, ipv6.ICMPTypeEchoReply: - p.success(r, int(seq), ip.String()) + p.success(r, int(seq), addr.String(), *rm) } } } diff --git a/internal/prober/ntp.go b/internal/prober/ntp.go index a8dc8f7..9654fdb 100644 --- a/internal/prober/ntp.go +++ b/internal/prober/ntp.go @@ -251,10 +251,10 @@ func (p *NTPProber) sent(result chan *Event, serverAddr, displayName string, sen } func (p *NTPProber) success(result chan *Event, serverAddr, displayName string, sentTime time.Time, rtt time.Duration, resp *ntpPacket, offset time.Duration) { - // NTP詳細情報を作成 - // ポートを抽出(serverAddrは "host:port" 形式) + // Create NTP detail information + // Extract port (serverAddr is in "host:port" format) _, portStr, _ := net.SplitHostPort(serverAddr) - port := 123 // デフォルトNTPポート + port := 123 // Default NTP port if portStr != "" { if p, err := net.LookupPort("udp", portStr); err == nil { port = p diff --git a/internal/prober/prober.go b/internal/prober/prober.go index aaebe37..951de8b 100644 --- a/internal/prober/prober.go +++ b/internal/prober/prober.go @@ -30,7 +30,7 @@ type Event struct { SentTime time.Time Rtt time.Duration Message string - Details *ProbeDetails // 追加:詳細情報 + Details *ProbeDetails // Added: detailed information } type Prober interface { diff --git a/internal/prober/tcp.go b/internal/prober/tcp.go index dcfacfc..ca84e81 100644 --- a/internal/prober/tcp.go +++ b/internal/prober/tcp.go @@ -192,7 +192,7 @@ func (p *TCPProber) sent(result chan *Event, target string, sentTime time.Time) func (p *TCPProber) success(result chan *Event, target string, sentTime time.Time, rtt time.Duration) { displayName := p.targets[target] // Get displayName from targets map - // TCP は接続可否のみの確認なので詳細情報なし + // TCP only checks connectivity, so no detailed information result <- &Event{ Key: target, DisplayName: displayName, @@ -200,7 +200,7 @@ func (p *TCPProber) success(result chan *Event, target string, sentTime time.Tim SentTime: sentTime, Rtt: rtt, Message: "", - Details: nil, // TCP は詳細情報なし + Details: nil, // TCP has no detailed information } } diff --git a/internal/stats/history.go b/internal/stats/history.go index a816aac..8522931 100644 --- a/internal/stats/history.go +++ b/internal/stats/history.go @@ -6,7 +6,7 @@ import ( "github.com/servak/mping/internal/prober" ) -// 履歴エントリ +// History entry type HistoryEntry struct { Timestamp time.Time `json:"timestamp"` RTT time.Duration `json:"rtt"` @@ -15,7 +15,7 @@ type HistoryEntry struct { Details *prober.ProbeDetails `json:"details,omitempty"` } -// 各ターゲットの履歴を管理する構造体(リングバッファ) +// Structure to manage history for each target (ring buffer) type TargetHistory struct { entries []HistoryEntry size int // リングバッファサイズ @@ -23,7 +23,7 @@ type TargetHistory struct { count int // 実際のエントリ数 } -// NewTargetHistory は新しいTargetHistoryを作成 +// NewTargetHistory creates a new TargetHistory func NewTargetHistory(size int) *TargetHistory { return &TargetHistory{ entries: make([]HistoryEntry, size), @@ -33,7 +33,7 @@ func NewTargetHistory(size int) *TargetHistory { } } -// AddEntry は新しい履歴エントリを追加 +// AddEntry adds a new history entry func (th *TargetHistory) AddEntry(entry HistoryEntry) { th.entries[th.index] = entry th.index = (th.index + 1) % th.size @@ -42,7 +42,7 @@ func (th *TargetHistory) AddEntry(entry HistoryEntry) { } } -// GetRecentEntries は最新のn件のエントリを取得(新しい順) +// GetRecentEntries retrieves the latest n entries (newest first) func (th *TargetHistory) GetRecentEntries(n int) []HistoryEntry { if n <= 0 || th.count == 0 { return []HistoryEntry{} @@ -54,7 +54,7 @@ func (th *TargetHistory) GetRecentEntries(n int) []HistoryEntry { result := make([]HistoryEntry, n) for i := 0; i < n; i++ { - // 最新から順に取得 + // Retrieve from newest to oldest pos := (th.index - 1 - i + th.size) % th.size result[i] = th.entries[pos] } @@ -62,7 +62,7 @@ func (th *TargetHistory) GetRecentEntries(n int) []HistoryEntry { return result } -// GetEntriesSince は指定時刻以降のエントリを取得 +// GetEntriesSince retrieves entries since the specified time func (th *TargetHistory) GetEntriesSince(since time.Time) []HistoryEntry { if th.count == 0 { return []HistoryEntry{} @@ -75,14 +75,14 @@ func (th *TargetHistory) GetEntriesSince(since time.Time) []HistoryEntry { if entry.Timestamp.After(since) || entry.Timestamp.Equal(since) { result = append(result, entry) } else { - break // 古いエントリに到達したので終了 + break // Reached older entries, so stop } } return result } -// GetConsecutiveFailures は連続失敗回数を取得 +// GetConsecutiveFailures retrieves the number of consecutive failures func (th *TargetHistory) GetConsecutiveFailures() int { if th.count == 0 { return 0 @@ -102,7 +102,7 @@ func (th *TargetHistory) GetConsecutiveFailures() int { return count } -// GetConsecutiveSuccesses は連続成功回数を取得 +// GetConsecutiveSuccesses retrieves the number of consecutive successes func (th *TargetHistory) GetConsecutiveSuccesses() int { if th.count == 0 { return 0 @@ -122,7 +122,7 @@ func (th *TargetHistory) GetConsecutiveSuccesses() int { return count } -// GetSuccessRateInPeriod は指定期間内の成功率を取得 +// GetSuccessRateInPeriod retrieves the success rate within the specified period func (th *TargetHistory) GetSuccessRateInPeriod(duration time.Duration) float64 { if th.count == 0 { return 0.0 @@ -145,7 +145,7 @@ func (th *TargetHistory) GetSuccessRateInPeriod(duration time.Duration) float64 return float64(successCount) / float64(len(entries)) * 100.0 } -// Clear は履歴をクリア +// Clear clears the history func (th *TargetHistory) Clear() { th.index = 0 th.count = 0 diff --git a/internal/stats/history_test.go b/internal/stats/history_test.go index c2a3118..854b306 100644 --- a/internal/stats/history_test.go +++ b/internal/stats/history_test.go @@ -10,7 +10,7 @@ import ( func TestTargetHistory(t *testing.T) { th := NewTargetHistory(3) - // 初期状態のテスト + // Test initial state if th.GetConsecutiveFailures() != 0 { t.Errorf("Expected 0 consecutive failures, got %d", th.GetConsecutiveFailures()) } @@ -20,7 +20,7 @@ func TestTargetHistory(t *testing.T) { now := time.Now() - // 成功エントリを追加 + // Add success entry th.AddEntry(HistoryEntry{ Timestamp: now, RTT: 100 * time.Millisecond, @@ -35,12 +35,12 @@ func TestTargetHistory(t *testing.T) { }, }) - // 連続成功数をテスト + // Test consecutive successes if th.GetConsecutiveSuccesses() != 1 { t.Errorf("Expected 1 consecutive success, got %d", th.GetConsecutiveSuccesses()) } - // 失敗エントリを追加 + // Add failure entry th.AddEntry(HistoryEntry{ Timestamp: now.Add(time.Second), RTT: 0, @@ -48,7 +48,7 @@ func TestTargetHistory(t *testing.T) { Error: "timeout", }) - // 連続失敗数をテスト + // Test consecutive failures if th.GetConsecutiveFailures() != 1 { t.Errorf("Expected 1 consecutive failure, got %d", th.GetConsecutiveFailures()) } @@ -56,7 +56,7 @@ func TestTargetHistory(t *testing.T) { t.Errorf("Expected 0 consecutive successes, got %d", th.GetConsecutiveSuccesses()) } - // 最新エントリを取得 + // Get recent entries recent := th.GetRecentEntries(2) if len(recent) != 2 { t.Errorf("Expected 2 recent entries, got %d", len(recent)) @@ -68,7 +68,7 @@ func TestTargetHistory(t *testing.T) { t.Errorf("Expected second recent entry to be success, got failure") } - // リングバッファの動作をテスト + // Test ring buffer behavior th.AddEntry(HistoryEntry{ Timestamp: now.Add(2 * time.Second), RTT: 200 * time.Millisecond, @@ -80,7 +80,7 @@ func TestTargetHistory(t *testing.T) { Success: true, }) - // 最大サイズを超えたエントリを取得 + // Get entries exceeding maximum size allRecent := th.GetRecentEntries(5) if len(allRecent) != 3 { t.Errorf("Expected 3 recent entries (max size), got %d", len(allRecent)) @@ -91,7 +91,7 @@ func TestMetricsWithHistory(t *testing.T) { mm := NewMetricsManager() host := "example.com" - // 成功を記録 + // Record success details := &prober.ProbeDetails{ ProbeType: "icmp", ICMP: &prober.ICMPDetails{ @@ -126,10 +126,10 @@ func TestMetricsWithHistory(t *testing.T) { t.Errorf("Expected probe type to be 'icmp', got %s", history[0].Details.ProbeType) } - // 失敗を記録 + // Record failure mm.Failed(host, time.Now(), "timeout") - // 連続失敗数をテスト + // Test consecutive failures if metrics.GetConsecutiveFailures() != 1 { t.Errorf("Expected 1 consecutive failure, got %d", metrics.GetConsecutiveFailures()) } @@ -139,8 +139,8 @@ func TestSuccessRateInPeriod(t *testing.T) { th := NewTargetHistory(10) now := time.Now() - // 現在時刻に近い時刻でテストデータを作成 - // 5つの成功を追加 + // Create test data with timestamps close to current time + // Add 5 successes for i := 0; i < 5; i++ { th.AddEntry(HistoryEntry{ Timestamp: now.Add(time.Duration(-i-10) * time.Second), @@ -149,7 +149,7 @@ func TestSuccessRateInPeriod(t *testing.T) { }) } - // 5つの失敗を追加 + // Add 5 failures for i := 0; i < 5; i++ { th.AddEntry(HistoryEntry{ Timestamp: now.Add(time.Duration(-i-1) * time.Second), @@ -159,13 +159,13 @@ func TestSuccessRateInPeriod(t *testing.T) { }) } - // 全期間の成功率(50%) + // Overall success rate (50%) rate := th.GetSuccessRateInPeriod(time.Hour) if rate != 50.0 { t.Errorf("Expected 50%% success rate, got %f%%", rate) } - // 直近6秒間の成功率(最新の失敗エントリのみ) + // Success rate for recent 6 seconds (only latest failure entries) recentRate := th.GetSuccessRateInPeriod(6 * time.Second) if recentRate != 0.0 { t.Errorf("Expected 0%% success rate for recent period, got %f%%", recentRate) diff --git a/internal/stats/interface.go b/internal/stats/interface.go index 6d98e66..f4966c8 100644 --- a/internal/stats/interface.go +++ b/internal/stats/interface.go @@ -6,9 +6,9 @@ import ( "github.com/servak/mping/internal/prober" ) -// ターゲット別のメトリクス読み取り用インターフェース +// Interface for reading metrics by target type MetricsReader interface { - // 基本統計情報 + // Basic statistics GetName() string GetTotal() int GetSuccessful() int @@ -22,7 +22,7 @@ type MetricsReader interface { GetLastFailTime() time.Time GetLastFailDetail() string - // 履歴情報 + // History information GetRecentHistory(n int) []HistoryEntry GetHistorySince(since time.Time) []HistoryEntry GetConsecutiveFailures() int @@ -30,27 +30,27 @@ type MetricsReader interface { GetSuccessRateInPeriod(duration time.Duration) float64 } -// メトリクス管理全体のインターフェース +// Interface for overall metrics management type MetricsManagerInterface interface { - // 基本操作 + // Basic operations GetMetrics(target string) MetricsReader GetAllTargets() []string ResetAllMetrics() - // 統計情報登録 + // Statistics registration Success(target string, rtt time.Duration, sentTime time.Time, details *prober.ProbeDetails) Failed(target string, sentTime time.Time, msg string) Sent(target string) - // 履歴機能 + // History functions GetTargetHistory(target string, n int) []HistoryEntry GetAllTargetsRecentHistory(n int) map[string][]HistoryEntry - // ソート機能 + // Sort functions SortBy(k Key, ascending bool) []MetricsReader } -// 履歴専用インターフェース +// Interface dedicated to history management type HistoryManagerInterface interface { AddSuccessEntry(target string, timestamp time.Time, rtt time.Duration, details *prober.ProbeDetails) AddFailureEntry(target string, timestamp time.Time, error string) diff --git a/internal/stats/manager.go b/internal/stats/manager.go index 5c59d2a..ba038da 100644 --- a/internal/stats/manager.go +++ b/internal/stats/manager.go @@ -9,21 +9,21 @@ import ( ) const ( - DefaultHistorySize = 100 // デフォルトの履歴保持数 + DefaultHistorySize = 100 // Default number of history entries to keep ) type MetricsManager struct { metrics map[string]*Metrics - historySize int // 履歴保持数 + historySize int // Number of history entries to keep mu sync.Mutex } -// 新しいMetricsManagerを生成 +// Create a new MetricsManager func NewMetricsManager() *MetricsManager { return NewMetricsManagerWithHistorySize(DefaultHistorySize) } -// 履歴サイズを指定してMetricsManagerを生成 +// Create MetricsManager with specified history size func NewMetricsManagerWithHistorySize(historySize int) *MetricsManager { return &MetricsManager{ metrics: make(map[string]*Metrics), @@ -68,12 +68,12 @@ func (mm *MetricsManager) ResetAllMetrics() { } } -// ホストに対する成功を登録 +// Register success for host func (mm *MetricsManager) Success(host string, rtt time.Duration, sentTime time.Time) { mm.SuccessWithDetails(host, rtt, sentTime, nil) } -// ホストに対する成功を詳細情報付きで登録 +// Register success for host with detailed information func (mm *MetricsManager) SuccessWithDetails(host string, rtt time.Duration, sentTime time.Time, details *prober.ProbeDetails) { m := mm.GetMetrics(host) @@ -90,7 +90,7 @@ func (mm *MetricsManager) SuccessWithDetails(host string, rtt time.Duration, sen mm.mu.Unlock() } -// ホストに対する失敗を登録 +// Register failure for host func (mm *MetricsManager) Failed(host string, sentTime time.Time, msg string) { m := mm.GetMetrics(host) @@ -199,7 +199,7 @@ func (mm *MetricsManager) SortBy(k Key, ascending bool) []Metrics { return res } -// SortByWithReader は MetricsReader インターフェースを使用するバージョン +// SortByWithReader is a version that uses the MetricsReader interface func (mm *MetricsManager) SortByWithReader(k Key, ascending bool) []MetricsReader { mm.mu.Lock() var res []MetricsReader @@ -253,7 +253,7 @@ func (mm *MetricsManager) SortByWithReader(k Key, ascending bool) []MetricsReade return res } -// GetAllTargets は全ターゲットのリストを取得 +// GetAllTargets retrieves a list of all targets func (mm *MetricsManager) GetAllTargets() []string { mm.mu.Lock() defer mm.mu.Unlock() @@ -265,7 +265,7 @@ func (mm *MetricsManager) GetAllTargets() []string { return targets } -// GetTargetHistory は指定ターゲットの履歴を取得 +// GetTargetHistory retrieves the history of the specified target func (mm *MetricsManager) GetTargetHistory(target string, n int) []HistoryEntry { mm.mu.Lock() defer mm.mu.Unlock() @@ -276,7 +276,7 @@ func (mm *MetricsManager) GetTargetHistory(target string, n int) []HistoryEntry return []HistoryEntry{} } -// GetAllTargetsRecentHistory は全ターゲットの最新履歴を取得 +// GetAllTargetsRecentHistory retrieves the recent history of all targets func (mm *MetricsManager) GetAllTargetsRecentHistory(n int) map[string][]HistoryEntry { mm.mu.Lock() defer mm.mu.Unlock() @@ -290,19 +290,19 @@ func (mm *MetricsManager) GetAllTargetsRecentHistory(n int) map[string][]History return result } -// GetMetricsAsReader は MetricsReader インターフェースとして取得 +// GetMetricsAsReader retrieves as MetricsReader interface func (mm *MetricsManager) GetMetricsAsReader(target string) MetricsReader { return mm.GetMetrics(target) } -// rejectLessAscending は昇順ソート用のRTT比較関数 -// 0値(未測定)は常に後ろに配置される +// 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 // i が 0 なら j を先に + return false // If i is 0, put j first } if j == 0 { - return true // j が 0 なら i を先に + return true // If j is 0, put i first } - return i < j // 両方とも 0 でないなら小さい方を先に + return i < j // If both are non-zero, put the smaller one first } diff --git a/internal/stats/metric.go b/internal/stats/metric.go index fb71493..d63029b 100644 --- a/internal/stats/metric.go +++ b/internal/stats/metric.go @@ -69,7 +69,7 @@ func (m *Metrics) Reset() { } } -// MetricsReader インターフェースの実装 +// Implementation of MetricsReader interface func (m *Metrics) GetName() string { return m.Name From bcbec2292b7ff8be9511af07e6c62131d0c104f3 Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Tue, 8 Jul 2025 09:25:14 +0900 Subject: [PATCH 09/13] refactor: Simplify ICMP packet details and enhance payload display - Remove unnecessary fields from ICMPDetails (TTL, ID, DataSize, SourceIP, PayloadMatch) - Add actual payload content display with formatted output (max 32 chars) - Remove ICMP Packet Details section from Host Details view - Simplify Recent History display with essential packet information - Update tests to reflect simplified structure This change focuses on showing only the most relevant ICMP information while maintaining detailed packet analysis in the Recent History section. --- internal/prober/details.go | 9 ++-- internal/prober/icmp.go | 90 +++++++++++++++++++++++++++++--- internal/stats/history_test.go | 6 +-- internal/ui/shared/formatters.go | 19 +++++-- 4 files changed, 107 insertions(+), 17 deletions(-) diff --git a/internal/prober/details.go b/internal/prober/details.go index b97f9a0..7c7c97f 100644 --- a/internal/prober/details.go +++ b/internal/prober/details.go @@ -13,9 +13,12 @@ type ProbeDetails struct { } type ICMPDetails struct { - Sequence int `json:"sequence"` - TTL int `json:"ttl"` - DataSize int `json:"data_size"` + Sequence int `json:"sequence"` + PacketSize int `json:"packet_size"` + ICMPType int `json:"icmp_type"` + ICMPCode int `json:"icmp_code"` + Checksum uint16 `json:"checksum"` + Payload string `json:"payload"` // Actual payload content with length limit } type HTTPDetails struct { diff --git a/internal/prober/icmp.go b/internal/prober/icmp.go index 44121ee..ff80922 100644 --- a/internal/prober/icmp.go +++ b/internal/prober/icmp.go @@ -171,7 +171,7 @@ func (p *ICMPProber) sent(r chan *Event, addr string) { } } -func (p *ICMPProber) success(r chan *Event, runCnt int, addr string, payload icmp.Message) { +func (p *ICMPProber) success(r chan *Event, runCnt int, addr string, payload icmp.Message, packetData []byte, packetSize int) { p.mu.Lock() defer p.mu.Unlock() for k, table := range p.tables { @@ -188,14 +188,13 @@ func (p *ICMPProber) success(r chan *Event, runCnt int, addr string, payload icm elapse := time.Since(k.sentTime) key, displayName := p.getTargetInfo(addr) + // Extract detailed packet information + icmpDetails := p.extractICMPDetails(runCnt, addr, payload, packetData, packetSize) + // Create ICMP detail information details := &ProbeDetails{ ProbeType: string(p.version), - ICMP: &ICMPDetails{ - Sequence: runCnt, - TTL: 0, // TODO: Extract from IP header - DataSize: len(p.body) + 8, // ICMP header (8 bytes) + data - }, + ICMP: icmpDetails, } r <- &Event{ @@ -210,6 +209,83 @@ func (p *ICMPProber) success(r chan *Event, runCnt int, addr string, payload icm } } +// extractICMPDetails extracts detailed information from ICMP packet +func (p *ICMPProber) extractICMPDetails(runCnt int, addr string, payload icmp.Message, packetData []byte, packetSize int) *ICMPDetails { + var payloadContent string + var checksum uint16 + + // Extract echo data if available + if echoBody, ok := payload.Body.(*icmp.Echo); ok { + + // Format payload content with length limit + payloadContent = formatPayloadContent(echoBody.Data) + } + + // Extract checksum from raw packet data if available + // ICMP checksum is at offset 2-3 in the ICMP header + if len(packetData) >= 4 { + checksum = binary.BigEndian.Uint16(packetData[2:4]) + } + + + // Convert ICMP type to int safely + var icmpType int + switch payload.Type { + case ipv4.ICMPTypeEchoReply: + icmpType = 0 + case ipv6.ICMPTypeEchoReply: + icmpType = 129 + default: + // Use reflection or type assertion for other types + if t, ok := payload.Type.(interface{ Int() int }); ok { + icmpType = t.Int() + } else { + icmpType = -1 // Unknown type + } + } + + details := &ICMPDetails{ + Sequence: runCnt, + PacketSize: packetSize, + ICMPType: icmpType, + ICMPCode: payload.Code, + Checksum: checksum, + Payload: payloadContent, + } + + return details +} + + + +// formatPayloadContent formats payload bytes for display with length limit +func formatPayloadContent(data []byte) string { + const maxDisplayLength = 32 // Maximum characters to display + + if len(data) == 0 { + return "" + } + + // Convert to string, replacing non-printable characters + var result strings.Builder + for _, b := range data { + if b >= 32 && b <= 126 { // Printable ASCII characters + result.WriteByte(b) + } else { + result.WriteString(fmt.Sprintf("\\x%02x", b)) + } + } + + payloadStr := result.String() + + // Truncate if too long + if len(payloadStr) > maxDisplayLength { + payloadStr = payloadStr[:maxDisplayLength-3] + "..." + } + + return payloadStr +} + func (p *ICMPProber) failed(r chan *Event, runCnt int, addr string, err error) { p.mu.Lock() defer p.mu.Unlock() @@ -335,7 +411,7 @@ func (p *ICMPProber) recvPkts(r chan *Event) { if rm.Code == 0 { switch rm.Type { case ipv4.ICMPTypeEchoReply, ipv6.ICMPTypeEchoReply: - p.success(r, int(seq), addr.String(), *rm) + p.success(r, int(seq), addr.String(), *rm, pktbuf[:n], n) } } } diff --git a/internal/stats/history_test.go b/internal/stats/history_test.go index 854b306..914ec87 100644 --- a/internal/stats/history_test.go +++ b/internal/stats/history_test.go @@ -29,8 +29,7 @@ func TestTargetHistory(t *testing.T) { ProbeType: "icmp", ICMP: &prober.ICMPDetails{ Sequence: 1, - TTL: 64, - DataSize: 64, + Payload: "test", }, }, }) @@ -96,8 +95,7 @@ func TestMetricsWithHistory(t *testing.T) { ProbeType: "icmp", ICMP: &prober.ICMPDetails{ Sequence: 1, - TTL: 64, - DataSize: 64, + Payload: "test", }, } mm.SuccessWithDetails(host, 50*time.Millisecond, time.Now(), details) diff --git a/internal/ui/shared/formatters.go b/internal/ui/shared/formatters.go index ae77164..ed9878a 100644 --- a/internal/ui/shared/formatters.go +++ b/internal/ui/shared/formatters.go @@ -82,6 +82,7 @@ func FormatHostDetail(metric stats.MetricsReader) string { return basicInfo } + // FormatHistory generates history section for a host func FormatHistory(metric stats.MetricsReader) string { history := metric.GetRecentHistory(10) @@ -131,9 +132,21 @@ func formatProbeDetails(details *prober.ProbeDetails) string { switch details.ProbeType { case "icmp", "icmpv4", "icmpv6": if details.ICMP != nil { - // Only show sequence and size for now (TTL is not properly implemented) - return fmt.Sprintf("seq=%d size=%d", - details.ICMP.Sequence, details.ICMP.DataSize) + // Show enhanced ICMP details + var parts []string + parts = append(parts, fmt.Sprintf("seq=%d", details.ICMP.Sequence)) + parts = append(parts, fmt.Sprintf("size=%d", details.ICMP.PacketSize)) + + + if details.ICMP.ICMPType >= 0 { + parts = append(parts, fmt.Sprintf("type=%d", details.ICMP.ICMPType)) + } + + if details.ICMP.Payload != "" { + parts = append(parts, fmt.Sprintf("payload=%s", details.ICMP.Payload)) + } + + return strings.Join(parts, " ") } return "icmp ping" case "http", "https": From 77f5b102aaaf6ec0f896a5771f694d9410c574df Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Tue, 8 Jul 2025 10:24:14 +0900 Subject: [PATCH 10/13] fix: Resolve TUI selection synchronization and add panel borders - Fix v key showing "Select a host to view details" by adding GetSelectedMetrics() method - Add selection synchronization in showDetailView() to display current host details - Add border containers to both Host List and Host Details panels - Update panel architecture to use Flex containers with borders - Fix test expectations for container-based panel architecture --- internal/ui/tui/layout.go | 6 +++ internal/ui/tui/panels/host_detail.go | 31 ++++++++++------ internal/ui/tui/panels/host_list.go | 47 ++++++++++++++++++------ internal/ui/tui/panels/host_list_test.go | 8 ++-- 4 files changed, 65 insertions(+), 27 deletions(-) diff --git a/internal/ui/tui/layout.go b/internal/ui/tui/layout.go index 6022493..530de61 100644 --- a/internal/ui/tui/layout.go +++ b/internal/ui/tui/layout.go @@ -124,6 +124,12 @@ func (l *LayoutManager) ToggleDetailView() { 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) + } + // Create horizontal layout for host list and detail mainContent := tview.NewFlex(). SetDirection(tview.FlexColumn). diff --git a/internal/ui/tui/panels/host_detail.go b/internal/ui/tui/panels/host_detail.go index 9a05104..dd66845 100644 --- a/internal/ui/tui/panels/host_detail.go +++ b/internal/ui/tui/panels/host_detail.go @@ -10,6 +10,7 @@ import ( // HostDetailPanel manages host detail display type HostDetailPanel struct { view *tview.TextView + container *tview.Flex // Container with border currentHost string currentMetrics stats.MetricsReader mm *stats.MetricsManager @@ -19,14 +20,20 @@ type HostDetailPanel struct { func NewHostDetailPanel(mm *stats.MetricsManager) *HostDetailPanel { view := tview.NewTextView() view.SetDynamicColors(true). - SetScrollable(true). - SetBorder(true). - SetTitle(" Host Details "). - SetTitleAlign(tview.AlignCenter) + SetScrollable(true) + + // Create container with border and title + container := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(view, 0, 1, false) + + container.SetBorder(true). + SetTitle(" Host Details ") return &HostDetailPanel{ - view: view, - mm: mm, + view: view, + container: container, + mm: mm, } } @@ -45,7 +52,7 @@ func (h *HostDetailPanel) Update() { // SetHost sets the current host to display details for func (h *HostDetailPanel) SetHost(hostname string) { h.currentHost = hostname - h.view.SetTitle(" Host Details: " + hostname + " ") + h.container.SetTitle(" Host Details: " + hostname + " ") } // SetMetrics sets the current metrics object directly @@ -53,20 +60,20 @@ func (h *HostDetailPanel) SetMetrics(metrics stats.MetricsReader) { h.currentMetrics = metrics if metrics != nil { h.currentHost = metrics.GetName() - h.view.SetTitle(" Host Details: " + h.currentHost + " ") + h.container.SetTitle(" Host Details: " + h.currentHost + " ") } } // GetView returns the underlying tview component -func (h *HostDetailPanel) GetView() *tview.TextView { - return h.view +func (h *HostDetailPanel) GetView() tview.Primitive { + return h.container } // SetVisible controls whether the detail panel is visible func (h *HostDetailPanel) SetVisible(visible bool) { if visible { - h.view.SetBorder(true) + h.container.SetBorder(true) } else { - h.view.SetBorder(false) + h.container.SetBorder(false) } } \ No newline at end of file diff --git a/internal/ui/tui/panels/host_list.go b/internal/ui/tui/panels/host_list.go index 53acf51..b1b4ba2 100644 --- a/internal/ui/tui/panels/host_list.go +++ b/internal/ui/tui/panels/host_list.go @@ -11,10 +11,11 @@ import ( // HostListPanel manages host list table display type HostListPanel struct { - table *tview.Table - renderState state.RenderState - selectionState state.SelectionState - mm *stats.MetricsManager + table *tview.Table + container *tview.Flex // Container with border + renderState state.RenderState + selectionState state.SelectionState + mm *stats.MetricsManager onSelectionChange func(metrics stats.MetricsReader) // Callback when selection changes } @@ -28,8 +29,17 @@ func NewHostListPanel(state HostListParams, mm *stats.MetricsManager) *HostListP table := tview.NewTable(). SetSelectable(true, false) + // Create container with border and title + container := tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(table, 0, 1, true) + + container.SetBorder(true). + SetTitle(" Host List ") + panel := &HostListPanel{ table: table, + container: container, renderState: state, selectionState: state, mm: mm, @@ -46,7 +56,7 @@ func (h *HostListPanel) Update() { // Clear existing content and repopulate h.table.Clear() - + // Configure table settings h.table. SetBorders(false). @@ -56,7 +66,7 @@ func (h *HostListPanel) Update() { SetSelectedStyle(tcell.StyleDefault. Background(tcell.ColorDarkGreen). Foreground(tcell.ColorWhite)) - + // Use TableData's logic but populate our existing table h.populateTableFromData(tableData) @@ -83,11 +93,11 @@ 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) - + // Call the callback to update detail panel with metrics object if metric, ok := h.GetSelectedMetric(tableData); ok && h.onSelectionChange != nil { h.onSelectionChange(metric) @@ -96,8 +106,8 @@ func (h *HostListPanel) updateSelectedHost() { } // GetView returns the underlying tview component -func (h *HostListPanel) GetView() *tview.Table { - return h.table +func (h *HostListPanel) GetView() tview.Primitive { + return h.container } // GetSelectedMetric returns the currently selected metric @@ -186,7 +196,6 @@ func (h *HostListPanel) PageUp() { h.updateSelectedHost() } - // populateTableFromData populates our table using TableData content func (h *HostListPanel) populateTableFromData(tableData *shared.TableData) { // Define alignment for each column (same as in shared/table_data.go) @@ -251,3 +260,19 @@ func (h *HostListPanel) restoreSelection(tableData *shared.TableData, selectedHo h.table.Select(1, 0) } } + +// GetSelectedMetrics returns the currently selected metrics +func (h *HostListPanel) GetSelectedMetrics() stats.MetricsReader { + 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 239c144..17fca13 100644 --- a/internal/ui/tui/panels/host_list_test.go +++ b/internal/ui/tui/panels/host_list_test.go @@ -67,13 +67,13 @@ func TestHostListPanelGetView(t *testing.T) { state := newMockState() panel := NewHostListPanel(state, mm) - table := panel.GetView() - if table == nil { + view := panel.GetView() + if view == nil { t.Error("GetView() returned nil") } - if table != panel.table { - t.Error("GetView() returned different table instance") + if view != panel.container { + t.Error("GetView() returned different container instance") } } From 97f108ffc674471f9f4b17f314d61d234aa2d9e6 Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Wed, 9 Jul 2025 08:29:28 +0900 Subject: [PATCH 11/13] refactor: Implement interface segregation and eliminate concrete type dependencies - Split MetricsManagerInterface into focused interfaces: - BasicMetrics: Basic statistics for display/sorting - DetailedMetrics: Extended metrics with history - MetricsProvider: External API for metrics access - MetricsSystemManager: System-level operations - MetricsEventRecorder: Internal event recording - Replace concrete *stats.MetricsManager with appropriate interfaces: - UI components now use MetricsProvider interface - Command layer uses MetricsManagerInterface - Each component only depends on methods it actually uses - Update MetricsManager implementation: - Separate internal getMetrics() from external GetMetrics() - Update method signatures to match interface contracts - Maintain backward compatibility with MetricsManagerInterface This change improves code maintainability by enforcing interface segregation principle and reducing coupling between components. --- internal/command/mping.go | 2 +- internal/stats/interface.go | 54 +++++++++++++++------------ internal/stats/manager.go | 51 +++++++++++++------------ internal/ui/tui/app.go | 4 +- internal/ui/tui/layout.go | 4 +- internal/ui/tui/panels/host_detail.go | 4 +- internal/ui/tui/panels/host_list.go | 4 +- 7 files changed, 68 insertions(+), 55 deletions(-) diff --git a/internal/command/mping.go b/internal/command/mping.go index ba3a8ae..551962c 100644 --- a/internal/command/mping.go +++ b/internal/command/mping.go @@ -124,7 +124,7 @@ mping dns://8.8.8.8/google.com`, return cmd } -func startTUI(manager *stats.MetricsManager, cfg *shared.Config, interval, timeout time.Duration) { +func startTUI(manager stats.MetricsManagerInterface, cfg *shared.Config, interval, timeout time.Duration) { app := tui.NewTUIApp(manager, cfg, interval, timeout) refreshTime := time.Millisecond * 250 // Minimum refresh time that can be set diff --git a/internal/stats/interface.go b/internal/stats/interface.go index f4966c8..3eef3f5 100644 --- a/internal/stats/interface.go +++ b/internal/stats/interface.go @@ -6,9 +6,8 @@ import ( "github.com/servak/mping/internal/prober" ) -// Interface for reading metrics by target -type MetricsReader interface { - // Basic statistics +// BasicMetrics provides basic statistics for display and sorting +type BasicMetrics interface { GetName() string GetTotal() int GetSuccessful() int @@ -21,41 +20,50 @@ type MetricsReader interface { GetLastSuccTime() time.Time GetLastFailTime() time.Time GetLastFailDetail() string +} - // History information +// DetailedMetrics extends BasicMetrics with history and detailed analysis +type DetailedMetrics interface { + BasicMetrics GetRecentHistory(n int) []HistoryEntry - GetHistorySince(since time.Time) []HistoryEntry GetConsecutiveFailures() int GetConsecutiveSuccesses() int GetSuccessRateInPeriod(duration time.Duration) float64 } -// Interface for overall metrics management -type MetricsManagerInterface interface { - // Basic operations +// MetricsReader provides complete read access to metrics (for backward compatibility) +type MetricsReader interface { + DetailedMetrics + GetHistorySince(since time.Time) []HistoryEntry +} + +// MetricsProvider provides external API for metrics access +type MetricsProvider interface { + SortByWithReader(k Key, ascending bool) []MetricsReader GetMetrics(target string) MetricsReader - GetAllTargets() []string +} + +// MetricsSystemManager provides system-level operations +type MetricsSystemManager interface { ResetAllMetrics() +} - // Statistics registration +// MetricsEventRecorder handles internal event recording +type MetricsEventRecorder interface { Success(target string, rtt time.Duration, sentTime time.Time, details *prober.ProbeDetails) Failed(target string, sentTime time.Time, msg string) Sent(target string) +} - // History functions +// MetricsManagerInterface provides comprehensive metrics management (for backward compatibility) +type MetricsManagerInterface interface { + MetricsProvider + MetricsSystemManager + MetricsEventRecorder + + // Legacy methods - will be removed in future versions + GetAllTargets() []string GetTargetHistory(target string, n int) []HistoryEntry GetAllTargetsRecentHistory(n int) map[string][]HistoryEntry - - // Sort functions SortBy(k Key, ascending bool) []MetricsReader -} - -// Interface dedicated to history management -type HistoryManagerInterface interface { - AddSuccessEntry(target string, timestamp time.Time, rtt time.Duration, details *prober.ProbeDetails) - AddFailureEntry(target string, timestamp time.Time, error string) - GetHistory(target string, n int) []HistoryEntry - GetHistorySince(target string, since time.Time) []HistoryEntry - ClearHistory(target string) - ClearAllHistory() } \ No newline at end of file diff --git a/internal/stats/manager.go b/internal/stats/manager.go index ba038da..9662fbf 100644 --- a/internal/stats/manager.go +++ b/internal/stats/manager.go @@ -42,8 +42,8 @@ func (mm *MetricsManager) Register(target, name string) { } } -// 指定されたホストのMetricsを取得 -func (mm *MetricsManager) GetMetrics(host string) *Metrics { +// 指定されたホストのMetricsを取得(内部用) +func (mm *MetricsManager) getMetrics(host string) *Metrics { mm.mu.Lock() defer mm.mu.Unlock() @@ -58,6 +58,11 @@ func (mm *MetricsManager) GetMetrics(host string) *Metrics { return m } +// 指定されたホストのMetricsを取得(外部用) +func (mm *MetricsManager) GetMetrics(host string) MetricsReader { + return mm.getMetrics(host) +} + // 全てのMetricsをリセット func (mm *MetricsManager) ResetAllMetrics() { mm.mu.Lock() @@ -69,13 +74,13 @@ func (mm *MetricsManager) ResetAllMetrics() { } // Register success for host -func (mm *MetricsManager) Success(host string, rtt time.Duration, sentTime time.Time) { - mm.SuccessWithDetails(host, rtt, sentTime, nil) +func (mm *MetricsManager) Success(host string, rtt time.Duration, sentTime time.Time, details *prober.ProbeDetails) { + mm.SuccessWithDetails(host, rtt, sentTime, details) } // Register success for host with detailed information func (mm *MetricsManager) SuccessWithDetails(host string, rtt time.Duration, sentTime time.Time, details *prober.ProbeDetails) { - m := mm.GetMetrics(host) + m := mm.getMetrics(host) mm.mu.Lock() m.Success(rtt, sentTime) @@ -92,7 +97,7 @@ func (mm *MetricsManager) SuccessWithDetails(host string, rtt time.Duration, sen // Register failure for host func (mm *MetricsManager) Failed(host string, sentTime time.Time, msg string) { - m := mm.GetMetrics(host) + m := mm.getMetrics(host) mm.mu.Lock() m.Fail(sentTime, msg) @@ -108,7 +113,7 @@ func (mm *MetricsManager) Failed(host string, sentTime time.Time, msg string) { } func (mm *MetricsManager) Sent(host string) { - m := mm.GetMetrics(host) + m := mm.getMetrics(host) mm.mu.Lock() m.Sent() @@ -147,16 +152,16 @@ func (mm *MetricsManager) autoRegister(key, displayName string) { } } -func (mm *MetricsManager) SortBy(k Key, ascending bool) []Metrics { +func (mm *MetricsManager) SortBy(k Key, ascending bool) []MetricsReader { mm.mu.Lock() - var res []Metrics + var res []MetricsReader for _, m := range mm.metrics { - res = append(res, *m) + res = append(res, m) } mm.mu.Unlock() if k != Host { sort.SliceStable(res, func(i, j int) bool { - return res[i].Name < res[j].Name + return res[i].GetName() < res[j].GetName() }) } sort.SliceStable(res, func(i, j int) bool { @@ -165,27 +170,27 @@ func (mm *MetricsManager) SortBy(k Key, ascending bool) []Metrics { var result bool switch k { case Host: - result = res[i].Name < res[j].Name + result = res[i].GetName() < res[j].GetName() case Sent: - result = mi.Total < mj.Total + result = mi.GetTotal() < mj.GetTotal() case Success: - result = mi.Successful < mj.Successful + result = mi.GetSuccessful() < mj.GetSuccessful() case Loss: - result = mi.Loss < mj.Loss + result = mi.GetLoss() < mj.GetLoss() case Fail: - result = mi.Failed < mj.Failed + result = mi.GetFailed() < mj.GetFailed() case Last: - result = rejectLessAscending(mi.LastRTT, mj.LastRTT) + result = rejectLessAscending(mi.GetLastRTT(), mj.GetLastRTT()) case Avg: - result = rejectLessAscending(mi.AverageRTT, mj.AverageRTT) + result = rejectLessAscending(mi.GetAverageRTT(), mj.GetAverageRTT()) case Best: - result = rejectLessAscending(mi.MinimumRTT, mj.MinimumRTT) + result = rejectLessAscending(mi.GetMinimumRTT(), mj.GetMinimumRTT()) case Worst: - result = rejectLessAscending(mi.MaximumRTT, mj.MaximumRTT) + result = rejectLessAscending(mi.GetMaximumRTT(), mj.GetMaximumRTT()) case LastSuccTime: - result = mi.LastSuccTime.Before(mj.LastSuccTime) + result = mi.GetLastSuccTime().Before(mj.GetLastSuccTime()) case LastFailTime: - result = mi.LastFailTime.Before(mj.LastFailTime) + result = mi.GetLastFailTime().Before(mj.GetLastFailTime()) default: return false } @@ -292,7 +297,7 @@ func (mm *MetricsManager) GetAllTargetsRecentHistory(n int) map[string][]History // GetMetricsAsReader retrieves as MetricsReader interface func (mm *MetricsManager) GetMetricsAsReader(target string) MetricsReader { - return mm.GetMetrics(target) + return mm.getMetrics(target) } // rejectLessAscending is RTT comparison function for ascending sort diff --git a/internal/ui/tui/app.go b/internal/ui/tui/app.go index 40e78d2..16bed8d 100644 --- a/internal/ui/tui/app.go +++ b/internal/ui/tui/app.go @@ -17,7 +17,7 @@ type TUIApp struct { app *tview.Application layout *LayoutManager state *state.UIState - mm *stats.MetricsManager + mm stats.MetricsManagerInterface config *shared.Config interval time.Duration timeout time.Duration @@ -26,7 +26,7 @@ type TUIApp struct { } // NewTUIApp creates a new TUIApp instance -func NewTUIApp(mm *stats.MetricsManager, cfg *shared.Config, interval, timeout time.Duration) *TUIApp { +func NewTUIApp(mm stats.MetricsManagerInterface, cfg *shared.Config, interval, timeout time.Duration) *TUIApp { if cfg == nil { cfg = shared.DefaultConfig() } diff --git a/internal/ui/tui/layout.go b/internal/ui/tui/layout.go index 530de61..f782ec1 100644 --- a/internal/ui/tui/layout.go +++ b/internal/ui/tui/layout.go @@ -44,7 +44,7 @@ type LayoutManager struct { } // NewLayoutManager creates a new LayoutManager -func NewLayoutManager(uiState *state.UIState, mm *stats.MetricsManager, config *shared.Config, interval, timeout time.Duration) *LayoutManager { +func NewLayoutManager(uiState *state.UIState, mm stats.MetricsManagerInterface, config *shared.Config, interval, timeout time.Duration) *LayoutManager { layout := &LayoutManager{ mode: ListOnly, } @@ -57,7 +57,7 @@ func NewLayoutManager(uiState *state.UIState, mm *stats.MetricsManager, config * } // setupPanels initializes all panels -func (l *LayoutManager) setupPanels(uiState *state.UIState, mm *stats.MetricsManager, config *shared.Config, interval, timeout time.Duration) { +func (l *LayoutManager) setupPanels(uiState *state.UIState, mm stats.MetricsManagerInterface, config *shared.Config, interval, timeout time.Duration) { l.header = panels.NewHeaderPanel(uiState, config, interval, timeout) l.hostList = panels.NewHostListPanel(uiState, mm) l.footer = panels.NewFooterPanel(config) diff --git a/internal/ui/tui/panels/host_detail.go b/internal/ui/tui/panels/host_detail.go index dd66845..61b4545 100644 --- a/internal/ui/tui/panels/host_detail.go +++ b/internal/ui/tui/panels/host_detail.go @@ -13,11 +13,11 @@ type HostDetailPanel struct { container *tview.Flex // Container with border currentHost string currentMetrics stats.MetricsReader - mm *stats.MetricsManager + mm stats.MetricsProvider } // NewHostDetailPanel creates a new HostDetailPanel -func NewHostDetailPanel(mm *stats.MetricsManager) *HostDetailPanel { +func NewHostDetailPanel(mm stats.MetricsProvider) *HostDetailPanel { view := tview.NewTextView() view.SetDynamicColors(true). SetScrollable(true) diff --git a/internal/ui/tui/panels/host_list.go b/internal/ui/tui/panels/host_list.go index b1b4ba2..2a23274 100644 --- a/internal/ui/tui/panels/host_list.go +++ b/internal/ui/tui/panels/host_list.go @@ -15,7 +15,7 @@ type HostListPanel struct { container *tview.Flex // Container with border renderState state.RenderState selectionState state.SelectionState - mm *stats.MetricsManager + mm stats.MetricsProvider onSelectionChange func(metrics stats.MetricsReader) // Callback when selection changes } @@ -25,7 +25,7 @@ type HostListParams interface { } // NewHostListPanel creates a new HostListPanel -func NewHostListPanel(state HostListParams, mm *stats.MetricsManager) *HostListPanel { +func NewHostListPanel(state HostListParams, mm stats.MetricsProvider) *HostListPanel { table := tview.NewTable(). SetSelectable(true, false) From 887b1e3141c2e808fd570eaa3243718105bc2738 Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Wed, 9 Jul 2025 09:05:20 +0900 Subject: [PATCH 12/13] refactor: Complete interface optimization and method name simplification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3-6 final optimization: - Remove unused interface methods (GetHistorySince, GetAllTargets, etc.) - Consolidate MetricsReader and DetailedMetrics interfaces - Eliminate unused dependencies in HostDetailPanel - Optimize interface usage in setupPanels to use MetricsProvider - Simplify method name: SortByWithReader → SortBy Interface cleanup results: - BasicMetrics: 12 methods (core statistics) - MetricsReader: 16 methods (BasicMetrics + history/analysis) - MetricsProvider: 2 methods (SortBy, GetMetrics) - MetricsSystemManager: 1 method (ResetAllMetrics) - MetricsEventRecorder: 3 methods (Success, Failed, Sent) - MetricsManagerInterface: Clean composition of above interfaces This completes the interface segregation principle implementation, removes dead code, and provides cleaner, more intuitive method names while maintaining full backward compatibility. --- internal/command/batch.go | 2 +- internal/command/mping.go | 4 +- internal/stats/history_test.go | 13 ++- internal/stats/interface.go | 31 ++----- internal/stats/manager.go | 129 ++++---------------------- internal/stats/metric.go | 6 -- internal/ui/tui/app.go | 11 +-- internal/ui/tui/layout.go | 18 ++-- internal/ui/tui/panels/host_detail.go | 4 +- internal/ui/tui/panels/host_list.go | 2 +- 10 files changed, 55 insertions(+), 165 deletions(-) diff --git a/internal/command/batch.go b/internal/command/batch.go index 1db7043..82fe3b6 100644 --- a/internal/command/batch.go +++ b/internal/command/batch.go @@ -99,7 +99,7 @@ mping batch dns://8.8.8.8/google.com`, // Stop probing probeManager.Stop() cmd.Print("\r") - metrics := metricsManager.SortByWithReader(stats.Success, true) + metrics := metricsManager.SortBy(stats.Success, true) tableData := shared.NewTableData(metrics, stats.Success, true) t := tableData.ToGoPrettyTable() t.SetStyle(table.StyleLight) diff --git a/internal/command/mping.go b/internal/command/mping.go index 551962c..81622d1 100644 --- a/internal/command/mping.go +++ b/internal/command/mping.go @@ -104,7 +104,7 @@ mping dns://8.8.8.8/google.com`, probeManager.Stop() // Final results - metrics := metricsManager.SortByWithReader(stats.Fail, false) + metrics := metricsManager.SortBy(stats.Fail, false) tableData := shared.NewTableData(metrics, stats.Fail, false) t := tableData.ToGoPrettyTable() t.SetStyle(table.StyleLight) @@ -124,7 +124,7 @@ mping dns://8.8.8.8/google.com`, return cmd } -func startTUI(manager stats.MetricsManagerInterface, cfg *shared.Config, interval, timeout time.Duration) { +func startTUI(manager stats.MetricsManager, cfg *shared.Config, interval, timeout time.Duration) { app := tui.NewTUIApp(manager, cfg, interval, timeout) refreshTime := time.Millisecond * 250 // Minimum refresh time that can be set diff --git a/internal/stats/history_test.go b/internal/stats/history_test.go index 914ec87..ba9063d 100644 --- a/internal/stats/history_test.go +++ b/internal/stats/history_test.go @@ -3,7 +3,7 @@ package stats import ( "testing" "time" - + "github.com/servak/mping/internal/prober" ) @@ -87,7 +87,10 @@ func TestTargetHistory(t *testing.T) { } func TestMetricsWithHistory(t *testing.T) { - mm := NewMetricsManager() + mm := metricsManager{ + metrics: make(map[string]*Metrics), + historySize: DefaultHistorySize, // Set history size for testing + } host := "example.com" // Record success @@ -98,7 +101,7 @@ func TestMetricsWithHistory(t *testing.T) { Payload: "test", }, } - mm.SuccessWithDetails(host, 50*time.Millisecond, time.Now(), details) + mm.Success(host, 50*time.Millisecond, time.Now(), details) // メトリクスを取得 metrics := mm.GetMetrics(host) @@ -146,7 +149,7 @@ func TestSuccessRateInPeriod(t *testing.T) { Success: true, }) } - + // Add 5 failures for i := 0; i < 5; i++ { th.AddEntry(HistoryEntry{ @@ -168,4 +171,4 @@ func TestSuccessRateInPeriod(t *testing.T) { if recentRate != 0.0 { t.Errorf("Expected 0%% success rate for recent period, got %f%%", recentRate) } -} \ No newline at end of file +} diff --git a/internal/stats/interface.go b/internal/stats/interface.go index 3eef3f5..bb99d83 100644 --- a/internal/stats/interface.go +++ b/internal/stats/interface.go @@ -2,7 +2,7 @@ package stats import ( "time" - + "github.com/servak/mping/internal/prober" ) @@ -20,27 +20,21 @@ type BasicMetrics interface { GetLastSuccTime() time.Time GetLastFailTime() time.Time GetLastFailDetail() string -} -// DetailedMetrics extends BasicMetrics with history and detailed analysis -type DetailedMetrics interface { - BasicMetrics GetRecentHistory(n int) []HistoryEntry GetConsecutiveFailures() int GetConsecutiveSuccesses() int GetSuccessRateInPeriod(duration time.Duration) float64 } -// MetricsReader provides complete read access to metrics (for backward compatibility) +// MetricsReader provides complete read access to metrics including history and detailed analysis type MetricsReader interface { - DetailedMetrics - GetHistorySince(since time.Time) []HistoryEntry + BasicMetrics } // MetricsProvider provides external API for metrics access type MetricsProvider interface { - SortByWithReader(k Key, ascending bool) []MetricsReader - GetMetrics(target string) MetricsReader + SortBy(k Key, ascending bool) []MetricsReader } // MetricsSystemManager provides system-level operations @@ -50,20 +44,13 @@ type MetricsSystemManager interface { // MetricsEventRecorder handles internal event recording type MetricsEventRecorder interface { - Success(target string, rtt time.Duration, sentTime time.Time, details *prober.ProbeDetails) - Failed(target string, sentTime time.Time, msg string) - Sent(target string) + Register(target, name string) + Subscribe(<-chan *prober.Event) } -// MetricsManagerInterface provides comprehensive metrics management (for backward compatibility) -type MetricsManagerInterface interface { +// MetricsManager provides comprehensive metrics management (for backward compatibility) +type MetricsManager interface { MetricsProvider MetricsSystemManager MetricsEventRecorder - - // Legacy methods - will be removed in future versions - GetAllTargets() []string - GetTargetHistory(target string, n int) []HistoryEntry - GetAllTargetsRecentHistory(n int) map[string][]HistoryEntry - SortBy(k Key, ascending bool) []MetricsReader -} \ No newline at end of file +} diff --git a/internal/stats/manager.go b/internal/stats/manager.go index 9662fbf..abd9355 100644 --- a/internal/stats/manager.go +++ b/internal/stats/manager.go @@ -12,26 +12,26 @@ const ( DefaultHistorySize = 100 // Default number of history entries to keep ) -type MetricsManager struct { +type metricsManager struct { metrics map[string]*Metrics historySize int // Number of history entries to keep mu sync.Mutex } // Create a new MetricsManager -func NewMetricsManager() *MetricsManager { +func NewMetricsManager() MetricsManager { return NewMetricsManagerWithHistorySize(DefaultHistorySize) } // Create MetricsManager with specified history size -func NewMetricsManagerWithHistorySize(historySize int) *MetricsManager { - return &MetricsManager{ +func NewMetricsManagerWithHistorySize(historySize int) MetricsManager { + return &metricsManager{ metrics: make(map[string]*Metrics), historySize: historySize, } } -func (mm *MetricsManager) Register(target, name string) { +func (mm *metricsManager) Register(target, name string) { v, ok := mm.metrics[target] if ok && v.Name != target { return @@ -43,7 +43,7 @@ func (mm *MetricsManager) Register(target, name string) { } // 指定されたホストのMetricsを取得(内部用) -func (mm *MetricsManager) getMetrics(host string) *Metrics { +func (mm *metricsManager) getMetrics(host string) *Metrics { mm.mu.Lock() defer mm.mu.Unlock() @@ -59,12 +59,12 @@ func (mm *MetricsManager) getMetrics(host string) *Metrics { } // 指定されたホストのMetricsを取得(外部用) -func (mm *MetricsManager) GetMetrics(host string) MetricsReader { +func (mm *metricsManager) GetMetrics(host string) MetricsReader { return mm.getMetrics(host) } // 全てのMetricsをリセット -func (mm *MetricsManager) ResetAllMetrics() { +func (mm *metricsManager) ResetAllMetrics() { mm.mu.Lock() defer mm.mu.Unlock() @@ -74,12 +74,12 @@ func (mm *MetricsManager) ResetAllMetrics() { } // Register success for host -func (mm *MetricsManager) Success(host string, rtt time.Duration, sentTime time.Time, details *prober.ProbeDetails) { +func (mm *metricsManager) Success(host string, rtt time.Duration, sentTime time.Time, details *prober.ProbeDetails) { mm.SuccessWithDetails(host, rtt, sentTime, details) } // Register success for host with detailed information -func (mm *MetricsManager) SuccessWithDetails(host string, rtt time.Duration, sentTime time.Time, details *prober.ProbeDetails) { +func (mm *metricsManager) SuccessWithDetails(host string, rtt time.Duration, sentTime time.Time, details *prober.ProbeDetails) { m := mm.getMetrics(host) mm.mu.Lock() @@ -96,7 +96,7 @@ func (mm *MetricsManager) SuccessWithDetails(host string, rtt time.Duration, sen } // Register failure for host -func (mm *MetricsManager) Failed(host string, sentTime time.Time, msg string) { +func (mm *metricsManager) Failed(host string, sentTime time.Time, msg string) { m := mm.getMetrics(host) mm.mu.Lock() @@ -112,7 +112,7 @@ func (mm *MetricsManager) Failed(host string, sentTime time.Time, msg string) { mm.mu.Unlock() } -func (mm *MetricsManager) Sent(host string) { +func (mm *metricsManager) Sent(host string) { m := mm.getMetrics(host) mm.mu.Lock() @@ -120,7 +120,7 @@ func (mm *MetricsManager) Sent(host string) { mm.mu.Unlock() } -func (mm *MetricsManager) Subscribe(res <-chan *prober.Event) { +func (mm *metricsManager) Subscribe(res <-chan *prober.Event) { go func() { for r := range res { switch r.Result { @@ -140,7 +140,7 @@ func (mm *MetricsManager) Subscribe(res <-chan *prober.Event) { } // autoRegister automatically registers target if not already registered -func (mm *MetricsManager) autoRegister(key, displayName string) { +func (mm *metricsManager) autoRegister(key, displayName string) { mm.mu.Lock() defer mm.mu.Unlock() @@ -152,67 +152,15 @@ func (mm *MetricsManager) autoRegister(key, displayName string) { } } -func (mm *MetricsManager) SortBy(k Key, ascending bool) []MetricsReader { +// SortBy sorts metrics by specified key and returns MetricsReader slice +func (mm *metricsManager) SortBy(k Key, ascending bool) []MetricsReader { mm.mu.Lock() var res []MetricsReader for _, m := range mm.metrics { res = append(res, m) } mm.mu.Unlock() - if k != Host { - sort.SliceStable(res, func(i, j int) bool { - return res[i].GetName() < res[j].GetName() - }) - } - sort.SliceStable(res, func(i, j int) bool { - mi := res[i] - mj := res[j] - var result bool - switch k { - case Host: - result = res[i].GetName() < res[j].GetName() - case Sent: - result = mi.GetTotal() < mj.GetTotal() - case Success: - result = mi.GetSuccessful() < mj.GetSuccessful() - case Loss: - result = 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()) - case LastSuccTime: - result = mi.GetLastSuccTime().Before(mj.GetLastSuccTime()) - case LastFailTime: - result = mi.GetLastFailTime().Before(mj.GetLastFailTime()) - default: - return false - } - - if ascending { - return result - } else { - return !result - } - }) - return res -} -// SortByWithReader is a version that uses the MetricsReader interface -func (mm *MetricsManager) SortByWithReader(k Key, ascending bool) []MetricsReader { - mm.mu.Lock() - var res []MetricsReader - for _, m := range mm.metrics { - res = append(res, m) - } - mm.mu.Unlock() - if k != Host { sort.SliceStable(res, func(i, j int) bool { return res[i].GetName() < res[j].GetName() @@ -258,45 +206,8 @@ func (mm *MetricsManager) SortByWithReader(k Key, ascending bool) []MetricsReade return res } -// GetAllTargets retrieves a list of all targets -func (mm *MetricsManager) GetAllTargets() []string { - mm.mu.Lock() - defer mm.mu.Unlock() - - var targets []string - for target := range mm.metrics { - targets = append(targets, target) - } - return targets -} - -// GetTargetHistory retrieves the history of the specified target -func (mm *MetricsManager) GetTargetHistory(target string, n int) []HistoryEntry { - mm.mu.Lock() - defer mm.mu.Unlock() - - if m, exists := mm.metrics[target]; exists && m.history != nil { - return m.history.GetRecentEntries(n) - } - return []HistoryEntry{} -} - -// GetAllTargetsRecentHistory retrieves the recent history of all targets -func (mm *MetricsManager) GetAllTargetsRecentHistory(n int) map[string][]HistoryEntry { - mm.mu.Lock() - defer mm.mu.Unlock() - - result := make(map[string][]HistoryEntry) - for target, m := range mm.metrics { - if m.history != nil { - result[target] = m.history.GetRecentEntries(n) - } - } - return result -} - // GetMetricsAsReader retrieves as MetricsReader interface -func (mm *MetricsManager) GetMetricsAsReader(target string) MetricsReader { +func (mm *metricsManager) GetMetricsAsReader(target string) MetricsReader { return mm.getMetrics(target) } @@ -304,10 +215,10 @@ func (mm *MetricsManager) GetMetricsAsReader(target string) MetricsReader { // 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 + return false // If i is 0, put j first } if j == 0 { - return true // If j is 0, put i first + return true // If j is 0, put i first } - return i < j // If both are non-zero, put the smaller one first + return i < j // If both are non-zero, put the smaller one first } diff --git a/internal/stats/metric.go b/internal/stats/metric.go index d63029b..5a70e12 100644 --- a/internal/stats/metric.go +++ b/internal/stats/metric.go @@ -126,12 +126,6 @@ func (m *Metrics) GetRecentHistory(n int) []HistoryEntry { return m.history.GetRecentEntries(n) } -func (m *Metrics) GetHistorySince(since time.Time) []HistoryEntry { - if m.history == nil { - return []HistoryEntry{} - } - return m.history.GetEntriesSince(since) -} func (m *Metrics) GetConsecutiveFailures() int { if m.history == nil { diff --git a/internal/ui/tui/app.go b/internal/ui/tui/app.go index 16bed8d..79a32f3 100644 --- a/internal/ui/tui/app.go +++ b/internal/ui/tui/app.go @@ -17,7 +17,7 @@ type TUIApp struct { app *tview.Application layout *LayoutManager state *state.UIState - mm stats.MetricsManagerInterface + mm stats.MetricsManager config *shared.Config interval time.Duration timeout time.Duration @@ -26,7 +26,7 @@ type TUIApp struct { } // NewTUIApp creates a new TUIApp instance -func NewTUIApp(mm stats.MetricsManagerInterface, cfg *shared.Config, interval, timeout time.Duration) *TUIApp { +func NewTUIApp(mm stats.MetricsManager, cfg *shared.Config, interval, timeout time.Duration) *TUIApp { if cfg == nil { cfg = shared.DefaultConfig() } @@ -88,7 +88,7 @@ func (a *TUIApp) setupCallbacks() { // Set row selection callback a.layout.GetHostListPanel().SetSelectedFunc(a.handleRowSelection) - + // Set selection change callback for detail panel updates a.layout.GetHostListPanel().SetSelectionChangeCallback(func(metrics stats.MetricsReader) { a.layout.SetSelectedMetrics(metrics) @@ -113,7 +113,6 @@ func (a *TUIApp) setupKeyBindings() { return event } - // When filter input is visible, let it handle its own keys if a.layout.IsFilterShown() { return event @@ -298,10 +297,8 @@ func (a *TUIApp) handleRowSelection(row, col int) { } } - // getFilteredMetrics returns filtered metrics based on current state func (a *TUIApp) getFilteredMetrics() []stats.MetricsReader { - metrics := a.mm.SortByWithReader(a.state.GetSortKey(), a.state.IsAscending()) + metrics := a.mm.SortBy(a.state.GetSortKey(), a.state.IsAscending()) return shared.FilterMetrics(metrics, a.state.GetFilter()) } - diff --git a/internal/ui/tui/layout.go b/internal/ui/tui/layout.go index f782ec1..1df3ca0 100644 --- a/internal/ui/tui/layout.go +++ b/internal/ui/tui/layout.go @@ -44,7 +44,7 @@ type LayoutManager struct { } // NewLayoutManager creates a new LayoutManager -func NewLayoutManager(uiState *state.UIState, mm stats.MetricsManagerInterface, config *shared.Config, interval, timeout time.Duration) *LayoutManager { +func NewLayoutManager(uiState *state.UIState, mm stats.MetricsManager, config *shared.Config, interval, timeout time.Duration) *LayoutManager { layout := &LayoutManager{ mode: ListOnly, } @@ -57,11 +57,11 @@ func NewLayoutManager(uiState *state.UIState, mm stats.MetricsManagerInterface, } // setupPanels initializes all panels -func (l *LayoutManager) setupPanels(uiState *state.UIState, mm stats.MetricsManagerInterface, config *shared.Config, interval, timeout time.Duration) { +func (l *LayoutManager) setupPanels(uiState *state.UIState, mm stats.MetricsProvider, config *shared.Config, interval, timeout time.Duration) { l.header = panels.NewHeaderPanel(uiState, config, interval, timeout) l.hostList = panels.NewHostListPanel(uiState, mm) l.footer = panels.NewFooterPanel(config) - l.hostDetail = panels.NewHostDetailPanel(mm) + l.hostDetail = panels.NewHostDetailPanel() // Setup filter input l.filterInput = tview.NewInputField(). @@ -123,18 +123,18 @@ func (l *LayoutManager) ToggleDetailView() { // showDetailView switches to dual pane layout func (l *LayoutManager) showDetailView() { l.mode = ListWithDetail - + // Get currently selected metrics and set them in the detail panel selectedMetrics := l.hostList.GetSelectedMetrics() if selectedMetrics != nil { l.hostDetail.SetMetrics(selectedMetrics) } - + // 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 + 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() @@ -146,7 +146,7 @@ func (l *LayoutManager) showDetailView() { // hideDetailView switches to single pane layout func (l *LayoutManager) hideDetailView() { l.mode = ListOnly - + // Rebuild root layout with single pane l.root.Clear() l.root.AddItem(l.header.GetView(), 1, 0, false). @@ -159,7 +159,7 @@ func (l *LayoutManager) UpdateAll() { l.header.Update() l.footer.Update() l.hostList.Update() - + // Only update detail panel when it's visible if l.mode == ListWithDetail { l.hostDetail.Update() diff --git a/internal/ui/tui/panels/host_detail.go b/internal/ui/tui/panels/host_detail.go index 61b4545..0aadbe7 100644 --- a/internal/ui/tui/panels/host_detail.go +++ b/internal/ui/tui/panels/host_detail.go @@ -13,11 +13,10 @@ type HostDetailPanel struct { container *tview.Flex // Container with border currentHost string currentMetrics stats.MetricsReader - mm stats.MetricsProvider } // NewHostDetailPanel creates a new HostDetailPanel -func NewHostDetailPanel(mm stats.MetricsProvider) *HostDetailPanel { +func NewHostDetailPanel() *HostDetailPanel { view := tview.NewTextView() view.SetDynamicColors(true). SetScrollable(true) @@ -33,7 +32,6 @@ func NewHostDetailPanel(mm stats.MetricsProvider) *HostDetailPanel { return &HostDetailPanel{ view: view, container: container, - mm: mm, } } diff --git a/internal/ui/tui/panels/host_list.go b/internal/ui/tui/panels/host_list.go index 2a23274..e2cc8ce 100644 --- a/internal/ui/tui/panels/host_list.go +++ b/internal/ui/tui/panels/host_list.go @@ -84,7 +84,7 @@ func (h *HostListPanel) Update() { // getFilteredMetrics returns filtered metrics based on current state func (h *HostListPanel) getFilteredMetrics() []stats.MetricsReader { - metrics := h.mm.SortByWithReader(h.renderState.GetSortKey(), h.renderState.IsAscending()) + metrics := h.mm.SortBy(h.renderState.GetSortKey(), h.renderState.IsAscending()) return shared.FilterMetrics(metrics, h.renderState.GetFilter()) } From 7e3c913001b3a11f556ecf9295a40c68ccc663fe Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Fri, 11 Jul 2025 11:29:35 +0900 Subject: [PATCH 13/13] fix: Update test code to match interface design changes - Update metric_test.go to use Getter methods instead of direct field access - Replace stats.MetricsReader with stats.Metrics interface in test files - Fix type assertions and method calls to work with new interface structure - All tests now pass after interface refactoring --- internal/stats/history_test.go | 2 +- internal/stats/interface.go | 11 +--- internal/stats/manager.go | 27 +++++---- internal/stats/metric.go | 71 ++++++++++++++++-------- internal/stats/metric_test.go | 44 ++++++++------- internal/ui/shared/filters.go | 8 +-- internal/ui/shared/filters_test.go | 65 +++++++--------------- internal/ui/shared/formatters.go | 44 +++++++-------- internal/ui/shared/formatters_test.go | 64 +++++++++++---------- internal/ui/shared/table_data.go | 6 +- internal/ui/tui/app.go | 4 +- internal/ui/tui/layout.go | 2 +- internal/ui/tui/panels/host_detail.go | 12 ++-- internal/ui/tui/panels/host_list.go | 14 ++--- internal/ui/tui/panels/host_list_test.go | 27 +++++---- 15 files changed, 199 insertions(+), 202 deletions(-) diff --git a/internal/stats/history_test.go b/internal/stats/history_test.go index ba9063d..b0dc1e2 100644 --- a/internal/stats/history_test.go +++ b/internal/stats/history_test.go @@ -88,7 +88,7 @@ func TestTargetHistory(t *testing.T) { func TestMetricsWithHistory(t *testing.T) { mm := metricsManager{ - metrics: make(map[string]*Metrics), + metrics: make(map[string]*metrics), historySize: DefaultHistorySize, // Set history size for testing } host := "example.com" diff --git a/internal/stats/interface.go b/internal/stats/interface.go index bb99d83..20f40b2 100644 --- a/internal/stats/interface.go +++ b/internal/stats/interface.go @@ -6,8 +6,8 @@ import ( "github.com/servak/mping/internal/prober" ) -// BasicMetrics provides basic statistics for display and sorting -type BasicMetrics interface { +// Metrics provides basic statistics for display and sorting +type Metrics interface { GetName() string GetTotal() int GetSuccessful() int @@ -27,14 +27,9 @@ type BasicMetrics interface { GetSuccessRateInPeriod(duration time.Duration) float64 } -// MetricsReader provides complete read access to metrics including history and detailed analysis -type MetricsReader interface { - BasicMetrics -} - // MetricsProvider provides external API for metrics access type MetricsProvider interface { - SortBy(k Key, ascending bool) []MetricsReader + SortBy(k Key, ascending bool) []Metrics } // MetricsSystemManager provides system-level operations diff --git a/internal/stats/manager.go b/internal/stats/manager.go index abd9355..f18a1a0 100644 --- a/internal/stats/manager.go +++ b/internal/stats/manager.go @@ -13,7 +13,7 @@ const ( ) type metricsManager struct { - metrics map[string]*Metrics + metrics map[string]*metrics historySize int // Number of history entries to keep mu sync.Mutex } @@ -26,7 +26,7 @@ func NewMetricsManager() MetricsManager { // Create MetricsManager with specified history size func NewMetricsManagerWithHistorySize(historySize int) MetricsManager { return &metricsManager{ - metrics: make(map[string]*Metrics), + metrics: make(map[string]*metrics), historySize: historySize, } } @@ -36,34 +36,33 @@ func (mm *metricsManager) Register(target, name string) { if ok && v.Name != target { return } - mm.metrics[target] = &Metrics{ + mm.metrics[target] = &metrics{ Name: name, history: NewTargetHistory(mm.historySize), } } // 指定されたホストのMetricsを取得(内部用) -func (mm *metricsManager) getMetrics(host string) *Metrics { +func (mm *metricsManager) getMetrics(host string) *metrics { mm.mu.Lock() defer mm.mu.Unlock() m, ok := mm.metrics[host] if !ok { - m = &Metrics{ + m = &metrics{ Name: host, history: NewTargetHistory(mm.historySize), } + // Register the new metrics for the host mm.metrics[host] = m } return m } -// 指定されたホストのMetricsを取得(外部用) -func (mm *metricsManager) GetMetrics(host string) MetricsReader { +func (mm *metricsManager) GetMetrics(host string) Metrics { return mm.getMetrics(host) } -// 全てのMetricsをリセット func (mm *metricsManager) ResetAllMetrics() { mm.mu.Lock() defer mm.mu.Unlock() @@ -145,17 +144,17 @@ func (mm *metricsManager) autoRegister(key, displayName string) { defer mm.mu.Unlock() if _, exists := mm.metrics[key]; !exists { - mm.metrics[key] = &Metrics{ + mm.metrics[key] = &metrics{ Name: displayName, history: NewTargetHistory(mm.historySize), } } } -// SortBy sorts metrics by specified key and returns MetricsReader slice -func (mm *metricsManager) SortBy(k Key, ascending bool) []MetricsReader { +// SortBy sorts metrics by specified key and returns Metrics slice +func (mm *metricsManager) SortBy(k Key, ascending bool) []Metrics { mm.mu.Lock() - var res []MetricsReader + var res []Metrics for _, m := range mm.metrics { res = append(res, m) } @@ -206,8 +205,8 @@ func (mm *metricsManager) SortBy(k Key, ascending bool) []MetricsReader { return res } -// GetMetricsAsReader retrieves as MetricsReader interface -func (mm *metricsManager) GetMetricsAsReader(target string) MetricsReader { +// GetMetricsAsReader retrieves as Metrics interface +func (mm *metricsManager) GetMetricsAsReader(target string) Metrics { return mm.getMetrics(target) } diff --git a/internal/stats/metric.go b/internal/stats/metric.go index 5a70e12..9108969 100644 --- a/internal/stats/metric.go +++ b/internal/stats/metric.go @@ -4,7 +4,33 @@ import ( "time" ) -type Metrics struct { +func NewMetrics(name string, historySize int) Metrics { + return &metrics{ + Name: name, + history: NewTargetHistory(historySize), + } +} + +func NewMetricsForTest(name string, historySize, total, success, failed int, loss float64, totalRTT, averageRTT, minimumRTT, maximumRTT, lastRTT time.Duration, lastSuccTime, lastFailTime time.Time, lastFailDetail string) Metrics { + return &metrics{ + Name: name, + Total: total, + Successful: success, + Failed: failed, + Loss: loss, + TotalRTT: totalRTT, + AverageRTT: averageRTT, + MinimumRTT: minimumRTT, + MaximumRTT: maximumRTT, + LastRTT: lastRTT, + LastSuccTime: lastSuccTime, + LastFailTime: lastFailTime, + LastFailDetail: lastFailDetail, + history: NewTargetHistory(historySize), + } +} + +type metrics struct { Name string Total int Successful int @@ -21,7 +47,7 @@ type Metrics struct { history *TargetHistory // 履歴情報 } -func (m *Metrics) Success(rtt time.Duration, sentTime time.Time) { +func (m *metrics) Success(rtt time.Duration, sentTime time.Time) { m.Successful++ m.LastSuccTime = sentTime m.LastRTT = rtt @@ -36,22 +62,22 @@ func (m *Metrics) Success(rtt time.Duration, sentTime time.Time) { m.loss() } -func (m *Metrics) Fail(sentTime time.Time, msg string) { +func (m *metrics) Fail(sentTime time.Time, msg string) { m.Failed++ m.LastFailTime = sentTime m.LastFailDetail = msg m.loss() } -func (m *Metrics) loss() { +func (m *metrics) loss() { m.Loss = float64(m.Failed) / float64(m.Successful+m.Failed) * 100 } -func (m *Metrics) Sent() { +func (m *metrics) Sent() { m.Total++ } -func (m *Metrics) Reset() { +func (m *metrics) Reset() { m.Total = 0 m.Successful = 0 m.Failed = 0 @@ -71,77 +97,76 @@ func (m *Metrics) Reset() { // Implementation of MetricsReader interface -func (m *Metrics) GetName() string { +func (m *metrics) GetName() string { return m.Name } -func (m *Metrics) GetTotal() int { +func (m *metrics) GetTotal() int { return m.Total } -func (m *Metrics) GetSuccessful() int { +func (m *metrics) GetSuccessful() int { return m.Successful } -func (m *Metrics) GetFailed() int { +func (m *metrics) GetFailed() int { return m.Failed } -func (m *Metrics) GetLoss() float64 { +func (m *metrics) GetLoss() float64 { return m.Loss } -func (m *Metrics) GetLastRTT() time.Duration { +func (m *metrics) GetLastRTT() time.Duration { return m.LastRTT } -func (m *Metrics) GetAverageRTT() time.Duration { +func (m *metrics) GetAverageRTT() time.Duration { return m.AverageRTT } -func (m *Metrics) GetMinimumRTT() time.Duration { +func (m *metrics) GetMinimumRTT() time.Duration { return m.MinimumRTT } -func (m *Metrics) GetMaximumRTT() time.Duration { +func (m *metrics) GetMaximumRTT() time.Duration { return m.MaximumRTT } -func (m *Metrics) GetLastSuccTime() time.Time { +func (m *metrics) GetLastSuccTime() time.Time { return m.LastSuccTime } -func (m *Metrics) GetLastFailTime() time.Time { +func (m *metrics) GetLastFailTime() time.Time { return m.LastFailTime } -func (m *Metrics) GetLastFailDetail() string { +func (m *metrics) GetLastFailDetail() string { return m.LastFailDetail } -func (m *Metrics) GetRecentHistory(n int) []HistoryEntry { +func (m *metrics) GetRecentHistory(n int) []HistoryEntry { if m.history == nil { return []HistoryEntry{} } return m.history.GetRecentEntries(n) } - -func (m *Metrics) GetConsecutiveFailures() int { +func (m *metrics) GetConsecutiveFailures() int { if m.history == nil { return 0 } return m.history.GetConsecutiveFailures() } -func (m *Metrics) GetConsecutiveSuccesses() int { +func (m *metrics) GetConsecutiveSuccesses() int { if m.history == nil { return 0 } return m.history.GetConsecutiveSuccesses() } -func (m *Metrics) GetSuccessRateInPeriod(duration time.Duration) float64 { +func (m *metrics) GetSuccessRateInPeriod(duration time.Duration) float64 { if m.history == nil { return 0.0 } diff --git a/internal/stats/metric_test.go b/internal/stats/metric_test.go index dcc3cfe..eb4c655 100644 --- a/internal/stats/metric_test.go +++ b/internal/stats/metric_test.go @@ -6,44 +6,46 @@ import ( ) func TestMetrics(t *testing.T) { - m := &Metrics{} + m := NewMetrics("", 1) now := time.Now() - m.Sent() - m.Success(100*time.Millisecond, now) + // メソッドとして実装されているSentを呼び出す + metricsImpl := m.(*metrics) + metricsImpl.Sent() + metricsImpl.Success(100*time.Millisecond, now) - if m.Total != 1 || m.Successful != 1 || m.Failed != 0 { - t.Errorf("Invalid values after first success: Total = %d, Successful = %d, Failed = %d", m.Total, m.Successful, m.Failed) + if m.GetTotal() != 1 || m.GetSuccessful() != 1 || m.GetFailed() != 0 { + t.Errorf("Invalid values after first success: Total = %d, Successful = %d, Failed = %d", m.GetTotal(), m.GetSuccessful(), m.GetFailed()) } - if m.AverageRTT != 100*time.Millisecond || m.LastRTT != 100*time.Millisecond || m.LastSuccTime != now { - t.Errorf("Invalid RTT values after first success: AverageRTT = %v, LastRTT = %v, LastSuccTime = %v", m.AverageRTT, m.LastRTT, m.LastSuccTime) + if m.GetAverageRTT() != 100*time.Millisecond || m.GetLastRTT() != 100*time.Millisecond || m.GetLastSuccTime() != now { + t.Errorf("Invalid RTT values after first success: AverageRTT = %v, LastRTT = %v, LastSuccTime = %v", m.GetAverageRTT(), m.GetLastRTT(), m.GetLastSuccTime()) } - m.Sent() - m.Fail(now, "timeout") + metricsImpl.Sent() + metricsImpl.Fail(now, "timeout") - if m.Total != 2 || m.Successful != 1 || m.Failed != 1 { - t.Errorf("Invalid values after first failure: Total = %d, Successful = %d, Failed = %d", m.Total, m.Successful, m.Failed) + if m.GetTotal() != 2 || m.GetSuccessful() != 1 || m.GetFailed() != 1 { + t.Errorf("Invalid values after first failure: Total = %d, Successful = %d, Failed = %d", m.GetTotal(), m.GetSuccessful(), m.GetFailed()) } - if m.LastFailTime != now { - t.Errorf("Invalid fail time after first failure: LastFailTime = %v", m.LastFailTime) + if m.GetLastFailTime() != now { + t.Errorf("Invalid fail time after first failure: LastFailTime = %v", m.GetLastFailTime()) } - m.Sent() - m.Success(50*time.Millisecond, now) + metricsImpl.Sent() + metricsImpl.Success(50*time.Millisecond, now) - if m.AverageRTT != 75*time.Millisecond || m.LastRTT != 50*time.Millisecond || m.LastSuccTime != now { - t.Errorf("Invalid RTT values after second success: AverageRTT = %v, LastRTT = %v, LastSuccTime = %v", m.AverageRTT, m.LastRTT, m.LastSuccTime) + if m.GetAverageRTT() != 75*time.Millisecond || m.GetLastRTT() != 50*time.Millisecond || m.GetLastSuccTime() != now { + t.Errorf("Invalid RTT values after second success: AverageRTT = %v, LastRTT = %v, LastSuccTime = %v", m.GetAverageRTT(), m.GetLastRTT(), m.GetLastSuccTime()) } - if m.MinimumRTT != 50*time.Millisecond || m.MaximumRTT != 100*time.Millisecond { - t.Errorf("Invalid min/max RTT values after second success: MinimumRTT = %v, MaximumRTT = %v", m.MinimumRTT, m.MaximumRTT) + if m.GetMinimumRTT() != 50*time.Millisecond || m.GetMaximumRTT() != 100*time.Millisecond { + t.Errorf("Invalid min/max RTT values after second success: MinimumRTT = %v, MaximumRTT = %v", m.GetMinimumRTT(), m.GetMaximumRTT()) } - if m.Loss != 33.33333333333333 { - t.Errorf("Invalid loss calculation: Loss = %f", m.Loss) + if m.GetLoss() != 33.33333333333333 { + t.Errorf("Invalid loss calculation: Loss = %f", m.GetLoss()) } } diff --git a/internal/ui/shared/filters.go b/internal/ui/shared/filters.go index 9246884..853cafe 100644 --- a/internal/ui/shared/filters.go +++ b/internal/ui/shared/filters.go @@ -7,12 +7,12 @@ import ( ) // FilterMetrics filters metrics based on filter text -func FilterMetrics(metrics []stats.MetricsReader, filterText string) []stats.MetricsReader { +func FilterMetrics(metrics []stats.Metrics, filterText string) []stats.Metrics { if filterText == "" { return metrics } - - filtered := []stats.MetricsReader{} + + filtered := []stats.Metrics{} filterLower := strings.ToLower(filterText) for _, m := range metrics { if strings.Contains(strings.ToLower(m.GetName()), filterLower) { @@ -20,4 +20,4 @@ func FilterMetrics(metrics []stats.MetricsReader, filterText string) []stats.Met } } return filtered -} \ No newline at end of file +} diff --git a/internal/ui/shared/filters_test.go b/internal/ui/shared/filters_test.go index 94c7ed3..a7e4b3f 100644 --- a/internal/ui/shared/filters_test.go +++ b/internal/ui/shared/filters_test.go @@ -2,49 +2,24 @@ package shared import ( "testing" - "time" "github.com/servak/mping/internal/stats" ) func TestFilterMetrics(t *testing.T) { // Create test metrics - metrics := []stats.MetricsReader{ - &stats.Metrics{ - Name: "google.com", - Total: 100, - Successful: 95, - Failed: 5, - LastSuccTime: time.Now(), - }, - &stats.Metrics{ - Name: "yahoo.com", - Total: 50, - Successful: 48, - Failed: 2, - LastSuccTime: time.Now(), - }, - &stats.Metrics{ - Name: "example.org", - Total: 25, - Successful: 25, - Failed: 0, - LastSuccTime: time.Now(), - }, - &stats.Metrics{ - Name: "test.net", - Total: 10, - Successful: 8, - Failed: 2, - LastSuccTime: time.Now(), - }, + metrics := []stats.Metrics{ + stats.NewMetrics("google.com", 1), + stats.NewMetrics("yahoo.com", 1), + stats.NewMetrics("example.org", 1), + stats.NewMetrics("test.net", 1), } tests := []struct { - name string - filterText string - expectedCount int - expectedNames []string + name string + filterText string + expectedCount int + expectedNames []string }{ { name: "empty filter returns all metrics", @@ -130,10 +105,10 @@ func TestFilterMetrics(t *testing.T) { } func TestFilterMetricsPreservesOrder(t *testing.T) { - metrics := []stats.MetricsReader{ - &stats.Metrics{Name: "alpha.com"}, - &stats.Metrics{Name: "beta.com"}, - &stats.Metrics{Name: "gamma.com"}, + metrics := []stats.Metrics{ + stats.NewMetrics("alpha.com", 1), + stats.NewMetrics("beta.com", 1), + stats.NewMetrics("gamma.com", 1), } result := FilterMetrics(metrics, ".com") @@ -147,7 +122,7 @@ func TestFilterMetricsPreservesOrder(t *testing.T) { } func TestFilterMetricsWithEmptyMetrics(t *testing.T) { - var metrics []stats.MetricsReader + var metrics []stats.Metrics result := FilterMetrics(metrics, "test") @@ -157,14 +132,14 @@ func TestFilterMetricsWithEmptyMetrics(t *testing.T) { } func TestFilterMetricsDoesNotModifyOriginal(t *testing.T) { - original := []stats.MetricsReader{ - &stats.Metrics{Name: "test1.com"}, - &stats.Metrics{Name: "test2.com"}, - &stats.Metrics{Name: "example.org"}, + original := []stats.Metrics{ + stats.NewMetrics("test1.com", 1), + stats.NewMetrics("test2.com", 1), + stats.NewMetrics("example.org", 1), } // Create a copy to compare later - originalCopy := make([]stats.MetricsReader, len(original)) + originalCopy := make([]stats.Metrics, len(original)) copy(originalCopy, original) // Filter metrics @@ -180,4 +155,4 @@ func TestFilterMetricsDoesNotModifyOriginal(t *testing.T) { t.Errorf("FilterMetrics() modified original slice at index %d: got %s, want %s", i, metric.GetName(), originalCopy[i].GetName()) } } -} \ No newline at end of file +} diff --git a/internal/ui/shared/formatters.go b/internal/ui/shared/formatters.go index ed9878a..2c4c833 100644 --- a/internal/ui/shared/formatters.go +++ b/internal/ui/shared/formatters.go @@ -29,7 +29,7 @@ func TimeFormater(t time.Time) string { } // FormatHostDetail generates detailed information for a host -func FormatHostDetail(metric stats.MetricsReader) string { +func FormatHostDetail(metric stats.Metrics) string { // Color-coded basic statistics lossRate := metric.GetLoss() lossColor := "green" @@ -38,17 +38,17 @@ func FormatHostDetail(metric stats.MetricsReader) string { } else if lossRate > 10 { lossColor = "yellow" } - + successColor := "green" if metric.GetSuccessful() == 0 { successColor = "red" } - + failColor := "white" if metric.GetFailed() > 0 { failColor = "red" } - + basicInfo := fmt.Sprintf(`[cyan]Total Probes:[white] %d [%s]Successful:[white] %d [%s]Failed:[white] %d @@ -82,9 +82,8 @@ func FormatHostDetail(metric stats.MetricsReader) string { return basicInfo } - // FormatHistory generates history section for a host -func FormatHistory(metric stats.MetricsReader) string { +func FormatHistory(metric stats.Metrics) string { history := metric.GetRecentHistory(10) if len(history) == 0 { return "" @@ -99,7 +98,7 @@ func FormatHistory(metric stats.MetricsReader) string { statusColor := "green" status := "OK" details := "" - + if !entry.Success { status = "FAIL" statusColor = "red" @@ -136,22 +135,21 @@ func formatProbeDetails(details *prober.ProbeDetails) string { var parts []string parts = append(parts, fmt.Sprintf("seq=%d", details.ICMP.Sequence)) parts = append(parts, fmt.Sprintf("size=%d", details.ICMP.PacketSize)) - - + if details.ICMP.ICMPType >= 0 { parts = append(parts, fmt.Sprintf("type=%d", details.ICMP.ICMPType)) } - + if details.ICMP.Payload != "" { parts = append(parts, fmt.Sprintf("payload=%s", details.ICMP.Payload)) } - + return strings.Join(parts, " ") } return "icmp ping" case "http", "https": if details.HTTP != nil { - return fmt.Sprintf("status=%d size=%d", + return fmt.Sprintf("status=%d size=%d", details.HTTP.StatusCode, details.HTTP.ResponseSize) } return "http probe" @@ -161,11 +159,11 @@ func formatProbeDetails(details *prober.ProbeDetails) string { if details.DNS.UseTCP { proto = "tcp " } - + // Show just the essential info: protocol, response code, answer count, and first answer - baseInfo := fmt.Sprintf("%scode=%d ans=%d", + baseInfo := fmt.Sprintf("%scode=%d ans=%d", proto, details.DNS.ResponseCode, details.DNS.AnswerCount) - + // Add first answer if available if len(details.DNS.Answers) > 0 { firstAnswer := extractDNSAnswer(details.DNS.Answers[0]) @@ -173,21 +171,21 @@ func formatProbeDetails(details *prober.ProbeDetails) string { baseInfo += " " + firstAnswer } } - + return baseInfo } return "dns query" case "ntp": if details.NTP != nil { offset := time.Duration(details.NTP.Offset) * time.Microsecond - return fmt.Sprintf("stratum=%d offset=%s", + return fmt.Sprintf("stratum=%d offset=%s", details.NTP.Stratum, DurationFormater(offset)) } return "ntp sync" case "tcp": return "connection" } - + return "" } @@ -197,22 +195,22 @@ func extractDNSAnswer(record string) string { if record == "" { return "" } - + // Split by whitespace and get the last part (the answer value) parts := strings.Fields(record) if len(parts) == 0 { return "" } - + // The last part is usually the answer value answer := parts[len(parts)-1] - + // Truncate very long answers (like long TXT records) // TODO: Make this configurable in the future maxAnswerLength := 35 if len(answer) > maxAnswerLength { answer = answer[:maxAnswerLength-3] + "..." } - + return answer -} \ No newline at end of file +} diff --git a/internal/ui/shared/formatters_test.go b/internal/ui/shared/formatters_test.go index de68ac5..114771a 100644 --- a/internal/ui/shared/formatters_test.go +++ b/internal/ui/shared/formatters_test.go @@ -95,21 +95,23 @@ func TestTimeFormater(t *testing.T) { func TestFormatHostDetail(t *testing.T) { testTime := time.Date(2024, 1, 1, 15, 30, 45, 0, time.UTC) - - metric := &stats.Metrics{ - Name: "example.com", - Total: 100, - Successful: 95, - Failed: 5, - Loss: 5.0, - LastRTT: 25 * time.Millisecond, - AverageRTT: 30 * time.Millisecond, - MinimumRTT: 20 * time.Millisecond, - MaximumRTT: 40 * time.Millisecond, - LastSuccTime: testTime, - LastFailTime: testTime.Add(time.Second), - LastFailDetail: "timeout", - } + + metric := stats.NewMetricsForTest( + "example.com", + 1, + 100, + 95, + 5, + 5.0, + 25*time.Millisecond, + 30*time.Millisecond, + 20*time.Millisecond, + 40*time.Millisecond, + 25*time.Millisecond, + testTime, + testTime.Add(time.Second), + "timeout", + ) result := FormatHostDetail(metric) @@ -136,20 +138,22 @@ func TestFormatHostDetail(t *testing.T) { } func TestFormatHostDetailWithZeroValues(t *testing.T) { - metric := &stats.Metrics{ - Name: "test.com", - Total: 0, - Successful: 0, - Failed: 0, - Loss: 0.0, - LastRTT: 0, - AverageRTT: 0, - MinimumRTT: 0, - MaximumRTT: 0, - LastSuccTime: time.Time{}, - LastFailTime: time.Time{}, - LastFailDetail: "", - } + metric := stats.NewMetricsForTest( + "test.com", + 1, + 0, + 0, + 0, + 0.0, + 0, + 0, + 0, + 0, + 0, + time.Time{}, + time.Time{}, + "", + ) result := FormatHostDetail(metric) @@ -187,4 +191,4 @@ func findSubstring(s, substr string) bool { } } return false -} \ No newline at end of file +} diff --git a/internal/ui/shared/table_data.go b/internal/ui/shared/table_data.go index 1284f1e..1508282 100644 --- a/internal/ui/shared/table_data.go +++ b/internal/ui/shared/table_data.go @@ -15,11 +15,11 @@ import ( type TableData struct { Headers []string Rows [][]string - Metrics []stats.MetricsReader // Keep reference for interactive row selection + Metrics []stats.Metrics // Keep reference for interactive row selection } // NewTableData creates TableData from metrics -func NewTableData(metrics []stats.MetricsReader, sortKey stats.Key, ascending bool) *TableData { +func NewTableData(metrics []stats.Metrics, sortKey stats.Key, ascending bool) *TableData { // Generate headers with sort arrows headers := []string{ headerWithArrow("Host", stats.Host, sortKey, ascending), @@ -151,7 +151,7 @@ func (td *TableData) ToTviewTable() *tview.Table { } // GetMetricAtRow returns the metric for a given row index -func (td *TableData) GetMetricAtRow(row int) (stats.MetricsReader, bool) { +func (td *TableData) GetMetricAtRow(row int) (stats.Metrics, bool) { if row < 0 || row >= len(td.Metrics) { return nil, false } diff --git a/internal/ui/tui/app.go b/internal/ui/tui/app.go index 79a32f3..9ca5922 100644 --- a/internal/ui/tui/app.go +++ b/internal/ui/tui/app.go @@ -90,7 +90,7 @@ func (a *TUIApp) setupCallbacks() { a.layout.GetHostListPanel().SetSelectedFunc(a.handleRowSelection) // Set selection change callback for detail panel updates - a.layout.GetHostListPanel().SetSelectionChangeCallback(func(metrics stats.MetricsReader) { + a.layout.GetHostListPanel().SetSelectionChangeCallback(func(metrics stats.Metrics) { a.layout.SetSelectedMetrics(metrics) }) } @@ -298,7 +298,7 @@ func (a *TUIApp) handleRowSelection(row, col int) { } // getFilteredMetrics returns filtered metrics based on current state -func (a *TUIApp) getFilteredMetrics() []stats.MetricsReader { +func (a *TUIApp) getFilteredMetrics() []stats.Metrics { metrics := a.mm.SortBy(a.state.GetSortKey(), a.state.IsAscending()) return shared.FilterMetrics(metrics, a.state.GetFilter()) } diff --git a/internal/ui/tui/layout.go b/internal/ui/tui/layout.go index 1df3ca0..c1041f2 100644 --- a/internal/ui/tui/layout.go +++ b/internal/ui/tui/layout.go @@ -107,7 +107,7 @@ func (l *LayoutManager) SetSelectedHost(hostname string) { } // SetSelectedMetrics updates the detail panel with the selected metrics -func (l *LayoutManager) SetSelectedMetrics(metrics stats.MetricsReader) { +func (l *LayoutManager) SetSelectedMetrics(metrics stats.Metrics) { l.hostDetail.SetMetrics(metrics) } diff --git a/internal/ui/tui/panels/host_detail.go b/internal/ui/tui/panels/host_detail.go index 0aadbe7..d0f80e3 100644 --- a/internal/ui/tui/panels/host_detail.go +++ b/internal/ui/tui/panels/host_detail.go @@ -2,7 +2,7 @@ package panels import ( "github.com/rivo/tview" - + "github.com/servak/mping/internal/stats" "github.com/servak/mping/internal/ui/shared" ) @@ -10,9 +10,9 @@ import ( // HostDetailPanel manages host detail display type HostDetailPanel struct { view *tview.TextView - container *tview.Flex // Container with border + container *tview.Flex // Container with border currentHost string - currentMetrics stats.MetricsReader + currentMetrics stats.Metrics } // NewHostDetailPanel creates a new HostDetailPanel @@ -25,7 +25,7 @@ func NewHostDetailPanel() *HostDetailPanel { container := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(view, 0, 1, false) - + container.SetBorder(true). SetTitle(" Host Details ") @@ -54,7 +54,7 @@ func (h *HostDetailPanel) SetHost(hostname string) { } // SetMetrics sets the current metrics object directly -func (h *HostDetailPanel) SetMetrics(metrics stats.MetricsReader) { +func (h *HostDetailPanel) SetMetrics(metrics stats.Metrics) { h.currentMetrics = metrics if metrics != nil { h.currentHost = metrics.GetName() @@ -74,4 +74,4 @@ func (h *HostDetailPanel) SetVisible(visible bool) { } else { h.container.SetBorder(false) } -} \ No newline at end of file +} diff --git a/internal/ui/tui/panels/host_list.go b/internal/ui/tui/panels/host_list.go index e2cc8ce..a6cb14b 100644 --- a/internal/ui/tui/panels/host_list.go +++ b/internal/ui/tui/panels/host_list.go @@ -12,11 +12,11 @@ import ( // HostListPanel manages host list table display type HostListPanel struct { table *tview.Table - container *tview.Flex // Container with border + container *tview.Flex // Container with border renderState state.RenderState selectionState state.SelectionState mm stats.MetricsProvider - onSelectionChange func(metrics stats.MetricsReader) // Callback when selection changes + onSelectionChange func(metrics stats.Metrics) // Callback when selection changes } type HostListParams interface { @@ -33,7 +33,7 @@ func NewHostListPanel(state HostListParams, mm stats.MetricsProvider) *HostListP container := tview.NewFlex(). SetDirection(tview.FlexRow). AddItem(table, 0, 1, true) - + container.SetBorder(true). SetTitle(" Host List ") @@ -83,7 +83,7 @@ func (h *HostListPanel) Update() { } // getFilteredMetrics returns filtered metrics based on current state -func (h *HostListPanel) getFilteredMetrics() []stats.MetricsReader { +func (h *HostListPanel) getFilteredMetrics() []stats.Metrics { metrics := h.mm.SortBy(h.renderState.GetSortKey(), h.renderState.IsAscending()) return shared.FilterMetrics(metrics, h.renderState.GetFilter()) } @@ -111,7 +111,7 @@ func (h *HostListPanel) GetView() tview.Primitive { } // GetSelectedMetric returns the currently selected metric -func (h *HostListPanel) GetSelectedMetric(tableData *shared.TableData) (stats.MetricsReader, bool) { +func (h *HostListPanel) GetSelectedMetric(tableData *shared.TableData) (stats.Metrics, bool) { row, _ := h.table.GetSelection() if row <= 0 { return nil, false @@ -133,7 +133,7 @@ func (h *HostListPanel) SetSelectedFunc(fn func(row, col int)) { } // SetSelectionChangeCallback sets the callback for when selection changes -func (h *HostListPanel) SetSelectionChangeCallback(fn func(metrics stats.MetricsReader)) { +func (h *HostListPanel) SetSelectionChangeCallback(fn func(metrics stats.Metrics)) { h.onSelectionChange = fn } @@ -262,7 +262,7 @@ func (h *HostListPanel) restoreSelection(tableData *shared.TableData, selectedHo } // GetSelectedMetrics returns the currently selected metrics -func (h *HostListPanel) GetSelectedMetrics() stats.MetricsReader { +func (h *HostListPanel) GetSelectedMetrics() stats.Metrics { metrics := h.getFilteredMetrics() if len(metrics) == 0 { return nil diff --git a/internal/ui/tui/panels/host_list_test.go b/internal/ui/tui/panels/host_list_test.go index 17fca13..0e4d2bf 100644 --- a/internal/ui/tui/panels/host_list_test.go +++ b/internal/ui/tui/panels/host_list_test.go @@ -16,21 +16,21 @@ type mockState struct { selectedHost string } -func (m *mockState) GetSortKey() stats.Key { return m.sortKey } +func (m *mockState) GetSortKey() stats.Key { return m.sortKey } func (m *mockState) SetSortKey(key stats.Key) { m.sortKey = key } -func (m *mockState) IsAscending() bool { return m.ascending } -func (m *mockState) ReverseSort() { m.ascending = !m.ascending } -func (m *mockState) GetFilter() string { return m.filter } +func (m *mockState) IsAscending() bool { return m.ascending } +func (m *mockState) ReverseSort() { m.ascending = !m.ascending } +func (m *mockState) GetFilter() string { return m.filter } func (m *mockState) SetFilter(filter string) { m.filter = filter } -func (m *mockState) ClearFilter() { m.filter = "" } -func (m *mockState) GetSelectedHost() string { return m.selectedHost } -func (m *mockState) SetSelectedHost(host string) { m.selectedHost = host } +func (m *mockState) ClearFilter() { m.filter = "" } +func (m *mockState) GetSelectedHost() string { return m.selectedHost } +func (m *mockState) SetSelectedHost(host string) { m.selectedHost = host } func newMockState() *mockState { return &mockState{ - sortKey: stats.Success, - ascending: false, - filter: "", + sortKey: stats.Success, + ascending: false, + filter: "", selectedHost: "", } } @@ -276,9 +276,9 @@ func TestHostListPanelRestoreSelection(t *testing.T) { mm.Register("google.com", "google.com") mm.Register("example.com", "example.com") - metrics := []stats.MetricsReader{ - &stats.Metrics{Name: "google.com"}, - &stats.Metrics{Name: "example.com"}, + metrics := []stats.Metrics{ + stats.NewMetrics("google.com", 1), + stats.NewMetrics("example.com", 1), } tableData := shared.NewTableData(metrics, stats.Success, false) @@ -292,4 +292,3 @@ func TestHostListPanelRestoreSelection(t *testing.T) { panel.restoreSelection(tableData, "google.com") panel.restoreSelection(tableData, "nonexistent.com") } -