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
+141 -1
View File
@@ -155,10 +155,150 @@ func addAlertCmd(root *cobra.Command) {
},
}
alert.AddCommand(addParent, listCmd, removeCmd, testCmd, defaultCmd)
alert.AddCommand(addParent, listCmd, removeCmd, testCmd, defaultCmd, buildAlertEditCmd())
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.`,
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 {
var host, user, password, from string
var port int
+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)
}
// 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.
func buildAddCheckCmd(ctype config.CheckType, use, argSpec, short string,
bind func(args []string, c *config.Check) error,
+69
View File
@@ -11,7 +11,9 @@ import (
"github.com/spf13/cobra"
"git.cer.sh/axodouble/quptime/internal/config"
"git.cer.sh/axodouble/quptime/internal/daemon"
"git.cer.sh/axodouble/quptime/internal/transport"
)
func addNodeCmd(root *cobra.Command) {
@@ -66,9 +68,76 @@ func addNodeCmd(root *cobra.Command) {
}
node.AddCommand(remove)
node.AddCommand(buildNodeEditCmd())
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
// interactively, then issue the actual add.
func runNodeAdd(ctx context.Context, cmd *cobra.Command, addr string) error {
+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 == "" {