From 119db78bff311d7f23890ec2a9deae15c9318858 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Thu, 9 Jun 2022 22:10:24 +0100 Subject: [PATCH] feat: update workspace deadline when workspace ttl updated (#2165) This commit adds the following changes to workspace scheduling behaviour: * CLI: updating a workspace TTL updates the deadline of the workspace. * If the TTL is being un-set, the workspace deadline is set to zero. * If the TTL is being set, the workspace deadline is updated to be the last updated time of the workspace build plus the requested TTL. Additionally, the user is prompted to confirm interactively (can be bypassed with -y). * UI: updating the workspace schedule behaves similarly to the CLI, showing a message to the user if the updated TTL/time to shutdown would effect changes to the lifetime of the running workspace. --- cli/ttl.go | 41 +++++++ cli/ttl_test.go | 81 +++++++++----- .../executor/lifecycle_executor_test.go | 33 +++++- coderd/workspaces.go | 48 +++++++- coderd/workspaces_test.go | 43 +++++--- .../WorkspaceScheduleForm.stories.tsx | 103 +++++++++++++++++- .../WorkspaceScheduleForm.test.ts | 35 +++++- .../WorkspaceScheduleForm.tsx | 31 +++++- .../WorkspaceSchedulePage.tsx | 1 + 9 files changed, 360 insertions(+), 56 deletions(-) diff --git a/cli/ttl.go b/cli/ttl.go index cce6ef84a9..9bc19dc033 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 00a0f29fd3..92ca201c81 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 unset + 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") + }) + + t.Run("SetOK", func(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) + 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) 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") - // unset schedule - cmd, root = clitest.New(t, "ttl", "unset", workspace.Name) - clitest.SetupConfig(t, client, root) - cmd.SetOut(stdoutBuf) - - err = cmd.Execute() - require.NoError(t, err, "unexpected error") - - // Ensure ttl updated - 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") + <-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 5b1045aea2..be435b7730 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) + }() + + // 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 still stop - sorry! - stats := <-statsCh + // 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 255685a059..99c50e2bf8 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 73fed89c80..b14a814975 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 27d62f2482..c9ea6eafa8 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 fae1fa4ff0..8fa25cc66a 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 20a86b58e8..e5d5376596 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 dbed18ea91..df5f511e5b 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 (