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:
Anthony Stirling
2025-11-21 13:19:53 +00:00
committed by GitHub
parent 4fd336c26c
commit e1a879a5f6
19 changed files with 542 additions and 182 deletions
@@ -18,11 +18,37 @@ import stirling.software.common.model.ApplicationProperties;
@Slf4j @Slf4j
public class EndpointConfiguration { 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 static final String REMOVE_BLANKS = "remove-blanks";
private final ApplicationProperties applicationProperties; private final ApplicationProperties applicationProperties;
@Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>(); @Getter private Map<String, Boolean> endpointStatuses = new ConcurrentHashMap<>();
private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>(); private Map<String, Set<String>> endpointGroups = new ConcurrentHashMap<>();
private Set<String> disabledGroups = new HashSet<>(); 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 Map<String, Set<String>> endpointAlternatives = new ConcurrentHashMap<>();
private final boolean runningProOrHigher; private final boolean runningProOrHigher;
@@ -35,16 +61,31 @@ public class EndpointConfiguration {
processEnvironmentConfigs(); processEnvironmentConfigs();
} }
private String normalizeEndpoint(String endpoint) {
if (endpoint == null) {
return null;
}
return endpoint.startsWith("/") ? endpoint.substring(1) : endpoint;
}
public void enableEndpoint(String endpoint) { public void enableEndpoint(String endpoint) {
endpointStatuses.put(endpoint, true); String normalized = normalizeEndpoint(endpoint);
log.debug("Enabled endpoint: {}", endpoint); endpointStatuses.put(normalized, true);
endpointDisableReasons.remove(normalized);
log.debug("Enabled endpoint: {}", normalized);
} }
public void disableEndpoint(String endpoint) { public void disableEndpoint(String endpoint) {
if (!Boolean.FALSE.equals(endpointStatuses.get(endpoint))) { disableEndpoint(endpoint, DisableReason.CONFIG);
log.debug("Disabling endpoint: {}", endpoint); }
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) { public boolean isEndpointEnabled(String endpoint) {
@@ -150,6 +191,10 @@ public class EndpointConfiguration {
} }
public void disableGroup(String group) { public void disableGroup(String group) {
disableGroup(group, DisableReason.CONFIG);
}
public void disableGroup(String group, DisableReason reason) {
if (disabledGroups.add(group)) { if (disabledGroups.add(group)) {
if (isToolGroup(group)) { if (isToolGroup(group)) {
log.debug( log.debug(
@@ -161,11 +206,12 @@ public class EndpointConfiguration {
group); group);
} }
} }
groupDisableReasons.put(group, reason);
// Only cascade to endpoints for *functional* groups // Only cascade to endpoints for *functional* groups
if (!isToolGroup(group)) { if (!isToolGroup(group)) {
Set<String> endpoints = endpointGroups.get(group); Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) { if (endpoints != null) {
endpoints.forEach(this::disableEndpoint); endpoints.forEach(endpoint -> disableEndpoint(endpoint, reason));
} }
} }
} }
@@ -174,12 +220,39 @@ public class EndpointConfiguration {
if (disabledGroups.remove(group)) { if (disabledGroups.remove(group)) {
log.debug("Enabling group: {}", group); log.debug("Enabling group: {}", group);
} }
groupDisableReasons.remove(group);
Set<String> endpoints = endpointGroups.get(group); Set<String> endpoints = endpointGroups.get(group);
if (endpoints != null) { if (endpoints != null) {
endpoints.forEach(this::enableEndpoint); 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() { public Set<String> getDisabledGroups() {
return new HashSet<>(disabledGroups); return new HashSet<>(disabledGroups);
} }
@@ -261,6 +334,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Convert", "pdf-to-csv"); addEndpointToGroup("Convert", "pdf-to-csv");
addEndpointToGroup("Convert", "pdf-to-markdown"); addEndpointToGroup("Convert", "pdf-to-markdown");
addEndpointToGroup("Convert", "eml-to-pdf"); addEndpointToGroup("Convert", "eml-to-pdf");
addEndpointToGroup("Convert", "cbz-to-pdf");
addEndpointToGroup("Convert", "pdf-to-cbz");
// Adding endpoints to "Security" group // Adding endpoints to "Security" group
addEndpointToGroup("Security", "add-password"); addEndpointToGroup("Security", "add-password");
@@ -394,6 +469,8 @@ public class EndpointConfiguration {
addEndpointToGroup("Java", "pdf-to-markdown"); addEndpointToGroup("Java", "pdf-to-markdown");
addEndpointToGroup("Java", "add-attachments"); addEndpointToGroup("Java", "add-attachments");
addEndpointToGroup("Java", "compress-pdf"); addEndpointToGroup("Java", "compress-pdf");
addEndpointToGroup("Java", "cbz-to-pdf");
addEndpointToGroup("Java", "pdf-to-cbz");
addEndpointToGroup("rar", "pdf-to-cbr"); addEndpointToGroup("rar", "pdf-to-cbr");
// Javascript // Javascript
@@ -12,6 +12,7 @@ import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration.DisableReason;
import stirling.software.common.configuration.RuntimePathConfig; import stirling.software.common.configuration.RuntimePathConfig;
import stirling.software.common.util.RegexPatternUtils; import stirling.software.common.util.RegexPatternUtils;
@@ -97,7 +98,7 @@ public class ExternalAppDepConfig {
if (affectedGroups != null) { if (affectedGroups != null) {
for (String group : affectedGroups) { for (String group : affectedGroups) {
List<String> affectedFeatures = getAffectedFeatures(group); List<String> affectedFeatures = getAffectedFeatures(group);
endpointConfiguration.disableGroup(group); endpointConfiguration.disableGroup(group, DisableReason.DEPENDENCY);
log.warn( log.warn(
"Missing dependency: {} - Disabling group: {} (Affected features: {})", "Missing dependency: {} - Disabling group: {} (Affected features: {})",
command, command,
@@ -127,8 +128,8 @@ public class ExternalAppDepConfig {
if (!pythonAvailable) { if (!pythonAvailable) {
List<String> pythonFeatures = getAffectedFeatures("Python"); List<String> pythonFeatures = getAffectedFeatures("Python");
List<String> openCVFeatures = getAffectedFeatures("OpenCV"); List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("Python"); endpointConfiguration.disableGroup("Python", DisableReason.DEPENDENCY);
endpointConfiguration.disableGroup("OpenCV"); endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn( log.warn(
"Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}", "Missing dependency: Python - Disabling Python features: {} and OpenCV features: {}",
String.join(", ", pythonFeatures), String.join(", ", pythonFeatures),
@@ -146,14 +147,14 @@ public class ExternalAppDepConfig {
int exitCode = process.waitFor(); int exitCode = process.waitFor();
if (exitCode != 0) { if (exitCode != 0) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV"); List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV"); endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn( log.warn(
"OpenCV not available in Python - Disabling OpenCV features: {}", "OpenCV not available in Python - Disabling OpenCV features: {}",
String.join(", ", openCVFeatures)); String.join(", ", openCVFeatures));
} }
} catch (Exception e) { } catch (Exception e) {
List<String> openCVFeatures = getAffectedFeatures("OpenCV"); List<String> openCVFeatures = getAffectedFeatures("OpenCV");
endpointConfiguration.disableGroup("OpenCV"); endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY);
log.warn( log.warn(
"Error checking OpenCV: {} - Disabling OpenCV features: {}", "Error checking OpenCV: {} - Disabling OpenCV features: {}",
e.getMessage(), e.getMessage(),
@@ -1,6 +1,7 @@
package stirling.software.SPDF.controller.api.misc; package stirling.software.SPDF.controller.api.misc;
import java.util.HashMap; import java.util.HashMap;
import java.util.List;
import java.util.Map; import java.util.Map;
import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContext;
@@ -10,9 +11,13 @@ import org.springframework.web.bind.annotation.RequestParam;
import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Hidden;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.SPDF.config.EndpointConfiguration;
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
import stirling.software.SPDF.config.InitialSetup; import stirling.software.SPDF.config.InitialSetup;
import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.annotations.api.ConfigApi;
import stirling.software.common.configuration.AppConfig; import stirling.software.common.configuration.AppConfig;
@@ -200,4 +205,19 @@ public class ConfigController {
} }
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@GetMapping("/endpoints-availability")
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
@RequestParam(name = "endpoints")
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
List<@NotBlank String> endpoints) {
Map<String, EndpointAvailability> result = new HashMap<>();
for (String endpoint : endpoints) {
String trimmedEndpoint = endpoint.trim();
result.put(
trimmedEndpoint,
endpointConfiguration.getEndpointAvailability(trimmedEndpoint));
}
return ResponseEntity.ok(result);
}
} }
@@ -17,6 +17,8 @@
"comingSoon": "Coming soon:", "comingSoon": "Coming soon:",
"favorite": "Add to favourites", "favorite": "Add to favourites",
"favorites": "Favourites", "favorites": "Favourites",
"unavailable": "Disabled by server administrator:",
"unavailableDependency": "Unavailable - required tool missing on server:",
"heading": "All tools (fullscreen view)", "heading": "All tools (fullscreen view)",
"noResults": "Try adjusting your search or toggle descriptions to find what you need.", "noResults": "Try adjusting your search or toggle descriptions to find what you need.",
"recommended": "Recommended", "recommended": "Recommended",
@@ -5721,7 +5723,11 @@
"latestVersion": "Latest Version", "latestVersion": "Latest Version",
"checkForUpdates": "Check for Updates", "checkForUpdates": "Check for Updates",
"viewDetails": "View Details" "viewDetails": "View Details"
} },
"hideUnavailableTools": "Hide unavailable tools",
"hideUnavailableToolsDescription": "Remove tools that have been disabled by your server instead of showing them greyed out.",
"hideUnavailableConversions": "Hide unavailable conversions",
"hideUnavailableConversionsDescription": "Remove disabled conversion options in the Convert tool instead of showing them greyed out."
}, },
"hotkeys": { "hotkeys": {
"errorConflict": "Shortcut already used by {{tool}}.", "errorConflict": "Shortcut already used by {{tool}}.",
@@ -53,11 +53,17 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
}; };
const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo); const summary = await updateService.getUpdateSummary(config.appVersion, machineInfo);
if (summary) { if (summary && summary.latest_version) {
const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0; const isNewerVersion = updateService.compareVersions(summary.latest_version, config.appVersion) > 0;
if (isNewerVersion) { if (isNewerVersion) {
setUpdateSummary(summary); setUpdateSummary(summary);
} else {
// Clear any existing update summary if user is on latest version
setUpdateSummary(null);
} }
} else {
// No update available (latest_version is null) - clear any existing update summary
setUpdateSummary(null);
} }
setCheckingUpdate(false); setCheckingUpdate(false);
}; };
@@ -128,83 +134,6 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
</Paper> </Paper>
)} )}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
</Text>
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
]}
/>
</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
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
/>
</div>
</Tooltip>
<Tooltip
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
</Text>
</div>
<NumberInput
value={fileLimitInput}
onChange={setFileLimitInput}
onBlur={() => {
const numValue = Number(fileLimitInput);
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
setFileLimitInput(finalValue);
updatePreference('autoUnzipFileLimit', finalValue);
}}
min={1}
max={100}
step={1}
disabled={!preferences.autoUnzip}
style={{ width: 90 }}
/>
</div>
</Tooltip>
</Stack>
</Paper>
{/* Update Check Section */} {/* Update Check Section */}
{config?.appVersion && ( {config?.appVersion && (
<Paper withBorder p="md" radius="md"> <Paper withBorder p="md" radius="md">
@@ -292,6 +221,111 @@ const GeneralSection: React.FC<GeneralSectionProps> = ({ hideTitle = false }) =>
</Paper> </Paper>
)} )}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.defaultToolPickerMode', 'Default tool picker mode')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.defaultToolPickerModeDescription', 'Choose whether the tool picker opens in fullscreen or sidebar by default')}
</Text>
</div>
<SegmentedControl
value={preferences.defaultToolPanelMode}
onChange={(val: string) => updatePreference('defaultToolPanelMode', val as ToolPanelMode)}
data={[
{ label: t('settings.general.mode.sidebar', 'Sidebar'), value: 'sidebar' },
{ label: t('settings.general.mode.fullscreen', 'Fullscreen'), value: 'fullscreen' },
]}
/>
</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>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.hideUnavailableConversions', 'Hide unavailable conversions')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.hideUnavailableConversionsDescription', 'Remove disabled conversion options in the Convert tool instead of showing them greyed out.')}
</Text>
</div>
<Switch
checked={preferences.hideUnavailableConversions}
onChange={(event) => updatePreference('hideUnavailableConversions', 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
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzip', 'Auto-unzip API responses')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipDescription', 'Automatically extract files from ZIP responses')}
</Text>
</div>
<Switch
checked={preferences.autoUnzip}
onChange={(event) => updatePreference('autoUnzip', event.currentTarget.checked)}
/>
</div>
</Tooltip>
<Tooltip
label={t('settings.general.autoUnzipFileLimitTooltip', 'Only unzip if the ZIP contains this many files or fewer. Set higher to extract larger ZIPs.')}
multiline
w={300}
withArrow
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', cursor: 'help' }}>
<div>
<Text fw={500} size="sm">
{t('settings.general.autoUnzipFileLimit', 'Auto-unzip file limit')}
</Text>
<Text size="xs" c="dimmed" mt={4}>
{t('settings.general.autoUnzipFileLimitDescription', 'Maximum number of files to extract from ZIP')}
</Text>
</div>
<NumberInput
value={fileLimitInput}
onChange={setFileLimitInput}
onBlur={() => {
const numValue = Number(fileLimitInput);
const finalValue = (!fileLimitInput || isNaN(numValue) || numValue < 1 || numValue > 100) ? DEFAULT_AUTO_UNZIP_FILE_LIMIT : numValue;
setFileLimitInput(finalValue);
updatePreference('autoUnzipFileLimit', finalValue);
}}
min={1}
max={100}
step={1}
disabled={!preferences.autoUnzip}
style={{ width: 90 }}
/>
</div>
</Tooltip>
</Stack>
</Paper>
{/* Update Modal */} {/* Update Modal */}
{updateSummary && config?.appVersion && config?.machineType && ( {updateSummary && config?.appVersion && config?.machineType && (
<UpdateModal <UpdateModal
@@ -8,6 +8,7 @@ import { getConversionEndpoints } from "@app/data/toolsTaxonomy";
import { useFileSelection } from "@app/contexts/FileContext"; import { useFileSelection } from "@app/contexts/FileContext";
import { useFileState } from "@app/contexts/FileContext"; import { useFileState } from "@app/contexts/FileContext";
import { detectFileExtension } from "@app/utils/fileUtils"; import { detectFileExtension } from "@app/utils/fileUtils";
import { usePreferences } from "@app/contexts/PreferencesContext";
import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown"; import GroupedFormatDropdown from "@app/components/tools/convert/GroupedFormatDropdown";
import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings"; import ConvertToImageSettings from "@app/components/tools/convert/ConvertToImageSettings";
import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings"; import ConvertFromImageSettings from "@app/components/tools/convert/ConvertFromImageSettings";
@@ -47,8 +48,12 @@ const ConvertSettings = ({
const { setSelectedFiles } = useFileSelection(); const { setSelectedFiles } = useFileSelection();
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
const activeFiles = state.files.ids; const activeFiles = state.files.ids;
const { preferences } = usePreferences();
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []); const allEndpoints = useMemo(() => {
const endpoints = getConversionEndpoints(EXTENSION_TO_ENDPOINT);
return endpoints;
}, []);
const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints); const { endpointStatus } = useMultipleEndpointsEnabled(allEndpoints);
@@ -56,7 +61,8 @@ const ConvertSettings = ({
const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt]; const endpointKey = EXTENSION_TO_ENDPOINT[fromExt]?.[toExt];
if (!endpointKey) return false; if (!endpointKey) return false;
return endpointStatus[endpointKey] === true; const isAvailable = endpointStatus[endpointKey] === true;
return isAvailable;
}; };
// Enhanced FROM options with endpoint availability // Enhanced FROM options with endpoint availability
@@ -74,6 +80,12 @@ const ConvertSettings = ({
}; };
}); });
// Filter out unavailable source formats if preference is enabled
let filteredOptions = baseOptions;
if (preferences.hideUnavailableConversions) {
filteredOptions = baseOptions.filter(opt => opt.enabled !== false);
}
// Add dynamic format option if current selection is a file-<extension> format // Add dynamic format option if current selection is a file-<extension> format
if (parameters.fromExtension && parameters.fromExtension.startsWith('file-')) { if (parameters.fromExtension && parameters.fromExtension.startsWith('file-')) {
const extension = parameters.fromExtension.replace('file-', ''); const extension = parameters.fromExtension.replace('file-', '');
@@ -85,22 +97,32 @@ const ConvertSettings = ({
}; };
// Add the dynamic option at the beginning // Add the dynamic option at the beginning
return [dynamicOption, ...baseOptions]; return [dynamicOption, ...filteredOptions];
} }
return baseOptions; return filteredOptions;
}, [parameters.fromExtension, endpointStatus]); }, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
// Enhanced TO options with endpoint availability // Enhanced TO options with endpoint availability
const enhancedToOptions = useMemo(() => { const enhancedToOptions = useMemo(() => {
if (!parameters.fromExtension) return []; if (!parameters.fromExtension) return [];
const availableOptions = getAvailableToExtensions(parameters.fromExtension) || []; const availableOptions = getAvailableToExtensions(parameters.fromExtension) || [];
return availableOptions.map(option => ({ const enhanced = availableOptions.map(option => {
...option, const enabled = isConversionAvailable(parameters.fromExtension, option.value);
enabled: isConversionAvailable(parameters.fromExtension, option.value) return {
})); ...option,
}, [parameters.fromExtension, endpointStatus]); enabled
};
});
// Filter out unavailable conversions if preference is enabled
if (preferences.hideUnavailableConversions) {
return enhanced.filter(opt => opt.enabled !== false);
}
return enhanced;
}, [parameters.fromExtension, endpointStatus, preferences.hideUnavailableConversions]);
const resetParametersToDefaults = () => { const resetParametersToDefaults = () => {
onParameterChange('imageOptions', { onParameterChange('imageOptions', {
@@ -5,7 +5,7 @@ import { Tooltip } from '@app/components/shared/Tooltip';
import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay'; import HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar'; import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy'; 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 { interface CompactToolItemProps {
id: string; id: string;
@@ -17,7 +17,7 @@ interface CompactToolItemProps {
const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => { const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected, onClick, tooltipPortalTarget }) => {
const { t } = useTranslation(); 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 categoryColor = getSubcategoryColor(tool.subcategoryId);
const iconBg = getIconBackground(categoryColor, false); const iconBg = getIconBackground(categoryColor, false);
const iconClasses = 'tool-panel__fullscreen-list-icon'; const iconClasses = 'tool-panel__fullscreen-list-icon';
@@ -73,9 +73,12 @@ const CompactToolItem: React.FC<CompactToolItemProps> = ({ id, tool, isSelected,
</button> </button>
); );
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
const disabledMessage = t(disabledKey, disabledFallback);
const tooltipContent = disabled 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' }}> <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 HotkeyDisplay from '@app/components/hotkeys/HotkeyDisplay';
import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar'; import FavoriteStar from '@app/components/tools/toolPicker/FavoriteStar';
import { ToolRegistryEntry, getSubcategoryColor } from '@app/data/toolsTaxonomy'; 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 { interface DetailedToolItemProps {
id: string; id: string;
@@ -15,7 +15,7 @@ interface DetailedToolItemProps {
const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelected, onClick }) => { const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelected, onClick }) => {
const { t } = useTranslation(); 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 categoryColor = getSubcategoryColor(tool.subcategoryId);
const iconBg = getIconBackground(categoryColor, true); const iconBg = getIconBackground(categoryColor, true);
@@ -34,6 +34,9 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
iconNode = tool.icon; iconNode = tool.icon;
} }
const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason);
const disabledMessage = t(disabledKey, disabledFallback);
return ( return (
<button <button
type="button" type="button"
@@ -60,7 +63,12 @@ const DetailedToolItem: React.FC<DetailedToolItemProps> = ({ id, tool, isSelecte
{tool.name} {tool.name}
</Text> </Text>
<Text size="sm" c="dimmed" className="tool-panel__fullscreen-description"> <Text size="sm" c="dimmed" className="tool-panel__fullscreen-description">
{tool.description} {disabled ? (
<>
<strong>{disabledMessage} </strong>
{tool.description}
</>
) : tool.description}
</Text> </Text>
{binding && ( {binding && (
<div className="tool-panel__fullscreen-shortcut"> <div className="tool-panel__fullscreen-shortcut">
@@ -2,6 +2,7 @@ import { useHotkeys } from '@app/contexts/HotkeyContext';
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
import { ToolRegistryEntry } from '@app/data/toolsTaxonomy'; import { ToolRegistryEntry } from '@app/data/toolsTaxonomy';
import { ToolId } from '@app/types/toolId'; import { ToolId } from '@app/types/toolId';
import type { ToolAvailabilityMap } from '@app/hooks/useToolManagement';
export const getItemClasses = (isDetailed: boolean): string => { export const getItemClasses = (isDetailed: boolean): string => {
return isDetailed ? 'tool-panel__fullscreen-item--detailed' : ''; return isDetailed ? 'tool-panel__fullscreen-item--detailed' : '';
@@ -22,23 +23,67 @@ export const getIconStyle = (): Record<string, string> => {
return {}; return {};
}; };
export const isToolDisabled = (id: string, tool: ToolRegistryEntry): boolean => { export type ToolDisabledReason = 'comingSoon' | 'disabledByAdmin' | 'missingDependency' | 'unknownUnavailable' | null;
return !tool.component && !tool.link && id !== 'read' && id !== 'multiTool';
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) { export function useToolMeta(id: string, tool: ToolRegistryEntry) {
const { hotkeys } = useHotkeys(); const { hotkeys } = useHotkeys();
const { isFavorite, toggleFavorite } = useToolWorkflow(); const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
const isFav = isFavorite(id as ToolId); const isFav = isFavorite(id as ToolId);
const binding = hotkeys[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 { return {
binding, binding,
isFav, isFav,
toggleFavorite: () => toggleFavorite(id as ToolId), toggleFavorite: () => toggleFavorite(id as ToolId),
disabled, disabled,
disabledReason,
}; };
} }
@@ -12,6 +12,7 @@ import HotkeyDisplay from "@app/components/hotkeys/HotkeyDisplay";
import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar"; import FavoriteStar from "@app/components/tools/toolPicker/FavoriteStar";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext"; import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { ToolId } from "@app/types/toolId"; import { ToolId } from "@app/types/toolId";
import { getToolDisabledReason, getDisabledLabel } from "@app/components/tools/fullscreen/shared";
interface ToolButtonProps { interface ToolButtonProps {
id: ToolId; id: ToolId;
@@ -26,12 +27,12 @@ interface ToolButtonProps {
const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => { const ToolButton: React.FC<ToolButtonProps> = ({ id, tool, isSelected, onSelect, disableNavigation = false, matchedSynonym, hasStars = false }) => {
const { t } = useTranslation(); const { t } = useTranslation();
// Special case: read and multiTool are navigational tools that are always available const { isFavorite, toggleFavorite, toolAvailability } = useToolWorkflow();
const isUnavailable = !tool.component && !tool.link && id !== 'read' && id !== 'multiTool'; const disabledReason = getToolDisabledReason(id, tool, toolAvailability);
const isUnavailable = disabledReason !== null;
const { hotkeys } = useHotkeys(); const { hotkeys } = useHotkeys();
const binding = hotkeys[id]; const binding = hotkeys[id];
const { getToolNavigation } = useToolNavigation(); const { getToolNavigation } = useToolNavigation();
const { isFavorite, toggleFavorite } = useToolWorkflow();
const fav = isFavorite(id as ToolId); const fav = isFavorite(id as ToolId);
const handleClick = (id: 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) // Get navigation props for URL support (only if navigation is not disabled)
const navProps = !isUnavailable && !tool.link && !disableNavigation ? getToolNavigation(id, tool) : null; 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 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' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
<span>{tool.description}</span> <span>{tool.description}</span>
@@ -4,7 +4,7 @@
*/ */
import React, { createContext, useContext, useReducer, useCallback, useMemo, useEffect } from 'react'; 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 { PageEditorFunctions } from '@app/types/pageEditor';
import { ToolRegistryEntry, ToolRegistry } from '@app/data/toolsTaxonomy'; import { ToolRegistryEntry, ToolRegistry } from '@app/data/toolsTaxonomy';
import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext'; import { useNavigationActions, useNavigationState } from '@app/contexts/NavigationContext';
@@ -44,6 +44,7 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
selectedTool: ToolRegistryEntry | null; selectedTool: ToolRegistryEntry | null;
toolRegistry: Partial<ToolRegistry>; toolRegistry: Partial<ToolRegistry>;
getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null; getSelectedTool: (toolId: ToolId | null) => ToolRegistryEntry | null;
toolAvailability: ToolAvailabilityMap;
// UI Actions // UI Actions
setSidebarsVisible: (visible: boolean) => void; setSidebarsVisible: (visible: boolean) => void;
@@ -112,7 +113,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
const navigationState = useNavigationState(); const navigationState = useNavigationState();
// Tool management hook // Tool management hook
const { toolRegistry, getSelectedTool } = useToolManagement(); const { toolRegistry, getSelectedTool, toolAvailability } = useToolManagement();
const { allTools } = useToolRegistry(); const { allTools } = useToolRegistry();
// Tool history hook // Tool history hook
@@ -258,6 +259,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
// Workflow actions (compound actions that coordinate multiple state changes) // Workflow actions (compound actions that coordinate multiple state changes)
const handleToolSelect = useCallback((toolId: ToolId) => { 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), // 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. // selecting any tool should take the user back to the default file manager view.
const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench); const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
@@ -299,7 +305,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
setSearchQuery(''); setSearchQuery('');
setLeftPanelView('toolContent'); setLeftPanelView('toolContent');
setReaderMode(false); // Disable read mode when selecting tools 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(() => { const handleBackToTools = useCallback(() => {
setLeftPanelView('toolPicker'); setLeftPanelView('toolPicker');
@@ -354,6 +360,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
toolResetFunctions, toolResetFunctions,
registerToolReset, registerToolReset,
resetTool, resetTool,
toolAvailability,
// Workflow Actions // Workflow Actions
handleToolSelect, handleToolSelect,
@@ -381,6 +388,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
selectedTool, selectedTool,
toolRegistry, toolRegistry,
getSelectedTool, getSelectedTool,
toolAvailability,
setSidebarsVisible, setSidebarsVisible,
setLeftPanelView, setLeftPanelView,
setReaderMode, setReaderMode,
@@ -82,6 +82,7 @@ import { adjustPageScaleOperationConfig } from "@app/hooks/tools/adjustPageScale
import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation"; import { scannerImageSplitOperationConfig } from "@app/hooks/tools/scannerImageSplit/useScannerImageSplitOperation";
import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation"; import { addPageNumbersOperationConfig } from "@app/components/tools/addPageNumbers/useAddPageNumbersOperation";
import { extractPagesOperationConfig } from "@app/hooks/tools/extractPages/useExtractPagesOperation"; 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 CompressSettings from "@app/components/tools/compress/CompressSettings";
import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings"; import AddPasswordSettings from "@app/components/tools/addPassword/AddPasswordSettings";
import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings"; import RemovePasswordSettings from "@app/components/tools/removePassword/RemovePasswordSettings";
@@ -300,6 +301,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"), description: t("home.getPdfInfo.desc", "Grabs any and all information possible on PDFs"),
categoryId: ToolCategoryId.STANDARD_TOOLS, categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.VERIFICATION, subcategoryId: SubcategoryId.VERIFICATION,
endpoints: ["get-info-on-pdf"],
synonyms: getSynonyms(t, "getPdfInfo"), synonyms: getSynonyms(t, "getPdfInfo"),
supportsAutomate: false, supportsAutomate: false,
automationSettings: null automationSettings: null
@@ -398,6 +400,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.split.desc", "Split PDFs into multiple documents"), description: t("home.split.desc", "Split PDFs into multiple documents"),
categoryId: ToolCategoryId.STANDARD_TOOLS, categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING, subcategoryId: SubcategoryId.PAGE_FORMATTING,
endpoints: Array.from(new Set(Object.values(SPLIT_ENDPOINT_NAMES))),
operationConfig: splitOperationConfig, operationConfig: splitOperationConfig,
automationSettings: SplitAutomationSettings, automationSettings: SplitAutomationSettings,
synonyms: getSynonyms(t, "split") synonyms: getSynonyms(t, "split")
@@ -465,6 +468,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"), description: t("home.bookletImposition.desc", "Create booklets with proper page ordering and multi-page layout for printing and binding"),
categoryId: ToolCategoryId.STANDARD_TOOLS, categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.PAGE_FORMATTING, subcategoryId: SubcategoryId.PAGE_FORMATTING,
endpoints: ["booklet-imposition"],
}, },
pdfToSinglePage: { pdfToSinglePage: {
@@ -559,6 +563,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS, categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL, subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1, maxFiles: -1,
endpoints: ["remove-annotations"],
operationConfig: removeAnnotationsOperationConfig, operationConfig: removeAnnotationsOperationConfig,
automationSettings: null, automationSettings: null,
synonyms: getSynonyms(t, "removeAnnotations") synonyms: getSynonyms(t, "removeAnnotations")
@@ -597,7 +602,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.STANDARD_TOOLS, categoryId: ToolCategoryId.STANDARD_TOOLS,
subcategoryId: SubcategoryId.REMOVAL, subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1, maxFiles: -1,
endpoints: ["remove-certificate-sign"], endpoints: ["remove-cert-sign"],
operationConfig: removeCertificateSignOperationConfig, operationConfig: removeCertificateSignOperationConfig,
synonyms: getSynonyms(t, "removeCertSign"), synonyms: getSynonyms(t, "removeCertSign"),
automationSettings: null, automationSettings: null,
@@ -626,7 +631,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
name: t("home.autoRename.title", "Auto Rename PDF File"), name: t("home.autoRename.title", "Auto Rename PDF File"),
component: AutoRename, component: AutoRename,
maxFiles: -1, maxFiles: -1,
endpoints: ["remove-certificate-sign"], endpoints: ["auto-rename"],
operationConfig: autoRenameOperationConfig, operationConfig: autoRenameOperationConfig,
description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"), description: t("home.autoRename.desc", "Automatically rename PDF files based on their content"),
categoryId: ToolCategoryId.ADVANCED_TOOLS, categoryId: ToolCategoryId.ADVANCED_TOOLS,
@@ -681,6 +686,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"), description: t("home.overlay-pdfs.desc", "Overlay one PDF on top of another"),
categoryId: ToolCategoryId.ADVANCED_TOOLS, categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING, subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
endpoints: ["overlay-pdf"],
operationConfig: overlayPdfsOperationConfig, operationConfig: overlayPdfsOperationConfig,
synonyms: getSynonyms(t, "overlay-pdfs"), synonyms: getSynonyms(t, "overlay-pdfs"),
automationSettings: OverlayPdfsSettings automationSettings: OverlayPdfsSettings
@@ -705,6 +711,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.addImage.desc", "Add images to PDF documents"), description: t("home.addImage.desc", "Add images to PDF documents"),
categoryId: ToolCategoryId.ADVANCED_TOOLS, categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING, subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
endpoints: ["add-image"],
synonyms: getSynonyms(t, "addImage"), synonyms: getSynonyms(t, "addImage"),
automationSettings: null automationSettings: null
}, },
@@ -715,6 +722,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
description: t("home.scannerEffect.desc", "Create a PDF that looks like it was scanned"), description: t("home.scannerEffect.desc", "Create a PDF that looks like it was scanned"),
categoryId: ToolCategoryId.ADVANCED_TOOLS, categoryId: ToolCategoryId.ADVANCED_TOOLS,
subcategoryId: SubcategoryId.ADVANCED_FORMATTING, subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
endpoints: ["scanner-effect"],
synonyms: getSynonyms(t, "scannerEffect"), synonyms: getSynonyms(t, "scannerEffect"),
automationSettings: null automationSettings: null
}, },
@@ -805,6 +813,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.RECOMMENDED_TOOLS, categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL, subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1, maxFiles: -1,
endpoints: ["compress-pdf"],
operationConfig: compressOperationConfig, operationConfig: compressOperationConfig,
automationSettings: CompressSettings, automationSettings: CompressSettings,
synonyms: getSynonyms(t, "compress") synonyms: getSynonyms(t, "compress")
@@ -848,6 +857,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
categoryId: ToolCategoryId.RECOMMENDED_TOOLS, categoryId: ToolCategoryId.RECOMMENDED_TOOLS,
subcategoryId: SubcategoryId.GENERAL, subcategoryId: SubcategoryId.GENERAL,
maxFiles: -1, maxFiles: -1,
endpoints: ["ocr-pdf"],
operationConfig: ocrOperationConfig, operationConfig: ocrOperationConfig,
automationSettings: OCRSettings, automationSettings: OCRSettings,
synonyms: getSynonyms(t, "ocr") synonyms: getSynonyms(t, "ocr")
@@ -14,6 +14,6 @@ export type RemoveCertificateSignParametersHook = BaseParametersHook<RemoveCerti
export const useRemoveCertificateSignParameters = (): RemoveCertificateSignParametersHook => { export const useRemoveCertificateSignParameters = (): RemoveCertificateSignParametersHook => {
return useBaseParameters({ return useBaseParameters({
defaultParameters, defaultParameters,
endpointName: 'remove-certificate-sign', endpointName: 'remove-cert-sign',
}); });
}; };
+77 -44
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient'; import apiClient from '@app/services/apiClient';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
// Track globally fetched endpoint sets to prevent duplicate fetches across components // Track globally fetched endpoint sets to prevent duplicate fetches across components
const globalFetchedSets = new Set<string>(); const globalFetchedSets = new Set<string>();
const globalEndpointCache: Record<string, boolean> = {}; const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
/** /**
* Hook to check if a specific endpoint is enabled * Hook to check if a specific endpoint is enabled
@@ -59,11 +60,13 @@ export function useEndpointEnabled(endpoint: string): {
*/ */
export function useMultipleEndpointsEnabled(endpoints: string[]): { export function useMultipleEndpointsEnabled(endpoints: string[]): {
endpointStatus: Record<string, boolean>; endpointStatus: Record<string, boolean>;
endpointDetails: Record<string, EndpointAvailabilityDetails>;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
refetch: () => Promise<void>; refetch: () => Promise<void>;
} { } {
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({}); const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); 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 // Skip if we already fetched these exact endpoints globally
if (!force && globalFetchedSets.has(endpointsKey)) { if (!force && globalFetchedSets.has(endpointsKey)) {
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache'); console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
const cachedStatus = endpoints.reduce((acc, endpoint) => { const cached = endpoints.reduce(
if (endpoint in globalEndpointCache) { (acc, endpoint) => {
acc[endpoint] = globalEndpointCache[endpoint]; const cachedDetails = globalEndpointCache[endpoint];
} if (cachedDetails) {
return acc; acc.status[endpoint] = cachedDetails.enabled;
}, {} as Record<string, boolean>); acc.details[endpoint] = cachedDetails;
setEndpointStatus(cachedStatus); }
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(cached.status);
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
setLoading(false); setLoading(false);
return; return;
} }
if (!endpoints || endpoints.length === 0) { if (!endpoints || endpoints.length === 0) {
setEndpointStatus({}); setEndpointStatus({});
setLoading(false); setEndpointDetails({});
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);
setLoading(false); setLoading(false);
return; return;
} }
@@ -110,11 +107,19 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache)); const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
if (newEndpoints.length === 0) { if (newEndpoints.length === 0) {
console.debug('[useEndpointConfig] All endpoints already in global cache'); console.debug('[useEndpointConfig] All endpoints already in global cache');
const cachedStatus = endpoints.reduce((acc, endpoint) => { const cached = endpoints.reduce(
acc[endpoint] = globalEndpointCache[endpoint]; (acc, endpoint) => {
return acc; const cachedDetails = globalEndpointCache[endpoint];
}, {} as Record<string, boolean>); if (cachedDetails) {
setEndpointStatus(cachedStatus); 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); globalFetchedSets.add(endpointsKey);
setLoading(false); setLoading(false);
return; return;
@@ -123,30 +128,51 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
// Use batch API for efficiency - only fetch new endpoints // Use batch API for efficiency - only fetch new endpoints
const endpointsParam = newEndpoints.join(','); 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; const statusMap = response.data;
// Update global cache with new results // 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) // Get all requested endpoints from cache (including previously cached ones)
const fullStatus = endpoints.reduce((acc, endpoint) => { const fullStatus = endpoints.reduce(
acc[endpoint] = globalEndpointCache[endpoint] ?? true; // Default to true if not in cache (acc, endpoint) => {
return acc; const cachedDetails = globalEndpointCache[endpoint];
}, {} as Record<string, boolean>); 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); globalFetchedSets.add(endpointsKey);
} catch (err: any) { } catch (err: any) {
// On 401 (auth error), use optimistic fallback instead of disabling // On 401 (auth error), use optimistic fallback instead of disabling
if (err.response?.status === 401) { if (err.response?.status === 401) {
console.warn('[useEndpointConfig] 401 error - using optimistic fallback'); console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
const optimisticStatus = endpoints.reduce((acc, endpoint) => { const optimisticStatus = endpoints.reduce(
acc[endpoint] = true; (acc, endpoint) => {
globalEndpointCache[endpoint] = true; // Cache the optimistic value const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
return acc; acc.status[endpoint] = true;
}, {} as Record<string, boolean>); acc.details[endpoint] = optimisticDetails;
setEndpointStatus(optimisticStatus); 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); setLoading(false);
return; return;
} }
@@ -156,11 +182,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
console.error('[EndpointConfig] Failed to check multiple endpoints:', err); console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
// Fallback: assume all endpoints are enabled on error (optimistic) // Fallback: assume all endpoints are enabled on error (optimistic)
const optimisticStatus = endpoints.reduce((acc, endpoint) => { const optimisticStatus = endpoints.reduce(
acc[endpoint] = true; (acc, endpoint) => {
return acc; const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
}, {} as Record<string, boolean>); acc.status[endpoint] = true;
setEndpointStatus(optimisticStatus); 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 { } finally {
setLoading(false); setLoading(false);
} }
@@ -186,6 +218,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return { return {
endpointStatus, endpointStatus,
endpointDetails,
loading, loading,
error, error,
refetch: () => fetchAllEndpointStatuses(true), refetch: () => fetchAllEndpointStatuses(true),
+67 -11
View File
@@ -1,9 +1,20 @@
import { useState, useCallback, useMemo } from 'react'; import { useState, useCallback, useMemo } from 'react';
import { useToolRegistry } from "@app/contexts/ToolRegistryContext"; import { useToolRegistry } from "@app/contexts/ToolRegistryContext";
import { usePreferences } from '@app/contexts/PreferencesContext';
import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy"; import { getAllEndpoints, type ToolRegistryEntry, type ToolRegistry } from "@app/data/toolsTaxonomy";
import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig"; import { useMultipleEndpointsEnabled } from "@app/hooks/useEndpointConfig";
import { FileId } from '@app/types/file'; import { FileId } from '@app/types/file';
import { ToolId } from "@app/types/toolId"; 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 { interface ToolManagementResult {
selectedTool: ToolRegistryEntry | null; selectedTool: ToolRegistryEntry | null;
@@ -11,6 +22,7 @@ interface ToolManagementResult {
toolRegistry: Partial<ToolRegistry>; toolRegistry: Partial<ToolRegistry>;
setToolSelectedFileIds: (fileIds: FileId[]) => void; setToolSelectedFileIds: (fileIds: FileId[]) => void;
getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null; getSelectedTool: (toolKey: ToolId | null) => ToolRegistryEntry | null;
toolAvailability: ToolAvailabilityMap;
} }
export const useToolManagement = (): ToolManagementResult => { export const useToolManagement = (): ToolManagementResult => {
@@ -19,9 +31,10 @@ export const useToolManagement = (): ToolManagementResult => {
// Build endpoints list from registry entries with fallback to legacy mapping // Build endpoints list from registry entries with fallback to legacy mapping
const { allTools } = useToolRegistry(); const { allTools } = useToolRegistry();
const baseRegistry = allTools; const baseRegistry = allTools;
const { preferences } = usePreferences();
const allEndpoints = useMemo(() => getAllEndpoints(baseRegistry), [baseRegistry]); 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 => { const isToolAvailable = useCallback((toolKey: string): boolean => {
// Keep tools enabled during loading (optimistic UX) // Keep tools enabled during loading (optimistic UX)
@@ -38,22 +51,64 @@ export const useToolManagement = (): ToolManagementResult => {
return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false); return endpoints.some((endpoint: string) => endpointStatus[endpoint] !== false);
}, [endpointsLoading, endpointStatus, baseRegistry]); }, [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 toolRegistry: Partial<ToolRegistry> = useMemo(() => {
const availableToolRegistry: Partial<ToolRegistry> = {}; const availableToolRegistry: Partial<ToolRegistry> = {};
(Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => { (Object.keys(baseRegistry) as ToolId[]).forEach(toolKey => {
if (isToolAvailable(toolKey)) { const baseTool = baseRegistry[toolKey];
const baseTool = baseRegistry[toolKey]; if (!baseTool) return;
if (baseTool) { const availabilityInfo = toolAvailability[toolKey];
availableToolRegistry[toolKey] = { const isAvailable = availabilityInfo ? availabilityInfo.available !== false : true;
...baseTool,
name: baseTool.name, // Check if tool is "coming soon" (has no component and no link)
description: baseTool.description, 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; return availableToolRegistry;
}, [isToolAvailable, baseRegistry]); }, [baseRegistry, preferences.hideUnavailableTools, toolAvailability]);
const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => { const getSelectedTool = useCallback((toolKey: ToolId | null): ToolRegistryEntry | null => {
return toolKey ? toolRegistry[toolKey] || null : null; return toolKey ? toolRegistry[toolKey] || null : null;
@@ -65,5 +120,6 @@ export const useToolManagement = (): ToolManagementResult => {
toolRegistry, toolRegistry,
setToolSelectedFileIds, setToolSelectedFileIds,
getSelectedTool, getSelectedTool,
toolAvailability,
}; };
}; };
@@ -9,6 +9,8 @@ export interface UserPreferences {
toolPanelModePromptSeen: boolean; toolPanelModePromptSeen: boolean;
showLegacyToolDescriptions: boolean; showLegacyToolDescriptions: boolean;
hasCompletedOnboarding: boolean; hasCompletedOnboarding: boolean;
hideUnavailableTools: boolean;
hideUnavailableConversions: boolean;
} }
export const DEFAULT_PREFERENCES: UserPreferences = { export const DEFAULT_PREFERENCES: UserPreferences = {
@@ -19,6 +21,8 @@ export const DEFAULT_PREFERENCES: UserPreferences = {
toolPanelModePromptSeen: false, toolPanelModePromptSeen: false,
showLegacyToolDescriptions: false, showLegacyToolDescriptions: false,
hasCompletedOnboarding: false, hasCompletedOnboarding: false,
hideUnavailableTools: false,
hideUnavailableConversions: false,
}; };
const STORAGE_KEY = 'stirlingpdf_preferences'; const STORAGE_KEY = 'stirlingpdf_preferences';
+2 -2
View File
@@ -1,6 +1,6 @@
export interface UpdateSummary { export interface UpdateSummary {
latest_version: string; latest_version: string | null;
latest_stable_version?: string; latest_stable_version?: string | null;
max_priority: 'urgent' | 'normal' | 'minor' | 'low'; max_priority: 'urgent' | 'normal' | 'minor' | 'low';
recommended_action?: string; recommended_action?: string;
any_breaking: boolean; any_breaking: boolean;
@@ -0,0 +1,6 @@
export type EndpointDisableReason = 'CONFIG' | 'DEPENDENCY' | 'UNKNOWN' | null;
export interface EndpointAvailabilityDetails {
enabled: boolean;
reason?: EndpointDisableReason;
}
@@ -4,8 +4,10 @@ import { useTranslation } from 'react-i18next';
import apiClient from '@app/services/apiClient'; import apiClient from '@app/services/apiClient';
import { tauriBackendService } from '@app/services/tauriBackendService'; import { tauriBackendService } from '@app/services/tauriBackendService';
import { isBackendNotReadyError } from '@app/constants/backendErrors'; import { isBackendNotReadyError } from '@app/constants/backendErrors';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
import { connectionModeService } from '@desktop/services/connectionModeService'; import { connectionModeService } from '@desktop/services/connectionModeService';
interface EndpointConfig { interface EndpointConfig {
backendUrl: string; backendUrl: string;
} }
@@ -128,6 +130,7 @@ export function useEndpointEnabled(endpoint: string): {
export function useMultipleEndpointsEnabled(endpoints: string[]): { export function useMultipleEndpointsEnabled(endpoints: string[]): {
endpointStatus: Record<string, boolean>; endpointStatus: Record<string, boolean>;
endpointDetails: Record<string, EndpointAvailabilityDetails>;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
refetch: () => Promise<void>; refetch: () => Promise<void>;
@@ -140,6 +143,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return acc; return acc;
}, {} as Record<string, boolean>); }, {} as Record<string, boolean>);
}); });
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
@@ -174,13 +178,27 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const endpointsParam = endpoints.join(','); 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 }, params: { endpoints: endpointsParam },
suppressErrorToast: true, suppressErrorToast: true,
}); });
if (!isMountedRef.current) return; 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) { } catch (err: unknown) {
const isBackendStarting = isBackendNotReadyError(err); const isBackendStarting = isBackendNotReadyError(err);
const message = getErrorMessage(err); const message = getErrorMessage(err);
@@ -188,10 +206,13 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message); setError(isBackendStarting ? t('backendHealth.starting', 'Backend starting up...') : message);
const fallbackStatus = endpoints.reduce((acc, endpointName) => { 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; return acc;
}, {} as Record<string, boolean>); }, { status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> });
setEndpointStatus(fallbackStatus); setEndpointStatus(fallbackStatus.status);
setEndpointDetails(prev => ({ ...prev, ...fallbackStatus.details }));
if (!retryTimeoutRef.current) { if (!retryTimeoutRef.current) {
retryTimeoutRef.current = setTimeout(() => { retryTimeoutRef.current = setTimeout(() => {
@@ -209,6 +230,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
useEffect(() => { useEffect(() => {
if (!endpoints || endpoints.length === 0) { if (!endpoints || endpoints.length === 0) {
setEndpointStatus({}); setEndpointStatus({});
setEndpointDetails({});
setLoading(false); setLoading(false);
return; return;
} }
@@ -230,6 +252,7 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
return { return {
endpointStatus, endpointStatus,
endpointDetails,
loading, loading,
error, error,
refetch: fetchAllEndpointStatuses, refetch: fetchAllEndpointStatuses,