Add dog ASCII animation on statistics refresh (task #1)
- DogLoader widget: ASCII dog animating while data loads, shown as a centered overlay and a docked progress line on the dashboard - shown on timer auto-refresh and manual refresh (r) - bump version to 1.0.4; CHANGELOG + README updated - tests: hidden when idle, shown during refresh, frame animation
This commit is contained in:
@@ -2,6 +2,21 @@
|
|||||||
|
|
||||||
Все заметные изменения проекта `selectel-ml-tui`.
|
Все заметные изменения проекта `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
|
## [1.0.3] — 2026-08-19
|
||||||
|
|
||||||
Полноценный вывод данных из Gateway API ИИ-роутера (IAM).
|
Полноценный вывод данных из Gateway API ИИ-роутера (IAM).
|
||||||
@@ -86,6 +101,7 @@
|
|||||||
- ruff (flake8/isort/format), mypy strict — без ошибок.
|
- ruff (flake8/isort/format), mypy strict — без ошибок.
|
||||||
- Подсказка при первом запуске без токена, FAQ и troubleshooting в README.
|
- Подсказка при первом запуске без токена, 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.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.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
|
[1.0.1]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.1
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре
|
|||||||
- Детализация метрик конкретной модели (Enter в таблице моделей)
|
- Детализация метрик конкретной модели (Enter в таблице моделей)
|
||||||
- Офлайн-режим: при недоступности API показывает последние данные из кэша
|
- Офлайн-режим: при недоступности API показывает последние данные из кэша
|
||||||
- Автообновление по интервалу из конфигурации (по умолчанию 60 с)
|
- Автообновление по интервалу из конфигурации (по умолчанию 60 с)
|
||||||
|
- Анимация-«собачка» и строка прогресса во время обновления статистики
|
||||||
- Экспорт истории в CSV/JSON, диагностика окружения (`doctor`)
|
- Экспорт истории в CSV/JSON, диагностика окружения (`doctor`)
|
||||||
|
|
||||||
## Интерфейс
|
## Интерфейс
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "selectel-ml-tui"
|
name = "selectel-ml-tui"
|
||||||
version = "1.0.3"
|
version = "1.0.4"
|
||||||
description = "TUI-приложение для отображения потребления ИИ-роутера Selectel"
|
description = "TUI-приложение для отображения потребления ИИ-роутера Selectel"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""TUI-приложение для отображения потребления ИИ-роутера Selectel."""
|
"""TUI-приложение для отображения потребления ИИ-роутера Selectel."""
|
||||||
|
|
||||||
__version__ = "1.0.3"
|
__version__ = "1.0.4"
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ from textual import work
|
|||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.binding import Binding
|
from textual.binding import Binding
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical
|
||||||
|
from textual.css.query import NoMatches
|
||||||
from textual.reactive import reactive
|
from textual.reactive import reactive
|
||||||
from textual.screen import Screen
|
from textual.screen import Screen
|
||||||
|
from textual.timer import Timer
|
||||||
from textual.widgets import (
|
from textual.widgets import (
|
||||||
Button,
|
Button,
|
||||||
DataTable,
|
DataTable,
|
||||||
@@ -50,6 +52,51 @@ def _mask_token(value: str) -> str:
|
|||||||
return f"{value[:4]}…({len(value)})"
|
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):
|
class ConsumptionBars(Static):
|
||||||
"""Unicode-бар-график потребления (столбцы)."""
|
"""Unicode-бар-график потребления (столбцы)."""
|
||||||
|
|
||||||
@@ -112,6 +159,8 @@ class DashboardScreen(Screen[Any]):
|
|||||||
yield Static("", id="metrics-panel", classes="panel")
|
yield Static("", id="metrics-panel", classes="panel")
|
||||||
yield Static("По моделям", id="models-title")
|
yield Static("По моделям", id="models-title")
|
||||||
yield DataTable(id="models-table")
|
yield DataTable(id="models-table")
|
||||||
|
yield DogLoader(id="dog-loader")
|
||||||
|
yield Static("", id="progress-line")
|
||||||
yield Footer()
|
yield Footer()
|
||||||
|
|
||||||
def on_mount(self) -> None:
|
def on_mount(self) -> None:
|
||||||
@@ -128,6 +177,22 @@ class DashboardScreen(Screen[Any]):
|
|||||||
finally:
|
finally:
|
||||||
self.loading = False
|
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:
|
def watch_data(self, data: DashboardData) -> None:
|
||||||
status = "offline" if data.offline else "online"
|
status = "offline" if data.offline else "online"
|
||||||
amount = (
|
amount = (
|
||||||
@@ -550,6 +615,26 @@ class SelectelMLTUI(App[Any]):
|
|||||||
padding: 1 2;
|
padding: 1 2;
|
||||||
border: round $primary;
|
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__(
|
def __init__(
|
||||||
|
|||||||
+71
-1
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
from decimal import Decimal
|
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.services import DashboardData
|
||||||
from selectel_ml_tui.storage import Storage
|
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:
|
class FakeService:
|
||||||
@@ -34,6 +35,20 @@ class FakeService:
|
|||||||
return self.data
|
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:
|
def _dashboard_data() -> DashboardData:
|
||||||
return DashboardData(
|
return DashboardData(
|
||||||
month_consumption=Consumption.model_validate(
|
month_consumption=Consumption.model_validate(
|
||||||
@@ -294,3 +309,58 @@ async def test_keys_screen(tmp_path) -> None:
|
|||||||
await pilot.press("escape")
|
await pilot.press("escape")
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert app.screen.__class__.__name__ == "DashboardScreen"
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user