clash-controller 0.2.5__tar.gz → 0.2.6__tar.gz

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.
Files changed (31) hide show
  1. {clash_controller-0.2.5 → clash_controller-0.2.6}/PKG-INFO +15 -4
  2. {clash_controller-0.2.5 → clash_controller-0.2.6}/README.md +14 -3
  3. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/__init__.py +1 -1
  4. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/api.py +132 -18
  5. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/app.tcss +29 -0
  6. clash_controller-0.2.6/clash_controller/proxies.py +238 -0
  7. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/screens/home.py +10 -5
  8. clash_controller-0.2.6/clash_controller/screens/nodes.py +617 -0
  9. clash_controller-0.2.6/clash_controller/screens/overview.py +565 -0
  10. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/widgets.py +9 -2
  11. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller.egg-info/PKG-INFO +15 -4
  12. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller.egg-info/SOURCES.txt +4 -0
  13. {clash_controller-0.2.5 → clash_controller-0.2.6}/pyproject.toml +1 -1
  14. clash_controller-0.2.5/clash_controller/screens/overview.py +0 -234
  15. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/__main__.py +0 -0
  16. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/app.py +0 -0
  17. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/config.py +0 -0
  18. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/deploy.py +0 -0
  19. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/main.py +0 -0
  20. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/screens/__init__.py +0 -0
  21. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/screens/config.py +0 -0
  22. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/screens/logs.py +0 -0
  23. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/screens/profiles.py +0 -0
  24. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/screens/settings.py +0 -0
  25. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/ssh.py +0 -0
  26. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller/ui.py +0 -0
  27. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller.egg-info/dependency_links.txt +0 -0
  28. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller.egg-info/entry_points.txt +0 -0
  29. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller.egg-info/requires.txt +0 -0
  30. {clash_controller-0.2.5 → clash_controller-0.2.6}/clash_controller.egg-info/top_level.txt +0 -0
  31. {clash_controller-0.2.5 → clash_controller-0.2.6}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: clash_controller
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: A Textual TUI for managing Clash proxy instances
5
5
  Author-email: Moha-Master <hongkongreporter@outlook.com>
6
6
  License-Expression: MIT
@@ -38,7 +38,8 @@ Requires-Dist: pytest-asyncio; extra == "dev"
38
38
  - 支持 HTTP 与 Unix Domain Socket 连接。
39
39
  - 远程端点支持 SSH 部署(密码 / 私钥文件 / 私钥文本三种认证)。
40
40
  - **实时监控面板**:
41
- - **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量。
41
+ - **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量;连接表支持关键字(地址 / 规则 / 节点 / 进程)、节点、隐藏 DIRECT 筛选,以及按默认(API 原始顺序)、上/下载速度、流量、时长、地址、规则、节点排序(可切换升/降序)。
42
+ - **节点管理**:按策略组浏览组内成员(节点 / 嵌套组 / DIRECT / REJECT),显示服务端记录的最新延迟、失败与未测状态;支持单节点测速、整组测速、在组内手动指定当前节点,以及对 URLTest / 故障转移组恢复自动(清除固定选择)。provider 节点测速自动走 provider 作用域接口。
42
43
  - **配置管理**:维护配置提供方列表,一键拉取远端配置、比对时间戳/哈希、部署到目标端点(本地或 SSH)并重载。
43
44
  - **设置菜单**:端点名称置于顶栏,TUN 与规则/全局/直连模式在面板内直接通过 Switch 和 Select 下拉列表即时生效切换(无需手动保存)、重载 GEO、重启 Clash、在线升级内核 / UI / GEO,以及查看运行日志。
44
45
 
@@ -51,9 +52,19 @@ Requires-Dist: pytest-asyncio; extra == "dev"
51
52
  | | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除端点配置 |
52
53
  | | `Ctrl+R` | 刷新列表 |
53
54
  | | `Esc` / `Ctrl+C` | 退出程序 |
54
- | **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
55
+ | **主菜单** | `1` / `2` / `3` / `4` / `5` | 直达概览 / 节点 / 配置管理 / 设置 / 切换端点 |
55
56
  | | `↑` / `↓` · `回车` | 移动光标 · 确认 |
