Differentiate unavailable tools by reason
This commit is contained in:
@@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
@Slf4j
|
||||
public class EndpointConfiguration {
|
||||
|
||||
public enum DisableReason {
|
||||
CONFIG,
|
||||
DEPENDENCY,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
public static class EndpointAvailability {
|
||||
private final boolean enabled;
|
||||
private final DisableReason reason;
|
||||
|
||||
public EndpointAvailability(boolean enabled, DisableReason reason) {
|
||||
this.enabled = enabled;
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public DisableReason getReason() {
|
||||
return reason;
|
||||
}
|
||||
}
|
||||
|
||||
private static final String REMOVE_BLANKS = "remove-blanks";
|
||||
private final ApplicationProperties applicationProperties;
|
||||
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
|
||||
private Set<String> disabledGroups = new HashSet<>();
|
||||
private Map<String, DisableReason> endpointDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, DisableReason> groupDisableReasons = new ConcurrentHashMap<>();
|
||||
private Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
|
||||
private final boolean runningProOrHigher;
|
||||
|
||||
@@ -35,16 +61,31 @@ public class EndpointConfiguration {
|
||||
processEnvironmentConfigs();
|
||||
}
|
||||
|
||||
private String normalizeEndpoint(String endpoint) {
|
||||
if (endpoint == null) {
|
||||
return null;
|
||||
}
|
||||
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
|
||||
}
|
||||
|
||||
public void enableEndpoint(String endpoint) {
|
||||
endpointStatuses.put(endpoint, true);
|
||||
log.debug("Enabled endpoint: {}", endpoint);
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
endpointStatuses.put(normalized, true);
|
||||
endpointDisableReasons.remove(normalized);
|
||||
log.debug("Enabled endpoint: {}", normalized);
|
||||
}
|
||||
|
||||
public void disableEndpoint(String endpoint) {
|
||||
if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) {
|
||||
log.debug("Disabling endpoint: {}", endpoint);
|
||||
disableEndpoint(endpoint, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
public void disableEndpoint(String endpoint, DisableReason reason) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
if (!Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
|
||||
log.debug("Disabling endpoint: {}", normalized);
|
||||
}
|
||||
endpointStatuses.put(endpoint, false);
|
||||
endpointStatuses.put(normalized, false);
|
||||
endpointDisableReasons.put(normalized, reason);
|
||||
}
|
||||
|
||||
public boolean isEndpointEnabled(String endpoint) {
|
||||
@@ -150,6 +191,10 @@ public class EndpointConfiguration {
|
||||
}
|
||||
|
||||
public void disableGroup(String group) {
|
||||
disableGroup(group, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
public void disableGroup(String group, DisableReason reason) {
|
||||
if (disabledGroups.add(group)) {
|
||||
if (isToolGroup(group)) {
|
||||
log.debug(
|
||||
@@ -161,11 +206,12 @@ public class EndpointConfiguration {
|
||||
group);
|
||||
}
|
||||
}
|
||||
groupDisableReasons.put(group, reason);
|
||||
// Only cascade to endpoints for *functional* groups
|
||||
if (!isToolGroup(group)) {
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints != null) {
|
||||
endpoints.forEach(this::disableEndpoint);
|
||||
endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,12 +220,39 @@ public class EndpointConfiguration {
|
||||
if (disabledGroups.remove(group)) {
|
||||
log.debug("Enabling group: {}", group);
|
||||
}
|
||||
groupDisableReasons.remove(group);
|
||||
Set<String> endpoints = endpointGroups.get(group);
|
||||
if (endpoints != null) {
|
||||
endpoints.forEach(this::enableEndpoint);
|
||||
}
|
||||
}
|
||||
|
||||
public EndpointAvailability getEndpointAvailability(String endpoint) {
|
||||
boolean enabled = isEndpointEnabled(endpoint);
|
||||
DisableReason reason = enabled ? null : determineDisableReason(endpoint);
|
||||
return new EndpointAvailability(enabled, reason);
|
||||
}
|
||||
|
||||
private DisableReason determineDisableReason(String endpoint) {
|
||||
String normalized = normalizeEndpoint(endpoint);
|
||||
if (Boolean.FALSE.equals(endpointStatuses.get(normalized))) {
|
||||
return endpointDisableReasons.getOrDefault(normalized, DisableReason.CONFIG);
|
||||
}
|
||||
|
||||
for (Map.Entry<String, Set<String>> entry : endpointGroups.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
Set<String> endpoints = entry.getValue();
|
||||
if (!disabledGroups.contains(group) || endpoints == null) {
|
||||
continue;
|
||||
}
|
||||
if (endpoints.contains(normalized)) {
|
||||
return groupDisableReasons.getOrDefault(group, DisableReason.CONFIG);
|
||||
}
|
||||
}
|
||||
|
||||
return DisableReason.UNKNOWN;
|
||||
}
|
||||
|
||||
public Set<String> getDisabledGroups() {
|
||||
return new HashSet<>(disabledGroups);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
|
||||
import stirling.software.common.configuration.RuntimePathConfig;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
|
||||
@@ -97,7 +98,7 @@ public class ExternalAppDepConfig {
|
||||
if (affectedGroups != null) {
|
||||
for (String group : affectedGroups) {
|
||||
List<String> affectedFeatures = getAffectedFeatures(group);
|
||||
endpointConfiguration.disableGroup(group);
|
||||
endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: {} - Disabling group: {} (Affected features: {})",
|
||||
command,
|
||||
@@ -127,8 +128,8 @@ public class ExternalAppDepConfig {
|
||||
if (!pythonAvailable) {
|
||||
List<String> pythonFeatures = getAffectedFeatures("Python");
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY);
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}",
|
||||
String.join(", ", pythonFeatures),
|
||||
@@ -146,14 +147,14 @@ public class ExternalAppDepConfig {
|
||||
int exitCode = process.waitFor();
|
||||
if (exitCode != 0) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"OpenCV not available in Python - Disabling OpenCV features: {}",
|
||||
String.join(", ", openCVFeatures));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
List<String> openCVFeatures = getAffectedFeatures("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV");
|
||||
endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
|
||||
log.warn(
|
||||
"Error checking OpenCV: {} - Disabling OpenCV features: {}",
|
||||
e.getMessage(),
|
||||
|
||||
+13
@@ -13,6 +13,7 @@ import io.swagger.v3.oas.annotations.Hidden;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||
import stirling.software.SPDF.config.InitialSetup;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
@@ -181,4 +182,16 @@ public class ConfigController {
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
|
||||
@GetMapping("/endpoints-availability")
|
||||
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
|
||||
@RequestParam(name = "endpoints") String endpoints) {
|
||||
Map<String, EndpointAvailability> result = new HashMap<>();
|
||||
String[] endpointArray = endpoints.split(",");
|
||||
for (String endpoint : endpointArray) {
|
||||
String trimmedEndpoint = endpoint.trim();
|
||||
result.put(trimmedEndpoint, endpointConfiguration.getEndpointAvailability(trimmedEndpoint));
|
||||
}
|
||||
return ResponseEntity.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
"comingSoon": "Coming soon:",
|
||||
"favorite": "Add to favourites",
|
||||
"favorites": "Favourites",
|
||||
"unavailable": "Disabled by server administrator:",
|
||||
"unavailableDependency": "Unavailable - required tool missing on server:",
|
||||
"heading": "All tools (fullscreen view)",
|
||||
"noResults": "Try adjusting your search or toggle descriptions to find what you need.",
|
||||
"recommended": "Recommended",
|
||||
|
||||
@@ -114,6 +114,20 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<div>
|
||||
<Text fw={500} size="sm">
|
||||
{t('settings.general.hideUnavailableTools', 'Hide unavailable tools')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{t('settings.general.hideUnavailableToolsDescription', 'Remove tools that have been disabled by your server instead of showing them greyed out.')}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
checked={preferences.hideUnavailableTools}
|
||||
onChange={(event) => updatePreference('hideUnavailableTools', event.currentTarget.checked)}
|
||||
/>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={t('settings.general.autoUnzipTooltip', 'Automatically extract ZIP files returned from API operations. Disable to keep ZIP files intact. This does not affect automation workflows.')}
|
||||
multiline
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Tooltip } from '@app/components/shared/Tooltip';
|
||||
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
|
||||
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
|
||||
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared';
|
||||
|
||||
interface CompactToolItemProps {
|
||||
id: string;
|
||||
@@ -17,7 +17,7 @@ interface CompactToolItemProps {
|
||||
|
||||
const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => {
|
||||
const { t } = useTranslation();
|
||||
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
|
||||
const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool);
|
||||
const categoryColor = getSubcategoryColor(tool.subcategoryId);
|
||||
const iconBg = getIconBackground(categoryColor, false);
|
||||
const iconClasses = 'tool-panel__fullscreen-list-icon';
|
||||
@@ -73,9 +73,12 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected,
|
||||
</button>
|
||||
);
|
||||
|
||||
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||
const disabledMessage = t(disabledKey, disabledFallback);
|
||||
|
||||
const tooltipContent = disabled
|
||||
? (
|
||||
<span><strong>{t('toolPanel.fullscreen.comingSoon', 'Coming soon:')}</strong> {tool.description}</span>
|
||||
<span><strong>{disabledMessage}</strong> {tool.description}</span>
|
||||
)
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
|
||||
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
|
||||
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta } from '@app/components/tools/fullscreen/shared';
|
||||
import { getIconBackground, getIconStyle, getItemClasses, useToolMeta, getDisabledLabel } from '@app/components/tools/fullscreen/shared';
|
||||
|
||||
interface DetailedToolItemProps {
|
||||
id: string;
|
||||
@@ -15,7 +15,7 @@ interface DetailedToolItemProps {
|
||||
|
||||
const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelected, onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { binding, isFav, toggleFavorite, disabled } = useToolMeta(id, tool);
|
||||
const { binding, isFav, toggleFavorite, disabled, disabledReason } = useToolMeta(id, tool);
|
||||
|
||||
const categoryColor = getSubcategoryColor(tool.subcategoryId);
|
||||
const iconBg = getIconBackground(categoryColor, true);
|
||||
@@ -34,6 +34,9 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
|
||||
iconNode = tool.icon;
|
||||
}
|
||||
|
||||
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||
const disabledMessage = t(disabledKey, disabledFallback);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -60,7 +63,12 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
|
||||
{tool.name}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed" className="tool-panel__fullscreen-description">
|
||||
{tool.description}
|
||||
{disabled ? (
|
||||
<>
|
||||
<strong>{disabledMessage} </strong>
|
||||
{tool.description}
|
||||
</>
|
||||
) : tool.description}
|
||||
</Text>
|
||||
{binding && (
|
||||
<div className="tool-panel__fullscreen-shortcut">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useHotkeys } from '@app/contexts/HotkeyContext';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
|
||||
import { ToolId } from '@app/types/toolId';
|
||||
import type { ToolAvailabilityMap } from '@app/hooks/useToolManagement';
|
||||
|
||||
export const getItemClasses = (isDetailed: boolean): string => {
|
||||
return isDetailed ? 'tool-panel__fullscreen-item--detailed' : '';
|
||||
@@ -22,23 +23,67 @@ export const getIconStyle = (): Record<string, string> => {
|
||||
return {};
|
||||
};
|
||||
|
||||
export const isToolDisabled = (id: string, tool: ToolRegistryEntry): boolean => {
|
||||
return !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||
export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | null;
|
||||
|
||||
export const getToolDisabledReason = (
|
||||
id: string,
|
||||
tool: ToolRegistryEntry,
|
||||
toolAvailability?: ToolAvailabilityMap
|
||||
): ToolDisabledReason => {
|
||||
if (!tool.component && !tool.link && id !== 'read' && id !== 'multiTool') {
|
||||
return 'comingSoon';
|
||||
}
|
||||
|
||||
const availabilityInfo = toolAvailability?.[id as ToolId];
|
||||
if (availabilityInfo && availabilityInfo.available === false) {
|
||||
if (availabilityInfo.reason === 'missingDependency') {
|
||||
return 'missingDependency';
|
||||
}
|
||||
if (availabilityInfo.reason === 'disabledByAdmin') {
|
||||
return 'disabledByAdmin';
|
||||
}
|
||||
return 'unknownUnavailable';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getDisabledLabel = (
|
||||
disabledReason: ToolDisabledReason
|
||||
): { key: string; fallback: string } => {
|
||||
if (disabledReason === 'missingDependency') {
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.unavailableDependency',
|
||||
fallback: 'Unavailable - required tool missing on server:'
|
||||
};
|
||||
}
|
||||
if (disabledReason === 'disabledByAdmin' || disabledReason === 'unknownUnavailable') {
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.unavailable',
|
||||
fallback: 'Disabled by server administrator:'
|
||||
};
|
||||
}
|
||||
return {
|
||||
key: 'toolPanel.fullscreen.comingSoon',
|
||||
fallback: 'Coming soon:'
|
||||
};
|
||||
};
|
||||
|
||||
export function useToolMeta(id: string, tool: ToolRegistryEntry) {
|
||||
const { hotkeys } = useHotkeys();
|
||||
const { isFavorite, toggleFavorite } = useToolWorkflow();
|
||||
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
|
||||
|
||||
const isFav = isFavorite(id as ToolId);
|
||||
const binding = hotkeys[id as ToolId];
|
||||
const disabled = isToolDisabled(id, tool);
|
||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
|
||||
const disabled = disabledReason !== null;
|
||||
|
||||
return {
|
||||
binding,
|
||||
isFav,
|
||||
toggleFavorite: () => toggleFavorite(id as ToolId),
|
||||
disabled,
|
||||
disabledReason,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
|
||||
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
|
||||
|
||||
interface ToolButtonProps {
|
||||
id: ToolId;
|
||||
@@ -26,12 +27,12 @@ interface ToolButtonProps {
|
||||
|
||||
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => {
|
||||
const { t } = useTranslation();
|
||||
// Special case: read and multiTool are navigational tools that are always available
|
||||
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
|
||||
const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
|
||||
const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
|
||||
const isUnavailable = disabledReason !== null;
|
||||
const { hotkeys } = useHotkeys();
|
||||
const binding = hotkeys[id];
|
||||
const { getToolNavigation } = useToolNavigation();
|
||||
const { isFavorite, toggleFavorite } = useToolWorkflow();
|
||||
const fav = isFavorite(id as ToolId);
|
||||
|
||||
const handleClick = (id: ToolId) => {
|
||||
@@ -48,8 +49,11 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
// Get navigation props for URL support (only if navigation is not disabled)
|
||||
const navProps = !isUnavailable && !tool.link && !disableNavigation ? getToolNavigation(id, tool) : null;
|
||||
|
||||
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
|
||||
const disabledMessage = t(disabledKey, disabledFallback);
|
||||
|
||||
const tooltipContent = isUnavailable
|
||||
? (<span><strong>Coming soon:</strong> {tool.description}</span>)
|
||||
? (<span><strong>{disabledMessage}</strong> {tool.description}</span>)
|
||||
: (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
||||
<span>{tool.description}</span>
|
||||
@@ -161,6 +165,7 @@ const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect,
|
||||
justify="flex-start"
|
||||
className="tool-button"
|
||||
aria-disabled={isUnavailable}
|
||||
disabled={isUnavailable}
|
||||
data-tour={`tool-button-${id}`}
|
||||
styles={{
|
||||
root: {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useToolManagement } from '@app/hooks/useToolManagement';
|
||||
import { useToolManagement, type ToolAvailabilityMap } from '@app/hooks/useToolManagement';
|
||||
import { PageEditorFunctions } from '@app/types/pageEditor';
|
||||
import { ToolRegistryEntry, ToolRegistry } from '@app/data/toolsTaxonomy';
|
||||
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
|
||||
@@ -44,6 +44,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
selectedTool: ToolRegistryEntry | null;
|
||||
toolRegistry: Partial<ToolRegistry>;
|
||||
getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null;
|
||||
toolAvailability: ToolAvailabilityMap;
|
||||
|
||||
// UI Actions
|
||||
setSidebarsVisible: (visible: boolean) => void;
|
||||
@@ -112,7 +113,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
const navigationState = useNavigationState();
|
||||
|
||||
// Tool management hook
|
||||
const { toolRegistry, getSelectedTool } = useToolManagement();
|
||||
const { toolRegistry, getSelectedTool, toolAvailability } = useToolManagement();
|
||||
const { allTools } = useToolRegistry();
|
||||
|
||||
// Tool history hook
|
||||
@@ -258,6 +259,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
|
||||
// Workflow actions (compound actions that coordinate multiple state changes)
|
||||
const handleToolSelect = useCallback((toolId: ToolId) => {
|
||||
const availabilityInfo = toolAvailability[toolId];
|
||||
const isExplicitlyDisabled = availabilityInfo ? availabilityInfo.available === false : false;
|
||||
if (toolId !== 'read' && toolId !== 'multiTool' && isExplicitlyDisabled) {
|
||||
return;
|
||||
}
|
||||
// If we're currently on a custom workbench (e.g., Validate Signature report),
|
||||
// selecting any tool should take the user back to the default file manager view.
|
||||
const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
|
||||
@@ -299,7 +305,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setSearchQuery('');
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false); // Disable read mode when selecting tools
|
||||
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]);
|
||||
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery, toolAvailability]);
|
||||
|
||||
const handleBackToTools = useCallback(() => {
|
||||
setLeftPanelView('toolPicker');
|
||||
@@ -354,6 +360,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
toolResetFunctions,
|
||||
registerToolReset,
|
||||
resetTool,
|
||||
toolAvailability,
|
||||
|
||||
// Workflow Actions
|
||||
handleToolSelect,
|
||||
@@ -381,6 +388,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
selectedTool,
|
||||
toolRegistry,
|
||||
getSelectedTool,
|
||||
toolAvailability,
|
||||
setSidebarsVisible,
|
||||
setLeftPanelView,
|
||||
setReaderMode,
|
||||
|
||||
@@ -80,6 +80,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale
|
||||
import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
|
||||
import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation";
|
||||
import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useExtractPagesOperation";
|
||||
import { ENDPOINTS as SPLIT_ENDPOINT_NAMES } from '@app/constants/splitConstants';
|
||||
import CompressSettings from "@app/components/tools/compress/CompressSettings";
|
||||
import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings";
|
||||
import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings";
|
||||
@@ -379,6 +380,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.split.desc", "Split PDFs into multiple documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINT_NAMES))),
|
||||
operationConfig: splitOperationConfig,
|
||||
automationSettings: SplitAutomationSettings,
|
||||
synonyms: getSynonyms(t, "split")
|
||||
@@ -446,6 +448,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.PAGE_FORMATTING,
|
||||
endpoints: ["booklet-imposition"],
|
||||
},
|
||||
pdfToSinglePage: {
|
||||
|
||||
@@ -578,7 +581,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.REMOVAL,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
endpoints: ["remove-cert-sign"],
|
||||
operationConfig: removeCertificateSignOperationConfig,
|
||||
synonyms: getSynonyms(t, "removeCertSign"),
|
||||
automationSettings: null,
|
||||
@@ -607,7 +610,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
||||
name: t("home.autoRename.title", "Auto Rename PDF File"),
|
||||
component: AutoRename,
|
||||
maxFiles: -1,
|
||||
endpoints: ["remove-certificate-sign"],
|
||||
endpoints: ["auto-rename"],
|
||||
operationConfig: autoRenameOperationConfig,
|
||||
description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"),
|
||||
categoryId: ToolCategoryId.ADVANCED_TOOLS,
|
||||
|
||||
+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,60 @@ 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;
|
||||
if (preferences.hideUnavailableTools && !isAvailable) {
|
||||
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 +116,6 @@ export const useToolManagement = (): ToolManagementResult => {
|
||||
toolRegistry,
|
||||
setToolSelectedFileIds,
|
||||
getSelectedTool,
|
||||
toolAvailability,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface UserPreferences {
|
||||
toolPanelModePromptSeen: boolean;
|
||||
showLegacyToolDescriptions: boolean;
|
||||
hasCompletedOnboarding: boolean;
|
||||
hideUnavailableTools: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
@@ -19,6 +20,7 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
|
||||
toolPanelModePromptSeen: false,
|
||||
showLegacyToolDescriptions: false,
|
||||
hasCompletedOnboarding: false,
|
||||
hideUnavailableTools: false,
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'stirlingpdf_preferences';
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | null;
|
||||
|
||||
export interface EndpointAvailabilityDetails {
|
||||
enabled: boolean;
|
||||
reason?: EndpointDisableReason;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '@app/services/apiClient';
|
||||
import { tauriBackendService } from '@app/services/tauriBackendService';
|
||||
import { isBackendNotReadyError } from '@app/constants/backendErrors';
|
||||
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
||||
|
||||
interface EndpointConfig {
|
||||
backendUrl: string;
|
||||
@@ -127,6 +128,7 @@ 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>;
|
||||
@@ -139,6 +141,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
});
|
||||
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const isMountedRef = useRef(true);
|
||||
@@ -173,13 +176,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
const endpointsParam = endpoints.join(',');
|
||||
|
||||
const response = await apiClient.get<Record<string, boolean>>('/api/v1/config/endpoints-enabled', {
|
||||
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>('/api/v1/config/endpoints-availability', {
|
||||
params: { endpoints: endpointsParam },
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
setEndpointStatus(response.data);
|
||||
const details = Object.entries(response.data).reduce((acc, [endpointName, detail]) => {
|
||||
acc[endpointName] = {
|
||||
enabled: detail?.enabled ?? true,
|
||||
reason: detail?.reason ?? null,
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, EndpointAvailabilityDetails>);
|
||||
|
||||
const statusMap = Object.keys(details).reduce((acc, key) => {
|
||||
acc[key] = details[key].enabled;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
|
||||
setEndpointDetails(prev => ({ ...prev, ...details }));
|
||||
setEndpointStatus(statusMap);
|
||||
} catch (err: unknown) {
|
||||
const isBackendStarting = isBackendNotReadyError(err);
|
||||
const message = getErrorMessage(err);
|
||||
@@ -187,10 +204,13 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
|
||||
|
||||
const fallbackStatus = endpoints.reduce((acc, endpointName) => {
|
||||
acc[endpointName] = true;
|
||||
const fallbackDetail: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
||||
acc.status[endpointName] = true;
|
||||
acc.details[endpointName] = fallbackDetail;
|
||||
return acc;
|
||||
}, {} as Record<string, boolean>);
|
||||
setEndpointStatus(fallbackStatus);
|
||||
}, { status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
|
||||
setEndpointStatus(fallbackStatus.status);
|
||||
setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details }));
|
||||
|
||||
if (!retryTimeoutRef.current) {
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
@@ -208,6 +228,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
useEffect(() => {
|
||||
if (!endpoints || endpoints.length === 0) {
|
||||
setEndpointStatus({});
|
||||
setEndpointDetails({});
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -229,6 +250,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
||||
|
||||
return {
|
||||
endpointStatus,
|
||||
endpointDetails,
|
||||
loading,
|
||||
error,
|
||||
refetch: fetchAllEndpointStatuses,
|
||||
|
||||
Reference in New Issue
Block a user