mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -19,7 +19,7 @@ export interface ApiErrorResponse {
|
||||
validations?: FieldError[];
|
||||
}
|
||||
|
||||
type ApiError = AxiosError<ApiErrorResponse> & {
|
||||
export type ApiError = AxiosError<ApiErrorResponse> & {
|
||||
response: AxiosResponse<ApiErrorResponse>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user