Files
QUptime/internal/tui/forms.go
T
2026-05-15 00:40:01 +00:00

816 lines
23 KiB
Go

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
width int // current terminal width; inputs resize to fill it
submit func(values []string) tea.Cmd
}
// defaultFieldWidth is the fallback input width used before the first
// WindowSizeMsg has arrived. Once we know the terminal size, inputs
// grow to fill the available horizontal space.
const defaultFieldWidth = 40
// fieldWidthFor derives the per-input visible width from the terminal
// width. It subtracts the modal's border+padding (6) and the form's
// label indent (2), then a couple of chars of safety margin.
func fieldWidthFor(termWidth int) int {
w := termWidth - 12
if w < defaultFieldWidth {
return defaultFieldWidth
}
return w
}
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 = defaultFieldWidth
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 = defaultFieldWidth
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 tea.WindowSizeMsg:
f.width = msg.Width
w := fieldWidthFor(msg.Width)
for i := range f.fields {
f.fields[i].input.Width = w
}
return f, nil
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
}