Skip to content

Commit 1656249

Browse files
authored
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
1 parent 35f9e2e commit 1656249

File tree

17 files changed

+360
-39
lines changed

17 files changed

+360
-39
lines changed

coderd/apidoc/docs.go

+30-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+26-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

+1
Original file line numberDiff line numberDiff line change
@@ -597,6 +597,7 @@ func New(options *Options) *API {
597597
})
598598
r.Route("/experiments", func(r chi.Router) {
599599
r.Use(apiKeyMiddleware)
600+
r.Get("/available", handleExperimentsSafe)
600601
r.Get("/", api.handleExperimentsGet)
601602
})
602603
r.Get("/updatecheck", api.updateCheck)

coderd/experiments.go

+17-2
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import (
44
"net/http"
55

66
"github.com/coder/coder/v2/coderd/httpapi"
7+
"github.com/coder/coder/v2/codersdk"
78
)
89

9-
// @Summary Get experiments
10-
// @ID get-experiments
10+
// @Summary Get enabled experiments
11+
// @ID get-enabled-experiments
1112
// @Security CoderSessionToken
1213
// @Produce json
1314
// @Tags General
@@ -17,3 +18,17 @@ func (api *API) handleExperimentsGet(rw http.ResponseWriter, r *http.Request) {
1718
ctx := r.Context()
1819
httpapi.Write(ctx, rw, http.StatusOK, api.Experiments)
1920
}
21+
22+
// @Summary Get safe experiments
23+
// @ID get-safe-experiments
24+
// @Security CoderSessionToken
25+
// @Produce json
26+
// @Tags General
27+
// @Success 200 {array} codersdk.Experiment
28+
// @Router /experiments/available [get]
29+
func handleExperimentsSafe(rw http.ResponseWriter, r *http.Request) {
30+
ctx := r.Context()
31+
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AvailableExperiments{
32+
Safe: codersdk.ExperimentsAll,
33+
})
34+
}

coderd/experiments_test.go

+17
Original file line numberDiff line numberDiff line change
@@ -116,4 +116,21 @@ func Test_Experiments(t *testing.T) {
116116
require.Error(t, err)
117117
require.ErrorContains(t, err, httpmw.SignedOutErrorMessage)
118118
})
119+
120+
t.Run("available experiments", func(t *testing.T) {
121+
t.Parallel()
122+
cfg := coderdtest.DeploymentValues(t)
123+
client := coderdtest.New(t, &coderdtest.Options{
124+
DeploymentValues: cfg,
125+
})
126+
_ = coderdtest.CreateFirstUser(t, client)
127+
128+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
129+
defer cancel()
130+
131+
experiments, err := client.SafeExperiments(ctx)
132+
require.NoError(t, err)
133+
require.NotNil(t, experiments)
134+
require.ElementsMatch(t, codersdk.ExperimentsAll, experiments.Safe)
135+
})
119136
}

codersdk/deployment.go

+21-1
Original file line numberDiff line numberDiff line change
@@ -2013,12 +2013,13 @@ var ExperimentsAll = Experiments{
20132013
ExperimentSingleTailnet,
20142014
}
20152015

2016-
// Experiments is a list of experiments that are enabled for the deployment.
2016+
// Experiments is a list of experiments.
20172017
// Multiple experiments may be enabled at the same time.
20182018
// Experiments are not safe for production use, and are not guaranteed to
20192019
// be backwards compatible. They may be removed or renamed at any time.
20202020
type Experiments []Experiment
20212021

