812 lines
23 KiB
Go
812 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
|
|
|
|
// =============================================================
|
|
// 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
|
|
}
|