feat: paginate workspaces page (#4647)

* Start - still needs api call changes

* Some xservice changes

* Finish adding count to xservice

* Mock out api call on frontend

* Handle errors

* Doctor getWorkspaces

* Add types, start writing count function

* Hook up route

* Use empty page struct

* Write interface and database fake

* SQL query

* Fix params type

* Missed a spot

* Space after alert banner

* Fix model queries

* Unpack query correctly

* Fix filter-page interaction

* Make mobile friendly

* Format

* Test backend

* Fix key

* Delete unnecessary conditional

* Add test helpers

* Use limit constant

* Show widget with no count

* Add test

* Format

* make gen from garretts workspace idk why

* fix authorize test'

* Hide widget with 0 records

* Fix tests

* Format

* Fix types generated

* Fix story

* Add alert banner story

* Format

* Fix import

* Format

* Try removing story

* Revert "Fix story"

This reverts commit c06765b7fb.

* Add counts to page view story

* Revert "Try removing story"

This reverts commit 476019b041.

Co-authored-by: Garrett <garrett@coder.com>
This commit is contained in:
Presley Pizzo
2022-10-20 13:23:14 -04:00
committed by GitHub
parent 423ac04156
commit 7c238f13e5
24 changed files with 1074 additions and 187 deletions

View File

@ -503,6 +503,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),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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