From 6e65e6f3f9eccec826d50e4d1b46303fb2bc6df4 Mon Sep 17 00:00:00 2001 From: kylecarbs Date: Tue, 10 May 2022 16:26:44 +0000 Subject: [PATCH] fix: Match kubectl table style for simpler scripting Fixes #1322. --- cli/cliui/table.go | 43 ++++++++++++++++++++++++ cli/list.go | 79 ++++++++++++++++++++++++++++++++++----------- cli/list_test.go | 40 +++++++++++++++++++++++ cli/root.go | 2 ++ cli/templatelist.go | 9 ++---- cli/userlist.go | 24 ++++++++------ 6 files changed, 164 insertions(+), 33 deletions(-) create mode 100644 cli/cliui/table.go create mode 100644 cli/list_test.go diff --git a/cli/cliui/table.go b/cli/cliui/table.go new file mode 100644 index 0000000000000..66ba29acaa170 --- /dev/null +++ b/cli/cliui/table.go @@ -0,0 +1,43 @@ +package cliui + +import ( + "strings" + + "github.com/jedib0t/go-pretty/v6/table" +) + +// Table creates a new table with standardized styles. +func Table() table.Writer { + tableWriter := table.NewWriter() + tableWriter.Style().Box.PaddingLeft = "" + tableWriter.Style().Box.PaddingRight = " " + tableWriter.Style().Options.DrawBorder = false + tableWriter.Style().Options.SeparateHeader = false + tableWriter.Style().Options.SeparateColumns = false + return tableWriter +} + +// FilterTableColumns returns configurations to hide columns +// that are not provided in the array. If the array is empty, +// no filtering will occur! +func FilterTableColumns(header table.Row, columns []string) []table.ColumnConfig { + if len(columns) == 0 { + return nil + } + columnConfigs := make([]table.ColumnConfig, 0) + for _, headerTextRaw := range header { + headerText, _ := headerTextRaw.(string) + hidden := true + for _, column := range columns { + if strings.EqualFold(strings.ReplaceAll(column, "_", " "), headerText) { + hidden = false + break + } + } + columnConfigs = append(columnConfigs, table.ColumnConfig{ + Name: headerText, + Hidden: hidden, + }) + } + return columnConfigs +} diff --git a/cli/list.go b/cli/list.go index 78bc106c62cea..236d96b331bc4 100644 --- a/cli/list.go +++ b/cli/list.go @@ -2,7 +2,10 @@ package cli import ( "fmt" + "strings" + "time" + "github.com/google/uuid" "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" @@ -12,7 +15,10 @@ import ( ) func list() *cobra.Command { - return &cobra.Command{ + var ( + columns []string + ) + cmd := &cobra.Command{ Annotations: workspaceCommand, Use: "list", Short: "List all workspaces", @@ -22,11 +28,7 @@ func list() *cobra.Command { if err != nil { return err } - organization, err := currentOrganization(cmd, client) - if err != nil { - return err - } - workspaces, err := client.WorkspacesByOwner(cmd.Context(), organization.ID, codersdk.Me) + workspaces, err := client.WorkspacesByUser(cmd.Context(), codersdk.Me) if err != nil { return err } @@ -37,11 +39,22 @@ func list() *cobra.Command { _, _ = fmt.Fprintln(cmd.OutOrStdout()) return nil } + users, err := client.Users(cmd.Context(), codersdk.UsersRequest{}) + if err != nil { + return err + } + usersByID := map[uuid.UUID]codersdk.User{} + for _, user := range users { + usersByID[user.ID] = user + } - tableWriter := table.NewWriter() - tableWriter.SetStyle(table.StyleLight) - tableWriter.Style().Options.SeparateColumns = false - tableWriter.AppendHeader(table.Row{"Workspace", "Template", "Status", "Last Built", "Outdated"}) + tableWriter := cliui.Table() + header := table.Row{"workspace", "template", "status", "last built", "outdated"} + tableWriter.AppendHeader(header) + tableWriter.SortBy([]table.SortBy{{ + Name: "workspace", + }}) + tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, columns)) for _, workspace := range workspaces { status := "" @@ -53,27 +66,54 @@ func list() *cobra.Command { switch workspace.LatestBuild.Transition { case database.WorkspaceTransitionStart: - status = "start" + status = "Running" if inProgress { - status = "starting" + status = "Starting" } case database.WorkspaceTransitionStop: - status = "stop" + status = "Stopped" if inProgress { - status = "stopping" + status = "Stopping" } case database.WorkspaceTransitionDelete: - status = "delete" + status = "Deleted" if inProgress { - status = "deleting" + status = "Deleting" } } + if workspace.LatestBuild.Job.Status == codersdk.ProvisionerJobFailed { + status = "Failed" + } + + duration := time.Now().UTC().Sub(workspace.LatestBuild.Job.CreatedAt).Truncate(time.Second) + if duration > time.Hour { + duration = duration.Truncate(time.Hour) + } + if duration > time.Minute { + duration = duration.Truncate(time.Minute) + } + days := 0 + for duration.Hours() > 24 { + days++ + duration -= 24 * time.Hour + } + durationDisplay := duration.String() + if days > 0 { + durationDisplay = fmt.Sprintf("%dd%s", days, durationDisplay) + } + if strings.HasSuffix(durationDisplay, "m0s") { + durationDisplay = durationDisplay[:len(durationDisplay)-2] + } + if strings.HasSuffix(durationDisplay, "h0m") { + durationDisplay = durationDisplay[:len(durationDisplay)-2] + } + user := usersByID[workspace.OwnerID] tableWriter.AppendRow(table.Row{ - cliui.Styles.Bold.Render(workspace.Name), + user.Username + "/" + workspace.Name, workspace.TemplateName, status, - workspace.LatestBuild.Job.CreatedAt.Format("January 2, 2006"), + durationDisplay, workspace.Outdated, }) } @@ -81,4 +121,7 @@ func list() *cobra.Command { return err }, } + cmd.Flags().StringArrayVarP(&columns, "column", "c", nil, + "Specify a column to filter in the table.") + return cmd } diff --git a/cli/list_test.go b/cli/list_test.go new file mode 100644 index 0000000000000..5dd24eddd0347 --- /dev/null +++ b/cli/list_test.go @@ -0,0 +1,40 @@ +package cli_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/cli/clitest" + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/pty/ptytest" +) + +func TestList(t *testing.T) { + t.Parallel() + t.Run("Single", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + cmd, root := clitest.New(t, "ls") + clitest.SetupConfig(t, client, root) + doneChan := make(chan struct{}) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + go func() { + defer close(doneChan) + err := cmd.Execute() + require.NoError(t, err) + }() + pty.ExpectMatch(workspace.Name) + pty.ExpectMatch("Running") + <-doneChan + }) +} diff --git a/cli/root.go b/cli/root.go index 97c6f121ccdd0..598c1eeefd502 100644 --- a/cli/root.go +++ b/cli/root.go @@ -80,7 +80,9 @@ func Root() *cobra.Command { cmd.PersistentFlags().String(varURL, "", "Specify the URL to your deployment.") cmd.PersistentFlags().String(varToken, "", "Specify an authentication token.") cliflag.String(cmd.PersistentFlags(), varAgentToken, "", "CODER_AGENT_TOKEN", "", "Specify an agent authentication token.") + _ = cmd.PersistentFlags().MarkHidden(varAgentToken) cliflag.String(cmd.PersistentFlags(), varAgentURL, "", "CODER_AGENT_URL", "", "Specify the URL for an agent to access your deployment.") + _ = cmd.PersistentFlags().MarkHidden(varAgentURL) cliflag.String(cmd.PersistentFlags(), varGlobalConfig, "", "CODER_CONFIG_DIR", configdir.LocalConfig("coderv2"), "Specify the path to the global `coder` config directory.") cmd.PersistentFlags().Bool(varForceTty, false, "Force the `coder` command to run as if connected to a TTY.") _ = cmd.PersistentFlags().MarkHidden(varForceTty) diff --git a/cli/templatelist.go b/cli/templatelist.go index 980215bcb4a5d..6671a35a33e89 100644 --- a/cli/templatelist.go +++ b/cli/templatelist.go @@ -34,10 +34,8 @@ func templateList() *cobra.Command { return nil } - tableWriter := table.NewWriter() - tableWriter.SetStyle(table.StyleLight) - tableWriter.Style().Options.SeparateColumns = false - tableWriter.AppendHeader(table.Row{"Name", "Source", "Last Updated", "Used By"}) + tableWriter := cliui.Table() + tableWriter.AppendHeader(table.Row{"Name", "Last Updated", "Used By"}) for _, template := range templates { suffix := "" @@ -45,8 +43,7 @@ func templateList() *cobra.Command { suffix = "s" } tableWriter.AppendRow(table.Row{ - cliui.Styles.Bold.Render(template.Name), - "Archive", + template.Name, template.UpdatedAt.Format("January 2, 2006"), cliui.Styles.Fuschia.Render(fmt.Sprintf("%d developer%s", template.WorkspaceOwnerCount, suffix)), }) diff --git a/cli/userlist.go b/cli/userlist.go index a96d5c6ce7bf8..108c22cc0fca7 100644 --- a/cli/userlist.go +++ b/cli/userlist.go @@ -2,17 +2,20 @@ package cli import ( "fmt" - "sort" "time" "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" + "github.com/coder/coder/cli/cliui" "github.com/coder/coder/codersdk" ) func userList() *cobra.Command { - return &cobra.Command{ + var ( + columns []string + ) + cmd := &cobra.Command{ Use: "list", Aliases: []string{"ls"}, RunE: func(cmd *cobra.Command, args []string) error { @@ -24,14 +27,14 @@ func userList() *cobra.Command { if err != nil { return err } - sort.Slice(users, func(i, j int) bool { - return users[i].Username < users[j].Username - }) - tableWriter := table.NewWriter() - tableWriter.SetStyle(table.StyleLight) - tableWriter.Style().Options.SeparateColumns = false - tableWriter.AppendHeader(table.Row{"Username", "Email", "Created At"}) + tableWriter := cliui.Table() + header := table.Row{"Username", "Email", "Created At"} + tableWriter.AppendHeader(header) + tableWriter.SetColumnConfigs(cliui.FilterTableColumns(header, columns)) + tableWriter.SortBy([]table.SortBy{{ + Name: "Username", + }}) for _, user := range users { tableWriter.AppendRow(table.Row{ user.Username, @@ -43,4 +46,7 @@ func userList() *cobra.Command { return err }, } + cmd.Flags().StringArrayVarP(&columns, "column", "c", nil, + "Specify a column to filter in the table.") + return cmd }