Просмотр исходного кода

fix: Request Filters improvements & SOCKS proxy fix (#501)

* fix: exclude deleted providers from group tags dropdown in Request Filters

The getDistinctProviderGroupsAction was showing group tags from soft-deleted
providers. Added deletedAt IS NULL filter to match the behavior of
getDistinctProviderGroups in repository/provider.ts.

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* feat(providers): add circuit breaker filter toggle

Add a switch toggle to filter providers by circuit breaker state.
The toggle appears only when there are providers with open circuit breaker.
When enabled, shows only providers with broken circuits.

- Filter works on top of existing filters (type, status, group, search)
- Visual feedback: icon and text turn red when filter is active
- Added i18n translations for all 5 languages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* fix(proxy): replace socks-proxy-agent with fetch-socks for undici compatibility

SocksProxyAgent from socks-proxy-agent is a Node.js HTTP Agent that doesn't
implement undici's Dispatcher interface, causing "this.dispatch is not a
function" error when used with undici.request().

Replace with fetch-socks which provides socksDispatcher - a native undici
Dispatcher implementation for SOCKS4/SOCKS5 proxies.

- Replace socks-proxy-agent with fetch-socks dependency
- Update createProxyAgentForProvider to use socksDispatcher
- Update next.config.ts outputFileTracingIncludes
- Update comments in forwarder.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* refactor: use drizzle-orm operators instead of raw SQL for group tag filtering

Replace sql template with isNotNull and ne operators for better type-safety
and consistency with drizzle-orm patterns.

Changes in both:
- src/actions/request-filters.ts
- src/repository/provider.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <[email protected]>

* chore: format code (fix-req-filters-adv-1b8577f)

---------

Co-authored-by: John Doe <[email protected]>
Co-authored-by: Claude Opus 4.5 <[email protected]>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
miraserver 1 месяц назад
Родитель
Сommit
f017a83f6f

+ 2 - 1
messages/en/settings.json

@@ -1527,7 +1527,8 @@
         "label": "Groups:",
         "all": "All",
         "default": "default"
-      }
+      },
+      "circuitBroken": "Circuit Broken"
     },
     "subtitle": "Provider Management",
     "subtitleDesc": "Configure upstream provider rate limiting and concurrent session limits. Leave empty for unlimited.",

+ 2 - 1
messages/ja/settings.json

@@ -1397,7 +1397,8 @@
         "label": "グループ:",
         "all": "すべて",
         "default": "default"
-      }
+      },
+      "circuitBroken": "サーキットブレーカー"
     },
     "subtitle": "プロバイダー管理",
     "subtitleDesc": "上流プロバイダーの支出制限とセッション並行制限を設定します。空のままにすると無制限です。",

+ 2 - 1
messages/ru/settings.json

@@ -1397,7 +1397,8 @@
         "label": "Группы:",
         "all": "Все",
         "default": "default"
-      }
+      },
+      "circuitBroken": "Сбой соединения"
     },
     "subtitle": "Поставщики",
     "subtitleDesc": "Настройка ограничений по расходам и параллельным сеансам.",

+ 2 - 1
messages/zh-CN/settings.json

@@ -1108,7 +1108,8 @@
         "label": "分组:",
         "all": "全部",
         "default": "default"
-      }
+      },
+      "circuitBroken": "熔断"
     },
     "editProvider": "编辑服务商",
     "createProvider": "新增服务商",

+ 2 - 1
messages/zh-TW/settings.json

@@ -1403,7 +1403,8 @@
         "label": "分組:",
         "all": "全部",
         "default": "default"
-      }
+      },
+      "circuitBroken": "熔斷"
     },
     "subtitle": "服務商管理",
     "subtitleDesc": "設定上游服務商的金額限流和並行限制。留空表示無限制。",

+ 2 - 2
next.config.ts

@@ -23,11 +23,11 @@ const nextConfig: NextConfig = {
     "drizzle-orm",
   ],
 
