mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-20 02:17:05 +00:00
fix: apply cliproxy retry settings safely
This commit is contained in:
1 parent
d5ea4eeea3
commit
480b79551b
11 files changed
+830
-127
No files matched your search
@@ -0,0 +1,112 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCliproxyRetryConfig, useUpdateCliproxyRetryConfig } from '@/hooks/use-cliproxy';
|
||||
|
||||
function parseRetryValue(value: string): number | null {
|
||||
if (!/^\d+$/.test(value)) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export function CliproxyRetryControl() {
|
||||
const { t } = useTranslation();
|
||||
const retryQuery = useCliproxyRetryConfig();
|
||||
const updateRetry = useUpdateCliproxyRetryConfig();
|
||||
const requestRetry = retryQuery.data?.request_retry ?? 0;
|
||||
const maxRetryInterval = retryQuery.data?.max_retry_interval ?? 0;
|
||||
const manageable = retryQuery.data?.manageable !== false;
|
||||
const disabled =
|
||||
retryQuery.isLoading || retryQuery.isError || updateRetry.isPending || !manageable;
|
||||
const statusMessage = retryQuery.error?.message ?? retryQuery.data?.message;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium text-foreground">
|
||||
{t('routingGuidance.retryTitle')}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t('routingGuidance.retryHint')}
|
||||
{retryQuery.data ? ` · ${retryQuery.data.source} · ${retryQuery.data.target}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<RetryInputs
|
||||
key={`${requestRetry}:${maxRetryInterval}`}
|
||||
requestRetry={requestRetry}
|
||||
maxRetryInterval={maxRetryInterval}
|
||||
disabled={disabled}
|
||||
onUpdate={(nextRequestRetry, nextMaxRetryInterval) =>
|
||||
updateRetry.mutate({
|
||||
request_retry: nextRequestRetry,
|
||||
max_retry_interval: nextMaxRetryInterval,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{statusMessage ? (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5 text-[10px] text-muted-foreground">
|
||||
{statusMessage}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RetryInputsProps {
|
||||
requestRetry: number;
|
||||
maxRetryInterval: number;
|
||||
disabled: boolean;
|
||||
onUpdate: (requestRetry: number, maxRetryInterval: number) => void;
|
||||
}
|
||||
|
||||
function RetryInputs({ requestRetry, maxRetryInterval, disabled, onUpdate }: RetryInputsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [requestRetryInput, setRequestRetryInput] = useState(String(requestRetry));
|
||||
const [maxRetryIntervalInput, setMaxRetryIntervalInput] = useState(String(maxRetryInterval));
|
||||
const [fieldError, setFieldError] = useState<string | null>(null);
|
||||
|
||||
const handleBlur = () => {
|
||||
if (disabled) return;
|
||||
const nextRequestRetry = parseRetryValue(requestRetryInput.trim());
|
||||
const nextMaxRetryInterval = parseRetryValue(maxRetryIntervalInput.trim());
|
||||
if (nextRequestRetry === null || nextMaxRetryInterval === null) {
|
||||
setFieldError(t('routingGuidance.retryRangeError'));
|
||||
setRequestRetryInput(String(requestRetry));
|
||||
setMaxRetryIntervalInput(String(maxRetryInterval));
|
||||
return;
|
||||
}
|
||||
|
||||
setFieldError(null);
|
||||
if (nextRequestRetry !== requestRetry || nextMaxRetryInterval !== maxRetryInterval) {
|
||||
onUpdate(nextRequestRetry, nextMaxRetryInterval);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
aria-label={t('routingGuidance.requestRetryLabel')}
|
||||
inputMode="numeric"
|
||||
className="h-6 w-10 rounded border border-border/70 bg-background px-1.5 text-[10px] text-foreground"
|
||||
value={requestRetryInput}
|
||||
onChange={(event) => setRequestRetryInput(event.target.value)}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">/</span>
|
||||
<input
|
||||
aria-label={t('routingGuidance.maxRetryIntervalLabel')}
|
||||
inputMode="numeric"
|
||||
className="h-6 w-10 rounded border border-border/70 bg-background px-1.5 text-[10px] text-foreground"
|
||||
value={maxRetryIntervalInput}
|
||||
onChange={(event) => setMaxRetryIntervalInput(event.target.value)}
|
||||
onBlur={handleBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
{fieldError ? <div className="text-[10px] text-destructive">{fieldError}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,15 +7,10 @@ import type {
|
||||
RoutingStrategy,
|
||||
CliproxySessionAffinityState,
|
||||
} from '@/lib/api-client';
|
||||
import { useCliproxyRetryConfig, useUpdateCliproxyRetryConfig } from '@/hooks/use-cliproxy';
|
||||
import { CliproxyRetryControl } from './cliproxy-retry-control';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
/** Retry fields accept only non-negative integers (matches the CLIProxy schema bounds). */
|
||||
function isValidRetryFieldValue(value: number): boolean {
|
||||
return Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
|
||||
interface RoutingGuidanceCardProps {
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
@@ -79,17 +74,6 @@ export function RoutingGuidanceCard({
|
||||
const pendingAffinityRef = useRef<{ enabled: boolean; ttl: string } | null>(null);
|
||||
const suppressNextAffinityBlurRef = useRef(false);
|
||||
|
||||
const retryConfigQuery = useCliproxyRetryConfig();
|
||||
const updateRetryConfig = useUpdateCliproxyRetryConfig();
|
||||
const currentRequestRetry = retryConfigQuery.data?.request_retry ?? 0;
|
||||
const currentMaxRetryInterval = retryConfigQuery.data?.max_retry_interval ?? 0;
|
||||
const [requestRetryInput, setRequestRetryInput] = useState(String(currentRequestRetry));
|
||||
const [maxRetryIntervalInput, setMaxRetryIntervalInput] = useState(
|
||||
String(currentMaxRetryInterval)
|
||||
);
|
||||
const [retryFieldError, setRetryFieldError] = useState<string | null>(null);
|
||||
const retryControlDisabled = retryConfigQuery.isLoading || updateRetryConfig.isPending;
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(currentStrategy);
|
||||
}, [currentStrategy]);
|
||||
@@ -99,11 +83,6 @@ export function RoutingGuidanceCard({
|
||||
setSelectedAffinityTtl(currentAffinityTtl);
|
||||
}, [currentAffinityEnabled, currentAffinityTtl]);
|
||||
|
||||
useEffect(() => {
|
||||
setRequestRetryInput(String(currentRequestRetry));
|
||||
setMaxRetryIntervalInput(String(currentMaxRetryInterval));
|
||||
}, [currentRequestRetry, currentMaxRetryInterval]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSaving || !pendingAffinityRef.current) {
|
||||
return;
|
||||
@@ -144,34 +123,6 @@ export function RoutingGuidanceCard({
|
||||
onApplyAffinity({ enabled: selectedAffinityEnabled, ttl: nextTtl });
|
||||
};
|
||||
|
||||
const handleRetryBlur = () => {
|
||||
const nextRequestRetry = Number(requestRetryInput.trim());
|
||||
const nextMaxRetryInterval = Number(maxRetryIntervalInput.trim());
|
||||
|
||||
if (
|
||||
!isValidRetryFieldValue(nextRequestRetry) ||
|
||||
!isValidRetryFieldValue(nextMaxRetryInterval)
|
||||
) {
|
||||
setRetryFieldError(t('routingGuidance.retryRangeError'));
|
||||
setRequestRetryInput(String(currentRequestRetry));
|
||||
setMaxRetryIntervalInput(String(currentMaxRetryInterval));
|
||||
return;
|
||||
}
|
||||
|
||||
setRetryFieldError(null);
|
||||
if (
|
||||
nextRequestRetry === currentRequestRetry &&
|
||||
nextMaxRetryInterval === currentMaxRetryInterval
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateRetryConfig.mutate({
|
||||
request_retry: nextRequestRetry,
|
||||
max_retry_interval: nextMaxRetryInterval,
|
||||
});
|
||||
};
|
||||
|
||||
if (compact) {
|
||||
const handleApply = (s: RoutingStrategy) => {
|
||||
setSelected(s);
|
||||
@@ -325,43 +276,7 @@ export function RoutingGuidanceCard({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-muted/20 px-2 py-1.5">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] font-medium text-foreground">
|
||||
{t('routingGuidance.retryTitle')}
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">
|
||||
{t('routingGuidance.retryHint')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
aria-label={t('routingGuidance.requestRetryLabel')}
|
||||
inputMode="numeric"
|
||||
className="h-6 w-10 rounded border border-border/70 bg-background px-1.5 text-[10px] text-foreground"
|
||||
value={requestRetryInput}
|
||||
onChange={(event) => setRequestRetryInput(event.target.value.replace(/\D/g, ''))}
|
||||
onBlur={handleRetryBlur}
|
||||
disabled={retryControlDisabled}
|
||||
/>
|
||||
<span className="text-[10px] text-muted-foreground">/</span>
|
||||
<input
|
||||
aria-label={t('routingGuidance.maxRetryIntervalLabel')}
|
||||
inputMode="numeric"
|
||||
className="h-6 w-10 rounded border border-border/70 bg-background px-1.5 text-[10px] text-foreground"
|
||||
value={maxRetryIntervalInput}
|
||||
onChange={(event) => setMaxRetryIntervalInput(event.target.value.replace(/\D/g, ''))}
|
||||
onBlur={handleRetryBlur}
|
||||
disabled={retryControlDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{retryFieldError ? (
|
||||
<div className="rounded-lg border border-destructive/25 bg-destructive/5 px-2 py-1.5 text-[10px] text-destructive">
|
||||
{retryFieldError}
|
||||
</div>
|
||||
) : null}
|
||||
<CliproxyRetryControl />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
type CreatePreset,
|
||||
type RoutingStrategy,
|
||||
type CliproxySessionAffinityApplyResult,
|
||||
type CliproxyRetryApplyResult,
|
||||
type CliproxyRetryValues,
|
||||
} from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -106,57 +108,23 @@ export function useUpdateCliproxySessionAffinity() {
|
||||
});
|
||||
}
|
||||
|
||||
/** CLIProxy request-retry config (config.cliproxy.retry). Defaults to disabled (0/0). */
|
||||
export interface CliproxyRetryConfig {
|
||||
request_retry: number;
|
||||
max_retry_interval: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CLIPROXY_RETRY_CONFIG: CliproxyRetryConfig = {
|
||||
request_retry: 0,
|
||||
max_retry_interval: 0,
|
||||
};
|
||||
|
||||
export function useCliproxyRetryConfig() {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-retry-config'],
|
||||
queryFn: async (): Promise<CliproxyRetryConfig> => {
|
||||
const config = await api.config.get();
|
||||
const cliproxy = config.cliproxy as { retry?: Partial<CliproxyRetryConfig> } | undefined;
|
||||
return {
|
||||
request_retry:
|
||||
cliproxy?.retry?.request_retry ?? DEFAULT_CLIPROXY_RETRY_CONFIG.request_retry,
|
||||
max_retry_interval:
|
||||
cliproxy?.retry?.max_retry_interval ?? DEFAULT_CLIPROXY_RETRY_CONFIG.max_retry_interval,
|
||||
};
|
||||
},
|
||||
queryFn: () => api.cliproxy.getRetrySettings(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Save CLIProxy retry config. Reuses the generic unified-config save path
|
||||
* (PUT /config) — retry is a static opt-in default, not a live-proxy setting
|
||||
* like routing.strategy/session_affinity, so it does not need a dedicated
|
||||
* management-API round trip.
|
||||
*/
|
||||
export function useUpdateCliproxyRetryConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (retry: CliproxyRetryConfig): Promise<CliproxyRetryConfig> => {
|
||||
const config = await api.config.get();
|
||||
const existingCliproxy = (config.cliproxy ?? {}) as Record<string, unknown>;
|
||||
await api.config.update({
|
||||
...config,
|
||||
cliproxy: { ...existingCliproxy, retry },
|
||||
});
|
||||
return retry;
|
||||
},
|
||||
onSuccess: (retry) => {
|
||||
queryClient.setQueryData(['cliproxy-retry-config'], retry);
|
||||
mutationFn: (retry: CliproxyRetryValues) => api.cliproxy.updateRetrySettings(retry),
|
||||
onSuccess: (result: CliproxyRetryApplyResult) => {
|
||||
queryClient.setQueryData(['cliproxy-retry-config'], result);
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-retry-config'] });
|
||||
toast.success(t('toasts.cliproxyRetryUpdated'));
|
||||
toast.success(result.message || t('toasts.cliproxyRetryUpdated'));
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
|
||||
@@ -550,6 +550,23 @@ export interface CliproxySessionAffinityApplyResult extends CliproxySessionAffin
|
||||
applied: 'config-and-live' | 'config-only' | 'unsupported';
|
||||
}
|
||||
|
||||
export interface CliproxyRetryValues {
|
||||
request_retry: number;
|
||||
max_retry_interval: number;
|
||||
}
|
||||
|
||||
export interface CliproxyRetryState extends CliproxyRetryValues {
|
||||
source: 'live' | 'config';
|
||||
target: 'local' | 'remote';
|
||||
reachable: boolean;
|
||||
manageable: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface CliproxyRetryApplyResult extends CliproxyRetryState {
|
||||
applied: 'live' | 'live-and-config' | 'config-only';
|
||||
}
|
||||
|
||||
/** Auth file info for Config tab */
|
||||
export interface AuthFile {
|
||||
name: string;
|
||||
@@ -1327,6 +1344,12 @@ export const api = {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getRetrySettings: () => request<CliproxyRetryState>('/cliproxy/retry'),
|
||||
updateRetrySettings: (data: CliproxyRetryValues) =>
|
||||
request<CliproxyRetryApplyResult>('/cliproxy/retry', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
aiProviders: {
|
||||
list: () => request<ListAiProvidersResult>('/cliproxy/ai-providers'),
|
||||
create: (family: AiProviderFamilyId, data: UpsertAiProviderEntryInput) =>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen } from '../../../setup/test-utils';
|
||||
import { CliproxyRetryControl } from '@/components/cliproxy/cliproxy-retry-control';
|
||||
|
||||
const hookState = vi.hoisted(() => ({
|
||||
query: {
|
||||
data: {
|
||||
request_retry: 2,
|
||||
max_retry_interval: 20,
|
||||
source: 'live' as const,
|
||||
target: 'local' as const,
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
message: undefined as string | undefined,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null as Error | null,
|
||||
},
|
||||
mutation: {
|
||||
isPending: false,
|
||||
mutate: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-cliproxy', () => ({
|
||||
useCliproxyRetryConfig: () => hookState.query,
|
||||
useUpdateCliproxyRetryConfig: () => hookState.mutation,
|
||||
}));
|
||||
|
||||
describe('CliproxyRetryControl', () => {
|
||||
beforeEach(() => {
|
||||
hookState.query.data = {
|
||||
request_retry: 2,
|
||||
max_retry_interval: 20,
|
||||
source: 'live',
|
||||
target: 'local',
|
||||
reachable: true,
|
||||
manageable: true,
|
||||
message: undefined,
|
||||
};
|
||||
hookState.query.isLoading = false;
|
||||
hookState.query.isError = false;
|
||||
hookState.query.error = null;
|
||||
hookState.mutation.isPending = false;
|
||||
hookState.mutation.mutate.mockReset();
|
||||
});
|
||||
|
||||
it('updates the pair through the dedicated retry mutation', () => {
|
||||
render(<CliproxyRetryControl />);
|
||||
const requestRetry = screen.getByRole('textbox', { name: 'Request retry count' });
|
||||
fireEvent.change(requestRetry, { target: { value: '4' } });
|
||||
fireEvent.blur(requestRetry);
|
||||
|
||||
expect(hookState.mutation.mutate).toHaveBeenCalledWith({
|
||||
request_retry: 4,
|
||||
max_retry_interval: 20,
|
||||
});
|
||||
expect(screen.getByText(/live · local/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects values outside the safe non-negative integer range', () => {
|
||||
render(<CliproxyRetryControl />);
|
||||
const requestRetry = screen.getByRole('textbox', { name: 'Request retry count' });
|
||||
fireEvent.change(requestRetry, { target: { value: String(Number.MAX_SAFE_INTEGER + 1) } });
|
||||
fireEvent.blur(requestRetry);
|
||||
|
||||
expect(hookState.mutation.mutate).not.toHaveBeenCalled();
|
||||
expect(screen.getByText('Must be a whole number, 0 or greater.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables editing on query errors, unmanageable state, or a pending update', () => {
|
||||
hookState.query.isError = true;
|
||||
hookState.query.error = new Error('Retry management unavailable');
|
||||
const { rerender } = render(<CliproxyRetryControl />);
|
||||
expect(screen.getByRole('textbox', { name: 'Request retry count' })).toBeDisabled();
|
||||
expect(screen.getByText('Retry management unavailable')).toBeInTheDocument();
|
||||
|
||||
hookState.query.isError = false;
|
||||
hookState.query.error = null;
|
||||
hookState.query.data = { ...hookState.query.data, manageable: false };
|
||||
rerender(<CliproxyRetryControl />);
|
||||
expect(screen.getByRole('textbox', { name: 'Request retry count' })).toBeDisabled();
|
||||
|
||||
hookState.query.data = { ...hookState.query.data, manageable: true };
|
||||
hookState.mutation.isPending = true;
|
||||
rerender(<CliproxyRetryControl />);
|
||||
expect(screen.getByRole('textbox', { name: 'Request retry count' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user