diff --git a/cli/login.go b/cli/login.go index e068077539f4a..74094aace7865 100644 --- a/cli/login.go +++ b/cli/login.go @@ -67,7 +67,7 @@ func login() *cobra.Command { if !isTTY(cmd) { return xerrors.New("the initial user cannot be created in non-interactive mode. use the API") } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", color.HiBlackString(">")) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Your Coder deployment hasn't been set up!\n", caret) _, err := prompt(cmd, &promptui.Prompt{ Label: "Would you like to create the first user?", @@ -147,7 +147,7 @@ func login() *cobra.Command { return xerrors.Errorf("write server url: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", color.HiBlackString(">"), color.HiCyanString(username)) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", caret, color.HiCyanString(username)) return nil } @@ -192,7 +192,7 @@ func login() *cobra.Command { return xerrors.Errorf("write server url: %w", err) } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", color.HiBlackString(">"), color.HiCyanString(resp.Username)) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Welcome to Coder, %s! You're authenticated.\n", caret, color.HiCyanString(resp.Username)) return nil }, } diff --git a/cli/projectcreate.go b/cli/projectcreate.go index 4c67201d5a894..2b1d17f0711f6 100644 --- a/cli/projectcreate.go +++ b/cli/projectcreate.go @@ -92,7 +92,7 @@ func projectCreate() *cobra.Command { return err } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s The %s project has been created!\n", color.HiBlackString(">"), color.HiCyanString(project.Name)) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s The %s project has been created!\n", caret, color.HiCyanString(project.Name)) _, err = prompt(cmd, &promptui.Prompt{ Label: "Create a new workspace?", IsConfirm: true, diff --git a/cli/projectlist.go b/cli/projectlist.go new file mode 100644 index 0000000000000..9bb60568d3aa8 --- /dev/null +++ b/cli/projectlist.go @@ -0,0 +1,63 @@ +package cli + +import ( + "fmt" + "text/tabwriter" + "time" + + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +func projectList() *cobra.Command { + return &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + RunE: func(cmd *cobra.Command, args []string) error { + client, err := createClient(cmd) + if err != nil { + return err + } + start := time.Now() + organization, err := currentOrganization(cmd, client) + if err != nil { + return err + } + projects, err := client.Projects(cmd.Context(), organization.Name) + if err != nil { + return err + } + + if len(projects) == 0 { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s No projects found in %s! Create one:\n\n", caret, color.HiWhiteString(organization.Name)) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), color.HiMagentaString(" $ coder projects create \n")) + return nil + } + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Projects found in %s %s\n\n", + caret, + color.HiWhiteString(organization.Name), + color.HiBlackString("[%dms]", + time.Since(start).Milliseconds())) + + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 4, ' ', 0) + _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", + color.HiBlackString("Project"), + color.HiBlackString("Source"), + color.HiBlackString("Last Updated"), + color.HiBlackString("Used By")) + for _, project := range projects { + suffix := "" + if project.WorkspaceOwnerCount != 1 { + suffix = "s" + } + _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\n", + color.New(color.FgHiCyan).Sprint(project.Name), + color.WhiteString("Archive"), + color.WhiteString(project.UpdatedAt.Format("January 2, 2006")), + color.New(color.FgHiWhite).Sprintf("%d developer%s", project.WorkspaceOwnerCount, suffix)) + } + return writer.Flush() + }, + } +} diff --git a/cli/projectlist_test.go b/cli/projectlist_test.go new file mode 100644 index 0000000000000..1c1a641a685de --- /dev/null +++ b/cli/projectlist_test.go @@ -0,0 +1,56 @@ +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 TestProjectList(t *testing.T) { + t.Parallel() + t.Run("None", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t) + coderdtest.CreateInitialUser(t, client) + cmd, root := clitest.New(t, "projects", "list") + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + closeChan := make(chan struct{}) + go func() { + err := cmd.Execute() + require.NoError(t, err) + close(closeChan) + }() + pty.ExpectMatch("No projects found") + <-closeChan + }) + t.Run("List", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t) + user := coderdtest.CreateInitialUser(t, client) + daemon := coderdtest.NewProvisionerDaemon(t, client) + job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil) + coderdtest.AwaitProjectImportJob(t, client, user.Organization, job.ID) + _ = daemon.Close() + project := coderdtest.CreateProject(t, client, user.Organization, job.ID) + cmd, root := clitest.New(t, "projects", "list") + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + closeChan := make(chan struct{}) + go func() { + err := cmd.Execute() + require.NoError(t, err) + close(closeChan) + }() + pty.ExpectMatch(project.Name) + <-closeChan + }) +} diff --git a/cli/projects.go b/cli/projects.go index bce9930bd21ca..763b1fcc42809 100644 --- a/cli/projects.go +++ b/cli/projects.go @@ -30,9 +30,12 @@ func projects() *cobra.Command { ` + color.New(color.FgHiMagenta).Sprint("$ coder projects update "), } - cmd.AddCommand(projectCreate()) - cmd.AddCommand(projectPlan()) - cmd.AddCommand(projectUpdate()) + cmd.AddCommand( + projectCreate(), + projectList(), + projectPlan(), + projectUpdate(), + ) return cmd } diff --git a/cli/root.go b/cli/root.go index 0c7358453d1e0..054d9d84f942b 100644 --- a/cli/root.go +++ b/cli/root.go @@ -18,6 +18,10 @@ import ( "github.com/coder/coder/codersdk" ) +var ( + caret = color.HiBlackString(">") +) + const ( varGlobalConfig = "global-config" varNoOpen = "no-open" diff --git a/cli/workspacecreate.go b/cli/workspacecreate.go index be883ebaffe00..f4eae0ab5d846 100644 --- a/cli/workspacecreate.go +++ b/cli/workspacecreate.go @@ -54,7 +54,7 @@ func workspaceCreate() *cobra.Command { } } - _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Previewing project create...\n", color.HiBlackString(">")) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s Previewing project create...\n", caret) project, err := client.Project(cmd.Context(), organization.Name, args[0]) if err != nil { diff --git a/coderd/projects.go b/coderd/projects.go index 90c6ce243888b..b14018800b4e3 100644 --- a/coderd/projects.go +++ b/coderd/projects.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "time" "github.com/go-chi/render" "github.com/google/uuid" @@ -30,7 +31,16 @@ type CreateParameterValueRequest struct { // Project is the JSON representation of a Coder project. // This type matches the database object for now, but is // abstracted for ease of change later on. -type Project database.Project +type Project struct { + ID uuid.UUID `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + OrganizationID string `json:"organization_id"` + Name string `json:"name"` + Provisioner database.ProvisionerType `json:"provisioner"` + ActiveVersionID uuid.UUID `json:"active_version_id"` + WorkspaceOwnerCount uint32 `json:"workspace_owner_count"` +} // CreateProjectRequest enables callers to create a new Project. type CreateProjectRequest struct { @@ -69,11 +79,22 @@ func (api *api) projects(rw http.ResponseWriter, r *http.Request) { }) return } - if projects == nil { - projects = []database.Project{} + projectIDs := make([]uuid.UUID, 0, len(projects)) + for _, project := range projects { + projectIDs = append(projectIDs, project.ID) + } + workspaceCounts, err := api.Database.GetWorkspaceOwnerCountsByProjectIDs(r.Context(), projectIDs) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace counts: %s", err.Error()), + }) + return } render.Status(r, http.StatusOK) - render.JSON(rw, r, projects) + render.JSON(rw, r, convertProjects(projects, workspaceCounts)) } // Lists all projects in an organization. @@ -89,11 +110,22 @@ func (api *api) projectsByOrganization(rw http.ResponseWriter, r *http.Request) }) return } - if projects == nil { - projects = []database.Project{} + projectIDs := make([]uuid.UUID, 0, len(projects)) + for _, project := range projects { + projectIDs = append(projectIDs, project.ID) + } + workspaceCounts, err := api.Database.GetWorkspaceOwnerCountsByProjectIDs(r.Context(), projectIDs) + if errors.Is(err, sql.ErrNoRows) { + err = nil + } + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: fmt.Sprintf("get workspace counts: %s", err.Error()), + }) + return } render.Status(r, http.StatusOK) - render.JSON(rw, r, projects) + render.JSON(rw, r, convertProjects(projects, workspaceCounts)) } // Create a new project in an organization. @@ -162,7 +194,7 @@ func (api *api) postProjectsByOrganization(rw http.ResponseWriter, r *http.Reque if err != nil { return xerrors.Errorf("insert project version: %s", err) } - project = Project(dbProject) + project = convertProject(dbProject, 0) return nil }) if err != nil { @@ -241,6 +273,38 @@ func (api *api) parametersByProject(rw http.ResponseWriter, r *http.Request) { render.JSON(rw, r, apiParameterValues) } +func convertProjects(projects []database.Project, workspaceCounts []database.GetWorkspaceOwnerCountsByProjectIDsRow) []Project { + apiProjects := make([]Project, 0, len(projects)) + for _, project := range projects { + found := false + for _, workspaceCount := range workspaceCounts { + if workspaceCount.ProjectID.String() != project.ID.String() { + continue + } + apiProjects = append(apiProjects, convertProject(project, uint32(workspaceCount.Count))) + found = true + break + } + if !found { + apiProjects = append(apiProjects, convertProject(project, uint32(0))) + } + } + return apiProjects +} + +func convertProject(project database.Project, workspaceOwnerCount uint32) Project { + return Project{ + ID: project.ID, + CreatedAt: project.CreatedAt, + UpdatedAt: project.UpdatedAt, + OrganizationID: project.OrganizationID, + Name: project.Name, + Provisioner: project.Provisioner, + ActiveVersionID: project.ActiveVersionID, + WorkspaceOwnerCount: workspaceOwnerCount, + } +} + func convertParameterValue(parameterValue database.ParameterValue) ParameterValue { parameterValue.SourceValue = "" return ParameterValue(parameterValue) diff --git a/coderd/projects_test.go b/coderd/projects_test.go index 9ea0cbf87f443..cc4c4161f0d89 100644 --- a/coderd/projects_test.go +++ b/coderd/projects_test.go @@ -36,6 +36,22 @@ func TestProjects(t *testing.T) { require.NoError(t, err) require.Len(t, projects, 1) }) + + t.Run("ListWorkspaceOwnerCount", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t) + user := coderdtest.CreateInitialUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + job := coderdtest.CreateProjectImportJob(t, client, user.Organization, nil) + coderdtest.AwaitProjectImportJob(t, client, user.Organization, job.ID) + project := coderdtest.CreateProject(t, client, user.Organization, job.ID) + _ = coderdtest.CreateWorkspace(t, client, "", project.ID) + _ = coderdtest.CreateWorkspace(t, client, "", project.ID) + projects, err := client.Projects(context.Background(), "") + require.NoError(t, err) + require.Len(t, projects, 1) + require.Equal(t, projects[0].WorkspaceOwnerCount, uint32(1)) + }) } func TestProjectsByOrganization(t *testing.T) { diff --git a/database/databasefake/databasefake.go b/database/databasefake/databasefake.go index 3af24ad8bb563..fb639082acbdd 100644 --- a/database/databasefake/databasefake.go +++ b/database/databasefake/databasefake.go @@ -195,6 +195,41 @@ func (q *fakeQuerier) GetWorkspaceByUserIDAndName(_ context.Context, arg databas return database.Workspace{}, sql.ErrNoRows } +func (q *fakeQuerier) GetWorkspaceOwnerCountsByProjectIDs(_ context.Context, projectIDs []uuid.UUID) ([]database.GetWorkspaceOwnerCountsByProjectIDsRow, error) { + counts := map[string]map[string]struct{}{} + for _, projectID := range projectIDs { + found := false + for _, workspace := range q.workspace { + if workspace.ProjectID.String() != projectID.String() { + continue + } + countByOwnerID, ok := counts[projectID.String()] + if !ok { + countByOwnerID = map[string]struct{}{} + } + countByOwnerID[workspace.OwnerID] = struct{}{} + counts[projectID.String()] = countByOwnerID + found = true + break + } + if !found { + counts[projectID.String()] = map[string]struct{}{} + } + } + res := make([]database.GetWorkspaceOwnerCountsByProjectIDsRow, 0) + for key, value := range counts { + uid := uuid.MustParse(key) + res = append(res, database.GetWorkspaceOwnerCountsByProjectIDsRow{ + ProjectID: uid, + Count: int64(len(value)), + }) + } + if len(res) == 0 { + return nil, sql.ErrNoRows + } + return res, nil +} + func (q *fakeQuerier) GetWorkspaceResourcesByHistoryID(_ context.Context, workspaceHistoryID uuid.UUID) ([]database.WorkspaceResource, error) { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/database/querier.go b/database/querier.go index 93ba57d931e93..9109b8130760f 100644 --- a/database/querier.go +++ b/database/querier.go @@ -39,6 +39,7 @@ type querier interface { GetWorkspaceHistoryByWorkspaceID(ctx context.Context, workspaceID uuid.UUID) ([]WorkspaceHistory, error) GetWorkspaceHistoryByWorkspaceIDAndName(ctx context.Context, arg GetWorkspaceHistoryByWorkspaceIDAndNameParams) (WorkspaceHistory, error) GetWorkspaceHistoryByWorkspaceIDWithoutAfter(ctx context.Context, workspaceID uuid.UUID) (WorkspaceHistory, error) + GetWorkspaceOwnerCountsByProjectIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByProjectIDsRow, error) GetWorkspaceResourcesByHistoryID(ctx context.Context, workspaceHistoryID uuid.UUID) ([]WorkspaceResource, error) GetWorkspacesByProjectAndUserID(ctx context.Context, arg GetWorkspacesByProjectAndUserIDParams) ([]Workspace, error) GetWorkspacesByUserID(ctx context.Context, ownerID string) ([]Workspace, error) diff --git a/database/query.sql b/database/query.sql index 2bffab68704d2..79c4e231a6994 100644 --- a/database/query.sql +++ b/database/query.sql @@ -278,6 +278,18 @@ WHERE owner_id = $1 AND project_id = $2; +-- name: GetWorkspaceOwnerCountsByProjectIDs :many +SELECT + project_id, + COUNT(DISTINCT owner_id) +FROM + workspace +WHERE + project_id = ANY(@ids :: uuid [ ]) +GROUP BY + project_id, + owner_id; + -- name: GetWorkspaceHistoryByID :one SELECT * diff --git a/database/query.sql.go b/database/query.sql.go index 5b2f3d8bae011..f956e0b51b9e0 100644 --- a/database/query.sql.go +++ b/database/query.sql.go @@ -1070,6 +1070,47 @@ func (q *sqlQuerier) GetWorkspaceHistoryByWorkspaceIDWithoutAfter(ctx context.Co return i, err } +const getWorkspaceOwnerCountsByProjectIDs = `-- name: GetWorkspaceOwnerCountsByProjectIDs :many +SELECT + project_id, + COUNT(DISTINCT owner_id) +FROM + workspace +WHERE + project_id = ANY($1 :: uuid [ ]) +GROUP BY + project_id, + owner_id +` + +type GetWorkspaceOwnerCountsByProjectIDsRow struct { + ProjectID uuid.UUID `db:"project_id" json:"project_id"` + Count int64 `db:"count" json:"count"` +} + +func (q *sqlQuerier) GetWorkspaceOwnerCountsByProjectIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByProjectIDsRow, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceOwnerCountsByProjectIDs, pq.Array(ids)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetWorkspaceOwnerCountsByProjectIDsRow + for rows.Next() { + var i GetWorkspaceOwnerCountsByProjectIDsRow + if err := rows.Scan(&i.ProjectID, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspaceResourcesByHistoryID = `-- name: GetWorkspaceResourcesByHistoryID :many SELECT id, created_at, workspace_history_id, type, name, workspace_agent_token, workspace_agent_id