56
- | | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `4`) |
57
+ | | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `5`) |
58
+ | **概览** | `↑` / `↓` / `PgUp` / `PgDn` | 滚动连接表 |
59
+ | | `/` | 聚焦筛选输入框(地址 / 规则 / 节点 / 进程) |
60
+ | | `Tab` / `Shift+Tab` | 在筛选框、节点 / 排序下拉、隐藏 DIRECT 开关间轮切 |
61
+ | | `Ctrl+R` | 刷新(焦点在输入框时让位给文本编辑) |
62
+ | **节点** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 在组内指定所选节点 |
63
+ | | `Ctrl+T` | 测所选节点延迟 |
64
+ | | `Ctrl+G` | 测整组延迟(URLTest / 故障转移组会清除固定选择,先确认) |
65
+ | | `Ctrl+U` | 恢复自动(清除 URLTest / 故障转移组的固定选择) |
66
+ | | `/` | 聚焦节点名称筛选框 |
67
+ | | `Ctrl+R` | 刷新策略组与延迟 |
57
68
  | **配置管理** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 部署所选提供方 |
58
69
  | | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
59
70
  | | `Ctrl+R` | 刷新 |
@@ -11,7 +11,8 @@
11
11
  - 支持 HTTP 与 Unix Domain Socket 连接。
12
12
  - 远程端点支持 SSH 部署(密码 / 私钥文件 / 私钥文本三种认证)。
13
13
  - **实时监控面板**:
14
- - **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量。
14
+ - **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量;连接表支持关键字(地址 / 规则 / 节点 / 进程)、节点、隐藏 DIRECT 筛选,以及按默认(API 原始顺序)、上/下载速度、流量、时长、地址、规则、节点排序(可切换升/降序)。
15
+ - **节点管理**:按策略组浏览组内成员(节点 / 嵌套组 / DIRECT / REJECT),显示服务端记录的最新延迟、失败与未测状态;支持单节点测速、整组测速、在组内手动指定当前节点,以及对 URLTest / 故障转移组恢复自动(清除固定选择)。provider 节点测速自动走 provider 作用域接口。
15
16
  - **配置管理**:维护配置提供方列表,一键拉取远端配置、比对时间戳/哈希、部署到目标端点(本地或 SSH)并重载。
16
17
  - **设置菜单**:端点名称置于顶栏,TUN 与规则/全局/直连模式在面板内直接通过 Switch 和 Select 下拉列表即时生效切换(无需手动保存)、重载 GEO、重启 Clash、在线升级内核 / UI / GEO,以及查看运行日志。
17
18
 
@@ -24,9 +25,19 @@
24
25
  | | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除端点配置 |
25
26
  | | `Ctrl+R` | 刷新列表 |
26
27
  | | `Esc` / `Ctrl+C` | 退出程序 |
27
- | **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
28
+ | **主菜单** | `1` / `2` / `3` / `4` / `5` | 直达概览 / 节点 / 配置管理 / 设置 / 切换端点 |
28
29
  | | `↑` / `↓` · `回车` | 移动光标 · 确认 |
29
- | | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `4`) |
30
+ | | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `5`) |
31
+ | **概览** | `↑` / `↓` / `PgUp` / `PgDn` | 滚动连接表 |
32
+ | | `/` | 聚焦筛选输入框(地址 / 规则 / 节点 / 进程) |
33
+ | | `Tab` / `Shift+Tab` | 在筛选框、节点 / 排序下拉、隐藏 DIRECT 开关间轮切 |
34
+ | | `Ctrl+R` | 刷新(焦点在输入框时让位给文本编辑) |
35
+ | **节点** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 在组内指定所选节点 |
36
+ | | `Ctrl+T` | 测所选节点延迟 |
37
+ | | `Ctrl+G` | 测整组延迟(URLTest / 故障转移组会清除固定选择,先确认) |
38
+ | | `Ctrl+U` | 恢复自动(清除 URLTest / 故障转移组的固定选择) |
39
+ | | `/` | 聚焦节点名称筛选框 |
40
+ | | `Ctrl+R` | 刷新策略组与延迟 |
30
41
  | **配置管理** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 部署所选提供方 |
31
42
  | | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
32
43
  | | `Ctrl+R` | 刷新 |
