mirror of https://github.com/coder/coder.git
feat: add activity bumping to template scheduling (#9040)
This commit is contained in:
parent
6214117d3d
commit
6e41cd1eda
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -9786,6 +9786,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
|
||||
|
@ -9945,23 +9963,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
|
||||
}
|
||||
|
|
|
@ -512,12 +512,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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -1153,6 +1153,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
|
||||
|
|
|
@ -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<PropsWithChildren<ConfirmDialogProps>> = ({
|
|||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
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<PropsWithChildren<ScheduleDialogProps>> = ({
|
||||
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 (
|
||||
<Dialog
|
||||
className={styles.dialogWrapper}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
data-testid="dialog"
|
||||
>
|
||||
<div className={styles.dialogContent}>
|
||||
<h3 className={styles.dialogTitle}>{title}</h3>
|
||||
<>
|
||||
{showDormancyWarning && (
|
||||
<>
|
||||
<h4>{"Dormancy Threshold"}</h4>
|
||||
<Stack direction="row" spacing={5}>
|
||||
<div className={styles.dialogDescription}>{`
|
||||
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?`}</div>
|
||||
<FormControlLabel
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
}}
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
onChange={(e) => {
|
||||
updateInactiveWorkspaces(e.target.checked)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Reset"
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showDeletionWarning && (
|
||||
<>
|
||||
<h4>{"Dormancy Auto-Deletion"}</h4>
|
||||
<Stack direction="row" spacing={5}>
|
||||
<div
|
||||
className={styles.dialogDescription}
|
||||
>{`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?`}</div>
|
||||
<FormControlLabel
|
||||
sx={{
|
||||
marginTop: 2,
|
||||
}}
|
||||
control={
|
||||
<Checkbox
|
||||
size="small"
|
||||
onChange={(e) => {
|
||||
updateLockedWorkspaces(e.target.checked)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Reset"
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
|
||||
<DialogActions>
|
||||
<DialogActionButtons
|
||||
cancelText={cancelText}
|
||||
confirmDialog
|
||||
confirmLoading={confirmLoading}
|
||||
confirmText="Submit"
|
||||
disabled={disabled}
|
||||
onCancel={!hideCancel ? onClose : undefined}
|
||||
onConfirm={onConfirm || onClose}
|
||||
type="delete"
|
||||
/>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
|
|
@ -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<React.PropsWithChildren<WorkspaceProps>> = ({
|
|||
handleCancel,
|
||||
handleSettings,
|
||||
handleChangeVersion,
|
||||
handleUnlock,
|
||||
handleDormantActivate: handleDormantActivate,
|
||||
workspace,
|
||||
isUpdating,
|
||||
isRestarting,
|
||||
|
@ -170,19 +169,14 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
|||
<>
|
||||
<FullWidthPageHeader>
|
||||
<Stack direction="row" spacing={3} alignItems="center">
|
||||
{workspace.locked_at ? (
|
||||
<LockIcon fontSize="large" color="error" />
|
||||
) : (
|
||||
<Avatar
|
||||
size="md"
|
||||
src={workspace.template_icon}
|
||||
variant={workspace.template_icon ? "square" : undefined}
|
||||
fitImage={Boolean(workspace.template_icon)}
|
||||
>
|
||||
{workspace.name}
|
||||
</Avatar>
|
||||
)}
|
||||
|
||||
<Avatar
|
||||
size="md"
|
||||
src={workspace.template_icon}
|
||||
variant={workspace.template_icon ? "square" : undefined}
|
||||
fitImage={Boolean(workspace.template_icon)}
|
||||
>
|
||||
{workspace.name}
|
||||
</Avatar>
|
||||
<div>
|
||||
<PageHeaderTitle>{workspace.name}</PageHeaderTitle>
|
||||
<PageHeaderSubtitle>{workspace.owner_name}</PageHeaderSubtitle>
|
||||
|
@ -211,7 +205,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
|||
handleCancel={handleCancel}
|
||||
handleSettings={handleSettings}
|
||||
handleChangeVersion={handleChangeVersion}
|
||||
handleUnlock={handleUnlock}
|
||||
handleDormantActivate={handleDormantActivate}
|
||||
canChangeVersions={canChangeVersions}
|
||||
isUpdating={isUpdating}
|
||||
isRestarting={isRestarting}
|
||||
|
@ -262,7 +256,7 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
|||
</Cond>
|
||||
<Cond>
|
||||
{/* <ImpendingDeletionBanner/> determines its own visibility */}
|
||||
<LockedWorkspaceBanner
|
||||
<DormantWorkspaceBanner
|
||||
workspaces={[workspace]}
|
||||
shouldRedisplayBanner={
|
||||
getLocal("dismissedWorkspace") !== workspace.id
|
||||
|
|
|
@ -3,7 +3,6 @@ import BlockIcon from "@mui/icons-material/Block"
|
|||
import CloudQueueIcon from "@mui/icons-material/CloudQueue"
|
||||
import CropSquareIcon from "@mui/icons-material/CropSquare"
|
||||
import PlayCircleOutlineIcon from "@mui/icons-material/PlayCircleOutline"
|
||||
import LockOpenIcon from "@mui/icons-material/LockOpen"
|
||||
import ReplayIcon from "@mui/icons-material/Replay"
|
||||
import { LoadingButton } from "components/LoadingButton/LoadingButton"
|
||||
import { FC } from "react"
|
||||
|
@ -11,6 +10,7 @@ import BlockOutlined from "@mui/icons-material/BlockOutlined"
|
|||
import ButtonGroup from "@mui/material/ButtonGroup"
|
||||
import { Workspace, WorkspaceBuildParameter } from "api/typesGenerated"
|
||||
import { BuildParametersPopover } from "./BuildParametersPopover"
|
||||
import PowerSettingsNewIcon from "@mui/icons-material/PowerSettingsNew"
|
||||
|
||||
interface WorkspaceAction {
|
||||
loading?: boolean
|
||||
|
@ -35,19 +35,19 @@ export const UpdateButton: FC<WorkspaceAction> = ({
|
|||
)
|
||||
}
|
||||
|
||||
export const UnlockButton: FC<WorkspaceAction> = ({
|
||||
export const ActivateButton: FC<WorkspaceAction> = ({
|
||||
handleAction,
|
||||
loading,
|
||||
}) => {
|
||||
return (
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
loadingIndicator="Unlocking..."
|
||||
loadingIndicator="Activating..."
|
||||
loadingPosition="start"
|
||||
startIcon={<LockOpenIcon />}
|
||||
startIcon={<PowerSettingsNewIcon />}
|
||||
onClick={handleAction}
|
||||
>
|
||||
Unlock
|
||||
Activate
|
||||
</LoadingButton>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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<WorkspaceActionsProps> = ({
|
|||
handleCancel,
|
||||
handleSettings,
|
||||
handleChangeVersion,
|
||||
handleUnlock,
|
||||
handleDormantActivate: handleDormantActivate,
|
||||
isUpdating,
|
||||
isRestarting,
|
||||
canChangeVersions,
|
||||
|
@ -96,9 +96,11 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
|
|||
[ButtonTypesEnum.canceling]: <DisabledButton label="Canceling..." />,
|
||||
[ButtonTypesEnum.deleted]: <DisabledButton label="Deleted" />,
|
||||
[ButtonTypesEnum.pending]: <ActionLoadingButton label="Pending..." />,
|
||||
[ButtonTypesEnum.unlock]: <UnlockButton handleAction={handleUnlock} />,
|
||||
[ButtonTypesEnum.unlocking]: (
|
||||
<UnlockButton loading handleAction={handleUnlock} />
|
||||
[ButtonTypesEnum.activate]: (
|
||||
<ActivateButton handleAction={handleDormantActivate} />
|
||||
),
|
||||
[ButtonTypesEnum.activating]: (
|
||||
<ActivateButton loading handleAction={handleDormantActivate} />
|
||||
),
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
|
|
@ -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
|
||||
</Link>{" "}
|
||||
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.
|
||||
</>
|
||||
)}
|
||||
</Alert>
|
||||
|
|
|
@ -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 (
|
||||
<ChooseOne>
|
||||
{/* <ImpendingDeletionBadge/> determines its own visibility */}
|
||||
<Cond condition={Boolean(LockedBadge({ workspace }))}>
|
||||
<LockedBadge workspace={workspace} />
|
||||
</Cond>
|
||||
<Cond condition={workspace.latest_build.status === "failed"}>
|
||||
<FailureTooltip
|
||||
title={
|
||||
|
|
|
@ -22,12 +22,12 @@
|
|||
"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 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.",
|
||||
"dormancyThresholdHelperText_zero": "Coder will not mark workspaces as dormant.",
|
||||
"dormancyThresholdHelperText_one": "Coder will mark workspaces as dormant after {{count}} day without user connections.",
|
||||
"dormancyThresholdHelperText_other": "Coder will mark workspaces as dormant after {{count}} days without user connections.",
|
||||
"dormancyAutoDeletionHelperText_zero": "Coder will not automatically delete dormant workspaces.",
|
||||
"dormancyAutoDeletionHelperText_one": "Coder will automatically delete dormant workspaces after {{count}} day.",
|
||||
"dormancyAutoDeletionHelperText_other": "Coder will automatically delete dormant workspaces after {{count}} days.",
|
||||
"allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.",
|
||||
"allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases.",
|
||||
"allowUsersCancelHelperText": "If checked, users may be able to corrupt their workspace.",
|
||||
|
|
|
@ -72,6 +72,8 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
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,
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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<TemplateScheduleForm> = ({
|
|||
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 &&
|
||||
workspacesToBeLockedToday &&
|
||||
workspacesToBeLockedToday.length > 0
|
||||
) {
|
||||
setIsInactivityDialogOpen(true)
|
||||
} else if (
|
||||
form.values.locked_cleanup_enabled &&
|
||||
workspacesToBeDeletedToday &&
|
||||
workspacesToBeDeletedToday.length > 0
|
||||
) {
|
||||
setIsLockedDialogOpen(true)
|
||||
dormancyChanged &&
|
||||
workspacesToDormancyInWeek &&
|
||||
workspacesToDormancyInWeek.length > 0
|
||||
|
||||
const deletionScheduleChanged =
|
||||
form.values.inactivity_cleanup_enabled &&
|
||||
deletionChanged &&
|
||||
workspacesToBeDeletedInWeek &&
|
||||
workspacesToBeDeletedInWeek.length > 0
|
||||
|
||||
if (dormancyScheduleChanged || deletionScheduleChanged) {
|
||||
setIsScheduleDialogOpen(true)
|
||||
} else {
|
||||
submitValues()
|
||||
}
|
||||
|
@ -115,18 +124,44 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
|||
const { t } = useTranslation("templateSettingsPage")
|
||||
const styles = useStyles()
|
||||
|
||||
const workspacesToBeLockedToday = useWorkspacesToBeLocked(
|
||||
template,
|
||||
form.values,
|
||||
)
|
||||
const workspacesToBeDeletedToday = useWorkspacesToBeDeleted(
|
||||
const now = new Date()
|
||||
const weekFromNow = new Date(now)
|
||||
weekFromNow.setDate(now.getDate() + 7)
|
||||
|
||||
const workspacesToDormancyNow = useWorkspacesToBeLocked(
|
||||
template,
|
||||
form.values,
|
||||
now,
|
||||
)
|
||||
|
||||
const [isInactivityDialogOpen, setIsInactivityDialogOpen] =
|
||||
const workspacesToDormancyInWeek = useWorkspacesToBeLocked(
|
||||
template,
|
||||
form.values,
|
||||
weekFromNow,
|
||||
)
|
||||
|
||||
const workspacesToBeDeletedNow = useWorkspacesToBeDeleted(
|
||||
template,
|
||||
form.values,
|
||||
now,
|
||||
)
|
||||
|
||||
const workspacesToBeDeletedInWeek = useWorkspacesToBeDeleted(
|
||||
template,
|
||||
form.values,
|
||||
weekFromNow,
|
||||
)
|
||||
|
||||
const showScheduleDialog =
|
||||
workspacesToDormancyNow &&
|
||||
workspacesToBeDeletedNow &&
|
||||
workspacesToDormancyInWeek &&
|
||||
workspacesToBeDeletedInWeek &&
|
||||
(workspacesToDormancyInWeek.length > 0 ||
|
||||
workspacesToBeDeletedInWeek.length > 0)
|
||||
|
||||
const [isScheduleDialogOpen, setIsScheduleDialogOpen] =
|
||||
useState<boolean>(false)
|
||||
const [isLockedDialogOpen, setIsLockedDialogOpen] = useState<boolean>(false)
|
||||
|
||||
const submitValues = () => {
|
||||
// on submit, convert from hours => ms
|
||||
|
@ -149,6 +184,8 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
|||
|
||||
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<TemplateScheduleForm> = ({
|
|||
</FormFields>
|
||||
</FormSection>
|
||||
<FormSection
|
||||
title="Inactivity TTL"
|
||||
description="When enabled, Coder will lock workspaces that have not been accessed after a specified number of days."
|
||||
title="Dormancy Threshold"
|
||||
description="When enabled, Coder will mark workspaces as dormant after a period of time with no connections. Dormant workspaces can be auto-deleted (see below) or manually reviewed by the workspace owner or admins."
|
||||
>
|
||||
<FormFields>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name="inactivityCleanupEnabled"
|
||||
name="dormancyThreshold"
|
||||
checked={form.values.inactivity_cleanup_enabled}
|
||||
onChange={handleToggleInactivityCleanup}
|
||||
/>
|
||||
}
|
||||
label="Enable Inactivity TTL"
|
||||
label="Enable Dormancy Threshold"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"inactivity_ttl_ms",
|
||||
<TTLHelperText
|
||||
translationName="inactivityTTLHelperText"
|
||||
translationName="dormancyThresholdHelperText"
|
||||
ttl={form.values.inactivity_ttl_ms}
|
||||
/>,
|
||||
)}
|
||||
|
@ -372,58 +409,88 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
|
|||
}
|
||||
fullWidth
|
||||
inputProps={{ min: 0, step: "any" }}
|
||||
label="Time until cleanup (days)"
|
||||
label="Time until dormant (days)"
|
||||
type="number"
|
||||
/>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
<FormSection
|
||||
title="Deletion Grace Period"
|
||||
description="When enabled, Coder will permanently delete workspaces that have been locked for a specified number of days."
|
||||
title="Dormancy Auto-Deletion"
|
||||
description="When enabled, Coder will permanently delete dormant workspaces after a period of time. Once a workspace is deleted it cannot be recovered."
|
||||
>
|
||||
<FormFields>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
name="lockedCleanupEnabled"
|
||||
name="dormancyAutoDeletion"
|
||||
checked={form.values.locked_cleanup_enabled}
|
||||
onChange={handleToggleLockedCleanup}
|
||||
/>
|
||||
}
|
||||
label="Enable Locked TTL"
|
||||
label="Enable Dormancy Auto-Deletion"
|
||||
/>
|
||||
<TextField
|
||||
{...getFieldHelpers(
|
||||
"locked_ttl_ms",
|
||||
<TTLHelperText
|
||||
translationName="lockedTTLHelperText"
|
||||
translationName="dormancyAutoDeletionHelperText"
|
||||
ttl={form.values.locked_ttl_ms}
|
||||
/>,
|
||||
)}
|
||||
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"
|
||||
/>
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
</>
|
||||
)}
|
||||
{workspacesToBeLockedToday && workspacesToBeLockedToday.length > 0 && (
|
||||
<InactivityDialog
|
||||
submitValues={submitValues}
|
||||
isInactivityDialogOpen={isInactivityDialogOpen}
|
||||
setIsInactivityDialogOpen={setIsInactivityDialogOpen}
|
||||
workspacesToBeLockedToday={workspacesToBeLockedToday?.length ?? 0}
|
||||
/>
|
||||
)}
|
||||
{workspacesToBeDeletedToday && workspacesToBeDeletedToday.length > 0 && (
|
||||
<DeleteLockedDialog
|
||||
submitValues={submitValues}
|
||||
isLockedDialogOpen={isLockedDialogOpen}
|
||||
setIsLockedDialogOpen={setIsLockedDialogOpen}
|
||||
workspacesToBeDeletedToday={workspacesToBeDeletedToday?.length ?? 0}
|
||||
{showScheduleDialog && (
|
||||
<ScheduleDialog
|
||||
onConfirm={() => {
|
||||
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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
})
|
||||
|
|
|
@ -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.",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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<
|
|||
<ErrorAlert error={error} />
|
||||
</Maybe>
|
||||
{/* <ImpendingDeletionBanner/> determines its own visibility */}
|
||||
<LockedWorkspaceBanner
|
||||
<DormantWorkspaceBanner
|
||||
workspaces={lockedWorkspaces}
|
||||
shouldRedisplayBanner={hasLockedWorkspace}
|
||||
onDismiss={() =>
|
||||
|
|
|
@ -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) => {
|
||||
|
|
Loading…
Reference in New Issue