From 65964c84b2f30a356e9a4f32da7366bc04d579d3 Mon Sep 17 00:00:00 2001 From: Martin Fournier Date: Mon, 10 Jan 2022 11:29:58 -0500 Subject: [PATCH] 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 --- src/DevMenu/ui/General.tsx | 9 +- src/ui/ErrorBoundary.tsx | 45 ++++++++-- src/ui/React/DeleteGameButton.tsx | 32 +++++++ src/ui/React/GameOptionsRoot.tsx | 54 +++--------- src/ui/React/RecoveryRoot.tsx | 88 ++++++++++++++----- src/ui/React/SoftResetButton.tsx | 36 ++++++++ src/utils/ErrorHelper.ts | 135 ++++++++++++++++++++++++++++++ 7 files changed, 325 insertions(+), 74 deletions(-) create mode 100644 src/ui/React/DeleteGameButton.tsx create mode 100644 src/ui/React/SoftResetButton.tsx create mode 100644 src/utils/ErrorHelper.ts diff --git a/src/DevMenu/ui/General.tsx b/src/DevMenu/ui/General.tsx index 0f7a89441..c6a2532d7 100644 --- a/src/DevMenu/ui/General.tsx +++ b/src/DevMenu/ui/General.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import Accordion from "@mui/material/Accordion"; import AccordionSummary from "@mui/material/AccordionSummary"; @@ -17,6 +17,8 @@ interface IProps { } export function General(props: IProps): React.ReactElement { + const [error, setError] = useState(false); + function addMoney(n: number) { return function () { props.player.gainMoney(n, "other"); @@ -43,6 +45,10 @@ export function General(props: IProps): React.ReactElement { props.router.toBitVerse(false, false); } + useEffect(() => { + if (error) throw new ReferenceError('Manually thrown error'); + }, [error]); + return ( }> @@ -81,6 +87,7 @@ export function General(props: IProps): React.ReactElement { + ); diff --git a/src/ui/ErrorBoundary.tsx b/src/ui/ErrorBoundary.tsx index ab555124e..41da024bf 100644 --- a/src/ui/ErrorBoundary.tsx +++ b/src/ui/ErrorBoundary.tsx @@ -1,29 +1,58 @@ import React, { ErrorInfo } from "react"; + +import { IErrorData, getErrorForDisplay } from "../utils/ErrorHelper"; import { RecoveryRoot } from "./React/RecoveryRoot"; -import { IRouter } from "./Router"; +import { IRouter, Page } from "./Router"; interface IProps { router: IRouter; softReset: () => void; } +interface IState { + error?: Error; + errorInfo?: React.ErrorInfo; + page?: Page; + hasError: boolean; +} + +export class ErrorBoundary extends React.Component { + state: IState -export class ErrorBoundary extends React.Component { - state: { hasError: boolean } constructor(props: IProps) { 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 { + this.setState({ + errorInfo, + page: this.props.router.page(), + }); console.error(error, errorInfo); } render(): React.ReactNode { if (this.state.hasError) { - return ; + 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 this.reset()} />; } return this.props.children; } - static getDerivedStateFromError(): { hasError: true} { - return { hasError: true }; + static getDerivedStateFromError(error: Error): IState { + return { hasError: true, error}; } -} \ No newline at end of file +} diff --git a/src/ui/React/DeleteGameButton.tsx b/src/ui/React/DeleteGameButton.tsx new file mode 100644 index 000000000..a2d2c2d15 --- /dev/null +++ b/src/ui/React/DeleteGameButton.tsx @@ -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 (<> + + + + { + 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!)"} + /> + ) +} diff --git a/src/ui/React/GameOptionsRoot.tsx b/src/ui/React/GameOptionsRoot.tsx index 432f160e6..2761626e6 100644 --- a/src/ui/React/GameOptionsRoot.tsx +++ b/src/ui/React/GameOptionsRoot.tsx @@ -21,6 +21,7 @@ import TextField from "@mui/material/TextField"; import DownloadIcon from "@mui/icons-material/Download"; import UploadIcon from "@mui/icons-material/Upload"; +import SaveIcon from '@mui/icons-material/Save'; import { FileDiagnosticModal } from "../../Diagnostic/FileDiagnosticModal"; import { dialogBoxCreate } from "./DialogBox"; @@ -31,9 +32,11 @@ import { StyleEditorModal } from "./StyleEditorModal"; import { SnackbarEvents } from "./Snackbar"; import { Settings } from "../../Settings/Settings"; -import { save, deleteGame } from "../../db"; +import { save } from "../../db"; import { formatTime } from "../../utils/helpers/formatTime"; import { OptionSwitch } from "./OptionSwitch"; +import { DeleteGameButton } from "./DeleteGameButton"; +import { SoftResetButton } from "./SoftResetButton"; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -71,10 +74,8 @@ export function GameOptionsRoot(props: IProps): React.ReactElement { const [timestampFormat, setTimestampFormat] = useState(Settings.TimestampsFormat); const [locale, setLocale] = useState(Settings.Locale); const [diagnosticOpen, setDiagnosticOpen] = useState(false); - const [deleteGameOpen, setDeleteOpen] = useState(false); const [themeEditorOpen, setThemeEditorOpen] = useState(false); const [styleEditorOpen, setStyleEditorOpen] = useState(false); - const [softResetOpen, setSoftResetOpen] = useState(false); const [importSaveOpen, setImportSaveOpen] = useState(false); const [importData, setImportData] = useState(null); @@ -194,14 +195,6 @@ export function GameOptionsRoot(props: IProps): React.ReactElement { }); } - function doSoftReset(): void { - if (!Settings.SuppressBuyAugmentationConfirmation) { - setSoftResetOpen(true); - } else { - props.softReset(); - } - } - return (
@@ -531,19 +524,19 @@ export function GameOptionsRoot(props: IProps): React.ReactElement { - - + + Export your game to a text file.}> - Import your game from a text file.
This will overwrite your current game. Back it up first!}> - @@ -587,21 +580,7 @@ export function GameOptionsRoot(props: IProps): React.ReactElement {
- - Perform a soft reset. Resets everything as if you had just purchased an Augmentation. - - } - > - - - setSoftResetOpen(false)} - onConfirm={props.softReset} - confirmationText={"This will perform the same action as installing Augmentations, are you sure?"} - /> + @@ -640,17 +619,6 @@ export function GameOptionsRoot(props: IProps): React.ReactElement { setDiagnosticOpen(false)} /> - { - 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!)"} - /> setThemeEditorOpen(false)} /> setStyleEditorOpen(false)} />
diff --git a/src/ui/React/RecoveryRoot.tsx b/src/ui/React/RecoveryRoot.tsx index 5c0e2e42c..207399b74 100644 --- a/src/ui/React/RecoveryRoot.tsx +++ b/src/ui/React/RecoveryRoot.tsx @@ -1,11 +1,16 @@ -import React from "react"; -import Typography from "@mui/material/Typography"; -import Link from "@mui/material/Link"; -import Button from "@mui/material/Button"; +import React, { useEffect } from "react"; + +import { Typography, Link, Button, ButtonGroup, Tooltip, Box, Paper, TextField } from "@mui/material"; import { Settings } from "../../Settings/Settings"; import { load } from "../../db"; import { IRouter } from "../Router"; 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; @@ -16,40 +21,79 @@ export function ActivateRecoveryMode(): void { interface IProps { router: IRouter; 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 { + if (resetError) resetError(); RecoveryMode = false; router.toTerminal(); } Settings.AutosaveInterval = 0; - load().then((content) => { - download("RECOVERY.json", content); - }); + + useEffect(() => { + load().then((content) => { + const epochTime = Math.round(Date.now() / 1000); + const filename = `RECOVERY_BITBURNER_${epochTime}.json`; + download(filename, content); + }); + }, []); + return ( - <> + RECOVERY MODE ACTIVATED - 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). At this point it is recommended to alert a developer. - - File an issue on github - - - Make a reddit post - - - Post in the #bug-report channel on Discord. - + + File an issue on github + + + Make a reddit post + + + Post in the #bug-report channel on Discord. + Please include your save file.

You can disable recovery mode now. But chances are the game will not work correctly. - - - + + + + + + + + + {errorData && ( + + + {errorData.title} + + + + + + + + + )} +
); } diff --git a/src/ui/React/SoftResetButton.tsx b/src/ui/React/SoftResetButton.tsx new file mode 100644 index 000000000..216f51d09 --- /dev/null +++ b/src/ui/React/SoftResetButton.tsx @@ -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 (<> + + + + setModalOpened(false)} + confirmationText={"This will perform the same action as installing Augmentations, are you sure?"} + /> + ) +} diff --git a/src/utils/ErrorHelper.ts b/src/utils/ErrorHelper.ts new file mode 100644 index 000000000..6e4189949 --- /dev/null +++ b/src/utils/ErrorHelper.ts @@ -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; +}