Skip to content

Commit 8a69685

Browse files
Use Cobra for flags and cleanups
1 parent db37bb7 commit 8a69685

12 files changed

Lines changed: 153 additions & 221 deletions

File tree

README.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,6 @@ Ember connects to the Caddy admin API and auto-detects FrankenPHP if present. In
8686
--expose addr Expose Prometheus metrics (e.g. --expose=:9191)
8787
--daemon Headless mode (requires --expose)
8888
--no-color Disable colors
89-
--completion shell Generate shell completions (bash, zsh, fish)
9089
```
9190

9291
### Keybindings
@@ -111,13 +110,13 @@ Ember connects to the Caddy admin API and auto-detects FrankenPHP if present. In
111110

112111
```bash
113112
# Bash
114-
ember --completion bash > /etc/bash_completion.d/ember
113+
ember completion bash > /etc/bash_completion.d/ember
115114

116115
# Zsh
117-
ember --completion zsh > "${fpath[1]}/_ember"
116+
ember completion zsh > "${fpath[1]}/_ember"
118117

119118
# Fish
120-
ember --completion fish > ~/.config/fish/completions/ember.fish
119+
ember completion fish > ~/.config/fish/completions/ember.fish
121120
```
122121

123122
## License

go.mod

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ go 1.26
55
require (
66
github.com/charmbracelet/bubbletea v1.3.10
77
github.com/charmbracelet/lipgloss v1.1.0
8+
github.com/guptarohit/asciigraph v0.8.1
89
github.com/muesli/termenv v0.16.0
910
github.com/prometheus/client_model v0.6.2
1011
github.com/prometheus/common v0.67.5
1112
github.com/shirou/gopsutil/v4 v4.26.2
13+
github.com/spf13/cobra v1.10.2
1214
github.com/stretchr/testify v1.11.1
1315
golang.org/x/sync v0.20.0
1416
)
@@ -23,7 +25,7 @@ require (
2325
github.com/ebitengine/purego v0.10.0 // indirect
2426
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
2527
github.com/go-ole/go-ole v1.2.6 // indirect
26-
github.com/guptarohit/asciigraph v0.8.1 // indirect
28+
github.com/inconshreveable/mousetrap v1.1.0 // indirect
2729
github.com/kr/pretty v0.3.1 // indirect
2830
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
2931
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
@@ -36,6 +38,7 @@ require (
3638
github.com/pmezard/go-difflib v1.0.0 // indirect
3739
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
3840
github.com/rivo/uniseg v0.4.7 // indirect
41+
github.com/spf13/pflag v1.0.9 // indirect
3942
github.com/tklauser/go-sysconf v0.3.16 // indirect
4043
github.com/tklauser/numcpus v0.11.0 // indirect
4144
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect

go.sum

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G
1212
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
1313
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
1414
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
15+
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
1516
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
1617
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
1718
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -26,6 +27,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
2627
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
2728
github.com/guptarohit/asciigraph v0.8.1 h1:JBeHTGj2ntBODnZxLQhp+GQZdlZ/48S/m7J1i1+KqFw=
2829
github.com/guptarohit/asciigraph v0.8.1/go.mod h1:dYl5wwK4gNsnFf9Zp+l06rFiDZ5YtXM6x7SRWZ3KGag=
30+
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
31+
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
2932
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
3033
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
3134
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -63,8 +66,13 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
6366
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
6467
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
6568
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
69+
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
6670
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
6771
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
72+
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
73+
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
74+
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
75+
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
6876
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
6977
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
7078
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
@@ -77,6 +85,7 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
7785
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
7886
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
7987
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
88+
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
8089
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
8190
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
8291
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=

internal/app/completion.go

Lines changed: 0 additions & 62 deletions
This file was deleted.

internal/app/daemon.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package app
22

33
import (
44
"context"
5+
"errors"
56
"fmt"
67
"net/http"
78
"os"
@@ -12,7 +13,10 @@ import (
1213
"github.com/alexandredaubois/ember/internal/model"
1314
)
1415

15-
func runDaemon(ctx context.Context, f *fetcher.HTTPFetcher, cfg *config) {
16+
func runDaemon(ctx context.Context, f *fetcher.HTTPFetcher, cfg *config) error {
17+
ctx, cancel := context.WithCancelCause(ctx)
18+
defer cancel(nil)
19+
1620
holder := &exporter.StateHolder{}
1721
var state model.State
1822

@@ -21,13 +25,12 @@ func runDaemon(ctx context.Context, f *fetcher.HTTPFetcher, cfg *config) {
2125
srv := &http.Server{Addr: cfg.expose, Handler: mux}
2226

2327
go func() {
24-
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
25-
fmt.Fprintf(os.Stderr, "metrics server error: %v\n", err)
26-
os.Exit(1)
28+
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
29+
cancel(fmt.Errorf("metrics server: %w", err))
2730
}
2831
}()
2932

30-
fmt.Fprintf(os.Stderr, "ember daemon: exposing metrics on %s/metrics\n", cfg.expose)
33+
fmt.Fprintf(os.Stderr, "ember daemon: exposing metrics on http://localhost%s/metrics\n", cfg.expose)
3134

3235
poll := func() {
3336
snap, err := f.Fetch(ctx)
@@ -50,7 +53,10 @@ func runDaemon(ctx context.Context, f *fetcher.HTTPFetcher, cfg *config) {
5053
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
5154
defer shutdownCancel()
5255
srv.Shutdown(shutdownCtx)
53-
return
56+
if cause := context.Cause(ctx); cause != nil && !errors.Is(cause, context.Canceled) {
57+
return cause
58+
}
59+
return nil
5460
case <-ticker.C:
5561
poll()
5662
}

internal/app/run.go

Lines changed: 78 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ package app
22

33
import (
44
"context"
5-
"flag"
65
"fmt"
76
"os"
87
"os/signal"
98
"syscall"
109
"time"
1110

1211
"github.com/alexandredaubois/ember/internal/fetcher"
12+
"github.com/spf13/cobra"
1313
)
1414

1515
type config struct {
@@ -23,70 +23,95 @@ type config struct {
2323
daemon bool
2424
}
2525

26-
func Run(args []string, version string) error {
26+
func newRootCmd(version string) *cobra.Command {
2727
var cfg config
2828

29-
fs := flag.NewFlagSet("ember", flag.ContinueOnError)
30-
fs.StringVar(&cfg.addr, "addr", "http://localhost:2019", "Caddy admin API address")
31-
fs.DurationVar(&cfg.interval, "interval", 1*time.Second, "polling interval")
32-
fs.IntVar(&cfg.slowThreshold, "slow-threshold", 500, "slow request threshold (ms)")
33-
fs.BoolVar(&cfg.noColor, "no-color", false, "disable colors")
34-
fs.BoolVar(&cfg.jsonMode, "json", false, "JSON output mode")
35-
fs.IntVar(&cfg.pid, "pid", 0, "FrankenPHP process PID (auto-detected if not set)")
36-
fs.StringVar(&cfg.expose, "expose", "", "expose Prometheus metrics on address (e.g. :9191)")
37-
fs.BoolVar(&cfg.daemon, "daemon", false, "run in daemon mode (no TUI, requires --expose)")
38-
showVersion := fs.Bool("version", false, "show version")
39-
completion := fs.String("completion", "", "generate shell completions (bash, zsh, fish)")
29+
cmd := &cobra.Command{
30+
Use: "ember [flags]",
31+
Short: "Real-time TUI dashboard for Caddy & FrankenPHP",
32+
Version: version,
33+
Long: `Ember - Real-time TUI dashboard for Caddy & FrankenPHP
4034
41-
fs.Usage = func() { printUsage(fs.Output(), version) }
35+
Monitor your Caddy server in real time: per-host traffic, latency
36+
percentiles, status codes, and more. When FrankenPHP is detected,
37+
unlock per-thread introspection, worker management, and memory tracking.
4238
43-
if err := fs.Parse(args); err != nil {
44-
return err
45-
}
39+
Keybindings:
40+
Tab / 1 / 2 Switch between Caddy and FrankenPHP tabs
41+
Up / Down / j / k Navigate list
42+
Home / End Jump to first / last item
43+
PgUp / PgDn Page navigation
44+
Enter Open detail panel
45+
s / S Cycle sort field
46+
p Pause / resume
47+
r Restart workers (FrankenPHP)
48+
/ Filter
49+
g Full-screen graphs
50+
? Help overlay
51+
q Quit`,
52+
Example: ` ember # default: localhost:2019
53+
ember --addr http://prod:2019 # custom address
54+
ember --json # pipe-friendly JSON output
55+
ember --expose :9191 # TUI + Prometheus endpoint
56+
ember --expose :9191 --daemon # headless metrics exporter`,
57+
SilenceUsage: true,
58+
SilenceErrors: true,
59+
PreRunE: func(cmd *cobra.Command, args []string) error {
60+
return validate(&cfg)
61+
},
62+
RunE: func(cmd *cobra.Command, args []string) error {
63+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
64+
defer cancel()
4665

47-
if *showVersion {
48-
fmt.Printf("ember %s\n", version)
49-
return nil
50-
}
66+
pid := int32(cfg.pid)
67+
if pid == 0 {
68+
detected, err := fetcher.FindFrankenPHPProcess(ctx)
69+
if err != nil {
70+
detected, err = fetcher.FindCaddyProcess(ctx)
71+
if err != nil && (cfg.jsonMode || cfg.daemon) {
72+
fmt.Fprintf(os.Stderr, "warning: no frankenphp or caddy process found\n")
73+
}
74+
}
75+
if err == nil {
76+
pid = detected
77+
}
78+
}
5179

52-
if *completion != "" {
53-
return printCompletion(os.Stdout, *completion)
54-
}
80+
f := fetcher.NewHTTPFetcher(cfg.addr, pid)
81+
hasFrankenPHP := f.DetectFrankenPHP(ctx)
82+
f.FetchServerNames(ctx)
5583

56-
if err := validate(&cfg); err != nil {
57-
return err
84+
switch {
85+
case cfg.jsonMode:
86+
runJSON(ctx, f, cfg.interval)
87+
case cfg.daemon:
88+
return runDaemon(ctx, f, &cfg)
89+
default:
90+
return runTUI(f, &cfg, hasFrankenPHP, version)
91+
}
92+
return nil
93+
},
5894
}
5995

