diff --git a/internal/command/mping.go b/internal/command/mping.go index 56ebb49..81622d1 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.SortBy(stats.Success, true) - tableData := shared.NewTableData(metrics, stats.Success, true) + metrics := metricsManager.SortBy(stats.Fail, false) + tableData := shared.NewTableData(metrics, stats.Fail, false) t := tableData.ToGoPrettyTable() t.SetStyle(table.StyleLight) cmd.Println(t.Render()) @@ -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.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/prober/details.go b/internal/prober/details.go new file mode 100644 index 0000000..7c7c97f --- /dev/null +++ b/internal/prober/details.go @@ -0,0 +1,48 @@ +package prober + +// 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 has no detailed information (only connection availability) +} + +type ICMPDetails struct { + 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 { + 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"` // 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 b42cb0e..679b1d7 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) { + // Create DNS detail information + 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..57dbc0c 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 { + // Create HTTP detail information + headers := make(map[string]string) + for key, values := range resp.Header { + if len(values) > 0 { + headers[key] = values[0] // Get only the first value + } + } + + 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..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) { +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 { @@ -187,17 +187,105 @@ func (p *ICMPProber) success(r chan *Event, runCnt int, addr string) { table[addr] = true 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, + } + r <- &Event{ Key: key, DisplayName: displayName, Result: SUCCESS, SentTime: k.sentTime, Rtt: elapse, + Details: details, } return } } +// 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() @@ -300,7 +388,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) @@ -323,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), ip.String()) + p.success(r, int(seq), addr.String(), *rm, pktbuf[:n], n) } } } diff --git a/internal/prober/ntp.go b/internal/prober/ntp.go index 5945b8d..9654fdb 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) { + // Create NTP detail information + // Extract port (serverAddr is in "host:port" format) + _, portStr, _ := net.SplitHostPort(serverAddr) + port := 123 // Default NTP port + 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..951de8b 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 // Added: detailed information } type Prober interface { diff --git a/internal/prober/tcp.go b/internal/prober/tcp.go index 0864a49..ca84e81 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 only checks connectivity, so no detailed information 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 has no detailed information } } diff --git a/internal/stats/history.go b/internal/stats/history.go new file mode 100644 index 0000000..8522931 --- /dev/null +++ b/internal/stats/history.go @@ -0,0 +1,152 @@ +package stats + +import ( + "time" + + "github.com/servak/mping/internal/prober" +) + +// History entry +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"` +} + +// Structure to manage history for each target (ring buffer) +type TargetHistory struct { + entries []HistoryEntry + size int // リングバッファサイズ + index int // 現在の書き込み位置 + count int // 実際のエントリ数 +} + +// NewTargetHistory creates a new TargetHistory +func NewTargetHistory(size int) *TargetHistory { + return &TargetHistory{ + entries: make([]HistoryEntry, size), + size: size, + index: 0, + count: 0, + } +} + +// AddEntry adds a new history entry +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 retrieves the latest n entries (newest first) +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++ { + // Retrieve from newest to oldest + pos := (th.index - 1 - i + th.size) % th.size + result[i] = th.entries[pos] + } + + return result +} + +// GetEntriesSince retrieves entries since the specified time +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 // Reached older entries, so stop + } + } + + return result +} + +// GetConsecutiveFailures retrieves the number of consecutive failures +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 retrieves the number of consecutive successes +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 retrieves the success rate within the specified period +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 clears the history +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..b0dc1e2 --- /dev/null +++ b/internal/stats/history_test.go @@ -0,0 +1,174 @@ +package stats + +import ( + "testing" + "time" + + "github.com/servak/mping/internal/prober" +) + +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()) + } + if th.GetConsecutiveSuccesses() != 0 { + t.Errorf("Expected 0 consecutive successes, got %d", th.GetConsecutiveSuccesses()) + } + + now := time.Now() + + // Add success entry + th.AddEntry(HistoryEntry{ + Timestamp: now, + RTT: 100 * time.Millisecond, + Success: true, + Details: &prober.ProbeDetails{ + ProbeType: "icmp", + ICMP: &prober.ICMPDetails{ + Sequence: 1, + Payload: "test", + }, + }, + }) + + // 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, + Success: false, + Error: "timeout", + }) + + // Test consecutive failures + 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()) + } + + // Get recent entries + 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") + } + + // Test ring buffer behavior + 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, + }) + + // 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)) + } +} + +func TestMetricsWithHistory(t *testing.T) { + mm := metricsManager{ + metrics: make(map[string]*metrics), + historySize: DefaultHistorySize, // Set history size for testing + } + host := "example.com" + + // Record success + details := &prober.ProbeDetails{ + ProbeType: "icmp", + ICMP: &prober.ICMPDetails{ + Sequence: 1, + Payload: "test", + }, + } + mm.Success(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) + } + + // 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()) + } +} + +func TestSuccessRateInPeriod(t *testing.T) { + th := NewTargetHistory(10) + now := time.Now() + + // 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), + RTT: 100 * time.Millisecond, + Success: true, + }) + } + + // Add 5 failures + for i := 0; i < 5; i++ { + th.AddEntry(HistoryEntry{ + Timestamp: now.Add(time.Duration(-i-1) * time.Second), + RTT: 0, + Success: false, + Error: "timeout", + }) + } + + // Overall success rate (50%) + rate := th.GetSuccessRateInPeriod(time.Hour) + if rate != 50.0 { + t.Errorf("Expected 50%% success rate, got %f%%", rate) + } + + // 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 new file mode 100644 index 0000000..20f40b2 --- /dev/null +++ b/internal/stats/interface.go @@ -0,0 +1,51 @@ +package stats + +import ( + "time" + + "github.com/servak/mping/internal/prober" +) + +// Metrics provides basic statistics for display and sorting +type Metrics 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 + GetConsecutiveFailures() int + GetConsecutiveSuccesses() int + GetSuccessRateInPeriod(duration time.Duration) float64 +} + +// MetricsProvider provides external API for metrics access +type MetricsProvider interface { + SortBy(k Key, ascending bool) []Metrics +} + +// MetricsSystemManager provides system-level operations +type MetricsSystemManager interface { + ResetAllMetrics() +} + +// MetricsEventRecorder handles internal event recording +type MetricsEventRecorder interface { + Register(target, name string) + Subscribe(<-chan *prober.Event) +} + +// MetricsManager provides comprehensive metrics management (for backward compatibility) +type MetricsManager interface { + MetricsProvider + MetricsSystemManager + MetricsEventRecorder +} diff --git a/internal/stats/manager.go b/internal/stats/manager.go index cf97729..f18a1a0 100644 --- a/internal/stats/manager.go +++ b/internal/stats/manager.go @@ -8,46 +8,62 @@ import ( "github.com/servak/mping/internal/prober" ) -type MetricsManager struct { - metrics map[string]*Metrics - mu sync.Mutex +const ( + DefaultHistorySize = 100 // Default number of history entries to keep +) + +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 { + return NewMetricsManagerWithHistorySize(DefaultHistorySize) } -// 新しいMetricsManagerを生成 -func NewMetricsManager() *MetricsManager { - metrics := make(map[string]*Metrics) - return &MetricsManager{ - metrics: metrics, +// Create MetricsManager with specified history size +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 } - mm.metrics[target] = &Metrics{ - Name: name, + mm.metrics[target] = &metrics{ + Name: name, + history: NewTargetHistory(mm.historySize), } } -// 指定されたホストのMetricsを取得 -func (mm *MetricsManager) GetMetrics(host string) *Metrics { +// 指定されたホストのMetricsを取得(内部用) +func (mm *metricsManager) getMetrics(host string) *metrics { mm.mu.Lock() defer mm.mu.Unlock() m, ok := mm.metrics[host] if !ok { - m = &Metrics{ - Name: host, + 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) ResetAllMetrics() { +func (mm *metricsManager) GetMetrics(host string) Metrics { + return mm.getMetrics(host) +} + +func (mm *metricsManager) ResetAllMetrics() { mm.mu.Lock() defer mm.mu.Unlock() @@ -56,33 +72,54 @@ func (mm *MetricsManager) ResetAllMetrics() { } } -// ホストに対する成功を登録 -func (mm *MetricsManager) Success(host string, rtt time.Duration, sentTime time.Time) { - m := mm.GetMetrics(host) +// Register success for host +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) 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() } -// ホストに対する失敗を登録 -func (mm *MetricsManager) Failed(host string, sentTime time.Time, msg string) { - m := mm.GetMetrics(host) +// Register failure for host +func (mm *metricsManager) Failed(host string, sentTime time.Time, msg string) { + m := mm.getMetrics(host) 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() } -func (mm *MetricsManager) Sent(host string) { - m := mm.GetMetrics(host) +func (mm *metricsManager) Sent(host string) { + m := mm.getMetrics(host) mm.mu.Lock() m.Sent() 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 { @@ -91,7 +128,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: @@ -102,27 +139,30 @@ 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() if _, exists := mm.metrics[key]; !exists { - mm.metrics[key] = &Metrics{ - Name: displayName, + mm.metrics[key] = &metrics{ + Name: displayName, + history: NewTargetHistory(mm.historySize), } } } -func (mm *MetricsManager) SortBy(k Key, ascending bool) []Metrics { +// SortBy sorts metrics by specified key and returns Metrics slice +func (mm *metricsManager) SortBy(k Key, ascending bool) []Metrics { mm.mu.Lock() var res []Metrics 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 { @@ -131,32 +171,31 @@ func (mm *MetricsManager) SortBy(k Key, ascending bool) []Metrics { var result bool switch k { case Host: - result = res[i].Name < res[j].Name + result = mi.GetName() < mj.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 } - // ascending=falseの場合は結果を反転 if ascending { return result } else { @@ -166,14 +205,19 @@ func (mm *MetricsManager) SortBy(k Key, ascending bool) []Metrics { return res } -// rejectLessAscending は昇順ソート用のRTT比較関数 -// 0値(未測定)は常に後ろに配置される +// GetMetricsAsReader retrieves as Metrics interface +func (mm *metricsManager) GetMetricsAsReader(target string) Metrics { + return mm.getMetrics(target) +} + +// 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 3dbee1a..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 @@ -18,9 +44,10 @@ type Metrics struct { LastFailTime time.Time LastSuccTime time.Time LastFailDetail string + 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 @@ -35,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 @@ -63,4 +90,85 @@ func (m *Metrics) Reset() { m.LastFailTime = time.Time{} m.LastSuccTime = time.Time{} m.LastFailDetail = "" + if m.history != nil { + m.history.Clear() + } +} + +// Implementation of MetricsReader interface + +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) 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/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 2c66bf7..853cafe 100644 --- a/internal/ui/shared/filters.go +++ b/internal/ui/shared/filters.go @@ -11,13 +11,13 @@ func FilterMetrics(metrics []stats.Metrics, filterText string) []stats.Metrics { if filterText == "" { return metrics } - + filtered := []stats.Metrics{} 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) } } 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 ffea9e5..a7e4b3f 100644 --- a/internal/ui/shared/filters_test.go +++ b/internal/ui/shared/filters_test.go @@ -2,7 +2,6 @@ package shared import ( "testing" - "time" "github.com/servak/mping/internal/stats" ) @@ -10,41 +9,17 @@ import ( func TestFilterMetrics(t *testing.T) { // Create test metrics metrics := []stats.Metrics{ - { - Name: "google.com", - Total: 100, - Successful: 95, - Failed: 5, - LastSuccTime: time.Now(), - }, - { - Name: "yahoo.com", - Total: 50, - Successful: 48, - Failed: 2, - LastSuccTime: time.Now(), - }, - { - Name: "example.org", - Total: 25, - Successful: 25, - Failed: 0, - LastSuccTime: time.Now(), - }, - { - Name: "test.net", - Total: 10, - Successful: 8, - Failed: 2, - LastSuccTime: time.Now(), - }, + 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", @@ -108,7 +83,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 +96,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) } @@ -131,17 +106,17 @@ func TestFilterMetrics(t *testing.T) { func TestFilterMetricsPreservesOrder(t *testing.T) { metrics := []stats.Metrics{ - {Name: "alpha.com"}, - {Name: "beta.com"}, - {Name: "gamma.com"}, + stats.NewMetrics("alpha.com", 1), + stats.NewMetrics("beta.com", 1), + stats.NewMetrics("gamma.com", 1), } 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]) } } } @@ -158,9 +133,9 @@ func TestFilterMetricsWithEmptyMetrics(t *testing.T) { func TestFilterMetricsDoesNotModifyOriginal(t *testing.T) { original := []stats.Metrics{ - {Name: "test1.com"}, - {Name: "test2.com"}, - {Name: "example.org"}, + stats.NewMetrics("test1.com", 1), + stats.NewMetrics("test2.com", 1), + stats.NewMetrics("example.org", 1), } // Create a copy to compare later @@ -176,8 +151,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.go b/internal/ui/shared/formatters.go index 9384140..2c4c833 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,30 +30,187 @@ func TimeFormater(t time.Time) string { // FormatHostDetail generates detailed information for a host func FormatHostDetail(metric stats.Metrics) string { - return fmt.Sprintf(`Host Details: %s - -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`, - 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, + // 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(), + successColor, metric.GetSuccessful(), + failColor, metric.GetFailed(), + lossColor, lossRate, + 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 + + // 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.Metrics) string { + history := metric.GetRecentHistory(10) + if len(history) == 0 { + return "" + } + + var sb strings.Builder + 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 = fmt.Sprintf("[red]%s[white]", entry.Error) + } + } else { + // Show probe-specific details for successful entries + details = formatProbeDetails(entry.Details) + } + + sb.WriteString(fmt.Sprintf("[gray]%-8s[white] [%s]%-6s[white] %-7s %s\n", + entry.Timestamp.Format("15:04:05"), + statusColor, 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", "icmpv4", "icmpv6": + if details.ICMP != nil { + // 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": + 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 { + 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": + 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" + } + + 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 +} diff --git a/internal/ui/shared/formatters_test.go b/internal/ui/shared/formatters_test.go index 4745ce7..114771a 100644 --- a/internal/ui/shared/formatters_test.go +++ b/internal/ui/shared/formatters_test.go @@ -95,37 +95,39 @@ 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) 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,36 +138,38 @@ 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) 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 { @@ -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 971c9b2..1508282 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" @@ -42,18 +43,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(), } } @@ -66,6 +67,7 @@ func NewTableData(metrics []stats.Metrics, sortKey stats.Key, ascending bool) *T // 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, @@ -151,7 +153,7 @@ func (td *TableData) ToTviewTable() *tview.Table { // GetMetricAtRow returns the metric for a given row index func (td *TableData) GetMetricAtRow(row int) (stats.Metrics, bool) { if row < 0 || row >= len(td.Metrics) { - return stats.Metrics{}, false + return nil, false } return td.Metrics[row], true } @@ -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/app.go b/internal/ui/tui/app.go index 54ad2a1..9ca5922 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.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.MetricsManager, 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,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.Metrics) { + a.layout.SetSelectedMetrics(metrics) + }) } // setupKeyBindings configures key bindings @@ -130,6 +135,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 +181,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 +241,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: @@ -239,7 +253,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 @@ -277,30 +292,13 @@ 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.Metrics) { - 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.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 99cb3bb..c1041f2 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 @@ -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.MetricsManager, config *shared.Config, interval, timeout time.Duration) *LayoutManager { layout := &LayoutManager{ mode: ListOnly, } @@ -57,11 +57,11 @@ 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.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) - // hostDetail will be created on demand + l.hostDetail = panels.NewHostDetailPanel() // 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,74 @@ 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.Metrics) { + 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 + + // 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 + + // 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 +272,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..d0f80e3 100644 --- a/internal/ui/tui/panels/host_detail.go +++ b/internal/ui/tui/panels/host_detail.go @@ -2,42 +2,76 @@ 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 + container *tview.Flex // Container with border + currentHost string + currentMetrics stats.Metrics } // NewHostDetailPanel creates a new HostDetailPanel func NewHostDetailPanel() *HostDetailPanel { view := tview.NewTextView() view.SetDynamicColors(true). - SetScrollable(true). - SetBorder(true). - SetTitle("Host Details") + 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, + view: view, + container: container, } } -// 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.container.SetTitle(" Host Details: " + hostname + " ") +} + +// SetMetrics sets the current metrics object directly +func (h *HostDetailPanel) SetMetrics(metrics stats.Metrics) { + h.currentMetrics = metrics + if metrics != nil { + h.currentHost = metrics.GetName() + 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) { - // Implementation for future layout mode switching -} \ No newline at end of file + if visible { + h.container.SetBorder(true) + } else { + h.container.SetBorder(false) + } +} diff --git a/internal/ui/tui/panels/host_list.go b/internal/ui/tui/panels/host_list.go index 3ffd54f..a6cb14b 100644 --- a/internal/ui/tui/panels/host_list.go +++ b/internal/ui/tui/panels/host_list.go @@ -11,10 +11,12 @@ 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.MetricsProvider + onSelectionChange func(metrics stats.Metrics) // Callback when selection changes } type HostListParams interface { @@ -23,12 +25,21 @@ 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) + // 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, @@ -45,7 +56,7 @@ func (h *HostListPanel) Update() { // Clear existing content and repopulate h.table.Clear() - + // Configure table settings h.table. SetBorders(false). @@ -55,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) @@ -82,19 +93,28 @@ 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) + + // 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) + } + } } // 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 func (h *HostListPanel) GetSelectedMetric(tableData *shared.TableData) (stats.Metrics, 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 +122,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 "" } @@ -112,6 +132,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.Metrics)) { + h.onSelectionChange = fn +} + // Navigation methods func (h *HostListPanel) ScrollDown() { row, _ := h.table.GetSelection() @@ -171,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) @@ -225,7 +249,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 } @@ -236,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.Metrics { + metrics := h.getFilteredMetrics() + if len(metrics) == 0 { + return nil + } + + row, _ := h.table.GetSelection() + // row 0 is header, so data starts from row 1 + if row >= 1 && row-1 < len(metrics) { + return metrics[row-1] + } + + return nil +} diff --git a/internal/ui/tui/panels/host_list_test.go b/internal/ui/tui/panels/host_list_test.go index 8672d47..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: "", } } @@ -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") } } @@ -277,8 +277,8 @@ func TestHostListPanelRestoreSelection(t *testing.T) { mm.Register("example.com", "example.com") metrics := []stats.Metrics{ - {Name: "google.com"}, - {Name: "example.com"}, + 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") } - 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 +} 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() {