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 {