mirror of
https://github.com/Gu1llaum-3/sshm.git
synced 2025-09-07 21:30:39 +02:00
feat: add async SSH ping for all hosts with status indicator
This commit is contained in:
parent
6577002e2b
commit
42387eb1fa
7
go.mod
7
go.mod
@ -28,7 +28,8 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/crypto v0.41.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sync v0.16.0 // indirect
|
||||||
golang.org/x/text v0.3.8 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
|
golang.org/x/text v0.28.0 // indirect
|
||||||
)
|
)
|
||||||
|
8
go.sum
8
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/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 h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
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 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
|
||||||
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
|
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 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
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.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 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
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/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=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
212
internal/connectivity/ping.go
Normal file
212
internal/connectivity/ping.go
Normal 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
|
||||||
|
}
|
@ -62,6 +62,10 @@ func (m *helpModel) View() string {
|
|||||||
" ",
|
" ",
|
||||||
m.styles.HelpText.Render("switch focus"),
|
m.styles.HelpText.Render("switch focus"),
|
||||||
" ",
|
" ",
|
||||||
|
m.styles.FocusedLabel.Render("p"),
|
||||||
|
" ",
|
||||||
|
m.styles.HelpText.Render("ping all"),
|
||||||
|
" ",
|
||||||
m.styles.FocusedLabel.Render("f"),
|
m.styles.FocusedLabel.Render("f"),
|
||||||
" ",
|
" ",
|
||||||
m.styles.HelpText.Render("port forward"),
|
m.styles.HelpText.Render("port forward"),
|
||||||
|
@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"sshm/internal/config"
|
"sshm/internal/config"
|
||||||
|
"sshm/internal/connectivity"
|
||||||
"sshm/internal/history"
|
"sshm/internal/history"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"github.com/charmbracelet/bubbles/table"
|
||||||
@ -73,6 +74,7 @@ type Model struct {
|
|||||||
deleteMode bool
|
deleteMode bool
|
||||||
deleteHost string
|
deleteHost string
|
||||||
historyManager *history.HistoryManager
|
historyManager *history.HistoryManager
|
||||||
|
pingManager *connectivity.PingManager
|
||||||
sortMode SortMode
|
sortMode SortMode
|
||||||
configFile string // Path to the SSH config file
|
configFile string // Path to the SSH config file
|
||||||
|
|
||||||
|
@ -9,6 +9,260 @@ import (
|
|||||||
"github.com/charmbracelet/bubbles/table"
|
"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
|
// calculateNameColumnWidth calculates the optimal width for the Name column
|
||||||
// based on the longest hostname, with a minimum of 8 and maximum of 40 characters
|
// based on the longest hostname, with a minimum of 8 and maximum of 40 characters
|
||||||
func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
func calculateNameColumnWidth(hosts []config.SSHHost) int {
|
||||||
@ -90,172 +344,3 @@ func calculateLastLoginColumnWidth(hosts []config.SSHHost, historyManager *histo
|
|||||||
|
|
||||||
return maxLength
|
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
|
|
||||||
}
|
|
||||||
|
@ -3,8 +3,10 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sshm/internal/config"
|
"sshm/internal/config"
|
||||||
|
"sshm/internal/connectivity"
|
||||||
"sshm/internal/history"
|
"sshm/internal/history"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/table"
|
"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)
|
// Create initial styles (will be updated on first WindowSizeMsg)
|
||||||
styles := NewStyles(80) // Default width
|
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
|
// Create the model with default sorting by name
|
||||||
m := Model{
|
m := Model{
|
||||||
hosts: hosts,
|
hosts: hosts,
|
||||||
historyManager: historyManager,
|
historyManager: historyManager,
|
||||||
|
pingManager: pingManager,
|
||||||
sortMode: SortByName,
|
sortMode: SortByName,
|
||||||
configFile: configFile,
|
configFile: configFile,
|
||||||
styles: styles,
|
styles: styles,
|
||||||
@ -48,19 +54,13 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
ti.CharLimit = 50
|
ti.CharLimit = 50
|
||||||
ti.Width = 50
|
ti.Width = 50
|
||||||
|
|
||||||
// Calculate optimal width for the Name column
|
// Use dynamic column width calculation (will fallback to static if width not available)
|
||||||
nameWidth := calculateNameColumnWidth(sortedHosts)
|
nameWidth, hostnameWidth, tagsWidth, lastLoginWidth := m.calculateDynamicColumnWidths(sortedHosts)
|
||||||
|
|
||||||
// Calculate optimal width for the Tags column
|
|
||||||
tagsWidth := calculateTagsColumnWidth(sortedHosts)
|
|
||||||
|
|
||||||
// Calculate optimal width for the Last Login column
|
|
||||||
lastLoginWidth := calculateLastLoginColumnWidth(sortedHosts, historyManager)
|
|
||||||
|
|
||||||
// Create table columns
|
// Create table columns
|
||||||
columns := []table.Column{
|
columns := []table.Column{
|
||||||
{Title: "Name", Width: nameWidth},
|
{Title: "Name", Width: nameWidth},
|
||||||
{Title: "Hostname", Width: 25},
|
{Title: "Hostname", Width: hostnameWidth},
|
||||||
// {Title: "User", Width: 12}, // Commented to save space
|
// {Title: "User", Width: 12}, // Commented to save space
|
||||||
// {Title: "Port", Width: 6}, // Commented to save space
|
// {Title: "Port", Width: 6}, // Commented to save space
|
||||||
{Title: "Tags", Width: tagsWidth},
|
{Title: "Tags", Width: tagsWidth},
|
||||||
@ -70,6 +70,9 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
// Convert hosts to table rows
|
// Convert hosts to table rows
|
||||||
var rows []table.Row
|
var rows []table.Row
|
||||||
for _, host := range sortedHosts {
|
for _, host := range sortedHosts {
|
||||||
|
// Get ping status indicator
|
||||||
|
statusIndicator := m.getPingStatusIndicator(host.Name)
|
||||||
|
|
||||||
// Format tags for display
|
// Format tags for display
|
||||||
var tagsStr string
|
var tagsStr string
|
||||||
if len(host.Tags) > 0 {
|
if len(host.Tags) > 0 {
|
||||||
@ -90,7 +93,7 @@ func NewModel(hosts []config.SSHHost, configFile string) Model {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rows = append(rows, table.Row{
|
rows = append(rows, table.Row{
|
||||||
host.Name,
|
statusIndicator + " " + host.Name,
|
||||||
host.Hostname,
|
host.Hostname,
|
||||||
// host.User, // Commented to save space
|
// host.User, // Commented to save space
|
||||||
// host.Port, // Commented to save space
|
// host.Port, // Commented to save space
|
||||||
|
@ -1,20 +1,59 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sshm/internal/config"
|
"sshm/internal/config"
|
||||||
|
"sshm/internal/connectivity"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
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
|
// Init initializes the model
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
return tea.Batch(
|
return tea.Batch(
|
||||||
textinput.Blink,
|
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
|
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:
|
case addFormSubmitMsg:
|
||||||
if msg.err != nil {
|
if msg.err != nil {
|
||||||
// Show error in form
|
// Show error in form
|
||||||
@ -396,7 +443,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// Connect to the selected host
|
// Connect to the selected host
|
||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if len(selected) > 0 {
|
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
|
// Record the connection in history
|
||||||
if m.historyManager != nil {
|
if m.historyManager != nil {
|
||||||
@ -425,7 +472,7 @@ func (m Model) handleListViewKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
// Edit the selected host
|
// Edit the selected host
|
||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if len(selected) > 0 {
|
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)
|
editForm, err := NewEditForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Handle error - could show in UI
|
// 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
|
// Show info for the selected host
|
||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if len(selected) > 0 {
|
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)
|
infoForm, err := NewInfoForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Handle error - could show in UI
|
// 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
|
// Delete the selected host
|
||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if len(selected) > 0 {
|
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.deleteMode = true
|
||||||
m.deleteHost = hostName
|
m.deleteHost = hostName
|
||||||
m.table.Blur()
|
m.table.Blur()
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
case "p":
|
||||||
|
if !m.searchMode && !m.deleteMode {
|
||||||
|
// Ping all hosts
|
||||||
|
return m, m.startPingAllCmd()
|
||||||
|
}
|
||||||
case "f":
|
case "f":
|
||||||
if !m.searchMode && !m.deleteMode {
|
if !m.searchMode && !m.deleteMode {
|
||||||
// Port forwarding for the selected host
|
// Port forwarding for the selected host
|
||||||
selected := m.table.SelectedRow()
|
selected := m.table.SelectedRow()
|
||||||
if len(selected) > 0 {
|
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.portForwardForm = NewPortForwardForm(hostName, m.styles, m.width, m.height, m.configFile)
|
||||||
m.viewMode = ViewPortForward
|
m.viewMode = ViewPortForward
|
||||||
return m, textinput.Blink
|
return m, textinput.Blink
|
||||||
|
@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sshm/internal/connectivity"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -69,3 +70,36 @@ func formatConfigFile(filePath string) string {
|
|||||||
}
|
}
|
||||||
return filePath
|
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
|
||||||
|
}
|
||||||
|
@ -74,7 +74,7 @@ func (m Model) renderListView() string {
|
|||||||
// Add the help text
|
// Add the help text
|
||||||
var helpText string
|
var helpText string
|
||||||
if !m.searchMode {
|
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 {
|
} else {
|
||||||
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
|
helpText = " Type to filter • Enter: validate • Tab: switch • ESC: quit"
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user