From 5f62e6f81fb4c52d5bb6f40d614938eef757eabb Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Sun, 16 Nov 2025 22:05:25 +0000 Subject: [PATCH] Differentiate unavailable tools by reason --- .../SPDF/config/EndpointConfiguration.java | 85 +++++++++++- .../SPDF/config/ExternalAppDepConfig.java | 11 +- .../controller/api/misc/ConfigController.java | 13 ++ .../public/locales/en-GB/translation.json | 2 + .../config/configSections/GeneralSection.tsx | 14 ++ .../tools/fullscreen/CompactToolItem.tsx | 9 +- .../tools/fullscreen/DetailedToolItem.tsx | 14 +- .../components/tools/fullscreen/shared.ts | 53 +++++++- .../tools/toolPicker/ToolButton.tsx | 13 +- .../src/core/contexts/ToolWorkflowContext.tsx | 14 +- .../core/data/useTranslatedToolRegistry.tsx | 7 +- .../useRemoveCertificateSignParameters.ts | 2 +- frontend/src/core/hooks/useEndpointConfig.ts | 121 +++++++++++------- frontend/src/core/hooks/useToolManagement.tsx | 74 +++++++++-- .../src/core/services/preferencesService.ts | 2 + .../src/core/types/endpointAvailability.ts | 6 + .../src/desktop/hooks/useEndpointConfig.ts | 32 ++++- 17 files changed, 381 insertions(+), 91 deletions(-) create mode 100644 frontend/src/core/types/endpointAvailability.ts diff --git a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index 0178c25971..04290bd3c5 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -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 endpointStatuses = new ConcurrentHashMap<>(); private Map> endpointGroups = new ConcurrentHashMap<>(); private Set disabledGroups = new HashSet<>(); + private Map endpointDisableReasons = new ConcurrentHashMap<>(); + private Map groupDisableReasons = new ConcurrentHashMap<>(); private Map> 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 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 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> entry : endpointGroups.entrySet()) { + String group = entry.getKey(); + Set 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 getDisabledGroups() { return new HashSet<>(disabledGroups); } diff --git a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java index fd3ab640d9..8c3a046f49 100644 --- a/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/config/ExternalAppDepConfig.java @@ -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 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 pythonFeatures = getAffectedFeatures("Python"); List 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 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 openCVFeatures = getAffectedFeatures("OpenCV"); - endpointConfiguration.disableGroup("OpenCV"); + endpointConfiguration.disableGroup("OpenCV", DisableReason.DEPENDENCY); log.warn( "Error checking OpenCV: {} - Disabling OpenCV features: {}", e.getMessage(), diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index ffbec5a7d8..25025c6803 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -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> getEndpointAvailability( + @RequestParam(name = "endpoints") String endpoints) { + Map 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); + } } diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index a0f7a2666e..22cba601d1 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -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", diff --git a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx index de9994705b..43370c8fa8 100644 --- a/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/core/components/shared/config/configSections/GeneralSection.tsx @@ -114,6 +114,20 @@ const GeneralSection: React.FC = ({ hideTitle = false }) => ]} /> +
+
+ + {t('settings.general.hideUnavailableTools', 'Hide unavailable tools')} + + + {t('settings.general.hideUnavailableToolsDescription', 'Remove tools that have been disabled by your server instead of showing them greyed out.')} + +
+ updatePreference('hideUnavailableTools', event.currentTarget.checked)} + /> +
= ({ 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 = ({ id, tool, isSelected, ); + const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason); + const disabledMessage = t(disabledKey, disabledFallback); + const tooltipContent = disabled ? ( - {t('toolPanel.fullscreen.comingSoon', 'Coming soon:')} {tool.description} + {disabledMessage} {tool.description} ) : (
diff --git a/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx b/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx index 2ebf93b357..8352a3c4e3 100644 --- a/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx +++ b/frontend/src/core/components/tools/fullscreen/DetailedToolItem.tsx @@ -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 = ({ 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 = ({ id, tool, isSelecte iconNode = tool.icon; } + const { key: disabledKey, fallback: disabledFallback } = getDisabledLabel(disabledReason); + const disabledMessage = t(disabledKey, disabledFallback); + return (