Added full bubbletea TUI
Release / release (push) Has been cancelled

This commit is contained in:
2026-05-14 01:09:45 +00:00
parent d6f65c58f6
commit 1b14a3ed33
10 changed files with 1757 additions and 6 deletions
+40
View File
@@ -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 <host:port> TOFU-add a peer
qu node list show peers + liveness
qu node remove <node-id> 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)
```
+27 -4
View File
@@ -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
)
+56 -2
View File
@@ -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=
+1
View File
@@ -25,5 +25,6 @@ func NewRootCommand(version string) *cobra.Command {
addAlertCmd(root)
addTrustCmd(root)
addStatusCmd(root)
addTUICmd(root)
return root
}
+21
View File
@@ -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)
}
+87
View File
@@ -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
}
+592
View File
@@ -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
}
+77
View File
@@ -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")
}
+289
View File
@@ -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] + "…"
}
+567
View File
@@ -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]
}