diff --git a/server/proxy_pool.go b/server/proxy_pool.go index 4463afe..7fadf0c 100644 --- a/server/proxy_pool.go +++ b/server/proxy_pool.go @@ -227,7 +227,7 @@ func (m *Manager) persistProxyPoolLocked() error { func (m *Manager) ProxyPoolSnapshot() ([]ProxyPoolEntry, string, []ProxySwitchLog) { if m.proxyPool == nil { - return nil, "", nil + return []ProxyPoolEntry{}, "", []ProxySwitchLog{} } m.proxyPool.mu.Lock() defer m.proxyPool.mu.Unlock() @@ -241,7 +241,7 @@ func (m *Manager) ProxyPoolSnapshot() ([]ProxyPoolEntry, string, []ProxySwitchLo } entries = append(entries, p) } - logs := append([]ProxySwitchLog(nil), m.proxyPool.logs...) + logs := append([]ProxySwitchLog{}, m.proxyPool.logs...) for i := range logs { logs[i].From, logs[i].To = maskSecrets(logs[i].From), maskSecrets(logs[i].To) } diff --git a/server/proxy_pool_test.go b/server/proxy_pool_test.go index 97f75be..d74ac7e 100644 --- a/server/proxy_pool_test.go +++ b/server/proxy_pool_test.go @@ -41,6 +41,35 @@ func TestProxyPoolSnapshotMasksCredentialsAndDisabledHealth(t *testing.T) { } } +func TestProxyPoolEmptyResponseUsesArrays(t *testing.T) { + for _, tc := range []struct { + name string + pool *proxyPoolState + }{ + {name: "uninitialized"}, + {name: "empty", pool: &proxyPoolState{}}, + {name: "member without switches", pool: &proxyPoolState{entries: []proxyPoolEntry{{ProxyPoolEntry: ProxyPoolEntry{ID: "a"}, RawURL: "http://a"}}}}, + } { + t.Run(tc.name, func(t *testing.T) { + s := &Server{m: &Manager{proxyPool: tc.pool}} + w := httptest.NewRecorder() + s.getProxyPool(w, httptest.NewRequest(http.MethodGet, "/api/settings/proxy-pool", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + var payload map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil { + t.Fatal(err) + } + for _, field := range []string{"entries", "switch_log"} { + if raw := strings.TrimSpace(string(payload[field])); !strings.HasPrefix(raw, "[") { + t.Errorf("%s must be a JSON array, got %s", field, raw) + } + } + }) + } +} + func TestProxyPoolSequentialSkipsUnhealthyAndDisabled(t *testing.T) { m := &Manager{proxyPool: &proxyPoolState{active: "a", entries: []proxyPoolEntry{ {ProxyPoolEntry: ProxyPoolEntry{ID: "a", Enabled: true, Health: "healthy"}, RawURL: "http://a"}, diff --git a/web/src/app/(main)/system/settings/page.tsx b/web/src/app/(main)/system/settings/page.tsx index 71d9e2b..38b4e78 100644 --- a/web/src/app/(main)/system/settings/page.tsx +++ b/web/src/app/(main)/system/settings/page.tsx @@ -66,7 +66,9 @@ export default function SystemSettingsPage() { const [proxyPoolThreshold, setProxyPoolThreshold] = React.useState("3"); const [proxyPoolCooldown, setProxyPoolCooldown] = React.useState("60"); const [proxyPoolHealthInterval, setProxyPoolHealthInterval] = React.useState("30"); - const [proxyPoolLogs, setProxyPoolLogs] = React.useState>([]); + const [proxyPoolLogs, setProxyPoolLogs] = React.useState< + Array<{ at: string; from: string; to: string; reason: string }> + >([]); const [proxyReconnectMax, setProxyReconnectMax] = React.useState("5"); const [proxyReconnectInterval, setProxyReconnectInterval] = React.useState("30"); const [savingEgressVisibility, setSavingEgressVisibility] = React.useState(false); @@ -227,11 +229,14 @@ export default function SystemSettingsPage() { .then((s) => { apply(s); setSettingsLoaded(true); - void api.proxyPool().then((pool) => { - setProxyPool(pool.entries); - setProxyPoolActiveId(pool.active_id); - setProxyPoolLogs(pool.switch_log); - }).catch(() => undefined); + void api + .proxyPool() + .then((pool) => { + setProxyPool(pool.entries); + setProxyPoolActiveId(pool.active_id); + setProxyPoolLogs(pool.switch_log); + }) + .catch(() => undefined); }) .catch(() => { setSettingsLoaded(false); @@ -464,7 +469,17 @@ export default function SystemSettingsPage() { }; const importProxyPool = () => { - api.importProxyPool({ text: proxyPoolText }).then((r) => { setProxyPool(r.entries); setProxyPoolActiveId(r.active_id); setProxyPoolLogs(r.switch_log); setProxyPoolText(""); if (r.errors.length) toast.error(r.errors.map((e) => `第${e.line}行:${e.error}`).join(";")); else toast.success("代理池已导入"); }).catch((e) => toast.error("导入失败:" + (e as Error).message)); + api + .importProxyPool({ text: proxyPoolText }) + .then((r) => { + setProxyPool(r.entries); + setProxyPoolActiveId(r.active_id); + setProxyPoolLogs(r.switch_log); + setProxyPoolText(""); + if (r.errors.length) toast.error(r.errors.map((e) => `第${e.line}行:${e.error}`).join(";")); + else toast.success("代理池已导入"); + }) + .catch((e) => toast.error("导入失败:" + (e as Error).message)); }; const saveProxyPoolStrategy = () => { const interval = Number(proxyPoolInterval); @@ -472,17 +487,69 @@ export default function SystemSettingsPage() { toast.error("定时轮换间隔必须是大于等于 3 秒的整数"); return; } - api.setSettings({ proxy_rotate_timed_enabled: proxyPoolTimed, proxy_rotate_interval_seconds: Number(proxyPoolInterval), proxy_rotate_on_failure: proxyPoolFailure, proxy_rotate_strategy: proxyPoolStrategy, proxy_health_fail_threshold: Number(proxyPoolThreshold), proxy_health_cooldown_seconds: Number(proxyPoolCooldown), proxy_health_check_interval_seconds: Number(proxyPoolHealthInterval) }).then((s) => { apply(s); toast.success("代理池轮换策略已保存"); }).catch((e) => toast.error("保存失败:" + (e as Error).message)); + api + .setSettings({ + proxy_rotate_timed_enabled: proxyPoolTimed, + proxy_rotate_interval_seconds: Number(proxyPoolInterval), + proxy_rotate_on_failure: proxyPoolFailure, + proxy_rotate_strategy: proxyPoolStrategy, + proxy_health_fail_threshold: Number(proxyPoolThreshold), + proxy_health_cooldown_seconds: Number(proxyPoolCooldown), + proxy_health_check_interval_seconds: Number(proxyPoolHealthInterval), + }) + .then((s) => { + apply(s); + toast.success("代理池轮换策略已保存"); + }) + .catch((e) => toast.error("保存失败:" + (e as Error).message)); }; - const testAllProxyPool = () => api.testAllProxyPool().then((r) => { setProxyPool(r.entries); setProxyPoolActiveId(r.active_id); setProxyPoolLogs(r.switch_log); toast.success("已完成全部代理探测"); }).catch((e) => toast.error("探测失败:" + (e as Error).message)); - const testOneProxyPool = (id: string) => api.testProxyPoolEntry(id).then(() => api.proxyPool()).then((r) => { setProxyPool(r.entries); setProxyPoolActiveId(r.active_id); setProxyPoolLogs(r.switch_log); }).catch((e) => toast.error("探测失败:" + (e as Error).message)); - const refreshProxyPool = () => api.proxyPool().then((r) => { setProxyPool(r.entries); setProxyPoolActiveId(r.active_id); setProxyPoolLogs(r.switch_log); }); - const toggleProxyPoolEntry = (entry: ProxyPoolEntry) => api.updateProxyPool(entry.id, { enabled: !entry.enabled }).then(() => refreshProxyPool()).catch((e) => toast.error("更新失败:" + (e as Error).message)); - const deleteProxyPoolEntry = (id: string) => api.deleteProxyPool(id).then(() => refreshProxyPool()).catch((e) => toast.error("删除失败:" + (e as Error).message)); + const testAllProxyPool = () => + api + .testAllProxyPool() + .then((r) => { + setProxyPool(r.entries); + setProxyPoolActiveId(r.active_id); + setProxyPoolLogs(r.switch_log); + toast.success("已完成全部代理探测"); + }) + .catch((e) => toast.error("探测失败:" + (e as Error).message)); + const testOneProxyPool = (id: string) => + api + .testProxyPoolEntry(id) + .then(() => api.proxyPool()) + .then((r) => { + setProxyPool(r.entries); + setProxyPoolActiveId(r.active_id); + setProxyPoolLogs(r.switch_log); + }) + .catch((e) => toast.error("探测失败:" + (e as Error).message)); + const refreshProxyPool = () => + api.proxyPool().then((r) => { + setProxyPool(r.entries); + setProxyPoolActiveId(r.active_id); + setProxyPoolLogs(r.switch_log); + }); + const toggleProxyPoolEntry = (entry: ProxyPoolEntry) => + api + .updateProxyPool(entry.id, { enabled: !entry.enabled }) + .then(() => refreshProxyPool()) + .catch((e) => toast.error("更新失败:" + (e as Error).message)); + const deleteProxyPoolEntry = (id: string) => + api + .deleteProxyPool(id) + .then(() => refreshProxyPool()) + .catch((e) => toast.error("删除失败:" + (e as Error).message)); const saveProxyReconnect = () => { - api.setSettings({ proxy_reconnect_max_attempts: Number(proxyReconnectMax), proxy_reconnect_interval_seconds: Number(proxyReconnectInterval) }) - .then((s) => { apply(s); toast.success("已保存代理重连策略"); }) + api + .setSettings({ + proxy_reconnect_max_attempts: Number(proxyReconnectMax), + proxy_reconnect_interval_seconds: Number(proxyReconnectInterval), + }) + .then((s) => { + apply(s); + toast.success("已保存代理重连策略"); + }) .catch((e) => toast.error("保存失败:" + (e as Error).message)); }; @@ -775,53 +842,81 @@ export default function SystemSettingsPage() { 全局代理 - 所有 Agent 的目标流量经此代理出网(隐藏源 IP / 走跳板)。支持 http / https / socks5 - ,可带 user:pass 认证。留空=直连。 -
- 开启流量捕获时,它作为记录代理的上游 - (流量仍全量落库,再经此代理出网);关闭捕获时,直接注入 Agent 的 bash / WebFetch - 出网。与网络搜索代理、LLM 代理相互独立。 -
- 提示:socks5 在关闭捕获时依赖各命令行工具对 ALL_PROXY 的支持——curl - 可用,python urllib 等会直接报错; 若主要用 socks5,建议开启流量捕获——此路径由 MITM - 亲自拨号,工具无感知、稳定生效。 + 设置 Agent 目标流量的统一出口。留空时直连;网络搜索和 LLM 代理不受影响。
- - -
- { - setGlobalProxyInput(e.target.value); - // 旧结果对应的是上一次的地址,留着会让人以为新值已经测过。 - clearEgressReport(); - }} - /> - - {/* 测表单里当前的值,不要求先保存。 */} - -
-

- {globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"} -

-
-
setProxyReconnectMax(e.target.value)} />
-
setProxyReconnectInterval(e.target.value)} />
- -
-

代理异常暂停的任务会按此策略探测;次数耗尽后保持暂停,可手动恢复。

+ +
+ +
+ { + setGlobalProxyInput(e.target.value); + // 旧结果对应的是上一次的地址,留着会让人以为新值已经测过。 + clearEgressReport(); + }} + /> + + {/* 测表单里当前的值,不要求先保存。 */} + +
+

+ {globalProxyInput.trim() ? "已配置 · 所有目标流量经此代理出网" : "未配置 · 目标流量直连出网"} +

+
+ 代理工作方式 +

+ 开启流量捕获时,此代理作为记录代理的上游;关闭时,代理会注入 Agent 的 bash / WebFetch 出网。支持 + http、https 和 socks5,可带账号密码。关闭流量捕获使用 socks5 时,命令行工具需支持 + ALL_PROXY;Python urllib 等工具可能不支持,开启流量捕获可避免此限制。 +

+
+
+
+ 异常重连策略 +
+

+ 代理异常暂停的任务按此策略探测;次数耗尽后保持暂停,可手动恢复。 +

+
+
+ + setProxyReconnectMax(e.target.value)} + /> +
+
+ + setProxyReconnectInterval(e.target.value)} + /> +
+ +
+
+
{/* 紧贴结果区:用户看两行时正好需要它。细节放文档。 */} -

- 点测试会访问 IP 回显服务(默认 api.ipify.org,失败自动换 ifconfig.me - ),它返回"这个请求从哪个 IP 来"。 ① 是不走代理时的 IP,② 是经全局代理出网时的 - IP。改完不用先保存,直接测。 -

+
+ 出口 IP 测试说明 +

+ 测试会访问 IP 回显服务(默认 api.ipify.org,失败时改用 ifconfig.me)。结果中的直连 IP 与代理 IP + 可用于确认代理是否生效。修改地址后无需保存即可测试。 +

+
{/* 内网里公网回显不可达,两行都会失败——不是代理坏了。 */}
@@ -928,40 +1025,220 @@ export default function SystemSettingsPage() { ))}
)} -
+
-

代理池

-

当前在用:{proxyPool.find((entry) => entry.id === proxyPoolActiveId)?.name ?? (proxyPoolActiveId ? "已选择代理" : "直连")} · 每行一个代理;可用 URL|名称|备注。账号密码只会以掩码返回。

+

+ 代理池 {proxyPool.length} +

+

+ 当前: + {proxyPool.find((entry) => entry.id === proxyPoolActiveId)?.name ?? + (proxyPoolActiveId ? "已选择代理" : "直连")} +

- +
-