refactor(site): Refactor error state (#7313)

This commit is contained in:
Bruno Quaresma
2023-04-28 13:03:01 +00:00
committed by GitHub
parent e747aad2b6
commit 88c362dfdc
5 changed files with 210 additions and 172 deletions
+9 -7
View File
@@ -1,5 +1,6 @@
import { makeStyles } from "@material-ui/core/styles"
import { FC } from "react"
import { combineClasses } from "utils/combineClasses"
import {
containerWidth,
containerWidthMedium,
@@ -24,14 +25,15 @@ const useStyles = makeStyles(() => ({
},
}))
interface MarginsProps {
size?: Size
}
export const Margins: FC<React.PropsWithChildren<MarginsProps>> = ({
children,
export const Margins: FC<JSX.IntrinsicElements["div"] & { size?: Size }> = ({
size = "regular",
...divProps
}) => {
const styles = useStyles({ maxWidth: widthBySize[size] })
return <div className={styles.margins}>{children}</div>
return (
<div
{...divProps}
className={combineClasses([styles.margins, divProps.className])}
/>
)
}
@@ -1,4 +1,4 @@
import { ComponentMeta, Story } from "@storybook/react"
import { Story } from "@storybook/react"
import { RuntimeErrorState, RuntimeErrorStateProps } from "./RuntimeErrorState"
const error = new Error("An error occurred")
@@ -6,12 +6,10 @@ const error = new Error("An error occurred")
export default {
title: "components/RuntimeErrorState",
component: RuntimeErrorState,
argTypes: {
error: {
defaultValue: error,
},
args: {
error,
},
} as ComponentMeta<typeof RuntimeErrorState>
}
const Template: Story<RuntimeErrorStateProps> = (args) => (
<RuntimeErrorState {...args} />
@@ -1,54 +0,0 @@
import { screen } from "@testing-library/react"
import { render } from "../../testHelpers/renderHelpers"
import { Language as ButtonLanguage } from "./createCtas"
import {
Language as RuntimeErrorStateLanguage,
RuntimeErrorState,
} from "./RuntimeErrorState"
const renderComponent = () => {
// Given
const errorText = "broken!"
const errorStateProps = {
error: new Error(errorText),
}
// When
return render(<RuntimeErrorState {...errorStateProps} />)
}
describe("RuntimeErrorState", () => {
it("should show stack when encountering runtime error", () => {
renderComponent()
// Then
const reportError = screen.getByText("broken!")
expect(reportError).toBeDefined()
// Despite appearances, this is the stack trace
const stackTrace = screen.getByText("Unable to get stack trace")
expect(stackTrace).toBeDefined()
})
it("should have a button bar", () => {
renderComponent()
// Then
const copyCta = screen.getByText(ButtonLanguage.copyReport)
expect(copyCta).toBeDefined()
const reloadCta = screen.getByText(ButtonLanguage.reloadApp)
expect(reloadCta).toBeDefined()
})
it("should have an email link", () => {
renderComponent()
// Then
const emailLink = screen.getByText(RuntimeErrorStateLanguage.link)
expect(emailLink.closest("a")).toHaveAttribute(
"href",
expect.stringContaining("mailto:support@coder.com"),
)
})
})
@@ -1,125 +1,216 @@
import Box from "@material-ui/core/Box"
import Button from "@material-ui/core/Button"
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
import ErrorOutlineIcon from "@material-ui/icons/ErrorOutline"
import { useEffect, useReducer, FC } from "react"
import { mapStackTrace } from "sourcemapped-stacktrace"
import RefreshOutlined from "@material-ui/icons/RefreshOutlined"
import { BuildInfoResponse } from "api/typesGenerated"
import { CopyButton } from "components/CopyButton/CopyButton"
import { CoderIcon } from "components/Icons/CoderIcon"
import { FullScreenLoader } from "components/Loader/FullScreenLoader"
import { Stack } from "components/Stack/Stack"
import { FC, useEffect, useState } from "react"
import { Helmet } from "react-helmet-async"
import { Margins } from "../Margins/Margins"
import { Section } from "../Section/Section"
import { Typography } from "../Typography/Typography"
import {
createFormattedStackTrace,
reducer,
RuntimeErrorReport,
stackTraceAvailable,
stackTraceUnavailable,
} from "./RuntimeErrorReport"
export const Language = {
title: "Coder encountered an error",
body: "Please copy the crash log using the button below and",
link: "send it to us.",
}
const fetchDynamicallyImportedModuleError =
"Failed to fetch dynamically imported module"
export interface RuntimeErrorStateProps {
error: Error
}
export type RuntimeErrorStateProps = { error: Error }
/**
* A title for our error boundary UI
*/
const ErrorStateTitle = () => {
const styles = useStyles()
return (
<Box className={styles.title} display="flex" alignItems="center">
<ErrorOutlineIcon />
<span>{Language.title}</span>
</Box>
)
}
/**
* A description for our error boundary UI
*/
const ErrorStateDescription = ({ emailBody }: { emailBody?: string }) => {
const styles = useStyles()
return (
<Typography variant="body2" color="textSecondary">
{Language.body}&nbsp;
<Link
href={`mailto:support@coder.com?subject=Error Report from Coder&body=${
emailBody && emailBody.replace(/\r\n|\r|\n/g, "%0D%0A") // preserving line breaks
}`}
className={styles.link}
>
{Language.link}
</Link>
</Typography>
)
}
/**
* An error UI that is displayed when our error boundary (ErrorBoundary.tsx) is triggered
*/
export const RuntimeErrorState: FC<RuntimeErrorStateProps> = ({ error }) => {
const styles = useStyles()
const [reportState, dispatch] = useReducer(reducer, {
error,
mappedStack: null,
})
const [checkingError, setCheckingError] = useState(true)
const [staticBuildInfo, setStaticBuildInfo] = useState<BuildInfoResponse>()
const coderVersion = staticBuildInfo?.version
// We use an effect to show a loading state if the page is trying to reload
useEffect(() => {
const isImportError = error.message.includes(
fetchDynamicallyImportedModuleError,
)
const isRetried = window.location.search.includes("retries=1")
if (isImportError && !isRetried) {
const url = new URL(location.href)
// Add a retry to avoid loops
url.searchParams.set("retries", "1")
location.assign(url.search)
return
}
setCheckingError(false)
}, [error.message])
useEffect(() => {
try {
mapStackTrace(error.stack, (mappedStack) =>
dispatch(stackTraceAvailable(mappedStack)),
)
} catch {
dispatch(stackTraceUnavailable)
if (!checkingError) {
setStaticBuildInfo(getStaticBuildInfo())
}
}, [error])
}, [checkingError])
return (
<Box display="flex" flexDirection="column">
<Margins>
<Section
className={styles.reportContainer}
title={<ErrorStateTitle />}
description={
<ErrorStateDescription
emailBody={createFormattedStackTrace(
reportState.error,
reportState.mappedStack,
).join("\r\n")}
/>
}
>
<RuntimeErrorReport
error={reportState.error}
mappedStack={reportState.mappedStack}
/>
</Section>
</Margins>
</Box>
<>
<Helmet>
<title>Something went wrong...</title>
</Helmet>
{!checkingError ? (
<Margins className={styles.root}>
<div className={styles.innerRoot}>
<CoderIcon className={styles.logo} />
<h1 className={styles.title}>Something went wrong...</h1>
<p className={styles.text}>
Please try reloading the page, if that doesn&lsquo;t work, you can
ask for help in the{" "}
<Link href="https://discord.gg/coder">
Coder Discord community
</Link>{" "}
or{" "}
<Link
href={`https://github.com/coder/coder/issues/new?body=${encodeURIComponent(
[
["**Version**", coderVersion ?? "-- Set version --"].join(
"\n",
),
["**Path**", "`" + location.pathname + "`"].join("\n"),
["**Error**", "```\n" + error.stack + "\n```"].join("\n"),
].join("\n\n"),
)}`}
target="_blank"
>
open an issue
</Link>
.
</p>
<Stack direction="row" justifyContent="center">
<Button
startIcon={<RefreshOutlined />}
onClick={() => {
window.location.reload()
}}
>
Reload page
</Button>
<Button component="a" href="/" variant="outlined">
Go to dashboard
</Button>
</Stack>
{error.stack && (
<div className={styles.stack}>
<div className={styles.stackHeader}>
Stacktrace
<CopyButton
buttonClassName={styles.copyButton}
text={error.stack}
tooltipTitle="Copy stacktrace"
/>
</div>
<pre className={styles.stackCode}>{error.stack}</pre>
</div>
)}
{coderVersion && (
<div className={styles.version}>Version: {coderVersion}</div>
)}
</div>
</Margins>
) : (
<FullScreenLoader />
)}
</>
)
}
// During the build process, we inject the build info into the HTML
const getStaticBuildInfo = () => {
const buildInfoJson = document
.querySelector("meta[property=build-info]")
?.getAttribute("content")
if (buildInfoJson) {
try {
return JSON.parse(buildInfoJson) as BuildInfoResponse
} catch {
return undefined
}
}
}
const useStyles = makeStyles((theme) => ({
title: {
"& span": {
paddingLeft: theme.spacing(1),
},
"& .MuiSvgIcon-root": {
color: theme.palette.error.main,
},
},
link: {
textDecoration: "none",
color: theme.palette.primary.main,
},
reportContainer: {
root: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(4),
textAlign: "center",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginTop: theme.spacing(5),
minHeight: "100vh",
maxWidth: theme.spacing(75),
},
innerRoot: { width: "100%" },
logo: {
fontSize: theme.spacing(8),
},
title: {
fontSize: theme.spacing(4),
fontWeight: 400,
},
text: {
fontSize: 16,
color: theme.palette.text.secondary,
lineHeight: "160%",
marginBottom: theme.spacing(4),
},
stack: {
backgroundColor: theme.palette.background.paper,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 4,
marginTop: theme.spacing(8),
display: "block",
textAlign: "left",
},
stackHeader: {
fontSize: 10,
textTransform: "uppercase",
fontWeight: 600,
letterSpacing: 1,
padding: theme.spacing(1, 1, 1, 2),
backgroundColor: theme.palette.background.paperLight,
borderBottom: `1px solid ${theme.palette.divider}`,
color: theme.palette.text.secondary,
display: "flex",
flexAlign: "center",
justifyContent: "space-between",
alignItems: "center",
},
stackCode: {
padding: theme.spacing(2),
margin: 0,
wordWrap: "break-word",
whiteSpace: "break-spaces",
},
copyButton: {
backgroundColor: "transparent",
border: 0,
borderRadius: 999,
minHeight: theme.spacing(4),
minWidth: theme.spacing(4),
height: theme.spacing(4),
width: theme.spacing(4),
"& svg": {
width: 16,
height: 16,
},
},
version: {
marginTop: theme.spacing(4),
fontSize: 12,
color: theme.palette.text.secondary,
},
}))
+2 -1
View File
@@ -20,7 +20,8 @@ export default defineConfig({
outDir: path.resolve(__dirname, "./out"),
// We need to keep the /bin folder and GITKEEP files
emptyOutDir: false,
sourcemap: process.env.NODE_ENV === "development",
// 'hidden' works like true except that the corresponding sourcemap comments in the bundled files are suppressed
sourcemap: "hidden",
},
define: {
"process.env": {