diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 7ae81c3b041d5..1fc1c9782235e 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2385,6 +2385,14 @@ func (q *querier) UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.Conte return q.db.UpdateTemplateVersionGitAuthProvidersByJobID(ctx, arg) } +func (q *querier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { + fetch := func(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) (database.Template, error) { + return q.db.GetTemplateByID(ctx, arg.TemplateID) + } + + return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateTemplateWorkspacesLastUsedAt)(ctx, arg) +} + // UpdateUserDeletedByID // Deprecated: Delete this function in favor of 'SoftDeleteUserByID'. Deletes are // irreversible. @@ -2663,12 +2671,12 @@ func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWor return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceTTL)(ctx, arg) } -func (q *querier) UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) error { - fetch := func(ctx context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) (database.Template, error) { +func (q *querier) UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { + fetch := func(ctx context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) (database.Template, error) { return q.db.GetTemplateByID(ctx, arg.TemplateID) } - return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspacesDeletingAtByTemplateID)(ctx, arg) + return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspacesLockedDeletingAtByTemplateID)(ctx, arg) } func (q *querier) UpsertAppSecurityKey(ctx context.Context, data string) error { diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 589c17efcae0a..d583a9b545d73 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -5199,6 +5199,26 @@ func (q *FakeQuerier) UpdateTemplateVersionGitAuthProvidersByJobID(_ context.Con return sql.ErrNoRows } +func (q *FakeQuerier) UpdateTemplateWorkspacesLastUsedAt(_ context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, ws := range q.workspaces { + if ws.TemplateID != arg.TemplateID { + continue + } + ws.LastUsedAt = arg.LastUsedAt + q.workspaces[i] = ws + } + + return nil +} + func (q *FakeQuerier) UpdateUserDeletedByID(_ context.Context, params database.UpdateUserDeletedByIDParams) error { if err := validateDatabaseType(params); err != nil { return err @@ -5796,7 +5816,7 @@ func (q *FakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW return sql.ErrNoRows } -func (q *FakeQuerier) UpdateWorkspacesDeletingAtByTemplateID(_ context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) error { +func (q *FakeQuerier) UpdateWorkspacesLockedDeletingAtByTemplateID(_ context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { q.mutex.Lock() defer q.mutex.Unlock() @@ -5806,9 +5826,21 @@ func (q *FakeQuerier) UpdateWorkspacesDeletingAtByTemplateID(_ context.Context, } for i, ws := range q.workspaces { + if ws.TemplateID != arg.TemplateID { + continue + } + if ws.LockedAt.Time.IsZero() { continue } + + if !arg.LockedAt.IsZero() { + ws.LockedAt = sql.NullTime{ + Valid: true, + Time: arg.LockedAt, + } + } + deletingAt := sql.NullTime{ Valid: arg.LockedTtlMs > 0, } diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 991be6b64ac6b..498ca57b504e4 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1453,6 +1453,13 @@ func (m metricsStore) UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.C return err } +func (m metricsStore) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg database.UpdateTemplateWorkspacesLastUsedAtParams) error { + start := time.Now() + r0 := m.s.UpdateTemplateWorkspacesLastUsedAt(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateTemplateWorkspacesLastUsedAt").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) UpdateUserDeletedByID(ctx context.Context, arg database.UpdateUserDeletedByIDParams) error { start := time.Now() err := m.s.UpdateUserDeletedByID(ctx, arg) @@ -1635,10 +1642,10 @@ func (m metricsStore) UpdateWorkspaceTTL(ctx context.Context, arg database.Updat return r0 } -func (m metricsStore) UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesDeletingAtByTemplateIDParams) error { +func (m metricsStore) UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { start := time.Now() - r0 := m.s.UpdateWorkspacesDeletingAtByTemplateID(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspacesDeletingAtByTemplateID").Observe(time.Since(start).Seconds()) + r0 := m.s.UpdateWorkspacesLockedDeletingAtByTemplateID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspacesLockedDeletingAtByTemplateID").Observe(time.Since(start).Seconds()) return r0 } diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 6f07fb72790ce..ac1e782e7d398 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -3062,6 +3062,20 @@ func (mr *MockStoreMockRecorder) UpdateTemplateVersionGitAuthProvidersByJobID(ar return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateVersionGitAuthProvidersByJobID", reflect.TypeOf((*MockStore)(nil).UpdateTemplateVersionGitAuthProvidersByJobID), arg0, arg1) } +// UpdateTemplateWorkspacesLastUsedAt mocks base method. +func (m *MockStore) UpdateTemplateWorkspacesLastUsedAt(arg0 context.Context, arg1 database.UpdateTemplateWorkspacesLastUsedAtParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateTemplateWorkspacesLastUsedAt", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateTemplateWorkspacesLastUsedAt indicates an expected call of UpdateTemplateWorkspacesLastUsedAt. +func (mr *MockStoreMockRecorder) UpdateTemplateWorkspacesLastUsedAt(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateTemplateWorkspacesLastUsedAt", reflect.TypeOf((*MockStore)(nil).UpdateTemplateWorkspacesLastUsedAt), arg0, arg1) +} + // UpdateUserDeletedByID mocks base method. func (m *MockStore) UpdateUserDeletedByID(arg0 context.Context, arg1 database.UpdateUserDeletedByIDParams) error { m.ctrl.T.Helper() @@ -3437,18 +3451,18 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceTTL(arg0, arg1 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceTTL), arg0, arg1) } -// UpdateWorkspacesDeletingAtByTemplateID mocks base method. -func (m *MockStore) UpdateWorkspacesDeletingAtByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesDeletingAtByTemplateIDParams) error { +// UpdateWorkspacesLockedDeletingAtByTemplateID mocks base method. +func (m *MockStore) UpdateWorkspacesLockedDeletingAtByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspacesDeletingAtByTemplateID", arg0, arg1) + ret := m.ctrl.Call(m, "UpdateWorkspacesLockedDeletingAtByTemplateID", arg0, arg1) ret0, _ := ret[0].(error) return ret0 } -// UpdateWorkspacesDeletingAtByTemplateID indicates an expected call of UpdateWorkspacesDeletingAtByTemplateID. -func (mr *MockStoreMockRecorder) UpdateWorkspacesDeletingAtByTemplateID(arg0, arg1 interface{}) *gomock.Call { +// UpdateWorkspacesLockedDeletingAtByTemplateID indicates an expected call of UpdateWorkspacesLockedDeletingAtByTemplateID. +func (mr *MockStoreMockRecorder) UpdateWorkspacesLockedDeletingAtByTemplateID(arg0, arg1 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesDeletingAtByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesDeletingAtByTemplateID), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesLockedDeletingAtByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesLockedDeletingAtByTemplateID), arg0, arg1) } // UpsertAppSecurityKey mocks base method. diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 2c0cc90277f9a..a217648035e90 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -270,6 +270,7 @@ type sqlcQuerier interface { UpdateTemplateVersionByID(ctx context.Context, arg UpdateTemplateVersionByIDParams) error UpdateTemplateVersionDescriptionByJobID(ctx context.Context, arg UpdateTemplateVersionDescriptionByJobIDParams) error UpdateTemplateVersionGitAuthProvidersByJobID(ctx context.Context, arg UpdateTemplateVersionGitAuthProvidersByJobIDParams) error + UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error UpdateUserDeletedByID(ctx context.Context, arg UpdateUserDeletedByIDParams) error UpdateUserHashedPassword(ctx context.Context, arg UpdateUserHashedPasswordParams) error UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLastSeenAtParams) (User, error) @@ -297,7 +298,7 @@ type sqlcQuerier interface { UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error - UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDeletingAtByTemplateIDParams) error + UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error UpsertAppSecurityKey(ctx context.Context, value string) error // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 86c953643bdeb..292f22ce74e05 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -9771,6 +9771,24 @@ func (q *sqlQuerier) InsertWorkspace(ctx context.Context, arg InsertWorkspacePar return i, err } +const updateTemplateWorkspacesLastUsedAt = `-- name: UpdateTemplateWorkspacesLastUsedAt :exec +UPDATE workspaces +SET + last_used_at = $1::timestamptz +WHERE + template_id = $2 +` + +type UpdateTemplateWorkspacesLastUsedAtParams struct { + LastUsedAt time.Time `db:"last_used_at" json:"last_used_at"` + TemplateID uuid.UUID `db:"template_id" json:"template_id"` +} + +func (q *sqlQuerier) UpdateTemplateWorkspacesLastUsedAt(ctx context.Context, arg UpdateTemplateWorkspacesLastUsedAtParams) error { + _, err := q.db.ExecContext(ctx, updateTemplateWorkspacesLastUsedAt, arg.LastUsedAt, arg.TemplateID) + return err +} + const updateWorkspace = `-- name: UpdateWorkspace :one UPDATE workspaces @@ -9930,23 +9948,28 @@ func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspace return err } -const updateWorkspacesDeletingAtByTemplateID = `-- name: UpdateWorkspacesDeletingAtByTemplateID :exec -UPDATE - workspaces +const updateWorkspacesLockedDeletingAtByTemplateID = `-- name: UpdateWorkspacesLockedDeletingAtByTemplateID :exec +UPDATE workspaces SET - deleting_at = CASE WHEN $1::bigint = 0 THEN NULL ELSE locked_at + interval '1 milliseconds' * $1::bigint END + deleting_at = CASE + WHEN $1::bigint = 0 THEN NULL + WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN ($2::timestamptz) + interval '1 milliseconds' * $1::bigint + ELSE locked_at + interval '1 milliseconds' * $1::bigint + END, + locked_at = CASE WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN $2::timestamptz ELSE locked_at END WHERE - template_id = $2 + template_id = $3 AND - locked_at IS NOT NULL + locked_at IS NOT NULL ` -type UpdateWorkspacesDeletingAtByTemplateIDParams struct { +type UpdateWorkspacesLockedDeletingAtByTemplateIDParams struct { LockedTtlMs int64 `db:"locked_ttl_ms" json:"locked_ttl_ms"` + LockedAt time.Time `db:"locked_at" json:"locked_at"` TemplateID uuid.UUID `db:"template_id" json:"template_id"` } -func (q *sqlQuerier) UpdateWorkspacesDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDeletingAtByTemplateIDParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspacesDeletingAtByTemplateID, arg.LockedTtlMs, arg.TemplateID) +func (q *sqlQuerier) UpdateWorkspacesLockedDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesLockedDeletingAtByTemplateIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspacesLockedDeletingAtByTemplateID, arg.LockedTtlMs, arg.LockedAt, arg.TemplateID) return err } diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 9dd8aa00b5f55..503d0a6b52bd8 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -501,12 +501,23 @@ AND workspaces.id = $1 RETURNING workspaces.*; --- name: UpdateWorkspacesDeletingAtByTemplateID :exec -UPDATE - workspaces +-- name: UpdateWorkspacesLockedDeletingAtByTemplateID :exec +UPDATE workspaces SET - deleting_at = CASE WHEN @locked_ttl_ms::bigint = 0 THEN NULL ELSE locked_at + interval '1 milliseconds' * @locked_ttl_ms::bigint END + deleting_at = CASE + WHEN @locked_ttl_ms::bigint = 0 THEN NULL + WHEN @locked_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN (@locked_at::timestamptz) + interval '1 milliseconds' * @locked_ttl_ms::bigint + ELSE locked_at + interval '1 milliseconds' * @locked_ttl_ms::bigint + END, + locked_at = CASE WHEN @locked_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN @locked_at::timestamptz ELSE locked_at END WHERE - template_id = @template_id + template_id = @template_id AND - locked_at IS NOT NULL; + locked_at IS NOT NULL; + +-- name: UpdateTemplateWorkspacesLastUsedAt :exec +UPDATE workspaces +SET + last_used_at = @last_used_at::timestamptz +WHERE + template_id = @template_id; diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index 7c6a906c6b62c..0e3774b798358 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -105,6 +105,18 @@ type TemplateScheduleOptions struct { // LockedTTL dictates the duration after which locked workspaces will be // permanently deleted. LockedTTL time.Duration `json:"locked_ttl"` + // UpdateWorkspaceLastUsedAt updates the template's workspaces' + // last_used_at field. This is useful for preventing updates to the + // templates inactivity_ttl immediately triggering a lock action against + // workspaces whose last_used_at field violates the new template + // inactivity_ttl threshold. + UpdateWorkspaceLastUsedAt bool `json:"update_workspace_last_used_at"` + // UpdateWorkspaceLockedAt updates the template's workspaces' + // locked_at field. This is useful for preventing updates to the + // templates locked_ttl immediately triggering a delete action against + // workspaces whose locked_at field violates the new template locked_ttl + // threshold. + UpdateWorkspaceLockedAt bool `json:"update_workspace_locked_at"` } // TemplateScheduleStore provides an interface for retrieving template diff --git a/coderd/templates.go b/coderd/templates.go index 75c18402d7ca5..f51f42668e1a1 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -622,9 +622,11 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { DaysOfWeek: restartRequirementDaysOfWeekParsed, Weeks: req.RestartRequirement.Weeks, }, - FailureTTL: failureTTL, - InactivityTTL: inactivityTTL, - LockedTTL: lockedTTL, + FailureTTL: failureTTL, + InactivityTTL: inactivityTTL, + LockedTTL: lockedTTL, + UpdateWorkspaceLastUsedAt: req.UpdateWorkspaceLastUsedAt, + UpdateWorkspaceLockedAt: req.UpdateWorkspaceLockedAt, }) if err != nil { return xerrors.Errorf("set template schedule options: %w", err) diff --git a/codersdk/templates.go b/codersdk/templates.go index 2022c876db360..f566ac4f2ce32 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -192,6 +192,15 @@ type UpdateTemplateMeta struct { FailureTTLMillis int64 `json:"failure_ttl_ms,omitempty"` InactivityTTLMillis int64 `json:"inactivity_ttl_ms,omitempty"` LockedTTLMillis int64 `json:"locked_ttl_ms,omitempty"` + // UpdateWorkspaceLastUsedAt updates the last_used_at field of workspaces + // spawned from the template. This is useful for preventing workspaces being + // immediately locked when updating the inactivity_ttl field to a new, shorter + // value. + UpdateWorkspaceLastUsedAt bool `json:"update_workspace_last_used_at"` + // UpdateWorkspaceLockedAt updates the locked_at field of workspaces spawned + // from the template. This is useful for preventing locked workspaces being immediately + // deleted when updating the locked_ttl field to a new, shorter value. + UpdateWorkspaceLockedAt bool `json:"update_workspace_locked_at"` } type TemplateExample struct { diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 19309454aacde..c5613c44e7880 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -113,11 +113,11 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S } var template database.Template - err = db.InTx(func(db database.Store) error { + err = db.InTx(func(tx database.Store) error { ctx, span := tracing.StartSpanWithName(ctx, "(*schedule.EnterpriseTemplateScheduleStore).Set()-InTx()") defer span.End() - err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + err := tx.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ ID: tpl.ID, UpdatedAt: s.now(), AllowUserAutostart: opts.UserAutostartEnabled, @@ -134,19 +134,36 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S return xerrors.Errorf("update template schedule: %w", err) } + var lockedAt time.Time + if opts.UpdateWorkspaceLockedAt { + lockedAt = database.Now() + } + // If we updated the locked_ttl we need to update all the workspaces deleting_at // to ensure workspaces are being cleaned up correctly. Similarly if we are // disabling it (by passing 0), then we want to delete nullify the deleting_at // fields of all the template workspaces. - err = db.UpdateWorkspacesDeletingAtByTemplateID(ctx, database.UpdateWorkspacesDeletingAtByTemplateIDParams{ + err = tx.UpdateWorkspacesLockedDeletingAtByTemplateID(ctx, database.UpdateWorkspacesLockedDeletingAtByTemplateIDParams{ TemplateID: tpl.ID, LockedTtlMs: opts.LockedTTL.Milliseconds(), + LockedAt: lockedAt, }) if err != nil { return xerrors.Errorf("update deleting_at of all workspaces for new locked_ttl %q: %w", opts.LockedTTL, err) } - template, err = db.GetTemplateByID(ctx, tpl.ID) + if opts.UpdateWorkspaceLastUsedAt { + err = tx.UpdateTemplateWorkspacesLastUsedAt(ctx, database.UpdateTemplateWorkspacesLastUsedAtParams{ + TemplateID: tpl.ID, + LastUsedAt: database.Now(), + }) + if err != nil { + return xerrors.Errorf("update template workspaces last_used_at: %w", err) + } + } + + // TODO: update all workspace max_deadlines to be within new bounds + template, err = tx.GetTemplateByID(ctx, tpl.ID) if err != nil { return xerrors.Errorf("get updated template schedule: %w", err) } @@ -154,7 +171,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S // Recalculate max_deadline and deadline for all running workspace // builds on this template. if s.UseRestartRequirement.Load() { - err = s.updateWorkspaceBuilds(ctx, db, template) + err = s.updateWorkspaceBuilds(ctx, tx, template) if err != nil { return xerrors.Errorf("update workspace builds: %w", err) } diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 4ac4a0e35d518..af364d3578b1c 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -283,10 +283,11 @@ func TestTemplates(t *testing.T) { require.Nil(t, unlockedWorkspace.LockedAt) require.Nil(t, unlockedWorkspace.DeletingAt) - lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) - require.NotNil(t, lockedWorkspace.LockedAt) - require.NotNil(t, lockedWorkspace.DeletingAt) - require.Equal(t, lockedWorkspace.LockedAt.Add(lockedTTL), *lockedWorkspace.DeletingAt) + updatedLockedWorkspace := coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) + require.NotNil(t, updatedLockedWorkspace.LockedAt) + require.NotNil(t, updatedLockedWorkspace.DeletingAt) + require.Equal(t, updatedLockedWorkspace.LockedAt.Add(lockedTTL), *updatedLockedWorkspace.DeletingAt) + require.Equal(t, updatedLockedWorkspace.LockedAt, lockedWorkspace.LockedAt) // Disable the locked_ttl on the template, then we can assert that the workspaces // no longer have a deleting_at field. @@ -307,6 +308,119 @@ func TestTemplates(t *testing.T) { require.NotNil(t, lockedWorkspace.LockedAt) require.Nil(t, lockedWorkspace.DeletingAt) }) + + t.Run("UpdateLockedAt", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }, + }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + unlockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + require.Nil(t, unlockedWorkspace.DeletingAt) + require.Nil(t, lockedWorkspace.DeletingAt) + + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, unlockedWorkspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + + err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{ + Lock: true, + }) + require.NoError(t, err) + + lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) + require.NotNil(t, lockedWorkspace.LockedAt) + // The deleting_at field should be nil since there is no template locked_ttl set. + require.Nil(t, lockedWorkspace.DeletingAt) + + lockedTTL := time.Minute + updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + LockedTTLMillis: lockedTTL.Milliseconds(), + UpdateWorkspaceLockedAt: true, + }) + require.NoError(t, err) + require.Equal(t, lockedTTL.Milliseconds(), updated.LockedTTLMillis) + + unlockedWorkspace = coderdtest.MustWorkspace(t, client, unlockedWorkspace.ID) + require.Nil(t, unlockedWorkspace.LockedAt) + require.Nil(t, unlockedWorkspace.DeletingAt) + + updatedLockedWorkspace := coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) + require.NotNil(t, updatedLockedWorkspace.LockedAt) + require.NotNil(t, updatedLockedWorkspace.DeletingAt) + // Validate that the workspace locked_at value is updated. + require.True(t, updatedLockedWorkspace.LockedAt.After(*lockedWorkspace.LockedAt)) + require.Equal(t, updatedLockedWorkspace.LockedAt.Add(lockedTTL), *updatedLockedWorkspace.DeletingAt) + }) + + t.Run("UpdateLastUsedAt", func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }, + }) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + unlockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + require.Nil(t, unlockedWorkspace.DeletingAt) + require.Nil(t, lockedWorkspace.DeletingAt) + + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, unlockedWorkspace.LatestBuild.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID) + + err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{ + Lock: true, + }) + require.NoError(t, err) + + lockedWorkspace = coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) + require.NotNil(t, lockedWorkspace.LockedAt) + // The deleting_at field should be nil since there is no template locked_ttl set. + require.Nil(t, lockedWorkspace.DeletingAt) + + inactivityTTL := time.Minute + updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + InactivityTTLMillis: inactivityTTL.Milliseconds(), + UpdateWorkspaceLastUsedAt: true, + }) + require.NoError(t, err) + require.Equal(t, inactivityTTL.Milliseconds(), updated.InactivityTTLMillis) + + updatedUnlockedWS := coderdtest.MustWorkspace(t, client, unlockedWorkspace.ID) + require.Nil(t, updatedUnlockedWS.LockedAt) + require.Nil(t, updatedUnlockedWS.DeletingAt) + require.True(t, updatedUnlockedWS.LastUsedAt.After(unlockedWorkspace.LastUsedAt)) + + updatedLockedWorkspace := coderdtest.MustWorkspace(t, client, lockedWorkspace.ID) + require.NotNil(t, updatedLockedWorkspace.LockedAt) + require.Nil(t, updatedLockedWorkspace.DeletingAt) + // Validate that the workspace locked_at value is updated. + require.Equal(t, updatedLockedWorkspace.LockedAt, lockedWorkspace.LockedAt) + require.True(t, updatedLockedWorkspace.LastUsedAt.After(lockedWorkspace.LastUsedAt)) + }) } func TestTemplateACL(t *testing.T) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index e0592b966bca4..b37f8fcf9dc8e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1152,6 +1152,8 @@ export interface UpdateTemplateMeta { readonly failure_ttl_ms?: number readonly inactivity_ttl_ms?: number readonly locked_ttl_ms?: number + readonly update_workspace_last_used_at: boolean + readonly update_workspace_locked_at: boolean } // From codersdk/users.go diff --git a/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx b/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx index 5d77d34b28906..5a1cfcc80060e 100644 --- a/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx +++ b/site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx @@ -7,6 +7,9 @@ import { DialogActionButtonsProps, } from "../Dialog" import { ConfirmDialogType } from "../types" +import Checkbox from "@mui/material/Checkbox" +import FormControlLabel from "@mui/material/FormControlLabel" +import { Stack } from "@mui/system" interface ConfirmDialogTypeConfig { confirmText: ReactNode @@ -151,3 +154,168 @@ export const ConfirmDialog: FC> = ({ ) } + +export interface ScheduleDialogProps extends ConfirmDialogProps { + readonly inactiveWorkspacesToGoDormant: number + readonly inactiveWorkspacesToGoDormantInWeek: number + readonly dormantWorkspacesToBeDeleted: number + readonly dormantWorkspacesToBeDeletedInWeek: number + readonly updateLockedWorkspaces: (confirm: boolean) => void + readonly updateInactiveWorkspaces: (confirm: boolean) => void + readonly dormantValueChanged: boolean + readonly deletionValueChanged: boolean +} + +export const ScheduleDialog: FC> = ({ + cancelText, + confirmLoading, + disabled = false, + hideCancel, + onClose, + onConfirm, + type, + open = false, + title, + inactiveWorkspacesToGoDormant, + inactiveWorkspacesToGoDormantInWeek, + dormantWorkspacesToBeDeleted, + dormantWorkspacesToBeDeletedInWeek, + updateLockedWorkspaces, + updateInactiveWorkspaces, + dormantValueChanged, + deletionValueChanged, +}) => { + const styles = useScheduleStyles({ type }) + + const defaults = CONFIRM_DIALOG_DEFAULTS["delete"] + + if (typeof hideCancel === "undefined") { + hideCancel = defaults.hideCancel + } + + const showDormancyWarning = + dormantValueChanged && + (inactiveWorkspacesToGoDormant > 0 || + inactiveWorkspacesToGoDormantInWeek > 0) + const showDeletionWarning = + deletionValueChanged && + (dormantWorkspacesToBeDeleted > 0 || dormantWorkspacesToBeDeletedInWeek > 0) + + return ( + +
+

