diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go
index 3b6ab51263b3d..b5041179736d2 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 849387dbfee63..6e2e5c0902ddb 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 f301265cc5ad7..7ab2e578462b1 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 1a8bb5ce1812a..f7debd8c68bbb 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 0f498e7e7cf2b..4288b9953fec6 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 195915b052ea4..4622808853aa5 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 1362b6edcd280..577781136ef61 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 1aa5101d9f4f4..dccdc383ccbf9 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 cc6a2a067fa1d..c24941d79273b 100644
--- a/site/src/api/queries/experiments.ts
+++ b/site/src/api/queries/experiments.ts
@@ -9,3 +9,10 @@ export const experiments = () => {
getMetadataAsJSON("experiments") ?? API.getExperiments(),
};
};
+
+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 483e034775b79..29fbd56f5e316 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 0b813157999f8..e8c51eac1e5f1 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 (typeof value === "object" && !Array.isArray(value)) {
+ return (
+
+ {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(children)) {
+ if (Array.isArray(value)) {
return (
-
- {children.map((item) => (
+
+ {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 b50e4f18242c0..a8920c2eddc02 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 890fa6a72c638..f9797b98f60d2 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 1976e52b4593b..6221356075fbb 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 42cfcc1e639bf..a4f45ae7a270f 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 42416e828b685..2b3ec0afa13d5 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 21623d813ee50..f5a3f2abf2c59 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}
/>
>