feat: add all safe experiments to the deployment page (#10276)

* added new option table type for experiments

* added tests

* fixed go tests

* added go test for new param

* removing query change

* clearing ExperimentsAll

* dont mutate ExperimentsAll

* added new route for safe experiments

* added new route for safe experiments

* added test for new route

* PR feedback

* altered design

* alias children
This commit is contained in:
Kira Pilot 2023-10-17 14:49:19 -04:00 committed by GitHub
parent 35f9e2ef7f
commit 1656249e07
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 360 additions and 39 deletions

32
coderd/apidoc/docs.go generated
View File

@ -587,8 +587,36 @@ const docTemplate = `{
"tags": [ "tags": [
"General" "General"
], ],
"summary": "Get experiments", "summary": "Get enabled experiments",
"operationId": "get-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": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",

View File

@ -497,8 +497,32 @@
], ],
"produces": ["application/json"], "produces": ["application/json"],
"tags": ["General"], "tags": ["General"],
"summary": "Get experiments", "summary": "Get enabled experiments",
"operationId": "get-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": { "responses": {
"200": { "200": {
"description": "OK", "description": "OK",

View File

@ -597,6 +597,7 @@ func New(options *Options) *API {
}) })
r.Route("/experiments", func(r chi.Router) { r.Route("/experiments", func(r chi.Router) {
r.Use(apiKeyMiddleware) r.Use(apiKeyMiddleware)
r.Get("/available", handleExperimentsSafe)
r.Get("/", api.handleExperimentsGet) r.Get("/", api.handleExperimentsGet)
}) })
r.Get("/updatecheck", api.updateCheck) r.Get("/updatecheck", api.updateCheck)

View File

@ -4,10 +4,11 @@ import (
"net/http" "net/http"
"github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
) )
// @Summary Get experiments // @Summary Get enabled experiments
// @ID get-experiments // @ID get-enabled-experiments
// @Security CoderSessionToken // @Security CoderSessionToken
// @Produce json // @Produce json
// @Tags General // @Tags General
@ -17,3 +18,17 @@ func (api *API) handleExperimentsGet(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
httpapi.Write(ctx, rw, http.StatusOK, api.Experiments) 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,
})
}

View File

@ -116,4 +116,21 @@ func Test_Experiments(t *testing.T) {
require.Error(t, err) require.Error(t, err)
require.ErrorContains(t, err, httpmw.SignedOutErrorMessage) 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)
})
} }

View File

@ -2013,12 +2013,13 @@ var ExperimentsAll = Experiments{
ExperimentSingleTailnet, 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. // Multiple experiments may be enabled at the same time.
// Experiments are not safe for production use, and are not guaranteed to // Experiments are not safe for production use, and are not guaranteed to
// be backwards compatible. They may be removed or renamed at any time. // be backwards compatible. They may be removed or renamed at any time.
type Experiments []Experiment type Experiments []Experiment
// Returns a list of experiments that are enabled for the deployment.
func (e Experiments) Enabled(ex Experiment) bool { func (e Experiments) Enabled(ex Experiment) bool {
for _, v := range e { for _, v := range e {
if v == ex { if v == ex {
@ -2041,6 +2042,25 @@ func (c *Client) Experiments(ctx context.Context) (Experiments, error) {
return exp, json.NewDecoder(res.Body).Decode(&exp) 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 { type DAUsResponse struct {
Entries []DAUEntry `json:"entries"` Entries []DAUEntry `json:"entries"`
TZHourOffset int `json:"tz_hour_offset"` TZHourOffset int `json:"tz_hour_offset"`

41
docs/api/general.md generated
View File

@ -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). To perform this operation, you must be authenticated. [Learn more](authentication.md).
## Get experiments ## Get enabled experiments
### Code samples ### 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) | | 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.Experiment](schemas.md#codersdkexperiment) |
<h3 id="get-experiments-responseschema">Response Schema</h3> <h3 id="get-enabled-experiments-responseschema">Response Schema</h3>
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) |
<h3 id="get-safe-experiments-responseschema">Response Schema</h3>
Status Code **200** Status Code **200**

View File

@ -864,6 +864,19 @@ export const getExperiments = async (): Promise<TypesGen.Experiment[]> => {
} }
}; };
export const getAvailableExperiments =
async (): Promise<TypesGen.AvailableExperiments> => {
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 ( export const getExternalAuthProvider = async (
provider: string, provider: string,
): Promise<TypesGen.ExternalAuth> => { ): Promise<TypesGen.ExternalAuth> => {

View File

@ -19,3 +19,10 @@ export const experiments = (queryClient: QueryClient) => {
}, },
} satisfies UseQueryOptions<Experiments>; } satisfies UseQueryOptions<Experiments>;
}; };
export const availableExperiments = () => {
return {
queryKey: ["availableExperiments"],
queryFn: async () => API.getAvailableExperiments(),
};
};

View File

@ -151,6 +151,11 @@ export interface AuthorizationRequest {
// From codersdk/authorization.go // From codersdk/authorization.go
export type AuthorizationResponse = Record<string, boolean>; export type AuthorizationResponse = Record<string, boolean>;
// From codersdk/deployment.go
export interface AvailableExperiments {
readonly safe: Experiment[];
}
// From codersdk/deployment.go // From codersdk/deployment.go
export interface BuildInfoResponse { export interface BuildInfoResponse {
readonly external_url: string; readonly external_url: string;

View File

@ -4,6 +4,7 @@ import Box, { BoxProps } from "@mui/material/Box";
import { useTheme } from "@mui/system"; import { useTheme } from "@mui/system";
import { DisabledBadge, EnabledBadge } from "./Badges"; import { DisabledBadge, EnabledBadge } from "./Badges";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined";
export const OptionName: FC<PropsWithChildren> = (props) => { export const OptionName: FC<PropsWithChildren> = (props) => {
const { children } = props; const { children } = props;
@ -38,11 +39,11 @@ export const OptionDescription: FC<PropsWithChildren> = (props) => {
}; };
interface OptionValueProps { interface OptionValueProps {
children?: boolean | number | string | string[]; children?: boolean | number | string | string[] | Record<string, boolean>;
} }
export const OptionValue: FC<OptionValueProps> = (props) => { export const OptionValue: FC<OptionValueProps> = (props) => {
const { children } = props; const { children: value } = props;
const theme = useTheme(); const theme = useTheme();
const optionStyles = css` const optionStyles = css`
@ -56,35 +57,74 @@ export const OptionValue: FC<OptionValueProps> = (props) => {
} }
`; `;
if (typeof children === "boolean") { const listStyles = css`
return children ? <EnabledBadge /> : <DisabledBadge />; margin: 0,
padding: 0,
display: "flex",
flex-direction: "column",
gap: theme.spacing(0.5),
`;
if (typeof value === "boolean") {
return value ? <EnabledBadge /> : <DisabledBadge />;
} }
if (typeof children === "number") { if (typeof value === "number") {
return <span css={optionStyles}>{children}</span>; return <span css={optionStyles}>{value}</span>;
} }
if (!children || children.length === 0) { if (!value || value.length === 0) {
return <span css={optionStyles}>Not set</span>; return <span css={optionStyles}>Not set</span>;
} }
if (typeof children === "string") { if (typeof value === "string") {
return <span css={optionStyles}>{children}</span>; return <span css={optionStyles}>{value}</span>;
} }
if (Array.isArray(children)) { if (typeof value === "object" && !Array.isArray(value)) {
return ( return (
<ul <ul css={listStyles && { listStyle: "none" }}>
css={{ {Object.entries(value)
margin: 0, .sort((a, b) => a[0].localeCompare(b[0]))
padding: 0, .map(([option, isEnabled]) => (
listStylePosition: "inside", <li
display: "flex", key={option}
flexDirection: "column", css={[
gap: theme.spacing(0.5), optionStyles,
}} !isEnabled && {
> marginLeft: 32,
{children.map((item) => ( color: theme.palette.text.disabled,
},
]}
>
<Box
sx={{
display: "inline-flex",
alignItems: "center",
}}
>
{isEnabled && (
<CheckCircleOutlined
sx={{
width: 16,
height: 16,
color: (theme) => theme.palette.success.light,
margin: (theme) => theme.spacing(0, 1),
}}
/>
)}
{option}
</Box>
</li>
))}
</ul>
);
}
if (Array.isArray(value)) {
return (
<ul css={listStyles && { listStylePosition: "inside" }}>
{value.map((item) => (
<li key={item} css={optionStyles}> <li key={item} css={optionStyles}>
{item} {item}
</li> </li>
@ -93,7 +133,7 @@ export const OptionValue: FC<OptionValueProps> = (props) => {
); );
} }
return <span css={optionStyles}>{JSON.stringify(children)}</span>; return <span css={optionStyles}>{JSON.stringify(value)}</span>;
}; };
interface OptionConfigProps extends BoxProps { interface OptionConfigProps extends BoxProps {

View File

@ -19,7 +19,8 @@ import { optionValue } from "./optionValue";
const OptionsTable: FC<{ const OptionsTable: FC<{
options: ClibaseOption[]; options: ClibaseOption[];
}> = ({ options }) => { additionalValues?: string[];
}> = ({ options, additionalValues }) => {
if (options.length === 0) { if (options.length === 0) {
return <p>No options to configure</p>; return <p>No options to configure</p>;
} }
@ -95,7 +96,9 @@ const OptionsTable: FC<{
</TableCell> </TableCell>
<TableCell> <TableCell>
<OptionValue>{optionValue(option)}</OptionValue> <OptionValue>
{optionValue(option, additionalValues)}
</OptionValue>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );

View File

@ -13,6 +13,7 @@ const defaultOption: ClibaseOption = {
describe("optionValue", () => { describe("optionValue", () => {
it.each<{ it.each<{
option: ClibaseOption; option: ClibaseOption;
additionalValues?: string[];
expected: unknown; expected: unknown;
}>([ }>([
{ {
@ -67,7 +68,46 @@ describe("optionValue", () => {
}, },
expected: [`"123"->"foo"`, `"456"->"bar"`, `"789"->"baz"`], 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);
},
);
}); });

View File

@ -2,7 +2,10 @@ import { ClibaseOption } from "api/typesGenerated";
import { intervalToDuration, formatDuration } from "date-fns"; import { intervalToDuration, formatDuration } from "date-fns";
// optionValue is a helper function to format the value of a specific deployment options // 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) { switch (option.name) {
case "Max Token Lifetime": case "Max Token Lifetime":
case "Session Duration": case "Session Duration":
@ -19,6 +22,27 @@ export function optionValue(option: ClibaseOption) {
return Object.entries(option.value as Record<string, string>).map( return Object.entries(option.value as Record<string, string>).map(
([key, value]) => `"${key}"->"${value}"`, ([key, value]) => `"${key}"->"${value}"`,
); );
case "Experiments": {
const experimentMap: Record<string, boolean> | undefined =
additionalValues?.reduce(
(acc, v) => {
return { ...acc, [v]: option.value.includes("*") ? true : false };
},
{} as Record<string, boolean>,
);
if (!experimentMap) {
break;
}
for (const v of option.value) {
if (Object.hasOwn(experimentMap, v)) {
experimentMap[v] = true;
}
}
return experimentMap;
}
default: default:
return option.value; return option.value;
} }

View File

@ -4,6 +4,7 @@ import { useQuery } from "react-query";
import { pageTitle } from "utils/page"; import { pageTitle } from "utils/page";
import { deploymentDAUs } from "api/queries/deployment"; import { deploymentDAUs } from "api/queries/deployment";
import { entitlements } from "api/queries/entitlements"; import { entitlements } from "api/queries/entitlements";
import { availableExperiments } from "api/queries/experiments";
import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout"; import { useDeploySettings } from "components/DeploySettingsLayout/DeploySettingsLayout";
import { GeneralSettingsPageView } from "./GeneralSettingsPageView"; import { GeneralSettingsPageView } from "./GeneralSettingsPageView";
@ -11,6 +12,7 @@ const GeneralSettingsPage: FC = () => {
const { deploymentValues } = useDeploySettings(); const { deploymentValues } = useDeploySettings();
const deploymentDAUsQuery = useQuery(deploymentDAUs()); const deploymentDAUsQuery = useQuery(deploymentDAUs());
const entitlementsQuery = useQuery(entitlements()); const entitlementsQuery = useQuery(entitlements());
const experimentsQuery = useQuery(availableExperiments());
return ( return (
<> <>
@ -22,6 +24,7 @@ const GeneralSettingsPage: FC = () => {
deploymentDAUs={deploymentDAUsQuery.data} deploymentDAUs={deploymentDAUsQuery.data}
deploymentDAUsError={deploymentDAUsQuery.error} deploymentDAUsError={deploymentDAUsQuery.error}
entitlements={entitlementsQuery.data} entitlements={entitlementsQuery.data}
safeExperiments={experimentsQuery.data?.safe ?? []}
/> />
</> </>
); );

View File

@ -34,12 +34,13 @@ const meta: Meta<typeof GeneralSettingsPageView> = {
description: 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.", "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", flag: "experiments",
value: ["*", "moons", "single_tailnet", "deployment_health_page"], value: ["single_tailnet"],
flag_shorthand: "", flag_shorthand: "",
hidden: false, hidden: false,
}, },
], ],
deploymentDAUs: MockDeploymentDAUResponse, 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"],
},
};

View File

@ -1,5 +1,10 @@
import Box from "@mui/material/Box"; 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 { ErrorAlert } from "components/Alert/ErrorAlert";
import { import {
ActiveUserChart, ActiveUserChart,
@ -17,12 +22,14 @@ export type GeneralSettingsPageViewProps = {
deploymentDAUs?: DAUsResponse; deploymentDAUs?: DAUsResponse;
deploymentDAUsError: unknown; deploymentDAUsError: unknown;
entitlements: Entitlements | undefined; entitlements: Entitlements | undefined;
safeExperiments: Experiments | undefined;
}; };
export const GeneralSettingsPageView = ({ export const GeneralSettingsPageView = ({
deploymentOptions, deploymentOptions,
deploymentDAUs, deploymentDAUs,
deploymentDAUsError, deploymentDAUsError,
entitlements, entitlements,
safeExperiments,
}: GeneralSettingsPageViewProps): JSX.Element => { }: GeneralSettingsPageViewProps): JSX.Element => {
return ( return (
<> <>
@ -57,6 +64,7 @@ export const GeneralSettingsPageView = ({
"Wildcard Access URL", "Wildcard Access URL",
"Experiments", "Experiments",
)} )}
additionalValues={safeExperiments}
/> />
</Stack> </Stack>
</> </>