From 42387eb1faa9c0311458695f5241e2546b027572 Mon Sep 17 00:00:00 2001 From: Gu1llaum-3 Date: Sat, 6 Sep 2025 10:29:56 +0200 Subject: [PATCH] feat: add async SSH ping for all hosts with status indicator --- go.mod | 7 +- go.sum | 8 + internal/connectivity/ping.go | 212 +++++++++++++++++ internal/ui/help_form.go | 4 + internal/ui/model.go | 2 + internal/ui/table.go | 423 ++++++++++++++++++++-------------- internal/ui/tui.go | 23 +- internal/ui/update.go | 64 ++++- internal/ui/utils.go | 34 +++ internal/ui/view.go | 2 +- 10 files changed, 590 insertions(+), 189 deletions(-) create mode 100644 internal/connectivity/ping.go diff --git a/go.mod b/go.mod index 5415504..963dac6 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,8 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.3.8 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect ) diff --git a/go.sum b/go.sum index 752eedd..053c482 100644 --- a/go.sum +++ b/go.sum @@ -49,15 +49,23 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/connectivity/ping.go b/internal/connectivity/ping.go new file mode 100644 index 0000000..7000463 --- /dev/null +++ b/internal/connectivity/ping.go @@ -0,0 +1,212 @@ +package connectivity + +import ( + "context" + "net" + "sshm/internal/config" + "strings" + "sync" + "time" + + "golang.org/x/crypto/ssh" +) + +// PingStatus represents the connectivity status of an SSH host +type PingStatus int + +const ( + StatusUnknown PingStatus = iota + StatusConnecting + StatusOnline + StatusOffline +) + +func (s PingStatus) String() string { + switch s { + case StatusUnknown: + return "unknown" + case StatusConnecting: + return "connecting" + case StatusOnline: + return "online" + case StatusOffline: + return "offline" + } + return "unknown" +} + +// HostPingResult represents the result of pinging a host +type HostPingResult struct { + HostName string + Status PingStatus + Error error + Duration time.Duration +} + +// PingManager manages SSH connectivity checks for multiple hosts +type PingManager struct { + results map[string]*HostPingResult + mutex sync.RWMutex + timeout time.Duration +} + +// NewPingManager creates a new ping manager with the specified timeout +func NewPingManager(timeout time.Duration) *PingManager { + return &PingManager{ + results: make(map[string]*HostPingResult), + timeout: timeout, + } +} + +// GetStatus returns the current status for a host +func (pm *PingManager) GetStatus(hostName string) PingStatus { + pm.mutex.RLock() + defer pm.mutex.RUnlock() + + if result, exists := pm.results[hostName]; exists { + return result.Status + } + return StatusUnknown +} + +// GetResult returns the complete result for a host +func (pm *PingManager) GetResult(hostName string) (*HostPingResult, bool) { + pm.mutex.RLock() + defer pm.mutex.RUnlock() + + result, exists := pm.results[hostName] + return result, exists +} + +// updateStatus updates the status for a host +func (pm *PingManager) updateStatus(hostName string, status PingStatus, err error, duration time.Duration) { + pm.mutex.Lock() + defer pm.mutex.Unlock() + + pm.results[hostName] = &HostPingResult{ + HostName: hostName, + Status: status, + Error: err, + Duration: duration, + } +} + +// PingHost performs an SSH connectivity check for a single host +func (pm *PingManager) PingHost(ctx context.Context, host config.SSHHost) *HostPingResult { + start := time.Now() + + // Mark as connecting + pm.updateStatus(host.Name, StatusConnecting, nil, 0) + + // Determine the actual hostname and port + hostname := host.Hostname + if hostname == "" { + hostname = host.Name + } + + port := host.Port + if port == "" { + port = "22" + } + + // Create context with timeout + pingCtx, cancel := context.WithTimeout(ctx, pm.timeout) + defer cancel() + + // Try to establish a TCP connection first (faster than SSH handshake) + dialer := &net.Dialer{} + conn, err := dialer.DialContext(pingCtx, "tcp", net.JoinHostPort(hostname, port)) + if err != nil { + duration := time.Since(start) + pm.updateStatus(host.Name, StatusOffline, err, duration) + return &HostPingResult{ + HostName: host.Name, + Status: StatusOffline, + Error: err, + Duration: duration, + } + } + defer conn.Close() + + // If TCP connection succeeds, try SSH handshake + sshConfig := &ssh.ClientConfig{ + User: host.User, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), // For ping purposes only + Timeout: time.Second * 2, // Short timeout for handshake + } + + // We don't need to authenticate, just check if SSH is responding + sshConn, _, _, err := ssh.NewClientConn(conn, net.JoinHostPort(hostname, port), sshConfig) + if sshConn != nil { + sshConn.Close() + } + + duration := time.Since(start) + + // Even if SSH handshake fails, if we got a TCP connection, consider it online + // This handles cases where authentication fails but the host is reachable + status := StatusOnline + if err != nil && isConnectionError(err) { + status = StatusOffline + } + + pm.updateStatus(host.Name, status, err, duration) + return &HostPingResult{ + HostName: host.Name, + Status: status, + Error: err, + Duration: duration, + } +} + +// PingAllHosts pings all hosts concurrently and returns a channel of results +func (pm *PingManager) PingAllHosts(ctx context.Context, hosts []config.SSHHost) <-chan *HostPingResult { + resultChan := make(chan *HostPingResult, len(hosts)) + + var wg sync.WaitGroup + + for _, host := range hosts { + wg.Add(1) + go func(h config.SSHHost) { + defer wg.Done() + result := pm.PingHost(ctx, h) + select { + case resultChan <- result: + case <-ctx.Done(): + return + } + }(host) + } + + // Close the channel when all goroutines are done + go func() { + wg.Wait() + close(resultChan) + }() + + return resultChan +} + +// isConnectionError determines if an error is a connection-related error +func isConnectionError(err error) bool { + if err == nil { + return false + } + + errStr := err.Error() + connectionErrors := []string{ + "connection refused", + "no route to host", + "network is unreachable", + "timeout", + "connection timed out", + } + + for _, connErr := range connectionErrors { + if strings.Contains(strings.ToLower(errStr), connErr) { + return true + } + } + + return false +} diff --git a/internal/ui/help_form.go b/internal/ui/help_form.go index 699e183..6dff415 100644 --- a/internal/ui/help_form.go +++ b/internal/ui/help_form.go @@ -62,6 +62,10 @@ func (m *helpModel) View() string { " ", m.styles.HelpText.Render("switch focus"), " ", + m.styles.FocusedLabel.Render("p"), + " ", + m.styles.HelpText.Render("ping all"), + " ", m.styles.FocusedLabel.Render("f"), " ", m.styles.HelpText.Render("port forward"), diff --git a/internal/ui/model.go b/internal/ui/model.go index a223777..c1cfc16 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -2,6 +2,7 @@ package ui import ( "sshm/internal/config" + "sshm/internal/connectivity" "sshm/internal/history" "github.com/charmbracelet/bubbles/table" @@ -73,6 +74,7 @@ type Model struct { deleteMode bool deleteHost string historyManager *history.HistoryManager + pingManager *connectivity.PingManager sortMode SortMode configFile string // Path to the SSH config file diff --git a/internal/ui/table.go b/internal/ui/table.go index b43df8c..509d83b 100644 --- a/internal/ui/table.go +++ b/internal/ui/table.go @@ -9,6 +9,260 @@ import ( "github.com/charmbracelet/bubbles/table" ) +// calculateDynamicColumnWidths calculates optimal column widths based on terminal width +// and content length, ensuring all content fits when possible +func (m *Model) calculateDynamicColumnWidths(hosts []config.SSHHost) (int, int, int, int) { + if m.width <= 0 { + // Fallback to static widths if terminal width is not available + return calculateNameColumnWidth(hosts), 25, calculateTagsColumnWidth(hosts), calculateLastLoginColumnWidth(hosts, m.historyManager) + } + + // Calculate content lengths + maxNameLength := 8 // Minimum for "Name" header + status indicator + maxHostnameLength := 8 // Minimum for "Hostname" header + maxTagsLength := 8 // Minimum for "Tags" header + maxLastLoginLength := 12 // Minimum for "Last Login" header + + for _, host := range hosts { + // Name column includes status indicator (2 chars) + space (1 char) + name + nameLength := 3 + len(host.Name) + if nameLength > maxNameLength { + maxNameLength = nameLength + } + + if len(host.Hostname) > maxHostnameLength { + maxHostnameLength = len(host.Hostname) + } + + // Calculate tags string length + var tagsStr string + if len(host.Tags) > 0 { + var formattedTags []string + for _, tag := range host.Tags { + formattedTags = append(formattedTags, "#"+tag) + } + tagsStr = strings.Join(formattedTags, " ") + } + if len(tagsStr) > maxTagsLength { + maxTagsLength = len(tagsStr) + } + + // Calculate last login length + if m.historyManager != nil { + if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists { + timeStr := formatTimeAgo(lastConnect) + if len(timeStr) > maxLastLoginLength { + maxLastLoginLength = len(timeStr) + } + } + } + } + + // Add padding to each column + maxNameLength += 2 + maxHostnameLength += 2 + maxTagsLength += 2 + maxLastLoginLength += 2 + + // Calculate available width (minus borders and separators) + // Table has borders (2 chars) + column separators (3 chars between 4 columns) + availableWidth := m.width - 5 + + totalNeededWidth := maxNameLength + maxHostnameLength + maxTagsLength + maxLastLoginLength + + if totalNeededWidth <= availableWidth { + // Everything fits perfectly + return maxNameLength, maxHostnameLength, maxTagsLength, maxLastLoginLength + } + + // Need to adjust widths - prioritize columns by importance + // Priority: Name > Hostname > Last Login > Tags + + // Calculate minimum widths + minNameWidth := 15 // Enough for status + short name + minHostnameWidth := 15 + minLastLoginWidth := 12 + minTagsWidth := 10 + + remainingWidth := availableWidth + + // Allocate minimum widths first + nameWidth := minNameWidth + hostnameWidth := minHostnameWidth + lastLoginWidth := minLastLoginWidth + tagsWidth := minTagsWidth + + remainingWidth -= (nameWidth + hostnameWidth + lastLoginWidth + tagsWidth) + + // Distribute remaining space proportionally + if remainingWidth > 0 { + // Calculate how much each column wants beyond minimum + nameWant := maxNameLength - minNameWidth + hostnameWant := maxHostnameLength - minHostnameWidth + lastLoginWant := maxLastLoginLength - minLastLoginWidth + tagsWant := maxTagsLength - minTagsWidth + + totalWant := nameWant + hostnameWant + lastLoginWant + tagsWant + + if totalWant > 0 { + // Distribute proportionally + nameExtra := (nameWant * remainingWidth) / totalWant + hostnameExtra := (hostnameWant * remainingWidth) / totalWant + lastLoginExtra := (lastLoginWant * remainingWidth) / totalWant + tagsExtra := remainingWidth - nameExtra - hostnameExtra - lastLoginExtra + + nameWidth += nameExtra + hostnameWidth += hostnameExtra + lastLoginWidth += lastLoginExtra + tagsWidth += tagsExtra + } + } + + return nameWidth, hostnameWidth, tagsWidth, lastLoginWidth +} + +// updateTableRows updates the table with filtered hosts +func (m *Model) updateTableRows() { + var rows []table.Row + hostsToShow := m.filteredHosts + if hostsToShow == nil { + hostsToShow = m.hosts + } + + for _, host := range hostsToShow { + // Get ping status indicator + statusIndicator := m.getPingStatusIndicator(host.Name) + + // Format tags for display + var tagsStr string + if len(host.Tags) > 0 { + // Add the # prefix to each tag and join them with spaces + var formattedTags []string + for _, tag := range host.Tags { + formattedTags = append(formattedTags, "#"+tag) + } + tagsStr = strings.Join(formattedTags, " ") + } + + // Format last login information + var lastLoginStr string + if m.historyManager != nil { + if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists { + lastLoginStr = formatTimeAgo(lastConnect) + } + } + + rows = append(rows, table.Row{ + statusIndicator + " " + host.Name, + host.Hostname, + // host.User, // Commented to save space + // host.Port, // Commented to save space + tagsStr, + lastLoginStr, + }) + } + + m.table.SetRows(rows) + + // Update table height and columns based on current terminal size + m.updateTableHeight() + m.updateTableColumns() +} + +// updateTableHeight dynamically adjusts table height based on terminal size +func (m *Model) updateTableHeight() { + if !m.ready { + return + } + + // Calculate dynamic table height based on terminal size + // Layout breakdown: + // - ASCII title: 5 lines (1 empty + 4 text lines) + // - Search bar: 1 line + // - Help text: 1 line + // - App margins/spacing: 3 lines + // - Safety margin: 3 lines (to ensure UI elements are always visible) + // Total reserved: 13 lines minimum to preserve essential UI elements + reservedHeight := 13 + availableHeight := m.height - reservedHeight + hostCount := len(m.table.Rows()) + + // Minimum height should be at least 3 rows for basic usability + // Even in very small terminals, we want to show at least header + 2 hosts + minTableHeight := 4 // 1 header + 3 data rows minimum + maxTableHeight := availableHeight + if maxTableHeight < minTableHeight { + maxTableHeight = minTableHeight + } + + tableHeight := 1 // header + dataRowsNeeded := hostCount + maxDataRows := maxTableHeight - 1 // subtract 1 for header + + if dataRowsNeeded <= maxDataRows { + // We have enough space for all hosts + tableHeight += dataRowsNeeded + } else { + // We need to limit to available space + tableHeight += maxDataRows + } + + // Add one extra line to prevent the last host from being hidden + // This compensates for table rendering quirks in bubble tea + tableHeight += 1 + + // Update table height + m.table.SetHeight(tableHeight) +} + +// updateTableColumns dynamically adjusts table column widths based on terminal size +func (m *Model) updateTableColumns() { + if !m.ready { + return + } + + hostsToShow := m.filteredHosts + if hostsToShow == nil { + hostsToShow = m.hosts + } + + // Use dynamic column width calculation + nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(hostsToShow) + + // Create new columns with updated widths and sort indicators + nameTitle := "Name" + lastLoginTitle := "Last Login" + + // Add sort indicators based on current sort mode + switch m.sortMode { + case SortByName: + nameTitle += " ↓" + case SortByLastUsed: + lastLoginTitle += " ↓" + } + + columns := []table.Column{ + {Title: nameTitle, Width: nameWidth}, + {Title: "Hostname", Width: hostnameWidth}, + // {Title: "User", Width: userWidth}, // Commented to save space + // {Title: "Port", Width: portWidth}, // Commented to save space + {Title: "Tags", Width: tagsWidth}, + {Title: lastLoginTitle, Width: lastLoginWidth}, + } + + m.table.SetColumns(columns) +} + +// max returns the maximum of two integers +func max(a, b int) int { + if a > b { + return a + } + return b +} + +// Legacy functions for compatibility + // calculateNameColumnWidth calculates the optimal width for the Name column // based on the longest hostname, with a minimum of 8 and maximum of 40 characters func calculateNameColumnWidth(hosts []config.SSHHost) int { @@ -90,172 +344,3 @@ func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *histo return maxLength } - -// updateTableRows updates the table with filtered hosts -func (m *Model) updateTableRows() { - var rows []table.Row - hostsToShow := m.filteredHosts - if hostsToShow == nil { - hostsToShow = m.hosts - } - - for _, host := range hostsToShow { - // Format tags for display - var tagsStr string - if len(host.Tags) > 0 { - // Add the # prefix to each tag and join them with spaces - var formattedTags []string - for _, tag := range host.Tags { - formattedTags = append(formattedTags, "#"+tag) - } - tagsStr = strings.Join(formattedTags, " ") - } - - // Format last login information - var lastLoginStr string - if m.historyManager != nil { - if lastConnect, exists := m.historyManager.GetLastConnectionTime(host.Name); exists { - lastLoginStr = formatTimeAgo(lastConnect) - } - } - - rows = append(rows, table.Row{ - host.Name, - host.Hostname, - // host.User, // Commented to save space - // host.Port, // Commented to save space - tagsStr, - lastLoginStr, - }) - } - - m.table.SetRows(rows) - - // Update table height and columns based on current terminal size - m.updateTableHeight() - m.updateTableColumns() -} - -// updateTableHeight dynamically adjusts table height based on terminal size -func (m *Model) updateTableHeight() { - if !m.ready { - return - } - - // Calculate dynamic table height based on terminal size - // Layout breakdown: - // - ASCII title: 5 lines (1 empty + 4 text lines) - // - Search bar: 1 line - // - Help text: 1 line - // - App margins/spacing: 3 lines - // - Safety margin: 3 lines (to ensure UI elements are always visible) - // Total reserved: 13 lines minimum to preserve essential UI elements - reservedHeight := 13 - availableHeight := m.height - reservedHeight - hostCount := len(m.table.Rows()) - - // Minimum height should be at least 3 rows for basic usability - // Even in very small terminals, we want to show at least header + 2 hosts - minTableHeight := 4 // 1 header + 3 data rows minimum - maxTableHeight := availableHeight - if maxTableHeight < minTableHeight { - maxTableHeight = minTableHeight - } - - tableHeight := 1 // header - dataRowsNeeded := hostCount - maxDataRows := maxTableHeight - 1 // subtract 1 for header - - if dataRowsNeeded <= maxDataRows { - // We have enough space for all hosts - tableHeight += dataRowsNeeded - } else { - // We need to limit to available space - tableHeight += maxDataRows - } - - // Add one extra line to prevent the last host from being hidden - // This compensates for table rendering quirks in bubble tea - tableHeight += 1 - - // Update table height - m.table.SetHeight(tableHeight) -} - -// updateTableColumns dynamically adjusts table column widths based on terminal size -func (m *Model) updateTableColumns() { - if !m.ready { - return - } - - hostsToShow := m.filteredHosts - if hostsToShow == nil { - hostsToShow = m.hosts - } - - // Calculate base column widths - nameWidth := calculateNameColumnWidth(hostsToShow) - tagsWidth := calculateTagsColumnWidth(hostsToShow) - lastLoginWidth := calculateLastLoginColumnWidth(hostsToShow, m.historyManager) - - // Fixed column widths - hostnameWidth := 25 - // userWidth := 12 // Commented to save space - // portWidth := 6 // Commented to save space - - // Calculate total width needed for all columns - totalFixedWidth := hostnameWidth // + userWidth + portWidth // Commented columns - totalVariableWidth := nameWidth + tagsWidth + lastLoginWidth - totalWidth := totalFixedWidth + totalVariableWidth - - // Available width (accounting for table borders and padding) - availableWidth := m.width - 4 // 4 chars for borders and padding - - // If the table is too wide, scale down the variable columns proportionally - if totalWidth > availableWidth { - excessWidth := totalWidth - availableWidth - variableColumnsWidth := totalVariableWidth - - if variableColumnsWidth > 0 { - // Reduce variable columns proportionally - nameReduction := (excessWidth * nameWidth) / variableColumnsWidth - tagsReduction := (excessWidth * tagsWidth) / variableColumnsWidth - lastLoginReduction := excessWidth - nameReduction - tagsReduction - - nameWidth = max(8, nameWidth-nameReduction) - tagsWidth = max(8, tagsWidth-tagsReduction) - lastLoginWidth = max(10, lastLoginWidth-lastLoginReduction) - } - } - - // Create new columns with updated widths and sort indicators - nameTitle := "Name" - lastLoginTitle := "Last Login" - - // Add sort indicators based on current sort mode - switch m.sortMode { - case SortByName: - nameTitle += " ↓" - case SortByLastUsed: - lastLoginTitle += " ↓" - } - - columns := []table.Column{ - {Title: nameTitle, Width: nameWidth}, - {Title: "Hostname", Width: hostnameWidth}, - // {Title: "User", Width: userWidth}, // Commented to save space - // {Title: "Port", Width: portWidth}, // Commented to save space - {Title: "Tags", Width: tagsWidth}, - {Title: lastLoginTitle, Width: lastLoginWidth}, - } - - m.table.SetColumns(columns) -} - -// max returns the maximum of two integers -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/internal/ui/tui.go b/internal/ui/tui.go index fe64a0e..02af8b2 100644 --- a/internal/ui/tui.go +++ b/internal/ui/tui.go @@ -3,8 +3,10 @@ package ui import ( "fmt" "strings" + "time" "sshm/internal/config" + "sshm/internal/connectivity" "sshm/internal/history" "github.com/charmbracelet/bubbles/table" @@ -26,10 +28,14 @@ func NewModel(hosts []config.SSHHost, configFile string) Model { // Create initial styles (will be updated on first WindowSizeMsg) styles := NewStyles(80) // Default width + // Initialize ping manager with 5 second timeout + pingManager := connectivity.NewPingManager(5 * time.Second) + // Create the model with default sorting by name m := Model{ hosts: hosts, historyManager: historyManager, + pingManager: pingManager, sortMode: SortByName, configFile: configFile, styles: styles, @@ -48,19 +54,13 @@ func NewModel(hosts []config.SSHHost, configFile string) Model { ti.CharLimit = 50 ti.Width = 50 - // Calculate optimal width for the Name column - nameWidth := calculateNameColumnWidth(sortedHosts) - - // Calculate optimal width for the Tags column - tagsWidth := calculateTagsColumnWidth(sortedHosts) - - // Calculate optimal width for the Last Login column - lastLoginWidth := calculateLastLoginColumnWidth(sortedHosts, historyManager) + // Use dynamic column width calculation (will fallback to static if width not available) + nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts) // Create table columns columns := []table.Column{ {Title: "Name", Width: nameWidth}, - {Title: "Hostname", Width: 25}, + {Title: "Hostname", Width: hostnameWidth}, // {Title: "User", Width: 12}, // Commented to save space // {Title: "Port", Width: 6}, // Commented to save space {Title: "Tags", Width: tagsWidth}, @@ -70,6 +70,9 @@ func NewModel(hosts []config.SSHHost, configFile string) Model { // Convert hosts to table rows var rows []table.Row for _, host := range sortedHosts { + // Get ping status indicator + statusIndicator := m.getPingStatusIndicator(host.Name) + // Format tags for display var tagsStr string if len(host.Tags) > 0 { @@ -90,7 +93,7 @@ func NewModel(hosts []config.SSHHost, configFile string) Model { } rows = append(rows, table.Row{ - host.Name, + statusIndicator + " " + host.Name, host.Hostname, // host.User, // Commented to save space // host.Port, // Commented to save space diff --git a/internal/ui/update.go b/internal/ui/update.go index b751927..bac117b 100644 --- a/internal/ui/update.go +++ b/internal/ui/update.go @@ -1,20 +1,59 @@ package ui import ( + "context" "fmt" "os/exec" + "time" "sshm/internal/config" + "sshm/internal/connectivity" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" ) +// Messages for SSH ping functionality +type ( + pingResultMsg *connectivity.HostPingResult +) + +// startPingAllCmd creates a command to ping all hosts concurrently +func (m Model) startPingAllCmd() tea.Cmd { + if m.pingManager == nil { + return nil + } + + return tea.Batch( + // Create individual ping commands for each host + func() tea.Cmd { + var cmds []tea.Cmd + for _, host := range m.hosts { + cmds = append(cmds, pingSingleHostCmd(m.pingManager, host)) + } + return tea.Batch(cmds...) + }(), + ) +} + +// listenForPingResultsCmd is no longer needed since we use individual ping commands + +// pingSingleHostCmd creates a command to ping a single host +func pingSingleHostCmd(pingManager *connectivity.PingManager, host config.SSHHost) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + result := pingManager.PingHost(ctx, host) + return pingResultMsg(result) + } +} + // Init initializes the model func (m Model) Init() tea.Cmd { return tea.Batch( textinput.Blink, - // Ajoute ici d'autres tea.Cmd si tu veux charger des données, démarrer un spinner, etc. + // Ping is now optional - use 'p' key to start ping ) } @@ -68,6 +107,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case pingResultMsg: + // Handle ping result - update table display + if msg != nil { + // Update the table to reflect the new ping status + m.updateTableRows() + } + return m, nil + case addFormSubmitMsg: if msg.err != nil { // Show error in form @@ -396,7 +443,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Connect to the selected host selected := m.table.SelectedRow() if len(selected) > 0 { - hostName := selected[0] // The hostname is in the first column + hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column // Record the connection in history if m.historyManager != nil { @@ -425,7 +472,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Edit the selected host selected := m.table.SelectedRow() if len(selected) > 0 { - hostName := selected[0] // The hostname is in the first column + hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile) if err != nil { // Handle error - could show in UI @@ -441,7 +488,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Show info for the selected host selected := m.table.SelectedRow() if len(selected) > 0 { - hostName := selected[0] // The hostname is in the first column + hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile) if err != nil { // Handle error - could show in UI @@ -495,19 +542,24 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Delete the selected host selected := m.table.SelectedRow() if len(selected) > 0 { - hostName := selected[0] // The hostname is in the first column + hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column m.deleteMode = true m.deleteHost = hostName m.table.Blur() return m, nil } } + case "p": + if !m.searchMode && !m.deleteMode { + // Ping all hosts + return m, m.startPingAllCmd() + } case "f": if !m.searchMode && !m.deleteMode { // Port forwarding for the selected host selected := m.table.SelectedRow() if len(selected) > 0 { - hostName := selected[0] // The hostname is in the first column + hostName := extractHostNameFromTableRow(selected[0]) // Extract hostname from first column m.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile) m.viewMode = ViewPortForward return m, textinput.Blink diff --git a/internal/ui/utils.go b/internal/ui/utils.go index 3c5a169..97bda52 100644 --- a/internal/ui/utils.go +++ b/internal/ui/utils.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "sshm/internal/connectivity" "strings" "time" ) @@ -69,3 +70,36 @@ func formatConfigFile(filePath string) string { } return filePath } + +// getPingStatusIndicator returns a colored circle indicator based on ping status +func (m *Model) getPingStatusIndicator(hostName string) string { + if m.pingManager == nil { + return "⚫" // Gray circle for unknown + } + + status := m.pingManager.GetStatus(hostName) + switch status { + case connectivity.StatusOnline: + return "🟢" // Green circle for online + case connectivity.StatusOffline: + return "🔴" // Red circle for offline + case connectivity.StatusConnecting: + return "🟡" // Yellow circle for connecting + default: + return "⚫" // Gray circle for unknown + } +} + +// extractHostNameFromTableRow extracts the host name from the first column, +// removing the ping status indicator +func extractHostNameFromTableRow(firstColumn string) string { + // The first column format is: "🟢 hostname" or "⚫ hostname" etc. + // We need to remove the emoji and space to get just the hostname + parts := strings.Fields(firstColumn) + if len(parts) >= 2 { + // Return everything after the first part (the emoji) + return strings.Join(parts[1:], " ") + } + // Fallback: if there's no space, return the whole string + return firstColumn +} diff --git a/internal/ui/view.go b/internal/ui/view.go index c3e4167..274403a 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -74,7 +74,7 @@ func (m Model) renderListView() string { // Add the help text var helpText string if !m.searchMode { - helpText = " ↑/↓: navigate • Enter: connect • i: info • h: help • q: quit" + helpText = " ↑/↓: navigate • Enter: connect • p: ping all • i: info • h: help • q: quit" } else { helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit" }