diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index d5ccfb06dfc47..573ec0f2273a5 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6097,7 +6097,7 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Response" + "$ref": "#/definitions/codersdk.Workspace" } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 69b3e1f6a5453..0c97b20c14efe 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5379,7 +5379,7 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/codersdk.Response" + "$ref": "#/definitions/codersdk.Workspace" } } } diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index f7176ae8cd721..b36a265b7ee98 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -178,7 +178,7 @@ func (e *Executor) runOnce(t time.Time) Stats { // Lock the workspace if it has breached the template's // threshold for inactivity. if reason == database.BuildReasonAutolock { - err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{ + ws, err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{ ID: ws.ID, LockedAt: sql.NullTime{ Time: database.Now(), diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index afeab08de9873..755ab95bed6e5 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2561,11 +2561,11 @@ func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.Up return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg) } -func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error { +func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { fetch := func(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { return q.db.GetWorkspaceByID(ctx, arg.ID) } - return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg) + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg) } func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 8e29bae4349fb..cb0247ed62941 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -5113,9 +5113,9 @@ func (q *FakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database. return sql.ErrNoRows } -func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error { +func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { if err := validateDatabaseType(arg); err != nil { - return err + return database.Workspace{}, err } q.mutex.Lock() defer q.mutex.Unlock() @@ -5137,7 +5137,7 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat } } if template.ID == uuid.Nil { - return xerrors.Errorf("unable to find workspace template") + return database.Workspace{}, xerrors.Errorf("unable to find workspace template") } if template.LockedTTL > 0 { workspace.DeletingAt = sql.NullTime{ @@ -5147,9 +5147,9 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat } } q.workspaces[index] = workspace - return nil + return workspace, nil } - return sql.ErrNoRows + return database.Workspace{}, sql.ErrNoRows } func (q *FakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { @@ -5593,6 +5593,16 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } } + // We omit locked workspaces by default. + if arg.LockedAt.IsZero() && workspace.LockedAt.Valid { + continue + } + + // Filter out workspaces that are locked after the timestamp. + if !arg.LockedAt.IsZero() && workspace.LockedAt.Time.Before(arg.LockedAt) { + continue + } + if len(arg.TemplateIDs) > 0 { match := false for _, id := range arg.TemplateIDs { diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 95dde653ca547..b8695b27e5790 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1558,11 +1558,11 @@ func (m metricsStore) UpdateWorkspaceLastUsedAt(ctx context.Context, arg databas return err } -func (m metricsStore) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error { +func (m metricsStore) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { start := time.Now() - r0 := m.s.UpdateWorkspaceLockedDeletingAt(ctx, arg) + ws, r0 := m.s.UpdateWorkspaceLockedDeletingAt(ctx, arg) m.queryLatencies.WithLabelValues("UpdateWorkspaceLockedDeletingAt").Observe(time.Since(start).Seconds()) - return r0 + return ws, r0 } func (m metricsStore) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) { diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index f6ee26a15f817..6298b0f9ef0a6 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3277,11 +3277,12 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceLastUsedAt(arg0, arg1 interface{ } // UpdateWorkspaceLockedDeletingAt mocks base method. -func (m *MockStore) UpdateWorkspaceLockedDeletingAt(arg0 context.Context, arg1 database.UpdateWorkspaceLockedDeletingAtParams) error { +func (m *MockStore) UpdateWorkspaceLockedDeletingAt(arg0 context.Context, arg1 database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "UpdateWorkspaceLockedDeletingAt", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(database.Workspace) + ret1, _ := ret[1].(error) + return ret0, ret1 } // UpdateWorkspaceLockedDeletingAt indicates an expected call of UpdateWorkspaceLockedDeletingAt. diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index ffa346d04998c..c75f38fc3b596 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -217,6 +217,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, + arg.LockedAt, arg.Offset, arg.Limit, ) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index be33b00bfc51b..67c0c4723048b 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -271,7 +271,7 @@ type sqlcQuerier interface { UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error - UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) error + UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) (Workspace, error) // This allows editing the properties of a workspace proxy. UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 87d86e9621fbb..8ede237618e4f 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -8828,6 +8828,14 @@ WHERE ) > 0 ELSE true END + -- Filter by locked workspaces. By default we do not return locked + -- workspaces since they are considered soft-deleted. + AND CASE + WHEN $10 :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN + locked_at IS NOT NULL AND locked_at >= $10 + ELSE + locked_at IS NULL + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY @@ -8839,11 +8847,11 @@ ORDER BY LOWER(workspaces.name) ASC LIMIT CASE - WHEN $11 :: integer > 0 THEN - $11 + WHEN $12 :: integer > 0 THEN + $12 END OFFSET - $10 + $11 ` type GetWorkspacesParams struct { @@ -8856,6 +8864,7 @@ type GetWorkspacesParams struct { Name string `db:"name" json:"name"` HasAgent string `db:"has_agent" json:"has_agent"` AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` + LockedAt time.Time `db:"locked_at" json:"locked_at"` Offset int32 `db:"offset_" json:"offset_"` Limit int32 `db:"limit_" json:"limit_"` } @@ -8891,6 +8900,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.Name, arg.HasAgent, arg.AgentInactiveDisconnectTimeoutSeconds, + arg.LockedAt, arg.Offset, arg.Limit, ) @@ -9193,7 +9203,7 @@ func (q *sqlQuerier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWo return err } -const updateWorkspaceLockedDeletingAt = `-- name: UpdateWorkspaceLockedDeletingAt :exec +const updateWorkspaceLockedDeletingAt = `-- name: UpdateWorkspaceLockedDeletingAt :one UPDATE workspaces SET @@ -9210,6 +9220,7 @@ WHERE workspaces.template_id = templates.id AND workspaces.id = $1 +RETURNING workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.locked_at, workspaces.deleting_at ` type UpdateWorkspaceLockedDeletingAtParams struct { @@ -9217,9 +9228,25 @@ type UpdateWorkspaceLockedDeletingAtParams struct { LockedAt sql.NullTime `db:"locked_at" json:"locked_at"` } -func (q *sqlQuerier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceLockedDeletingAt, arg.ID, arg.LockedAt) - return err +func (q *sqlQuerier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg UpdateWorkspaceLockedDeletingAtParams) (Workspace, error) { + row := q.db.QueryRowContext(ctx, updateWorkspaceLockedDeletingAt, arg.ID, arg.LockedAt) + var i Workspace + err := row.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OwnerID, + &i.OrganizationID, + &i.TemplateID, + &i.Deleted, + &i.Name, + &i.AutostartSchedule, + &i.Ttl, + &i.LastUsedAt, + &i.LockedAt, + &i.DeletingAt, + ) + return i, err } const updateWorkspaceTTL = `-- name: UpdateWorkspaceTTL :exec diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 5e540a0e5c90a..9dd8aa00b5f55 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -259,6 +259,14 @@ WHERE ) > 0 ELSE true END + -- Filter by locked workspaces. By default we do not return locked + -- workspaces since they are considered soft-deleted. + AND CASE + WHEN @locked_at :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN + locked_at IS NOT NULL AND locked_at >= @locked_at + ELSE + locked_at IS NULL + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY @@ -474,7 +482,7 @@ WHERE ) ) AND workspaces.deleted = 'false'; --- name: UpdateWorkspaceLockedDeletingAt :exec +-- name: UpdateWorkspaceLockedDeletingAt :one UPDATE workspaces SET @@ -490,7 +498,8 @@ FROM WHERE workspaces.template_id = templates.id AND - workspaces.id = $1; + workspaces.id = $1 +RETURNING workspaces.*; -- name: UpdateWorkspacesDeletingAtByTemplateID :exec UPDATE diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 9b216d0180e15..3518f0744947e 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -114,9 +114,15 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT filter.Name = parser.String(values, "", "name") filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus])) filter.HasAgent = parser.String(values, "", "has-agent") + filter.LockedAt = parser.Time(values, time.Time{}, "locked_at", "2006-01-02") if _, ok := values["deleting_by"]; ok { postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02")) + // We want to make sure to grab locked workspaces since they + // are omitted by default. + if filter.LockedAt.IsZero() { + filter.LockedAt = time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC) + } } parser.ErrorExcessParams(values) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0aa1cb0675155..8ad02faff33fb 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -768,7 +768,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { // @Tags Workspaces // @Param workspace path string true "Workspace ID" format(uuid) // @Param request body codersdk.UpdateWorkspaceLock true "Lock or unlock a workspace" -// @Success 200 {object} codersdk.Response +// @Success 200 {object} codersdk.Workspace // @Router /workspaces/{workspace}/lock [put] func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() @@ -779,9 +779,6 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { return } - code := http.StatusOK - resp := codersdk.Response{} - // If the workspace is already in the desired state do nothing! if workspace.LockedAt.Valid == req.Lock { httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{ @@ -797,7 +794,7 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { lockedAt.Time = database.Now() } - err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{ + workspace, err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{ ID: workspace.ID, LockedAt: lockedAt, }) @@ -809,10 +806,21 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) { return } - // TODO should we kick off a build to stop the workspace if it's started - // from this endpoint? I'm leaning no to keep things simple and kick - // the responsibility back to the client. - httpapi.Write(ctx, rw, code, resp) + data, err := api.workspaceData(ctx, []database.Workspace{workspace}) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching workspace resources.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace( + workspace, + data.builds[0], + data.templates[0], + findUser(workspace.OwnerID, data.users), + )) } // @Summary Extend workspace deadline by ID diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index db5db020488b7..2213cd4657a70 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1407,6 +1407,46 @@ func TestWorkspaceFilterManual(t *testing.T) { // and template.InactivityTTL should be 0 assert.Len(t, res.Workspaces, 0) }) + + t.Run("LockedAt", func(t *testing.T) { + // this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }) + user := coderdtest.CreateFirstUser(t, client) + authToken := uuid.NewString() + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: echo.ProvisionComplete, + ProvisionApply: echo.ProvisionApplyWithAgent(authToken), + }) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + + // update template with inactivity ttl + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + + // Create another workspace to validate that we do not return unlocked workspaces. + _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + + err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{ + Lock: true, + }) + require.NoError(t, err) + + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("locked_at:%s", time.Now().Add(-time.Minute).Format("2006-01-02")), + }) + require.NoError(t, err) + require.Len(t, res.Workspaces, 1) + require.NotNil(t, res.Workspaces[0].LockedAt) + }) } func TestOffsetLimit(t *testing.T) { diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 390a82448886c..6cfb468f6af50 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -931,22 +931,164 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/lock \ ```json { - "detail": "string", - "message": "string", - "validations": [ - { - "detail": "string", - "field": "string" - } - ] + "autostart_schedule": "string", + "created_at": "2019-08-24T14:15:22Z", + "deleting_at": "2019-08-24T14:15:22Z", + "health": { + "failing_agents": ["497f6eca-6276-4993-bfeb-53cbbbba6f08"], + "healthy": false + }, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "last_used_at": "2019-08-24T14:15:22Z", + "latest_build": { + "build_number": 0, + "created_at": "2019-08-24T14:15:22Z", + "daily_cost": 0, + "deadline": "2019-08-24T14:15:22Z", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "initiator_id": "06588898-9a84-4b35-ba8f-f9cbd64946f3", + "initiator_name": "string", + "job": { + "canceled_at": "2019-08-24T14:15:22Z", + "completed_at": "2019-08-24T14:15:22Z", + "created_at": "2019-08-24T14:15:22Z", + "error": "string", + "error_code": "MISSING_TEMPLATE_PARAMETER", + "file_id": "8a0cfb4f-ddc9-436d-91bb-75133c583767", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "queue_position": 0, + "queue_size": 0, + "started_at": "2019-08-24T14:15:22Z", + "status": "pending", + "tags": { + "property1": "string", + "property2": "string" + }, + "worker_id": "ae5fa6f7-c55b-40c1-b40a-b36ac467652b" + }, + "max_deadline": "2019-08-24T14:15:22Z", + "reason": "initiator", + "resources": [ + { + "agents": [ + { + "apps": [ + { + "command": "string", + "display_name": "string", + "external": true, + "health": "disabled", + "healthcheck": { + "interval": 0, + "threshold": 0, + "url": "string" + }, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "sharing_level": "owner", + "slug": "string", + "subdomain": true, + "url": "string" + } + ], + "architecture": "string", + "connection_timeout_seconds": 0, + "created_at": "2019-08-24T14:15:22Z", + "directory": "string", + "disconnected_at": "2019-08-24T14:15:22Z", + "environment_variables": { + "property1": "string", + "property2": "string" + }, + "expanded_directory": "string", + "first_connected_at": "2019-08-24T14:15:22Z", + "health": { + "healthy": false, + "reason": "agent has lost connection" + }, + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "instance_id": "string", + "last_connected_at": "2019-08-24T14:15:22Z", + "latency": { + "property1": { + "latency_ms": 0, + "preferred": true + }, + "property2": { + "latency_ms": 0, + "preferred": true + } + }, + "lifecycle_state": "created", + "login_before_ready": true, + "logs_length": 0, + "logs_overflowed": true, + "name": "string", + "operating_system": "string", + "ready_at": "2019-08-24T14:15:22Z", + "resource_id": "4d5215ed-38bb-48ed-879a-fdb9ca58522f", + "shutdown_script": "string", + "shutdown_script_timeout_seconds": 0, + "started_at": "2019-08-24T14:15:22Z", + "startup_script": "string", + "startup_script_behavior": "blocking", + "startup_script_timeout_seconds": 0, + "status": "connecting", + "subsystem": "envbox", + "troubleshooting_url": "string", + "updated_at": "2019-08-24T14:15:22Z", + "version": "string" + } + ], + "created_at": "2019-08-24T14:15:22Z", + "daily_cost": 0, + "hide": true, + "icon": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "job_id": "453bd7d7-5355-4d6d-a38e-d9e7eb218c3f", + "metadata": [ + { + "key": "string", + "sensitive": true, + "value": "string" + } + ], + "name": "string", + "type": "string", + "workspace_transition": "start" + } + ], + "status": "pending", + "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", + "template_version_name": "string", + "transition": "start", + "updated_at": "2019-08-24T14:15:22Z", + "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", + "workspace_name": "string", + "workspace_owner_id": "e7078695-5279-4c86-8774-3ac2367a2fc7", + "workspace_owner_name": "string" + }, + "locked_at": "2019-08-24T14:15:22Z", + "name": "string", + "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", + "outdated": true, + "owner_id": "8826ee2e-7933-4665-aef2-2393f84a0d05", + "owner_name": "string", + "template_allow_user_cancel_workspace_jobs": true, + "template_display_name": "string", + "template_icon": "string", + "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", + "template_name": "string", + "ttl_ms": 0, + "updated_at": "2019-08-24T14:15:22Z" } ``` ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------ | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Response](schemas.md#codersdkresponse) | +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.Workspace](schemas.md#codersdkworkspace) | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/site/src/api/api.ts b/site/src/api/api.ts index a1ac0a16366e6..44c6f2bd94a4c 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -554,6 +554,21 @@ export const cancelWorkspaceBuild = async ( return response.data } +export const updateWorkspaceLock = async ( + workspaceId: string, + lock: boolean, +): Promise => { + const data: TypesGen.UpdateWorkspaceLock = { + lock: lock, + } + + const response = await axios.put( + `/api/v2/workspaces/${workspaceId}/lock`, + data, + ) + return response.data +} + export const restartWorkspace = async ({ workspace, buildParameters, diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index fd9065abdb27a..ed26b64ffc481 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -87,3 +87,13 @@ export const useDashboard = (): DashboardProviderValue => { return context } + +export const useIsWorkspaceActionsEnabled = (): boolean => { + const { entitlements, experiments } = useDashboard() + const allowAdvancedScheduling = + entitlements.features["advanced_template_scheduling"].enabled + // This check can be removed when https://github.com/coder/coder/milestone/19 + // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions") + return allowWorkspaceActions && allowAdvancedScheduling +} diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 364bb50643bba..4e97fa0bb4b12 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -1,5 +1,6 @@ import Button from "@mui/material/Button" import { makeStyles } from "@mui/styles" +import LockIcon from "@mui/icons-material/Lock" import { Avatar } from "components/Avatar/Avatar" import { AgentRow } from "components/Resources/AgentRow" import { @@ -26,7 +27,7 @@ import { } from "components/PageHeader/FullWidthPageHeader" import { TemplateVersionWarnings } from "components/TemplateVersionWarnings/TemplateVersionWarnings" import { ErrorAlert } from "components/Alert/ErrorAlert" -import { ImpendingDeletionBanner } from "components/WorkspaceDeletion" +import { LockedWorkspaceBanner } from "components/WorkspaceDeletion" import { useLocalStorage } from "hooks" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import AlertTitle from "@mui/material/AlertTitle" @@ -53,6 +54,7 @@ export interface WorkspaceProps { handleCancel: () => void handleSettings: () => void handleChangeVersion: () => void + handleUnlock: () => void isUpdating: boolean isRestarting: boolean workspace: TypesGen.Workspace @@ -86,6 +88,7 @@ export const Workspace: FC> = ({ handleCancel, handleSettings, handleChangeVersion, + handleUnlock, workspace, isUpdating, isRestarting, @@ -167,14 +170,19 @@ export const Workspace: FC> = ({ <> - - {workspace.name} - + {workspace.locked_at ? ( + + ) : ( + + {workspace.name} + + )} +
{workspace.name} {workspace.owner_name} @@ -203,6 +211,7 @@ export const Workspace: FC> = ({ handleCancel={handleCancel} handleSettings={handleSettings} handleChangeVersion={handleChangeVersion} + handleUnlock={handleUnlock} canChangeVersions={canChangeVersions} isUpdating={isUpdating} isRestarting={isRestarting} @@ -253,8 +262,8 @@ export const Workspace: FC> = ({ {/* determines its own visibility */} - = ({ ) } +export const UnlockButton: FC = ({ + handleAction, + loading, +}) => { + return ( + } + onClick={handleAction} + > + Unlock + + ) +} + export const StartButton: FC< Omit & { workspace: Workspace diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 8242b470c8c67..409c77e673eab 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -12,6 +12,7 @@ import { StopButton, RestartButton, UpdateButton, + UnlockButton, } from "./Buttons" import { ButtonMapping, @@ -33,6 +34,7 @@ export interface WorkspaceActionsProps { handleCancel: () => void handleSettings: () => void handleChangeVersion: () => void + handleUnlock: () => void isUpdating: boolean isRestarting: boolean children?: ReactNode @@ -49,6 +51,7 @@ export const WorkspaceActions: FC = ({ handleCancel, handleSettings, handleChangeVersion, + handleUnlock, isUpdating, isRestarting, canChangeVersions, @@ -58,7 +61,7 @@ export const WorkspaceActions: FC = ({ canCancel, canAcceptJobs, actions: actionsByStatus, - } = actionsByWorkspaceStatus(workspace.latest_build.status) + } = actionsByWorkspaceStatus(workspace, workspace.latest_build.status) const canBeUpdated = workspace.outdated && canAcceptJobs const menuTriggerRef = useRef(null) const [isMenuOpen, setIsMenuOpen] = useState(false) @@ -93,6 +96,10 @@ export const WorkspaceActions: FC = ({ [ButtonTypesEnum.canceling]: , [ButtonTypesEnum.deleted]: , [ButtonTypesEnum.pending]: , + [ButtonTypesEnum.unlock]: , + [ButtonTypesEnum.unlocking]: ( + + ), } // Returns a function that will execute the action and close the menu diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index c4e81ca976ca5..c21fdedc98249 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -1,4 +1,4 @@ -import { WorkspaceStatus } from "api/typesGenerated" +import { Workspace, WorkspaceStatus } from "api/typesGenerated" import { ReactNode } from "react" // the button types we have @@ -12,6 +12,8 @@ export enum ButtonTypesEnum { deleting = "deleting", update = "update", updating = "updating", + unlock = "lock", + unlocking = "unlocking", // disabled buttons canceling = "canceling", deleted = "deleted", @@ -29,8 +31,16 @@ interface WorkspaceAbilities { } export const actionsByWorkspaceStatus = ( + workspace: Workspace, status: WorkspaceStatus, ): WorkspaceAbilities => { + if (workspace.locked_at) { + return { + actions: [ButtonTypesEnum.unlock], + canCancel: false, + canAcceptJobs: false, + } + } return statusToActions[status] } diff --git a/site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx b/site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx index eb27db82a3e2e..dc09566fc69e9 100644 --- a/site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx +++ b/site/src/components/WorkspaceDeletion/ImpendingDeletionBadge.tsx @@ -1,31 +1,17 @@ import { Workspace } from "api/typesGenerated" -import { displayImpendingDeletion } from "./utils" -import { useDashboard } from "components/Dashboard/DashboardProvider" +import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider" import { Pill } from "components/Pill/Pill" -import ErrorIcon from "@mui/icons-material/ErrorOutline" +import LockIcon from "@mui/icons-material/Lock" -export const ImpendingDeletionBadge = ({ +export const LockedBadge = ({ workspace, }: { workspace: Workspace }): JSX.Element | null => { - const { entitlements, experiments } = useDashboard() - const allowAdvancedScheduling = - entitlements.features["advanced_template_scheduling"].enabled - // This check can be removed when https://github.com/coder/coder/milestone/19 - // is merged up - const allowWorkspaceActions = experiments.includes("workspace_actions") - // return null - - if ( - !displayImpendingDeletion( - workspace, - allowAdvancedScheduling, - allowWorkspaceActions, - ) - ) { + const experimentEnabled = useIsWorkspaceActionsEnabled() + if (!workspace.locked_at || !experimentEnabled) { return null } - return } text="Impending deletion" type="error" /> + return } text="Locked" type="error" /> } diff --git a/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx b/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx index d793c16d5dc3a..501dd50dfa95f 100644 --- a/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx +++ b/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx @@ -1,8 +1,7 @@ import { Workspace } from "api/typesGenerated" -import { displayImpendingDeletion } from "./utils" -import { useDashboard } from "components/Dashboard/DashboardProvider" +import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider" import { Alert } from "components/Alert/Alert" -import { formatDistanceToNow, differenceInDays, add, format } from "date-fns" +import { formatDistanceToNow } from "date-fns" import Link from "@mui/material/Link" import { Link as RouterLink } from "react-router-dom" @@ -11,69 +10,90 @@ export enum Count { Multiple, } -export const ImpendingDeletionBanner = ({ - workspace, +export const LockedWorkspaceBanner = ({ + workspaces, onDismiss, shouldRedisplayBanner, count = Count.Singular, }: { - workspace?: Workspace + workspaces?: Workspace[] onDismiss: () => void shouldRedisplayBanner: boolean count?: Count }): JSX.Element | null => { - const { entitlements, experiments } = useDashboard() - const allowAdvancedScheduling = - entitlements.features["advanced_template_scheduling"].enabled - // This check can be removed when https://github.com/coder/coder/milestone/19 - // is merged up - const allowWorkspaceActions = experiments.includes("workspace_actions") + const experimentEnabled = useIsWorkspaceActionsEnabled() + + if (!workspaces) { + return null + } + + const hasLockedWorkspaces = workspaces.find( + (workspace) => workspace.locked_at, + ) + + const hasDeletionScheduledWorkspaces = workspaces.find( + (workspace) => workspace.deleting_at, + ) if ( - !workspace || - !displayImpendingDeletion( - workspace, - allowAdvancedScheduling, - allowWorkspaceActions, - ) || + // Only show this if the experiment is included. + !experimentEnabled || + !hasLockedWorkspaces || // Banners should be redisplayed after dismissal when additional workspaces are newly scheduled for deletion !shouldRedisplayBanner ) { return null } - // if deleting_at is 7 days away or less, display an 'error' banner to convey urgency to user - const daysUntilDelete = differenceInDays( - Date.parse(workspace.last_used_at), - new Date(), - ) + const formatDate = (dateStr: string): string => { + const date = new Date(dateStr) + return date.toLocaleDateString(undefined, { + month: "long", + day: "numeric", + year: "numeric", + }) + } - const plusFourteen = add(new Date(), { days: 14 }) + const alertText = (): string => { + if (workspaces.length === 1) { + if ( + hasDeletionScheduledWorkspaces && + hasDeletionScheduledWorkspaces.deleting_at && + hasDeletionScheduledWorkspaces.locked_at + ) { + return `This workspace has been locked since ${formatDistanceToNow( + Date.parse(hasDeletionScheduledWorkspaces.locked_at), + )} and is scheduled to be deleted at ${formatDate( + hasDeletionScheduledWorkspaces.deleting_at, + )} . To keep it you must unlock the workspace.` + } else if (hasLockedWorkspaces && hasLockedWorkspaces.locked_at) { + return `This workspace has been locked since ${formatDate( + hasLockedWorkspaces.locked_at, + )} + and cannot be interacted + with. Locked workspaces are eligible for + permanent deletion. To prevent deletion, unlock + the workspace.` + } + } + return "" + } return ( - + {count === Count.Singular ? ( - `This workspace has been unused for ${formatDistanceToNow( - Date.parse(workspace.last_used_at), - )} and is scheduled for deletion. To keep it, connect via SSH or the web terminal.` + alertText() ) : ( <> There are{" "} workspaces {" "} - that will be deleted soon due to inactivity. To keep these workspaces, - connect to them via SSH or the web terminal. + that may be deleted soon due to inactivity. Unlock the workspaces you + wish to retain. )} diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 7f742dab2ff3c..2ace2d0b903b8 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -5,7 +5,7 @@ import { makeStyles } from "@mui/styles" import { combineClasses } from "utils/combineClasses" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import { - ImpendingDeletionBadge, + LockedBadge, ImpendingDeletionText, } from "components/WorkspaceDeletion" import { getDisplayWorkspaceStatus } from "utils/workspace" @@ -25,8 +25,8 @@ export const WorkspaceStatusBadge: FC< return ( {/* determines its own visibility */} - - + + diff --git a/site/src/i18n/en/templateSettingsPage.json b/site/src/i18n/en/templateSettingsPage.json index 11f314e0a7472..d1b0927b5a78a 100644 --- a/site/src/i18n/en/templateSettingsPage.json +++ b/site/src/i18n/en/templateSettingsPage.json @@ -22,9 +22,9 @@ "failureTTLHelperText_zero": "Coder will not automatically stop failed workspaces", "failureTTLHelperText_one": "Coder will attempt to stop failed workspaces after {{count}} day.", "failureTTLHelperText_other": "Coder will attempt to stop failed workspaces after {{count}} days.", - "inactivityTTLHelperText_zero": "Coder will not automatically delete inactive workspaces", - "inactivityTTLHelperText_one": "Coder will automatically delete inactive workspaces after {{count}} day.", - "inactivityTTLHelperText_other": "Coder will automatically delete inactive workspaces after {{count}} days.", + "inactivityTTLHelperText_zero": "Coder will not automatically lock inactive workspaces", + "inactivityTTLHelperText_one": "Coder will automatically lock inactive workspaces after {{count}} day.", + "inactivityTTLHelperText_other": "Coder will automatically lock inactive workspaces after {{count}} days.", "lockedTTLHelperText_zero": "Coder will not automatically delete locked workspaces", "lockedTTLHelperText_one": "Coder will automatically delete locked workspaces after {{count}} day.", "lockedTTLHelperText_other": "Coder will automatically delete locked workspaces after {{count}} days.", diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx index 645c7dfb84ddd..6128700299e28 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.stories.tsx @@ -14,6 +14,6 @@ export const OpenDialog: Story = { submitValues: () => null, isInactivityDialogOpen: true, setIsInactivityDialogOpen: () => null, - workspacesToBeDeletedToday: 2, + workspacesToBeLockedToday: 2, }, } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx index 3f5ec252b08be..a9407f6d5eab5 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/InactivityDialog.tsx @@ -4,12 +4,12 @@ export const InactivityDialog = ({ submitValues, isInactivityDialogOpen, setIsInactivityDialogOpen, - workspacesToBeDeletedToday, + workspacesToBeLockedToday, }: { submitValues: () => void isInactivityDialogOpen: boolean setIsInactivityDialogOpen: (arg0: boolean) => void - workspacesToBeDeletedToday: number + workspacesToBeLockedToday: number }) => { return ( setIsInactivityDialogOpen(false)} - title="Delete inactive workspaces" + title="Lock inactive workspaces" + confirmText="Lock Workspaces" + description={`There are ${ + workspacesToBeLockedToday ? workspacesToBeLockedToday : "" + } workspaces that already match this filter and will be locked upon form submission. Are you sure you want to proceed?`} + /> + ) +} + +export const DeleteLockedDialog = ({ + submitValues, + isLockedDialogOpen, + setIsLockedDialogOpen, + workspacesToBeDeletedToday, +}: { + submitValues: () => void + isLockedDialogOpen: boolean + setIsLockedDialogOpen: (arg0: boolean) => void + workspacesToBeDeletedToday: number +}) => { + return ( + { + submitValues() + setIsLockedDialogOpen(false) + }} + onClose={() => setIsLockedDialogOpen(false)} + title="Delete Locked Workspaces" confirmText="Delete Workspaces" description={`There are ${ workspacesToBeDeletedToday ? workspacesToBeDeletedToday : "" diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx index 598f701338f1e..1efdcec885cfb 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx @@ -16,8 +16,11 @@ import Link from "@mui/material/Link" import Checkbox from "@mui/material/Checkbox" import FormControlLabel from "@mui/material/FormControlLabel" import Switch from "@mui/material/Switch" -import { InactivityDialog } from "./InactivityDialog" -import { useWorkspacesToBeDeleted } from "./useWorkspacesToBeDeleted" +import { DeleteLockedDialog, InactivityDialog } from "./InactivityDialog" +import { + useWorkspacesToBeLocked, + useWorkspacesToBeDeleted, +} from "./useWorkspacesToBeDeleted" import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers" import { TTLHelperText } from "./TTLHelperText" import { docs } from "utils/docs" @@ -89,10 +92,16 @@ export const TemplateScheduleForm: FC = ({ onSubmit: () => { if ( form.values.inactivity_cleanup_enabled && + workspacesToBeLockedToday && + workspacesToBeLockedToday.length > 0 + ) { + setIsInactivityDialogOpen(true) + } else if ( + form.values.locked_cleanup_enabled && workspacesToBeDeletedToday && workspacesToBeDeletedToday.length > 0 ) { - setIsInactivityDialogOpen(true) + setIsLockedDialogOpen(true) } else { submitValues() } @@ -106,10 +115,18 @@ export const TemplateScheduleForm: FC = ({ const { t } = useTranslation("templateSettingsPage") const styles = useStyles() - const workspacesToBeDeletedToday = useWorkspacesToBeDeleted(form.values) + const workspacesToBeLockedToday = useWorkspacesToBeLocked( + template, + form.values, + ) + const workspacesToBeDeletedToday = useWorkspacesToBeDeleted( + template, + form.values, + ) const [isInactivityDialogOpen, setIsInactivityDialogOpen] = useState(false) + const [isLockedDialogOpen, setIsLockedDialogOpen] = useState(false) const submitValues = () => { // on submit, convert from hours => ms @@ -324,12 +341,11 @@ export const TemplateScheduleForm: FC = ({ inputProps={{ min: 0, step: "any" }} label="Time until cleanup (days)" type="number" - aria-label="Failure Cleanup" /> @@ -341,7 +357,7 @@ export const TemplateScheduleForm: FC = ({ onChange={handleToggleInactivityCleanup} /> } - label="Enable Inactivity Cleanup" + label="Enable Inactivity TTL" /> = ({ inputProps={{ min: 0, step: "any" }} label="Time until cleanup (days)" type="number" - aria-label="Inactivity Cleanup" /> @@ -375,7 +390,7 @@ export const TemplateScheduleForm: FC = ({ onChange={handleToggleLockedCleanup} /> } - label="Enable Locked Cleanup" + label="Enable Locked TTL" /> = ({ inputProps={{ min: 0, step: "any" }} label="Time until cleanup (days)" type="number" - aria-label="Locked Cleanup" /> )} - + {workspacesToBeLockedToday && workspacesToBeLockedToday.length > 0 && ( + + )} + {workspacesToBeDeletedToday && workspacesToBeDeletedToday.length > 0 && ( + + )} + { + const { data: workspacesData } = useQuery({ + queryKey: ["workspaces"], + queryFn: () => + getWorkspaces({ + q: "template:" + template.name, + }), + enabled: formValues.inactivity_cleanup_enabled, + }) + + return workspacesData?.workspaces?.filter((workspace: Workspace) => { + if (!formValues.inactivity_ttl_ms) { + return + } + + if (workspace.locked_at) { + return + } + + const proposedLocking = new Date( + new Date(workspace.last_used_at).getTime() + + formValues.inactivity_ttl_ms * 86400000, + ) + + if (compareAsc(proposedLocking, new Date()) < 1) { + return workspace + } + }) +} export const useWorkspacesToBeDeleted = ( + template: Template, formValues: TemplateScheduleFormValues, ) => { const { data: workspacesData } = useQuery({ queryKey: ["workspaces"], - queryFn: () => getWorkspaces({}), - enabled: formValues.inactivity_cleanup_enabled, + queryFn: () => + getWorkspaces({ + q: "template:" + template.name, + }), + enabled: formValues.locked_cleanup_enabled, }) return workspacesData?.workspaces?.filter((workspace: Workspace) => { - const isInactive = inactiveStatuses.includes(workspace.latest_build.status) + if (!workspace.locked_at || !formValues.locked_ttl_ms) { + return false + } - const proposedDeletion = add(new Date(workspace.last_used_at), { - days: formValues.inactivity_ttl_ms, - }) + const proposedLocking = new Date( + new Date(workspace.locked_at).getTime() + + formValues.locked_ttl_ms * 86400000, + ) - if (isInactive && compareAsc(proposedDeletion, endOfToday()) < 1) { + if (compareAsc(proposedLocking, new Date()) < 1) { return workspace } }) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index 5b37f01f5b8ce..15612a544d89e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -63,12 +63,12 @@ const fillAndSubmitForm = async ({ await user.type(failureTtlField, failure_ttl_ms.toString()) const inactivityTtlField = screen.getByRole("checkbox", { - name: /Inactivity Cleanup/i, + name: /Inactivity TTL/i, }) await user.type(inactivityTtlField, inactivity_ttl_ms.toString()) const lockedTtlField = screen.getByRole("checkbox", { - name: /Locked Cleanup/i, + name: /Locked TTL/i, }) await user.type(lockedTtlField, locked_ttl_ms.toString()) @@ -76,6 +76,10 @@ const fillAndSubmitForm = async ({ FooterFormLanguage.defaultSubmitLabel, ) await user.click(submitButton) + + // User needs to confirm inactivity and locked ttl + const confirmButton = await screen.findByTestId("confirm-button") + await user.click(confirmButton) } describe("TemplateSchedulePage", () => { diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 4407f8f017775..3fd3124399da3 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -176,6 +176,7 @@ export const WorkspaceReadyPage = ({ handleChangeVersion={() => { setChangeVersionDialogOpen(true) }} + handleUnlock={() => workspaceSend({ type: "UNLOCK" })} resources={workspace.latest_build.resources} builds={builds} canUpdateWorkspace={canUpdateWorkspace} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 112a89ae5eaa1..6194f1fc4add8 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -1,5 +1,7 @@ import { usePagination } from "hooks/usePagination" -import { FC } from "react" +import { Workspace } from "api/typesGenerated" +import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider" +import { FC, useEffect, useState } from "react" import { Helmet } from "react-helmet-async" import { pageTitle } from "utils/page" import { useWorkspacesData, useWorkspaceUpdate } from "./data" @@ -9,8 +11,10 @@ import { useTemplateFilterMenu, useStatusFilterMenu } from "./filter/menus" import { useSearchParams } from "react-router-dom" import { useFilter } from "components/Filter/filter" import { useUserFilterMenu } from "components/Filter/UserFilter" +import { getWorkspaces } from "api/api" const WorkspacesPage: FC = () => { + const [lockedWorkspaces, setLockedWorkspaces] = useState([]) // If we use a useSearchParams for each hook, the values will not be in sync. // So we have to use a single one, centralizing the values, and pass it to // each hook. @@ -21,6 +25,37 @@ const WorkspacesPage: FC = () => { ...pagination, query: filterProps.filter.query, }) + + const experimentEnabled = useIsWorkspaceActionsEnabled() + // If workspace actions are enabled we need to fetch the locked + // workspaces as well. This lets us determine whether we should + // show a banner to the user indicating that some of their workspaces + // are at risk of being deleted. + useEffect(() => { + if (experimentEnabled) { + const includesLocked = filterProps.filter.query.includes("locked_at") + const lockedQuery = includesLocked + ? filterProps.filter.query + : filterProps.filter.query + " locked_at:1970-01-01" + + if (includesLocked && data) { + setLockedWorkspaces(data.workspaces) + } else { + getWorkspaces({ q: lockedQuery }) + .then((resp) => { + setLockedWorkspaces(resp.workspaces) + }) + .catch(() => { + // TODO + }) + } + } else { + // If the experiment isn't included then we'll pretend + // like locked workspaces don't exist. + setLockedWorkspaces([]) + } + }, [experimentEnabled, data, filterProps.filter.query]) + const updateWorkspace = useWorkspaceUpdate(queryKey) return ( @@ -31,6 +66,7 @@ const WorkspacesPage: FC = () => { page: number @@ -45,6 +45,7 @@ export const WorkspacesPageView: FC< React.PropsWithChildren > = ({ workspaces, + lockedWorkspaces, error, limit, count, @@ -53,32 +54,14 @@ export const WorkspacesPageView: FC< onUpdateWorkspace, page, }) => { - const { saveLocal, getLocal } = useLocalStorage() + const { saveLocal } = useLocalStorage() - const workspaceIdsWithImpendingDeletions = workspaces + const workspacesDeletionScheduled = lockedWorkspaces ?.filter((workspace) => workspace.deleting_at) .map((workspace) => workspace.id) - /** - * Returns a boolean indicating if there are workspaces that have been - * recently marked for deletion but are not in local storage. - * If there are, we want to alert the user so they can potentially take action - * before deletion takes place. - * @returns {boolean} - */ - const isNewWorkspacesImpendingDeletion = (): boolean => { - const dismissedList = getLocal("dismissedWorkspaceList") - if (!dismissedList) { - return true - } - - const diff = difference( - workspaceIdsWithImpendingDeletions, - JSON.parse(dismissedList), - ) - - return diff && diff.length > 0 - } + const hasLockedWorkspace = + lockedWorkspaces !== undefined && lockedWorkspaces.length > 0 return ( @@ -104,13 +87,13 @@ export const WorkspacesPageView: FC< {/* determines its own visibility */} - workspace.deleting_at)} - shouldRedisplayBanner={isNewWorkspacesImpendingDeletion()} + saveLocal( "dismissedWorkspaceList", - JSON.stringify(workspaceIdsWithImpendingDeletions), + JSON.stringify(workspacesDeletionScheduled), ) } count={Count.Multiple} diff --git a/site/src/pages/WorkspacesPage/filter/filter.tsx b/site/src/pages/WorkspacesPage/filter/filter.tsx index 2772347d995cf..8d98c0b03182f 100644 --- a/site/src/pages/WorkspacesPage/filter/filter.tsx +++ b/site/src/pages/WorkspacesPage/filter/filter.tsx @@ -1,5 +1,6 @@ import { FC } from "react" import Box from "@mui/material/Box" +import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider" import { Avatar, AvatarProps } from "components/Avatar/Avatar" import { Palette, PaletteColor } from "@mui/material/styles" import { TemplateFilterMenu, StatusFilterMenu } from "./menus" @@ -43,9 +44,17 @@ export const WorkspacesFilter = ({ status: StatusFilterMenu } }) => { + const presets = [...PRESET_FILTERS] + if (useIsWorkspaceActionsEnabled()) { + presets.push({ + query: workspaceFilterQuery.locked, + name: "Locked workspaces", + }) + } + return ( { + const message = getErrorMessage(data, "Error unlocking workspace.") + displayError(message) + }, assignMissedParameters: assign({ missedParameters: (_, { data }) => { if (!(data instanceof API.MissingBuildParameters)) { @@ -675,6 +695,18 @@ export const workspaceMachine = createMachine( throw Error("Cannot cancel workspace without build id") } }, + unlockWorkspace: (context) => async (send) => { + if (context.workspace) { + const unlockWorkspacePromise = await API.updateWorkspaceLock( + context.workspace.id, + false, + ) + send({ type: "REFRESH_WORKSPACE", data: unlockWorkspacePromise }) + return unlockWorkspacePromise + } else { + throw Error("Cannot unlock workspace without workspace id") + } + }, listening: (context) => (send) => { if (!context.eventSource) { send({ type: "EVENT_SOURCE_ERROR", error: "error initializing sse" })