Add information to the recovery page

Adds error & environment information to the recovery page when
available. The info will be displayed when the error boundary catches an error only.

Otherwise, does a few minor tweaks to the UI of the page.

- Add DevPage button to throw an uncaught error to go into recovery
- Add "Delete Save" button in recovery from Game Options (refactored into its own
component)
- Use "Soft Reset" button from Game Options (refactored into its own
component)
- The "Soft Reset" & "Delete Save" buttons now have confirmations
- Add tooltip on "Disable Recovery Mode" button
- Add timestamp to the RECOVERY.json filename
- Add textarea containing markdown with the current error details, if
available
  - Error
  - Page
  - Version
  - Environment
  - Platform
  - UserAgent
  - Features
  - Source
  - Stack Trace
- Change GitHub new issue link to contain default body & title, if possible
- Change links to not take the full width (they were clickable by mistake)
- Fix "Disable Recovery Mode" not resetting the ErrorBoundary's state,
making going back to terminal impossible
This commit is contained in:
Martin Fournier 2022-01-10 11:29:58 -05:00
parent 8b69fd7faa
commit 65964c84b2
7 changed files with 325 additions and 74 deletions

@ -1,4 +1,4 @@
import React from "react"; import React, { useEffect, useState } from "react";
import Accordion from "@mui/material/Accordion"; import Accordion from "@mui/material/Accordion";
import AccordionSummary from "@mui/material/AccordionSummary"; import AccordionSummary from "@mui/material/AccordionSummary";
@ -17,6 +17,8 @@ interface IProps {
} }
export function General(props: IProps): React.ReactElement { export function General(props: IProps): React.ReactElement {
const [error, setError] = useState(false);
function addMoney(n: number) { function addMoney(n: number) {
return function () { return function () {
props.player.gainMoney(n, "other"); props.player.gainMoney(n, "other");
@ -43,6 +45,10 @@ export function General(props: IProps): React.ReactElement {
props.router.toBitVerse(false, false); props.router.toBitVerse(false, false);
} }
useEffect(() => {
if (error) throw new ReferenceError('Manually thrown error');
}, [error]);
return ( return (
<Accordion TransitionProps={{ unmountOnExit: true }}> <Accordion TransitionProps={{ unmountOnExit: true }}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}> <AccordionSummary expandIcon={<ExpandMoreIcon />}>
@ -81,6 +87,7 @@ export function General(props: IProps): React.ReactElement {
<Button onClick={b1tflum3}>Run b1t_flum3.exe</Button> <Button onClick={b1tflum3}>Run b1t_flum3.exe</Button>
<Button onClick={quickHackW0r1dD43m0n}>Quick w0rld_d34m0n</Button> <Button onClick={quickHackW0r1dD43m0n}>Quick w0rld_d34m0n</Button>
<Button onClick={hackW0r1dD43m0n}>Hack w0rld_d34m0n</Button> <Button onClick={hackW0r1dD43m0n}>Hack w0rld_d34m0n</Button>
<Button onClick={() => setError(true)}>Throw Error</Button>
</AccordionDetails> </AccordionDetails>
</Accordion> </Accordion>
); );

@ -1,29 +1,58 @@
import React, { ErrorInfo } from "react"; import React, { ErrorInfo } from "react";
import { IErrorData, getErrorForDisplay } from "../utils/ErrorHelper";
import { RecoveryRoot } from "./React/RecoveryRoot"; import { RecoveryRoot } from "./React/RecoveryRoot";
import { IRouter } from "./Router"; import { IRouter, Page } from "./Router";
interface IProps { interface IProps {
router: IRouter; router: IRouter;
softReset: () => void; softReset: () => void;
} }
interface IState {
error?: Error;
errorInfo?: React.ErrorInfo;
page?: Page;
hasError: boolean;
}
export class ErrorBoundary extends React.Component<IProps, IState> {
state: IState
export class ErrorBoundary extends React.Component<IProps> {
state: { hasError: boolean }
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props);
this.state = { hasError: false }; this.state = { hasError: false } as IState;
} }
reset(): void {
this.setState( { hasError: false } as IState);
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void { componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
this.setState({
errorInfo,
page: this.props.router.page(),
});
console.error(error, errorInfo); console.error(error, errorInfo);
} }
render(): React.ReactNode { render(): React.ReactNode {
if (this.state.hasError) { if (this.state.hasError) {
return <RecoveryRoot router={this.props.router} softReset={this.props.softReset} />; let errorData: IErrorData | undefined;
if (this.state.error) {
try {
// We don't want recursive errors, so in case this fails, it's in a try catch.
errorData = getErrorForDisplay(this.state.error, this.state.errorInfo, this.state.page);
} catch (ex) {
console.error(ex);
}
}
return <RecoveryRoot router={this.props.router} softReset={this.props.softReset}
errorData={errorData} resetError={() => this.reset()} />;
} }
return this.props.children; return this.props.children;
} }
static getDerivedStateFromError(): { hasError: true} { static getDerivedStateFromError(error: Error): IState {
return { hasError: true }; return { hasError: true, error};
} }
} }

@ -0,0 +1,32 @@
import React, { useState } from 'react';
import { deleteGame } from "../../db";
import { ConfirmationModal } from "./ConfirmationModal";
import Button from "@mui/material/Button";
import { Tooltip } from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
interface IProps {
color?: "primary" | "warning" | "error";
}
export function DeleteGameButton({ color = "primary" }: IProps): React.ReactElement {
const [modalOpened, setModalOpened] = useState(false);
return (<>
<Tooltip title="This will permanently delete your local save game. Did you export it before?">
<Button startIcon={<DeleteIcon />} color={color} onClick={() => setModalOpened(true)}>Delete Save</Button>
</Tooltip>
<ConfirmationModal
onConfirm={() => {
setModalOpened(false);
deleteGame()
.then(() => setTimeout(() => location.reload(), 1000))
.catch((r) => console.error(`Could not delete game: ${r}`));
}}
open={modalOpened}
onClose={() => setModalOpened(false)}
confirmationText={"Really delete your game? (It's permanent!)"}
/>
</>)
}

@ -21,6 +21,7 @@ import TextField from "@mui/material/TextField";
import DownloadIcon from "@mui/icons-material/Download"; import DownloadIcon from "@mui/icons-material/Download";
import UploadIcon from "@mui/icons-material/Upload"; import UploadIcon from "@mui/icons-material/Upload";
import SaveIcon from '@mui/icons-material/Save';
import { FileDiagnosticModal } from "../../Diagnostic/FileDiagnosticModal"; import { FileDiagnosticModal } from "../../Diagnostic/FileDiagnosticModal";
import { dialogBoxCreate } from "./DialogBox"; import { dialogBoxCreate } from "./DialogBox";
@ -31,9 +32,11 @@ import { StyleEditorModal } from "./StyleEditorModal";
import { SnackbarEvents } from "./Snackbar"; import { SnackbarEvents } from "./Snackbar";
import { Settings } from "../../Settings/Settings"; import { Settings } from "../../Settings/Settings";
import { save, deleteGame } from "../../db"; import { save } from "../../db";
import { formatTime } from "../../utils/helpers/formatTime"; import { formatTime } from "../../utils/helpers/formatTime";
import { OptionSwitch } from "./OptionSwitch"; import { OptionSwitch } from "./OptionSwitch";
import { DeleteGameButton } from "./DeleteGameButton";
import { SoftResetButton } from "./SoftResetButton";
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
@ -71,10 +74,8 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
const [timestampFormat, setTimestampFormat] = useState(Settings.TimestampsFormat); const [timestampFormat, setTimestampFormat] = useState(Settings.TimestampsFormat);
const [locale, setLocale] = useState(Settings.Locale); const [locale, setLocale] = useState(Settings.Locale);
const [diagnosticOpen, setDiagnosticOpen] = useState(false); const [diagnosticOpen, setDiagnosticOpen] = useState(false);
const [deleteGameOpen, setDeleteOpen] = useState(false);
const [themeEditorOpen, setThemeEditorOpen] = useState(false); const [themeEditorOpen, setThemeEditorOpen] = useState(false);
const [styleEditorOpen, setStyleEditorOpen] = useState(false); const [styleEditorOpen, setStyleEditorOpen] = useState(false);
const [softResetOpen, setSoftResetOpen] = useState(false);
const [importSaveOpen, setImportSaveOpen] = useState(false); const [importSaveOpen, setImportSaveOpen] = useState(false);
const [importData, setImportData] = useState<ImportData | null>(null); const [importData, setImportData] = useState<ImportData | null>(null);
@ -194,14 +195,6 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
}); });
} }
function doSoftReset(): void {
if (!Settings.SuppressBuyAugmentationConfirmation) {
setSoftResetOpen(true);
} else {
props.softReset();
}
}
return ( return (
<div className={classes.root} style={{ width: "90%" }}> <div className={classes.root} style={{ width: "90%" }}>
<Typography variant="h4" gutterBottom> <Typography variant="h4" gutterBottom>
@ -531,19 +524,19 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
</Grid> </Grid>
<Box sx={{ display: 'grid', width: 'fit-content', height: 'fit-content' }}> <Box sx={{ display: 'grid', width: 'fit-content', height: 'fit-content' }}>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}> <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
<Button onClick={() => props.save()}>Save Game</Button> <Button onClick={() => props.save()} startIcon={<SaveIcon />} >
<Button onClick={() => setDeleteOpen(true)}>Delete Game</Button> Save Game
</Button>
<DeleteGameButton />
</Box> </Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}> <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
<Tooltip title={<Typography>Export your game to a text file.</Typography>}> <Tooltip title={<Typography>Export your game to a text file.</Typography>}>
<Button onClick={() => props.export()}> <Button onClick={() => props.export()} startIcon={<DownloadIcon />}>
<DownloadIcon color="primary" />
Export Game Export Game
</Button> </Button>
</Tooltip> </Tooltip>
<Tooltip title={<Typography>Import your game from a text file.<br />This will <strong>overwrite</strong> your current game. Back it up first!</Typography>}> <Tooltip title={<Typography>Import your game from a text file.<br />This will <strong>overwrite</strong> your current game. Back it up first!</Typography>}>
<Button onClick={startImport}> <Button onClick={startImport} startIcon={<UploadIcon />}>
<UploadIcon color="primary" />
Import Game Import Game
<input ref={importInput} id="import-game-file-selector" type="file" hidden onChange={onImport} /> <input ref={importInput} id="import-game-file-selector" type="file" hidden onChange={onImport} />
</Button> </Button>
@ -587,21 +580,7 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
</Tooltip> </Tooltip>
</Box> </Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}> <Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
<Tooltip <SoftResetButton noConfirmation={Settings.SuppressBuyAugmentationConfirmation} onTriggered={props.softReset} />
title={
<Typography>
Perform a soft reset. Resets everything as if you had just purchased an Augmentation.
</Typography>
}
>
<Button onClick={doSoftReset}>Soft Reset</Button>
</Tooltip>
<ConfirmationModal
open={softResetOpen}
onClose={() => setSoftResetOpen(false)}
onConfirm={props.softReset}
confirmationText={"This will perform the same action as installing Augmentations, are you sure?"}
/>
<Tooltip <Tooltip
title={ title={
<Typography> <Typography>
@ -640,17 +619,6 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
</Box> </Box>
</Grid> </Grid>
<FileDiagnosticModal open={diagnosticOpen} onClose={() => setDiagnosticOpen(false)} /> <FileDiagnosticModal open={diagnosticOpen} onClose={() => setDiagnosticOpen(false)} />
<ConfirmationModal
onConfirm={() => {
setDeleteOpen(false);
deleteGame()
.then(() => setTimeout(() => location.reload(), 1000))
.catch((r) => console.error(`Could not delete game: ${r}`));
}}
open={deleteGameOpen}
onClose={() => setDeleteOpen(false)}
confirmationText={"Really delete your game? (It's permanent!)"}
/>
<ThemeEditorModal open={themeEditorOpen} onClose={() => setThemeEditorOpen(false)} /> <ThemeEditorModal open={themeEditorOpen} onClose={() => setThemeEditorOpen(false)} />
<StyleEditorModal open={styleEditorOpen} onClose={() => setStyleEditorOpen(false)} /> <StyleEditorModal open={styleEditorOpen} onClose={() => setStyleEditorOpen(false)} />
</div> </div>

@ -1,11 +1,16 @@
import React from "react"; import React, { useEffect } from "react";
import Typography from "@mui/material/Typography";
import Link from "@mui/material/Link"; import { Typography, Link, Button, ButtonGroup, Tooltip, Box, Paper, TextField } from "@mui/material";
import Button from "@mui/material/Button";
import { Settings } from "../../Settings/Settings"; import { Settings } from "../../Settings/Settings";
import { load } from "../../db"; import { load } from "../../db";
import { IRouter } from "../Router"; import { IRouter } from "../Router";
import { download } from "../../SaveObject"; import { download } from "../../SaveObject";
import { IErrorData, newIssueUrl } from "../../utils/ErrorHelper";
import { DeleteGameButton } from "./DeleteGameButton";
import { SoftResetButton } from "./SoftResetButton";
import DirectionsRunIcon from '@mui/icons-material/DirectionsRun';
import GitHubIcon from "@mui/icons-material/GitHub";
export let RecoveryMode = false; export let RecoveryMode = false;
@ -16,40 +21,79 @@ export function ActivateRecoveryMode(): void {
interface IProps { interface IProps {
router: IRouter; router: IRouter;
softReset: () => void; softReset: () => void;
errorData?: IErrorData;
resetError?: () => void;
} }
export function RecoveryRoot({ router, softReset }: IProps): React.ReactElement { export function RecoveryRoot({ router, softReset, errorData, resetError }: IProps): React.ReactElement {
function recover(): void { function recover(): void {
if (resetError) resetError();
RecoveryMode = false; RecoveryMode = false;
router.toTerminal(); router.toTerminal();
} }
Settings.AutosaveInterval = 0; Settings.AutosaveInterval = 0;
useEffect(() => {
load().then((content) => { load().then((content) => {
download("RECOVERY.json", content); const epochTime = Math.round(Date.now() / 1000);
const filename = `RECOVERY_BITBURNER_${epochTime}.json`;
download(filename, content);
}); });
}, []);
return ( return (
<> <Box sx={{ padding: "8px 16px", minHeight: "100vh", maxWidth: '1200px', boxSizing: "border-box",}}>
<Typography variant="h3">RECOVERY MODE ACTIVATED</Typography> <Typography variant="h3">RECOVERY MODE ACTIVATED</Typography>
<Typography> <Typography>
There was an error loading your save file and the game went into recovery mode. In this mode saving is disabled There was an error with your save file and the game went into recovery mode. In this mode saving is disabled
and the game will automatically export your save file (to prevent corruption). and the game will automatically export your save file (to prevent corruption).
</Typography> </Typography>
<Typography>At this point it is recommended to alert a developer.</Typography> <Typography>At this point it is recommended to alert a developer.</Typography>
<Link href="https://github.com/danielyxie/bitburner/issues/new" target="_blank"> <Typography>
<Typography>File an issue on github</Typography> <Link href={errorData?.issueUrl ?? newIssueUrl} target="_blank">File an issue on github</Link>
</Link> </Typography>
<Link href="https://www.reddit.com/r/Bitburner/" target="_blank"> <Typography>
<Typography>Make a reddit post</Typography> <Link href="https://www.reddit.com/r/Bitburner/" target="_blank">Make a reddit post</Link>
</Link> </Typography>
<Link href="https://discord.gg/TFc3hKD" target="_blank"> <Typography>
<Typography>Post in the #bug-report channel on Discord.</Typography> <Link href="https://discord.gg/TFc3hKD" target="_blank">Post in the #bug-report channel on Discord.</Link>
</Link> </Typography>
<Typography>Please include your save file.</Typography> <Typography>Please include your save file.</Typography>
<br /> <br />
<br /> <br />
<Typography>You can disable recovery mode now. But chances are the game will not work correctly.</Typography> <Typography>You can disable recovery mode now. But chances are the game will not work correctly.</Typography>
<Button onClick={recover}>DISABLE RECOVERY MODE</Button> <ButtonGroup sx={{ my: 2 }}>
<Button onClick={softReset}>PERFORM SOFT RESET</Button> <Tooltip title="Disables the recovery mode & attempt to head back to the terminal page. This may or may not work. Ensure you have saved the recovery file.">
</> <Button onClick={recover} startIcon={<DirectionsRunIcon />}>Disable Recovery Mode</Button>
</Tooltip>
<SoftResetButton color="warning" onTriggered={softReset} />
<DeleteGameButton color="error" />
</ButtonGroup>
{errorData && (
<Paper sx={{ px: 2, pt: 1, pb: 2, mt: 2}}>
<Typography variant="h5">
{errorData.title}
</Typography>
<Box sx={{ my: 2 }}>
<TextField
label="Bug Report Text"
value={errorData.body}
variant="outlined"
color="secondary"
multiline
fullWidth
rows={12}
sx={{ "& .MuiOutlinedInput-root": { color: Settings.theme.secondary }}} />
</Box>
<Tooltip title="Submitting an issue to GitHub really help us improve the game!">
<Button component={Link} startIcon={<GitHubIcon />} color="info" sx={{ px: 2 }}
href={errorData.issueUrl ?? newIssueUrl} target={"_blank"} >
Submit Issue to GitHub
</Button>
</Tooltip>
</Paper>
)}
</Box>
); );
} }

@ -0,0 +1,36 @@
import React, { useState } from 'react';
import { ConfirmationModal } from "./ConfirmationModal";
import Button from "@mui/material/Button";
import { Tooltip } from '@mui/material';
import RestartAltIcon from '@mui/icons-material/RestartAlt';
interface IProps {
color?: "primary" | "warning" | "error";
noConfirmation?: boolean;
onTriggered: () => void;
}
export function SoftResetButton({ color = "primary", noConfirmation = false, onTriggered }: IProps): React.ReactElement {
const [modalOpened, setModalOpened] = useState(false);
function handleButtonClick(): void {
if (noConfirmation) {
onTriggered();
} else {
setModalOpened(true);
}
}
return (<>
<Tooltip title="Perform a soft reset. Resets everything as if you had just purchased an Augmentation.">
<Button startIcon={<RestartAltIcon />} color={color} onClick={handleButtonClick}>Soft Reset</Button>
</Tooltip>
<ConfirmationModal
onConfirm={onTriggered}
open={modalOpened}
onClose={() => setModalOpened(false)}
confirmationText={"This will perform the same action as installing Augmentations, are you sure?"}
/>
</>)
}

135
src/utils/ErrorHelper.ts Normal file

@ -0,0 +1,135 @@
import React from "react";
import { Page } from "../ui/Router";
import { hash } from "../hash/hash";
import { CONSTANTS } from "../Constants";
enum GameEnv {
Production,
Development,
}
enum Platform {
Browser,
Steam,
}
interface GameVersion {
version: string;
hash: string;
toDisplay: () => string;
}
interface BrowserFeatures {
userAgent: string;
language: string;
cookiesEnabled: boolean;
doNotTrack: string | null;
indexedDb: boolean;
}
interface IErrorMetadata {
error: Error;
errorInfo?: React.ErrorInfo;
page?: Page;
environment: GameEnv;
platform: Platform;
version: GameVersion;
features: BrowserFeatures;
}
export interface IErrorData {
metadata: IErrorMetadata;
title: string;
body: string;
features: string;
fileName?: string;
issueUrl: string;
}
export const newIssueUrl = `https://github.com/danielyxie/bitburner/issues/new`;
function getErrorMetadata(error: Error, errorInfo?: React.ErrorInfo, page?: Page): IErrorMetadata {
const isElectron = navigator.userAgent.toLowerCase().indexOf(" electron/") > -1;
const env = process.env.NODE_ENV === "development" ? GameEnv.Development : GameEnv.Production;
const version: GameVersion = {
version: CONSTANTS.VersionString,
hash: hash(),
toDisplay: () => `v${CONSTANTS.VersionString} (${hash()})`,
}
const features: BrowserFeatures = {
userAgent: navigator.userAgent,
language: navigator.language,
cookiesEnabled: navigator.cookieEnabled,
doNotTrack: navigator.doNotTrack,
indexedDb: (!!window.indexedDB),
}
const metadata: IErrorMetadata = {
platform: isElectron ? Platform.Steam : Platform.Browser,
environment: env,
version, features,
error, errorInfo, page,
}
return metadata;
}
export function getErrorForDisplay(error: Error, errorInfo?: React.ErrorInfo, page?: Page): IErrorData {
const metadata = getErrorMetadata(error, errorInfo, page);
const fileName = (metadata.error as any).fileName;
const features = `lang=${metadata.features.language} cookiesEnabled=${metadata.features.cookiesEnabled.toString()}` +
` doNotTrack=${metadata.features.doNotTrack} indexedDb=${metadata.features.indexedDb.toString()}`;
const title = `${metadata.error.name}: ${metadata.error.message}${metadata.page && ` (at "${Page[metadata.page]}")`}`;
const body = `
## ${title}
### How did this happen?
Please fill this information with details if relevant.
- [ ] Save file
- [ ] Minimal scripts to reproduce the issue
- [ ] Steps to reproduce
### Environment
* Error: ${metadata.error?.toString() ?? 'n/a'}
* Page: ${metadata.page ? Page[metadata.page] : 'n/a'}
* Version: ${metadata.version.toDisplay()}
* Environment: ${GameEnv[metadata.environment]}
* Platform: ${Platform[metadata.platform]}
* UserAgent: ${navigator.userAgent}
* Features: ${features}
* Source: ${fileName ?? 'n/a'}
${metadata.environment === GameEnv.Development ? `
### Stack Trace
\`\`\`
${metadata.errorInfo?.componentStack.toString().trim()}
\`\`\`
` : ''}
### Save
\`\`\`
Copy your save here if possible
\`\`\`
`.trim();
const issueUrl = `${newIssueUrl}?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`;
const data: IErrorData = {
metadata,
fileName,
features,
title,
body,
issueUrl,
}
return data;
}