From bef4e3fbd585a272f51c71aaf16d06185d3e26d9 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:26:26 -0800 Subject: [PATCH] Feature: Add "Troubleshooting Mode" (#6343) Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Co-authored-by: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> --- graphql/schema/types/config.graphql | 6 ++ internal/api/resolver_mutation_configure.go | 2 + internal/api/resolver_query_configuration.go | 2 + internal/api/server.go | 6 +- internal/manager/config/config.go | 8 ++ ui/v2.5/graphql/data/config.graphql | 1 + ui/v2.5/src/App.tsx | 9 +- ui/v2.5/src/components/Help/Manual.tsx | 6 ++ ui/v2.5/src/components/Settings/Settings.tsx | 4 + ui/v2.5/src/components/Settings/styles.scss | 12 +++ .../TroubleshootingModeButton.tsx | 67 +++++++++++++++ .../TroubleshootingModeOverlay.tsx | 28 +++++++ .../useTroubleshootingMode.ts | 83 +++++++++++++++++++ .../src/docs/en/Manual/TroubleshootingMode.md | 7 ++ ui/v2.5/src/index.scss | 37 +++++++++ ui/v2.5/src/locales/en-GB.json | 14 ++++ ui/v2.5/src/plugins.tsx | 28 +++++-- 17 files changed, 309 insertions(+), 11 deletions(-) create mode 100644 ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx create mode 100644 ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx create mode 100644 ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts create mode 100644 ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index b6f52091b..6990d9d95 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -395,6 +395,9 @@ input ConfigInterfaceInput { customLocales: String customLocalesEnabled: Boolean + "When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting" + disableCustomizations: Boolean + "Interface language" language: String @@ -469,6 +472,9 @@ type ConfigInterfaceResult { customLocales: String customLocalesEnabled: Boolean + "When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting" + disableCustomizations: Boolean + "Interface language" language: String diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index daed0b5b7..23b61c208 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -515,6 +515,8 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI r.setConfigBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled) + r.setConfigBool(config.DisableCustomizations, input.DisableCustomizations) + if input.DisableDropdownCreate != nil { ddc := input.DisableDropdownCreate r.setConfigBool(config.DisableDropdownCreatePerformer, ddc.Performer) diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go index 8a20fcad1..bc76212eb 100644 --- a/internal/api/resolver_query_configuration.go +++ b/internal/api/resolver_query_configuration.go @@ -156,6 +156,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult { javascriptEnabled := config.GetJavascriptEnabled() customLocales := config.GetCustomLocales() customLocalesEnabled := config.GetCustomLocalesEnabled() + disableCustomizations := config.GetDisableCustomizations() language := config.GetLanguage() handyKey := config.GetHandyKey() scriptOffset := config.GetFunscriptOffset() @@ -183,6 +184,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult { JavascriptEnabled: &javascriptEnabled, CustomLocales: &customLocales, CustomLocalesEnabled: &customLocalesEnabled, + DisableCustomizations: &disableCustomizations, Language: &language, ImageLightbox: &imageLightboxOptions, diff --git a/internal/api/server.go b/internal/api/server.go index ed11a99a5..a7516da52 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -450,7 +450,7 @@ func cssHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { var paths []string - if c.GetCSSEnabled() { + if c.GetCSSEnabled() && !c.GetDisableCustomizations() { // search for custom.css in current directory, then $HOME/.stash fn := c.GetCSSPath() exists, _ := fsutil.FileExists(fn) @@ -468,7 +468,7 @@ func javascriptHandler(c *config.Config) func(w http.ResponseWriter, r *http.Req return func(w http.ResponseWriter, r *http.Request) { var paths []string - if c.GetJavascriptEnabled() { + if c.GetJavascriptEnabled() && !c.GetDisableCustomizations() { // search for custom.js in current directory, then $HOME/.stash fn := c.GetJavascriptPath() exists, _ := fsutil.FileExists(fn) @@ -486,7 +486,7 @@ func customLocalesHandler(c *config.Config) func(w http.ResponseWriter, r *http. return func(w http.ResponseWriter, r *http.Request) { buffer := bytes.Buffer{} - if c.GetCustomLocalesEnabled() { + if c.GetCustomLocalesEnabled() && !c.GetDisableCustomizations() { // search for custom-locales.json in current directory, then $HOME/.stash path := c.GetCustomLocalesPath() exists, _ := fsutil.FileExists(path) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 35534f119..bb99bdcfc 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -194,6 +194,7 @@ const ( CSSEnabled = "cssenabled" JavascriptEnabled = "javascriptenabled" CustomLocalesEnabled = "customlocalesenabled" + DisableCustomizations = "disable_customizations" ShowScrubber = "show_scrubber" showScrubberDefault = true @@ -1479,6 +1480,13 @@ func (i *Config) GetCustomLocalesEnabled() bool { return i.getBool(CustomLocalesEnabled) } +// GetDisableCustomizations returns true if all customizations (plugins, custom CSS, +// custom JavaScript, and custom locales) should be disabled. This is useful for +// troubleshooting issues without permanently disabling individual customizations. +func (i *Config) GetDisableCustomizations() bool { + return i.getBool(DisableCustomizations) +} + func (i *Config) GetHandyKey() string { return i.getString(HandyKey) } diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index b65ba21cc..08dcf5d3b 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -92,6 +92,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { javascriptEnabled customLocales customLocalesEnabled + disableCustomizations language imageLightbox { slideshowDelay diff --git a/ui/v2.5/src/App.tsx b/ui/v2.5/src/App.tsx index 761352373..d08274b18 100644 --- a/ui/v2.5/src/App.tsx +++ b/ui/v2.5/src/App.tsx @@ -49,6 +49,7 @@ import { PluginRoutes, PluginsLoader } from "./plugins"; // import plugin_api to run code import "./pluginApi"; import { ConnectionMonitor } from "./ConnectionMonitor"; +import { TroubleshootingModeOverlay } from "./components/TroubleshootingMode/TroubleshootingModeOverlay"; import { PatchFunction } from "./patch"; import moment from "moment/min/moment-with-locales"; @@ -352,11 +353,17 @@ export const App: React.FC = () => { formats={intlFormats} > - + {maybeRenderReleaseNotes()} + }> diff --git a/ui/v2.5/src/components/Help/Manual.tsx b/ui/v2.5/src/components/Help/Manual.tsx index d8fc1dbed..e90e2e5ac 100644 --- a/ui/v2.5/src/components/Help/Manual.tsx +++ b/ui/v2.5/src/components/Help/Manual.tsx @@ -23,6 +23,7 @@ import Interactive from "src/docs/en/Manual/Interactive.md"; import Captions from "src/docs/en/Manual/Captions.md"; import Identify from "src/docs/en/Manual/Identify.md"; import Browsing from "src/docs/en/Manual/Browsing.md"; +import TroubleshootingMode from "src/docs/en/Manual/TroubleshootingMode.md"; import { MarkdownPage } from "../Shared/MarkdownPage"; interface IManualProps { @@ -152,6 +153,11 @@ export const Manual: React.FC = ({ title: "Keyboard Shortcuts", content: KeyboardShortcuts, }, + { + key: "TroubleshootingMode.md", + title: "Troubleshooting Mode", + content: TroubleshootingMode, + }, { key: "Contributing.md", title: "Contributing", diff --git a/ui/v2.5/src/components/Settings/Settings.tsx b/ui/v2.5/src/components/Settings/Settings.tsx index 4c2b02455..86a781445 100644 --- a/ui/v2.5/src/components/Settings/Settings.tsx +++ b/ui/v2.5/src/components/Settings/Settings.tsx @@ -18,6 +18,8 @@ import { SettingsContext, useSettings } from "./context"; import { SettingsLibraryPanel } from "./SettingsLibraryPanel"; import { SettingsSecurityPanel } from "./SettingsSecurityPanel"; import Changelog from "../Changelog/Changelog"; +import { TroubleshootingModeButton } from "../TroubleshootingMode/TroubleshootingModeButton"; +import { useTroubleshootingMode } from "../TroubleshootingMode/useTroubleshootingMode"; const validTabs = [ "tasks", @@ -43,6 +45,7 @@ function isTabKey(tab: string | null): tab is TabKey { const SettingTabs: React.FC<{ tab: TabKey }> = ({ tab }) => { const { advancedMode, setAdvancedMode } = useSettings(); + const { isActive: troubleshootingModeActive } = useTroubleshootingMode(); const titleProps = useTitleProps({ id: "settings" }); @@ -148,6 +151,7 @@ const SettingTabs: React.FC<{ tab: TabKey }> = ({ tab }) => { /> + {!troubleshootingModeActive && }
diff --git a/ui/v2.5/src/components/Settings/styles.scss b/ui/v2.5/src/components/Settings/styles.scss index ed8242ce3..3f3a292b4 100644 --- a/ui/v2.5/src/components/Settings/styles.scss +++ b/ui/v2.5/src/components/Settings/styles.scss @@ -447,3 +447,15 @@ display: inline-block; } } + +.troubleshooting-mode-button { + bottom: 1rem; + left: 1rem; + position: fixed; + z-index: 100; + + @include media-breakpoint-down(xs) { + padding-left: 0.5rem; + position: static; + } +} diff --git a/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx b/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx new file mode 100644 index 000000000..164774446 --- /dev/null +++ b/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeButton.tsx @@ -0,0 +1,67 @@ +import React, { useState } from "react"; +import { Button } from "react-bootstrap"; +import { FormattedMessage, useIntl } from "react-intl"; +import { faBug } from "@fortawesome/free-solid-svg-icons"; +import { ModalComponent } from "src/components/Shared/Modal"; +import { useTroubleshootingMode } from "./useTroubleshootingMode"; + +const DIALOG_ITEMS = [ + "config.ui.troubleshooting_mode.dialog_item_plugins", + "config.ui.troubleshooting_mode.dialog_item_css", + "config.ui.troubleshooting_mode.dialog_item_js", + "config.ui.troubleshooting_mode.dialog_item_locales", +] as const; + +export const TroubleshootingModeButton: React.FC = () => { + const intl = useIntl(); + const [showDialog, setShowDialog] = useState(false); + const { enable, isLoading } = useTroubleshootingMode(); + + return ( + <> +
+ +
+ + setShowDialog(false)} + header={intl.formatMessage({ + id: "config.ui.troubleshooting_mode.dialog_title", + })} + icon={faBug} + accept={{ + text: intl.formatMessage({ + id: "config.ui.troubleshooting_mode.enable", + }), + variant: "primary", + onClick: enable, + }} + cancel={{ + onClick: () => setShowDialog(false), + variant: "secondary", + }} + isRunning={isLoading} + > +

+ +

+
    + {DIALOG_ITEMS.map((id) => ( +
  • + +
  • + ))} +
+

+ +

+

+ +

+
+ + ); +}; diff --git a/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx b/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx new file mode 100644 index 000000000..bf2b38f8a --- /dev/null +++ b/ui/v2.5/src/components/TroubleshootingMode/TroubleshootingModeOverlay.tsx @@ -0,0 +1,28 @@ +import React from "react"; +import { Button } from "react-bootstrap"; +import { FormattedMessage } from "react-intl"; +import { faBug } from "@fortawesome/free-solid-svg-icons"; +import { Icon } from "src/components/Shared/Icon"; +import { useTroubleshootingMode } from "./useTroubleshootingMode"; + +export const TroubleshootingModeOverlay: React.FC = () => { + const { isActive, isLoading, disable } = useTroubleshootingMode(); + + if (!isActive) { + return null; + } + + return ( +
+
+ + + + + +
+
+ ); +}; diff --git a/ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts b/ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts new file mode 100644 index 000000000..63b4edd4f --- /dev/null +++ b/ui/v2.5/src/components/TroubleshootingMode/useTroubleshootingMode.ts @@ -0,0 +1,83 @@ +import { useState, useRef, useEffect } from "react"; +import { + useConfigureInterface, + useConfigureGeneral, + useConfiguration, +} from "src/core/StashService"; + +const ORIGINAL_LOG_LEVEL_KEY = "troubleshootingMode_originalLogLevel"; + +export function useTroubleshootingMode() { + const [isLoading, setIsLoading] = useState(false); + const isMounted = useRef(true); + + const { data: config } = useConfiguration(); + const [configureInterface] = useConfigureInterface(); + const [configureGeneral] = useConfigureGeneral(); + + const isActive = + config?.configuration?.interface?.disableCustomizations ?? false; + const currentLogLevel = config?.configuration?.general?.logLevel || "Info"; + + useEffect(() => { + return () => { + isMounted.current = false; + }; + }, []); + + async function enable() { + setIsLoading(true); + try { + // Store original log level for restoration later + localStorage.setItem(ORIGINAL_LOG_LEVEL_KEY, currentLogLevel); + + // Enable troubleshooting mode and set log level to Debug + await Promise.all([ + configureInterface({ + variables: { input: { disableCustomizations: true } }, + }), + configureGeneral({ + variables: { input: { logLevel: "Debug" } }, + }), + ]); + + window.location.reload(); + } catch (e) { + if (isMounted.current) { + setIsLoading(false); + } + throw e; + } + } + + async function disable() { + setIsLoading(true); + try { + // Restore original log level + const originalLogLevel = + localStorage.getItem(ORIGINAL_LOG_LEVEL_KEY) || "Info"; + + // Disable troubleshooting mode and restore log level + await Promise.all([ + configureInterface({ + variables: { input: { disableCustomizations: false } }, + }), + configureGeneral({ + variables: { input: { logLevel: originalLogLevel } }, + }), + ]); + + // Clean up localStorage + localStorage.removeItem(ORIGINAL_LOG_LEVEL_KEY); + + window.location.reload(); + } catch (e) { + if (isMounted.current) { + setIsLoading(false); + } + throw e; + } + } + + return { isActive, isLoading, enable, disable }; +} diff --git a/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md b/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md new file mode 100644 index 000000000..d7a2c1cee --- /dev/null +++ b/ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md @@ -0,0 +1,7 @@ +# Troubleshooting Mode + +Troubleshooting Mode disables all plugins and all custom CSS, JavaScript, and locales. It also temporarily sets the log level to `DEBUG`. This is useful when you are experiencing issues with your Stash instance to eliminate the possibility that a plugin or custom code is causing the issue. + +Troubleshooting Mode is enabled from the Settings page, by clicking the `Troubleshooting Mode` button at the bottom left of the page. + +When Troubleshooting Mode is enabled, a red border and a banner will be displayed to remind you that you are in Troubleshooting Mode. To exit Troubleshooting Mode, click the `Exit` button in the banner. \ No newline at end of file diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 0c0bffdec..24679c158 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -1438,3 +1438,40 @@ select { h3 .TruncatedText { line-height: 1.5; } + +// Troubleshooting Mode overlay banner +.troubleshooting-mode-overlay { + border: 5px solid $danger; + bottom: 0; + left: 0; + opacity: 0.75; + pointer-events: none; + position: fixed; + right: 0; + top: 0; + z-index: 1040; + + .troubleshooting-mode-alert { + align-items: baseline; + border-radius: 0; + bottom: 0.5rem; + display: inline-flex; + margin: 0; + position: fixed; + right: 0.5rem; + + @include media-breakpoint-down(xs) { + @media (orientation: portrait) { + bottom: $navbar-height; + + & > span { + font-size: 0.75rem; + } + } + } + } + + .btn { + pointer-events: auto; + } +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 9fc6f0c0d..4cec0af66 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -616,6 +616,20 @@ "heading": "Custom CSS", "option_label": "Custom CSS enabled" }, + "troubleshooting_mode": { + "button": "Troubleshooting Mode", + "dialog_title": "Enable Troubleshooting Mode", + "dialog_description": "This will temporarily disable all customizations to help diagnose issues:", + "dialog_item_plugins": "All plugins", + "dialog_item_css": "Custom CSS", + "dialog_item_js": "Custom JavaScript", + "dialog_item_locales": "Custom locales", + "dialog_log_level": "Log level will be set to Debug for detailed diagnostics.", + "dialog_reload_note": "The page will reload automatically.", + "enable": "Enable & Reload", + "overlay_message": "Troubleshooting Mode is active - all customizations are disabled", + "exit": "Exit" + }, "custom_javascript": { "description": "Page must be reloaded for changes to take effect. There is no guarantee of compatibility between custom Javascript and future releases of Stash.", "heading": "Custom Javascript", diff --git a/ui/v2.5/src/plugins.tsx b/ui/v2.5/src/plugins.tsx index 41577a92c..00ffb9ca4 100644 --- a/ui/v2.5/src/plugins.tsx +++ b/ui/v2.5/src/plugins.tsx @@ -59,7 +59,8 @@ function sortPlugins(plugins: PluginList) { // load all plugins and their dependencies // returns true when all plugins are loaded, regardess of success or failure -function useLoadPlugins() { +// if disableCustomizations is true, skip loading plugins entirely +function useLoadPlugins(disableCustomizations?: boolean) { const { data: plugins, loading: pluginsLoading, @@ -74,6 +75,12 @@ function useLoadPlugins() { }, [plugins?.plugins, pluginsLoading, pluginsError]); const pluginJavascripts = useMemoOnce(() => { + // Skip loading plugin JS if customizations are disabled. + // Note: We check inside useMemoOnce rather than early-returning from useLoadPlugins + // to comply with React's rules of hooks - hooks must be called unconditionally. + if (disableCustomizations) { + return [[], true]; + } return [ uniq( sortedPlugins @@ -83,9 +90,12 @@ function useLoadPlugins() { ), !!sortedPlugins && !pluginsLoading && !pluginsError, ]; - }, [sortedPlugins, pluginsLoading, pluginsError]); + }, [sortedPlugins, pluginsLoading, pluginsError, disableCustomizations]); const pluginCSS = useMemoOnce(() => { + if (disableCustomizations) { + return [[], true]; + } return [ uniq( sortedPlugins @@ -95,7 +105,7 @@ function useLoadPlugins() { ), !!sortedPlugins && !pluginsLoading && !pluginsError, ]; - }, [sortedPlugins, pluginsLoading, pluginsError]); + }, [sortedPlugins, pluginsLoading, pluginsError, disableCustomizations]); const pluginJavascriptLoaded = useScript( pluginJavascripts ?? [], @@ -109,11 +119,15 @@ function useLoadPlugins() { }; } -export const PluginsLoader: React.FC> = ({ - children, -}) => { +interface IPluginsLoaderProps { + disableCustomizations?: boolean; +} + +export const PluginsLoader: React.FC< + React.PropsWithChildren +> = ({ disableCustomizations, children }) => { const Toast = useToast(); - const { loading: loaded, error } = useLoadPlugins(); + const { loading: loaded, error } = useLoadPlugins(disableCustomizations); useEffect(() => { if (error) {