From 5a8bdcf903ffa8cc523f0bdbc0bf78b1aa4e99ae Mon Sep 17 00:00:00 2001 From: David Gageot Date: Fri, 25 Jan 2019 16:41:53 +0100 Subject: [PATCH] Experimental terminal UI Only for `skaffold dev` Signed-off-by: David Gageot --- cmd/skaffold/app/cmd/dev.go | 110 +++++++++++++++++- docs/content/en/docs/references/cli/_index.md | 2 + pkg/skaffold/color/formatter.go | 10 ++ pkg/skaffold/config/options.go | 8 ++ pkg/skaffold/kubernetes/log.go | 2 +- pkg/skaffold/runner/dev.go | 18 +-- pkg/skaffold/runner/dev_test.go | 14 ++- 7 files changed, 147 insertions(+), 17 deletions(-) diff --git a/cmd/skaffold/app/cmd/dev.go b/cmd/skaffold/app/cmd/dev.go index ea51c821309..0ab3c4977e7 100644 --- a/cmd/skaffold/app/cmd/dev.go +++ b/cmd/skaffold/app/cmd/dev.go @@ -19,9 +19,13 @@ package cmd import ( "context" "io" + "strings" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/color" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/config" "github.com/GoogleContainerTools/skaffold/pkg/skaffold/runner" "github.com/pkg/errors" + "github.com/rivo/tview" "github.com/sirupsen/logrus" "github.com/spf13/cobra" ) @@ -34,7 +38,7 @@ func NewCmdDev(out io.Writer) *cobra.Command { Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { opts.Command = "dev" - return dev(out) + return dev(out, opts.ExperimentalGUI) }, } AddRunDevFlags(cmd) @@ -45,13 +49,17 @@ func NewCmdDev(out io.Writer) *cobra.Command { cmd.Flags().IntVarP(&opts.WatchPollInterval, "watch-poll-interval", "i", 1000, "Interval (in ms) between two checks for file changes") cmd.Flags().BoolVar(&opts.PortForward, "port-forward", true, "Port-forward exposed container ports within pods") cmd.Flags().StringArrayVarP(&opts.CustomLabels, "label", "l", nil, "Add custom labels to deployed objects. Set multiple times for multiple labels") + cmd.Flags().BoolVar(&opts.ExperimentalGUI, "experimental-gui", false, "Experimental Graphical User Interface") + return cmd } -func dev(out io.Writer) error { +func dev(out io.Writer, ui bool) error { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - catchCtrlC(cancel) + if !ui { + catchCtrlC(cancel) + } cleanup := func() {} if opts.Cleanup { @@ -60,6 +68,25 @@ func dev(out io.Writer) error { }() } + var ( + app *tview.Application + output *config.Output + ) + if ui { + app, output = createApp() + defer app.Stop() + + go func() { + app.Run() + cancel() + }() + } else { + output = &config.Output{ + Main: out, + Logs: out, + } + } + for { select { case <-ctx.Done(): @@ -70,7 +97,7 @@ func dev(out io.Writer) error { return errors.Wrap(err, "creating runner") } - err = r.Dev(ctx, out, config.Build.Artifacts) + err = r.Dev(ctx, output, config.Build.Artifacts) if r.HasDeployed() { cleanup = func() { if err := r.Cleanup(context.Background(), out); err != nil { @@ -86,3 +113,78 @@ func dev(out io.Writer) error { } } } + +func createApp() (*tview.Application, *config.Output) { + app := tview.NewApplication() + + mainView := tview.NewTextView() + mainView. + SetChangedFunc(func() { + app.Draw() + }). + SetDynamicColors(true). + SetBorder(true). + SetTitle("Build") + + logsView := tview.NewTextView() + logsView. + SetChangedFunc(func() { + app.Draw() + }). + SetDynamicColors(true). + SetBorder(true). + SetTitle("Logs") + + grid := tview.NewGrid() + grid. + SetRows(0, 0). + SetColumns(0). + SetBorders(false). + AddItem(mainView, 0, 0, 1, 1, 0, 0, false). + AddItem(logsView, 1, 0, 1, 1, 0, 0, false) + + app. + SetRoot(grid, true). + SetFocus(grid) + + output := &config.Output{ + Main: color.ColoredWriter{Writer: ansiWriter(mainView)}, + Logs: color.ColoredWriter{Writer: ansiWriter(logsView)}, + } + + return app, output +} + +func ansiWriter(writer io.Writer) io.Writer { + return &ansi{ + Writer: writer, + replacer: strings.NewReplacer( + "\033[31m", "[maroon]", + "\033[32m", "[green]", + "\033[33m", "[olive]", + "\033[34m", "[navy]", + "\033[35m", "[purple]", + "\033[36m", "[teal]", + "\033[37m", "[silver]", + + "\033[91m", "[red]", + "\033[92m", "[lime]", + "\033[93m", "[yellow]", + "\033[94m", "[blue]", + "\033[95m", "[fuchsia]", + "\033[96m", "[aqua]", + "\033[97m", "[white]", + + "\033[0m", "", + ), + } +} + +type ansi struct { + io.Writer + replacer *strings.Replacer +} + +func (a *ansi) Write(text []byte) (int, error) { + return a.replacer.WriteString(a.Writer, string(text)) +} diff --git a/docs/content/en/docs/references/cli/_index.md b/docs/content/en/docs/references/cli/_index.md index 9ad73668be5..32304b62dda 100755 --- a/docs/content/en/docs/references/cli/_index.md +++ b/docs/content/en/docs/references/cli/_index.md @@ -207,6 +207,7 @@ Usage: Flags: --cleanup Delete deployments after dev mode is interrupted (default true) -d, --default-repo string Default repository value (overrides global config) + --experimental-gui Experimental Graphical User Interface -f, --filename string Filename or URL to the pipeline file (default "skaffold.yaml") -l, --label stringArray Add custom labels to deployed objects. Set multiple times for multiple labels -n, --namespace string Run deployments in the specified namespace @@ -228,6 +229,7 @@ Env vars: * `SKAFFOLD_CLEANUP` (same as --cleanup) * `SKAFFOLD_DEFAULT_REPO` (same as --default-repo) +* `SKAFFOLD_EXPERIMENTAL_GUI` (same as --experimental-gui) * `SKAFFOLD_FILENAME` (same as --filename) * `SKAFFOLD_LABEL` (same as --label) * `SKAFFOLD_NAMESPACE` (same as --namespace) diff --git a/pkg/skaffold/color/formatter.go b/pkg/skaffold/color/formatter.go index e82ea0fba23..3923ff4da76 100644 --- a/pkg/skaffold/color/formatter.go +++ b/pkg/skaffold/color/formatter.go @@ -56,6 +56,8 @@ var ( Purple = Color(35) // Cyan can format text to be displayed to the terminal in cyan, using ANSI escape codes. Cyan = Color(36) + // White can format text to be displayed to the terminal in white, using ANSI escape codes. + White = Color(37) // None uses ANSI escape codes to reset all formatting. None = Color(0) @@ -99,12 +101,20 @@ type ColoredWriteCloser struct { io.WriteCloser } +// ColoredWriter forces printing with colors to an io.Writer. +type ColoredWriter struct { + io.Writer +} + // This implementation comes from logrus (https://github.com/sirupsen/logrus/blob/master/terminal_check_notappengine.go), // unfortunately logrus doesn't expose a public interface we can use to call it. func isTerminal(w io.Writer) bool { if _, ok := w.(ColoredWriteCloser); ok { return true } + if _, ok := w.(ColoredWriter); ok { + return true + } switch v := w.(type) { case *os.File: diff --git a/pkg/skaffold/config/options.go b/pkg/skaffold/config/options.go index cf34c823063..46984592425 100644 --- a/pkg/skaffold/config/options.go +++ b/pkg/skaffold/config/options.go @@ -17,9 +17,16 @@ limitations under the License. package config import ( + "io" "strings" ) +// Output defines which zones on the screen to print to +type Output struct { + Main io.Writer + Logs io.Writer +} + // SkaffoldOptions are options that are set by command line arguments not included // in the config file itself type SkaffoldOptions struct { @@ -30,6 +37,7 @@ type SkaffoldOptions struct { TailDev bool PortForward bool SkipTests bool + ExperimentalGUI bool Profiles []string CustomTag string Namespace string diff --git a/pkg/skaffold/kubernetes/log.go b/pkg/skaffold/kubernetes/log.go index a14ddcc7622..a41535b35be 100644 --- a/pkg/skaffold/kubernetes/log.go +++ b/pkg/skaffold/kubernetes/log.go @@ -196,7 +196,7 @@ func (a *LogAggregator) streamRequest(ctx context.Context, headerColor color.Col if _, err := headerColor.Fprintf(a.output, "%s ", header); err != nil { return errors.Wrap(err, "writing pod prefix header to out") } - if _, err := fmt.Fprint(a.output, string(line)); err != nil { + if _, err := color.White.Fprint(a.output, string(line)); err != nil { return errors.Wrap(err, "writing pod log to out") } } diff --git a/pkg/skaffold/runner/dev.go b/pkg/skaffold/runner/dev.go index c9798326fa3..0f918acd261 100644 --- a/pkg/skaffold/runner/dev.go +++ b/pkg/skaffold/runner/dev.go @@ -18,13 +18,13 @@ package runner import ( "context" - "io" "github.com/pkg/errors" "github.com/sirupsen/logrus" "github.com/GoogleContainerTools/skaffold/pkg/skaffold/build" "github.com/GoogleContainerTools/skaffold/pkg/skaffold/color" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/config" "github.com/GoogleContainerTools/skaffold/pkg/skaffold/kubernetes" "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/latest" "github.com/GoogleContainerTools/skaffold/pkg/skaffold/sync" @@ -36,11 +36,11 @@ var ErrorConfigurationChanged = errors.New("configuration changed") // Dev watches for changes and runs the skaffold build and deploy // pipeline until interrupted by the user. -func (r *SkaffoldRunner) Dev(ctx context.Context, out io.Writer, artifacts []*latest.Artifact) error { - logger := r.newLogger(out, artifacts) +func (r *SkaffoldRunner) Dev(ctx context.Context, output *config.Output, artifacts []*latest.Artifact) error { + logger := r.newLogger(output.Logs, artifacts) defer logger.Stop() - portForwarder := kubernetes.NewPortForwarder(out, r.imageList, r.namespaces) + portForwarder := kubernetes.NewPortForwarder(output.Main, r.imageList, r.namespaces) defer portForwarder.Stop() // Create watcher and register artifacts to build current state of files. @@ -67,7 +67,7 @@ func (r *SkaffoldRunner) Dev(ctx context.Context, out io.Writer, artifacts []*la return ErrorConfigurationChanged case len(changed.needsResync) > 0: for _, s := range changed.needsResync { - color.Default.Fprintf(out, "Syncing %d files for %s\n", len(s.Copy)+len(s.Delete), s.Image) + color.Default.Fprintf(output.Main, "Syncing %d files for %s\n", len(s.Copy)+len(s.Delete), s.Image) if err := r.Syncer.Sync(ctx, s); err != nil { logrus.Warnln("Skipping deploy due to sync error:", err) @@ -75,12 +75,12 @@ func (r *SkaffoldRunner) Dev(ctx context.Context, out io.Writer, artifacts []*la } } case len(changed.needsRebuild) > 0: - if err := r.buildTestDeploy(ctx, out, changed.needsRebuild); err != nil { + if err := r.buildTestDeploy(ctx, output.Main, changed.needsRebuild); err != nil { logrus.Warnln("Skipping deploy due to error:", err) return nil } case changed.needsRedeploy: - if err := r.Deploy(ctx, out, r.builds); err != nil { + if err := r.Deploy(ctx, output.Main, r.builds); err != nil { logrus.Warnln("Skipping deploy due to error:", err) return nil } @@ -131,7 +131,7 @@ func (r *SkaffoldRunner) Dev(ctx context.Context, out io.Writer, artifacts []*la } // First run - if err := r.buildTestDeploy(ctx, out, artifacts); err != nil { + if err := r.buildTestDeploy(ctx, output.Main, artifacts); err != nil { return errors.Wrap(err, "exiting dev mode because first run failed") } @@ -148,5 +148,5 @@ func (r *SkaffoldRunner) Dev(ctx context.Context, out io.Writer, artifacts []*la } } - return r.Watcher.Run(ctx, out, onChange) + return r.Watcher.Run(ctx, output.Main, onChange) } diff --git a/pkg/skaffold/runner/dev_test.go b/pkg/skaffold/runner/dev_test.go index 13ea808ab4d..5d58750defa 100644 --- a/pkg/skaffold/runner/dev_test.go +++ b/pkg/skaffold/runner/dev_test.go @@ -23,6 +23,7 @@ import ( "io/ioutil" "testing" + "github.com/GoogleContainerTools/skaffold/pkg/skaffold/config" "github.com/GoogleContainerTools/skaffold/pkg/skaffold/schema/latest" "github.com/GoogleContainerTools/skaffold/pkg/skaffold/watch" "github.com/GoogleContainerTools/skaffold/testutil" @@ -82,6 +83,13 @@ func (t *TestWatcher) Run(ctx context.Context, out io.Writer, onChange func() er return nil } +func discardOutput() *config.Output { + return &config.Output{ + Main: ioutil.Discard, + Logs: ioutil.Discard, + } +} + func TestDevFailFirstCycle(t *testing.T) { var tests = []struct { description string @@ -129,7 +137,7 @@ func TestDevFailFirstCycle(t *testing.T) { runner := createRunner(t, test.testBench) runner.Watcher = test.watcher - err := runner.Dev(context.Background(), ioutil.Discard, []*latest.Artifact{{ + err := runner.Dev(context.Background(), discardOutput(), []*latest.Artifact{{ ImageName: "img", }}) @@ -260,7 +268,7 @@ func TestDev(t *testing.T) { testBench: test.testBench, } - err := runner.Dev(context.Background(), ioutil.Discard, []*latest.Artifact{ + err := runner.Dev(context.Background(), discardOutput(), []*latest.Artifact{ {ImageName: "img1"}, {ImageName: "img2"}, }) @@ -325,7 +333,7 @@ func TestDevSync(t *testing.T) { testBench: test.testBench, } - err := runner.Dev(context.Background(), ioutil.Discard, []*latest.Artifact{ + err := runner.Dev(context.Background(), discardOutput(), []*latest.Artifact{ { ImageName: "img1", Sync: map[string]string{