mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-15 19:10: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:
@@ -9,6 +9,7 @@ import { useBackendInitializer } from '@app/hooks/useBackendInitializer';
|
||||
import { DESKTOP_DEFAULT_APP_CONFIG } from '@app/config/defaultAppConfig';
|
||||
import { connectionModeService } from '@app/services/connectionModeService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { selfHostedServerMonitor } from '@app/services/selfHostedServerMonitor';
|
||||
import { authService } from '@app/services/authService';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
@@ -64,7 +65,17 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (setupComplete && !isFirstLaunch && connectionMode === 'selfhosted') {
|
||||
void tauriBackendService.initializeExternalBackend();
|
||||
// Also start the self-hosted server monitor so the operation router and UI
|
||||
// can detect when the remote server goes offline and fall back to local backend.
|
||||
connectionModeService.getServerConfig().then(cfg => {
|
||||
if (cfg?.url) {
|
||||
selfHostedServerMonitor.start(cfg.url);
|
||||
}
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
selfHostedServerMonitor.stop();
|
||||
};
|
||||
}, [setupComplete, isFirstLaunch, connectionMode]);
|
||||
|
||||
// Initialize monitoring for bundled backend (already started in Rust)
|
||||
@@ -72,38 +83,31 @@ export function AppProviders({ children }: { children: ReactNode }) {
|
||||
const shouldMonitorBackend = setupComplete && !isFirstLaunch && connectionMode === 'saas';
|
||||
useBackendInitializer(shouldMonitorBackend);
|
||||
|
||||
// Preload endpoint availability after backend is healthy
|
||||
// Preload endpoint availability for the local bundled backend.
|
||||
// SaaS mode: triggers when the bundled backend reports healthy.
|
||||
// Self-hosted mode: triggers when the local bundled backend port is discovered
|
||||
// (so useSelfHostedToolAvailability can use the cache instead of making
|
||||
// individual requests per-tool when the remote server goes offline).
|
||||
const shouldPreloadLocalEndpoints =
|
||||
(setupComplete && !isFirstLaunch && connectionMode === 'saas') ||
|
||||
(setupComplete && !isFirstLaunch && connectionMode === 'selfhosted');
|
||||
useEffect(() => {
|
||||
if (!shouldMonitorBackend) {
|
||||
return; // Only preload in SaaS mode with bundled backend
|
||||
}
|
||||
if (!shouldPreloadLocalEndpoints) return;
|
||||
|
||||
const preloadEndpoints = async () => {
|
||||
const backendHealthy = tauriBackendService.isBackendHealthy();
|
||||
if (backendHealthy) {
|
||||
console.debug('[AppProviders] Preloading common tool endpoints');
|
||||
await endpointAvailabilityService.preloadEndpoints(
|
||||
COMMON_TOOL_ENDPOINTS,
|
||||
tauriBackendService.getBackendUrl()
|
||||
);
|
||||
console.debug('[AppProviders] Endpoint preloading complete');
|
||||
}
|
||||
const tryPreload = () => {
|
||||
const backendUrl = tauriBackendService.getBackendUrl();
|
||||
if (!backendUrl) return;
|
||||
// tauriBackendService.isOnline now always reflects the local backend.
|
||||
// Wait for it to be healthy before preloading in both modes.
|
||||
if (!tauriBackendService.isOnline) return;
|
||||
console.debug('[AppProviders] Preloading common tool endpoints for local backend');
|
||||
void endpointAvailabilityService.preloadEndpoints(COMMON_TOOL_ENDPOINTS, backendUrl);
|
||||
};
|
||||
|
||||
// Subscribe to backend status changes
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus((status) => {
|
||||
if (status === 'healthy') {
|
||||
preloadEndpoints();
|
||||
}
|
||||
});
|
||||
|
||||
// Also check immediately in case backend is already healthy
|
||||
if (tauriBackendService.isBackendHealthy()) {
|
||||
preloadEndpoints();
|
||||
}
|
||||
|
||||
const unsubscribe = tauriBackendService.subscribeToStatus(() => tryPreload());
|
||||
tryPreload();
|
||||
return unsubscribe;
|
||||
}, [shouldMonitorBackend]);
|
||||
}, [shouldPreloadLocalEndpoints, connectionMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!authChecked) {
|
||||
|
||||
@@ -13,29 +13,29 @@ export const BackendHealthIndicator: React.FC<BackendHealthIndicatorProps> = ({
|
||||
const { t } = useTranslation();
|
||||
const theme = useMantineTheme();
|
||||
const colorScheme = useComputedColorScheme('light');
|
||||
const { status, isHealthy, checkHealth } = useBackendHealth();
|
||||
const { status, isOnline, checkHealth } = useBackendHealth();
|
||||
|
||||
const label = useMemo(() => {
|
||||
if (status === 'starting') {
|
||||
return t('backendHealth.checking', 'Checking backend status...');
|
||||
}
|
||||
|
||||
if (isHealthy) {
|
||||
if (isOnline) {
|
||||
return t('backendHealth.online', 'Backend Online');
|
||||
}
|
||||
|
||||
return t('backendHealth.offline', 'Backend Offline');
|
||||
}, [status, isHealthy, t]);
|
||||
}, [status, isOnline, t]);
|
||||
|
||||
const dotColor = useMemo(() => {
|
||||
if (status === 'starting') {
|
||||
return theme.colors.yellow?.[5] ?? '#fcc419';
|
||||
}
|
||||
if (isHealthy) {
|
||||
if (isOnline) {
|
||||
return theme.colors.green?.[5] ?? '#37b24d';
|
||||
}
|
||||
return theme.colors.red?.[6] ?? '#e03131';
|
||||
}, [status, isHealthy, theme.colors.green, theme.colors.red, theme.colors.yellow]);
|
||||
}, [status, isOnline, theme.colors.green, theme.colors.red, theme.colors.yellow]);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLSpanElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useBanner } from '@app/contexts/BannerContext';
|
||||
import { DefaultAppBanner } from '@app/components/shared/DefaultAppBanner';
|
||||
import UpgradeBanner from '@app/components/shared/UpgradeBanner';
|
||||
import { TeamInvitationBanner } from '@app/components/shared/TeamInvitationBanner';
|
||||
import { SelfHostedOfflineBanner } from '@app/components/shared/SelfHostedOfflineBanner';
|
||||
|
||||
export function DesktopBannerInitializer() {
|
||||
const { setBanner } = useBanner();
|
||||
@@ -10,6 +11,7 @@ export function DesktopBannerInitializer() {
|
||||
useEffect(() => {
|
||||
setBanner(
|
||||
<>
|
||||
<SelfHostedOfflineBanner />
|
||||
<TeamInvitationBanner />
|
||||
<UpgradeBanner />
|
||||
<DefaultAppBanner />
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Paper, Group, Text, ActionIcon, UnstyledButton, Popover, List, ScrollArea } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import LocalIcon from '@app/components/shared/LocalIcon';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useConversionCloudStatus } from '@app/hooks/useConversionCloudStatus';
|
||||
import { selfHostedServerMonitor, type SelfHostedServerState } from '@app/services/selfHostedServerMonitor';
|
||||
import { connectionModeService, type ConnectionMode } from '@app/services/connectionModeService';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { endpointAvailabilityService } from '@app/services/endpointAvailabilityService';
|
||||
import { EXTENSION_TO_ENDPOINT } from '@app/constants/convertConstants';
|
||||
import { ENDPOINTS as SPLIT_ENDPOINTS } from '@app/constants/splitConstants';
|
||||
import type { ToolId } from '@app/types/toolId';
|
||||
|
||||
const BANNER_BG = 'var(--mantine-color-gray-1)';
|
||||
const BANNER_BORDER = 'var(--mantine-color-gray-3)';
|
||||
const BANNER_TEXT = 'var(--mantine-color-gray-7)';
|
||||
const BANNER_ICON = 'var(--mantine-color-gray-5)';
|
||||
const BANNER_LINK = 'var(--mantine-color-gray-6)';
|
||||
|
||||
/** Maps split endpoint → [i18n key, English fallback] for the method name */
|
||||
const SPLIT_ENDPOINT_I18N: Record<string, [string, string]> = {
|
||||
'split-pages': ['split.methods.byPages.name', 'Pages'],
|
||||
'split-pdf-by-sections': ['split.methods.bySections.name', 'Sections'],
|
||||
'split-by-size-or-count': ['split.methods.bySize.name', 'File Size'],
|
||||
'split-pdf-by-chapters': ['split.methods.byChapters.name', 'Chapters'],
|
||||
'auto-split-pdf': ['split.methods.byPageDivider.name', 'Page Divider'],
|
||||
'split-for-poster-print': ['split.methods.byPoster.name', 'Printable Chunks'],
|
||||
};
|
||||
|
||||
/** Conversion endpoint keys that have translations under selfHosted.offline.conversionTypes */
|
||||
const KNOWN_CONVERSION_ENDPOINTS = new Set([
|
||||
'file-to-pdf', 'pdf-to-img', 'img-to-pdf', 'svg-to-pdf', 'cbz-to-pdf',
|
||||
'pdf-to-cbz', 'pdf-to-word', 'pdf-to-presentation', 'pdf-to-text', 'pdf-to-csv',
|
||||
'pdf-to-xlsx', 'pdf-to-markdown', 'pdf-to-html', 'pdf-to-xml', 'pdf-to-pdfa',
|
||||
'html-to-pdf', 'markdown-to-pdf', 'eml-to-pdf', 'cbr-to-pdf', 'pdf-to-cbr',
|
||||
'ebook-to-pdf', 'pdf-to-epub',
|
||||
]);
|
||||
|
||||
|
||||
/**
|
||||
* Desktop-only banner shown when the user is in self-hosted mode and the
|
||||
* configured Stirling-PDF server is unreachable.
|
||||
*
|
||||
* - Warns the user their server is offline
|
||||
* - Explains whether local fallback is active
|
||||
* - Shows an expandable list of tools that are unavailable locally
|
||||
* - Session-dismissable (reappears on next launch if server still offline)
|
||||
*/
|
||||
export function SelfHostedOfflineBanner() {
|
||||
const { t } = useTranslation();
|
||||
const [connectionMode, setConnectionMode] = useState<ConnectionMode | null>(null);
|
||||
const [serverState, setServerState] = useState<SelfHostedServerState>(
|
||||
() => selfHostedServerMonitor.getSnapshot()
|
||||
);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [localBackendReady, setLocalBackendReady] = useState(
|
||||
() => !!tauriBackendService.getBackendUrl()
|
||||
);
|
||||
|
||||
// Load connection mode once on mount
|
||||
useEffect(() => {
|
||||
void connectionModeService.getCurrentMode().then(setConnectionMode);
|
||||
}, []);
|
||||
|
||||
// Subscribe to self-hosted server status changes
|
||||
useEffect(() => {
|
||||
const unsub = selfHostedServerMonitor.subscribe(state => {
|
||||
setServerState(state);
|
||||
// Auto-collapse tool list when server comes back online
|
||||
if (state.isOnline) setExpanded(false);
|
||||
});
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// React to local backend port being discovered
|
||||
useEffect(() => {
|
||||
return tauriBackendService.subscribeToStatus(() => {
|
||||
setLocalBackendReady(!!tauriBackendService.getBackendUrl());
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Re-use the toolAvailability already computed by useToolManagement —
|
||||
// tools with reason 'selfHostedOffline' are the ones unavailable locally.
|
||||
const { toolAvailability, toolRegistry } = useToolWorkflow();
|
||||
|
||||
// Re-use conversion availability already computed by useConversionCloudStatus.
|
||||
const { availability: conversionAvailability } = useConversionCloudStatus();
|
||||
|
||||
const [splitAvailability, setSplitAvailability] = useState<Record<string, boolean>>({});
|
||||
useEffect(() => {
|
||||
if (serverState.status !== 'offline') {
|
||||
setSplitAvailability({});
|
||||
return;
|
||||
}
|
||||
const localUrl = tauriBackendService.getBackendUrl();
|
||||
if (!localUrl) {
|
||||
setSplitAvailability({});
|
||||
return;
|
||||
}
|
||||
const uniqueEndpoints = [...new Set(Object.values(SPLIT_ENDPOINTS))] as string[];
|
||||
void Promise.all(
|
||||
uniqueEndpoints.map(async ep => ({
|
||||
ep,
|
||||
supported: await endpointAvailabilityService.isEndpointSupportedLocally(ep, localUrl).catch(() => false),
|
||||
}))
|
||||
).then(results => {
|
||||
const map: Record<string, boolean> = {};
|
||||
for (const { ep, supported } of results) map[ep] = supported;
|
||||
setSplitAvailability(map);
|
||||
});
|
||||
}, [serverState.status]);
|
||||
|
||||
const allUnavailableNames = useMemo(() => {
|
||||
// Top-level tools unavailable in self-hosted offline mode
|
||||
const toolNames = (Object.keys(toolAvailability) as ToolId[])
|
||||
.filter(id => toolAvailability[id]?.available === false && toolAvailability[id]?.reason === 'selfHostedOffline')
|
||||
.map(id => toolRegistry[id]?.name ?? id)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
// Use translated tool names from the registry as prefixes
|
||||
const convertPrefix = toolRegistry['convert' as ToolId]?.name ?? 'Convert';
|
||||
const splitPrefix = toolRegistry['split' as ToolId]?.name ?? 'Split';
|
||||
|
||||
// Conversion types unavailable locally — deduplicated by endpoint
|
||||
const unavailableEndpoints = new Set<string>();
|
||||
for (const [key, available] of Object.entries(conversionAvailability)) {
|
||||
if (!available) {
|
||||
const dashIdx = key.indexOf('-');
|
||||
const fromExt = key.slice(0, dashIdx);
|
||||
const toExt = key.slice(dashIdx + 1);
|
||||
const endpoint = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
|
||||
if (endpoint) unavailableEndpoints.add(endpoint);
|
||||
}
|
||||
}
|
||||
const conversionNames = [...unavailableEndpoints]
|
||||
.map(ep => {
|
||||
const suffix = KNOWN_CONVERSION_ENDPOINTS.has(ep)
|
||||
? t(`selfHosted.offline.conversionTypes.${ep}`, ep)
|
||||
: ep;
|
||||
return `${convertPrefix}: ${suffix}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// Split methods unavailable locally
|
||||
const unavailableSplitNames = Object.entries(splitAvailability)
|
||||
.filter(([, available]) => !available)
|
||||
.map(([ep]) => {
|
||||
const i18n = SPLIT_ENDPOINT_I18N[ep];
|
||||
const suffix = i18n ? t(i18n[0], i18n[1]) : ep;
|
||||
return `${splitPrefix}: ${suffix}`;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return [...toolNames, ...conversionNames, ...unavailableSplitNames].sort();
|
||||
}, [toolAvailability, toolRegistry, conversionAvailability, splitAvailability, t]);
|
||||
|
||||
// Only show when in self-hosted mode, server confirmed offline, and not dismissed
|
||||
const show =
|
||||
!dismissed &&
|
||||
connectionMode === 'selfhosted' &&
|
||||
serverState.status === 'offline';
|
||||
|
||||
if (!show) return null;
|
||||
|
||||
const messageText = localBackendReady
|
||||
? t('selfHosted.offline.messageWithFallback', 'Some tools require a server connection.')
|
||||
: t('selfHosted.offline.messageNoFallback', 'Tools are unavailable until your server comes back online.');
|
||||
|
||||
return (
|
||||
<Paper
|
||||
radius={0}
|
||||
style={{
|
||||
background: BANNER_BG,
|
||||
borderBottom: `1px solid ${BANNER_BORDER}`,
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" align="center" wrap="nowrap" justify="space-between" px="sm" py={6}>
|
||||
<Group gap="xs" align="center" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
|
||||
<LocalIcon
|
||||
icon="warning-rounded"
|
||||
width="1rem"
|
||||
height="1rem"
|
||||
style={{ color: BANNER_ICON, flexShrink: 0 }}
|
||||
/>
|
||||
<Text size="xs" fw={600} style={{ color: BANNER_TEXT, flexShrink: 0 }}>
|
||||
{t('selfHosted.offline.title', 'Server unreachable')}
|
||||
</Text>
|
||||
<Text size="xs" style={{ color: BANNER_TEXT, opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{messageText}
|
||||
</Text>
|
||||
</Group>
|
||||
{allUnavailableNames.length > 0 && (
|
||||
<Popover
|
||||
opened={expanded}
|
||||
onClose={() => setExpanded(false)}
|
||||
position="bottom-end"
|
||||
withinPortal
|
||||
shadow="md"
|
||||
width={260}
|
||||
>
|
||||
<Popover.Target>
|
||||
<UnstyledButton
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
style={{ color: BANNER_LINK, fontSize: 'var(--mantine-font-size-xs)', fontWeight: 500, flexShrink: 0, whiteSpace: 'nowrap' }}
|
||||
>
|
||||
{expanded
|
||||
? t('selfHosted.offline.hideTools', 'Hide unavailable tools ▴')
|
||||
: t('selfHosted.offline.showTools', 'View unavailable tools ▾')}
|
||||
</UnstyledButton>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown p="xs">
|
||||
<ScrollArea.Autosize mah={300}>
|
||||
<List size="xs" spacing={2}>
|
||||
{allUnavailableNames.map(name => (
|
||||
<List.Item key={name}>{name}</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</ScrollArea.Autosize>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
)}
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setDismissed(true)}
|
||||
aria-label={t('close', 'Close')}
|
||||
style={{ color: BANNER_TEXT }}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="0.8rem" height="0.8rem" />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user