dsh-provider-proxy 0.5.1 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -77,6 +77,11 @@ Host catalog 是否列出某个 provider(`live`)不影响已保存路由的
77
77
  因为成功判据在响应头到达时就已成立(流式转发不能靠消费 body 来判断)。
78
78
  「最近调用」的时间取请求**发起**时刻;「最近成功 / 失败」的结果与时间来自最近一次**结束**的请求——
79
79
  两者分开记录,并发调用下不会把一次请求的发起时间配到另一次的结果上。
80
+ **这条记录只属于当前路由配置**:改代理端点、凭据、`useDefault` 的默认端点、目标域名规则或
81
+ `localDirect` 后,该 provider 的「最近调用」会变成「尚无代理记录」;`testUrl` / `testTarget`
82
+ 只影响测试,不会清掉它。仅关闭再打开代理开关也保留记录。修改配置前已经启动的请求不会把旧结果
83
+ 写回新配置;删除后重新添加同名 provider 同样视为一条新路由。`hits` 命中计数是历史累计,
84
+ 上述配置变化都不会清空它。
80
85
 
81
86
  页面顶部与顶层还有三项整页设置(写在 `cordis.patch.yml`,也可以直接编辑):
82
87
 
@@ -86,6 +91,10 @@ Host catalog 是否列出某个 provider(`live`)不影响已保存路由的
86
91
  | `localDirect` | `true` | 本机 / 内网目标是否直连(见「目标域名规则」一节) |
87
92
  | `testUrl` | `https://api.ipify.org?format=json` | 自检的最后兜底目标;可写多个(逗号或空格分隔,上限 5 个),按序回退 |
88
93
 
94
+ 其中的 `localDirect` 与 `testUrl` 也可以在默认代理区块下方的 **全局设置(高级)** 折叠块里修改:
95
+ `testUrl` 留空时 Host 使用内置默认值,非法候选或超过 5 项只会提示、不会阻止保存;
96
+ `localDirect` 默认勾选。修改任一项都会清空页面上所有自检结果,并且点「测试」前会把待写设置先落盘。
97
+
89
98
  列表只包含**已激活**的 provider(`llm.listProviders()` 能列出的那些)。仅在配置里声明、
90
99
  但没有实际注册的可配置 provider 不显示——它的请求根本不会被发出去。
91
100
  已经在 `providers` 里配过代理的条目即使暂时不激活也仍然保留,避免已保存的设置悄悄消失;
@@ -93,10 +102,18 @@ catalog 的 `live` 只影响未配置 provider 是否出现在列表里,不参
93
102
 
94
103
  保存立即对新请求生效,不需要重启;正在进行中的流会继续用它启动时的那条连接。
95
104
 
96
- 每张卡片还有一个 **测试** 按钮:点它会让 Host 通过**该 provider 自己的代理**发一次请求,回报延迟与(探测目标返回时)出口 IP。只要该卡片有完整端点它就会出现——**不必先启用**;未启用的检查会标注为「未启用,仅测试」。代理不可达时它会明确失败,而不是假通过。结果与当前配置绑定:改端点 / 凭据 / 自检目标 / 域名规则后旧结果会立即消失;点「测试」前会先把防抖中的设置写入,Host 测的是刚填的值。
105
+ 设置写入失败时,字段会回滚,并在页面顶部显示「保存失败:<字段> — <原因>」和关闭按钮;下一次成功写入或点击关闭后消失。多次失败只保留最新一条。写入成功与失败的诊断都不会包含密码值,密码字段固定显示「值已隐藏」。
106
+
107
+ 每张卡片还有一个 **测试** 按钮:点它会让 Host 通过**该 provider 自己的代理**发一次请求,回报 HTTP 状态、延迟、脱敏后的探测目标与(探测目标返回时)出口 IP;冷却命中时标注「缓存」。只要该卡片有完整端点它就会出现——**不必先启用**;未启用的检查会标注为「未启用,仅测试」。代理不可达时它会明确失败,而不是假通过。结果与当前配置绑定:改端点 / 凭据 / 自检目标 / 域名规则 / 全局设置后旧结果会立即消失;点「测试」前会先把防抖中的设置写入,Host 测的是刚填的值。
108
+
109
+ 探测目标在 Host 侧脱敏后才进入响应:合法 URL 只保留 `origin + pathname`,userinfo、query 与 fragment 都不会出现在结果、诊断或错误文本里;非法 URL 也会去掉 userinfo / query / fragment 并截断长度。网络错误同样不会回显原始目标。
97
110
 
98
111
  **目标与判定口径**:目标按三级取用——卡片的 `自检目标` → 该 provider 的真实上游(见下一节)→ 顶层 `testUrl`(可写多个候选,按序回退,命中任一即停;`407` 会提前终止,因为那是代理的问题)。结果按状态码分类:
99
112
 
113
+ 结果行会标出**这次用的是哪一级**:`(本卡片自检目标)` / `(该 provider 的真实上游)` / `(全局自检地址)`。
114
+ 所以「改了 `testUrl` 但某张卡片没变」是可以解释的——该 provider 自己发布了真实上游,本就不读 `testUrl`;
115
+ 要覆盖它,请在那张卡片的「高级 → 自检目标」里显式填写。
116
+
100
117
  | 状态 | 含义 |
101
118
  | --- | --- |
102
119
  | `407` | 代理要求认证 |
