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