From 022ff1d5e3138b612b3ea6a03aa6a2a3d4bd3c9d Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Tue, 30 Sep 2025 14:32:11 +0900 Subject: [PATCH 1/3] feat(beep): Add beep sound notification package with debouncing Implement a beep sound notification system for probe failures: - Thread-safe Beeper with mutex protection - 500ms debounce interval to prevent continuous beeping - Non-blocking asynchronous execution using goroutines - ON/OFF toggle functionality - Comprehensive test coverage including concurrent access tests --- internal/beep/beep.go | 107 +++++++++++++++++++++++++++ internal/beep/beep_test.go | 145 +++++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 internal/beep/beep.go create mode 100644 internal/beep/beep_test.go diff --git a/internal/beep/beep.go b/internal/beep/beep.go new file mode 100644 index 0000000..bcf5f51 --- /dev/null +++ b/internal/beep/beep.go @@ -0,0 +1,107 @@ +package beep + +import ( + "fmt" + "os" + "sync" + "time" +) + +const ( + // DefaultDebounceInterval is the default interval for debouncing beep sounds + DefaultDebounceInterval = 500 * time.Millisecond +) + +// Beeper manages beep sound playback with debouncing +type Beeper struct { + enabled bool + debounceTime time.Duration + lastBeepTime time.Time + mu sync.Mutex +} + +// NewBeeper creates a new Beeper instance +func NewBeeper() *Beeper { + return &Beeper{ + enabled: true, // Default is ON + debounceTime: DefaultDebounceInterval, + } +} + +// NewBeeperWithDebounce creates a new Beeper with custom debounce interval +func NewBeeperWithDebounce(debounceInterval time.Duration) *Beeper { + return &Beeper{ + enabled: true, + debounceTime: debounceInterval, + } +} + +// Beep plays a beep sound if enabled and debounce time has passed +// This function is non-blocking and thread-safe +func (b *Beeper) Beep() { + go func() { + b.mu.Lock() + defer b.mu.Unlock() + + // Skip if disabled + if !b.enabled { + return + } + + // Check debounce time + now := time.Now() + if now.Sub(b.lastBeepTime) < b.debounceTime { + return // Skip if within debounce period + } + + // Play beep sound + if err := b.playBeep(); err != nil { + // Log error but don't propagate + // This ensures beep failures don't affect main processing + fmt.Fprintf(os.Stderr, "beep: failed to play sound: %v\n", err) + } + + b.lastBeepTime = now + }() +} + +// playBeep outputs the system bell character +func (b *Beeper) playBeep() error { + _, err := fmt.Print("\a") + return err +} + +// Enable turns on beep sound +func (b *Beeper) Enable() { + b.mu.Lock() + defer b.mu.Unlock() + b.enabled = true +} + +// Disable turns off beep sound +func (b *Beeper) Disable() { + b.mu.Lock() + defer b.mu.Unlock() + b.enabled = false +} + +// Toggle switches beep sound on/off +func (b *Beeper) Toggle() { + b.mu.Lock() + defer b.mu.Unlock() + b.enabled = !b.enabled +} + +// IsEnabled returns current beep sound state +func (b *Beeper) IsEnabled() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.enabled +} + +// SetDebounceInterval sets the debounce interval +func (b *Beeper) SetDebounceInterval(interval time.Duration) { + b.mu.Lock() + defer b.mu.Unlock() + b.debounceTime = interval +} diff --git a/internal/beep/beep_test.go b/internal/beep/beep_test.go new file mode 100644 index 0000000..4987c9f --- /dev/null +++ b/internal/beep/beep_test.go @@ -0,0 +1,145 @@ +package beep + +import ( + "testing" + "time" +) + +func TestNewBeeper(t *testing.T) { + beeper := NewBeeper() + + if !beeper.IsEnabled() { + t.Error("Expected beeper to be enabled by default") + } + + if beeper.debounceTime != DefaultDebounceInterval { + t.Errorf("Expected debounce time to be %v, got %v", DefaultDebounceInterval, beeper.debounceTime) + } +} + +func TestNewBeeperWithDebounce(t *testing.T) { + customInterval := 200 * time.Millisecond + beeper := NewBeeperWithDebounce(customInterval) + + if !beeper.IsEnabled() { + t.Error("Expected beeper to be enabled by default") + } + + if beeper.debounceTime != customInterval { + t.Errorf("Expected debounce time to be %v, got %v", customInterval, beeper.debounceTime) + } +} + +func TestToggle(t *testing.T) { + beeper := NewBeeper() + + initialState := beeper.IsEnabled() + beeper.Toggle() + + if beeper.IsEnabled() == initialState { + t.Error("Toggle did not change beeper state") + } + + beeper.Toggle() + if beeper.IsEnabled() != initialState { + t.Error("Toggle did not restore original state") + } +} + +func TestEnableDisable(t *testing.T) { + beeper := NewBeeper() + + beeper.Disable() + if beeper.IsEnabled() { + t.Error("Expected beeper to be disabled") + } + + beeper.Enable() + if !beeper.IsEnabled() { + t.Error("Expected beeper to be enabled") + } +} + +func TestDebounce(t *testing.T) { + debounceInterval := 100 * time.Millisecond + beeper := NewBeeperWithDebounce(debounceInterval) + + // First beep should work + beeper.Beep() + time.Sleep(10 * time.Millisecond) // Wait for goroutine to execute + + firstBeepTime := beeper.lastBeepTime + if firstBeepTime.IsZero() { + t.Error("Expected first beep to set lastBeepTime") + } + + // Immediate second beep should be debounced + beeper.Beep() + time.Sleep(10 * time.Millisecond) + + if beeper.lastBeepTime != firstBeepTime { + t.Error("Expected second beep to be debounced") + } + + // Wait for debounce period and try again + time.Sleep(debounceInterval) + beeper.Beep() + time.Sleep(10 * time.Millisecond) + + if beeper.lastBeepTime == firstBeepTime { + t.Error("Expected beep to work after debounce period") + } +} + +func TestBeepWhenDisabled(t *testing.T) { + beeper := NewBeeper() + beeper.Disable() + + // Beep should not update lastBeepTime when disabled + beeper.Beep() + time.Sleep(10 * time.Millisecond) + + if !beeper.lastBeepTime.IsZero() { + t.Error("Expected lastBeepTime to remain zero when disabled") + } +} + +func TestConcurrentAccess(t *testing.T) { + beeper := NewBeeper() + done := make(chan bool) + + // Test concurrent beep calls + for i := 0; i < 10; i++ { + go func() { + beeper.Beep() + done <- true + }() + } + + // Test concurrent state changes + for i := 0; i < 10; i++ { + go func() { + beeper.Toggle() + _ = beeper.IsEnabled() + done <- true + }() + } + + // Wait for all goroutines + for i := 0; i < 20; i++ { + <-done + } + + // If we get here without deadlock or panic, the test passes +} + +func TestSetDebounceInterval(t *testing.T) { + beeper := NewBeeper() + newInterval := 200 * time.Millisecond + + beeper.SetDebounceInterval(newInterval) + + if beeper.debounceTime != newInterval { + t.Errorf("Expected debounce time to be %v, got %v", newInterval, beeper.debounceTime) + } +} From 29dbd86b2d1dc1aaedb7964681593763315ae4c2 Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Tue, 30 Sep 2025 14:32:19 +0900 Subject: [PATCH 2/3] feat(ui): Integrate beep sound notification with stats and UI Integrate beep functionality across the application: - Stats manager plays beep on probe failures - Add ToggleBeep() and IsBeepEnabled() to MetricsSystemManager interface - UI key binding 'b' to toggle beep on/off - Header panel displays beep state (ON/OFF) - Footer panel shows 'b:beep' operation guide - Help modal includes beep toggle documentation - BeepStateProvider interface for UI state access All changes are non-blocking and maintain thread safety. --- internal/stats/interface.go | 2 + internal/stats/manager.go | 23 +++++++++++ internal/ui/tui/app.go | 55 ++++++++++++++----------- internal/ui/tui/layout.go | 6 +++ internal/ui/tui/panels/header_footer.go | 34 ++++++++++++--- 5 files changed, 91 insertions(+), 29 deletions(-) diff --git a/internal/stats/interface.go b/internal/stats/interface.go index 20f40b2..2e0a8da 100644 --- a/internal/stats/interface.go +++ b/internal/stats/interface.go @@ -35,6 +35,8 @@ type MetricsProvider interface { // MetricsSystemManager provides system-level operations type MetricsSystemManager interface { ResetAllMetrics() + ToggleBeep() + IsBeepEnabled() bool } // MetricsEventRecorder handles internal event recording diff --git a/internal/stats/manager.go b/internal/stats/manager.go index f18a1a0..2b388fd 100644 --- a/internal/stats/manager.go +++ b/internal/stats/manager.go @@ -5,6 +5,7 @@ import ( "sync" "time" + "github.com/servak/mping/internal/beep" "github.com/servak/mping/internal/prober" ) @@ -15,6 +16,7 @@ const ( type metricsManager struct { metrics map[string]*metrics historySize int // Number of history entries to keep + beeper *beep.Beeper mu sync.Mutex } @@ -28,6 +30,7 @@ func NewMetricsManagerWithHistorySize(historySize int) MetricsManager { return &metricsManager{ metrics: make(map[string]*metrics), historySize: historySize, + beeper: beep.NewBeeper(), } } @@ -109,6 +112,11 @@ func (mm *metricsManager) Failed(host string, sentTime time.Time, msg string) { }) } mm.mu.Unlock() + + // Play beep sound on failure (non-blocking) + if mm.beeper != nil { + mm.beeper.Beep() + } } func (mm *metricsManager) Sent(host string) { @@ -210,6 +218,21 @@ func (mm *metricsManager) GetMetricsAsReader(target string) Metrics { return mm.getMetrics(target) } +// ToggleBeep toggles beep sound on/off +func (mm *metricsManager) ToggleBeep() { + if mm.beeper != nil { + mm.beeper.Toggle() + } +} + +// IsBeepEnabled returns current beep sound state +func (mm *metricsManager) IsBeepEnabled() bool { + if mm.beeper != nil { + return mm.beeper.IsEnabled() + } + return false +} + // rejectLessAscending is RTT comparison function for ascending sort // Zero values (unmeasured) are always placed at the end func rejectLessAscending(i, j time.Duration) bool { diff --git a/internal/ui/tui/app.go b/internal/ui/tui/app.go index f9082a2..7e1dffe 100644 --- a/internal/ui/tui/app.go +++ b/internal/ui/tui/app.go @@ -157,6 +157,9 @@ func (a *TUIApp) setupKeyBindings() { case 't': a.cycleTheme() return nil + case 'b': + a.toggleBeep() + return nil } // Delegate navigation to layout @@ -172,29 +175,30 @@ func (a *TUIApp) setupHelpModal() { // createHelpModal creates help modal content func (a *TUIApp) createHelpModal() *tview.Modal { - helpText := `mping - Multi-target Ping Tool - -NAVIGATION: - j, ↓ Move down - k, ↑ Move up - g Go to top - G Go to bottom - u, Page Up Page up - d, Page Down Page down - s Next sort key - S Previous sort key - r Reverse sort order - R Reset all metrics - v Toggle detail view - / Filter hosts - t Cycle theme - h Show/hide this help - q, Ctrl+C Quit application - -FILTER: - / Start filter input - Enter Apply filter - Esc Cancel/Clear filter + helpText := `mping - Multi-target Ping Tool + +NAVIGATION: + j, ↓ Move down + k, ↑ Move up + g Go to top + G Go to bottom + u, Page Up Page up + d, Page Down Page down + s Next sort key + S Previous sort key + r Reverse sort order + R Reset all metrics + v Toggle detail view + / Filter hosts + t Cycle theme + b Toggle beep sound + h Show/hide this help + q, Ctrl+C Quit application + +FILTER: + / Start filter input + Enter Apply filter + Esc Cancel/Clear filter Press 'h' or Esc to close ` @@ -312,3 +316,8 @@ func (a *TUIApp) getFilteredMetrics() []stats.Metrics { func (a *TUIApp) cycleTheme() { a.config.CycleTheme() } + +// Beep-related methods +func (a *TUIApp) toggleBeep() { + a.mm.ToggleBeep() +} diff --git a/internal/ui/tui/layout.go b/internal/ui/tui/layout.go index b53b8d5..d7f3e69 100644 --- a/internal/ui/tui/layout.go +++ b/internal/ui/tui/layout.go @@ -59,6 +59,12 @@ func NewLayoutManager(uiState *state.UIState, mm stats.MetricsManager, config *s // setupPanels initializes all panels 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) + + // Set beep state provider if mm implements the interface + if beepProvider, ok := mm.(panels.BeepStateProvider); ok { + l.header.SetBeepProvider(beepProvider) + } + l.hostList = panels.NewHostListPanel(uiState, mm, config) l.footer = panels.NewFooterPanel(config) l.hostDetail = panels.NewHostDetailPanel(config) diff --git a/internal/ui/tui/panels/header_footer.go b/internal/ui/tui/panels/header_footer.go index dc06470..168de47 100644 --- a/internal/ui/tui/panels/header_footer.go +++ b/internal/ui/tui/panels/header_footer.go @@ -14,11 +14,17 @@ import ( // HeaderPanel manages header display type HeaderPanel struct { - view *tview.TextView - renderState state.RenderState - config *shared.Config - interval time.Duration - timeout time.Duration + view *tview.TextView + renderState state.RenderState + config *shared.Config + interval time.Duration + timeout time.Duration + beepProvider BeepStateProvider +} + +// BeepStateProvider provides access to beep state +type BeepStateProvider interface { + IsBeepEnabled() bool } // NewHeaderPanel creates a new HeaderPanel @@ -36,6 +42,11 @@ func NewHeaderPanel(renderState state.RenderState, config *shared.Config, interv } } +// SetBeepProvider sets the beep state provider +func (h *HeaderPanel) SetBeepProvider(provider BeepStateProvider) { + h.beepProvider = provider +} + // Update refreshes header display based on current state func (h *HeaderPanel) Update() { theme := h.config.GetTheme() @@ -62,6 +73,16 @@ func (h *HeaderPanel) generateHeaderContent() string { if filterText != "" { parts = append(parts, fmt.Sprintf("[%s]Filter: %s[-]", theme.Warning, filterText)) } + + // Add beep state + if h.beepProvider != nil { + beepState := "OFF" + if h.beepProvider.IsBeepEnabled() { + beepState = "ON" + } + parts = append(parts, fmt.Sprintf("[%s]Beep: %s[-]", theme.Accent, beepState)) + } + parts = append(parts, fmt.Sprintf("[%s]Theme: %s[-]", theme.Secondary, h.config.Theme)) sep := fmt.Sprintf("[%s] | [-]", theme.Separator) return strings.Join(parts, sep) @@ -108,8 +129,9 @@ func (f *FooterPanel) generateFooterContent() string { resetText := fmt.Sprintf("[%s]R:reset[-]", theme.Secondary) filterText := fmt.Sprintf("[%s]/:filter[-]", theme.Secondary) themeText := fmt.Sprintf("[%s]t:theme[-]", theme.Secondary) + beepText := fmt.Sprintf("[%s]b:beep[-]", theme.Secondary) moveText := fmt.Sprintf("[%s]j/k/g/G/u/d:move[-]", theme.Secondary) - return fmt.Sprintf("%s %s %s %s %s %s %s %s", helpText, quitText, sortText, reverseText, resetText, filterText, themeText, moveText) + return fmt.Sprintf("%s %s %s %s %s %s %s %s %s", helpText, quitText, sortText, reverseText, resetText, filterText, themeText, beepText, moveText) } // GetView returns the underlying tview component From c9d1802cff0b6faae48d68d531e152ccd30a0793 Mon Sep 17 00:00:00 2001 From: Kakuya Ando Date: Tue, 30 Sep 2025 14:50:37 +0900 Subject: [PATCH 3/3] fix(beep): Fix race condition and lint issues in tests - Add GetLastBeepTime() method with mutex protection for thread-safe access - Use time.Time.Equal() for time comparison as recommended by staticcheck - Fix race condition detected in TestDebounce - All tests now pass with -race flag --- internal/beep/beep.go | 7 +++++++ internal/beep/beep_test.go | 8 ++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/internal/beep/beep.go b/internal/beep/beep.go index bcf5f51..b5d1413 100644 --- a/internal/beep/beep.go +++ b/internal/beep/beep.go @@ -105,3 +105,10 @@ func (b *Beeper) SetDebounceInterval(interval time.Duration) { defer b.mu.Unlock() b.debounceTime = interval } + +// GetLastBeepTime returns the last beep time (for testing) +func (b *Beeper) GetLastBeepTime() time.Time { + b.mu.Lock() + defer b.mu.Unlock() + return b.lastBeepTime +} diff --git a/internal/beep/beep_test.go b/internal/beep/beep_test.go index 4987c9f..024e2a6 100644 --- a/internal/beep/beep_test.go +++ b/internal/beep/beep_test.go @@ -68,7 +68,7 @@ func TestDebounce(t *testing.T) { beeper.Beep() time.Sleep(10 * time.Millisecond) // Wait for goroutine to execute - firstBeepTime := beeper.lastBeepTime + firstBeepTime := beeper.GetLastBeepTime() if firstBeepTime.IsZero() { t.Error("Expected first beep to set lastBeepTime") } @@ -77,7 +77,7 @@ func TestDebounce(t *testing.T) { beeper.Beep() time.Sleep(10 * time.Millisecond) - if beeper.lastBeepTime != firstBeepTime { + if !beeper.GetLastBeepTime().Equal(firstBeepTime) { t.Error("Expected second beep to be debounced") } @@ -86,7 +86,7 @@ func TestDebounce(t *testing.T) { beeper.Beep() time.Sleep(10 * time.Millisecond) - if beeper.lastBeepTime == firstBeepTime { + if beeper.GetLastBeepTime().Equal(firstBeepTime) { t.Error("Expected beep to work after debounce period") } } @@ -99,7 +99,7 @@ func TestBeepWhenDisabled(t *testing.T) { beeper.Beep() time.Sleep(10 * time.Millisecond) - if !beeper.lastBeepTime.IsZero() { + if !beeper.GetLastBeepTime().IsZero() { t.Error("Expected lastBeepTime to remain zero when disabled") } }