@@ -2,4 +2,4 @@
2
2
  Clash Controller - A Textual TUI for managing Clash proxy instances.
3
3
  """
4
4
 
5
- __version__ = "0.2.5"
5
+ __version__ = "0.2.6"
@@ -54,7 +54,10 @@ class ClashAPI:
54
54
  except Exception:
55
55
  return False
56
56
 
57
- def _request(self, method, endpoint, params=None, json_data=None, stream=False):
57
+ def _request(
58
+ self, method, endpoint, params=None, json_data=None, stream=False,
59
+ timeout: float | None = None, retry: bool = True,
60
+ ):
58
61
  """Helper method to make requests to the API.
59
62
 
60
63
  Behavior changes:
@@ -62,8 +65,10 @@ class ClashAPI:
62
65
  - Treats PUT /configs ReadTimeout conservatively as success (server-side reload may have completed).
63
66
  - For streaming requests (stream=True) returns the raw response object so callers can iterate.
64
67
  - When debug=True, emits basic request/response information via the log callback.
68
+ - ``timeout``(秒)覆盖本次请求超时;``retry=False`` 时 ReadTimeout 不重试(测速不能重复打内核)。
65
69
  """
66
70
  url = f"{self.base_url}{endpoint}"
71
+ effective_timeout = self.timeout if timeout is None else timeout
67
72
 
68
73
  def do_request(timeout_val):
69
74
  return self.session.request(
@@ -79,11 +84,11 @@ class ClashAPI:
79
84
  try:
80
85
  if self.debug:
81
86
  try:
82
- self._log(f"[DEBUG] Request: {method} {url}\n headers={self.headers}\n params={params}\n json={json_data}\n stream={stream}\n timeout={self.timeout}")
87
+ self._log(f"[DEBUG] Request: {method} {url}\n headers={self.headers}\n params={params}\n json={json_data}\n stream={stream}\n timeout={effective_timeout}")
83
88
  except Exception:
84
89
  pass
85
90
 
86
- response = do_request(self.timeout)
91
+ response = do_request(effective_timeout)
87
92
 
88
93
  # If caller requested streaming, return the raw response so the caller can iterate
89
94
  if stream:
@@ -116,6 +121,11 @@ class ClashAPI:
116
121
  return {"body": parsed, "status_code": response.status_code}, None
117
122
 
118
123
  except requests.exceptions.ReadTimeout as e:
124
+ # 测速等场景不能重试(会重复打内核),由调用方显式关闭。
125
+ if not retry:
126
+ if self.debug:
127
+ self._log("[DEBUG] ReadTimeout occurred, retry disabled")
128
+ return None, "请求超时"
119
129
  # Retry once with an increased timeout. If retry fails due to
120
130
  # connection problems and the API is local, return the sentinel so
121
131
  # caller can ask user to verify the outcome manually.
@@ -267,16 +277,24 @@ class ClashAPI:
267
277
 
268
278
  def get_proxy(self, name: str):
269
279
  """Get a specific proxy or group's information."""
270
- return self._request('GET', f'/proxies/{quote(name)}')
280
+ return self._request('GET', f'/proxies/{quote(name, safe="")}')
271
281
 
272
282
  def select_proxy_in_group(self, group_name: str, proxy_name: str):
273
283
  """Select a proxy for a specific group."""
274
- return self._request('PUT', f'/proxies/{quote(group_name)}', json_data={"name": proxy_name})
284
+ return self._request('PUT', f'/proxies/{quote(group_name, safe="")}', json_data={"name": proxy_name})
275
285
 
276
- def test_proxy_delay(self, name: str, url: str, timeout: int):
277
- """Test a proxy's delay."""
286
+ def test_proxy_delay(self, name: str, url: str, timeout: int = 5000, expected=None):
287
+ """Test a proxy's delay (timeout 单位为毫秒,内核限制 <= 32767)。"""
278
288
  params = {'url': url, 'timeout': str(timeout)}
279
- return self._request('GET', f'/proxies/{quote(name)}/delay', params=params)
289
+ if expected:
290
+ params['expected'] = str(expected)
291
+ return self._request(
292
+ 'GET',
293
+ f'/proxies/{quote(name, safe="")}/delay',
294
+ params=params,
295
+ timeout=timeout / 1000 + 10,
296
+ retry=False,
297
+ )
280
298
 
