From 18f84396e2cbb998d6e182b0a38353b76663b884 Mon Sep 17 00:00:00 2001 From: Michael Ficocelli Date: Mon, 16 Jun 2025 19:43:21 -0400 Subject: [PATCH] FEATURE: Add "Recent Errors" tab and improved error modal (#2169) --- src/ErrorHandling/DisplayError.ts | 62 +++++++++++ src/ErrorHandling/ErrorModal.tsx | 114 ++++++++++++++++++++ src/ErrorHandling/ErrorState.tsx | 35 ++++++ src/ErrorHandling/RecentErrorsPage.tsx | 120 +++++++++++++++++++++ src/GameOptions/ui/GameplayPage.tsx | 16 +++ src/Netscript/killWorkerScript.ts | 12 ++- src/Settings/Settings.ts | 6 ++ src/Sidebar/ui/SidebarAccordion.tsx | 2 +- src/Sidebar/ui/SidebarItem.tsx | 1 + src/Sidebar/ui/SidebarRoot.tsx | 9 +- src/ui/ActiveScripts/ActiveScriptsRoot.tsx | 89 ++++++++++++--- src/ui/Enums.ts | 2 + src/ui/GameRoot.tsx | 18 +++- src/ui/React/OptionSwitch.tsx | 7 +- src/utils/ErrorHandler.ts | 62 +++++++++-- src/utils/ErrorHelper.ts | 2 +- test/jest/Netscript/RunScript.test.ts | 7 +- 17 files changed, 530 insertions(+), 34 deletions(-) create mode 100644 src/ErrorHandling/DisplayError.ts create mode 100644 src/ErrorHandling/ErrorModal.tsx create mode 100644 src/ErrorHandling/ErrorState.tsx create mode 100644 src/ErrorHandling/RecentErrorsPage.tsx diff --git a/src/ErrorHandling/DisplayError.ts b/src/ErrorHandling/DisplayError.ts new file mode 100644 index 000000000..4c19a0b59 --- /dev/null +++ b/src/ErrorHandling/DisplayError.ts @@ -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, ""); + return ( + ErrorState.Errors.find( + (e) => e.message.replaceAll(e.server, "") === serverAgnosticMessage || e.message === message, + ) ?? null + ); +} + +function updateActiveError(error: ErrorRecord): void { + if (!ErrorState.ActiveError && !errorModalsAreSuppressed()) { + ErrorState.ActiveError = error; + ErrorState.ErrorUpdate.emit(ErrorState.ActiveError); + } +} diff --git a/src/ErrorHandling/ErrorModal.tsx b/src/ErrorHandling/ErrorModal.tsx new file mode 100644 index 000000000..89a42ec62 --- /dev/null +++ b/src/ErrorHandling/ErrorModal.tsx @@ -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(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 ( + onClose()}> + {error && ( + <> + +

{error.errorType} ERROR

+ {/* Add a zero-width space after each slash to allow clean wrapping. */} +

{error.message.replaceAll("/", "/\u200B")}

+

+ Script: {error.scriptName} +
+ PID: {error.pid} +

+ {!Settings.SuppressErrorModals && ( + 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. + + } + /> + )} +
+ + +
+ + +
+
+ + )} +
+ ); +} + +const useStyles = makeStyles()(() => ({ + inlineFlexBox: { + display: "inline-flex", + flexDirection: "row", + width: "100%", + justifyContent: "space-between", + }, +})); diff --git a/src/ErrorHandling/ErrorState.tsx b/src/ErrorHandling/ErrorState.tsx new file mode 100644 index 000000000..72cce5a01 --- /dev/null +++ b/src/ErrorHandling/ErrorState.tsx @@ -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(); + } +} diff --git a/src/ErrorHandling/RecentErrorsPage.tsx b/src/ErrorHandling/RecentErrorsPage.tsx new file mode 100644 index 000000000..0f6675c42 --- /dev/null +++ b/src/ErrorHandling/RecentErrorsPage.tsx @@ -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 ( +
+ + + + + + + + + + + + + {ErrorState.Errors.map((e, i) => ( + showError(e)}> + + + + + + + ))} + +
CountTypeMessageScriptTime
+
{e.occurrences}
+
+
{e.errorType}
+
+
+ {formatMessage(e.message)} +
+
+
+ {formatMessage(e.scriptName)}}> +
{formatMessage(e.scriptName)}
+
+
+
+
{e.time.toLocaleString()}
+
+
+
+ ); +} + +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", + }, +})); diff --git a/src/GameOptions/ui/GameplayPage.tsx b/src/GameOptions/ui/GameplayPage.tsx index 75d159cfb..f2e54a10f 100644 --- a/src/GameOptions/ui/GameplayPage.tsx +++ b/src/GameOptions/ui/GameplayPage.tsx @@ -3,8 +3,13 @@ import { OptionSwitch } from "../../ui/React/OptionSwitch"; import { Settings } from "../../Settings/Settings"; import { GameOptionsPage } from "./GameOptionsPage"; import { Player } from "@player"; +import { toggleSuppressErrorModals } from "../../ErrorHandling/ErrorState"; export const GameplayPage = (): React.ReactElement => { + const toggleSuppressErrorModalsSetting = (newValue: boolean): void => { + Settings.SuppressErrorModals = newValue; + toggleSuppressErrorModals(newValue, true); + }; return ( { text="Suppress TIX messages" tooltip={<>If this is set, the stock market will never create any popup.} /> + + If this is set, script errors will never create any popups. The errors can still be seen on the "Recent + Errors" tab in the Active Scripts page. + + } + /> {Player.bladeburner && ( { + for (const server of GetAllServers()) { + for (const byPid of server.runningScriptMap.values()) { + for (const pid of byPid.keys()) { + killWorkerScriptByPid(pid); + } + } + } +}; + function stopAndCleanUpWorkerScript(ws: WorkerScript): void { // Only clean up once. // Important: Only this function can set stopFlag! diff --git a/src/Settings/Settings.ts b/src/Settings/Settings.ts index 8fa3d7974..8f1cd4924 100644 --- a/src/Settings/Settings.ts +++ b/src/Settings/Settings.ts @@ -12,6 +12,7 @@ import { assertAndSanitizeStyles, } from "../JsonSchema/JSONSchemaAssertion"; import { mergePlayerDefinedKeyBindings, type PlayerDefinedKeyBindingsType } from "../utils/KeyBindingUtils"; +import { toggleSuppressErrorModals } from "../ErrorHandling/ErrorState"; /** * This function won't be able to catch **all** invalid hostnames. In order to validate a hostname properly, we need to @@ -119,6 +120,8 @@ export const Settings = { SaveGameOnFileSave: true, /** Whether to hide the confirmation dialog for augmentation purchases. */ SuppressBuyAugmentationConfirmation: false, + /** Whether to hide the info dialog for script errors. */ + SuppressErrorModals: false, /** Whether to hide the dialog showing new faction invites. */ SuppressFactionInvites: false, /** Whether to hide the dialog when the player receives a new message file. */ @@ -253,5 +256,8 @@ export const Settings = { // Merge Settings.KeyBindings with DefaultKeyBindings. mergePlayerDefinedKeyBindings(Settings.KeyBindings); + + // Set up initial state for error modal suppression + toggleSuppressErrorModals(Settings.SuppressErrorModals, true); }, }; diff --git a/src/Sidebar/ui/SidebarAccordion.tsx b/src/Sidebar/ui/SidebarAccordion.tsx index ff4f4a9c8..b62d25cfa 100644 --- a/src/Sidebar/ui/SidebarAccordion.tsx +++ b/src/Sidebar/ui/SidebarAccordion.tsx @@ -88,7 +88,7 @@ export function SidebarAccordion({ key_={key_} icon={icon} count={count} - active={active ?? page === key_} + active={active ?? (page === key_ || x.alternateKeys?.includes(page))} clickFn={getClickFn(clickPage, key_)} flash={flash === key_} classes={classes} diff --git a/src/Sidebar/ui/SidebarItem.tsx b/src/Sidebar/ui/SidebarItem.tsx index e181a9089..6ffb90494 100644 --- a/src/Sidebar/ui/SidebarItem.tsx +++ b/src/Sidebar/ui/SidebarItem.tsx @@ -13,6 +13,7 @@ export interface ICreateProps { icon: React.ReactElement["type"]; count?: number; active?: boolean; + alternateKeys?: Page[]; } export interface SidebarItemProps extends ICreateProps { diff --git a/src/Sidebar/ui/SidebarRoot.tsx b/src/Sidebar/ui/SidebarRoot.tsx index 128df030c..699fa1ad6 100644 --- a/src/Sidebar/ui/SidebarRoot.tsx +++ b/src/Sidebar/ui/SidebarRoot.tsx @@ -68,6 +68,7 @@ import { CurrentKeyBindings, } from "../../utils/KeyBindingUtils"; import { throwIfReachable } from "../../utils/helpers/throwIfReachable"; +import { ErrorState } from "../../ErrorHandling/ErrorState"; const RotatedDoubleArrowIcon = React.forwardRef(function RotatedDoubleArrowIcon( props: { color: "primary" | "secondary" | "error" }, @@ -148,6 +149,7 @@ export function SidebarRoot(props: { page: Page }): React.ReactElement { const augmentationCount = Player.queuedAugmentations.length; const invitationsCount = Player.factionInvitations.filter((f) => !InvitationsSeen.has(f)).length; const programCount = getAvailableCreatePrograms().length - ProgramsSeen.size; + const errorCount = ErrorState.UnreadErrors; const canOpenFactions = Player.factionInvitations.length > 0 || @@ -339,7 +341,12 @@ export function SidebarRoot(props: { page: Page }): React.ReactElement { items={[ { key_: Page.Terminal, icon: LastPageIcon }, { key_: Page.ScriptEditor, icon: CreateIcon }, - { key_: Page.ActiveScripts, icon: StorageIcon }, + { + key_: Page.ActiveScripts, + icon: StorageIcon, + count: errorCount, + alternateKeys: [Page.RecentErrors, Page.RecentlyKilledScripts], + }, { key_: Page.CreateProgram, icon: BugReportIcon, count: programCount }, canStaneksGift && { key_: Page.StaneksGift, icon: DeveloperBoardIcon }, ]} diff --git a/src/ui/ActiveScripts/ActiveScriptsRoot.tsx b/src/ui/ActiveScripts/ActiveScriptsRoot.tsx index a91b86739..bd135129e 100644 --- a/src/ui/ActiveScripts/ActiveScriptsRoot.tsx +++ b/src/ui/ActiveScripts/ActiveScriptsRoot.tsx @@ -2,30 +2,95 @@ * Root React Component for the "Active Scripts" UI page. This page displays * and provides information about all of the player's scripts that are currently running */ -import React, { useState } from "react"; -import Tabs from "@mui/material/Tabs"; -import Tab from "@mui/material/Tab"; +import React, { useState, useEffect } from "react"; +import { Button, Tabs, Tab } from "@mui/material"; import { ActiveScriptsPage } from "./ActiveScriptsPage"; import { RecentScriptsPage } from "./RecentScriptsPage"; +import { RecentErrorsPage } from "../../ErrorHandling/RecentErrorsPage"; import { useRerender } from "../React/hooks"; +import { errorModalsAreSuppressed, ErrorState, toggleSuppressErrorModals } from "../../ErrorHandling/ErrorState"; +import { OptionSwitch } from "../React/OptionSwitch"; +import { killAllScripts } from "../../Netscript/killWorkerScript"; +import { SimplePage } from "@enums"; +import { Router } from "../GameRoot"; +import { Settings } from "../../Settings/Settings"; -export function ActiveScriptsRoot(): React.ReactElement { - const [tab, setTab] = useState<"active" | "recent">("active"); +type ActiveScriptsTab = SimplePage.ActiveScripts | SimplePage.RecentlyKilledScripts | SimplePage.RecentErrors; + +export type ComponentProps = { + page: ActiveScriptsTab; +}; + +export function ActiveScriptsRoot({ page }: ComponentProps): React.ReactElement { + const [tab, setTab] = useState(page); useRerender(400); - function handleChange(event: React.SyntheticEvent, tab: "active" | "recent"): void { + useEffect(() => { + if (ErrorState.UnreadErrors > 0) { + handleChange(null, SimplePage.RecentErrors); + } + }, []); + + function handleChange( + __event: React.SyntheticEvent | null, + tab: SimplePage.ActiveScripts | SimplePage.RecentlyKilledScripts | SimplePage.RecentErrors, + ): void { setTab(tab); + Router.toPage(tab); } + + function errorTabText(): string { + if (!ErrorState.UnreadErrors || tab === SimplePage.RecentErrors) { + return "Recent Errors"; + } + return `Recent Errors (${ErrorState.UnreadErrors})`; + } + return ( <> - - - - +
+ + + + + + {Settings.SuppressErrorModals ? ( +
+ ) : ( + 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. + + } + wrapperStyles={{ marginLeft: "20px" }} + /> + )} + +
- {tab === "active" && } - {tab === "recent" && } + {tab === SimplePage.ActiveScripts && } + {tab === SimplePage.RecentlyKilledScripts && } + {tab === SimplePage.RecentErrors && } ); } diff --git a/src/ui/Enums.ts b/src/ui/Enums.ts index c3f510b75..ff2576cd6 100644 --- a/src/ui/Enums.ts +++ b/src/ui/Enums.ts @@ -13,6 +13,8 @@ export enum ToastVariant { */ export enum SimplePage { ActiveScripts = "Active Scripts", + RecentlyKilledScripts = "Recently Killed Scripts", + RecentErrors = "Recent Errors", Augmentations = "Augmentations", Bladeburner = "Bladeburner", City = "City", diff --git a/src/ui/GameRoot.tsx b/src/ui/GameRoot.tsx index 3e392f97a..e7b1672c4 100644 --- a/src/ui/GameRoot.tsx +++ b/src/ui/GameRoot.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useEffect, useState } from "react"; import { Box, Typography } from "@mui/material"; import { Theme } from "@mui/material/styles"; import { makeStyles } from "tss-react/mui"; @@ -7,7 +7,7 @@ import { Player } from "@player"; import { installAugmentations } from "../Augmentation/AugmentationHelpers"; import { saveObject } from "../SaveObject"; import { onExport } from "../ExportBonus"; -import { CompletedProgramName, LocationName } from "@enums"; +import { CompletedProgramName, LocationName, SimplePage } from "@enums"; import { ITutorial, iTutorialStart } from "../InteractiveTutorial"; import { InteractiveTutorialRoot } from "./InteractiveTutorial/InteractiveTutorialRoot"; import { ITutorialEvents } from "./InteractiveTutorial/ITutorialEvents"; @@ -18,7 +18,7 @@ import { GetAllServers } from "../Server/AllServers"; import { StockMarket } from "../StockMarket/StockMarket"; import type { ComplexPage } from "./Enums"; -import type { PageWithContext, IRouter, PageContext } from "./Router"; +import type { IRouter, PageContext, PageWithContext } from "./Router"; import { Page } from "./Router"; import { Overview } from "./React/Overview"; import { SidebarRoot } from "../Sidebar/ui/SidebarRoot"; @@ -78,6 +78,7 @@ import { Settings } from "../Settings/Settings"; import { isBitNodeFinished } from "../BitNode/BitNodeUtils"; import { exceptionAlert } from "../utils/helpers/exceptionAlert"; import { SpecialServers } from "../Server/data/SpecialServers"; +import { ErrorModal } from "../ErrorHandling/ErrorModal"; import { DocumentationPopUp } from "../Documentation/ui/DocumentationPopUp"; const htmlLocation = location; @@ -281,7 +282,15 @@ export function GameRoot(): React.ReactElement { break; } case Page.ActiveScripts: { - mainPage = ; + mainPage = ; + break; + } + case Page.RecentlyKilledScripts: { + mainPage = ; + break; + } + case Page.RecentErrors: { + mainPage = ; break; } case Page.Hacknet: { @@ -441,6 +450,7 @@ export function GameRoot(): React.ReactElement {