diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3b6ab51263..b504117973 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -587,8 +587,36 @@ const docTemplate = `{ "tags": [ "General" ], - "summary": "Get experiments", - "operationId": "get-experiments", + "summary": "Get enabled experiments", + "operationId": "get-enabled-experiments", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Experiment" + } + } + } + } + } + }, + "/experiments/available": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "General" + ], + "summary": "Get safe experiments", + "operationId": "get-safe-experiments", "responses": { "200": { "description": "OK", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 849387dbfe..6e2e5c0902 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -497,8 +497,32 @@ ], "produces": ["application/json"], "tags": ["General"], - "summary": "Get experiments", - "operationId": "get-experiments", + "summary": "Get enabled experiments", + "operationId": "get-enabled-experiments", + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.Experiment" + } + } + } + } + } + }, + "/experiments/available": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["General"], + "summary": "Get safe experiments", + "operationId": "get-safe-experiments", "responses": { "200": { "description": "OK", diff --git a/coderd/coderd.go b/coderd/coderd.go index f301265cc5..7ab2e57846 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -597,6 +597,7 @@ func New(options *Options) *API { }) r.Route("/experiments", func(r chi.Router) { r.Use(apiKeyMiddleware) + r.Get("/available", handleExperimentsSafe) r.Get("/", api.handleExperimentsGet) }) r.Get("/updatecheck", api.updateCheck) diff --git a/coderd/experiments.go b/coderd/experiments.go index 1a8bb5ce18..f7debd8c68 100644 --- a/coderd/experiments.go +++ b/coderd/experiments.go @@ -4,10 +4,11 @@ import ( "net/http" "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" ) -// @Summary Get experiments -// @ID get-experiments +// @Summary Get enabled experiments +// @ID get-enabled-experiments // @Security CoderSessionToken // @Produce json // @Tags General @@ -17,3 +18,17 @@ func (api *API) handleExperimentsGet(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() httpapi.Write(ctx, rw, http.StatusOK, api.Experiments) } + +// @Summary Get safe experiments +// @ID get-safe-experiments +// @Security CoderSessionToken +// @Produce json +// @Tags General +// @Success 200 {array} codersdk.Experiment +// @Router /experiments/available [get] +func handleExperimentsSafe(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + httpapi.Write(ctx, rw, http.StatusOK, codersdk.AvailableExperiments{ + Safe: codersdk.ExperimentsAll, + }) +} diff --git a/coderd/experiments_test.go b/coderd/experiments_test.go index 0f498e7e7c..4288b9953f 100644 --- a/coderd/experiments_test.go +++ b/coderd/experiments_test.go @@ -116,4 +116,21 @@ func Test_Experiments(t *testing.T) { require.Error(t, err) require.ErrorContains(t, err, httpmw.SignedOutErrorMessage) }) + + t.Run("available experiments", func(t *testing.T) { + t.Parallel() + cfg := coderdtest.DeploymentValues(t) + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: cfg, + }) + _ = coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + experiments, err := client.SafeExperiments(ctx) + require.NoError(t, err) + require.NotNil(t, experiments) + require.ElementsMatch(t, codersdk.ExperimentsAll, experiments.Safe) + }) } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 195915b052..4622808853 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -2013,12 +2013,13 @@ var ExperimentsAll = Experiments{ ExperimentSingleTailnet, } -// Experiments is a list of experiments that are enabled for the deployment. +// Experiments is a list of experiments. // Multiple experiments may be enabled at the same time. // Experiments are not safe for production use, and are not guaranteed to // be backwards compatible. They may be removed or renamed at any time. type Experiments []Experiment +// Returns a list of experiments that are enabled for the deployment. func (e Experiments) Enabled(ex Experiment) bool { for _, v := range e { if v == ex { @@ -2041,6 +2042,25 @@ func (c *Client) Experiments(ctx context.Context) (Experiments, error) { return exp, json.NewDecoder(res.Body).Decode(&exp) } +// AvailableExperiments is an expandable type that returns all safe experiments +// available to be used with a deployment. +type AvailableExperiments struct { + Safe []Experiment `json:"safe"` +} + +func (c *Client) SafeExperiments(ctx context.Context) (AvailableExperiments, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/experiments/available", nil) + if err != nil { + return AvailableExperiments{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return AvailableExperiments{}, ReadBodyAsError(res) + } + var exp AvailableExperiments + return exp, json.NewDecoder(res.Body).Decode(&exp) +} + type DAUsResponse struct { Entries []DAUEntry `json:"entries"` TZHourOffset int `json:"tz_hour_offset"` diff --git a/docs/api/general.md b/docs/api/general.md index 1362b6edcd..577781136e 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -535,7 +535,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/stats \ To perform this operation, you must be authenticated. [Learn more](authentication.md). -## Get experiments +## Get enabled experiments ### Code samples @@ -562,7 +562,44 @@ curl -X GET http://coder-server:8080/api/v2/experiments \ | ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------- | | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Experiment](schemas.md#codersdkexperiment) | -

Response Schema

+

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| -------------- | ----- | -------- | ------------ | ----------- | +| `[array item]` | array | false | | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Get safe experiments + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/experiments/available \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /experiments/available` + +### Example responses + +> 200 Response + +```json +["moons"] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Experiment](schemas.md#codersdkexperiment) | + +