60-
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
61-
defer cancel()
96+
f := cmd.Flags()
97+
f.StringVar(&cfg.addr, "addr", "http://localhost:2019", "Caddy admin API address")
98+
f.DurationVar(&cfg.interval, "interval", 1*time.Second, "Polling interval")
99+
f.IntVar(&cfg.slowThreshold, "slow-threshold", 500, "Slow request threshold in ms")
100+
f.BoolVar(&cfg.noColor, "no-color", false, "Disable colors")
101+
f.BoolVar(&cfg.jsonMode, "json", false, "JSON output mode (streaming JSONL)")
102+
f.IntVar(&cfg.pid, "pid", 0, "FrankenPHP PID (auto-detected if not set)")
103+
f.StringVar(&cfg.expose, "expose", "", "Expose Prometheus metrics (e.g. :9191)")
104+
f.BoolVar(&cfg.daemon, "daemon", false, "Headless mode (requires --expose)")
62105

63-
pid := int32(cfg.pid)
64-
if pid == 0 {
65-
detected, err := fetcher.FindFrankenPHPProcess(ctx)
66-
if err != nil {
67-
detected, err = fetcher.FindCaddyProcess(ctx)
68-
if err != nil && (cfg.jsonMode || cfg.daemon) {
69-
fmt.Fprintf(os.Stderr, "warning: no frankenphp or caddy process found\n")
70-
}
71-
}
72-
if err == nil {
73-
pid = detected
74-
}
75-
}
106+
cmd.SetVersionTemplate("ember {{.Version}}\n")
76107

77-
f := fetcher.NewHTTPFetcher(cfg.addr, pid)
78-
hasFrankenPHP := f.DetectFrankenPHP(ctx)
79-
f.FetchServerNames(ctx)
108+
return cmd
109+
}
80110

81-
switch {
82-
case cfg.jsonMode:
83-
runJSON(ctx, f, cfg.interval)
84-
case cfg.daemon:
85-
runDaemon(ctx, f, &cfg)
86-
default:
87-
return runTUI(ctx, f, &cfg, hasFrankenPHP, version)
88-
}
89-
return nil
111+
func Run(args []string, version string) error {
112+
cmd := newRootCmd(version)
113+
cmd.SetArgs(args)
114+
return cmd.Execute()
90115
}
91116

92117
func validate(cfg *config) error {

0 commit comments

Comments
 (0)