FEATURE: Add "Recent Errors" tab and improved error modal (#2169)

This commit is contained in:
Michael Ficocelli
2025-06-16 19:43:21 -04:00
committed by GitHub
parent cf72937faf
commit 18f84396e2
17 changed files with 530 additions and 34 deletions
+62
View File
@@ -0,0 +1,62 @@
import { Router } from "../ui/GameRoot";
import { SimplePage } from "@enums";
import { errorModalsAreSuppressed, ErrorRecord, ErrorState } from "./ErrorState";
let currentId = 0;
export const DisplayError = (
message: string,
errorType: string,
scriptName = "",
hostname: string = "",
pid: number = -1,
) => {
const errorPageOpen = Router.page() === SimplePage.RecentErrors;
if (!errorPageOpen) {
ErrorState.UnreadErrors++;
}
const prior = findExistingErrorCopy(message, hostname);
if (prior) {
prior.occurrences++;
prior.time = new Date();
if (pid !== -1) {
prior.pid = pid;
}
prior.server = hostname;
prior.message = message;
updateActiveError(prior);
} else {
ErrorState.Errors.unshift({
id: currentId++,
server: hostname,
errorType,
scriptName,
message,
pid,
occurrences: 1,
time: new Date(),
unread: !errorPageOpen,
});
while (ErrorState.Errors.length > 100) {
ErrorState.Errors.pop();
}
updateActiveError(ErrorState.Errors[0]);
}
};
function findExistingErrorCopy(message: string, hostname: string): ErrorRecord | null {
const serverAgnosticMessage = message.replaceAll(hostname, "<server>");
return (
ErrorState.Errors.find(
(e) => e.message.replaceAll(e.server, "<server>") === serverAgnosticMessage || e.message === message,
) ?? null
);
}
function updateActiveError(error: ErrorRecord): void {
if (!ErrorState.ActiveError && !errorModalsAreSuppressed()) {
ErrorState.ActiveError = error;
ErrorState.ErrorUpdate.emit(ErrorState.ActiveError);
}
}
+114
View File
@@ -0,0 +1,114 @@
import React, { useEffect, useState } from "react";
import { makeStyles } from "tss-react/mui";
import { Modal } from "../ui/React/Modal";
import { Box, Button, Typography } from "@mui/material/";
import { errorModalsAreSuppressed, type ErrorRecord, ErrorState, toggleSuppressErrorModals } from "./ErrorState";
import { Router } from "../ui/GameRoot";
import { SimplePage, ToastVariant } from "@enums";
import { useRerender } from "../ui/React/hooks";
import { OptionSwitch } from "../ui/React/OptionSwitch";
import { LogBoxEvents } from "../ui/React/LogBoxManager";
import { recentScripts } from "../Netscript/RecentScripts";
import { SnackbarEvents } from "../ui/React/Snackbar";
import { Settings } from "../Settings/Settings";
export function ErrorModal(): React.ReactElement {
const { classes } = useStyles();
const rerender = useRerender();
const [error, setError] = useState<ErrorRecord | null>(ErrorState.ActiveError);
useEffect(() => {
const listener = (newError: ErrorRecord) => {
if (newError.force || (Router.page() !== SimplePage.RecentErrors && !errorModalsAreSuppressed())) {
setError(newError);
rerender();
} else {
ErrorState.ActiveError = null;
setError(null);
}
};
return ErrorState.ErrorUpdate.subscribe(listener);
}, [rerender]);
const onClose = (force = false): void => {
ErrorState.ActiveError && (ErrorState.ActiveError.unread = false);
if (force || errorModalsAreSuppressed()) {
ErrorState.ActiveError = null;
setError(null);
} else {
const nextError = ErrorState.Errors.find((e) => e.unread) ?? null;
ErrorState.ActiveError = nextError;
setError(nextError);
}
ErrorState.UnreadErrors = ErrorState.Errors.filter((e) => e.unread).length;
};
const viewLogs = (): void => {
if (error === null) {
return;
}
const recentScript = recentScripts.find((script) => script.runningScript.pid === error.pid);
if (!recentScript) {
SnackbarEvents.emit(`No recent script found with pid ${error.pid}`, ToastVariant.INFO, 2000);
return;
}
onClose();
LogBoxEvents.emit(recentScript.runningScript);
};
const goToErrorPage = () => {
onClose(true);
Router.toPage(SimplePage.RecentErrors);
};
return (
<Modal open={!!error} onClose={() => onClose()}>
{error && (
<>
<Typography component="div">
<h2>{error.errorType} ERROR</h2>
{/* Add a zero-width space after each slash to allow clean wrapping. */}
<p style={{ whiteSpace: "pre-wrap" }}>{error.message.replaceAll("/", "/\u200B")}</p>
<p>
Script: {error.scriptName}
<br />
PID: {error.pid}
</p>
{!Settings.SuppressErrorModals && (
<OptionSwitch
checked={errorModalsAreSuppressed()}
onChange={(newValue) => toggleSuppressErrorModals(newValue)}
text="Suppress error modals (5 min)"
tooltip={
<>
If this is set, no error modals will be shown for the next five minutes, and only log errors to the
Recent Errors page.
</>
}
/>
)}
</Typography>
<Box className={classes.inlineFlexBox}>
<Button onClick={() => onClose()}>Close</Button>
<div>
<Button disabled={error.pid === -1} onClick={viewLogs}>
View Script Logs
</Button>
<Button onClick={goToErrorPage}>Errors Page</Button>
</div>
</Box>
</>
)}
</Modal>
);
}
const useStyles = makeStyles()(() => ({
inlineFlexBox: {
display: "inline-flex",
flexDirection: "row",
width: "100%",
justifyContent: "space-between",
},
}));
+35
View File
@@ -0,0 +1,35 @@
import { EventEmitter } from "../utils/EventEmitter";
export type ErrorRecord = {
id: number;
server: string;
errorType: string;
message: string;
scriptName: string;
pid: number;
occurrences: number;
time: Date;
unread: boolean;
force?: boolean;
};
export const ErrorState = {
ErrorUpdate: new EventEmitter<[ErrorRecord]>(),
ActiveError: null as ErrorRecord | null,
Errors: [] as ErrorRecord[],
UnreadErrors: 0,
PreventModalsUntil: new Date(),
};
export function errorModalsAreSuppressed(): boolean {
return ErrorState.PreventModalsUntil.getTime() > Date.now();
}
export function toggleSuppressErrorModals(newValue: boolean, indefinite = false): void {
if (newValue) {
ErrorState.PreventModalsUntil = new Date(indefinite ? new Date("3000-01-01") : Date.now() + 1000 * 60 * 5);
ErrorState.ActiveError = null;
} else {
ErrorState.PreventModalsUntil = new Date();
}
}
+120
View File
@@ -0,0 +1,120 @@
import React, { useEffect } from "react";
import { makeStyles } from "tss-react/mui";
import { type ErrorRecord, ErrorState } from "./ErrorState";
import { useRerender } from "../ui/React/hooks";
import { Typography, Tooltip } from "@mui/material";
import { Theme } from "@mui/material/styles";
export function RecentErrorsPage(): React.ReactElement {
const rerender = useRerender();
React.useEffect(() => {
const clearSubscription = ErrorState.ErrorUpdate.subscribe(rerender);
ErrorState.UnreadErrors = 0;
return () => {
clearSubscription();
ErrorState.UnreadErrors = 0;
};
}, [rerender]);
useEffect(() => {
ErrorState.Errors.forEach((error) => {
error.unread = false; // Mark all errors as read when the page is loaded
});
}, []);
const { classes } = useStyles();
const showError = (error: ErrorRecord): void => {
ErrorState.ErrorUpdate.emit({ ...error, force: true });
};
const formatMessage = (message: string): string => {
/**
* - Add a zero-width space after each slash to allow clean wrapping.
* - Replace 2+ newline characters with only 1 newline character to reduce the number of empty lines.
*/
return message.replaceAll("/", "/\u200B").replaceAll(/\n{2,}/g, "\n");
};
return (
<div>
<Typography component="div" sx={{ height: "100vh", overflowY: "auto", scrollbarWidth: "thin" }}>
<table className={classes.errorTable}>
<thead>
<tr>
<th className={classes.cellText}>Count</th>
<th className={classes.cellText}>Type</th>
<th className={classes.cellText}>Message</th>
<th className={classes.cellText}>Script</th>
<th className={classes.cellText}>Time</th>
</tr>
</thead>
<tbody>
{ErrorState.Errors.map((e, i) => (
<tr key={i} className={classes.errorRow} onClick={() => showError(e)}>
<td className={classes.cellText}>
<div className={classes.xsmall}>{e.occurrences}</div>
</td>
<td className={classes.cellText}>
<div className={classes.xsmall}>{e.errorType}</div>
</td>
<td>
<div className={classes.errorText} key={i}>
{formatMessage(e.message)}
</div>
</td>
<td className={classes.cellText}>
<div className={classes.small}>
<Tooltip title={<>{formatMessage(e.scriptName)}</>}>
<div style={{ textOverflow: "ellipsis", overflow: "auto" }}>{formatMessage(e.scriptName)}</div>
</Tooltip>
</div>
</td>
<td className={classes.cellText}>
<div className={classes.xsmall}>{e.time.toLocaleString()}</div>
</td>
</tr>
))}
</tbody>
</table>
</Typography>
</div>
);
}
const useStyles = makeStyles()((theme: Theme) => ({
errorTable: {
width: "100%",
maxWidth: "100%",
borderCollapse: "collapse",
},
errorRow: {
borderTop: `1px solid ${theme.colors.button}`,
"&:hover": {
backgroundColor: theme.colors.button,
},
},
cellText: {
verticalAlign: "top",
padding: "4px",
textAlign: "left",
},
errorText: {
margin: "4px",
color: "white",
textOverflow: "ellipsis",
whiteSpace: "pre-wrap",
lineClamp: "6",
lineHeight: 1.1,
overflowX: "auto",
maxHeight: "200px",
},
xsmall: {
maxWidth: "110px",
fontSize: "14px",
lineHeight: 1.2,
},
small: {
maxWidth: "200px",
},
}));