mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: filter for running workspaces (#4157)
* Refactor workspaces xservice * Remove layout comment * Format * Add comments * Add running workspaces filter to frontend * Start on backend - add status to filter * Update sql and add test - wip * Attempt to unconvert status for easier querying * Fix syntax * Join jobs table, untested * sql * Add Status to GetAuthorizedWorkspaces * Update job tests to have canceled time * fmt * add status filter to database fake Co-authored-by: Colin Adler <colin1adler@gmail.com>
This commit is contained in:
@@ -553,7 +553,8 @@ func (q *fakeQuerier) GetWorkspaces(ctx context.Context, arg database.GetWorkspa
|
||||
return workspaces, err
|
||||
}
|
||||
|
||||
func (q *fakeQuerier) GetAuthorizedWorkspaces(_ context.Context, arg database.GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]database.Workspace, error) {
|
||||
//nolint:gocyclo
|
||||
func (q *fakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]database.Workspace, error) {
|
||||
q.mutex.RLock()
|
||||
defer q.mutex.RUnlock()
|
||||
|
||||
@@ -562,18 +563,21 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(_ context.Context, arg database.Ge
|
||||
if arg.OwnerID != uuid.Nil && workspace.OwnerID != arg.OwnerID {
|
||||
continue
|
||||
}
|
||||
|
||||
if arg.OwnerUsername != "" {
|
||||
owner, err := q.GetUserByID(context.Background(), workspace.OwnerID)
|
||||
owner, err := q.GetUserByID(ctx, workspace.OwnerID)
|
||||
if err == nil && !strings.EqualFold(arg.OwnerUsername, owner.Username) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if arg.TemplateName != "" {
|
||||
template, err := q.GetTemplateByID(context.Background(), workspace.TemplateID)
|
||||
template, err := q.GetTemplateByID(ctx, workspace.TemplateID)
|
||||
if err == nil && !strings.EqualFold(arg.TemplateName, template.Name) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if !arg.Deleted && workspace.Deleted {
|
||||
continue
|
||||
}
|
||||
@@ -581,6 +585,96 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(_ context.Context, arg database.Ge
|
||||
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 nil, xerrors.Errorf("get latest build: %w", err)
|
||||
}
|
||||
|
||||
job, err := q.GetProvisionerJobByID(ctx, build.JobID)
|
||||
if err != nil {
|
||||
return nil, 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 nil, xerrors.Errorf("unknown workspace status in filter: %q", arg.Status)
|
||||
}
|
||||
}
|
||||
|
||||
if len(arg.TemplateIds) > 0 {
|
||||
match := false
|
||||
for _, id := range arg.TemplateIds {
|
||||
@@ -771,7 +865,7 @@ func (q *fakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(_ context.Context, wo
|
||||
var row database.WorkspaceBuild
|
||||
var buildNum int32 = -1
|
||||
for _, workspaceBuild := range q.workspaceBuilds {
|
||||
if workspaceBuild.WorkspaceID.String() == workspaceID.String() && workspaceBuild.BuildNumber > buildNum {
|
||||
if workspaceBuild.WorkspaceID == workspaceID && workspaceBuild.BuildNumber > buildNum {
|
||||
row = workspaceBuild
|
||||
buildNum = workspaceBuild.BuildNumber
|
||||
}
|
||||
@@ -816,7 +910,7 @@ func (q *fakeQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(_ context.Context,
|
||||
buildNumbers := make(map[uuid.UUID]int32)
|
||||
for _, workspaceBuild := range q.workspaceBuilds {
|
||||
for _, id := range ids {
|
||||
if id.String() == workspaceBuild.WorkspaceID.String() && workspaceBuild.BuildNumber > buildNumbers[id] {
|
||||
if id == workspaceBuild.WorkspaceID && workspaceBuild.BuildNumber > buildNumbers[id] {
|
||||
builds[id] = workspaceBuild
|
||||
buildNumbers[id] = workspaceBuild.BuildNumber
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
|
||||
query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s AND %s", getWorkspaces, authorizedFilter.SQLString(rbac.NoACLConfig()))
|
||||
rows, err := q.db.QueryContext(ctx, query,
|
||||
arg.Deleted,
|
||||
arg.Status,
|
||||
arg.OwnerID,
|
||||
arg.OwnerUsername,
|
||||
arg.TemplateName,
|
||||
|
||||
+102
-16
@@ -5431,48 +5431,133 @@ func (q *sqlQuerier) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, i
|
||||
|
||||
const getWorkspaces = `-- name: GetWorkspaces :many
|
||||
SELECT
|
||||
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
|
||||
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
|
||||
FROM
|
||||
workspaces
|
||||
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
|
||||
-- Optionally include deleted workspaces
|
||||
workspaces.deleted = $1
|
||||
-- Filter by owner_id
|
||||
AND CASE
|
||||
WHEN $2 :: uuid != '00000000-00000000-00000000-00000000' THEN
|
||||
owner_id = $2
|
||||
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_name
|
||||
-- Filter by owner_id
|
||||
AND CASE
|
||||
WHEN $3 :: text != '' THEN
|
||||
owner_id = (SELECT id FROM users WHERE lower(username) = lower($3))
|
||||
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))
|
||||
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.
|
||||
-- Use the organization filter to restrict to 1 org if needed.
|
||||
AND CASE
|
||||
WHEN $4 :: text != '' THEN
|
||||
template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower($4))
|
||||
WHEN $5 :: text != '' THEN
|
||||
template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower($5))
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by template_ids
|
||||
AND CASE
|
||||
WHEN array_length($5 :: uuid[], 1) > 0 THEN
|
||||
template_id = ANY($5)
|
||||
WHEN array_length($6 :: uuid[], 1) > 0 THEN
|
||||
template_id = ANY($6)
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by name, matching on substring
|
||||
AND CASE
|
||||
WHEN $6 :: text != '' THEN
|
||||
name ILIKE '%' || $6 || '%'
|
||||
WHEN $7 :: text != '' THEN
|
||||
name ILIKE '%' || $7 || '%'
|
||||
ELSE true
|
||||
END
|
||||
`
|
||||
|
||||
type GetWorkspacesParams 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"`
|
||||
@@ -5483,6 +5568,7 @@ type GetWorkspacesParams struct {
|
||||
func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]Workspace, error) {
|
||||
rows, err := q.db.QueryContext(ctx, getWorkspaces,
|
||||
arg.Deleted,
|
||||
arg.Status,
|
||||
arg.OwnerID,
|
||||
arg.OwnerUsername,
|
||||
arg.TemplateName,
|
||||
|
||||
@@ -10,19 +10,103 @@ LIMIT
|
||||
|
||||
-- name: GetWorkspaces :many
|
||||
SELECT
|
||||
*
|
||||
workspaces.*
|
||||
FROM
|
||||
workspaces
|
||||
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
|
||||
-- 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
|
||||
-- Filter by owner_name
|
||||
AND CASE
|
||||
WHEN @owner_username :: text != '' THEN
|
||||
owner_id = (SELECT id FROM users WHERE lower(username) = lower(@owner_username))
|
||||
@@ -30,7 +114,7 @@ WHERE
|
||||
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.
|
||||
-- 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))
|
||||
@@ -45,7 +129,7 @@ WHERE
|
||||
-- Filter by name, matching on substring
|
||||
AND CASE
|
||||
WHEN @name :: text != '' THEN
|
||||
name ILIKE '%' || @name || '%'
|
||||
name ILIKE '%' || @name || '%'
|
||||
ELSE true
|
||||
END
|
||||
;
|
||||
|
||||
@@ -328,6 +328,9 @@ func convertProvisionerJob(provisionerJob database.ProvisionerJob) codersdk.Prov
|
||||
if provisionerJob.CompletedAt.Valid {
|
||||
job.CompletedAt = &provisionerJob.CompletedAt.Time
|
||||
}
|
||||
if provisionerJob.CanceledAt.Valid {
|
||||
job.CanceledAt = &provisionerJob.CanceledAt.Time
|
||||
}
|
||||
if provisionerJob.WorkerID.Valid {
|
||||
job.WorkerID = &provisionerJob.WorkerID.UUID
|
||||
}
|
||||
|
||||
@@ -185,7 +185,8 @@ func TestConvertProvisionerJob_Unit(t *testing.T) {
|
||||
CompletedAt: invalidNullTimeMock,
|
||||
},
|
||||
expected: codersdk.ProvisionerJob{
|
||||
Status: codersdk.ProvisionerJobCanceling,
|
||||
CanceledAt: &validNullTimeMock.Time,
|
||||
Status: codersdk.ProvisionerJobCanceling,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -196,6 +197,7 @@ func TestConvertProvisionerJob_Unit(t *testing.T) {
|
||||
Error: errorMock,
|
||||
},
|
||||
expected: codersdk.ProvisionerJob{
|
||||
CanceledAt: &validNullTimeMock.Time,
|
||||
CompletedAt: &validNullTimeMock.Time,
|
||||
Status: codersdk.ProvisionerJobFailed,
|
||||
Error: errorMock.String,
|
||||
@@ -208,6 +210,7 @@ func TestConvertProvisionerJob_Unit(t *testing.T) {
|
||||
CompletedAt: validNullTimeMock,
|
||||
},
|
||||
expected: codersdk.ProvisionerJob{
|
||||
CanceledAt: &validNullTimeMock.Time,
|
||||
CompletedAt: &validNullTimeMock.Time,
|
||||
Status: codersdk.ProvisionerJobCanceled,
|
||||
},
|
||||
|
||||
@@ -1117,6 +1117,7 @@ func workspaceSearchQuery(query string) (database.GetWorkspacesParams, []codersd
|
||||
OwnerUsername: parser.String(searchParams, "", "owner"),
|
||||
TemplateName: parser.String(searchParams, "", "template"),
|
||||
Name: parser.String(searchParams, "", "name"),
|
||||
Status: parser.String(searchParams, "", "status"),
|
||||
}
|
||||
|
||||
return filter, parser.Errors
|
||||
|
||||
@@ -711,6 +711,52 @@ func TestWorkspaceFilterManual(t *testing.T) {
|
||||
require.Len(t, ws, 1)
|
||||
require.Equal(t, workspace.ID, ws[0].ID)
|
||||
})
|
||||
t.Run("Status", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
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)
|
||||
workspace1 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
workspace2 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
|
||||
// wait for workspaces to be "running"
|
||||
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace1.LatestBuild.ID)
|
||||
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace2.LatestBuild.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
// filter finds both running workspaces
|
||||
ws1, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ws1, 2)
|
||||
|
||||
// stop workspace1
|
||||
build1 := coderdtest.CreateWorkspaceBuild(t, client, workspace1, database.WorkspaceTransitionStop)
|
||||
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, build1.ID)
|
||||
|
||||
// filter finds one running workspace
|
||||
ws2, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Status: "running",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ws2, 1)
|
||||
require.Equal(t, workspace2.ID, ws2[0].ID)
|
||||
|
||||
// stop workspace2
|
||||
build2 := coderdtest.CreateWorkspaceBuild(t, client, workspace2, database.WorkspaceTransitionStop)
|
||||
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, build2.ID)
|
||||
|
||||
// filter finds no running workspaces
|
||||
ws3, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Status: "running",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ws3, 0)
|
||||
})
|
||||
t.Run("FilterQuery", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
|
||||
@@ -68,6 +68,7 @@ type ProvisionerJob struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
StartedAt *time.Time `json:"started_at,omitempty"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
CanceledAt *time.Time `json:"canceled_at,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Status ProvisionerJobStatus `json:"status"`
|
||||
WorkerID *uuid.UUID `json:"worker_id,omitempty"`
|
||||
|
||||
@@ -252,6 +252,8 @@ type WorkspaceFilter struct {
|
||||
Template string `json:"template,omitempty" typescript:"-"`
|
||||
// Name will return partial matches
|
||||
Name string `json:"name,omitempty" typescript:"-"`
|
||||
// Status is a workspace status, which is really the status of the latest build
|
||||
Status string `json:"status,omitempty" typescript:"-"`
|
||||
// FilterQuery supports a raw filter query string
|
||||
FilterQuery string `json:"q,omitempty"`
|
||||
}
|
||||
@@ -272,6 +274,9 @@ func (f WorkspaceFilter) asRequestOption() RequestOption {
|
||||
if f.Template != "" {
|
||||
params = append(params, fmt.Sprintf("template:%q", f.Template))
|
||||
}
|
||||
if f.Status != "" {
|
||||
params = append(params, fmt.Sprintf("status:%q", f.Status))
|
||||
}
|
||||
if f.FilterQuery != "" {
|
||||
// If custom stuff is added, just add it on here.
|
||||
params = append(params, f.FilterQuery)
|
||||
|
||||
@@ -500,6 +500,7 @@ export interface ProvisionerJob {
|
||||
readonly created_at: string
|
||||
readonly started_at?: string
|
||||
readonly completed_at?: string
|
||||
readonly canceled_at?: string
|
||||
readonly error?: string
|
||||
readonly status: ProvisionerJobStatus
|
||||
readonly worker_id?: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMachine } from "@xstate/react"
|
||||
import { FC, useEffect } from "react"
|
||||
import { FC } from "react"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import { useSearchParams } from "react-router-dom"
|
||||
import { workspaceFilterQuery } from "util/filters"
|
||||
@@ -9,25 +9,15 @@ import { WorkspacesPageView } from "./WorkspacesPageView"
|
||||
|
||||
const WorkspacesPage: FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const filter = searchParams.get("filter")
|
||||
const defaultFilter = filter ?? workspaceFilterQuery.me
|
||||
const filter = searchParams.get("filter") ?? workspaceFilterQuery.me
|
||||
const [workspacesState, send] = useMachine(workspacesMachine, {
|
||||
context: {
|
||||
filter: defaultFilter,
|
||||
filter,
|
||||
},
|
||||
})
|
||||
|
||||
const { workspaceRefs } = workspacesState.context
|
||||
|
||||
// On page load, populate the table with workspaces
|
||||
useEffect(() => {
|
||||
send({
|
||||
type: "GET_WORKSPACES",
|
||||
query: defaultFilter,
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
|
||||
@@ -18,6 +18,7 @@ export const Language = {
|
||||
pageTitle: "Workspaces",
|
||||
yourWorkspacesButton: "Your workspaces",
|
||||
allWorkspacesButton: "All workspaces",
|
||||
runningWorkspacesButton: "Running workspaces",
|
||||
createANewWorkspace: `Create a new workspace from a `,
|
||||
template: "Template",
|
||||
}
|
||||
@@ -35,6 +36,10 @@ export const WorkspacesPageView: FC<
|
||||
const presetFilters = [
|
||||
{ query: workspaceFilterQuery.me, name: Language.yourWorkspacesButton },
|
||||
{ query: workspaceFilterQuery.all, name: Language.allWorkspacesButton },
|
||||
{
|
||||
query: workspaceFilterQuery.running,
|
||||
name: Language.runningWorkspacesButton,
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,7 @@ export const queryToFilter = (
|
||||
export const workspaceFilterQuery = {
|
||||
me: "owner:me",
|
||||
all: "",
|
||||
running: "status:running",
|
||||
}
|
||||
|
||||
export const userFilterQuery = {
|
||||
|
||||
@@ -209,12 +209,11 @@ interface WorkspacesContext {
|
||||
}
|
||||
|
||||
type WorkspacesEvent =
|
||||
| { type: "GET_WORKSPACES"; query: string }
|
||||
| { type: "GET_WORKSPACES"; query?: string }
|
||||
| { type: "UPDATE_VERSION"; workspaceId: string }
|
||||
|
||||
export const workspacesMachine = createMachine(
|
||||
{
|
||||
predictableActionArguments: true,
|
||||
tsTypes: {} as import("./workspacesXService.typegen").Typegen1,
|
||||
schema: {
|
||||
context: {} as WorkspacesContext,
|
||||
@@ -223,21 +222,28 @@ export const workspacesMachine = createMachine(
|
||||
getWorkspaces: {
|
||||
data: TypesGen.Workspace[]
|
||||
}
|
||||
updateWorkspaceRefs: {
|
||||
data: {
|
||||
refsToKeep: WorkspaceItemMachineRef[]
|
||||
newWorkspaces: TypesGen.Workspace[]
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
predictableActionArguments: true,
|
||||
id: "workspacesState",
|
||||
on: {
|
||||
GET_WORKSPACES: {
|
||||
actions: "assignFilter",
|
||||
target: "gettingWorkspaces",
|
||||
target: ".gettingWorkspaces",
|
||||
internal: false,
|
||||
},
|
||||
UPDATE_VERSION: {
|
||||
actions: "triggerUpdateVersion",
|
||||
},
|
||||
},
|
||||
initial: "idle",
|
||||
initial: "gettingWorkspaces",
|
||||
states: {
|
||||
idle: {},
|
||||
gettingWorkspaces: {
|
||||
entry: "clearGetWorkspacesError",
|
||||
invoke: {
|
||||
@@ -245,24 +251,39 @@ export const workspacesMachine = createMachine(
|
||||
id: "getWorkspaces",
|
||||
onDone: [
|
||||
{
|
||||
target: "waitToRefreshWorkspaces",
|
||||
actions: ["assignWorkspaceRefs"],
|
||||
actions: "assignWorkspaceRefs",
|
||||
cond: "isEmpty",
|
||||
target: "waitToRefreshWorkspaces",
|
||||
},
|
||||
{
|
||||
target: "waitToRefreshWorkspaces",
|
||||
actions: ["updateWorkspaceRefs"],
|
||||
target: "updatingWorkspaceRefs",
|
||||
},
|
||||
],
|
||||
onError: [
|
||||
{
|
||||
actions: "assignGetWorkspacesError",
|
||||
target: "waitToRefreshWorkspaces",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
updatingWorkspaceRefs: {
|
||||
invoke: {
|
||||
src: "updateWorkspaceRefs",
|
||||
id: "updateWorkspaceRefs",
|
||||
onDone: [
|
||||
{
|
||||
actions: "assignUpdatedWorkspaceRefs",
|
||||
target: "waitToRefreshWorkspaces",
|
||||
},
|
||||
],
|
||||
onError: {
|
||||
target: "waitToRefreshWorkspaces",
|
||||
actions: ["assignGetWorkspacesError"],
|
||||
},
|
||||
},
|
||||
},
|
||||
waitToRefreshWorkspaces: {
|
||||
after: {
|
||||
5000: "gettingWorkspaces",
|
||||
"5000": {
|
||||
target: "gettingWorkspaces",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -279,7 +300,7 @@ export const workspacesMachine = createMachine(
|
||||
}),
|
||||
}),
|
||||
assignFilter: assign({
|
||||
filter: (_, event) => event.query,
|
||||
filter: (context, event) => event.query ?? context.filter,
|
||||
}),
|
||||
assignGetWorkspacesError: assign({
|
||||
getWorkspacesError: (_, event) => event.data,
|
||||
@@ -297,55 +318,48 @@ export const workspacesMachine = createMachine(
|
||||
|
||||
workspaceRef.send("UPDATE_VERSION")
|
||||
},
|
||||
// Opened discussion on XState https://github.com/statelyai/xstate/discussions/3406
|
||||
updateWorkspaceRefs: assign({
|
||||
workspaceRefs: (context, event) => {
|
||||
let workspaceRefs = context.workspaceRefs
|
||||
|
||||
if (!workspaceRefs) {
|
||||
throw new Error("No workspaces loaded.")
|
||||
}
|
||||
|
||||
// Update the existent workspaces or create the new ones
|
||||
for (const data of event.data) {
|
||||
const ref = workspaceRefs.find((ref) => ref.id === data.id)
|
||||
|
||||
if (!ref) {
|
||||
workspaceRefs.push(
|
||||
spawn(workspaceItemMachine.withContext({ data }), data.id),
|
||||
)
|
||||
} else {
|
||||
ref.send({ type: "UPDATE_DATA", data })
|
||||
}
|
||||
}
|
||||
|
||||
// Remove workspaces that were deleted
|
||||
for (const ref of workspaceRefs) {
|
||||
const refData = event.data.find(
|
||||
(workspaceData) => workspaceData.id === ref.id,
|
||||
)
|
||||
|
||||
// If there is no refData, it is because the workspace was deleted
|
||||
if (!refData) {
|
||||
// Stop the actor before remove it from the array
|
||||
if (ref.stop) {
|
||||
ref.stop()
|
||||
}
|
||||
|
||||
// Remove ref from the array
|
||||
workspaceRefs = workspaceRefs.filter(
|
||||
(oldRef) => oldRef.id !== ref.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return workspaceRefs
|
||||
assignUpdatedWorkspaceRefs: assign({
|
||||
workspaceRefs: (_, event) => {
|
||||
const newWorkspaceRefs = event.data.newWorkspaces.map((workspace) =>
|
||||
spawn(
|
||||
workspaceItemMachine.withContext({ data: workspace }),
|
||||
workspace.id,
|
||||
),
|
||||
)
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
const newWorkspaces = event.data.filter(
|
||||
(workspace) =>
|
||||
!context.workspaceRefs?.find((ref) => ref.id === workspace.id),
|
||||
)
|
||||
|
||||
return Promise.resolve({
|
||||
refsToKeep,
|
||||
newWorkspaces,
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user