clash-controller 0.2.4__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.
- {clash_controller-0.2.4 → clash_controller-0.2.6}/PKG-INFO +17 -10
- {clash_controller-0.2.4 → clash_controller-0.2.6}/README.md +16 -9
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/__init__.py +1 -1
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/api.py +132 -18
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/app.tcss +29 -0
- clash_controller-0.2.6/clash_controller/proxies.py +238 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/screens/config.py +2 -2
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/screens/home.py +10 -5
- clash_controller-0.2.6/clash_controller/screens/nodes.py +617 -0
- clash_controller-0.2.6/clash_controller/screens/overview.py +565 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/widgets.py +37 -9
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller.egg-info/PKG-INFO +17 -10
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller.egg-info/SOURCES.txt +4 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/pyproject.toml +1 -1
- clash_controller-0.2.4/clash_controller/screens/overview.py +0 -234
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/__main__.py +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/app.py +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/config.py +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/deploy.py +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/main.py +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/screens/__init__.py +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/screens/logs.py +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/screens/profiles.py +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/screens/settings.py +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/ssh.py +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller/ui.py +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller.egg-info/dependency_links.txt +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller.egg-info/entry_points.txt +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller.egg-info/requires.txt +0 -0
- {clash_controller-0.2.4 → clash_controller-0.2.6}/clash_controller.egg-info/top_level.txt +0 -0
- {clash_controller-0.2.4 → 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.
|
|
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
|
|
@@ -31,14 +31,15 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
31
31
|
|
|
32
32
|
## 功能特性
|
|
33
33
|
|
|
34
|
-
- **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 +
|
|
34
|
+
- **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 + 全鼠标操作,数据行单击选中、双击执行,非阻塞异步加载与应用内日志。
|
|
35
35
|
- **多端点管理**:
|
|
36
36
|
- 自动保存连接过的 Clash 端点(地址与密钥)。
|
|
37
37
|
- 启动时进入类似首页的居中大 LOGO 档案选择页,支持新增 / 编辑 / 删除。
|
|
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,10 +52,20 @@ 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` | 退出程序(切换端点请按 `
|
|
57
|
-
|
|
|
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` | 刷新策略组与延迟 |
|
|
68
|
+
| **配置管理** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 部署所选提供方 |
|
|
58
69
|
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
|
|
59
70
|
| | `Ctrl+R` | 刷新 |
|
|
60
71
|
| **功能页通用** | `Home` / `End` / `PgUp` / `PgDn` | 页面 / 表格滚动 |
|
|
@@ -64,10 +75,6 @@ Requires-Dist: pytest-asyncio; extra == "dev"
|
|
|
64
75
|
| **弹窗** | `回车` | 提交(表单)/ 确认(危险操作需点按「确认」) |
|
|
65
76
|
| | `Esc` / `Ctrl+C` / 「取消」 | 取消并关闭 |
|
|
66
77
|
|
|
67
|
-
> 表单类弹窗统一为三段式:标题栏 / 字段区(单行紧凑输入,密码框留空 = 保持原值,开关为单行 Switch)/ 底部按钮栏 `[取消] [保存] [危险操作置右]`。
|
|
68
|
-
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴,`TextArea` 另有 `Ctrl+Z/Y` 撤销 / 重做),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
69
|
-
> 「查看应用日志」位于设置页右上角 `菜单 ▾` 折叠浮层中。
|
|
70
|
-
|
|
71
78
|
## 安装
|
|
72
79
|
|
|
73
80
|
```bash
|
|
@@ -4,14 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
## 功能特性
|
|
6
6
|
|
|
7
|
-
- **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 +
|
|
7
|
+
- **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 + 全鼠标操作,数据行单击选中、双击执行,非阻塞异步加载与应用内日志。
|
|
8
8
|
- **多端点管理**:
|
|
9
9
|
- 自动保存连接过的 Clash 端点(地址与密钥)。
|
|
10
10
|
- 启动时进入类似首页的居中大 LOGO 档案选择页,支持新增 / 编辑 / 删除。
|
|
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,10 +25,20 @@
|
|
|
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` | 退出程序(切换端点请按 `
|
|
30
|
-
|
|
|
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` | 刷新策略组与延迟 |
|
|
41
|
+
| **配置管理** | `↑` / `↓` · `回车` 或 **双击行** | 移动光标 · 部署所选提供方 |
|
|
31
42
|
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
|
|
32
43
|
| | `Ctrl+R` | 刷新 |
|
|
33
44
|
| **功能页通用** | `Home` / `End` / `PgUp` / `PgDn` | 页面 / 表格滚动 |
|
|
@@ -37,10 +48,6 @@
|
|
|
37
48
|
| **弹窗** | `回车` | 提交(表单)/ 确认(危险操作需点按「确认」) |
|
|
38
49
|
| | `Esc` / `Ctrl+C` / 「取消」 | 取消并关闭 |
|
|
39
50
|
|
|
40
|
-
> 表单类弹窗统一为三段式:标题栏 / 字段区(单行紧凑输入,密码框留空 = 保持原值,开关为单行 Switch)/ 底部按钮栏 `[取消] [保存] [危险操作置右]`。
|
|
41
|
-
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴,`TextArea` 另有 `Ctrl+Z/Y` 撤销 / 重做),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
42
|
-
> 「查看应用日志」位于设置页右上角 `菜单 ▾` 折叠浮层中。
|
|
43
|
-
|
|
44
51
|
## 安装
|
|
45
52
|
|
|
46
53
|
```bash
|
|
@@ -54,7 +54,10 @@ class ClashAPI:
|
|
|
54
54
|
except Exception:
|
|
55
55
|
return False
|
|
56
56
|
|
|
57
|
-
def _request(
|
|
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={
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"]
|
|
@@ -30,7 +30,7 @@ COLUMN_WEIGHTS = [2, 7]
|
|
|
30
30
|
|
|
31
31
|
class ConfigScreen(PageScreen):
|
|
32
32
|
TITLE = "配置管理"
|
|
33
|
-
HINT = "↑↓ 移动 · 回车 /
|
|
33
|
+
HINT = "↑↓ 移动 · 回车 / 双击 拉取并部署所选 · Ctrl+N 新增 · Ctrl+E 编辑 · Ctrl+D 删除 · Ctrl+R 刷新 · Esc/Ctrl+C 返回"
|
|
34
34
|
|
|
35
35
|
BINDINGS = [
|
|
36
36
|
Binding("ctrl+n", "add_provider", show=False),
|
|
@@ -54,7 +54,7 @@ class ConfigScreen(PageScreen):
|
|
|
54
54
|
yield Button("重载配置", id="reload-api")
|
|
55
55
|
yield Button("更新Provider", id="update-providers")
|
|
56
56
|
yield Static("", classes="fill")
|
|
57
|
-
yield Static("
|
|
57
|
+
yield Static("双击行 = 部署该提供方", classes="fl-label")
|
|
58
58
|
yield make_table("名称", "地址", id="cf-table")
|
|
59
59
|
|
|
60
60
|
def on_mount(self) -> None:
|