Skip to content

Commit 79480ca

Browse files
feat(site): display build logs on template creation (coder#12271)
1 parent 13359aa commit 79480ca

25 files changed

+607
-668
lines changed

site/jest.setup.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ global.TextDecoder = TextDecoder as any;
4949
global.Blob = Blob as any;
5050
global.scrollTo = jest.fn();
5151

52+
window.HTMLElement.prototype.scrollIntoView = function () {};
53+
5254
// Polyfill the getRandomValues that is used on utils/random.ts
5355
Object.defineProperty(global.self, "crypto", {
5456
value: {

site/src/@types/storybook.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,8 @@ declare module "@storybook/react" {
77
features?: FeatureName[];
88
experiments?: Experiments;
99
queries?: { key: QueryKey; data: unknown }[];
10+
webSocket?: {
11+
messages: string[];
12+
};
1013
}
1114
}

site/src/api/api.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1543,7 +1543,7 @@ export const watchAgentMetadata = (agentId: string): EventSource => {
15431543
type WatchBuildLogsByTemplateVersionIdOptions = {
15441544
after?: number;
15451545
onMessage: (log: TypesGen.ProvisionerJobLog) => void;
1546-
onDone: () => void;
1546+
onDone?: () => void;
15471547
onError: (error: Error) => void;
15481548
};
15491549
export const watchBuildLogsByTemplateVersionId = (
@@ -1575,7 +1575,7 @@ export const watchBuildLogsByTemplateVersionId = (
15751575
});
15761576
socket.addEventListener("close", () => {
15771577
// When the socket closes, logs have finished streaming!
1578-
onDone();
1578+
onDone?.();
15791579
});
15801580
return socket;
15811581
};

site/src/api/queries/templates.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -206,16 +206,21 @@ export const createTemplate = () => {
206206
};
207207
};
208208

209-
const createTemplateFn = async (options: {
209+
export type CreateTemplateOptions = {
210210
organizationId: string;
211211
version: CreateTemplateVersionRequest;
212212
template: Omit<CreateTemplateRequest, "template_version_id">;
213-
}) => {
213+
onCreateVersion?: (version: TemplateVersion) => void;
214+
onTemplateVersionChanges?: (version: TemplateVersion) => void;
215+
};
216+
217+
const createTemplateFn = async (options: CreateTemplateOptions) => {
214218
const version = await API.createTemplateVersion(
215219
options.organizationId,
216220
options.version,
217221
);
218-
await waitBuildToBeFinished(version);
222+
options.onCreateVersion?.(version);
223+
await waitBuildToBeFinished(version, options.onTemplateVersionChanges);
219224
return API.createTemplate(options.organizationId, {
220225
...options.template,
221226
template_version_id: version.id,
@@ -278,12 +283,17 @@ export const previousTemplateVersion = (
278283
};
279284
};
280285

281-
const waitBuildToBeFinished = async (version: TemplateVersion) => {
286+
const waitBuildToBeFinished = async (
287+
version: TemplateVersion,
288+
onRequest?: (data: TemplateVersion) => void,
289+
) => {
282290
let data: TemplateVersion;
283-
let jobStatus: ProvisionerJobStatus;
291+
let jobStatus: ProvisionerJobStatus | undefined = undefined;
284292
do {
285-
await delay(1000);
293+
// When pending we want to poll more frequently
294+
await delay(jobStatus === "pending" ? 250 : 1000);
286295
data = await API.getTemplateVersion(version.id);
296+
onRequest?.(data);
287297
jobStatus = data.job.status;
288298

289299
if (jobStatus === "succeeded") {

site/src/components/Form/Form.tsx

Lines changed: 72 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
useContext,
77
ReactNode,
88
ComponentProps,
9+
forwardRef,
910
} from "react";
1011
import { AlphaBadge, DeprecatedBadge } from "components/Badges/Badges";
1112
import { Stack } from "components/Stack/Stack";
@@ -81,59 +82,49 @@ interface FormSectionProps {
8182
deprecated?: boolean;
8283
}
8384

84-
export const FormSection: FC<FormSectionProps> = ({
85-
children,
86-
title,
87-
description,
88-
classes = {},
89-
alpha = false,
90-
deprecated = false,
91-
}) => {
92-
const { direction } = useContext(FormContext);
93-
const theme = useTheme();
94-
95-
return (
96-
<div
97-
css={{
98-
display: "flex",
99-
alignItems: "flex-start",
100-
flexDirection: direction === "horizontal" ? "row" : "column",
101-
gap: direction === "horizontal" ? 120 : 24,
102-
103-
[theme.breakpoints.down("md")]: {
104-
flexDirection: "column",
105-
gap: 16,
106-
},
107-
}}
108-
className={classes.root}
109-
>
110-
<div
111-
css={{
112-
width: "100%",
113-
maxWidth: direction === "horizontal" ? 312 : undefined,
114-
flexShrink: 0,
115-
position: direction === "horizontal" ? "sticky" : undefined,
116-
top: 24,
117-
118-
[theme.breakpoints.down("md")]: {
119-
width: "100%",
120-
position: "initial" as const,
121-
},
122-
}}
123-
className={classes.sectionInfo}
85+
export const FormSection = forwardRef<HTMLDivElement, FormSectionProps>(
86+
(
87+
{
88+
children,
89+
title,
90+
description,
91+
classes = {},
92+
alpha = false,
93+
deprecated = false,
94+
},
95+
ref,
96+
) => {
97+
const { direction } = useContext(FormContext);
98+
99+
return (
100+
<section
101+
ref={ref}
102+
css={[
103+
styles.formSection,
104+
direction === "horizontal" && styles.formSectionHorizontal,
105+
]}
106+
className={classes.root}
124107
>
125-
<h2 css={styles.formSectionInfoTitle} className={classes.infoTitle}>
126-
{title}
127-
{alpha && <AlphaBadge />}
128-
{deprecated && <DeprecatedBadge />}
129-
</h2>
130-
<div css={styles.formSectionInfoDescription}>{description}</div>
131-
</div>
132-
133-
{children}
134-
</div>
135-
);
136-
};
108+
<div
109+
css={[
110+
styles.formSectionInfo,
111+
direction === "horizontal" && styles.formSectionInfoHorizontal,
112+
]}
113+
className={classes.sectionInfo}
114+
>
115+
<h2 css={styles.formSectionInfoTitle} className={classes.infoTitle}>
116+
{title}
117+
{alpha && <AlphaBadge />}
118+
{deprecated && <DeprecatedBadge />}
119+
</h2>
120+
<div css={styles.formSectionInfoDescription}>{description}</div>
121+
</div>
122+
123+
{children}
124+
</section>
125+
);
126+
},
127+
);
137128

138129
export const FormFields: FC<ComponentProps<typeof Stack>> = (props) => {
139130
return (
@@ -147,6 +138,35 @@ export const FormFields: FC<ComponentProps<typeof Stack>> = (props) => {
147138
};
148139

149140
const styles = {
141+
formSection: (theme) => ({
142+
display: "flex",
143+
alignItems: "flex-start",
144+
flexDirection: "column",
145+
gap: 24,
146+
147+
[theme.breakpoints.down("md")]: {
148+
flexDirection: "column",
149+
gap: 16,
150+
},
151+
}),
152+
formSectionHorizontal: {
153+
flexDirection: "row",
154+
gap: 120,
155+
},
156+
formSectionInfo: (theme) => ({
157+
width: "100%",
158+
flexShrink: 0,
159+
top: 24,
160+
161+
[theme.breakpoints.down("md")]: {
162+
width: "100%",
163+
position: "initial" as const,
164+
},
165+
}),
166+
formSectionInfoHorizontal: {
167+
maxWidth: 312,
168+
position: "sticky",
169+
},
150170
formSectionInfoTitle: (theme) => ({
151171
fontSize: 20,
152172
color: theme.palette.text.primary,

site/src/components/FormFooter/FormFooter.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@ export interface FormFooterProps {
1919
styles?: FormFooterStyles;
2020
submitLabel?: string;
2121
submitDisabled?: boolean;
22+
extraActions?: React.ReactNode;
2223
}
2324

2425
export const FormFooter: FC<FormFooterProps> = ({
2526
onCancel,
27+
extraActions,
2628
isLoading,
2729
submitDisabled,
2830
submitLabel = Language.defaultSubmitLabel,
@@ -52,6 +54,7 @@ export const FormFooter: FC<FormFooterProps> = ({
5254
>
5355
{Language.cancelLabel}
5456
</Button>
57+
{extraActions}
5558
</div>
5659
);
5760
};
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { watchBuildLogsByTemplateVersionId } from "api/api";
2+
import { ProvisionerJobLog, TemplateVersion } from "api/typesGenerated";
3+
import { useState, useEffect } from "react";
4+
5+
export const useWatchVersionLogs = (
6+
templateVersion: TemplateVersion | undefined,
7+
options?: { onDone: () => Promise<unknown> },
8+
) => {
9+
const [logs, setLogs] = useState<ProvisionerJobLog[] | undefined>();
10+
const templateVersionId = templateVersion?.id;
11+
const templateVersionStatus = templateVersion?.job.status;
12+
13+
useEffect(() => {
14+
setLogs(undefined);
15+
}, [templateVersionId]);
16+
17+
useEffect(() => {
18+
if (!templateVersionId || !templateVersionStatus) {
19+
return;
20+
}
21+
22+
if (templateVersionStatus !== "running") {
23+
return;
24+
}
25+
26+
const socket = watchBuildLogsByTemplateVersionId(templateVersionId, {
27+
onMessage: (log) => {
28+
setLogs((logs) => (logs ? [...logs, log] : [log]));
29+
},
30+
onDone: options?.onDone,
31+
onError: (error) => {
32+
console.error(error);
33+
},
34+
});
35+
36+
return () => {
37+
socket.close();
38+
};
39+
}, [options?.onDone, templateVersionId, templateVersionStatus]);
40+
41+
return logs;
42+
};

site/src/modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ const styles = {
112112
borderRadius: "0 0 8px 8px",
113113
},
114114

115-
"&:first-child": {
115+
"&:first-of-type": {
116116
borderRadius: "8px 8px 0 0",
117117
},
118118
}),
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { JobError } from "api/queries/templates";
2+
import { BuildLogsDrawer } from "./BuildLogsDrawer";
3+
import type { Meta, StoryObj } from "@storybook/react";
4+
import {
5+
MockProvisionerJob,
6+
MockTemplateVersion,
7+
MockWorkspaceBuildLogs,
8+
} from "testHelpers/entities";
9+
import { withWebSocket } from "testHelpers/storybook";
10+
11+
const meta: Meta<typeof BuildLogsDrawer> = {
12+
title: "pages/CreateTemplatePage/BuildLogsDrawer",
13+
component: BuildLogsDrawer,
14+
args: {
15+
open: true,
16+
},
17+
};
18+
19+
export default meta;
20+
type Story = StoryObj<typeof BuildLogsDrawer>;
21+
22+
export const Loading: Story = {};
23+
24+
export const MissingVariables: Story = {
25+
args: {
26+
templateVersion: MockTemplateVersion,
27+
error: new JobError(
28+
{
29+
...MockProvisionerJob,
30+
error_code: "REQUIRED_TEMPLATE_VARIABLES",
31+
},
32+
MockTemplateVersion,
33+
),
34+
},
35+
};
36+
37+
export const Logs: Story = {
38+
args: {
39+
templateVersion: {
40+
...MockTemplateVersion,
41+
job: {
42+
...MockTemplateVersion.job,
43+
status: "running",
44+
},
45+
},
46+
},
47+
decorators: [withWebSocket],
48+
parameters: {
49+
webSocket: {
50+
messages: MockWorkspaceBuildLogs.map((log) => JSON.stringify(log)),
51+
},
52+
},
53+
};

0 commit comments

Comments
 (0)