mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 11:00:47 +02:00
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:
@@ -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...'))
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user