2022+
// Returns a list of experiments that are enabled for the deployment.
20222023
func (e Experiments) Enabled(ex Experiment) bool {
20232024
for _, v := range e {
20242025
if v == ex {
@@ -2041,6 +2042,25 @@ func (c *Client) Experiments(ctx context.Context) (Experiments, error) {
20412042
return exp, json.NewDecoder(res.Body).Decode(&exp)
20422043
}
20432044

2045+
// AvailableExperiments is an expandable type that returns all safe experiments
2046+
// available to be used with a deployment.
2047+
type AvailableExperiments struct {
2048+
Safe []Experiment `json:"safe"`
2049+
}
2050+
2051+
func (c *Client) SafeExperiments(ctx context.Context) (AvailableExperiments, error) {
2052+
res, err := c.Request(ctx, http.MethodGet, "/api/v2/experiments/available", nil)
2053+
if err != nil {
2054+
return AvailableExperiments{}, err
2055+
}
2056+
defer res.Body.Close()
2057+
if res.StatusCode != http.StatusOK {
2058+
return AvailableExperiments{}, ReadBodyAsError(res)
2059+
}
2060+
var exp AvailableExperiments
2061+
return exp, json.NewDecoder(res.Body).Decode(&exp)
2062+
}
2063+
20442064
type DAUsResponse struct {
20452065
Entries []DAUEntry `json:"entries"`
20462066
TZHourOffset int `json:"tz_hour_offset"`

docs/api/general.md

+39-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/api/api.ts

+13
Original file line numberDiff line numberDiff line change
@@ -864,6 +864,19 @@ export const getExperiments = async (): Promise<TypesGen.Experiment[]> => {
864864
}
865865
};
866866

867+
export const getAvailableExperiments =
868+
async (): Promise<TypesGen.AvailableExperiments> => {
869+
try {
870+
const response = await axios.get("/api/v2/experiments/available");
871+
return response.data;
872+
} catch (error) {
873+
if (axios.isAxiosError(error) && error.response?.status === 404) {
874+
return { safe: [] };
875+
}
876+
throw error;
877+
}
878+
};
879+
867880
export const getExternalAuthProvider = async (
868881
provider: string,
869882
): Promise<TypesGen.ExternalAuth> => {

site/src/api/queries/experiments.ts

+7
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,10 @@ export const experiments = (queryClient: QueryClient) => {
1919
},
2020
} satisfies UseQueryOptions<Experiments>;
2121
};
22+
23+
export const availableExperiments = () => {
24+
return {
25+
queryKey: ["availableExperiments"],
26+
queryFn: async () => API.getAvailableExperiments(),
27+
};
28+
};

site/src/api/typesGenerated.ts

+5
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

site/src/components/DeploySettingsLayout/Option.tsx

+62-22
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import Box, { BoxProps } from "@mui/material/Box";
44
import { useTheme } from "@mui/system";
55
import { DisabledBadge, EnabledBadge } from "./Badges";
66
import { css } from "@emotion/react";
7+
import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined";
78

89
export const OptionName: FC<PropsWithChildren> = (props) => {
910
const { children } = props;
@@ -38,11 +39,11 @@ export const OptionDescription: FC<PropsWithChildren> = (props) => {
3839
};
3940

4041
interface OptionValueProps {
41-
children?: boolean | number | string | string[];
42+
children?: boolean | number | string | string[] | Record<string, boolean>;
4243
}
4344

4445
export const OptionValue: FC<OptionValueProps> = (props) => {
45-
const { children } = props;
46+
const { children: value } = props;
4647
const theme = useTheme();
4748

4849
const optionStyles = css`
@@ -56,35 +57,74 @@ export const OptionValue: FC<OptionValueProps> = (props) => {
5657
}
5758
`;
5859

59-
if (typeof children === "boolean") {
60-
return children ? <EnabledBadge /> : <DisabledBadge />;
60+
const listStyles = css`
61+
margin: 0,
62+
padding: 0,
63+
display: "flex",
64+
flex-direction: "column",
65+
gap: theme.spacing(0.5),
66+
`;
67+
68+
if (typeof value === "boolean") {
69+
return value ? <EnabledBadge /> : <DisabledBadge />;
6170
}
6271

63-
if (typeof children === "number") {
64-
return <span css={optionStyles}>{children}</span>;
72+
if (typeof value === "number") {
73+
return <span css={optionStyles}>{value}</span>;
6574
}
6675

67-
if (!children || children.length === 0) {
76+
if (!value || value.length === 0) {
6877
return <span css={optionStyles}>Not set</span>;
6978
}
7079

71-
if (typeof children === "string") {
72-
return <span css={optionStyles}>{children}</span>;
80+
if (typeof value === "string") {
81+
return <span css={optionStyles}>{value}</span>;
82+
}
83+
84+
if (typeof value === "object" && !Array.isArray(value)) {
85+
return (
86+
<ul css={listStyles && { listStyle: "none" }}>
87+
{Object.entries(value)
88+
.sort((a, b) => a[0].localeCompare(b[0]))
89+
.map(([option, isEnabled]) => (
90+
<li
91+
key={option}
92+
css={[
93+
optionStyles,
94+
!isEnabled && {
95+
marginLeft: 32,
96+
color: theme.palette.text.disabled,
97+
},
98+
]}
99+
>
100+
<Box
101+
sx={{
102+
display: "inline-flex",
103+
alignItems: "center",
104+
}}
105+
>
106+
{isEnabled && (
107+
<CheckCircleOutlined
108+
sx={{
109+
width: 16,
110+
height: 16,
111+
color: (theme) => theme.palette.success.light,
112+
margin: (theme) => theme.spacing(0, 1),
113+
}}
114+
/>
115+
)}
116+
{option}
117+
</Box>
118+
</li>
119+
))}
120+
</ul>
121+
);
73122
}
74123

75-
if (Array.isArray(children)) {
124+
if (Array.isArray(value)) {
76125
return (
77-
<ul
78-
css={{
79-
margin: 0,
80-
padding: 0,
81-
listStylePosition: "inside",
82-
display: "flex",
83-
flexDirection: "column",
84-
gap: theme.spacing(0.5),
85-
}}
86-
>
87-
{children.map((item) => (
126+
<ul css={listStyles && { listStylePosition: "inside" }}>
127+
{value.map((item) => (
88128
<li key={item} css={optionStyles}>
89129
{item}
90130
</li>
@@ -93,7 +133,7 @@ export const OptionValue: FC<OptionValueProps> = (props) => {
93133
);
94134
}
95135

96-
return <span css={optionStyles}>{JSON.stringify(children)}</span>;
136+
return <span css={optionStyles}>{JSON.stringify(value)}</span>;
97137
};
98138

99139
interface OptionConfigProps extends BoxProps {

0 commit comments

Comments
 (0)