feat: add experimental template autostop requirement template settings UI (#9417)

This commit is contained in:
Dean Sheather 2023-08-30 13:41:27 -07:00 committed by GitHub
parent d2462e5b88
commit 1de61246a3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 568 additions and 126 deletions

View File

@ -317,7 +317,7 @@ func TestScheduleOverride(t *testing.T) {
)
require.Zero(t, template.DefaultTTLMillis)
require.Empty(t, template.AutostopRequirement.DaysOfWeek)
require.Zero(t, template.AutostopRequirement.Weeks)
require.EqualValues(t, 1, template.AutostopRequirement.Weeks)
// Unset the workspace TTL
err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil})

View File

@ -1,7 +1,25 @@
BEGIN;
DROP VIEW template_with_users;
ALTER TABLE templates RENAME COLUMN autostop_requirement_days_of_week TO restart_requirement_days_of_week;
ALTER TABLE templates RENAME COLUMN autostop_requirement_weeks TO restart_requirement_weeks;
CREATE VIEW
template_with_users
AS
SELECT
templates.*,
coalesce(visible_users.avatar_url, '') AS created_by_avatar_url,
coalesce(visible_users.username, '') AS created_by_username
FROM
templates
LEFT JOIN
visible_users
ON
templates.created_by = visible_users.id;
COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.';
COMMIT;

View File

@ -1,11 +1,11 @@
BEGIN;
DROP VIEW template_with_users;
ALTER TABLE templates RENAME COLUMN restart_requirement_days_of_week TO autostop_requirement_days_of_week;
ALTER TABLE templates RENAME COLUMN restart_requirement_weeks TO autostop_requirement_weeks;
DROP VIEW template_with_users;
CREATE VIEW
template_with_users
AS

View File