281
299
  def get_groups(self):
282
300
  """Get policy groups."""
@@ -284,16 +302,31 @@ class ClashAPI:
284
302
 
285
303
  def get_group(self, name: str):
286
304
  """Get a specific policy group."""
287
- return self._request('GET', f'/group/{quote(name)}')
305
+ return self._request('GET', f'/group/{quote(name, safe="")}')
288
306
 
289
307
  def reset_auto_group_selection(self, name: str):
290
- """Reset the fixed selection of an auto policy group."""
291
- return self._request('DELETE', f'/group/{quote(name)}')
308
+ """Reset the fixed selection of an auto policy group.
309
+
310
+ 内核没有 ``DELETE /group/{name}``;清除 URLTest / Fallback 的 fixed 走
311
+ ``DELETE /proxies/{name}``(仅 SelectAble 且非 Selector 的组返回 204)。
312
+ """
313
+ return self._request('DELETE', f'/proxies/{quote(name, safe="")}')
292
314
 
293
- def test_group_delay(self, name: str, url: str, timeout: int):
294
- """Test the delay of all proxies in a group."""
315
+ def test_group_delay(self, name: str, url: str, timeout: int = 5000, expected=None):
316
+ """Test the delay of all proxies in a group.
317
+
318
+ 注意:该端点会先清除 URLTest / Fallback 的 fixed,再并发测试成员。
319
+ """
295
320
  params = {'url': url, 'timeout': str(timeout)}
296
- return self._request('GET', f'/group/{quote(name)}/delay', params=params)
321
+ if expected:
322
+ params['expected'] = str(expected)
323
+ return self._request(
324
+ 'GET',
325
+ f'/group/{quote(name, safe="")}/delay',
326
+ params=params,
327
+ timeout=timeout / 1000 + 10,
328
+ retry=False,
329
+ )
297
330
 
298
331
  # === Providers ===
299
332
  def get_proxy_providers(self):
@@ -302,15 +335,32 @@ class ClashAPI:
302
335
 
303
336
  def get_proxy_provider(self, name: str):
304
337
  """Get a specific proxy provider."""
305
- return self._request('GET', f'/providers/proxies/{quote(name)}')
338
+ return self._request('GET', f'/providers/proxies/{quote(name, safe="")}')
306
339
 
307
340
  def update_proxy_provider(self, name: str):
308
341
  """Update a proxy provider."""
309
- return self._request('PUT', f'/providers/proxies/{quote(name)}')
342
+ return self._request('PUT', f'/providers/proxies/{quote(name, safe="")}')
310
343
 
311
344
  def healthcheck_proxy_provider(self, name: str):
312
345
  """Trigger a health check for a proxy provider."""
313
- return self._request('GET', f'/providers/proxies/{quote(name)}/healthcheck')
346
+ return self._request('GET', f'/providers/proxies/{quote(name, safe="")}/healthcheck')
347
+
348
+ def test_provider_proxy_delay(self, provider: str, node: str, url: str, timeout: int = 5000, expected=None):
349
+ """Test a single provider node's delay.
350
+
351
+ provider 节点不在 ``/proxies`` 顶层字典里,单测必须走 provider 作用域
352
+ (``/providers/proxies/{provider}/{node}/healthcheck``),否则 404。
353
+ """
354
+ params = {'url': url, 'timeout': str(timeout)}
355
+ if expected:
356
+ params['expected'] = str(expected)
357
+ return self._request(
358
+ 'GET',
359
+ f'/providers/proxies/{quote(provider, safe="")}/{quote(node, safe="")}/healthcheck',
360
+ params=params,
361
+ timeout=timeout / 1000 + 10,
362
+ retry=False,
363
+ )
314
364
 
315
365
  def get_rule_providers(self):
316
366
  """Get all rule providers."""
@@ -318,7 +368,7 @@ class ClashAPI:
318
368
 
319
369
  def update_rule_provider(self, name: str):
320
370
  """Update a rule provider."""
321
- return self._request('PUT', f'/providers/rules/{quote(name)}')
371
+ return self._request('PUT', f'/providers/rules/{quote(name, safe="")}')
322
372
 
323
373
  def update_all_providers(self):
