Skip to content

Commit 76303f0

Browse files
committed
feat: health check
1 parent 473b8c3 commit 76303f0

File tree

5 files changed

+204
-18
lines changed

5 files changed

+204
-18
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ make run
6666
- e: edit selected host
6767
- y: duplicate selected host
6868
- d: delete selected host (with confirmation)
69+
- h: run health check for all saved hosts (green/yellow/red indicators)
6970
- enter: connect to selected host
7071
- x: export backup
7172
- i: import backup

internal/tui/app.go

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ type hostVerifyDoneMsg struct {
4848
type hostTrustDoneMsg struct{ err error }
4949
type saveKeyPassDoneMsg struct{ err error }
5050
type dashboardTrustDoneMsg struct{ err error }
51+
type healthCheckDoneMsg struct {
52+
statuses map[string]hostHealthStatus
53+
}
5154

5255
type formUserConfig struct {
5356
Username string
@@ -75,20 +78,22 @@ type model struct {
7578
err string
7679

7780
// Dashboard
78-
store *host.Store
79-
filtered []host.Host
80-
hostCursor int
81-
search textinput.Model
82-
searchFocused bool
83-
confirmDelete bool
84-
connErr string
85-
exportErr string
86-
exportDir string
87-
importErr string
88-
importPath string
89-
importReturn phase
90-
userCursor int
91-
selectedHost host.Host
81+
store *host.Store
82+
filtered []host.Host
83+
hostCursor int
84+
search textinput.Model
85+
searchFocused bool
86+
confirmDelete bool
87+
connErr string
88+
exportErr string
89+
exportDir string
90+
importErr string
91+
importPath string
92+
importReturn phase
93+
userCursor int
94+
selectedHost host.Host
95+
healthChecking bool
96+
healthStatuses map[string]hostHealthStatus
9297

9398
// Host form
9499
formInputs []textinput.Model
@@ -192,6 +197,8 @@ func (m model) initDashboard() (model, error) {
192197
m.hostCursor = 0
193198
m.confirmDelete = false
194199
m.connErr = ""
200+
m.healthChecking = false
201+
m.healthStatuses = make(map[string]hostHealthStatus)
195202
m.filtered = store.Filter("")
196203
m.phase = phaseDashboard
197204
return m, nil
@@ -290,6 +297,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
290297
return m, nil
291298
}
292299
return m.connectSSHWithResolved(m.connectHost, m.connectUser, m.connectResolved, m.pendingKeyPassphrase, m.pendingKeyPassSave)
300+
case healthCheckDoneMsg:
301+
m.healthChecking = false
302+
m.healthStatuses = msg.statuses
303+
return m, nil
293304
}
294305

295306
switch m.phase {

internal/tui/dashboard.go

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,12 @@ func (m model) updateDashboardNormal(msg tea.Msg) (tea.Model, tea.Cmd) {
7777
return m, textinput.Blink
7878
case "i":
7979
return m.startImportFlow()
80+
case "h":
81+
if m.healthChecking {
82+
return m, nil
83+
}
84+
m.healthChecking = true
85+
return m, healthCheckAllCmd(m.store.Hosts, m.encKey)
8086
case "j", "down":
8187
if m.hostCursor < len(m.filtered)-1 {
8288
m.hostCursor++
@@ -356,6 +362,9 @@ func (m model) viewDashboard() string {
356362
}
357363

358364
view := title + "\n" + searchLine + "\n\n" + panels
365+
if m.healthChecking {
366+
view += "\n" + hintStyle.Render(" Running health check across all saved hosts...")
367+
}
359368

360369
if m.connErr != "" {
361370
errBanner := errorStyle.Render(" ✗ " + m.connErr)
@@ -377,7 +386,7 @@ func (m model) renderHostList(maxW, maxH int) string {
377386
}
378387

379388
// Column widths — adapt to available space.
380-
available := maxW - 3
389+
available := maxW - 6
381390
if available < 10 {
382391
available = 10
383392
}
@@ -402,6 +411,7 @@ func (m model) renderHostList(maxW, maxH int) string {
402411

403412
for i := offset; i < end; i++ {
404413
h := m.filtered[i]
414+
indicator := m.healthIndicator(m.hostHealth(h.ID))
405415

406416
cursor := " "
407417
style := lipgloss.NewStyle().Foreground(text)
@@ -420,7 +430,7 @@ func (m model) renderHostList(maxW, maxH int) string {
420430
}
421431
hostColStr := lipgloss.NewStyle().Width(colHost).MaxWidth(colHost).Render(hostStr)
422432

423-
line := fmt.Sprintf("%s%s %s", cursor, aliasStr, hostColStr)
433+
line := fmt.Sprintf("%s%s %s %s", cursor, indicator, aliasStr, hostColStr)
424434
b.WriteString(style.Render(line))
425435
if i < end-1 {
426436
b.WriteByte('\n')
@@ -469,6 +479,7 @@ func (m model) renderDetails() string {
469479
}
470480

471481
lines := []string{
482+
render("Health", healthLabel(m.hostHealth(h.ID))),
472483
render("Alias", h.Alias),
473484
render("Host", h.Hostname),
474485
render("Users", strings.Join(users, ", ")),
@@ -502,9 +513,9 @@ func (m model) renderCommands(maxW int) string {
502513
return " " + pad(cmd("/", "Search"), col) + cmd("l", "Lock Session") + "\n" +
503514
" " + pad(cmd("a", "Add"), col) + cmd("c", "Change Master Key") + "\n" +
504515
" " + pad(cmd("e", "Edit"), col) + cmd("y", "Duplicate") + "\n" +
505-
" " + pad(cmd("d", "Delete"), col) + cmd("", "Connect") + "\n" +
516+
" " + pad(cmd("d", "Delete"), col) + cmd("h", "Health Check") + "\n" +
506517
" " + pad(cmd("x", "Export Backup"), col) + cmd("i", "Import Backup") + "\n" +
507-
" " + cmd("q", "Quit")
518+
" " + pad(cmd("⏎", "Connect"), col) + cmd("q", "Quit")
508519
}
509520

510521
func (m model) updateUserSelect(msg tea.Msg) (tea.Model, tea.Cmd) {

internal/tui/healthcheck.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package tui
2+
3+
import (
4+
"context"
5+
"errors"
6+
"os/exec"
7+
"strings"
8+
"time"
9+
10+
tea "github.com/charmbracelet/bubbletea"
11+
12+
"github.com/managedssh/managedssh/internal/host"
13+
"github.com/managedssh/managedssh/internal/sshclient"
14+
"github.com/managedssh/managedssh/internal/vault"
15+
)
16+
17+
type hostHealthStatus int
18+
19+
const (
20+
healthUnknown hostHealthStatus = iota
21+
healthRed
22+
healthYellow
23+
healthGreen
24+
)
25+
26+
func healthCheckAllCmd(hosts []host.Host, encKey []byte) tea.Cmd {
27+
copiedHosts := make([]host.Host, len(hosts))
28+
copy(copiedHosts, hosts)
29+
return func() tea.Msg {
30+
statuses := make(map[string]hostHealthStatus, len(copiedHosts))
31+
for _, h := range copiedHosts {
32+
statuses[h.ID] = checkHostHealth(h, encKey)
33+
}
34+
return healthCheckDoneMsg{statuses: statuses}
35+
}
36+
}
37+
38+
func checkHostHealth(h host.Host, encKey []byte) hostHealthStatus {
39+
users := h.AccountNames()
40+
if len(users) == 0 {
41+
return healthRed
42+
}
43+
44+
for _, username := range users {
45+
_, resolved, ok := h.ResolveAccount(username)
46+
if !ok {
47+
continue
48+
}
49+
50+
var password []byte
51+
if resolved.AuthType == "password" && len(resolved.Password) > 0 {
52+
dec, err := vault.Decrypt(encKey, resolved.Password)
53+
if err != nil {
54+
continue
55+
}
56+
password = dec
57+
}
58+
59+
var keyData []byte
60+
if resolved.AuthType == "key" && len(resolved.EncKey) > 0 {
61+
dec, err := vault.Decrypt(encKey, resolved.EncKey)
62+
if err != nil {
63+
zeroBytes(password)
64+
continue
65+
}
66+
keyData = dec
67+
}
68+
69+
var keyPassphrase []byte
70+
if resolved.AuthType == "key" && len(resolved.EncKeyPass) > 0 {
71+
dec, err := vault.Decrypt(encKey, resolved.EncKeyPass)
72+
if err != nil {
73+
zeroBytes(password)
74+
zeroBytes(keyData)
75+
continue
76+
}
77+
keyPassphrase = dec
78+
}
79+
80+
err := sshclient.Verify(sshclient.VerifyConfig{
81+
Host: h.Hostname,
82+
Port: h.Port,
83+
User: username,
84+
Password: password,
85+
KeyPath: resolved.KeyPath,
86+
KeyData: keyData,
87+
KeyPassphrase: keyPassphrase,
88+
})
89+
zeroBytes(password)
90+
zeroBytes(keyData)
91+
zeroBytes(keyPassphrase)
92+
if err == nil {
93+
return healthGreen
94+
}
95+
96+
var unknown *sshclient.UnknownHostError
97+
if errors.As(err, &unknown) {
98+
return healthGreen
99+
}
100+
}
101+
102+
if hostRespondsToPing(h.Hostname) {
103+
return healthYellow
104+
}
105+
return healthRed
106+
}
107+
108+
func hostRespondsToPing(hostname string) bool {
109+
hostname = strings.TrimSpace(hostname)
110+
if hostname == "" {
111+
return false
112+
}
113+
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
114+
defer cancel()
115+
116+
cmd := exec.CommandContext(ctx, "ping", "-c", "1", hostname)
117+
if err := cmd.Run(); err == nil {
118+
return true
119+
}
120+
return false
121+
}
122+
123+
func (m model) healthIndicator(status hostHealthStatus) string {
124+
switch status {
125+
case healthGreen:
126+
return lipHealthGreen.Render("●")
127+
case healthYellow:
128+
return lipHealthYellow.Render("●")
129+
case healthRed:
130+
return lipHealthRed.Render("●")
131+
default:
132+
if m.healthChecking {
133+
return lipHealthPending.Render("○")
134+
}
135+
return lipHealthUnknown.Render("○")
136+
}
137+
}
138+
139+
func healthLabel(status hostHealthStatus) string {
140+
switch status {
141+
case healthGreen:
142+
return "Green (reachable)"
143+
case healthYellow:
144+
return "Yellow (ping OK, SSH uncertain)"
145+
case healthRed:
146+
return "Red (unreachable/invalid)"
147+
default:
148+
return "Unknown"
149+
}
150+
}
151+
152+
func (m model) hostHealth(hostID string) hostHealthStatus {
153+
if status, ok := m.healthStatuses[hostID]; ok {
154+
return status
155+
}
156+
return healthUnknown
157+
}

internal/tui/styles.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,4 +78,10 @@ var (
7878

7979
cmdDescStyle = lipgloss.NewStyle().
8080
Foreground(text)
81+
82+
lipHealthGreen = lipgloss.NewStyle().Foreground(lipgloss.Color("#22C55E"))
83+
lipHealthYellow = lipgloss.NewStyle().Foreground(lipgloss.Color("#F59E0B"))
84+
lipHealthRed = lipgloss.NewStyle().Foreground(lipgloss.Color("#EF4444"))
85+
lipHealthPending = lipgloss.NewStyle().Foreground(lipgloss.Color("#60A5FA"))
86+
lipHealthUnknown = lipgloss.NewStyle().Foreground(subtle)
8187
)

0 commit comments

Comments
 (0)