mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
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:
@ -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
|
||||
},
|
||||
}
|
||||
|
@ -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
63
cli/projectlist.go
Normal 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
56
cli/projectlist_test.go
Normal 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
|
||||
})
|
||||
}
|
@ -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
|
||||
}
|
||||
|
@ -18,6 +18,10 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
var (
|
||||
caret = color.HiBlackString(">")
|
||||
)
|
||||
|
||||
const (
|
||||
varGlobalConfig = "global-config"
|
||||
varNoOpen = "no-open"
|
||||
|
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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) {
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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
|
||||
|
Reference in New Issue
Block a user