mirror of https://github.com/coder/coder.git
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 commitc06765b7fb
. * Add counts to page view story * Revert "Try removing story" This reverts commit476019b041
. Co-authored-by: Garrett <garrett@coder.com>
This commit is contained in:
parent
423ac04156
commit
7c238f13e5
|
@ -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),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
*
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -280,10 +280,35 @@ export const getURLWithSearchParams = (
|
|||
}
|
||||
|
||||
export const getWorkspaces = async (
|
||||
filter?: TypesGen.WorkspaceFilter,
|
||||
options: TypesGen.WorkspacesRequest,
|
||||
): Promise<TypesGen.Workspace[]> => {
|
||||
const url = getURLWithSearchParams("/api/v2/workspaces", filter)
|
||||
const response = await axios.get<TypesGen.Workspace[]>(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<TypesGen.Workspace[]>(
|
||||
`/api/v2/workspaces?${searchParams.toString()}`,
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getWorkspacesCount = async (
|
||||
options: TypesGen.WorkspaceCountRequest,
|
||||
): Promise<TypesGen.WorkspaceCountResponse> => {
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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",
|
||||
}
|
||||
|
|
|
@ -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<AlertBannerProps> = ({
|
||||
severity,
|
||||
|
|
|
@ -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 (
|
||||
<Button
|
||||
className={
|
||||
activePage === page
|
||||
? `${styles.pageButton} ${styles.activePageButton}`
|
||||
: styles.pageButton
|
||||
}
|
||||
aria-label={`${page === activePage ? "Current Page" : ""} ${
|
||||
page === numPages ? "Last Page" : ""
|
||||
} Page${page}`}
|
||||
name="Page button"
|
||||
onClick={() => onPageClick && onPageClick(page)}
|
||||
>
|
||||
<div>{page}</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
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 = ({
|
|||
<KeyboardArrowLeft />
|
||||
<div>{prevLabel}</div>
|
||||
</Button>
|
||||
{numPages > 0 &&
|
||||
buildPagedList(numPages, activePage).map((page) =>
|
||||
typeof page !== "number" ? (
|
||||
<Button className={styles.pageButton} key={`Page${page}`} disabled>
|
||||
<div>...</div>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
className={
|
||||
activePage === page
|
||||
? `${styles.pageButton} ${styles.activePageButton}`
|
||||
: styles.pageButton
|
||||
}
|
||||
aria-label={`${page === activePage ? "Current Page" : ""} ${
|
||||
page === numPages ? "Last Page" : ""
|
||||
} Page${page}`}
|
||||
name="Page button"
|
||||
key={`Page${page}`}
|
||||
onClick={() => onPageClick && onPageClick(page)}
|
||||
>
|
||||
<div>{page}</div>
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
<Maybe condition={numPages > 0}>
|
||||
<ChooseOne>
|
||||
<Cond condition={isMobile}>
|
||||
<PageButton
|
||||
activePage={activePage}
|
||||
page={activePage}
|
||||
numPages={numPages}
|
||||
/>
|
||||
</Cond>
|
||||
<Cond>
|
||||
{buildPagedList(numPages, activePage).map((page) =>
|
||||
typeof page !== "number" ? (
|
||||
<Button
|
||||
className={styles.pageButton}
|
||||
key={`Page${page}`}
|
||||
disabled
|
||||
>
|
||||
<div>...</div>
|
||||
</Button>
|
||||
) : (
|
||||
<PageButton
|
||||
key={`Page${page}`}
|
||||
activePage={activePage}
|
||||
page={page}
|
||||
numPages={numPages}
|
||||
onPageClick={onPageClick}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</Cond>
|
||||
</ChooseOne>
|
||||
</Maybe>
|
||||
<Button
|
||||
aria-label="Next page"
|
||||
disabled={lastPageActive}
|
||||
|
|
|
@ -220,7 +220,7 @@ describe("WorkspacePage", () => {
|
|||
|
||||
await waitFor(() =>
|
||||
expect(api.startWorkspace).toBeCalledWith(
|
||||
"test-workspace",
|
||||
"test-outdated-workspace",
|
||||
"test-template-version",
|
||||
),
|
||||
)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { screen } from "@testing-library/react"
|
||||
import { screen, waitFor } from "@testing-library/react"
|
||||
import { rest } from "msw"
|
||||
import * as CreateDayString from "util/createDayString"
|
||||
import { Language as WorkspacesTableBodyLanguage } from "../../components/WorkspacesTable/WorkspacesTableBody"
|
||||
|
@ -34,9 +34,24 @@ describe("WorkspacesPage", () => {
|
|||
|
||||
it("renders a filled workspaces page", async () => {
|
||||
// When
|
||||
render(<WorkspacesPage />)
|
||||
const { container } = render(<WorkspacesPage />)
|
||||
|
||||
// Then
|
||||
const nextPage = await screen.findByRole("button", { name: "Next page" })
|
||||
expect(nextPage).toBeEnabled()
|
||||
await waitFor(
|
||||
async () => {
|
||||
const prevPage = await screen.findByRole("button", {
|
||||
name: "Previous page",
|
||||
})
|
||||
expect(prevPage).toBeDisabled()
|
||||
const pageButtons = await container.querySelectorAll(
|
||||
`button[name="Page button"]`,
|
||||
)
|
||||
expect(pageButtons.length).toBe(2)
|
||||
},
|
||||
{ timeout: 2000 },
|
||||
)
|
||||
await screen.findByText(MockWorkspace.name)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -1,22 +1,43 @@
|
|||
import { useMachine } from "@xstate/react"
|
||||
import { DEFAULT_RECORDS_PER_PAGE } from "components/PaginationWidget/PaginationWidget"
|
||||
import { FC } from "react"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { useNavigate, useSearchParams } from "react-router-dom"
|
||||
import { workspaceFilterQuery } from "util/filters"
|
||||
import { pageTitle } from "util/page"
|
||||
import { workspacesMachine } from "xServices/workspaces/workspacesXService"
|
||||
import { WorkspacesPageView } from "./WorkspacesPageView"
|
||||
|
||||
const WorkspacesPage: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const filter = searchParams.get("filter") ?? workspaceFilterQuery.me
|
||||
const currentPage = searchParams.get("page")
|
||||
? Number(searchParams.get("page"))
|
||||
: 1
|
||||
const [workspacesState, send] = useMachine(workspacesMachine, {
|
||||
context: {
|
||||
page: currentPage,
|
||||
limit: DEFAULT_RECORDS_PER_PAGE,
|
||||
filter,
|
||||
},
|
||||
actions: {
|
||||
onPageChange: ({ page }) => {
|
||||
navigate({
|
||||
search: `?page=${page}`,
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { workspaceRefs } = workspacesState.context
|
||||
const {
|
||||
workspaceRefs,
|
||||
count,
|
||||
page,
|
||||
limit,
|
||||
getWorkspacesError,
|
||||
getCountError,
|
||||
} = workspacesState.context
|
||||
|
||||
return (
|
||||
<>
|
||||
|
@ -28,10 +49,24 @@ const WorkspacesPage: FC = () => {
|
|||
filter={workspacesState.context.filter}
|
||||
isLoading={!workspaceRefs}
|
||||
workspaceRefs={workspaceRefs}
|
||||
count={count}
|
||||
getWorkspacesError={getWorkspacesError}
|
||||
getCountError={getCountError}
|
||||
page={page}
|
||||
limit={limit}
|
||||
onNext={() => {
|
||||
send("NEXT")
|
||||
}}
|
||||
onPrevious={() => {
|
||||
send("PREVIOUS")
|
||||
}}
|
||||
onGoToPage={(page) => {
|
||||
send("GO_TO_PAGE", { page })
|
||||
}}
|
||||
onFilter={(query) => {
|
||||
setSearchParams({ filter: query })
|
||||
send({
|
||||
type: "GET_WORKSPACES",
|
||||
type: "UPDATE_FILTER",
|
||||
query,
|
||||
})
|
||||
}}
|
||||
|
|
|
@ -107,16 +107,19 @@ AllStates.args = {
|
|||
...Object.values(workspaces),
|
||||
...Object.values(additionalWorkspaces),
|
||||
],
|
||||
count: 14,
|
||||
}
|
||||
|
||||
export const OwnerHasNoWorkspaces = Template.bind({})
|
||||
OwnerHasNoWorkspaces.args = {
|
||||
workspaceRefs: [],
|
||||
filter: workspaceFilterQuery.me,
|
||||
count: 0,
|
||||
}
|
||||
|
||||
export const NoResults = Template.bind({})
|
||||
NoResults.args = {
|
||||
workspaceRefs: [],
|
||||
filter: "searchtearmwithnoresults",
|
||||
count: 0,
|
||||
}
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import Link from "@material-ui/core/Link"
|
||||
import { AlertBanner } from "components/AlertBanner/AlertBanner"
|
||||
import { Maybe } from "components/Conditionals/Maybe"
|
||||
import { PaginationWidget } from "components/PaginationWidget/PaginationWidget"
|
||||
import { FC } from "react"
|
||||
import { Link as RouterLink } from "react-router-dom"
|
||||
import { Margins } from "../../components/Margins/Margins"
|
||||
|
@ -26,13 +29,34 @@ export const Language = {
|
|||
export interface WorkspacesPageViewProps {
|
||||
isLoading?: boolean
|
||||
workspaceRefs?: WorkspaceItemMachineRef[]
|
||||
count?: number
|
||||
getWorkspacesError: Error | unknown
|
||||
getCountError: Error | unknown
|
||||
page: number
|
||||
limit: number
|
||||
filter?: string
|
||||
onFilter: (query: string) => void
|
||||
onNext: () => void
|
||||
onPrevious: () => void
|
||||
onGoToPage: (page: number) => void
|
||||
}
|
||||
|
||||
export const WorkspacesPageView: FC<
|
||||
React.PropsWithChildren<WorkspacesPageViewProps>
|
||||
> = ({ isLoading, workspaceRefs, filter, onFilter }) => {
|
||||
> = ({
|
||||
isLoading,
|
||||
workspaceRefs,
|
||||
count,
|
||||
getWorkspacesError,
|
||||
getCountError,
|
||||
page,
|
||||
limit,
|
||||
filter,
|
||||
onFilter,
|
||||
onNext,
|
||||
onPrevious,
|
||||
onGoToPage,
|
||||
}) => {
|
||||
const presetFilters = [
|
||||
{ query: workspaceFilterQuery.me, name: Language.yourWorkspacesButton },
|
||||
{ query: workspaceFilterQuery.all, name: Language.allWorkspacesButton },
|
||||
|
@ -61,17 +85,45 @@ export const WorkspacesPageView: FC<
|
|||
</PageHeaderSubtitle>
|
||||
</PageHeader>
|
||||
|
||||
<SearchBarWithFilter
|
||||
filter={filter}
|
||||
onFilter={onFilter}
|
||||
presetFilters={presetFilters}
|
||||
/>
|
||||
<Stack>
|
||||
<Maybe condition={getWorkspacesError !== undefined}>
|
||||
<AlertBanner
|
||||
error={getWorkspacesError}
|
||||
severity={
|
||||
workspaceRefs !== undefined && workspaceRefs.length > 0
|
||||
? "warning"
|
||||
: "error"
|
||||
}
|
||||
/>
|
||||
</Maybe>
|
||||
|
||||
<Maybe condition={getCountError !== undefined}>
|
||||
<AlertBanner error={getCountError} severity="warning" />
|
||||
</Maybe>
|
||||
|
||||
<SearchBarWithFilter
|
||||
filter={filter}
|
||||
onFilter={onFilter}
|
||||
presetFilters={presetFilters}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<WorkspacesTable
|
||||
isLoading={isLoading}
|
||||
workspaceRefs={workspaceRefs}
|
||||
filter={filter}
|
||||
/>
|
||||
|
||||
<PaginationWidget
|
||||
prevLabel=""
|
||||
nextLabel=""
|
||||
onPrevClick={onPrevious}
|
||||
onNextClick={onNext}
|
||||
onPageClick={onGoToPage}
|
||||
numRecords={count}
|
||||
activePage={page}
|
||||
numRecordsPerPage={limit}
|
||||
/>
|
||||
</Margins>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -424,10 +424,12 @@ export const MockWorkspace: TypesGen.Workspace = {
|
|||
|
||||
export const MockStoppedWorkspace: TypesGen.Workspace = {
|
||||
...MockWorkspace,
|
||||
id: "test-stopped-workspace",
|
||||
latest_build: { ...MockWorkspaceBuildStop, status: "stopped" },
|
||||
}
|
||||
export const MockStoppingWorkspace: TypesGen.Workspace = {
|
||||
...MockWorkspace,
|
||||
id: "test-stopping-workspace",
|
||||
latest_build: {
|
||||
...MockWorkspaceBuildStop,
|
||||
job: MockRunningProvisionerJob,
|
||||
|
@ -436,6 +438,7 @@ export const MockStoppingWorkspace: TypesGen.Workspace = {
|
|||
}
|
||||
export const MockStartingWorkspace: TypesGen.Workspace = {
|
||||
...MockWorkspace,
|
||||
id: "test-starting-workspace",
|
||||
latest_build: {
|
||||
...MockWorkspaceBuild,
|
||||
job: MockRunningProvisionerJob,
|
||||
|
@ -445,6 +448,7 @@ export const MockStartingWorkspace: TypesGen.Workspace = {
|
|||
}
|
||||
export const MockCancelingWorkspace: TypesGen.Workspace = {
|
||||
...MockWorkspace,
|
||||
id: "test-canceling-workspace",
|
||||
latest_build: {
|
||||
...MockWorkspaceBuild,
|
||||
job: MockCancelingProvisionerJob,
|
||||
|
@ -453,6 +457,7 @@ export const MockCancelingWorkspace: TypesGen.Workspace = {
|
|||
}
|
||||
export const MockCanceledWorkspace: TypesGen.Workspace = {
|
||||
...MockWorkspace,
|
||||
id: "test-canceled-workspace",
|
||||
latest_build: {
|
||||
...MockWorkspaceBuild,
|
||||
job: MockCanceledProvisionerJob,
|
||||
|
@ -461,6 +466,7 @@ export const MockCanceledWorkspace: TypesGen.Workspace = {
|
|||
}
|
||||
export const MockFailedWorkspace: TypesGen.Workspace = {
|
||||
...MockWorkspace,
|
||||
id: "test-failed-workspace",
|
||||
latest_build: {
|
||||
...MockWorkspaceBuild,
|
||||
job: MockFailedProvisionerJob,
|
||||
|
@ -469,6 +475,7 @@ export const MockFailedWorkspace: TypesGen.Workspace = {
|
|||
}
|
||||
export const MockDeletingWorkspace: TypesGen.Workspace = {
|
||||
...MockWorkspace,
|
||||
id: "test-deleting-workspace",
|
||||
latest_build: {
|
||||
...MockWorkspaceBuildDelete,
|
||||
job: MockRunningProvisionerJob,
|
||||
|
@ -477,16 +484,19 @@ export const MockDeletingWorkspace: TypesGen.Workspace = {
|
|||
}
|
||||
export const MockDeletedWorkspace: TypesGen.Workspace = {
|
||||
...MockWorkspace,
|
||||
id: "test-deleted-workspace",
|
||||
latest_build: { ...MockWorkspaceBuildDelete, status: "deleted" },
|
||||
}
|
||||
|
||||
export const MockOutdatedWorkspace: TypesGen.Workspace = {
|
||||
...MockFailedWorkspace,
|
||||
id: "test-outdated-workspace",
|
||||
outdated: true,
|
||||
}
|
||||
|
||||
export const MockPendingWorkspace: TypesGen.Workspace = {
|
||||
...MockWorkspace,
|
||||
id: "test-pending-workspace",
|
||||
latest_build: {
|
||||
...MockWorkspaceBuild,
|
||||
job: MockPendingProvisionerJob,
|
||||
|
@ -502,6 +512,10 @@ export const MockWorkspaceRequest: TypesGen.CreateWorkspaceRequest = {
|
|||
template_id: "test-template",
|
||||
}
|
||||
|
||||
export const MockWorkspaceCountResponse: TypesGen.WorkspaceCountResponse = {
|
||||
count: 26, // just over 1 page
|
||||
}
|
||||
|
||||
export const MockUserAgent: Types.UserAgent = {
|
||||
browser: "Chrome 99.0.4844",
|
||||
device: "Other",
|
||||
|
|
|
@ -140,6 +140,10 @@ export const handlers = [
|
|||
rest.get("/api/v2/workspaces", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json([M.MockWorkspace]))
|
||||
}),
|
||||
// has to come before the parameterized endpoints
|
||||
rest.get("/api/v2/workspaces/count", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockWorkspaceCountResponse))
|
||||
}),
|
||||
rest.get("/api/v2/workspaces/:workspaceId", async (req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json(M.MockWorkspace))
|
||||
}),
|
||||
|
|
|
@ -5,6 +5,7 @@ import { createMachine, assign } from "xstate"
|
|||
export const deploymentFlagsMachine = createMachine(
|
||||
{
|
||||
id: "deploymentFlagsMachine",
|
||||
predictableActionArguments: true,
|
||||
initial: "idle",
|
||||
schema: {
|
||||
context: {} as {
|
||||
|
|
|
@ -206,160 +206,255 @@ interface WorkspacesContext {
|
|||
workspaceRefs?: WorkspaceItemMachineRef[]
|
||||
filter: string
|
||||
getWorkspacesError?: Error | unknown
|
||||
getCountError?: Error | unknown
|
||||
page: number
|
||||
count?: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
type WorkspacesEvent =
|
||||
| { type: "GET_WORKSPACES"; query?: string }
|
||||
| { type: "UPDATE_FILTER"; query?: string }
|
||||
| { type: "UPDATE_VERSION"; workspaceId: string }
|
||||
| { type: "NEXT" }
|
||||
| { type: "PREVIOUS" }
|
||||
| { type: "GO_TO_PAGE"; page: number }
|
||||
|
||||
export const workspacesMachine = createMachine(
|
||||
{
|
||||
tsTypes: {} as import("./workspacesXService.typegen").Typegen1,
|
||||
schema: {
|
||||
context: {} as WorkspacesContext,
|
||||
events: {} as WorkspacesEvent,
|
||||
services: {} as {
|
||||
getWorkspaces: {
|
||||
data: TypesGen.Workspace[]
|
||||
}
|
||||
updateWorkspaceRefs: {
|
||||
data: {
|
||||
refsToKeep: WorkspaceItemMachineRef[]
|
||||
newWorkspaces: TypesGen.Workspace[]
|
||||
export const workspacesMachine =
|
||||
/** @xstate-layout N4IgpgJg5mDOIC5QHcD2AnA1rADgQwGM4BlAFz1LADpk8BLUgFVQCUwAzdOACwHUNs+IrADEAD1jlKVPO0roAFAFYADGoCUItFlyESU6rQbM2nHvx1C4AbRUBdRKBypYDOqgB2jkGMQBGAGYANgCqFQAmAE4-cIAOOPDwoIB2ZIAaEABPRHCVKljAv2S-EMiClSUAFliAXxqM7UE9WDIKanYwUgJuOg8oKgJUAFcPUioYUlJeqABhYdGRCE9qXoA3VExqCYsm4TmR0lsHJBBnVynPb18ESsq8uOLk2KCVSqVY4qUM7IQAyqoApElOFKskgpEUn5avUQI1dMJWtIOl0en0BvMxhMpn19gswOh0BgqDgADYUdgYAC2406O3hcFxh3s3jObkuJ2ufyUVEiAXi4QCySUAWBARF3xyaiowuqZShQSCiUidQaAnpLQMVGR3WmNDVVlgVCGOAgFGmdKsplESw8Kw8602RpNbQteitRxZLjZXg5iFitwByTKARUQVulQCiQlCBeeX9sWifihhXBKth+uaiPanR1aLhBppk3NGeEi2WVDWGy2tJLNmZJ1ZFx9oGub25f0jyQqSiUQvi0aUiqoiXCfnecT8kRUwTT+czmu1qP6c+EhexUFdpZtdod1dIm5sfmOTi9TauiFukWlCb5097-tD0cqQ57dw+8cCz9ntY1bS1OaXPVLGaNdi2A0t8UJdBiTJUgKXQalth-D0G1Pdxmx8fximHF5O1uVJKgFAJoxCZIqChPlIhBUMngib9wP0P9F2mMtbSoSQ-xXRikQA6YUJPc50PPBBAhSYcQWBJJcmBfsshyIpxNHLsCiUIFIyCejdm4sARAAcQAUUYAB9XgAHkWAAaWIAAFABBGZ9OIfjTjQ9kW0QABaQiwmKadkhDQjgmSSpow8gIx3Ivkg17KIwSUPxNPVLMRAAVWsgARWzGH0oyADEAEkABlspYZzGyE30ECePwAVicLkiiJNKjHcJQvC-5Xg+Io+XHYFEoNZK0sy7KjIANX0lhiHy0yADkytcjDrj8NQyNHBUilUYplsiNrvJFJJVEHZqew0mEuN-SgRBm-SAA1GHmwS3MwkSgm5XsFQO4E-FBQI2u+sJqgaiFAeKV7+vnNoRGslh9NG6aUqc+sBO9YTBX+cFR1HQdIgjI6-o6wGojDD5QaUcGEQMPTTKMxhqbsgyHpRyr3iCKhgtE0E7gFZ58YBj4iZBkoybTDxUAgOBvHOrMaHoJhWA4LhYD4H9PUexb-DFdHe26p44hFPxo1EnkgihfzBV5QI+rOn9peYtFBgOUCcQxVWmfc35-nCVTYh9iFwpBEFo0BAEp3iCJRzUEVKnJ7T-xRXUHdGKht1ds9KpCVnXj+V5ihBD7ozWtnEnlPluZ7AIY4u7N4-tl3ULV4SPMCNm7iTZbgg+d7QqqKgXlBYIIxUSIcd5Svbd4vMfydU11wPK1U4q926vCHkdeFKiXjHJ9qjZwcffUwEVGW4XVQYqu49zZcp6xMCtPgeu3eev5pVSSdEjFRUShKbfuSFIJ339hCL2p1T533HjXK+Z9k7LAXk9a4xRPZ3F8tzMUEJoz+TeikXkipQx7wrtbM+4DL5ATvrA9WCAm6hEInEFQqQFSCjqv6IOg5d7-zeIEP4pQx4LgnlAMhjcTYtyPkmac-8hRglCt9UIfcGrdg+LQj43C2j8Mqk3IeQi26iM7hIuSFC7j-ECD7LOdVBRDzqHUIAA */
|
||||
createMachine(
|
||||
{
|
||||
tsTypes: {} as import("./workspacesXService.typegen").Typegen1,
|
||||
schema: {
|
||||
context: {} as WorkspacesContext,
|
||||
events: {} as WorkspacesEvent,
|
||||
services: {} as {
|
||||
getWorkspaces: {
|
||||
data: TypesGen.Workspace[]
|
||||
}
|
||||
getWorkspacesCount: {
|
||||
data: { count: number }
|
||||
}
|
||||
updateWorkspaceRefs: {
|
||||
data: {
|
||||
refsToKeep: WorkspaceItemMachineRef[]
|
||||
newWorkspaces: TypesGen.Workspace[]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
predictableActionArguments: true,
|
||||
id: "workspacesState",
|
||||
on: {
|
||||
GET_WORKSPACES: {
|
||||
actions: "assignFilter",
|
||||
target: ".gettingWorkspaces",
|
||||
internal: false,
|
||||
},
|
||||
UPDATE_VERSION: {
|
||||
actions: "triggerUpdateVersion",
|
||||
},
|
||||
},
|
||||
initial: "gettingWorkspaces",
|
||||
states: {
|
||||
gettingWorkspaces: {
|
||||
entry: "clearGetWorkspacesError",
|
||||
invoke: {
|
||||
src: "getWorkspaces",
|
||||
id: "getWorkspaces",
|
||||
onDone: [
|
||||
{
|
||||
actions: "assignWorkspaceRefs",
|
||||
cond: "isEmpty",
|
||||
target: "waitToRefreshWorkspaces",
|
||||
},
|
||||
{
|
||||
target: "updatingWorkspaceRefs",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
actions: "assignGetWorkspacesError",
|
||||
target: "waitToRefreshWorkspaces",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
updatingWorkspaceRefs: {
|
||||
invoke: {
|
||||
src: "updateWorkspaceRefs",
|
||||
id: "updateWorkspaceRefs",
|
||||
onDone: [
|
||||
{
|
||||
actions: "assignUpdatedWorkspaceRefs",
|
||||
target: "waitToRefreshWorkspaces",
|
||||
},
|
||||
],
|
||||
predictableActionArguments: true,
|
||||
id: "workspacesState",
|
||||
on: {
|
||||
UPDATE_FILTER: {
|
||||
target: ".fetching",
|
||||
actions: ["assignFilter", "resetPage"],
|
||||
},
|
||||
UPDATE_VERSION: {
|
||||
actions: "triggerUpdateVersion",
|
||||
},
|
||||
NEXT: {
|
||||
target: ".fetching",
|
||||
actions: ["assignNextPage", "onPageChange"],
|
||||
},
|
||||
PREVIOUS: {
|
||||
target: ".fetching",
|
||||
actions: ["assignPreviousPage", "onPageChange"],
|
||||
},
|
||||
GO_TO_PAGE: {
|
||||
target: ".fetching",
|
||||
actions: ["assignPage", "onPageChange"],
|
||||
},
|
||||
},
|
||||
waitToRefreshWorkspaces: {
|
||||
after: {
|
||||
"5000": {
|
||||
target: "gettingWorkspaces",
|
||||
initial: "fetching",
|
||||
states: {
|
||||
waitToRefreshWorkspaces: {
|
||||
after: {
|
||||
"5000": {
|
||||
target: "#workspacesState.fetching",
|
||||
actions: [],
|
||||
internal: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
fetching: {
|
||||
type: "parallel",
|
||||
states: {
|
||||
count: {
|
||||
initial: "gettingCount",
|
||||
states: {
|
||||
gettingCount: {
|
||||
entry: "clearGetCountError",
|
||||
invoke: {
|
||||
src: "getWorkspacesCount",
|
||||
id: "getWorkspacesCount",
|
||||
onDone: [
|
||||
{
|
||||
target: "done",
|
||||
actions: "assignCount",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
target: "done",
|
||||
actions: "assignGetCountError",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
done: {
|
||||
type: "final",
|
||||
},
|
||||
},
|
||||
},
|
||||
workspaces: {
|
||||
initial: "gettingWorkspaces",
|
||||
states: {
|
||||
updatingWorkspaceRefs: {
|
||||
invoke: {
|
||||
src: "updateWorkspaceRefs",
|
||||
id: "updateWorkspaceRefs",
|
||||
onDone: [
|
||||
{
|
||||
target: "done",
|
||||
actions: "assignUpdatedWorkspaceRefs",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
gettingWorkspaces: {
|
||||
entry: "clearGetWorkspacesError",
|
||||
invoke: {
|
||||
src: "getWorkspaces",
|
||||
id: "getWorkspaces",
|
||||
onDone: [
|
||||
{
|
||||
target: "done",
|
||||
cond: "isEmpty",
|
||||
actions: "assignWorkspaceRefs",
|
||||
},
|
||||
{
|
||||
target: "updatingWorkspaceRefs",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
target: "done",
|
||||
actions: "assignGetWorkspacesError",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
done: {
|
||||
type: "final",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
onDone: {
|
||||
target: "waitToRefreshWorkspaces",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
guards: {
|
||||
isEmpty: (context) => !context.workspaceRefs,
|
||||
},
|
||||
actions: {
|
||||
assignWorkspaceRefs: assign({
|
||||
workspaceRefs: (_, event) =>
|
||||
event.data.map((data) => {
|
||||
return spawn(workspaceItemMachine.withContext({ data }), data.id)
|
||||
}),
|
||||
}),
|
||||
assignFilter: assign({
|
||||
filter: (context, event) => event.query ?? context.filter,
|
||||
}),
|
||||
assignGetWorkspacesError: assign({
|
||||
getWorkspacesError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetWorkspacesError: (context) =>
|
||||
assign({ ...context, getWorkspacesError: undefined }),
|
||||
triggerUpdateVersion: (context, event) => {
|
||||
const workspaceRef = context.workspaceRefs?.find(
|
||||
(ref) => ref.id === event.workspaceId,
|
||||
)
|
||||
|
||||
if (!workspaceRef) {
|
||||
throw new Error(`No workspace ref found for ${event.workspaceId}.`)
|
||||
}
|
||||
|
||||
workspaceRef.send("UPDATE_VERSION")
|
||||
{
|
||||
guards: {
|
||||
isEmpty: (context) => !context.workspaceRefs,
|
||||
},
|
||||
assignUpdatedWorkspaceRefs: assign({
|
||||
workspaceRefs: (_, event) => {
|
||||
const newWorkspaceRefs = event.data.newWorkspaces.map((workspace) =>
|
||||
spawn(
|
||||
workspaceItemMachine.withContext({ data: workspace }),
|
||||
workspace.id,
|
||||
),
|
||||
actions: {
|
||||
assignWorkspaceRefs: assign({
|
||||
workspaceRefs: (_, event) =>
|
||||
event.data.map((data) => {
|
||||
return spawn(workspaceItemMachine.withContext({ data }), data.id)
|
||||
}),
|
||||
}),
|
||||
assignFilter: assign({
|
||||
filter: (context, event) => event.query ?? context.filter,
|
||||
}),
|
||||
assignGetWorkspacesError: assign({
|
||||
getWorkspacesError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetWorkspacesError: (context) =>
|
||||
assign({ ...context, getWorkspacesError: undefined }),
|
||||
triggerUpdateVersion: (context, event) => {
|
||||
const workspaceRef = context.workspaceRefs?.find(
|
||||
(ref) => ref.id === event.workspaceId,
|
||||
)
|
||||
return event.data.refsToKeep.concat(newWorkspaceRefs)
|
||||
},
|
||||
}),
|
||||
},
|
||||
services: {
|
||||
getWorkspaces: (context) =>
|
||||
API.getWorkspaces(queryToFilter(context.filter)),
|
||||
updateWorkspaceRefs: (context, event) => {
|
||||
const refsToKeep: WorkspaceItemMachineRef[] = []
|
||||
context.workspaceRefs?.forEach((ref) => {
|
||||
const matchingWorkspace = event.data.find(
|
||||
(workspace) => ref.id === workspace.id,
|
||||
)
|
||||
if (matchingWorkspace) {
|
||||
// if a workspace machine reference describes a workspace that has not been deleted,
|
||||
// update its data and mark it as a refToKeep
|
||||
ref.send({ type: "UPDATE_DATA", data: matchingWorkspace })
|
||||
refsToKeep.push(ref)
|
||||
} else {
|
||||
// if it describes a workspace that has been deleted, stop the machine
|
||||
ref.stop && ref.stop()
|
||||
|
||||
if (!workspaceRef) {
|
||||
throw new Error(`No workspace ref found for ${event.workspaceId}.`)
|
||||
}
|
||||
})
|
||||
|
||||
const newWorkspaces = event.data.filter(
|
||||
(workspace) =>
|
||||
!context.workspaceRefs?.find((ref) => ref.id === workspace.id),
|
||||
)
|
||||
workspaceRef.send("UPDATE_VERSION")
|
||||
},
|
||||
assignUpdatedWorkspaceRefs: assign({
|
||||
workspaceRefs: (_, event) => {
|
||||
const newWorkspaceRefs = event.data.newWorkspaces.map((workspace) =>
|
||||
spawn(
|
||||
workspaceItemMachine.withContext({ data: workspace }),
|
||||
workspace.id,
|
||||
),
|
||||
)
|
||||
return event.data.refsToKeep.concat(newWorkspaceRefs)
|
||||
},
|
||||
}),
|
||||
assignNextPage: assign({
|
||||
page: (context) => context.page + 1,
|
||||
}),
|
||||
assignPreviousPage: assign({
|
||||
page: (context) => context.page - 1,
|
||||
}),
|
||||
assignPage: assign({
|
||||
page: (_, event) => event.page,
|
||||
}),
|
||||
resetPage: assign({
|
||||
page: (_) => 1,
|
||||
}),
|
||||
assignCount: assign({
|
||||
count: (_, event) => event.data.count,
|
||||
}),
|
||||
assignGetCountError: assign({
|
||||
getCountError: (_, event) => event.data,
|
||||
}),
|
||||
clearGetCountError: assign({
|
||||
getCountError: (_) => undefined,
|
||||
}),
|
||||
},
|
||||
services: {
|
||||
getWorkspaces: (context) =>
|
||||
API.getWorkspaces({
|
||||
...queryToFilter(context.filter),
|
||||
offset: (context.page - 1) * context.limit,
|
||||
limit: context.limit,
|
||||
}),
|
||||
updateWorkspaceRefs: (context, event) => {
|
||||
const refsToKeep: WorkspaceItemMachineRef[] = []
|
||||
context.workspaceRefs?.forEach((ref) => {
|
||||
const matchingWorkspace = event.data.find(
|
||||
(workspace) => ref.id === workspace.id,
|
||||
)
|
||||
if (matchingWorkspace) {
|
||||
// if a workspace machine reference describes a workspace that has not been deleted,
|
||||
// update its data and mark it as a refToKeep
|
||||
ref.send({ type: "UPDATE_DATA", data: matchingWorkspace })
|
||||
refsToKeep.push(ref)
|
||||
} else {
|
||||
// if it describes a workspace that has been deleted, stop the machine
|
||||
ref.stop && ref.stop()
|
||||
}
|
||||
})
|
||||
|
||||
return Promise.resolve({
|
||||
refsToKeep,
|
||||
newWorkspaces,
|
||||
})
|
||||
const newWorkspaces = event.data.filter(
|
||||
(workspace) =>
|
||||
!context.workspaceRefs?.find((ref) => ref.id === workspace.id),
|
||||
)
|
||||
|
||||
return Promise.resolve({
|
||||
refsToKeep,
|
||||
newWorkspaces,
|
||||
})
|
||||
},
|
||||
getWorkspacesCount: (context) =>
|
||||
API.getWorkspacesCount({ q: context.filter }),
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue