Desktop: Fallback to local backend if self-hosted server is offline (#5880)

* Adds a fallback mechanism so the desktop app routes tool operations to
the local bundled backend when the user's self-hosted Stirling-PDF
server goes offline, and disables tools in the UI that aren't supported
locally.

* `selfHostedServerMonitor.ts` independently polls the self-hosted
server every 15s and exposes which tool endpoints are unavailable when
it goes offline
* `operationRouter.ts` intercepts operations destined for the
self-hosted server and reroutes them to the local bundled backend when
the monitor reports it offline
* `useSelfHostedToolAvailability.ts` feeds the offline tool set into
useToolManagement, disabling affected tools in the UI with a
selfHostedOffline reason and banner warning

- `SelfHostedOfflineBanner `is a dismissable (session-only) gray bar
shown at the top of the UI when in self-hosted mode and the server goes
offline. It shows:
This commit is contained in:
ConnorYoh
2026-03-10 10:04:56 +00:00
committed by GitHub
parent 6d9fc59bc5
commit 8bc37bf5ae
31 changed files with 1003 additions and 343 deletions
@@ -1,10 +1,9 @@
import { useState, useEffect } from "react";
import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl } from "@mantine/core";
import { Stack, Text, NumberInput, Select, Divider, Checkbox, Slider, SegmentedControl, Tooltip, Box } from "@mantine/core";
import SliderWithInput from '@app/components/shared/sliderWithInput/SliderWithInput';
import { useTranslation } from "react-i18next";
import { CompressParameters } from "@app/hooks/tools/compress/useCompressParameters";
import ButtonSelector from "@app/components/shared/ButtonSelector";
import apiClient from "@app/services/apiClient";
import { useGroupEnabled } from "@app/hooks/useGroupEnabled";
import { Z_INDEX_AUTOMATE_DROPDOWN } from "@app/styles/zIndex";
interface CompressSettingsProps {
@@ -15,20 +14,7 @@ interface CompressSettingsProps {
const CompressSettings = ({ parameters, onParameterChange, disabled = false }: CompressSettingsProps) => {
const { t } = useTranslation();
const [imageMagickAvailable, setImageMagickAvailable] = useState<boolean | null>(null);
useEffect(() => {
const checkImageMagick = async () => {
try {
const response = await apiClient.get<boolean>('/api/v1/config/group-enabled?group=ImageMagick');
setImageMagickAvailable(response.data);
} catch (error) {
console.error('Failed to check ImageMagick availability:', error);
setImageMagickAvailable(true); // Optimistic fallback
}
};
checkImageMagick();
}, []);
const { enabled: imageMagickAvailable, unavailableReason: imageMagickReason } = useGroupEnabled('ImageMagick');
return (
<Stack gap="md">
@@ -122,17 +108,27 @@ const CompressSettings = ({ parameters, onParameterChange, disabled = false }: C
/>
</Stack>
<Checkbox
checked={parameters.lineArt}
onChange={(event) => onParameterChange('lineArt', event.currentTarget.checked)}
disabled={disabled || imageMagickAvailable === false}
label={t("compress.lineArt.label", "Convert images to line art (bilevel)")}
description={
imageMagickAvailable === false
? t("compress.lineArt.unavailable", "ImageMagick is not installed or enabled on this server")
: t("compress.lineArt.description", "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction.")
}
/>
<Tooltip
label={imageMagickReason ?? t("compress.lineArt.unavailable", "ImageMagick is not installed or enabled on this server")}
disabled={imageMagickAvailable !== false}
multiline
maw={280}
>
<Box style={{ cursor: imageMagickAvailable === false ? 'not-allowed' : undefined }}>
<Checkbox
checked={parameters.lineArt}
onChange={(event) => onParameterChange('lineArt', event.currentTarget.checked)}
disabled={disabled || imageMagickAvailable === false}
label={t("compress.lineArt.label", "Convert images to line art (bilevel)")}
description={
imageMagickAvailable !== false
? t("compress.lineArt.description", "Uses ImageMagick to reduce pages to high-contrast black and white for maximum size reduction.")
: undefined
}
style={{ pointerEvents: imageMagickAvailable === false ? 'none' : undefined }}
/>
</Box>
</Tooltip>
{parameters.lineArt && (
<Stack gap="xs" style={{ opacity: (disabled || imageMagickAvailable === false) ? 0.6 : 1 }}>
<Text size="sm" fw={600}>{t('compress.lineArt.detailLevel', 'Detail level')}</Text>
@@ -24,7 +24,7 @@ export const getIconStyle = (): Record<string, string> => {
return {};
};
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | 'requiresPremium' | null;
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | 'requiresPremium' | 'selfHostedOffline' | null;
export const getToolDisabledReason = (
id: string,
@@ -43,6 +43,9 @@ export const getToolDisabledReason = (
const availabilityInfo = toolAvailability?.[id as ToolId];
if (availabilityInfo && availabilityInfo.available === false) {
if (availabilityInfo.reason === 'selfHostedOffline') {
return 'selfHostedOffline';
}
if (availabilityInfo.reason === 'missingDependency') {
return 'missingDependency';
}
@@ -64,6 +67,12 @@ export const getDisabledLabel = (
fallback: 'Premium feature:'
};
}
if (disabledReason === 'selfHostedOffline') {
return {
key: 'toolPanel.fullscreen.selfHostedOffline',
fallback: 'Requires your Stirling-PDF server (currently offline):'
};
}
if (disabledReason === 'missingDependency') {
return {
key: 'toolPanel.fullscreen.unavailableDependency',
@@ -36,8 +36,8 @@ const OperationButton = ({
'data-tour': dataTour
}: OperationButtonProps) => {
const { t } = useTranslation();
const { isHealthy, message: backendMessage } = useBackendHealth();
const blockedByBackend = !isHealthy;
const { isOnline, message: backendMessage } = useBackendHealth();
const blockedByBackend = !isOnline;
const combinedDisabled = disabled || blockedByBackend;
const tooltipLabel = blockedByBackend
? (backendMessage ?? t('backendHealth.checking', 'Checking backend status...'))