mirror of
https://github.com/coder/coder.git
synced 2025-07-09 11:45:56 +00:00
feat(site): display build logs on template creation (#12271)
This commit is contained in:
@ -49,6 +49,8 @@ global.TextDecoder = TextDecoder as any;
|
||||
global.Blob = Blob as any;
|
||||
global.scrollTo = jest.fn();
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = function () {};
|
||||
|
||||
// Polyfill the getRandomValues that is used on utils/random.ts
|
||||
Object.defineProperty(global.self, "crypto", {
|
||||
value: {
|
||||
|
3
site/src/@types/storybook.d.ts
vendored
3
site/src/@types/storybook.d.ts
vendored
@ -7,5 +7,8 @@ declare module "@storybook/react" {
|
||||
features?: FeatureName[];
|
||||
experiments?: Experiments;
|
||||
queries?: { key: QueryKey; data: unknown }[];
|
||||
webSocket?: {
|
||||
messages: string[];
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -1543,7 +1543,7 @@ export const watchAgentMetadata = (agentId: string): EventSource => {
|
||||
type WatchBuildLogsByTemplateVersionIdOptions = {
|
||||
after?: number;
|
||||
onMessage: (log: TypesGen.ProvisionerJobLog) => void;
|
||||
onDone: () => void;
|
||||
onDone?: () => void;
|
||||
onError: (error: Error) => void;
|
||||
};
|
||||
export const watchBuildLogsByTemplateVersionId = (
|
||||
@ -1575,7 +1575,7 @@ export const watchBuildLogsByTemplateVersionId = (
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
// When the socket closes, logs have finished streaming!
|
||||
onDone();
|
||||
onDone?.();
|
||||
});
|
||||
return socket;
|
||||
};
|
||||
|
@ -206,16 +206,21 @@ export const createTemplate = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const createTemplateFn = async (options: {
|
||||
export type CreateTemplateOptions = {
|
||||
organizationId: string;
|
||||
version: CreateTemplateVersionRequest;
|
||||
template: Omit<CreateTemplateRequest, "template_version_id">;
|
||||
}) => {
|
||||
onCreateVersion?: (version: TemplateVersion) => void;
|
||||
onTemplateVersionChanges?: (version: TemplateVersion) => void;
|
||||
};
|
||||
|
||||
const createTemplateFn = async (options: CreateTemplateOptions) => {
|
||||
const version = await API.createTemplateVersion(
|
||||
options.organizationId,
|
||||
options.version,
|
||||
);
|
||||
await waitBuildToBeFinished(version);
|
||||
options.onCreateVersion?.(version);
|
||||
await waitBuildToBeFinished(version, options.onTemplateVersionChanges);
|
||||
return API.createTemplate(options.organizationId, {
|
||||
...options.template,
|
||||
template_version_id: version.id,
|
||||
@ -278,12 +283,17 @@ export const previousTemplateVersion = (
|
||||
};
|
||||
};
|
||||
|
||||
const waitBuildToBeFinished = async (version: TemplateVersion) => {
|
||||
const waitBuildToBeFinished = async (
|
||||
version: TemplateVersion,
|
||||
onRequest?: (data: TemplateVersion) => void,
|
||||
) => {
|
||||
let data: TemplateVersion;
|
||||
let jobStatus: ProvisionerJobStatus;
|
||||
let jobStatus: ProvisionerJobStatus | undefined = undefined;
|
||||
do {
|
||||
await delay(1000);
|
||||
// When pending we want to poll more frequently
|
||||
await delay(jobStatus === "pending" ? 250 : 1000);
|
||||
data = await API.getTemplateVersion(version.id);
|
||||
onRequest?.(data);
|
||||
jobStatus = data.job.status;
|
||||
|
||||
if (jobStatus === "succeeded") {
|
||||
|
@ -6,6 +6,7 @@ import {
|
||||
useContext,
|
||||
ReactNode,
|
||||
ComponentProps,
|
||||
forwardRef,
|
||||
} from "react";
|
||||
import { AlphaBadge, DeprecatedBadge } from "components/Badges/Badges";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
@ -81,59 +82,49 @@ interface FormSectionProps {
|
||||
deprecated?: boolean;
|
||||
}
|
||||
|
||||
export const FormSection: FC<FormSectionProps> = ({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
classes = {},
|
||||
alpha = false,
|
||||
deprecated = false,
|
||||
}) => {
|
||||
const { direction } = useContext(FormContext);
|
||||
const theme = useTheme();
|
||||
export const FormSection = forwardRef<HTMLDivElement, FormSectionProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
classes = {},
|
||||
alpha = false,
|
||||
deprecated = false,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { direction } = useContext(FormContext);
|
||||
|
||||
return (
|
||||
<div
|
||||
css={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
flexDirection: direction === "horizontal" ? "row" : "column",
|
||||
gap: direction === "horizontal" ? 120 : 24,
|
||||
|
||||
[theme.breakpoints.down("md")]: {
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
},
|
||||
}}
|
||||
className={classes.root}
|
||||
>
|
||||
<div
|
||||
css={{
|
||||
width: "100%",
|
||||
maxWidth: direction === "horizontal" ? 312 : undefined,
|
||||
flexShrink: 0,
|
||||
position: direction === "horizontal" ? "sticky" : undefined,
|
||||
top: 24,
|
||||
|
||||
[theme.breakpoints.down("md")]: {
|
||||
width: "100%",
|
||||
position: "initial" as const,
|
||||
},
|
||||
}}
|
||||
className={classes.sectionInfo}
|
||||
return (
|
||||
<section
|
||||
ref={ref}
|
||||
css={[
|
||||
styles.formSection,
|
||||
direction === "horizontal" && styles.formSectionHorizontal,
|
||||
]}
|
||||
className={classes.root}
|
||||
>
|
||||
<h2 css={styles.formSectionInfoTitle} className={classes.infoTitle}>
|
||||
{title}
|
||||
{alpha && <AlphaBadge />}
|
||||
{deprecated && <DeprecatedBadge />}
|
||||
</h2>
|
||||
<div css={styles.formSectionInfoDescription}>{description}</div>
|
||||
</div>
|
||||
<div
|
||||
css={[
|
||||
styles.formSectionInfo,
|
||||
direction === "horizontal" && styles.formSectionInfoHorizontal,
|
||||
]}
|
||||
className={classes.sectionInfo}
|
||||
>
|
||||
<h2 css={styles.formSectionInfoTitle} className={classes.infoTitle}>
|
||||
{title}
|
||||
{alpha && <AlphaBadge />}
|
||||
{deprecated && <DeprecatedBadge />}
|
||||
</h2>
|
||||
<div css={styles.formSectionInfoDescription}>{description}</div>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const FormFields: FC<ComponentProps<typeof Stack>> = (props) => {
|
||||
return (
|
||||
@ -147,6 +138,35 @@ export const FormFields: FC<ComponentProps<typeof Stack>> = (props) => {
|
||||
};
|
||||
|
||||
const styles = {
|
||||
formSection: (theme) => ({
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
flexDirection: "column",
|
||||
gap: 24,
|
||||
|
||||
[theme.breakpoints.down("md")]: {
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
},
|
||||
}),
|
||||
formSectionHorizontal: {
|
||||
flexDirection: "row",
|
||||
gap: 120,
|
||||
},
|
||||
formSectionInfo: (theme) => ({
|
||||
width: "100%",
|
||||
flexShrink: 0,
|
||||
top: 24,
|
||||
|
||||
[theme.breakpoints.down("md")]: {
|
||||
width: "100%",
|
||||
position: "initial" as const,
|
||||
},
|
||||
}),
|
||||
formSectionInfoHorizontal: {
|
||||
maxWidth: 312,
|
||||
position: "sticky",
|
||||
},
|
||||
formSectionInfoTitle: (theme) => ({
|
||||
fontSize: 20,
|
||||
color: theme.palette.text.primary,
|
||||
|
@ -19,10 +19,12 @@ export interface FormFooterProps {
|
||||
styles?: FormFooterStyles;
|
||||
submitLabel?: string;
|
||||
submitDisabled?: boolean;
|
||||
extraActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const FormFooter: FC<FormFooterProps> = ({
|
||||
onCancel,
|
||||
extraActions,
|
||||
isLoading,
|
||||
submitDisabled,
|
||||
submitLabel = Language.defaultSubmitLabel,
|
||||
@ -52,6 +54,7 @@ export const FormFooter: FC<FormFooterProps> = ({
|
||||
>
|
||||
{Language.cancelLabel}
|
||||
</Button>
|
||||
{extraActions}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
42
site/src/modules/templates/useWatchVersionLogs.ts
Normal file
42
site/src/modules/templates/useWatchVersionLogs.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { watchBuildLogsByTemplateVersionId } from "api/api";
|
||||
import { ProvisionerJobLog, TemplateVersion } from "api/typesGenerated";
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
export const useWatchVersionLogs = (
|
||||
templateVersion: TemplateVersion | undefined,
|
||||
options?: { onDone: () => Promise<unknown> },
|
||||
) => {
|
||||
const [logs, setLogs] = useState<ProvisionerJobLog[] | undefined>();
|
||||
const templateVersionId = templateVersion?.id;
|
||||
const templateVersionStatus = templateVersion?.job.status;
|
||||
|
||||
useEffect(() => {
|
||||
setLogs(undefined);
|
||||
}, [templateVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateVersionId || !templateVersionStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (templateVersionStatus !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = watchBuildLogsByTemplateVersionId(templateVersionId, {
|
||||
onMessage: (log) => {
|
||||
setLogs((logs) => (logs ? [...logs, log] : [log]));
|
||||
},
|
||||
onDone: options?.onDone,
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, [options?.onDone, templateVersionId, templateVersionStatus]);
|
||||
|
||||
return logs;
|
||||
};
|
@ -112,7 +112,7 @@ const styles = {
|
||||
borderRadius: "0 0 8px 8px",
|
||||
},
|
||||
|
||||
"&:first-child": {
|
||||
"&:first-of-type": {
|
||||
borderRadius: "8px 8px 0 0",
|
||||
},
|
||||
}),
|
||||
|
@ -0,0 +1,53 @@
|
||||
import { JobError } from "api/queries/templates";
|
||||
import { BuildLogsDrawer } from "./BuildLogsDrawer";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import {
|
||||
MockProvisionerJob,
|
||||
MockTemplateVersion,
|
||||
MockWorkspaceBuildLogs,
|
||||
} from "testHelpers/entities";
|
||||
import { withWebSocket } from "testHelpers/storybook";
|
||||
|
||||
const meta: Meta<typeof BuildLogsDrawer> = {
|
||||
title: "pages/CreateTemplatePage/BuildLogsDrawer",
|
||||
component: BuildLogsDrawer,
|
||||
args: {
|
||||
open: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof BuildLogsDrawer>;
|
||||
|
||||
export const Loading: Story = {};
|
||||
|
||||
export const MissingVariables: Story = {
|
||||
args: {
|
||||
templateVersion: MockTemplateVersion,
|
||||
error: new JobError(
|
||||
{
|
||||
...MockProvisionerJob,
|
||||
error_code: "REQUIRED_TEMPLATE_VARIABLES",
|
||||
},
|
||||
MockTemplateVersion,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const Logs: Story = {
|
||||
args: {
|
||||
templateVersion: {
|
||||
...MockTemplateVersion,
|
||||
job: {
|
||||
...MockTemplateVersion.job,
|
||||
status: "running",
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [withWebSocket],
|
||||
parameters: {
|
||||
webSocket: {
|
||||
messages: MockWorkspaceBuildLogs.map((log) => JSON.stringify(log)),
|
||||
},
|
||||
},
|
||||
};
|
179
site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx
Normal file
179
site/src/pages/CreateTemplatePage/BuildLogsDrawer.tsx
Normal file
@ -0,0 +1,179 @@
|
||||
import Drawer from "@mui/material/Drawer";
|
||||
import Close from "@mui/icons-material/Close";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import { FC, useLayoutEffect, useRef } from "react";
|
||||
import { TemplateVersion } from "api/typesGenerated";
|
||||
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
||||
import { Interpolation, Theme } from "@emotion/react";
|
||||
import { navHeight } from "theme/constants";
|
||||
import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs";
|
||||
import { JobError } from "api/queries/templates";
|
||||
import Button from "@mui/material/Button";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import WarningOutlined from "@mui/icons-material/WarningOutlined";
|
||||
|
||||
type BuildLogsDrawerProps = {
|
||||
error: unknown;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
templateVersion: TemplateVersion | undefined;
|
||||
variablesSectionRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export const BuildLogsDrawer: FC<BuildLogsDrawerProps> = ({
|
||||
templateVersion,
|
||||
error,
|
||||
variablesSectionRef,
|
||||
...drawerProps
|
||||
}) => {
|
||||
const logs = useWatchVersionLogs(templateVersion);
|
||||
const logsContainer = useRef<HTMLDivElement>(null);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
setTimeout(() => {
|
||||
if (logsContainer.current) {
|
||||
logsContainer.current.scrollTop = logsContainer.current.scrollHeight;
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [logs]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (drawerProps.open) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [drawerProps.open]);
|
||||
|
||||
const isMissingVariables =
|
||||
error instanceof JobError &&
|
||||
error.job.error_code === "REQUIRED_TEMPLATE_VARIABLES";
|
||||
|
||||
return (
|
||||
<Drawer anchor="right" {...drawerProps}>
|
||||
<div css={styles.root}>
|
||||
<header css={styles.header}>
|
||||
<h3 css={styles.title}>Creating template...</h3>
|
||||
<IconButton size="small" onClick={drawerProps.onClose}>
|
||||
<Close css={styles.closeIcon} />
|
||||
<span style={visuallyHidden}>Close build logs</span>
|
||||
</IconButton>
|
||||
</header>
|
||||
|
||||
{isMissingVariables ? (
|
||||
<MissingVariablesBanner
|
||||
onFillVariables={() => {
|
||||
variablesSectionRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
});
|
||||
const firstVariableInput =
|
||||
variablesSectionRef.current?.querySelector("input");
|
||||
setTimeout(() => firstVariableInput?.focus(), 0);
|
||||
drawerProps.onClose();
|
||||
}}
|
||||
/>
|
||||
) : logs ? (
|
||||
<section ref={logsContainer} css={styles.logs}>
|
||||
<WorkspaceBuildLogs logs={logs} css={{ border: 0 }} />
|
||||
</section>
|
||||
) : (
|
||||
<Loader />
|
||||
)}
|
||||
</div>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
const MissingVariablesBanner: FC<{ onFillVariables: () => void }> = ({
|
||||
onFillVariables,
|
||||
}) => {
|
||||
return (
|
||||
<div css={bannerStyles.root}>
|
||||
<div css={bannerStyles.content}>
|
||||
<WarningOutlined css={bannerStyles.icon} />
|
||||
<h4 css={bannerStyles.title}>Missing variables</h4>
|
||||
<p css={bannerStyles.description}>
|
||||
During the build process, we identified some missing variables. Rest
|
||||
assured, we have automatically added them to the form for you.
|
||||
</p>
|
||||
<Button
|
||||
css={bannerStyles.button}
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={onFillVariables}
|
||||
>
|
||||
Fill variables
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
root: {
|
||||
width: 800,
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
header: (theme) => ({
|
||||
height: navHeight,
|
||||
padding: "0 24px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
}),
|
||||
title: {
|
||||
margin: 0,
|
||||
fontWeight: 500,
|
||||
fontSize: 16,
|
||||
},
|
||||
closeIcon: {
|
||||
fontSize: 20,
|
||||
},
|
||||
logs: (theme) => ({
|
||||
flex: 1,
|
||||
overflow: "auto",
|
||||
backgroundColor: theme.palette.background.default,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
const bannerStyles = {
|
||||
root: {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 40,
|
||||
},
|
||||
content: {
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
textAlign: "center",
|
||||
maxWidth: 360,
|
||||
},
|
||||
icon: (theme) => ({
|
||||
fontSize: 32,
|
||||
color: theme.roles.warning.fill.outline,
|
||||
}),
|
||||
title: {
|
||||
fontWeight: 500,
|
||||
lineHeight: "1",
|
||||
margin: 0,
|
||||
marginTop: 16,
|
||||
},
|
||||
description: (theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
fontSize: 14,
|
||||
margin: 0,
|
||||
marginTop: 8,
|
||||
lineHeight: "1.5",
|
||||
}),
|
||||
button: {
|
||||
marginTop: 16,
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
@ -11,7 +11,7 @@ import { CreateTemplateForm } from "./CreateTemplateForm";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
|
||||
const meta: Meta<typeof CreateTemplateForm> = {
|
||||
title: "pages/CreateTemplatePage",
|
||||
title: "pages/CreateTemplatePage/CreateTemplateForm",
|
||||
component: CreateTemplateForm,
|
||||
args: {
|
||||
isSubmitting: false,
|
||||
@ -50,320 +50,3 @@ export const DuplicateTemplateWithVariables: Story = {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const WithJobError: Story = {
|
||||
args: {
|
||||
copiedTemplate: MockTemplate,
|
||||
jobError:
|
||||
"template import provision for start: recv import provision: plan terraform: terraform plan: exit status 1",
|
||||
logs: [
|
||||
{
|
||||
id: 461061,
|
||||
created_at: "2023-03-06T14:47:32.501Z",
|
||||
log_source: "provisioner_daemon",
|
||||
log_level: "info",
|
||||
stage: "Adding README.md...",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
id: 461062,
|
||||
created_at: "2023-03-06T14:47:32.501Z",
|
||||
log_source: "provisioner_daemon",
|
||||
log_level: "info",
|
||||
stage: "Setting up",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
id: 461063,
|
||||
created_at: "2023-03-06T14:47:32.528Z",
|
||||
log_source: "provisioner_daemon",
|
||||
log_level: "info",
|
||||
stage: "Parsing template parameters",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
id: 461064,
|
||||
created_at: "2023-03-06T14:47:32.552Z",
|
||||
log_source: "provisioner_daemon",
|
||||
log_level: "info",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
id: 461065,
|
||||
created_at: "2023-03-06T14:47:32.633Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
id: 461066,
|
||||
created_at: "2023-03-06T14:47:32.633Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "Initializing the backend...",
|
||||
},
|
||||
{
|
||||
id: 461067,
|
||||
created_at: "2023-03-06T14:47:32.71Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
id: 461068,
|
||||
created_at: "2023-03-06T14:47:32.711Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "Initializing provider plugins...",
|
||||
},
|
||||
{
|
||||
id: 461069,
|
||||
created_at: "2023-03-06T14:47:32.712Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: '- Finding coder/coder versions matching "~\u003e 0.6.12"...',
|
||||
},
|
||||
{
|
||||
id: 461070,
|
||||
created_at: "2023-03-06T14:47:32.922Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: '- Finding hashicorp/aws versions matching "~\u003e 4.55"...',
|
||||
},
|
||||
{
|
||||
id: 461071,
|
||||
created_at: "2023-03-06T14:47:33.132Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "- Installing hashicorp/aws v4.57.0...",
|
||||
},
|
||||
{
|
||||
id: 461072,
|
||||
created_at: "2023-03-06T14:47:37.364Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "- Installed hashicorp/aws v4.57.0 (signed by HashiCorp)",
|
||||
},
|
||||
{
|
||||
id: 461073,
|
||||
created_at: "2023-03-06T14:47:38.142Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "- Installing coder/coder v0.6.15...",
|
||||
},
|
||||
{
|
||||
id: 461074,
|
||||
created_at: "2023-03-06T14:47:39.083Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output:
|
||||
"- Installed coder/coder v0.6.15 (signed by a HashiCorp partner, key ID 93C75807601AA0EC)",
|
||||
},
|
||||
{
|
||||
id: 461075,
|
||||
created_at: "2023-03-06T14:47:39.394Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
id: 461076,
|
||||
created_at: "2023-03-06T14:47:39.394Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output:
|
||||
"Partner and community providers are signed by their developers.",
|
||||
},
|
||||
{
|
||||
id: 461077,
|
||||
created_at: "2023-03-06T14:47:39.394Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output:
|
||||
"If you'd like to know more about provider signing, you can read about it here:",
|
||||
},
|
||||
{
|
||||
id: 461078,
|
||||
created_at: "2023-03-06T14:47:39.394Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "https://www.terraform.io/docs/cli/plugins/signing.html",
|
||||
},
|
||||
{
|
||||
id: 461079,
|
||||
created_at: "2023-03-06T14:47:39.394Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
id: 461080,
|
||||
created_at: "2023-03-06T14:47:39.394Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output:
|
||||
"Terraform has created a lock file .terraform.lock.hcl to record the provider",
|
||||
},
|
||||
{
|
||||
id: 461081,
|
||||
created_at: "2023-03-06T14:47:39.394Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output:
|
||||
"selections it made above. Include this file in your version control repository",
|
||||
},
|
||||
{
|
||||
id: 461082,
|
||||
created_at: "2023-03-06T14:47:39.394Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output:
|
||||
"so that Terraform can guarantee to make the same selections by default when",
|
||||
},
|
||||
{
|
||||
id: 461083,
|
||||
created_at: "2023-03-06T14:47:39.395Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: 'you run "terraform init" in the future.',
|
||||
},
|
||||
{
|
||||
id: 461084,
|
||||
created_at: "2023-03-06T14:47:39.395Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
id: 461085,
|
||||
created_at: "2023-03-06T14:47:39.395Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "Terraform has been successfully initialized!",
|
||||
},
|
||||
{
|
||||
id: 461086,
|
||||
created_at: "2023-03-06T14:47:39.395Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
id: 461087,
|
||||
created_at: "2023-03-06T14:47:39.395Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output:
|
||||
'You may now begin working with Terraform. Try running "terraform plan" to see',
|
||||
},
|
||||
{
|
||||
id: 461088,
|
||||
created_at: "2023-03-06T14:47:39.395Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output:
|
||||
"any changes that are required for your infrastructure. All Terraform commands",
|
||||
},
|
||||
{
|
||||
id: 461089,
|
||||
created_at: "2023-03-06T14:47:39.395Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "should now work.",
|
||||
},
|
||||
{
|
||||
id: 461090,
|
||||
created_at: "2023-03-06T14:47:39.397Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
id: 461091,
|
||||
created_at: "2023-03-06T14:47:39.397Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output:
|
||||
"If you ever set or change modules or backend configuration for Terraform,",
|
||||
},
|
||||
{
|
||||
id: 461092,
|
||||
created_at: "2023-03-06T14:47:39.397Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output:
|
||||
"rerun this command to reinitialize your working directory. If you forget, other",
|
||||
},
|
||||
{
|
||||
id: 461093,
|
||||
created_at: "2023-03-06T14:47:39.397Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "debug",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "commands will detect it and remind you to do so if necessary.",
|
||||
},
|
||||
{
|
||||
id: 461094,
|
||||
created_at: "2023-03-06T14:47:39.431Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "info",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "Terraform 1.1.9",
|
||||
},
|
||||
{
|
||||
id: 461095,
|
||||
created_at: "2023-03-06T14:47:43.759Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "error",
|
||||
stage: "Detecting persistent resources",
|
||||
output:
|
||||
"Error: configuring Terraform AWS Provider: no valid credential sources for Terraform AWS Provider found.\n\nPlease see https://registry.terraform.io/providers/hashicorp/aws\nfor more information about providing credentials.\n\nError: failed to refresh cached credentials, no EC2 IMDS role found, operation error ec2imds: GetMetadata, http response error StatusCode: 404, request to EC2 IMDS failed\n",
|
||||
},
|
||||
{
|
||||
id: 461096,
|
||||
created_at: "2023-03-06T14:47:43.759Z",
|
||||
log_source: "provisioner",
|
||||
log_level: "error",
|
||||
stage: "Detecting persistent resources",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
id: 461097,
|
||||
created_at: "2023-03-06T14:47:43.777Z",
|
||||
log_source: "provisioner_daemon",
|
||||
log_level: "info",
|
||||
stage: "Cleaning Up",
|
||||
output: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { type Interpolation, type Theme } from "@emotion/react";
|
||||
import TextField from "@mui/material/TextField";
|
||||
import { useFormik } from "formik";
|
||||
import { type FC, useEffect } from "react";
|
||||
import { type FC } from "react";
|
||||
import camelCase from "lodash/camelCase";
|
||||
import capitalize from "lodash/capitalize";
|
||||
import * as Yup from "yup";
|
||||
@ -12,7 +11,6 @@ import type {
|
||||
TemplateVersionVariable,
|
||||
VariableValue,
|
||||
} from "api/typesGenerated";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate";
|
||||
import {
|
||||
nameValidator,
|
||||
@ -25,7 +23,6 @@ import {
|
||||
type TemplateAutostopRequirementDaysValue,
|
||||
} from "utils/schedule";
|
||||
import { sortedDays } from "modules/templates/TemplateScheduleAutostart/TemplateScheduleAutostart";
|
||||
import { WorkspaceBuildLogs } from "modules/workspaces/WorkspaceBuildLogs/WorkspaceBuildLogs";
|
||||
import { IconField } from "components/IconField/IconField";
|
||||
import {
|
||||
HorizontalForm,
|
||||
@ -166,24 +163,28 @@ export type CreateTemplateFormProps = (
|
||||
) & {
|
||||
onCancel: () => void;
|
||||
onSubmit: (data: CreateTemplateData) => void;
|
||||
onOpenBuildLogsDrawer: () => void;
|
||||
isSubmitting: boolean;
|
||||
variables?: TemplateVersionVariable[];
|
||||
error?: unknown;
|
||||
jobError?: string;
|
||||
logs?: ProvisionerJobLog[];
|
||||
allowAdvancedScheduling: boolean;
|
||||
variablesSectionRef: React.RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
||||
const {
|
||||
onCancel,
|
||||
onSubmit,
|
||||
onOpenBuildLogsDrawer,
|
||||
variables,
|
||||
isSubmitting,
|
||||
error,
|
||||
jobError,
|
||||
logs,
|
||||
allowAdvancedScheduling,
|
||||
variablesSectionRef,
|
||||
} = props;
|
||||
const form = useFormik<CreateTemplateData>({
|
||||
initialValues: getInitialValues({
|
||||
@ -198,18 +199,6 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
||||
});
|
||||
const getFieldHelpers = getFormHelpers<CreateTemplateData>(form, error);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (jobError) {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
}
|
||||
}, [logs, jobError]);
|
||||
|
||||
return (
|
||||
<HorizontalForm onSubmit={form.handleSubmit}>
|
||||
{/* General info */}
|
||||
@ -283,6 +272,7 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
||||
{/* Variables */}
|
||||
{variables && variables.length > 0 && (
|
||||
<FormSection
|
||||
ref={variablesSectionRef}
|
||||
title="Variables"
|
||||
description="Input variables allow you to customize templates without altering their source code."
|
||||
>
|
||||
@ -305,27 +295,37 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
|
||||
</FormSection>
|
||||
)}
|
||||
|
||||
{jobError && (
|
||||
<Stack>
|
||||
<div css={styles.error}>
|
||||
<h5 css={styles.errorTitle}>Error during provisioning</h5>
|
||||
<p css={styles.errorDescription}>
|
||||
Looks like we found an error during the template provisioning. You
|
||||
can see the logs bellow.
|
||||
</p>
|
||||
<div className="flex items-center">
|
||||
<FormFooter
|
||||
extraActions={
|
||||
logs && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBuildLogsDrawer}
|
||||
css={(theme) => ({
|
||||
backgroundColor: "transparent",
|
||||
border: 0,
|
||||
fontWeight: 500,
|
||||
fontSize: 14,
|
||||
cursor: "pointer",
|
||||
color: theme.palette.text.secondary,
|
||||
|
||||
<code css={styles.errorDetails}>{jobError}</code>
|
||||
</div>
|
||||
|
||||
<WorkspaceBuildLogs logs={logs ?? []} />
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<FormFooter
|
||||
onCancel={onCancel}
|
||||
isLoading={isSubmitting}
|
||||
submitLabel={jobError ? "Retry" : "Create template"}
|
||||
/>
|
||||
"&:hover": {
|
||||
textDecoration: "underline",
|
||||
textUnderlineOffset: 4,
|
||||
color: theme.palette.text.primary,
|
||||
},
|
||||
})}
|
||||
>
|
||||
Show build logs
|
||||
</button>
|
||||
)
|
||||
}
|
||||
onCancel={onCancel}
|
||||
isLoading={isSubmitting}
|
||||
submitLabel={jobError ? "Retry" : "Create template"}
|
||||
/>
|
||||
</div>
|
||||
</HorizontalForm>
|
||||
);
|
||||
};
|
||||
@ -344,44 +344,3 @@ const fillNameAndDisplayWithFilename = async (
|
||||
form.setFieldValue("display_name", capitalize(name)),
|
||||
]);
|
||||
};
|
||||
|
||||
const styles = {
|
||||
ttlFields: {
|
||||
width: "100%",
|
||||
},
|
||||
|
||||
optionText: (theme) => ({
|
||||
fontSize: 16,
|
||||
color: theme.palette.text.primary,
|
||||
}),
|
||||
|
||||
optionHelperText: (theme) => ({
|
||||
fontSize: 12,
|
||||
color: theme.palette.text.secondary,
|
||||
}),
|
||||
|
||||
error: (theme) => ({
|
||||
padding: 24,
|
||||
borderRadius: 8,
|
||||
background: theme.palette.background.paper,
|
||||
border: `1px solid ${theme.palette.error.main}`,
|
||||
}),
|
||||
|
||||
errorTitle: {
|
||||
fontSize: 16,
|
||||
margin: 0,
|
||||
},
|
||||
|
||||
errorDescription: (theme) => ({
|
||||
margin: 0,
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: 4,
|
||||
}),
|
||||
|
||||
errorDetails: (theme) => ({
|
||||
display: "block",
|
||||
marginTop: 8,
|
||||
color: theme.palette.error.light,
|
||||
fontSize: 16,
|
||||
}),
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
@ -19,7 +19,7 @@ const renderPage = async (searchParams: URLSearchParams) => {
|
||||
route: `/templates/new?${searchParams.toString()}`,
|
||||
path: "/templates/new",
|
||||
// We need this because after creation, the user will be redirected to here
|
||||
extraRoutes: [{ path: "templates/:template", element: <></> }],
|
||||
extraRoutes: [{ path: "templates/:template/files", element: <></> }],
|
||||
});
|
||||
// It is lazy loaded, so we have to wait for it to be rendered to not get an
|
||||
// act error
|
||||
@ -62,6 +62,12 @@ test("Create template from starter template", async () => {
|
||||
within(form).getByRole("button", { name: /create template/i }),
|
||||
);
|
||||
|
||||
// Wait for the drawer error to be rendered
|
||||
await screen.findByRole("heading", { name: /missing variables/i });
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: /fill variables/i }),
|
||||
);
|
||||
|
||||
// Wait for the variables form to be rendered and fill it
|
||||
await screen.findByText(/Variables/, undefined, { timeout: 5_000 });
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { type FC } from "react";
|
||||
import { useState, type FC, useRef } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { pageTitle } from "utils/page";
|
||||
@ -6,20 +6,40 @@ import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizont
|
||||
import { DuplicateTemplateView } from "./DuplicateTemplateView";
|
||||
import { ImportStarterTemplateView } from "./ImportStarterTemplateView";
|
||||
import { UploadTemplateView } from "./UploadTemplateView";
|
||||
import { Template } from "api/typesGenerated";
|
||||
import { BuildLogsDrawer } from "./BuildLogsDrawer";
|
||||
import { useMutation } from "react-query";
|
||||
import { createTemplate } from "api/queries/templates";
|
||||
import { CreateTemplatePageViewProps } from "./types";
|
||||
import { TemplateVersion } from "api/typesGenerated";
|
||||
|
||||
const CreateTemplatePage: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const onSuccess = (template: Template) => {
|
||||
navigate(`/templates/${template.name}/files`);
|
||||
};
|
||||
const [isBuildLogsOpen, setIsBuildLogsOpen] = useState(false);
|
||||
const [templateVersion, setTemplateVersion] = useState<TemplateVersion>();
|
||||
const createTemplateMutation = useMutation(createTemplate());
|
||||
const variablesSectionRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const onCancel = () => {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const pageViewProps: CreateTemplatePageViewProps = {
|
||||
onCreateTemplate: async (options) => {
|
||||
setIsBuildLogsOpen(true);
|
||||
const template = await createTemplateMutation.mutateAsync({
|
||||
...options,
|
||||
onCreateVersion: setTemplateVersion,
|
||||
onTemplateVersionChanges: setTemplateVersion,
|
||||
});
|
||||
navigate(`/templates/${template.name}/files`);
|
||||
},
|
||||
onOpenBuildLogsDrawer: () => setIsBuildLogsOpen(true),
|
||||
error: createTemplateMutation.error,
|
||||
isCreating: createTemplateMutation.isLoading,
|
||||
variablesSectionRef,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@ -28,13 +48,21 @@ const CreateTemplatePage: FC = () => {
|
||||
|
||||
<FullPageHorizontalForm title="Create Template" onCancel={onCancel}>
|
||||
{searchParams.has("fromTemplate") ? (
|
||||
<DuplicateTemplateView onSuccess={onSuccess} />
|
||||
<DuplicateTemplateView {...pageViewProps} />
|
||||
) : searchParams.has("exampleId") ? (
|
||||
<ImportStarterTemplateView onSuccess={onSuccess} />
|
||||
<ImportStarterTemplateView {...pageViewProps} />
|
||||
) : (
|
||||
<UploadTemplateView onSuccess={onSuccess} />
|
||||
<UploadTemplateView {...pageViewProps} />
|
||||
)}
|
||||
</FullPageHorizontalForm>
|
||||
|
||||
<BuildLogsDrawer
|
||||
error={createTemplateMutation.error}
|
||||
open={isBuildLogsOpen}
|
||||
onClose={() => setIsBuildLogsOpen(false)}
|
||||
templateVersion={templateVersion}
|
||||
variablesSectionRef={variablesSectionRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { type FC } from "react";
|
||||
import { useQuery, useMutation } from "react-query";
|
||||
import { useQuery } from "react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
templateVersionLogs,
|
||||
@ -7,7 +7,6 @@ import {
|
||||
templateVersion,
|
||||
templateVersionVariables,
|
||||
JobError,
|
||||
createTemplate,
|
||||
} from "api/queries/templates";
|
||||
import { useOrganizationId } from "contexts/auth/useOrganizationId";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
@ -15,14 +14,14 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
|
||||
import { Loader } from "components/Loader/Loader";
|
||||
import { CreateTemplateForm } from "./CreateTemplateForm";
|
||||
import { firstVersionFromFile, getFormPermissions, newTemplate } from "./utils";
|
||||
import { Template } from "api/typesGenerated";
|
||||
import { CreateTemplatePageViewProps } from "./types";
|
||||
|
||||
type DuplicateTemplateViewProps = {
|
||||
onSuccess: (template: Template) => void;
|
||||
};
|
||||
|
||||
export const DuplicateTemplateView: FC<DuplicateTemplateViewProps> = ({
|
||||
onSuccess,
|
||||
export const DuplicateTemplateView: FC<CreateTemplatePageViewProps> = ({
|
||||
onCreateTemplate,
|
||||
onOpenBuildLogsDrawer,
|
||||
variablesSectionRef,
|
||||
error,
|
||||
isCreating,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const organizationId = useOrganizationId();
|
||||
@ -51,11 +50,9 @@ export const DuplicateTemplateView: FC<DuplicateTemplateViewProps> = ({
|
||||
const dashboard = useDashboard();
|
||||
const formPermissions = getFormPermissions(dashboard.entitlements);
|
||||
|
||||
const createTemplateMutation = useMutation(createTemplate());
|
||||
const createError = createTemplateMutation.error;
|
||||
const isJobError = createError instanceof JobError;
|
||||
const isJobError = error instanceof JobError;
|
||||
const templateVersionLogsQuery = useQuery({
|
||||
...templateVersionLogs(isJobError ? createError.version.id : ""),
|
||||
...templateVersionLogs(isJobError ? error.version.id : ""),
|
||||
enabled: isJobError,
|
||||
});
|
||||
|
||||
@ -70,15 +67,17 @@ export const DuplicateTemplateView: FC<DuplicateTemplateViewProps> = ({
|
||||
return (
|
||||
<CreateTemplateForm
|
||||
{...formPermissions}
|
||||
variablesSectionRef={variablesSectionRef}
|
||||
onOpenBuildLogsDrawer={onOpenBuildLogsDrawer}
|
||||
copiedTemplate={templateByNameQuery.data!}
|
||||
error={createTemplateMutation.error}
|
||||
isSubmitting={createTemplateMutation.isLoading}
|
||||
error={error}
|
||||
isSubmitting={isCreating}
|
||||
variables={templateVersionVariablesQuery.data}
|
||||
onCancel={() => navigate(-1)}
|
||||
jobError={isJobError ? createError.job.error : undefined}
|
||||
jobError={isJobError ? error.job.error : undefined}
|
||||
logs={templateVersionLogsQuery.data}
|
||||
onSubmit={async (formData) => {
|
||||
const template = await createTemplateMutation.mutateAsync({
|
||||
await onCreateTemplate({
|
||||
organizationId,
|
||||
version: firstVersionFromFile(
|
||||
templateVersionQuery.data!.job.file_id,
|
||||
@ -86,7 +85,6 @@ export const DuplicateTemplateView: FC<DuplicateTemplateViewProps> = ({
|
||||
),
|
||||
template: newTemplate(formData),
|
||||
});
|
||||
onSuccess(template);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -1,10 +1,9 @@
|
||||
import { type FC } from "react";
|
||||
import { useQuery, useMutation } from "react-query";
|
||||
import { useQuery } from "react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
templateVersionLogs,
|
||||
JobError,
|
||||
createTemplate,
|
||||
templateExamples,
|
||||
templateVersionVariables,
|
||||
} from "api/queries/templates";
|
||||
@ -18,14 +17,14 @@ import {
|
||||
getFormPermissions,
|
||||
newTemplate,
|
||||
} from "./utils";
|
||||
import { Template } from "api/typesGenerated";
|
||||
import { CreateTemplatePageViewProps } from "./types";
|
||||
|
||||
type ImportStarterTemplateViewProps = {
|
||||
onSuccess: (template: Template) => void;
|
||||
};
|
||||
|
||||
export const ImportStarterTemplateView: FC<ImportStarterTemplateViewProps> = ({
|
||||
onSuccess,
|
||||
export const ImportStarterTemplateView: FC<CreateTemplatePageViewProps> = ({
|
||||
onCreateTemplate,
|
||||
onOpenBuildLogsDrawer,
|
||||
variablesSectionRef,
|
||||
error,
|
||||
isCreating,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const organizationId = useOrganizationId();
|
||||
@ -41,19 +40,17 @@ export const ImportStarterTemplateView: FC<ImportStarterTemplateViewProps> = ({
|
||||
const dashboard = useDashboard();
|
||||
const formPermissions = getFormPermissions(dashboard.entitlements);
|
||||
|
||||
const createTemplateMutation = useMutation(createTemplate());
|
||||
const createError = createTemplateMutation.error;
|
||||
const isJobError = createError instanceof JobError;
|
||||
const isJobError = error instanceof JobError;
|
||||
const templateVersionLogsQuery = useQuery({
|
||||
...templateVersionLogs(isJobError ? createError.version.id : ""),
|
||||
...templateVersionLogs(isJobError ? error.version.id : ""),
|
||||
enabled: isJobError,
|
||||
});
|
||||
|
||||
const missedVariables = useQuery({
|
||||
...templateVersionVariables(isJobError ? createError.version.id : ""),
|
||||
...templateVersionVariables(isJobError ? error.version.id : ""),
|
||||
keepPreviousData: true,
|
||||
enabled:
|
||||
isJobError &&
|
||||
createError.job.error_code === "REQUIRED_TEMPLATE_VARIABLES",
|
||||
isJobError && error.job.error_code === "REQUIRED_TEMPLATE_VARIABLES",
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@ -67,15 +64,17 @@ export const ImportStarterTemplateView: FC<ImportStarterTemplateViewProps> = ({
|
||||
return (
|
||||
<CreateTemplateForm
|
||||
{...formPermissions}
|
||||
variablesSectionRef={variablesSectionRef}
|
||||
onOpenBuildLogsDrawer={onOpenBuildLogsDrawer}
|
||||
starterTemplate={templateExample!}
|
||||
variables={missedVariables.data}
|
||||
error={createTemplateMutation.error}
|
||||
isSubmitting={createTemplateMutation.isLoading}
|
||||
error={error}
|
||||
isSubmitting={isCreating}
|
||||
onCancel={() => navigate(-1)}
|
||||
jobError={isJobError ? createError.job.error : undefined}
|
||||
jobError={isJobError ? error.job.error : undefined}
|
||||
logs={templateVersionLogsQuery.data}
|
||||
onSubmit={async (formData) => {
|
||||
const template = await createTemplateMutation.mutateAsync({
|
||||
await onCreateTemplate({
|
||||
organizationId,
|
||||
version: firstVersionFromExample(
|
||||
templateExample!,
|
||||
@ -83,7 +82,6 @@ export const ImportStarterTemplateView: FC<ImportStarterTemplateViewProps> = ({
|
||||
),
|
||||
template: newTemplate(formData),
|
||||
});
|
||||
onSuccess(template);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
@ -3,7 +3,6 @@ import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
templateVersionLogs,
|
||||
JobError,
|
||||
createTemplate,
|
||||
templateVersionVariables,
|
||||
} from "api/queries/templates";
|
||||
import { uploadFile } from "api/queries/files";
|
||||
@ -11,15 +10,15 @@ import { useOrganizationId } from "contexts/auth/useOrganizationId";
|
||||
import { useDashboard } from "modules/dashboard/useDashboard";
|
||||
import { CreateTemplateForm } from "./CreateTemplateForm";
|
||||
import { firstVersionFromFile, getFormPermissions, newTemplate } from "./utils";
|
||||
import { Template } from "api/typesGenerated";
|
||||
import { FC } from "react";
|
||||
import { CreateTemplatePageViewProps } from "./types";
|
||||
|
||||
type UploadTemplateViewProps = {
|
||||
onSuccess: (template: Template) => void;
|
||||
};
|
||||
|
||||
export const UploadTemplateView: FC<UploadTemplateViewProps> = ({
|
||||
onSuccess,
|
||||
export const UploadTemplateView: FC<CreateTemplatePageViewProps> = ({
|
||||
onCreateTemplate,
|
||||
onOpenBuildLogsDrawer,
|
||||
variablesSectionRef,
|
||||
isCreating,
|
||||
error,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const organizationId = useOrganizationId();
|
||||
@ -30,29 +29,28 @@ export const UploadTemplateView: FC<UploadTemplateViewProps> = ({
|
||||
const uploadFileMutation = useMutation(uploadFile());
|
||||
const uploadedFile = uploadFileMutation.data;
|
||||
|
||||
const createTemplateMutation = useMutation(createTemplate());
|
||||
const createError = createTemplateMutation.error;
|
||||
const isJobError = createError instanceof JobError;
|
||||
const isJobError = error instanceof JobError;
|
||||
const templateVersionLogsQuery = useQuery({
|
||||
...templateVersionLogs(isJobError ? createError.version.id : ""),
|
||||
...templateVersionLogs(isJobError ? error.version.id : ""),
|
||||
enabled: isJobError,
|
||||
});
|
||||
|
||||
const missedVariables = useQuery({
|
||||
...templateVersionVariables(isJobError ? createError.version.id : ""),
|
||||
...templateVersionVariables(isJobError ? error.version.id : ""),
|
||||
enabled:
|
||||
isJobError &&
|
||||
createError.job.error_code === "REQUIRED_TEMPLATE_VARIABLES",
|
||||
isJobError && error.job.error_code === "REQUIRED_TEMPLATE_VARIABLES",
|
||||
});
|
||||
|
||||
return (
|
||||
<CreateTemplateForm
|
||||
{...formPermissions}
|
||||
onOpenBuildLogsDrawer={onOpenBuildLogsDrawer}
|
||||
variablesSectionRef={variablesSectionRef}
|
||||
variables={missedVariables.data}
|
||||
error={createTemplateMutation.error}
|
||||
isSubmitting={createTemplateMutation.isLoading}
|
||||
error={error}
|
||||
isSubmitting={isCreating}
|
||||
onCancel={() => navigate(-1)}
|
||||
jobError={isJobError ? createError.job.error : undefined}
|
||||
jobError={isJobError ? error.job.error : undefined}
|
||||
logs={templateVersionLogsQuery.data}
|
||||
upload={{
|
||||
onUpload: uploadFileMutation.mutateAsync,
|
||||
@ -61,7 +59,7 @@ export const UploadTemplateView: FC<UploadTemplateViewProps> = ({
|
||||
file: uploadFileMutation.variables,
|
||||
}}
|
||||
onSubmit={async (formData) => {
|
||||
const template = await createTemplateMutation.mutateAsync({
|
||||
await onCreateTemplate({
|
||||
organizationId,
|
||||
version: firstVersionFromFile(
|
||||
uploadedFile!.hash,
|
||||
@ -69,7 +67,6 @@ export const UploadTemplateView: FC<UploadTemplateViewProps> = ({
|
||||
),
|
||||
template: newTemplate(formData),
|
||||
});
|
||||
onSuccess(template);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
9
site/src/pages/CreateTemplatePage/types.ts
Normal file
9
site/src/pages/CreateTemplatePage/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { CreateTemplateOptions } from "api/queries/templates";
|
||||
|
||||
export type CreateTemplatePageViewProps = {
|
||||
onCreateTemplate: (options: CreateTemplateOptions) => Promise<void>;
|
||||
onOpenBuildLogsDrawer: () => void;
|
||||
variablesSectionRef: React.RefObject<HTMLDivElement>;
|
||||
error: unknown;
|
||||
isCreating: boolean;
|
||||
};
|
@ -30,7 +30,6 @@ const meta: Meta<typeof TemplateVersionEditor> = {
|
||||
template: MockTemplate,
|
||||
templateVersion: MockTemplateVersion,
|
||||
defaultFileTree: MockTemplateVersionFileTree,
|
||||
onPreview: action("onPreview"),
|
||||
onPublish: action("onPublish"),
|
||||
onConfirmPublish: action("onConfirmPublish"),
|
||||
onCancelPublish: action("onCancelPublish"),
|
||||
|
@ -67,7 +67,7 @@ export interface TemplateVersionEditorProps {
|
||||
resources?: WorkspaceResource[];
|
||||
disablePreview?: boolean;
|
||||
disableUpdate?: boolean;
|
||||
onPreview: (files: FileTree) => void;
|
||||
onPreview: (files: FileTree) => Promise<void>;
|
||||
onPublish: () => void;
|
||||
onConfirmPublish: (data: PublishVersionData) => void;
|
||||
onCancelPublish: () => void;
|
||||
@ -122,14 +122,14 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
const [renameFileOpen, setRenameFileOpen] = useState<string>();
|
||||
const [dirty, setDirty] = useState(false);
|
||||
|
||||
const triggerPreview = useCallback(() => {
|
||||
onPreview(fileTree);
|
||||
const triggerPreview = useCallback(async () => {
|
||||
await onPreview(fileTree);
|
||||
setSelectedTab("logs");
|
||||
}, [fileTree, onPreview]);
|
||||
|
||||
// Stop ctrl+s from saving files and make ctrl+enter trigger a preview.
|
||||
useEffect(() => {
|
||||
const keyListener = (event: KeyboardEvent) => {
|
||||
const keyListener = async (event: KeyboardEvent) => {
|
||||
if (!(navigator.platform.match("Mac") ? event.metaKey : event.ctrlKey)) {
|
||||
return;
|
||||
}
|
||||
@ -140,7 +140,7 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
break;
|
||||
case "Enter":
|
||||
event.preventDefault();
|
||||
triggerPreview();
|
||||
await triggerPreview();
|
||||
break;
|
||||
}
|
||||
};
|
||||
@ -252,8 +252,8 @@ export const TemplateVersionEditor: FC<TemplateVersionEditorProps> = ({
|
||||
}
|
||||
title="Build template (Ctrl + Enter)"
|
||||
disabled={disablePreview}
|
||||
onClick={() => {
|
||||
triggerPreview();
|
||||
onClick={async () => {
|
||||
await triggerPreview();
|
||||
}}
|
||||
>
|
||||
Build
|
||||
@ -719,7 +719,7 @@ const styles = {
|
||||
// Hack to update logs header and lines
|
||||
"& .logs-header": {
|
||||
border: 0,
|
||||
padding: "0 16px",
|
||||
padding: "8px 16px",
|
||||
fontFamily: MONOSPACE_FONT_FAMILY,
|
||||
|
||||
"&:first-of-type": {
|
||||
|
@ -4,7 +4,7 @@ import {
|
||||
} from "testHelpers/renderHelpers";
|
||||
import TemplateVersionEditorPage from "./TemplateVersionEditorPage";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import userEvent, { UserEvent } from "@testing-library/user-event";
|
||||
import * as api from "api/api";
|
||||
import {
|
||||
MockTemplate,
|
||||
@ -21,6 +21,7 @@ import { RequireAuth } from "contexts/auth/RequireAuth";
|
||||
import { server } from "testHelpers/server";
|
||||
import { rest } from "msw";
|
||||
import { AppProviders } from "App";
|
||||
import { TemplateVersion } from "api/typesGenerated";
|
||||
|
||||
// For some reason this component in Jest is throwing a MUI style warning so,
|
||||
// since we don't need it for this test, we can mock it out
|
||||
@ -50,35 +51,50 @@ const renderTemplateEditorPage = () => {
|
||||
});
|
||||
};
|
||||
|
||||
test("Use custom name, message and set it as active when publishing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTemplateEditorPage();
|
||||
const topbar = await screen.findByTestId("topbar");
|
||||
|
||||
// Build Template
|
||||
const buildTemplateVersion = async (
|
||||
templateVersion: TemplateVersion,
|
||||
user: UserEvent,
|
||||
topbar: HTMLElement,
|
||||
) => {
|
||||
jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" });
|
||||
const newTemplateVersion = {
|
||||
...MockTemplateVersion,
|
||||
id: "new-version-id",
|
||||
name: "new-version",
|
||||
};
|
||||
jest
|
||||
.spyOn(api, "createTemplateVersion")
|
||||
.mockResolvedValue(newTemplateVersion);
|
||||
jest.spyOn(api, "createTemplateVersion").mockResolvedValue({
|
||||
...templateVersion,
|
||||
job: {
|
||||
...templateVersion.job,
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
jest
|
||||
.spyOn(api, "getTemplateVersionByName")
|
||||
.mockResolvedValue(newTemplateVersion);
|
||||
.mockResolvedValue(templateVersion);
|
||||
jest
|
||||
.spyOn(api, "watchBuildLogsByTemplateVersionId")
|
||||
.mockImplementation((_, options) => {
|
||||
options.onMessage(MockWorkspaceBuildLogs[0]);
|
||||
options.onDone();
|
||||
return jest.fn() as never;
|
||||
options.onDone?.();
|
||||
const wsMock = {
|
||||
close: jest.fn(),
|
||||
} as unknown;
|
||||
return wsMock as WebSocket;
|
||||
});
|
||||
const buildButton = within(topbar).getByRole("button", {
|
||||
name: "Build",
|
||||
});
|
||||
await user.click(buildButton);
|
||||
await within(topbar).findByText("Success");
|
||||
};
|
||||
|
||||
test("Use custom name, message and set it as active when publishing", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderTemplateEditorPage();
|
||||
const topbar = await screen.findByTestId("topbar");
|
||||
|
||||
const newTemplateVersion: TemplateVersion = {
|
||||
...MockTemplateVersion,
|
||||
id: "new-version-id",
|
||||
name: "new-version",
|
||||
};
|
||||
await buildTemplateVersion(newTemplateVersion, user, topbar);
|
||||
|
||||
// Publish
|
||||
const patchTemplateVersion = jest
|
||||
@ -87,7 +103,6 @@ test("Use custom name, message and set it as active when publishing", async () =
|
||||
const updateActiveTemplateVersion = jest
|
||||
.spyOn(api, "updateActiveTemplateVersion")
|
||||
.mockResolvedValue({ message: "" });
|
||||
await within(topbar).findByText("Success");
|
||||
const publishButton = within(topbar).getByRole("button", {
|
||||
name: "Publish",
|
||||
});
|
||||
@ -118,30 +133,12 @@ test("Do not mark as active if promote is not checked", async () => {
|
||||
renderTemplateEditorPage();
|
||||
const topbar = await screen.findByTestId("topbar");
|
||||
|
||||
// Build Template
|
||||
jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" });
|
||||
const newTemplateVersion = {
|
||||
...MockTemplateVersion,
|
||||
id: "new-version-id",
|
||||
name: "new-version",
|
||||
};
|
||||
jest
|
||||
.spyOn(api, "createTemplateVersion")
|
||||
.mockResolvedValue(newTemplateVersion);
|
||||
jest
|
||||
.spyOn(api, "getTemplateVersionByName")
|
||||
.mockResolvedValue(newTemplateVersion);
|
||||
jest
|
||||
.spyOn(api, "watchBuildLogsByTemplateVersionId")
|
||||
.mockImplementation((_, options) => {
|
||||
options.onMessage(MockWorkspaceBuildLogs[0]);
|
||||
options.onDone();
|
||||
return jest.fn() as never;
|
||||
});
|
||||
const buildButton = within(topbar).getByRole("button", {
|
||||
name: "Build",
|
||||
});
|
||||
await user.click(buildButton);
|
||||
await buildTemplateVersion(newTemplateVersion, user, topbar);
|
||||
|
||||
// Publish
|
||||
const patchTemplateVersion = jest
|
||||
@ -150,7 +147,6 @@ test("Do not mark as active if promote is not checked", async () => {
|
||||
const updateActiveTemplateVersion = jest
|
||||
.spyOn(api, "updateActiveTemplateVersion")
|
||||
.mockResolvedValue({ message: "" });
|
||||
await within(topbar).findByText("Success");
|
||||
const publishButton = within(topbar).getByRole("button", {
|
||||
name: "Publish",
|
||||
});
|
||||
@ -175,44 +171,22 @@ test("Do not mark as active if promote is not checked", async () => {
|
||||
});
|
||||
|
||||
test("Patch request is not send when there are no changes", async () => {
|
||||
const newTemplateVersion = {
|
||||
...MockTemplateVersion,
|
||||
id: "new-version-id",
|
||||
name: "new-version",
|
||||
};
|
||||
const MockTemplateVersionWithEmptyMessage = {
|
||||
...newTemplateVersion,
|
||||
message: "",
|
||||
};
|
||||
const user = userEvent.setup();
|
||||
renderTemplateEditorPage();
|
||||
const topbar = await screen.findByTestId("topbar");
|
||||
|
||||
// Build Template
|
||||
jest.spyOn(api, "uploadFile").mockResolvedValueOnce({ hash: "hash" });
|
||||
jest
|
||||
.spyOn(api, "createTemplateVersion")
|
||||
.mockResolvedValue(MockTemplateVersionWithEmptyMessage);
|
||||
jest
|
||||
.spyOn(api, "getTemplateVersionByName")
|
||||
.mockResolvedValue(MockTemplateVersionWithEmptyMessage);
|
||||
jest
|
||||
.spyOn(api, "watchBuildLogsByTemplateVersionId")
|
||||
.mockImplementation((_, options) => {
|
||||
options.onMessage(MockWorkspaceBuildLogs[0]);
|
||||
options.onDone();
|
||||
return jest.fn() as never;
|
||||
});
|
||||
const buildButton = within(topbar).getByRole("button", {
|
||||
name: "Build",
|
||||
});
|
||||
await user.click(buildButton);
|
||||
const newTemplateVersion = {
|
||||
...MockTemplateVersion,
|
||||
id: "new-version-id",
|
||||
name: "new-version",
|
||||
message: "",
|
||||
};
|
||||
await buildTemplateVersion(newTemplateVersion, user, topbar);
|
||||
|
||||
// Publish
|
||||
const patchTemplateVersion = jest
|
||||
.spyOn(api, "patchTemplateVersion")
|
||||
.mockResolvedValue(MockTemplateVersionWithEmptyMessage);
|
||||
await within(topbar).findByText("Success");
|
||||
.mockResolvedValue(newTemplateVersion);
|
||||
const publishButton = within(topbar).getByRole("button", {
|
||||
name: "Publish",
|
||||
});
|
||||
@ -220,7 +194,7 @@ test("Patch request is not send when there are no changes", async () => {
|
||||
const publishDialog = await screen.findByTestId("dialog");
|
||||
// It is using the name from the template
|
||||
const nameField = within(publishDialog).getByLabelText("Version name");
|
||||
expect(nameField).toHaveValue(MockTemplateVersionWithEmptyMessage.name);
|
||||
expect(nameField).toHaveValue(newTemplateVersion.name);
|
||||
// Publish
|
||||
await user.click(
|
||||
within(publishDialog).getByRole("button", { name: "Publish" }),
|
||||
|
@ -5,14 +5,9 @@ import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import { TemplateVersionEditor } from "./TemplateVersionEditor";
|
||||
import { useOrganizationId } from "contexts/auth/useOrganizationId";
|
||||
import { pageTitle } from "utils/page";
|
||||
import {
|
||||
patchTemplateVersion,
|
||||
updateActiveTemplateVersion,
|
||||
watchBuildLogsByTemplateVersionId,
|
||||
} from "api/api";
|
||||
import { patchTemplateVersion, updateActiveTemplateVersion } from "api/api";
|
||||
import type {
|
||||
PatchTemplateVersionRequest,
|
||||
ProvisionerJobLog,
|
||||
TemplateVersion,
|
||||
} from "api/typesGenerated";
|
||||
import {
|
||||
@ -28,6 +23,7 @@ import { FileTree, traverse } from "utils/filetree";
|
||||
import { createTemplateVersionFileTree } from "utils/templateVersion";
|
||||
import { displayError } from "components/GlobalSnackbar/utils";
|
||||
import { FullScreenLoader } from "components/Loader/FullScreenLoader";
|
||||
import { useWatchVersionLogs } from "modules/templates/useWatchVersionLogs";
|
||||
|
||||
type Params = {
|
||||
version: string;
|
||||
@ -58,7 +54,7 @@ export const TemplateVersionEditorPage: FC = () => {
|
||||
...resources(templateVersionQuery.data?.id ?? ""),
|
||||
enabled: templateVersionQuery.data?.job.status === "succeeded",
|
||||
});
|
||||
const { logs, setLogs } = useVersionLogs(templateVersionQuery.data, {
|
||||
const logs = useWatchVersionLogs(templateVersionQuery.data, {
|
||||
onDone: templateVersionQuery.refetch,
|
||||
});
|
||||
const { fileTree, tarFile } = useFileTree(templateVersionQuery.data);
|
||||
@ -97,22 +93,6 @@ export const TemplateVersionEditorPage: FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// Optimistically update the template version data job status to make the
|
||||
// build action feels faster
|
||||
const onBuildStart = () => {
|
||||
setLogs([]);
|
||||
|
||||
queryClient.setQueryData(templateVersionOptions.queryKey, () => {
|
||||
return {
|
||||
...templateVersionQuery.data,
|
||||
job: {
|
||||
...templateVersionQuery.data?.job,
|
||||
status: "pending",
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const onBuildEnds = (newVersion: TemplateVersion) => {
|
||||
queryClient.setQueryData(templateVersionOptions.queryKey, newVersion);
|
||||
navigateToVersion(newVersion);
|
||||
@ -145,7 +125,6 @@ export const TemplateVersionEditorPage: FC = () => {
|
||||
if (!tarFile) {
|
||||
return;
|
||||
}
|
||||
onBuildStart();
|
||||
const newVersionFile = await generateVersionFiles(
|
||||
tarFile,
|
||||
newFileTree,
|
||||
@ -159,6 +138,7 @@ export const TemplateVersionEditorPage: FC = () => {
|
||||
template_id: templateQuery.data.id,
|
||||
file_id: serverFile.hash,
|
||||
});
|
||||
|
||||
onBuildEnds(newVersion);
|
||||
}}
|
||||
onPublish={() => {
|
||||
@ -218,7 +198,6 @@ export const TemplateVersionEditorPage: FC = () => {
|
||||
if (!uploadFileMutation.data) {
|
||||
return;
|
||||
}
|
||||
onBuildStart();
|
||||
const newVersion = await createTemplateVersionMutation.mutateAsync({
|
||||
provisioner: "terraform",
|
||||
storage_method: "file",
|
||||
@ -276,45 +255,6 @@ const useFileTree = (templateVersion: TemplateVersion | undefined) => {
|
||||
return state;
|
||||
};
|
||||
|
||||
const useVersionLogs = (
|
||||
templateVersion: TemplateVersion | undefined,
|
||||
options: { onDone: () => Promise<unknown> },
|
||||
) => {
|
||||
const [logs, setLogs] = useState<ProvisionerJobLog[]>();
|
||||
const templateVersionId = templateVersion?.id;
|
||||
const refetchTemplateVersion = options.onDone;
|
||||
const templateVersionStatus = templateVersion?.job.status;
|
||||
|
||||
useEffect(() => {
|
||||
if (!templateVersionId || !templateVersionStatus) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (templateVersionStatus !== "running") {
|
||||
return;
|
||||
}
|
||||
|
||||
const socket = watchBuildLogsByTemplateVersionId(templateVersionId, {
|
||||
onMessage: (log) => {
|
||||
setLogs((logs) => (logs ? [...logs, log] : [log]));
|
||||
},
|
||||
onDone: refetchTemplateVersion,
|
||||
onError: (error) => {
|
||||
console.error(error);
|
||||
},
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.close();
|
||||
};
|
||||
}, [refetchTemplateVersion, templateVersionId, templateVersionStatus]);
|
||||
|
||||
return {
|
||||
logs,
|
||||
setLogs,
|
||||
};
|
||||
};
|
||||
|
||||
const useMissingVariables = (templateVersion: TemplateVersion | undefined) => {
|
||||
const isRequiringVariables =
|
||||
templateVersion?.job.error_code === "REQUIRED_TEMPLATE_VARIABLES";
|
||||
|
@ -33,7 +33,7 @@ const renderWorkspacePage = async (workspace: Workspace) => {
|
||||
jest
|
||||
.spyOn(api, "watchWorkspaceAgentLogs")
|
||||
.mockImplementation((_, options) => {
|
||||
options.onDone && options.onDone();
|
||||
options.onDone?.();
|
||||
return new WebSocket("");
|
||||
});
|
||||
|
||||
|
@ -373,6 +373,15 @@ export const handlers = [
|
||||
},
|
||||
),
|
||||
|
||||
rest.post("/api/v2/files", (_, res, ctx) => {
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
hash: "some-file-hash",
|
||||
}),
|
||||
);
|
||||
}),
|
||||
|
||||
rest.get("/api/v2/files/:fileId", (_, res, ctx) => {
|
||||
const fileBuffer = fs.readFileSync(
|
||||
path.resolve(__dirname, "./templateFiles.tar"),
|
||||
|
@ -44,3 +44,30 @@ export const withDashboardProvider = (
|
||||
</DashboardContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const withWebSocket = (Story: FC, { parameters }: StoryContext) => {
|
||||
if (!parameters.webSocket) {
|
||||
console.warn(
|
||||
"Looks like you forgot to add websocket messages to the story",
|
||||
);
|
||||
}
|
||||
|
||||
// @ts-expect-error -- TS doesn't know about the global WebSocket
|
||||
window.WebSocket = function () {
|
||||
return {
|
||||
addEventListener: (
|
||||
type: string,
|
||||
callback: (ev: Record<"data", string>) => void,
|
||||
) => {
|
||||
if (type === "message") {
|
||||
parameters.webSocket?.messages.forEach((message) => {
|
||||
callback({ data: message });
|
||||
});
|
||||
}
|
||||
},
|
||||
close: () => {},
|
||||
};
|
||||
};
|
||||
|
||||
return <Story />;
|
||||
};
|
||||
|
Reference in New Issue
Block a user