diff --git a/coderd/schedule/cron/cron.go b/coderd/schedule/cron/cron.go index 1399215843999..35102d66af34a 100644 --- a/coderd/schedule/cron/cron.go +++ b/coderd/schedule/cron/cron.go @@ -173,6 +173,19 @@ func (s Schedule) Min() time.Duration { return durMin } +// TimeParsed returns the parsed time.Time of the minute and hour fields. If the +// time cannot be represented in a valid time.Time, a zero time is returned. +func (s Schedule) TimeParsed() time.Time { + minute := strings.Fields(s.cronStr)[0] + hour := strings.Fields(s.cronStr)[1] + maybeTime := fmt.Sprintf("%s:%s", hour, minute) + t, err := time.ParseInLocation("15:4", maybeTime, s.sched.Location) + if err != nil { + return time.Time{} + } + return t +} + // Time returns a humanized form of the minute and hour fields. func (s Schedule) Time() string { minute := strings.Fields(s.cronStr)[0] diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 70fa7bbfc483a..8ee505f50e3fc 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -63,6 +63,7 @@ var FeatureNames = []FeatureName{ FeatureExternalProvisionerDaemons, FeatureAppearance, FeatureAdvancedTemplateScheduling, + FeatureTemplateAutostopRequirement, FeatureWorkspaceProxy, FeatureUserRoleManagement, FeatureWorkspaceBatchActions, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 1bc0815cd5248..0effc88dc0401 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -440,7 +440,7 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureAdvancedTemplateScheduling: true, // FeatureTemplateAutostopRequirement depends on // FeatureAdvancedTemplateScheduling. - codersdk.FeatureTemplateAutostopRequirement: api.DefaultQuietHoursSchedule != "", + codersdk.FeatureTemplateAutostopRequirement: api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateAutostopRequirement) && api.DefaultQuietHoursSchedule != "", codersdk.FeatureWorkspaceProxy: true, codersdk.FeatureUserRoleManagement: true, }) diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go index 2af162a68a363..6398a93c95e85 100644 --- a/enterprise/coderd/users.go +++ b/enterprise/coderd/users.go @@ -68,7 +68,7 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{ RawSchedule: opts.Schedule.String(), UserSet: opts.UserSet, - Time: opts.Schedule.Time(), + Time: opts.Schedule.TimeParsed().Format("15:40"), Timezone: opts.Schedule.Location().String(), Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())), }) @@ -114,7 +114,7 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{ RawSchedule: opts.Schedule.String(), UserSet: opts.UserSet, - Time: opts.Schedule.Time(), + Time: opts.Schedule.TimeParsed().Format("15:40"), Timezone: opts.Schedule.Location().String(), Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())), }) diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go index f91b3b348ca08..e88a3e4df55f3 100644 --- a/enterprise/coderd/users_test.go +++ b/enterprise/coderd/users_test.go @@ -21,14 +21,14 @@ func TestUserQuietHours(t *testing.T) { t.Run("OK", func(t *testing.T) { t.Parallel() - defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 0 * * *" + defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 1 * * *" defaultScheduleParsed, err := cron.Daily(defaultQuietHoursSchedule) require.NoError(t, err) nextTime := defaultScheduleParsed.Next(time.Now().In(defaultScheduleParsed.Location())) if time.Until(nextTime) < time.Hour { // Use a different default schedule instead, because we want to avoid // the schedule "ticking over" during this test run. - defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 0 12 * * *" + defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 0 13 * * *" defaultScheduleParsed, err = cron.Daily(defaultQuietHoursSchedule) require.NoError(t, err) } @@ -55,7 +55,7 @@ func TestUserQuietHours(t *testing.T) { require.NoError(t, err) require.Equal(t, defaultScheduleParsed.String(), sched1.RawSchedule) require.False(t, sched1.UserSet) - require.Equal(t, defaultScheduleParsed.Time(), sched1.Time) + require.Equal(t, defaultScheduleParsed.TimeParsed().Format("15:40"), sched1.Time) require.Equal(t, defaultScheduleParsed.Location().String(), sched1.Timezone) require.WithinDuration(t, defaultScheduleParsed.Next(time.Now()), sched1.Next, 15*time.Second) @@ -78,7 +78,7 @@ func TestUserQuietHours(t *testing.T) { require.NoError(t, err) require.Equal(t, customScheduleParsed.String(), sched2.RawSchedule) require.True(t, sched2.UserSet) - require.Equal(t, customScheduleParsed.Time(), sched2.Time) + require.Equal(t, customScheduleParsed.TimeParsed().Format("15:40"), sched2.Time) require.Equal(t, customScheduleParsed.Location().String(), sched2.Timezone) require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched2.Next, 15*time.Second) @@ -87,7 +87,7 @@ func TestUserQuietHours(t *testing.T) { require.NoError(t, err) require.Equal(t, customScheduleParsed.String(), sched3.RawSchedule) require.True(t, sched3.UserSet) - require.Equal(t, customScheduleParsed.Time(), sched3.Time) + require.Equal(t, customScheduleParsed.TimeParsed().Format("15:40"), sched3.Time) require.Equal(t, customScheduleParsed.Location().String(), sched3.Timezone) require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched3.Next, 15*time.Second) diff --git a/site/src/AppRouter.tsx b/site/src/AppRouter.tsx index 785048fe7298d..2747cf2c6c13b 100644 --- a/site/src/AppRouter.tsx +++ b/site/src/AppRouter.tsx @@ -29,6 +29,9 @@ const CliAuthenticationPage = lazy( const AccountPage = lazy( () => import("./pages/UserSettingsPage/AccountPage/AccountPage"), ) +const SchedulePage = lazy( + () => import("./pages/UserSettingsPage/SchedulePage/SchedulePage"), +) const SecurityPage = lazy( () => import("./pages/UserSettingsPage/SecurityPage/SecurityPage"), ) @@ -289,6 +292,7 @@ export const AppRouter: FC = () => { }> } /> + } /> } /> } /> diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 3567e4f977332..6c893aa3552e2 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -667,6 +667,21 @@ export const updateProfile = async ( return response.data } +export const getUserQuietHoursSchedule = async ( + userId: TypesGen.User["id"], +): Promise => { + const response = await axios.get(`/api/v2/users/${userId}/quiet-hours`) + return response.data +} + +export const updateUserQuietHoursSchedule = async ( + userId: TypesGen.User["id"], + data: TypesGen.UpdateUserQuietHoursScheduleRequest, +): Promise => { + const response = await axios.put(`/api/v2/users/${userId}/quiet-hours`, data) + return response.data +} + export const activateUser = async ( userId: TypesGen.User["id"], ): Promise => { diff --git a/site/src/components/SettingsLayout/Sidebar.tsx b/site/src/components/SettingsLayout/Sidebar.tsx index e67c229f9e11f..8e736a4e96f62 100644 --- a/site/src/components/SettingsLayout/Sidebar.tsx +++ b/site/src/components/SettingsLayout/Sidebar.tsx @@ -8,7 +8,9 @@ import { FC, ElementType, PropsWithChildren, ReactNode } from "react" import { NavLink } from "react-router-dom" import { combineClasses } from "utils/combineClasses" import AccountIcon from "@mui/icons-material/Person" +import ScheduleIcon from "@mui/icons-material/EditCalendarOutlined" import SecurityIcon from "@mui/icons-material/LockOutlined" +import { useDashboard } from "components/Dashboard/DashboardProvider" const SidebarNavItem: FC< PropsWithChildren<{ href: string; icon: ReactNode }> @@ -41,6 +43,9 @@ const SidebarNavItemIcon: React.FC<{ icon: ElementType }> = ({ export const Sidebar: React.FC<{ user: User }> = ({ user }) => { const styles = useStyles() + const { entitlements } = useDashboard() + const allowAutostopRequirement = + entitlements.features.template_autostop_requirement.enabled return (