aliyun-controller 0.2.2__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.
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/PKG-INFO +1 -1
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/__init__.py +1 -1
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/app.py +66 -3
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/screens/billing.py +8 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/screens/dns.py +32 -12
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/widgets.py +202 -2
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/PKG-INFO +1 -1
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/pyproject.toml +1 -1
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/README.md +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/app.tcss +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/billing.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/client.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/config.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/dns.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/main.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/screens/__init__.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/screens/home.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/screens/settings.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/screens/setup.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller/ui.py +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/SOURCES.txt +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/dependency_links.txt +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/entry_points.txt +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/requires.txt +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/top_level.txt +0 -0
- {aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aliyun_controller
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: A Textual TUI for managing Alibaba Cloud services including billing queries and DNS management
|
|
5
5
|
Author-email: Moha-Master <hongkongreporter@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -1,18 +1,65 @@
|
|
|
1
1
|
"""Textual 应用入口:全局配置、服务客户端、状态栏与首启配置向导。"""
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
|
+
from rich.spinner import SPINNERS
|
|
5
|
+
from rich.text import Text
|
|
4
6
|
from textual import work
|
|
5
7
|
from textual.app import App, ComposeResult
|
|
6
8
|
from textual.binding import Binding
|
|
9
|
+
from textual.widget import Widget
|
|
7
10
|
from textual.widgets import Static
|
|
8
11
|
|
|
9
12
|
from .billing import AliCloudBssQuerier
|
|
10
13
|
from .config import config_exists, load_config
|
|
11
14
|
from .dns import AliCloudDnsQuerier
|
|
15
|
+
from .widgets import ProgressCover
|
|
16
|
+
|
|
17
|
+
_DOTS = SPINNERS["dots"]
|
|
12
18
|
|
|
13
19
|
|
|
14
20
|
class StatusBar(Static):
|
|
15
|
-
"""底部状态栏:展示最近一次操作结果 /
|
|
21
|
+
"""底部状态栏:展示最近一次操作结果 / 进行中提示;spinner=True 时显示点阵转圈。"""
|
|
22
|
+
|
|
23
|
+
def __init__(self, *args, **kwargs) -> None:
|
|
24
|
+
super().__init__(*args, **kwargs)
|
|
25
|
+
self._text = ""
|
|
26
|
+
self._busy = False
|
|
27
|
+
self._frame = 0
|
|
28
|
+
self._timer = None
|
|
29
|
+
|
|
30
|
+
def render(self) -> Text:
|
|
31
|
+
if self._busy:
|
|
32
|
+
glyph = _DOTS["frames"][self._frame % len(_DOTS["frames"])]
|
|
33
|
+
return Text.assemble((glyph, "bold"), " ", self._text)
|
|
34
|
+
return Text(self._text)
|
|
35
|
+
|
|
36
|
+
def set_status(self, text: str) -> None:
|
|
37
|
+
self._busy = False
|
|
38
|
+
self._text = text
|
|
39
|
+
self._stop_timer()
|
|
40
|
+
self.refresh()
|
|
41
|
+
|
|
42
|
+
def set_spinner(self, text: str) -> None:
|
|
43
|
+
self._busy = True
|
|
44
|
+
self._text = text
|
|
45
|
+
self._ensure_timer()
|
|
46
|
+
self.refresh()
|
|
47
|
+
|
|
48
|
+
def _ensure_timer(self) -> None:
|
|
49
|
+
if self._timer is None and self.is_mounted:
|
|
50
|
+
self._timer = self.set_interval(_DOTS["interval"] / 1000, self._tick)
|
|
51
|
+
|
|
52
|
+
def _stop_timer(self) -> None:
|
|
53
|
+
if self._timer is not None:
|
|
54
|
+
self._timer.stop()
|
|
55
|
+
self._timer = None
|
|
56
|
+
|
|
57
|
+
def _tick(self) -> None:
|
|
58
|
+
self._frame += 1
|
|
59
|
+
self.refresh()
|
|
60
|
+
|
|
61
|
+
def on_mount(self) -> None:
|
|
62
|
+
self._ensure_timer()
|
|
16
63
|
|
|
17
64
|
|
|
18
65
|
class AliyunControllerApp(App):
|
|
@@ -58,13 +105,29 @@ class AliyunControllerApp(App):
|
|
|
58
105
|
def compose(self) -> ComposeResult:
|
|
59
106
|
yield StatusBar("", id="status")
|
|
60
107
|
|
|
108
|
+
def get_loading_widget(self) -> Widget:
|
|
109
|
+
"""全局默认:任意控件 .loading=True 时用 indeterminate 进度条覆盖其区域。"""
|
|
110
|
+
return ProgressCover()
|
|
111
|
+
|
|
61
112
|
def status(self, message: str, kind: str = "") -> None:
|
|
62
113
|
try:
|
|
63
114
|
bar = self.query_one("#status", StatusBar)
|
|
64
|
-
except Exception:
|
|
115
|
+
except Exception: # noqa: BLE001
|
|
65
116
|
return
|
|
66
117
|
bar.update_classes({"ok": kind == "ok", "warn": kind == "warn", "err": kind == "err"})
|
|
67
|
-
bar.
|
|
118
|
+
bar.set_status(message)
|
|
119
|
+
|
|
120
|
+
def spinner(self, on: bool, message: str = "加载中…") -> None:
|
|
121
|
+
"""状态栏点阵 spinner(无遮罩、不阻断输入):适合轻量、即时的后台动作。"""
|
|
122
|
+
try:
|
|
123
|
+
bar = self.query_one("#status", StatusBar)
|
|
124
|
+
except Exception: # noqa: BLE001
|
|
125
|
+
return
|
|
126
|
+
bar.update_classes({})
|
|
127
|
+
if on:
|
|
128
|
+
bar.set_spinner(message)
|
|
129
|
+
else:
|
|
130
|
+
bar.set_status("")
|
|
68
131
|
|
|
69
132
|
def notify_ok(self, message: str) -> None:
|
|
70
133
|
self.status(message, "ok")
|
|
@@ -45,6 +45,9 @@ class _BillingScreen(PageScreen):
|
|
|
45
45
|
@work(exclusive=True)
|
|
46
46
|
async def _reload(self) -> None:
|
|
47
47
|
self.app.status(f"正在查询 {self.billing_cycle} 的账单明细…")
|
|
48
|
+
tables = list(self.query(DataTable))
|
|
49
|
+
for t in tables:
|
|
50
|
+
t.loading = True
|
|
48
51
|
querier = self.app.get_billing()
|
|
49
52
|
try:
|
|
50
53
|
payload = await asyncio.to_thread(self.fetch, self.billing_cycle, querier)
|
|
@@ -56,6 +59,11 @@ class _BillingScreen(PageScreen):
|
|
|
56
59
|
self.app.notify_err(f"查询失败: {type(e).__name__}: {e}")
|
|
57
60
|
self._show_error()
|
|
58
61
|
return
|
|
62
|
+
finally:
|
|
63
|
+
for t in tables:
|
|
64
|
+
t.loading = False
|
|
65
|
+
if tables:
|
|
66
|
+
tables[0].focus()
|
|
59
67
|
self.set_subtitle(self.billing_cycle)
|
|
60
68
|
self._show_result(payload)
|
|
61
69
|
|
|
@@ -25,6 +25,7 @@ from ..widgets import (
|
|
|
25
25
|
ConfirmModal,
|
|
26
26
|
FormField,
|
|
27
27
|
FormModal,
|
|
28
|
+
busy,
|
|
28
29
|
filter_fuzzy,
|
|
29
30
|
fit_table_columns,
|
|
30
31
|
load_rows,
|
|
@@ -77,6 +78,8 @@ class DomainListScreen(PageScreen):
|
|
|
77
78
|
@work(exclusive=True)
|
|
78
79
|
async def _load(self) -> None:
|
|
79
80
|
self.app.status("正在获取域名列表…")
|
|
81
|
+
table = self.query_one(DataTable)
|
|
82
|
+
table.loading = True
|
|
80
83
|
try:
|
|
81
84
|
domains = await asyncio.to_thread(self.app.get_dns().get_domains)
|
|
82
85
|
except AliyunApiError as e:
|
|
@@ -89,6 +92,9 @@ class DomainListScreen(PageScreen):
|
|
|
89
92
|
self._domains = []
|
|
90
93
|
self._rebuild()
|
|
91
94
|
return
|
|
95
|
+
finally:
|
|
96
|
+
table.loading = False
|
|
97
|
+
table.focus()
|
|
92
98
|
self._domains = domains
|
|
93
99
|
self._rebuild()
|
|
94
100
|
|
|
@@ -252,6 +258,8 @@ class RecordListScreen(PageScreen):
|
|
|
252
258
|
@work(exclusive=True)
|
|
253
259
|
async def _load(self) -> None:
|
|
254
260
|
self.app.status(f"正在获取 {self.domain_name} 的解析记录…")
|
|
261
|
+
table = self._table()
|
|
262
|
+
table.loading = True
|
|
255
263
|
try:
|
|
256
264
|
records = await asyncio.to_thread(self.app.get_dns().get_domain_records, self.domain_name)
|
|
257
265
|
except AliyunApiError as e:
|
|
@@ -264,6 +272,9 @@ class RecordListScreen(PageScreen):
|
|
|
264
272
|
self._records = []
|
|
265
273
|
self._rebuild()
|
|
266
274
|
return
|
|
275
|
+
finally:
|
|
276
|
+
table.loading = False
|
|
277
|
+
table.focus()
|
|
267
278
|
self._records = records
|
|
268
279
|
self._rebuild()
|
|
269
280
|
|
|
@@ -350,14 +361,14 @@ class RecordListScreen(PageScreen):
|
|
|
350
361
|
elif isinstance(result, dict):
|
|
351
362
|
self._save_record(record, is_new, result)
|
|
352
363
|
|
|
353
|
-
|
|
364
|
+
@work(exclusive=True)
|
|
365
|
+
async def _save_record(self, record: dict | None, is_new: bool, data: dict) -> None:
|
|
354
366
|
dns = self.app.get_dns()
|
|
355
367
|
enabled = bool(data.get("status", True))
|
|
356
|
-
|
|
368
|
+
|
|
369
|
+
def _apply() -> None:
|
|
357
370
|
if is_new:
|
|
358
|
-
rid = dns.add_domain_record(
|
|
359
|
-
self.domain_name, data["rr"], data["type"], data["value"], data["ttl"]
|
|
360
|
-
)
|
|
371
|
+
rid = dns.add_domain_record(self.domain_name, data["rr"], data["type"], data["value"], data["ttl"])
|
|
361
372
|
if rid and not enabled:
|
|
362
373
|
dns.set_domain_record_status(rid, "Disable")
|
|
363
374
|
else:
|
|
@@ -368,6 +379,10 @@ class RecordListScreen(PageScreen):
|
|
|
368
379
|
dns.set_domain_record_status(
|
|
369
380
|
record.get("RecordId"), "Enable" if enabled else "Disable"
|
|
370
381
|
)
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
async with busy(self.app, "正在保存解析记录…", mode="bar"):
|
|
385
|
+
await asyncio.to_thread(_apply)
|
|
371
386
|
except AliyunApiError as e:
|
|
372
387
|
self.app.notify_err(str(e))
|
|
373
388
|
return
|
|
@@ -380,13 +395,7 @@ class RecordListScreen(PageScreen):
|
|
|
380
395
|
def confirm(ok: bool) -> None:
|
|
381
396
|
if not ok:
|
|
382
397
|
return
|
|
383
|
-
|
|
384
|
-
self.app.get_dns().delete_domain_record(record.get("RecordId"))
|
|
385
|
-
except AliyunApiError as e:
|
|
386
|
-
self.app.notify_err(str(e))
|
|
387
|
-
return
|
|
388
|
-
self.app.notify_ok(f"已删除解析记录 {name}")
|
|
389
|
-
self._load()
|
|
398
|
+
self._do_delete(record, name)
|
|
390
399
|
|
|
391
400
|
self.app.push_screen(
|
|
392
401
|
ConfirmModal(
|
|
@@ -398,6 +407,17 @@ class RecordListScreen(PageScreen):
|
|
|
398
407
|
confirm,
|
|
399
408
|
)
|
|
400
409
|
|
|
410
|
+
@work(exclusive=True)
|
|
411
|
+
async def _do_delete(self, record: dict, name: str) -> None:
|
|
412
|
+
try:
|
|
413
|
+
async with busy(self.app, f"正在删除 {name}…", mode="bar"):
|
|
414
|
+
await asyncio.to_thread(self.app.get_dns().delete_domain_record, record.get("RecordId"))
|
|
415
|
+
except AliyunApiError as e:
|
|
416
|
+
self.app.notify_err(str(e))
|
|
417
|
+
return
|
|
418
|
+
self.app.notify_ok(f"已删除解析记录 {name}")
|
|
419
|
+
self._load()
|
|
420
|
+
|
|
401
421
|
# ------------------------------------------------------------ 事件
|
|
402
422
|
|
|
403
423
|
@on(Input.Changed, "#rc-search")
|
|
@@ -3,18 +3,37 @@
|
|
|
3
3
|
所有模态均为 ModalScreen 泛型返回值,配合 push_screen_wait / 回调消费;
|
|
4
4
|
数据展示统一使用 DataTable,不构造字符串伪表格。
|
|
5
5
|
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import contextlib
|
|
6
9
|
from collections.abc import Callable
|
|
7
10
|
from dataclasses import dataclass, field
|
|
8
11
|
from typing import Any
|
|
9
12
|
|
|
10
13
|
from rich.cells import cell_len
|
|
14
|
+
from rich.style import Style
|
|
11
15
|
from rich.text import Text
|
|
12
16
|
from textual import events
|
|
13
17
|
from textual.binding import Binding
|
|
18
|
+
from textual.clock import Clock
|
|
14
19
|
from textual.containers import Horizontal, ScrollableContainer, Vertical, VerticalScroll
|
|
15
20
|
from textual.coordinate import Coordinate
|
|
21
|
+
from textual.css.query import NoMatches
|
|
16
22
|
from textual.screen import ModalScreen
|
|
17
|
-
from textual.
|
|
23
|
+
from textual.widget import Widget
|
|
24
|
+
from textual.widgets import (
|
|
25
|
+
Button,
|
|
26
|
+
DataTable,
|
|
27
|
+
Input,
|
|
28
|
+
Label,
|
|
29
|
+
LoadingIndicator,
|
|
30
|
+
OptionList,
|
|
31
|
+
ProgressBar,
|
|
32
|
+
Select,
|
|
33
|
+
Static,
|
|
34
|
+
Switch,
|
|
35
|
+
TextArea,
|
|
36
|
+
)
|
|
18
37
|
|
|
19
38
|
try: # textual 各版本 Option 导出位置不同
|
|
20
39
|
from textual.widgets.option_list import Option
|
|
@@ -331,7 +350,7 @@ class FormModal(ModalScreen["dict | None"]):
|
|
|
331
350
|
text = HINT_FORM
|
|
332
351
|
self.query_one("#frm-hint", HintBar).update(text)
|
|
333
352
|
|
|
334
|
-
def _collect(self) ->
|
|
353
|
+
def _collect(self) -> dict | None:
|
|
335
354
|
"""收集表单值;校验失败时写入错误信息并返回 None。可在子类扩展。"""
|
|
336
355
|
err_box = self.query_one("#frm-error", Static)
|
|
337
356
|
err_box.update(Text(""))
|
|
@@ -688,10 +707,191 @@ def fit_table_columns(table: DataTable, weights: list[float], rows: int | None =
|
|
|
688
707
|
return visible
|
|
689
708
|
|
|
690
709
|
|
|
710
|
+
# ---------------------------------------------------------------- 加载指示器
|
|
711
|
+
|
|
712
|
+
class _IndeterminateBar:
|
|
713
|
+
"""自渲染不定进度条:按**绘制时**的区域宽度取景并居中。
|
|
714
|
+
|
|
715
|
+
不依赖 ``Widget.size``:覆盖层是交给合成器临时绘制的,首帧 ``_size`` 可能
|
|
716
|
+
尚未由 ``_size_updated`` 写入,若在 ``render()`` 里读 ``self.size`` 会拿到 0
|
|
717
|
+
而画出一格宽的「隐形条」。改用 Rich 渲染协议,``options.max_width`` 即为本次
|
|
718
|
+
真正要绘制的区域宽度,保证任何一帧都居中可见。
|
|
719
|
+
"""
|
|
720
|
+
|
|
721
|
+
def __init__(
|
|
722
|
+
self, clock: Clock, animation_level: str, highlight: Style, track: Style, cap: int
|
|
723
|
+
) -> None:
|
|
724
|
+
self._clock = clock
|
|
725
|
+
self._animation_level = animation_level
|
|
726
|
+
self._highlight = highlight
|
|
727
|
+
self._track = track
|
|
728
|
+
self._cap = cap
|
|
729
|
+
|
|
730
|
+
def __rich_console__(self, console, options): # noqa: ANN001, ANN201
|
|
731
|
+
total = max(1, options.max_width)
|
|
732
|
+
width = min(total, self._cap)
|
|
733
|
+
bar = max(2, int(width * 0.25))
|
|
734
|
+
span = width + bar
|
|
735
|
+
if self._animation_level == "none":
|
|
736
|
+
start = 0
|
|
737
|
+
else:
|
|
738
|
+
speed = 30 # cells/s,与 Textual ProgressBar 的 indeterminate 一致
|
|
739
|
+
pos = int((speed * self._clock.time) % (2 * span))
|
|
740
|
+
if pos > span:
|
|
741
|
+
pos = 2 * span - pos
|
|
742
|
+
start = pos - bar
|
|
743
|
+
text = Text()
|
|
744
|
+
for i in range(width):
|
|
745
|
+
text.append("━", style=self._highlight if start <= i < start + bar else self._track)
|
|
746
|
+
yield text
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
class ProgressCover(Widget):
|
|
750
|
+
"""控件 loading=True 时覆盖其区域的不定进度条(方框 + 居中往复高亮条)。
|
|
751
|
+
|
|
752
|
+
必须是**单个自渲染 widget**:Textual 的 ``Widget._cover()`` 只把它交给合成器
|
|
753
|
+
替换原控件区域,并不挂进 DOM 参与布局;若用容器 + 子件,子件 region 恒为 0
|
|
754
|
+
而完全不可见(``ProgressBar`` 自身 compose 出 ``Bar`` 子件,因此同样不可用)。
|
|
755
|
+
"""
|
|
756
|
+
|
|
757
|
+
COMPONENT_CLASSES = {"pc--bar", "pc--highlight"}
|
|
758
|
+
|
|
759
|
+
DEFAULT_CSS = """
|
|
760
|
+
ProgressCover {
|
|
761
|
+
width: 100%;
|
|
762
|
+
height: 100%;
|
|
763
|
+
background: $surface;
|
|
764
|
+
border: round $secondary;
|
|
765
|
+
margin: 1 1 0 1;
|
|
766
|
+
content-align: center middle;
|
|
767
|
+
&> .pc--bar { color: $panel-lighten-1; background: $surface; }
|
|
768
|
+
&> .pc--highlight { color: $primary; background: $surface; }
|
|
769
|
+
}
|
|
770
|
+
"""
|
|
771
|
+
|
|
772
|
+
BAR_WIDTH = 40
|
|
773
|
+
"""进度条固定宽(与全屏 BusyOverlay `.busy-box` 的内容宽一致),由 content-align 居中。"""
|
|
774
|
+
|
|
775
|
+
def __init__(self) -> None:
|
|
776
|
+
super().__init__()
|
|
777
|
+
self._clock = Clock()
|
|
778
|
+
|
|
779
|
+
def on_mount(self) -> None:
|
|
780
|
+
self._clock.reset()
|
|
781
|
+
self.auto_refresh = 1 / 15
|
|
782
|
+
|
|
783
|
+
def render(self) -> _IndeterminateBar:
|
|
784
|
+
highlight = Style.from_color(self.get_component_rich_style("pc--highlight").color)
|
|
785
|
+
track = Style.from_color(self.get_component_rich_style("pc--bar").color)
|
|
786
|
+
return _IndeterminateBar(
|
|
787
|
+
self._clock, self.app.animation_level, highlight, track, self.BAR_WIDTH
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
class BusyOverlay(ModalScreen[None]):
|
|
792
|
+
"""全屏忙碌遮罩:阻断一切输入直到被 pop。
|
|
793
|
+
|
|
794
|
+
使用固定宽度(46)与分行结构防止文案变化引起容器抖动或 Emoji 换行。
|
|
795
|
+
"""
|
|
796
|
+
|
|
797
|
+
DEFAULT_CSS = """
|
|
798
|
+
BusyOverlay {
|
|
799
|
+
align: center middle;
|
|
800
|
+
background: $boost;
|
|
801
|
+
}
|
|
802
|
+
BusyOverlay .busy-box {
|
|
803
|
+
width: 46;
|
|
804
|
+
height: auto;
|
|
805
|
+
min-height: 5;
|
|
806
|
+
padding: 1 2;
|
|
807
|
+
border: round $primary;
|
|
808
|
+
background: $surface;
|
|
809
|
+
align: center middle;
|
|
810
|
+
}
|
|
811
|
+
BusyOverlay #busy-title {
|
|
812
|
+
width: 100%;
|
|
813
|
+
content-align: center middle;
|
|
814
|
+
text-style: bold;
|
|
815
|
+
text-wrap: nowrap;
|
|
816
|
+
text-overflow: ellipsis;
|
|
817
|
+
overflow: hidden;
|
|
818
|
+
margin-bottom: 1;
|
|
819
|
+
}
|
|
820
|
+
BusyOverlay LoadingIndicator { width: 100%; height: 1; }
|
|
821
|
+
BusyOverlay ProgressBar { width: 100%; }
|
|
822
|
+
BusyOverlay #busy-counter {
|
|
823
|
+
width: 100%;
|
|
824
|
+
content-align: center middle;
|
|
825
|
+
color: $text-muted;
|
|
826
|
+
margin-top: 1;
|
|
827
|
+
}
|
|
828
|
+
"""
|
|
829
|
+
|
|
830
|
+
def __init__(self, message: str = "正在处理…", *, mode: str = "dots", total: float | None = None) -> None:
|
|
831
|
+
super().__init__()
|
|
832
|
+
self._message = message
|
|
833
|
+
self._mode = mode
|
|
834
|
+
self._total = total
|
|
835
|
+
self._done = 0.0
|
|
836
|
+
|
|
837
|
+
def compose(self):
|
|
838
|
+
with Vertical(classes="busy-box"):
|
|
839
|
+
yield Static(self._message, id="busy-title")
|
|
840
|
+
if self._mode == "bar":
|
|
841
|
+
yield ProgressBar(total=None, show_bar=True, show_percentage=False, show_eta=False)
|
|
842
|
+
elif self._mode == "percent":
|
|
843
|
+
yield ProgressBar(total=self._total, show_percentage=True, show_eta=False)
|
|
844
|
+
yield Static(f"0/{int(self._total or 0)} (0%)", id="busy-counter")
|
|
845
|
+
else:
|
|
846
|
+
yield LoadingIndicator()
|
|
847
|
+
|
|
848
|
+
def set_message(self, message: str) -> None:
|
|
849
|
+
self._message = message
|
|
850
|
+
if self.is_mounted:
|
|
851
|
+
with contextlib.suppress(NoMatches):
|
|
852
|
+
self.query_one("#busy-title", Static).update(message)
|
|
853
|
+
|
|
854
|
+
def advance(self, amount: float = 1) -> None:
|
|
855
|
+
"""percent 模式:推进进度条并刷新计数文案。"""
|
|
856
|
+
self._done += amount
|
|
857
|
+
if self.is_mounted:
|
|
858
|
+
with contextlib.suppress(NoMatches):
|
|
859
|
+
bar = self.query_one(ProgressBar)
|
|
860
|
+
bar.progress += amount
|
|
861
|
+
if self._mode == "percent":
|
|
862
|
+
pct = int(round((self._done / (self._total or 1)) * 100))
|
|
863
|
+
self.query_one("#busy-counter", Static).update(
|
|
864
|
+
f"{int(self._done)}/{int(self._total or 0)} ({pct}%)"
|
|
865
|
+
)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
@contextlib.asynccontextmanager
|
|
869
|
+
async def busy(app, message: str = "正在处理…", *, mode: str = "dots", total: float | None = None):
|
|
870
|
+
"""异步 worker 内包裹耗时操作,全程显示全屏忙碌遮罩(异常/取消亦收起)。
|
|
871
|
+
|
|
872
|
+
用法:async with busy(self.app, "正在保存…", mode="bar"): await asyncio.to_thread(...)
|
|
873
|
+
需要进度回调时接住返回的遮罩对象:async with busy(app, "拉取中", mode="percent", total=n) as ov: ...
|
|
874
|
+
"""
|
|
875
|
+
overlay = BusyOverlay(message, mode=mode, total=total)
|
|
876
|
+
app.push_screen(overlay)
|
|
877
|
+
try:
|
|
878
|
+
yield overlay
|
|
879
|
+
finally:
|
|
880
|
+
# 确保遮罩被收起:可能仍是栈顶,也可能已被其它层覆盖
|
|
881
|
+
for _ in range(len(app.screen_stack)):
|
|
882
|
+
if isinstance(app.screen, BusyOverlay) or app.screen is overlay:
|
|
883
|
+
app.pop_screen()
|
|
884
|
+
else:
|
|
885
|
+
break
|
|
886
|
+
if overlay.is_mounted:
|
|
887
|
+
overlay.remove()
|
|
888
|
+
|
|
889
|
+
|
|
691
890
|
__all__ = [
|
|
692
891
|
"ConfirmModal", "InputModal", "FormField", "FormModal", "OutputModal",
|
|
693
892
|
"ClickTable", "make_table", "load_rows", "filter_fuzzy", "fuzzy_score",
|
|
694
893
|
"shorten", "rcell", "fit_table_columns",
|
|
894
|
+
"ProgressCover", "BusyOverlay", "busy",
|
|
695
895
|
"tint", "colored_text",
|
|
696
896
|
"STYLE_OK", "STYLE_ERR", "STYLE_WARN", "STYLE_INFO", "STYLE_DIM",
|
|
697
897
|
"HINT_PICK", "HINT_MENU", "HINT_FORM", "EDIT_HINT", "HINT_FORM_EDIT", "HINT_FORM_TA",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aliyun_controller
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
4
4
|
Summary: A Textual TUI for managing Alibaba Cloud services including billing queries and DNS management
|
|
5
5
|
Author-email: Moha-Master <hongkongreporter@outlook.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "aliyun_controller"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.3"
|
|
8
8
|
description = "A Textual TUI for managing Alibaba Cloud services including billing queries and DNS management"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
authors = [
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{aliyun_controller-0.2.2 → aliyun_controller-0.2.3}/aliyun_controller.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|