diff --git a/CHANGELOG.md b/CHANGELOG.md index ba0735d..3797ed6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ Все заметные изменения проекта `selectel-ml-tui`. +## [1.0.4] — 2026-09-03 + +Анимация-«собачка» при обновлении статистики. + +### Добавлено +- Экран **Dashboard**: ASCII-«собачка» (`DogLoader`) как индикатор загрузки — + центрированный overlay поверх дашборда с анимацией (виляет хвостом, + кадры меняются каждые 0,15 с) и подписью «Обновление статистики…». +- Индикация обновления в нижней строке (`#progress-line`, `dock: bottom`). +- Показывается при автообновлении по таймеру и при ручном обновлении (`r`); + скрывается по завершении загрузки. + +### Примечание +- Реализует задачу #1 («Новая загрузка обновления статистики»). + ## [1.0.3] — 2026-08-19 Полноценный вывод данных из Gateway API ИИ-роутера (IAM). @@ -86,6 +101,7 @@ - ruff (flake8/isort/format), mypy strict — без ошибок. - Подсказка при первом запуске без токена, FAQ и troubleshooting в README. +[1.0.4]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.4 [1.0.3]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.3 [1.0.2]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.2 [1.0.1]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.1 diff --git a/README.md b/README.md index a28fe1e..2529cd6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре - Детализация метрик конкретной модели (Enter в таблице моделей) - Офлайн-режим: при недоступности API показывает последние данные из кэша - Автообновление по интервалу из конфигурации (по умолчанию 60 с) +- Анимация-«собачка» и строка прогресса во время обновления статистики - Экспорт истории в CSV/JSON, диагностика окружения (`doctor`) ## Интерфейс diff --git a/pyproject.toml b/pyproject.toml index 6ae965c..4816806 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "selectel-ml-tui" -version = "1.0.3" +version = "1.0.4" description = "TUI-приложение для отображения потребления ИИ-роутера Selectel" readme = "README.md" requires-python = ">=3.10" diff --git a/src/selectel_ml_tui/__init__.py b/src/selectel_ml_tui/__init__.py index 31e995f..e51bb48 100644 --- a/src/selectel_ml_tui/__init__.py +++ b/src/selectel_ml_tui/__init__.py @@ -1,3 +1,3 @@ """TUI-приложение для отображения потребления ИИ-роутера Selectel.""" -__version__ = "1.0.3" +__version__ = "1.0.4" diff --git a/src/selectel_ml_tui/ui/app.py b/src/selectel_ml_tui/ui/app.py index 2d6880d..82d3586 100644 --- a/src/selectel_ml_tui/ui/app.py +++ b/src/selectel_ml_tui/ui/app.py @@ -13,8 +13,10 @@ from textual import work from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Horizontal, Vertical +from textual.css.query import NoMatches from textual.reactive import reactive from textual.screen import Screen +from textual.timer import Timer from textual.widgets import ( Button, DataTable, @@ -50,6 +52,51 @@ def _mask_token(value: str) -> str: return f"{value[:4]}…({len(value)})" +class DogLoader(Static): + """ASCII-«собачка» — анимация ожидания обновления статистики. + + Кадры сменяются по таймеру (виляет хвостом). Показывается как + центрированный overlay поверх дашборда на время загрузки данных. + """ + + FRAMES = ( + " /^ ^\\\n ( o.o ) ~\n \\ - / ~~~\n |_|", + " /^ ^\\\n ( o.o ) ~~~\n \\ - / ~~~~\n |_|", + " /^ ^\\\n ( o.o ) ~~\n \\ - / ~\n |_|", + ) + CAPTION = "Обновление статистики…" + INTERVAL = 0.15 + + frame = reactive(0) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._timer: Timer | None = None + + @property + def running(self) -> bool: + """Идёт ли анимация.""" + return self._timer is not None + + def start(self) -> None: + """Запустить анимацию (идемпотентно).""" + if self._timer is None: + self._timer = self.set_interval(self.INTERVAL, self._next_frame) + + def stop(self) -> None: + """Остановить анимацию (идемпотентно).""" + if self._timer is not None: + self._timer.pause() + self._timer = None + + def _next_frame(self) -> None: + self.frame = (self.frame + 1) % len(self.FRAMES) + + def render(self) -> Any: + art = self.FRAMES[self.frame % len(self.FRAMES)] + return f"{art}\n\n{self.CAPTION}" + + class ConsumptionBars(Static): """Unicode-бар-график потребления (столбцы).""" @@ -112,6 +159,8 @@ class DashboardScreen(Screen[Any]): yield Static("", id="metrics-panel", classes="panel") yield Static("По моделям", id="models-title") yield DataTable(id="models-table") + yield DogLoader(id="dog-loader") + yield Static("", id="progress-line") yield Footer() def on_mount(self) -> None: @@ -128,6 +177,22 @@ class DashboardScreen(Screen[Any]): finally: self.loading = False + def watch_loading(self, loading: bool) -> None: + """Показать/скрыть собачку и строку прогресса при обновлении.""" + try: + loader = self.query_one("#dog-loader", DogLoader) + line = self.query_one("#progress-line", Static) + except NoMatches: # watcher может сработать до монтирования детей + return + if loading: + loader.add_class("visible") + loader.start() + line.update("[b]🐕 Собачка обновляет статистику…[/]") + else: + loader.remove_class("visible") + loader.stop() + line.update("") + def watch_data(self, data: DashboardData) -> None: status = "offline" if data.offline else "online" amount = ( @@ -550,6 +615,26 @@ class SelectelMLTUI(App[Any]): padding: 1 2; border: round $primary; } + #dog-loader { + display: none; + layer: overlay; + width: auto; + height: auto; + padding: 1 4; + border: round $accent; + background: $surface; + content-align: center middle; + text-align: center; + } + #dog-loader.visible { + display: block; + align: center middle; + } + #progress-line { + dock: bottom; + height: 1; + text-align: center; + } """ def __init__( diff --git a/tests/test_tui.py b/tests/test_tui.py index e10f13f..2f0bd14 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import datetime as dt from decimal import Decimal @@ -19,7 +20,7 @@ from selectel_ml_tui.models import ( ) from selectel_ml_tui.services import DashboardData from selectel_ml_tui.storage import Storage -from selectel_ml_tui.ui.app import SelectelMLTUI +from selectel_ml_tui.ui.app import DogLoader, SelectelMLTUI class FakeService: @@ -34,6 +35,20 @@ class FakeService: return self.data +class BlockingService: + """Сервис-заглушка: блокирует refresh до снятия события.""" + + def __init__(self, data: DashboardData, release: asyncio.Event) -> None: + self.data = data + self.release = release + self.started = asyncio.Event() + + async def refresh(self) -> DashboardData: + self.started.set() + await self.release.wait() + return self.data + + def _dashboard_data() -> DashboardData: return DashboardData( month_consumption=Consumption.model_validate( @@ -294,3 +309,58 @@ async def test_keys_screen(tmp_path) -> None: await pilot.press("escape") await pilot.pause() assert app.screen.__class__.__name__ == "DashboardScreen" + + +async def test_dog_loader_hidden_when_idle(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + loader = app.screen.query_one("#dog-loader", DogLoader) + assert not loader.has_class("visible") + assert not loader.running + line = app.screen.query_one("#progress-line", Static) + assert str(line.render()) == "" + + +async def test_dog_loader_shown_during_refresh(tmp_path) -> None: + release = asyncio.Event() + settings = Settings(refresh_interval=3600) + creds = Credentials(static_token="tok") + storage = Storage(tmp_path / "cache.db") + service = BlockingService(_dashboard_data(), release) + app = SelectelMLTUI( + settings=settings, + credentials=creds, + storage=storage, + service=service, # type: ignore[arg-type] + ) + async with app.run_test() as pilot: + await service.started.wait() + await pilot.pause() + loader = app.screen.query_one("#dog-loader", DogLoader) + assert loader.has_class("visible") + assert loader.running + line = app.screen.query_one("#progress-line", Static) + assert "обновляет статистику" in str(line.render()) + + release.set() + await pilot.pause(0.1) + assert not loader.has_class("visible") + assert not loader.running + assert str(line.render()) == "" + + +async def test_dog_loader_animates(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + loader = app.screen.query_one("#dog-loader", DogLoader) + loader.add_class("visible") + loader.start() + assert loader.running + first = loader.render() + await pilot.pause(0.4) + second = loader.render() + assert first != second + loader.stop() + assert not loader.running