Skip to content

feat: add better error display for workspace builds #18518

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion site/src/api/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export interface ApiErrorResponse {
validations?: FieldError[];
}

type ApiError = AxiosError<ApiErrorResponse> & {
export type ApiError = AxiosError<ApiErrorResponse> & {
response: AxiosResponse<ApiErrorResponse>;
};

Expand Down
43 changes: 30 additions & 13 deletions site/src/components/Dialog/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* @see {@link https://ui.shadcn.com/docs/components/dialog}
*/
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { type VariantProps, cva } from "class-variance-authority";
import {
type ComponentPropsWithoutRef,
type ElementRef,
Expand Down Expand Up @@ -36,25 +37,41 @@ const DialogOverlay = forwardRef<
/>
));

const dialogVariants = cva(
`fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg gap-6
border border-solid bg-surface-primary p-8 shadow-lg duration-200 sm:rounded-lg
translate-x-[-50%] translate-y-[-50%]
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]
data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]`,
{
variants: {
variant: {
default: "border-border-primary",
destructive: "border-border-destructive",
},
},
defaultVariants: {
variant: "default",
},
},
);

interface DialogContentProps
extends ComponentPropsWithoutRef<typeof DialogPrimitive.Content>,
VariantProps<typeof dialogVariants> {}

export const DialogContent = forwardRef<
ElementRef<typeof DialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
DialogContentProps
>(({ className, variant, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
`fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg gap-6
border border-solid border-border bg-surface-primary p-8 shadow-lg duration-200 sm:rounded-lg
translate-x-[-50%] translate-y-[-50%]
data-[state=open]:animate-in data-[state=closed]:animate-out
data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0
data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95
data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]
data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]`,
className,
)}
className={cn(dialogVariants({ variant }), className)}
{...props}
>
{children}
Expand Down
83 changes: 83 additions & 0 deletions site/src/modules/workspaces/ErrorDialog/WorkspaceErrorDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { getErrorDetail, getErrorMessage, isApiError } from "api/errors";
import { Button } from "components/Button/Button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "components/Dialog/Dialog";
import type { FC } from "react";
import { useNavigate } from "react-router-dom";

interface WorkspaceErrorDialogProps {
open: boolean;
error?: unknown;
onClose: () => void;
showDetail: boolean;
workspaceOwner: string;
workspaceName: string;
templateVersionId: string;
}

export const WorkspaceErrorDialog: FC<WorkspaceErrorDialogProps> = ({
open,
error,
onClose,
showDetail,
workspaceOwner,
workspaceName,
templateVersionId,
}) => {
const navigate = useNavigate();

if (!error) {
return null;
}

const handleGoToParameters = () => {
onClose();
navigate(
`/@${workspaceOwner}/${workspaceName}/settings/parameters?templateVersionId=${templateVersionId}`,
);
};

const errorDetail = getErrorDetail(error);
const validations = isApiError(error)
? error.response.data.validations
: undefined;

return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent variant="destructive">
<DialogHeader>
<DialogTitle>Error building workspace</DialogTitle>
<DialogDescription className="flex flex-row gap-4">
<strong className="text-content-primary">Message</strong>{" "}
<span>{getErrorMessage(error, "Failed to build workspace.")}</span>
</DialogDescription>
{errorDetail && showDetail && (
<DialogDescription className="flex flex-row gap-9">
<strong className="text-content-primary">Detail</strong>{" "}
<span>{errorDetail}</span>
</DialogDescription>
)}
{validations && (
<DialogDescription className="flex flex-row gap-4">
<strong className="text-content-primary">Validations</strong>{" "}
<span>
{validations.map((validation) => validation.detail).join(", ")}
</span>
</DialogDescription>
)}
</DialogHeader>
<DialogFooter>
<Button onClick={handleGoToParameters}>
Review workspace settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
88 changes: 70 additions & 18 deletions site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { API } from "api/api";
import { getErrorMessage } from "api/errors";
import { type ApiError, getErrorMessage } from "api/errors";
import { isApiError } from "api/errors";
import { templateVersion } from "api/queries/templates";
import { workspaceBuildTimings } from "api/queries/workspaceBuilds";
import {
Expand All @@ -15,9 +16,10 @@ import {
ConfirmDialog,
type ConfirmDialogProps,
} from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { EphemeralParametersDialog } from "components/EphemeralParametersDialog/EphemeralParametersDialog";
import { displayError } from "components/GlobalSnackbar/utils";
import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
import { EphemeralParametersDialog } from "modules/workspaces/EphemeralParametersDialog/EphemeralParametersDialog";
import { WorkspaceErrorDialog } from "modules/workspaces/ErrorDialog/WorkspaceErrorDialog";
import {
WorkspaceUpdateDialogs,
useWorkspaceUpdate,
Expand Down Expand Up @@ -55,15 +57,35 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
buildParameters?: TypesGen.WorkspaceBuildParameter[];
}>({ open: false });

const [workspaceErrorDialog, setWorkspaceErrorDialog] = useState<{
open: boolean;
error?: ApiError;
}>({ open: false });

const handleError = (error: unknown) => {
if (isApiError(error) && error.code === "ERR_BAD_REQUEST") {
setWorkspaceErrorDialog({
open: true,
error: error,
});
} else {
displayError(getErrorMessage(error, "Failed to build workspace."));
}
};

const [ephemeralParametersDialog, setEphemeralParametersDialog] = useState<{
open: boolean;
action: "start" | "restart";
buildParameters?: TypesGen.WorkspaceBuildParameter[];
ephemeralParameters: TypesGen.TemplateVersionParameter[];
}>({ open: false, action: "start", ephemeralParameters: [] });

const { mutate: mutateRestartWorkspace, isPending: isRestarting } =
useMutation({
mutationFn: API.restartWorkspace,
onError: (error: unknown) => {
handleError(error);
},
});

// Favicon
Expand Down Expand Up @@ -92,32 +114,52 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
});

// Delete workspace
const deleteWorkspaceMutation = useMutation(
deleteWorkspace(workspace, queryClient),
);
const deleteWorkspaceMutation = useMutation({
...deleteWorkspace(workspace, queryClient),
onError: (error: unknown) => {
handleError(error);
},
});

// Activate workspace
const activateWorkspaceMutation = useMutation(
activate(workspace, queryClient),
);
const activateWorkspaceMutation = useMutation({
...activate(workspace, queryClient),
onError: (error: unknown) => {
handleError(error);
},
});

// Stop workspace
const stopWorkspaceMutation = useMutation(
stopWorkspace(workspace, queryClient),
);
const stopWorkspaceMutation = useMutation({
...stopWorkspace(workspace, queryClient),
onError: (error: unknown) => {
handleError(error);
},
});

// Start workspace
const startWorkspaceMutation = useMutation(
startWorkspace(workspace, queryClient),
);
const startWorkspaceMutation = useMutation({
...startWorkspace(workspace, queryClient),
onError: (error: unknown) => {
handleError(error);
},
});

// Toggle workspace favorite
const toggleFavoriteMutation = useMutation(
toggleFavorite(workspace, queryClient),
);
const toggleFavoriteMutation = useMutation({
...toggleFavorite(workspace, queryClient),
onError: (error: unknown) => {
handleError(error);
},
});

// Cancel build
const cancelBuildMutation = useMutation(cancelBuild(workspace, queryClient));
const cancelBuildMutation = useMutation({
...cancelBuild(workspace, queryClient),
onError: (error: unknown) => {
handleError(error);
},
});

// Workspace Timings.
const timingsQuery = useQuery({
Expand Down Expand Up @@ -341,6 +383,16 @@ export const WorkspaceReadyPage: FC<WorkspaceReadyPageProps> = ({
/>

<WorkspaceUpdateDialogs {...workspaceUpdate.dialogs} />

<WorkspaceErrorDialog
open={workspaceErrorDialog.open}
error={workspaceErrorDialog.error}
onClose={() => setWorkspaceErrorDialog({ open: false })}
showDetail={workspace.template_use_classic_parameter_flow}
workspaceOwner={workspace.owner_name}
workspaceName={workspace.name}
templateVersionId={workspace.latest_build.template_version_id}
/>
</>
);
};
Expand Down
Loading