From 10867e911bf2b50b8a587aa90b9e89a5333abd2a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 30 May 2025 09:30:49 -0500 Subject: [PATCH 1/5] chore: align CSRF settings with deployment config (#18116) (cherry picked from commit 216fe441cf8a8c7013e85f4126d3b3bc56d9e378) --- coderd/coderd.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 37e7d22a6d080..3c88383fd2ea5 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -860,7 +860,7 @@ func New(options *Options) *API { next.ServeHTTP(w, r) }) }, - // httpmw.CSRF(options.DeploymentValues.HTTPCookies), + httpmw.CSRF(options.DeploymentValues.HTTPCookies), ) // This incurs a performance hit from the middleware, but is required to make sure From 5a4c1c94fa78d1c448d096b5ce1f27811557e067 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 28 May 2025 10:00:39 -0500 Subject: [PATCH 2/5] chore: keep previous workspace build parameters for dynamic params (#18059) The existing code persists all static parameters and their values. Using the previous build as the source if no new inputs are found. Dynamic params do not have a state of the parameters saved to disk. So instead, all previous values are persisted always, and new inputs override. (cherry picked from commit ca8660cea6e3c914217f3c794efd9e376316f0f7) --- coderd/parameters_test.go | 97 ++++++++++++++++++++++++++++++++--- coderd/wsbuilder/wsbuilder.go | 28 ++++++++-- 2 files changed, 114 insertions(+), 11 deletions(-) diff --git a/coderd/parameters_test.go b/coderd/parameters_test.go index 91809d3a037d6..98a5d546eaffc 100644 --- a/coderd/parameters_test.go +++ b/coderd/parameters_test.go @@ -15,6 +15,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/coder/v2/provisioner/echo" @@ -211,6 +212,86 @@ func TestDynamicParametersWithTerraformValues(t *testing.T) { require.Zero(t, setup.api.FileCache.Count()) }) + t.Run("RebuildParameters", func(t *testing.T) { + t.Parallel() + + dynamicParametersTerraformSource, err := os.ReadFile("testdata/parameters/modules/main.tf") + require.NoError(t, err) + + modulesArchive, err := terraform.GetModulesArchive(os.DirFS("testdata/parameters/modules")) + require.NoError(t, err) + + setup := setupDynamicParamsTest(t, setupDynamicParamsTestParams{ + provisionerDaemonVersion: provProto.CurrentVersion.String(), + mainTF: dynamicParametersTerraformSource, + modulesArchive: modulesArchive, + plan: nil, + static: nil, + }) + + ctx := testutil.Context(t, testutil.WaitMedium) + stream := setup.stream + previews := stream.Chan() + + // Should see the output of the module represented + preview := testutil.RequireReceive(ctx, t, previews) + require.Equal(t, -1, preview.ID) + require.Empty(t, preview.Diagnostics) + + require.Len(t, preview.Parameters, 1) + require.Equal(t, "jetbrains_ide", preview.Parameters[0].Name) + require.True(t, preview.Parameters[0].Value.Valid) + require.Equal(t, "CL", preview.Parameters[0].Value.Value) + _ = stream.Close(websocket.StatusGoingAway) + + wrk := coderdtest.CreateWorkspace(t, setup.client, setup.template.ID, func(request *codersdk.CreateWorkspaceRequest) { + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + { + Name: preview.Parameters[0].Name, + Value: "GO", + }, + } + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID) + + params, err := setup.client.WorkspaceBuildParameters(ctx, wrk.LatestBuild.ID) + require.NoError(t, err) + require.Len(t, params, 1) + require.Equal(t, "jetbrains_ide", params[0].Name) + require.Equal(t, "GO", params[0].Value) + + // A helper function to assert params + doTransition := func(t *testing.T, trans codersdk.WorkspaceTransition) { + t.Helper() + + fooVal := coderdtest.RandomUsername(t) + bld, err := setup.client.CreateWorkspaceBuild(ctx, wrk.ID, codersdk.CreateWorkspaceBuildRequest{ + TemplateVersionID: setup.template.ActiveVersionID, + Transition: trans, + RichParameterValues: []codersdk.WorkspaceBuildParameter{ + // No validation, so this should work as is. + // Overwrite the value on each transition + {Name: "foo", Value: fooVal}, + }, + EnableDynamicParameters: ptr.Ref(true), + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, setup.client, wrk.LatestBuild.ID) + + latestParams, err := setup.client.WorkspaceBuildParameters(ctx, bld.ID) + require.NoError(t, err) + require.ElementsMatch(t, latestParams, []codersdk.WorkspaceBuildParameter{ + {Name: "jetbrains_ide", Value: "GO"}, + {Name: "foo", Value: fooVal}, + }) + } + + // Restart the workspace, then delete. Asserting params on all builds. + doTransition(t, codersdk.WorkspaceTransitionStop) + doTransition(t, codersdk.WorkspaceTransitionStart) + doTransition(t, codersdk.WorkspaceTransitionDelete) + }) + t.Run("BadOwner", func(t *testing.T) { t.Parallel() @@ -266,9 +347,10 @@ type setupDynamicParamsTestParams struct { } type dynamicParamsTest struct { - client *codersdk.Client - api *coderd.API - stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest] + client *codersdk.Client + api *coderd.API + stream *wsjson.Stream[codersdk.DynamicParametersResponse, codersdk.DynamicParametersRequest] + template codersdk.Template } func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dynamicParamsTest { @@ -300,7 +382,7 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn version := coderdtest.CreateTemplateVersion(t, templateAdmin, owner.OrganizationID, files) coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdmin, version.ID) - _ = coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID) + tpl := coderdtest.CreateTemplate(t, templateAdmin, owner.OrganizationID, version.ID) ctx := testutil.Context(t, testutil.WaitShort) stream, err := templateAdmin.TemplateVersionDynamicParameters(ctx, version.ID) @@ -321,9 +403,10 @@ func setupDynamicParamsTest(t *testing.T, args setupDynamicParamsTestParams) dyn }) return dynamicParamsTest{ - client: ownerClient, - stream: stream, - api: api, + client: ownerClient, + api: api, + stream: stream, + template: tpl, } } diff --git a/coderd/wsbuilder/wsbuilder.go b/coderd/wsbuilder/wsbuilder.go index 46035f28dda77..bcc2cef40ebdc 100644 --- a/coderd/wsbuilder/wsbuilder.go +++ b/coderd/wsbuilder/wsbuilder.go @@ -623,6 +623,11 @@ func (b *Builder) getParameters() (names, values []string, err error) { return nil, nil, BuildError{http.StatusBadRequest, "Unable to build workspace with unsupported parameters", err} } + lastBuildParameterValues := db2sdk.WorkspaceBuildParameters(lastBuildParameters) + resolver := codersdk.ParameterResolver{ + Rich: lastBuildParameterValues, + } + // Dynamic parameters skip all parameter validation. // Deleting a workspace also should skip parameter validation. // Pass the user's input as is. @@ -632,19 +637,34 @@ func (b *Builder) getParameters() (names, values []string, err error) { // conditional parameter existence, the static frame of reference // is not sufficient. So assume the user is correct, or pull in the // dynamic param code to find the actual parameters. + latestValues := make(map[string]string, len(b.richParameterValues)) + for _, latest := range b.richParameterValues { + latestValues[latest.Name] = latest.Value + } + + // Merge the inputs with values from the previous build. + for _, last := range lastBuildParameterValues { + // TODO: Ideally we use the resolver here and look at parameter + // fields such as 'ephemeral'. This requires loading the terraform + // files. For now, just send the previous inputs as is. + if _, exists := latestValues[last.Name]; exists { + // latestValues take priority, so skip this previous value. + continue + } + names = append(names, last.Name) + values = append(values, last.Value) + } + for _, value := range b.richParameterValues { names = append(names, value.Name) values = append(values, value.Value) } + b.parameterNames = &names b.parameterValues = &values return names, values, nil } - resolver := codersdk.ParameterResolver{ - Rich: db2sdk.WorkspaceBuildParameters(lastBuildParameters), - } - for _, templateVersionParameter := range templateVersionParameters { tvp, err := db2sdk.TemplateVersionParameter(templateVersionParameter) if err != nil { From ad3aafed533fdaedf4c3e0bb566bb0b409ff4612 Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Thu, 29 May 2025 10:24:55 -0500 Subject: [PATCH 3/5] fix: autofill with workspace build parameters from the latest build (#18091) Set the form parameters using autofill parameters based on the workspace build parameters for the latest build --------- Co-authored-by: Steven Masley (cherry picked from commit 177bda318764518204368eb8286c8c7ba2f987b5) --- .../DynamicParameter/DynamicParameter.tsx | 20 +++++++-- .../WorkspaceParametersPageExperimental.tsx | 42 +++++++++++++++++++ ...orkspaceParametersPageViewExperimental.tsx | 22 +++++++++- 3 files changed, 78 insertions(+), 6 deletions(-) diff --git a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx index 72113ce8f504b..35c5763c23d25 100644 --- a/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx +++ b/site/src/modules/workspaces/DynamicParameter/DynamicParameter.tsx @@ -84,6 +84,7 @@ export const DynamicParameter: FC = ({ value={value} onChange={onChange} disabled={disabled} + isPreset={isPreset} /> ) : ( void; disabled?: boolean; id: string; + isPreset?: boolean; } const DebouncedParameterField: FC = ({ @@ -239,6 +241,7 @@ const DebouncedParameterField: FC = ({ onChange, disabled, id, + isPreset, }) => { const [localValue, setLocalValue] = useState( value !== undefined ? value : validValue(parameter.value), @@ -251,19 +254,26 @@ const DebouncedParameterField: FC = ({ // This is necessary in the case of fields being set by preset parameters useEffect(() => { - if (value !== undefined && value !== prevValueRef.current) { + if (isPreset && value !== undefined && value !== prevValueRef.current) { setLocalValue(value); prevValueRef.current = value; } - }, [value]); + }, [value, isPreset]); useEffect(() => { - if (prevDebouncedValueRef.current !== undefined) { + // Only call onChangeEvent if debouncedLocalValue is different from the previously committed value + // and it's not the initial undefined state. + if ( + prevDebouncedValueRef.current !== undefined && + prevDebouncedValueRef.current !== debouncedLocalValue + ) { onChangeEvent(debouncedLocalValue); } + // Update the ref to the current debounced value for the next comparison prevDebouncedValueRef.current = debouncedLocalValue; }, [debouncedLocalValue, onChangeEvent]); + const textareaRef = useRef(null); const resizeTextarea = useEffectEvent(() => { @@ -513,7 +523,9 @@ const ParameterField: FC = ({ max={parameter.validations[0]?.validation_max ?? 100} disabled={disabled} /> - {parameter.value.value} + + {Number.isFinite(Number(value)) ? value : "0"} + ); case "error": diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx index 781f8b12e8c67..e80542f3144da 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx @@ -26,6 +26,7 @@ import { useMutation, useQuery } from "react-query"; import { useNavigate } from "react-router-dom"; import { docs } from "utils/docs"; import { pageTitle } from "utils/page"; +import type { AutofillBuildParameter } from "utils/richParameters"; import { type WorkspacePermissions, workspaceChecks, @@ -39,11 +40,27 @@ const WorkspaceParametersPageExperimental: FC = () => { const navigate = useNavigate(); const experimentalFormContext = useContext(ExperimentalFormContext); + // autofill the form with the workspace build parameters from the latest build + const { + data: latestBuildParameters, + isLoading: latestBuildParametersLoading, + } = useQuery({ + queryKey: ["workspaceBuilds", workspace.latest_build.id, "parameters"], + queryFn: () => API.getWorkspaceBuildParameters(workspace.latest_build.id), + }); + const [latestResponse, setLatestResponse] = useState(null); const wsResponseId = useRef(-1); const ws = useRef(null); const [wsError, setWsError] = useState(null); + const initialParamsSentRef = useRef(false); + + const autofillParameters: AutofillBuildParameter[] = + latestBuildParameters?.map((p) => ({ + ...p, + source: "active_build", + })) ?? []; const sendMessage = useEffectEvent((formValues: Record) => { const request: DynamicParametersRequest = { @@ -57,11 +74,34 @@ const WorkspaceParametersPageExperimental: FC = () => { } }); + // On page load, sends initial workspace build parameters to the websocket. + // This ensures the backend has the form's complete initial state, + // vital for rendering dynamic UI elements dependent on initial parameter values. + const sendInitialParameters = useEffectEvent(() => { + if (initialParamsSentRef.current) return; + if (autofillParameters.length === 0) return; + + const initialParamsToSend: Record = {}; + for (const param of autofillParameters) { + if (param.name && param.value) { + initialParamsToSend[param.name] = param.value; + } + } + if (Object.keys(initialParamsToSend).length === 0) return; + + sendMessage(initialParamsToSend); + initialParamsSentRef.current = true; + }); + const onMessage = useEffectEvent((response: DynamicParametersResponse) => { if (latestResponse && latestResponse?.id >= response.id) { return; } + if (!initialParamsSentRef.current && response.parameters?.length > 0) { + sendInitialParameters(); + } + setLatestResponse(response); }); @@ -149,6 +189,7 @@ const WorkspaceParametersPageExperimental: FC = () => { const error = wsError || updateParameters.error; if ( + latestBuildParametersLoading || !latestResponse || (ws.current && ws.current.readyState === WebSocket.CONNECTING) ) { @@ -202,6 +243,7 @@ const WorkspaceParametersPageExperimental: FC = () => { {sortedParams.length > 0 ? ( = ({ workspace, + autofillParameters, parameters, diagnostics, canChangeVersions, @@ -42,17 +45,32 @@ export const WorkspaceParametersPageViewExperimental: FC< sendMessage, onCancel, }) => { + const autofillByName = Object.fromEntries( + autofillParameters.map((param) => [param.name, param]), + ); + const initialTouched = parameters.reduce( + (touched, parameter) => { + if (autofillByName[parameter.name] !== undefined) { + touched[parameter.name] = true; + } + return touched; + }, + {} as Record, + ); const form = useFormik({ onSubmit, initialValues: { - rich_parameter_values: getInitialParameterValues(parameters), + rich_parameter_values: getInitialParameterValues( + parameters, + autofillParameters, + ), }, + initialTouched, validationSchema: useValidationSchemaForDynamicParameters(parameters), enableReinitialize: false, validateOnChange: true, validateOnBlur: true, }); - // Group parameters by ephemeral status const ephemeralParameters = parameters.filter((p) => p.ephemeral); const standardParameters = parameters.filter((p) => !p.ephemeral); From bd62ec5607050015faa19ee23187f7b84ad5826d Mon Sep 17 00:00:00 2001 From: Jaayden Halko Date: Fri, 30 May 2025 12:17:17 -0500 Subject: [PATCH 4/5] feat: add early access badges for dynamic parameters (#18114) Workspace creation page Screenshot 2025-05-30 at 13 38 22 Workspace parameter settings Screenshot 2025-05-30 at 13 37 19 Screenshot 2025-05-30 at 13 43 27 (cherry picked from commit 9b53e69e32c6ed3b059b93789733f54f3bc168ca) --- .../FeatureStageBadge.stories.tsx | 19 ++- .../FeatureStageBadge/FeatureStageBadge.tsx | 142 ++++++------------ .../CreateWorkspacePageViewExperimental.tsx | 64 ++++---- site/src/pages/UserSettingsPage/Section.tsx | 2 +- .../WorkspaceParametersPage.tsx | 20 +-- .../WorkspaceParametersPageExperimental.tsx | 72 +++++---- 6 files changed, 142 insertions(+), 177 deletions(-) diff --git a/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx b/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx index 330b3c9a41105..c0f3aad774473 100644 --- a/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx +++ b/site/src/components/FeatureStageBadge/FeatureStageBadge.stories.tsx @@ -12,27 +12,30 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const MediumBeta: Story = { +export const SmallBeta: Story = { args: { - size: "md", + size: "sm", + contentType: "beta", }, }; -export const SmallBeta: Story = { +export const MediumBeta: Story = { args: { - size: "sm", + size: "md", + contentType: "beta", }, }; -export const LargeBeta: Story = { +export const SmallEarlyAccess: Story = { args: { - size: "lg", + size: "sm", + contentType: "early_access", }, }; -export const MediumExperimental: Story = { +export const MediumEarlyAccess: Story = { args: { size: "md", - contentType: "experimental", + contentType: "early_access", }, }; diff --git a/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx b/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx index 18b03b2e93661..78ad6c0311c06 100644 --- a/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx +++ b/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx @@ -1,9 +1,12 @@ -import type { Interpolation, Theme } from "@emotion/react"; -import Link from "@mui/material/Link"; -import { visuallyHidden } from "@mui/utils"; -import { HelpTooltipContent } from "components/HelpTooltip/HelpTooltip"; -import { Popover, PopoverTrigger } from "components/deprecated/Popover/Popover"; +import { Link } from "components/Link/Link"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "components/Tooltip/Tooltip"; import type { FC, HTMLAttributes, ReactNode } from "react"; +import { cn } from "utils/cn"; import { docs } from "utils/docs"; /** @@ -11,132 +14,73 @@ import { docs } from "utils/docs"; * ensure that we can't accidentally make typos when writing the badge text. */ export const featureStageBadgeTypes = { + early_access: "early access", beta: "beta", - experimental: "experimental", } as const satisfies Record; type FeatureStageBadgeProps = Readonly< Omit, "children"> & { contentType: keyof typeof featureStageBadgeTypes; labelText?: string; - size?: "sm" | "md" | "lg"; - showTooltip?: boolean; + size?: "sm" | "md"; } >; +const badgeColorClasses = { + early_access: "bg-surface-orange text-content-warning", + beta: "bg-surface-sky text-highlight-sky", +} as const; + +const badgeSizeClasses = { + sm: "text-xs font-medium px-2 py-1", + md: "text-base px-2 py-1", +} as const; + export const FeatureStageBadge: FC = ({ contentType, labelText = "", size = "md", - showTooltip = true, // This is a temporary until the deprecated popover is removed + className, ...delegatedProps }) => { + const colorClasses = badgeColorClasses[contentType]; + const sizeClasses = badgeSizeClasses[size]; + return ( - - - {({ isOpen }) => ( + + + - (This is a + (This is a {labelText && `${labelText} `} {featureStageBadgeTypes[contentType]} - feature) + feature) - )} - - - {showTooltip && ( - -

+ + +

This feature has not yet reached general availability (GA).

Learn about feature stages - (link opens in new tab) + (link opens in new tab) -
- )} -
+ + + ); }; - -const styles = { - badge: (theme) => ({ - // Base type is based on a span so that the element can be placed inside - // more types of HTML elements without creating invalid markdown, but we - // still want the default display behavior to be div-like - display: "block", - maxWidth: "fit-content", - - // Base style assumes that medium badges will be the default - fontSize: "0.75rem", - - cursor: "default", - flexShrink: 0, - padding: "4px 8px", - lineHeight: 1, - whiteSpace: "nowrap", - border: `1px solid ${theme.branding.featureStage.border}`, - color: theme.branding.featureStage.text, - backgroundColor: theme.branding.featureStage.background, - borderRadius: "6px", - transition: - "color 0.2s ease-in-out, border-color 0.2s ease-in-out, background-color 0.2s ease-in-out", - }), - - badgeHover: (theme) => ({ - color: theme.branding.featureStage.hover.text, - borderColor: theme.branding.featureStage.hover.border, - backgroundColor: theme.branding.featureStage.hover.background, - }), - - badgeLargeText: { - fontSize: "1rem", - }, - - badgeSmallText: { - // Have to beef up font weight so that the letters still maintain the - // same relative thickness as all our other main UI text - fontWeight: 500, - fontSize: "0.625rem", - }, - - tooltipTitle: (theme) => ({ - color: theme.palette.text.primary, - fontWeight: 600, - fontFamily: "inherit", - fontSize: 18, - margin: 0, - lineHeight: 1, - paddingBottom: "8px", - }), - - tooltipDescription: { - margin: 0, - lineHeight: 1.4, - paddingBottom: "8px", - }, - - tooltipLink: { - fontWeight: 600, - lineHeight: 1.2, - }, -} as const satisfies Record>; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 817a7abfccb09..55b507e219c56 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -3,12 +3,12 @@ import type { FriendlyDiagnostic, PreviewParameter } from "api/typesGenerated"; import { Alert } from "components/Alert/Alert"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Avatar } from "components/Avatar/Avatar"; +import { Badge } from "components/Badge/Badge"; import { Button } from "components/Button/Button"; import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; import { Input } from "components/Input/Input"; import { Label } from "components/Label/Label"; import { Link } from "components/Link/Link"; -import { Pill } from "components/Pill/Pill"; import { Select, SelectContent, @@ -353,21 +353,39 @@ export const CreateWorkspacePageViewExperimental: FC<
-
- -

- {template.display_name.length > 0 - ? template.display_name - : template.name} -

+
+ + +

+ {template.display_name.length > 0 + ? template.display_name + : template.name} +

+ {template.deprecated && ( + + Deprecated + + )} +
+ {experimentalFormContext && ( + + )}

New workspace

+ @@ -389,19 +407,11 @@ export const CreateWorkspacePageViewExperimental: FC<
- - {template.deprecated && Deprecated} - - {experimentalFormContext && ( - - )} +
- +
diff --git a/site/src/pages/UserSettingsPage/Section.tsx b/site/src/pages/UserSettingsPage/Section.tsx index 0d162b73a7f6b..2227dcd9cdbf8 100644 --- a/site/src/pages/UserSettingsPage/Section.tsx +++ b/site/src/pages/UserSettingsPage/Section.tsx @@ -53,7 +53,7 @@ export const Section: FC = ({ {featureStage && ( )} diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx index 6dac6536b5bfb..56720292957ff 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx @@ -117,18 +117,18 @@ export const WorkspaceParametersPageView: FC< return (
- +

Workspace parameters

+ {experimentalFormContext && ( + + Try out the new workspace parameters ✨ + + )}
- {experimentalFormContext && ( - - Try out the new workspace parameters ✨ - - )}
{submitError && !isApiValidationError(submitError) ? ( diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx index e80542f3144da..37baf9c3a1240 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx @@ -9,6 +9,7 @@ import type { import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Button } from "components/Button/Button"; import { EmptyState } from "components/EmptyState/EmptyState"; +import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; import { Link } from "components/Link/Link"; import { Loader } from "components/Loader/Loader"; import { @@ -203,39 +204,46 @@ const WorkspaceParametersPageExperimental: FC = () => {
- -

Workspace parameters

- - - - - - - Dynamic Parameters enhances Coder's existing parameter system - with real-time validation, conditional parameter behavior, and - richer input types. -
- - View docs - -
-
-
+ + +

Workspace parameters

+ + + + + + + Dynamic Parameters enhances Coder's existing parameter system + with real-time validation, conditional parameter behavior, and + richer input types. +
+ + View docs + +
+
+
+
+ {experimentalFormContext && ( + + )}
- {experimentalFormContext && ( - - )} +
{Boolean(error) && } From 7bf0173e87c3202e773c48ad983aa25a8ec4b5fc Mon Sep 17 00:00:00 2001 From: Ethan <39577870+ethanndickson@users.noreply.github.com> Date: Thu, 29 May 2025 17:26:27 +1000 Subject: [PATCH 5/5] fix: handle `workspace.agent` and `agent.workspace.owner` in `coder ssh` (#18093) Closes #18088. The linked issue is misleading -- `coder config-ssh` continues to support the `coder.` prefix. The reason the command `ssh coder.workspace.agent` fails is because `coder ssh workspace.agent` wasn't supported. This PR fixes that. We know we used to support `workspace.agent`, as this is what we recommend in the Web UI: ![image](https://github.com/user-attachments/assets/702bbbc7-c586-4947-98a6-4508a481280b) This PR also adds support for `coder ssh agent.workspace.owner`, such that after running `coder config-ssh`, a command like ``` ssh agent.workspace.owner.coder ``` works, even without Coder Connect running. This is done for parity with an existing workflow that uses `ssh workspace.coder`, which either uses Coder Connect if available, or the CLI. (cherry picked from commit da02375f008817720336e2cb0a5d1ffcbdb02716) --- cli/ssh.go | 9 +++++++++ cli/ssh_test.go | 2 ++ 2 files changed, 11 insertions(+) diff --git a/cli/ssh.go b/cli/ssh.go index 5cc81284ca317..51f53e10bcbd2 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -1594,12 +1594,14 @@ func writeCoderConnectNetInfo(ctx context.Context, networkInfoDir string) error // Converts workspace name input to owner/workspace.agent format // Possible valid input formats: // workspace +// workspace.agent // owner/workspace // owner--workspace // owner/workspace--agent // owner/workspace.agent // owner--workspace--agent // owner--workspace.agent +// agent.workspace.owner - for parity with Coder Connect func normalizeWorkspaceInput(input string) string { // Split on "/", "--", and "." parts := workspaceNameRe.Split(input, -1) @@ -1608,8 +1610,15 @@ func normalizeWorkspaceInput(input string) string { case 1: return input // "workspace" case 2: + if strings.Contains(input, ".") { + return fmt.Sprintf("%s.%s", parts[0], parts[1]) // "workspace.agent" + } return fmt.Sprintf("%s/%s", parts[0], parts[1]) // "owner/workspace" case 3: + // If the only separator is a dot, it's the Coder Connect format + if !strings.Contains(input, "/") && !strings.Contains(input, "--") { + return fmt.Sprintf("%s/%s.%s", parts[2], parts[1], parts[0]) // "owner/workspace.agent" + } return fmt.Sprintf("%s/%s.%s", parts[0], parts[1], parts[2]) // "owner/workspace.agent" default: return input // Fallback diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 147fc07372032..8845200273697 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -107,12 +107,14 @@ func TestSSH(t *testing.T) { cases := []string{ "myworkspace", + "myworkspace.dev", "myuser/myworkspace", "myuser--myworkspace", "myuser/myworkspace--dev", "myuser/myworkspace.dev", "myuser--myworkspace--dev", "myuser--myworkspace.dev", + "dev.myworkspace.myuser", } for _, tc := range cases {