diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index c9a328803b..9705a08ff0 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -19,7 +19,7 @@ export interface ApiErrorResponse { validations?: FieldError[]; } -type ApiError = AxiosError & { +export type ApiError = AxiosError & { response: AxiosResponse; }; diff --git a/site/src/components/Dialog/Dialog.tsx b/site/src/components/Dialog/Dialog.tsx index 2ec8ab4078..2ec5fa4dae 100644 --- a/site/src/components/Dialog/Dialog.tsx +++ b/site/src/components/Dialog/Dialog.tsx @@ -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, + VariantProps {} + export const DialogContent = forwardRef< ElementRef, - ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + DialogContentProps +>(({ className, variant, children, ...props }, ref) => ( {children} diff --git a/site/src/components/EphemeralParametersDialog/EphemeralParametersDialog.tsx b/site/src/modules/workspaces/EphemeralParametersDialog/EphemeralParametersDialog.tsx similarity index 100% rename from site/src/components/EphemeralParametersDialog/EphemeralParametersDialog.tsx rename to site/src/modules/workspaces/EphemeralParametersDialog/EphemeralParametersDialog.tsx diff --git a/site/src/modules/workspaces/ErrorDialog/WorkspaceErrorDialog.tsx b/site/src/modules/workspaces/ErrorDialog/WorkspaceErrorDialog.tsx new file mode 100644 index 0000000000..cc2512692c --- /dev/null +++ b/site/src/modules/workspaces/ErrorDialog/WorkspaceErrorDialog.tsx @@ -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 = ({ + 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 ( + !isOpen && onClose()}> + + + Error building workspace + + Message{" "} + {getErrorMessage(error, "Failed to build workspace.")} + + {errorDetail && showDetail && ( + + Detail{" "} + {errorDetail} + + )} + {validations && ( + + Validations{" "} + + {validations.map((validation) => validation.detail).join(", ")} + + + )} + + + + + + + ); +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx index f1d48f0fcc..1e727faf46 100644 --- a/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceReadyPage.tsx @@ -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 = ({ 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 = ({ }); // 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 = ({ /> + + setWorkspaceErrorDialog({ open: false })} + showDetail={workspace.template_use_classic_parameter_flow} + workspaceOwner={workspace.owner_name} + workspaceName={workspace.name} + templateVersionId={workspace.latest_build.template_version_id} + /> ); };