mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Differentiate unavailable tools by reason (#4916)
## Summary - track endpoint disable reasons server-side and expose them through a new `/api/v1/config/endpoints-availability` API that the frontend can consume - refresh the web UI tool management logic to cache endpoint details, compute per-tool availability metadata, and show reason-specific messaging (admin disabled vs missing dependency) when a tool cannot be launched - add the missing en-GB translations for the new unavailability labels so the UI copy reflects the new distinction <img width="1156" height="152" alt="image" src="https://github.com/user-attachments/assets/b54eda37-fe5c-42f9-bd5f-9ee00398d1ae" /> <img width="930" height="168" alt="image" src="https://github.com/user-attachments/assets/47c07ffa-adb7-4ce3-910c-b6ff73f6f993" /> ## Testing - `npm run typecheck:core` *(fails: frontend/src/core/components/shared/LocalIcon.tsx expects ../../../assets/material-symbols-icons.json, which is not present in this environment)* ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_6919af7a493c8328bb5ac3d07e65452b)
This commit is contained in:
+1
-1
@@ -14,6 +14,6 @@ export type RemoveCertificateSignParametersHook = BaseParametersHook<RemoveCerti
|
||||
export const useRemoveCertificateSignParameters = (): RemoveCertificateSignParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'remove-certificate-sign',
|
||||
endpointName: 'remove-cert-sign',
|
||||
});
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
||||
|
||||
// Track globally fetched endpoint sets to prevent duplicate fetches across components
|
||||
const globalFetchedSets = new Set<string>();
|
||||
const globalEndpointCache: Record<string, boolean> = {};
|
||||
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
|
||||
|
||||
/**
|
||||
* Hook to check if a specific endpoint is enabled
|
||||
@@ -59,11 +60,13 @@ export function useEndpointEnabled(endpoint: string): {
|
||||
*/
|
||||
export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
endpointStatus: Record<string, boolean>;
|
||||
endpointDetails: Record<string, EndpointAvailabilityDetails>;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refetch: () => Promise<void>;
|
||||
} {
|
||||
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
||||
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -73,31 +76,25 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
// Skip if we already fetched these exact endpoints globally
|
||||
if (!force && globalFetchedSets.has(endpointsKey)) {
|
||||
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
|
||||
const cachedStatus = endpoints.reduce((acc, endpoint) => {
|
||||
if (endpoint in globalEndpointCache) {
|
||||
acc[endpoint] = globalEndpointCache[endpoint];
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(cachedStatus);
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if JWT exists - if not, optimistically enable all endpoints
|
||||
const hasJwt = !!localStorage.getItem('stirling_jwt');
|
||||
if (!hasJwt) {
|
||||
console.debug('[useEndpointConfig] No JWT found - optimistically enabling all endpoints');
|
||||
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(optimisticStatus);
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -110,11 +107,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
|
||||
if (newEndpoints.length === 0) {
|
||||
console.debug('[useEndpointConfig] All endpoints already in global cache');
|
||||
const cachedStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = globalEndpointCache[endpoint];
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(cachedStatus);
|
||||
const cached = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(cached.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -123,30 +128,51 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
// Use batch API for efficiency - only fetch new endpoints
|
||||
const endpointsParam = newEndpoints.join(',');
|
||||
|
||||
const response = await apiClient.get<Record<string, boolean>>(`/api/v1/config/endpoints-enabled?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
|
||||
const statusMap = response.data;
|
||||
|
||||
// Update global cache with new results
|
||||
Object.assign(globalEndpointCache, statusMap);
|
||||
Object.entries(statusMap).forEach(([endpoint, details]) => {
|
||||
globalEndpointCache[endpoint] = {
|
||||
enabled: details?.enabled ?? true,
|
||||
reason: details?.reason ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
// Get all requested endpoints from cache (including previously cached ones)
|
||||
const fullStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
const fullStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const cachedDetails = globalEndpointCache[endpoint];
|
||||
if (cachedDetails) {
|
||||
acc.status[endpoint] = cachedDetails.enabled;
|
||||
acc.details[endpoint] = cachedDetails;
|
||||
} else {
|
||||
acc.status[endpoint] = true;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
|
||||
setEndpointStatus(fullStatus);
|
||||
setEndpointStatus(fullStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
|
||||
globalFetchedSets.add(endpointsKey);
|
||||
} catch (err: any) {
|
||||
// On 401 (auth error), use optimistic fallback instead of disabling
|
||||
if (err.response?.status === 401) {
|
||||
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
|
||||
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = true;
|
||||
globalEndpointCache[endpoint] = true; // Cache the optimistic value
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(optimisticStatus);
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
globalEndpointCache[endpoint] = optimisticDetails;
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -156,11 +182,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
|
||||
|
||||
// Fallback: assume all endpoints are enabled on error (optimistic)
|
||||
const optimisticStatus = endpoints.reduce((acc, endpoint) => {
|
||||
acc[endpoint] = true;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(optimisticStatus);
|
||||
const optimisticStatus = endpoints.reduce(
|
||||
(acc, endpoint) => {
|
||||
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpoint] = true;
|
||||
acc.details[endpoint] = optimisticDetails;
|
||||
return acc;
|
||||
},
|
||||
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
||||
);
|
||||
setEndpointStatus(optimisticStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -186,6 +218,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
endpointDetails,
|
||||
loading,
|
||||
error,
|
||||
refetch: () => fetchAllEndpointStatuses(true),
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
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 { 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 interface ToolAvailabilityInfo {
|
||||
available: boolean;
|
||||
reason?: ToolDisableCause;
|
||||
}
|
||||
|
||||
export type ToolAvailabilityMap = Partial<Record<ToolId, ToolAvailabilityInfo>>;
|
||||
|
||||
interface ToolManagementResult {
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
@@ -11,6 +22,7 @@ interface ToolManagementResult {
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
setToolSelectedFileIds: (fileIds: FileId[]) => void;
|
||||
getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null;
|
||||
toolAvailability: ToolAvailabilityMap;
|
||||
}
|
||||
|
||||
export const useToolManagement = (): ToolManagementResult => {
|
||||
@@ -19,9 +31,10 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
// Build endpoints list from registry entries with fallback to legacy mapping
|
||||
const { allTools } = useToolRegistry();
|
||||
const baseRegistry = allTools;
|
||||
const { preferences } = usePreferences();
|
||||
|
||||
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]);
|
||||
const { endpointStatus, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
const { endpointStatus, endpointDetails, loading: endpointsLoading } = useMultipleEndpointsEnabled(allEndpoints);
|
||||
|
||||
const isToolAvailable = useCallback((toolKey: string): boolean => {
|
||||
// Keep tools enabled during loading (optimistic UX)
|
||||
@@ -38,22 +51,64 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
|
||||
}, [endpointsLoading, endpointStatus, baseRegistry]);
|
||||
|
||||
const deriveToolDisableReason = useCallback((toolKey: ToolId): ToolDisableCause => {
|
||||
const tool = baseRegistry[toolKey];
|
||||
if (!tool) {
|
||||
return 'unknown';
|
||||
}
|
||||
const endpoints = tool.endpoints || [];
|
||||
const disabledReasons: EndpointDisableReason[] = endpoints
|
||||
.filter(endpoint => endpointStatus[endpoint] === false)
|
||||
.map(endpoint => endpointDetails[endpoint]?.reason ?? 'CONFIG');
|
||||
|
||||
if (disabledReasons.some(reason => reason === 'DEPENDENCY')) {
|
||||
return 'missingDependency';
|
||||
}
|
||||
if (disabledReasons.some(reason => reason === 'CONFIG')) {
|
||||
return 'disabledByAdmin';
|
||||
}
|
||||
if (disabledReasons.length > 0) {
|
||||
return 'unknown';
|
||||
}
|
||||
return 'unknown';
|
||||
}, [baseRegistry, endpointDetails, endpointStatus]);
|
||||
|
||||
const toolAvailability = useMemo(() => {
|
||||
if (endpointsLoading) {
|
||||
return {};
|
||||
}
|
||||
const availability: ToolAvailabilityMap = {};
|
||||
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
|
||||
const available = isToolAvailable(toolKey);
|
||||
availability[toolKey] = available
|
||||
? { available: true }
|
||||
: { available: false, reason: deriveToolDisableReason(toolKey) };
|
||||
});
|
||||
return availability;
|
||||
}, [baseRegistry, deriveToolDisableReason, endpointsLoading, isToolAvailable]);
|
||||
|
||||
const toolRegistry: Partial<ToolRegistry> = useMemo(() => {
|
||||
const availableToolRegistry: Partial<ToolRegistry> = {};
|
||||
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
|
||||
if (isToolAvailable(toolKey)) {
|
||||
const baseTool = baseRegistry[toolKey];
|
||||
if (baseTool) {
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseTool,
|
||||
name: baseTool.name,
|
||||
description: baseTool.description,
|
||||
};
|
||||
}
|
||||
const baseTool = baseRegistry[toolKey];
|
||||
if (!baseTool) return;
|
||||
const availabilityInfo = toolAvailability[toolKey];
|
||||
const isAvailable = availabilityInfo ? availabilityInfo.available !== false : true;
|
||||
|
||||
// Check if tool is "coming soon" (has no component and no link)
|
||||
const isComingSoon = !baseTool.component && !baseTool.link && toolKey !== 'read' && toolKey !== 'multiTool';
|
||||
|
||||
if (preferences.hideUnavailableTools && (!isAvailable || isComingSoon)) {
|
||||
return;
|
||||
}
|
||||
availableToolRegistry[toolKey] = {
|
||||
...baseTool,
|
||||
name: baseTool.name,
|
||||
description: baseTool.description,
|
||||
};
|
||||
});
|
||||
return availableToolRegistry;
|
||||
}, [isToolAvailable, baseRegistry]);
|
||||
}, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]);
|
||||
|
||||
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
|
||||
return toolKey ? toolRegistry[toolKey] || null : null;
|
||||
@@ -65,5 +120,6 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
toolRegistry,
|
||||
setToolSelectedFileIds,
|
||||
getSelectedTool,
|
||||
toolAvailability,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user