feat: Add "projects list" command to the CLI (#333)

This adds a WorkspaceOwnerCount parameter returned from the
projects API. It's helpful to display the amount of usage
a specific project has.
This commit is contained in:
Kyle Carberry
2022-02-21 12:47:08 -06:00
committed by GitHub
parent 59ee22d368
commit 67613da86d
13 changed files with 311 additions and 16 deletions

View File

@ -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
},
}

View File

@ -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,

63
cli/projectlist.go Normal file
View File

@ -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 <directory>\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()
},
}
}

56
cli/projectlist_test.go Normal file
View File

@ -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
})
}

View File

@ -30,9 +30,12 @@ func projects() *cobra.Command {
` + color.New(color.FgHiMagenta).Sprint("$ coder projects update <name>"),
}
cmd.AddCommand(projectCreate())
cmd.AddCommand(projectPlan())
cmd.AddCommand(projectUpdate())
cmd.AddCommand(
projectCreate(),
projectList(),
projectPlan(),
projectUpdate(),
)
return cmd
}

View File

@ -18,6 +18,10 @@ import (
"github.com/coder/coder/codersdk"
)
var (
caret = color.HiBlackString(">")
)
const (
varGlobalConfig = "global-config"
varNoOpen = "no-open"

View File

@ -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 {

View File

@ -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)

View File

@ -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) {

View File

@ -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()

View File

@ -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)

View File

@ -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
*

View File

@ -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