diff --git a/README.md b/README.md index e4e88a6..39e3e23 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,44 @@ specific default by adding the alert's ID or name to its `suppress_alert_ids` list in `cluster.yaml` (see "Edit cluster.yaml directly" below). +## Interactive TUI + +Prefer a dashboard over typing commands? `qu tui` opens a full-screen +[bubbletea](https://github.com/charmbracelet/bubbletea) UI over the +local daemon socket. The header shows quorum, master, term, and config +version; three tabs hold peers, checks, and alerts with auto-refresh +every two seconds. + +``` +┌─ QUptime ── node: 88a00af9 master: 3438fd6f (follower) ● quorum 3/2 term 4 ver 10 ──┐ +│ Peers (3) [2] Checks (3) [3] Alerts (1) │ +├──────────────────────────────────────────────────────────────────────────────────────────────┤ +│ ID NAME STATE OK/TOTAL ALERTS DETAIL │ +│ ddbd... homepage ● up 3/3 oncall* │ +│ 0006... db ● down 1/3 oncall* dial timeout │ +│ 24f4... gateway ○ unknown 0/0 - │ +└──────────────────────────────────────────────────────────────────────────────────────────────┘ +↑↓ navigate ⇥ next tab 1/2/3 jump r refresh a add check d remove check q quit +``` + +Keybindings: + +| Key | Action | +|---|---| +| `↑` / `↓` | move cursor within a tab | +| `Tab` / `Shift+Tab` | next / previous tab | +| `1` / `2` / `3` | jump to Peers / Checks / Alerts | +| `r` | force-refresh | +| `a` | add (opens a picker on Checks/Alerts; node form on Peers) | +| `d` | remove the selected row (confirmation prompt) | +| `t` | send a test message to the selected alert | +| `D` | toggle the selected alert's `default` flag | +| `q` / `Ctrl+C` | quit | + +Forms run the same control-plane methods the CLI does, so any side +effect (a mutation, a node add, an alert test) ends up routed through +the master exactly like `qu …` from the shell. + ## Custom alert messages Each alert can carry its own `subject_template` and `body_template` @@ -292,6 +330,7 @@ sudo setcap cap_net_raw=+ep ./qu qu init generate identity + keys qu serve run the daemon qu status quorum, master, check states +qu tui interactive dashboard qu node add TOFU-add a peer qu node list show peers + liveness qu node remove remove from cluster + trust @@ -344,4 +383,5 @@ internal/checks/ HTTP/TCP/ICMP probers, scheduler, aggregator internal/alerts/ SMTP + Discord dispatchers, message rendering internal/daemon/ glue: wires every component + control socket internal/cli/ cobra commands, the user-facing surface +internal/tui/ bubbletea dashboard (qu tui) ``` diff --git a/go.mod b/go.mod index 0dfa046..8c733ed 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,41 @@ module git.cer.sh/axodouble/quptime -go 1.23.0 +go 1.24.2 require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/google/uuid v1.6.0 + github.com/prometheus-community/pro-bing v0.4.1 github.com/spf13/cobra v1.8.1 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/google/uuid v1.6.0 // indirect + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/prometheus-community/pro-bing v0.4.1 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/net v0.27.0 // indirect golang.org/x/sync v0.7.0 // indirect - golang.org/x/sys v0.22.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.16.0 // indirect ) diff --git a/go.sum b/go.sum index 8ba4924..23ecd98 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,75 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/prometheus-community/pro-bing v0.4.1 h1:aMaJwyifHZO0y+h8+icUz0xbToHbia0wdmzdVZ+Kl3w= github.com/prometheus-community/pro-bing v0.4.1/go.mod h1:aLsw+zqCaDoa2RLVVSX3+UiCkBBXTMtZC3c7EkfWnAE= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys= golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 6a459fa..95be3d3 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -25,5 +25,6 @@ func NewRootCommand(version string) *cobra.Command { addAlertCmd(root) addTrustCmd(root) addStatusCmd(root) + addTUICmd(root) return root } diff --git a/internal/cli/tui.go b/internal/cli/tui.go new file mode 100644 index 0000000..03dc2c6 --- /dev/null +++ b/internal/cli/tui.go @@ -0,0 +1,21 @@ +package cli + +import ( + "github.com/spf13/cobra" + + "git.cer.sh/axodouble/quptime/internal/tui" +) + +func addTUICmd(root *cobra.Command) { + cmd := &cobra.Command{ + Use: "tui", + Short: "Open the interactive terminal UI", + Long: "Open a full-screen TUI that overlays the same commands the CLI offers.\n" + + "The TUI is a thin client over the local daemon socket — start the daemon\n" + + "with `qu serve` before running this.", + RunE: func(cmd *cobra.Command, args []string) error { + return tui.Run() + }, + } + root.AddCommand(cmd) +} diff --git a/internal/tui/client.go b/internal/tui/client.go new file mode 100644 index 0000000..d106205 --- /dev/null +++ b/internal/tui/client.go @@ -0,0 +1,87 @@ +package tui + +import ( + "context" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "time" + + "git.cer.sh/axodouble/quptime/internal/config" + "git.cer.sh/axodouble/quptime/internal/daemon" +) + +// callDaemon is the same protocol the cli package uses against the +// local control socket — duplicated here so the TUI doesn't have to +// import the cli package (which would cycle). +func callDaemon(ctx context.Context, method string, body any) (json.RawMessage, error) { + var rawBody json.RawMessage + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + rawBody = b + } + req := daemon.CtrlRequest{Method: method, Body: rawBody} + reqBytes, err := json.Marshal(req) + if err != nil { + return nil, err + } + + sock := config.SocketPath() + d := net.Dialer{} + conn, err := d.DialContext(ctx, "unix", sock) + if err != nil { + return nil, fmt.Errorf("dial daemon socket %s: %w", sock, err) + } + defer conn.Close() + + if dl, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(dl) + } else { + _ = conn.SetDeadline(time.Now().Add(30 * time.Second)) + } + + if err := writeFrame(conn, reqBytes); err != nil { + return nil, err + } + respBytes, err := readFrame(conn) + if err != nil { + return nil, err + } + var resp daemon.CtrlResponse + if err := json.Unmarshal(respBytes, &resp); err != nil { + return nil, err + } + if resp.Error != "" { + return nil, errors.New(resp.Error) + } + return resp.Body, nil +} + +func writeFrame(w io.Writer, body []byte) error { + var hdr [4]byte + binary.BigEndian.PutUint32(hdr[:], uint32(len(body))) + if _, err := w.Write(hdr[:]); err != nil { + return err + } + _, err := w.Write(body) + return err +} + +func readFrame(r io.Reader) ([]byte, error) { + var hdr [4]byte + if _, err := io.ReadFull(r, hdr[:]); err != nil { + return nil, err + } + n := binary.BigEndian.Uint32(hdr[:]) + buf := make([]byte, n) + if _, err := io.ReadFull(r, buf); err != nil { + return nil, err + } + return buf, nil +} diff --git a/internal/tui/forms.go b/internal/tui/forms.go new file mode 100644 index 0000000..46cc363 --- /dev/null +++ b/internal/tui/forms.go @@ -0,0 +1,592 @@ +package tui + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/google/uuid" + + "git.cer.sh/axodouble/quptime/internal/config" + "git.cer.sh/axodouble/quptime/internal/daemon" + "git.cer.sh/axodouble/quptime/internal/transport" +) + +// modalDone tells the parent the modal is finished. Flash, when set, +// is shown as a one-shot status line; level controls the color. +type modalDone struct { + flash string + level flashLevel +} + +type flashLevel int + +const ( + flashInfo flashLevel = iota + flashWarn + flashError +) + +// modal is implemented by every pop-up form/dialog. Parent passes all +// input to the modal's Update; when the modal completes it returns +// modalDone as a tea.Msg via its tea.Cmd. +type modal interface { + Update(tea.Msg) (modal, tea.Cmd) + View() string + Title() string +} + +func modalDoneCmd(flash string, level flashLevel) tea.Cmd { + return func() tea.Msg { return modalDone{flash: flash, level: level} } +} + +// ============================================================= +// Generic field-based form (used by check/alert/node add flows). +// ============================================================= + +type formField struct { + label string + input textinput.Model + required bool + hint string +} + +type form struct { + title string + fields []formField + cursor int + busy bool + err string + + submit func(values []string) tea.Cmd +} + +func newForm(title string, fields []formField, submit func([]string) tea.Cmd) *form { + for i := range fields { + fields[i].input.Prompt = "" + fields[i].input.CharLimit = 256 + if i == 0 { + fields[i].input.Focus() + } else { + fields[i].input.Blur() + } + } + return &form{title: title, fields: fields, submit: submit} +} + +func textField(label, hint string, required bool) formField { + ti := textinput.New() + ti.Width = 40 + ti.Placeholder = hint + return formField{label: label, hint: hint, required: required, input: ti} +} + +func passwordField(label, hint string) formField { + ti := textinput.New() + ti.Width = 40 + ti.Placeholder = hint + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '•' + return formField{label: label, hint: hint, input: ti} +} + +func (f *form) Title() string { return f.title } + +func (f *form) View() string { + var b strings.Builder + fmt.Fprintf(&b, "%s\n\n", titleStyle.Render(f.title)) + for i, fld := range f.fields { + marker := " " + labelStyle := subtleStyle + if i == f.cursor { + marker = "▸ " + labelStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + } + fmt.Fprintf(&b, "%s%s\n", marker, labelStyle.Render(fld.label)) + fmt.Fprintf(&b, " %s\n", fld.input.View()) + if i == f.cursor && fld.hint != "" { + fmt.Fprintf(&b, " %s\n", helpStyle.Render(fld.hint)) + } + b.WriteByte('\n') + } + if f.err != "" { + fmt.Fprintf(&b, "%s\n\n", flashErrorStyle.Render("error: "+f.err)) + } + if f.busy { + fmt.Fprintf(&b, "%s\n", flashWarnStyle.Render("working…")) + } else { + fmt.Fprintf(&b, "%s\n", helpStyle.Render("↑↓ field enter next/submit esc cancel")) + } + return b.String() +} + +func (f *form) Update(msg tea.Msg) (modal, tea.Cmd) { + switch msg := msg.(type) { + case formSubmitErr: + f.busy = false + f.err = string(msg) + return f, nil + + case tea.KeyMsg: + switch msg.String() { + case "esc": + return f, modalDoneCmd("", flashInfo) + case "tab", "down": + f.advance(1) + return f, nil + case "shift+tab", "up": + f.advance(-1) + return f, nil + case "enter": + if f.busy { + return f, nil + } + if f.cursor < len(f.fields)-1 { + f.advance(1) + return f, nil + } + vals := make([]string, len(f.fields)) + for i, fld := range f.fields { + vals[i] = fld.input.Value() + } + for i, fld := range f.fields { + if fld.required && strings.TrimSpace(vals[i]) == "" { + f.err = fld.label + " is required" + f.cursor = i + f.focusOnly(i) + return f, nil + } + } + f.busy = true + f.err = "" + return f, f.submit(vals) + } + } + var cmd tea.Cmd + f.fields[f.cursor].input, cmd = f.fields[f.cursor].input.Update(msg) + return f, cmd +} + +func (f *form) advance(delta int) { + n := len(f.fields) + if n == 0 { + return + } + f.cursor = (f.cursor + delta + n) % n + f.focusOnly(f.cursor) +} + +func (f *form) focusOnly(i int) { + for j := range f.fields { + if j == i { + f.fields[j].input.Focus() + } else { + f.fields[j].input.Blur() + } + } +} + +// formSubmitErr is a tea.Msg the submit cmd returns to surface an +// error inline without closing the form. +type formSubmitErr string + +func submitErr(err error) tea.Cmd { + return func() tea.Msg { return formSubmitErr(err.Error()) } +} + +// ============================================================= +// Specific forms. +// ============================================================= + +func newAddCheckForm(checkType config.CheckType) *form { + fields := []formField{ + textField("Name", "human-friendly identifier", true), + textField("Target", targetHint(checkType), true), + textField("Interval", "e.g. 30s, 1m", false), + textField("Timeout", "e.g. 10s", false), + textField("Alerts", "comma-separated alert IDs/names (optional)", false), + } + if checkType == config.CheckHTTP { + fields = append(fields, + textField("Expect status", "e.g. 200 (HTTP only)", false), + textField("Body match", "substring required (HTTP only)", false), + ) + } + return newForm("Add "+strings.ToUpper(string(checkType))+" check", fields, func(vals []string) tea.Cmd { + return func() tea.Msg { + ch := config.Check{ + ID: uuid.NewString(), + Name: strings.TrimSpace(vals[0]), + Type: checkType, + Target: strings.TrimSpace(vals[1]), + Interval: parseDurationOr(vals[2], 30*time.Second), + Timeout: parseDurationOr(vals[3], 10*time.Second), + } + if a := strings.TrimSpace(vals[4]); a != "" { + for _, p := range strings.Split(a, ",") { + p = strings.TrimSpace(p) + if p != "" { + ch.AlertIDs = append(ch.AlertIDs, p) + } + } + } + if checkType == config.CheckHTTP { + ch.ExpectStatus = atoiOr(vals[5], 200) + ch.BodyMatch = strings.TrimSpace(vals[6]) + } + if err := mutateAdd(transport.MutationAddCheck, ch); err != nil { + return formSubmitErr(err.Error()) + } + return modalDone{flash: "added check " + ch.Name, level: flashInfo} + } + }) +} + +func newAddDiscordForm() *form { + fields := []formField{ + textField("Name", "human-friendly identifier", true), + textField("Webhook URL", "https://discord.com/api/webhooks/...", true), + textField("Default", "yes/no — attach to every check automatically", false), + textField("Body template", "leave empty for default formatting", false), + } + return newForm("Add Discord alert", fields, func(vals []string) tea.Cmd { + return func() tea.Msg { + a := config.Alert{ + ID: uuid.NewString(), + Name: strings.TrimSpace(vals[0]), + Type: config.AlertDiscord, + DiscordWebhook: strings.TrimSpace(vals[1]), + Default: parseBool(vals[2]), + BodyTemplate: vals[3], + } + if err := mutateAdd(transport.MutationAddAlert, a); err != nil { + return formSubmitErr(err.Error()) + } + return modalDone{flash: "added discord alert " + a.Name, level: flashInfo} + } + }) +} + +func newAddSMTPForm() *form { + fields := []formField{ + textField("Name", "human-friendly identifier", true), + textField("Host", "smtp.example.com", true), + textField("Port", "default 587", false), + textField("User", "leave empty for anonymous", false), + passwordField("Password", "smtp auth password"), + textField("From", "envelope From address", true), + textField("To", "comma-separated recipient addresses", true), + textField("StartTLS", "yes/no — default yes", false), + textField("Default", "yes/no — attach to every check", false), + textField("Subject template", "optional", false), + textField("Body template", "optional", false), + } + return newForm("Add SMTP alert", fields, func(vals []string) tea.Cmd { + return func() tea.Msg { + to := strings.Split(strings.TrimSpace(vals[6]), ",") + for i := range to { + to[i] = strings.TrimSpace(to[i]) + } + a := config.Alert{ + ID: uuid.NewString(), + Name: strings.TrimSpace(vals[0]), + Type: config.AlertSMTP, + SMTPHost: strings.TrimSpace(vals[1]), + SMTPPort: atoiOr(vals[2], 587), + SMTPUser: strings.TrimSpace(vals[3]), + SMTPPassword: vals[4], + SMTPFrom: strings.TrimSpace(vals[5]), + SMTPTo: to, + SMTPStartTLS: parseBoolOr(vals[7], true), + Default: parseBool(vals[8]), + SubjectTemplate: vals[9], + BodyTemplate: vals[10], + } + if err := mutateAdd(transport.MutationAddAlert, a); err != nil { + return formSubmitErr(err.Error()) + } + return modalDone{flash: "added smtp alert " + a.Name, level: flashInfo} + } + }) +} + +func newAddNodeForm() *form { + fields := []formField{ + textField("Address", "host:9901 of the peer to invite", true), + } + return newForm("Add node (TOFU)", fields, func(vals []string) tea.Cmd { + return func() tea.Msg { + addr := strings.TrimSpace(vals[0]) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + raw, err := callDaemon(ctx, daemon.CtrlNodeProbe, daemon.NodeProbeBody{Address: addr}) + if err != nil { + return formSubmitErr(fmt.Sprintf("probe: %v", err)) + } + var probe daemon.NodeProbeResult + if err := json.Unmarshal(raw, &probe); err != nil { + return formSubmitErr(err.Error()) + } + // auto-accept the fingerprint we just observed. The cluster + // secret check on the remote side already prevents random + // hosts from being trusted. + raw, err = callDaemon(ctx, daemon.CtrlNodeAdd, daemon.NodeAddBody{ + Address: addr, + Fingerprint: probe.Fingerprint, + }) + if err != nil { + return formSubmitErr(fmt.Sprintf("add: %v", err)) + } + var res daemon.NodeAddResult + _ = json.Unmarshal(raw, &res) + return modalDone{ + flash: fmt.Sprintf("added node %s — cluster version %d", res.NodeID, res.Version), + level: flashInfo, + } + } + }) +} + +// ============================================================= +// Pickers and confirmations. +// ============================================================= + +type pickerOption struct { + label string + hint string + choose func() modal + act func() tea.Cmd // if non-nil, picker returns this cmd directly instead of opening another modal +} + +type picker struct { + title string + options []pickerOption + cursor int +} + +func newPicker(title string, options []pickerOption) *picker { + return &picker{title: title, options: options} +} + +func (p *picker) Title() string { return p.title } + +func (p *picker) View() string { + var b strings.Builder + fmt.Fprintf(&b, "%s\n\n", titleStyle.Render(p.title)) + for i, o := range p.options { + marker := " " + style := subtleStyle + if i == p.cursor { + marker = "▸ " + style = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + } + fmt.Fprintf(&b, "%s%s\n", marker, style.Render(o.label)) + if o.hint != "" { + fmt.Fprintf(&b, " %s\n", helpStyle.Render(o.hint)) + } + } + fmt.Fprintf(&b, "\n%s\n", helpStyle.Render("↑↓ select enter pick esc cancel")) + return b.String() +} + +func (p *picker) Update(msg tea.Msg) (modal, tea.Cmd) { + km, ok := msg.(tea.KeyMsg) + if !ok { + return p, nil + } + switch km.String() { + case "esc": + return p, modalDoneCmd("", flashInfo) + case "up", "k": + if p.cursor > 0 { + p.cursor-- + } + return p, nil + case "down", "j": + if p.cursor < len(p.options)-1 { + p.cursor++ + } + return p, nil + case "enter": + if p.cursor < 0 || p.cursor >= len(p.options) { + return p, nil + } + opt := p.options[p.cursor] + if opt.act != nil { + return p, opt.act() + } + if opt.choose != nil { + return opt.choose(), nil + } + } + return p, nil +} + +// confirm asks yes/no and runs onConfirm if the user picks yes. +type confirm struct { + prompt string + onConfirm func() tea.Cmd + choice int // 0=no, 1=yes + busy bool + err string +} + +func newConfirm(prompt string, onConfirm func() tea.Cmd) *confirm { + return &confirm{prompt: prompt, onConfirm: onConfirm} +} + +func (c *confirm) Title() string { return "Confirm" } + +func (c *confirm) View() string { + noStyle, yesStyle := subtleStyle, subtleStyle + if c.choice == 0 { + noStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + } else { + yesStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) + } + body := fmt.Sprintf("%s\n\n [%s] [%s]\n", + c.prompt, + noStyle.Render("No"), + yesStyle.Render("Yes"), + ) + if c.err != "" { + body += "\n" + flashErrorStyle.Render("error: "+c.err) + "\n" + } + if c.busy { + body += "\n" + flashWarnStyle.Render("working…") + "\n" + } else { + body += "\n" + helpStyle.Render("←→ or h/l select enter confirm esc cancel") + } + return body +} + +func (c *confirm) Update(msg tea.Msg) (modal, tea.Cmd) { + switch msg := msg.(type) { + case formSubmitErr: + c.busy = false + c.err = string(msg) + return c, nil + case tea.KeyMsg: + switch msg.String() { + case "esc": + return c, modalDoneCmd("", flashInfo) + case "left", "right", "h", "l", "tab": + c.choice = 1 - c.choice + return c, nil + case "y", "Y": + c.choice = 1 + return c.commit() + case "n", "N": + return c, modalDoneCmd("cancelled", flashInfo) + case "enter": + if c.choice == 1 { + return c.commit() + } + return c, modalDoneCmd("cancelled", flashInfo) + } + } + return c, nil +} + +func (c *confirm) commit() (modal, tea.Cmd) { + if c.busy { + return c, nil + } + c.busy = true + c.err = "" + return c, c.onConfirm() +} + +// ============================================================= +// Helpers shared by submit closures. +// ============================================================= + +func mutateAdd(kind transport.MutationKind, payload any) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + raw, err := json.Marshal(payload) + if err != nil { + return err + } + body := daemon.MutateBody{Kind: kind, Payload: raw} + _, err = callDaemon(ctx, daemon.CtrlMutate, body) + return err +} + +func mutateRemove(kind transport.MutationKind, idOrName string) error { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + raw, err := json.Marshal(idOrName) + if err != nil { + return err + } + body := daemon.MutateBody{Kind: kind, Payload: raw} + _, err = callDaemon(ctx, daemon.CtrlMutate, body) + return err +} + +func targetHint(t config.CheckType) string { + switch t { + case config.CheckHTTP: + return "https://example.com/health" + case config.CheckTCP: + return "db.internal:5432" + case config.CheckICMP: + return "10.0.0.1" + } + return "" +} + +func parseDurationOr(s string, fallback time.Duration) time.Duration { + s = strings.TrimSpace(s) + if s == "" { + return fallback + } + d, err := time.ParseDuration(s) + if err != nil { + return fallback + } + return d +} + +func atoiOr(s string, fallback int) int { + s = strings.TrimSpace(s) + if s == "" { + return fallback + } + var n int + for _, r := range s { + if r < '0' || r > '9' { + return fallback + } + n = n*10 + int(r-'0') + } + return n +} + +func parseBool(s string) bool { + switch strings.ToLower(strings.TrimSpace(s)) { + case "yes", "y", "true", "t", "on", "1": + return true + } + return false +} + +func parseBoolOr(s string, fallback bool) bool { + s = strings.ToLower(strings.TrimSpace(s)) + if s == "" { + return fallback + } + switch s { + case "yes", "y", "true", "t", "on", "1": + return true + case "no", "n", "false", "f", "off", "0": + return false + } + return fallback +} diff --git a/internal/tui/styles.go b/internal/tui/styles.go new file mode 100644 index 0000000..f7f55c5 --- /dev/null +++ b/internal/tui/styles.go @@ -0,0 +1,77 @@ +package tui + +import "github.com/charmbracelet/lipgloss" + +var ( + colorBorder = lipgloss.Color("63") // soft purple + colorAccent = lipgloss.Color("212") // pink + colorMuted = lipgloss.Color("241") // gray + colorSuccess = lipgloss.Color("42") // green + colorWarn = lipgloss.Color("214") // orange + colorError = lipgloss.Color("196") // red + colorTabActive = lipgloss.Color("212") + colorTabIdle = lipgloss.Color("241") +) + +var ( + titleStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("230")). + Background(colorAccent). + Bold(true). + Padding(0, 1) + + subtleStyle = lipgloss.NewStyle().Foreground(colorMuted) + + headerStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(0, 1) + + tabActiveStyle = lipgloss.NewStyle(). + Foreground(colorTabActive). + Bold(true). + Underline(true). + Padding(0, 1) + + tabIdleStyle = lipgloss.NewStyle(). + Foreground(colorTabIdle). + Padding(0, 1) + + bodyStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(colorBorder). + Padding(0, 1) + + helpStyle = lipgloss.NewStyle().Foreground(colorMuted) + + flashInfoStyle = lipgloss.NewStyle().Foreground(colorSuccess).Bold(true) + flashErrorStyle = lipgloss.NewStyle().Foreground(colorError).Bold(true) + flashWarnStyle = lipgloss.NewStyle().Foreground(colorWarn).Bold(true) + + modalStyle = lipgloss.NewStyle(). + Border(lipgloss.DoubleBorder()). + BorderForeground(colorAccent). + Padding(1, 2) + + stateUpStyle = lipgloss.NewStyle().Foreground(colorSuccess).Bold(true) + stateDownStyle = lipgloss.NewStyle().Foreground(colorError).Bold(true) + stateUnknownStyle = lipgloss.NewStyle().Foreground(colorMuted) +) + +func renderState(s string) string { + switch s { + case "up": + return stateUpStyle.Render("● up") + case "down": + return stateDownStyle.Render("● down") + default: + return stateUnknownStyle.Render("○ unknown") + } +} + +func renderLive(live bool) string { + if live { + return stateUpStyle.Render("● live") + } + return stateDownStyle.Render("● dead") +} diff --git a/internal/tui/tabs.go b/internal/tui/tabs.go new file mode 100644 index 0000000..e641b59 --- /dev/null +++ b/internal/tui/tabs.go @@ -0,0 +1,289 @@ +package tui + +import ( + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "git.cer.sh/axodouble/quptime/internal/transport" +) + +// tabModel is the small surface every tab implements. Tabs share the +// same Update/View shape so the parent can dispatch generically. +type tabModel interface { + Update(tea.Msg) (tabModel, tea.Cmd) + View() string + SetSize(width, height int) + // Selected returns the row identifier for the row under the cursor, + // or "" if the table is empty. For peers/nodes this is a NodeID; + // for checks it's a CheckID; for alerts it's an AlertID. + Selected() string + // SelectedName returns a human-friendly label for the selected row + // (used in confirm dialogs). + SelectedName() string +} + +// peersTab — read-only view of cluster membership. +type peersTab struct { + tbl table.Model +} + +func newPeersTab() *peersTab { + cols := []table.Column{ + {Title: "NODE_ID", Width: 38}, + {Title: "ADVERTISE", Width: 28}, + {Title: "LIVE", Width: 8}, + {Title: "LAST SEEN", Width: 22}, + } + t := table.New(table.WithColumns(cols), table.WithFocused(true)) + t.SetStyles(tableStyles()) + return &peersTab{tbl: t} +} + +func (p *peersTab) Update(msg tea.Msg) (tabModel, tea.Cmd) { + var cmd tea.Cmd + p.tbl, cmd = p.tbl.Update(msg) + return p, cmd +} + +func (p *peersTab) View() string { return p.tbl.View() } + +func (p *peersTab) SetSize(w, h int) { + p.tbl.SetWidth(w) + p.tbl.SetHeight(h) +} + +func (p *peersTab) Selected() string { + r := p.tbl.SelectedRow() + if r == nil { + return "" + } + return r[0] +} + +func (p *peersTab) SelectedName() string { return p.Selected() } + +func (p *peersTab) Refresh(st transport.StatusResponse, selfID string) { + rows := make([]table.Row, 0, len(st.Peers)) + for _, peer := range st.Peers { + lastSeen := "-" + if !peer.LastSeen.IsZero() { + lastSeen = peer.LastSeen.UTC().Format(time.RFC3339) + } + id := peer.NodeID + if peer.NodeID == selfID { + id = "* " + peer.NodeID + } + rows = append(rows, table.Row{id, peer.Advertise, livenessText(peer.Live), lastSeen}) + } + p.tbl.SetRows(rows) +} + +// checksTab — checks with state and effective alerts. +type checksTab struct { + tbl table.Model +} + +func newChecksTab() *checksTab { + cols := []table.Column{ + {Title: "ID", Width: 38}, + {Title: "NAME", Width: 18}, + {Title: "STATE", Width: 12}, + {Title: "OK/TOTAL", Width: 10}, + {Title: "ALERTS", Width: 24}, + {Title: "DETAIL", Width: 40}, + } + t := table.New(table.WithColumns(cols), table.WithFocused(true)) + t.SetStyles(tableStyles()) + return &checksTab{tbl: t} +} + +func (c *checksTab) Update(msg tea.Msg) (tabModel, tea.Cmd) { + var cmd tea.Cmd + c.tbl, cmd = c.tbl.Update(msg) + return c, cmd +} + +func (c *checksTab) View() string { return c.tbl.View() } + +func (c *checksTab) SetSize(w, h int) { + c.tbl.SetWidth(w) + c.tbl.SetHeight(h) +} + +func (c *checksTab) Selected() string { + r := c.tbl.SelectedRow() + if r == nil { + return "" + } + return r[0] +} + +func (c *checksTab) SelectedName() string { + r := c.tbl.SelectedRow() + if r == nil { + return "" + } + return r[1] +} + +func (c *checksTab) Refresh(st transport.StatusResponse) { + rows := make([]table.Row, 0, len(st.Checks)) + for _, ch := range st.Checks { + okTotal := lipgloss.NewStyle().Render("0/0") + if ch.Total > 0 { + okTotal = lipgloss.NewStyle().Render(itoa(ch.OKCount) + "/" + itoa(ch.Total)) + } + alerts := strings.Join(ch.Alerts, ",") + if alerts == "" { + alerts = "-" + } + rows = append(rows, table.Row{ + ch.CheckID, ch.Name, renderState(ch.State), okTotal, alerts, truncate(ch.Detail, 38), + }) + } + c.tbl.SetRows(rows) +} + +// alertsTab — configured notification channels. +type alertsTab struct { + tbl table.Model + alerts []alertRow +} + +type alertRow struct { + ID string + Name string + Type string + Default bool + HasTmpl bool + Endpoint string +} + +func newAlertsTab() *alertsTab { + cols := []table.Column{ + {Title: "ID", Width: 38}, + {Title: "NAME", Width: 16}, + {Title: "TYPE", Width: 10}, + {Title: "DEFAULT", Width: 8}, + {Title: "CUSTOM-MSG", Width: 11}, + {Title: "ENDPOINT", Width: 36}, + } + t := table.New(table.WithColumns(cols), table.WithFocused(true)) + t.SetStyles(tableStyles()) + return &alertsTab{tbl: t} +} + +func (a *alertsTab) Update(msg tea.Msg) (tabModel, tea.Cmd) { + var cmd tea.Cmd + a.tbl, cmd = a.tbl.Update(msg) + return a, cmd +} + +func (a *alertsTab) View() string { return a.tbl.View() } + +func (a *alertsTab) SetSize(w, h int) { + a.tbl.SetWidth(w) + a.tbl.SetHeight(h) +} + +func (a *alertsTab) Selected() string { + r := a.tbl.SelectedRow() + if r == nil { + return "" + } + return r[0] +} + +func (a *alertsTab) SelectedName() string { + r := a.tbl.SelectedRow() + if r == nil { + return "" + } + return r[1] +} + +// SelectedAlert returns the row metadata for the cursor, so the parent +// can flip the default flag without a roundtrip. +func (a *alertsTab) SelectedAlert() *alertRow { + idx := a.tbl.Cursor() + if idx < 0 || idx >= len(a.alerts) { + return nil + } + cp := a.alerts[idx] + return &cp +} + +func (a *alertsTab) Refresh(alerts []alertRow) { + a.alerts = alerts + rows := make([]table.Row, 0, len(alerts)) + for _, r := range alerts { + def := "-" + if r.Default { + def = "yes" + } + tmpl := "-" + if r.HasTmpl { + tmpl = "yes" + } + rows = append(rows, table.Row{r.ID, r.Name, r.Type, def, tmpl, truncate(r.Endpoint, 34)}) + } + a.tbl.SetRows(rows) +} + +func tableStyles() table.Styles { + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(colorBorder). + BorderBottom(true). + Bold(true) + s.Selected = s.Selected. + Foreground(lipgloss.Color("230")). + Background(colorAccent). + Bold(true) + return s +} + +func livenessText(live bool) string { + if live { + return "live" + } + return "dead" +} + +func itoa(i int) string { + // avoid pulling fmt in the hot path of refresh + if i == 0 { + return "0" + } + neg := i < 0 + if neg { + i = -i + } + var buf [20]byte + pos := len(buf) + for i > 0 { + pos-- + buf[pos] = byte('0' + i%10) + i /= 10 + } + if neg { + pos-- + buf[pos] = '-' + } + return string(buf[pos:]) +} + +func truncate(s string, max int) string { + if max <= 0 || len(s) <= max { + return s + } + if max < 4 { + return s[:max] + } + return s[:max-1] + "…" +} diff --git a/internal/tui/tui.go b/internal/tui/tui.go new file mode 100644 index 0000000..0b3c78c --- /dev/null +++ b/internal/tui/tui.go @@ -0,0 +1,567 @@ +// Package tui implements the interactive overview/control surface +// reachable via `qu tui`. It is a thin bubbletea client over the same +// unix control socket the CLI uses; nothing here talks to peers +// directly. +package tui + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "git.cer.sh/axodouble/quptime/internal/config" + "git.cer.sh/axodouble/quptime/internal/daemon" + "git.cer.sh/axodouble/quptime/internal/transport" +) + +const refreshInterval = 2 * time.Second + +// Run starts the bubbletea program. Blocks until the user quits. +func Run() error { + m := initialModel() + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err := p.Run() + return err +} + +type tabIndex int + +const ( + tabPeers tabIndex = iota + tabChecks + tabAlerts +) + +var tabNames = []string{"Peers", "Checks", "Alerts"} + +type model struct { + width, height int + + status transport.StatusResponse + statusLoaded bool + statusErr string + + // Cached alerts come from cluster.yaml directly (the daemon status + // only ships per-check effective alert names). We need full Alert + // records to render the alerts tab and to support default-toggle. + alerts []config.Alert + + active tabIndex + peers *peersTab + checks *checksTab + alertsT *alertsTab + + modal modal + + flash string + flashLevel flashLevel + flashUntil time.Time +} + +func initialModel() model { + return model{ + peers: newPeersTab(), + checks: newChecksTab(), + alertsT: newAlertsTab(), + } +} + +// ============================================================= +// Bubbletea lifecycle. +// ============================================================= + +func (m model) Init() tea.Cmd { + return tea.Batch(loadStatusCmd(), loadAlertsCmd(), tickCmd()) +} + +type tickMsg time.Time + +type statusMsg struct { + st transport.StatusResponse + err error +} + +type alertsMsg struct { + alerts []config.Alert + err error +} + +func tickCmd() tea.Cmd { + return tea.Tick(refreshInterval, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +func loadStatusCmd() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + raw, err := callDaemon(ctx, daemon.CtrlStatus, nil) + if err != nil { + return statusMsg{err: err} + } + var st transport.StatusResponse + if err := json.Unmarshal(raw, &st); err != nil { + return statusMsg{err: err} + } + return statusMsg{st: st} + } +} + +func loadAlertsCmd() tea.Cmd { + return func() tea.Msg { + cfg, err := config.LoadClusterConfig() + if err != nil { + return alertsMsg{err: err} + } + snap := cfg.Snapshot() + return alertsMsg{alerts: snap.Alerts} + } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.resizeTabs() + return m, nil + + case tickMsg: + return m, tea.Batch(loadStatusCmd(), loadAlertsCmd(), tickCmd()) + + case statusMsg: + if msg.err != nil { + m.statusErr = msg.err.Error() + } else { + m.statusErr = "" + m.status = msg.st + m.statusLoaded = true + m.peers.Refresh(msg.st, msg.st.NodeID) + m.checks.Refresh(msg.st) + } + return m, nil + + case alertsMsg: + if msg.err == nil { + m.alerts = msg.alerts + m.alertsT.Refresh(toAlertRows(msg.alerts)) + } + return m, nil + + case modalDone: + m.modal = nil + if msg.flash != "" { + m.setFlash(msg.flash, msg.level) + } + // Force-refresh in case the modal mutated cluster state. + return m, tea.Batch(loadStatusCmd(), loadAlertsCmd()) + } + + // Modal grabs all input while open. + if m.modal != nil { + newModal, cmd := m.modal.Update(msg) + m.modal = newModal + return m, cmd + } + + if km, ok := msg.(tea.KeyMsg); ok { + return m.handleKey(km) + } + + // Pass through to the active tab so j/k/PgUp/PgDn scroll the table. + switch m.active { + case tabPeers: + _, cmd := m.peers.Update(msg) + return m, cmd + case tabChecks: + _, cmd := m.checks.Update(msg) + return m, cmd + case tabAlerts: + _, cmd := m.alertsT.Update(msg) + return m, cmd + } + return m, nil +} + +func (m model) handleKey(km tea.KeyMsg) (tea.Model, tea.Cmd) { + switch km.String() { + case "ctrl+c", "q": + return m, tea.Quit + case "tab", "right", "L": + m.active = (m.active + 1) % 3 + return m, nil + case "shift+tab", "left", "H": + m.active = (m.active + 2) % 3 + return m, nil + case "1": + m.active = tabPeers + return m, nil + case "2": + m.active = tabChecks + return m, nil + case "3": + m.active = tabAlerts + return m, nil + case "r": + m.setFlash("refreshing…", flashInfo) + return m, tea.Batch(loadStatusCmd(), loadAlertsCmd()) + case "a": + m.modal = m.openAddPicker() + return m, nil + case "d": + return m.openRemoveConfirm() + case "t": + if m.active == tabAlerts { + return m.testSelectedAlert() + } + case "D": + if m.active == tabAlerts { + return m.toggleSelectedDefault() + } + } + + // Forward everything else (arrow keys etc.) to the active tab. + switch m.active { + case tabPeers: + _, cmd := m.peers.Update(km) + return m, cmd + case tabChecks: + _, cmd := m.checks.Update(km) + return m, cmd + case tabAlerts: + _, cmd := m.alertsT.Update(km) + return m, cmd + } + return m, nil +} + +// ============================================================= +// View. +// ============================================================= + +func (m model) View() string { + if m.width == 0 { + return "loading…" + } + header := m.renderHeader() + tabs := m.renderTabs() + body := m.renderActiveTab() + help := m.renderHelp() + + page := lipgloss.JoinVertical(lipgloss.Left, header, tabs, body, m.renderFlash(), help) + + if m.modal != nil { + overlay := modalStyle.Render(m.modal.View()) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, overlay, lipgloss.WithWhitespaceChars(" ")) + } + return page +} + +func (m model) renderHeader() string { + if !m.statusLoaded { + msg := "connecting to daemon…" + if m.statusErr != "" { + msg = "daemon: " + m.statusErr + } + return headerStyle.Width(m.width - 2).Render(titleStyle.Render("QUptime") + " " + helpStyle.Render(msg)) + } + st := m.status + quorum := stateDownStyle.Render("● no quorum") + if st.HasQuorum { + quorum = stateUpStyle.Render(fmt.Sprintf("● quorum %d/%d", liveCount(st.Peers), st.QuorumSize)) + } + master := stateUnknownStyle.Render("master: —") + if st.MasterID != "" { + master = "master: " + shortID(st.MasterID) + } + role := "" + if st.NodeID == st.MasterID { + role = stateUpStyle.Render("(you are master)") + } else { + role = subtleStyle.Render("(follower)") + } + left := lipgloss.JoinHorizontal(lipgloss.Top, + titleStyle.Render("QUptime"), + " ", + "node: "+shortID(st.NodeID), + " ", + master, + " ", + role, + ) + right := lipgloss.JoinHorizontal(lipgloss.Top, + quorum, + " ", + subtleStyle.Render(fmt.Sprintf("term %d ver %d", st.Term, st.Version)), + ) + width := m.width - 2 + if width < 20 { + width = 20 + } + gap := width - lipgloss.Width(left) - lipgloss.Width(right) + if gap < 1 { + gap = 1 + } + row := left + strings.Repeat(" ", gap) + right + return headerStyle.Width(width).Render(row) +} + +func (m model) renderTabs() string { + parts := make([]string, len(tabNames)) + for i, name := range tabNames { + count := "" + switch tabIndex(i) { + case tabPeers: + count = fmt.Sprintf(" (%d)", len(m.status.Peers)) + case tabChecks: + count = fmt.Sprintf(" (%d)", len(m.status.Checks)) + case tabAlerts: + count = fmt.Sprintf(" (%d)", len(m.alerts)) + } + label := name + count + if tabIndex(i) == m.active { + parts[i] = tabActiveStyle.Render(label) + } else { + parts[i] = tabIdleStyle.Render(fmt.Sprintf("[%d] %s", i+1, label)) + } + } + return lipgloss.JoinHorizontal(lipgloss.Top, parts...) +} + +func (m model) renderActiveTab() string { + var view string + switch m.active { + case tabPeers: + view = m.peers.View() + case tabChecks: + view = m.checks.View() + case tabAlerts: + view = m.alertsT.View() + } + return bodyStyle.Width(m.width - 2).Render(view) +} + +func (m model) renderHelp() string { + specific := "" + switch m.active { + case tabPeers: + specific = "a add node d remove node" + case tabChecks: + specific = "a add check d remove check" + case tabAlerts: + specific = "a add alert d remove alert t test D toggle default" + } + return helpStyle.Render(fmt.Sprintf("↑↓ navigate ⇥ next tab 1/2/3 jump r refresh %s q quit", specific)) +} + +func (m model) renderFlash() string { + if m.flash == "" || time.Now().After(m.flashUntil) { + return "" + } + switch m.flashLevel { + case flashError: + return flashErrorStyle.Render(m.flash) + case flashWarn: + return flashWarnStyle.Render(m.flash) + default: + return flashInfoStyle.Render(m.flash) + } +} + +// ============================================================= +// Actions. +// ============================================================= + +func (m model) openAddPicker() modal { + switch m.active { + case tabPeers: + return newAddNodeForm() + case tabChecks: + return newPicker("Add check — pick type", []pickerOption{ + {label: "HTTP", hint: "url + status code", choose: func() modal { return newAddCheckForm(config.CheckHTTP) }}, + {label: "TCP", hint: "host:port connect", choose: func() modal { return newAddCheckForm(config.CheckTCP) }}, + {label: "ICMP", hint: "ping a host", choose: func() modal { return newAddCheckForm(config.CheckICMP) }}, + }) + case tabAlerts: + return newPicker("Add alert — pick type", []pickerOption{ + {label: "Discord", hint: "webhook URL", choose: func() modal { return newAddDiscordForm() }}, + {label: "SMTP", hint: "email via relay", choose: func() modal { return newAddSMTPForm() }}, + }) + } + return nil +} + +func (m model) openRemoveConfirm() (tea.Model, tea.Cmd) { + var prompt string + var run func() tea.Cmd + switch m.active { + case tabPeers: + id := m.peers.Selected() + name := strings.TrimPrefix(m.peers.SelectedName(), "* ") + if id == "" { + return m, nil + } + id = strings.TrimPrefix(id, "* ") + prompt = fmt.Sprintf("Remove peer %s from the cluster?\nThis revokes trust and updates cluster.yaml.", shortID(name)) + run = func() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + if _, err := callDaemon(ctx, daemon.CtrlNodeRemove, daemon.NodeRemoveBody{NodeID: id}); err != nil { + return formSubmitErr(err.Error()) + } + return modalDone{flash: "removed node " + shortID(id), level: flashInfo} + } + } + case tabChecks: + id := m.checks.Selected() + name := m.checks.SelectedName() + if id == "" { + return m, nil + } + prompt = fmt.Sprintf("Remove check %q?", name) + run = func() tea.Cmd { + return func() tea.Msg { + if err := mutateRemove(transport.MutationRemoveCheck, id); err != nil { + return formSubmitErr(err.Error()) + } + return modalDone{flash: "removed check " + name, level: flashInfo} + } + } + case tabAlerts: + id := m.alertsT.Selected() + name := m.alertsT.SelectedName() + if id == "" { + return m, nil + } + prompt = fmt.Sprintf("Remove alert %q?", name) + run = func() tea.Cmd { + return func() tea.Msg { + if err := mutateRemove(transport.MutationRemoveAlert, id); err != nil { + return formSubmitErr(err.Error()) + } + return modalDone{flash: "removed alert " + name, level: flashInfo} + } + } + default: + return m, nil + } + m.modal = newConfirm(prompt, run) + return m, nil +} + +func (m model) testSelectedAlert() (tea.Model, tea.Cmd) { + id := m.alertsT.Selected() + if id == "" { + return m, nil + } + name := m.alertsT.SelectedName() + m.setFlash("sending test to "+name+"…", flashInfo) + return m, func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if _, err := callDaemon(ctx, daemon.CtrlAlertTest, daemon.AlertTestBody{AlertID: id}); err != nil { + return modalDone{flash: "test failed: " + err.Error(), level: flashError} + } + return modalDone{flash: "test sent via " + name, level: flashInfo} + } +} + +func (m model) toggleSelectedDefault() (tea.Model, tea.Cmd) { + row := m.alertsT.SelectedAlert() + if row == nil { + return m, nil + } + var target *config.Alert + for i := range m.alerts { + if m.alerts[i].ID == row.ID { + cp := m.alerts[i] + target = &cp + break + } + } + if target == nil { + m.setFlash("alert not found in local cluster.yaml", flashError) + return m, nil + } + target.Default = !target.Default + name := target.Name + newState := target.Default + return m, func() tea.Msg { + if err := mutateAdd(transport.MutationAddAlert, target); err != nil { + return modalDone{flash: "toggle failed: " + err.Error(), level: flashError} + } + state := "off" + if newState { + state = "on" + } + return modalDone{flash: fmt.Sprintf("alert %s default=%s", name, state), level: flashInfo} + } +} + +// ============================================================= +// Small helpers. +// ============================================================= + +func (m *model) setFlash(s string, level flashLevel) { + m.flash = s + m.flashLevel = level + m.flashUntil = time.Now().Add(4 * time.Second) +} + +func (m *model) resizeTabs() { + bodyH := m.height - 8 + if bodyH < 5 { + bodyH = 5 + } + bodyW := m.width - 4 + if bodyW < 20 { + bodyW = 20 + } + m.peers.SetSize(bodyW, bodyH) + m.checks.SetSize(bodyW, bodyH) + m.alertsT.SetSize(bodyW, bodyH) +} + +func toAlertRows(alerts []config.Alert) []alertRow { + out := make([]alertRow, 0, len(alerts)) + for _, a := range alerts { + endpoint := "" + switch a.Type { + case config.AlertDiscord: + endpoint = a.DiscordWebhook + case config.AlertSMTP: + endpoint = fmt.Sprintf("%s:%d → %s", a.SMTPHost, a.SMTPPort, strings.Join(a.SMTPTo, ",")) + } + out = append(out, alertRow{ + ID: a.ID, + Name: a.Name, + Type: string(a.Type), + Default: a.Default, + HasTmpl: a.SubjectTemplate != "" || a.BodyTemplate != "", + Endpoint: endpoint, + }) + } + return out +} + +func liveCount(peers []transport.PeerLiveness) int { + n := 0 + for _, p := range peers { + if p.Live { + n++ + } + } + return n +} + +func shortID(id string) string { + if len(id) <= 8 { + return id + } + return id[:8] +} +