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...'))
+1 -1
View File
@@ -5,6 +5,6 @@ export function useBackendHealth(): BackendHealthState {
status: 'healthy',
message: null,
error: null,
isHealthy: true,
isOnline: true,
};
}
@@ -0,0 +1,28 @@
import { useState, useEffect, useRef } from 'react';
import apiClient from '@app/services/apiClient';
import type { GroupEnabledResult } from '@app/types/groupEnabled';
export type { GroupEnabledResult };
/**
* Checks whether a named feature group is enabled on the backend.
* Returns { enabled: null } while loading, then true/false with an optional reason.
*/
export function useGroupEnabled(group: string): GroupEnabledResult {
const [result, setResult] = useState<GroupEnabledResult>({ enabled: null, unavailableReason: null });
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => { isMountedRef.current = false; };
}, []);
useEffect(() => {
apiClient
.get<boolean>(`/api/v1/config/group-enabled?group=${encodeURIComponent(group)}`)
.then(res => { if (isMountedRef.current) setResult({ enabled: res.data, unavailableReason: null }); })
.catch(() => { if (isMountedRef.current) setResult({ enabled: false, unavailableReason: null }); });
}, [group]);
return result;
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Stub implementation for web builds.
* In desktop builds this is shadowed by desktop/hooks/useSaaSMode.ts which
* returns whether the app is currently in SaaS connection mode (vs self-hosted).
*/
export function useSaaSMode(): boolean {
return false;
}
@@ -0,0 +1,11 @@
/**
* Stub implementation for web / SaaS builds.
* In self-hosted desktop mode this is shadowed by the desktop override which
* returns the set of tool IDs that are unavailable when the self-hosted server
* is offline (i.e. tools whose endpoints the local bundled backend does not support).
*/
export function useSelfHostedToolAvailability(
_tools: Array<{ id: string; endpoints?: string[] }>
): Set<string> {
return new Set<string>();
}
+46 -12
View File
@@ -3,11 +3,13 @@ import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { usePreferences } from '@app/contexts/PreferencesContext';
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
import { useSelfHostedToolAvailability } from "@app/hooks/useSelfHostedToolAvailability";
import { useSaaSMode } from "@app/hooks/useSaaSMode";
import { FileId } from '@app/types/file';
import { ToolId } from "@app/types/toolId";
import type { EndpointDisableReason } from '@app/types/endpointAvailability';
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown';
export type ToolDisableCause = 'disabledByAdmin' | 'missingDependency' | 'unknown' | 'selfHostedOffline';
export interface ToolAvailabilityInfo {
available: boolean;
@@ -28,30 +30,59 @@ interface ToolManagementResult {
export const useToolManagement = (): ToolManagementResult => {
const [toolSelectedFileIds, setToolSelectedFileIds] = useState<FileId[]>([]);
// Build endpoints list from registry entries with fallback to legacy mapping
const { allTools } = useToolRegistry();
const baseRegistry = allTools;
const { preferences } = usePreferences();
const isSaaSMode = useSaaSMode();
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
const toolEndpointList = useMemo(
() => (Object.keys(baseRegistry) as ToolId[])
// Exclude coming-soon tools (no component and no link) — they are already
// unavailable regardless of server state and should not appear in the
// self-hosted offline banner.
.filter(id => {
const tool = baseRegistry[id];
return !!(tool?.component ?? tool?.link);
})
.map(id => ({
id,
endpoints: baseRegistry[id]?.endpoints ?? [],
})),
[baseRegistry]
);
const selfHostedOfflineIds = useSelfHostedToolAvailability(toolEndpointList);
const isToolAvailable = useCallback((toolKey: string): boolean => {
// Keep tools enabled during loading (optimistic UX)
// Self-hosted offline check must come before the loading gate:
// in self-hosted offline mode endpointsLoading stays true indefinitely
// (health check never resolves), so checking it first would wrongly
// keep all tools enabled.
if (selfHostedOfflineIds.has(toolKey)) return false;
// Keep tools enabled while endpoint status is loading (optimistic UX)
if (endpointsLoading) return true;
const tool = baseRegistry[toolKey as ToolId];
const endpoints = tool?.endpoints || [];
// Tools without endpoints are always available
if (endpoints.length === 0) return true;
// Check if at least one endpoint is enabled
// If endpoint is not in status map, assume enabled (optimistic fallback)
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
}, [endpointsLoading, endpointStatus, baseRegistry]);
const hasLocalSupport = endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
// In SaaS mode tools without local support can route to the cloud backend
if (!hasLocalSupport && isSaaSMode) return true;
return hasLocalSupport;
}, [endpointsLoading, endpointStatus, baseRegistry, isSaaSMode, selfHostedOfflineIds]);
const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => {
if (selfHostedOfflineIds.has(toolKey)) {
return 'selfHostedOffline';
}
const tool = baseRegistry[toolKey];
if (!tool) {
return 'unknown';
@@ -71,10 +102,13 @@ export const useToolManagement = (): ToolManagementResult => {
return 'unknown';
}
return 'unknown';
}, [baseRegistry, endpointDetails, endpointStatus]);
}, [baseRegistry, endpointDetails, endpointStatus, selfHostedOfflineIds]);
const toolAvailability = useMemo(() => {
if (endpointsLoading) {
// Skip computation during loading UNLESS some tools are already known offline.
// In self-hosted offline mode endpointsLoading never clears, so we must still
// compute the map to surface the selfHostedOfflineIds set.
if (endpointsLoading && selfHostedOfflineIds.size === 0) {
return {};
}
const availability: ToolAvailabilityMap = {};
@@ -85,7 +119,7 @@ export const useToolManagement = (): ToolManagementResult => {
: { available: false, reason: deriveToolDisableReason(toolKey) };
});
return availability;
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]);
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable, selfHostedOfflineIds]);
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
const availableToolRegistry: Partial<ToolRegistry> = {};
@@ -115,7 +149,7 @@ export const useToolManagement = (): ToolManagementResult => {
}, [toolRegistry]);
return {
selectedTool: getSelectedTool(null), // This will be unused, kept for compatibility
selectedTool: getSelectedTool(null),
toolSelectedFileIds,
toolRegistry,
setToolSelectedFileIds,
+1 -1
View File
@@ -4,5 +4,5 @@ export interface BackendHealthState {
status: BackendStatus;
message?: string | null;
error: string | null;
isHealthy: boolean;
isOnline: boolean;
}
@@ -1,4 +1,4 @@
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | null;
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | 'NOT_SUPPORTED_LOCALLY' | null;
export interface EndpointAvailabilityDetails {
enabled: boolean;
+5
View File
@@ -0,0 +1,5 @@
export interface GroupEnabledResult {
enabled: boolean | null;
/** Human-readable reason shown when the feature is unavailable. Null while loading or when enabled. */
unavailableReason: string | null;
}