Added the ability to edit entries in the CLI & TUI
Release / release (push) Successful in 12m26s

This commit is contained in:
2026-05-14 05:18:23 +00:00
parent ce5c089413
commit 624d8d8e44
5 changed files with 606 additions and 18 deletions
+197
View File
@@ -80,18 +80,38 @@ func newForm(title string, fields []formField, submit func([]string) tea.Cmd) *f
}
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}
}
@@ -352,6 +372,183 @@ func newAddNodeForm() *form {
})
}
// =============================================================
// 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", "leave empty for default formatting", 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", "optional", existing.SubjectTemplate, false),
textFieldWithValue("Body template", "optional", 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.
// =============================================================
+88 -16
View File
@@ -46,10 +46,13 @@ type model struct {
statusLoaded bool
statusErr string
// Cached alerts come from cluster.yaml directly (the daemon status
// only ships per-check effective alert names). We need full Alert
// records to render the alerts tab and to support default-toggle.
alerts []config.Alert
// 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
@@ -76,7 +79,7 @@ func initialModel() model {
// =============================================================
func (m model) Init() tea.Cmd {
return tea.Batch(loadStatusCmd(), loadAlertsCmd(), tickCmd())
return tea.Batch(loadStatusCmd(), loadConfigCmd(), tickCmd())
}
type tickMsg time.Time
@@ -86,7 +89,9 @@ type statusMsg struct {
err error
}
type alertsMsg struct {
type configMsg struct {
peers []config.PeerInfo
checks []config.Check
alerts []config.Alert
err error
}
@@ -111,14 +116,14 @@ func loadStatusCmd() tea.Cmd {
}
}
func loadAlertsCmd() tea.Cmd {
func loadConfigCmd() tea.Cmd {
return func() tea.Msg {
cfg, err := config.LoadClusterConfig()
if err != nil {
return alertsMsg{err: err}
return configMsg{err: err}
}
snap := cfg.Snapshot()
return alertsMsg{alerts: snap.Alerts}
return configMsg{peers: snap.Peers, checks: snap.Checks, alerts: snap.Alerts}
}
}
@@ -130,7 +135,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
case tickMsg:
return m, tea.Batch(loadStatusCmd(), loadAlertsCmd(), tickCmd())
return m, tea.Batch(loadStatusCmd(), loadConfigCmd(), tickCmd())
case statusMsg:
if msg.err != nil {
@@ -150,8 +155,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, nil
case alertsMsg:
case configMsg:
if msg.err == nil {
m.peersFull = msg.peers
m.checksFull = msg.checks
m.alerts = msg.alerts
m.alertsT.Refresh(toAlertRows(msg.alerts))
}
@@ -163,7 +170,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.setFlash(msg.flash, msg.level)
}
// Force-refresh in case the modal mutated cluster state.
return m, tea.Batch(loadStatusCmd(), loadAlertsCmd())
return m, tea.Batch(loadStatusCmd(), loadConfigCmd())
}
// Modal grabs all input while open.
@@ -213,12 +220,14 @@ func (m model) handleKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
case "r":
m.setFlash("refreshing…", flashInfo)
return m, tea.Batch(loadStatusCmd(), loadAlertsCmd())
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()
@@ -390,11 +399,11 @@ func (m model) renderHelp() string {
specific := ""
switch m.active {
case tabPeers:
specific = "a add node d remove node"
specific = "a add e edit d remove"
case tabChecks:
specific = "a add check d remove check"
specific = "a add e edit d remove"
case tabAlerts:
specific = "a add alert d remove alert t test D toggle default"
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))
}
@@ -495,6 +504,69 @@ func (m model) openRemoveConfirm() (tea.Model, tea.Cmd) {
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 == "" {