clash-controller 0.0.2__tar.gz → 0.2.0__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.0/PKG-INFO +113 -0
- clash_controller-0.2.0/README.md +86 -0
- clash_controller-0.2.0/clash_controller/__init__.py +5 -0
- clash_controller-0.2.0/clash_controller/__main__.py +6 -0
- {clash_controller-0.0.2 → clash_controller-0.2.0}/clash_controller/api.py +98 -24
- clash_controller-0.2.0/clash_controller/app.py +116 -0
- clash_controller-0.2.0/clash_controller/app.tcss +235 -0
- clash_controller-0.2.0/clash_controller/config.py +62 -0
- clash_controller-0.2.0/clash_controller/deploy.py +96 -0
- clash_controller-0.2.0/clash_controller/main.py +51 -0
- clash_controller-0.2.0/clash_controller/screens/__init__.py +1 -0
- clash_controller-0.2.0/clash_controller/screens/config.py +247 -0
- clash_controller-0.2.0/clash_controller/screens/home.py +96 -0
- clash_controller-0.2.0/clash_controller/screens/logs.py +29 -0
- clash_controller-0.2.0/clash_controller/screens/overview.py +205 -0
- clash_controller-0.2.0/clash_controller/screens/profiles.py +410 -0
- clash_controller-0.2.0/clash_controller/screens/settings.py +137 -0
- clash_controller-0.2.0/clash_controller/ssh.py +216 -0
- clash_controller-0.2.0/clash_controller/ui.py +302 -0
- clash_controller-0.2.0/clash_controller/widgets.py +520 -0
- clash_controller-0.2.0/clash_controller.egg-info/PKG-INFO +113 -0
- clash_controller-0.2.0/clash_controller.egg-info/SOURCES.txt +44 -0
- clash_controller-0.2.0/clash_controller.egg-info/entry_points.txt +2 -0
- clash_controller-0.2.0/clash_controller.egg-info/requires.txt +8 -0
- clash_controller-0.2.0/pyproject.toml +63 -0
- clash_controller-0.0.2/PKG-INFO +0 -77
- clash_controller-0.0.2/README.md +0 -60
- clash_controller-0.0.2/clash_controller/__init__.py +0 -6
- clash_controller-0.0.2/clash_controller/__main__.py +0 -9
- clash_controller-0.0.2/clash_controller/cli.py +0 -814
- clash_controller-0.0.2/clash_controller.egg-info/PKG-INFO +0 -77
- clash_controller-0.0.2/clash_controller.egg-info/SOURCES.txt +0 -12
- clash_controller-0.0.2/clash_controller.egg-info/entry_points.txt +0 -2
- clash_controller-0.0.2/clash_controller.egg-info/requires.txt +0 -3
- clash_controller-0.0.2/pyproject.toml +0 -31
- {clash_controller-0.0.2 → clash_controller-0.2.0}/clash_controller.egg-info/dependency_links.txt +0 -0
- {clash_controller-0.0.2 → clash_controller-0.2.0}/clash_controller.egg-info/top_level.txt +0 -0
- {clash_controller-0.0.2 → clash_controller-0.2.0}/setup.cfg +0 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clash_controller
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: A Textual TUI for managing Clash proxy instances
|
|
5
|
+
Author-email: Moha-Master <hongkongreporter@outlook.com>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/Moha-Master/Clash-Controller
|
|
8
|
+
Project-URL: Repository, https://github.com/Moha-Master/Clash-Controller.git
|
|
9
|
+
Project-URL: GitHub, https://github.com/Moha-Master/Clash-Controller
|
|
10
|
+
Project-URL: Issues, https://github.com/Moha-Master/Clash-Controller/issues
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
Requires-Dist: requests
|
|
21
|
+
Requires-Dist: requests_unixsocket
|
|
22
|
+
Requires-Dist: paramiko
|
|
23
|
+
Requires-Dist: textual>=8.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=6.0; extra == "dev"
|
|
26
|
+
Requires-Dist: pytest-asyncio; extra == "dev"
|
|
27
|
+
|
|
28
|
+
# Clash Controller
|
|
29
|
+
|
|
30
|
+
一个基于 [Textual](https://github.com/Textualize/textual) 构建的、功能丰富的 `clash` 文本用户界面(TUI)控制器,方便在终端中管理和监控一个或多个 `clash` 实例。
|
|
31
|
+
|
|
32
|
+
## 功能特性
|
|
33
|
+
|
|
34
|
+
- **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 + 全鼠标操作,单击数据行即执行,非阻塞异步加载与应用内日志。
|
|
35
|
+
- **多端点管理**:
|
|
36
|
+
- 自动保存连接过的 Clash 端点(地址与密钥)。
|
|
37
|
+
- 启动时进入类似首页的居中大 LOGO 档案选择页,支持新增 / 编辑 / 删除。
|
|
38
|
+
- 支持 HTTP 与 Unix Domain Socket 连接。
|
|
39
|
+
- 远程端点支持 SSH 部署(密码 / 私钥文件 / 私钥文本三种认证)。
|
|
40
|
+
- **实时监控面板**:
|
|
41
|
+
- **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量。
|
|
42
|
+
- **配置管理**:维护配置提供方列表,一键拉取远端配置、比对时间戳/哈希、部署到目标端点(本地或 SSH)并重载。
|
|
43
|
+
- **设置菜单**:端点名称置于顶栏,TUN 与规则/全局/直连模式在面板内直接通过 Switch 和 Select 下拉列表即时生效切换(无需手动保存)、重载 GEO、重启 Clash、在线升级内核 / UI / GEO,以及查看运行日志。
|
|
44
|
+
|
|
45
|
+
## 界面与快捷键
|
|
46
|
+
|
|
47
|
+
| 场景 | 快捷键 | 作用 |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| **端点选择(根屏)** | `↑` / `↓` · `回车` 或 **单击** | 移动光标 · 连接所选端点 |
|
|
50
|
+
| | `1-9` | 按数字直达连接第 N 个端点 |
|
|
51
|
+
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除端点配置 |
|
|
52
|
+
| | `Ctrl+R` | 刷新列表 |
|
|
53
|
+
| | `Esc` / `Ctrl+C` | 退出程序 |
|
|
54
|
+
| **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
|
|
55
|
+
| | `↑` / `↓` · `回车` | 移动光标 · 确认 |
|
|
56
|
+
| | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `4`) |
|
|
57
|
+
| **配置管理** | `↑` / `↓` · `回车` 或 **单击行** | 移动光标 · 部署所选提供方 |
|
|
58
|
+
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
|
|
59
|
+
| | `Ctrl+R` | 刷新 |
|
|
60
|
+
| **功能页通用** | `Home` / `End` / `PgUp` / `PgDn` | 页面 / 表格滚动 |
|
|
61
|
+
| | `Tab` / `Shift+Tab` | 表单字段轮切焦点 |
|
|
62
|
+
| | `Esc` / `Ctrl+C` | 返回上一页 |
|
|
63
|
+
| | `Ctrl+Q` | 任意界面直接退出程序 |
|
|
64
|
+
| **弹窗** | `回车` | 提交(表单)/ 确认(危险操作需点按「确认」) |
|
|
65
|
+
| | `Esc` / `Ctrl+C` / 「取消」 | 取消并关闭 |
|
|
66
|
+
|
|
67
|
+
> 表单类弹窗统一为三段式:标题栏 / 字段区(单行紧凑输入,密码框留空 = 保持原值,开关为单行 Switch)/ 底部按钮栏 `[取消] [保存] [危险操作置右]`。
|
|
68
|
+
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴,`TextArea` 另有 `Ctrl+Z/Y` 撤销 / 重做),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
69
|
+
> 「查看应用日志」位于设置页右上角 `菜单 ▾` 折叠浮层中。
|
|
70
|
+
|
|
71
|
+
## 安装
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install clash-controller
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
安装后,可以通过以下命令启动程序:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
clashctl
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
> 需要 Python 3.10 及以上版本。
|
|
84
|
+
|
|
85
|
+
可选参数:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
clashctl -D /path/to/config/dir # 指定配置目录(默认 ~/.config/clash-controller)
|
|
89
|
+
clashctl --debug # 启用 API / SSH 调试日志(显示在应用日志页)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## 开发者安装
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
git clone https://github.com/Moha-Master/clash-controller.git
|
|
96
|
+
cd clash-controller
|
|
97
|
+
|
|
98
|
+
python -m venv venv
|
|
99
|
+
source venv/bin/activate # 在 Windows 上使用 venv\Scripts\activate
|
|
100
|
+
|
|
101
|
+
pip install -e ".[dev]"
|
|
102
|
+
|
|
103
|
+
python -m clash_controller
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## 要求
|
|
107
|
+
|
|
108
|
+
- Python 3.10+
|
|
109
|
+
- 运行中的 Clash 实例,已开启外部控制 API
|
|
110
|
+
|
|
111
|
+
## 许可证
|
|
112
|
+
|
|
113
|
+
MIT
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Clash Controller
|
|
2
|
+
|
|
3
|
+
一个基于 [Textual](https://github.com/Textualize/textual) 构建的、功能丰富的 `clash` 文本用户界面(TUI)控制器,方便在终端中管理和监控一个或多个 `clash` 实例。
|
|
4
|
+
|
|
5
|
+
## 功能特性
|
|
6
|
+
|
|
7
|
+
- **现代 TUI 界面**:统一页面骨架(顶栏返回 / 折叠菜单 / 底部提示栏 + 状态栏),全键盘 + 全鼠标操作,单击数据行即执行,非阻塞异步加载与应用内日志。
|
|
8
|
+
- **多端点管理**:
|
|
9
|
+
- 自动保存连接过的 Clash 端点(地址与密钥)。
|
|
10
|
+
- 启动时进入类似首页的居中大 LOGO 档案选择页,支持新增 / 编辑 / 删除。
|
|
11
|
+
- 支持 HTTP 与 Unix Domain Socket 连接。
|
|
12
|
+
- 远程端点支持 SSH 部署(密码 / 私钥文件 / 私钥文本三种认证)。
|
|
13
|
+
- **实时监控面板**:
|
|
14
|
+
- **概览**:合并流式监控与连接管理,在一个页面同时显示上/下行流量、内存使用、内核版本,并带每秒自动刷新的当前活动连接表与累计流量。
|
|
15
|
+
- **配置管理**:维护配置提供方列表,一键拉取远端配置、比对时间戳/哈希、部署到目标端点(本地或 SSH)并重载。
|
|
16
|
+
- **设置菜单**:端点名称置于顶栏,TUN 与规则/全局/直连模式在面板内直接通过 Switch 和 Select 下拉列表即时生效切换(无需手动保存)、重载 GEO、重启 Clash、在线升级内核 / UI / GEO,以及查看运行日志。
|
|
17
|
+
|
|
18
|
+
## 界面与快捷键
|
|
19
|
+
|
|
20
|
+
| 场景 | 快捷键 | 作用 |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| **端点选择(根屏)** | `↑` / `↓` · `回车` 或 **单击** | 移动光标 · 连接所选端点 |
|
|
23
|
+
| | `1-9` | 按数字直达连接第 N 个端点 |
|
|
24
|
+
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除端点配置 |
|
|
25
|
+
| | `Ctrl+R` | 刷新列表 |
|
|
26
|
+
| | `Esc` / `Ctrl+C` | 退出程序 |
|
|
27
|
+
| **主菜单** | `1` / `2` / `3` / `4` | 直达概览 / 配置管理 / 设置 / 切换端点 |
|
|
28
|
+
| | `↑` / `↓` · `回车` | 移动光标 · 确认 |
|
|
29
|
+
| | `Esc` / `Ctrl+C` | 退出程序(切换端点请按 `4`) |
|
|
30
|
+
| **配置管理** | `↑` / `↓` · `回车` 或 **单击行** | 移动光标 · 部署所选提供方 |
|
|
31
|
+
| | `Ctrl+N` / `Ctrl+E` / `Ctrl+D` | 新增 / 编辑 / 删除提供方 |
|
|
32
|
+
| | `Ctrl+R` | 刷新 |
|
|
33
|
+
| **功能页通用** | `Home` / `End` / `PgUp` / `PgDn` | 页面 / 表格滚动 |
|
|
34
|
+
| | `Tab` / `Shift+Tab` | 表单字段轮切焦点 |
|
|
35
|
+
| | `Esc` / `Ctrl+C` | 返回上一页 |
|
|
36
|
+
| | `Ctrl+Q` | 任意界面直接退出程序 |
|
|
37
|
+
| **弹窗** | `回车` | 提交(表单)/ 确认(危险操作需点按「确认」) |
|
|
38
|
+
| | `Esc` / `Ctrl+C` / 「取消」 | 取消并关闭 |
|
|
39
|
+
|
|
40
|
+
> 表单类弹窗统一为三段式:标题栏 / 字段区(单行紧凑输入,密码框留空 = 保持原值,开关为单行 Switch)/ 底部按钮栏 `[取消] [保存] [危险操作置右]`。
|
|
41
|
+
> 焦点进入输入框即切换为**文本编辑模式**:使用控件原生编辑键(`Home`/`End`、`Ctrl+Shift+A` 全选、`Ctrl+X/C/V` 剪切 / 复制 / 粘贴,`TextArea` 另有 `Ctrl+Z/Y` 撤销 / 重做),底部提示栏同步切换,此时 `Ctrl+R` 刷新让位给输入框;`Ctrl+C` 有选中则复制、无选中则返回 / 取消。
|
|
42
|
+
> 「查看应用日志」位于设置页右上角 `菜单 ▾` 折叠浮层中。
|
|
43
|
+
|
|
44
|
+
## 安装
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install clash-controller
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
安装后,可以通过以下命令启动程序:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
clashctl
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
> 需要 Python 3.10 及以上版本。
|
|
57
|
+
|
|
58
|
+
可选参数:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
clashctl -D /path/to/config/dir # 指定配置目录(默认 ~/.config/clash-controller)
|
|
62
|
+
clashctl --debug # 启用 API / SSH 调试日志(显示在应用日志页)
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## 开发者安装
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/Moha-Master/clash-controller.git
|
|
69
|
+
cd clash-controller
|
|
70
|
+
|
|
71
|
+
python -m venv venv
|
|
72
|
+
source venv/bin/activate # 在 Windows 上使用 venv\Scripts\activate
|
|
73
|
+
|
|
74
|
+
pip install -e ".[dev]"
|
|
75
|
+
|
|
76
|
+
python -m clash_controller
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## 要求
|
|
80
|
+
|
|
81
|
+
- Python 3.10+
|
|
82
|
+
- 运行中的 Clash 实例,已开启外部控制 API
|
|
83
|
+
|
|
84
|
+
## 许可证
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -1,12 +1,19 @@
|
|
|
1
|
-
import requests
|
|
2
1
|
from urllib.parse import quote
|
|
3
2
|
|
|
3
|
+
import requests
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _noop(_message: str) -> None:
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
4
10
|
class ClashAPI:
|
|
5
11
|
# Special sentinel returned when the request was sent but the connection
|
|
6
12
|
# was interrupted (server closed connection / connection reset) and the
|
|
7
13
|
# client cannot determine final outcome. Caller should prompt user to verify.
|
|
8
14
|
SENT_BUT_DISCONNECTED = "SENT_BUT_DISCONNECTED"
|
|
9
|
-
|
|
15
|
+
|
|
16
|
+
def __init__(self, base_url, secret=None, timeout=10, working_directory=None, debug=False, log=None):
|
|
10
17
|
"""
|
|
11
18
|
Initializes the Clash API client.
|
|
12
19
|
|
|
@@ -15,20 +22,24 @@ class ClashAPI:
|
|
|
15
22
|
:param secret: The secret for API authentication.
|
|
16
23
|
:param timeout: Request timeout in seconds.
|
|
17
24
|
:param working_directory: The working directory of the Clash core, used for config paths.
|
|
25
|
+
:param debug: When True, emit raw request/response info via ``log``.
|
|
26
|
+
:param log: Callable receiving debug strings (defaults to no-op; never prints).
|
|
18
27
|
"""
|
|
19
28
|
self.base_url = base_url
|
|
20
29
|
self.timeout = timeout
|
|
21
30
|
self.headers = {}
|
|
22
31
|
self.working_directory = working_directory
|
|
23
|
-
# When debug is True, the client will print raw request/response info
|
|
24
32
|
self.debug = bool(debug)
|
|
33
|
+
# Debug output is routed to this callback (never stdout) so it cannot
|
|
34
|
+
# corrupt the TUI.
|
|
35
|
+
self._log = log or _noop
|
|
25
36
|
if secret:
|
|
26
37
|
self.headers['Authorization'] = f'Bearer {secret}'
|
|
27
38
|
|
|
28
39
|
if self.base_url.startswith('unix://'):
|
|
29
40
|
import requests_unixsocket
|
|
30
41
|
self.session = requests_unixsocket.Session()
|
|
31
|
-
encoded_path = quote(self.base_url.
|
|
42
|
+
encoded_path = quote(self.base_url.removeprefix('unix://'), safe='')
|
|
32
43
|
self.base_url = f'http+unix://{encoded_path}'
|
|
33
44
|
else:
|
|
34
45
|
self.session = requests.Session()
|
|
@@ -50,7 +61,7 @@ class ClashAPI:
|
|
|
50
61
|
- Retries once on ReadTimeout with a longer timeout.
|
|
51
62
|
- Treats PUT /configs ReadTimeout conservatively as success (server-side reload may have completed).
|
|
52
63
|
- For streaming requests (stream=True) returns the raw response object so callers can iterate.
|
|
53
|
-
- When debug=True,
|
|
64
|
+
- When debug=True, emits basic request/response information via the log callback.
|
|
54
65
|
"""
|
|
55
66
|
url = f"{self.base_url}{endpoint}"
|
|
56
67
|
|
|
@@ -68,7 +79,7 @@ class ClashAPI:
|
|
|
68
79
|
try:
|
|
69
80
|
if self.debug:
|
|
70
81
|
try:
|
|
71
|
-
|
|
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}")
|
|
72
83
|
except Exception:
|
|
73
84
|
pass
|
|
74
85
|
|
|
@@ -77,7 +88,7 @@ class ClashAPI:
|
|
|
77
88
|
# If caller requested streaming, return the raw response so the caller can iterate
|
|
78
89
|
if stream:
|
|
79
90
|
if self.debug:
|
|
80
|
-
|
|
91
|
+
self._log(f"[DEBUG] Response: status={response.status_code} headers={dict(response.headers)} (streaming)")
|
|
81
92
|
return response, None
|
|
82
93
|
|
|
83
94
|
# For non-stream responses: always return the API-provided body (prefer JSON),
|
|
@@ -85,7 +96,7 @@ class ClashAPI:
|
|
|
85
96
|
# encodes operation result details in the response body.
|
|
86
97
|
if response.status_code == 204 or not response.content:
|
|
87
98
|
if self.debug:
|
|
88
|
-
|
|
99
|
+
self._log(f"[DEBUG] Response: status={response.status_code} (no content)")
|
|
89
100
|
return {"status": "success", "status_code": response.status_code}, None
|
|
90
101
|
|
|
91
102
|
# Try JSON first, fall back to text
|
|
@@ -96,7 +107,7 @@ class ClashAPI:
|
|
|
96
107
|
|
|
97
108
|
if self.debug:
|
|
98
109
|
raw = (response.text or '')[:4096]
|
|
99
|
-
|
|
110
|
+
self._log(f"[DEBUG] Response: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
|
|
100
111
|
|
|
101
112
|
# If the parsed result is a dict, inject status_code for caller convenience
|
|
102
113
|
if isinstance(parsed, dict):
|
|
@@ -109,30 +120,33 @@ class ClashAPI:
|
|
|
109
120
|
# connection problems and the API is local, return the sentinel so
|
|
110
121
|
# caller can ask user to verify the outcome manually.
|
|
111
122
|
try:
|
|
112
|
-
longer_timeout =
|
|
123
|
+
longer_timeout = 15
|
|
113
124
|
if self.debug:
|
|
114
|
-
|
|
125
|
+
self._log(f"[DEBUG] ReadTimeout occurred, retrying with timeout={longer_timeout}")
|
|
115
126
|
response = do_request(longer_timeout)
|
|
116
127
|
response.raise_for_status()
|
|
117
128
|
|
|
118
129
|
if stream:
|
|
119
130
|
if self.debug:
|
|
120
|
-
|
|
131
|
+
self._log(f"[DEBUG] Response after retry: status={response.status_code} headers={dict(response.headers)} (streaming)")
|
|
121
132
|
return response, None
|
|
122
133
|
|
|
123
134
|
if response.status_code == 204 or not response.content:
|
|
124
135
|
if self.debug:
|
|
125
|
-
|
|
136
|
+
self._log(f"[DEBUG] Response after retry: status={response.status_code} (no content)")
|
|
126
137
|
return {"status": "success"}, None
|
|
127
138
|
|
|
128
139
|
if self.debug:
|
|
129
140
|
raw = response.text[:4096]
|
|
130
|
-
|
|
141
|
+
self._log(f"[DEBUG] Response after retry: status={response.status_code} headers={dict(response.headers)} body(<=4k)={raw}")
|
|
131
142
|
return response.json(), None
|
|
132
143
|
|
|
133
144
|
except requests.exceptions.RequestException as exc:
|
|
134
145
|
if self.debug:
|
|
135
|
-
|
|
146
|
+
self._log(f"[DEBUG] ReadTimeout retry failed: {exc}")
|
|
147
|
+
# Reload config may legitimately finish server-side while client times out.
|
|
148
|
+
if method.upper() == 'PUT' and endpoint.startswith('/configs'):
|
|
149
|
+
return None, self.SENT_BUT_DISCONNECTED
|
|
136
150
|
# If this is a local API, we conservatively cannot determine the final
|
|
137
151
|
# state (server may have applied the operation then closed the connection),
|
|
138
152
|
# so inform the caller to verify manually.
|
|
@@ -144,7 +158,9 @@ class ClashAPI:
|
|
|
144
158
|
# Connection errors/remote close — if local, return sentinel so caller
|
|
145
159
|
# can prompt user to manually verify; otherwise return the error.
|
|
146
160
|
if self.debug:
|
|
147
|
-
|
|
161
|
+
self._log(f"[DEBUG] Connection error: {e}")
|
|
162
|
+
if method.upper() == 'PUT' and endpoint.startswith('/configs'):
|
|
163
|
+
return None, self.SENT_BUT_DISCONNECTED
|
|
148
164
|
if self._is_local():
|
|
149
165
|
return None, self.SENT_BUT_DISCONNECTED
|
|
150
166
|
return None, str(e)
|
|
@@ -165,9 +181,9 @@ class ClashAPI:
|
|
|
165
181
|
|
|
166
182
|
if self.debug:
|
|
167
183
|
try:
|
|
168
|
-
|
|
184
|
+
self._log(f"[DEBUG] Error Response: status={resp.status_code} headers={dict(resp.headers)} body(<=4k)={(resp.text or '')[:4096]}")
|
|
169
185
|
except Exception:
|
|
170
|
-
|
|
186
|
+
self._log(f"[DEBUG] Error Response: status={getattr(resp, 'status_code', 'unknown')}")
|
|
171
187
|
|
|
172
188
|
# Return the response body as the result, do not treat as exception
|
|
173
189
|
if isinstance(body, dict):
|
|
@@ -177,7 +193,7 @@ class ClashAPI:
|
|
|
177
193
|
|
|
178
194
|
# No response available: transport-level error
|
|
179
195
|
if self.debug:
|
|
180
|
-
|
|
196
|
+
self._log(f"[DEBUG] RequestException without response (transport error): {e}")
|
|
181
197
|
return None, str(e)
|
|
182
198
|
|
|
183
199
|
# === Real-time Data ===
|
|
@@ -218,7 +234,7 @@ class ClashAPI:
|
|
|
218
234
|
def reload_configs(self, path="", payload=""):
|
|
219
235
|
"""Reload configuration from path."""
|
|
220
236
|
return self._request('PUT', '/configs', params={'force': 'true'}, json_data={"path": path, "payload": payload} or {})
|
|
221
|
-
|
|
237
|
+
|
|
222
238
|
def set_mode(self, mode: str):
|
|
223
239
|
"""Sets the connection mode ('rule', 'global', 'direct')."""
|
|
224
240
|
return self.update_configs({"mode": mode.lower()})
|
|
@@ -252,7 +268,7 @@ class ClashAPI:
|
|
|
252
268
|
def get_proxy(self, name: str):
|
|
253
269
|
"""Get a specific proxy or group's information."""
|
|
254
270
|
return self._request('GET', f'/proxies/{quote(name)}')
|
|
255
|
-
|
|
271
|
+
|
|
256
272
|
def select_proxy_in_group(self, group_name: str, proxy_name: str):
|
|
257
273
|
"""Select a proxy for a specific group."""
|
|
258
274
|
return self._request('PUT', f'/proxies/{quote(group_name)}', json_data={"name": proxy_name})
|
|
@@ -278,7 +294,7 @@ class ClashAPI:
|
|
|
278
294
|
"""Test the delay of all proxies in a group."""
|
|
279
295
|
params = {'url': url, 'timeout': str(timeout)}
|
|
280
296
|
return self._request('GET', f'/group/{quote(name)}/delay', params=params)
|
|
281
|
-
|
|
297
|
+
|
|
282
298
|
# === Providers ===
|
|
283
299
|
def get_proxy_providers(self):
|
|
284
300
|
"""Get all proxy providers."""
|
|
@@ -295,7 +311,7 @@ class ClashAPI:
|
|
|
295
311
|
def healthcheck_proxy_provider(self, name: str):
|
|
296
312
|
"""Trigger a health check for a proxy provider."""
|
|
297
313
|
return self._request('GET', f'/providers/proxies/{quote(name)}/healthcheck')
|
|
298
|
-
|
|
314
|
+
|
|
299
315
|
def get_rule_providers(self):
|
|
300
316
|
"""Get all rule providers."""
|
|
301
317
|
return self._request('GET', '/providers/rules')
|
|
@@ -325,4 +341,62 @@ class ClashAPI:
|
|
|
325
341
|
# === DNS ===
|
|
326
342
|
def query_dns(self, name: str, query_type: str = 'A'):
|
|
327
343
|
"""Query DNS."""
|
|
328
|
-
return self._request('GET', '/dns/query', params={'name': name, 'type': query_type})
|
|
344
|
+
return self._request('GET', '/dns/query', params={'name': name, 'type': query_type})
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def interpret_api_result(action_desc: str, result, error) -> tuple[str, str]:
|
|
348
|
+
"""把 ClashAPI 的 ``(result, error)`` 解释为 ``(message, severity)``。
|
|
349
|
+
|
|
350
|
+
severity ∈ {"ok", "warn", "err"};纯逻辑、无副作用,供界面层展示。
|
|
351
|
+
保留 ``SENT_BUT_DISCONNECTED`` 的「已发送但断连」语义。
|
|
352
|
+
"""
|
|
353
|
+
|
|
354
|
+
def _clean(raw: str) -> str:
|
|
355
|
+
if not raw:
|
|
356
|
+
return ''
|
|
357
|
+
s = raw.strip()
|
|
358
|
+
for prefix in ('update error:', 'error:', 'message:', 'update:'):
|
|
359
|
+
if s.lower().startswith(prefix):
|
|
360
|
+
s = s[len(prefix):].strip()
|
|
361
|
+
break
|
|
362
|
+
if s:
|
|
363
|
+
s = s[0].upper() + s[1:]
|
|
364
|
+
return s
|
|
365
|
+
|
|
366
|
+
if error is None:
|
|
367
|
+
if isinstance(result, dict):
|
|
368
|
+
status_val = str(result.get('status', '')).lower()
|
|
369
|
+
status_code = result.get('status_code')
|
|
370
|
+
if 'message' in result:
|
|
371
|
+
short_msg = _clean(str(result['message']))
|
|
372
|
+
elif 'info' in result:
|
|
373
|
+
short_msg = _clean(str(result['info']))
|
|
374
|
+
elif isinstance(result.get('body'), str):
|
|
375
|
+
short_msg = _clean(result['body'])
|
|
376
|
+
else:
|
|
377
|
+
short_msg = ''
|
|
378
|
+
|
|
379
|
+
success = status_val in ('success', 'ok', 'done', 'true') or (
|
|
380
|
+
isinstance(status_code, int) and status_code in (200, 201, 202, 204)
|
|
381
|
+
)
|
|
382
|
+
if success:
|
|
383
|
+
return (f"已成功{action_desc}。" + (f" 提示:{short_msg}" if short_msg else ""), "ok")
|
|
384
|
+
if short_msg:
|
|
385
|
+
return (f"{action_desc}: {short_msg}", "warn")
|
|
386
|
+
return (f"{action_desc}已完成(请查看详情)。", "ok")
|
|
387
|
+
|
|
388
|
+
text = str(result) if result is not None else ''
|
|
389
|
+
return (f"{action_desc}: {text}" if text else f"已成功{action_desc}。", "ok")
|
|
390
|
+
|
|
391
|
+
if error == ClashAPI.SENT_BUT_DISCONNECTED:
|
|
392
|
+
return (
|
|
393
|
+
f"请求“{action_desc}”已发送,但在读取最终响应前连接中断;"
|
|
394
|
+
"该操作可能已生效,请手动检查 Clash 日志 / 状态确认。",
|
|
395
|
+
"warn",
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
if isinstance(result, dict) and result.get('info'):
|
|
399
|
+
return (f"{action_desc}完成:{str(result.get('info'))[:2000]}", "ok")
|
|
400
|
+
|
|
401
|
+
return (f"{action_desc}失败:{error}", "err")
|
|
402
|
+
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Textual 应用入口:全局档案 / 客户端、状态栏、日志与启动流程。"""
|
|
2
|
+
from datetime import datetime
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from textual import work
|
|
6
|
+
from textual.app import App, ComposeResult
|
|
7
|
+
from textual.binding import Binding
|
|
8
|
+
from textual.widgets import Static
|
|
9
|
+
|
|
10
|
+
from .api import ClashAPI
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StatusBar(Static):
|
|
14
|
+
"""底部状态栏:展示最近一次操作结果 / 进行中提示。"""
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ClashControllerApp(App):
|
|
18
|
+
"""Clash 管理 TUI。"""
|
|
19
|
+
|
|
20
|
+
CSS_PATH = Path(__file__).parent / "app.tcss"
|
|
21
|
+
TITLE = "Clash Controller"
|
|
22
|
+
ENABLE_COMMAND_PALETTE = False
|
|
23
|
+
|
|
24
|
+
BINDINGS = [
|
|
25
|
+
Binding("ctrl+q", "quit", "退出", priority=True),
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
def __init__(self, debug: bool = False) -> None:
|
|
29
|
+
super().__init__()
|
|
30
|
+
self.debug_mode = bool(debug)
|
|
31
|
+
self.profile: dict | None = None
|
|
32
|
+
self._api: ClashAPI | None = None
|
|
33
|
+
self.app_logs: list[str] = []
|
|
34
|
+
|
|
35
|
+
# ------------------------------------------------------------ 日志
|
|
36
|
+
|
|
37
|
+
def add_log(self, message: str) -> None:
|
|
38
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
39
|
+
self.app_logs.append(f"[{timestamp}] {message}")
|
|
40
|
+
|
|
41
|
+
# ------------------------------------------------------------ 档案 / 客户端
|
|
42
|
+
|
|
43
|
+
def set_profile(self, profile: dict) -> None:
|
|
44
|
+
self.profile = profile
|
|
45
|
+
self._api = None
|
|
46
|
+
|
|
47
|
+
def clear_profile(self) -> None:
|
|
48
|
+
self.profile = None
|
|
49
|
+
self._api = None
|
|
50
|
+
|
|
51
|
+
def get_api(self) -> ClashAPI:
|
|
52
|
+
if self.profile is None:
|
|
53
|
+
raise RuntimeError("尚未选择端点档案")
|
|
54
|
+
if self._api is None:
|
|
55
|
+
debug = self.debug_mode or bool(self.profile.get("debug_ssh"))
|
|
56
|
+
self._api = ClashAPI(
|
|
57
|
+
base_url=self.profile["url"],
|
|
58
|
+
secret=self.profile.get("secret"),
|
|
59
|
+
working_directory=self.profile.get("config_directory"),
|
|
60
|
+
debug=debug,
|
|
61
|
+
log=self.add_log,
|
|
62
|
+
)
|
|
63
|
+
return self._api
|
|
64
|
+
|
|
65
|
+
# ------------------------------------------------------------ 状态栏 / 提示
|
|
66
|
+
|
|
67
|
+
def compose(self) -> ComposeResult:
|
|
68
|
+
yield StatusBar("", id="status")
|
|
69
|
+
|
|
70
|
+
def status(self, message: str, kind: str = "") -> None:
|
|
71
|
+
try:
|
|
72
|
+
bar = self.query_one("#status", StatusBar)
|
|
73
|
+
except Exception:
|
|
74
|
+
return
|
|
75
|
+
bar.update_classes({"ok": kind == "ok", "warn": kind == "warn", "err": kind == "err"})
|
|
76
|
+
bar.update(message)
|
|
77
|
+
|
|
78
|
+
def notify_ok(self, message: str) -> None:
|
|
79
|
+
self.status(message, "ok")
|
|
80
|
+
self.notify(message, severity="success", timeout=4)
|
|
81
|
+
|
|
82
|
+
def notify_err(self, message: str) -> None:
|
|
83
|
+
self.status(message, "err")
|
|
84
|
+
self.notify(message, severity="error", timeout=8)
|
|
85
|
+
|
|
86
|
+
def notify_warn(self, message: str) -> None:
|
|
87
|
+
self.status(message, "warn")
|
|
88
|
+
self.notify(message, severity="warning", timeout=5)
|
|
89
|
+
|
|
90
|
+
def report(self, action_desc: str, result, error) -> str:
|
|
91
|
+
"""统一解释 ClashAPI 结果并反馈到状态栏 / 通知 / 日志,返回严重级别。"""
|
|
92
|
+
from .api import interpret_api_result
|
|
93
|
+
|
|
94
|
+
message, severity = interpret_api_result(action_desc, result, error)
|
|
95
|
+
self.add_log(message)
|
|
96
|
+
if self.debug_mode:
|
|
97
|
+
self.add_log(f"[DEBUG] result={result!r} error={error!r}")
|
|
98
|
+
if severity == "ok":
|
|
99
|
+
self.notify_ok(message)
|
|
100
|
+
elif severity == "warn":
|
|
101
|
+
self.notify_warn(message)
|
|
102
|
+
else:
|
|
103
|
+
self.notify_err(message)
|
|
104
|
+
return severity
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------ 启动
|
|
107
|
+
|
|
108
|
+
def on_mount(self) -> None:
|
|
109
|
+
self.add_log("Application started.")
|
|
110
|
+
self._bootstrap()
|
|
111
|
+
|
|
112
|
+
@work(exclusive=True)
|
|
113
|
+
async def _bootstrap(self) -> None:
|
|
114
|
+
from .screens.profiles import ProfileListScreen
|
|
115
|
+
|
|
116
|
+
self.push_screen(ProfileListScreen())
|