13 Commits

Author SHA1 Message Date
Axodouble 7b45c8fcf0 Updated readme with the correct text formatting options
Release / release (push) Successful in 11m40s
2026-05-14 06:38:02 +00:00
Axodouble cbb311d877 Added helper variables and templating for custom messages 2026-05-14 06:26:00 +00:00
Axodouble d30dd5906a Updated exit conditions 2026-05-14 06:00:44 +00:00
Axodouble 40c0d9e5a0 Another attempt at fixing the autoinstall 2026-05-14 05:47:27 +00:00
Axodouble d1913c4278 Added correct build name in script 2026-05-14 05:45:26 +00:00
Axodouble eedd86e571 Updated subshell for release tag 2026-05-14 05:44:35 +00:00
Axodouble c07079497b Added check for used commands 2026-05-14 05:43:42 +00:00
Axodouble 4cfd7159bf Updated install scripts 2026-05-14 05:42:14 +00:00
Axodouble 624d8d8e44 Added the ability to edit entries in the CLI & TUI
Release / release (push) Successful in 12m26s
2026-05-14 05:18:23 +00:00
Axodouble ce5c089413 Fixed issue on 48x91 terminal size causing top two lines to disappear 2026-05-14 05:02:06 +00:00
Axodouble 2d192f3a32 Added better compatibility for the TUI in smaller terminals 2026-05-14 03:43:54 +00:00
Axodouble 481839d348 Update README.md 2026-05-14 01:10:58 +00:00
Axodouble 1b14a3ed33 Added full bubbletea TUI
Release / release (push) Has been cancelled
2026-05-14 01:09:45 +00:00
15 changed files with 2532 additions and 46 deletions
+72 -16
View File
@@ -10,6 +10,16 @@ A single static binary contains the daemon, the CLI, and everything in
between. Inter-node traffic is mutual TLS with SSH-style fingerprint between. Inter-node traffic is mutual TLS with SSH-style fingerprint
trust — no central CA, no shared secret. trust — no central CA, no shared secret.
## Installation
### From pre-built binary
This can be done in one step, either by downloading the latest release from
the [Gitea releases page](https://git.cer.sh/axodouble/quptime/releases) or by running the following script:
```sh
curl -fsSL https://git.cer.sh/Axodouble/QUptime/raw/branch/master/install.sh | sudo bash
```
## Why ## Why
Most uptime monitors are either a SaaS or a single box that, by Most uptime monitors are either a SaaS or a single box that, by
@@ -52,7 +62,7 @@ and the daemon will replicate the edit cluster-wide.
## Build ## Build
Requires Go 1.23 or newer. Requires Go 1.24.2 or newer.
```sh ```sh
go build -o qu ./cmd/qu go build -o qu ./cmd/qu
@@ -188,6 +198,44 @@ specific default by adding the alert's ID or name to its
`suppress_alert_ids` list in `cluster.yaml` (see "Edit cluster.yaml `suppress_alert_ids` list in `cluster.yaml` (see "Edit cluster.yaml
directly" below). 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 ## Custom alert messages
Each alert can carry its own `subject_template` and `body_template` Each alert can carry its own `subject_template` and `body_template`
@@ -209,21 +257,27 @@ qu alert add smtp ops --host ... --from ... --to ... \
Available template variables: Available template variables:
| Variable | Meaning | | Variable | Meaning |
|---|---| | ----------------------- | ------------------------------------------ |
| `{{.Check.Name}}` | check name | | `{{.Check.Name}}` | check name |
| `{{.Check.Type}}` | `http` / `tcp` / `icmp` | | `{{.Check.Type}}` | `http` / `tcp` / `icmp` |
| `{{.Check.Target}}` | URL or host:port being probed | | `{{.Check.Target}}` | URL or host:port being probed |
| `{{.Check.ID}}` | UUID | | `{{.Check.ID}}` | UUID |
| `{{.From}}` | previous state (`up` / `down` / `unknown`) | | `{{.From}}` | previous state (`up` / `down` / `unknown`) |
| `{{.To}}` | new state | | `{{.To}}` | new state |
| `{{.Verb}}` | `UP` / `DOWN` / `RECOVERED` | | `{{.Verb}}` | `UP` / `DOWN` / `RECOVERED` |
| `{{.Snapshot.Reports}}` | total per-node reports counted | | `{{.Snapshot.Reports}}` | total per-node reports counted |
| `{{.Snapshot.OKCount}}` | how many reported OK | | `{{.Snapshot.OKCount}}` | how many reported OK |
| `{{.Snapshot.NotOK}}` | how many reported failure | | `{{.Snapshot.NotOK}}` | how many reported failure |
| `{{.Snapshot.Detail}}` | first failure detail string | | `{{.Snapshot.Detail}}` | first failure detail string |
| `{{.NodeID}}` | master that dispatched | | `{{.NodeID}}` | master that dispatched |
| `{{.When}}` | RFC3339 timestamp | | `{{.When}}` | RFC3339 timestamp |
The same variable list is surfaced in-app: `qu alert add smtp --help`,
`qu alert add discord --help`, and `qu alert edit --help` each print
it under their flag table, and `qu tui` shows a compact reminder of
the supported variables as a hint when the cursor lands on a Subject
or Body template field in the add/edit alert forms.
`qu alert test <name>` exercises the template against a synthetic `qu alert test <name>` exercises the template against a synthetic
"homepage going DOWN" transition, so you can verify rendering before "homepage going DOWN" transition, so you can verify rendering before
@@ -292,6 +346,7 @@ sudo setcap cap_net_raw=+ep ./qu
qu init generate identity + keys qu init generate identity + keys
qu serve run the daemon qu serve run the daemon
qu status quorum, master, check states qu status quorum, master, check states
qu tui interactive dashboard
qu node add <host:port> TOFU-add a peer qu node add <host:port> TOFU-add a peer
qu node list show peers + liveness qu node list show peers + liveness
qu node remove <node-id> remove from cluster + trust qu node remove <node-id> remove from cluster + trust
@@ -344,4 +399,5 @@ internal/checks/ HTTP/TCP/ICMP probers, scheduler, aggregator
internal/alerts/ SMTP + Discord dispatchers, message rendering internal/alerts/ SMTP + Discord dispatchers, message rendering
internal/daemon/ glue: wires every component + control socket internal/daemon/ glue: wires every component + control socket
internal/cli/ cobra commands, the user-facing surface 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 module git.cer.sh/axodouble/quptime
go 1.23.0 go 1.24.2
require ( 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 github.com/spf13/cobra v1.8.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( 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/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/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/net v0.27.0 // indirect
golang.org/x/sync v0.7.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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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 h1:aMaJwyifHZO0y+h8+icUz0xbToHbia0wdmzdVZ+Kl3w=
github.com/prometheus-community/pro-bing v0.4.1/go.mod h1:aLsw+zqCaDoa2RLVVSX3+UiCkBBXTMtZC3c7EkfWnAE= 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/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 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 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 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 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 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE= 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 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+55 -19
View File
@@ -1,24 +1,60 @@
#!/bin/bash #!/bin/bash
# Check if ~/.local/bin exists, if not, create it # Helper function which echo's all commands before executing them in grayscale prefixed with >
if [ ! -d "$HOME/.local/bin" ]; then echo_cmd() {
mkdir -p "$HOME/.local/bin" echo -e "\033[90m> $1\033[0m"
fi eval "$1"
}
# Check if ~/.local/bin is in the PATH, if not, give the user a command to add it # Check if jq and curl are installed, if not, error out and ask the user to install them
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then if ! command -v jq > /dev/null; then
echo "Please add the following line to your shell configuration file (e.g., ~/.bashrc, ~/.zshrc) to include ~/.local/bin in your PATH:" echo "Error: jq is not installed. Please install jq and try again."
echo 'export PATH="$HOME/.local/bin:$PATH"'
echo "After adding the line, please restart your terminal or run 'source ~/.bashrc' (or the appropriate command for your shell) to apply the changes."
fi
# Download the binary from git.cer.sh/axodouble/quptime
# Check whether curl or wget is available
if command -v curl > /dev/null; then
curl -L -o "$HOME/.local/bin/quptime" "https://git.cer.sh/axodouble/quptime/-/raw/main/quptime"
elif command -v wget > /dev/null; then
wget -O "$HOME/.local/bin/quptime" "https://git.cer.sh/axodouble/quptime/-/raw/main/quptime"
else
echo "Error: Neither curl nor wget is installed. Please install one of these tools to download the quptime binary."
exit 1 exit 1
fi fi
if ! command -v curl > /dev/null; then
echo "Error: curl is not installed. Please install curl and try again."
exit 1
fi
# Check if the user is allowed to write to /usr/local/bin, if so, install qu there, else error out and ask the user to install qu manually
if [ -w "/usr/local/bin" ]; then
# Get release tag by $(curl -s https://git.cer.sh/api/v1/repos/axodouble/quptime/releases/latest | jq -r '.tag_name')
RELEASE=$(curl -s https://git.cer.sh/api/v1/repos/axodouble/quptime/releases/latest | jq -r '.tag_name')
# Download the latest release binary from the Git repository and save it to /usr/local/bin/qu
if command -v curl > /dev/null; then
echo_cmd "curl -L -o \"/usr/local/bin/qu\" \"https://git.cer.sh/axodouble/quptime/releases/download/${RELEASE}/qu-${RELEASE}-linux-amd64\""
echo_cmd "chmod +x \"/usr/local/bin/qu\""
echo "> qu has been installed to /usr/local/bin/qu"
else
echo "Error: curl is not installed. Please install curl and try again."
exit 1
fi
else
echo "Error: You are not allowed to write to /usr/local/bin. Please install qu manually, or run this script with sudo."
exit 1
fi
# Check if the user has systemd, if so create a systemd service file for qu serve
if command -v systemctl > /dev/null; then
echo "> Creating systemd service file for qu serve..."
cat <<EOL > /etc/systemd/system/qu-serve.service
[Unit]
Description=QUptime Serve
After=network.target
[Service]
ExecStart=/usr/local/bin/qu serve
Restart=always
User=$(whoami)
[Install]
WantedBy=multi-user.target
EOL
echo_cmd "systemctl daemon-reload"
echo_cmd "systemctl enable qu-serve.service"
echo "> qu serve service has been created and enabled. You can start it with 'systemctl start qu-serve.service'"
else
echo "> Warning: systemd is not available on this system. qu serve will not be automatically started on boot. You can start it manually with '/usr/local/bin/qu serve'"
fi
echo "Installation complete, before starting `qu serve`, make sure to run `qu init` and read the documentation."
+44
View File
@@ -0,0 +1,44 @@
package alerts
// TemplateVarsHint returns a compact, multi-line listing of the
// variables a subject/body template can reference. Designed for
// embedding in TUI form hints where vertical space is tight.
//
// Continuation lines are pre-indented so they line up under the
// first line when the caller prepends a fixed indent (e.g. " ").
func TemplateVarsHint() string {
return "Go text/template — leave empty to use the built-in format.\n" +
" Vars: {{.Check.Name}}, {{.Check.Target}}, {{.Check.Type}}, {{.Check.ID}},\n" +
" {{.Verb}} (UP|DOWN|RECOVERED), {{.From}}, {{.To}}, {{.NodeID}}, {{.When}},\n" +
" {{.Snapshot.Detail}}, {{.Snapshot.Reports}}, {{.Snapshot.OKCount}}, {{.Snapshot.NotOK}}"
}
// TemplateVarsHelp returns the long-form documentation for available
// template variables, suitable for embedding in a CLI command's Long
// help text. Each variable is described on its own line and an
// example template is included at the end.
func TemplateVarsHelp() string {
return `Subject and body templates use Go text/template syntax. They are
optional — leaving them empty falls back to the built-in format.
Discord ignores the subject template (it has no subject line); SMTP
uses both.
Available variables:
{{.Check.Name}} check name (e.g. "homepage")
{{.Check.Target}} URL / host:port / host being probed
{{.Check.Type}} http | tcp | icmp
{{.Check.ID}} stable check UUID
{{.Verb}} UP | DOWN | RECOVERED
{{.From}} previous state name
{{.To}} new state name
{{.NodeID}} master node that rendered the message
{{.When}} RFC3339 timestamp of the transition
{{.Snapshot.Detail}} probe detail string (e.g. "connection refused")
{{.Snapshot.Reports}} total reports in the flip window
{{.Snapshot.OKCount}} ok report count
{{.Snapshot.NotOK}} failing report count
Example body template:
{{.Check.Name}} is {{.Verb}} (target {{.Check.Target}}).
Detail: {{.Snapshot.Detail}}`
}
+148 -3
View File
@@ -11,6 +11,7 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"git.cer.sh/axodouble/quptime/internal/alerts"
"git.cer.sh/axodouble/quptime/internal/config" "git.cer.sh/axodouble/quptime/internal/config"
"git.cer.sh/axodouble/quptime/internal/daemon" "git.cer.sh/axodouble/quptime/internal/daemon"
"git.cer.sh/axodouble/quptime/internal/transport" "git.cer.sh/axodouble/quptime/internal/transport"
@@ -21,9 +22,9 @@ import (
// variants (if non-empty) and returns the effective subject + body // variants (if non-empty) and returns the effective subject + body
// template strings. Inline flags take precedence over file flags. // template strings. Inline flags take precedence over file flags.
func bindTemplateFlags(cmd *cobra.Command) { func bindTemplateFlags(cmd *cobra.Command) {
cmd.Flags().String("subject", "", "subject template (text/template syntax — SMTP only)") cmd.Flags().String("subject", "", "subject template, Go text/template (SMTP only; see --help for variables)")
cmd.Flags().String("subject-file", "", "path to a file containing the subject template") cmd.Flags().String("subject-file", "", "path to a file containing the subject template")
cmd.Flags().String("body", "", "body template (text/template syntax)") cmd.Flags().String("body", "", "body template, Go text/template (see --help for variables)")
cmd.Flags().String("body-file", "", "path to a file containing the body template") cmd.Flags().String("body-file", "", "path to a file containing the body template")
} }
@@ -155,10 +156,152 @@ func addAlertCmd(root *cobra.Command) {
}, },
} }
alert.AddCommand(addParent, listCmd, removeCmd, testCmd, defaultCmd) alert.AddCommand(addParent, listCmd, removeCmd, testCmd, defaultCmd, buildAlertEditCmd())
root.AddCommand(alert) root.AddCommand(alert)
} }
// buildAlertEditCmd returns `qu alert edit`, which updates fields of an
// existing alert. Only flags actually passed take effect. The alert's
// type cannot be changed (would require re-validating type-specific
// fields end-to-end); delete and re-add instead if you need to switch
// from SMTP to Discord or vice versa.
func buildAlertEditCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "edit <id-or-name>",
Short: "Update fields of an existing alert channel",
Long: `Update one or more fields of an existing alert. Only flags you pass
take effect; everything else is preserved.
The type (smtp/discord) cannot be changed in place — delete and re-add
the alert if you need to switch channels.
` + alerts.TemplateVarsHelp(),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
defer cancel()
cluster, err := config.LoadClusterConfig()
if err != nil {
return err
}
existing := cluster.FindAlert(args[0])
if existing == nil {
return fmt.Errorf("no alert named %q", args[0])
}
f := cmd.Flags()
if f.Changed("name") {
v, _ := f.GetString("name")
existing.Name = v
}
if f.Changed("default") {
v, _ := f.GetBool("default")
existing.Default = v
}
// Templates: inline flag wins over file flag. Either changing
// applies; passing an empty inline string clears the template.
if f.Changed("subject") {
v, _ := f.GetString("subject")
existing.SubjectTemplate = v
} else if f.Changed("subject-file") {
p, _ := f.GetString("subject-file")
if p != "" {
raw, e := os.ReadFile(p)
if e != nil {
return fmt.Errorf("read --subject-file %s: %w", p, e)
}
existing.SubjectTemplate = string(raw)
}
}
if f.Changed("body") {
v, _ := f.GetString("body")
existing.BodyTemplate = v
} else if f.Changed("body-file") {
p, _ := f.GetString("body-file")
if p != "" {
raw, e := os.ReadFile(p)
if e != nil {
return fmt.Errorf("read --body-file %s: %w", p, e)
}
existing.BodyTemplate = string(raw)
}
}
switch existing.Type {
case config.AlertSMTP:
if f.Changed("webhook") {
return fmt.Errorf("--webhook only applies to Discord alerts")
}
if f.Changed("host") {
v, _ := f.GetString("host")
existing.SMTPHost = v
}
if f.Changed("port") {
v, _ := f.GetInt("port")
existing.SMTPPort = v
}
if f.Changed("user") {
v, _ := f.GetString("user")
existing.SMTPUser = v
}
if f.Changed("password") {
v, _ := f.GetString("password")
existing.SMTPPassword = v
}
if f.Changed("from") {
v, _ := f.GetString("from")
existing.SMTPFrom = v
}
if f.Changed("to") {
v, _ := f.GetStringSlice("to")
existing.SMTPTo = v
}
if f.Changed("starttls") {
v, _ := f.GetBool("starttls")
existing.SMTPStartTLS = v
}
case config.AlertDiscord:
for _, smtpFlag := range []string{"host", "port", "user", "password", "from", "to", "starttls"} {
if f.Changed(smtpFlag) {
return fmt.Errorf("--%s only applies to SMTP alerts", smtpFlag)
}
}
if f.Changed("webhook") {
v, _ := f.GetString("webhook")
existing.DiscordWebhook = v
}
}
payload, err := json.Marshal(existing)
if err != nil {
return err
}
body := daemon.MutateBody{Kind: transport.MutationAddAlert, Payload: payload}
raw, err := callDaemon(ctx, daemon.CtrlMutate, body)
if err != nil {
return err
}
var res daemon.MutateResult
_ = json.Unmarshal(raw, &res)
fmt.Fprintf(cmd.OutOrStdout(), "updated alert %s (cluster version now %d)\n", existing.Name, res.Version)
return nil
},
}
cmd.Flags().String("name", "", "rename the alert")
cmd.Flags().Bool("default", false, "attach to every check automatically")
cmd.Flags().String("host", "", "SMTP server host (SMTP only)")
cmd.Flags().Int("port", 587, "SMTP server port (SMTP only)")
cmd.Flags().String("user", "", "SMTP auth user (SMTP only)")
cmd.Flags().String("password", "", "SMTP auth password (SMTP only)")
cmd.Flags().String("from", "", "envelope From address (SMTP only)")
cmd.Flags().StringSlice("to", nil, "recipient address, repeatable (SMTP only)")
cmd.Flags().Bool("starttls", true, "negotiate STARTTLS (SMTP only)")
cmd.Flags().String("webhook", "", "Discord webhook URL (Discord only)")
bindTemplateFlags(cmd)
return cmd
}
func buildSMTPAddCmd() *cobra.Command { func buildSMTPAddCmd() *cobra.Command {
var host, user, password, from string var host, user, password, from string
var port int var port int
@@ -168,6 +311,7 @@ func buildSMTPAddCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "smtp <name>", Use: "smtp <name>",
Short: "Add an SMTP relay alert", Short: "Add an SMTP relay alert",
Long: "Add an SMTP relay alert.\n\n" + alerts.TemplateVarsHelp(),
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second) ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
@@ -225,6 +369,7 @@ func buildDiscordAddCmd() *cobra.Command {
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "discord <name>", Use: "discord <name>",
Short: "Add a Discord webhook alert", Short: "Add a Discord webhook alert",
Long: "Add a Discord webhook alert.\n\n" + alerts.TemplateVarsHelp(),
Args: cobra.ExactArgs(1), Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second) ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
+111 -1
View File
@@ -84,10 +84,120 @@ func addCheckCmd(root *cobra.Command) {
}, },
} }
check.AddCommand(addParent, listCmd, removeCmd) check.AddCommand(addParent, listCmd, removeCmd, buildCheckEditCmd())
root.AddCommand(check) root.AddCommand(check)
} }
// buildCheckEditCmd returns `qu check edit`, which updates fields of an
// existing check in place. Only flags that the operator actually passes
// modify the corresponding field — everything else is preserved from the
// existing record, including the ID. Identity match is by ID or Name.
func buildCheckEditCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "edit <id-or-name>",
Short: "Update fields of an existing check",
Long: `Update one or more fields of an existing check.
Identifies the target by ID or Name. Only flags you pass take effect;
all other fields are preserved from the existing record. HTTP-only flags
(--expect, --body-match) error out on non-HTTP checks.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
defer cancel()
cluster, err := config.LoadClusterConfig()
if err != nil {
return err
}
snap := cluster.Snapshot()
var existing *config.Check
for i := range snap.Checks {
if snap.Checks[i].ID == args[0] || snap.Checks[i].Name == args[0] {
cp := snap.Checks[i]
existing = &cp
break
}
}
if existing == nil {
return fmt.Errorf("no check named %q", args[0])
}
f := cmd.Flags()
if f.Changed("name") {
v, _ := f.GetString("name")
existing.Name = strings.TrimSpace(v)
}
if f.Changed("target") {
v, _ := f.GetString("target")
existing.Target = strings.TrimSpace(v)
}
if f.Changed("interval") {
s, _ := f.GetString("interval")
d, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("--interval: %w", err)
}
existing.Interval = d
}
if f.Changed("timeout") {
s, _ := f.GetString("timeout")
d, err := time.ParseDuration(s)
if err != nil {
return fmt.Errorf("--timeout: %w", err)
}
existing.Timeout = d
}
if f.Changed("alerts") {
csv, _ := f.GetString("alerts")
existing.AlertIDs = nil
for _, p := range strings.Split(csv, ",") {
p = strings.TrimSpace(p)
if p != "" {
existing.AlertIDs = append(existing.AlertIDs, p)
}
}
}
if f.Changed("expect") {
if existing.Type != config.CheckHTTP {
return fmt.Errorf("--expect only applies to HTTP checks (this is %s)", existing.Type)
}
v, _ := f.GetInt("expect")
existing.ExpectStatus = v
}
if f.Changed("body-match") {
if existing.Type != config.CheckHTTP {
return fmt.Errorf("--body-match only applies to HTTP checks (this is %s)", existing.Type)
}
v, _ := f.GetString("body-match")
existing.BodyMatch = v
}
payload, err := json.Marshal(existing)
if err != nil {
return err
}
body := daemon.MutateBody{Kind: transport.MutationAddCheck, Payload: payload}
raw, err := callDaemon(ctx, daemon.CtrlMutate, body)
if err != nil {
return err
}
var res daemon.MutateResult
_ = json.Unmarshal(raw, &res)
fmt.Fprintf(cmd.OutOrStdout(), "updated check %s (cluster version now %d)\n", existing.Name, res.Version)
return nil
},
}
cmd.Flags().String("name", "", "rename the check")
cmd.Flags().String("target", "", "new probe target (URL, host:port, or host)")
cmd.Flags().String("interval", "", "new probe interval (e.g. 30s, 1m)")
cmd.Flags().String("timeout", "", "new per-probe timeout (e.g. 10s)")
cmd.Flags().String("alerts", "", "replace alert list with this CSV of IDs/names (pass empty to clear)")
cmd.Flags().Int("expect", 0, "expected HTTP status code (HTTP only)")
cmd.Flags().String("body-match", "", "substring required in body (HTTP only)")
return cmd
}
// buildAddCheckCmd produces the per-type "qu check add <type>" subcommand. // buildAddCheckCmd produces the per-type "qu check add <type>" subcommand.
func buildAddCheckCmd(ctype config.CheckType, use, argSpec, short string, func buildAddCheckCmd(ctype config.CheckType, use, argSpec, short string,
bind func(args []string, c *config.Check) error, bind func(args []string, c *config.Check) error,
+1
View File
@@ -25,5 +25,6 @@ func NewRootCommand(version string) *cobra.Command {
addAlertCmd(root) addAlertCmd(root)
addTrustCmd(root) addTrustCmd(root)
addStatusCmd(root) addStatusCmd(root)
addTUICmd(root)
return root return root
} }
+69
View File
@@ -11,7 +11,9 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
"git.cer.sh/axodouble/quptime/internal/config"
"git.cer.sh/axodouble/quptime/internal/daemon" "git.cer.sh/axodouble/quptime/internal/daemon"
"git.cer.sh/axodouble/quptime/internal/transport"
) )
func addNodeCmd(root *cobra.Command) { func addNodeCmd(root *cobra.Command) {
@@ -66,9 +68,76 @@ func addNodeCmd(root *cobra.Command) {
} }
node.AddCommand(remove) node.AddCommand(remove)
node.AddCommand(buildNodeEditCmd())
root.AddCommand(node) root.AddCommand(node)
} }
// buildNodeEditCmd returns `qu node edit`, which currently only updates
// the peer's advertise address. The NodeID, fingerprint, and certificate
// are part of the cluster's trust relationship and cannot be edited —
// remove and re-add the node (with the new cert) if those need to change.
func buildNodeEditCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "edit <node-id>",
Short: "Update the advertise address (host:port) of an existing peer",
Long: `Update fields of an existing peer.
Only the advertise address is editable — the NodeID, fingerprint, and
certificate are bound by trust and cannot be changed in place. To change
those, remove the node and add it again (which re-performs TOFU).`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
defer cancel()
if !cmd.Flags().Changed("address") {
return fmt.Errorf("--address is required")
}
newAddr, _ := cmd.Flags().GetString("address")
newAddr = strings.TrimSpace(newAddr)
if newAddr == "" {
return fmt.Errorf("--address cannot be empty")
}
cluster, err := config.LoadClusterConfig()
if err != nil {
return err
}
snap := cluster.Snapshot()
var existing *config.PeerInfo
for i := range snap.Peers {
if snap.Peers[i].NodeID == args[0] {
cp := snap.Peers[i]
existing = &cp
break
}
}
if existing == nil {
return fmt.Errorf("no peer with node id %q", args[0])
}
existing.Advertise = newAddr
payload, err := json.Marshal(existing)
if err != nil {
return err
}
body := daemon.MutateBody{Kind: transport.MutationAddPeer, Payload: payload}
raw, err := callDaemon(ctx, daemon.CtrlMutate, body)
if err != nil {
return err
}
var res daemon.MutateResult
_ = json.Unmarshal(raw, &res)
fmt.Fprintf(cmd.OutOrStdout(), "updated peer %s -> %s (cluster version now %d)\n",
existing.NodeID, existing.Advertise, res.Version)
return nil
},
}
cmd.Flags().String("address", "", "new host:port advertise address")
return cmd
}
// runNodeAdd does a two-step TOFU: probe peer, confirm fingerprint // runNodeAdd does a two-step TOFU: probe peer, confirm fingerprint
// interactively, then issue the actual add. // interactively, then issue the actual add.
func runNodeAdd(ctx context.Context, cmd *cobra.Command, addr string) error { func runNodeAdd(ctx context.Context, cmd *cobra.Command, addr string) error {
+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
}
+790
View File
@@ -0,0 +1,790 @@
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/alerts"
"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 {
return textFieldWithValue(label, hint, "", required)
}
// textFieldWithValue is the same as textField but pre-populates the
// input with `value`. Used by edit forms so the user sees the current
// contents and can tweak instead of retyping everything.
func textFieldWithValue(label, hint, value string, required bool) formField {
ti := textinput.New()
ti.Width = 40
ti.Placeholder = hint
if value != "" {
ti.SetValue(value)
}
return formField{label: label, hint: hint, required: required, input: ti}
}
func passwordField(label, hint string) formField {
return passwordFieldWithValue(label, hint, "")
}
// passwordFieldWithValue pre-populates the masked input. Mostly useful
// for edit forms — the user sees that *something* is set (dots) without
// the actual value leaking on-screen.
func passwordFieldWithValue(label, hint, value string) formField {
ti := textinput.New()
ti.Width = 40
ti.Placeholder = hint
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '•'
if value != "" {
ti.SetValue(value)
}
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", alerts.TemplateVarsHint(), 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", alerts.TemplateVarsHint(), false),
textField("Body template", alerts.TemplateVarsHint(), 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,
}
}
})
}
// =============================================================
// Edit forms — same shape as the add forms above, but the inputs are
// pre-populated from an existing record and the submit closure reuses
// the original ID so the daemon's upsert path replaces the entry
// instead of creating a new one.
// =============================================================
func newEditCheckForm(existing config.Check) *form {
intervalStr := ""
if existing.Interval > 0 {
intervalStr = existing.Interval.String()
}
timeoutStr := ""
if existing.Timeout > 0 {
timeoutStr = existing.Timeout.String()
}
expectStr := ""
if existing.ExpectStatus > 0 {
expectStr = fmt.Sprintf("%d", existing.ExpectStatus)
}
fields := []formField{
textFieldWithValue("Name", "human-friendly identifier", existing.Name, true),
textFieldWithValue("Target", targetHint(existing.Type), existing.Target, true),
textFieldWithValue("Interval", "e.g. 30s, 1m", intervalStr, false),
textFieldWithValue("Timeout", "e.g. 10s", timeoutStr, false),
textFieldWithValue("Alerts", "comma-separated alert IDs/names (optional)", strings.Join(existing.AlertIDs, ","), false),
}
if existing.Type == config.CheckHTTP {
fields = append(fields,
textFieldWithValue("Expect status", "e.g. 200 (HTTP only)", expectStr, false),
textFieldWithValue("Body match", "substring required (HTTP only)", existing.BodyMatch, false),
)
}
checkType := existing.Type
id := existing.ID
suppress := append([]string(nil), existing.SuppressAlertIDs...)
return newForm("Edit "+strings.ToUpper(string(checkType))+" check", fields, func(vals []string) tea.Cmd {
return func() tea.Msg {
ch := config.Check{
ID: id,
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),
SuppressAlertIDs: suppress,
}
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: "updated check " + ch.Name, level: flashInfo}
}
})
}
func newEditDiscordForm(existing config.Alert) *form {
fields := []formField{
textFieldWithValue("Name", "human-friendly identifier", existing.Name, true),
textFieldWithValue("Webhook URL", "https://discord.com/api/webhooks/...", existing.DiscordWebhook, true),
textFieldWithValue("Default", "yes/no — attach to every check automatically", boolStr(existing.Default), false),
textFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false),
}
id := existing.ID
subject := existing.SubjectTemplate
return newForm("Edit Discord alert", fields, func(vals []string) tea.Cmd {
return func() tea.Msg {
a := config.Alert{
ID: id,
Name: strings.TrimSpace(vals[0]),
Type: config.AlertDiscord,
DiscordWebhook: strings.TrimSpace(vals[1]),
Default: parseBool(vals[2]),
BodyTemplate: vals[3],
SubjectTemplate: subject,
}
if err := mutateAdd(transport.MutationAddAlert, a); err != nil {
return formSubmitErr(err.Error())
}
return modalDone{flash: "updated discord alert " + a.Name, level: flashInfo}
}
})
}
func newEditSMTPForm(existing config.Alert) *form {
portStr := ""
if existing.SMTPPort > 0 {
portStr = fmt.Sprintf("%d", existing.SMTPPort)
}
fields := []formField{
textFieldWithValue("Name", "human-friendly identifier", existing.Name, true),
textFieldWithValue("Host", "smtp.example.com", existing.SMTPHost, true),
textFieldWithValue("Port", "default 587", portStr, false),
textFieldWithValue("User", "leave empty for anonymous", existing.SMTPUser, false),
passwordFieldWithValue("Password", "smtp auth password", existing.SMTPPassword),
textFieldWithValue("From", "envelope From address", existing.SMTPFrom, true),
textFieldWithValue("To", "comma-separated recipient addresses", strings.Join(existing.SMTPTo, ","), true),
textFieldWithValue("StartTLS", "yes/no — default yes", boolStr(existing.SMTPStartTLS), false),
textFieldWithValue("Default", "yes/no — attach to every check", boolStr(existing.Default), false),
textFieldWithValue("Subject template", alerts.TemplateVarsHint(), existing.SubjectTemplate, false),
textFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false),
}
id := existing.ID
return newForm("Edit 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: id,
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: "updated smtp alert " + a.Name, level: flashInfo}
}
})
}
// newEditNodeForm only exposes the advertise address. The NodeID and
// fingerprint/cert are bound by trust and cannot be edited in place;
// removing and re-adding the node is the path for those changes.
func newEditNodeForm(existing config.PeerInfo) *form {
fields := []formField{
textFieldWithValue("Address", "host:9901 — peer's advertise endpoint", existing.Advertise, true),
}
id := existing.NodeID
fp := existing.Fingerprint
cert := existing.CertPEM
return newForm("Edit node "+shortID(id), fields, func(vals []string) tea.Cmd {
return func() tea.Msg {
p := config.PeerInfo{
NodeID: id,
Advertise: strings.TrimSpace(vals[0]),
Fingerprint: fp,
CertPEM: cert,
}
if err := mutateAdd(transport.MutationAddPeer, p); err != nil {
return formSubmitErr(err.Error())
}
return modalDone{flash: "updated node " + shortID(id), level: flashInfo}
}
})
}
func boolStr(b bool) string {
if b {
return "yes"
}
return "no"
}
// =============================================================
// 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] + "…"
}
+684
View File
@@ -0,0 +1,684 @@
// 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
// Full records cached from cluster.yaml directly (the daemon status
// only ships per-check effective alert names and per-peer liveness).
// We need the full records to render the alerts tab, to support the
// default-toggle, and to pre-fill edit forms with current values.
peersFull []config.PeerInfo
checksFull []config.Check
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(), loadConfigCmd(), tickCmd())
}
type tickMsg time.Time
type statusMsg struct {
st transport.StatusResponse
err error
}
type configMsg struct {
peers []config.PeerInfo
checks []config.Check
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 loadConfigCmd() tea.Cmd {
return func() tea.Msg {
cfg, err := config.LoadClusterConfig()
if err != nil {
return configMsg{err: err}
}
snap := cfg.Snapshot()
return configMsg{peers: snap.Peers, checks: snap.Checks, 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(), loadConfigCmd(), tickCmd())
case statusMsg:
if msg.err != nil {
m.statusErr = msg.err.Error()
} else {
m.statusErr = ""
wasLoaded := m.statusLoaded
m.status = msg.st
m.statusLoaded = true
m.peers.Refresh(msg.st, msg.st.NodeID)
m.checks.Refresh(msg.st)
// First load may change header height on narrow terminals;
// re-run the layout so the body shrinks to compensate.
if !wasLoaded {
m.resizeTabs()
}
}
return m, nil
case configMsg:
if msg.err == nil {
m.peersFull = msg.peers
m.checksFull = msg.checks
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(), loadConfigCmd())
}
// 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(), loadConfigCmd())
case "a":
m.modal = m.openAddPicker()
return m, nil
case "d":
return m.openRemoveConfirm()
case "e":
return m.openEditForm()
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 {
outerW := m.width - 2
if outerW < 20 {
outerW = 20
}
// headerStyle has Padding(0,1), so the usable content width is outerW-2.
innerW := outerW - 2
if innerW < 1 {
innerW = 1
}
if !m.statusLoaded {
msg := "connecting to daemon…"
if m.statusErr != "" {
msg = "daemon: " + m.statusErr
}
return headerStyle.Width(outerW).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)),
)
leftW := lipgloss.Width(left)
rightW := lipgloss.Width(right)
// Single row when both halves fit with at least one space between them.
if leftW+rightW+1 <= innerW {
gap := innerW - leftW - rightW
row := left + strings.Repeat(" ", gap) + right
return headerStyle.Width(outerW).Render(row)
}
// Otherwise stack vertically so nothing gets clipped on narrow terminals.
rows := lipgloss.JoinVertical(lipgloss.Left, left, right)
return headerStyle.Width(outerW).Render(rows)
}
// headerHeight returns the actual number of terminal rows renderHeader
// produces, including the rounded border. Used to compute the body area in
// resizeTabs. We measure the rendered output rather than guess because the
// header's content can line-wrap on very narrow terminals (e.g. the left
// half being wider than the inner content area), which a width-based
// heuristic can't see.
func (m model) headerHeight() int {
if m.width == 0 {
return 3
}
return lipgloss.Height(m.renderHeader())
}
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()
}
// Table columns can sum to more than the terminal width on narrow
// terminals. Without this, bodyStyle.Width(...) would wrap each over-wide
// row onto extra lines and push the page taller than m.height, clipping
// the top of the TUI. Truncate per line so the bordered box stays the
// exact bodyH rows we sized for.
innerW := m.width - 4
if innerW < 1 {
innerW = 1
}
view = lipgloss.NewStyle().MaxWidth(innerW).Render(view)
return bodyStyle.Width(m.width - 2).Render(view)
}
func (m model) renderHelp() string {
specific := ""
switch m.active {
case tabPeers:
specific = "a add e edit d remove"
case tabChecks:
specific = "a add e edit d remove"
case tabAlerts:
specific = "a add e edit d remove 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
}
// openEditForm dispatches to the right pre-filled edit form based on the
// active tab and the row under the cursor. Looks up the full record in
// m.peersFull / m.checksFull / m.alerts (populated by loadConfigCmd) so
// the form starts with the entry's current values rather than blanks.
func (m model) openEditForm() (tea.Model, tea.Cmd) {
switch m.active {
case tabPeers:
id := strings.TrimPrefix(m.peers.Selected(), "* ")
if id == "" {
m.setFlash("no peer selected", flashWarn)
return m, nil
}
for i := range m.peersFull {
if m.peersFull[i].NodeID == id {
m.modal = newEditNodeForm(m.peersFull[i])
return m, nil
}
}
m.setFlash("peer not found in local cluster.yaml", flashError)
return m, nil
case tabChecks:
id := m.checks.Selected()
if id == "" {
m.setFlash("no check selected", flashWarn)
return m, nil
}
for i := range m.checksFull {
if m.checksFull[i].ID == id {
m.modal = newEditCheckForm(m.checksFull[i])
return m, nil
}
}
m.setFlash("check not found in local cluster.yaml", flashError)
return m, nil
case tabAlerts:
id := m.alertsT.Selected()
if id == "" {
m.setFlash("no alert selected", flashWarn)
return m, nil
}
for i := range m.alerts {
if m.alerts[i].ID != id {
continue
}
switch m.alerts[i].Type {
case config.AlertDiscord:
m.modal = newEditDiscordForm(m.alerts[i])
case config.AlertSMTP:
m.modal = newEditSMTPForm(m.alerts[i])
default:
m.setFlash("unsupported alert type", flashError)
return m, nil
}
return m, nil
}
m.setFlash("alert not found in local cluster.yaml", flashError)
return m, nil
}
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() {
// Rows consumed outside the body: header (variable), tabs (1),
// body's own rounded border (2), flash (1), help (1).
reserved := m.headerHeight() + 5
bodyH := m.height - reserved
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]
}