diff --git a/cli/bump.go b/cli/bump.go index 220f6c406b816..e49f922495837 100644 --- a/cli/bump.go +++ b/cli/bump.go @@ -58,8 +58,9 @@ func bump() *cobra.Command { _, _ = fmt.Fprintf( cmd.OutOrStdout(), - "Workspace %q will now stop at %s\n", workspace.Name, - newDeadline.Format(time.RFC822), + "Workspace %q will now stop at %s on %s\n", workspace.Name, + newDeadline.Format(timeFormat), + newDeadline.Format(dateFormat), ) return nil diff --git a/cli/constants.go b/cli/constants.go new file mode 100644 index 0000000000000..af9dd5260ee39 --- /dev/null +++ b/cli/constants.go @@ -0,0 +1,6 @@ +package cli + +const ( + timeFormat = "3:04:05 PM MST" + dateFormat = "Jan 2, 2006" +) diff --git a/cli/list.go b/cli/list.go index 47eae30d23a7b..c9400c984a202 100644 --- a/cli/list.go +++ b/cli/list.go @@ -127,6 +127,9 @@ func hasExtension(ws codersdk.Workspace) (bool, time.Duration) { if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart { return false, 0 } + if ws.LatestBuild.Job.CompletedAt == nil { + return false, 0 + } if ws.LatestBuild.Deadline.IsZero() { return false, 0 } @@ -134,7 +137,7 @@ func hasExtension(ws codersdk.Workspace) (bool, time.Duration) { return false, 0 } ttl := time.Duration(*ws.TTLMillis) * time.Millisecond - delta := ws.LatestBuild.Deadline.Add(-ttl).Sub(ws.LatestBuild.CreatedAt) + delta := ws.LatestBuild.Deadline.Add(-ttl).Sub(*ws.LatestBuild.Job.CompletedAt) if delta < time.Minute { return false, 0 } diff --git a/cli/ttl.go b/cli/ttl.go index 9bc19dc033bea..1b293c43044a1 100644 --- a/cli/ttl.go +++ b/cli/ttl.go @@ -1,14 +1,14 @@ package cli import ( - "errors" "fmt" "time" "github.com/spf13/cobra" "golang.org/x/xerrors" - "github.com/coder/coder/cli/cliui" + "github.com/coder/coder/coderd/autobuild/schedule" + "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" ) @@ -91,30 +91,6 @@ func ttlset() *cobra.Command { _, _ = fmt.Fprintf(cmd.OutOrStdout(), "warning: ttl rounded down to %s\n", truncated) } - if changed, newDeadline := changedNewDeadline(workspace, truncated); changed { - // For the purposes of the user, "less than a minute" is essentially the same as "immediately". - timeRemaining := time.Until(newDeadline).Truncate(time.Minute) - humanRemaining := "in " + timeRemaining.String() - if timeRemaining <= 0 { - humanRemaining = "immediately" - } - _, err = cliui.Prompt(cmd, cliui.PromptOptions{ - Text: fmt.Sprintf( - "Workspace %q will be stopped %s. Are you sure?", - workspace.Name, - humanRemaining, - ), - Default: "yes", - IsConfirm: true, - }) - if err != nil { - if errors.Is(err, cliui.Canceled) { - return nil - } - return err - } - } - millis := truncated.Milliseconds() if err = client.UpdateWorkspaceTTL(cmd.Context(), workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ TTLMillis: &millis, @@ -122,6 +98,25 @@ func ttlset() *cobra.Command { return xerrors.Errorf("update workspace ttl: %w", err) } + if ptr.NilOrEmpty(workspace.AutostartSchedule) { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%q will shut down %s after start.\n", workspace.Name, truncated) + return nil + } + + sched, err := schedule.Weekly(*workspace.AutostartSchedule) + if err != nil { + return xerrors.Errorf("parse workspace schedule: %w", err) + } + + nextShutdown := sched.Next(time.Now()).Add(truncated).In(sched.Location()) + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%q will shut down at %s on %s (%s after start).\n", + workspace.Name, + nextShutdown.Format(timeFormat), + nextShutdown.Format(dateFormat), + truncated, + ) + + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "NOTE: this will only take effect the next time the workspace is started.\n") return nil }, } @@ -157,18 +152,3 @@ func ttlunset() *cobra.Command { }, } } - -func changedNewDeadline(ws codersdk.Workspace, newTTL time.Duration) (changed bool, newDeadline time.Time) { - if ws.LatestBuild.Transition != codersdk.WorkspaceTransitionStart { - // not running - return false, newDeadline - } - - if ws.LatestBuild.Job.CompletedAt == nil { - // still building - return false, newDeadline - } - - newDeadline = ws.LatestBuild.Job.CompletedAt.Add(newTTL) - return true, newDeadline -} diff --git a/cli/ttl_test.go b/cli/ttl_test.go index 92ca201c81a44..28097d27071e2 100644 --- a/cli/ttl_test.go +++ b/cli/ttl_test.go @@ -3,7 +3,6 @@ package cli_test import ( "bytes" "context" - "fmt" "strings" "testing" "time" @@ -109,9 +108,6 @@ func TestTTL(t *testing.T) { assert.NoError(t, err, "unexpected error") }() - pty.ExpectMatch(fmt.Sprintf("warning: ttl rounded down to %s", ttl.Truncate(time.Minute))) - pty.ExpectMatch(fmt.Sprintf("Workspace %q will be stopped in 8h29m0s. Are you sure?", workspace.Name)) - pty.WriteLine("yes") // Ensure ttl updated updated, err := client.Workspace(ctx, workspace.ID) require.NoError(t, err, "fetch updated workspace") diff --git a/coderd/autobuild/executor/lifecycle_executor.go b/coderd/autobuild/executor/lifecycle_executor.go index 55b8b3228f775..161b85cf0f53f 100644 --- a/coderd/autobuild/executor/lifecycle_executor.go +++ b/coderd/autobuild/executor/lifecycle_executor.go @@ -85,11 +85,9 @@ func (e *Executor) runOnce(t time.Time) Stats { // is what we compare against when performing autostop operations, rounded down // to the minute. // - // NOTE: Currently, if a workspace build is created with a given TTL and then - // the user either changes or unsets the TTL, the deadline for the workspace - // build will not have changed. So, autostop will still happen at the - // original TTL value from when the workspace build was created. - // Whether this is expected behavior from a user's perspective is not yet known. + // NOTE: If a workspace build is created with a given TTL and then the user either + // changes or unsets the TTL, the deadline for the workspace build will not + // have changed. This behavior is as expected per #2229. eligibleWorkspaces, err := db.GetWorkspacesAutostart(e.ctx) if err != nil { return xerrors.Errorf("get eligible workspaces for autostart or autostop: %w", err) diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go index be435b7730ab8..1680fe0368e12 100644 --- a/coderd/autobuild/executor/lifecycle_executor_test.go +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -308,8 +308,7 @@ func TestExecutorAutostopNotEnabled(t *testing.T) { require.NoError(t, err) require.Nil(t, workspace.TTLMillis) - // TODO(cian): need to stop and start the workspace as we do not update the deadline yet - // see: https://github.com/coder/coder/issues/1783 + // TODO(cian): need to stop and start the workspace as we do not update the deadline. See: #2229 coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart) @@ -440,29 +439,36 @@ func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) { err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}) require.NoError(t, err) - // Then: the deadline should be the zero value + // Then: the deadline should still be the original value updated := coderdtest.MustWorkspace(t, client, workspace.ID) - assert.Zero(t, updated.LatestBuild.Deadline) + assert.WithinDuration(t, workspace.LatestBuild.Deadline, updated.LatestBuild.Deadline, time.Minute) // When: the autobuild executor ticks after the original deadline go func() { tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute) }() - // Then: the workspace should not stop + // Then: the workspace should stop stats := <-statsCh assert.NoError(t, stats.Error) - assert.Len(t, stats.Transitions, 0) + assert.Len(t, stats.Transitions, 1) + assert.Equal(t, stats.Transitions[workspace.ID], database.WorkspaceTransitionStop) + + // Wait for stop to complete + updated = coderdtest.MustWorkspace(t, client, workspace.ID) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, updated.LatestBuild.ID) + + // Start the workspace again + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, database.WorkspaceTransitionStop, database.WorkspaceTransitionStart) // Given: the user changes their mind again and wants to enable auto-stop newTTL := 8 * time.Hour - expectedDeadline := workspace.LatestBuild.UpdatedAt.Add(newTTL) err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: ptr.Ref(newTTL.Milliseconds())}) require.NoError(t, err) - // Then: the deadline should be updated based on the TTL + // Then: the deadline should remain at the zero value updated = coderdtest.MustWorkspace(t, client, workspace.ID) - assert.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline, time.Minute) + assert.Zero(t, updated.LatestBuild.Deadline) // When: the relentless onward march of time continues go func() { @@ -470,11 +476,10 @@ func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) { close(tickCh) }() - // Then: the workspace should stop + // Then: the workspace should not stop stats = <-statsCh assert.NoError(t, stats.Error) - assert.Len(t, stats.Transitions, 1) - assert.Equal(t, stats.Transitions[workspace.ID], database.WorkspaceTransitionStop) + assert.Len(t, stats.Transitions, 0) } func TestExecutorAutostartMultipleOK(t *testing.T) { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 920adb85295da..c5e57f9fc8979 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -31,6 +31,8 @@ import ( "github.com/coder/coder/codersdk" ) +const workspaceDefaultTTL = 12 * time.Hour + func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { workspace := httpmw.WorkspaceParam(r) if !api.Authorize(rw, r, rbac.ActionRead, workspace) { @@ -289,8 +291,8 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req } if !dbTTL.Valid { - // Default to template maximum when creating a new workspace - dbTTL = sql.NullInt64{Valid: true, Int64: template.MaxTtl} + // Default to min(12 hours, template maximum). Just defaulting to template maximum can be surprising. + dbTTL = sql.NullInt64{Valid: true, Int64: min(template.MaxTtl, int64(workspaceDefaultTTL))} } workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(r.Context(), database.GetWorkspaceByOwnerIDAndNameParams{ @@ -511,30 +513,22 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return } - template, err := api.Database.GetTemplateByID(r.Context(), workspace.TemplateID) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: "Error fetching workspace template!", - }) - return - } + var validErrs []httpapi.Error - dbTTL, err := validWorkspaceTTLMillis(req.TTLMillis, time.Duration(template.MaxTtl)) - if err != nil { - httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ - Message: "Invalid workspace TTL.", - Detail: err.Error(), - Validations: []httpapi.Error{ - { - Field: "ttl_ms", - Detail: err.Error(), - }, - }, - }) - return - } + err := api.Database.InTx(func(s database.Store) error { + template, err := s.GetTemplateByID(r.Context(), workspace.TemplateID) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ + Message: "Error fetching workspace template!", + }) + return xerrors.Errorf("fetch workspace template: %w", err) + } - err = api.Database.InTx(func(s database.Store) error { + dbTTL, err := validWorkspaceTTLMillis(req.TTLMillis, time.Duration(template.MaxTtl)) + if err != nil { + validErrs = append(validErrs, httpapi.Error{Field: "ttl_ms", Detail: err.Error()}) + return err + } if err := s.UpdateWorkspaceTTL(r.Context(), database.UpdateWorkspaceTTLParams{ ID: workspace.ID, Ttl: dbTTL, @@ -542,44 +536,18 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("update workspace TTL: %w", err) } - // Also extend the workspace deadline if the workspace is running - latestBuild, err := s.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) - if err != nil { - return xerrors.Errorf("get latest workspace build: %w", err) - } - - if latestBuild.Transition != database.WorkspaceTransitionStart { - return nil // nothing to do - } - - if latestBuild.UpdatedAt.IsZero() { - // Build in progress; provisionerd should update with the new TTL. - return nil - } - - var newDeadline time.Time - if dbTTL.Valid { - newDeadline = latestBuild.UpdatedAt.Add(time.Duration(dbTTL.Int64)) - } - - if err := s.UpdateWorkspaceBuildByID( - r.Context(), - database.UpdateWorkspaceBuildByIDParams{ - ID: latestBuild.ID, - UpdatedAt: latestBuild.UpdatedAt, - ProvisionerState: latestBuild.ProvisionerState, - Deadline: newDeadline, - }, - ); err != nil { - return xerrors.Errorf("update workspace deadline: %w", err) - } return nil }) if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ - Message: "Error updating workspace time until shutdown!", - Detail: err.Error(), + code := http.StatusInternalServerError + if len(validErrs) > 0 { + code = http.StatusBadRequest + } + httpapi.Write(rw, code, httpapi.Response{ + Message: "Error updating workspace time until shutdown!", + Validations: validErrs, + Detail: err.Error(), }) return } @@ -1024,3 +992,10 @@ func splitQueryParameterByDelimiter(query string, delimiter rune, maintainQuotes return parts } + +func min(x, y int64) int64 { + if x < y { + return x + } + return y +} diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 9e207362fe18d..93f9b33dcc0d7 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -868,23 +868,20 @@ func TestWorkspaceUpdateTTL(t *testing.T) { t.Parallel() testCases := []struct { - name string - ttlMillis *int64 - expectedError string - expectedDeadline *time.Time - modifyTemplate func(*codersdk.CreateTemplateRequest) + name string + ttlMillis *int64 + expectedError string + modifyTemplate func(*codersdk.CreateTemplateRequest) }{ { - name: "disable ttl", - ttlMillis: nil, - expectedError: "", - expectedDeadline: ptr.Ref(time.Time{}), + name: "disable ttl", + ttlMillis: nil, + expectedError: "", }, { - name: "update ttl", - ttlMillis: ptr.Ref(12 * time.Hour.Milliseconds()), - expectedError: "", - expectedDeadline: ptr.Ref(time.Now().Add(12*time.Hour + time.Minute)), + name: "update ttl", + ttlMillis: ptr.Ref(12 * time.Hour.Milliseconds()), + expectedError: "", }, { name: "below minimum ttl", @@ -892,16 +889,14 @@ func TestWorkspaceUpdateTTL(t *testing.T) { expectedError: "ttl must be at least one minute", }, { - name: "minimum ttl", - ttlMillis: ptr.Ref(time.Minute.Milliseconds()), - expectedError: "", - expectedDeadline: ptr.Ref(time.Now().Add(2 * time.Minute)), + name: "minimum ttl", + ttlMillis: ptr.Ref(time.Minute.Milliseconds()), + expectedError: "", }, { - name: "maximum ttl", - ttlMillis: ptr.Ref((24 * 7 * time.Hour).Milliseconds()), - expectedError: "", - expectedDeadline: ptr.Ref(time.Now().Add(24*7*time.Hour + time.Minute)), + name: "maximum ttl", + ttlMillis: ptr.Ref((24 * 7 * time.Hour).Milliseconds()), + expectedError: "", }, { name: "above maximum ttl", @@ -954,9 +949,6 @@ func TestWorkspaceUpdateTTL(t *testing.T) { require.NoError(t, err, "fetch updated workspace") require.Equal(t, testCase.ttlMillis, updated.TTLMillis, "expected autostop ttl to equal requested") - if testCase.expectedDeadline != nil { - require.WithinDuration(t, *testCase.expectedDeadline, updated.LatestBuild.Deadline, time.Minute, "expected autostop deadline to be equal expected") - } }) } diff --git a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx index 5d5d1db960c5c..2a60dd5e4ce71 100644 --- a/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx +++ b/site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx @@ -13,7 +13,7 @@ import { FC } from "react" import { Link as RouterLink } from "react-router-dom" import { Workspace } from "../../api/typesGenerated" import { MONOSPACE_FONT_FAMILY } from "../../theme/constants" -import { stripTimezone } from "../../util/schedule" +import { extractTimezone, stripTimezone } from "../../util/schedule" import { isWorkspaceOn } from "../../util/workspace" import { Stack } from "../Stack/Stack" @@ -66,7 +66,10 @@ export const Language = { } }, editScheduleLink: "Edit schedule", - schedule: `Schedule (${dayjs.tz.guess()})`, + scheduleHeader: (workspace: Workspace): string => { + const tz = workspace.autostart_schedule ? extractTimezone(workspace.autostart_schedule) : dayjs.tz.guess() + return `Schedule (${tz})` + }, } export interface WorkspaceScheduleProps { @@ -81,7 +84,7 @@ export const WorkspaceSchedule: FC = ({ workspace }) => - {Language.schedule} + {Language.scheduleHeader(workspace)}
{Language.autoStartLabel} diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx index 5c3126fc74248..611d75458ece7 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx @@ -4,7 +4,6 @@ import dayjs from "dayjs" import advancedFormat from "dayjs/plugin/advancedFormat" import timezone from "dayjs/plugin/timezone" import utc from "dayjs/plugin/utc" -import * as Mocks from "../../testHelpers/entities" import { defaultWorkspaceSchedule, WorkspaceScheduleForm, WorkspaceScheduleFormProps } from "./WorkspaceScheduleForm" dayjs.extend(advancedFormat) @@ -18,97 +17,51 @@ export default { const Template: Story = (args) => -export const WorkspaceNotRunning = Template.bind({}) -WorkspaceNotRunning.args = { - now: dayjs("2022-05-17T17:40:00Z"), +export const WorkspaceWillNotShutDown = Template.bind({}) +WorkspaceWillNotShutDown.args = { initialValues: { ...defaultWorkspaceSchedule(5), - timezone: "UTC", - }, - workspace: { - ...Mocks.MockWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - transition: "stop", - updated_at: "2022-05-17T17:39:00Z", - }, + ttl: 0, }, onCancel: () => action("onCancel"), onSubmit: () => action("onSubmit"), } -export const WorkspaceWillNotShutDown = Template.bind({}) -WorkspaceWillNotShutDown.args = { - now: dayjs("2022-05-17T17:40:00Z"), +export const WorkspaceWillShutdownInAnHour = Template.bind({}) +WorkspaceWillShutdownInAnHour.args = { initialValues: { ...defaultWorkspaceSchedule(5), - timezone: "UTC", - ttl: 0, - }, - workspace: { - ...Mocks.MockWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - updated_at: "2022-05-17T17:39:00Z", - }, + ttl: 1, }, onCancel: () => action("onCancel"), onSubmit: () => action("onSubmit"), } -export const WorkspaceWillShutdown = Template.bind({}) -WorkspaceWillShutdown.args = { - now: dayjs("2022-05-17T17:40:00Z"), +export const WorkspaceWillShutdownInTwoHours = Template.bind({}) +WorkspaceWillShutdownInTwoHours.args = { initialValues: { - ...defaultWorkspaceSchedule(5), - timezone: "UTC", - }, - workspace: { - ...Mocks.MockWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - updated_at: "2022-05-17T17:39:00Z", - }, + ...defaultWorkspaceSchedule(2), + ttl: 2, }, onCancel: () => action("onCancel"), onSubmit: () => action("onSubmit"), } -export const WorkspaceWillShutdownSoon = Template.bind({}) -WorkspaceWillShutdownSoon.args = { - now: dayjs("2022-05-17T16:39:00Z"), +export const WorkspaceWillShutdownInADay = Template.bind({}) +WorkspaceWillShutdownInADay.args = { initialValues: { ...defaultWorkspaceSchedule(2), - timezone: "UTC", - ttl: 1, - }, - workspace: { - ...Mocks.MockWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - deadline: "2022-05-17T18:09:00Z", - }, - ttl_ms: 2 * 60 * 60 * 1000, // 2 hours = shuts off at 18:09 + ttl: 24, }, onCancel: () => action("onCancel"), onSubmit: () => action("onSubmit"), } -export const WorkspaceWillShutdownImmediately = Template.bind({}) -WorkspaceWillShutdownImmediately.args = { - now: dayjs("2022-05-17T17:09:00Z"), +export const WorkspaceWillShutdownInTwoDays = Template.bind({}) +WorkspaceWillShutdownInTwoDays.args = { initialValues: { - ...defaultWorkspaceSchedule(1), - timezone: "UTC", - ttl: 1, - }, - workspace: { - ...Mocks.MockWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - deadline: "2022-05-17T18:09:00Z", - }, - ttl_ms: 2 * 60 * 60 * 1000, // 2 hours = shuts off at 18:09 + ...defaultWorkspaceSchedule(2), + ttl: 48, }, onCancel: () => action("onCancel"), onSubmit: () => action("onSubmit"), diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.test.ts b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.test.ts index 3e37484b9c09b..2d4364543ec91 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.test.ts +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.test.ts @@ -1,6 +1,3 @@ -import dayjs from "dayjs" -import { Workspace } from "../../api/typesGenerated" -import * as Mocks from "../../testHelpers/entities" import { Language, ttlShutdownAt, validationSchema, WorkspaceScheduleFormValues } from "./WorkspaceScheduleForm" import { zones } from "./zones" @@ -160,99 +157,29 @@ describe("validationSchema", () => { }) describe("ttlShutdownAt", () => { - it.each<[string, dayjs.Dayjs, Workspace, string, number, string]>([ + it.each<[string, number, string]>([ + ["Manual shutdown --> manual helper text", 0, Language.ttlCausesNoShutdownHelperText], [ - "Workspace is stopped --> helper text", - dayjs("2022-05-17T18:09:00Z"), - Mocks.MockStoppedWorkspace, - "America/Chicago", + "One hour --> helper text shows shutdown after an hour", 1, - Language.ttlHelperText, + `${Language.ttlCausesShutdownHelperText} an hour ${Language.ttlCausesShutdownAfterStart}.`, ], [ - "TTL is not modified --> helper text", - dayjs("2022-05-17T16:09:00Z"), - { - ...Mocks.MockWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - deadline: "2022-05-17T18:09:00Z", - }, - ttl_ms: 2 * 60 * 60 * 1000, // 2 hours = shuts off at 18:09 - }, - "America/Chicago", + "Two hours --> helper text shows shutdown after 2 hours", 2, - Language.ttlHelperText, + `${Language.ttlCausesShutdownHelperText} 2 hours ${Language.ttlCausesShutdownAfterStart}.`, ], [ - "TTL becomes 0 --> manual helper text", - dayjs("2022-05-17T18:09:00Z"), - Mocks.MockWorkspace, - "America/Chicago", - 0, - Language.ttlCausesNoShutdownHelperText, + "24 hours --> helper text shows shutdown after a day", + 24, + `${Language.ttlCausesShutdownHelperText} a day ${Language.ttlCausesShutdownAfterStart}.`, ], [ - "Deadline of 18:09 becomes 17:09 at 17:09 --> immediate shutdown", - dayjs("2022-05-17T17:09:00Z"), - { - ...Mocks.MockWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - deadline: "2022-05-17T18:09:00Z", - }, - ttl_ms: 2 * 60 * 60 * 1000, // 2 hours = shuts off at 18:09 - }, - "America/Chicago", - 1, - `⚠️ ${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownImmediately} ⚠️`, - ], - [ - "Deadline of 18:09 becomes 17:09 at 16:39 --> display shutdown soon", - dayjs("2022-05-17T16:39:00Z"), - { - ...Mocks.MockWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - deadline: "2022-05-17T18:09:00Z", - }, - ttl_ms: 2 * 60 * 60 * 1000, // 2 hours = shuts off at 18:09 - }, - "America/Chicago", - 1, - `⚠️ ${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownSoon} ⚠️`, - ], - [ - "Deadline of 18:09 becomes 17:09 at 16:09 --> display 12:09 CDT", - dayjs("2022-05-17T16:09:00Z"), - { - ...Mocks.MockWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - deadline: "2022-05-17T18:09:00Z", - }, - ttl_ms: 2 * 60 * 60 * 1000, // 2 hours = shuts off at 18:09 - }, - "America/Chicago", - 1, - `${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownAt} May 17, 2022 12:09 PM.`, - ], - [ - "Manual workspace gets new deadline of 18:09 at 17:09 --> display 1:09 CDT", - dayjs("2022-05-17T17:09:00Z"), - { - ...Mocks.MockWorkspace, - latest_build: { - ...Mocks.MockWorkspaceBuild, - deadline: "0001-01-01T00:00:00Z", - }, - ttl_ms: 0, - }, - "America/Chicago", - 1, - `${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownAt} May 17, 2022 1:09 PM.`, + "48 hours --> helper text shows shutdown after 2 days", + 48, + `${Language.ttlCausesShutdownHelperText} 2 days ${Language.ttlCausesShutdownAfterStart}.`, ], - ])("%p", (_, now, workspace, timezone, ttlHours, expected) => { - expect(ttlShutdownAt(now, workspace, timezone, ttlHours)).toEqual(expected) + ])("%p", (_, ttlHours, expected) => { + expect(ttlShutdownAt(ttlHours)).toEqual(expected) }) }) diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx index efd2c78199e34..cab7356e168ac 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx @@ -9,16 +9,15 @@ import makeStyles from "@material-ui/core/styles/makeStyles" import TextField from "@material-ui/core/TextField" import dayjs from "dayjs" import advancedFormat from "dayjs/plugin/advancedFormat" -import isSameOrBefore from "dayjs/plugin/isSameOrBefore" +import duration from "dayjs/plugin/duration" +import relativeTime from "dayjs/plugin/relativeTime" import timezone from "dayjs/plugin/timezone" import utc from "dayjs/plugin/utc" import { useFormik } from "formik" import { FC } from "react" import * as Yup from "yup" import { FieldErrors } from "../../api/errors" -import { Workspace } from "../../api/typesGenerated" import { getFormHelpers } from "../../util/formUtils" -import { isWorkspaceOn } from "../../util/workspace" import { FormFooter } from "../FormFooter/FormFooter" import { FullPageForm } from "../FullPageForm/FullPageForm" import { Stack } from "../Stack/Stack" @@ -28,7 +27,8 @@ import { zones } from "./zones" // sorted alphabetically. dayjs.extend(utc) dayjs.extend(advancedFormat) -dayjs.extend(isSameOrBefore) +dayjs.extend(duration) +dayjs.extend(relativeTime) dayjs.extend(timezone) export const Language = { @@ -48,11 +48,8 @@ export const Language = { startTimeHelperText: "Your workspace will automatically start at this time.", timezoneLabel: "Timezone", ttlLabel: "Time until shutdown (hours)", - ttlHelperText: "Your workspace will automatically shut down after this amount of time has elapsed.", ttlCausesShutdownHelperText: "Your workspace will shut down", - ttlCausesShutdownAt: "at", - ttlCausesShutdownImmediately: "immediately!", - ttlCausesShutdownSoon: "within 30 minutes.", + ttlCausesShutdownAfterStart: "after start", ttlCausesNoShutdownHelperText: "Your workspace will not automatically shut down.", } @@ -60,10 +57,8 @@ export interface WorkspaceScheduleFormProps { fieldErrors?: FieldErrors initialValues?: WorkspaceScheduleFormValues isLoading: boolean - now?: dayjs.Dayjs onCancel: () => void onSubmit: (values: WorkspaceScheduleFormValues) => void - workspace: Workspace } export interface WorkspaceScheduleFormValues { @@ -186,10 +181,8 @@ export const WorkspaceScheduleForm: FC = ({ fieldErrors, initialValues = defaultWorkspaceSchedule(), isLoading, - now = dayjs(), onCancel, onSubmit, - workspace, }) => { const styles = useStyles() @@ -269,7 +262,7 @@ export const WorkspaceScheduleForm: FC = ({ = ({ ) } -export const ttlShutdownAt = (now: dayjs.Dayjs, workspace: Workspace, tz: string, formTTL: number): string => { - // a manual shutdown has a deadline of '"0001-01-01T00:00:00Z"' - // SEE: #1834 - const deadline = dayjs(workspace.latest_build.deadline).utc() - const hasDeadline = deadline.year() > 1 - const ttl = workspace.ttl_ms ? workspace.ttl_ms / (1000 * 60 * 60) : 0 - const delta = formTTL - ttl - - if (delta === 0 || !isWorkspaceOn(workspace)) { - return Language.ttlHelperText - } else if (formTTL === 0) { +export const ttlShutdownAt = (formTTL: number): string => { + if (formTTL < 1) { + // Passing an empty value for TTL in the form results in a number that is not zero but less than 1. return Language.ttlCausesNoShutdownHelperText } else { - const newDeadline = dayjs(hasDeadline ? deadline : now).add(delta, "hours") - if (newDeadline.isSameOrBefore(now)) { - return `⚠️ ${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownImmediately} ⚠️` - } else if (newDeadline.isSameOrBefore(now.add(30, "minutes"))) { - return `⚠️ ${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownSoon} ⚠️` - } else { - return `${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownAt} ${newDeadline - .tz(tz) - .format("MMM D, YYYY h:mm A")}.` - } + return `${Language.ttlCausesShutdownHelperText} ${dayjs.duration(formTTL, "hours").humanize()} ${ + Language.ttlCausesShutdownAfterStart + }.` } } diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index df5f511e5bfe4..dbed18ea91927 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -161,7 +161,6 @@ export const WorkspaceSchedulePage: React.FC = () => { } else if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) { return (