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>
This commit is contained in:
Gykes 2026-01-26 19:26:26 -08:00 committed by GitHub
parent 5b3785f164
commit bef4e3fbd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 309 additions and 11 deletions

View file

@ -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

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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)
}

View file

@ -92,6 +92,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
javascriptEnabled
customLocales
customLocalesEnabled
disableCustomizations
language
imageLightbox {
slideshowDelay

View file

@ -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}
>
<ToastProvider>
<PluginsLoader>
<PluginsLoader
disableCustomizations={
config.data?.configuration?.interface?.disableCustomizations ??
false
}
>
<AppContainer>
<ConfigurationProvider configuration={config.data!.configuration}>
{maybeRenderReleaseNotes()}
<ConnectionMonitor />
<TroubleshootingModeOverlay />
<Suspense fallback={<LoadingIndicator />}>
<LightboxProvider>
<ManualProvider>

View file

@ -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<IManualProps> = ({
title: "Keyboard Shortcuts",
content: KeyboardShortcuts,
},
{
key: "TroubleshootingMode.md",
title: "Troubleshooting Mode",
content: TroubleshootingMode,
},
{
key: "Contributing.md",
title: "Contributing",

View file

@ -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 }) => {
/>
</div>
</Nav.Item>
{!troubleshootingModeActive && <TroubleshootingModeButton />}
<hr className="d-sm-none" />
</Nav>
</Col>

View file

@ -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;
}
}

View file

@ -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 (
<>
<div className="troubleshooting-mode-button">
<Button variant="primary" size="sm" onClick={() => setShowDialog(true)}>
<FormattedMessage id="config.ui.troubleshooting_mode.button" />
</Button>
</div>
<ModalComponent
show={showDialog}
onHide={() => 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}
>
<p>
<FormattedMessage id="config.ui.troubleshooting_mode.dialog_description" />
</p>
<ul>
{DIALOG_ITEMS.map((id) => (
<li key={id}>
<FormattedMessage id={id} />
</li>
))}
</ul>
<p>
<FormattedMessage id="config.ui.troubleshooting_mode.dialog_log_level" />
</p>
<p className="text-muted">
<FormattedMessage id="config.ui.troubleshooting_mode.dialog_reload_note" />
</p>
</ModalComponent>
</>
);
};

View file

@ -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 (
<div className="troubleshooting-mode-overlay">
<div className="troubleshooting-mode-alert">
<span>
<Icon icon={faBug} className="mr-2" />
<FormattedMessage id="config.ui.troubleshooting_mode.overlay_message" />
</span>
<Button variant="link" onClick={disable} disabled={isLoading}>
<FormattedMessage id="config.ui.troubleshooting_mode.exit" />
</Button>
</div>
</div>
);
};

View file

@ -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 };
}

View file

@ -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.

View file

@ -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;
}
}

View file

@ -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",

View file

@ -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<React.PropsWithChildren<{}>> = ({
children,
}) => {
interface IPluginsLoaderProps {
disableCustomizations?: boolean;
}
export const PluginsLoader: React.FC<
React.PropsWithChildren<IPluginsLoaderProps>
> = ({ disableCustomizations, children }) => {
const Toast = useToast();
const { loading: loaded, error } = useLoadPlugins();
const { loading: loaded, error } = useLoadPlugins(disableCustomizations);
useEffect(() => {
if (error) {