diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx index ac0dc9a875304..955fde47d95f9 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditor.tsx @@ -175,10 +175,10 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({ typeof editorValue === "string" ? isBinaryData(editorValue) : false; // Auto scroll - const buildLogsRef = useRef<HTMLDivElement>(null); + const logsContentRef = useRef<HTMLDivElement>(null); useEffect(() => { - if (buildLogsRef.current) { - buildLogsRef.current.scrollTop = buildLogsRef.current.scrollHeight; + if (logsContentRef.current) { + logsContentRef.current.scrollTop = logsContentRef.current.scrollHeight; } }, [buildLogs]); @@ -237,9 +237,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({ paddingRight: 16, }} > - {buildLogs && ( - <TemplateVersionStatusBadge version={templateVersion} /> - )} + <TemplateVersionStatusBadge version={templateVersion} /> <ButtonGroup variant="outlined" @@ -575,62 +573,51 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({ )} </div> - <div - ref={buildLogsRef} - css={{ - display: selectedTab !== "logs" ? "none" : "flex", - height: selectedTab ? 280 : 0, - flexDirection: "column", - overflowY: "auto", - }} - > - {templateVersion.job.error && ( - <div> - <Alert - severity="error" - css={{ - borderRadius: 0, - border: 0, - borderBottom: `1px solid ${theme.palette.divider}`, - borderLeft: `2px solid ${theme.palette.error.main}`, - }} - > - <AlertTitle>Error during the build</AlertTitle> - <AlertDetail>{templateVersion.job.error}</AlertDetail> - </Alert> - </div> - )} - - {buildLogs && buildLogs.length === 0 && ( - <Loader css={{ height: "100%" }} /> - )} - - {buildLogs && buildLogs.length > 0 && ( - <WorkspaceBuildLogs - css={styles.buildLogs} - hideTimestamps - logs={buildLogs} - /> - )} - </div> + {selectedTab === "logs" && ( + <div + css={[styles.logs, styles.tabContent]} + ref={logsContentRef} + > + {templateVersion.job.error && ( + <div> + <Alert + severity="error" + css={{ + borderRadius: 0, + border: 0, + borderBottom: `1px solid ${theme.palette.divider}`, + borderLeft: `2px solid ${theme.palette.error.main}`, + }} + > + <AlertTitle>Error during the build</AlertTitle> + <AlertDetail>{templateVersion.job.error}</AlertDetail> + </Alert> + </div> + )} + + {buildLogs && buildLogs.length > 0 ? ( + <WorkspaceBuildLogs + css={styles.buildLogs} + hideTimestamps + logs={buildLogs} + /> + ) : ( + <Loader css={{ height: "100%" }} /> + )} + </div> + )} - <div - css={[ - { - display: selectedTab !== "resources" ? "none" : undefined, - height: selectedTab ? 280 : 0, - }, - styles.resources, - ]} - > - {resources && ( - <TemplateResourcesTable - resources={resources.filter( - (r) => r.workspace_transition === "start", - )} - /> - )} - </div> + {selectedTab === "resources" && ( + <div css={[styles.resources, styles.tabContent]}> + {resources && ( + <TemplateResourcesTable + resources={resources.filter( + (r) => r.workspace_transition === "start", + )} + /> + )} + </div> + )} </div> </div> </div> @@ -751,6 +738,17 @@ const styles = { }, }), + tabContent: { + height: 280, + overflowY: "auto", + }, + + logs: { + display: "flex", + height: "100%", + flexDirection: "column", + }, + buildLogs: { borderRadius: 0, border: 0, @@ -780,8 +778,6 @@ const styles = { }, resources: { - overflowY: "auto", - // Hack to access customize resource-card from here "& .resource-card": { borderLeft: 0, diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx index 1558cb5c27ac4..6f54255fbe23a 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.test.tsx @@ -1,5 +1,6 @@ import { render, screen, waitFor, within } from "@testing-library/react"; import userEvent, { type UserEvent } from "@testing-library/user-event"; +import WS from "jest-websocket-mock"; import { HttpResponse, http } from "msw"; import { QueryClient } from "react-query"; import { RouterProvider, createMemoryRouter } from "react-router-dom"; @@ -16,6 +17,7 @@ import { MockWorkspaceBuildLogs, } from "testHelpers/entities"; import { + createTestQueryClient, renderWithAuth, waitForLoaderToBeRemoved, } from "testHelpers/renderHelpers"; @@ -291,30 +293,7 @@ describe.each([ ); } - render( - <AppProviders queryClient={queryClient}> - <RouterProvider - router={createMemoryRouter( - [ - { - element: <RequireAuth />, - children: [ - { - element: <TemplateVersionEditorPage />, - path: "/templates/:template/versions/:version/edit", - }, - ], - }, - ], - { - initialEntries: [ - `/templates/${MockTemplate.name}/versions/${MockTemplateVersion.name}/edit`, - ], - }, - )} - /> - </AppProviders>, - ); + renderEditorPage(queryClient); await waitForLoaderToBeRemoved(); const dialogSelector = /template variables/i; @@ -326,3 +305,80 @@ describe.each([ }); }, ); + +test("display pending badge and update it to running when status changes", async () => { + const MockPendingTemplateVersion = { + ...MockTemplateVersion, + job: { + ...MockTemplateVersion.job, + status: "pending", + }, + }; + const MockRunningTemplateVersion = { + ...MockTemplateVersion, + job: { + ...MockTemplateVersion.job, + status: "running", + }, + }; + + let calls = 0; + server.use( + http.get( + "/api/v2/organizations/:org/templates/:template/versions/:version", + () => { + calls += 1; + return HttpResponse.json( + calls > 1 ? MockRunningTemplateVersion : MockPendingTemplateVersion, + ); + }, + ), + ); + + // Mock the logs when the status is running. This prevents connection errors + // from being thrown in the console during the test. + new WS( + `ws://localhost/api/v2/templateversions/${MockTemplateVersion.name}/logs?follow=true`, + ); + + renderEditorPage(createTestQueryClient()); + + const status = await screen.findByRole("status"); + expect(status).toHaveTextContent("Pending"); + + await waitFor( + () => { + expect(status).toHaveTextContent("Running"); + }, + // Increase the timeout due to the page fetching results every second, which + // may cause delays. + { timeout: 5_000 }, + ); +}); + +function renderEditorPage(queryClient: QueryClient) { + return render( + <AppProviders queryClient={queryClient}> + <RouterProvider + router={createMemoryRouter( + [ + { + element: <RequireAuth />, + children: [ + { + element: <TemplateVersionEditorPage />, + path: "/templates/:template/versions/:version/edit", + }, + ], + }, + ], + { + initialEntries: [ + `/templates/${MockTemplate.name}/versions/${MockTemplateVersion.name}/edit`, + ], + }, + )} + /> + </AppProviders>, + ); +} diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx index 2df3712aa6bd0..10412bd616a67 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionEditorPage.tsx @@ -43,27 +43,31 @@ export const TemplateVersionEditorPage: FC = () => { templateName, versionName, ); - const templateVersionQuery = useQuery({ + const activeTemplateVersionQuery = useQuery({ ...templateVersionOptions, keepPreviousData: true, + refetchInterval(data) { + return data?.job.status === "pending" ? 1_000 : false; + }, }); + const { data: activeTemplateVersion } = activeTemplateVersionQuery; const uploadFileMutation = useMutation(uploadFile()); const createTemplateVersionMutation = useMutation( createTemplateVersion(organizationId), ); const resourcesQuery = useQuery({ - ...resources(templateVersionQuery.data?.id ?? ""), - enabled: templateVersionQuery.data?.job.status === "succeeded", + ...resources(activeTemplateVersion?.id ?? ""), + enabled: activeTemplateVersion?.job.status === "succeeded", }); - const logs = useWatchVersionLogs(templateVersionQuery.data, { - onDone: templateVersionQuery.refetch, + const logs = useWatchVersionLogs(activeTemplateVersion, { + onDone: activeTemplateVersionQuery.refetch, }); - const { fileTree, tarFile } = useFileTree(templateVersionQuery.data); + const { fileTree, tarFile } = useFileTree(activeTemplateVersion); const { missingVariables, setIsMissingVariablesDialogOpen, isMissingVariablesDialogOpen, - } = useMissingVariables(templateVersionQuery.data); + } = useMissingVariables(activeTemplateVersion); // Handle template publishing const [isPublishingDialogOpen, setIsPublishingDialogOpen] = useState(false); @@ -109,10 +113,10 @@ export const TemplateVersionEditorPage: FC = () => { Record<string, string> >({}); useEffect(() => { - if (templateVersionQuery.data?.job.tags) { - setProvisionerTags(templateVersionQuery.data.job.tags); + if (activeTemplateVersion?.job.tags) { + setProvisionerTags(activeTemplateVersion.job.tags); } - }, [templateVersionQuery.data?.job.tags]); + }, [activeTemplateVersion?.job.tags]); return ( <> @@ -120,14 +124,14 @@ export const TemplateVersionEditorPage: FC = () => { <title>{pageTitle(`${templateName} ยท Template Editor`)}</title> </Helmet> - {!(templateQuery.data && templateVersionQuery.data && fileTree) ? ( + {!(templateQuery.data && activeTemplateVersion && fileTree) ? ( <Loader fullscreen /> ) : ( <TemplateVersionEditor activePath={activePath} onActivePathChange={onActivePathChange} template={templateQuery.data} - templateVersion={templateVersionQuery.data} + templateVersion={activeTemplateVersion} defaultFileTree={fileTree} onPreview={async (newFileTree) => { if (!tarFile) { @@ -159,10 +163,10 @@ export const TemplateVersionEditorPage: FC = () => { await publishVersionMutation.mutateAsync({ isActiveVersion, data, - version: templateVersionQuery.data, + version: activeTemplateVersion, }); const publishedVersion = { - ...templateVersionQuery.data, + ...activeTemplateVersion, ...data, }; setIsPublishingDialogOpen(false); @@ -190,13 +194,12 @@ export const TemplateVersionEditorPage: FC = () => { isBuilding={ createTemplateVersionMutation.isLoading || uploadFileMutation.isLoading || - templateVersionQuery.data.job.status === "running" || - templateVersionQuery.data.job.status === "pending" + activeTemplateVersion.job.status === "running" || + activeTemplateVersion.job.status === "pending" } canPublish={ - templateVersionQuery.data.job.status === "succeeded" && - templateQuery.data.active_version_id !== - templateVersionQuery.data.id + activeTemplateVersion.job.status === "succeeded" && + templateQuery.data.active_version_id !== activeTemplateVersion.id } resources={resourcesQuery.data} buildLogs={logs} diff --git a/site/src/pages/TemplateVersionEditorPage/TemplateVersionStatusBadge.tsx b/site/src/pages/TemplateVersionEditorPage/TemplateVersionStatusBadge.tsx index 625927020d8d4..6da9ce01e2e9d 100644 --- a/site/src/pages/TemplateVersionEditorPage/TemplateVersionStatusBadge.tsx +++ b/site/src/pages/TemplateVersionEditorPage/TemplateVersionStatusBadge.tsx @@ -1,9 +1,11 @@ import CheckIcon from "@mui/icons-material/CheckOutlined"; import ErrorIcon from "@mui/icons-material/ErrorOutline"; +import QueuedIcon from "@mui/icons-material/HourglassEmpty"; import type { FC, ReactNode } from "react"; import type { TemplateVersion } from "api/typesGenerated"; import { Pill, PillSpinner } from "components/Pill/Pill"; import type { ThemeRole } from "theme/roles"; +import { getPendingStatusLabel } from "utils/provisionerJob"; interface TemplateVersionStatusBadgeProps { version: TemplateVersion; @@ -14,7 +16,12 @@ export const TemplateVersionStatusBadge: FC< > = ({ version }) => { const { text, icon, type } = getStatus(version); return ( - <Pill icon={icon} type={type} title={`Build status is ${text}`}> + <Pill + icon={icon} + type={type} + title={`Build status is ${text}`} + role="status" + > {text} </Pill> ); @@ -37,8 +44,8 @@ export const getStatus = ( case "pending": return { type: "info", - text: "Pending", - icon: <PillSpinner />, + text: getPendingStatusLabel(version.job), + icon: <QueuedIcon />, }; case "canceling": return { diff --git a/site/src/utils/provisionerJob.ts b/site/src/utils/provisionerJob.ts new file mode 100644 index 0000000000000..00d618f433773 --- /dev/null +++ b/site/src/utils/provisionerJob.ts @@ -0,0 +1,10 @@ +import type { ProvisionerJob } from "api/typesGenerated"; + +export const getPendingStatusLabel = ( + provisionerJob?: ProvisionerJob, +): string => { + if (!provisionerJob || provisionerJob.queue_size === 0) { + return "Pending"; + } + return "Position in queue: " + provisionerJob.queue_position; +}; diff --git a/site/src/utils/workspace.tsx b/site/src/utils/workspace.tsx index 2bc789e0d0395..59bb67050827b 100644 --- a/site/src/utils/workspace.tsx +++ b/site/src/utils/workspace.tsx @@ -10,6 +10,7 @@ import utc from "dayjs/plugin/utc"; import semver from "semver"; import type * as TypesGen from "api/typesGenerated"; import { PillSpinner } from "components/Pill/Pill"; +import { getPendingStatusLabel } from "./provisionerJob"; dayjs.extend(duration); dayjs.extend(utc); @@ -234,21 +235,12 @@ export const getDisplayWorkspaceStatus = ( case "pending": return { type: "active", - text: getPendingWorkspaceStatusText(provisionerJob), + text: getPendingStatusLabel(provisionerJob), icon: <QueuedIcon />, } as const; } }; -const getPendingWorkspaceStatusText = ( - provisionerJob?: TypesGen.ProvisionerJob, -): string => { - if (!provisionerJob || provisionerJob.queue_size === 0) { - return "Pending"; - } - return "Position in queue: " + provisionerJob.queue_position; -}; - export const hasJobError = (workspace: TypesGen.Workspace) => { return workspace.latest_build.job.error !== undefined; };