clash-controller 0.2.1__tar.gz → 0.2.3__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.1 → clash_controller-0.2.3}/PKG-INFO +1 -1
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/__init__.py +1 -1
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/app.py +66 -3
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/app.tcss +26 -4
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/config.py +63 -61
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/home.py +14 -20
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/overview.py +58 -43
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/profiles.py +28 -28
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/settings.py +39 -21
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/ui.py +16 -9
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/widgets.py +477 -8
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller.egg-info/PKG-INFO +1 -1
- {clash_controller-0.2.1 → clash_controller-0.2.3}/pyproject.toml +1 -1
- {clash_controller-0.2.1 → clash_controller-0.2.3}/README.md +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/__main__.py +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/api.py +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/config.py +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/deploy.py +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/main.py +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/__init__.py +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/screens/logs.py +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller/ssh.py +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller.egg-info/SOURCES.txt +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller.egg-info/dependency_links.txt +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller.egg-info/entry_points.txt +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller.egg-info/requires.txt +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/clash_controller.egg-info/top_level.txt +0 -0
- {clash_controller-0.2.1 → clash_controller-0.2.3}/setup.cfg +0 -0
|
@@ -2,16 +2,63 @@
|
|
|
2
2
|
from datetime import datetime
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
|
+
from rich.spinner import SPINNERS
|
|
6
|
+
from rich.text import Text
|
|
5
7
|
from textual import work
|
|
6
8
|
from textual.app import App, ComposeResult
|
|
7
9
|
from textual.binding import Binding
|
|
10
|
+
from textual.widget import Widget
|
|
8
11
|
from textual.widgets import Static
|
|
9
12
|
|
|
10
13
|
from .api import ClashAPI
|
|
14
|
+
from .widgets import ProgressCover
|
|
15
|
+
|
|
16
|
+
_DOTS = SPINNERS["dots"]
|
|
11
17
|
|
|
12
18
|
|
|
13
19
|
class StatusBar(Static):
|
|
14
|
-
"""底部状态栏:展示最近一次操作结果 /
|
|
20
|
+
"""底部状态栏:展示最近一次操作结果 / 进行中提示;spinner=True 时显示点阵转圈。"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
23
|
+
super().__init__(*args, **kwargs)
|
|
24
|
+
self._text = ""
|
|
25
|
+
self._busy = False
|
|
26
|
+
self._frame = 0
|
|
27
|
+
self._timer = None
|
|
28
|
+
|
|
29
|
+
def render(self) -> Text:
|
|
30
|
+
if self._busy:
|
|
31
|
+
glyph = _DOTS["frames"][self._frame % len(_DOTS["frames"])]
|
|
32
|
+
return Text.assemble((glyph, "bold"), " ", self._text)
|
|
33
|
+
return Text(self._text)
|
|
34
|
+
|
|
35
|
+
def set_status(self, text: str) -> None:
|
|
36
|
+
self._busy = False
|
|
37
|
+
self._text = text
|
|
38
|
+
self._stop_timer()
|
|
39
|
+
self.refresh()
|
|
40
|
+
|
|
41
|
+
def set_spinner(self, text: str) -> None:
|
|
42
|
+
self._busy = True
|
|
43
|
+
self._text = text
|
|
44
|
+
self._ensure_timer()
|
|
45
|
+
self.refresh()
|
|
46
|
+
|
|
47
|
+
def _ensure_timer(self) -> None:
|
|
48
|
+
if self._timer is None and self.is_mounted:
|
|
49
|
+
self._timer = self.set_interval(_DOTS["interval"] / 1000, self._tick)
|
|
50
|
+
|
|
51
|
+
def _stop_timer(self) -> None:
|
|
52
|
+
if self._timer is not None:
|
|
53
|
+
self._timer.stop()
|
|
54
|
+
self._timer = None
|
|
55
|
+
|
|
56
|
+
def _tick(self) -> None:
|
|
57
|
+
self._frame += 1
|
|
58
|
+
self.refresh()
|
|
59
|
+
|
|
60
|
+
def on_mount(self) -> None:
|
|
61
|
+
self._ensure_timer()
|
|
15
62
|
|
|
16
63
|
|
|
17
64
|
class ClashControllerApp(App):
|
|
@@ -67,13 +114,29 @@ class ClashControllerApp(App):
|
|
|
67
114
|
def compose(self) -> ComposeResult:
|
|
68
115
|
yield StatusBar("", id="status")
|
|
69
116
|
|
|
117
|
+
def get_loading_widget(self) -> Widget:
|
|
118
|
+
"""全局默认:任意控件 .loading=True 时用 indeterminate 进度条覆盖其区域。"""
|
|
119
|
+
return ProgressCover()
|
|
120
|
+
|
|
70
121
|
def status(self, message: str, kind: str = "") -> None:
|
|
71
122
|
try:
|
|
72
123
|
bar = self.query_one("#status", StatusBar)
|
|
73
|
-
except Exception:
|
|
124
|
+
except Exception: # noqa: BLE001
|
|
74
125
|
return
|
|
75
126
|
bar.update_classes({"ok": kind == "ok", "warn": kind == "warn", "err": kind == "err"})
|
|
76
|
-
bar.
|
|
127
|
+
bar.set_status(message)
|
|
128
|
+
|
|
129
|
+
def spinner(self, on: bool, message: str = "加载中…") -> None:
|
|
130
|
+
"""状态栏点阵 spinner(无遮罩、不阻断输入):适合轻量、即时的后台动作。"""
|
|
131
|
+
try:
|
|
132
|
+
bar = self.query_one("#status", StatusBar)
|
|
133
|
+
except Exception: # noqa: BLE001
|
|
134
|
+
return
|
|
135
|
+
bar.update_classes({})
|
|
136
|
+
if on:
|
|
137
|
+
bar.set_spinner(message)
|
|
138
|
+
else:
|
|
139
|
+
bar.set_status("")
|
|
77
140
|
|
|
78
141
|
def notify_ok(self, message: str) -> None:
|
|
79
142
|
self.status(message, "ok")
|
|
@@ -4,6 +4,23 @@ Screen {
|
|
|
4
4
|
background: $surface;
|
|
5
5
|
}
|
|
6
6
|
|
|
7
|
+
/* ---------- 按钮:全局单行(替代 compact,无边框;三项目一致) ---------- */
|
|
8
|
+
Button {
|
|
9
|
+
height: 1;
|
|
10
|
+
border: none;
|
|
11
|
+
min-width: 0;
|
|
12
|
+
padding: 0 1;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
Button:focus {
|
|
16
|
+
border: none;
|
|
17
|
+
text-style: bold reverse;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
Button:disabled {
|
|
21
|
+
text-opacity: 60%;
|
|
22
|
+
}
|
|
23
|
+
|
|
7
24
|
/* ---------- 模态三段式:标题栏 / 内容 / 按钮栏 ---------- */
|
|
8
25
|
.modal-box {
|
|
9
26
|
layout: vertical;
|
|
@@ -40,7 +57,7 @@ Screen {
|
|
|
40
57
|
|
|
41
58
|
/* ---------- 页面公共 ---------- */
|
|
42
59
|
.page-hint {
|
|
43
|
-
height:
|
|
60
|
+
height: 1;
|
|
44
61
|
padding: 0 1;
|
|
45
62
|
color: $text-muted;
|
|
46
63
|
}
|
|
@@ -104,7 +121,7 @@ Screen {
|
|
|
104
121
|
}
|
|
105
122
|
|
|
106
123
|
.tbl:focus {
|
|
107
|
-
border:
|
|
124
|
+
border: round $primary;
|
|
108
125
|
}
|
|
109
126
|
|
|
110
127
|
/* ---------- 信息面板 ---------- */
|
|
@@ -165,12 +182,17 @@ Screen {
|
|
|
165
182
|
}
|
|
166
183
|
|
|
167
184
|
#home-col {
|
|
168
|
-
width:
|
|
185
|
+
width: auto;
|
|
169
186
|
max-width: 90%;
|
|
170
187
|
height: auto;
|
|
171
188
|
}
|
|
172
189
|
|
|
173
|
-
#home-banner
|
|
190
|
+
#home-banner {
|
|
191
|
+
width: 1fr;
|
|
192
|
+
text-align: left;
|
|
193
|
+
color: $primary;
|
|
194
|
+
}
|
|
195
|
+
|
|
174
196
|
#home-plain {
|
|
175
197
|
width: 1fr;
|
|
176
198
|
text-align: center;
|
|
@@ -4,14 +4,14 @@ import asyncio
|
|
|
4
4
|
from rich.text import Text
|
|
5
5
|
from textual import on, work
|
|
6
6
|
from textual.binding import Binding
|
|
7
|
-
from textual.containers import Horizontal
|
|
7
|
+
from textual.containers import Horizontal, Vertical
|
|
8
8
|
from textual.widgets import Button, DataTable, Static
|
|
9
9
|
|
|
10
10
|
from ..config import load_config_providers, save_config_providers
|
|
11
11
|
from ..deploy import compute_sha256, evaluate_update, fetch_remote_config, read_current_config
|
|
12
12
|
from ..ssh import apply_local_config_file, apply_remote_config_via_ssh
|
|
13
13
|
from ..ui import PageScreen
|
|
14
|
-
from ..widgets import ConfirmModal, FormField, FormModal, fit_table_columns, load_rows, make_table, shorten
|
|
14
|
+
from ..widgets import ConfirmModal, FormField, FormModal, busy, fit_table_columns, load_rows, make_table, shorten
|
|
15
15
|
|
|
16
16
|
COLUMN_WEIGHTS = [2, 7]
|
|
17
17
|
|
|
@@ -32,14 +32,15 @@ class ConfigScreen(PageScreen):
|
|
|
32
32
|
self._display: list = []
|
|
33
33
|
|
|
34
34
|
def compose_page(self):
|
|
35
|
-
with
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
with Vertical(classes="panel"):
|
|
36
|
+
with Horizontal(classes="filter-row"):
|
|
37
|
+
yield Button("拉取并部署", id="deploy", variant="primary")
|
|
38
|
+
yield Button("+ 新增", id="add")
|
|
39
|
+
yield Button("编辑", id="edit")
|
|
40
|
+
yield Button("删除", id="delete", variant="error")
|
|
41
|
+
yield Button("重载配置", id="reload-api")
|
|
42
|
+
yield Static("", classes="fill")
|
|
43
|
+
yield Static("单击行 = 部署该提供方", classes="fl-label")
|
|
43
44
|
yield make_table("名称", "地址", id="cf-table")
|
|
44
45
|
|
|
45
46
|
def on_mount(self) -> None:
|
|
@@ -175,61 +176,62 @@ class ConfigScreen(PageScreen):
|
|
|
175
176
|
debug = self.app.debug_mode or bool(profile.get("debug_ssh"))
|
|
176
177
|
url = provider.get("url", "")
|
|
177
178
|
|
|
178
|
-
self.app
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
179
|
+
async with busy(self.app, "正在拉取远程配置…", mode="bar") as ov:
|
|
180
|
+
self.app.add_log(f"Fetching config from {url}...")
|
|
181
|
+
try:
|
|
182
|
+
remote_payload, remote_hash, remote_mod_time = await asyncio.to_thread(fetch_remote_config, url)
|
|
183
|
+
ov.set_message("正在读取目标配置…")
|
|
184
|
+
local_mtime, local_bytes = await asyncio.to_thread(
|
|
185
|
+
read_current_config, profile, self.app.add_log, debug
|
|
186
|
+
)
|
|
187
|
+
except Exception as e: # noqa: BLE001
|
|
188
|
+
self.app.notify_err(f"拉取或读取配置失败: {type(e).__name__}: {e}")
|
|
189
|
+
return
|
|
188
190
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
proceed = True
|
|
193
|
-
if decision == "auto":
|
|
194
|
-
self.app.add_log("Remote config newer with different hash. Proceeding without prompt.")
|
|
195
|
-
elif decision == "confirm_older":
|
|
196
|
-
proceed = await self.app.push_screen_wait(
|
|
197
|
-
ConfirmModal("远程配置可能比本地更旧,仍要覆盖目标配置吗?", default_yes=False)
|
|
198
|
-
)
|
|
199
|
-
elif decision == "confirm_unknown":
|
|
200
|
-
proceed = await self.app.push_screen_wait(
|
|
201
|
-
ConfirmModal("远程配置内容不同,但无法获取更新时间。是否继续覆盖目标配置?", default_yes=True)
|
|
202
|
-
)
|
|
203
|
-
else: # confirm_same
|
|
204
|
-
proceed = await self.app.push_screen_wait(
|
|
205
|
-
ConfirmModal("远程配置与目标配置内容相同,仍要覆盖吗?", default_yes=False)
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
if not proceed:
|
|
209
|
-
self.app.status("已取消更新", "warn")
|
|
210
|
-
self.app.add_log("Config update cancelled by user after comparison.")
|
|
211
|
-
return
|
|
191
|
+
local_hash = compute_sha256(local_bytes)
|
|
192
|
+
decision = evaluate_update(remote_hash, remote_mod_time, local_hash, local_mtime)
|
|
212
193
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
await
|
|
218
|
-
|
|
194
|
+
proceed = True
|
|
195
|
+
if decision == "auto":
|
|
196
|
+
self.app.add_log("Remote config newer with different hash. Proceeding without prompt.")
|
|
197
|
+
elif decision == "confirm_older":
|
|
198
|
+
proceed = await self.app.push_screen_wait(
|
|
199
|
+
ConfirmModal("远程配置可能比本地更旧,仍要覆盖目标配置吗?", default_yes=False)
|
|
200
|
+
)
|
|
201
|
+
elif decision == "confirm_unknown":
|
|
202
|
+
proceed = await self.app.push_screen_wait(
|
|
203
|
+
ConfirmModal("远程配置内容不同,但无法获取更新时间。是否继续覆盖目标配置?", default_yes=True)
|
|
204
|
+
)
|
|
205
|
+
else: # confirm_same
|
|
206
|
+
proceed = await self.app.push_screen_wait(
|
|
207
|
+
ConfirmModal("远程配置与目标配置内容相同,仍要覆盖吗?", default_yes=False)
|
|
219
208
|
)
|
|
220
|
-
else:
|
|
221
|
-
self.app.add_log("Deploying config to local endpoint file.")
|
|
222
|
-
await asyncio.to_thread(apply_local_config_file, profile, remote_payload)
|
|
223
|
-
except Exception as e: # noqa: BLE001
|
|
224
|
-
self.app.notify_err(f"部署配置失败: {type(e).__name__}: {e}")
|
|
225
|
-
return
|
|
226
209
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
210
|
+
if not proceed:
|
|
211
|
+
self.app.status("已取消更新", "warn")
|
|
212
|
+
self.app.add_log("Config update cancelled by user after comparison.")
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
ov.set_message("正在部署配置…")
|
|
216
|
+
try:
|
|
217
|
+
if profile.get("endpoint_type") == "remote":
|
|
218
|
+
self.app.add_log("Deploying config to remote endpoint via SSH.")
|
|
219
|
+
await asyncio.to_thread(
|
|
220
|
+
apply_remote_config_via_ssh, profile, remote_payload, self.app.add_log, debug
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
self.app.add_log("Deploying config to local endpoint file.")
|
|
224
|
+
await asyncio.to_thread(apply_local_config_file, profile, remote_payload)
|
|
225
|
+
except Exception as e: # noqa: BLE001
|
|
226
|
+
self.app.notify_err(f"部署配置失败: {type(e).__name__}: {e}")
|
|
227
|
+
return
|
|
228
|
+
|
|
229
|
+
ov.set_message("正在通过 Clash API 重载配置…")
|
|
230
|
+
try:
|
|
231
|
+
result, error = await asyncio.to_thread(self.app.get_api().reload_configs)
|
|
232
|
+
except Exception as e: # noqa: BLE001
|
|
233
|
+
self.app.notify_err(f"重载配置失败: {type(e).__name__}: {e}")
|
|
234
|
+
return
|
|
233
235
|
self.app.report("重载配置", result, error)
|
|
234
236
|
|
|
235
237
|
@on(Button.Pressed, "#reload-api")
|
|
@@ -7,19 +7,13 @@ from textual.screen import Screen
|
|
|
7
7
|
from textual.widgets import OptionList, Static
|
|
8
8
|
|
|
9
9
|
from .. import __version__
|
|
10
|
-
from ..ui import LOGO_WIDTH, logo_text
|
|
11
|
-
|
|
12
|
-
try: # textual 各版本 Option 导出位置不同
|
|
13
|
-
from textual.widgets.option_list import Option
|
|
14
|
-
except ImportError: # pragma: no cover
|
|
15
|
-
from textual.widgets._option_list import Option
|
|
10
|
+
from ..ui import LOGO_WIDTH, home_col_width, logo_text
|
|
11
|
+
from ..widgets import HintBar, MenuMarquee
|
|
16
12
|
|
|
17
13
|
|
|
18
14
|
class HomeScreen(Screen):
|
|
19
15
|
"""功能主菜单:概览 / 配置管理 / 设置 / 切换端点。"""
|
|
20
16
|
|
|
21
|
-
CSS_CLASSES = "logo-page"
|
|
22
|
-
|
|
23
17
|
def __init__(self) -> None:
|
|
24
18
|
super().__init__(classes="logo-page")
|
|
25
19
|
|
|
@@ -46,29 +40,29 @@ class HomeScreen(Screen):
|
|
|
46
40
|
name = (self.app.profile or {}).get("name", "")
|
|
47
41
|
yield Static(f"v{__version__}" + (f" · {name}" if name else ""), id="home-version")
|
|
48
42
|
yield OptionList(id="home-list")
|
|
49
|
-
yield
|
|
43
|
+
yield HintBar("↑↓ 选择 · 回车 进入 · 1-4 直达 · Esc/Ctrl+C 退出", classes="page-hint")
|
|
50
44
|
|
|
51
45
|
def on_mount(self) -> None:
|
|
52
46
|
ol = self.query_one("#home-list", OptionList)
|
|
53
|
-
|
|
47
|
+
self._marquee = MenuMarquee(
|
|
48
|
+
self, "home-list", [(label, desc) for _, label, desc in self.ITEMS]
|
|
49
|
+
)
|
|
50
|
+
ol.add_options(self._marquee.placeholder_options())
|
|
54
51
|
ol.highlighted = 0
|
|
55
52
|
ol.focus()
|
|
56
53
|
self._apply_breakpoint()
|
|
54
|
+
self.call_after_refresh(self._marquee.start)
|
|
57
55
|
|
|
58
56
|
def on_resize(self, event) -> None:
|
|
59
57
|
self._apply_breakpoint()
|
|
58
|
+
if getattr(self, "_marquee", None) is not None:
|
|
59
|
+
self._marquee.sync()
|
|
60
60
|
|
|
61
61
|
def _apply_breakpoint(self) -> None:
|
|
62
|
-
"""
|
|
63
|
-
self.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
for i, (_, label, desc) in enumerate(self.ITEMS, start=1):
|
|
67
|
-
t = Text()
|
|
68
|
-
t.append(f" {i} ", style="bold")
|
|
69
|
-
t.append(f"{label:<14}", style="bold")
|
|
70
|
-
t.append(desc, style="dim")
|
|
71
|
-
yield Option(t)
|
|
62
|
+
"""宽度不足以容纳艺术字时降级为普通标题文本;列宽跟随 logo。"""
|
|
63
|
+
small = self.size.width < LOGO_WIDTH + 6
|
|
64
|
+
self.set_class(small, "-sm")
|
|
65
|
+
self.query_one("#home-col").styles.width = None if small else home_col_width()
|
|
72
66
|
|
|
73
67
|
def _open(self, key: str) -> None:
|
|
74
68
|
from .config import ConfigScreen
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""概览屏:版本 + 实时流量 / 内存(流式接口)+ 当前活动连接表(每秒轮询)。"""
|
|
2
2
|
import asyncio
|
|
3
3
|
import json
|
|
4
|
+
import time
|
|
4
5
|
from collections.abc import Callable
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
|
|
@@ -10,7 +11,7 @@ from textual.containers import Horizontal, Vertical
|
|
|
10
11
|
from textual.widgets import DataTable, Static
|
|
11
12
|
|
|
12
13
|
from ..ui import PageScreen
|
|
13
|
-
from ..widgets import fit_table_columns, load_rows, make_table, shorten
|
|
14
|
+
from ..widgets import fit_table_columns, hold_busy, load_rows, make_table, shorten
|
|
14
15
|
|
|
15
16
|
COLUMN_WEIGHTS = [4, 1.5, 1.5, 3, 3]
|
|
16
17
|
|
|
@@ -55,7 +56,7 @@ class OverviewScreen(PageScreen):
|
|
|
55
56
|
self._consume("内存", api.get_memory_stream, self._on_memory)
|
|
56
57
|
self.query_one(DataTable).focus()
|
|
57
58
|
self._timer = self.set_interval(1.0, self._refresh_connections)
|
|
58
|
-
self._refresh_connections()
|
|
59
|
+
self._refresh_connections(show_overlay=True)
|
|
59
60
|
|
|
60
61
|
def on_unmount(self) -> None:
|
|
61
62
|
self._stop = True
|
|
@@ -72,7 +73,7 @@ class OverviewScreen(PageScreen):
|
|
|
72
73
|
|
|
73
74
|
def reload_page(self) -> None:
|
|
74
75
|
self._fetch_version()
|
|
75
|
-
self._refresh_connections()
|
|
76
|
+
self._refresh_connections(show_overlay=True)
|
|
76
77
|
|
|
77
78
|
# ------------------------------------------------------------ 后台线程
|
|
78
79
|
|
|
@@ -130,50 +131,64 @@ class OverviewScreen(PageScreen):
|
|
|
130
131
|
fit_table_columns(self.query_one("#cn-table", DataTable), COLUMN_WEIGHTS, rows=self._count)
|
|
131
132
|
|
|
132
133
|
@work(exclusive=False)
|
|
133
|
-
async def _refresh_connections(self) -> None:
|
|
134
|
-
if self._busy
|
|
134
|
+
async def _refresh_connections(self, show_overlay: bool = False) -> None:
|
|
135
|
+
if self._busy:
|
|
135
136
|
return
|
|
136
137
|
self._busy = True
|
|
138
|
+
started = time.monotonic()
|
|
139
|
+
table = self.query_one("#cn-table", DataTable)
|
|
140
|
+
if show_overlay:
|
|
141
|
+
table.loading = True
|
|
137
142
|
try:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
f" · 更新于 {datetime.now():%H:%M:%S}"
|
|
143
|
+
try:
|
|
144
|
+
data, error = await asyncio.to_thread(self.app.get_api().get_connections)
|
|
145
|
+
except Exception as e: # noqa: BLE001
|
|
146
|
+
self.app.notify_err(f"获取连接失败: {type(e).__name__}: {e}")
|
|
147
|
+
return
|
|
148
|
+
if not self.is_mounted:
|
|
149
|
+
return
|
|
150
|
+
if error:
|
|
151
|
+
self.app.notify_err(f"获取连接失败: {error}")
|
|
152
|
+
return
|
|
153
|
+
|
|
154
|
+
data = data if isinstance(data, dict) else {}
|
|
155
|
+
connections = data.get("connections", []) or []
|
|
156
|
+
total_dl = data.get("downloadTotal", 0) / (1024 * 1024)
|
|
157
|
+
total_ul = data.get("uploadTotal", 0) / (1024 * 1024)
|
|
158
|
+
|
|
159
|
+
rows = []
|
|
160
|
+
for conn in connections:
|
|
161
|
+
metadata = conn.get("metadata", {}) or {}
|
|
162
|
+
host = metadata.get("host") or metadata.get("destinationIP", "未知")
|
|
163
|
+
rows.append([
|
|
164
|
+
Text(shorten(str(host), 40)),
|
|
165
|
+
Text(str(metadata.get("network", "?"))),
|
|
166
|
+
Text(str(metadata.get("type", "?"))),
|
|
167
|
+
Text(shorten(str(conn.get("rule", "?")), 30)),
|
|
168
|
+
Text(shorten(" -> ".join(conn.get("chains", []) or []), 30)),
|
|
169
|
+
])
|
|
170
|
+
self._count = len(rows)
|
|
171
|
+
load_rows(table, rows)
|
|
172
|
+
self._refit()
|
|
173
|
+
self.query_one("#cn-summary", Static).update(
|
|
174
|
+
Text(
|
|
175
|
+
f"{len(connections)} 条 · 上传 {total_ul:.2f} MB / 下载 {total_dl:.2f} MB"
|
|
176
|
+
f" · 更新于 {datetime.now():%H:%M:%S}"
|
|
177
|
+
)
|
|
174
178
|
)
|
|
175
|
-
|
|
176
|
-
|
|
179
|
+
self.app.status(f"连接总数 {len(connections)}")
|
|
180
|
+
finally:
|
|
181
|
+
self._busy = False
|
|
182
|
+
if show_overlay:
|
|
183
|
+
# 无论正常/异常/取消,覆盖层都必须撤下
|
|
184
|
+
try:
|
|
185
|
+
if self.is_mounted:
|
|
186
|
+
await hold_busy(started)
|
|
187
|
+
finally:
|
|
188
|
+
table.loading = False
|
|
189
|
+
if self.is_mounted:
|
|
190
|
+
self.call_after_refresh(self._refit) # 覆盖层撤下后区域宽可能变化,按最终宽度重排
|
|
191
|
+
table.focus()
|
|
177
192
|
|
|
178
193
|
# ------------------------------------------------------------ UI 更新
|
|
179
194
|
|
|
@@ -11,8 +11,8 @@ from textual.widgets import Button, OptionList, Static
|
|
|
11
11
|
from .. import __version__
|
|
12
12
|
from ..config import load_profiles, save_profiles
|
|
13
13
|
from ..deploy import default_endpoint_type
|
|
14
|
-
from ..ui import LOGO_WIDTH, logo_text
|
|
15
|
-
from ..widgets import ConfirmModal, FormField, FormModal,
|
|
14
|
+
from ..ui import LOGO_WIDTH, home_col_width, logo_text
|
|
15
|
+
from ..widgets import ConfirmModal, FormField, FormModal, HintBar, MenuMarquee
|
|
16
16
|
|
|
17
17
|
try: # textual 各版本 Option 导出位置不同
|
|
18
18
|
from textual.widgets.option_list import Option
|
|
@@ -20,8 +20,6 @@ except ImportError: # pragma: no cover
|
|
|
20
20
|
from textual.widgets._option_list import Option
|
|
21
21
|
|
|
22
22
|
REQUIRED_FIELDS = ["endpoint_type", "config_directory", "use_sudo"]
|
|
23
|
-
NAME_MAX = 14
|
|
24
|
-
URL_MAX = 30
|
|
25
23
|
|
|
26
24
|
|
|
27
25
|
def validate_profile(profile: dict) -> str | None:
|
|
@@ -210,6 +208,7 @@ class ProfileListScreen(Screen):
|
|
|
210
208
|
super().__init__(classes="logo-page")
|
|
211
209
|
self._profiles: list = []
|
|
212
210
|
self._display: list = []
|
|
211
|
+
self._marquee = None
|
|
213
212
|
|
|
214
213
|
# ------------------------------------------------------------ 组合
|
|
215
214
|
|
|
@@ -221,15 +220,17 @@ class ProfileListScreen(Screen):
|
|
|
221
220
|
yield Static(f"v{__version__}", id="home-version")
|
|
222
221
|
yield OptionList(id="pf-list")
|
|
223
222
|
with Horizontal(id="pf-actions"):
|
|
224
|
-
yield Button("+ 新增端点", id="add", variant="primary"
|
|
225
|
-
yield Button("编辑", id="edit"
|
|
226
|
-
yield Button("删除", id="delete", variant="error"
|
|
227
|
-
yield
|
|
223
|
+
yield Button("+ 新增端点", id="add", variant="primary")
|
|
224
|
+
yield Button("编辑", id="edit")
|
|
225
|
+
yield Button("删除", id="delete", variant="error")
|
|
226
|
+
yield HintBar(self.HINT, classes="page-hint")
|
|
228
227
|
|
|
229
228
|
def on_mount(self) -> None:
|
|
229
|
+
self._marquee = MenuMarquee(self, "pf-list", [])
|
|
230
230
|
self._load()
|
|
231
231
|
self.query_one("#pf-list", OptionList).focus()
|
|
232
232
|
self._apply_breakpoint()
|
|
233
|
+
self.call_after_refresh(self._marquee.start)
|
|
233
234
|
|
|
234
235
|
def on_resize(self, event) -> None:
|
|
235
236
|
self._apply_breakpoint()
|
|
@@ -237,8 +238,10 @@ class ProfileListScreen(Screen):
|
|
|
237
238
|
self._rebuild()
|
|
238
239
|
|
|
239
240
|
def _apply_breakpoint(self) -> None:
|
|
240
|
-
"""
|
|
241
|
-
self.
|
|
241
|
+
"""宽度不足以容纳艺术字时降级为普通标题文本;列宽跟随 logo。"""
|
|
242
|
+
small = self.size.width < LOGO_WIDTH + 6
|
|
243
|
+
self.set_class(small, "-sm")
|
|
244
|
+
self.query_one("#home-col").styles.width = None if small else home_col_width()
|
|
242
245
|
|
|
243
246
|
# ------------------------------------------------------------ 数据加载
|
|
244
247
|
|
|
@@ -252,31 +255,28 @@ class ProfileListScreen(Screen):
|
|
|
252
255
|
def action_refresh_page(self) -> None:
|
|
253
256
|
self._load()
|
|
254
257
|
|
|
255
|
-
def
|
|
256
|
-
if not self._display:
|
|
257
|
-
return [Option(Text("(暂无端点,Ctrl+N 创建)", style="dim"), disabled=True)]
|
|
258
|
+
def _rows(self) -> list[tuple[str, str]]:
|
|
258
259
|
labels = {"local": "本地", "remote": "远程"}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
t.append(f" {i} ", style="bold")
|
|
266
|
-
t.append(f"{shorten(str(p.get('name', '?')), NAME_MAX):<{NAME_MAX}}", style="bold")
|
|
267
|
-
t.append(" · ", style="dim")
|
|
268
|
-
t.append(f"{shorten(str(p.get('url', '')), url_cap):<{url_cap}}", style="dim")
|
|
269
|
-
t.append(" · ", style="dim")
|
|
270
|
-
t.append(labels.get(p.get("endpoint_type", ""), p.get("endpoint_type", "?")))
|
|
271
|
-
opts.append(Option(t))
|
|
272
|
-
return opts
|
|
260
|
+
rows = []
|
|
261
|
+
for p in self._display:
|
|
262
|
+
kind = labels.get(p.get("endpoint_type", ""), p.get("endpoint_type", "?"))
|
|
263
|
+
name = str(p.get("name", "?") or "?")
|
|
264
|
+
rows.append((name, f"{p.get('url', '')} · {kind}"))
|
|
265
|
+
return rows
|
|
273
266
|
|
|
274
267
|
def _rebuild(self) -> None:
|
|
268
|
+
if self._marquee is None:
|
|
269
|
+
return
|
|
275
270
|
self._display = list(self._profiles)
|
|
276
271
|
ol = self.query_one("#pf-list", OptionList)
|
|
277
272
|
prev = ol.highlighted
|
|
278
273
|
ol.clear_options()
|
|
279
|
-
|
|
274
|
+
if self._display:
|
|
275
|
+
self._marquee.rows = self._rows()
|
|
276
|
+
ol.add_options(self._marquee.placeholder_options())
|
|
277
|
+
self._marquee.set_rows(self._rows())
|
|
278
|
+
else:
|
|
279
|
+
ol.add_options([Option(Text("(暂无端点,Ctrl+N 创建)", style="dim"), disabled=True)])
|
|
280
280
|
ol.highlighted = prev if prev is not None and 0 <= prev < len(self._display) else (0 if self._display else None)
|
|
281
281
|
n = len(self._profiles)
|
|
282
282
|
self.query_one("#home-version", Static).update(f"v{__version__} · {n} 个端点")
|