mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
Differentiate unavailable tools by reason (#4916)
## Summary - track endpoint disable reasons server-side and expose them through a new `/api/v1/config/endpoints-availability` API that the frontend can consume - refresh the web UI tool management logic to cache endpoint details, compute per-tool availability metadata, and show reason-specific messaging (admin disabled vs missing dependency) when a tool cannot be launched - add the missing en-GB translations for the new unavailability labels so the UI copy reflects the new distinction <img width="1156" height="152" alt="image" src="https://github.com/user-attachments/assets/b54eda37-fe5c-42f9-bd5f-9ee00398d1ae" /> <img width="930" height="168" alt="image" src="https://github.com/user-attachments/assets/47c07ffa-adb7-4ce3-910c-b6ff73f6f993" /> ## Testing - `npm run typecheck:core` *(fails: frontend/src/core/components/shared/LocalIcon.tsx expects ../../../assets/material-symbols-icons.json, which is not present in this environment)* ------ [Codex Task](https://chatgpt.com/codex/tasks/task_b_6919af7a493c8328bb5ac3d07e65452b)
This commit is contained in:
@@ -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(),
|
||||||
|
|||||||
+20
@@ -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")
|
||||||
|
|||||||
+1
-1
@@ -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',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user