Skip to content

Commit dfeafa8

Browse files
authored
feat: show a warning when an organization has no provisioners (#14136)
1 parent efbd625 commit dfeafa8

File tree

5 files changed

+113
-11
lines changed

5 files changed

+113
-11
lines changed

docs/admin/provisioners.md

+7
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ There are two exceptions:
6767
**Organization-scoped Provisioners** can pick up build jobs created by any user.
6868
These provisioners always have the implicit tags `scope=organization owner=""`.
6969

70+
```shell
71+
coder provisionerd start --org <organization_name>
72+
```
73+
74+
If you omit the `--org` argument, the provisioner will be assigned to the
75+
default organization.
76+
7077
```shell
7178
coder provisionerd start
7279
```

site/src/api/api.ts

+12
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,18 @@ class ApiMethods {
627627
return response.data;
628628
};
629629

630+
/**
631+
* @param organization Can be the organization's ID or name
632+
*/
633+
getProvisionerDaemonsByOrganization = async (
634+
organization: string,
635+
): Promise<TypesGen.ProvisionerDaemon[]> => {
636+
const response = await this.axios.get<TypesGen.ProvisionerDaemon[]>(
637+
`/api/v2/organizations/${organization}/provisionerdaemons`,
638+
);
639+
return response.data;
640+
};
641+
630642
getTemplate = async (templateId: string): Promise<TypesGen.Template> => {
631643
const response = await this.axios.get<TypesGen.Template>(
632644
`/api/v2/templates/${templateId}`,

site/src/api/queries/organizations.ts

+13
Original file line numberDiff line numberDiff line change
@@ -107,3 +107,16 @@ export const organizations = () => {
107107
queryFn: () => API.getOrganizations(),
108108
};
109109
};
110+
111+
export const getProvisionerDaemonsKey = (organization: string) => [
112+
"organization",
113+
organization,
114+
"provisionerDaemons",
115+
];
116+
117+
export const provisionerDaemons = (organization: string) => {
118+
return {
119+
queryKey: getProvisionerDaemonsKey(organization),
120+
queryFn: () => API.getProvisionerDaemonsByOrganization(organization),
121+
};
122+
};

site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx

+32
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { action } from "@storybook/addon-actions";
22
import type { Meta, StoryObj } from "@storybook/react";
3+
import { screen, userEvent } from "@storybook/test";
34
import {
5+
getProvisionerDaemonsKey,
6+
organizationsKey,
7+
} from "api/queries/organizations";
8+
import {
9+
MockDefaultOrganization,
10+
MockOrganization2,
411
MockTemplate,
512
MockTemplateExample,
613
MockTemplateVersionVariable1,
@@ -54,6 +61,31 @@ export const StarterTemplateWithOrgPicker: Story = {
5461
},
5562
};
5663

64+
export const StarterTemplateWithProvisionerWarning: Story = {
65+
parameters: {
66+
queries: [
67+
{
68+
key: organizationsKey,
69+
data: [MockDefaultOrganization, MockOrganization2],
70+
},
71+
{
72+
key: getProvisionerDaemonsKey(MockOrganization2.id),
73+
data: [],
74+
},
75+
],
76+
},
77+
args: {
78+
...StarterTemplate.args,
79+
showOrganizationPicker: true,
80+
},
81+
play: async () => {
82+
const organizationPicker = screen.getByPlaceholderText("Organization name");
83+
await userEvent.click(organizationPicker);
84+
const org2 = await screen.findByText(MockOrganization2.display_name);
85+
await userEvent.click(org2);
86+
},
87+
};
88+
5789
export const DuplicateTemplateWithVariables: Story = {
5890
args: {
5991
copiedTemplate: MockTemplate,

site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx

+49-11
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import Link from "@mui/material/Link";
12
import TextField from "@mui/material/TextField";
23
import { useFormik } from "formik";
34
import camelCase from "lodash/camelCase";
45
import capitalize from "lodash/capitalize";
56
import { useState, type FC } from "react";
7+
import { useQuery } from "react-query";
68
import { useSearchParams } from "react-router-dom";
79
import * as Yup from "yup";
10+
import { provisionerDaemons } from "api/queries/organizations";
811
import type {
912
Organization,
1013
ProvisionerJobLog,
@@ -14,6 +17,7 @@ import type {
1417
TemplateVersionVariable,
1518
VariableValue,
1619
} from "api/typesGenerated";
20+
import { Alert } from "components/Alert/Alert";
1721
import {
1822
HorizontalForm,
1923
FormSection,
@@ -23,6 +27,7 @@ import {
2327
import { IconField } from "components/IconField/IconField";
2428
import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete";
2529
import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate";
30+
import { docs } from "utils/docs";
2631
import {
2732
nameValidator,
2833
getFormHelpers,
@@ -210,6 +215,24 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
210215
});
211216
const getFieldHelpers = getFormHelpers<CreateTemplateFormData>(form, error);
212217

218+
const provisionerDaemonsQuery = useQuery(
219+
selectedOrg
220+
? {
221+
...provisionerDaemons(selectedOrg.id),
222+
enabled: showOrganizationPicker,
223+
select: (provisioners) => provisioners.length < 1,
224+
}
225+
: { enabled: false },
226+
);
227+
228+
// TODO: Ideally, we would have a backend endpoint that could notify the
229+
// frontend that a provisioner has been connected, so that we could hide
230+
// this warning. In the meantime, **do not use this variable to disable
231+
// form submission**!! A user could easily see this warning, connect a
232+
// provisioner, and then not refresh the page. Even if they submit without
233+
// a provisioner, it'll just sit in the job queue until they connect one.
234+
const showProvisionerWarning = provisionerDaemonsQuery.data;
235+
213236
return (
214237
<HorizontalForm onSubmit={form.handleSubmit}>
215238
{/* General info */}
@@ -232,17 +255,20 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
232255
)}
233256

234257
{showOrganizationPicker && (
235-
<OrganizationAutocomplete
236-
{...getFieldHelpers("organization")}
237-
required
238-
label="Belongs to"
239-
value={selectedOrg}
240-
onChange={(newValue) => {
241-
setSelectedOrg(newValue);
242-
void form.setFieldValue("organization", newValue?.name || "");
243-
}}
244-
size="medium"
245-
/>
258+
<>
259+
{showProvisionerWarning && <ProvisionerWarning />}
260+
<OrganizationAutocomplete
261+
{...getFieldHelpers("organization")}
262+
required
263+
label="Belongs to"
264+
value={selectedOrg}
265+
onChange={(newValue) => {
266+
setSelectedOrg(newValue);
267+
void form.setFieldValue("organization", newValue?.name || "");
268+
}}
269+
size="medium"
270+
/>
271+
</>
246272
)}
247273

248274
{"copiedTemplate" in props && (
@@ -369,3 +395,15 @@ const fillNameAndDisplayWithFilename = async (
369395
form.setFieldValue("display_name", capitalize(name)),
370396
]);
371397
};
398+
399+
const ProvisionerWarning: FC = () => {
400+
return (
401+
<Alert severity="warning" css={{ marginBottom: 16 }}>
402+
This organization does not have any provisioners. Before you create a
403+
template, you&apos;ll need to configure a provisioner.{" "}
404+
<Link href={docs("/admin/provisioners#organization-scoped-provisioners")}>
405+
See our documentation.
406+
</Link>
407+
</Alert>
408+
);
409+
};

0 commit comments

Comments
 (0)