feat: add async SSH ping for all hosts with status indicator

This commit is contained in:
Gu1llaum-3 2025-09-06 10:29:56 +02:00
parent 6577002e2b
commit 42387eb1fa
10 changed files with 590 additions and 189 deletions

7
go.mod
View File

@ -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
)

8
go.sum
View File

@ -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=

View File

@ -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
}

View File

@ -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"),

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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"
}