## 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)
227 lines
8.1 KiB
TypeScript
227 lines
8.1 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import apiClient from '@app/services/apiClient';
|
|
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
|
|
|
|
// Track globally fetched endpoint sets to prevent duplicate fetches across components
|
|
const globalFetchedSets = new Set<string>();
|
|
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
|
|
|
|
/**
|
|
* Hook to check if a specific endpoint is enabled
|
|
* This wraps the context for single endpoint checks
|
|
*/
|
|
export function useEndpointEnabled(endpoint: string): {
|
|
enabled: boolean | null;
|
|
loading: boolean;
|
|
error: string | null;
|
|
refetch: () => Promise<void>;
|
|
} {
|
|
const [enabled, setEnabled] = useState<boolean | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchEndpointStatus = async () => {
|
|
if (!endpoint) {
|
|
setEnabled(null);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
const response = await apiClient.get<boolean>(`/api/v1/config/endpoint-enabled?endpoint=${encodeURIComponent(endpoint)}`);
|
|
const isEnabled = response.data;
|
|
setEnabled(isEnabled);
|
|
} catch (err) {
|
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
|
setError(errorMessage);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchEndpointStatus();
|
|
}, [endpoint]);
|
|
|
|
return {
|
|
enabled,
|
|
loading,
|
|
error,
|
|
refetch: fetchEndpointStatus,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hook to check multiple endpoints at once using batch API
|
|
* Returns a map of endpoint -> enabled status
|
|
*/
|
|
export function useMultipleEndpointsEnabled(endpoints: string[]): {
|
|
endpointStatus: Record<string, boolean>;
|
|
endpointDetails: Record<string, EndpointAvailabilityDetails>;
|
|
loading: boolean;
|
|
error: string | null;
|
|
refetch: () => Promise<void>;
|
|
} {
|
|
const [endpointStatus, setEndpointStatus] = useState<Record<string, boolean>>({});
|
|
const [endpointDetails, setEndpointDetails] = useState<Record<string, EndpointAvailabilityDetails>>({});
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const fetchAllEndpointStatuses = async (force = false) => {
|
|
const endpointsKey = [...endpoints].sort().join(',');
|
|
|
|
// Skip if we already fetched these exact endpoints globally
|
|
if (!force && globalFetchedSets.has(endpointsKey)) {
|
|
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
|
|
const cached = endpoints.reduce(
|
|
(acc, endpoint) => {
|
|
const cachedDetails = globalEndpointCache[endpoint];
|
|
if (cachedDetails) {
|
|
acc.status[endpoint] = cachedDetails.enabled;
|
|
acc.details[endpoint] = cachedDetails;
|
|
}
|
|
return acc;
|
|
},
|
|
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
|
);
|
|
setEndpointStatus(cached.status);
|
|
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
if (!endpoints || endpoints.length === 0) {
|
|
setEndpointStatus({});
|
|
setEndpointDetails({});
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoading(true);
|
|
setError(null);
|
|
|
|
// Check which endpoints we haven't fetched yet
|
|
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
|
|
if (newEndpoints.length === 0) {
|
|
console.debug('[useEndpointConfig] All endpoints already in global cache');
|
|
const cached = endpoints.reduce(
|
|
(acc, endpoint) => {
|
|
const cachedDetails = globalEndpointCache[endpoint];
|
|
if (cachedDetails) {
|
|
acc.status[endpoint] = cachedDetails.enabled;
|
|
acc.details[endpoint] = cachedDetails;
|
|
}
|
|
return acc;
|
|
},
|
|
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
|
);
|
|
setEndpointStatus(cached.status);
|
|
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
|
|
globalFetchedSets.add(endpointsKey);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
// Use batch API for efficiency - only fetch new endpoints
|
|
const endpointsParam = newEndpoints.join(',');
|
|
|
|
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
|
|
const statusMap = response.data;
|
|
|
|
// Update global cache with new results
|
|
Object.entries(statusMap).forEach(([endpoint, details]) => {
|
|
globalEndpointCache[endpoint] = {
|
|
enabled: details?.enabled ?? true,
|
|
reason: details?.reason ?? null,
|
|
};
|
|
});
|
|
|
|
// Get all requested endpoints from cache (including previously cached ones)
|
|
const fullStatus = endpoints.reduce(
|
|
(acc, endpoint) => {
|
|
const cachedDetails = globalEndpointCache[endpoint];
|
|
if (cachedDetails) {
|
|
acc.status[endpoint] = cachedDetails.enabled;
|
|
acc.details[endpoint] = cachedDetails;
|
|
} else {
|
|
acc.status[endpoint] = true;
|
|
}
|
|
return acc;
|
|
},
|
|
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
|
);
|
|
|
|
setEndpointStatus(fullStatus.status);
|
|
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
|
|
globalFetchedSets.add(endpointsKey);
|
|
} catch (err: any) {
|
|
// On 401 (auth error), use optimistic fallback instead of disabling
|
|
if (err.response?.status === 401) {
|
|
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
|
|
const optimisticStatus = endpoints.reduce(
|
|
(acc, endpoint) => {
|
|
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
|
acc.status[endpoint] = true;
|
|
acc.details[endpoint] = optimisticDetails;
|
|
globalEndpointCache[endpoint] = optimisticDetails;
|
|
return acc;
|
|
},
|
|
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
|
);
|
|
setEndpointStatus(optimisticStatus.status);
|
|
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
|
|
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
|
|
setError(errorMessage);
|
|
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
|
|
|
|
// Fallback: assume all endpoints are enabled on error (optimistic)
|
|
const optimisticStatus = endpoints.reduce(
|
|
(acc, endpoint) => {
|
|
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
|
|
acc.status[endpoint] = true;
|
|
acc.details[endpoint] = optimisticDetails;
|
|
return acc;
|
|
},
|
|
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
|
|
);
|
|
setEndpointStatus(optimisticStatus.status);
|
|
setEndpointDetails(prev => ({ ...prev, ...optimisticStatus.details }));
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchAllEndpointStatuses();
|
|
}, [endpoints.join(',')]); // Re-run when endpoints array changes
|
|
|
|
// Listen for JWT availability (triggered on login/signup)
|
|
useEffect(() => {
|
|
const handleJwtAvailable = () => {
|
|
console.debug('[useEndpointConfig] JWT available event - clearing cache for refetch with auth');
|
|
// Clear the global cache to allow refetch with JWT
|
|
globalFetchedSets.clear();
|
|
Object.keys(globalEndpointCache).forEach(key => delete globalEndpointCache[key]);
|
|
fetchAllEndpointStatuses(true);
|
|
};
|
|
|
|
window.addEventListener('jwt-available', handleJwtAvailable);
|
|
return () => window.removeEventListener('jwt-available', handleJwtAvailable);
|
|
}, [endpoints.join(',')]);
|
|
|
|
return {
|
|
endpointStatus,
|
|
endpointDetails,
|
|
loading,
|
|
error,
|
|
refetch: () => fetchAllEndpointStatuses(true),
|
|
};
|
|
}
|