diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 1a2a919925ec2..5a652164d3a6f 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -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 } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 109f74db3852d..9fc6e37176dc1 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -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, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ba90e102b819a..15a5080e309ae 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 + 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 $2 :: uuid != '00000000-00000000-00000000-00000000' THEN - owner_id = $2 + WHEN $3 :: uuid != '00000000-00000000-00000000-00000000' THEN + owner_id = $3 ELSE true END - -- Filter by owner_name + -- Filter by owner_name AND CASE - WHEN $3 :: text != '' THEN - owner_id = (SELECT id FROM users WHERE lower(username) = lower($3)) + 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, diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 8e9e0b60902eb..69a61375c2073 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -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 ; diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 56a825ea09a3a..e2784112245cf 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -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 } diff --git a/coderd/provisionerjobs_internal_test.go b/coderd/provisionerjobs_internal_test.go index a5a760757baf3..6ac466a57644a 100644 --- a/coderd/provisionerjobs_internal_test.go +++ b/coderd/provisionerjobs_internal_test.go @@ -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, }, diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0ce8ff347ef83..fda034dc6eb88 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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 diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index ce414eeb1819f..648973d505197 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -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}) diff --git a/codersdk/provisionerdaemons.go b/codersdk/provisionerdaemons.go index d49ddb1bc6f8f..5eb8872fe6620 100644 --- a/codersdk/provisionerdaemons.go +++ b/codersdk/provisionerdaemons.go @@ -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"` diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 3b20b0b4999bc..7899fb33f8bd6 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -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) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6ad32fecce4e1..7cf2c03588fc1 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -497,6 +497,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 diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 037549f92c603..41e122cb617ad 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -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 ( <> diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index a1197df870bf8..097f24d732406 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -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 ( diff --git a/site/src/util/filters.ts b/site/src/util/filters.ts index 397820976663d..b1c7cc8d24717 100644 --- a/site/src/util/filters.ts +++ b/site/src/util/filters.ts @@ -13,6 +13,7 @@ export const queryToFilter = ( export const workspaceFilterQuery = { me: "owner:me", all: "", + running: "status:running", } export const userFilterQuery = { diff --git a/site/src/xServices/workspaces/workspacesXService.ts b/site/src/xServices/workspaces/workspacesXService.ts index 48ec4053f78a9..7b0844dddc152 100644 --- a/site/src/xServices/workspaces/workspacesXService.ts +++ b/site/src/xServices/workspaces/workspacesXService.ts @@ -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: "updatingWorkspaceRefs", }, + ], + onError: [ { + actions: "assignGetWorkspacesError", + target: "waitToRefreshWorkspaces", + }, + ], + }, + }, + updatingWorkspaceRefs: { + invoke: { + src: "updateWorkspaceRefs", + id: "updateWorkspaceRefs", + onDone: [ + { + actions: "assignUpdatedWorkspaceRefs", target: "waitToRefreshWorkspaces", - actions: ["updateWorkspaceRefs"], }, ], - 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, + }) + }, }, }, )