Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 15 commits into from
Oct 17, 2023
Merged
8 changes: 8 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 10 additions & 1 deletion coderd/experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,25 @@ import (
"net/http"

"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/codersdk"
)

// @Summary Get experiments
// @ID get-experiments
// @Security CoderSessionToken
// @Produce json
// @Tags General
// @Param include_all query bool false "All available experiments"
// @Success 200 {array} codersdk.Experiment
// @Router /experiments [get]
func (api *API) handleExperimentsGet(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
httpapi.Write(ctx, rw, http.StatusOK, api.Experiments)
all := r.URL.Query().Has("include_all")

if !all {
httpapi.Write(ctx, rw, http.StatusOK, api.Experiments)
return
}

httpapi.Write(ctx, rw, http.StatusOK, codersdk.ExperimentsAll)
}
28 changes: 23 additions & 5 deletions coderd/experiments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func Test_Experiments(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

experiments, err := client.Experiments(ctx)
experiments, err := client.Experiments(ctx, codersdk.ExperimentOptions{})
require.NoError(t, err)
require.NotNil(t, experiments)
require.Empty(t, experiments)
Expand All @@ -44,7 +44,7 @@ func Test_Experiments(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

experiments, err := client.Experiments(ctx)
experiments, err := client.Experiments(ctx, codersdk.ExperimentOptions{})
require.NoError(t, err)
require.NotNil(t, experiments)
// Should be lower-cased.
Expand All @@ -66,7 +66,7 @@ func Test_Experiments(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

experiments, err := client.Experiments(ctx)
experiments, err := client.Experiments(ctx, codersdk.ExperimentOptions{})
require.NoError(t, err)
require.NotNil(t, experiments)
require.ElementsMatch(t, codersdk.ExperimentsAll, experiments)
Expand All @@ -88,7 +88,7 @@ func Test_Experiments(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

experiments, err := client.Experiments(ctx)
experiments, err := client.Experiments(ctx, codersdk.ExperimentOptions{})
require.NoError(t, err)
require.NotNil(t, experiments)
require.ElementsMatch(t, append(codersdk.ExperimentsAll, "danger"), experiments)
Expand All @@ -112,8 +112,26 @@ func Test_Experiments(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

_, err := client.Experiments(ctx)
_, err := client.Experiments(ctx, codersdk.ExperimentOptions{})
require.Error(t, err)
require.ErrorContains(t, err, httpmw.SignedOutErrorMessage)
})

t.Run("include_all query param", func(t *testing.T) {
t.Parallel()
cfg := coderdtest.DeploymentValues(t)
cfg.Experiments = []string{"foo", "BAR"}
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.Experiments(ctx, codersdk.ExperimentOptions{IncludeAll: true})
require.NoError(t, err)
require.NotNil(t, experiments)
require.ElementsMatch(t, codersdk.ExperimentsAll, experiments)
})
}
25 changes: 22 additions & 3 deletions codersdk/deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -2028,8 +2029,26 @@ func (e Experiments) Enabled(ex Experiment) bool {
return false
}

func (c *Client) Experiments(ctx context.Context) (Experiments, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/experiments", nil)
type ExperimentOptions struct {
// All signifies that all experiments - rather than just those that are enabled -
// should be returned
IncludeAll bool `json:"include_all,omitempty"`
}

// asRequestOption returns a function that can be used in (*Client).Request.
// It modifies the request query parameters.
func (o ExperimentOptions) asRequestOption() RequestOption {
return func(r *http.Request) {
q := r.URL.Query()
if o.IncludeAll {
q.Set("include_all", "true")
}
r.URL.RawQuery = q.Encode()
}
}

func (c *Client) Experiments(ctx context.Context, opts ExperimentOptions) (Experiments, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/v2/experiments", nil, opts.asRequestOption())
if err != nil {
return nil, err
}
Expand Down
6 changes: 6 additions & 0 deletions docs/api/general.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,9 +852,11 @@ export const getEntitlements = async (): Promise<TypesGen.Entitlements> => {
}
};

export const getExperiments = async (): Promise<TypesGen.Experiment[]> => {
export const getExperiments = async (
params?: TypesGen.ExperimentOptions,
): Promise<TypesGen.Experiment[]> => {
try {
const response = await axios.get("/api/v2/experiments");
const response = await axios.get("/api/v2/experiments", { params });
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
Expand Down
9 changes: 8 additions & 1 deletion site/src/api/queries/experiments.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as API from "api/api";
import { Experiments } from "api/typesGenerated";
import { Experiments, ExperimentOptions } from "api/typesGenerated";
import { getMetadataAsJSON } from "utils/metadata";

export const experiments = () => {
Expand All @@ -9,3 +9,10 @@ export const experiments = () => {
getMetadataAsJSON<Experiments>("experiments") ?? API.getExperiments(),
};
};

export const updatedExperiments = (params?: ExperimentOptions) => {
return {
queryKey: ["experiments"],
queryFn: async () => API.getExperiments(params),
};
};
5 changes: 5 additions & 0 deletions site/src/api/typesGenerated.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

52 changes: 41 additions & 11 deletions site/src/components/DeploySettingsLayout/Option.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -38,7 +39,7 @@ 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) => {
Expand All @@ -56,6 +57,15 @@ export const OptionValue: FC<OptionValueProps> = (props) => {
}
`;

const listStyles = css`
margin: 0,
padding: 0,
listStylePosition: "inside",
display: "flex",
flexDirection: "column",
gap: theme.spacing(0.5),
`;

if (typeof children === "boolean") {
return children ? <EnabledBadge /> : <DisabledBadge />;
}
Expand All @@ -72,18 +82,38 @@ export const OptionValue: FC<OptionValueProps> = (props) => {
return <span css={optionStyles}>{children}</span>;
}

if (typeof children === "object" && !Array.isArray(children)) {
return (
<ul css={listStyles}>
{Object.entries(children).map(([option, isEnabled]) => (
<li key={option} css={optionStyles}>
<Box
sx={{
display: "inline-flex",
alignItems: "center",
}}
>
{option}
{isEnabled && (
<CheckCircleOutlined
sx={{
width: 16,
height: 16,
color: (theme) => theme.palette.success.light,
margin: (theme) => theme.spacing(0, 1),
}}
/>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like the check. If nothing is set though, it is tricky to know if nothing is set, or they are all set.

Can we do something to indicate the value is available, but not set? Like maybe greying out the words? Just one idea.

Screenshot from 2023-10-16 10-57-20

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah! I like the design you've added above. I'll throw both in Slack and see what folks think.

Copy link
Member Author

@Kira-Pilot Kira-Pilot Oct 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Screenshot 2023-10-17 at 1 35 28 PM

I actually kind of like either what I had previously, or what you suggested, but maybe not the mix of both as suggested in Slack.

)}
</Box>
</li>
))}
</ul>
);
}

if (Array.isArray(children)) {
return (
<ul
css={{
margin: 0,
padding: 0,
listStylePosition: "inside",
display: "flex",
flexDirection: "column",
gap: theme.spacing(0.5),
}}
>
<ul css={listStyles}>
{children.map((item) => (
<li key={item} css={optionStyles}>
{item}
Expand Down
7 changes: 5 additions & 2 deletions site/src/components/DeploySettingsLayout/OptionsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
}
Expand Down Expand Up @@ -95,7 +96,9 @@ const OptionsTable: FC<{
</TableCell>

<TableCell>
<OptionValue>{optionValue(option)}</OptionValue>
<OptionValue>
{optionValue(option, additionalValues)}
</OptionValue>
</TableCell>
</TableRow>
);
Expand Down
46 changes: 43 additions & 3 deletions site/src/components/DeploySettingsLayout/optionValue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const defaultOption: ClibaseOption = {
describe("optionValue", () => {
it.each<{
option: ClibaseOption;
additionalValues?: string[];
expected: unknown;
}>([
{
Expand Down Expand Up @@ -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);
},
);
});
Loading