@@ -104,7 +121,8 @@ catalog 的 `live` 只影响未配置 provider 是否出现在列表里,不参
104
121
  | `5xx` | 疑似代理或上游故障。HTTP 代理够不到上游时会**自己**回 502/503/504 |
105
122
  | 抛出(`ECONNREFUSED`、超时等) | 按失败原因分类,不会假通过 |
106
123
 
107
- 延迟数值按快慢着色——≤500 ms 绿、≤1500 ms 黄、更慢红——但数值始终照常显示,颜色只是辅助。
124
+ 延迟数值按快慢着色——≤500 ms 绿、≤1500 ms 黄、更慢红——但**只有延迟数字**带这个颜色,
125
+ 状态码、目标与其它文字用普通标签色。一次超过 1500 ms 的成功检查仍然是成功,不会整行变红。
108
126
 
109
127
  **自检遵循与真实流量相同的代理 / 直连判定。** 目标命中本机 / 内网地址、`bypassHosts`,或落在 `onlyHosts` 之外时,自检与路由一样**直连**——不把一个到不了该目标的请求塞给代理,也就不会把一条健康路由报成「代理坏了」。直连成功时卡片会标注「· 直连(未经过代理)」,直连失败则报「目标不可达」而非指向从未使用的代理。
110
128
 
@@ -121,7 +139,7 @@ catalog 的 `live` 只影响未配置 provider 是否出现在列表里,不参
121
139
 
122
140
  **指向本机或内网的请求默认直连。** 目标主机命中 `localhost`(含子域)、`127.0.0.0/8`、`::1`、`10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16`、`169.254.0.0/16` 时不经代理。这类目标通常是跑在同一台机器上的中转(LiteLLM、Ollama、本地 mock),正向代理根本到不了它,硬走代理只会失败,而且失败看起来像「代理坏了」。顶层 `localDirect: false` 可关掉这条(例如代理本身就是目标)。
123
141
 
124
- 带协议前缀(`https://…`)或路径(`example.com/v1`)的条目永远匹配不上——规则只认主机名。这类条目会在输入框下以错误色标出,因为后果是静默的:`onlyHosts` 里的死条目会让 fail-closed 把流量直连出去,而用户以为已经走代理。
142
+ 带协议前缀(`https://…`)、路径(`example.com/v1`)、端口(`example.com:443`)或杂散通配符(`*foo`、`foo*`)的条目永远匹配不上——规则只认主机名,通配符只能是单独的 `*` 或以 `*.` 开头;单独的 `*.` 也视为死条目。方括号 IPv6 字面量(如 `[::1]`)是合法的。这类条目会在输入框下以错误色标出,因为后果是静默的:`onlyHosts` 里的死条目会让 fail-closed 把流量直连出去,而用户以为已经走代理。Host 与设置页使用同一份共享解析 / 判定逻辑。
125
143
 
126
144
  当组成里存在 web 服务时,列表还会多出一张 **Web 搜索** 卡片(保留 id `web-search`,字段与其它卡片完全相同,也支持自检、计数与域名规则)。它承载 `web_search` 工具的流量;不启用它,搜索保持直连。
127
145
 
@@ -181,7 +199,7 @@ catalog 的 `live` 只影响未配置 provider 是否出现在列表里,不参
181
199
 
182
200
  ```sh
183
201
  npm run build # 构建 lib/index.js 与 lib/client.js
184
- npm test # 218 个用例,跑的是构建产物
202
+ npm test # 248 个用例,跑的是构建产物
185
203
  npm run typecheck # 类型检查(仅覆盖 src/)
186
204
  npm run verify # 上面三件事,按顺序
187
205
  ```
