From b9e5c6818b1e9eab58e8ce9a0e81c0e65441059e Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 2 Aug 2024 20:15:54 +0000 Subject: [PATCH 1/4] feat: show a warning when an organization has no provisioners --- docs/admin/provisioners.md | 7 +++ site/src/api/api.ts | 12 +++++ .../CreateTemplatePage/CreateTemplateForm.tsx | 54 +++++++++++++++---- 3 files changed, 62 insertions(+), 11 deletions(-) diff --git a/docs/admin/provisioners.md b/docs/admin/provisioners.md index 422aa9b29d94c..ef94004106805 100644 --- a/docs/admin/provisioners.md +++ b/docs/admin/provisioners.md @@ -67,6 +67,13 @@ There are two exceptions: **Organization-scoped Provisioners** can pick up build jobs created by any user. These provisioners always have the implicit tags `scope=organization owner=""`. +```shell +coder provisionerd start --org +``` + +If you omit the `--org` argument, the provisioner will be assigned to the +default organization. + ```shell coder provisionerd start ``` diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 7aeefe98a444c..ed4743be3ae06 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -617,6 +617,18 @@ class ApiMethods { return response.data; }; + /** + * @param organization Can be the organization's ID or name + */ + getProvisionerDaemonsByOrganization = async ( + organization: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organization}/provisionerdaemons`, + ); + return response.data; + }; + getTemplate = async (templateId: string): Promise => { const response = await this.axios.get( `/api/v2/templates/${templateId}`, diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 929c4fc313a0f..89efba98f1435 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -1,10 +1,14 @@ +import Alert from "@mui/material/Alert"; +import Link from "@mui/material/Link"; import TextField from "@mui/material/TextField"; import { useFormik } from "formik"; import camelCase from "lodash/camelCase"; import capitalize from "lodash/capitalize"; import { useState, type FC } from "react"; +import { useQuery } from "react-query"; import { useSearchParams } from "react-router-dom"; import * as Yup from "yup"; +import { API } from "api/api"; import type { Organization, ProvisionerJobLog, @@ -23,6 +27,7 @@ import { import { IconField } from "components/IconField/IconField"; import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete"; import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate"; +import { docs } from "utils/docs"; import { nameValidator, getFormHelpers, @@ -210,6 +215,18 @@ export const CreateTemplateForm: FC = (props) => { }); const getFieldHelpers = getFormHelpers(form, error); + const warnAboutProvisionersQuery = useQuery({ + enabled: showOrganizationPicker && Boolean(selectedOrg), + queryKey: ["warnAboutProvisioners", selectedOrg?.id], + queryFn: async () => { + const provisioners = await API.getProvisionerDaemonsByOrganization( + selectedOrg!.id, + ); + + return provisioners.length === 0; + }, + }); + return ( {/* General info */} @@ -232,17 +249,20 @@ export const CreateTemplateForm: FC = (props) => { )} {showOrganizationPicker && ( - { - setSelectedOrg(newValue); - void form.setFieldValue("organization", newValue?.id || ""); - }} - size="medium" - /> + <> + {warnAboutProvisionersQuery.data && } + { + setSelectedOrg(newValue); + void form.setFieldValue("organization", newValue?.id || ""); + }} + size="medium" + /> + )} {"copiedTemplate" in props && ( @@ -369,3 +389,15 @@ const fillNameAndDisplayWithFilename = async ( form.setFieldValue("display_name", capitalize(name)), ]); }; + +const ProvisionerWarning: FC = () => { + return ( + + This organization does not have any provisioners. Before you create a + template, you'll need to configure a provisioner.{" "} + + See our documentation. + + + ); +}; From 1d603d9c1c169aa9317dca84ed3bdf3dcb306db4 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Fri, 2 Aug 2024 22:20:56 +0000 Subject: [PATCH 2/4] refactor a bit --- site/src/api/queries/organizations.ts | 7 +++++++ .../CreateTemplatePage/CreateTemplateForm.tsx | 15 ++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 98c3c9a61e66a..246538223b79e 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -107,3 +107,10 @@ export const organizations = () => { queryFn: () => API.getOrganizations(), }; }; + +export const provisionerDaemons = (organization: string) => { + return { + queryKey: ["organization", organization, "provisionerDaemons"], + queryFn: () => API.getProvisionerDaemonsByOrganization(organization), + }; +}; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 89efba98f1435..a749e6cd32ca7 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -9,6 +9,7 @@ import { useQuery } from "react-query"; import { useSearchParams } from "react-router-dom"; import * as Yup from "yup"; import { API } from "api/api"; +import { provisionerDaemons } from "api/queries/organizations"; import type { Organization, ProvisionerJobLog, @@ -227,6 +228,18 @@ export const CreateTemplateForm: FC = (props) => { }, }); + const provisionerDaemonsQuery = useQuery( + selectedOrg + ? { + ...provisionerDaemons(selectedOrg.id), + enabled: showOrganizationPicker, + select: (provisioners) => provisioners.length < 1, + } + : { enabled: false }, + ); + + const showProvisionerWarning = provisionerDaemonsQuery.data; + return ( {/* General info */} @@ -250,7 +263,7 @@ export const CreateTemplateForm: FC = (props) => { {showOrganizationPicker && ( <> - {warnAboutProvisionersQuery.data && } + {showProvisionerWarning && } Date: Fri, 2 Aug 2024 22:27:11 +0000 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=A7=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreateTemplatePage/CreateTemplateForm.tsx | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index a749e6cd32ca7..7d1b607d39c3e 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -1,4 +1,3 @@ -import Alert from "@mui/material/Alert"; import Link from "@mui/material/Link"; import TextField from "@mui/material/TextField"; import { useFormik } from "formik"; @@ -8,7 +7,6 @@ import { useState, type FC } from "react"; import { useQuery } from "react-query"; import { useSearchParams } from "react-router-dom"; import * as Yup from "yup"; -import { API } from "api/api"; import { provisionerDaemons } from "api/queries/organizations"; import type { Organization, @@ -19,6 +17,7 @@ import type { TemplateVersionVariable, VariableValue, } from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; import { HorizontalForm, FormSection, @@ -216,18 +215,6 @@ export const CreateTemplateForm: FC = (props) => { }); const getFieldHelpers = getFormHelpers(form, error); - const warnAboutProvisionersQuery = useQuery({ - enabled: showOrganizationPicker && Boolean(selectedOrg), - queryKey: ["warnAboutProvisioners", selectedOrg?.id], - queryFn: async () => { - const provisioners = await API.getProvisionerDaemonsByOrganization( - selectedOrg!.id, - ); - - return provisioners.length === 0; - }, - }); - const provisionerDaemonsQuery = useQuery( selectedOrg ? { @@ -407,7 +394,7 @@ const ProvisionerWarning: FC = () => { return ( This organization does not have any provisioners. Before you create a - template, you'll need to configure a provisioner.{" "} + template, you'll need to configure a provisioner.{" "} See our documentation. From 2f969b1c73338fae7bf084a7856f179e7484cf48 Mon Sep 17 00:00:00 2001 From: McKayla Washburn Date: Mon, 5 Aug 2024 16:34:37 +0000 Subject: [PATCH 4/4] cleanup --- site/src/api/queries/organizations.ts | 8 ++++- .../CreateTemplateForm.stories.tsx | 32 +++++++++++++++++++ .../CreateTemplatePage/CreateTemplateForm.tsx | 8 ++++- 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 246538223b79e..11d97fedcff01 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -108,9 +108,15 @@ export const organizations = () => { }; }; +export const getProvisionerDaemonsKey = (organization: string) => [ + "organization", + organization, + "provisionerDaemons", +]; + export const provisionerDaemons = (organization: string) => { return { - queryKey: ["organization", organization, "provisionerDaemons"], + queryKey: getProvisionerDaemonsKey(organization), queryFn: () => API.getProvisionerDaemonsByOrganization(organization), }; }; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx index 6f62439ae3d45..b6da0e8f127a1 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx @@ -1,6 +1,13 @@ import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; +import { screen, userEvent } from "@storybook/test"; import { + getProvisionerDaemonsKey, + organizationsKey, +} from "api/queries/organizations"; +import { + MockDefaultOrganization, + MockOrganization2, MockTemplate, MockTemplateExample, 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 = { args: { copiedTemplate: MockTemplate, diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index 7d1b607d39c3e..4487d5c7e8291 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -225,6 +225,12 @@ export const CreateTemplateForm: FC = (props) => { : { 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 ( @@ -252,7 +258,7 @@ export const CreateTemplateForm: FC = (props) => { <> {showProvisionerWarning && }