-  // 强制包含 undici 到 standalone 输出
+  // 强制包含 undici 和 fetch-socks 到 standalone 输出
   // Next.js 依赖追踪无法正确追踪动态导入和类型导入的传递依赖
   // 参考: https://nextjs.org/docs/app/api-reference/config/next-config-js/output
   outputFileTracingIncludes: {
-    "/**": ["./node_modules/undici/**/*", "./node_modules/socks-proxy-agent/**/*"],
+    "/**": ["./node_modules/undici/**/*", "./node_modules/fetch-socks/**/*"],
   },
 
   // 文件上传大小限制(用于数据库备份导入)

+ 1 - 1
package.json

@@ -61,6 +61,7 @@
     "decimal.js-light": "^2",
     "dotenv": "^17",
     "drizzle-orm": "^0.44",
+    "fetch-socks": "^1.3.2",
     "hono": "^4",
     "html2canvas": "^1",
     "ioredis": "^5",
@@ -80,7 +81,6 @@
     "recharts": "^3",
     "safe-regex": "^2",
     "server-only": "^0.0.1",
-    "socks-proxy-agent": "^8",
     "sonner": "^2",
     "tailwind-merge": "^3",
     "timeago.js": "^4",

+ 7 - 2
src/actions/request-filters.ts

@@ -281,12 +281,17 @@ export async function getDistinctProviderGroupsAction(): Promise<ActionResult<st
   try {
     const { db } = await import("@/drizzle/db");
     const { providers } = await import("@/drizzle/schema");
-    const { isNotNull } = await import("drizzle-orm");
+    const { isNull, isNotNull, ne, and } = await import("drizzle-orm");
 
     const result = await db
       .selectDistinct({ groupTag: providers.groupTag })
       .from(providers)
-      .where(isNotNull(providers.groupTag));
+      .where(
+        and(
+          isNull(providers.deletedAt),
+          and(isNotNull(providers.groupTag), ne(providers.groupTag, ""))
+        )
+      );
 
     // Parse comma-separated tags and flatten into unique array
     const allTags = new Set<string>();

+ 68 - 18
src/app/[locale]/settings/providers/_components/provider-manager.tsx

@@ -1,9 +1,10 @@
 "use client";
-import { Loader2, Search, X } from "lucide-react";
+import { AlertTriangle, Loader2, Search, X } from "lucide-react";
 import { useTranslations } from "next-intl";
 import { type ReactNode, useMemo, useState } from "react";
 import { Button } from "@/components/ui/button";
 import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
 import {
   Select,
   SelectContent,
@@ -12,6 +13,7 @@ import {
   SelectValue,
 } from "@/components/ui/select";
 import { Skeleton } from "@/components/ui/skeleton";
+import { Switch } from "@/components/ui/switch";
 import { useDebounce } from "@/lib/hooks/use-debounce";
 import type { CurrencyCode } from "@/lib/utils/currency";
 import type { ProviderDisplay, ProviderType } from "@/types/provider";
@@ -61,6 +63,12 @@ export function ProviderManager({
   // Status and group filters
   const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive">("all");
   const [groupFilter, setGroupFilter] = useState<string[]>([]);
+  const [circuitBrokenFilter, setCircuitBrokenFilter] = useState(false);
+
+  // Count providers with circuit breaker open
+  const circuitBrokenCount = useMemo(() => {
+    return providers.filter((p) => healthStatus[p.id]?.circuitState === "open").length;
+  }, [providers, healthStatus]);
 
   // Extract unique groups from all providers
   const allGroups = useMemo(() => {
@@ -132,6 +140,11 @@ export function ProviderManager({
       });
     }
 
+    // Filter by circuit breaker state
+    if (circuitBrokenFilter) {
+      result = result.filter((p) => healthStatus[p.id]?.circuitState === "open");
+    }
+
     // 排序
     return [...result].sort((a, b) => {
       switch (sortBy) {
@@ -161,7 +174,16 @@ export function ProviderManager({
           return 0;
       }
     });
-  }, [providers, debouncedSearchTerm, typeFilter, sortBy, statusFilter, groupFilter]);
+  }, [
+    providers,
+    debouncedSearchTerm,
+    typeFilter,
+    sortBy,
+    statusFilter,
+    groupFilter,
+    circuitBrokenFilter,
+    healthStatus,
+  ]);
 
   return (
     <div className="space-y-4">
@@ -242,22 +264,50 @@ export function ProviderManager({
             ))}
           </div>
         )}
-        {/* 搜索结果提示 */}
-        {debouncedSearchTerm ? (
-          <p className="text-sm text-muted-foreground">
-            {loading
-              ? tCommon("loading")
-              : filteredProviders.length > 0
-                ? t("found", { count: filteredProviders.length })
-                : t("notFound")}
-          </p>
-        ) : (
-          <div className="text-sm text-muted-foreground">
-            {loading
-              ? tCommon("loading")
-              : t("showing", { filtered: filteredProviders.length, total: providers.length })}
-          </div>
-        )}
+        {/* 搜索结果提示 + Circuit Breaker filter */}
+        <div className="flex items-center justify-between">
+          {debouncedSearchTerm ? (
+            <p className="text-sm text-muted-foreground">
+              {loading
+                ? tCommon("loading")
+                : filteredProviders.length > 0
+                  ? t("found", { count: filteredProviders.length })
+                  : t("notFound")}
+            </p>
+          ) : (
+            <div className="text-sm text-muted-foreground">
+              {loading
+                ? tCommon("loading")
+                : t("showing", { filtered: filteredProviders.length, total: providers.length })}
+            </div>
+          )}
+
+          {/* Circuit Breaker toggle - only show if there are broken providers */}
+          {circuitBrokenCount > 0 && (
+            <div className="flex items-center gap-2">
+              <AlertTriangle
+                className={`h-4 w-4 ${circuitBrokenFilter ? "text-destructive" : "text-muted-foreground"}`}
+              />
+              <Label
+                htmlFor="circuit-broken-filter"
+                className={`text-sm cursor-pointer select-none ${circuitBrokenFilter ? "text-destructive font-medium" : "text-muted-foreground"}`}
+              >
+                {tFilter("circuitBroken")}
+              </Label>
+              <Switch
+                id="circuit-broken-filter"
+                checked={circuitBrokenFilter}
+                onCheckedChange={setCircuitBrokenFilter}
+                disabled={loading}
+              />
+              <span
+                className={`text-sm tabular-nums ${circuitBrokenFilter ? "text-destructive font-medium" : "text-muted-foreground"}`}
+              >
+                ({circuitBrokenCount})
+              </span>
+            </div>
+          )}
+        </div>
       </div>
 
       {/* 供应商列表 */}

+ 2 - 2
src/app/v1/_lib/proxy/forwarder.ts

@@ -128,7 +128,7 @@ function resolveMaxAttemptsForProvider(
 /**
  * undici request 超时配置(毫秒)
  *
- * 背景:undiciRequest() 在使用非 undici 原生 dispatcher(如 SocksProxyAgent)时,
+ * 背景:undiciRequest() 在使用自定义 dispatcher(如 SOCKS 代理)时,
  * 不会继承全局 Agent 的超时配置,需要显式传递超时参数。
  *
  * 这里与全局 undici Agent 使用同一套环境变量配置(FETCH_HEADERS_TIMEOUT / FETCH_BODY_TIMEOUT)。
@@ -1873,7 +1873,7 @@ export class ProxyForwarder {
     }
 
     // 使用 undici.request 获取未自动解压的响应
-    // ⭐ 显式配置超时:确保使用非 undici 原生 dispatcher(如 SocksProxyAgent)时也能正确应用超时
+    // ⭐ 显式配置超时:确保使用自定义 dispatcher(如 SOCKS 代理)时也能正确应用超时
     const undiciRes = await undiciRequest(url, {
       method: init.method as string,
       headers: headersObj,

+ 22 - 14
src/lib/proxy-agent.ts

@@ -1,5 +1,5 @@
-import { SocksProxyAgent } from "socks-proxy-agent";
-import { Agent, ProxyAgent, setGlobalDispatcher } from "undici";
+import { socksDispatcher } from "fetch-socks";
+import { Agent, type Dispatcher, ProxyAgent, setGlobalDispatcher } from "undici";
 import type { Provider } from "@/types/provider";
 import { getEnvConfig } from "./config/env.schema";
 import { logger } from "./logger";
@@ -43,8 +43,7 @@ logger.info("undici global dispatcher configured", {
  * 代理配置结果
  */
 export interface ProxyConfig {
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  agent: ProxyAgent | SocksProxyAgent | any; // any to support non-undici agents
+  agent: ProxyAgent | Dispatcher;
   fallbackToDirect: boolean;
   proxyUrl: string;
   http2Enabled: boolean; // HTTP/2 是否启用(SOCKS 代理不支持 HTTP/2)
@@ -99,17 +98,26 @@ export function createProxyAgentForProvider(
     const parsedProxy = new URL(proxyUrl);
 
     // 根据协议选择 Agent
-    let agent: ProxyAgent | SocksProxyAgent;
+    let agent: ProxyAgent | Dispatcher;
     let actualHttp2Enabled = false; // 实际是否启用 HTTP/2
 
     if (parsedProxy.protocol === "socks5:" || parsedProxy.protocol === "socks4:") {
-      // SOCKS 代理(不支持 HTTP/2)
-      // ⭐ 超时说明:
-      // - SocksProxyAgent 仅处理 SOCKS 连接建立阶段(默认 30s 超时,足够)
-      // - 连接建立后,HTTP 数据传输由全局 undici Agent 控制(headersTimeout/bodyTimeout 可配置)
-      // - 因此 SOCKS 代理无需额外配置 headersTimeout/bodyTimeout
-      // @see https://github.com/TooTallNate/node-socks-proxy-agent/issues/26
-      agent = new SocksProxyAgent(proxyUrl);
+      // SOCKS 代理通过 fetch-socks(undici 兼容)
+      // 使用 socksDispatcher 创建 undici 兼容的 Dispatcher
+      agent = socksDispatcher(
+        {
+          type: parsedProxy.protocol === "socks5:" ? 5 : 4,
+          host: parsedProxy.hostname,
+          port: parseInt(parsedProxy.port) || 1080,
+          userId: parsedProxy.username || undefined,
+          password: parsedProxy.password || undefined,
+        },
+        {
+          connect: {
+            timeout: connectTimeout,
+          },
+        }
+      );
       actualHttp2Enabled = false; // SOCKS 不支持 HTTP/2
 
       // 警告:SOCKS 代理不支持 HTTP/2
@@ -121,14 +129,14 @@ export function createProxyAgentForProvider(
         });
       }
 
-      logger.debug("SOCKS ProxyAgent created", {
+      logger.debug("SOCKS dispatcher created via fetch-socks", {
         providerId: provider.id,
         providerName: provider.name ?? "unknown",
         protocol: parsedProxy.protocol,
         proxyHost: parsedProxy.hostname,
         proxyPort: parsedProxy.port,
         targetUrl: new URL(targetUrl).origin,
-        http2Enabled: false, // SOCKS 不支持 HTTP/2
+        http2Enabled: false,
       });
     } else if (parsedProxy.protocol === "http:" || parsedProxy.protocol === "https:") {
       // HTTP/HTTPS 代理(使用 undici)

+ 2 - 2
src/repository/provider.ts

@@ -1,6 +1,6 @@
 "use server";
 
-import { and, desc, eq, isNull, sql } from "drizzle-orm";
+import { and, desc, eq, isNotNull, isNull, ne, sql } from "drizzle-orm";
 import { db } from "@/drizzle/db";
 import { providers } from "@/drizzle/schema";
 import { getEnvConfig } from "@/lib/config";
@@ -453,7 +453,7 @@ export async function getDistinctProviderGroups(): Promise<string[]> {
     .where(
       and(
         isNull(providers.deletedAt),
-        sql`${providers.groupTag} IS NOT NULL AND ${providers.groupTag} != ''`
+        and(isNotNull(providers.groupTag), ne(providers.groupTag, ""))
       )
     )
     .orderBy(providers.groupTag);