mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 09:37:37 +08:00
feat(proxy): implement streaming timeout control with validation
- Add first byte timeout (0 or 1-180s) for streaming requests - Add idle timeout (0 or 60-600s) for streaming data gaps - Add non-streaming timeout (0 or 60-1800s) for total request - Implement timeout logic in response processor - Add 1800s global timeout fallback when disabled - Add database schema migration for timeout fields - Add i18n translations for timeout settings
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
useCircuitBreakerConfig,
|
||||
useUpdateCircuitBreakerConfig,
|
||||
} from "@/lib/query/failover";
|
||||
import { useProxyConfig } from "@/hooks/useProxyConfig";
|
||||
|
||||
export interface AutoFailoverConfigPanelProps {
|
||||
enabled?: boolean;
|
||||
@@ -26,6 +27,12 @@ export function AutoFailoverConfigPanel({
|
||||
const { t } = useTranslation();
|
||||
const { data: config, isLoading, error } = useCircuitBreakerConfig();
|
||||
const updateConfig = useUpdateCircuitBreakerConfig();
|
||||
const {
|
||||
config: proxyConfig,
|
||||
isLoading: isProxyLoading,
|
||||
updateConfig: updateProxyConfig,
|
||||
isUpdating: isProxyUpdating,
|
||||
} = useProxyConfig();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
failureThreshold: 5,
|
||||
@@ -34,6 +41,11 @@ export function AutoFailoverConfigPanel({
|
||||
errorRateThreshold: 0.5,
|
||||
minRequests: 10,
|
||||
});
|
||||
const [timeoutConfig, setTimeoutConfig] = useState({
|
||||
streaming_first_byte_timeout: 30,
|
||||
streaming_idle_timeout: 60,
|
||||
non_streaming_timeout: 600,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
@@ -43,6 +55,17 @@ export function AutoFailoverConfigPanel({
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (proxyConfig) {
|
||||
setTimeoutConfig({
|
||||
streaming_first_byte_timeout:
|
||||
proxyConfig.streaming_first_byte_timeout ?? 30,
|
||||
streaming_idle_timeout: proxyConfig.streaming_idle_timeout ?? 60,
|
||||
non_streaming_timeout: proxyConfig.non_streaming_timeout ?? 300,
|
||||
});
|
||||
}
|
||||
}, [proxyConfig]);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await updateConfig.mutateAsync({
|
||||
@@ -52,6 +75,15 @@ export function AutoFailoverConfigPanel({
|
||||
errorRateThreshold: formData.errorRateThreshold,
|
||||
minRequests: formData.minRequests,
|
||||
});
|
||||
if (proxyConfig) {
|
||||
await updateProxyConfig({
|
||||
...proxyConfig,
|
||||
streaming_first_byte_timeout:
|
||||
timeoutConfig.streaming_first_byte_timeout,
|
||||
streaming_idle_timeout: timeoutConfig.streaming_idle_timeout,
|
||||
non_streaming_timeout: timeoutConfig.non_streaming_timeout,
|
||||
});
|
||||
}
|
||||
toast.success(
|
||||
t("proxy.autoFailover.configSaved", "自动故障转移配置已保存"),
|
||||
{ closeButton: true },
|
||||
@@ -69,6 +101,14 @@ export function AutoFailoverConfigPanel({
|
||||
...config,
|
||||
});
|
||||
}
|
||||
if (proxyConfig) {
|
||||
setTimeoutConfig({
|
||||
streaming_first_byte_timeout:
|
||||
proxyConfig.streaming_first_byte_timeout ?? 30,
|
||||
streaming_idle_timeout: proxyConfig.streaming_idle_timeout ?? 60,
|
||||
non_streaming_timeout: proxyConfig.non_streaming_timeout ?? 300,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
@@ -255,20 +295,134 @@ export function AutoFailoverConfigPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 代理请求超时配置 */}
|
||||
<div className="space-y-4 rounded-lg border border-white/10 bg-muted/30 p-4">
|
||||
<h4 className="text-sm font-semibold">
|
||||
{t("proxy.settings.timeout.title", {
|
||||
defaultValue: "超时设置",
|
||||
})}
|
||||
</h4>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="streamingFirstByteTimeout">
|
||||
{t("proxy.settings.fields.streamingFirstByteTimeout.label", {
|
||||
defaultValue: "流式首字超时(秒)",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="streamingFirstByteTimeout"
|
||||
type="number"
|
||||
min="0"
|
||||
max="180"
|
||||
value={timeoutConfig.streaming_first_byte_timeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
if (val === 0 || (val >= 1 && val <= 180)) {
|
||||
setTimeoutConfig({
|
||||
...timeoutConfig,
|
||||
streaming_first_byte_timeout: val,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!enabled || isProxyLoading || isProxyUpdating}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"proxy.settings.fields.streamingFirstByteTimeout.description",
|
||||
"等待首个数据块的最大时间(0 禁用,范围 1-180 秒)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="streamingIdleTimeout">
|
||||
{t("proxy.settings.fields.streamingIdleTimeout.label", {
|
||||
defaultValue: "流式静默超时(秒)",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="streamingIdleTimeout"
|
||||
type="number"
|
||||
min="0"
|
||||
max="600"
|
||||
value={timeoutConfig.streaming_idle_timeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
if (val === 0 || (val >= 60 && val <= 600)) {
|
||||
setTimeoutConfig({
|
||||
...timeoutConfig,
|
||||
streaming_idle_timeout: val,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!enabled || isProxyLoading || isProxyUpdating}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"proxy.settings.fields.streamingIdleTimeout.description",
|
||||
"数据块之间的最大间隔(0 禁用,范围 60-600 秒)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="nonStreamingTimeout">
|
||||
{t("proxy.settings.fields.nonStreamingTimeout.label", {
|
||||
defaultValue: "非流式超时(秒)",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="nonStreamingTimeout"
|
||||
type="number"
|
||||
min="0"
|
||||
max="1800"
|
||||
value={timeoutConfig.non_streaming_timeout}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value) || 0;
|
||||
if (val === 0 || (val >= 60 && val <= 1800)) {
|
||||
setTimeoutConfig({
|
||||
...timeoutConfig,
|
||||
non_streaming_timeout: val,
|
||||
});
|
||||
}
|
||||
}}
|
||||
disabled={!enabled || isProxyLoading || isProxyUpdating}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"proxy.settings.fields.nonStreamingTimeout.description",
|
||||
"非流式请求的总超时时间(0 禁用,范围 60-1800 秒)",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={updateConfig.isPending || !enabled}
|
||||
disabled={
|
||||
updateConfig.isPending ||
|
||||
isProxyLoading ||
|
||||
isProxyUpdating ||
|
||||
!enabled
|
||||
}
|
||||
>
|
||||
{t("common.reset", "重置")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={updateConfig.isPending || !enabled}
|
||||
disabled={
|
||||
updateConfig.isPending ||
|
||||
isProxyLoading ||
|
||||
isProxyUpdating ||
|
||||
!enabled
|
||||
}
|
||||
>
|
||||
{updateConfig.isPending ? (
|
||||
{updateConfig.isPending || isProxyUpdating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("common.saving", "保存中...")}
|
||||
|
||||
@@ -34,29 +34,45 @@ type ProxyConfigForm = Pick<
|
||||
| "max_retries"
|
||||
| "request_timeout"
|
||||
| "enable_logging"
|
||||
| "streaming_first_byte_timeout"
|
||||
| "streaming_idle_timeout"
|
||||
| "non_streaming_timeout"
|
||||
>;
|
||||
|
||||
const createProxyConfigSchema = (t: TFunction) => {
|
||||
const requestTimeoutSchema = z
|
||||
// 流式首字节超时:0 或 1-180 秒
|
||||
const streamingFirstByteSchema = z
|
||||
.number()
|
||||
.min(
|
||||
0,
|
||||
t("proxy.settings.validation.timeoutNonNegative", {
|
||||
defaultValue: "超时时间不能为负数",
|
||||
}),
|
||||
)
|
||||
.max(
|
||||
600,
|
||||
t("proxy.settings.validation.timeoutMax", {
|
||||
defaultValue: "超时时间最多600秒",
|
||||
}),
|
||||
)
|
||||
.refine((value) => value === 0 || value >= 10, {
|
||||
message: t("proxy.settings.validation.timeoutRange", {
|
||||
defaultValue: "请输入 0 或 10-600 之间的数值",
|
||||
.min(0)
|
||||
.refine((val) => val === 0 || (val >= 1 && val <= 180), {
|
||||
message: t("proxy.settings.validation.streamingFirstByteRange", {
|
||||
defaultValue: "请输入 0 或 1-180 之间的数值",
|
||||
}),
|
||||
});
|
||||
|
||||
// 流式静默期超时:0 或 60-600 秒
|
||||
const streamingIdleSchema = z
|
||||
.number()
|
||||
.min(0)
|
||||
.refine((val) => val === 0 || (val >= 60 && val <= 600), {
|
||||
message: t("proxy.settings.validation.streamingIdleRange", {
|
||||
defaultValue: "请输入 0 或 60-600 之间的数值",
|
||||
}),
|
||||
});
|
||||
|
||||
// 非流式总超时:0 或 60-1800 秒
|
||||
const nonStreamingSchema = z
|
||||
.number()
|
||||
.min(0)
|
||||
.refine((val) => val === 0 || (val >= 60 && val <= 1800), {
|
||||
message: t("proxy.settings.validation.nonStreamingRange", {
|
||||
defaultValue: "请输入 0 或 60-1800 之间的数值",
|
||||
}),
|
||||
});
|
||||
|
||||
// 旧版请求超时(兼容)
|
||||
const requestTimeoutSchema = z.number().min(0);
|
||||
|
||||
return z.object({
|
||||
listen_address: z.string().regex(
|
||||
/^(\d{1,3}\.){3}\d{1,3}$/,
|
||||
@@ -94,6 +110,9 @@ const createProxyConfigSchema = (t: TFunction) => {
|
||||
),
|
||||
request_timeout: requestTimeoutSchema,
|
||||
enable_logging: z.boolean(),
|
||||
streaming_first_byte_timeout: streamingFirstByteSchema,
|
||||
streaming_idle_timeout: streamingIdleSchema,
|
||||
non_streaming_timeout: nonStreamingSchema,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -120,6 +139,9 @@ export function ProxySettingsDialog({
|
||||
max_retries: 3,
|
||||
request_timeout: 300,
|
||||
enable_logging: true,
|
||||
streaming_first_byte_timeout: 30,
|
||||
streaming_idle_timeout: 60,
|
||||
non_streaming_timeout: 600,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -132,6 +154,9 @@ export function ProxySettingsDialog({
|
||||
max_retries: config.max_retries,
|
||||
request_timeout: config.request_timeout,
|
||||
enable_logging: config.enable_logging,
|
||||
streaming_first_byte_timeout: config.streaming_first_byte_timeout ?? 30,
|
||||
streaming_idle_timeout: config.streaming_idle_timeout ?? 60,
|
||||
non_streaming_timeout: config.non_streaming_timeout ?? 300,
|
||||
});
|
||||
}
|
||||
}, [config, form]);
|
||||
@@ -395,6 +420,131 @@ export function ProxySettingsDialog({
|
||||
)}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-xl border border-white/10 glass-card p-6">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{t("proxy.settings.timeout.title", {
|
||||
defaultValue: "超时设置",
|
||||
})}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("proxy.settings.timeout.description", {
|
||||
defaultValue: "配置流式和非流式请求的超时时间。",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="streaming_first_byte_timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"proxy.settings.fields.streamingFirstByteTimeout.label",
|
||||
{
|
||||
defaultValue: "流式首字超时(秒)",
|
||||
},
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
placeholder="30"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"proxy.settings.fields.streamingFirstByteTimeout.description",
|
||||
{
|
||||
defaultValue: "等待首个数据块的最大时间",
|
||||
},
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="streaming_idle_timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("proxy.settings.fields.streamingIdleTimeout.label", {
|
||||
defaultValue: "流式静默超时(秒)",
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
placeholder="60"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"proxy.settings.fields.streamingIdleTimeout.description",
|
||||
{
|
||||
defaultValue: "数据块之间的最大间隔",
|
||||
},
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="non_streaming_timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("proxy.settings.fields.nonStreamingTimeout.label", {
|
||||
defaultValue: "非流式超时(秒)",
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
placeholder="300"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"proxy.settings.fields.nonStreamingTimeout.description",
|
||||
{
|
||||
defaultValue: "非流式请求的总超时时间",
|
||||
},
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user