{title}

+ <> + {showDormancyWarning && ( + <> +

{"Dormancy Threshold"}

+ +
{` + This change will result in ${inactiveWorkspacesToGoDormant} workspaces being immediately transitioned to the dormant state and ${inactiveWorkspacesToGoDormantInWeek} over the next seven days. To prevent this, do you want to reset the inactivity period for all template workspaces?`}
+ { + updateInactiveWorkspaces(e.target.checked) + }} + /> + } + label="Reset" + /> +
+ + )} + + {showDeletionWarning && ( + <> +

{"Dormancy Auto-Deletion"}

+ +
{`This change will result in ${dormantWorkspacesToBeDeleted} workspaces being immediately deleted and ${dormantWorkspacesToBeDeletedInWeek} over the next 7 days. To prevent this, do you want to reset the dormancy period for all template workspaces?`}
+ { + updateLockedWorkspaces(e.target.checked) + }} + /> + } + label="Reset" + /> +
+ + )} + +
+ + + + +
+ ) +} + +const useScheduleStyles = makeStyles((theme) => ({ + dialogWrapper: { + "& .MuiPaper-root": { + background: theme.palette.background.paper, + border: `1px solid ${theme.palette.divider}`, + width: "100%", + maxWidth: theme.spacing(125), + }, + "& .MuiDialogActions-spacing": { + padding: `0 ${theme.spacing(5)} ${theme.spacing(5)}`, + }, + }, + dialogContent: { + color: theme.palette.text.secondary, + padding: theme.spacing(5), + }, + dialogTitle: { + margin: 0, + marginBottom: theme.spacing(2), + color: theme.palette.text.primary, + fontWeight: 400, + fontSize: theme.spacing(2.5), + }, + dialogDescription: { + color: theme.palette.text.secondary, + lineHeight: "160%", + fontSize: 16, + + "& strong": { + color: theme.palette.text.primary, + }, + + "& p:not(.MuiFormHelperText-root)": { + margin: 0, + }, + + "& > p": { + margin: theme.spacing(1, 0), + }, + }, +})) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 4e97fa0bb4b12..d64e25dd89979 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -1,6 +1,5 @@ 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 { @@ -27,7 +26,7 @@ import { } from "components/PageHeader/FullWidthPageHeader" import { TemplateVersionWarnings } from "components/TemplateVersionWarnings/TemplateVersionWarnings" import { ErrorAlert } from "components/Alert/ErrorAlert" -import { LockedWorkspaceBanner } from "components/WorkspaceDeletion" +import { DormantWorkspaceBanner } from "components/WorkspaceDeletion" import { useLocalStorage } from "hooks" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" import AlertTitle from "@mui/material/AlertTitle" @@ -54,7 +53,7 @@ export interface WorkspaceProps { handleCancel: () => void handleSettings: () => void handleChangeVersion: () => void - handleUnlock: () => void + handleDormantActivate: () => void isUpdating: boolean isRestarting: boolean workspace: TypesGen.Workspace @@ -88,7 +87,7 @@ export const Workspace: FC> = ({ handleCancel, handleSettings, handleChangeVersion, - handleUnlock, + handleDormantActivate: handleDormantActivate, workspace, isUpdating, isRestarting, @@ -170,19 +169,14 @@ export const Workspace: FC> = ({ <> - {workspace.locked_at ? ( - - ) : ( - - {workspace.name} - - )} - + + {workspace.name} +
{workspace.name} {workspace.owner_name} @@ -211,7 +205,7 @@ export const Workspace: FC> = ({ handleCancel={handleCancel} handleSettings={handleSettings} handleChangeVersion={handleChangeVersion} - handleUnlock={handleUnlock} + handleDormantActivate={handleDormantActivate} canChangeVersions={canChangeVersions} isUpdating={isUpdating} isRestarting={isRestarting} @@ -262,7 +256,7 @@ export const Workspace: FC> = ({ {/* determines its own visibility */} - = ({ ) } -export const UnlockButton: FC = ({ +export const ActivateButton: FC = ({ handleAction, loading, }) => { return ( } + startIcon={} onClick={handleAction} > - Unlock + Activate ) } diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index 409c77e673eab..23c2398372f40 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -12,7 +12,7 @@ import { StopButton, RestartButton, UpdateButton, - UnlockButton, + ActivateButton, } from "./Buttons" import { ButtonMapping, @@ -34,7 +34,7 @@ export interface WorkspaceActionsProps { handleCancel: () => void handleSettings: () => void handleChangeVersion: () => void - handleUnlock: () => void + handleDormantActivate: () => void isUpdating: boolean isRestarting: boolean children?: ReactNode @@ -51,7 +51,7 @@ export const WorkspaceActions: FC = ({ handleCancel, handleSettings, handleChangeVersion, - handleUnlock, + handleDormantActivate: handleDormantActivate, isUpdating, isRestarting, canChangeVersions, @@ -96,9 +96,11 @@ export const WorkspaceActions: FC = ({ [ButtonTypesEnum.canceling]: , [ButtonTypesEnum.deleted]: , [ButtonTypesEnum.pending]: , - [ButtonTypesEnum.unlock]: , - [ButtonTypesEnum.unlocking]: ( - + [ButtonTypesEnum.activate]: ( + + ), + [ButtonTypesEnum.activating]: ( + ), } diff --git a/site/src/components/WorkspaceActions/constants.ts b/site/src/components/WorkspaceActions/constants.ts index c21fdedc98249..1d2eeb9d4811e 100644 --- a/site/src/components/WorkspaceActions/constants.ts +++ b/site/src/components/WorkspaceActions/constants.ts @@ -12,8 +12,8 @@ export enum ButtonTypesEnum { deleting = "deleting", update = "update", updating = "updating", - unlock = "lock", - unlocking = "unlocking", + activate = "activate", + activating = "activating", // disabled buttons canceling = "canceling", deleted = "deleted", @@ -36,7 +36,7 @@ export const actionsByWorkspaceStatus = ( ): WorkspaceAbilities => { if (workspace.locked_at) { return { - actions: [ButtonTypesEnum.unlock], + actions: [ButtonTypesEnum.activate], canCancel: false, canAcceptJobs: false, } diff --git a/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx b/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx index 501dd50dfa95f..b65f3a5cdc28b 100644 --- a/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx +++ b/site/src/components/WorkspaceDeletion/ImpendingDeletionBanner.tsx @@ -10,7 +10,7 @@ export enum Count { Multiple, } -export const LockedWorkspaceBanner = ({ +export const DormantWorkspaceBanner = ({ workspaces, onDismiss, shouldRedisplayBanner, @@ -61,18 +61,18 @@ export const LockedWorkspaceBanner = ({ hasDeletionScheduledWorkspaces.deleting_at && hasDeletionScheduledWorkspaces.locked_at ) { - return `This workspace has been locked since ${formatDistanceToNow( + return `This workspace has been dormant for ${formatDistanceToNow( Date.parse(hasDeletionScheduledWorkspaces.locked_at), - )} and is scheduled to be deleted at ${formatDate( + )} and is scheduled to be deleted on ${formatDate( hasDeletionScheduledWorkspaces.deleting_at, - )} . To keep it you must unlock the workspace.` + )} . To keep it you must activate the workspace.` } else if (hasLockedWorkspaces && hasLockedWorkspaces.locked_at) { - return `This workspace has been locked since ${formatDate( - hasLockedWorkspaces.locked_at, + return `This workspace has been dormant for ${formatDistanceToNow( + Date.parse(hasLockedWorkspaces.locked_at), )} and cannot be interacted - with. Locked workspaces are eligible for - permanent deletion. To prevent deletion, unlock + with. Dormant workspaces are eligible for + permanent deletion. To prevent deletion, activate the workspace.` } } @@ -92,8 +92,8 @@ export const LockedWorkspaceBanner = ({ > workspaces {" "} - that may be deleted soon due to inactivity. Unlock the workspaces you - wish to retain. + that may be deleted soon due to inactivity. Activate the workspaces + you wish to retain. )} diff --git a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx index 75a35f6839657..5618ba056d7d8 100644 --- a/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx +++ b/site/src/components/WorkspaceStatusBadge/WorkspaceStatusBadge.tsx @@ -4,10 +4,7 @@ import { FC, PropsWithChildren } from "react" import { makeStyles } from "@mui/styles" import { combineClasses } from "utils/combineClasses" import { ChooseOne, Cond } from "components/Conditionals/ChooseOne" -import { - LockedBadge, - ImpendingDeletionText, -} from "components/WorkspaceDeletion" +import { ImpendingDeletionText } from "components/WorkspaceDeletion" import { getDisplayWorkspaceStatus } from "utils/workspace" import Tooltip, { TooltipProps, tooltipClasses } from "@mui/material/Tooltip" import { styled } from "@mui/material/styles" @@ -28,10 +25,6 @@ export const WorkspaceStatusBadge: FC< ) return ( - {/* determines its own visibility */} - - - = ({ icon: template.icon, allow_user_cancel_workspace_jobs: template.allow_user_cancel_workspace_jobs, + update_workspace_last_used_at: false, + update_workspace_locked_at: false, }, validationSchema, onSubmit, diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 597ad0a15387e..9f9f0d231f9c4 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -33,6 +33,8 @@ const validFormValues: FormValues = { failure_ttl_ms: 0, inactivity_ttl_ms: 0, locked_ttl_ms: 0, + update_workspace_last_used_at: false, + update_workspace_locked_at: false, } const renderTemplateSettingsPage = async () => { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx index 1efdcec885cfb..180d81df978b8 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx @@ -16,7 +16,6 @@ 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 { DeleteLockedDialog, InactivityDialog } from "./InactivityDialog" import { useWorkspacesToBeLocked, useWorkspacesToBeDeleted, @@ -24,6 +23,7 @@ import { import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers" import { TTLHelperText } from "./TTLHelperText" import { docs } from "utils/docs" +import { ScheduleDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog" const MS_HOUR_CONVERSION = 3600000 const MS_DAY_CONVERSION = 86400000 @@ -87,21 +87,30 @@ export const TemplateScheduleForm: FC = ({ allowAdvancedScheduling && Boolean(template.inactivity_ttl_ms), locked_cleanup_enabled: allowAdvancedScheduling && Boolean(template.locked_ttl_ms), + update_workspace_last_used_at: false, + update_workspace_locked_at: false, }, validationSchema, onSubmit: () => { - if ( + const dormancyChanged = + form.initialValues.inactivity_ttl_ms !== form.values.inactivity_ttl_ms + const deletionChanged = + form.initialValues.locked_ttl_ms !== form.values.locked_ttl_ms + + const dormancyScheduleChanged = + form.values.inactivity_cleanup_enabled && + dormancyChanged && + workspacesToDormancyInWeek && + workspacesToDormancyInWeek.length > 0 + + const deletionScheduleChanged = form.values.inactivity_cleanup_enabled && - workspacesToBeLockedToday && - workspacesToBeLockedToday.length > 0 - ) { - setIsInactivityDialogOpen(true) - } else if ( - form.values.locked_cleanup_enabled && - workspacesToBeDeletedToday && - workspacesToBeDeletedToday.length > 0 - ) { - setIsLockedDialogOpen(true) + deletionChanged && + workspacesToBeDeletedInWeek && + workspacesToBeDeletedInWeek.length > 0 + + if (dormancyScheduleChanged || deletionScheduleChanged) { + setIsScheduleDialogOpen(true) } else { submitValues() } @@ -115,18 +124,44 @@ export const TemplateScheduleForm: FC = ({ const { t } = useTranslation("templateSettingsPage") const styles = useStyles() - const workspacesToBeLockedToday = useWorkspacesToBeLocked( + const now = new Date() + const weekFromNow = new Date(now) + weekFromNow.setDate(now.getDate() + 7) + + const workspacesToDormancyNow = useWorkspacesToBeLocked( template, form.values, + now, ) - const workspacesToBeDeletedToday = useWorkspacesToBeDeleted( + + const workspacesToDormancyInWeek = useWorkspacesToBeLocked( + template, + form.values, + weekFromNow, + ) + + const workspacesToBeDeletedNow = useWorkspacesToBeDeleted( template, form.values, + now, ) - const [isInactivityDialogOpen, setIsInactivityDialogOpen] = + const workspacesToBeDeletedInWeek = useWorkspacesToBeDeleted( + template, + form.values, + weekFromNow, + ) + + const showScheduleDialog = + workspacesToDormancyNow && + workspacesToBeDeletedNow && + workspacesToDormancyInWeek && + workspacesToBeDeletedInWeek && + (workspacesToDormancyInWeek.length > 0 || + workspacesToBeDeletedInWeek.length > 0) + + const [isScheduleDialogOpen, setIsScheduleDialogOpen] = useState(false) - const [isLockedDialogOpen, setIsLockedDialogOpen] = useState(false) const submitValues = () => { // on submit, convert from hours => ms @@ -149,6 +184,8 @@ export const TemplateScheduleForm: FC = ({ allow_user_autostart: form.values.allow_user_autostart, allow_user_autostop: form.values.allow_user_autostop, + update_workspace_last_used_at: form.values.update_workspace_last_used_at, + update_workspace_locked_at: form.values.update_workspace_locked_at, }) } @@ -345,25 +382,25 @@ export const TemplateScheduleForm: FC = ({ } - label="Enable Inactivity TTL" + label="Enable Dormancy Threshold" /> , )} @@ -372,58 +409,88 @@ export const TemplateScheduleForm: FC = ({ } fullWidth inputProps={{ min: 0, step: "any" }} - label="Time until cleanup (days)" + label="Time until dormant (days)" type="number" /> } - label="Enable Locked TTL" + label="Enable Dormancy Auto-Deletion" /> , )} disabled={isSubmitting || !form.values.locked_cleanup_enabled} fullWidth inputProps={{ min: 0, step: "any" }} - label="Time until cleanup (days)" + label="Time until deletion (days)" type="number" /> )} - {workspacesToBeLockedToday && workspacesToBeLockedToday.length > 0 && ( - - )} - {workspacesToBeDeletedToday && workspacesToBeDeletedToday.length > 0 && ( - { + submitValues() + setIsScheduleDialogOpen(false) + // These fields are request-scoped so they should be reset + // after every submission. + form + .setFieldValue("update_workspace_locked_at", false) + .catch((error) => { + throw error + }) + form + .setFieldValue("update_workspace_last_used_at", false) + .catch((error) => { + throw error + }) + }} + inactiveWorkspacesToGoDormant={workspacesToDormancyNow.length} + inactiveWorkspacesToGoDormantInWeek={ + workspacesToDormancyInWeek.length - workspacesToDormancyNow.length + } + dormantWorkspacesToBeDeleted={workspacesToBeDeletedNow.length} + dormantWorkspacesToBeDeletedInWeek={ + workspacesToBeDeletedInWeek.length - workspacesToBeDeletedNow.length + } + open={isScheduleDialogOpen} + onClose={() => { + setIsScheduleDialogOpen(false) + }} + title="Workspace Scheduling" + updateLockedWorkspaces={(update: boolean) => + form.setFieldValue("update_workspace_locked_at", update) + } + updateInactiveWorkspaces={(update: boolean) => + form.setFieldValue("update_workspace_last_used_at", update) + } + dormantValueChanged={ + form.initialValues.inactivity_ttl_ms !== + form.values.inactivity_ttl_ms + } + deletionValueChanged={ + form.initialValues.locked_ttl_ms !== form.values.locked_ttl_ms + } /> )} diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx index ee1fe7b13d302..d36fcd85b021c 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/formHelpers.tsx @@ -51,10 +51,10 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => }, ), inactivity_ttl_ms: Yup.number() - .min(0, "Inactivity cleanup days must not be less than 0.") + .min(0, "Dormancy threshold days must not be less than 0.") .test( "positive-if-enabled", - "Inactivity cleanup days must be greater than zero when enabled.", + "Dormancy threshold days must be greater than zero when enabled.", function (value) { const parent = this.parent as TemplateScheduleFormValues if (parent.inactivity_cleanup_enabled) { @@ -65,10 +65,10 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => }, ), locked_ttl_ms: Yup.number() - .min(0, "Locked cleanup days must not be less than 0.") + .min(0, "Dormancy auto-deletion days must not be less than 0.") .test( "positive-if-enabled", - "Locked cleanup days must be greater than zero when enabled.", + "Dormancy auto-deletion days must be greater than zero when enabled.", function (value) { const parent = this.parent as TemplateScheduleFormValues if (parent.locked_cleanup_enabled) { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts index 9836c5b273b3f..346d371f02951 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/useWorkspacesToBeDeleted.ts @@ -1,23 +1,20 @@ -import { useQuery } from "@tanstack/react-query" -import { getWorkspaces } from "api/api" import { compareAsc } from "date-fns" import { Workspace, Template } from "api/typesGenerated" import { TemplateScheduleFormValues } from "./formHelpers" +import { useWorkspacesData } from "pages/WorkspacesPage/data" export const useWorkspacesToBeLocked = ( template: Template, formValues: TemplateScheduleFormValues, + fromDate: Date, ) => { - const { data: workspacesData } = useQuery({ - queryKey: ["workspaces"], - queryFn: () => - getWorkspaces({ - q: "template:" + template.name, - }), - enabled: formValues.inactivity_cleanup_enabled, + const { data } = useWorkspacesData({ + page: 0, + limit: 0, + query: "template:" + template.name, }) - return workspacesData?.workspaces?.filter((workspace: Workspace) => { + return data?.workspaces?.filter((workspace: Workspace) => { if (!formValues.inactivity_ttl_ms) { return } @@ -28,38 +25,38 @@ export const useWorkspacesToBeLocked = ( const proposedLocking = new Date( new Date(workspace.last_used_at).getTime() + - formValues.inactivity_ttl_ms * 86400000, + formValues.inactivity_ttl_ms * DayInMS, ) - if (compareAsc(proposedLocking, new Date()) < 1) { + if (compareAsc(proposedLocking, fromDate) < 1) { return workspace } }) } +const DayInMS = 86400000 + export const useWorkspacesToBeDeleted = ( template: Template, formValues: TemplateScheduleFormValues, + fromDate: Date, ) => { - const { data: workspacesData } = useQuery({ - queryKey: ["workspaces"], - queryFn: () => - getWorkspaces({ - q: "template:" + template.name, - }), - enabled: formValues.locked_cleanup_enabled, + const { data } = useWorkspacesData({ + page: 0, + limit: 0, + query: "template:" + template.name + " locked_at:1970-01-01", }) - return workspacesData?.workspaces?.filter((workspace: Workspace) => { + return data?.workspaces?.filter((workspace: Workspace) => { if (!workspace.locked_at || !formValues.locked_ttl_ms) { return false } const proposedLocking = new Date( new Date(workspace.locked_at).getTime() + - formValues.locked_ttl_ms * 86400000, + formValues.locked_ttl_ms * DayInMS, ) - if (compareAsc(proposedLocking, new Date()) < 1) { + if (compareAsc(proposedLocking, fromDate) < 1) { return workspace } }) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index 15612a544d89e..dd03610717829 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -23,6 +23,8 @@ const validFormValues = { failure_ttl_ms: 7, inactivity_ttl_ms: 180, locked_ttl_ms: 30, + update_workspace_last_used_at: false, + update_workspace_locked_at: false, } const renderTemplateSchedulePage = async () => { @@ -63,12 +65,12 @@ const fillAndSubmitForm = async ({ await user.type(failureTtlField, failure_ttl_ms.toString()) const inactivityTtlField = screen.getByRole("checkbox", { - name: /Inactivity TTL/i, + name: /Dormancy Threshold/i, }) await user.type(inactivityTtlField, inactivity_ttl_ms.toString()) const lockedTtlField = screen.getByRole("checkbox", { - name: /Locked TTL/i, + name: /Dormancy Auto-Deletion/i, }) await user.type(lockedTtlField, locked_ttl_ms.toString()) @@ -123,7 +125,7 @@ describe("TemplateSchedulePage", () => { ) }) - test("failure, inactivity, and locked ttl converted to and from days", async () => { + test("failure, dormancy, and dormancy auto-deletion converted to and from days", async () => { await renderTemplateSchedulePage() jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({ @@ -237,11 +239,11 @@ describe("TemplateSchedulePage", () => { } const validate = () => getValidationSchema().validateSync(values) expect(validate).toThrowError( - "Inactivity cleanup days must not be less than 0.", + "Dormancy threshold days must not be less than 0.", ) }) - it("allows a locked ttl of 7 days", () => { + it("allows a dormancy ttl of 7 days", () => { const values: UpdateTemplateMeta = { ...validFormValues, locked_ttl_ms: 86400000 * 7, @@ -250,7 +252,7 @@ describe("TemplateSchedulePage", () => { expect(validate).not.toThrowError() }) - it("allows a locked ttl of 0", () => { + it("allows a dormancy ttl of 0", () => { const values: UpdateTemplateMeta = { ...validFormValues, locked_ttl_ms: 0, @@ -266,7 +268,7 @@ describe("TemplateSchedulePage", () => { } const validate = () => getValidationSchema().validateSync(values) expect(validate).toThrowError( - "Locked cleanup days must not be less than 0.", + "Dormancy auto-deletion days must not be less than 0.", ) }) }) diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index 4dc714960e57e..e7a92458e63bd 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -176,7 +176,7 @@ export const WorkspaceReadyPage = ({ handleChangeVersion={() => { setChangeVersionDialogOpen(true) }} - handleUnlock={() => workspaceSend({ type: "UNLOCK" })} + handleDormantActivate={() => workspaceSend({ type: "ACTIVATE" })} resources={workspace.latest_build.resources} builds={builds} canUpdateWorkspace={canUpdateWorkspace} diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 78e61d7be39b4..9190db98c9575 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -14,7 +14,7 @@ import { Stack } from "components/Stack/Stack" import { WorkspaceHelpTooltip } from "components/Tooltips" import { WorkspacesTable } from "pages/WorkspacesPage/WorkspacesTable" import { useLocalStorage } from "hooks" -import { LockedWorkspaceBanner, Count } from "components/WorkspaceDeletion" +import { DormantWorkspaceBanner, Count } from "components/WorkspaceDeletion" import { ErrorAlert } from "components/Alert/ErrorAlert" import { WorkspacesFilter } from "./filter/filter" import { hasError, isApiValidationError } from "api/errors" @@ -101,7 +101,7 @@ export const WorkspacesPageView: FC< {/* determines its own visibility */} - diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index ead81dcf5a3e4..d3e9f7204e25c 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -96,7 +96,7 @@ export type WorkspaceEvent = | { type: "INCREASE_DEADLINE"; hours: number } | { type: "DECREASE_DEADLINE"; hours: number } | { type: "RETRY_BUILD" } - | { type: "UNLOCK" } + | { type: "ACTIVATE" } export const checks = { readWorkspace: "readWorkspace", @@ -171,7 +171,7 @@ export const workspaceMachine = createMachine( cancelWorkspace: { data: Types.Message } - unlockWorkspace: { + activateWorkspace: { data: Types.Message } listening: { @@ -264,7 +264,7 @@ export const workspaceMachine = createMachine( actions: ["enableDebugMode"], }, ], - UNLOCK: "requestingUnlock", + ACTIVATE: "requestingActivate", }, }, askingDelete: { @@ -410,15 +410,15 @@ export const workspaceMachine = createMachine( ], }, }, - requestingUnlock: { + requestingActivate: { entry: ["clearBuildError"], invoke: { - src: "unlockWorkspace", - id: "unlockWorkspace", + src: "activateWorkspace", + id: "activateWorkspace", onDone: "idle", onError: { target: "idle", - actions: ["displayUnlockError"], + actions: ["displayActivateError"], }, }, }, @@ -576,8 +576,8 @@ export const workspaceMachine = createMachine( ) displayError(message) }, - displayUnlockError: (_, { data }) => { - const message = getErrorMessage(data, "Error unlocking workspace.") + displayActivateError: (_, { data }) => { + const message = getErrorMessage(data, "Error activate workspace.") displayError(message) }, assignMissedParameters: assign({ @@ -695,16 +695,16 @@ export const workspaceMachine = createMachine( throw Error("Cannot cancel workspace without build id") } }, - unlockWorkspace: (context) => async (send) => { + activateWorkspace: (context) => async (send) => { if (context.workspace) { - const unlockWorkspacePromise = await API.updateWorkspaceLock( + const activateWorkspacePromise = await API.updateWorkspaceLock( context.workspace.id, false, ) - send({ type: "REFRESH_WORKSPACE", data: unlockWorkspacePromise }) - return unlockWorkspacePromise + send({ type: "REFRESH_WORKSPACE", data: activateWorkspacePromise }) + return activateWorkspacePromise } else { - throw Error("Cannot unlock workspace without workspace id") + throw Error("Cannot activate workspace without workspace id") } }, listening: (context) => (send) => {