mirror of
https://github.com/coder/coder.git
synced 2025-07-12 00:14:10 +00:00
feat: show a warning when an organization has no provisioners (#14136)
This commit is contained in:
committed by
GitHub
parent
efbd6257e4
commit
dfeafa8f5a
@ -67,6 +67,13 @@ There are two exceptions:
|
|||||||
**Organization-scoped Provisioners** can pick up build jobs created by any user.
|
**Organization-scoped Provisioners** can pick up build jobs created by any user.
|
||||||
These provisioners always have the implicit tags `scope=organization owner=""`.
|
These provisioners always have the implicit tags `scope=organization owner=""`.
|
||||||
|
|
||||||
|
```shell
|
||||||
|
coder provisionerd start --org <organization_name>
|
||||||
|
```
|
||||||
|
|
||||||
|
If you omit the `--org` argument, the provisioner will be assigned to the
|
||||||
|
default organization.
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
coder provisionerd start
|
coder provisionerd start
|
||||||
```
|
```
|
||||||
|
@ -627,6 +627,18 @@ class ApiMethods {
|
|||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param organization Can be the organization's ID or name
|
||||||
|
*/
|
||||||
|
getProvisionerDaemonsByOrganization = async (
|
||||||
|
organization: string,
|
||||||
|
): Promise<TypesGen.ProvisionerDaemon[]> => {
|
||||||
|
const response = await this.axios.get<TypesGen.ProvisionerDaemon[]>(
|
||||||
|
`/api/v2/organizations/${organization}/provisionerdaemons`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
getTemplate = async (templateId: string): Promise<TypesGen.Template> => {
|
getTemplate = async (templateId: string): Promise<TypesGen.Template> => {
|
||||||
const response = await this.axios.get<TypesGen.Template>(
|
const response = await this.axios.get<TypesGen.Template>(
|
||||||
`/api/v2/templates/${templateId}`,
|
`/api/v2/templates/${templateId}`,
|
||||||
|
@ -107,3 +107,16 @@ export const organizations = () => {
|
|||||||
queryFn: () => API.getOrganizations(),
|
queryFn: () => API.getOrganizations(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getProvisionerDaemonsKey = (organization: string) => [
|
||||||
|
"organization",
|
||||||
|
organization,
|
||||||
|
"provisionerDaemons",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const provisionerDaemons = (organization: string) => {
|
||||||
|
return {
|
||||||
|
queryKey: getProvisionerDaemonsKey(organization),
|
||||||
|
queryFn: () => API.getProvisionerDaemonsByOrganization(organization),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
import { action } from "@storybook/addon-actions";
|
import { action } from "@storybook/addon-actions";
|
||||||
import type { Meta, StoryObj } from "@storybook/react";
|
import type { Meta, StoryObj } from "@storybook/react";
|
||||||
|
import { screen, userEvent } from "@storybook/test";
|
||||||
import {
|
import {
|
||||||
|
getProvisionerDaemonsKey,
|
||||||
|
organizationsKey,
|
||||||
|
} from "api/queries/organizations";
|
||||||
|
import {
|
||||||
|
MockDefaultOrganization,
|
||||||
|
MockOrganization2,
|
||||||
MockTemplate,
|
MockTemplate,
|
||||||
MockTemplateExample,
|
MockTemplateExample,
|
||||||
MockTemplateVersionVariable1,
|
MockTemplateVersionVariable1,
|
||||||
@ -54,6 +61,31 @@ export const StarterTemplateWithOrgPicker: Story = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const StarterTemplateWithProvisionerWarning: Story = {
|
||||||
|
parameters: {
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
key: organizationsKey,
|
||||||
|
data: [MockDefaultOrganization, MockOrganization2],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: getProvisionerDaemonsKey(MockOrganization2.id),
|
||||||
|
data: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
args: {
|
||||||
|
...StarterTemplate.args,
|
||||||
|
showOrganizationPicker: true,
|
||||||
|
},
|
||||||
|
play: async () => {
|
||||||
|
const organizationPicker = screen.getByPlaceholderText("Organization name");
|
||||||
|
await userEvent.click(organizationPicker);
|
||||||
|
const org2 = await screen.findByText(MockOrganization2.display_name);
|
||||||
|
await userEvent.click(org2);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const DuplicateTemplateWithVariables: Story = {
|
export const DuplicateTemplateWithVariables: Story = {
|
||||||
args: {
|
args: {
|
||||||
copiedTemplate: MockTemplate,
|
copiedTemplate: MockTemplate,
|
||||||
|
@ -1,10 +1,13 @@
|
|||||||
|
import Link from "@mui/material/Link";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
import { useFormik } from "formik";
|
import { useFormik } from "formik";
|
||||||
import camelCase from "lodash/camelCase";
|
import camelCase from "lodash/camelCase";
|
||||||
import capitalize from "lodash/capitalize";
|
import capitalize from "lodash/capitalize";
|
||||||
import { useState, type FC } from "react";
|
import { useState, type FC } from "react";
|
||||||
|
import { useQuery } from "react-query";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
|
import { provisionerDaemons } from "api/queries/organizations";
|
||||||
import type {
|
import type {
|
||||||
Organization,
|
Organization,
|
||||||
ProvisionerJobLog,
|
ProvisionerJobLog,
|
||||||
@ -14,6 +17,7 @@ import type {
|
|||||||
TemplateVersionVariable,
|
TemplateVersionVariable,
|
||||||
VariableValue,
|
VariableValue,
|
||||||
} from "api/typesGenerated";
|
} from "api/typesGenerated";
|
||||||
|
import { Alert } from "components/Alert/Alert";
|
||||||
import {
|
import {
|
||||||
HorizontalForm,
|
HorizontalForm,
|
||||||
FormSection,
|
FormSection,
|
||||||
@ -23,6 +27,7 @@ import {
|
|||||||
import { IconField } from "components/IconField/IconField";
|
import { IconField } from "components/IconField/IconField";
|
||||||
import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete";
|
import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||||
import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate";
|
import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate";
|
||||||
|
import { docs } from "utils/docs";
|
||||||
import {
|
import {
|
||||||
nameValidator,
|
nameValidator,
|
||||||
getFormHelpers,
|
getFormHelpers,
|
||||||
@ -210,6 +215,24 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
|||||||
});
|
});
|
||||||
const getFieldHelpers = getFormHelpers<CreateTemplateFormData>(form, error);
|
const getFieldHelpers = getFormHelpers<CreateTemplateFormData>(form, error);
|
||||||
|
|
||||||
|
const provisionerDaemonsQuery = useQuery(
|
||||||
|
selectedOrg
|
||||||
|
? {
|
||||||
|
...provisionerDaemons(selectedOrg.id),
|
||||||
|
enabled: showOrganizationPicker,
|
||||||
|
select: (provisioners) => provisioners.length < 1,
|
||||||
|
}
|
||||||
|
: { enabled: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Ideally, we would have a backend endpoint that could notify the
|
||||||
|
// frontend that a provisioner has been connected, so that we could hide
|
||||||
|
// this warning. In the meantime, **do not use this variable to disable
|
||||||
|
// form submission**!! A user could easily see this warning, connect a
|
||||||
|
// provisioner, and then not refresh the page. Even if they submit without
|
||||||
|
// a provisioner, it'll just sit in the job queue until they connect one.
|
||||||
|
const showProvisionerWarning = provisionerDaemonsQuery.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HorizontalForm onSubmit={form.handleSubmit}>
|
<HorizontalForm onSubmit={form.handleSubmit}>
|
||||||
{/* General info */}
|
{/* General info */}
|
||||||
@ -232,6 +255,8 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showOrganizationPicker && (
|
{showOrganizationPicker && (
|
||||||
|
<>
|
||||||
|
{showProvisionerWarning && <ProvisionerWarning />}
|
||||||
<OrganizationAutocomplete
|
<OrganizationAutocomplete
|
||||||
{...getFieldHelpers("organization")}
|
{...getFieldHelpers("organization")}
|
||||||
required
|
required
|
||||||
@ -243,6 +268,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
|||||||
}}
|
}}
|
||||||
size="medium"
|
size="medium"
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{"copiedTemplate" in props && (
|
{"copiedTemplate" in props && (
|
||||||
@ -369,3 +395,15 @@ const fillNameAndDisplayWithFilename = async (
|
|||||||
form.setFieldValue("display_name", capitalize(name)),
|
form.setFieldValue("display_name", capitalize(name)),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ProvisionerWarning: FC = () => {
|
||||||
|
return (
|
||||||
|
<Alert severity="warning" css={{ marginBottom: 16 }}>
|
||||||
|
This organization does not have any provisioners. Before you create a
|
||||||
|
template, you'll need to configure a provisioner.{" "}
|
||||||
|
<Link href={docs("/admin/provisioners#organization-scoped-provisioners")}>
|
||||||
|
See our documentation.
|
||||||
|
</Link>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
Reference in New Issue
Block a user