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
32 changes: 30 additions & 2 deletions coderd/apidoc/docs.go

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

28 changes: 26 additions & 2 deletions coderd/apidoc/swagger.json

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

1 change: 1 addition & 0 deletions coderd/coderd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
19 changes: 17 additions & 2 deletions coderd/experiments.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
})
}
17 changes: 17 additions & 0 deletions coderd/experiments_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
22 changes: 21 additions & 1 deletion 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 @@ -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"`
Expand Down
41 changes: 39 additions & 2 deletions docs/api/general.md

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

13 changes: 13 additions & 0 deletions site/src/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> => {
Expand Down
7 changes: 7 additions & 0 deletions site/src/api/queries/experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,10 @@ export const experiments = () => {
getMetadataAsJSON<Experiments>("experiments") ?? API.getExperiments(),
};
};

export const availableExperiments = () => {
return {
queryKey: ["availableExperiments"],
queryFn: async () => API.getAvailableExperiments(),
};
};
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.

84 changes: 62 additions & 22 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,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`
Expand All @@ -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 (typeof value === "object" && !Array.isArray(value)) {
return (
<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(children)) {
if (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 && { listStylePosition: "inside" }}>
{value.map((item) => (
<li key={item} css={optionStyles}>
{item}
</li>
Expand All @@ -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 {
Expand Down
Loading