diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 3e6b0e1d15ab4..dd41fe08ffa28 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -345,7 +345,7 @@ func mapAgentStatus(dbAgent database.WorkspaceAgent, agentInactiveDisconnectTime return status } -func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspaces []database.Workspace, count int64) []database.GetWorkspacesRow { +func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspaces []database.Workspace, count int64, withSummary bool) []database.GetWorkspacesRow { //nolint:revive // withSummary flag ensures the extra technical row rows := make([]database.GetWorkspacesRow, 0, len(workspaces)) for _, w := range workspaces { wr := database.GetWorkspacesRow{ @@ -389,6 +389,12 @@ func (q *FakeQuerier) convertToWorkspaceRowsNoLock(ctx context.Context, workspac rows = append(rows, wr) } + if withSummary { + rows = append(rows, database.GetWorkspacesRow{ + Name: "**TECHNICAL_ROW**", + Count: count, + }) + } return rows } @@ -8278,12 +8284,12 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } if arg.Limit > 0 { if int(arg.Limit) > len(workspaces) { - return q.convertToWorkspaceRowsNoLock(ctx, workspaces, int64(beforePageCount)), nil + return q.convertToWorkspaceRowsNoLock(ctx, workspaces, int64(beforePageCount), arg.WithSummary), nil } workspaces = workspaces[:arg.Limit] } - return q.convertToWorkspaceRowsNoLock(ctx, workspaces, int64(beforePageCount)), nil + return q.convertToWorkspaceRowsNoLock(ctx, workspaces, int64(beforePageCount), arg.WithSummary), nil } func (q *FakeQuerier) GetAuthorizedUsers(ctx context.Context, arg database.GetUsersParams, prepared rbac.PreparedAuthorized) ([]database.GetUsersRow, error) { diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 34d43ecd924bd..8ea2ed873e06a 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -231,6 +231,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.RequesterID, arg.Offset, arg.Limit, + arg.WithSummary, ) if err != nil { return nil, err @@ -258,6 +259,11 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, + &i.Username, + &i.LatestBuildCompletedAt, + &i.LatestBuildCanceledAt, + &i.LatestBuildError, + &i.LatestBuildTransition, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 6ddf88be6ee24..f3633a47bcb3f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11771,12 +11771,17 @@ func (q *sqlQuerier) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Conte } const getWorkspaces = `-- name: GetWorkspaces :many +WITH filtered_workspaces AS ( SELECT workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, COALESCE(template.name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, - COUNT(*) OVER () as count + users.username as username, + latest_build.completed_at as latest_build_completed_at, + latest_build.canceled_at as latest_build_canceled_at, + latest_build.error as latest_build_error, + latest_build.transition as latest_build_transition FROM workspaces JOIN @@ -11960,22 +11965,75 @@ WHERE END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter -ORDER BY - -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN workspaces.owner_id = $14 AND workspaces.favorite THEN 0 ELSE 1 END ASC, - (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) DESC, - LOWER(users.username) ASC, - LOWER(workspaces.name) ASC -LIMIT - CASE - WHEN $16 :: integer > 0 THEN - $16 - END -OFFSET - $15 +), filtered_workspaces_order AS ( + SELECT + fw.id, fw.created_at, fw.updated_at, fw.owner_id, fw.organization_id, fw.template_id, fw.deleted, fw.name, fw.autostart_schedule, fw.ttl, fw.last_used_at, fw.dormant_at, fw.deleting_at, fw.automatic_updates, fw.favorite, fw.template_name, fw.template_version_id, fw.template_version_name, fw.username, fw.latest_build_completed_at, fw.latest_build_canceled_at, fw.latest_build_error, fw.latest_build_transition + FROM + filtered_workspaces fw + ORDER BY + -- To ensure that 'favorite' workspaces show up first in the list only for their owner. + CASE WHEN owner_id = $14 AND favorite THEN 0 ELSE 1 END ASC, + (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) DESC, + LOWER(username) ASC, + LOWER(name) ASC + LIMIT + CASE + WHEN $16 :: integer > 0 THEN + $16 + END + OFFSET + $15 +), filtered_workspaces_order_with_summary AS ( + SELECT + fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.template_name, fwo.template_version_id, fwo.template_version_name, fwo.username, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition + FROM + filtered_workspaces_order fwo + -- Return a technical summary row with total count of workspaces. + -- It is used to present the correct count if pagination goes beyond the offset. + UNION ALL + SELECT + '00000000-0000-0000-0000-000000000000'::uuid, -- id + '0001-01-01 00:00:00+00'::timestamp, -- created_at + '0001-01-01 00:00:00+00'::timestamp, -- updated_at + '00000000-0000-0000-0000-000000000000'::uuid, -- owner_id + '00000000-0000-0000-0000-000000000000'::uuid, -- organization_id + '00000000-0000-0000-0000-000000000000'::uuid, -- template_id + false, -- deleted + '**TECHNICAL_ROW**', -- name + '', -- autostart_schedule + 0, -- ttl + '0001-01-01 00:00:00+00'::timestamp, -- last_used_at + '0001-01-01 00:00:00+00'::timestamp, -- dormant_at + '0001-01-01 00:00:00+00'::timestamp, -- deleting_at + 'never'::automatic_updates, -- automatic_updates + false, -- favorite + -- Extra columns added to ` + "`" + `filtered_workspaces` + "`" + ` + '', -- template_name + '00000000-0000-0000-0000-000000000000'::uuid, -- template_version_id + '', -- template_version_name + '', -- username + '0001-01-01 00:00:00+00'::timestamp, -- latest_build_completed_at, + '0001-01-01 00:00:00+00'::timestamp, -- latest_build_canceled_at, + '', -- latest_build_error + 'start'::workspace_transition -- latest_build_transition + WHERE + $17 :: boolean = true +), total_count AS ( + SELECT + count(*) AS count + FROM + filtered_workspaces +) +SELECT + fwos.id, fwos.created_at, fwos.updated_at, fwos.owner_id, fwos.organization_id, fwos.template_id, fwos.deleted, fwos.name, fwos.autostart_schedule, fwos.ttl, fwos.last_used_at, fwos.dormant_at, fwos.deleting_at, fwos.automatic_updates, fwos.favorite, fwos.template_name, fwos.template_version_id, fwos.template_version_name, fwos.username, fwos.latest_build_completed_at, fwos.latest_build_canceled_at, fwos.latest_build_error, fwos.latest_build_transition, + tc.count +FROM + filtered_workspaces_order_with_summary fwos +CROSS JOIN + total_count tc ` type GetWorkspacesParams struct { @@ -11995,28 +12053,34 @@ type GetWorkspacesParams struct { RequesterID uuid.UUID `db:"requester_id" json:"requester_id"` Offset int32 `db:"offset_" json:"offset_"` Limit int32 `db:"limit_" json:"limit_"` + WithSummary bool `db:"with_summary" json:"with_summary"` } type GetWorkspacesRow struct { - ID uuid.UUID `db:"id" json:"id"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` - Deleted bool `db:"deleted" json:"deleted"` - Name string `db:"name" json:"name"` - AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` - Ttl sql.NullInt64 `db:"ttl" json:"ttl"` - LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` - DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` - DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` - AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` - Favorite bool `db:"favorite" json:"favorite"` - TemplateName string `db:"template_name" json:"template_name"` - TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` - TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` - Count int64 `db:"count" json:"count"` + ID uuid.UUID `db:"id" json:"id"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Deleted bool `db:"deleted" json:"deleted"` + Name string `db:"name" json:"name"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` + Ttl sql.NullInt64 `db:"ttl" json:"ttl"` + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` + DeletingAt sql.NullTime `db:"deleting_at" json:"deleting_at"` + AutomaticUpdates AutomaticUpdates `db:"automatic_updates" json:"automatic_updates"` + Favorite bool `db:"favorite" json:"favorite"` + TemplateName string `db:"template_name" json:"template_name"` + TemplateVersionID uuid.UUID `db:"template_version_id" json:"template_version_id"` + TemplateVersionName sql.NullString `db:"template_version_name" json:"template_version_name"` + Username string `db:"username" json:"username"` + LatestBuildCompletedAt sql.NullTime `db:"latest_build_completed_at" json:"latest_build_completed_at"` + LatestBuildCanceledAt sql.NullTime `db:"latest_build_canceled_at" json:"latest_build_canceled_at"` + LatestBuildError sql.NullString `db:"latest_build_error" json:"latest_build_error"` + LatestBuildTransition WorkspaceTransition `db:"latest_build_transition" json:"latest_build_transition"` + Count int64 `db:"count" json:"count"` } func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) { @@ -12037,6 +12101,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.RequesterID, arg.Offset, arg.Limit, + arg.WithSummary, ) if err != nil { return nil, err @@ -12064,6 +12129,11 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) &i.TemplateName, &i.TemplateVersionID, &i.TemplateVersionName, + &i.Username, + &i.LatestBuildCompletedAt, + &i.LatestBuildCanceledAt, + &i.LatestBuildError, + &i.LatestBuildTransition, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 592aefb1acce9..66b03185116f2 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -77,12 +77,17 @@ WHERE ); -- name: GetWorkspaces :many +WITH filtered_workspaces AS ( SELECT workspaces.*, COALESCE(template.name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, - COUNT(*) OVER () as count + users.username as username, + latest_build.completed_at as latest_build_completed_at, + latest_build.canceled_at as latest_build_canceled_at, + latest_build.error as latest_build_error, + latest_build.transition as latest_build_transition FROM workspaces JOIN @@ -266,23 +271,75 @@ WHERE END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter -ORDER BY - -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN workspaces.owner_id = @requester_id AND workspaces.favorite THEN 0 ELSE 1 END ASC, - (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) DESC, - LOWER(users.username) ASC, - LOWER(workspaces.name) ASC -LIMIT - CASE - WHEN @limit_ :: integer > 0 THEN - @limit_ - END -OFFSET - @offset_ -; +), filtered_workspaces_order AS ( + SELECT + fw.* + FROM + filtered_workspaces fw + ORDER BY + -- To ensure that 'favorite' workspaces show up first in the list only for their owner. + CASE WHEN owner_id = @requester_id AND favorite THEN 0 ELSE 1 END ASC, + (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) DESC, + LOWER(username) ASC, + LOWER(name) ASC + LIMIT + CASE + WHEN @limit_ :: integer > 0 THEN + @limit_ + END + OFFSET + @offset_ +), filtered_workspaces_order_with_summary AS ( + SELECT + fwo.* + FROM + filtered_workspaces_order fwo + -- Return a technical summary row with total count of workspaces. + -- It is used to present the correct count if pagination goes beyond the offset. + UNION ALL + SELECT + '00000000-0000-0000-0000-000000000000'::uuid, -- id + '0001-01-01 00:00:00+00'::timestamp, -- created_at + '0001-01-01 00:00:00+00'::timestamp, -- updated_at + '00000000-0000-0000-0000-000000000000'::uuid, -- owner_id + '00000000-0000-0000-0000-000000000000'::uuid, -- organization_id + '00000000-0000-0000-0000-000000000000'::uuid, -- template_id + false, -- deleted + '**TECHNICAL_ROW**', -- name + '', -- autostart_schedule + 0, -- ttl + '0001-01-01 00:00:00+00'::timestamp, -- last_used_at + '0001-01-01 00:00:00+00'::timestamp, -- dormant_at + '0001-01-01 00:00:00+00'::timestamp, -- deleting_at + 'never'::automatic_updates, -- automatic_updates + false, -- favorite + -- Extra columns added to `filtered_workspaces` + '', -- template_name + '00000000-0000-0000-0000-000000000000'::uuid, -- template_version_id + '', -- template_version_name + '', -- username + '0001-01-01 00:00:00+00'::timestamp, -- latest_build_completed_at, + '0001-01-01 00:00:00+00'::timestamp, -- latest_build_canceled_at, + '', -- latest_build_error + 'start'::workspace_transition -- latest_build_transition + WHERE + @with_summary :: boolean = true +), total_count AS ( + SELECT + count(*) AS count + FROM + filtered_workspaces +) +SELECT + fwos.*, + tc.count +FROM + filtered_workspaces_order_with_summary fwos +CROSS JOIN + total_count tc; -- name: GetWorkspaceByOwnerIDAndName :one SELECT diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0ab1e5ee41660..05bf9688e7536 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -173,6 +173,9 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { // the workspace owner_id when ordering the rows. filter.RequesterID = apiKey.UserID + // We need the technical row to present the correct count on every page. + filter.WithSummary = true + workspaceRows, err := api.Database.GetAuthorizedWorkspaces(ctx, filter, prepared) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ @@ -181,6 +184,23 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { }) return } + if len(workspaceRows) == 0 { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspaces.", + Detail: "Workspace summary row is missing.", + }) + return + } + if len(workspaceRows) == 1 { + httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{ + Workspaces: []codersdk.Workspace{}, + Count: int(workspaceRows[0].Count), + }) + return + } + // Skip technical summary row + workspaceRows = workspaceRows[:len(workspaceRows)-1] + if len(workspaceRows) == 0 { httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{ Workspaces: []codersdk.Workspace{}, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index af20807cd5b32..3100a622e0f12 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1753,6 +1753,7 @@ func TestOffsetLimit(t *testing.T) { }) require.NoError(t, err) require.Len(t, ws.Workspaces, 0) + require.Equal(t, ws.Count, 3) // can't find workspaces, but count is non-zero } func TestWorkspaceUpdateAutostart(t *testing.T) {