From 86219dcd05a53fc6f97501ef05dd732b5daca9b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Thu, 26 Jun 2025 15:33:41 +0200 Subject: [PATCH 01/12] cmd/tui: Don't use yellow color for table input summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MicroCloud TUI uses yellow for warnings and red for errors. Using yellow also for input highlights is confusing. Signed-off-by: Julian Pelizäus --- cmd/tui/selectable_table.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/tui/selectable_table.go b/cmd/tui/selectable_table.go index ca4efc4dd..fd3b88634 100644 --- a/cmd/tui/selectable_table.go +++ b/cmd/tui/selectable_table.go @@ -90,7 +90,7 @@ type selectableTable struct { func SummarizeResult(tmpl string, args ...any) string { fmtArgs := []Fmt{} for _, arg := range args { - fmtArgs = append(fmtArgs, Fmt{Arg: arg, Color: Yellow, Bold: true}) + fmtArgs = append(fmtArgs, Fmt{Arg: arg, Bold: true}) } return Printf(Fmt{Arg: " " + tmpl, Color: White}, fmtArgs...) From 0a7671d49f2fcf2e4c36dfd7d07c8a7b3b94171e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Thu, 26 Jun 2025 18:28:45 +0200 Subject: [PATCH 02/12] cmd/microcloud: Don't use yellow color for trust establishment info messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- cmd/microcloud/ask.go | 2 +- cmd/microcloud/session.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/microcloud/ask.go b/cmd/microcloud/ask.go index e710458e1..695d55b76 100644 --- a/cmd/microcloud/ask.go +++ b/cmd/microcloud/ask.go @@ -1657,7 +1657,7 @@ func (c *initConfig) askJoinConfirmation(gw *cloudClient.WebsocketGateway, servi fmt.Println(tui.SummarizeResult("Received confirmation from system %s", session.Intent.Name)) fmt.Println("") fmt.Println(tui.Note(tui.Yellow, tui.WarningSymbol()+tui.SetColor(tui.Bright, " Do not exit out to keep the session alive", true)) + "\n") - fmt.Println(tui.Printf(tui.Fmt{Arg: "Complete the remaining configuration on %s ..."}, tui.Fmt{Arg: session.Intent.Name, Color: tui.Yellow, Bold: true})) + fmt.Println(tui.Printf(tui.Fmt{Arg: "Complete the remaining configuration on %s ..."}, tui.Fmt{Arg: session.Intent.Name, Bold: true})) } err = gw.ReceiveWithContext(gw.Context(), &session) diff --git a/cmd/microcloud/session.go b/cmd/microcloud/session.go index 51f7b8deb..b7d5a349e 100644 --- a/cmd/microcloud/session.go +++ b/cmd/microcloud/session.go @@ -201,8 +201,8 @@ func (c *initConfig) joiningSession(gw *cloudClient.WebsocketGateway, sh *servic fmt.Printf("\n%s %s\n\n", tmpl, fingerprintArg) tmplArg := tui.Fmt{Arg: "Select %s on %s to let it join the cluster"} - localArg := tui.Fmt{Arg: sh.Name, Color: tui.Yellow, Bold: true} - remoteArg := tui.Fmt{Arg: session.InitiatorName, Color: tui.Yellow, Bold: true} + localArg := tui.Fmt{Arg: sh.Name, Bold: true} + remoteArg := tui.Fmt{Arg: session.InitiatorName, Bold: true} fmt.Println(tui.Printf(tmplArg, localArg, remoteArg)) } From 62c871d9f257b57c6f3df2fee5239494490ad132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Thu, 26 Jun 2025 17:21:08 +0200 Subject: [PATCH 03/12] cmd/tui: Use consistent format for PrintWarning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- cmd/tui/style.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/tui/style.go b/cmd/tui/style.go index 0616bf1fa..2f5f8da3d 100644 --- a/cmd/tui/style.go +++ b/cmd/tui/style.go @@ -111,7 +111,7 @@ func (*ColorErr) Write(p []byte) (n int, err error) { // PrintWarning calls Println but it appends "! Warning:" to the front of the message. func PrintWarning(s string) { - fmt.Printf("%s %s: %s\n", WarningSymbol(), WarningColor("Warning", true), s) + fmt.Println(WarningSymbol(), WarningColor("Warning:", true), WarningColor(s, false)) } // Fmt represents the data supplied to ColorPrintf. In particular, it takes a color to apply to the text, and the text itself. From 2acc0c163cf12637bbf8fd6a0401e08dd0faf1d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Thu, 26 Jun 2025 17:21:47 +0200 Subject: [PATCH 04/12] cmd/tui: Add PrintError which uses consistent message format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- cmd/tui/style.go | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cmd/tui/style.go b/cmd/tui/style.go index 2f5f8da3d..ad708b49a 100644 --- a/cmd/tui/style.go +++ b/cmd/tui/style.go @@ -114,6 +114,11 @@ func PrintWarning(s string) { fmt.Println(WarningSymbol(), WarningColor("Warning:", true), WarningColor(s, false)) } +// PrintError calls Println but it appends "⨯ Error:" to the front of the message. +func PrintError(s string) { + fmt.Println(ErrorSymbol(), ErrorColor("Error:", true), ErrorColor(s, false)) +} + // Fmt represents the data supplied to ColorPrintf. In particular, it takes a color to apply to the text, and the text itself. type Fmt struct { Color lipgloss.TerminalColor From cdd3cd9a35defda8898f8916cb730a4f4243c0e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Thu, 26 Jun 2025 18:22:28 +0200 Subject: [PATCH 05/12] cmd/microcloud: Use tui's PrintError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- cmd/microcloud/ask.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/microcloud/ask.go b/cmd/microcloud/ask.go index 695d55b76..22a28047f 100644 --- a/cmd/microcloud/ask.go +++ b/cmd/microcloud/ask.go @@ -146,7 +146,7 @@ func (c *initConfig) askRetry(question string, f func() error) error { return err } - fmt.Printf("%s %s\n", tui.ErrorSymbol(), tui.ErrorColor(err.Error(), true)) + tui.PrintError(err.Error()) retry, err = c.asker.AskBool(question, true) if err != nil { return err From 72a63c16e56fb8a8ad821133481372e3dc66e402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Thu, 26 Jun 2025 18:23:48 +0200 Subject: [PATCH 06/12] cmd/microcloud: Use tui's PrintWarning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- cmd/microcloud/ask.go | 6 +++--- cmd/microcloud/preseed.go | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/cmd/microcloud/ask.go b/cmd/microcloud/ask.go index 22a28047f..9ea08086c 100644 --- a/cmd/microcloud/ask.go +++ b/cmd/microcloud/ask.go @@ -645,7 +645,7 @@ func (c *initConfig) askRemotePool(sh *service.Handler) error { } if availableDiskCount == 0 { - fmt.Println(tui.WarningColor("Warning: No disks available for distributed storage. Skipping configuration", false)) + tui.PrintWarning("No disks available for distributed storage. Skipping configuration") return nil } @@ -1125,7 +1125,7 @@ func (c *initConfig) askOVNNetwork(sh *service.Handler) error { canOVNUnderlay := true for peer, system := range c.systems { if len(c.state[system.ServerInfo.Name].AvailableOVNInterfaces) == 0 { - fmt.Println(tui.WarningColor(fmt.Sprintf("Not enough interfaces available on %s to create an underlay network, skipping", peer), false)) + tui.PrintWarning(fmt.Sprintf("Not enough interfaces available on %s to create an underlay network. Skipping configuration", peer)) canOVNUnderlay = false break } @@ -1323,7 +1323,7 @@ func (c *initConfig) askCephNetwork(sh *service.Handler) error { availableCephNetworkInterfaces := map[string]map[string]service.DedicatedInterface{} for name, state := range c.state { if len(state.AvailableCephInterfaces) == 0 { - fmt.Println(tui.WarningColor(fmt.Sprintf("No network interfaces found with IPs on %q to set a dedicated Ceph network, skipping Ceph network setup", name), false)) + tui.PrintWarning(fmt.Sprintf("No network interfaces found with IPs on %q to set up a dedicated Ceph network. Skipping Ceph network setup", name)) return nil } diff --git a/cmd/microcloud/preseed.go b/cmd/microcloud/preseed.go index dd1586bd6..b26c0e092 100644 --- a/cmd/microcloud/preseed.go +++ b/cmd/microcloud/preseed.go @@ -24,6 +24,7 @@ import ( "github.com/canonical/microcloud/microcloud/api" "github.com/canonical/microcloud/microcloud/api/types" cloudClient "github.com/canonical/microcloud/microcloud/client" + "github.com/canonical/microcloud/microcloud/cmd/tui" "github.com/canonical/microcloud/microcloud/multicast" "github.com/canonical/microcloud/microcloud/service" ) @@ -695,7 +696,7 @@ func (p *Preseed) Parse(s *service.Handler, c *initConfig, installedServices map for serviceType, cluster := range existingClusters { if len(cluster) > 0 { - fmt.Printf("Existing %s cluster is incompatible with MicroCloud, skipping %s setup\n", serviceType, serviceType) + tui.PrintWarning(fmt.Sprintf("Existing %s cluster is incompatible with MicroCloud. Skipping %s setup", serviceType, serviceType)) delete(s.Services, serviceType) } From 81ebb7e865ab49a954a2fdc1d40aeefe5a05a9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Thu, 26 Jun 2025 17:23:00 +0200 Subject: [PATCH 07/12] cmd/tui: Use our own PrintWarning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- cmd/tui/handler.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cmd/tui/handler.go b/cmd/tui/handler.go index a3e3a933e..3ffb29a9b 100644 --- a/cmd/tui/handler.go +++ b/cmd/tui/handler.go @@ -80,7 +80,7 @@ func (i *InputHandler) formatQuestion(question string, defaultAnswer string, acc // AskBoolWarn is the same as AskBool but it prints the given warning before asking. func (i *InputHandler) AskBoolWarn(warning string, question string, defaultAnswer bool) (bool, error) { - i.printWarning(warning) + PrintWarning(warning) return i.AskBool(question, defaultAnswer) } @@ -99,7 +99,7 @@ func (i *InputHandler) AskBool(question string, defaultAnswer bool) (bool, error // AskStringWarn is the same as AskString but it prints the given warning before asking. func (i *InputHandler) AskStringWarn(warning string, question string, defaultAnswer string, validator func(string) error) (string, error) { - i.printWarning(warning) + PrintWarning(warning) return i.AskString(question, defaultAnswer, validator) } From e83132460d43ba1ba98e84a7c9ae28814193c727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Thu, 26 Jun 2025 17:23:31 +0200 Subject: [PATCH 08/12] cmd/tui: Remove redundant printWarning in favor of the exported PrintWarning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- cmd/tui/handler.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/cmd/tui/handler.go b/cmd/tui/handler.go index 3ffb29a9b..aa38daf36 100644 --- a/cmd/tui/handler.go +++ b/cmd/tui/handler.go @@ -58,11 +58,6 @@ func (i *InputHandler) countAllRows() int { return i.table.countRawRows() } -// printWarning prints the given warning with "!" appended to the front of the message. -func (i *InputHandler) printWarning(warning string) { - fmt.Printf("%s %s\n", WarningSymbol(), warning) -} - // formatQuestion enriches the plain question string with default and accepted answers. func (i *InputHandler) formatQuestion(question string, defaultAnswer string, acceptedAnswers []string) string { var acceptedAnswersBlock string From 660346cc493be12785852746b0611957dae04711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Thu, 26 Jun 2025 17:23:53 +0200 Subject: [PATCH 09/12] service: Use tui's PrintWarning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- service/lxd.go | 2 +- service/version.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/service/lxd.go b/service/lxd.go index 5a4197943..e5ffaaa5c 100644 --- a/service/lxd.go +++ b/service/lxd.go @@ -643,7 +643,7 @@ func (s *LXDService) ValidateCephInterfaces(cephNetworkSubnetStr string, peerInt } if len(data) == 0 { - fmt.Println(tui.WarningColor("No network interfaces found with IPs in the specified subnet, skipping Ceph network setup", false)) + tui.PrintWarning("No network interfaces found with IPs in the specified subnet. Skipping Ceph network setup") } return data, nil diff --git a/service/version.go b/service/version.go index bbfe94e43..36ab4773e 100644 --- a/service/version.go +++ b/service/version.go @@ -37,7 +37,7 @@ func validateVersion(serviceType types.ServiceType, daemonVersion string) error // Print a warning in case a non-LTS version of LXD is used. if semver.Compare(semver.MajorMinor(lxdVersion), semver.MajorMinor(expectedVersion)) == 1 { - fmt.Printf("%s Discovered non-LTS version %q of LXD\n", tui.WarningSymbol(), daemonVersion) + tui.PrintWarning(fmt.Sprintf("Discovered non-LTS version %q of LXD", daemonVersion)) } case types.MicroOVN: From 2644ed12f14ceac25fd3ed580600d0ba70868e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Thu, 26 Jun 2025 17:48:54 +0200 Subject: [PATCH 10/12] cmd/tui: Add SprintError to be more flexible with the actual error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- cmd/tui/style.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cmd/tui/style.go b/cmd/tui/style.go index ad708b49a..ad91a247e 100644 --- a/cmd/tui/style.go +++ b/cmd/tui/style.go @@ -114,9 +114,14 @@ func PrintWarning(s string) { fmt.Println(WarningSymbol(), WarningColor("Warning:", true), WarningColor(s, false)) } +// SprintError crafts the error string without writing it to any output yet. +func SprintError(s string) string { + return fmt.Sprintln(ErrorSymbol(), ErrorColor("Error:", true), ErrorColor(s, false)) +} + // PrintError calls Println but it appends "⨯ Error:" to the front of the message. func PrintError(s string) { - fmt.Println(ErrorSymbol(), ErrorColor("Error:", true), ErrorColor(s, false)) + fmt.Print(SprintError(s)) } // Fmt represents the data supplied to ColorPrintf. In particular, it takes a color to apply to the text, and the text itself. From 777ed5841fbd82e5cb08bea7bcc1fb4ca758752f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Thu, 26 Jun 2025 17:50:45 +0200 Subject: [PATCH 11/12] cmd/tui: Use the tui's PrintError also for regular CLI errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Julian Pelizäus --- cmd/tui/style.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/tui/style.go b/cmd/tui/style.go index ad91a247e..2231d7d74 100644 --- a/cmd/tui/style.go +++ b/cmd/tui/style.go @@ -106,7 +106,15 @@ type ColorErr struct{} // Write colors the given error red and sends it to os.Stderr. func (*ColorErr) Write(p []byte) (n int, err error) { - return os.Stderr.WriteString(ErrorColor(strings.TrimSpace(string(p)), false) + "\n") + trimmedErr := strings.TrimSpace(string(p)) + + // Cobra allows setting the error prefix using SetErrPrefix() func but + // it ignores the setting when assigning an empty string. + // Therefore we can only trim the prefix to be able to set our own + // customized error prefix using tui styling. + withoutPrefixErr := strings.TrimPrefix(trimmedErr, "Error: ") + + return os.Stderr.WriteString(SprintError(withoutPrefixErr)) } // PrintWarning calls Println but it appends "! Warning:" to the front of the message. From da82ff23dbc2113cd89b7759c5d71886c4c54be4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20Peliz=C3=A4us?= Date: Thu, 26 Jun 2025 18:15:36 +0200 Subject: [PATCH 12/12] cmd/tui: Use own asker implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This allows influencing the error caused on wrong inputs. To stay consistent with the other tui errors we cannot use LXD's asker as we cannot modify the yielded error. Signed-off-by: Julian Pelizäus --- cmd/tui/handler.go | 66 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 57 insertions(+), 9 deletions(-) diff --git a/cmd/tui/handler.go b/cmd/tui/handler.go index aa38daf36..01df497c3 100644 --- a/cmd/tui/handler.go +++ b/cmd/tui/handler.go @@ -4,16 +4,19 @@ import ( "bufio" "fmt" "os" + "slices" "strings" "sync" - "github.com/canonical/lxd/shared/cmd" tea "github.com/charmbracelet/bubbletea" ) // ContextError is the charmbracelet representation of a context cancellation error. var ContextError error = tea.ErrProgramKilled +// InvalidInputError is used to indicate false input to an asked question. +var InvalidInputError func() = func() { PrintError("Invalid input, try again") } + // InputHandler handles input dialogs. type InputHandler struct { input *os.File @@ -73,6 +76,24 @@ func (i *InputHandler) formatQuestion(question string, defaultAnswer string, acc return fmt.Sprintf("%s%s%s: ", question, acceptedAnswersBlock, defaultAnswerBlock) } +// Ask a question on the output stream and read the answer from the input stream. +func (i *InputHandler) askQuestion(question, defaultAnswer string) (string, error) { + fmt.Print(question) + + return i.readAnswer(defaultAnswer) +} + +// Read the user's answer from the input stream, trimming newline and providing a default. +func (i *InputHandler) readAnswer(defaultAnswer string) (string, error) { + answer, err := bufio.NewReader(i.input).ReadString('\n') + answer = strings.TrimSpace(strings.TrimSuffix(answer, "\n")) + if answer == "" { + answer = defaultAnswer + } + + return answer, err +} + // AskBoolWarn is the same as AskBool but it prints the given warning before asking. func (i *InputHandler) AskBoolWarn(warning string, question string, defaultAnswer bool) (bool, error) { PrintWarning(warning) @@ -88,8 +109,20 @@ func (i *InputHandler) AskBool(question string, defaultAnswer bool) (bool, error defaultAnswerStr = "yes" } - asker := cmd.NewAsker(bufio.NewReader(i.input), nil) - return asker.AskBool(i.formatQuestion(question, defaultAnswerStr, []string{"yes", "no"}), defaultAnswerStr) + for { + answer, err := i.askQuestion(i.formatQuestion(question, defaultAnswerStr, []string{"yes", "no"}), defaultAnswerStr) + if err != nil { + return false, err + } + + if slices.Contains([]string{"yes", "y"}, strings.ToLower(answer)) { + return true, nil + } else if slices.Contains([]string{"no", "n"}, strings.ToLower(answer)) { + return false, nil + } + + InvalidInputError() + } } // AskStringWarn is the same as AskString but it prints the given warning before asking. @@ -103,11 +136,26 @@ func (i *InputHandler) AskString(question string, defaultAnswer string, validato i.setActive(true) defer i.setActive(false) - asker := cmd.NewAsker(bufio.NewReader(i.input), nil) - result, err := asker.AskString(i.formatQuestion(question, defaultAnswer, nil), defaultAnswer, validator) - if err != nil { - return "", err - } + for { + answer, err := i.askQuestion(i.formatQuestion(question, defaultAnswer, nil), defaultAnswer) + if err != nil { + return "", err + } - return result, nil + if validator != nil { + err = validator(answer) + if err != nil { + InvalidInputError() + continue + } + + return answer, err + } + + if len(answer) != 0 { + return answer, err + } + + InvalidInputError() + } }