Skip to content

Commit de6bc39

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 e75b61e commit de6bc39

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
@@ -153,6 +153,7 @@ make run
153153
| Enter | SSH into selected server |
154154
| c | Copy SSH command to clipboard |
155155
| g | Ping selected server |
156+
| G | Ping all servers |
156157
| r | Refresh background data |
157158
| a | Add server |
158159
| 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
@@ -230,22 +233,38 @@ func (t *tui) handleFormCancel() {
230233
t.returnToMain()
231234
}
232235

236+
const (
237+
statusUp = "up"
238+
statusDown = "down"
239+
statusChecking = "checking"
240+
)
241+
233242
func (t *tui) handlePingSelected() {
234243
if server, ok := t.serverList.GetSelectedServer(); ok {
235244
alias := server.Alias
236245

246+
// Set checking status
247+
server.PingStatus = statusChecking
248+
t.pingStatuses[alias] = server
249+
t.updateServerListWithPingStatus()
250+
237251
t.showStatusTemp(fmt.Sprintf("Pinging %s…", alias))
238252
go func() {
239253
up, dur, err := t.serverService.Ping(server)
240254
t.app.QueueUpdateDraw(func() {
241-
if err != nil {
242-
t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN (%v)", alias, err), "#FF6B6B")
243-
return
244-
}
245-
if up {
246-
t.showStatusTempColor(fmt.Sprintf("Ping %s: UP (%s)", alias, dur), "#A0FFA0")
247-
} else {
248-
t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN", alias), "#FF6B6B")
255+
// Update ping status
256+
if ps, ok := t.pingStatuses[alias]; ok {
257+
if err != nil || !up {
258+
ps.PingStatus = statusDown
259+
ps.PingLatency = 0
260+
t.showStatusTempColor(fmt.Sprintf("Ping %s: DOWN", alias), "#FF6B6B")
261+
} else {
262+
ps.PingStatus = statusUp
263+
ps.PingLatency = dur
264+
t.showStatusTempColor(fmt.Sprintf("Ping %s: UP (%s)", alias, dur), "#A0FFA0")
265+
}
266+
t.pingStatuses[alias] = ps
267+
t.updateServerListWithPingStatus()
249268
}
250269
})
251270
}()
@@ -384,6 +403,93 @@ func (t *tui) returnToMain() {
384403
t.app.SetRoot(t.root, true)
385404
}
386405

406+
func (t *tui) updateServerListWithPingStatus() {
407+
// Get current server list
408+
query := ""
409+
if t.searchVisible {
410+
query = t.searchBar.InputField.GetText()
411+
}
412+
servers, _ := t.serverService.ListServers(query)
413+
sortServersForUI(servers, t.sortMode)
414+
415+
// Update ping status for each server
416+
for i := range servers {
417+
if ps, ok := t.pingStatuses[servers[i].Alias]; ok {
418+
servers[i].PingStatus = ps.PingStatus
419+
servers[i].PingLatency = ps.PingLatency
420+
}
421+
}
422+
423+
t.serverList.UpdateServers(servers)
424+
}
425+
426+
func (t *tui) handlePingAll() {
427+
query := ""
428+
if t.searchVisible {
429+
query = t.searchBar.InputField.GetText()
430+
}
431+
servers, err := t.serverService.ListServers(query)
432+
if err != nil {
433+
t.showStatusTempColor(fmt.Sprintf("Failed to get servers: %v", err), "#FF6B6B")
434+
return
435+
}
436+
437+
if len(servers) == 0 {
438+
t.showStatusTemp("No servers to ping")
439+
return
440+
}
441+
442+
t.showStatusTemp(fmt.Sprintf("Pinging all %d servers…", len(servers)))
443+
444+
// Clear existing statuses
445+
t.pingStatuses = make(map[string]domain.Server)
446+
447+
// Set all servers to checking status
448+
for _, server := range servers {
449+
s := server
450+
s.PingStatus = statusChecking
451+
t.pingStatuses[s.Alias] = s
452+
}
453+
t.updateServerListWithPingStatus()
454+
455+
// Ping all servers concurrently
456+
for _, server := range servers {
457+
go func(srv domain.Server) {
458+
up, dur, err := t.serverService.Ping(srv)
459+
t.app.QueueUpdateDraw(func() {
460+
if ps, ok := t.pingStatuses[srv.Alias]; ok {
461+
if err != nil || !up {
462+
ps.PingStatus = statusDown
463+
ps.PingLatency = 0
464+
} else {
465+
ps.PingStatus = statusUp
466+
ps.PingLatency = dur
467+
}
468+
t.pingStatuses[srv.Alias] = ps
469+
t.updateServerListWithPingStatus()
470+
}
471+
})
472+
}(server)
473+
}
474+
475+
// Show completion status after 3 seconds
476+
go func() {
477+
time.Sleep(3 * time.Second)
478+
t.app.QueueUpdateDraw(func() {
479+
upCount := 0
480+
downCount := 0
481+
for _, ps := range t.pingStatuses {
482+
if ps.PingStatus == statusUp {
483+
upCount++
484+
} else if ps.PingStatus == statusDown {
485+
downCount++
486+
}
487+
}
488+
t.showStatusTempColor(fmt.Sprintf("Ping completed: %d UP, %d DOWN", upCount, downCount), "#A0FFA0")
489+
})
490+
}()
491+
}
492+
387493
// showStatusTemp displays a temporary message in the status bar (default green) and then restores the default text.
388494
func (t *tui) showStatusTemp(msg string) {
389495
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 {
@@ -57,8 +58,12 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) {
5758
sl.servers = servers
5859
sl.List.Clear()
5960

61+
// Get current width
62+
_, _, width, _ := sl.List.GetInnerRect() //nolint:dogsled
63+
sl.currentWidth = width
64+
6065
for i := range servers {
61-
primary, secondary := formatServerLine(servers[i])
66+
primary, secondary := formatServerLine(servers[i], width)
6267
idx := i
6368
sl.List.AddItem(primary, secondary, 0, func() {
6469
if sl.onSelection != nil {
@@ -75,6 +80,22 @@ func (sl *ServerList) UpdateServers(servers []domain.Server) {
7580
}
7681
}
7782

83+
// RefreshDisplay re-renders the list with current width
84+
func (sl *ServerList) RefreshDisplay() {
85+
_, _, width, _ := sl.List.GetInnerRect() //nolint:dogsled
86+
if width != sl.currentWidth {
87+
sl.currentWidth = width
88+
// Save current selection
89+
currentIdx := sl.List.GetCurrentItem()
90+
// Re-render
91+
sl.UpdateServers(sl.servers)
92+
// Restore selection
93+
if currentIdx >= 0 && currentIdx < sl.List.GetItemCount() {
94+
sl.List.SetCurrentItem(currentIdx)
95+
}
96+
}
97+
}
98+
7899
func (sl *ServerList) GetSelectedServer() (domain.Server, bool) {
79100
idx := sl.List.GetCurrentItem()
80101
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
@@ -64,12 +64,102 @@ func pinnedIcon(pinnedAt time.Time) string {
6464
return "📌" // pinned
6565
}
6666

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

75165
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,4 +27,6 @@ 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
}

0 commit comments

Comments
 (0)