diff --git a/coderd/coderd.go b/coderd/coderd.go index cf8a20d3734cd..5115098cf3930 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -495,6 +495,7 @@ func New(options *Options) *API { apiKeyMiddleware, ) r.Get("/", api.workspaces) + r.Get("/count", api.workspaceCount) r.Route("/{workspace}", func(r chi.Router) { r.Use( httpmw.ExtractWorkspaceParam(options.Database), diff --git a/coderd/coderdtest/authorize.go b/coderd/coderdtest/authorize.go index a5183f2b6e450..8c172cd5f7a57 100644 --- a/coderd/coderdtest/authorize.go +++ b/coderd/coderdtest/authorize.go @@ -243,7 +243,8 @@ func AGPLRoutes(a *AuthTester) (map[string]string, map[string]RouteCheck) { "POST:/api/v2/organizations/{organization}/templateversions": {StatusCode: http.StatusBadRequest, NoAuthorize: true}, // Endpoints that use the SQLQuery filter. - "GET:/api/v2/workspaces/": {StatusCode: http.StatusOK, NoAuthorize: true}, + "GET:/api/v2/workspaces/": {StatusCode: http.StatusOK, NoAuthorize: true}, + "GET:/api/v2/workspaces/count": {StatusCode: http.StatusOK, NoAuthorize: true}, } // Routes like proxy routes support all HTTP methods. A helper func to expand diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 6c946ccfff24c..f72e278724549 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -788,6 +788,156 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. return workspaces, nil } +func (q *fakeQuerier) GetWorkspaceCount(ctx context.Context, arg database.GetWorkspaceCountParams) (int64, error) { + count, err := q.GetAuthorizedWorkspaceCount(ctx, arg, nil) + return count, err +} + +//nolint:gocyclo +func (q *fakeQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg database.GetWorkspaceCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspaces := make([]database.Workspace, 0) + for _, workspace := range q.workspaces { + if arg.OwnerID != uuid.Nil && workspace.OwnerID != arg.OwnerID { + continue + } + + if arg.OwnerUsername != "" { + owner, err := q.GetUserByID(ctx, workspace.OwnerID) + if err == nil && !strings.EqualFold(arg.OwnerUsername, owner.Username) { + continue + } + } + + if arg.TemplateName != "" { + template, err := q.GetTemplateByID(ctx, workspace.TemplateID) + if err == nil && !strings.EqualFold(arg.TemplateName, template.Name) { + continue + } + } + + if !arg.Deleted && workspace.Deleted { + continue + } + + if arg.Name != "" && !strings.Contains(strings.ToLower(workspace.Name), strings.ToLower(arg.Name)) { + continue + } + + if arg.Status != "" { + build, err := q.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + if err != nil { + return 0, xerrors.Errorf("get latest build: %w", err) + } + + job, err := q.GetProvisionerJobByID(ctx, build.JobID) + if err != nil { + return 0, xerrors.Errorf("get provisioner job: %w", err) + } + + switch arg.Status { + case "pending": + if !job.StartedAt.Valid { + continue + } + + case "starting": + if !job.StartedAt.Valid && + !job.CanceledAt.Valid && + job.CompletedAt.Valid && + time.Since(job.UpdatedAt) > 30*time.Second || + build.Transition != database.WorkspaceTransitionStart { + continue + } + + case "running": + if !job.CompletedAt.Valid && + job.CanceledAt.Valid && + job.Error.Valid || + build.Transition != database.WorkspaceTransitionStart { + continue + } + + case "stopping": + if !job.StartedAt.Valid && + !job.CanceledAt.Valid && + job.CompletedAt.Valid && + time.Since(job.UpdatedAt) > 30*time.Second || + build.Transition != database.WorkspaceTransitionStop { + continue + } + + case "stopped": + if !job.CompletedAt.Valid && + job.CanceledAt.Valid && + job.Error.Valid || + build.Transition != database.WorkspaceTransitionStop { + continue + } + + case "failed": + if (!job.CanceledAt.Valid && !job.Error.Valid) || + (!job.CompletedAt.Valid && !job.Error.Valid) { + continue + } + + case "canceling": + if !job.CanceledAt.Valid && job.CompletedAt.Valid { + continue + } + + case "canceled": + if !job.CanceledAt.Valid && !job.CompletedAt.Valid { + continue + } + + case "deleted": + if !job.StartedAt.Valid && + job.CanceledAt.Valid && + !job.CompletedAt.Valid && + time.Since(job.UpdatedAt) > 30*time.Second || + build.Transition != database.WorkspaceTransitionDelete { + continue + } + + case "deleting": + if !job.CompletedAt.Valid && + job.CanceledAt.Valid && + job.Error.Valid && + build.Transition != database.WorkspaceTransitionDelete { + continue + } + + default: + return 0, xerrors.Errorf("unknown workspace status in filter: %q", arg.Status) + } + } + + if len(arg.TemplateIds) > 0 { + match := false + for _, id := range arg.TemplateIds { + if workspace.TemplateID == id { + match = true + break + } + } + if !match { + continue + } + } + + // If the filter exists, ensure the object is authorized. + if authorizedFilter != nil && !authorizedFilter.Eval(workspace.RBACObject()) { + continue + } + workspaces = append(workspaces, workspace) + } + + return int64(len(workspaces)), nil +} + func (q *fakeQuerier) GetWorkspaceByID(_ context.Context, id uuid.UUID) (database.Workspace, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 8ccbe5f14f6ca..56cd7ff074622 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -112,6 +112,7 @@ func (q *sqlQuerier) GetTemplateGroupRoles(ctx context.Context, id uuid.UUID) ([ type workspaceQuerier interface { GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]Workspace, error) + GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) } // GetAuthorizedWorkspaces returns all workspaces that the user is authorized to access. @@ -166,3 +167,23 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa } return items, nil } + +func (q *sqlQuerier) GetAuthorizedWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams, authorizedFilter rbac.AuthorizeFilter) (int64, error) { + // In order to properly use ORDER BY, OFFSET, and LIMIT, we need to inject the + // authorizedFilter between the end of the where clause and those statements. + filter := strings.Replace(getWorkspaceCount, "-- @authorize_filter", fmt.Sprintf(" AND %s", authorizedFilter.SQLString(rbac.NoACLConfig())), 1) + // The name comment is for metric tracking + query := fmt.Sprintf("-- name: GetAuthorizedWorkspaceCount :one\n%s", filter) + row := q.db.QueryRowContext(ctx, query, + arg.Deleted, + arg.Status, + arg.OwnerID, + arg.OwnerUsername, + arg.TemplateName, + pq.Array(arg.TemplateIds), + arg.Name, + ) + var count int64 + err := row.Scan(&count) + return count, err +} diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5a6b8675f35eb..557cfa141776f 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -110,6 +110,8 @@ type sqlcQuerier interface { GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) GetWorkspaceByID(ctx context.Context, id uuid.UUID) (Workspace, error) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWorkspaceByOwnerIDAndNameParams) (Workspace, error) + // this duplicates the filtering in GetWorkspaces + GetWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams) (int64, error) GetWorkspaceCountByUserID(ctx context.Context, ownerID uuid.UUID) (int64, error) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, ids []uuid.UUID) ([]GetWorkspaceOwnerCountsByTemplateIDsRow, error) GetWorkspaceResourceByID(ctx context.Context, id uuid.UUID) (WorkspaceResource, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index e7ce3e458ce1e..d9c34db7001fb 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5756,6 +5756,160 @@ func (q *sqlQuerier) GetWorkspaceByOwnerIDAndName(ctx context.Context, arg GetWo return i, err } +const getWorkspaceCount = `-- name: GetWorkspaceCount :one +SELECT + COUNT(*) as count +FROM + workspaces +LEFT JOIN LATERAL ( + SELECT + workspace_builds.transition, + provisioner_jobs.started_at, + provisioner_jobs.updated_at, + provisioner_jobs.canceled_at, + provisioner_jobs.completed_at, + provisioner_jobs.error + FROM + workspace_builds + LEFT JOIN + provisioner_jobs + ON + provisioner_jobs.id = workspace_builds.job_id + WHERE + workspace_builds.workspace_id = workspaces.id + ORDER BY + build_number DESC + LIMIT + 1 +) latest_build ON TRUE +WHERE + -- Optionally include deleted workspaces + workspaces.deleted = $1 + AND CASE + WHEN $2 :: text != '' THEN + CASE + WHEN $2 = 'pending' THEN + latest_build.started_at IS NULL + WHEN $2 = 'starting' THEN + latest_build.started_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.completed_at IS NULL AND + latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND + latest_build.transition = 'start'::workspace_transition + + WHEN $2 = 'running' THEN + latest_build.completed_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.error IS NULL AND + latest_build.transition = 'start'::workspace_transition + + WHEN $2 = 'stopping' THEN + latest_build.started_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.completed_at IS NULL AND + latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND + latest_build.transition = 'stop'::workspace_transition + + WHEN $2 = 'stopped' THEN + latest_build.completed_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.error IS NULL AND + latest_build.transition = 'stop'::workspace_transition + + WHEN $2 = 'failed' THEN + (latest_build.canceled_at IS NOT NULL AND + latest_build.error IS NOT NULL) OR + (latest_build.completed_at IS NOT NULL AND + latest_build.error IS NOT NULL) + + WHEN $2 = 'canceling' THEN + latest_build.canceled_at IS NOT NULL AND + latest_build.completed_at IS NULL + + WHEN $2 = 'canceled' THEN + latest_build.canceled_at IS NOT NULL AND + latest_build.completed_at IS NOT NULL + + WHEN $2 = 'deleted' THEN + latest_build.started_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.completed_at IS NOT NULL AND + latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND + latest_build.transition = 'delete'::workspace_transition + + WHEN $2 = 'deleting' THEN + latest_build.completed_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.error IS NULL AND + latest_build.transition = 'delete'::workspace_transition + + ELSE + true + END + ELSE true + END + -- Filter by owner_id + AND CASE + WHEN $3 :: uuid != '00000000-00000000-00000000-00000000' THEN + owner_id = $3 + ELSE true + END + -- Filter by owner_name + AND CASE + WHEN $4 :: text != '' THEN + owner_id = (SELECT id FROM users WHERE lower(username) = lower($4) AND deleted = false) + ELSE true + END + -- Filter by template_name + -- There can be more than 1 template with the same name across organizations. + -- Use the organization filter to restrict to 1 org if needed. + AND CASE + WHEN $5 :: text != '' THEN + template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower($5) AND deleted = false) + ELSE true + END + -- Filter by template_ids + AND CASE + WHEN array_length($6 :: uuid[], 1) > 0 THEN + template_id = ANY($6) + ELSE true + END + -- Filter by name, matching on substring + AND CASE + WHEN $7 :: text != '' THEN + name ILIKE '%' || $7 || '%' + ELSE true + END + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaceCount + -- @authorize_filter +` + +type GetWorkspaceCountParams struct { + Deleted bool `db:"deleted" json:"deleted"` + Status string `db:"status" json:"status"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OwnerUsername string `db:"owner_username" json:"owner_username"` + TemplateName string `db:"template_name" json:"template_name"` + TemplateIds []uuid.UUID `db:"template_ids" json:"template_ids"` + Name string `db:"name" json:"name"` +} + +// this duplicates the filtering in GetWorkspaces +func (q *sqlQuerier) GetWorkspaceCount(ctx context.Context, arg GetWorkspaceCountParams) (int64, error) { + row := q.db.QueryRowContext(ctx, getWorkspaceCount, + arg.Deleted, + arg.Status, + arg.OwnerID, + arg.OwnerUsername, + arg.TemplateName, + pq.Array(arg.TemplateIds), + arg.Name, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + const getWorkspaceCountByUserID = `-- name: GetWorkspaceCountByUserID :one SELECT COUNT(id) diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 6e7f0436dbff8..15e35391f7ed7 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -145,6 +145,135 @@ OFFSET @offset_ ; +-- this duplicates the filtering in GetWorkspaces +-- name: GetWorkspaceCount :one +SELECT + COUNT(*) as count +FROM + workspaces +LEFT JOIN LATERAL ( + SELECT + workspace_builds.transition, + provisioner_jobs.started_at, + provisioner_jobs.updated_at, + provisioner_jobs.canceled_at, + provisioner_jobs.completed_at, + provisioner_jobs.error + FROM + workspace_builds + LEFT JOIN + provisioner_jobs + ON + provisioner_jobs.id = workspace_builds.job_id + WHERE + workspace_builds.workspace_id = workspaces.id + ORDER BY + build_number DESC + LIMIT + 1 +) latest_build ON TRUE +WHERE + -- Optionally include deleted workspaces + workspaces.deleted = @deleted + AND CASE + WHEN @status :: text != '' THEN + CASE + WHEN @status = 'pending' THEN + latest_build.started_at IS NULL + WHEN @status = 'starting' THEN + latest_build.started_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.completed_at IS NULL AND + latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND + latest_build.transition = 'start'::workspace_transition + + WHEN @status = 'running' THEN + latest_build.completed_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.error IS NULL AND + latest_build.transition = 'start'::workspace_transition + + WHEN @status = 'stopping' THEN + latest_build.started_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.completed_at IS NULL AND + latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND + latest_build.transition = 'stop'::workspace_transition + + WHEN @status = 'stopped' THEN + latest_build.completed_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.error IS NULL AND + latest_build.transition = 'stop'::workspace_transition + + WHEN @status = 'failed' THEN + (latest_build.canceled_at IS NOT NULL AND + latest_build.error IS NOT NULL) OR + (latest_build.completed_at IS NOT NULL AND + latest_build.error IS NOT NULL) + + WHEN @status = 'canceling' THEN + latest_build.canceled_at IS NOT NULL AND + latest_build.completed_at IS NULL + + WHEN @status = 'canceled' THEN + latest_build.canceled_at IS NOT NULL AND + latest_build.completed_at IS NOT NULL + + WHEN @status = 'deleted' THEN + latest_build.started_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.completed_at IS NOT NULL AND + latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND + latest_build.transition = 'delete'::workspace_transition + + WHEN @status = 'deleting' THEN + latest_build.completed_at IS NOT NULL AND + latest_build.canceled_at IS NULL AND + latest_build.error IS NULL AND + latest_build.transition = 'delete'::workspace_transition + + ELSE + true + END + ELSE true + END + -- Filter by owner_id + AND CASE + WHEN @owner_id :: uuid != '00000000-00000000-00000000-00000000' THEN + owner_id = @owner_id + ELSE true + END + -- Filter by owner_name + AND CASE + WHEN @owner_username :: text != '' THEN + owner_id = (SELECT id FROM users WHERE lower(username) = lower(@owner_username) AND deleted = false) + ELSE true + END + -- Filter by template_name + -- There can be more than 1 template with the same name across organizations. + -- Use the organization filter to restrict to 1 org if needed. + AND CASE + WHEN @template_name :: text != '' THEN + template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower(@template_name) AND deleted = false) + ELSE true + END + -- Filter by template_ids + AND CASE + WHEN array_length(@template_ids :: uuid[], 1) > 0 THEN + template_id = ANY(@template_ids) + ELSE true + END + -- Filter by name, matching on substring + AND CASE + WHEN @name :: text != '' THEN + name ILIKE '%' || @name || '%' + ELSE true + END + -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaceCount + -- @authorize_filter +; + -- name: GetWorkspaceByOwnerIDAndName :one SELECT * diff --git a/coderd/workspaces.go b/coderd/workspaces.go index d847af70328f4..0295dc29d5e56 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -157,6 +157,58 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusOK, wss) } +func (api *API) workspaceCount(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + apiKey := httpmw.APIKey(r) + + queryStr := r.URL.Query().Get("q") + filter, errs := workspaceSearchQuery(queryStr, codersdk.Pagination{}) + if len(errs) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid audit search query.", + Validations: errs, + }) + return + } + + if filter.OwnerUsername == "me" { + filter.OwnerID = apiKey.UserID + filter.OwnerUsername = "" + } + + sqlFilter, err := api.HTTPAuth.AuthorizeSQLFilter(r, rbac.ActionRead, rbac.ResourceWorkspace.Type) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error preparing sql filter.", + Detail: err.Error(), + }) + return + } + + countFilter := database.GetWorkspaceCountParams{ + Deleted: filter.Deleted, + OwnerUsername: filter.OwnerUsername, + OwnerID: filter.OwnerID, + Name: filter.Name, + Status: filter.Status, + TemplateIds: filter.TemplateIds, + TemplateName: filter.TemplateName, + } + + count, err := api.Database.GetAuthorizedWorkspaceCount(ctx, countFilter, sqlFilter) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace count.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceCountResponse{ + Count: count, + }) +} + func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() owner := httpmw.UserParam(r) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 1026dcdd6dd76..fce2ffdf9c221 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -822,6 +822,33 @@ func TestOffsetLimit(t *testing.T) { require.Len(t, ws, 0) } +func TestWorkspaceCount(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(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) + template2 := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template2.ID) + _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template2.ID) + + response, err := client.WorkspaceCount(ctx, codersdk.WorkspaceCountRequest{}) + require.NoError(t, err, "fetch workspace count") + // counts all + require.Equal(t, int(response.Count), 3) + + response2, err2 := client.WorkspaceCount(ctx, codersdk.WorkspaceCountRequest{ + SearchQuery: fmt.Sprintf("template:%s", template.Name), + }) + require.NoError(t, err2, "fetch workspace count") + // counts only those that pass filter + require.Equal(t, int(response2.Count), 1) +} + func TestPostWorkspaceBuild(t *testing.T) { t.Parallel() t.Run("NoTemplateVersion", func(t *testing.T) { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index a019504ad9d09..69d287a595ca6 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -31,6 +31,18 @@ type Workspace struct { LastUsedAt time.Time `json:"last_used_at"` } +type WorkspacesRequest struct { + SearchQuery string `json:"q,omitempty"` + Pagination +} + +type WorkspaceCountRequest struct { + SearchQuery string `json:"q,omitempty"` +} +type WorkspaceCountResponse struct { + Count int64 `json:"count"` +} + // CreateWorkspaceBuildRequest provides options to update the latest workspace build. type CreateWorkspaceBuildRequest struct { TemplateVersionID uuid.UUID `json:"template_version_id,omitempty"` @@ -312,6 +324,34 @@ func (c *Client) Workspaces(ctx context.Context, filter WorkspaceFilter) ([]Work return workspaces, json.NewDecoder(res.Body).Decode(&workspaces) } +func (c *Client) WorkspaceCount(ctx context.Context, req WorkspaceCountRequest) (WorkspaceCountResponse, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/workspaces/count", nil, func(r *http.Request) { + q := r.URL.Query() + var params []string + if req.SearchQuery != "" { + params = append(params, req.SearchQuery) + } + q.Set("q", strings.Join(params, " ")) + r.URL.RawQuery = q.Encode() + }) + if err != nil { + return WorkspaceCountResponse{}, err + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return WorkspaceCountResponse{}, readBodyAsError(res) + } + + var countRes WorkspaceCountResponse + err = json.NewDecoder(res.Body).Decode(&countRes) + if err != nil { + return WorkspaceCountResponse{}, err + } + + return countRes, nil +} + // WorkspaceByOwnerAndName returns a workspace by the owner's UUID and the workspace's name. func (c *Client) WorkspaceByOwnerAndName(ctx context.Context, owner string, name string, params WorkspaceOptions) (Workspace, error) { res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/users/%s/workspace/%s", owner, name), nil, func(r *http.Request) { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index e9a0cd7e44cf8..ded0d3d3c89ba 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -280,10 +280,35 @@ export const getURLWithSearchParams = ( } export const getWorkspaces = async ( - filter?: TypesGen.WorkspaceFilter, + options: TypesGen.WorkspacesRequest, ): Promise => { - const url = getURLWithSearchParams("/api/v2/workspaces", filter) - const response = await axios.get(url) + const searchParams = new URLSearchParams() + if (options.limit) { + searchParams.set("limit", options.limit.toString()) + } + if (options.offset) { + searchParams.set("offset", options.offset.toString()) + } + if (options.q) { + searchParams.set("q", options.q) + } + + const response = await axios.get( + `/api/v2/workspaces?${searchParams.toString()}`, + ) + return response.data +} + +export const getWorkspacesCount = async ( + options: TypesGen.WorkspaceCountRequest, +): Promise => { + const searchParams = new URLSearchParams() + if (options.q) { + searchParams.set("q", options.q) + } + const response = await axios.get( + `/api/v2/workspaces/count?${searchParams.toString()}`, + ) return response.data } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 5b3c44bd0b979..c8540c31888a9 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -845,6 +845,16 @@ export interface WorkspaceBuildsRequest extends Pagination { readonly Since: string } +// From codersdk/workspaces.go +export interface WorkspaceCountRequest { + readonly q?: string +} + +// From codersdk/workspaces.go +export interface WorkspaceCountResponse { + readonly count: number +} + // From codersdk/workspaces.go export interface WorkspaceFilter { readonly q?: string @@ -882,6 +892,11 @@ export interface WorkspaceResourceMetadata { readonly sensitive: boolean } +// From codersdk/workspaces.go +export interface WorkspacesRequest extends Pagination { + readonly q?: string +} + // From codersdk/apikey.go export type APIKeyScope = "all" | "application_connect" diff --git a/site/src/components/AlertBanner/AlertBanner.stories.tsx b/site/src/components/AlertBanner/AlertBanner.stories.tsx index d426d97393cd6..10eebd8ed810c 100644 --- a/site/src/components/AlertBanner/AlertBanner.stories.tsx +++ b/site/src/components/AlertBanner/AlertBanner.stories.tsx @@ -1,7 +1,8 @@ import { Story } from "@storybook/react" -import { AlertBanner, AlertBannerProps } from "./AlertBanner" +import { AlertBanner } from "./AlertBanner" import Button from "@material-ui/core/Button" import { makeMockApiError } from "testHelpers/entities" +import { AlertBannerProps } from "./alertTypes" export default { title: "components/AlertBanner", @@ -99,3 +100,9 @@ ErrorWithActionRetryAndDismiss.args = { dismissible: true, severity: "error", } + +export const ErrorAsWarning = Template.bind({}) +ErrorAsWarning.args = { + error: mockError, + severity: "warning", +} diff --git a/site/src/components/AlertBanner/AlertBanner.tsx b/site/src/components/AlertBanner/AlertBanner.tsx index 9c857811908a0..1f79f2e8d1a76 100644 --- a/site/src/components/AlertBanner/AlertBanner.tsx +++ b/site/src/components/AlertBanner/AlertBanner.tsx @@ -11,12 +11,12 @@ import { severityConstants } from "./severityConstants" import { AlertBannerCtas } from "./AlertBannerCtas" /** - * severity: the level of alert severity (see ./severityTypes.ts) - * text: default text to be displayed to the user; useful for warnings or as a fallback error message - * error: should be passed in if the severity is 'Error'; warnings can use 'text' instead - * actions: an array of CTAs passed in by the consumer - * dismissible: determines whether or not the banner should have a `Dismiss` CTA - * retry: a handler to retry the action that spawned the error + * @param severity: the level of alert severity (see ./severityTypes.ts) + * @param text: default text to be displayed to the user; useful for warnings or as a fallback error message + * @param error: should be passed in if the severity is 'Error'; warnings can use 'text' instead + * @param actions: an array of CTAs passed in by the consumer + * @param dismissible: determines whether or not the banner should have a `Dismiss` CTA + * @param retry: a handler to retry the action that spawned the error */ export const AlertBanner: FC = ({ severity, diff --git a/site/src/components/PaginationWidget/PaginationWidget.tsx b/site/src/components/PaginationWidget/PaginationWidget.tsx index ba963aed92921..f2dfbfb0900ed 100644 --- a/site/src/components/PaginationWidget/PaginationWidget.tsx +++ b/site/src/components/PaginationWidget/PaginationWidget.tsx @@ -1,7 +1,10 @@ import Button from "@material-ui/core/Button" -import { makeStyles } from "@material-ui/core/styles" +import { makeStyles, useTheme } from "@material-ui/core/styles" +import useMediaQuery from "@material-ui/core/useMediaQuery" import KeyboardArrowLeft from "@material-ui/icons/KeyboardArrowLeft" import KeyboardArrowRight from "@material-ui/icons/KeyboardArrowRight" +import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" +import { Maybe } from "components/Conditionals/Maybe" import { CSSProperties } from "react" export type PaginationWidgetProps = { @@ -24,7 +27,7 @@ export type PaginationWidgetProps = { const range = (start: number, stop: number, step = 1) => Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step) -const DEFAULT_RECORDS_PER_PAGE = 25 +export const DEFAULT_RECORDS_PER_PAGE = 25 // Number of pages to the left or right of the current page selection. const PAGE_NEIGHBORS = 1 // Number of pages displayed for cases where there are multiple ellipsis showing. This can be @@ -74,6 +77,38 @@ export const buildPagedList = ( return range(1, numPages) } +interface PageButtonProps { + activePage: number + page: number + numPages: number + onPageClick?: (page: number) => void +} + +const PageButton = ({ + activePage, + page, + numPages, + onPageClick, +}: PageButtonProps): JSX.Element => { + const styles = useStyles() + return ( + + ) +} + export const PaginationWidget = ({ prevLabel, nextLabel, @@ -88,11 +123,12 @@ export const PaginationWidget = ({ const numPages = numRecords ? Math.ceil(numRecords / numRecordsPerPage) : 0 const firstPageActive = activePage === 1 && numPages !== 0 const lastPageActive = activePage === numPages && numPages !== 0 - + const theme = useTheme() + const isMobile = useMediaQuery(theme.breakpoints.down("sm")) const styles = useStyles() // No need to display any pagination if we know the number of pages is 1 - if (numPages === 1) { + if (numPages === 1 || numRecords === 0) { return null } @@ -107,30 +143,38 @@ export const PaginationWidget = ({
{prevLabel}
- {numPages > 0 && - buildPagedList(numPages, activePage).map((page) => - typeof page !== "number" ? ( - - ) : ( - - ), - )} + 0}> + + + + + + {buildPagedList(numPages, activePage).map((page) => + typeof page !== "number" ? ( + + ) : ( + + ), + )} + + +