324
374
  """更新所有代理合集 (proxy-providers) 与规则合集 (rule-providers)。
@@ -476,6 +526,70 @@ def interpret_api_result(action_desc: str, result, error) -> tuple[str, str]:
476
526
  return (f"{action_desc}失败:{error}", "err")
477
527
 
478
528
 
529
+ DELAY_OK = "ok"
530
+ DELAY_TIMEOUT = "timeout"
531
+ DELAY_ERROR = "error"
532
+
533
+
534
+ def _delay_error_kind(error) -> str | None:
535
+ """把传输层错误归一化为 timeout / error;无错误返回 None。"""
536
+ if error == ClashAPI.SENT_BUT_DISCONNECTED:
537
+ return DELAY_TIMEOUT
538
+ if not error:
539
+ return None
540
+ low = str(error).lower()
541
+ if "timeout" in low or "timed out" in low or "超时" in low:
542
+ return DELAY_TIMEOUT
543
+ return DELAY_ERROR
544
+
545
+
546
+ def interpret_delay_result(result, error) -> tuple[str, int | None]:
547
+ """解释单节点测速结果:``(DELAY_OK|DELAY_TIMEOUT|DELAY_ERROR, 毫秒或 None)``。
548
+
549
+ 内核把 504 当正常响应体返回(ClashAPI 不抛异常),因此必须读 ``status_code``。
550
+ """
551
+ kind = _delay_error_kind(error)
552
+ if kind is not None:
553
+ return kind, None
554
+ if not isinstance(result, dict):
555
+ return DELAY_ERROR, None
556
+ status_code = result.get("status_code")
557
+ if status_code == 504:
558
+ return DELAY_TIMEOUT, None
559
+ if status_code in (400, 404, 503):
560
+ return DELAY_ERROR, None
561
+ if status_code != 200:
562
+ return DELAY_ERROR, None
563
+ delay = result.get("delay")
564
+ if isinstance(delay, bool) or not isinstance(delay, int) or delay <= 0:
565
+ return DELAY_ERROR, None
566
+ return DELAY_OK, delay
567
+
568
+
569
+ def interpret_group_delay_result(result, error) -> tuple[str, dict[str, int]]:
570
+ """解释整组测速结果:``(状态, {节点名: 毫秒})``;只有整数项被保留。"""
571
+ kind = _delay_error_kind(error)
572
+ if kind is not None:
573
+ return kind, {}
574
+ if not isinstance(result, dict):
575
+ return DELAY_ERROR, {}
576
+ status_code = result.get("status_code")
577
+ if status_code == 504:
578
+ return DELAY_TIMEOUT, {}
579
+ if status_code in (400, 404, 503):
580
+ return DELAY_ERROR, {}
581
+ if status_code != 200:
582
+ return DELAY_ERROR, {}
583
+ mapping: dict[str, int] = {}
584
+ for key, value in result.items():
585
+ if key == "status_code":
586
+ continue
587
+ if isinstance(value, bool) or not isinstance(value, int):
588
+ continue
589
+ mapping[str(key)] = value
590
+ return DELAY_OK, mapping
591
+
592
+
479
593
  def _brief(raw, limit: int = 120) -> str:
480
594
  """把任意响应体压成一行短文本,供提示栏/通知展示。"""
481
595
  if raw is None:
@@ -112,6 +112,35 @@ Button:disabled {
112
112
  margin-left: 2;
113
113
  }
114
114
 
115
+ /* 概览页连接筛选:Select 显式定宽(否则内部 1fr 标签会吃掉整行),Switch 压成单行 */
116
+ #cn-node {
117
+ width: 20;
118
+ }
119
+
120
+ #cn-sort {
121
+ width: 14;
122
+ }
123
+
124
+ /* 节点页:策略组 / 排序下拉显式定宽(Select 内部 1fr 标签会吃掉整行) */
125
+ #nd-group {
126
+ width: 24;
127
+ }
128
+
129
+ #nd-sort {
130
+ width: 12;
131
+ }
132
+
133
+ .filter-row Switch {
134
+ height: 1;
135
+ border: none;
136
+ padding: 0 1;
137
+ margin-left: 1;
138
+ }
139
+
140
+ .filter-row Switch:focus {
141
+ background-tint: $foreground 8%;
142
+ }
143
+
115
144
  /* ---------- 表格 ---------- */
116
145
  .tbl {
117
146
  height: 1fr;
@@ -0,0 +1,238 @@
1
+ """策略组 / 节点数据归一化(零 Textual 依赖)。
2
+
3
+ Clash 的 ``GET /proxies`` 把节点与策略组放在同一个顶层字典里:组带 ``all`` 数组,
4
+ 节点没有;provider 节点只出现在 ``GET /providers/proxies`` 中(``config.go`` 不把
5
+ 它们注册进顶层字典)。本模块把两份原始响应合并成可展示的 :class:`ProxiesSnapshot`,
6
+ 并负责把 ``history`` / ``extra`` 里的延迟记录解释成「毫秒 / 失败 / 未测」。
7
+
8
+ 约定:``delay is None`` = 未测;``delay == 0`` = 测试失败(不是 0ms)。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+ DEFAULT_TEST_URL = "https://www.gstatic.com/generate_204"
15
+ DEFAULT_TEST_TIMEOUT = 5000
16
+
17
+ GROUP_TYPE_LABELS = {
18
+ "selector": "手动",
19
+ "urltest": "自动",
20
+ "fallback": "故障转移",
21
+ "loadbalance": "负载均衡",
22
+ }
23
+
24
+
25
+ @dataclass
26
+ class ProxyNode:
27
+ name: str
28
+ type: str
29
+ udp: bool = False
30
+ provider: str = "" # 空 = 配置内节点;否则 provider 名
31
+ is_group: bool = False # 该成员是嵌套策略组
32
+ delay: int | None = None # None=未测;0=失败;>0=毫秒
33
+
34
+
35
+ @dataclass
36
+ class ProxyGroup:
37
+ name: str
38
+ type: str # 原始类型字符串,如 "Selector"
39
+ kind: str # 小写归一化:selector/urltest/fallback/loadbalance/other
40
+ now: str = ""
41
+ all: list[str] = field(default_factory=list)
42
+ fixed: str = ""
43
+ hidden: bool = False
44
+ test_url: str = ""
45
+
46
+
47
+ @dataclass
48
+ class ProxiesSnapshot:
49
+ nodes: dict[str, ProxyNode]
50
+ groups: dict[str, ProxyGroup]
51
+ order: list[str] # 组装后的组顺序(GLOBAL 最后)
52
+
53
+ def group_list(self, include_hidden: bool = False) -> list[ProxyGroup]:
54
+ out: list[ProxyGroup] = []
55
+ for name in self.order:
56
+ group = self.groups.get(name)
57
+ if group is None:
58
+ continue
59
+ if group.hidden and not include_hidden:
60
+ continue
61
+ out.append(group)
62
+ return out
63
+
64
+ def member(self, group: ProxyGroup, name: str) -> ProxyNode:
65
+ node = self.nodes.get(name)
66
+ if node is not None:
67
+ return node
68
+ nested = self.groups.get(name)
69
+ if nested is not None:
70
+ return ProxyNode(
71
+ name=name,
72
+ type=nested.type,
73
+ provider="",
74
+ is_group=True,
75
+ delay=self.resolve_delay(nested.now),
76
+ )
77
+ return ProxyNode(name=name, type="")
78
+
79
+ def resolve_delay(self, name: str, _seen: set[str] | None = None) -> int | None:
80
+ """沿组的 ``now`` 递归到叶子节点;遇到环或找不到返回 None。"""
81
+ if not name:
82
+ return None
83
+ seen = _seen if _seen is not None else set()
84
+ if name in seen:
85
+ return None
86
+ seen.add(name)
87
+ node = self.nodes.get(name)
88
+ if node is not None:
89
+ return node.delay
90
+ group = self.groups.get(name)
91
+ if group is None:
92
+ return None
93
+ return self.resolve_delay(group.now, seen)
94
+
95
+
96
+ def group_test_url(group: ProxyGroup) -> str:
97
+ return group.test_url or DEFAULT_TEST_URL
98
+
99
+
100
+ def _as_int(value) -> int | None:
101
+ if isinstance(value, bool) or value is None:
102
+ return None
103
+ if isinstance(value, int):
104
+ return value
105
+ try:
106
+ return int(value)
107
+ except (TypeError, ValueError):
108
+ return None
109
+
110
+
111
+ def _history_delay(entries) -> int | None:
112
+ if not isinstance(entries, list) or not entries:
113
+ return None
114
+ last = entries[-1]
115
+ if not isinstance(last, dict):
116
+ return None
117
+ return _as_int(last.get("delay"))
118
+
119
+
120
+ def _node_delay(raw: dict) -> int | None:
121
+ """优先取 ``history[-1].delay``;否则取 ``extra`` 里时间最晚的一条记录。"""
122
+ delay = _history_delay(raw.get("history"))
123
+ if delay is not None:
124
+ return delay
125
+ extra = raw.get("extra")
126
+ if not isinstance(extra, dict):
127
+ return None
128
+ best_time: str | None = None
129
+ best: int | None = None
130
+ for entry in extra.values():
131
+ if not isinstance(entry, dict):
132
+ continue
133
+ history = entry.get("history")
134
+ if not isinstance(history, list) or not history:
135
+ continue
136
+ last = history[-1]
137
+ if not isinstance(last, dict):
138
+ continue
139
+ stamp = str(last.get("time") or "")
140
+ value = _as_int(last.get("delay"))
141
+ if value is None:
142
+ continue
143
+ if best_time is None or stamp > best_time:
144
+ best_time = stamp
145
+ best = value
146
+ return best
147
+
148
+
149
+ def _build_node(name: str, raw: dict, provider: str = "") -> ProxyNode:
150
+ return ProxyNode(
151
+ name=name,
152
+ type=str(raw.get("type") or ""),
153
+ udp=bool(raw.get("udp", False)),
154
+ provider=provider,
155
+ is_group=False,
156
+ delay=_node_delay(raw),
157
+ )
158
+
159
+
160
+ def _build_group(name: str, raw: dict) -> ProxyGroup:
161
+ raw_type = str(raw.get("type") or "")
162
+ kind = raw_type.lower()
163
+ if kind not in GROUP_TYPE_LABELS:
164
+ kind = "other"
165
+ members = [str(item) for item in raw.get("all") or [] if isinstance(item, (str, int))]
166
+ return ProxyGroup(
167
+ name=name,
168
+ type=raw_type,
169
+ kind=kind,
170
+ now=str(raw.get("now") or ""),
171
+ all=members,
172
+ fixed=str(raw.get("fixed") or ""),
173
+ hidden=bool(raw.get("hidden", False)),
174
+ test_url=str(raw.get("testUrl") or ""),
175
+ )
176
+
177
+
178
+ def build_snapshot(proxies_result, providers_result=None) -> ProxiesSnapshot:
179
+ """合并 ``/proxies`` 与 ``/providers/proxies`` 的返回体;非法入参按空处理。"""
180
+ nodes: dict[str, ProxyNode] = {}
181
+ groups: dict[str, ProxyGroup] = {}
182
+
183
+ data = proxies_result if isinstance(proxies_result, dict) else {}
184
+ raw_proxies = data.get("proxies")
185
+ if isinstance(raw_proxies, dict):
186
+ for key, raw in raw_proxies.items():
187
+ if not isinstance(raw, dict):
188
+ continue
189
+ name = str(raw.get("name") or key)
190
+ if isinstance(raw.get("all"), list):
191
+ groups[name] = _build_group(name, raw)
192
+ else:
193
+ nodes[name] = _build_node(name, raw)
194
+
195
+ providers = providers_result.get("providers") if isinstance(providers_result, dict) else None
196
+ if isinstance(providers, dict):
197
+ for key, entry in providers.items():
198
+ if not isinstance(entry, dict):
199
+ continue
200
+ pname = str(entry.get("name") or key)
201
+ # 每个内联组都会生成同名 Compatible provider,default 是内置项,均需过滤
202
+ if pname.lower() == "default":
203
+ continue
204
+ if str(entry.get("vehicleType") or "").lower() == "compatible":
205
+ continue
206
+ plist = entry.get("proxies")
207
+ if not isinstance(plist, list):
208
+ continue
209
+ for raw in plist:
210
+ if not isinstance(raw, dict):
211
+ continue
212
+ node_name = str(raw.get("name") or "")
213
+ if not node_name:
214
+ continue
215
+ existing = nodes.get(node_name)
216
+ if existing is not None:
217
+ if not existing.provider:
218
+ existing.provider = pname
219
+ continue
220
+ nodes[node_name] = _build_node(node_name, raw, provider=pname)
221
+
222
+ return ProxiesSnapshot(nodes=nodes, groups=groups, order=_group_order(groups))
223
+
224
+
225
+ def _group_order(groups: dict[str, ProxyGroup]) -> list[str]:
226
+ """GLOBAL.all 决定组顺序;不在其中的组按名称排在前面;GLOBAL 永远最后。"""
227
+ global_group = groups.get("GLOBAL")
228
+ if global_group is None:
229
+ return sorted(groups, key=str.casefold)
230
+ listed: list[str] = []
231
+ for name in global_group.all:
232
+ if name != "GLOBAL" and name in groups and name not in listed:
233
+ listed.append(name)
234
+ rest = sorted(
235
+ (name for name in groups if name != "GLOBAL" and name not in listed),
236
+ key=str.casefold,
237
+ )
238
+ return [*rest, *listed, "GLOBAL"]
@@ -12,7 +12,7 @@ from ..widgets import HintBar, MenuMarquee
12
12
 
13
13
 
14
14
  class HomeScreen(Screen):
15
- """功能主菜单:概览 / 配置管理 / 设置 / 切换端点。"""
15
+ """功能主菜单:概览 / 节点 / 配置管理 / 设置 / 切换端点。"""
16
16
 
17
17
  def __init__(self) -> None:
18
18
  super().__init__(classes="logo-page")
@@ -20,13 +20,15 @@ class HomeScreen(Screen):
20
20
  BINDINGS = [
21
21
  Binding("escape,ctrl+c", "menu('quit')", "退出"),
22
22
  Binding("1", "menu('overview')", "概览", show=False),
23
- Binding("2", "menu('configuration')", "配置", show=False),
24
- Binding("3", "menu('settings')", "设置", show=False),
25
- Binding("4", "menu('switch')", "端点", show=False),
23
+ Binding("2", "menu('nodes')", "节点", show=False),
24
+ Binding("3", "menu('configuration')", "配置", show=False),
25
+ Binding("4", "menu('settings')", "设置", show=False),
26
+ Binding("5", "menu('switch')", "端点", show=False),
26
27
  ]
27
28
 
28
29
  ITEMS = [
29
30
  ("overview", "概览", "流量 / 内存 / 活动连接实时监控"),
31
+ ("nodes", "节点", "策略组成员 / 延迟测速 / 手动指定"),
30
32
  ("configuration", "配置管理", "配置提供方与拉取部署"),
31
33
  ("settings", "设置", "TUN / 模式 / 重载 / 升级 / 日志"),
32
34
  ("switch", "切换端点", "返回端点选择"),
@@ -40,7 +42,7 @@ class HomeScreen(Screen):
40
42
  name = (self.app.profile or {}).get("name", "")
41
43
  yield Static(f"v{__version__}" + (f" · {name}" if name else ""), id="home-version")
42
44
  yield OptionList(id="home-list")
43
- yield HintBar("↑↓ 选择 · 回车 进入 · 1-4 直达 · Esc/Ctrl+C 退出", classes="page-hint")
45
+ yield HintBar("↑↓ 选择 · 回车 进入 · 1-5 直达 · Esc/Ctrl+C 退出", classes="page-hint")
44
46
 
45
47
  def on_mount(self) -> None:
46
48
  ol = self.query_one("#home-list", OptionList)
@@ -66,11 +68,14 @@ class HomeScreen(Screen):
66
68
 
67
69
  def _open(self, key: str) -> None:
68
70
  from .config import ConfigScreen
71
+ from .nodes import NodeScreen
69
72
  from .overview import OverviewScreen
70
73
  from .settings import SettingsScreen
71
74
 
72
75
  if key == "overview":
73
76
  self.app.push_screen(OverviewScreen())
77
+ elif key == "nodes":
78
+ self.app.push_screen(NodeScreen())
74
79
  elif key == "configuration":
75
80
  self.app.push_screen(ConfigScreen())
76
81
  elif key == "settings":