mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
refactor(site): Refactor error state (#7313)
This commit is contained in:
@@ -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}
|
||||
<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‘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
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user