package/lib/client.js CHANGED
@@ -64,6 +64,31 @@ __export(client_exports, {
64
64
  module.exports = __toCommonJS(client_exports);
65
65
  var import_client = __toESM(require_client(), 1);
66
66
 
67
+ // src/host-rules.ts
68
+ function splitHostRules(raw) {
69
+ return raw.split(/[,\s]+/).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
70
+ }
71
+ function normalizeHostRule(raw) {
72
+ return raw.trim().replace(/^\*?\./, "").toLowerCase();
73
+ }
74
+ function isBracketedIpv6(token) {
75
+ return /^\[[0-9a-f:.]+\]$/i.test(token);
76
+ }
77
+ function hostRuleProblem(raw) {
78
+ const token = raw.trim();
79
+ if (token.length === 0) return void 0;
80
+ if (token === "*." || normalizeHostRule(token).length === 0) return "\u7A7A\u901A\u914D\u7B26";
81
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(token)) return "\u5E26\u534F\u8BAE\u524D\u7F00";
82
+ if (token.includes("/")) return "\u5E26\u8DEF\u5F84";
83
+ if (token.includes(":") && !isBracketedIpv6(token)) return "\u5E26\u7AEF\u53E3\u6216\u975E\u6CD5\u5192\u53F7";
84
+ const withoutLeadingWildcard = token.replace(/^\*\./, "");
85
+ if (withoutLeadingWildcard.includes("*") && token !== "*") return "\u6742\u6563\u901A\u914D\u7B26";
86
+ return void 0;
87
+ }
88
+ function deadRuleEntries(raw) {
89
+ return splitHostRules(raw).filter((entry) => hostRuleProblem(entry) !== void 0);
90
+ }
91
+
67
92
  // src/proxy-address.ts
68
93
  var PROXY_HOST_INVALID = "\u4EE3\u7406\u5730\u5740\u53EA\u80FD\u586B IPv4\u3001IPv6 \u6216\u57DF\u540D";
69
94
  var IPV4 = /^(?:(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)$/;
@@ -203,6 +228,8 @@ var CATALOG_PATH = "/dsh-provider-proxy/providers";
203
228
  var TEST_PATH = "/dsh-provider-proxy/test";
204
229
  var LATENCY_FAST_MS = 500;
205
230
  var LATENCY_SLOW_MS = 1500;
231
+ var DEFAULT_TEST_URL = "https://api.ipify.org?format=json";
232
+ var MAX_TEST_URLS = 5;
206
233
  function latencyColor(ms) {
207
234
  if (typeof ms !== "number" || !Number.isFinite(ms)) return "var(--dsw-alias-label-secondary)";
208
235
  if (ms <= LATENCY_FAST_MS) return "var(--dsw-alias-state-success-primary, #2ea043)";
@@ -216,6 +243,10 @@ function testResultFrom(body, strings) {
216
243
  ok,
217
244
  ms: typeof body?.ms === "number" && Number.isFinite(body.ms) ? body.ms : void 0,
218
245
  ip: typeof body?.exitIp === "string" && body.exitIp.length > 0 ? body.exitIp : void 0,
246
+ status: typeof body?.status === "number" && Number.isInteger(body.status) ? body.status : void 0,
247
+ target: typeof body?.target === "string" && body.target.length > 0 ? body.target : void 0,
248
+ targetSource: body?.targetSource === "route" || body?.targetSource === "upstream" ? body.targetSource : body?.targetSource === "testUrl" ? "testUrl" : void 0,
249
+ cached: body?.cached === true,
219
250
  // The Host probes a target the way routing would: a loopback/private upstream
220
251
  // (or a bypassHosts match) is reached DIRECT, not through the proxy. The card
221
252
  // says so, so a green check is not read as "the proxy works".
@@ -223,6 +254,57 @@ function testResultFrom(body, strings) {
223
254
  error: ok ? void 0 : typeof body?.error === "string" && body.error.length > 0 ? body.error : strings.testFailed
224
255
  };
225
256
  }
257
+ function splitTestUrls(raw) {
258
+ return raw.split(/[,\s]+/).map((entry) => entry.trim()).filter((entry) => entry.length > 0);
259
+ }
260
+ function testUrlHint(raw, t) {
261
+ const entries = splitTestUrls(raw);
262
+ if (entries.length === 0) return t.testUrlEmptyHint;
263
+ const invalid = entries.filter((entry) => !isAbsoluteHttpUrl(entry));
264
+ if (invalid.length > 0) return t.testUrlInvalidHint;
265
+ if (entries.length > MAX_TEST_URLS) return t.testUrlTooManyHint;
266
+ return void 0;
267
+ }
268
+ function isAbsoluteHttpUrl(value) {
269
+ try {
270
+ const url = new URL(value);
271
+ return (url.protocol === "http:" || url.protocol === "https:") && url.hostname.length > 0;
272
+ } catch {
273
+ return false;
274
+ }
275
+ }
276
+ function targetSourceSuffix(result, t) {
277
+ switch (result.targetSource) {
278
+ case "route":
279
+ return t.targetSourceRoute;
280
+ case "upstream":
281
+ return t.targetSourceUpstream;
282
+ case "testUrl":
283
+ return t.targetSourceTestUrl;
284
+ default:
285
+ return "";
286
+ }
287
+ }
288
+ function redactSensitiveText(text, secrets = []) {
289
+ let safe = text;
290
+ for (const secret of secrets) {
291
+ if (typeof secret !== "string" || secret.length === 0) continue;
292
+ safe = safe.split(secret).join("***");
293
+ }
294
+ safe = safe.replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^/@\s]+@/gi, "$1");
295
+ safe = safe.replace(/([?&#])[^\s"'<>]*/g, "$1");
296
+ return safe.length > 500 ? `${safe.slice(0, 500)}\u2026` : safe;
297
+ }
298
+ function secretsFromOps(ops) {
299
+ const out = [];
300
+ for (const op of ops) {
301
+ const field = op?.path?.[op.path.length - 1];
302
+ if (field === "password" && typeof op?.value === "string" && op.value.length > 0) {
303
+ out.push(op.value);
304
+ }
305
+ }
306
+ return out;
307
+ }
226
308
  var catalogueSetter = () => {
227
309
  };
228
310
  function relativeTime(at, t) {
@@ -293,9 +375,6 @@ function touchCatalogPolling() {
293
375
  }, CATALOG_REFRESH_MS);
294
376
  }
295
377
  var byName = (a, b) => a.name.localeCompare(b.name);
296
- function deadRuleEntries(raw) {
297
- return raw.split(/[,\s]+/).map((entry) => entry.trim()).filter((entry) => /^[a-z][a-z0-9+.-]*:\/\//i.test(entry) || entry.includes("/"));
298
- }
299
378
  function view(route) {
300
379
  return {
301
380
  enabled: route?.enabled === true,
@@ -462,6 +541,14 @@ var S = {
462
541
  cursor: "pointer"
463
542
  },
464
543
  testResult: {
544
+ // A flex row so the target can drop onto its own line: the container was a
545
+ // plain span, where `flexBasis: 100%` does nothing and the target ran into
546
+ // the "cached" label.
547
+ display: "flex",
548
+ flexWrap: "wrap",
549
+ alignItems: "baseline",
550
+ columnGap: "6px",
551
+ rowGap: "2px",
465
552
  fontSize: "11px",
466
553
  color: "var(--dsw-alias-label-secondary)",
467
554
  wordBreak: "break-all"
@@ -570,6 +657,19 @@ var S = {
570
657
  color: "var(--dsw-alias-label-primary)",
571
658
  font: "inherit",
572
659
  fontSize: "12px"
660
+ },
661
+ diag: {
662
+ marginTop: "4px",
663
+ padding: "8px 10px",
664
+ border: "1px solid var(--dsw-alias-border-l1)",
665
+ borderRadius: "8px",
666
+ fontSize: "11px",
667
+ color: "var(--dsw-alias-label-secondary)"
668
+ },
669
+ hint: {
670
+ gridColumn: "2",
671
+ fontSize: "11px",
672
+ color: "var(--dsw-alias-label-secondary)"
573
673
  }
574
674
  };
575
675
  function renderPage(ctx) {
@@ -641,6 +741,13 @@ function renderPage(ctx) {
641
741
  const [defaultDraft, setDefaultDraft] = (React.useState ?? ((v) => [v, () => {
642
742
  }]))({});
643
743
  const editDefault = (field2, value) => setDefaultDraft((current) => ({ ...current, [field2]: value }));
744
+ const [globalDraft, setGlobalDraft] = (React.useState ?? ((v) => [v, () => {
745
+ }]))({});
746
+ const editGlobal = (field2, value) => setGlobalDraft((current) => ({ ...current, [field2]: value }));
747
+ const [saveError, setSaveError] = (React.useState ?? ((v) => [v, () => {
748
+ }]))(void 0);
749
+ const [savedNote, setSavedNote] = (React.useState ?? ((v) => [v, () => {
750
+ }]))(void 0);
644
751
  const [tests, setTests] = (React.useState ?? ((v) => [v, () => {
645
752
  }]))({});
646
753
  const invalidateTests = (ids) => {
@@ -668,7 +775,7 @@ function renderPage(ctx) {
668
775
  void (async () => {
669
776
  try {
670
777
  for (const [key, entry] of [...pendingWrites]) {
671
- if (key.startsWith(`${id}\0`) || key.startsWith("\0default\0")) {
778
+ if (key.startsWith(`${id}\0`) || key.startsWith("\0default\0") || key.startsWith("\0global\0")) {
672
779
  void entry.flush();
673
780
  }
674
781
  }
@@ -694,12 +801,20 @@ function renderPage(ctx) {
694
801
  const rows = knownProviders({ ...configured, ...draft }, published);
695
802
  const inheritingIds = () => rows.filter((row) => view({ ...configured[row.id], ...draft[row.id] }).useDefault === true).map((row) => row.id);
696
803
  const save = (key, path, label, value, onRefused) => {
804
+ const field2 = path[path.length - 1];
805
+ const secret = field2 === "password" && typeof value === "string" ? value : void 0;
806
+ const safeLabel = secret === void 0 ? label : `${label}\uFF08\u503C\u5DF2\u9690\u85CF\uFF09`;
697
807
  scheduleWrite(
698
808
  key,
699
809
  () => writeConfig(ctx, [{ op: "set", path, value }]).then(() => {
700
- note(`\u5DF2\u4FDD\u5B58 ${label} = ${JSON.stringify(value)}`);
810
+ setSaveError(void 0);
811
+ setSavedNote(secret === void 0 ? `\u5DF2\u4FDD\u5B58 ${label}` : `\u5DF2\u4FDD\u5B58 ${safeLabel}`);
701
812
  }).catch((error) => {
702
- note(`\u4FDD\u5B58\u5931\u8D25 ${label}\uFF1A${error?.message ?? error}`);
813
+ const message = redactSensitiveText(String(error?.message ?? error), [
814
+ ...secret === void 0 ? [] : [secret]
815
+ ]);
816
+ setSavedNote(void 0);
817
+ setSaveError({ label: safeLabel, message });
703
818
  onRefused();
704
819
  })
705
820
  );
@@ -739,13 +854,31 @@ function renderPage(ctx) {
739
854
  });
740
855
  });
741
856
  };
857
+ const bindGlobal = (field2) => (value) => {
858
+ editGlobal(field2, value);
859
+ invalidateTests(rows.map((row) => row.id));
860
+ save(`\0global\0${field2}`, [field2], field2, value, () => {
861
+ setGlobalDraft((current) => {
862
+ if (current[field2] === void 0) return current;
863
+ const next = { ...current };
864
+ delete next[field2];
865
+ return next;
866
+ });
867
+ });
868
+ };
742
869
  const saveMany = (key, ops, label, onRefused) => {
870
+ const secrets = secretsFromOps(ops);
871
+ const hidesValues = ops.some((op) => op?.path?.[op.path.length - 1] === "password");
872
+ const safeLabel = hidesValues ? `${label}\uFF08\u503C\u5DF2\u9690\u85CF\uFF09` : label;
743
873
  scheduleWrite(
744
874
  key,
745
875
  () => writeConfig(ctx, ops).then(() => {
746
- note(`\u5DF2\u4FDD\u5B58 ${label}`);
876
+ setSaveError(void 0);
877
+ setSavedNote(`\u5DF2\u4FDD\u5B58 ${safeLabel}`);
747
878
  }).catch((error) => {
748
- note(`\u4FDD\u5B58\u5931\u8D25 ${label}\uFF1A${error?.message ?? error}`);
879
+ const message = redactSensitiveText(String(error?.message ?? error), secrets);
880
+ setSavedNote(void 0);
881
+ setSaveError({ label: safeLabel, message });
749
882
  onRefused();
750
883
  })
751
884
  );
@@ -968,6 +1101,75 @@ function renderPage(ctx) {
968
1101
  )
969
1102
  )
970
1103
  );
1104
+ const storedTestUrl = typeof snapshot.value?.testUrl === "string" ? snapshot.value.testUrl : DEFAULT_TEST_URL;
1105
+ const storedLocalDirect = snapshot.value?.localDirect !== false;
1106
+ const globalTestUrl = typeof globalDraft.testUrl === "string" ? globalDraft.testUrl : storedTestUrl;
1107
+ const globalLocalDirect = typeof globalDraft.localDirect === "boolean" ? globalDraft.localDirect : storedLocalDirect;
1108
+ const globalTestUrlHint = testUrlHint(globalTestUrl, t);
1109
+ const globalBlock = h(
1110
+ "details",
1111
+ {
1112
+ className: import_client.default.diag,
1113
+ style: {
1114
+ ...S.diag,
1115
+ marginTop: "0"
1116
+ },
1117
+ key: "global-settings"
1118
+ },
1119
+ h("summary", { className: import_client.default.diagSummary }, t.globalTitle),
1120
+ h("p", { className: import_client.default.defaultLead, style: S.defaultLead }, t.globalLead),
1121
+ h(
1122
+ "label",
1123
+ { className: import_client.default.field, style: S.field },
1124
+ h("span", { className: import_client.default.label, style: S.label }, t.testUrl),
1125
+ h("input", {
1126
+ className: import_client.default.input,
1127
+ style: S.input,
1128
+ value: globalTestUrl,
1129
+ onChange: (event) => bindGlobal("testUrl")(event.target.value)
1130
+ }),
1131
+ globalTestUrlHint === void 0 ? h("span", { style: S.hint }, t.testUrlHint) : h(
1132
+ "span",
1133
+ {
1134
+ className: import_client.default.hint,
1135
+ style: {
1136
+ ...S.hint,
1137
+ color: "var(--dsw-alias-state-error-primary, #f85149)"
1138
+ }
1139
+ },
1140
+ globalTestUrlHint
1141
+ )
1142
+ ),
1143
+ h(
1144
+ "div",
1145
+ { style: S.testRow },
1146
+ h(
1147
+ "button",
1148
+ {
1149
+ className: import_client.default.testButton,
1150
+ style: S.testButton,
1151
+ type: "button",
1152
+ onClick: () => bindGlobal("testUrl")(DEFAULT_TEST_URL)
1153
+ },
1154
+ t.restoreDefaultTestUrl
1155
+ )
1156
+ ),
1157
+ h(
1158
+ "label",
1159
+ { className: import_client.default.toggle, style: S.toggle },
1160
+ h("input", {
1161
+ type: "checkbox",
1162
+ checked: globalLocalDirect,
1163
+ onChange: (event) => bindGlobal("localDirect")(event.target.checked === true)
1164
+ }),
1165
+ h("span", null, t.localDirect)
1166
+ ),
1167
+ h(
1168
+ "p",
1169
+ { style: S.hint },
1170
+ globalLocalDirect ? t.localDirectOnHint : t.localDirectOffHint
1171
+ )
1172
+ );
971
1173
  const query = filter.trim().toLowerCase();
972
1174
  const shown = query.length === 0 ? rows : rows.filter((row) => `${row.id} ${row.name}`.toLowerCase().includes(query));
973
1175
  const filterRow = rows.length === 0 ? null : field(
@@ -1186,19 +1388,56 @@ function renderPage(ctx) {
1186
1388
  result?.pending === true ? t.testRunning : t.testButton
1187
1389
  ),
1188
1390
  result?.pending === false ? h(
1189
- "span",
1391
+ "div",
1190
1392
  { style: S.testResult },
1191
1393
  result.ok === true ? [
1192
- // Only the latency carries the band colour; the exit IP
1193
- // stays in the secondary label colour.
1394
+ // The band colour belongs to the LATENCY alone. The
1395
+ // status sits beside it in the plain label colour:
1396
+ // colouring the whole line made a successful check
1397
+ // over 1500 ms read as a failure.
1194
1398
  h(
1195
1399
  "span",
1196
1400
  { key: "lat", style: { color: latencyColor(result.ms) } },
1197
1401
  `\u2713 ${typeof result.ms === "number" ? result.ms : "\u2014"} ms`
1198
1402
  ),
1199
- result.ip === void 0 ? null : ` \xB7 ${t.exitIpLabel}${result.ip}`,
1200
- result.direct === true ? ` \xB7 ${t.directLabel}` : null
1201
- ] : h("span", { key: "err", style: S.testFailure }, `\u2717 ${result.error}`)
1403
+ typeof result.status === "number" ? h(
1404
+ "span",
1405
+ { key: "status" },
1406
+ `HTTP ${result.status}`
1407
+ ) : null,
1408
+ result.ip === void 0 ? null : h("span", { key: "ip" }, `${t.exitIpLabel}${result.ip}`),
1409
+ result.direct === true ? h("span", { key: "direct" }, t.directLabel) : null,
1410
+ result.cached === true ? h("span", { key: "cached" }, t.cachedLabel) : null,
1411
+ result.target === void 0 ? null : h(
1412
+ "span",
1413
+ {
1414
+ key: "target",
1415
+ style: {
1416
+ flexBasis: "100%",
1417
+ fontSize: "11px",
1418
+ color: "var(--dsw-alias-label-secondary)",
1419
+ wordBreak: "break-all"
1420
+ }
1421
+ },
1422
+ `${t.testTargetLabel}${result.target}${targetSourceSuffix(result, t)}`
1423
+ )
1424
+ ] : [
1425
+ h("span", { key: "err", style: S.testFailure }, `\u2717 ${result.error}`),
1426
+ typeof result.status === "number" ? h("span", { key: "status" }, `HTTP ${result.status}`) : null,
1427
+ result.target === void 0 ? null : h(
1428
+ "span",
1429
+ {
1430
+ key: "target",
1431
+ style: {
1432
+ flexBasis: "100%",
1433
+ fontSize: "11px",
1434
+ color: "var(--dsw-alias-label-secondary)",
1435
+ wordBreak: "break-all"
1436
+ }
1437
+ },
1438
+ `${t.testTargetLabel}${result.target}${targetSourceSuffix(result, t)}`
1439
+ )
1440
+ ]
1202
1441
  ) : null,
1203
1442
  // Promote this card's endpoint to the shared default. Offered only
1204
1443
  // when the card carries its OWN endpoint: an inheriting card would
@@ -1264,7 +1503,40 @@ function renderPage(ctx) {
1264
1503
  "div",
1265
1504
  { className: import_client.default.root, style: S.root },
1266
1505
  h("p", { className: import_client.default.lead, style: { margin: "0 0 4px", fontSize: "12px", lineHeight: "1.6", color: "var(--dsw-alias-label-secondary)" } }, t.lead),
1506
+ saveError === void 0 ? null : h(
1507
+ "div",
1508
+ {
1509
+ role: "alert",
1510
+ key: "save-error",
1511
+ style: {
1512
+ display: "flex",
1513
+ alignItems: "flex-start",
1514
+ justifyContent: "space-between",
1515
+ gap: "10px",
1516
+ padding: "8px 10px",
1517
+ border: "1px solid var(--dsw-alias-state-error-primary, #f85149)",
1518
+ borderRadius: "8px",
1519
+ background: "var(--dsw-alias-bg-layer-1)",
1520
+ color: "var(--dsw-alias-state-error-primary, #f85149)",
1521
+ fontSize: "12px",
1522
+ fontWeight: "500",
1523
+ wordBreak: "break-all"
1524
+ }
1525
+ },
1526
+ h("span", null, `${t.saveFailedPrefix}${saveError.label} \u2014 ${saveError.message}`),
1527
+ h(
1528
+ "button",
1529
+ {
1530
+ className: import_client.default.testButton,
1531
+ style: { ...S.testButton, flex: "none" },
1532
+ type: "button",
1533
+ onClick: () => setSaveError(void 0)
1534
+ },
1535
+ t.close
1536
+ )
1537
+ ),
1267
1538
  defaultBlock,
1539
+ globalBlock,
1268
1540
  catalogueNotice,
1269
1541
  filterRow,
1270
1542
  ...body,
@@ -1293,8 +1565,9 @@ function renderPage(ctx) {
1293
1565
  h(
1294
1566
  "ul",
1295
1567
  { className: import_client.default.diagList, style: { margin: "6px 0 0", paddingLeft: "18px" } },
1296
- ...problemCount === 0 && diagnostics.length === 0 ? [h("li", { key: "none" }, t.diagPending)] : [
1568
+ ...problemCount === 0 && diagnostics.length === 0 && savedNote === void 0 ? [h("li", { key: "none" }, t.diagPending)] : [
1297
1569
  ...problemLines,
1570
+ ...savedNote === void 0 ? [] : [h("li", { key: "saved" }, savedNote)],
1298
1571
  ...diagnostics.map((line, index) => h("li", { key: `d${index}` }, line))
1299
1572
  ]
1300
1573
  )
@@ -1342,12 +1615,28 @@ var EN = {
1342
1615
  testFailed: "Test failed",
1343
1616
  exitIpLabel: "exit IP ",
1344
1617
  directLabel: "direct (not through the proxy)",
1618
+ cachedLabel: "cached",
1619
+ testTargetLabel: "Target: ",
1620
+ targetSourceRoute: " (this card's own target)",
1621
+ targetSourceUpstream: " (this provider's real upstream)",
1622
+ targetSourceTestUrl: " (global self-check URL)",
1623
+ globalTitle: "Global settings (advanced)",
1624
+ globalLead: "These fields are saved with the plugin rather than per provider. Leave testUrl empty to use the built-in default; comma/space separated candidates are tried in order and only the first 5 are used. localDirect is on by default; turn it off only when the proxy itself is a loopback or private target.",
1625
+ testUrl: "Self-check URL",
1626
+ testUrlHint: "Comma/space separated; http(s) URLs in the order they should be tried.",
1627
+ testUrlEmptyHint: "Empty means the Host uses its built-in default URL.",
1628
+ testUrlInvalidHint: "Some candidates are not absolute http(s) URLs. They are saved, but those checks will fail.",
1629
+ testUrlTooManyHint: "More than 5 candidates are listed; only the first 5 take effect.",
1630
+ restoreDefaultTestUrl: "Restore default",
1631
+ localDirect: "Loopback / private targets go direct",
1632
+ localDirectOnHint: "On: localhost, 127.x and private ranges bypass the proxy.",
1633
+ localDirectOffHint: "Off: loopback/private targets also use the proxy. Enable only when the proxy itself is the target.",
1345
1634
  advanced: "Advanced: target host rules",
1346
1635
  onlyHosts: "Proxy only",
1347
1636
  onlyHostsHint: "Comma/space separated; when set, only requests to these hosts are proxied (suffix match, * for all).",
1348
1637
  bypassHosts: "Bypass",
1349
1638
  bypassHostsHint: "Comma/space separated; requests to these hosts go direct, taking priority.",
1350
- ruleDeadHint: "These entries never match \u2014 rules take host names only, without scheme or path: ",
1639
+ ruleDeadHint: "These entries never match \u2014 use bare host names (no port, scheme or path); a wildcard must be `*` or start with `*.`: ",
1351
1640
  testTarget: "Self-check target",
1352
1641
  testTargetHint: "Empty uses the top-level testUrl; when set, this card's Test requests this URL instead \u2014 point it at a real upstream to verify the end-to-end path.",
1353
1642
  socks5Hint: "SOCKS5 tunnels the connection; the proxy resolves the target host name.",
@@ -1357,7 +1646,9 @@ var EN = {
1357
1646
  complete: "complete",
1358
1647
  notConfigured: "not configured",
1359
1648
  diagnostics: "Connection diagnostics",
1360
- diagPending: "(collecting\u2026)"
1649
+ diagPending: "(collecting\u2026)",
1650
+ saveFailedPrefix: "Save failed: ",
1651
+ close: "Close"
1361
1652
  };
1362
1653
  var ZH = {
1363
1654
  lead: "\u8BA9\u6307\u5B9A\u7684 provider \u8D70\u4F60\u81EA\u5DF1\u7684\u6B63\u5411\u4EE3\u7406\uFF0C\u5176\u5B83 provider \u4FDD\u6301\u76F4\u8FDE\u3002\u6307\u5411\u672C\u673A\u6216\u5185\u7F51\u5730\u5740\uFF08localhost\u3001127.x\u3001\u79C1\u6709\u7F51\u6BB5\uFF09\u7684\u8BF7\u6C42\u9ED8\u8BA4\u76F4\u8FDE\uFF0C\u9876\u5C42 localDirect \u8BBE\u4E3A false \u65F6\u6062\u590D\u8D70\u4EE3\u7406\u2014\u2014\u6B63\u5411\u4EE3\u7406\u901A\u5E38\u591F\u4E0D\u5230\u4F60\u81EA\u5DF1\u7684\u673A\u5668\u3002",
@@ -1400,12 +1691,28 @@ var ZH = {
1400
1691
  testFailed: "\u6D4B\u8BD5\u5931\u8D25",
1401
1692
  exitIpLabel: "\u51FA\u53E3 IP ",
1402
1693
  directLabel: "\u76F4\u8FDE\uFF08\u672A\u7ECF\u8FC7\u4EE3\u7406\uFF09",
1694
+ cachedLabel: "\u7F13\u5B58",
1695
+ testTargetLabel: "\u76EE\u6807 ",
1696
+ targetSourceRoute: "\uFF08\u672C\u5361\u7247\u81EA\u68C0\u76EE\u6807\uFF09",
1697
+ targetSourceUpstream: "\uFF08\u8BE5 provider \u7684\u771F\u5B9E\u4E0A\u6E38\uFF09",
1698
+ targetSourceTestUrl: "\uFF08\u5168\u5C40\u81EA\u68C0\u5730\u5740\uFF09",
1699
+ globalTitle: "\u5168\u5C40\u8BBE\u7F6E\uFF08\u9AD8\u7EA7\uFF09",
1700
+ globalLead: "\u8FD9\u91CC\u7684\u4E24\u9879\u5C5E\u4E8E\u63D2\u4EF6\u5168\u5C40\u8BBE\u7F6E\u3002testUrl \u7559\u7A7A\u65F6 Host \u4F7F\u7528\u5185\u7F6E\u9ED8\u8BA4\u503C\uFF1B\u591A\u4E2A\u5019\u9009\u7528\u9017\u53F7\u6216\u7A7A\u683C\u5206\u9694\uFF0C\u6309\u987A\u5E8F\u56DE\u9000\uFF0C\u53EA\u6709\u524D 5 \u4E2A\u751F\u6548\u3002localDirect \u9ED8\u8BA4\u5F00\u542F\uFF1B\u53EA\u6709\u4EE3\u7406\u672C\u8EAB\u5C31\u662F\u672C\u673A\u6216\u5185\u7F51\u76EE\u6807\u65F6\u624D\u9700\u8981\u5173\u95ED\u3002",
1701
+ testUrl: "\u81EA\u68C0\u5730\u5740",
1702
+ testUrlHint: "\u9017\u53F7\u6216\u7A7A\u683C\u5206\u9694\uFF1B\u6309\u987A\u5E8F\u5C1D\u8BD5\u7684 http(s) URL\u3002",
1703
+ testUrlEmptyHint: "\u7559\u7A7A\u65F6 Host \u4F7F\u7528\u5185\u7F6E\u9ED8\u8BA4\u5730\u5740\u3002",
1704
+ testUrlInvalidHint: "\u90E8\u5206\u5019\u9009\u4E0D\u662F\u7EDD\u5BF9\u7684 http(s) URL\u3002\u4ECD\u4F1A\u4FDD\u5B58\uFF0C\u4F46\u8FD9\u4E9B\u5019\u9009\u4F1A\u6D4B\u8BD5\u5931\u8D25\u3002",
1705
+ testUrlTooManyHint: "\u5019\u9009\u8D85\u8FC7 5 \u4E2A\uFF0C\u53EA\u6709\u524D 5 \u4E2A\u751F\u6548\u3002",
1706
+ restoreDefaultTestUrl: "\u6062\u590D\u9ED8\u8BA4",
1707
+ localDirect: "\u672C\u673A / \u5185\u7F51\u76EE\u6807\u76F4\u8FDE",
1708
+ localDirectOnHint: "\u5F00\u542F\uFF1Alocalhost\u3001127.x \u4E0E\u79C1\u6709\u7F51\u6BB5\u7ED5\u8FC7\u4EE3\u7406\u3002",
1709
+ localDirectOffHint: "\u5173\u95ED\uFF1A\u672C\u673A / \u5185\u7F51\u76EE\u6807\u4E5F\u8D70\u4EE3\u7406\uFF1B\u4EC5\u5728\u4EE3\u7406\u672C\u8EAB\u5C31\u662F\u76EE\u6807\u65F6\u4F7F\u7528\u3002",
1403
1710
  advanced: "\u9AD8\u7EA7\uFF1A\u76EE\u6807\u57DF\u540D\u89C4\u5219",
1404
1711
  onlyHosts: "\u4EC5\u8D70\u4EE3\u7406",
1405
1712
  onlyHostsHint: "\u9017\u53F7\u6216\u7A7A\u683C\u5206\u9694\uFF1B\u975E\u7A7A\u65F6\u53EA\u6709\u5339\u914D\u8FD9\u4E9B\u4E3B\u673A\u7684\u8BF7\u6C42\u8D70\u4EE3\u7406\uFF08\u540E\u7F00\u5339\u914D\uFF0C* \u8868\u793A\u5168\u90E8\uFF09\u3002",
1406
1713
  bypassHosts: "\u76F4\u8FDE",
1407
1714
  bypassHostsHint: "\u9017\u53F7\u6216\u7A7A\u683C\u5206\u9694\uFF1B\u5339\u914D\u8FD9\u4E9B\u4E3B\u673A\u7684\u8BF7\u6C42\u76F4\u8FDE\uFF0C\u4F18\u5148\u4E8E\u4E0A\u4E00\u9879\u3002",
1408
- ruleDeadHint: "\u4EE5\u4E0B\u6761\u76EE\u6C38\u8FDC\u5339\u914D\u4E0D\u4E0A\u2014\u2014\u89C4\u5219\u53EA\u8BA4\u4E3B\u673A\u540D\uFF0C\u4E0D\u8981\u5E26\u534F\u8BAE\u524D\u7F00\u6216\u8DEF\u5F84\uFF1A",
1715
+ ruleDeadHint: "\u4EE5\u4E0B\u6761\u76EE\u6C38\u8FDC\u5339\u914D\u4E0D\u4E0A\u2014\u2014\u89C4\u5219\u53EA\u8BA4\u4E3B\u673A\u540D\uFF08\u4E0D\u8981\u5E26\u7AEF\u53E3\u3001\u534F\u8BAE\u524D\u7F00\u6216\u8DEF\u5F84\uFF09\uFF0C\u901A\u914D\u7B26\u53EA\u80FD\u662F\u5355\u72EC\u7684 * \u6216\u4EE5 *. \u5F00\u5934\uFF1A",
1409
1716
  testTarget: "\u81EA\u68C0\u76EE\u6807",
1410
1717
  testTargetHint: "\u7559\u7A7A\u7528\u9876\u5C42 testUrl\uFF1B\u586B\u5199\u540E\u8BE5\u5361\u7247\u7684\u300C\u6D4B\u8BD5\u300D\u6539\u4E3A\u8BF7\u6C42\u8FD9\u4E2A\u5730\u5740\uFF0C\u53EF\u62FF\u771F\u5B9E\u4E0A\u6E38\u9A8C\u8BC1\u7AEF\u5230\u7AEF\u8DEF\u5F84\u3002",
1411
1718
  socks5Hint: "SOCKS5 \u4F1A\u5EFA\u7ACB\u96A7\u9053\uFF0C\u76EE\u6807\u57DF\u540D\u7531\u4EE3\u7406\u7AEF\u89E3\u6790\u3002",
@@ -1415,7 +1722,9 @@ var ZH = {
1415
1722
  complete: "\u914D\u7F6E\u5B8C\u6574",
1416
1723
  notConfigured: "\u672A\u914D\u7F6E",
1417
1724
  diagnostics: "\u8FDE\u63A5\u8BCA\u65AD",
1418
- diagPending: "\uFF08\u6B63\u5728\u6536\u96C6\u2026\uFF09"
1725
+ diagPending: "\uFF08\u6B63\u5728\u6536\u96C6\u2026\uFF09",
1726
+ saveFailedPrefix: "\u4FDD\u5B58\u5931\u8D25\uFF1A",
1727
+ close: "\u5173\u95ED"
1419
1728
  };
1420
1729
  function stringsFor(ctx) {
1421
1730
  const language = localeOf(ctx);