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 ( pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy