diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 3ad195f2bd9e4..cfba27408e9c6 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -682,12 +682,20 @@ class ApiMethods { /** * @param organization Can be the organization's ID or name + * @param tags to filter provisioner daemons by. */ getProvisionerDaemonsByOrganization = async ( organization: string, + tags?: Record, ): Promise => { + const params = new URLSearchParams(); + + if (tags) { + params.append("tags", JSON.stringify(tags)); + } + const response = await this.axios.get( - `/api/v2/organizations/${organization}/provisionerdaemons`, + `/api/v2/organizations/${organization}/provisionerdaemons?${params.toString()}`, ); return response.data; }; diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index d1df8f409dcdf..c3f5a4ebd3ced 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -115,16 +115,18 @@ export const organizations = () => { }; }; -export const getProvisionerDaemonsKey = (organization: string) => [ - "organization", - organization, - "provisionerDaemons", -]; +export const getProvisionerDaemonsKey = ( + organization: string, + tags?: Record, +) => ["organization", organization, tags, "provisionerDaemons"]; -export const provisionerDaemons = (organization: string) => { +export const provisionerDaemons = ( + organization: string, + tags?: Record, +) => { return { - queryKey: getProvisionerDaemonsKey(organization), - queryFn: () => API.getProvisionerDaemonsByOrganization(organization), + queryKey: getProvisionerDaemonsKey(organization, tags), + queryFn: () => API.getProvisionerDaemonsByOrganization(organization, tags), }; }; diff --git a/site/src/components/Alert/Alert.tsx b/site/src/components/Alert/Alert.tsx index df741a1924fa9..7750a6bc7d1e8 100644 --- a/site/src/components/Alert/Alert.tsx +++ b/site/src/components/Alert/Alert.tsx @@ -1,4 +1,5 @@ import MuiAlert, { + type AlertColor as MuiAlertColor, type AlertProps as MuiAlertProps, // biome-ignore lint/nursery/noRestrictedImports: Used as base component } from "@mui/material/Alert"; @@ -11,6 +12,8 @@ import { useState, } from "react"; +export type AlertColor = MuiAlertColor; + export type AlertProps = MuiAlertProps & { actions?: ReactNode; dismissible?: boolean; diff --git a/site/src/modules/provisioners/ProvisionerAlert.stories.tsx b/site/src/modules/provisioners/ProvisionerAlert.stories.tsx new file mode 100644 index 0000000000000..d9ca1501d6611 --- /dev/null +++ b/site/src/modules/provisioners/ProvisionerAlert.stories.tsx @@ -0,0 +1,28 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { chromatic } from "testHelpers/chromatic"; +import { ProvisionerAlert } from "./ProvisionerAlert"; + +const meta: Meta = { + title: "modules/provisioners/ProvisionerAlert", + parameters: { + chromatic, + layout: "centered", + }, + component: ProvisionerAlert, + args: { + title: "Title", + detail: "Detail", + severity: "info", + tags: { tag: "tagValue" }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Info: Story = {}; +export const NullTags: Story = { + args: { + tags: undefined, + }, +}; diff --git a/site/src/modules/provisioners/ProvisionerAlert.tsx b/site/src/modules/provisioners/ProvisionerAlert.tsx new file mode 100644 index 0000000000000..54d9ab8473e87 --- /dev/null +++ b/site/src/modules/provisioners/ProvisionerAlert.tsx @@ -0,0 +1,45 @@ +import AlertTitle from "@mui/material/AlertTitle"; +import { Alert, type AlertColor } from "components/Alert/Alert"; +import { AlertDetail } from "components/Alert/Alert"; +import { Stack } from "components/Stack/Stack"; +import { ProvisionerTag } from "modules/provisioners/ProvisionerTag"; +import type { FC } from "react"; +interface ProvisionerAlertProps { + title: string; + detail: string; + severity: AlertColor; + tags: Record; +} + +export const ProvisionerAlert: FC = ({ + title, + detail, + severity, + tags, +}) => { + return ( + { + return { + borderRadius: 0, + border: 0, + borderBottom: `1px solid ${theme.palette.divider}`, + borderLeft: `2px solid ${theme.palette[severity].main}`, + }; + }} + > + {title} + +
{detail}
+ + {Object.entries(tags ?? {}) + .filter(([key]) => key !== "owner") + .map(([key, value]) => ( + + ))} + +
+
+ ); +}; diff --git a/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx b/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx new file mode 100644 index 0000000000000..d4f746e99c417 --- /dev/null +++ b/site/src/modules/provisioners/ProvisionerStatusAlert.stories.tsx @@ -0,0 +1,55 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { chromatic } from "testHelpers/chromatic"; +import { MockTemplateVersion } from "testHelpers/entities"; +import { ProvisionerStatusAlert } from "./ProvisionerStatusAlert"; + +const meta: Meta = { + title: "modules/provisioners/ProvisionerStatusAlert", + parameters: { + chromatic, + layout: "centered", + }, + component: ProvisionerStatusAlert, + args: { + matchingProvisioners: 0, + availableProvisioners: 0, + tags: MockTemplateVersion.job.tags, + }, +}; + +export default meta; +type Story = StoryObj; + +export const HealthyProvisioners: Story = { + args: { + matchingProvisioners: 1, + availableProvisioners: 1, + }, +}; + +export const UndefinedMatchingProvisioners: Story = { + args: { + matchingProvisioners: undefined, + availableProvisioners: undefined, + }, +}; + +export const UndefinedAvailableProvisioners: Story = { + args: { + matchingProvisioners: 1, + availableProvisioners: undefined, + }, +}; + +export const NoMatchingProvisioners: Story = { + args: { + matchingProvisioners: 0, + }, +}; + +export const NoAvailableProvisioners: Story = { + args: { + matchingProvisioners: 1, + availableProvisioners: 0, + }, +}; diff --git a/site/src/modules/provisioners/ProvisionerStatusAlert.tsx b/site/src/modules/provisioners/ProvisionerStatusAlert.tsx new file mode 100644 index 0000000000000..54a2b56704877 --- /dev/null +++ b/site/src/modules/provisioners/ProvisionerStatusAlert.tsx @@ -0,0 +1,47 @@ +import type { AlertColor } from "components/Alert/Alert"; +import type { FC } from "react"; +import { ProvisionerAlert } from "./ProvisionerAlert"; + +interface ProvisionerStatusAlertProps { + matchingProvisioners: number | undefined; + availableProvisioners: number | undefined; + tags: Record; +} + +export const ProvisionerStatusAlert: FC = ({ + matchingProvisioners, + availableProvisioners, + tags, +}) => { + let title: string; + let detail: string; + let severity: AlertColor; + switch (true) { + case matchingProvisioners === 0: + title = "Build pending provisioner deployment"; + detail = + "Your build has been enqueued, but there are no provisioners that accept the required tags. Once a compatible provisioner becomes available, your build will continue. Please contact your administrator."; + severity = "warning"; + break; + case availableProvisioners === 0: + title = "Build delayed"; + detail = + "Provisioners that accept the required tags have not responded for longer than expected. This may delay your build. Please contact your administrator if your build does not complete."; + severity = "warning"; + break; + default: + title = "Build enqueued"; + detail = + "Your build has been enqueued and will begin once a provisioner becomes available to process it."; + severity = "info"; + } + + return ( + + ); +}; diff --git a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx index afc3c1321a6b4..29229fadfd0ad 100644 --- a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx +++ b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.stories.tsx @@ -34,6 +34,42 @@ export const MissingVariables: Story = { }, }; +export const NoProvisioners: Story = { + args: { + templateVersion: { + ...MockTemplateVersion, + matched_provisioners: { + count: 0, + available: 0, + }, + }, + }, +}; + +export const ProvisionersUnhealthy: Story = { + args: { + templateVersion: { + ...MockTemplateVersion, + matched_provisioners: { + count: 1, + available: 0, + }, + }, + }, +}; + +export const ProvisionersHealthy: Story = { + args: { + templateVersion: { + ...MockTemplateVersion, + matched_provisioners: { + count: 1, + available: 1, + }, + }, + }, +}; + export const Logs: Story = { args: { templateVersion: { diff --git a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx index 5af38b649c695..4eb1805b60e36 100644 --- a/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx +++ b/site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx @@ -8,6 +8,7 @@ import { visuallyHidden } from "@mui/utils"; import { JobError } from "api/queries/templates"; import type { TemplateVersion } from "api/typesGenerated"; import { Loader } from "components/Loader/Loader"; +import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs"; import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs"; import { type FC, useLayoutEffect, useRef } from "react"; @@ -27,6 +28,10 @@ export const BuildLogsDrawer: FC = ({ variablesSectionRef, ...drawerProps }) => { + const matchingProvisioners = templateVersion?.matched_provisioners?.count; + const availableProvisioners = + templateVersion?.matched_provisioners?.available; + const logs = useWatchVersionLogs(templateVersion); const logsContainer = useRef(null); @@ -65,6 +70,8 @@ export const BuildLogsDrawer: FC = ({ + {} + {isMissingVariables ? ( { @@ -82,7 +89,14 @@ export const BuildLogsDrawer: FC = ({ ) : ( - + <> + + + )} diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx index 1382aa100a1dc..4b8413215c9e8 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.stories.tsx @@ -49,6 +49,73 @@ type Story = StoryObj; export const Example: Story = {}; +export const UndefinedLogs: Story = { + args: { + defaultTab: "logs", + buildLogs: undefined, + templateVersion: { + ...MockTemplateVersion, + job: MockRunningProvisionerJob, + }, + }, +}; + +export const EmptyLogs: Story = { + args: { + defaultTab: "logs", + buildLogs: [], + templateVersion: { + ...MockTemplateVersion, + job: MockRunningProvisionerJob, + }, + }, +}; + +export const NoProvisioners: Story = { + args: { + defaultTab: "logs", + buildLogs: [], + templateVersion: { + ...MockTemplateVersion, + job: MockRunningProvisionerJob, + matched_provisioners: { + count: 0, + available: 0, + }, + }, + }, +}; + +export const UnavailableProvisioners: Story = { + args: { + defaultTab: "logs", + buildLogs: [], + templateVersion: { + ...MockTemplateVersion, + job: MockRunningProvisionerJob, + matched_provisioners: { + count: 1, + available: 0, + }, + }, + }, +}; + +export const HealthyProvisioners: Story = { + args: { + defaultTab: "logs", + buildLogs: [], + templateVersion: { + ...MockTemplateVersion, + job: MockRunningProvisionerJob, + matched_provisioners: { + count: 1, + available: 1, + }, + }, + }, +}; + export const Logs: Story = { args: { defaultTab: "logs", diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index 943370f89e2a4..858f57dd59493 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -4,7 +4,6 @@ import ArrowBackOutlined from "@mui/icons-material/ArrowBackOutlined"; import CloseOutlined from "@mui/icons-material/CloseOutlined"; import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined"; import WarningOutlined from "@mui/icons-material/WarningOutlined"; -import AlertTitle from "@mui/material/AlertTitle"; import Button from "@mui/material/Button"; import ButtonGroup from "@mui/material/ButtonGroup"; import IconButton from "@mui/material/IconButton"; @@ -17,7 +16,7 @@ import type { VariableValue, WorkspaceResource, } from "api/typesGenerated"; -import { Alert, AlertDetail } from "components/Alert/Alert"; +import { Alert } from "components/Alert/Alert"; import { Sidebar } from "components/FullPageLayout/Sidebar"; import { Topbar, @@ -29,6 +28,8 @@ import { } from "components/FullPageLayout/Topbar"; import { Loader } from "components/Loader/Loader"; import { linkToTemplate, useLinks } from "modules/navigation"; +import { ProvisionerAlert } from "modules/provisioners/ProvisionerAlert"; +import { ProvisionerStatusAlert } from "modules/provisioners/ProvisionerStatusAlert"; import { TemplateFileTree } from "modules/templates/TemplateFiles/TemplateFileTree"; import { isBinaryData } from "modules/templates/TemplateFiles/isBinaryData"; import { TemplateResourcesTable } from "modules/templates/TemplateResourcesTable/TemplateResourcesTable"; @@ -126,6 +127,8 @@ export const TemplateVersionEditor: FC = ({ const [deleteFileOpen, setDeleteFileOpen] = useState(); const [renameFileOpen, setRenameFileOpen] = useState(); const [dirty, setDirty] = useState(false); + const matchingProvisioners = templateVersion.matched_provisioners?.count; + const availableProvisioners = templateVersion.matched_provisioners?.available; const triggerPreview = useCallback(async () => { await onPreview(fileTree); @@ -192,6 +195,8 @@ export const TemplateVersionEditor: FC = ({ linkToTemplate(template.organization_name, template.name), ); + const gotBuildLogs = buildLogs && buildLogs.length > 0; + return ( <>
@@ -581,31 +586,34 @@ export const TemplateVersionEditor: FC = ({ css={[styles.logs, styles.tabContent]} ref={logsContentRef} > - {templateVersion.job.error && ( + {templateVersion.job.error ? (
- - Error during the build - {templateVersion.job.error} - + tags={templateVersion.job.tags} + />
+ ) : ( + !gotBuildLogs && ( + <> + + + + ) )} - {buildLogs && buildLogs.length > 0 ? ( + {gotBuildLogs && ( - ) : ( - )}
)} diff --git a/site/src/testHelpers/storybook.tsx b/site/src/testHelpers/storybook.tsx index e905a9b412c2c..514d34e0265e8 100644 --- a/site/src/testHelpers/storybook.tsx +++ b/site/src/testHelpers/storybook.tsx @@ -1,6 +1,7 @@ import type { StoryContext } from "@storybook/react"; import { withDefaultFeatures } from "api/api"; import { getAuthorizationKey } from "api/queries/authCheck"; +import { getProvisionerDaemonsKey } from "api/queries/organizations"; import { hasFirstUserKey, meKey } from "api/queries/users"; import type { Entitlements } from "api/typesGenerated"; import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar"; @@ -121,6 +122,30 @@ export const withAuthProvider = (Story: FC, { parameters }: StoryContext) => { ); }; +export const withProvisioners = (Story: FC, { parameters }: StoryContext) => { + if (!parameters.organization_id) { + throw new Error( + "You forgot to add `parameters.organization_id` to your story", + ); + } + if (!parameters.provisioners) { + throw new Error( + "You forgot to add `parameters.provisioners` to your story", + ); + } + if (!parameters.tags) { + throw new Error("You forgot to add `parameters.tags` to your story"); + } + + const queryClient = useQueryClient(); + queryClient.setQueryData( + getProvisionerDaemonsKey(parameters.organization_id, parameters.tags), + parameters.provisioners, + ); + + return ; +}; + export const withGlobalSnackbar = (Story: FC) => ( <>