diff --git a/site/src/api/api.ts b/site/src/api/api.ts index c602a727e389e..8461c758bf692 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -576,6 +576,21 @@ export const updateWorkspaceDormancy = async ( return response.data; }; +export const updateWorkspaceAutomaticUpdates = async ( + workspaceId: string, + automaticUpdates: TypesGen.AutomaticUpdates, +): Promise => { + const req: TypesGen.UpdateWorkspaceAutomaticUpdatesRequest = { + automatic_updates: automaticUpdates, + }; + + const response = await axios.put( + `/api/v2/workspaces/${workspaceId}/autoupdates`, + req, + ); + return response.data; +}; + export const restartWorkspace = async ({ workspace, buildParameters, diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index ae05ff0ae7447..4243c8b01effe 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -121,3 +121,11 @@ export const useIsWorkspaceActionsEnabled = (): boolean => { const allowWorkspaceActions = experiments.includes("workspace_actions"); return allowWorkspaceActions && allowAdvancedScheduling; }; + +export const useTemplatePoliciesEnabled = (): boolean => { + const { entitlements, experiments } = useDashboard(); + return ( + entitlements.features.access_control.enabled && + experiments.includes("template_update_policies") + ); +}; diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx index d483fb7ad053f..3a3fa11a425f7 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx @@ -10,7 +10,7 @@ import { useTemplateSettings } from "../TemplateSettingsLayout"; import { TemplateSettingsPageView } from "./TemplateSettingsPageView"; import { templateByNameKey } from "api/queries/templates"; import { useOrganizationId } from "hooks"; -import { useDashboard } from "components/Dashboard/DashboardProvider"; +import { useTemplatePoliciesEnabled } from "components/Dashboard/DashboardProvider"; export const TemplateSettingsPage: FC = () => { const { template: templateName } = useParams() as { template: string }; @@ -18,10 +18,7 @@ export const TemplateSettingsPage: FC = () => { const orgId = useOrganizationId(); const { template } = useTemplateSettings(); const queryClient = useQueryClient(); - const { entitlements, experiments } = useDashboard(); - const accessControlEnabled = - entitlements.features["advanced_template_scheduling"].enabled && - experiments.includes("template_update_policies"); + const accessControlEnabled = useTemplatePoliciesEnabled(); const { mutate: updateTemplate, diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 7dbcd6cc0527a..a6ed168ec87ed 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -191,6 +191,7 @@ export const Workspace: FC> = ({ quotaBudget={quotaBudget} handleUpdate={handleUpdate} canUpdateWorkspace={canUpdateWorkspace} + canChangeVersions={canChangeVersions} maxDeadlineDecrease={scheduleProps.maxDeadlineDecrease} maxDeadlineIncrease={scheduleProps.maxDeadlineIncrease} onDeadlineMinus={scheduleProps.onDeadlineMinus} diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts b/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts index dc57d0e4fbd0e..4d49df9d0b0a1 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts +++ b/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts @@ -1,5 +1,6 @@ import { Workspace, WorkspaceStatus } from "api/typesGenerated"; import { ReactNode } from "react"; +import { workspaceUpdatePolicy } from "utils/workspace"; // the button types we have export enum ButtonTypesEnum { @@ -43,9 +44,8 @@ export const actionsByWorkspaceStatus = ( }; } if ( - workspace.template_require_active_version && workspace.outdated && - !canChangeVersions + workspaceUpdatePolicy(workspace, canChangeVersions) ) { if (status === "running") { return { diff --git a/site/src/pages/WorkspacePage/WorkspaceStats.tsx b/site/src/pages/WorkspacePage/WorkspaceStats.tsx index 773be0baa59f6..bf4c37d3ecf52 100644 --- a/site/src/pages/WorkspacePage/WorkspaceStats.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceStats.tsx @@ -7,6 +7,7 @@ import { getDisplayWorkspaceBuildInitiatedBy, getDisplayWorkspaceTemplateName, isWorkspaceOn, + workspaceUpdatePolicy, } from "utils/workspace"; import { Workspace } from "api/typesGenerated"; import { Stats, StatsItem } from "components/Stats/Stats"; @@ -26,6 +27,12 @@ import { PopoverTrigger, usePopover, } from "components/Popover/Popover"; +import { useTemplatePoliciesEnabled } from "components/Dashboard/DashboardProvider"; +import { + HelpTooltip, + HelpTooltipText, +} from "components/HelpTooltip/HelpTooltip"; +import { Stack } from "components/Stack/Stack"; const Language = { workspaceDetails: "Workspace Details", @@ -37,6 +44,7 @@ const Language = { upToDate: "Up to date", byLabel: "Last built by", costLabel: "Daily cost", + updatePolicy: "Update policy", }; export interface WorkspaceStatsProps { @@ -44,6 +52,7 @@ export interface WorkspaceStatsProps { maxDeadlineIncrease: number; maxDeadlineDecrease: number; canUpdateWorkspace: boolean; + canChangeVersions: boolean; quotaBudget?: number; onDeadlinePlus: (hours: number) => void; onDeadlineMinus: (hours: number) => void; @@ -56,6 +65,7 @@ export const WorkspaceStats: FC = ({ maxDeadlineDecrease, maxDeadlineIncrease, canUpdateWorkspace, + canChangeVersions, handleUpdate, onDeadlineMinus, onDeadlinePlus, @@ -67,6 +77,7 @@ export const WorkspaceStats: FC = ({ const styles = useStyles(); const deadlinePlusEnabled = maxDeadlineIncrease >= 1; const deadlineMinusEnabled = maxDeadlineDecrease >= 1; + const templatePoliciesEnabled = useTemplatePoliciesEnabled(); return ( <> @@ -198,6 +209,27 @@ export const WorkspaceStats: FC = ({ }`} /> )} + {templatePoliciesEnabled && ( + + + {workspace.automatic_updates === "never" && + workspace.template_require_active_version && + !canChangeVersions && ( + + + Your workspace has not opted in to automatic updates but + your template requires updating to the active version. + + + )} + + )} ); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsForm.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsForm.tsx index 7626e12585797..605fbd406c2d7 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsForm.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsForm.tsx @@ -13,27 +13,36 @@ import { getFormHelpers, onChangeTrimmed, } from "utils/formUtils"; -import { Workspace } from "api/typesGenerated"; +import { + AutomaticUpdates, + AutomaticUpdateses, + Workspace, +} from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; +import MenuItem from "@mui/material/MenuItem"; +import upperFirst from "lodash/upperFirst"; export type WorkspaceSettingsFormValues = { name: string; + automatic_updates: AutomaticUpdates; }; export const WorkspaceSettingsForm: FC<{ - isSubmitting: boolean; workspace: Workspace; error: unknown; + templatePoliciesEnabled: boolean; onCancel: () => void; - onSubmit: (values: WorkspaceSettingsFormValues) => void; -}> = ({ onCancel, onSubmit, workspace, error, isSubmitting }) => { + onSubmit: (values: WorkspaceSettingsFormValues) => Promise; +}> = ({ onCancel, onSubmit, workspace, error, templatePoliciesEnabled }) => { const form = useFormik({ onSubmit, initialValues: { name: workspace.name, + automatic_updates: workspace.automatic_updates, }, validationSchema: Yup.object({ name: nameValidator("Name"), + automatic_updates: Yup.string().oneOf(AutomaticUpdateses), }), }); const getFieldHelpers = getFormHelpers( @@ -43,7 +52,10 @@ export const WorkspaceSettingsForm: FC<{ return ( - + - + {templatePoliciesEnabled && ( + + + + {AutomaticUpdateses.map((value) => ( + + {upperFirst(value)} + + ))} + + + + )} + ); }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx index 38b65eeacf6ec..4ba770eb65735 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPage.tsx @@ -5,8 +5,9 @@ import { useWorkspaceSettings } from "./WorkspaceSettingsLayout"; import { WorkspaceSettingsPageView } from "./WorkspaceSettingsPageView"; import { useMutation } from "react-query"; import { displaySuccess } from "components/GlobalSnackbar/utils"; -import { patchWorkspace } from "api/api"; +import { patchWorkspace, updateWorkspaceAutomaticUpdates } from "api/api"; import { WorkspaceSettingsFormValues } from "./WorkspaceSettingsForm"; +import { useTemplatePoliciesEnabled } from "components/Dashboard/DashboardProvider"; const WorkspaceSettingsPage = () => { const params = useParams() as { @@ -17,9 +18,18 @@ const WorkspaceSettingsPage = () => { const username = params.username.replace("@", ""); const workspace = useWorkspaceSettings(); const navigate = useNavigate(); + const templatePoliciesEnabled = useTemplatePoliciesEnabled(); + const mutation = useMutation({ - mutationFn: (formValues: WorkspaceSettingsFormValues) => - patchWorkspace(workspace.id, { name: formValues.name }), + mutationFn: async (formValues: WorkspaceSettingsFormValues) => { + await Promise.all([ + patchWorkspace(workspace.id, { name: formValues.name }), + updateWorkspaceAutomaticUpdates( + workspace.id, + formValues.automatic_updates, + ), + ]); + }, onSuccess: (_, formValues) => { displaySuccess("Workspace updated successfully"); navigate(`/@${username}/${formValues.name}/settings`); @@ -34,10 +44,10 @@ const WorkspaceSettingsPage = () => { navigate(`/@${username}/${workspaceName}`)} - onSubmit={mutation.mutate} + onSubmit={mutation.mutateAsync} + templatePoliciesEnabled={templatePoliciesEnabled} /> ); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx index 13a44283d6aa1..1a316b4bb9488 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.stories.tsx @@ -7,7 +7,6 @@ const meta: Meta = { component: WorkspaceSettingsPageView, args: { error: undefined, - isSubmitting: false, workspace: MockWorkspace, }, }; @@ -15,6 +14,10 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const Example: Story = {}; +export const Example: Story = {}; -export { Example as WorkspaceSettingsPageView }; +export const AutoUpdates: Story = { + args: { + templatePoliciesEnabled: true, + }, +}; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.tsx index eae60876b07d3..20bb4664dbd27 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSettingsPageView.tsx @@ -5,18 +5,18 @@ import { Workspace } from "api/typesGenerated"; export type WorkspaceSettingsPageViewProps = { error: unknown; - isSubmitting: boolean; workspace: Workspace; onCancel: () => void; onSubmit: ComponentProps["onSubmit"]; + templatePoliciesEnabled: boolean; }; export const WorkspaceSettingsPageView: FC = ({ onCancel, onSubmit, - isSubmitting, error, workspace, + templatePoliciesEnabled, }) => { return ( <> @@ -30,10 +30,10 @@ export const WorkspaceSettingsPageView: FC = ({ ); diff --git a/site/src/utils/workspace.tsx b/site/src/utils/workspace.tsx index 9365e5d615cac..bfa06dde290ef 100644 --- a/site/src/utils/workspace.tsx +++ b/site/src/utils/workspace.tsx @@ -303,3 +303,15 @@ export const getMatchingAgentOrFirst = ( }) .filter((a) => a)[0]; }; + +export const workspaceUpdatePolicy = ( + workspace: TypesGen.Workspace, + canChangeVersions: boolean, +): TypesGen.AutomaticUpdates => { + // If a template requires the active version and you cannot change versions + // (restricted to template admins), then your policy must be "Always". + if (workspace.template_require_active_version && !canChangeVersions) { + return "always"; + } + return workspace.automatic_updates; +};