fix: coderd: decouple ttl and deadline (#2282)

This commit makes the following changes:

- Partially reverts the changes of feat: update workspace deadline when workspace ttl updated #2165, making the deadline of a running workspace build independant of TTL, once started.
- CLI: updating a workspace TTL no longer updates the deadline of the workspace.
- UI: updating a workspace TTL no longer updates the deadline of the workspace.
- Drive-by: API: When creating a workspace, default TTL to min(12 hours, template max_ttl) if not instructed otherwise.
- Drive-by: CLI: list: measure workspace extension correctly (+X in last column) from the time the provisioner job was completed
- Drive-by: WorkspaceSchedule: show timezone of schedule if it is set, defaulting to dayjs guess otherwise.
- Drive-by: WorkspaceScheduleForm: fixed an issue where deleting the "TTL" value in the form would show the text "Your workspace will shut down a few seconds after start".
This commit is contained in:
Cian Johnston 2022-06-14 17:09:24 +01:00 committed by GitHub
parent 251316751e
commit c28b7ecdf2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 155 additions and 339 deletions

View File

@ -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

6
cli/constants.go Normal file
View File

@ -0,0 +1,6 @@
package cli
const (
timeFormat = "3:04:05 PM MST"
dateFormat = "Jan 2, 2006"
)

View File

@ -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
}

View File

@ -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
}

View File

@ -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")

View File

@ -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)

View File

@ -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) {

View File

@ -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(r, rbac.ActionRead, workspace) {
@ -291,8 +293,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{
@ -513,30 +515,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,
@ -544,44 +538,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
}
@ -1028,3 +996,10 @@ func splitQueryParameterByDelimiter(query string, delimiter rune, maintainQuotes
return parts
}
func min(x, y int64) int64 {
if x < y {
return x
}
return y
}

View File

@ -867,23 +867,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",
@ -891,16 +888,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",
@ -953,9 +948,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")
}
})
}

View File

@ -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<WorkspaceScheduleProps> = ({ workspace }) =>
<Stack spacing={2}>
<Typography variant="body1" className={styles.title}>
<ScheduleIcon className={styles.scheduleIcon} />
{Language.schedule}
{Language.scheduleHeader(workspace)}
</Typography>
<div>
<span className={styles.scheduleLabel}>{Language.autoStartLabel}</span>

View File

@ -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<WorkspaceScheduleFormProps> = (args) => <WorkspaceScheduleForm {...args} />
export const WorkspaceNotRunning = Template.bind({})
WorkspaceNotRunning.args = {
now: dayjs("2022-05-17T17:40:00Z"),
initialValues: {
...defaultWorkspaceSchedule(5),
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),
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"),
export const WorkspaceWillShutdownInAnHour = Template.bind({})
WorkspaceWillShutdownInAnHour.args = {
initialValues: {
...defaultWorkspaceSchedule(5),
timezone: "UTC",
},
workspace: {
...Mocks.MockWorkspace,
latest_build: {
...Mocks.MockWorkspaceBuild,
updated_at: "2022-05-17T17:39:00Z",
},
ttl: 1,
},
onCancel: () => action("onCancel"),
onSubmit: () => action("onSubmit"),
}
export const WorkspaceWillShutdownSoon = Template.bind({})
WorkspaceWillShutdownSoon.args = {
now: dayjs("2022-05-17T16:39:00Z"),
export const WorkspaceWillShutdownInTwoHours = Template.bind({})
WorkspaceWillShutdownInTwoHours.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: 2,
},
onCancel: () => action("onCancel"),
onSubmit: () => action("onSubmit"),
}
export const WorkspaceWillShutdownImmediately = Template.bind({})
WorkspaceWillShutdownImmediately.args = {
now: dayjs("2022-05-17T17:09:00Z"),
export const WorkspaceWillShutdownInADay = Template.bind({})
WorkspaceWillShutdownInADay.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: 24,
},
onCancel: () => action("onCancel"),
onSubmit: () => action("onSubmit"),
}
export const WorkspaceWillShutdownInTwoDays = Template.bind({})
WorkspaceWillShutdownInTwoDays.args = {
initialValues: {
...defaultWorkspaceSchedule(2),
ttl: 48,
},
onCancel: () => action("onCancel"),
onSubmit: () => action("onSubmit"),

View File

@ -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} ⚠️`,
"48 hours --> helper text shows shutdown after 2 days",
48,
`${Language.ttlCausesShutdownHelperText} 2 days ${Language.ttlCausesShutdownAfterStart}.`,
],
[
"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.`,
],
])("%p", (_, now, workspace, timezone, ttlHours, expected) => {
expect(ttlShutdownAt(now, workspace, timezone, ttlHours)).toEqual(expected)
])("%p", (_, ttlHours, expected) => {
expect(ttlShutdownAt(ttlHours)).toEqual(expected)
})
})

View File

@ -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<WorkspaceScheduleFormProps> = ({
fieldErrors,
initialValues = defaultWorkspaceSchedule(),
isLoading,
now = dayjs(),
onCancel,
onSubmit,
workspace,
}) => {
const styles = useStyles()
@ -269,7 +262,7 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
</FormControl>
<TextField
{...formHelpers("ttl", ttlShutdownAt(now, workspace, form.values.timezone, form.values.ttl))}
{...formHelpers("ttl", ttlShutdownAt(form.values.ttl))}
disabled={isLoading}
inputProps={{ min: 0, step: 1 }}
label={Language.ttlLabel}
@ -283,29 +276,14 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
)
}
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
}.`
}
}

View File

@ -161,7 +161,6 @@ export const WorkspaceSchedulePage: React.FC = () => {
} else if (scheduleState.matches("presentForm") || scheduleState.matches("submittingSchedule")) {
return (
<WorkspaceScheduleForm
workspace={workspace}
fieldErrors={formErrors}
initialValues={workspaceToInitialValues(workspace, dayjs.tz.guess())}
isLoading={scheduleState.tags.has("loading")}