Skip to content

Commit b7ff3ea

Browse files
committed
feat: add status TUI subcommand with bubbletea
New 'healthd status' command renders check results grouped by category with colored pass/fail indicators and recent alert history. - One-shot mode: runs checks once, renders, exits - Watch mode (-w): live-updating display on config interval - Supports --only and --group filters - Uses charmbracelet/bubbletea + lipgloss for rendering
1 parent 487492f commit b7ff3ea

File tree

11 files changed

+778
-1
lines changed

11 files changed

+778
-1
lines changed

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ func NewRootCommand() *cobra.Command {
1212
root.AddCommand(newDaemonCommand())
1313
root.AddCommand(newInitCommand())
1414
root.AddCommand(newNotifyCommand())
15+
root.AddCommand(newStatusCommand())
1516
root.AddCommand(newValidateCommand())
1617
return root
1718
}

cmd/status.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package cmd
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
7+
tea "github.com/charmbracelet/bubbletea"
8+
"github.com/spf13/cobra"
9+
"github.com/uinaf/healthd/internal/config"
10+
"github.com/uinaf/healthd/internal/runner"
11+
"github.com/uinaf/healthd/internal/tui"
12+
)
13+
14+
func newStatusCommand() *cobra.Command {
15+
var configPath string
16+
var only []string
17+
var groups []string
18+
var watch bool
19+
20+
cmd := &cobra.Command{
21+
Use: "status",
22+
Short: "Render health check status in a terminal UI",
23+
Args: cobra.NoArgs,
24+
RunE: func(cmd *cobra.Command, args []string) error {
25+
cmd.SilenceUsage = true
26+
27+
resolvedPath, err := config.ResolvePath(configPath)
28+
if err != nil {
29+
return err
30+
}
31+
32+
cfg, err := config.LoadFromPath(resolvedPath)
33+
if err != nil {
34+
return err
35+
}
36+
37+
checks := runner.FilterChecks(cfg.Checks, only, groups)
38+
if len(checks) == 0 {
39+
return errors.New("no checks matched filters")
40+
}
41+
42+
model := tui.NewModel(cfg, checks, watch)
43+
program := tea.NewProgram(model, tea.WithOutput(cmd.OutOrStdout()), tea.WithInput(cmd.InOrStdin()))
44+
finalModel, err := program.Run()
45+
if err != nil {
46+
return err
47+
}
48+
49+
if !watch {
50+
tuiModel, ok := finalModel.(tui.Model)
51+
if !ok {
52+
return fmt.Errorf("unexpected final model type %T", finalModel)
53+
}
54+
if !runner.AllPassed(tuiModel.Results()) {
55+
return errors.New("one or more checks failed")
56+
}
57+
}
58+
59+
return nil
60+
},
61+
}
62+
63+
cmd.Flags().StringVar(&configPath, "config", "", fmt.Sprintf("config file path (default: %s)", config.DefaultConfigPath))
64+
cmd.Flags().StringSliceVar(&only, "only", nil, "only run checks by name (repeat flag or pass comma-separated names)")
65+
cmd.Flags().StringSliceVar(&groups, "group", nil, "only run checks by group (repeat flag or pass comma-separated groups)")
66+
cmd.Flags().BoolVarP(&watch, "watch", "w", false, "watch checks on the configured interval")
67+
return cmd
68+
}

