diff --git a/cli/ttl.go b/cli/ttl.go index cce6ef84a9345..9bc19dc033bea 100644 --- a/cli/ttl.go +++ b/cli/ttl.go @@ -1,12 +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/codersdk" ) @@ -89,6 +91,30 @@ 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, @@ -131,3 +157,18 @@ 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 00a0f29fd3811..92ca201c81a44 100644 --- a/cli/ttl_test.go +++ b/cli/ttl_test.go @@ -3,16 +3,19 @@ package cli_test import ( "bytes" "context" + "fmt" "strings" "testing" "time" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/coder/coder/cli/clitest" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" + "github.com/coder/coder/pty/ptytest" ) func TestTTL(t *testing.T) { @@ -22,33 +25,29 @@ func TestTTL(t *testing.T) { t.Parallel() var ( - ctx = context.Background() client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) + template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + ttl = 7*time.Hour + 30*time.Minute + 30*time.Second + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TTLMillis = ptr.Ref(ttl.Milliseconds()) + }) cmdArgs = []string{"ttl", "show", workspace.Name} - ttl = 8*time.Hour + 30*time.Minute + 30*time.Second stdoutBuf = &bytes.Buffer{} ) - err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ - TTLMillis: ptr.Ref(ttl.Milliseconds()), - }) - require.NoError(t, err) - cmd, root := clitest.New(t, cmdArgs...) clitest.SetupConfig(t, client, root) cmd.SetOut(stdoutBuf) - err = cmd.Execute() + err := cmd.Execute() require.NoError(t, err, "unexpected error") require.Equal(t, ttl.Truncate(time.Minute).String(), strings.TrimSpace(stdoutBuf.String())) }) - t.Run("SetUnsetOK", func(t *testing.T) { + t.Run("UnsetOK", func(t *testing.T) { t.Parallel() var ( @@ -58,9 +57,11 @@ func TestTTL(t *testing.T) { version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID) ttl = 8*time.Hour + 30*time.Minute + 30*time.Second - cmdArgs = []string{"ttl", "set", workspace.Name, ttl.String()} + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TTLMillis = ptr.Ref(ttl.Milliseconds()) + }) + cmdArgs = []string{"ttl", "unset", workspace.Name} stdoutBuf = &bytes.Buffer{} ) @@ -71,24 +72,52 @@ func TestTTL(t *testing.T) { err := cmd.Execute() require.NoError(t, err, "unexpected error") - // Ensure ttl updated + // Ensure ttl unset updated, err := client.Workspace(ctx, workspace.ID) require.NoError(t, err, "fetch updated workspace") - require.Equal(t, ttl.Truncate(time.Minute), time.Duration(*updated.TTLMillis)*time.Millisecond) - require.Contains(t, stdoutBuf.String(), "warning: ttl rounded down") + require.Nil(t, updated.TTLMillis, "expected ttl to not be set") + }) - // unset schedule - cmd, root = clitest.New(t, "ttl", "unset", workspace.Name) - clitest.SetupConfig(t, client, root) - cmd.SetOut(stdoutBuf) + t.Run("SetOK", func(t *testing.T) { + t.Parallel() - err = cmd.Execute() - require.NoError(t, err, "unexpected error") + var ( + ctx = context.Background() + client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + project = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + ttl = 8*time.Hour + 30*time.Minute + 30*time.Second + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, project.ID, func(cwr *codersdk.CreateWorkspaceRequest) { + cwr.TTLMillis = ptr.Ref(ttl.Milliseconds()) + }) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + cmdArgs = []string{"ttl", "set", workspace.Name, ttl.String()} + done = make(chan struct{}) + ) + cmd, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + pty := ptytest.New(t) + cmd.SetIn(pty.Input()) + cmd.SetOut(pty.Output()) + + go func() { + defer close(done) + err := cmd.Execute() + 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) + updated, err := client.Workspace(ctx, workspace.ID) require.NoError(t, err, "fetch updated workspace") - require.Nil(t, updated.TTLMillis, "expected ttl to not be set") + require.Equal(t, ttl.Truncate(time.Minute), time.Duration(*updated.TTLMillis)*time.Millisecond) + + <-done }) t.Run("ZeroInvalid", func(t *testing.T) { diff --git a/coderd/autobuild/executor/lifecycle_executor_test.go b/coderd/autobuild/executor/lifecycle_executor_test.go index 5b1045aea2cab..be435b7730ab8 100644 --- a/coderd/autobuild/executor/lifecycle_executor_test.go +++ b/coderd/autobuild/executor/lifecycle_executor_test.go @@ -440,18 +440,41 @@ func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) { err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}) require.NoError(t, err) - // When: the autobuild executor ticks after the deadline + // Then: the deadline should be the zero value + updated := coderdtest.MustWorkspace(t, client, workspace.ID) + assert.Zero(t, updated.LatestBuild.Deadline) + + // When: the autobuild executor ticks after the original deadline go func() { tickCh <- workspace.LatestBuild.Deadline.Add(time.Minute) - close(tickCh) }() - // Then: the workspace should still stop - sorry! + // Then: the workspace should not stop stats := <-statsCh assert.NoError(t, stats.Error) + assert.Len(t, stats.Transitions, 0) + + // 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 + updated = coderdtest.MustWorkspace(t, client, workspace.ID) + assert.WithinDuration(t, expectedDeadline, updated.LatestBuild.Deadline, time.Minute) + + // When: the relentless onward march of time continues + go func() { + tickCh <- workspace.LatestBuild.Deadline.Add(newTTL + time.Minute) + close(tickCh) + }() + + // Then: the workspace should stop + stats = <-statsCh + assert.NoError(t, stats.Error) assert.Len(t, stats.Transitions, 1) - assert.Contains(t, stats.Transitions, workspace.ID) - assert.Equal(t, database.WorkspaceTransitionStop, stats.Transitions[workspace.ID]) + assert.Equal(t, stats.Transitions[workspace.ID], database.WorkspaceTransitionStop) } func TestExecutorAutostartMultipleOK(t *testing.T) { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 255685a05901e..99c50e2bf89db 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -566,17 +566,57 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return } - err = api.Database.UpdateWorkspaceTTL(r.Context(), database.UpdateWorkspaceTTLParams{ - ID: workspace.ID, - Ttl: dbTTL, + err = api.Database.InTx(func(s database.Store) error { + if err := s.UpdateWorkspaceTTL(r.Context(), database.UpdateWorkspaceTTLParams{ + ID: workspace.ID, + Ttl: dbTTL, + }); err != nil { + 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: "Internal error updating workspace TTL.", + Message: "Error updating workspace time until shutdown!", Detail: err.Error(), }) return } + + httpapi.Write(rw, http.StatusOK, nil) } func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 73fed89c80c79..b14a814975e8c 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -550,19 +550,16 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { name: "invalid location", schedule: ptr.Ref("CRON_TZ=Imaginary/Place 30 9 * * 1-5"), expectedError: "parse schedule: provided bad location Imaginary/Place: unknown time zone Imaginary/Place", - // expectedError: "status code 500: Invalid autostart schedule\n\tError: parse schedule: provided bad location Imaginary/Place: unknown time zone Imaginary/Place", }, { name: "invalid schedule", schedule: ptr.Ref("asdf asdf asdf "), expectedError: `validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix`, - // expectedError: "status code 500: Invalid autostart schedule\n\tError: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix", }, { name: "only 3 values", schedule: ptr.Ref("CRON_TZ=Europe/Dublin 30 9 *"), expectedError: `validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix`, - // expectedError: "status code 500: Invalid autostart schedule\n\tError: validate weekly schedule: expected schedule to consist of 5 fields with an optional CRON_TZ= prefix", }, } @@ -640,15 +637,23 @@ func TestWorkspaceUpdateTTL(t *testing.T) { t.Parallel() testCases := []struct { - name string - ttlMillis *int64 - expectedError string - modifyTemplate func(*codersdk.CreateTemplateRequest) + name string + ttlMillis *int64 + expectedError string + expectedDeadline *time.Time + modifyTemplate func(*codersdk.CreateTemplateRequest) }{ { - name: "disable ttl", - ttlMillis: nil, - expectedError: "", + name: "disable ttl", + ttlMillis: nil, + expectedError: "", + expectedDeadline: ptr.Ref(time.Time{}), + }, + { + name: "update ttl", + ttlMillis: ptr.Ref(12 * time.Hour.Milliseconds()), + expectedError: "", + expectedDeadline: ptr.Ref(time.Now().Add(12*time.Hour + time.Minute)), }, { name: "below minimum ttl", @@ -656,14 +661,16 @@ func TestWorkspaceUpdateTTL(t *testing.T) { expectedError: "ttl must be at least one minute", }, { - name: "minimum ttl", - ttlMillis: ptr.Ref(time.Minute.Milliseconds()), - expectedError: "", + name: "minimum ttl", + ttlMillis: ptr.Ref(time.Minute.Milliseconds()), + expectedError: "", + expectedDeadline: ptr.Ref(time.Now().Add(2 * time.Minute)), }, { - name: "maximum ttl", - ttlMillis: ptr.Ref((24 * 7 * time.Hour).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: "above maximum ttl", @@ -698,6 +705,7 @@ func TestWorkspaceUpdateTTL(t *testing.T) { cwr.AutostartSchedule = nil cwr.TTLMillis = nil }) + _ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) ) err := client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{ @@ -715,6 +723,9 @@ 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/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx index 27d62f2482a0c..c9ea6eafa8b9d 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.stories.tsx @@ -1,6 +1,15 @@ import { action } from "@storybook/addon-actions" import { Story } from "@storybook/react" -import { WorkspaceScheduleForm, WorkspaceScheduleFormProps } from "./WorkspaceScheduleForm" +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) +dayjs.extend(utc) +dayjs.extend(timezone) export default { title: "components/WorkspaceScheduleForm", @@ -9,8 +18,96 @@ export default { const Template: Story = (args) => -export const Example = Template.bind({}) -Example.args = { +export const WorkspaceNotRunning = Template.bind({}) +WorkspaceNotRunning.args = { + now: dayjs("2022-05-17T17:40:00Z"), + initialValues: { + ...defaultWorkspaceSchedule(5, "asdfasdf"), + timezone: "UTC", + }, + workspace: { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + transition: "stop", + updated_at: "2022-05-17T17:39:00Z", + }, + }, + onCancel: () => action("onCancel"), + onSubmit: () => action("onSubmit"), +} + +export const WorkspaceWillNotShutDown = Template.bind({}) +WorkspaceWillNotShutDown.args = { + now: dayjs("2022-05-17T17:40:00Z"), + initialValues: { + ...defaultWorkspaceSchedule(5, "asdfasdf"), + timezone: "UTC", + ttl: 0, + }, + workspace: { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + updated_at: "2022-05-17T17:39:00Z", + }, + }, + onCancel: () => action("onCancel"), + onSubmit: () => action("onSubmit"), +} + +export const WorkspaceWillShutdown = Template.bind({}) +WorkspaceWillShutdown.args = { + now: dayjs("2022-05-17T17:40:00Z"), + initialValues: { + ...defaultWorkspaceSchedule(5, "asdfasdf"), + timezone: "UTC", + }, + workspace: { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + updated_at: "2022-05-17T17:39:00Z", + }, + }, + onCancel: () => action("onCancel"), + onSubmit: () => action("onSubmit"), +} + +export const WorkspaceWillShutdownSoon = Template.bind({}) +WorkspaceWillShutdownSoon.args = { + now: dayjs("2022-05-17T18:10:00Z"), + initialValues: { + ...defaultWorkspaceSchedule(5, "asdfasdf"), + timezone: "UTC", + ttl: 1, + }, + workspace: { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + updated_at: "2022-05-17T17:39:00Z", + }, + }, + onCancel: () => action("onCancel"), + onSubmit: () => action("onSubmit"), +} + +export const WorkspaceWillShutdownImmediately = Template.bind({}) +WorkspaceWillShutdownImmediately.args = { + now: dayjs("2022-05-17T18:40:00Z"), + initialValues: { + ...defaultWorkspaceSchedule(5, "asdfasdf"), + timezone: "UTC", + ttl: 1, + }, + workspace: { + ...Mocks.MockWorkspace, + latest_build: { + ...Mocks.MockWorkspaceBuild, + updated_at: "2022-05-17T17:39:00Z", + }, + }, 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 fae1fa4ff0546..8fa25cc66abd3 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.test.ts +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.test.ts @@ -1,4 +1,7 @@ -import { Language, validationSchema, WorkspaceScheduleFormValues } from "./WorkspaceScheduleForm" +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" const valid: WorkspaceScheduleFormValues = { @@ -155,3 +158,33 @@ describe("validationSchema", () => { expect(validate).toThrowError("ttl must be less than or equal to 168") }) }) + +describe("ttlShutdownAt", () => { + it.each<[dayjs.Dayjs, Workspace, string, number, string]>([ + [dayjs("2022-05-17T18:09:00Z"), Mocks.MockStoppedWorkspace, "America/Chicago", 1, Language.ttlHelperText], + [dayjs("2022-05-17T18:09:00Z"), Mocks.MockWorkspace, "America/Chicago", 0, Language.ttlCausesNoShutdownHelperText], + [ + dayjs("2022-05-17T18:09:00Z"), + Mocks.MockWorkspace, + "America/Chicago", + 1, + `${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownAt} 01:39 PM CDT.`, + ], + [ + dayjs("2022-05-17T18:10:00Z"), + Mocks.MockWorkspace, + "America/Chicago", + 1, + `⚠️ ${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownSoon} ⚠️`, + ], + [ + dayjs("2022-05-17T18:40:00Z"), + Mocks.MockWorkspace, + "America/Chicago", + 1, + `⚠️ ${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownImmediately} ⚠️`, + ], + ])("ttlShutdownAt(%p, %p, %p, %p) returns %p", (now, workspace, timezone, ttlHours, expected) => { + expect(ttlShutdownAt(now, workspace, timezone, ttlHours)).toEqual(expected) + }) +}) diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx index 20a86b58e8718..e5d5376596c94 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx @@ -8,13 +8,16 @@ import MenuItem from "@material-ui/core/MenuItem" 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 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" @@ -23,6 +26,7 @@ import { zones } from "./zones" // REMARK: timezone plugin depends on UTC // // SEE: https://day.js.org/docs/en/timezone/timezone +dayjs.extend(advancedFormat) dayjs.extend(utc) dayjs.extend(timezone) @@ -44,14 +48,21 @@ export const Language = { 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.", + ttlCausesNoShutdownHelperText: "Your workspace will not automatically shut down.", } export interface WorkspaceScheduleFormProps { fieldErrors?: FieldErrors initialValues?: WorkspaceScheduleFormValues isLoading: boolean + now?: dayjs.Dayjs onCancel: () => void onSubmit: (values: WorkspaceScheduleFormValues) => void + workspace: Workspace } export interface WorkspaceScheduleFormValues { @@ -174,8 +185,10 @@ export const WorkspaceScheduleForm: FC = ({ fieldErrors, initialValues = defaultWorkspaceSchedule(), isLoading, + now = dayjs(), onCancel, onSubmit, + workspace, }) => { const styles = useStyles() @@ -255,7 +268,7 @@ export const WorkspaceScheduleForm: FC = ({ = ({ ) } +export const ttlShutdownAt = (now: dayjs.Dayjs, workspace: Workspace, tz: string, newTTL: number): string => { + const newDeadline = dayjs(workspace.latest_build.updated_at).add(newTTL, "hour") + if (!isWorkspaceOn(workspace)) { + return Language.ttlHelperText + } else if (newTTL === 0) { + return Language.ttlCausesNoShutdownHelperText + } else if (newDeadline.isBefore(now)) { + return `⚠️ ${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownImmediately} ⚠️` + } else if (newDeadline.isBefore(now.add(30, "minute"))) { + return `⚠️ ${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownSoon} ⚠️` + } else { + const newDeadlineString = newDeadline.tz(tz).format("hh:mm A z") + return `${Language.ttlCausesShutdownHelperText} ${Language.ttlCausesShutdownAt} ${newDeadlineString}.` + } +} + const useStyles = makeStyles({ form: { "& input": { diff --git a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx index dbed18ea91927..df5f511e5bfe4 100644 --- a/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx +++ b/site/src/pages/WorkspaceSchedulePage/WorkspaceSchedulePage.tsx @@ -161,6 +161,7 @@ export const WorkspaceSchedulePage: React.FC = () => { } else if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) { return (