@ -72,8 +72,8 @@ func VerifyTemplateAutostopRequirement(days uint8, weeks int64) error {
if days > 0b11111111 {
return xerrors.New("invalid autostop requirement days, too large")
}
if weeks < 0 {
return xerrors.New("invalid autostop requirement weeks, negative")
if weeks < 1 {
return xerrors.New("invalid autostop requirement weeks, less than 1")
}
if weeks > MaxTemplateAutostopRequirementWeeks {
return xerrors.New("invalid autostop requirement weeks, too large")
@ -154,8 +154,10 @@ func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, te
UseAutostopRequirement: false,
MaxTTL: 0,
AutostopRequirement: TemplateAutostopRequirement{
// No days means never. The weeks value should always be greater
// than zero though.
DaysOfWeek: 0,
Weeks: 0,
Weeks: 1,
},
FailureTTL: 0,
TimeTilDormant: 0,

View File

@ -527,6 +527,12 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
if req.AutostopRequirement.Weeks < 0 {
validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: "Must be a positive integer."})
}
if req.AutostopRequirement.Weeks == 0 {
req.AutostopRequirement.Weeks = 1
}
if template.AutostopRequirementWeeks <= 0 {
template.AutostopRequirementWeeks = 1
}
if req.AutostopRequirement.Weeks > schedule.MaxTemplateAutostopRequirementWeeks {
validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateAutostopRequirementWeeks)})
}
@ -737,6 +743,11 @@ func (api *API) convertTemplate(
buildTimeStats := api.metricsCache.TemplateBuildTimeStats(template.ID)
autostopRequirementWeeks := template.AutostopRequirementWeeks
if autostopRequirementWeeks < 1 {
autostopRequirementWeeks = 1
}
return codersdk.Template{
ID: template.ID,
CreatedAt: template.CreatedAt,
@ -762,7 +773,7 @@ func (api *API) convertTemplate(
TimeTilDormantAutoDeleteMillis: time.Duration(template.TimeTilDormantAutoDelete).Milliseconds(),
AutostopRequirement: codersdk.TemplateAutostopRequirement{
DaysOfWeek: codersdk.BitmapToWeekdays(uint8(template.AutostopRequirementDaysOfWeek)),
Weeks: template.AutostopRequirementWeeks,
Weeks: autostopRequirementWeeks,
},
}
}

View File

@ -296,7 +296,7 @@ func TestPostTemplateByOrganization(t *testing.T) {
require.EqualValues(t, 1, atomic.LoadInt64(&setCalled))
require.Empty(t, got.AutostopRequirement.DaysOfWeek)
require.Zero(t, got.AutostopRequirement.Weeks)
require.EqualValues(t, 1, got.AutostopRequirement.Weeks)
})
t.Run("OK", func(t *testing.T) {
@ -379,7 +379,7 @@ func TestPostTemplateByOrganization(t *testing.T) {
require.NoError(t, err)
// ignored and use AGPL defaults
require.Empty(t, got.AutostopRequirement.DaysOfWeek)
require.Zero(t, got.AutostopRequirement.Weeks)
require.EqualValues(t, 1, got.AutostopRequirement.Weeks)
})
})
}
@ -1006,7 +1006,7 @@ func TestPatchTemplateMeta(t *testing.T) {
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
require.EqualValues(t, 1, atomic.LoadInt64(&setCalled))
require.Empty(t, template.AutostopRequirement.DaysOfWeek)
require.Zero(t, template.AutostopRequirement.Weeks)
require.EqualValues(t, 1, template.AutostopRequirement.Weeks)
req := codersdk.UpdateTemplateMeta{
Name: template.Name,
DisplayName: template.DisplayName,
@ -1045,7 +1045,7 @@ func TestPatchTemplateMeta(t *testing.T) {
SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
if atomic.AddInt64(&setCalled, 1) == 2 {
assert.EqualValues(t, 0, options.AutostopRequirement.DaysOfWeek)
assert.EqualValues(t, 0, options.AutostopRequirement.Weeks)
assert.EqualValues(t, 1, options.AutostopRequirement.Weeks)
}
err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{
@ -1102,12 +1102,12 @@ func TestPatchTemplateMeta(t *testing.T) {
require.NoError(t, err)
require.EqualValues(t, 2, atomic.LoadInt64(&setCalled))
require.Empty(t, updated.AutostopRequirement.DaysOfWeek)
require.EqualValues(t, 0, updated.AutostopRequirement.Weeks)
require.EqualValues(t, 1, updated.AutostopRequirement.Weeks)
template, err = client.Template(ctx, template.ID)
require.NoError(t, err)
require.Empty(t, template.AutostopRequirement.DaysOfWeek)
require.EqualValues(t, 0, template.AutostopRequirement.Weeks)
require.EqualValues(t, 1, template.AutostopRequirement.Weeks)
})
t.Run("EnterpriseOnly", func(t *testing.T) {
@ -1118,7 +1118,7 @@ func TestPatchTemplateMeta(t *testing.T) {
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
require.Empty(t, template.AutostopRequirement.DaysOfWeek)
require.Zero(t, template.AutostopRequirement.Weeks)
require.EqualValues(t, 1, template.AutostopRequirement.Weeks)
req := codersdk.UpdateTemplateMeta{
Name: template.Name,
DisplayName: template.DisplayName,
@ -1138,12 +1138,12 @@ func TestPatchTemplateMeta(t *testing.T) {
updated, err := client.UpdateTemplateMeta(ctx, template.ID, req)
require.NoError(t, err)
require.Empty(t, updated.AutostopRequirement.DaysOfWeek)
require.Zero(t, updated.AutostopRequirement.Weeks)
require.EqualValues(t, 1, updated.AutostopRequirement.Weeks)
template, err = client.Template(ctx, template.ID)
require.NoError(t, err)
require.Empty(t, template.AutostopRequirement.DaysOfWeek)
require.Zero(t, template.AutostopRequirement.Weeks)
require.EqualValues(t, 1, template.AutostopRequirement.Weeks)
})
})
}

View File

@ -83,7 +83,7 @@ func WeekdaysToBitmap(days []string) (uint8, error) {
// BitmapToWeekdays converts a bitmap to a list of weekdays in accordance with
// the schedule package's rules (see above).
func BitmapToWeekdays(bitmap uint8) []string {
var days []string
days := []string{}
for i := 0; i < 7; i++ {
if bitmap&(1<<i) != 0 {
switch i {

View File

@ -68,6 +68,9 @@ func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.S
if tpl.AutostopRequirementDaysOfWeek > 0b11111111 {
return agpl.TemplateScheduleOptions{}, xerrors.New("invalid autostop requirement days, too large")
}
if tpl.AutostopRequirementWeeks == 0 {
tpl.AutostopRequirementWeeks = 1
}
err = agpl.VerifyTemplateAutostopRequirement(uint8(tpl.AutostopRequirementDaysOfWeek), tpl.AutostopRequirementWeeks)
if err != nil {
return agpl.TemplateScheduleOptions{}, err
@ -94,6 +97,13 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
ctx, span := tracing.StartSpan(ctx)
defer span.End()
if opts.AutostopRequirement.Weeks <= 0 {
opts.AutostopRequirement.Weeks = 1
}
if tpl.AutostopRequirementWeeks <= 0 {
tpl.AutostopRequirementWeeks = 1
}
if int64(opts.DefaultTTL) == tpl.DefaultTTL &&
int64(opts.MaxTTL) == tpl.MaxTTL &&
int16(opts.AutostopRequirement.DaysOfWeek) == tpl.AutostopRequirementDaysOfWeek &&

View File

@ -158,7 +158,7 @@ func TestTemplates(t *testing.T) {
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
require.Empty(t, 0, template.AutostopRequirement.DaysOfWeek)
require.Zero(t, template.AutostopRequirement.Weeks)
require.EqualValues(t, 1, template.AutostopRequirement.Weeks)
// ctx := testutil.Context(t, testutil.WaitLong)
ctx := context.Background()

View File

@ -351,7 +351,7 @@ export const WorkspaceScheduleForm: FC<
<FormSection
title="Autostop"
description="Set how many hours should elapse after a workspace is started before it automatically shuts down. If workspace connection activity is detected, the autostop timer will be bumped up one hour."
description="Set how many hours should elapse after a workspace is started before it automatically shuts down. If workspace connection activity is detected, the autostop timer will be bumped by this value."
>
<FormFields>
<FormControlLabel

View File

@ -28,12 +28,18 @@
"icon": "Icon",
"autostop": "Default autostop (hours)",
"maxTTL": "Max lifetime (hours)",
"allowUsersToCancel": "Allow users to cancel in-progress workspace jobs"
"allowUsersToCancel": "Allow users to cancel in-progress workspace jobs",
"autostopRequirementDays": "Days with required stop",
"autostopRequirementDays_off": "Off",
"autostopRequirementDays_daily": "Daily",
"autostopRequirementDays_saturday": "Saturday",
"autostopRequirementDays_sunday": "Sunday",
"autostopRequirementWeeks": "Weeks between required stops"
},
"helperText": {
"defaultTTLHelperText_zero": "Workspaces will run until stopped manually.",
"defaultTTLHelperText_one": "Workspaces will default to stopping after {{count}} hour. If Coder detects workspace connection activity, the autostop timer is bumped up one hour.",
"defaultTTLHelperText_other": "Workspaces will default to stopping after {{count}} hours. If Coder detects workspace connection activity, the autostop timer is bumped up one hour.",
"defaultTTLHelperText_one": "Workspaces will default to stopping after {{count}} hour. If Coder detects workspace connection activity, the autostop timer is bumped by this value.",
"defaultTTLHelperText_other": "Workspaces will default to stopping after {{count}} hours. If Coder detects workspace connection activity, the autostop timer is bumped by this value.",
"maxTTLHelperText_zero": "Workspaces may run indefinitely.",
"maxTTLHelperText_one": "Workspaces must stop within 1 hour of starting, regardless of any active connections.",
"maxTTLHelperText_other": "Workspaces must stop within {{count}} hours of starting, regardless of any active connections.",

View File

@ -6,19 +6,32 @@
"descriptionMaxError": "Please enter a description that is less than or equal to 128 characters.",
"defaultTtlLabel": "Default autostop (hours)",
"maxTtlLabel": "Max lifetime (hours)",
"autostopRequirementDaysLabel": "Days with required stop",
"autostopRequirementWeeksLabel": "Weeks between required stops",
"iconLabel": "Icon",
"formAriaLabel": "Template settings form",
"selectEmoji": "Select emoji",
"defaultTTLMaxError": "Please enter a limit that is less than or equal to 720 hours (30 days).",
"defaultTTLMinError": "Default time until autostop must not be less than 0.",
"defaultTTLHelperText_zero": "Workspaces will run until stopped manually.",
"defaultTTLHelperText_one": "Workspaces will default to stopping after {{count}} hour. If Coder detects workspace connection activity, the autostop timer is bumped up one hour.",
"defaultTTLHelperText_other": "Workspaces will default to stopping after {{count}} hours. If Coder detects workspace connection activity, the autostop timer is bumped up one hour.",
"defaultTTLHelperText_one": "Workspaces will default to stopping after {{count}} hour. If Coder detects workspace connection activity, the autostop timer is bumped by this value.",
"defaultTTLHelperText_other": "Workspaces will default to stopping after {{count}} hours. If Coder detects workspace connection activity, the autostop timer is bumped by this value.",
"maxTTLMaxError": "Please enter a limit that is less than or equal to 720 hours (30 days).",
"maxTTLMinError": "Maximum time until autostop must not be less than 0.",
"maxTTLHelperText_zero": "Workspaces may run indefinitely.",
"maxTTLHelperText_one": "Workspaces must stop within 1 hour of starting, regardless of any active connections.",
"maxTTLHelperText_other": "Workspaces must stop within {{count}} hours of starting, regardless of any active connections.",
"autostopRequirementDays_off": "Off",
"autostopRequirementDays_daily": "Daily",
"autostopRequirementDays_saturday": "Saturday",
"autostopRequirementDays_sunday": "Sunday",
"autostopRequirementDaysHelperText_off": "Workspaces are not required to stop periodically.",
"autostopRequirementDaysHelperText_daily": "Workspaces are required to be automatically stopped daily in the user's quiet hours and timezone.",
"autostopRequirementDaysHelperText_saturday": "Workspaces are required to be automatically stopped every Saturday in the user's quiet hours and timezone.",
"autostopRequirementDaysHelperText_sunday": "Workspaces are required to be automatically stopped every Sunday in the user's quiet hours and timezone.",
"autostopRequirementWeeksHelperText_disabled": "Weeks between required stops cannot be set unless days between required stops is Saturday or Sunday.",
"autostopRequirementWeeksHelperText_one": "Workspaces are required to be automatically stopped every week on the specified day in the user's quiet hours and timezone.",
"autostopRequirementWeeksHelperText_other": "Workspaces are required to be automatically stopped every {{count}} weeks on the specified day in the user's quiet hours and timezone.",
"failureTTLHelperText_zero": "Coder will not automatically stop failed workspaces",
"failureTTLHelperText_one": "Coder will attempt to stop failed workspaces after {{count}} day.",
"failureTTLHelperText_other": "Coder will attempt to stop failed workspaces after {{count}} days.",
@ -43,6 +56,10 @@
"title": "Schedule",
"description": "Define when workspaces created from this template are stopped."
},
"autostopRequirement": {
"title": "Autostop Requirement",
"description": "Define when workspaces created from this template are stopped periodically to enforce template updates and ensure idle workspaces are stopped."
},
"operations": {
"title": "Operations",
"description": "Regulate actions allowed on workspaces created from this template."

View File

@ -40,6 +40,11 @@ import camelCase from "lodash/camelCase"
import capitalize from "lodash/capitalize"
import { VariableInput } from "./VariableInput"
import { docs } from "utils/docs"
import {
AutostopRequirementDaysHelperText,
AutostopRequirementWeeksHelperText,
} from "pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/AutostopRequirementHelperText"
import MenuItem from "@mui/material/MenuItem"
const MAX_DESCRIPTION_CHAR_LIMIT = 128
const MAX_TTL_DAYS = 30
@ -89,6 +94,8 @@ const validationSchema = Yup.object({
24 * MAX_TTL_DAYS /* 30 days in hours */,
"Please enter a limit that is less than or equal to 720 hours (30 days).",
),
autostop_requirement_days_of_week: Yup.string().required(),
autostop_requirement_weeks: Yup.number().required().min(1).max(16),
})
const defaultInitialValues: CreateTemplateData = {
@ -103,6 +110,14 @@ const defaultInitialValues: CreateTemplateData = {
// The maximum value is 30 days but we default to 7 days as it's a much more
// sensible value for most teams.
max_ttl_hours: 24 * 7,
// autostop_requirement is an enterprise-only feature, and the server ignores
// the value if you are not licensed. We hide the form value based on
// entitlements.
//
// Default to requiring restart every Sunday in the user's quiet hours in the
// user's timezone.
autostop_requirement_days_of_week: "sunday",
autostop_requirement_weeks: 1,
allow_user_cancel_workspace_jobs: false,
allow_user_autostart: false,
allow_user_autostop: false,
@ -180,6 +195,7 @@ export interface CreateTemplateFormProps {
allowAdvancedScheduling: boolean
copiedTemplate?: Template
allowDisableEveryoneAccess: boolean
allowAutostopRequirement: boolean
}
export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
@ -195,6 +211,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
logs,
allowAdvancedScheduling,
allowDisableEveryoneAccess,
allowAutostopRequirement,
}) => {
const styles = useStyles()
const form = useFormik<CreateTemplateData>({
@ -223,6 +240,25 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
}
}, [logs, jobError])
// Set autostop_requirement weeks to 1 when days_of_week is set to "off" or
// "daily". Technically you can set weeks to a different value in the backend
// and it will work, but this is a UX decision so users don't set days=daily
// and weeks=2 and get confused when workspaces only restart daily during
// every second week.
//
// We want to set the value to 1 when the user selects "off" or "daily"
// because the input gets disabled so they can't change it to 1 themselves.
const {
values: { autostop_requirement_days_of_week },
setFieldValue,
} = form
useEffect(() => {
if (!["saturday", "sunday"].includes(autostop_requirement_days_of_week)) {
// This is async but we don't really need to await the value.
void setFieldValue("autostop_requirement_weeks", 1)
}
}, [autostop_requirement_days_of_week, setFieldValue])
return (
<HorizontalForm onSubmit={form.handleSubmit}>
{/* General info */}
@ -312,30 +348,84 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = ({
type="number"
/>
<TextField
{...getFieldHelpers(
"max_ttl_hours",
allowAdvancedScheduling ? (
<TTLHelperText
translationName="form.helperText.maxTTLHelperText"
ttl={form.values.max_ttl_hours}
/>
) : (
<>
{commonT("licenseFieldTextHelper")}{" "}
<Link href={docs("/enterprise")}>
{commonT("learnMore")}
</Link>
.
</>
),
)}
disabled={isSubmitting || !allowAdvancedScheduling}
fullWidth
label={t("form.fields.maxTTL")}
type="number"
/>
{!allowAutostopRequirement && (
<TextField
{...getFieldHelpers(
"max_ttl_hours",
allowAdvancedScheduling ? (
<TTLHelperText
translationName="form.helperText.maxTTLHelperText"
ttl={form.values.max_ttl_hours}
/>
) : (
<>
{commonT("licenseFieldTextHelper")}{" "}
<Link href={docs("/enterprise")}>
{commonT("learnMore")}
</Link>
.
</>
),
)}
disabled={isSubmitting || !allowAdvancedScheduling}
fullWidth
label={t("form.fields.maxTTL")}
type="number"
/>
)}
</Stack>
{allowAutostopRequirement && (
<Stack direction="row" className={styles.ttlFields}>
<TextField
{...getFieldHelpers(
"autostop_requirement_days_of_week",
<AutostopRequirementDaysHelperText
days={form.values.autostop_requirement_days_of_week}
/>,
)}
disabled={isSubmitting}
fullWidth
select
value={form.values.autostop_requirement_days_of_week}
label={t("form.fields.autostopRequirementDays")}
>
<MenuItem key="off" value="off">
{t("form.fields.autostopRequirementDays_off")}
</MenuItem>
<MenuItem key="daily" value="daily">
{t("form.fields.autostopRequirementDays_daily")}
</MenuItem>
<MenuItem key="saturday" value="saturday">
{t("form.fields.autostopRequirementDays_saturday")}
</MenuItem>
<MenuItem key="sunday" value="sunday">
{t("form.fields.autostopRequirementDays_sunday")}
</MenuItem>
</TextField>
<TextField
{...getFieldHelpers(
"autostop_requirement_weeks",
<AutostopRequirementWeeksHelperText
days={form.values.autostop_requirement_days_of_week}
weeks={form.values.autostop_requirement_weeks}
/>,
)}
disabled={
isSubmitting ||
!["saturday", "sunday"].includes(
form.values.autostop_requirement_days_of_week || "",
)
}
fullWidth
inputProps={{ min: 1, max: 16, step: 1 }}
label={t("form.fields.autostopRequirementWeeks")}
type="number"
/>
</Stack>
)}
<Stack direction="column">
<Stack direction="row" alignItems="center">
<Checkbox

View File

@ -36,13 +36,16 @@ const CreateTemplatePage: FC = () => {
const { starterTemplate, error, file, jobError, jobLogs, variables } =
state.context
const shouldDisplayForm = !state.hasTag("loading")
const { entitlements } = useDashboard()
const { entitlements, experiments } = useDashboard()
const allowAdvancedScheduling =
entitlements.features["advanced_template_scheduling"].enabled
// Requires the template RBAC feature, otherwise disabling everyone access
// means no one can access.
const allowDisableEveryoneAccess =
entitlements.features["template_rbac"].enabled
const allowAutostopRequirement = experiments.includes(
"template_autostop_requirement",
)
const onCancel = () => {
navigate(-1)
@ -69,6 +72,7 @@ const CreateTemplatePage: FC = () => {
copiedTemplate={state.context.copiedTemplate}
allowAdvancedScheduling={allowAdvancedScheduling}
allowDisableEveryoneAccess={allowDisableEveryoneAccess}
allowAutostopRequirement={allowAutostopRequirement}
error={error}
starterTemplate={starterTemplate}
isSubmitting={state.hasTag("submitting")}

View File

@ -0,0 +1,86 @@
import { Template } from "api/typesGenerated"
import { useTranslation } from "react-i18next"
export type TemplateAutostopRequirementDaysValue =
| "off"
| "daily"
| "saturday"
| "sunday"
export const convertAutostopRequirementDaysValue = (
days: Template["autostop_requirement"]["days_of_week"],
): TemplateAutostopRequirementDaysValue => {
if (days.length === 7) {
return "daily"
} else if (days.length === 1 && days[0] === "saturday") {
return "saturday"
} else if (days.length === 1 && days[0] === "sunday") {
return "sunday"
}
// On unsupported values we default to "off".
return "off"
}
export const calculateAutostopRequirementDaysValue = (
value: TemplateAutostopRequirementDaysValue,
): Template["autostop_requirement"]["days_of_week"] => {
switch (value) {
case "daily":
return [
"monday",
"tuesday",
"wednesday",
"thursday",
"friday",
"saturday",
"sunday",
]
case "saturday":
return ["saturday"]
case "sunday":
return ["sunday"]
}
return []
}
export const AutostopRequirementDaysHelperText = ({
days,
}: {
days: TemplateAutostopRequirementDaysValue
}) => {
const { t } = useTranslation("templateSettingsPage")
let str = "off"
if (days) {
str = days
}
return <span>{t("autostopRequirementDaysHelperText_" + str)}</span>
}
export const AutostopRequirementWeeksHelperText = ({
days,
weeks,
}: {
days: TemplateAutostopRequirementDaysValue
weeks: number
}) => {
const { t } = useTranslation("templateSettingsPage")
let str = "disabled"
if (days === "saturday" || days === "sunday") {
if (weeks === 0 || weeks === 1) {
str = "one"
} else {
str = "other"
}
}
return (
<span>
{t("autostopRequirementWeeksHelperText_" + str, { count: weeks })}
</span>
)
}

View File

@ -1,7 +1,7 @@
import TextField from "@mui/material/TextField"
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
import { FormikTouched, useFormik } from "formik"
import { FC, ChangeEvent, useState } from "react"
import { FC, ChangeEvent, useState, useEffect } from "react"
import { getFormHelpers } from "utils/formUtils"
import { useTranslation } from "react-i18next"
import {
@ -24,6 +24,13 @@ import { TemplateScheduleFormValues, getValidationSchema } from "./formHelpers"
import { TTLHelperText } from "./TTLHelperText"
import { docs } from "utils/docs"
import { ScheduleDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog"
import MenuItem from "@mui/material/MenuItem"
import {
AutostopRequirementDaysHelperText,
AutostopRequirementWeeksHelperText,
calculateAutostopRequirementDaysValue,
convertAutostopRequirementDaysValue,
} from "./AutostopRequirementHelperText"
const MS_HOUR_CONVERSION = 3600000
const MS_DAY_CONVERSION = 86400000
@ -39,6 +46,7 @@ export interface TemplateScheduleForm {
error?: unknown
allowAdvancedScheduling: boolean
allowWorkspaceActions: boolean
allowAutostopRequirement: boolean
// Helpful to show field errors on Storybook
initialTouched?: FormikTouched<UpdateTemplateMeta>
}
@ -50,6 +58,7 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
error,
allowAdvancedScheduling,
allowWorkspaceActions,
allowAutostopRequirement,
isSubmitting,
initialTouched,
}) => {
@ -74,10 +83,16 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
? template.time_til_dormant_autodelete_ms / MS_DAY_CONVERSION
: 0,
autostop_requirement: {
days_of_week: template.autostop_requirement.days_of_week,
weeks: template.autostop_requirement.weeks,
},
autostop_requirement_days_of_week: allowAutostopRequirement
? convertAutostopRequirementDaysValue(
template.autostop_requirement.days_of_week,
)
: "off",
autostop_requirement_weeks: allowAutostopRequirement
? template.autostop_requirement.weeks > 0
? template.autostop_requirement.weeks
: 1
: 1,
allow_user_autostart: template.allow_user_autostart,
allow_user_autostop: template.allow_user_autostop,
@ -120,6 +135,7 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
},
initialTouched,
})
const getFieldHelpers = getFormHelpers<TemplateScheduleFormValues>(
form,
error,
@ -167,6 +183,12 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
useState<boolean>(false)
const submitValues = () => {
const autostop_requirement_weeks = ["saturday", "sunday"].includes(
form.values.autostop_requirement_days_of_week,
)
? form.values.autostop_requirement_weeks
: 1
// on submit, convert from hours => ms
onSubmit({
default_ttl_ms: form.values.default_ttl_ms
@ -185,6 +207,13 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
? form.values.time_til_dormant_autodelete_ms * MS_DAY_CONVERSION
: undefined,
autostop_requirement: {
days_of_week: calculateAutostopRequirementDaysValue(
form.values.autostop_requirement_days_of_week,
),
weeks: autostop_requirement_weeks,
},
allow_user_autostart: form.values.allow_user_autostart,
allow_user_autostop: form.values.allow_user_autostop,
update_workspace_last_used_at: form.values.update_workspace_last_used_at,
@ -192,6 +221,30 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
})
}
// Set autostop_requirement weeks to 1 when days_of_week is set to "off" or
// "daily". Technically you can set weeks to a different value in the backend
// and it will work, but this is a UX decision so users don't set days=daily
// and weeks=2 and get confused when workspaces only restart daily during
// every second week.
//
// We want to set the value to 1 when the user selects "off" or "daily"
// because the input gets disabled so they can't change it to 1 themselves.
const { values: currentValues, setValues } = form
useEffect(() => {
if (
!["saturday", "sunday"].includes(
currentValues.autostop_requirement_days_of_week,
) &&
currentValues.autostop_requirement_weeks !== 1
) {
// This is async but we don't really need to await the value.
void setValues({
...currentValues,
autostop_requirement_weeks: 1,
})
}
}, [currentValues, setValues])
const handleToggleFailureCleanup = async (e: ChangeEvent) => {
form.handleChange(e)
if (!form.values.failure_cleanup_enabled) {
@ -274,31 +327,91 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
type="number"
/>
<TextField
{...getFieldHelpers(
"max_ttl_ms",
allowAdvancedScheduling ? (
<TTLHelperText
translationName="maxTTLHelperText"
ttl={form.values.max_ttl_ms}
/>
) : (
<>
{commonT("licenseFieldTextHelper")}{" "}
<Link href={docs("/enterprise")}>{commonT("learnMore")}</Link>
.
</>
),
)}
disabled={isSubmitting || !allowAdvancedScheduling}
fullWidth
inputProps={{ min: 0, step: 1 }}
label={t("maxTtlLabel")}
type="number"
/>
{!allowAutostopRequirement && (
<TextField
{...getFieldHelpers(
"max_ttl_ms",
allowAdvancedScheduling ? (
<TTLHelperText
translationName="maxTTLHelperText"
ttl={form.values.max_ttl_ms}
/>
) : (
<>
{commonT("licenseFieldTextHelper")}{" "}
<Link href={docs("/enterprise")}>
{commonT("learnMore")}
</Link>
.
</>
),
)}
disabled={isSubmitting || !allowAdvancedScheduling}
fullWidth
inputProps={{ min: 0, step: 1 }}
label={t("maxTtlLabel")}
type="number"
/>
)}
</Stack>
</FormSection>
{allowAutostopRequirement && (
<FormSection
title={t("autostopRequirement.title").toString()}
description={t("autostopRequirement.description").toString()}
>
<Stack direction="row" className={styles.ttlFields}>
<TextField
{...getFieldHelpers(
"autostop_requirement_days_of_week",
<AutostopRequirementDaysHelperText
days={form.values.autostop_requirement_days_of_week}
/>,
)}
disabled={isSubmitting}
fullWidth
select
value={form.values.autostop_requirement_days_of_week}
label={t("autostopRequirementDaysLabel")}
>
<MenuItem key="off" value="off">
{t("autostopRequirementDays_off")}
</MenuItem>
<MenuItem key="daily" value="daily">
{t("autostopRequirementDays_daily")}
</MenuItem>
<MenuItem key="saturday" value="saturday">
{t("autostopRequirementDays_saturday")}
</MenuItem>
<MenuItem key="sunday" value="sunday">
{t("autostopRequirementDays_sunday")}
</MenuItem>
</TextField>
<TextField
{...getFieldHelpers(
"autostop_requirement_weeks",
<AutostopRequirementWeeksHelperText
days={form.values.autostop_requirement_days_of_week}
weeks={form.values.autostop_requirement_weeks}
/>,
)}
disabled={
isSubmitting ||
!["saturday", "sunday"].includes(
form.values.autostop_requirement_days_of_week || "",
)
}
fullWidth
inputProps={{ min: 1, max: 16, step: 1 }}
label={t("autostopRequirementWeeksLabel")}
type="number"
/>
</Stack>
</FormSection>
)}
<FormSection
title="Allow users scheduling"
description="Allow users to set custom autostart and autostop scheduling options for workspaces created from this template."

View File

@ -1,8 +1,12 @@
import { UpdateTemplateMeta } from "api/typesGenerated"
import * as Yup from "yup"
import i18next from "i18next"
import { TemplateAutostopRequirementDaysValue } from "./AutostopRequirementHelperText"
export interface TemplateScheduleFormValues extends UpdateTemplateMeta {
export interface TemplateScheduleFormValues
extends Omit<UpdateTemplateMeta, "autostop_requirement"> {
autostop_requirement_days_of_week: TemplateAutostopRequirementDaysValue
autostop_requirement_weeks: number
failure_cleanup_enabled: boolean
inactivity_cleanup_enabled: boolean
dormant_autodeletion_cleanup_enabled: boolean
@ -80,4 +84,7 @@ export const getValidationSchema = (): Yup.AnyObjectSchema =>
),
allow_user_autostart: Yup.boolean(),
allow_user_autostop: Yup.boolean(),
autostop_requirement_days_of_week: Yup.string().required(),
autostop_requirement_weeks: Yup.number().required().min(1).max(16),
})

View File

@ -1,7 +1,6 @@
import { screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import * as API from "api/api"
import { UpdateTemplateMeta } from "api/typesGenerated"
import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter"
import {
MockEntitlementsWithScheduling,
@ -11,13 +10,16 @@ import {
renderWithTemplateSettingsLayout,
waitForLoaderToBeRemoved,
} from "testHelpers/renderHelpers"
import { getValidationSchema } from "./TemplateScheduleForm/formHelpers"
import {
TemplateScheduleFormValues,
getValidationSchema,
} from "./TemplateScheduleForm/formHelpers"
import TemplateSchedulePage from "./TemplateSchedulePage"
import i18next from "i18next"
const { t } = i18next
const validFormValues = {
const validFormValues: TemplateScheduleFormValues = {
default_ttl_ms: 1,
max_ttl_ms: 2,
failure_ttl_ms: 7,
@ -25,6 +27,11 @@ const validFormValues = {
time_til_dormant_autodelete_ms: 30,
update_workspace_last_used_at: false,
update_workspace_dormant_at: false,
autostop_requirement_days_of_week: "off",
autostop_requirement_weeks: 1,
failure_cleanup_enabled: false,
inactivity_cleanup_enabled: false,
dormant_autodeletion_cleanup_enabled: false,
}
const renderTemplateSchedulePage = async () => {
@ -42,40 +49,51 @@ const fillAndSubmitForm = async ({
time_til_dormant_ms,
time_til_dormant_autodelete_ms,
}: {
default_ttl_ms: number
max_ttl_ms: number
failure_ttl_ms: number
time_til_dormant_ms: number
time_til_dormant_autodelete_ms: number
default_ttl_ms?: number
max_ttl_ms?: number
failure_ttl_ms?: number
time_til_dormant_ms?: number
time_til_dormant_autodelete_ms?: number
}) => {
const user = userEvent.setup()
const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" })
const defaultTtlField = await screen.findByLabelText(defaultTtlLabel)
await user.clear(defaultTtlField)
await user.type(defaultTtlField, default_ttl_ms.toString())
const maxTtlLabel = t("maxTtlLabel", { ns: "templateSettingsPage" })
const maxTtlField = await screen.findByLabelText(maxTtlLabel)
await user.clear(maxTtlField)
await user.type(maxTtlField, max_ttl_ms.toString())
if (default_ttl_ms) {
const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" })
const defaultTtlField = await screen.findByLabelText(defaultTtlLabel)
await user.clear(defaultTtlField)
await user.type(defaultTtlField, default_ttl_ms.toString())
}
const failureTtlField = screen.getByRole("checkbox", {
name: /Failure Cleanup/i,
})
await user.type(failureTtlField, failure_ttl_ms.toString())
if (max_ttl_ms) {
const maxTtlLabel = t("maxTtlLabel", { ns: "templateSettingsPage" })
const maxTtlField = await screen.findByLabelText(maxTtlLabel)
await user.clear(maxTtlField)
await user.type(maxTtlField, max_ttl_ms.toString())
}
const inactivityTtlField = screen.getByRole("checkbox", {
name: /Dormancy Threshold/i,
})
await user.type(inactivityTtlField, time_til_dormant_ms.toString())
if (failure_ttl_ms) {
const failureTtlField = screen.getByRole("checkbox", {
name: /Failure Cleanup/i,
})
await user.type(failureTtlField, failure_ttl_ms.toString())
}
const dormancyAutoDeletionField = screen.getByRole("checkbox", {
name: /Dormancy Auto-Deletion/i,
})
await user.type(
dormancyAutoDeletionField,
time_til_dormant_autodelete_ms.toString(),
)
if (time_til_dormant_ms) {
const inactivityTtlField = screen.getByRole("checkbox", {
name: /Dormancy Threshold/i,
})
await user.type(inactivityTtlField, time_til_dormant_ms.toString())
}
if (time_til_dormant_autodelete_ms) {
const dormancyAutoDeletionField = screen.getByRole("checkbox", {
name: /Dormancy Auto-Deletion/i,
})
await user.type(
dormancyAutoDeletionField,
time_til_dormant_autodelete_ms.toString(),
)
}
const submitButton = await screen.findByText(
FooterFormLanguage.defaultSubmitLabel,
@ -121,8 +139,8 @@ describe("TemplateSchedulePage", () => {
expect(API.updateTemplateMeta).toBeCalledWith(
"test-template",
expect.objectContaining({
default_ttl_ms: validFormValues.default_ttl_ms * 3600000,
max_ttl_ms: validFormValues.max_ttl_ms * 3600000,
default_ttl_ms: (validFormValues.default_ttl_ms || 0) * 3600000,
max_ttl_ms: (validFormValues.max_ttl_ms || 0) * 3600000,
}),
),
)
@ -142,17 +160,18 @@ describe("TemplateSchedulePage", () => {
expect(API.updateTemplateMeta).toBeCalledWith(
"test-template",
expect.objectContaining({
failure_ttl_ms: validFormValues.failure_ttl_ms * 86400000,
time_til_dormant_ms: validFormValues.time_til_dormant_ms * 86400000,
failure_ttl_ms: (validFormValues.failure_ttl_ms || 0) * 86400000,
time_til_dormant_ms:
(validFormValues.time_til_dormant_ms || 0) * 86400000,
time_til_dormant_autodelete_ms:
validFormValues.time_til_dormant_autodelete_ms * 86400000,
(validFormValues.time_til_dormant_autodelete_ms || 0) * 86400000,
}),
),
)
})
it("allows a default ttl of 7 days", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
default_ttl_ms: 24 * 7,
}
@ -161,7 +180,7 @@ describe("TemplateSchedulePage", () => {
})
it("allows default ttl of 0", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
default_ttl_ms: 0,
}
@ -170,7 +189,7 @@ describe("TemplateSchedulePage", () => {
})
it("allows a default ttl of 30 days", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
default_ttl_ms: 24 * 30,
}
@ -179,7 +198,7 @@ describe("TemplateSchedulePage", () => {
})
it("disallows a default ttl of 30 days + 1 hour", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
default_ttl_ms: 24 * 30 + 1,
}
@ -190,7 +209,7 @@ describe("TemplateSchedulePage", () => {
})
it("allows a failure ttl of 7 days", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
failure_ttl_ms: 86400000 * 7,
}
@ -199,7 +218,7 @@ describe("TemplateSchedulePage", () => {
})
it("allows failure ttl of 0", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
failure_ttl_ms: 0,
}
@ -208,7 +227,7 @@ describe("TemplateSchedulePage", () => {
})
it("disallows a negative failure ttl", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
failure_ttl_ms: -1,
}
@ -219,7 +238,7 @@ describe("TemplateSchedulePage", () => {
})
it("allows an inactivity ttl of 7 days", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
time_til_dormant_ms: 86400000 * 7,
}
@ -228,7 +247,7 @@ describe("TemplateSchedulePage", () => {
})
it("allows an inactivity ttl of 0", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
time_til_dormant_ms: 0,
}
@ -237,7 +256,7 @@ describe("TemplateSchedulePage", () => {
})
it("disallows a negative inactivity ttl", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
time_til_dormant_ms: -1,
}
@ -248,7 +267,7 @@ describe("TemplateSchedulePage", () => {
})
it("allows a dormancy ttl of 7 days", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
time_til_dormant_autodelete_ms: 86400000 * 7,
}
@ -257,7 +276,7 @@ describe("TemplateSchedulePage", () => {
})
it("allows a dormancy ttl of 0", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
time_til_dormant_autodelete_ms: 0,
}
@ -266,7 +285,7 @@ describe("TemplateSchedulePage", () => {
})
it("disallows a negative inactivity ttl", () => {
const values: UpdateTemplateMeta = {
const values: TemplateScheduleFormValues = {
...validFormValues,
time_til_dormant_autodelete_ms: -1,
}
@ -275,4 +294,41 @@ describe("TemplateSchedulePage", () => {
"Dormancy auto-deletion days must not be less than 0.",
)
})
it("allows an autostop requirement weeks of 1", () => {
const values: TemplateScheduleFormValues = {
...validFormValues,
autostop_requirement_days_of_week: "saturday",
autostop_requirement_weeks: 1,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError()
})
it("allows a autostop requirement weeks of 16", () => {
const values: TemplateScheduleFormValues = {
...validFormValues,
autostop_requirement_weeks: 16,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError()
})
it("disallows a autostop requirement weeks of 0", () => {
const values: TemplateScheduleFormValues = {
...validFormValues,
autostop_requirement_weeks: 0,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).toThrowError()
})
it("disallows a autostop requirement weeks of 17", () => {
const values: TemplateScheduleFormValues = {
...validFormValues,
autostop_requirement_weeks: 17,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).toThrowError()
})
})

View File

@ -21,6 +21,9 @@ const TemplateSchedulePage: FC = () => {
// This check can be removed when https://github.com/coder/coder/milestone/19
// is merged up
const allowWorkspaceActions = experiments.includes("workspace_actions")
const allowAutostopRequirement = experiments.includes(
"template_autostop_requirement",
)
const { clearLocal } = useLocalStorage()
const {
@ -47,6 +50,7 @@ const TemplateSchedulePage: FC = () => {
<TemplateSchedulePageView
allowAdvancedScheduling={allowAdvancedScheduling}
allowWorkspaceActions={allowWorkspaceActions}
allowAutostopRequirement={allowAutostopRequirement}
isSubmitting={isSubmitting}
template={template}
submitError={submitError}

View File

@ -13,6 +13,7 @@ export interface TemplateSchedulePageViewProps {
initialTouched?: ComponentProps<typeof TemplateScheduleForm>["initialTouched"]
allowAdvancedScheduling: boolean
allowWorkspaceActions: boolean
allowAutostopRequirement: boolean
}
export const TemplateSchedulePageView: FC<TemplateSchedulePageViewProps> = ({
@ -22,6 +23,7 @@ export const TemplateSchedulePageView: FC<TemplateSchedulePageViewProps> = ({
isSubmitting,
allowAdvancedScheduling,
allowWorkspaceActions,
allowAutostopRequirement,
submitError,
initialTouched,
}) => {
@ -36,6 +38,7 @@ export const TemplateSchedulePageView: FC<TemplateSchedulePageViewProps> = ({
<TemplateScheduleForm
allowAdvancedScheduling={allowAdvancedScheduling}
allowWorkspaceActions={allowWorkspaceActions}
allowAutostopRequirement={allowAutostopRequirement}
initialTouched={initialTouched}
isSubmitting={isSubmitting}
template={template}

View File

@ -20,6 +20,10 @@ import {
VariableValue,
} from "api/typesGenerated"
import { displayError } from "components/GlobalSnackbar/utils"
import {
TemplateAutostopRequirementDaysValue,
calculateAutostopRequirementDaysValue,
} from "pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/AutostopRequirementHelperText"
import { delay } from "utils/delay"
import { assign, createMachine } from "xstate"
@ -45,6 +49,8 @@ export interface CreateTemplateData {
icon: string
default_ttl_hours: number
max_ttl_hours: number
autostop_requirement_days_of_week: TemplateAutostopRequirementDaysValue
autostop_requirement_weeks: number
allow_user_autostart: boolean
allow_user_autostop: boolean
allow_user_cancel_workspace_jobs: boolean
@ -464,15 +470,24 @@ export const createTemplateMachine =
max_ttl_hours,
parameter_values_by_name,
allow_everyone_group_access,
autostop_requirement_days_of_week,
autostop_requirement_weeks,
...safeTemplateData
} = templateData
return createTemplate(organizationId, {
...safeTemplateData,
disable_everyone_group_access: !allow_everyone_group_access,
disable_everyone_group_access:
!templateData.allow_everyone_group_access,
default_ttl_ms: templateData.default_ttl_hours * 60 * 60 * 1000, // Convert hours to ms
max_ttl_ms: templateData.max_ttl_hours * 60 * 60 * 1000, // Convert hours to ms
template_version_id: version.id,
autostop_requirement: {
days_of_week: calculateAutostopRequirementDaysValue(
templateData.autostop_requirement_days_of_week,
),
weeks: templateData.autostop_requirement_weeks,
},
})
},
loadVersionLogs: ({ version }) => {