feat: add better error display for workspace builds (#18518)

Classic parameters templates

<img width="548" alt="Screenshot 2025-06-23 at 23 27 46"
src="https://github.com/user-attachments/assets/e8e774bf-e201-4a80-a90c-3d6cc3658c20"
/>


Dynamic parameters templates
<img width="541" alt="Screenshot 2025-06-23 at 23 52 05"
src="https://github.com/user-attachments/assets/6a40f144-c0b2-4e16-8137-d31a52b71460"
/>
This commit is contained in:
Jaayden Halko
2025-06-24 09:03:40 -04:00
committed by GitHub
parent bca5c35aa2
commit 31d0c6fb33
5 changed files with 184 additions and 32 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ export interface ApiErrorResponse {
validations?: FieldError[];
}
type ApiError = AxiosError<ApiErrorResponse> & {
export type ApiError = AxiosError<ApiErrorResponse> & {
response: AxiosResponse<ApiErrorResponse>;
};
+30 -13
View File
@@ -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,
@@ -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}
@@ -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>
);
};
@@ -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 {
@@ -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,
@@ -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
@@ -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({
@@ -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}
/>
</>
);
};