Response Schema

Status Code **200** diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 1aa5101d9f..dccdc383cc 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -864,6 +864,19 @@ export const getExperiments = async (): Promise => { } }; +export const getAvailableExperiments = + async (): Promise => { + try { + const response = await axios.get("/api/v2/experiments/available"); + return response.data; + } catch (error) { + if (axios.isAxiosError(error) && error.response?.status === 404) { + return { safe: [] }; + } + throw error; + } + }; + export const getExternalAuthProvider = async ( provider: string, ): Promise => { diff --git a/site/src/api/queries/experiments.ts b/site/src/api/queries/experiments.ts index 27126696a7..3d3618819f 100644 --- a/site/src/api/queries/experiments.ts +++ b/site/src/api/queries/experiments.ts @@ -19,3 +19,10 @@ export const experiments = (queryClient: QueryClient) => { }, } satisfies UseQueryOptions; }; + +export const availableExperiments = () => { + return { + queryKey: ["availableExperiments"], + queryFn: async () => API.getAvailableExperiments(), + }; +}; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 483e034775..29fbd56f5e 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -151,6 +151,11 @@ export interface AuthorizationRequest { // From codersdk/authorization.go export type AuthorizationResponse = Record; +// From codersdk/deployment.go +export interface AvailableExperiments { + readonly safe: Experiment[]; +} + // From codersdk/deployment.go export interface BuildInfoResponse { readonly external_url: string; diff --git a/site/src/components/DeploySettingsLayout/Option.tsx b/site/src/components/DeploySettingsLayout/Option.tsx index 0b81315799..e8c51eac1e 100644 --- a/site/src/components/DeploySettingsLayout/Option.tsx +++ b/site/src/components/DeploySettingsLayout/Option.tsx @@ -4,6 +4,7 @@ import Box, { BoxProps } from "@mui/material/Box"; import { useTheme } from "@mui/system"; import { DisabledBadge, EnabledBadge } from "./Badges"; import { css } from "@emotion/react"; +import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"; export const OptionName: FC = (props) => { const { children } = props; @@ -38,11 +39,11 @@ export const OptionDescription: FC = (props) => { }; interface OptionValueProps { - children?: boolean | number | string | string[]; + children?: boolean | number | string | string[] | Record; } export const OptionValue: FC = (props) => { - const { children } = props; + const { children: value } = props; const theme = useTheme(); const optionStyles = css` @@ -56,35 +57,74 @@ export const OptionValue: FC = (props) => { } `; - if (typeof children === "boolean") { - return children ? : ; + const listStyles = css` + margin: 0, + padding: 0, + display: "flex", + flex-direction: "column", + gap: theme.spacing(0.5), + `; + + if (typeof value === "boolean") { + return value ? : ; } - if (typeof children === "number") { - return {children}; + if (typeof value === "number") { + return {value}; } - if (!children || children.length === 0) { + if (!value || value.length === 0) { return Not set; } - if (typeof children === "string") { - return {children}; + if (typeof value === "string") { + return {value}; } - if (Array.isArray(children)) { + if (typeof value === "object" && !Array.isArray(value)) { return ( -
    - {children.map((item) => ( +
      + {Object.entries(value) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([option, isEnabled]) => ( +
    • + + {isEnabled && ( + theme.palette.success.light, + margin: (theme) => theme.spacing(0, 1), + }} + /> + )} + {option} + +
    • + ))} +
    + ); + } + + if (Array.isArray(value)) { + return ( +
      + {value.map((item) => (
    • {item}
    • @@ -93,7 +133,7 @@ export const OptionValue: FC = (props) => { ); } - return {JSON.stringify(children)}; + return {JSON.stringify(value)}; }; interface OptionConfigProps extends BoxProps { diff --git a/site/src/components/DeploySettingsLayout/OptionsTable.tsx b/site/src/components/DeploySettingsLayout/OptionsTable.tsx index b50e4f1824..a8920c2edd 100644 --- a/site/src/components/DeploySettingsLayout/OptionsTable.tsx +++ b/site/src/components/DeploySettingsLayout/OptionsTable.tsx @@ -19,7 +19,8 @@ import { optionValue } from "./optionValue"; const OptionsTable: FC<{ options: ClibaseOption[]; -}> = ({ options }) => { + additionalValues?: string[]; +}> = ({ options, additionalValues }) => { if (options.length === 0) { return

      No options to configure

      ; } @@ -95,7 +96,9 @@ const OptionsTable: FC<{ - {optionValue(option)} + + {optionValue(option, additionalValues)} + ); diff --git a/site/src/components/DeploySettingsLayout/optionValue.test.ts b/site/src/components/DeploySettingsLayout/optionValue.test.ts index 890fa6a72c..f9797b98f6 100644 --- a/site/src/components/DeploySettingsLayout/optionValue.test.ts +++ b/site/src/components/DeploySettingsLayout/optionValue.test.ts @@ -13,6 +13,7 @@ const defaultOption: ClibaseOption = { describe("optionValue", () => { it.each<{ option: ClibaseOption; + additionalValues?: string[]; expected: unknown; }>([ { @@ -67,7 +68,46 @@ describe("optionValue", () => { }, expected: [`"123"->"foo"`, `"456"->"bar"`, `"789"->"baz"`], }, - ])(`[$option.name]optionValue($option.value)`, ({ option, expected }) => { - expect(optionValue(option)).toEqual(expected); - }); + { + option: { + ...defaultOption, + name: "Experiments", + value: ["single_tailnet"], + }, + additionalValues: ["single_tailnet", "deployment_health_page"], + expected: { single_tailnet: true, deployment_health_page: false }, + }, + { + option: { + ...defaultOption, + name: "Experiments", + value: [], + }, + additionalValues: ["single_tailnet", "deployment_health_page"], + expected: { single_tailnet: false, deployment_health_page: false }, + }, + { + option: { + ...defaultOption, + name: "Experiments", + value: ["moons"], + }, + additionalValues: ["single_tailnet", "deployment_health_page"], + expected: { single_tailnet: false, deployment_health_page: false }, + }, + { + option: { + ...defaultOption, + name: "Experiments", + value: ["*"], + }, + additionalValues: ["single_tailnet", "deployment_health_page"], + expected: { single_tailnet: true, deployment_health_page: true }, + }, + ])( + `[$option.name]optionValue($option.value)`, + ({ option, expected, additionalValues }) => { + expect(optionValue(option, additionalValues)).toEqual(expected); + }, + ); }); diff --git a/site/src/components/DeploySettingsLayout/optionValue.ts b/site/src/components/DeploySettingsLayout/optionValue.ts index 1976e52b45..6221356075 100644 --- a/site/src/components/DeploySettingsLayout/optionValue.ts +++ b/site/src/components/DeploySettingsLayout/optionValue.ts @@ -2,7 +2,10 @@ import { ClibaseOption } from "api/typesGenerated"; import { intervalToDuration, formatDuration } from "date-fns"; // optionValue is a helper function to format the value of a specific deployment options -export function optionValue(option: ClibaseOption) { +export function optionValue( + option: ClibaseOption, + additionalValues?: string[], +) { switch (option.name) { case "Max Token Lifetime": case "Session Duration": @@ -19,6 +22,27 @@ export function optionValue(option: ClibaseOption) { return Object.entries(option.value as Record).map( ([key, value]) => `"${key}"->"${value}"`, ); + case "Experiments": { + const experimentMap: Record | undefined = + additionalValues?.reduce( + (acc, v) => { + return { ...acc, [v]: option.value.includes("*") ? true : false }; + }, + {} as Record, + ); + + if (!experimentMap) { + break; + } + + for (const v of option.value) { + if (Object.hasOwn(experimentMap, v)) { + experimentMap[v] = true; + } + } + + return experimentMap; + } default: return option.value; } diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx index 42cfcc1e63..a4f45ae7a2 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPage.tsx @@ -4,6 +4,7 @@ import { useQuery } from "react-query"; import { pageTitle } from "utils/page"; import { deploymentDAUs } from "api/queries/deployment"; import { entitlements } from "api/queries/entitlements"; +import { availableExperiments } from "api/queries/experiments"; import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout"; import { GeneralSettingsPageView } from "./GeneralSettingsPageView"; @@ -11,6 +12,7 @@ const GeneralSettingsPage: FC = () => { const { deploymentValues } = useDeploySettings(); const deploymentDAUsQuery = useQuery(deploymentDAUs()); const entitlementsQuery = useQuery(entitlements()); + const experimentsQuery = useQuery(availableExperiments()); return ( <> @@ -22,6 +24,7 @@ const GeneralSettingsPage: FC = () => { deploymentDAUs={deploymentDAUsQuery.data} deploymentDAUsError={deploymentDAUsQuery.error} entitlements={entitlementsQuery.data} + safeExperiments={experimentsQuery.data?.safe ?? []} /> ); diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx index 42416e828b..2b3ec0afa1 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.stories.tsx @@ -34,12 +34,13 @@ const meta: Meta = { description: "Enable one or more experiments. These are not ready for production. Separate multiple experiments with commas, or enter '*' to opt-in to all available experiments.", flag: "experiments", - value: ["*", "moons", "single_tailnet", "deployment_health_page"], + value: ["single_tailnet"], flag_shorthand: "", hidden: false, }, ], deploymentDAUs: MockDeploymentDAUResponse, + safeExperiments: ["single_tailnet", "deployment_health_page"], }, }; @@ -69,3 +70,38 @@ export const DAUError: Story = { }), }, }; + +export const allExperimentsEnabled: Story = { + args: { + deploymentOptions: [ + { + name: "Access URL", + description: + "The URL that users will use to access the Coder deployment.", + flag: "access-url", + flag_shorthand: "", + value: "https://dev.coder.com", + hidden: false, + }, + { + name: "Wildcard Access URL", + description: + 'Specifies the wildcard hostname to use for workspace applications in the form "*.example.com".', + flag: "wildcard-access-url", + flag_shorthand: "", + value: "*--apps.dev.coder.com", + hidden: false, + }, + { + name: "Experiments", + description: + "Enable one or more experiments. These are not ready for production. Separate multiple experiments with commas, or enter '*' to opt-in to all available experiments.", + flag: "experiments", + value: ["*"], + flag_shorthand: "", + hidden: false, + }, + ], + safeExperiments: ["single_tailnet", "deployment_health_page"], + }, +}; diff --git a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx index 21623d813e..f5a3f2abf2 100644 --- a/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx +++ b/site/src/pages/DeploySettingsPage/GeneralSettingsPage/GeneralSettingsPageView.tsx @@ -1,5 +1,10 @@ import Box from "@mui/material/Box"; -import { ClibaseOption, DAUsResponse, Entitlements } from "api/typesGenerated"; +import { + ClibaseOption, + DAUsResponse, + Entitlements, + Experiments, +} from "api/typesGenerated"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { ActiveUserChart, @@ -17,12 +22,14 @@ export type GeneralSettingsPageViewProps = { deploymentDAUs?: DAUsResponse; deploymentDAUsError: unknown; entitlements: Entitlements | undefined; + safeExperiments: Experiments | undefined; }; export const GeneralSettingsPageView = ({ deploymentOptions, deploymentDAUs, deploymentDAUsError, entitlements, + safeExperiments, }: GeneralSettingsPageViewProps): JSX.Element => { return ( <> @@ -57,6 +64,7 @@ export const GeneralSettingsPageView = ({ "Wildcard Access URL", "Experiments", )} + additionalValues={safeExperiments} />