cmd/status_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package cmd
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
"testing"
7+
)
8+
9+
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\[\?[0-9;]*[a-zA-Z]`)
10+
11+
func stripANSI(s string) string {
12+
return ansiEscape.ReplaceAllString(s, "")
13+
}
14+
15+
func TestStatusRendersAndFailsOnCheckFailure(t *testing.T) {
16+
t.Parallel()
17+
18+
configPath := writeTestConfig(t, `
19+
interval = "1s"
20+
timeout = "1s"
21+
22+
[[check]]
23+
name = "ok-check"
24+
group = "services"
25+
command = "true"
26+
27+
[[check]]
28+
name = "bad-check"
29+
group = "services"
30+
command = "false"
31+
`)
32+
33+
result := executeCheckCommand(t, "status", "--config", configPath)
34+
if result.err == nil {
35+
t.Fatalf("expected non-nil error when checks fail")
36+
}
37+
cleaned := stripANSI(result.stdout)
38+
if !strings.Contains(cleaned, "healthd - 2 checks - 1 ok - 1 fail") {
39+
t.Fatalf("expected status header in output, got: %q", cleaned)
40+
}
41+
if !strings.Contains(cleaned, "services") {
42+
t.Fatalf("expected group heading in output, got: %q", cleaned)
43+
}
44+
}
45+
46+
func TestStatusReturnsErrorWhenFiltersMatchNothing(t *testing.T) {
47+
t.Parallel()
48+
49+
configPath := writeTestConfig(t, `
50+
interval = "1s"
51+
timeout = "1s"
52+
53+
[[check]]
54+
name = "ok-check"
55+
group = "services"
56+
command = "true"
57+
`)
58+
59+
result := executeCheckCommand(t, "status", "--config", configPath, "--only", "missing")
60+
if result.err == nil {
61+
t.Fatal("expected filter miss to error")
62+
}
63+
if !strings.Contains(result.err.Error(), "no checks matched filters") {
64+
t.Fatalf("unexpected error: %v", result.err)
65+
}
66+
}

go.mod

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
module github.com/uinaf/healthd
22

3-
go 1.22
3+
go 1.24.0
44

55
require (
66
github.com/BurntSushi/toml v1.5.0
@@ -9,9 +9,28 @@ require (
99
)
1010

1111
require (
12+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
13+
github.com/charmbracelet/bubbletea v1.3.10 // indirect
14+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
15+
github.com/charmbracelet/lipgloss v1.1.0 // indirect
16+
github.com/charmbracelet/x/ansi v0.10.1 // indirect
17+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
18+
github.com/charmbracelet/x/term v0.2.1 // indirect
1219
github.com/davecgh/go-spew v1.1.1 // indirect
20+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
1321
github.com/inconshreveable/mousetrap v1.1.0 // indirect
22+
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
23+
github.com/mattn/go-isatty v0.0.20 // indirect
24+
github.com/mattn/go-localereader v0.0.1 // indirect
25+
github.com/mattn/go-runewidth v0.0.16 // indirect
26+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
27+
github.com/muesli/cancelreader v0.2.2 // indirect
28+
github.com/muesli/termenv v0.16.0 // indirect
1429
github.com/pmezard/go-difflib v1.0.0 // indirect
30+
github.com/rivo/uniseg v0.4.7 // indirect
1531
github.com/spf13/pflag v1.0.9 // indirect
32+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
33+
golang.org/x/sys v0.36.0 // indirect
34+
golang.org/x/text v0.3.8 // indirect
1635
gopkg.in/yaml.v3 v3.0.1 // indirect
1736
)

go.sum

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,60 @@
11
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
22
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3+
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
4+
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
5+
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
6+
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
7+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
8+
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
9+
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
10+
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
11+
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
12+
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
13+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
14+
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
15+
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
16+
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
317
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
418
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
519
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
20+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
21+
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
622
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
723
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
24+
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
25+
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
26+
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
27+
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
28+
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
29+
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
30+
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
31+
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
32+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
33+
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
34+
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
35+
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
36+
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
37+
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
838
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
939
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
40+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
41+
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
42+
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
1043
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
1144
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
1245
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
1346
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
1447
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
1548
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
1649
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
50+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
51+
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
52+
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
53+
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
54+
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
55+
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
56+
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
57+
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
1758
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
1859
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
1960
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

internal/tui/alerts.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package tui
2+
3+
import (
4+
"bufio"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"strings"
10+
"time"
11+
)
12+
13+
var alertLinePattern = regexp.MustCompile(`^(\S+) \[([^\]]+)\] ([^(]+) \(([^)]*)\) - (.*)$`)
14+
15+
type AlertLine struct {
16+
Time time.Time
17+
State string
18+
CheckName string
19+
Group string
20+
Reason string
21+
}
22+
23+
func DefaultAlertsLogPath() (string, error) {
24+
home, err := os.UserHomeDir()
25+
if err != nil {
26+
return "", fmt.Errorf("resolve user home: %w", err)
27+
}
28+
return filepath.Join(home, ".local", "state", "healthd", "alerts.log"), nil
29+
}
30+
31+
func LoadRecentAlerts(path string, limit int) ([]AlertLine, error) {
32+
if limit <= 0 {
33+
return []AlertLine{}, nil
34+
}
35+
36+
file, err := os.Open(path)
37+
if err != nil {
38+
if os.IsNotExist(err) {
39+
return []AlertLine{}, nil
40+
}
41+
return nil, fmt.Errorf("open alerts log %q: %w", path, err)
42+
}
43+
defer file.Close()
44+
45+
parsed := make([]AlertLine, 0, limit)
46+
scanner := bufio.NewScanner(file)
47+
for scanner.Scan() {
48+
line := strings.TrimSpace(scanner.Text())
49+
if line == "" {
50+
continue
51+
}
52+
entry, ok := parseAlertLine(line)
53+
if !ok {
54+
continue
55+
}
56+
parsed = append(parsed, entry)
57+
}
58+
59+
if err := scanner.Err(); err != nil {
60+
return nil, fmt.Errorf("read alerts log %q: %w", path, err)
61+
}
62+
63+
if len(parsed) <= limit {
64+
return parsed, nil
65+
}
66+
return parsed[len(parsed)-limit:], nil
67+
}
68+
69+
func parseAlertLine(line string) (AlertLine, bool) {
70+
matches := alertLinePattern.FindStringSubmatch(line)
71+
if len(matches) != 6 {
72+
return AlertLine{}, false
73+
}
74+
75+
ts, err := time.Parse(time.RFC3339, matches[1])
76+
if err != nil {
77+
return AlertLine{}, false
78+
}
79+
80+
return AlertLine{
81+
Time: ts,
82+
State: strings.TrimSpace(matches[2]),
83+
CheckName: strings.TrimSpace(matches[3]),
84+
Group: strings.TrimSpace(matches[4]),
85+
Reason: strings.TrimSpace(matches[5]),
86+
}, true
87+
}

internal/tui/alerts_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package tui
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestLoadRecentAlertsParsesAndTails(t *testing.T) {
10+
t.Parallel()
11+
12+
dir := t.TempDir()
13+
path := filepath.Join(dir, "alerts.log")
14+
content := "\n" +
15+
"2026-02-27T08:37:00Z [crit] openclaw-up-to-date (services) - expected exit_code=0, got 1\n" +
16+
"bad line\n" +
17+
"2026-02-27T13:00:00Z [recovered] colima-running (services) - ok\n"
18+
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
19+
t.Fatalf("write log: %v", err)
20+
}
21+
22+
alerts, err := LoadRecentAlerts(path, 1)
23+
if err != nil {
24+
t.Fatalf("LoadRecentAlerts error: %v", err)
25+
}
26+
if len(alerts) != 1 {
27+
t.Fatalf("expected 1 alert, got %d", len(alerts))
28+
}
29+
if alerts[0].State != "recovered" {
30+
t.Fatalf("expected recovered state, got %q", alerts[0].State)
31+
}
32+
if alerts[0].CheckName != "colima-running" {
33+
t.Fatalf("expected check name colima-running, got %q", alerts[0].CheckName)
34+
}
35+
}
36+
37+
func TestLoadRecentAlertsMissingFileIsEmpty(t *testing.T) {
38+
t.Parallel()
39+
40+
alerts, err := LoadRecentAlerts(filepath.Join(t.TempDir(), "missing.log"), 10)
41+
if err != nil {
42+
t.Fatalf("expected no error for missing file, got %v", err)
43+
}
44+
if len(alerts) != 0 {
45+
t.Fatalf("expected no alerts, got %d", len(alerts))
46+
}
47+
}

0 commit comments

Comments
 (0)