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": [
"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",

View File

@ -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",

View File

@ -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)

View File

@ -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,
})
}

View File

@ -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)
})
}

View File

@ -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"`

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).
## 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) |
<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**

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 (
provider: string,
): Promise<TypesGen.ExternalAuth> => {

View File

@ -19,3 +19,10 @@ export const experiments = (queryClient: QueryClient) => {
},
} 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
export type AuthorizationResponse = Record<string, boolean>;
// From codersdk/deployment.go
export interface AvailableExperiments {
readonly safe: Experiment[];
}
// From codersdk/deployment.go
export interface BuildInfoResponse {
readonly external_url: string;

View File

@ -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<PropsWithChildren> = (props) => {
const { children } = props;
@ -38,11 +39,11 @@ export const OptionDescription: FC<PropsWithChildren> = (props) => {
};
interface OptionValueProps {
children?: boolean | number | string | string[];
children?: boolean | number | string | string[] | Record<string, boolean>;
}
export const OptionValue: FC<OptionValueProps> = (props) => {
const { children } = props;
const { children: value } = props;
const theme = useTheme();
const optionStyles = css`
@ -56,35 +57,74 @@ export const OptionValue: FC<OptionValueProps> = (props) => {
}
`;
if (typeof children === "boolean") {
return children ? <EnabledBadge /> : <DisabledBadge />;
const listStyles = css`
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") {
return <span css={optionStyles}>{children}</span>;
if (typeof value === "number") {
return <span css={optionStyles}>{value}</span>;
}
if (!children || children.length === 0) {
if (!value || value.length === 0) {
return <span css={optionStyles}>Not set</span>;
}
if (typeof children === "string") {
return <span css={optionStyles}>{children}</span>;
if (typeof value === "string") {
return <span css={optionStyles}>{value}</span>;
}
if (Array.isArray(children)) {
if (typeof value === "object" && !Array.isArray(value)) {
return (
<ul
css={{
margin: 0,
padding: 0,
listStylePosition: "inside",
display: "flex",
flexDirection: "column",
gap: theme.spacing(0.5),
}}
>
{children.map((item) => (
<ul css={listStyles && { listStyle: "none" }}>
{Object.entries(value)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([option, isEnabled]) => (
<li
key={option}
css={[
optionStyles,
!isEnabled && {
marginLeft: 32,
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}>
{item}
</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 {

View File

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

View File

@ -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);
},
);
});

View File

@ -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<string, string>).map(
([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:
return option.value;
}

View File

@ -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 ?? []}
/>
</>
);

View File

@ -34,12 +34,13 @@ const meta: Meta<typeof GeneralSettingsPageView> = {
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"],
},
};

View File

@ -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}
/>
</Stack>
</>