Skip to content

Commit 0bd05ec

Browse files
committed
feat: implement Ping All feature with right-aligned status indicators
Add comprehensive ping functionality: - Add 'G' shortcut to ping all servers simultaneously - Add right-aligned status indicators on server list - Show ping latency with adaptive formatting (<100ms as "##ms", >=100ms as "#.#s") - Responsive design adapts to window width changes - Color-coded status: green (up), red (down), orange (checking) - Update single server ping (g) to also show status
1 parent 87c7f40 commit 0bd05ec

File tree

8 files changed

+247
-15
lines changed

8 files changed

+247
-15
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ make run
169169
| Enter | SSH into selected server |
170170
| c | Copy SSH command to clipboard |
171171
| g | Ping selected server |
172+
| G | Ping all servers |
172173
| r | Refresh background data |
173174
| a | Add server |
174175
| e | Edit server |

internal/adapters/ui/handlers.go

Lines changed: 114 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ func (t *tui) handleGlobalKeys(event *tcell.EventKey) *tcell.EventKey {
6666
case 'g':
6767
t.handlePingSelected()
6868
return nil
69+
case 'G':
70+
t.handlePingAll()
71+
return nil
6972
case 'r':
7073
t.handleRefreshBackground()
7174
return nil
@@ -234,22 +237,38 @@ func (t *tui) handleFormCancel() {
234237
t.returnToMain()
235238
}
236239

240+
const (
241+
statusUp = "up"
242+
statusDown = "down"
243+
statusChecking = "checking"
244+
)
245+
237246
func (t *tui) handlePingSelected() {
238247
if server, ok := t.serverList.GetSelectedServer(); ok {
239248
alias := server.Alias
240249

250+
// Set checking status
251+
server.PingStatus = statusChecking
252+
t.pingStatuses[alias] = server
253+
t.updateServerListWithPingStatus()
254+
241255
t.showStatusTemp(fmt.Sprintf("Pinging %s…", alias))
242256
go func() {
243257
up, dur, err := t.serverService.Ping(server)
244258
t.app.QueueUpdateDraw(func() {
245-
if err != nil {
246-
t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN (%v)", alias, err), "#FF6B6B")
247-
return
248-
}
249-
if up {
250-
t.showStatusTempColor(fmt.Sprintf("Ping %s: UP (%s)", alias, dur), "#A0FFA0")
251-
} else {
252-
t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN", alias), "#FF6B6B")
259+
// Update ping status
260+
if ps, ok := t.pingStatuses[alias]; ok {
261+
if err != nil || !up {
262+
ps.PingStatus = statusDown
263+
ps.PingLatency = 0
264+
t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN", alias), "#FF6B6B")
265+
} else {
266+
ps.PingStatus = statusUp
267+
ps.PingLatency = dur
268+
t.showStatusTempColor(fmt.Sprintf("Ping %s: UP (%s)", alias, dur), "#A0FFA0")
269+
}
270+
t.pingStatuses[alias] = ps
271+
t.updateServerListWithPingStatus()
253272
}
254273
})
255274
}()
@@ -406,6 +425,93 @@ func (t *tui) returnToMain() {
406425
t.app.SetRoot(t.root, true)
407426
}
408427

428+
func (t *tui) updateServerListWithPingStatus() {
429+
// Get current server list
430+
query := ""
431+
if t.searchVisible {
432+
query = t.searchBar.InputField.GetText()
433+
}
434+
servers, _ := t.serverService.ListServers(query)
435+
sortServersForUI(servers, t.sortMode)
436+
437+
// Update ping status for each server
438+
for i := range servers {
439+
if ps, ok := t.pingStatuses[servers[i].Alias]; ok {
440+
servers[i].PingStatus = ps.PingStatus
441+
servers[i].PingLatency = ps.PingLatency
442+
}
443+
}
444+
445+
t.serverList.UpdateServers(servers)
446+
}
447+
448+
func (t *tui) handlePingAll() {
449+
query := ""
450+
if t.searchVisible {
451+
query = t.searchBar.InputField.GetText()
452+
}
453+
servers, err := t.serverService.ListServers(query)
454+
if err != nil {
455+
t.showStatusTempColor(fmt.Sprintf("Failed to get servers: %v", err), "#FF6B6B")
456+
return
457+
}
458+
459+
if len(servers) == 0 {
460+
t.showStatusTemp("No servers to ping")
461+
return
462+
}
463+
464+
t.showStatusTemp(fmt.Sprintf("Pinging all %d servers…", len(servers)))
465+
466+
// Clear existing statuses
467+
t.pingStatuses = make(map[string]domain.Server)
468+
469+
// Set all servers to checking status
470+
for _, server := range servers {
471+
s := server
472+
s.PingStatus = statusChecking
473+
t.pingStatuses[s.Alias] = s
474+
}
475+
t.updateServerListWithPingStatus()
476+
477+
// Ping all servers concurrently
478+
for _, server := range servers {
479+
go func(srv domain.Server) {
480+
up, dur, err := t.serverService.Ping(srv)
481+
t.app.QueueUpdateDraw(func() {
482+
if ps, ok := t.pingStatuses[srv.Alias]; ok {
483+
if err != nil || !up {
484+
ps.PingStatus = statusDown
485+
ps.PingLatency = 0
486+
} else {
487+
ps.PingStatus = statusUp
488+
ps.PingLatency = dur
489+
}
490+
t.pingStatuses[srv.Alias] = ps
491+
t.updateServerListWithPingStatus()
492+
}
493+
})
494+
}(server)
495+
}
496+
497+
// Show completion status after 3 seconds
498+
go func() {
499+
time.Sleep(3 * time.Second)
500+
t.app.QueueUpdateDraw(func() {
501+
upCount := 0
502+
downCount := 0
503+
for _, ps := range t.pingStatuses {
504+
if ps.PingStatus == statusUp {
505+
upCount++
506+
} else if ps.PingStatus == statusDown {
507+
downCount++
508+
}
509+
}
510+
t.showStatusTempColor(fmt.Sprintf("Ping completed: %d UP, %d DOWN", upCount, downCount), "#A0FFA0")
511+
})
512+
}()
513+
}
514+
409515
// showStatusTemp displays a temporary message in the status bar (default green) and then restores the default text.
410516
func (t *tui) showStatusTemp(msg string) {
411517
if t.statusBar == nil {

internal/adapters/ui/hint_bar.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,6 @@ import (
2222
func NewHintBar() *tview.TextView {
2323
hint := tview.NewTextView().SetDynamicColors(true)
2424
hint.SetBackgroundColor(tcell.Color233)
25-
hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • g Ping • r Refresh • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort[-]")
25+
hint.SetText("[#BBBBBB]Press [::b]/[-:-:b] to search… • ↑↓ Navigate • Enter SSH • c Copy SSH • g/G Ping (All) • r Refresh • a Add • e Edit • t Tags • d Delete • p Pin/Unpin • s Sort[-]")
2626
return hint
2727
}

internal/adapters/ui/server_list.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type ServerList struct {
2525
servers []domain.Server
2626
onSelection func(domain.Server)
2727
onSelectionChange func(domain.Server)
28+
currentWidth int
2829
}
2930

3031
func NewServerList() *ServerList {
@@ -58,8 +59,12 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) {
5859
sl.servers = servers
5960
sl.List.Clear()
6061

62+
// Get current width
63+
_, _, width, _ := sl.List.GetInnerRect() //nolint:dogsled
64+
sl.currentWidth = width
65+
6166
for i := range servers {
62-
primary, secondary := formatServerLine(servers[i])
67+
primary, secondary := formatServerLine(servers[i], width)
6368
idx := i
6469
sl.List.AddItem(primary, secondary, 0, func() {
6570
if sl.onSelection != nil {
@@ -76,6 +81,22 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) {
7681
}
7782
}
7883

84+
// RefreshDisplay re-renders the list with current width
85+
func (sl *ServerList) RefreshDisplay() {
86+
_, _, width, _ := sl.List.GetInnerRect() //nolint:dogsled
87+
if width != sl.currentWidth {
88+
sl.currentWidth = width
89+
// Save current selection
90+
currentIdx := sl.List.GetCurrentItem()
91+
// Re-render
92+
sl.UpdateServers(sl.servers)
93+
// Restore selection
94+
if currentIdx >= 0 && currentIdx < sl.List.GetItemCount() {
95+
sl.List.SetCurrentItem(currentIdx)
96+
}
97+
}
98+
}
99+
79100
func (sl *ServerList) GetSelectedServer() (domain.Server, bool) {
80101
idx := sl.List.GetCurrentItem()
81102
if idx >= 0 && idx < len(sl.servers) {

internal/adapters/ui/status_bar.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import (
2020
)
2121

2222
func DefaultStatusText() string {
23-
return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g[-] Ping • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit"
23+
return "[white]↑↓[-] Navigate • [white]Enter[-] SSH • [white]c[-] Copy SSH • [white]a[-] Add • [white]e[-] Edit • [white]g/G[-] Ping (All) • [white]d[-] Delete • [white]p[-] Pin/Unpin • [white]/[-] Search • [white]q[-] Quit"
2424
}
2525

2626
func NewStatusBar() *tview.TextView {

internal/adapters/ui/tui.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/gdamore/tcell/v2"
1919
"go.uber.org/zap"
2020

21+
"github.com/Adembc/lazyssh/internal/core/domain"
2122
"github.com/Adembc/lazyssh/internal/core/ports"
2223
"github.com/rivo/tview"
2324
)
@@ -48,6 +49,8 @@ type tui struct {
4849

4950
sortMode SortMode
5051
searchVisible bool
52+
53+
pingStatuses map[string]domain.Server // stores ping status for each server
5154
}
5255

5356
func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit string) App {
@@ -57,6 +60,7 @@ func NewTUI(logger *zap.SugaredLogger, ss ports.ServerService, version, commit s
5760
serverService: ss,
5861
version: version,
5962
commit: commit,
63+
pingStatuses: make(map[string]domain.Server),
6064
}
6165
}
6266

@@ -127,6 +131,14 @@ func (t *tui) buildLayout() *tui {
127131

128132
func (t *tui) bindEvents() *tui {
129133
t.root.SetInputCapture(t.handleGlobalKeys)
134+
135+
// Handle window resize
136+
t.app.SetBeforeDrawFunc(func(screen tcell.Screen) bool {
137+
// Refresh server list display on resize
138+
t.serverList.RefreshDisplay()
139+
return false
140+
})
141+
130142
return t
131143
}
132144

internal/adapters/ui/utils.go

Lines changed: 94 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,102 @@ func pinnedIcon(pinnedAt time.Time) string {
7878
return "📌" // pinned
7979
}
8080

81-
func formatServerLine(s domain.Server) (primary, secondary string) {
81+
func formatServerLine(s domain.Server, width int) (primary, secondary string) {
8282
icon := cellPad(pinnedIcon(s.PinnedAt), 2)
83-
// Use a consistent color for alias; the icon reflects pinning
84-
primary = fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last SSH: %s[-] %s", icon, s.Alias, s.Host, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags))
83+
84+
// Build main content
85+
mainText := fmt.Sprintf("%s [white::b]%-12s[-] [#AAAAAA]%-18s[-] [#888888]Last SSH: %s[-] %s",
86+
icon, s.Alias, s.Host, humanizeDuration(s.LastSeen), renderTagBadgesForList(s.Tags))
87+
88+
// Format ping status (4 chars max for value)
89+
pingIndicator := ""
90+
if s.PingStatus != "" {
91+
switch s.PingStatus {
92+
case "up":
93+
if s.PingLatency > 0 {
94+
ms := s.PingLatency.Milliseconds()
95+
var statusText string
96+
if ms < 100 {
97+
statusText = fmt.Sprintf("%dms", ms) // e.g., "57ms"
98+
} else {
99+
// Format as #.#s for >= 100ms
100+
seconds := float64(ms) / 1000.0
101+
statusText = fmt.Sprintf("%.1fs", seconds) // e.g., "0.3s", "1.5s"
102+
}
103+
// Ensure exactly 4 chars
104+
statusText = fmt.Sprintf("%-4s", statusText)
105+
pingIndicator = fmt.Sprintf("[#4AF626]● %s[-]", statusText)
106+
} else {
107+
pingIndicator = "[#4AF626]● UP [-]"
108+
}
109+
case "down":
110+
pingIndicator = "[#FF6B6B]● DOWN[-]"
111+
case "checking":
112+
pingIndicator = "[#FFB86C]● ... [-]"
113+
}
114+
}
115+
116+
// Calculate padding for right alignment
117+
if pingIndicator != "" && width > 0 {
118+
// Strip color codes to calculate real length
119+
mainTextLen := len(stripSimpleColors(mainText))
120+
indicatorLen := 6 // "● XXXX" is always 6 display chars
121+
122+
// Calculate padding needed
123+
switch {
124+
case width > 80:
125+
// Wide screen: show full indicator
126+
paddingLen := width - mainTextLen - indicatorLen // No margin, stick to right edge
127+
if paddingLen < 1 {
128+
paddingLen = 1
129+
}
130+
padding := strings.Repeat(" ", paddingLen)
131+
primary = fmt.Sprintf("%s%s%s", mainText, padding, pingIndicator)
132+
case width > 60:
133+
// Medium screen: show only dot
134+
simplePingIndicator := ""
135+
switch s.PingStatus {
136+
case "up":
137+
simplePingIndicator = "[#4AF626]●[-]"
138+
case "down":
139+
simplePingIndicator = "[#FF6B6B]●[-]"
140+
case "checking":
141+
simplePingIndicator = "[#FFB86C]●[-]"
142+
}
143+
paddingLen := width - mainTextLen - 1 // 1 for dot, no margin
144+
if paddingLen < 1 {
145+
paddingLen = 1
146+
}
147+
padding := strings.Repeat(" ", paddingLen)
148+
primary = fmt.Sprintf("%s%s%s", mainText, padding, simplePingIndicator)
149+
default:
150+
// Narrow screen: no ping indicator
151+
primary = mainText
152+
}
153+
} else {
154+
primary = mainText
155+
}
156+
85157
secondary = ""
86-
return
158+
return primary, secondary
159+
}
160+
161+
// stripSimpleColors removes basic tview color codes for length calculation
162+
func stripSimpleColors(s string) string {
163+
result := s
164+
// Remove color tags like [#FFFFFF] or [-]
165+
for {
166+
start := strings.Index(result, "[")
167+
if start == -1 {
168+
break
169+
}
170+
end := strings.Index(result[start:], "]")
171+
if end == -1 {
172+
break
173+
}
174+
result = result[:start] + result[start+end+1:]
175+
}
176+
return result
87177
}
88178

89179
func humanizeDuration(t time.Time) string {

internal/core/domain/server.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ type Server struct {
2727
LastSeen time.Time
2828
PinnedAt time.Time
2929
SSHCount int
30+
PingStatus string // "up", "down", "checking", or ""
31+
PingLatency time.Duration // ping latency
3032

3133
// Additional SSH config fields
3234
// Connection and proxy settings

0 commit comments

Comments
 (0)