From 6621917731e14cadea87d1908e5f34b7c4ea1088 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:39:10 +0300 Subject: [PATCH 01/79] Add .gitignore --- .gitignore | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5edb56d --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Python +__pycache__/ +*.py[cod] +*.egg-info/ +.eggs/ +build/ +dist/ +.venv/ +venv/ +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.coverage +htmlcov/ +coverage.xml + +# Secrets and local config +.env +config.toml +credentials + +# OS +.DS_Store +Thumbs.db From 5215e54d8e2d7d4fa44fff0e7a1c3585900dc0a1 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:39:47 +0300 Subject: [PATCH 02/79] Add .editorconfig --- .editorconfig | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..52a32a5 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.py] +indent_style = space +indent_size = 4 + +[*.{md,toml,yml,yaml}] +indent_style = space +indent_size = 2 From ab29e1722e3064b200cc6c1fe3b57da1a5990818 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:39:54 +0300 Subject: [PATCH 03/79] Add mypy.ini --- mypy.ini | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 mypy.ini diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..70b1dde --- /dev/null +++ b/mypy.ini @@ -0,0 +1,13 @@ +[mypy] +python_version = 3.10 +strict = true +warn_unused_configs = true +check_untyped_defs = true +disallow_any_generics = true +warn_redundant_casts = true +warn_unused_ignores = true + +[[mypy.selectel_ml_tui.ui.*]] +# Textual widget internals use dynamic attributes; relax strictness there. +check_untyped_defs = false +disallow_any_generics = false From c8e312f2097b883c8f708fb9dc089bb8e1ad122e Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:39:56 +0300 Subject: [PATCH 04/79] Add ruff.toml --- ruff.toml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 ruff.toml diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..1acefda --- /dev/null +++ b/ruff.toml @@ -0,0 +1,13 @@ +target-version = "py310" + +[lint] +select = ["E", "F", "W", "I", "B", "UP"] +ignore = [] + +[lint.isort] +known-first-party = ["selectel_ml_tui"] + +[format] +quote-style = "double" +indent-style = "space" +line-ending = "lf" From 3c6489f806cf8464a376770c4ef760f40debd506 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:40:03 +0300 Subject: [PATCH 05/79] Add Makefile --- Makefile | 48 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c974c4d --- /dev/null +++ b/Makefile @@ -0,0 +1,48 @@ +PYTHON ?= $(shell command -v python3.13 || command -v python3.12 || command -v python3.11 || command -v python3.10 || command -v python3) +VENV ?= .venv +PIP := $(VENV)/bin/pip +PY := $(VENV)/bin/python + +.PHONY: help venv install run test lint typecheck format build clean install-ubuntu install-macos + +help: ## Показать доступные цели + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' + +venv: ## Создать виртуальное окружение + $(PYTHON) -m venv $(VENV) + +install: venv ## Установить проект в режиме разработки + $(PIP) install --upgrade pip + $(PIP) install -e ".[dev]" + +run: ## Запустить TUI-приложение + $(PY) -m selectel_ml_tui run + +test: ## Запустить тесты + $(PY) -m pytest -v --cov=selectel_ml_tui --cov-report=term-missing + +lint: ## Проверить стиль кода (ruff) + $(VENV)/bin/ruff check . + +typecheck: ## Проверить типы (mypy) + $(VENV)/bin/mypy src + +format: ## Форматировать код (ruff format) + $(VENV)/bin/ruff format . + +build: ## Собрать однофайловый исполняемый файл (PyInstaller) + $(PIP) install pyinstaller + $(PY) -m PyInstaller --noconfirm --clean --onefile --console \ + --name selectel-ml-tui \ + --collect-all textual \ + --paths src \ + src/selectel_ml_tui/__main__.py + +install-ubuntu: ## Установить на Ubuntu (apt) + ./scripts/install_ubuntu.sh + +install-macos: ## Установить на macOS (Homebrew) + ./scripts/install_macos.sh + +clean: ## Удалить артефакты и виртуальное окружение + rm -rf $(VENV) .pytest_cache .mypy_cache .ruff_cache .coverage htmlcov build dist *.egg-info From 2022cea83c98216eac80d3aa65ab19c4d70a75eb Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:40:12 +0300 Subject: [PATCH 06/79] Add selectel-ml-tui.spec --- selectel-ml-tui.spec | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 selectel-ml-tui.spec diff --git a/selectel-ml-tui.spec b/selectel-ml-tui.spec new file mode 100644 index 0000000..298604f --- /dev/null +++ b/selectel-ml-tui.spec @@ -0,0 +1,45 @@ +# -*- mode: python ; coding: utf-8 -*- +from PyInstaller.utils.hooks import collect_all + +datas = [] +binaries = [] +hiddenimports = [] +tmp_ret = collect_all('textual') +datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] + + +a = Analysis( + ['src/selectel_ml_tui/__main__.py'], + pathex=['src'], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=[], + noarchive=False, + optimize=0, +) +pyz = PYZ(a.pure) + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.datas, + [], + name='selectel-ml-tui', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, + disable_windowed_traceback=False, + argv_emulation=False, + target_arch=None, + codesign_identity=None, + entitlements_file=None, +) From 1e3b638710fcab3e31f72178e20187de4eb33552 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:40:18 +0300 Subject: [PATCH 07/79] Add pyproject.toml --- pyproject.toml | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..89fc266 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "selectel-ml-tui" +version = "1.0.3" +description = "TUI-приложение для отображения потребления ИИ-роутера Selectel" +readme = "README.md" +requires-python = ">=3.10" +license = { text = "MIT" } +authors = [{ name = "Selectel ML Tokens" }] +keywords = ["selectel", "ai-router", "tui", "textual", "billing"] +classifiers = [ + "Environment :: Console", + "Operating System :: POSIX :: Linux", + "Operating System :: MacOS", + "Programming Language :: Python :: 3", + "Topic :: Terminals :: Terminal Emulators/X Terminals", +] + +dependencies = [ + "textual>=2.0", + "httpx>=0.27", + "pydantic>=2.7", + "pydantic-settings>=2.3", + "python-dotenv>=1.0", + "click>=8.1", + "platformdirs>=4.0", + "tomli>=2.0", + "tomli-w>=1.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", + "pytest-cov>=5.0", + "ruff>=0.5", + "mypy>=1.10", +] +build = ["pyinstaller>=6.0"] + +[project.scripts] +selectel-ml-tui = "selectel_ml_tui.cli:main" + +[project.urls] +Homepage = "https://selectel.ru" + +[tool.hatch.build.targets.wheel] +packages = ["src/selectel_ml_tui"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" + +[tool.coverage.run] +branch = true +source = ["selectel_ml_tui"] + +[tool.coverage.report] +show_missing = true +fail_under = 80 +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:", +] + +[tool.ruff] +target-version = "py310" +line-length = 88 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] + +[tool.ruff.lint.isort] +known-first-party = ["selectel_ml_tui"] + +[tool.ruff.format] +quote-style = "double" + +[tool.mypy] +python_version = "3.10" +strict = true +files = ["src"] +show_error_codes = true +warn_unused_ignores = true From 6dc6b51b94a6120a5a09cb70098522e1fd412b77 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:40:56 +0300 Subject: [PATCH 08/79] Add README.md --- README.md | 282 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 282 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca21510 --- /dev/null +++ b/README.md @@ -0,0 +1,282 @@ +# selectel-ml-tui + +TUI-приложение (Python 3.10+) для отображения потребления **ИИ-роутера Selectel** +прямо в терминале. Работает на Ubuntu (22.04+/24.04) и macOS (12+). + +## Возможности + +- Дашборд потребления за месяц (сумма, запросы, input/output/cache токены) +- Метрики за 24 часа (доступность, TTFT, задержка) — общие и по моделям +- Бюджет роутера (лимит/расход/остаток) и лимиты по API-ключам +- История потребления по дням с Unicode-бар-графиком (локальный SQLite-кэш) +- Детализация метрик конкретной модели (Enter в таблице моделей) +- Офлайн-режим: при недоступности API показывает последние данные из кэша +- Автообновление по интервалу из конфигурации (по умолчанию 60 с) +- Экспорт истории в CSV/JSON, диагностика окружения (`doctor`) + +## Интерфейс + +``` +┌─ Selectel ML Tokens ──────────────── online · 1 186,73 ₽ ───────┐ +│ ┌─ Потребление за месяц ─┐ ┌─ Метрики за 24 часа ──────────────┐ │ +│ │ Сумма: 1 186,73 ₽ │ │ Доступность: 100,0% │ │ +│ │ Запросы: 1 096 │ │ TTFT: 3 670,0 мс │ │ +│ │ Токены: 482 749 246 │ │ Запросы: 1 097 │ │ +│ │ Input: 244 004 716 │ │ Input/Output/Cache: … │ │ +│ │ Output/Cache: … │ └───────────────────────────────────┘ │ +│ │ Бюджет: 2 000 ₽ │ │ +│ └────────────────────────┘ │ +│ По моделям │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ Модель │ Запросы │ Доступность │ TTFT, мс │ Задержка, мс │ │ +│ │ deepseek-v4-pro │ 1028 │ 100,0% │ 3,69 │ 12,29 │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ r — обновить h — история k — ключи s — настройки q — выход │ +└──────────────────────────────────────────────────────────────────┘ +``` + +Экран **История** (**h**): бар-график стоимости по дням + таблица +(период 7/30/90 дней — кнопками). Экран **Ключи** (**k**): лимиты и расход по +API-ключам. Экран **Настройки** (**s**): интервал обновления, «Очистить кэш», +пути и маскированный токен. **Esc** — назад. + +## Горячие клавиши + +| Клавиша | Действие | +|---|---| +| `r` | Обновить данные | +| `h` | История потребления | +| `k` | API-ключи и лимиты | +| `s` | Настройки | +| `q` | Выход | +| `Esc` | Назад (с экрана истории/настроек/модели) | +| `Enter` / клик | Детализация метрик модели (в таблице «По моделям») | + +На macOS горячие клавиши — обычные символьные (`r/h/s/q`); Option/Command +зарезервированы системой и не используются. + +## Требования + +- Python 3.10+ +- Ubuntu 22.04+/24.04, macOS 12+ +- Терминал с поддержкой цвета и UTF-8 (GNOME Terminal, Konsole, tmux, + Terminal.app, iTerm2) + +## Источники данных + +- **Billing Statistics API** (`https://api.selectel.ru`, заголовок `X-Token`): + стоимость потребления ИИ-роутера (провайдер `aig`) по дням/часам. Параметры + `start`/`end` принимаются в полном ISO-формате (`YYYY-MM-DDTHH:MM:SS.000`), + короткие даты дополняются клиентом автоматически. Значение `value` в копейках. +- **Gateway API ИИ-роутера** (`/aig/gateway/v1/*`, IAM-токен проекта + `X-Project-Id`): + токены, метрики, каталог моделей. +- **IAM-авторизация**: `POST https://cloud.api.selcloud.ru/identity/v3/auth/tokens` + (логин/пароль сервисного пользователя, scope project, TTL 24 ч, кэш с + автообновлением за 5 минут до истечения). + +## Получение токенов (панель Selectel) + +- **Статический токен** (billing-метрики): панель `my.selectel.ru` → профиль → + **Доступ** → **API-ключи**. Вставьте токен в мастер настройки. +- **IAM-токен** (gateway: сумма/токены/модели/ключи): нужен **сервисный + пользователь**. Создайте его в `my.selectel.ru` → **IAM** → **Сервисные + пользователи** (имя без email, например `ml-monitor`), назначьте роль на + нужный ML-проект. Затем в `selectel-ml-tui config` укажите имя сервисного + пользователя, его пароль, `project_id` и `domain_id` (номер аккаунта). + Без сервисного пользователя доступна только стоимость из billing. + +Подробнее об API: [Selectel ML / управление роутером](https://selectel.ru/services/ml/) — +внутренние API панели и документация по биллингу. + +## Установка + +```bash +make install # Ubuntu/macOS +# или +./scripts/install_ubuntu.sh # Ubuntu +./scripts/install_macos.sh # macOS +# или make install-ubuntu / make install-macos +``` + +Всё сводится к двум командам на каждой ОС: + +```bash +# Ubuntu +sudo apt-get update && sudo apt-get install -y python3 python3-venv python3-pip +pip install --user . # или ./scripts/install_ubuntu.sh +``` + +```bash +# macOS +brew install python@3.11 # или ./scripts/install_macos.sh +pip install --user . +``` + +## Запуск + +```bash +make run +# или +.venv/bin/selectel-ml-tui run +# или (с корректным TERM/LANG, подходит для tmux/ssh) +./scripts/run.sh run +``` + +CLI-точка входа: `selectel-ml-tui` (console_scripts), а также `python -m selectel_ml_tui`. + +При первом запуске без настроенных учётных данных выводится подсказка +(`selectel-ml-tui config`); `selectel-ml-tui doctor` предупреждает, что токен +не настроен. + +TUI-дашборд: +- **Dashboard**: потребление за месяц (сумма, токены), метрики за 24 часа, + таблица «По моделям»; статус в заголовке — `online`/`offline` +- **r** — обновить, **h** — история, **s** — настройки, **q** — выход +- Автообновление по интервалу из конфигурации (по умолчанию 60 с) +- При недоступности API показывает последние данные из кэша (офлайн) +- **История** (**h**): таблица по дням из кэша + Unicode-бар-график стоимости, + выбор периода 7/30/90 дней (кнопки) +- **Модель** (Enter/клик в таблице моделей): детализация метрик конкретной модели +- **Настройки** (**s**): изменение интервала обновления (сохранение в конфиг), + «Очистить кэш», пути к конфигу/БД, маскированный токен +- Навигация: **Esc** — назад + +## Конфигурация + +```bash +selectel-ml-tui config # интерактивный мастер настройки +selectel-ml-tui config --path # показать пути конфигурации +selectel-ml-tui config --show # показать текущие настройки (секреты маскируются) +``` + +Настройки хранятся в `config.toml`, учётные данные (пароль сервисного +пользователя, статический токен) — в `credentials.json` в каталоге конфигурации +(права 0600). Каталог: Linux — `~/.config/selectel-ml-tui`, macOS — +`~/Library/Application Support/selectel-ml-tui`. + +Переменные окружения имеют приоритет над файлом конфигурации: + +- `SELCTEL_API_URL` — базовый URL API (вместо `api_base_url`) +- `SELCTEL_TOKEN` — статический токен (вместо `static_token`) +- `SELCTEL_SERVICE_USER`, `SELCTEL_PASSWORD` — сервисный пользователь +- `SELCTEL_PROJECT_ID`, `SELCTEL_DOMAIN_ID` — проект и аккаунт +- остальные поля: `SELCTEL_API_BASE_URL`, `SELCTEL_REFRESH_INTERVAL`, `SELCTEL_ROUTER_ID`, … + +## Локальный кэш и история + +Данные сохраняются в SQLite-базу (`cache.db`) в каталоге данных платформы +(Linux: `~/.local/share/selectel-ml-tui`, macOS: +`~/Library/Application Support/selectel-ml-tui`) при каждом успешном обновлении: +- `consumption_snapshots` — снимки потребления (сумма, токены) +- `metrics_snapshots` — снимки общих метрик +- `model_metrics` — метрики по моделям +- `history` — точки истории по периодам (upsert, без дублей) + +При недоступности API TUI показывает «офлайн» и последние данные из кэша. +Старые записи удаляются автоматически (TTL 180 дней). + +Экспорт истории: `selectel-ml-tui export --format json|csv -o <файл>`. + +## Платформы (Ubuntu и macOS) + +Диагностика окружения: `selectel-ml-tui doctor` — проверяет Python (>=3.10), +`TERM`, `LANG`, кодировку UTF-8, выводит пути к конфигурации и данным. + +Отличия платформ: + +| | Ubuntu 22.04+/24.04 | macOS 12+ | +|---|---|---| +| Python | python3.10+ (apt: `python3 python3-venv python3-pip`) | Homebrew `python@3.11`+ | +| Каталог конфигурации | `~/.config/selectel-ml-tui` | `~/Library/Application Support/selectel-ml-tui` | +| Каталог данных | `~/.local/share/selectel-ml-tui` | `~/Library/Application Support/selectel-ml-tui` | +| Права доступа | 0700/0600 (по умолчанию) | то же; доступ на чтение к `Application Support` | +| Терминалы | GNOME Terminal, Konsole, tmux | Terminal.app, iTerm2 | +| Клавиши | — | hotkeys через Ctrl/символы (r/h/s/q); Option/Command зарезервированы системой | + +Требования к окружению терминала: +- `TERM` должен указывать на цветной терминал (`xterm-256color`, `tmux-256color`). + В `tmux`/`screen` задаётся автоматически; `scripts/run.sh` подставляет + `xterm-256color`, если переменная не задана. +- `LANG`/`locale` должны быть UTF-8 (`ru_RU.UTF-8`, `en_US.UTF-8`), иначе + возможны сбои отображения русского текста и граничных символов (▌▀█). + +CI: GitHub Actions (`ubuntu-latest` + `macos-latest`, Python 3.11/3.12) — ruff, +mypy, pytest+coverage, doctor. + +## Упаковка (однофайловый исполняемый файл) + +Опционально: сборка автономного бинарника через PyInstaller. + +```bash +make build # или ./scripts/build_ubuntu.sh / build_macos.sh +./dist/selectel-ml-tui doctor # проверка +``` + +Артефакт не зависит от установленного Python и venv (собирается на целевой ОС). + +История версий: [CHANGELOG.md](CHANGELOG.md). Финальный чек-лист тестирования: +[docs/FINAL_CHECKLIST.md](docs/FINAL_CHECKLIST.md). + +## Разработка + +```bash +make test # pytest + coverage (порог 80%) +make lint # ruff (flake8/isort/format) +make typecheck # mypy (strict) +make format # ruff format +``` + +- Unit-тесты: config (приоритет env), storage, models (парсинг/форматирование), + API-клиент (httpx.MockTransport: успех, 401, 429, 500, timeout, сетевые ошибки) +- Интеграционный smoke-тест TUI: headless-запуск приложения через `App.run_test()` +- Статический анализ настроен в `pyproject.toml` (`[tool.ruff]`, `[tool.mypy]`, + `fail_under = 80`). + +## FAQ и troubleshooting + +**«Учётные данные не настроены» при запуске** +Выполните `selectel-ml-tui config` — мастер настройки запросит токен +(и опционально сервисного пользователя для метрик моделей). Проверка: +`selectel-ml-tui doctor` → `[OK] token`. + +**Ошибки авторизации (401 / 403)** +- Статический токен истёк или неверен — получите новый в панели Selectel + (профиль → API-токены) и обновите через `selectel-ml-tui config`. +- IAM-доступ: сервисный пользователь должен иметь роль *reader* на нужном + ML-проекте; проверьте `project_id` и `domain_id` (аккаунт). У IAM-токена + TTL 24 ч — приложение обновляет его автоматически (за 5 минут до истечения). + +**В дашборде нет метрик по моделям (Input/Output/Cache отсутствуют)** +Gateway-данные (токены по моделям, каталог моделей) требуют IAM-токена +сервисного пользователя. Если сервисный пользователь не создан, доступна +только стоимость (billing) — на дашборде выводится пояснение +«Токены и модели — только через IAM». Создать сервисного пользователя: +my.selectel.ru → IAM → Сервисные пользователи (с областью доступа на нужный +проект), затем `selectel-ml-tui config`. + +**PermissionError при запуске (`credentials.json` не читается)** +Файлы конфигурации должны принадлежать текущему пользователю (права 0600). +Если запускать приложение от `root`/`sudo`, файлы создаются с владельцем root, +и обычный запуск падает. Исправление (macOS/Linux): +`sudo chown -R $USER ~/Library/Application\ Support/selectel-ml-tui` +(на Linux путь `~/.config/selectel-ml-tui`). Не запускайте приложение от root. + +**Ошибка 422 при запросе данных** +API принимает только полные ISO-таймстампы (`YYYY-MM-DDTHH:MM:SS.000`). +Приложение формирует их самостоятельно; если ошибка повторяется — проверьте +часовой пояс (`doctor` → `LANG`/`locale`). + +**«Краказябры»/битые символы в терминале** +`LANG`/`locale` должны быть UTF-8 (`ru_RU.UTF-8`, `en_US.UTF-8`), `TERM` — +цветной (`xterm-256color`). Запуск через `./scripts/run.sh run` подставит +значения автоматически (полезно в tmux/ssh). + +**Офлайн-режим** +При недоступности API в заголовке появляется `offline`, а дашборд показывает +последние снимки из SQLite-кэша. Данные обновятся автоматически при +восстановлении сети (клавиша `r` — вручную). + +**«Нет данных для графика» в истории** +Кэш пуст — данные появляются после первого успешного обновления (каждая +загрузка сохраняет точку за день). Очистить кэш можно в экране «Настройки» +или через `export` (не очищает). From 23ac79e05f45422bb5346f68bbfde314fbe92904 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:41:10 +0300 Subject: [PATCH 09/79] Add CHANGELOG.md --- CHANGELOG.md | 92 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a81beb5 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,92 @@ +# Changelog + +Все заметные изменения проекта `selectel-ml-tui`. + +## [1.0.3] — 2026-08-19 + +Полноценный вывод данных из Gateway API ИИ-роутера (IAM). + +### Добавлено +- Парсинг реальной схемы Gateway API: + - `/overview` → потребление за месяц (сумма в нанорублях, запросы, токены + всего, input/output/cache read/cache write), бюджет роутера (лимит/расход/ + остаток); + - `/metrics` → метрики за 24 ч (доступность `success_rate_pct`, TTFT и + задержка в секундах→мс, запросы, токены) и `by_model` — по моделям + (запросы/доступность/TTFT); + - `/keys` → лимиты и расход по API-ключам (имя, префикс, статус, лимит, + потрачено, остаток). +- Новый экран **«API-ключи и лимиты»** (клавиша `k`) и панель бюджета на + главном экране. +- Модели: добавлены поля `requests`, `tokens_total`, `avg_latency_ms`; новые + модели `ApiKeyUsage`, `BudgetInfo`. + +### Исправлено +- «Потрачено за месяц» берётся из Gateway Overview (совпадает с вкладкой + «Обзор»), биллинг — фолбэк при отсутствии IAM. +- `SettingsScreen` не перезаписывает конфиг, если интервал не менялся. + +## [1.0.2] — 2026-08-19 + +Исправление отображения данных и прав на файлы конфигурации. + +### Исправлено +- **Сумма за месяц**: раньше `end` обрезался до начала текущего дня — сегодняшние + расходы не входили в сумму. Теперь `end` = текущий момент (naive datetime), + сумма включает текущий день. +- **История**: в историю записывалась месячная сумма как «точка дня». Теперь + сохраняется дневная стоимость (отдельно показывается «Сегодня»). +- **Токены/модели**: при отсутствии IAM (нет сервисного пользователя) поля + Input/Output/Cache больше не показывают вводящие в заблуждение нули — выводится + пояснение «Токены и модели — только через IAM». + +### Примечание +- Gateway API (токены по моделям, каталог моделей) требует IAM-токен сервисного + пользователя. В аккаунте нет сервисных пользователей — необходимо создать его + в панели (my.selectel.ru → IAM → Сервисные пользователи) и настроить через + `selectel-ml-tui config`. +- `credentials.json`/`config.toml` должны принадлежать текущему пользователю + (права 0600). Запуск от `root`/`sudo` создаёт файлы с владельцем root, из-за + чего обычный запуск падает с `PermissionError` — исправляется `chown`. + +## [1.0.1] — 2026-08-19 + +Исправление: крах при запуске, если в `config.toml` (или env +`SELCTEL_REFRESH_INTERVAL`) указан `refresh_interval < 10`. + +### Исправлено +- `load_settings()` больше не бросает `ValidationError` на некорректных + значениях: `refresh_interval` приводится к допустимому диапазону + (минимум 10 секунд), мусорные значения — к умолчанию 60. +- Мастер `selectel-ml-tui config` отклоняет интервал < 10 (повторный запрос). + +## [1.0.0] — 2026-08-19 + +Релиз, готовый к использованию. + +### Добавлено +- TUI-дашборд (Textual): потребление за месяц, метрики за 24 часа, таблица + «По моделям», статус `online`/`offline` в заголовке, автообновление. +- Экраны: **История** (бар-график + таблица по дням, периоды 7/30/90), + **Детали модели** (Enter в таблице моделей), **Настройки** (интервал + обновления, «Очистить кэш», пути, маскированный токен). +- API-клиенты: Billing Statistics (стоимость), Gateway API (токены/метрики), + IAM (получение и кэширование токена с автообновлением). +- Модели данных (pydantic) с валидацией и форматированием чисел. +- Локальный SQLite-кэш: снимки потребления/метрик/моделей, история по дням + (upsert), очистка старых записей (TTL 180 дней), экспорт CSV/JSON. +- CLI: `config` (интерактивный мастер), `run`, `export`, `doctor` + (диагностика окружения); `python -m selectel_ml_tui`. +- Адаптация под Ubuntu и macOS: скрипты установки, `scripts/run.sh`, + диагностика `TERM`/`LANG`/UTF-8, CI (GitHub Actions: ubuntu + macos). +- Упаковка: однофайловый бинарник через PyInstaller (`make build`). + +### Качество +- 89 тестов, покрытие 91% (порог 80%). +- ruff (flake8/isort/format), mypy strict — без ошибок. +- Подсказка при первом запуске без токена, FAQ и troubleshooting в README. + +[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 +[1.0.0]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.0 From a738d56682db3940460de2fd1c240416a091ebcd Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:41:16 +0300 Subject: [PATCH 10/79] Add docs/FINAL_CHECKLIST.md --- docs/FINAL_CHECKLIST.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 docs/FINAL_CHECKLIST.md diff --git a/docs/FINAL_CHECKLIST.md b/docs/FINAL_CHECKLIST.md new file mode 100644 index 0000000..c3ec476 --- /dev/null +++ b/docs/FINAL_CHECKLIST.md @@ -0,0 +1,39 @@ +# Финальный чек-лист тестирования (релиз 1.0.0) + +Прогон на каждой ОС (Ubuntu 22.04/24.04 и macOS 12+). Автоматические проверки +выполняются в CI (GitHub Actions: `ubuntu-latest`, `macos-latest`); ручные — +в терминале. + +## Автоматически (CI: `make test lint typecheck`) + +- [x] Установка: `python3 -m venv .venv && .venv/bin/pip install -e ".[dev]"` +- [x] CLI entry point: `selectel-ml-tui --version` → 1.0.0 +- [x] Первый запуск без токена: подсказка + exit code 1 +- [x] Линт: `ruff check src tests` +- [x] Типы: `mypy src` (strict) +- [x] Тесты: `pytest --cov=selectel_ml_tui` (порог 80%) +- [x] Диагностика: `selectel-ml-tui doctor` + +## Ручные проверки (обе ОС) + +| # | Проверка | Ожидание | +|---|---|---| +| 1 | `./scripts/install_ubuntu.sh` / `install_macos.sh` | установка без ошибок | +| 2 | `selectel-ml-tui config` | мастер сохраняет токен (права 0600) | +| 3 | `selectel-ml-tui run` | запуск дашборда, статус `online` | +| 4 | Данные: сумма за месяц | значение > 0, формат `1 234,56 ₽` | +| 5 | Метрики/модели | таблица «По моделям», Enter → детали | +| 6 | Кэш | история (`h`) показывает точки за дни | +| 7 | Экспорт | `export --format csv/json` создаёт файл | +| 8 | Офлайн (отключить сеть, `r`) | заголовок `offline`, данные из кэша | +| 9 | Ресайз окна | интерфейс перерисовывается, нет обрезки | +| 10 | Клавиши `r/h/s/q`, `Esc` | навигация работает | +| 11 | Русский текст | корректное отображение (UTF-8) | +| 12 | `doctor` | все `[OK]`, пути существуют | + +## Отметка выполнения + +- Ubuntu: ____ +- macOS: ____ + +Комментарии/проблемы: ____ From 8522860d12d3bab04a7be1b4c4132fea567b0bb3 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:41:20 +0300 Subject: [PATCH 11/79] Add scripts/build_ubuntu.sh --- scripts/build_ubuntu.sh | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 scripts/build_ubuntu.sh diff --git a/scripts/build_ubuntu.sh b/scripts/build_ubuntu.sh new file mode 100644 index 0000000..c5ef0a0 --- /dev/null +++ b/scripts/build_ubuntu.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Сборка однофайлового исполняемого файла selectel-ml-tui для Linux (Ubuntu). +set -euo pipefail + +cd "$(dirname "$0")/.." +make build +echo "Готово: dist/selectel-ml-tui (запуск: ./dist/selectel-ml-tui doctor)" From 5052f471f75fed8a7ec11256029465b18ef42ab9 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:41:22 +0300 Subject: [PATCH 12/79] Add scripts/build_macos.sh --- scripts/build_macos.sh | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 scripts/build_macos.sh diff --git a/scripts/build_macos.sh b/scripts/build_macos.sh new file mode 100644 index 0000000..05fbd7a --- /dev/null +++ b/scripts/build_macos.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Сборка однофайлового исполняемого файла selectel-ml-tui для macOS. +set -euo pipefail + +cd "$(dirname "$0")/.." +make build +echo "Готово: dist/selectel-ml-tui (запуск: ./dist/selectel-ml-tui doctor)" From 1a5b69e7dc0aab165877acb20f9138bd5969dfbf Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:41:24 +0300 Subject: [PATCH 13/79] Add scripts/install_ubuntu.sh --- scripts/install_ubuntu.sh | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 scripts/install_ubuntu.sh diff --git a/scripts/install_ubuntu.sh b/scripts/install_ubuntu.sh new file mode 100644 index 0000000..40ec98a --- /dev/null +++ b/scripts/install_ubuntu.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +# Установка selectel-ml-tui на Ubuntu (22.04+) +set -euo pipefail + +sudo apt-get update +sudo apt-get install -y python3 python3-venv python3-pip + +cd "$(dirname "$0")/.." +make install + +echo "Готово. Запуск: make run или .venv/bin/selectel-ml-tui run" From 53ddc40b3612daad2ceb85e221d01ab71bf4dcc1 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:41:25 +0300 Subject: [PATCH 14/79] Add scripts/install_macos.sh --- scripts/install_macos.sh | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 scripts/install_macos.sh diff --git a/scripts/install_macos.sh b/scripts/install_macos.sh new file mode 100644 index 0000000..9517d7d --- /dev/null +++ b/scripts/install_macos.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Установка selectel-ml-tui на macOS (Homebrew) +set -euo pipefail + +brew install python@3.11 + +cd "$(dirname "$0")/.." +make install + +echo "Готово. Запуск: make run или .venv/bin/selectel-ml-tui run" From 26ae2a3ab977ef74d235fa035a817ea0f45b587a Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:41:28 +0300 Subject: [PATCH 15/79] Add scripts/run.sh --- scripts/run.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 scripts/run.sh diff --git a/scripts/run.sh b/scripts/run.sh new file mode 100644 index 0000000..a29effc --- /dev/null +++ b/scripts/run.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Запуск selectel-ml-tui с корректным окружением терминала. +# Использование: scripts/run.sh [doctor|run|...] +set -euo pipefail + +cd "$(dirname "$0")/.." + +# Подстраховка для минималистичных терминалов (tmux/screen, ssh). +if [[ -z "${TERM:-}" ]]; then + export TERM=xterm-256color +fi +if [[ -z "${LANG:-}" ]]; then + export LANG=C.UTF-8 +fi + +if command -v uv >/dev/null 2>&1; then + exec uv run selectel-ml-tui "$@" +elif command -v selectel-ml-tui >/dev/null 2>&1; then + exec selectel-ml-tui "$@" +else + echo "selectel-ml-tui не найден. Установите через 'pip install .' или 'uv sync'" >&2 + exit 1 +fi From 88c8f042264a82c93c6229254979bc684f6dd285 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:41:32 +0300 Subject: [PATCH 16/79] Add src/selectel_ml_tui/__init__.py --- src/selectel_ml_tui/__init__.py | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 src/selectel_ml_tui/__init__.py diff --git a/src/selectel_ml_tui/__init__.py b/src/selectel_ml_tui/__init__.py new file mode 100644 index 0000000..31e995f --- /dev/null +++ b/src/selectel_ml_tui/__init__.py @@ -0,0 +1,3 @@ +"""TUI-приложение для отображения потребления ИИ-роутера Selectel.""" + +__version__ = "1.0.3" From f41a6d34283898615d3bfbb5b110dc2d96258b45 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:41:33 +0300 Subject: [PATCH 17/79] Add src/selectel_ml_tui/__main__.py --- src/selectel_ml_tui/__main__.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 src/selectel_ml_tui/__main__.py diff --git a/src/selectel_ml_tui/__main__.py b/src/selectel_ml_tui/__main__.py new file mode 100644 index 0000000..ba2d426 --- /dev/null +++ b/src/selectel_ml_tui/__main__.py @@ -0,0 +1,4 @@ +from selectel_ml_tui.cli import main + +if __name__ == "__main__": + main() From a1bc9ea0991fbe376214c035c53bae4913becbc9 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:41:44 +0300 Subject: [PATCH 18/79] Add src/selectel_ml_tui/auth.py --- src/selectel_ml_tui/auth.py | 137 ++++++++++++++++++++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 src/selectel_ml_tui/auth.py diff --git a/src/selectel_ml_tui/auth.py b/src/selectel_ml_tui/auth.py new file mode 100644 index 0000000..bcf2bb9 --- /dev/null +++ b/src/selectel_ml_tui/auth.py @@ -0,0 +1,137 @@ +"""IAM-авторизация Selectel. + +Получение и кэширование IAM-токена проекта через +POST {identity_url}/auth/tokens (сервисный пользователь, scope project). +Время жизни токена — 24 часа; IamTokenManager автоматически обновляет токен +при приближении к истечению (запас 5 минут). +""" + +from __future__ import annotations + +import datetime as dt +from typing import cast + +import httpx +from pydantic import BaseModel, ConfigDict, Field + +from selectel_ml_tui.config import Credentials, Settings + + +class IamToken(BaseModel): + """IAM-токен с временем жизни.""" + + model_config = ConfigDict(populate_by_name=True) + + value: str = Field(alias="X-Subject-Token") + issued_at: dt.datetime = Field( + default_factory=lambda: dt.datetime.now(dt.timezone.utc) + ) + expires_at: dt.datetime = Field(alias="expires_at") + + +class IamAuthError(RuntimeError): + """Ошибка получения IAM-токена.""" + + +def _parse_expires_at(raw: str) -> dt.datetime: + """Распарсить expires_at из ответа IAM (ISO8601, возможно с 'Z').""" + text = raw.replace("Z", "+00:00") + return dt.datetime.fromisoformat(text) + + +class IamClient: + """Клиент получения IAM-токена проекта.""" + + def __init__( + self, + settings: Settings, + credentials: Credentials, + transport: httpx.AsyncBaseTransport | None = None, + ) -> None: + self._settings = settings + self._credentials = credentials + self._transport = transport + + async def get_token(self) -> IamToken: + """Получить IAM-токен проекта для аккаунта/домена.""" + if not self._credentials.service_user or not self._credentials.password: + raise IamAuthError( + "Не заданы логин/пароль сервисного пользователя. " + "Выполните: selectel-ml-tui config" + ) + body = { + "auth": { + "identity": { + "methods": ["password"], + "password": { + "user": { + "name": self._credentials.service_user, + "domain": {"name": self._credentials.domain_id}, + "password": self._credentials.password, + } + }, + }, + "scope": {"project": {"id": self._credentials.project_id}}, + } + } + url = f"{self._settings.identity_url}/auth/tokens" + try: + async with httpx.AsyncClient( + timeout=30, transport=self._transport + ) as client: + response = await client.post(url, json=body) + except httpx.HTTPError as exc: + raise IamAuthError(f"Ошибка сети при обращении к {url}: {exc}") from exc + if response.status_code not in (200, 201): + raise IamAuthError( + f"IAM: HTTP {response.status_code}: {response.text[:300]}" + ) + token = cast(str | None, response.headers.get("X-Subject-Token")) + if not token: + raise IamAuthError("IAM: отсутствует заголовок X-Subject-Token в ответе") + data = response.json() + expires_raw = ( + data.get("token", {}).get("expires_at") if isinstance(data, dict) else None + ) + if not isinstance(expires_raw, str): + raise IamAuthError("IAM: отсутствует поле token.expires_at в ответе") + return IamToken.model_validate( + { + "X-Subject-Token": token, + "expires_at": _parse_expires_at(expires_raw), + } + ) + + +class IamTokenManager: + """Кэширует IAM-токен и обновляет его по истечении TTL. + + Позволяет разделить получение токена и его использование клиентами. + """ + + SAFETY_MARGIN = dt.timedelta(minutes=5) + + def __init__(self, client: IamClient) -> None: + self._client = client + self._token: str | None = None + self._expires_at: dt.datetime | None = None + + def invalidate(self) -> None: + """Сбросить кэш (например, после HTTP 401).""" + self._token = None + self._expires_at = None + + def is_valid(self) -> bool: + if self._token is None or self._expires_at is None: + return False + now = dt.datetime.now(dt.timezone.utc) + return self._expires_at > now + self.SAFETY_MARGIN + + async def get_token(self) -> str: + """Вернуть валидный токен, при необходимости обновив его.""" + if self.is_valid(): + return cast(str, self._token) + iam_token = await self._client.get_token() + self._token = iam_token.value + self._expires_at = iam_token.expires_at + return iam_token.value From db895ab565f170c41e818d9d31701d1d74a58d99 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:41:59 +0300 Subject: [PATCH 19/79] Add src/selectel_ml_tui/cli.py --- src/selectel_ml_tui/cli.py | 201 +++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 src/selectel_ml_tui/cli.py diff --git a/src/selectel_ml_tui/cli.py b/src/selectel_ml_tui/cli.py new file mode 100644 index 0000000..186ea90 --- /dev/null +++ b/src/selectel_ml_tui/cli.py @@ -0,0 +1,201 @@ +"""CLI-интерфейс selectel-ml-tui.""" + +from __future__ import annotations + +from pathlib import Path +from typing import cast + +import click + +from selectel_ml_tui import __version__ +from selectel_ml_tui.config import ( + Credentials, + Settings, + config_dir, + data_dir, + load_credentials, + load_settings, + save_credentials, + save_settings, +) + + +def _mask(value: str) -> str: + """Замаскировать секрет: показать первые 4 символа и длину.""" + if not value: + return "(не задан)" + return f"{value[:4]}…({len(value)})" + + +def _print_config(settings: Settings, credentials: Credentials) -> None: + click.echo(f"API base URL: {settings.api_base_url}") + click.echo(f"Identity URL: {settings.identity_url}") + click.echo(f"Refresh interval: {settings.refresh_interval} c") + router_id = settings.router_id or "(не задан, будет выбран первый)" + click.echo(f"Router id: {router_id}") + if settings.region: + click.echo(f"Region: {settings.region}") + click.echo(f"Service user: {credentials.service_user or '(не задан)'}") + click.echo(f"Project id: {credentials.project_id or '(не задан)'}") + click.echo(f"Domain id: {credentials.domain_id or '(не задан)'}") + click.echo(f"Static token: {_mask(credentials.static_token)}") + click.echo(f"Password: {_mask(credentials.password)}") + + +def _prompt_str( + label: str, + current: str, + hide_input: bool = False, + default_note: str = "(Enter — оставить)", +) -> str: + prompt = f"{label} [{current or 'пусто'}] {default_note}: " + value = click.prompt(prompt, default="", hide_input=hide_input, show_default=False) + if value == "": + return current + return cast(str, value) + + +@click.group() +@click.version_option(__version__, prog_name="selectel-ml-tui") +def main() -> None: + """TUI-приложение для отображения потребления ИИ-роутера Selectel.""" + + +@main.command() +@click.option("--path", is_flag=True, help="Показать пути к конфигурации и выйти") +@click.option("--show", is_flag=True, help="Показать текущие настройки и выйти") +@click.option( + "--non-interactive", + is_flag=True, + help="Не запрашивать ввод (эквивалентно --show)", +) +def config(path: bool, show: bool, non_interactive: bool) -> None: + """Настроить приложение (интерактивный мастер) или показать настройки.""" + if path: + click.echo(f"config dir: {config_dir()}") + click.echo(f"data dir: {data_dir()}") + return + + settings = load_settings() + credentials = load_credentials() + + if show or non_interactive: + _print_config(settings, credentials) + return + + click.echo("Настройка selectel-ml-tui. Пустые значения сохраняют текущие.") + settings.api_base_url = _prompt_str("API base URL", settings.api_base_url) + while True: + raw = _prompt_str( + "Интервал обновления (сек, min 10)", + str(settings.refresh_interval), + ) + try: + value = int(raw) + except ValueError: + click.echo("Ошибка: введите целое число секунд") + continue + if value < 10: + click.echo("Ошибка: интервал не может быть меньше 10 секунд") + continue + settings.refresh_interval = value + break + settings.router_id = _prompt_str( + "ID ИИ-роутера (необязательно)", settings.router_id + ) + settings.region = _prompt_str("Регион (необязательно)", settings.region) + + credentials.service_user = _prompt_str( + "Сервисный пользователь", credentials.service_user + ) + credentials.domain_id = _prompt_str( + "Номер аккаунта (domain)", credentials.domain_id + ) + credentials.project_id = _prompt_str("ID проекта (ML)", credentials.project_id) + credentials.password = _prompt_str( + "Пароль сервисного пользователя", + credentials.password, + hide_input=True, + ) + credentials.static_token = _prompt_str( + "Статический токен (X-Token)", + credentials.static_token, + hide_input=True, + ) + + save_settings(settings) + save_credentials(credentials) + click.echo(f"Сохранено: {config_dir()}") + _print_config(settings, credentials) + + +@main.command() +@click.option( + "--format", + "fmt", + type=click.Choice(["csv", "json"]), + default="json", + help="Формат экспорта", +) +@click.option( + "--output", + "-o", + type=click.Path(path_type=Path), + default=None, + help="Путь к файлу экспорта (по умолчанию history.csv/json в каталоге данных)", +) +def export(fmt: str, output: Path | None) -> None: + """Экспортировать историю потребления в CSV/JSON.""" + from selectel_ml_tui.storage import Storage + + path = output if output is not None else data_dir() / f"history.{fmt}" + storage = Storage() + try: + if fmt == "csv": + storage.export_csv(path) + else: + storage.export_json(path) + finally: + storage.close() + click.echo(f"Экспортировано: {path}") + + +@main.command() +def doctor() -> None: + """Проверить окружение (ОС, терминал, кодировку) и пути.""" + from selectel_ml_tui.platform import check_environment, get_platform_info + + info = get_platform_info() + click.echo(f"ОС: {info.system}") + click.echo(f"Python: {info.python_version}") + failed = False + for check in check_environment(): + mark = "OK " if check.ok else "WARN" + failed = failed or not check.ok + click.echo(f"[{mark}] {check.name}: {check.detail}") + credentials = load_credentials() + has_token = bool(credentials.static_token or credentials.password) + hint = "не настроен — выполните: selectel-ml-tui config" + token_status = "настроен" if has_token else hint + click.echo(f"[{'OK ' if has_token else 'WARN'}] token: {token_status}") + click.echo(f"Config dir: {config_dir()}") + click.echo(f"Data dir: {data_dir()}") + if failed: + raise click.exceptions.Exit(1) + + +@main.command() +def run() -> None: + """Запустить TUI-интерфейс (дашборд).""" + from selectel_ml_tui.ui.app import run_app + + credentials = load_credentials() + if not credentials.static_token and not credentials.password: + msg = "Учётные данные не настроены. Выполните: selectel-ml-tui config" + click.echo(msg, err=True) + raise click.exceptions.Exit(1) + run_app() + + +if __name__ == "__main__": + main() From 2d3af443fe555aab41fa160e4c2ae3ea889d4459 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:42:17 +0300 Subject: [PATCH 20/79] Add src/selectel_ml_tui/config.py --- src/selectel_ml_tui/config.py | 206 ++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 src/selectel_ml_tui/config.py diff --git a/src/selectel_ml_tui/config.py b/src/selectel_ml_tui/config.py new file mode 100644 index 0000000..c0b8697 --- /dev/null +++ b/src/selectel_ml_tui/config.py @@ -0,0 +1,206 @@ +"""Конфигурация приложения: настройки, пути, секреты. + +Поддерживается чтение переменных окружения с префиксом SELCTEL_ и файла +конфигурации config.toml. Секреты (пароль сервисного пользователя, +статический токен) хранятся отдельно в файле credentials (права 0600). +""" + +from __future__ import annotations + +import json +import os +import sys +from pathlib import Path +from typing import Any + +from platformdirs import user_config_dir, user_data_dir +from pydantic import BaseModel, Field, ValidationError +from pydantic_settings import BaseSettings, SettingsConfigDict + +APP_NAME = "selectel-ml-tui" + +if sys.version_info >= (3, 11): + import tomllib +else: # pragma: no cover - Python 3.10 fallback + import tomli as tomllib + + +class Settings(BaseSettings): + """Несекретные настройки приложения. + + Источники: env (SELCTEL_*), затем config.toml. + """ + + model_config = SettingsConfigDict( + env_prefix="SELCTEL_", env_file=".env", extra="ignore" + ) + + api_base_url: str = Field( + default="https://api.selectel.ru", + description="Базовый URL API Selectel", + ) + identity_url: str = Field( + default="https://cloud.api.selcloud.ru/identity/v3", + description="URL сервиса IAM-авторизации", + ) + refresh_interval: int = Field( + default=60, + ge=10, + description="Интервал автообновления TUI, секунд", + ) + router_id: str = Field( + default="", + description="ID ИИ-роутера (если не указан, берётся первый из списка)", + ) + region: str = Field( + default="", + description="Регион/пул (необязательно, для совместимости)", + ) + + +class Credentials(BaseModel): + """Учётные данные для доступа к API.""" + + service_user: str = "" + password: str = "" + project_id: str = "" + domain_id: str = "" + static_token: str = "" + + +def config_dir() -> Path: + """Каталог конфигурации (Linux: ~/.config, macOS: ~/Library/Application Support).""" + return Path(user_config_dir(APP_NAME)) + + +def data_dir() -> Path: + """Каталог данных (Linux: ~/.local/share, macOS: ~/Library/Application Support).""" + return Path(user_data_dir(APP_NAME)) + + +def _config_file() -> Path: + return config_dir() / "config.toml" + + +def _credentials_file() -> Path: + return config_dir() / "credentials.json" + + +def load_settings() -> Settings: + """Загрузить настройки: env > config.toml > значения по умолчанию. + + Дополнительные env-алиасы: SELCTEL_API_URL (для api_base_url). + Некорректные значения (например, refresh_interval < 10 из старого + конфига) не приводят к ошибке: поле приводится к допустимому значению. + """ + base = _settings_from_env() + cfg = _read_config_file() + if not cfg: + settings = base + else: + # Поля, заданные через env, имеют приоритет над файлом конфигурации. + env_fields = { + name: value + for name, value in base.model_dump().items() + if name in base.model_fields_set + } + settings = _validate_settings({**cfg, **env_fields}) + + api_url = os.environ.get("SELCTEL_API_URL") + if api_url: + settings = settings.model_copy(update={"api_base_url": api_url}) + return settings + + +def _settings_from_env() -> Settings: + """Настройки из переменных окружения (устойчиво к некорректным значениям).""" + try: + return Settings() + except ValidationError: + # Некорректное значение в env (например, SELCTEL_REFRESH_INTERVAL=5): + # model_construct() не читает env и не валидирует — чистые дефолты. + return Settings.model_construct() + + +def _validate_settings(data: dict[str, Any]) -> Settings: + """Валидировать настройки; при ошибке починить и повторить.""" + try: + return Settings.model_validate(data) + except ValidationError: + return Settings.model_validate(_sanitize_settings(data)) + + +def _sanitize_settings(data: dict[str, Any]) -> dict[str, Any]: + """Привести некорректные значения к допустимым (refresh_interval >= 10).""" + repaired = dict(data) + interval = repaired.get("refresh_interval") + if interval is not None: + try: + repaired["refresh_interval"] = max(10, int(interval)) + except (TypeError, ValueError): + repaired["refresh_interval"] = 60 + return repaired + + +def save_settings(settings: Settings) -> None: + """Сохранить настройки в config.toml.""" + path = _config_file() + path.parent.mkdir(parents=True, exist_ok=True) + _write_config_file(path, settings.model_dump(exclude_defaults=True)) + + +def _read_config_file() -> dict[str, Any]: + path = _config_file() + if not path.exists(): + return {} + with path.open("rb") as fh: + return dict(tomllib.load(fh)) + + +def _write_config_file(path: Path, data: dict[str, Any]) -> None: + try: + import tomli_w + except ImportError: # pragma: no cover + # tomli_w не установлен — пишем JSON (совместимый фолбэк) + path.write_text(json.dumps(data, indent=2, ensure_ascii=False)) + return + path.write_text(tomli_w.dumps(data)) + + +def load_credentials() -> Credentials: + """Загрузить секреты: env (SELCTEL_*) > файл credentials.json. + + Env-алиасы: SELCTEL_TOKEN (static_token), SELCTEL_SERVICE_USER, + SELCTEL_PASSWORD, SELCTEL_PROJECT_ID, SELCTEL_DOMAIN_ID. + """ + path = _credentials_file() + if path.exists(): + with path.open(encoding="utf-8") as fh: + data: dict[str, Any] = json.load(fh) + else: + data = {} + + env_overrides = { + "static_token": os.environ.get("SELCTEL_TOKEN"), + "service_user": os.environ.get("SELCTEL_SERVICE_USER"), + "password": os.environ.get("SELCTEL_PASSWORD"), + "project_id": os.environ.get("SELCTEL_PROJECT_ID"), + "domain_id": os.environ.get("SELCTEL_DOMAIN_ID"), + } + for name, value in env_overrides.items(): + if value is not None: + data[name] = value + return Credentials.model_validate(data) + + +def save_credentials(credentials: Credentials) -> None: + """Сохранить секреты в файл credentials.json с правами 0600.""" + path = _credentials_file() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(credentials.model_dump_json(indent=2)) + _chmod_0600(path) + + +def _chmod_0600(path: Path) -> None: + if os.name == "posix": # pragma: no cover + path.chmod(0o600) From 95458805e205744c8c298e58b5cf9c57a02dd36a Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:42:32 +0300 Subject: [PATCH 21/79] Add src/selectel_ml_tui/models.py --- src/selectel_ml_tui/models.py | 197 ++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 src/selectel_ml_tui/models.py diff --git a/src/selectel_ml_tui/models.py b/src/selectel_ml_tui/models.py new file mode 100644 index 0000000..ae8fb8f --- /dev/null +++ b/src/selectel_ml_tui/models.py @@ -0,0 +1,197 @@ +"""Pydantic-модели данных Selectel ML. + +Модели ответов Billing Statistics API и Gateway API ИИ-роутера, а также +агрегированные структуры для TUI (потребление, метрики, история). + +Все модели устойчивы к отсутствующим полям (значения по умолчанию) и +валидируют неотрицательные числа. +""" + +from __future__ import annotations + +from datetime import datetime +from decimal import Decimal +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +def parse_iso_dt(value: str) -> datetime: + """Распарсить ISO-дату, допускающую 'Z' и нативные (без tz) значения.""" + text = value.replace("Z", "+00:00") + try: + return datetime.fromisoformat(text) + except ValueError as exc: + raise ValueError(f"Некорректная дата: {value!r}") from exc + + +def _validate_dt(value: Any) -> Any: + if isinstance(value, str): + return parse_iso_dt(value) + return value + + +def format_rub(amount: Decimal | int | float | str) -> str: + """Сумма в рублях: 854.83 -> '854,83 ₽', 1234.5 -> '1 234,50 ₽'.""" + value = Decimal(str(amount)) + rub, kop = f"{value:.2f}".split(".") + rub = f"{int(rub):,}".replace(",", " ") + return f"{rub},{kop} ₽" + + +def format_tokens(value: int) -> str: + """Число токенов с разделителями: 1234567 -> '1 234 567'.""" + return f"{int(value):,}".replace(",", " ") + + +def format_percent(value: float) -> str: + """Процент: 99.912 -> '99,9%'.""" + return f"{float(value):.1f}".replace(".", ",") + "%" + + +class TokenUsage(BaseModel): + """Метрики токенов (все неотрицательные).""" + + input_tokens: int = Field(default=0, ge=0) + output_tokens: int = Field(default=0, ge=0) + cache_read_tokens: int = Field(default=0, ge=0) + cache_write_tokens: int = Field(default=0, ge=0) + + +class Consumption(TokenUsage): + """Потребление за период: стоимость + токены.""" + + period: datetime + amount_rub: Decimal = Field(default=Decimal("0"), ge=0) + requests: int = Field(default=0, ge=0) + tokens_total: int = Field(default=0, ge=0) + + _validate_period = field_validator("period", mode="before")(_validate_dt) + + +class Metrics(TokenUsage): + """Общие метрики роутера за 24 часа.""" + + availability_pct: float = Field(default=100.0, ge=0, le=100) + avg_ttft_ms: float = Field(default=0.0, ge=0) + avg_latency_ms: float = Field(default=0.0, ge=0) + requests: int = Field(default=0, ge=0) + + +class ModelMetrics(Metrics): + """Метрики по конкретной модели.""" + + model_id: str = "" + model_name: str = "" + + +class ApiKeyUsage(BaseModel): + """Лимиты и расход по одному API-ключу роутера.""" + + name: str = "" + prefix: str = "" + is_active: bool = True + spend_limit_rub: Decimal = Field(default=Decimal("0"), ge=0) + spend_rub: Decimal = Field(default=Decimal("0"), ge=0) + remaining_rub: Decimal = Field(default=Decimal("0"), ge=0) + + +class BudgetInfo(BaseModel): + """Бюджет роутера: лимит, расход, остаток.""" + + spend_limit_rub: Decimal = Field(default=Decimal("0"), ge=0) + spend_rub: Decimal = Field(default=Decimal("0"), ge=0) + remaining_rub: Decimal = Field(default=Decimal("0"), ge=0) + window: str = "" + + +class HistoryPoint(BaseModel): + """Точка истории для графика: время, стоимость, токены.""" + + timestamp: datetime + amount_rub: Decimal = Field(default=Decimal("0"), ge=0) + tokens: TokenUsage = Field(default_factory=TokenUsage) + + _validate_timestamp = field_validator("timestamp", mode="before")(_validate_dt) + + +class MetricInfo(BaseModel): + """Информация о метрике из billing statistics API.""" + + id: str = "" + name: str = "" + unit: str = "" + quantity: Decimal | None = None + locale: str | None = None + region: str | None = None + + +class ProjectRef(BaseModel): + """Ссылка на проект (ML).""" + + id: str = "" + name: str = "" + + +class ObjectRef(BaseModel): + """Ссылка на объект (ключ/роутер).""" + + id: str = "" + name: str = "" + parent_id: str | None = None + parent_name: str | None = None + type: str | None = None + type_name: str | None = None + + +class ConsumptionItem(BaseModel): + """Элемент потребления из /v1/cloud_billing/statistic/consumption. + + ``project`` и ``object`` могут быть строкой (id), объектом или null. + """ + + model_config = ConfigDict(populate_by_name=True) + + account_id: str = "" + period: str = "" + provider_key: str = "" + metric: MetricInfo | None = None + value: Decimal = Decimal("0") + project: ProjectRef | str | None = Field(default=None, alias="project") + object: ObjectRef | str | None = Field(default=None, alias="object") + balance: Decimal | None = None + provision_start: str | None = None + provision_end: str | None = None + + @property + def project_id(self) -> str | None: + """ID проекта (из строки или объекта).""" + if isinstance(self.project, ProjectRef): + return self.project.id + return self.project + + @property + def object_id(self) -> str | None: + """ID объекта (из строки или объекта).""" + if isinstance(self.object, ObjectRef): + return self.object.id + return self.object + + +class RouterOverview(BaseModel): + """Потребление ИИ-роутера (GET /aig/gateway/v1/gateways/{id}/overview). + + ``raw`` хранит необработанные данные до полного описания схемы. + """ + + raw: dict[str, Any] = Field( + default_factory=dict, description="Необработанные данные ответа" + ) + + +class RouterMetrics(BaseModel): + """Метрики ИИ-роутера за 24 часа (GET /aig/gateway/v1/gateways/{id}/metrics).""" + + raw: dict[str, Any] = Field( + default_factory=dict, description="Необработанные данные ответа" + ) From c36a447e9a641d1c29e423699de079cb33822a30 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:42:39 +0300 Subject: [PATCH 22/79] Add src/selectel_ml_tui/platform.py --- src/selectel_ml_tui/platform.py | 83 +++++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 src/selectel_ml_tui/platform.py diff --git a/src/selectel_ml_tui/platform.py b/src/selectel_ml_tui/platform.py new file mode 100644 index 0000000..d556a3f --- /dev/null +++ b/src/selectel_ml_tui/platform.py @@ -0,0 +1,83 @@ +"""Платформенно-зависимые проверки и диагностика окружения. + +Изолирует код, зависящий от ОС и терминала (Python, TERM, LANG, кодировка), +чтобы его можно было тестировать на любой платформе. +""" + +from __future__ import annotations + +import os +import platform as _platform +import sys +from dataclasses import dataclass + +MIN_PYTHON = (3, 10) + +# TERM-значения, которые Textual считает пригодными для TUI. +_SUPPORTED_TERM_MARKERS = ("xterm", "linux", "tmux", "screen") + + +@dataclass(frozen=True) +class PlatformInfo: + """Снимок окружения, на котором запущено приложение.""" + + system: str + python_version: str + term: str + lang: str + encoding: str + utf8: bool + + +@dataclass(frozen=True) +class CheckResult: + """Результат одной проверки окружения.""" + + ok: bool + name: str + detail: str + + +def get_platform_info() -> PlatformInfo: + """Собрать информацию об ОС и терминале.""" + encoding = sys.stdout.encoding or sys.getdefaultencoding() or "" + return PlatformInfo( + system=_platform.system(), + python_version=_platform.python_version(), + term=os.environ.get("TERM", ""), + lang=os.environ.get("LANG", ""), + encoding=encoding, + utf8=encoding.lower().replace("-", "").replace("_", "") == "utf8", + ) + + +def check_environment() -> list[CheckResult]: + """Проверить окружение: Python, TERM, LANG, кодировку UTF-8.""" + info = get_platform_info() + major, minor = (int(x) for x in _platform.python_version_tuple()[:2]) + checks: list[CheckResult] = [ + CheckResult( + ok=(major, minor) >= MIN_PYTHON, + name="python", + detail=( + f"{info.python_version} (требуется >= {MIN_PYTHON[0]}.{MIN_PYTHON[1]})" + ), + ), + CheckResult( + ok=bool(info.term) + and any(marker in info.term for marker in _SUPPORTED_TERM_MARKERS), + name="TERM", + detail=info.term or "не задан (рекомендуется xterm-256color)", + ), + CheckResult( + ok=bool(info.lang), + name="LANG", + detail=info.lang or "не задан (рекомендуется *.UTF-8)", + ), + CheckResult( + ok=info.utf8, + name="UTF-8", + detail=f"{info.encoding} (stdout)", + ), + ] + return checks From 4a6a188af100749a9940c3db5009e3d5d0118e85 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:43:10 +0300 Subject: [PATCH 23/79] Add src/selectel_ml_tui/services.py --- src/selectel_ml_tui/services.py | 365 ++++++++++++++++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 src/selectel_ml_tui/services.py diff --git a/src/selectel_ml_tui/services.py b/src/selectel_ml_tui/services.py new file mode 100644 index 0000000..97f6386 --- /dev/null +++ b/src/selectel_ml_tui/services.py @@ -0,0 +1,365 @@ +"""Сервисный слой: агрегация данных для TUI. + +Объединяет Billing API (стоимость за месяц) и Gateway API (метрики, токены, +модели). При недоступности API подставляет последние данные из SQLite-кэша +(офлайн-режим). +""" + +from __future__ import annotations + +import datetime as dt +from decimal import Decimal +from typing import Any + +from pydantic import BaseModel, Field + +from selectel_ml_tui.api.billing import BillingClient +from selectel_ml_tui.api.client import SelectelApiError +from selectel_ml_tui.api.gateway import GatewayClient +from selectel_ml_tui.auth import IamClient, IamTokenManager +from selectel_ml_tui.config import Credentials, Settings +from selectel_ml_tui.models import ( + ApiKeyUsage, + BudgetInfo, + Consumption, + HistoryPoint, + Metrics, + ModelMetrics, + TokenUsage, +) +from selectel_ml_tui.storage import Storage + +_NANO = Decimal("1000000000") + + +def _nano_to_rub(value: Any) -> Decimal: + """Нанорубли (строка/число) -> рубли.""" + if value is None: + return Decimal("0") + try: + return Decimal(str(value)) / _NANO + except (ValueError, ArithmeticError, TypeError): + return Decimal("0") + + +def _pick(data: dict[str, Any], keys: tuple[str, ...]) -> Any: + for key in keys: + if key in data and data[key] is not None: + return data[key] + return None + + +def _dict(value: Any) -> dict[str, Any]: + """Привести произвольное значение к dict (иначе пустой dict).""" + return value if isinstance(value, dict) else {} + + +def parse_tokens(data: dict[str, Any]) -> TokenUsage: + """Извлечь токены из объекта ответа (устойчиво к разным ключам). + + Поддерживает как вложенную структуру Gateway (``tokens.input`` и т.п.), + так и плоские ключи (``input_tokens`` и т.п.). + """ + nested = _dict(data.get("tokens")) + return TokenUsage( + input_tokens=int( + _pick(data, ("input_tokens", "input", "inputTokens", "tokens_in")) + or _pick(nested, ("input", "input_tokens")) + or 0 + ), + output_tokens=int( + _pick(data, ("output_tokens", "output", "outputTokens", "tokens_out")) + or _pick(nested, ("output", "output_tokens")) + or 0 + ), + cache_read_tokens=int( + _pick(data, ("cache_read_tokens", "cacheReadTokens", "cache_read")) + or _pick(nested, ("cached_input", "cache_read_tokens", "cache_read")) + or 0 + ), + cache_write_tokens=int( + _pick(data, ("cache_write_tokens", "cacheWriteTokens", "cache_write")) + or _pick(nested, ("cache_write", "cache_write_tokens")) + or 0 + ), + ) + + +def parse_overview(raw: dict[str, Any], period: dt.datetime) -> Consumption: + """Разобрать /gateways/{id}/overview в Consumption. + + Сумма из ``totals.cost_rub_nano`` (нанорубли), токены из ``totals.tokens``, + общее число токенов из ``totals.tokens_total.total``. + """ + totals = _dict(raw.get("totals")) + tokens_total = _dict(totals.get("tokens_total")) + tokens = parse_tokens(totals) + return Consumption( + period=period, + amount_rub=_nano_to_rub(totals.get("cost_rub_nano")), + requests=int(totals.get("requests") or 0), + tokens_total=int(tokens_total.get("total") or 0), + **tokens.model_dump(), + ) + + +def parse_metrics(raw: dict[str, Any]) -> Metrics | None: + """Разобрать /gateways/{id}/metrics в Metrics. + + Доступность из ``global.success_rate_pct``, TTFT/latency из секунд + ``global.avg_ttft_seconds``/``avg_latency_seconds`` (в мс), токены из + ``global.tokens``. + """ + if not raw: + return None + global_ = _dict(raw.get("global")) or raw + tokens = parse_tokens(global_) + return Metrics( + availability_pct=float(global_.get("success_rate_pct") or 100.0), + avg_ttft_ms=float(global_.get("avg_ttft_seconds") or 0.0) * 1000, + avg_latency_ms=float(global_.get("avg_latency_seconds") or 0.0) * 1000, + requests=int(global_.get("requests") or 0), + input_tokens=tokens.input_tokens, + output_tokens=tokens.output_tokens, + cache_read_tokens=tokens.cache_read_tokens, + cache_write_tokens=tokens.cache_write_tokens, + ) + + +def parse_models(raw_metrics: dict[str, Any]) -> list[ModelMetrics]: + """Построить метрики по моделям из ``by_model`` в /metrics. + + По модели API отдаёт: ``model``, ``requests``, ``success_rate_pct``, + ``avg_ttft_seconds`` (токенов по модели нет). + """ + by_model = raw_metrics.get("by_model", []) + if not isinstance(by_model, list): + by_model = [] + models: list[ModelMetrics] = [] + for item in by_model: + model_id = str(item.get("model") or "") if isinstance(item, dict) else "" + models.append( + ModelMetrics( + model_id=model_id, + model_name=model_id, + availability_pct=float(item.get("success_rate_pct") or 100.0) + if isinstance(item, dict) + else 100.0, + avg_ttft_ms=( + float(item.get("avg_ttft_seconds") or 0.0) * 1000 + if isinstance(item, dict) + else 0.0 + ), + avg_latency_ms=( + float(item.get("avg_latency_seconds") or 0.0) * 1000 + if isinstance(item, dict) + else 0.0 + ), + requests=( + int(item.get("requests") or 0) if isinstance(item, dict) else 0 + ), + ) + ) + return models + + +def parse_keys(raw_keys: list[dict[str, Any]]) -> list[ApiKeyUsage]: + """Разобрать /keys в список лимитов по API-ключам.""" + keys: list[ApiKeyUsage] = [] + for item in raw_keys: + budget = _dict(item.get("budget")) + limit = _nano_to_rub(budget.get("spend_limit_rub_nano")) + usage = _dict(budget.get("current_usage")) or _dict(item.get("usage")) + spend = _nano_to_rub(usage.get("spend_rub_nano")) + keys.append( + ApiKeyUsage( + name=str(item.get("name") or ""), + prefix=str(item.get("prefix") or ""), + is_active=bool(item.get("is_active", True)), + spend_limit_rub=limit, + spend_rub=spend, + remaining_rub=max(limit - spend, Decimal("0")), + ) + ) + return keys + + +def parse_budget(raw: dict[str, Any]) -> BudgetInfo | None: + """Бюджет роутера из ``budget`` в /overview.""" + budget = _dict(raw.get("budget")) + if not budget: + return None + limit = _nano_to_rub(budget.get("spend_limit_rub_nano")) + spend = _nano_to_rub(budget.get("spend_rub_nano")) + return BudgetInfo( + spend_limit_rub=limit, + spend_rub=spend, + remaining_rub=max(limit - spend, Decimal("0")), + window=str(budget.get("window") or ""), + ) + + +class DashboardData(BaseModel): + """Данные для отображения на главном экране.""" + + month_consumption: Consumption | None = None + today_amount_rub: Decimal | None = None + metrics: Metrics | None = None + models: list[ModelMetrics] = Field(default_factory=list) + keys: list[ApiKeyUsage] = Field(default_factory=list) + budget: BudgetInfo | None = None + offline: bool = False + updated_at: dt.datetime | None = None + error: str | None = None + source: str = "live" + + @property + def has_data(self) -> bool: + return ( + self.month_consumption is not None + or self.metrics is not None + or bool(self.models) + ) + + +class DataService: + """Собирает данные из API и сохраняет снимки в кэш.""" + + def __init__( + self, + settings: Settings, + credentials: Credentials, + storage: Storage | None = None, + transport: Any = None, + ) -> None: + self._settings = settings + self._credentials = credentials + self._storage = storage + self._transport = transport + + async def refresh(self) -> DashboardData: + """Получить актуальные данные (online) или последние из кэша (offline).""" + now = dt.datetime.now(dt.timezone.utc) + month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + data = DashboardData(updated_at=now) + errors: list[str] = [] + + await self._load_billing(data, month_start, now, errors) + await self._load_gateway(data, errors) + + if data.month_consumption is None and self._storage is not None: + cached = self._storage.get_latest_consumption() + if cached is not None: + data.month_consumption = cached + data.offline = True + data.source = "cache" + + if errors: + data.error = "; ".join(errors) + + if data.month_consumption is not None and self._storage is not None: + history_amount = data.today_amount_rub + if history_amount is None: + history_amount = data.month_consumption.amount_rub + self._storage.save_snapshot( + consumption=data.month_consumption, + metrics=data.metrics, + models=data.models, + history_point=HistoryPoint( + timestamp=now.replace(hour=0, minute=0, second=0, microsecond=0), + amount_rub=history_amount, + tokens=TokenUsage( + input_tokens=data.metrics.input_tokens if data.metrics else 0, + output_tokens=( + data.metrics.output_tokens if data.metrics else 0 + ), + cache_read_tokens=( + data.metrics.cache_read_tokens if data.metrics else 0 + ), + cache_write_tokens=( + data.metrics.cache_write_tokens if data.metrics else 0 + ), + ), + ), + ) + return data + + async def _load_billing( + self, + data: DashboardData, + month_start: dt.datetime, + now: dt.datetime, + errors: list[str], + ) -> None: + billing = BillingClient( + self._settings.api_base_url, + self._credentials.static_token, + transport=self._transport, + ) + try: + # end — текущий момент с временем, чтобы включить сегодняшний день + # (end = дата без времени отсекает текущий день из суммы). + # API принимает только naive datetime (без смещения часового пояса). + end_full = now.replace(tzinfo=None).isoformat(timespec="milliseconds") + total_kop = await billing.get_summary( + month_start.date().isoformat(), end_full + ) + data.month_consumption = Consumption(period=now, amount_rub=total_kop / 100) + # Дневная стоимость сегодняшнего дня — для точки истории. + try: + today_items = await billing.get_consumption( + now.date().isoformat(), end_full, period_group_type="day" + ) + today_total = sum( + (item.value for item in today_items), start=Decimal("0") + ) + data.today_amount_rub = today_total / 100 + except SelectelApiError: + data.today_amount_rub = None + except SelectelApiError as exc: + errors.append(f"Billing: {exc}") + finally: + await billing.aclose() + + async def _load_gateway(self, data: DashboardData, errors: list[str]) -> None: + """Gateway API требует IAM-доступ; недоступность не роняет дашборд.""" + if not self._credentials.service_user or not self._credentials.password: + errors.append( + "Gateway: не настроен сервисный пользователь (токены/модели недоступны)" + ) + return + try: + iam = IamClient( + self._settings, self._credentials, transport=self._transport + ) + provider = IamTokenManager(iam) + gateway = GatewayClient( + self._settings.api_base_url, + provider, + self._credentials.project_id, + self._credentials.domain_id, + transport=self._transport, + ) + try: + gateways = await gateway.list_gateways() + router_id = self._settings.router_id or ( + str(gateways[0]["id"]) if gateways else "" + ) + if router_id: + overview = await gateway.get_overview(router_id) + metrics = await gateway.get_metrics(router_id) + data.metrics = parse_metrics(metrics.raw) + data.models = parse_models(metrics.raw) + data.keys = parse_keys(await gateway.list_keys()) + data.budget = parse_budget(overview.raw) + # Сумма за месяц из Gateway Overview (совпадает с вкладкой + # «Обзор»); биллинг используется как фолбэк. + data.month_consumption = parse_overview( + overview.raw, dt.datetime.now(dt.timezone.utc) + ) + else: + errors.append("Gateway: нет ИИ-роутеров") + finally: + await gateway.aclose() + except Exception as exc: # noqa: BLE001 - на уровне сервиса ловим всё + errors.append(f"Gateway: {exc}") From 8ce00747843a8c2ae36652a6a38283f557c293c8 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:43:39 +0300 Subject: [PATCH 24/79] Add src/selectel_ml_tui/storage.py --- src/selectel_ml_tui/storage.py | 361 +++++++++++++++++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 src/selectel_ml_tui/storage.py diff --git a/src/selectel_ml_tui/storage.py b/src/selectel_ml_tui/storage.py new file mode 100644 index 0000000..75c02bb --- /dev/null +++ b/src/selectel_ml_tui/storage.py @@ -0,0 +1,361 @@ +"""Локальный кэш и история данных (SQLite). + +База данных в каталоге данных платформы (Linux: ~/.local/share, +macOS: ~/Library/Application Support). Хранит снимки потребления и метрик +при каждом успешном обновлении, а также историю по периодам для графиков. +При недоступности API TUI может показать последние данные из кэша. +""" + +from __future__ import annotations + +import csv +import datetime as dt +import json +import sqlite3 +from decimal import Decimal +from pathlib import Path +from typing import Any, cast + +from selectel_ml_tui.config import data_dir +from selectel_ml_tui.models import ( + Consumption, + HistoryPoint, + Metrics, + ModelMetrics, + TokenUsage, + parse_iso_dt, +) + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS consumption_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + period TEXT NOT NULL, + amount_rub TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS metrics_snapshots ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + availability_pct REAL NOT NULL DEFAULT 100, + avg_ttft_ms REAL NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS model_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + created_at TEXT NOT NULL, + model_id TEXT NOT NULL, + model_name TEXT NOT NULL DEFAULT '', + availability_pct REAL NOT NULL DEFAULT 100, + avg_ttft_ms REAL NOT NULL DEFAULT 0, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS history ( + timestamp TEXT PRIMARY KEY, + amount_rub TEXT NOT NULL, + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + cache_read_tokens INTEGER NOT NULL DEFAULT 0, + cache_write_tokens INTEGER NOT NULL DEFAULT 0 +); +""" + + +def default_db_path() -> Path: + """Путь к базе данных по умолчанию (каталог данных платформы).""" + return data_dir() / "cache.db" + + +def _utc_now() -> str: + return dt.datetime.now(dt.timezone.utc).isoformat() + + +def _history_columns(point: HistoryPoint) -> tuple[Any, ...]: + return ( + point.timestamp.isoformat(), + str(point.amount_rub), + point.tokens.input_tokens, + point.tokens.output_tokens, + point.tokens.cache_read_tokens, + point.tokens.cache_write_tokens, + ) + + +def _history_from_row(row: sqlite3.Row) -> HistoryPoint: + return HistoryPoint( + timestamp=parse_iso_dt(cast(str, row["timestamp"])), + amount_rub=Decimal(str(row["amount_rub"])), + tokens=TokenUsage( + input_tokens=cast(int, row["input_tokens"]), + output_tokens=cast(int, row["output_tokens"]), + cache_read_tokens=cast(int, row["cache_read_tokens"]), + cache_write_tokens=cast(int, row["cache_write_tokens"]), + ), + ) + + +class Storage: + """SQLite-кэш: снимки потребления/метрик и история для графиков.""" + + def __init__(self, path: Path | None = None) -> None: + self._path = path if path is not None else default_db_path() + self._path.parent.mkdir(parents=True, exist_ok=True) + self._conn = sqlite3.connect(self._path) + self._conn.row_factory = sqlite3.Row + self._init_schema() + + @property + def path(self) -> Path: + return self._path + + def _init_schema(self) -> None: + self._conn.executescript(SCHEMA) + self._conn.commit() + + def save_consumption(self, item: Consumption) -> None: + """Сохранить снимок потребления.""" + self._conn.execute( + """ + INSERT INTO consumption_snapshots + (created_at, period, amount_rub, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + _utc_now(), + item.period.isoformat(), + str(item.amount_rub), + item.input_tokens, + item.output_tokens, + item.cache_read_tokens, + item.cache_write_tokens, + ), + ) + self._conn.commit() + + def save_metrics(self, metrics: Metrics) -> None: + """Сохранить снимок общих метрик.""" + self._conn.execute( + """ + INSERT INTO metrics_snapshots + (created_at, availability_pct, avg_ttft_ms, input_tokens, + output_tokens, cache_read_tokens, cache_write_tokens) + VALUES (?, ?, ?, ?, ?, ?, ?) + """, + ( + _utc_now(), + metrics.availability_pct, + metrics.avg_ttft_ms, + metrics.input_tokens, + metrics.output_tokens, + metrics.cache_read_tokens, + metrics.cache_write_tokens, + ), + ) + self._conn.commit() + + def save_model_metrics(self, models: list[ModelMetrics]) -> None: + """Сохранить снимок метрик по моделям (один пакет = один created_at).""" + batch = _utc_now() + for model in models: + self._conn.execute( + """ + INSERT INTO model_metrics + (created_at, model_id, model_name, availability_pct, + avg_ttft_ms, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + batch, + model.model_id, + model.model_name, + model.availability_pct, + model.avg_ttft_ms, + model.input_tokens, + model.output_tokens, + model.cache_read_tokens, + model.cache_write_tokens, + ), + ) + self._conn.commit() + + def save_history(self, point: HistoryPoint) -> None: + """Сохранить/обновить точку истории (upsert по timestamp).""" + self._conn.execute( + """ + INSERT OR REPLACE INTO history + (timestamp, amount_rub, input_tokens, output_tokens, + cache_read_tokens, cache_write_tokens) + VALUES (?, ?, ?, ?, ?, ?) + """, + _history_columns(point), + ) + self._conn.commit() + + def save_snapshot( + self, + *, + consumption: Consumption | None = None, + metrics: Metrics | None = None, + models: list[ModelMetrics] | None = None, + history_point: HistoryPoint | None = None, + ) -> None: + """Сохранить полный снимок данных после успешного обновления.""" + if consumption is not None: + self.save_consumption(consumption) + if metrics is not None: + self.save_metrics(metrics) + if models: + self.save_model_metrics(models) + if history_point is not None: + self.save_history(history_point) + + def get_latest_consumption(self) -> Consumption | None: + """Последний снимок потребления (для офлайн-режима).""" + row = self._conn.execute( + "SELECT * FROM consumption_snapshots ORDER BY id DESC LIMIT 1" + ).fetchone() + if row is None: + return None + return Consumption( + period=parse_iso_dt(cast(str, row["period"])), + amount_rub=Decimal(str(row["amount_rub"])), + input_tokens=cast(int, row["input_tokens"]), + output_tokens=cast(int, row["output_tokens"]), + cache_read_tokens=cast(int, row["cache_read_tokens"]), + cache_write_tokens=cast(int, row["cache_write_tokens"]), + ) + + def get_latest_metrics(self) -> Metrics | None: + """Последний снимок общих метрик.""" + row = self._conn.execute( + "SELECT * FROM metrics_snapshots ORDER BY id DESC LIMIT 1" + ).fetchone() + if row is None: + return None + return Metrics( + availability_pct=cast(float, row["availability_pct"]), + avg_ttft_ms=cast(float, row["avg_ttft_ms"]), + input_tokens=cast(int, row["input_tokens"]), + output_tokens=cast(int, row["output_tokens"]), + cache_read_tokens=cast(int, row["cache_read_tokens"]), + cache_write_tokens=cast(int, row["cache_write_tokens"]), + ) + + def get_latest_model_metrics(self) -> list[ModelMetrics]: + """Снимок метрик по моделям за последнее обновление.""" + rows = self._conn.execute( + """ + SELECT * FROM model_metrics + WHERE created_at = (SELECT MAX(created_at) FROM model_metrics) + ORDER BY model_id + """ + ).fetchall() + models: list[ModelMetrics] = [] + for row in rows: + models.append( + ModelMetrics( + model_id=cast(str, row["model_id"]), + model_name=cast(str, row["model_name"]), + availability_pct=cast(float, row["availability_pct"]), + avg_ttft_ms=cast(float, row["avg_ttft_ms"]), + input_tokens=cast(int, row["input_tokens"]), + output_tokens=cast(int, row["output_tokens"]), + cache_read_tokens=cast(int, row["cache_read_tokens"]), + cache_write_tokens=cast(int, row["cache_write_tokens"]), + ) + ) + return models + + def get_history(self, days: int = 7) -> list[HistoryPoint]: + """Серия точек истории за последние ``days`` дней для графика.""" + cutoff = ( + dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=days) + ).isoformat() + rows = self._conn.execute( + "SELECT * FROM history WHERE timestamp >= ? ORDER BY timestamp ASC", + (cutoff,), + ).fetchall() + return [_history_from_row(row) for row in rows] + + def cleanup(self, max_age_days: int = 180) -> int: + """Удалить записи старше ``max_age_days`` дней. Возвращает число удалённых.""" + cutoff = ( + dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=max_age_days) + ).isoformat() + deleted = 0 + for table, column in ( + ("consumption_snapshots", "created_at"), + ("metrics_snapshots", "created_at"), + ("model_metrics", "created_at"), + ("history", "timestamp"), + ): + cursor = self._conn.execute( + f"DELETE FROM {table} WHERE {column} < ?", (cutoff,) + ) + deleted += cursor.rowcount + self._conn.commit() + return deleted + + def export_json(self, path: Path) -> None: + """Экспортировать всю историю в JSON.""" + points = self.get_history(days=3650) + data = [ + { + "timestamp": point.timestamp.isoformat(), + "amount_rub": str(point.amount_rub), + "input_tokens": point.tokens.input_tokens, + "output_tokens": point.tokens.output_tokens, + "cache_read_tokens": point.tokens.cache_read_tokens, + "cache_write_tokens": point.tokens.cache_write_tokens, + } + for point in points + ] + path.write_text( + json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8" + ) + + def export_csv(self, path: Path) -> None: + """Экспортировать всю историю в CSV.""" + points = self.get_history(days=3650) + with path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + writer.writerow( + [ + "timestamp", + "amount_rub", + "input_tokens", + "output_tokens", + "cache_read_tokens", + "cache_write_tokens", + ] + ) + for point in points: + writer.writerow(_history_columns(point)) + + def clear(self) -> int: + """Полностью очистить кэш. Возвращает число удалённых записей.""" + deleted = 0 + for table in ( + "consumption_snapshots", + "metrics_snapshots", + "model_metrics", + "history", + ): + cursor = self._conn.execute(f"DELETE FROM {table}") + deleted += cursor.rowcount + self._conn.commit() + return deleted + + def close(self) -> None: + self._conn.close() From da19070e20d16e279035a6cf5d2354d0fccd9f4b Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:43:44 +0300 Subject: [PATCH 25/79] Add src/selectel_ml_tui/api/__init__.py --- src/selectel_ml_tui/api/__init__.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/selectel_ml_tui/api/__init__.py diff --git a/src/selectel_ml_tui/api/__init__.py b/src/selectel_ml_tui/api/__init__.py new file mode 100644 index 0000000..d4c24f6 --- /dev/null +++ b/src/selectel_ml_tui/api/__init__.py @@ -0,0 +1,11 @@ +"""Клиенты API Selectel: billing (стоимость) и gateway (ИИ-роутер).""" + +from selectel_ml_tui.api.billing import BillingApiError, BillingClient +from selectel_ml_tui.api.gateway import GatewayApiError, GatewayClient + +__all__ = [ + "BillingApiError", + "BillingClient", + "GatewayApiError", + "GatewayClient", +] From fbc7f0eca3b1add3ffdee8cd99903dfc7b6b4503 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:43:54 +0300 Subject: [PATCH 26/79] Add src/selectel_ml_tui/api/billing.py --- src/selectel_ml_tui/api/billing.py | 132 +++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 src/selectel_ml_tui/api/billing.py diff --git a/src/selectel_ml_tui/api/billing.py b/src/selectel_ml_tui/api/billing.py new file mode 100644 index 0000000..0280e46 --- /dev/null +++ b/src/selectel_ml_tui/api/billing.py @@ -0,0 +1,132 @@ +"""Клиент Billing Statistics API Selectel (провайдер aig). + +Аутентификация: статический токен в заголовке X-Token. +Данные: стоимость потребления ИИ-роутера по дням/часам. +""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any, cast + +import httpx + +from selectel_ml_tui.api.client import BaseApiClient, SelectelApiError +from selectel_ml_tui.models import ConsumptionItem + +BillingApiError = SelectelApiError + + +def normalize_dt(value: str) -> str: + """Привести дату к формату, принимаемому Billing API. + + Billing API ожидает полный ISO-таймстамп (например + ``2026-08-01T00:00:00.000``); короткая дата дополняется. + """ + if "T" in value: + return value + return f"{value}T00:00:00.000" + + +class BillingClient(BaseApiClient): + """Клиент /v1/cloud_billing/statistic/* для провайдера aig.""" + + PROVIDER = "aig" + + def __init__( + self, + api_base_url: str, + static_token: str, + timeout: float = 30.0, + max_retries: int = 2, + transport: httpx.AsyncBaseTransport | None = None, + sleep: Any = None, + ) -> None: + super().__init__( + timeout=timeout, + max_retries=max_retries, + transport=transport, + sleep=sleep, + ) + self._base_url = api_base_url.rstrip("/") + self._token = static_token + + def _headers(self) -> dict[str, str]: + return {"X-Token": self._token, "Content-Type": "application/json"} + + async def _get( + self, path: str, params: dict[str, str] | None = None + ) -> dict[str, Any]: + url = f"{self._base_url}{path}" + response = await self.request( + "GET", url, params=params, headers=self._headers() + ) + data = response.json() + if not isinstance(data, dict) or data.get("status") != "success": + raise BillingApiError(f"Billing API: {data}") + return data + + async def get_consumption( + self, + start: str, + end: str, + locale: str = "ru", + group_type: str = "metric", + period_group_type: str = "day", + ) -> list[ConsumptionItem]: + """Получить потребление за период. + + group_type: project | object | metric | project_metric | region_metric + period_group_type: hour | day | week | month | year | all + """ + data = await self._get( + "/v1/cloud_billing/statistic/consumption", + params={ + "provider_keys": self.PROVIDER, + "start": normalize_dt(start), + "end": normalize_dt(end), + "locale": locale, + "group_type": group_type, + "period_group_type": period_group_type, + }, + ) + return [ConsumptionItem.model_validate(item) for item in data.get("data", [])] + + async def get_consumption_history( + self, + start: str, + end: str, + group: str = "day", + ) -> list[ConsumptionItem]: + """История потребления по дням/часам за период. + + group: hour | day | week | month | year | all + """ + return await self.get_consumption( + start, end, period_group_type=group, group_type="metric" + ) + + async def get_summary( + self, + start: str, + end: str, + group_type: str = "metric", + period_group_type: str = "month", + ) -> Decimal: + """Суммарная стоимость за период в копейках (value).""" + data = await self._get( + "/v1/cloud_billing/statistic/summary_stats", + params={ + "provider_keys": self.PROVIDER, + "start": normalize_dt(start), + "end": normalize_dt(end), + "group_type": group_type, + "period_group_type": period_group_type, + "items_count_period_group_type": "all", + }, + ) + rows = data.get("data", []) + total: Decimal = Decimal("0") + for row in cast(list[dict[str, Any]], rows): + total += Decimal(str(row.get("value", 0))) + return total From 1c57a40140c5450358c0d4325e596e8081809952 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:44:01 +0300 Subject: [PATCH 27/79] Add src/selectel_ml_tui/api/client.py --- src/selectel_ml_tui/api/client.py | 93 +++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/selectel_ml_tui/api/client.py diff --git a/src/selectel_ml_tui/api/client.py b/src/selectel_ml_tui/api/client.py new file mode 100644 index 0000000..b609ed8 --- /dev/null +++ b/src/selectel_ml_tui/api/client.py @@ -0,0 +1,93 @@ +"""Базовый HTTP-слой для клиентов API Selectel. + +httpx.AsyncClient с таймаутами и лимитом соединений; единая обработка ошибок: +- 429 (rate limit) и 5xx — повтор с экспоненциальной паузой; +- сетевые ошибки httpx — повтор; +- остальные 4xx — исключение SelectelApiError с понятным текстом. + +Слой отделён от TUI и тестируется моками httpx.MockTransport. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any + +import httpx + + +class SelectelApiError(RuntimeError): + """Ошибка обращения к API Selectel. + + Атрибут ``status_code`` заполнен для HTTP-ошибок (None для сетевых). + """ + + def __init__(self, message: str, status_code: int | None = None) -> None: + super().__init__(message) + self.status_code = status_code + + +class BaseApiClient: + """Базовый клиент: транспорт, ретраи, понятные ошибки.""" + + def __init__( + self, + *, + timeout: float = 30.0, + max_retries: int = 2, + transport: httpx.AsyncBaseTransport | None = None, + sleep: Callable[[float], Awaitable[None]] | None = None, + ) -> None: + limits = httpx.Limits(max_connections=10, max_keepalive_connections=5) + self._client = httpx.AsyncClient( + timeout=timeout, + limits=limits, + transport=transport, + ) + self._max_retries = max_retries + self._sleep = sleep if sleep is not None else asyncio.sleep + + async def aclose(self) -> None: + """Закрыть транспортные соединения.""" + await self._client.aclose() + + async def request( + self, + method: str, + url: str, + *, + params: dict[str, str] | None = None, + json: Any = None, + headers: dict[str, str] | None = None, + ) -> httpx.Response: + """Выполнить запрос с повторами и понятной обработкой ошибок.""" + last_exc: SelectelApiError | None = None + for attempt in range(self._max_retries + 1): + try: + response = await self._client.request( + method, url, params=params, json=json, headers=headers + ) + except httpx.HTTPError as exc: + if attempt < self._max_retries: + await self._sleep(self._backoff_seconds(attempt)) + continue + raise SelectelApiError(f"Ошибка сети: {exc}") from exc + + if response.status_code < 400: + return response + + retriable = response.status_code == 429 or response.status_code >= 500 + if retriable and attempt < self._max_retries: + await self._sleep(self._backoff_seconds(attempt)) + continue + + raise SelectelApiError( + f"HTTP {response.status_code}: {response.text[:300]}", + status_code=response.status_code, + ) + raise last_exc if last_exc else SelectelApiError("Запрос не выполнен") + + @staticmethod + def _backoff_seconds(attempt: int) -> float: + return float(0.5 * (2**attempt)) From 14f740bca0090a58ac7ade4b051544c03113d54a Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:44:10 +0300 Subject: [PATCH 28/79] Add src/selectel_ml_tui/api/gateway.py --- src/selectel_ml_tui/api/gateway.py | 98 ++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 src/selectel_ml_tui/api/gateway.py diff --git a/src/selectel_ml_tui/api/gateway.py b/src/selectel_ml_tui/api/gateway.py new file mode 100644 index 0000000..4b4d80f --- /dev/null +++ b/src/selectel_ml_tui/api/gateway.py @@ -0,0 +1,98 @@ +"""Клиент Gateway API ИИ-роутера Selectel (/aig/gateway/v1/*). + +Аутентификация: IAM-токен проекта (X-Auth-Token) + X-Project-Id и др. +Данные: потребление (overview), метрики 24ч, модели, ключи. + +Токен берётся из IamTokenManager; при HTTP 401 выполняется его +автоматическое обновление и один повтор запроса. +""" + +from __future__ import annotations + +from typing import Any, cast + +import httpx + +from selectel_ml_tui.api.client import BaseApiClient, SelectelApiError +from selectel_ml_tui.auth import IamTokenManager +from selectel_ml_tui.models import RouterMetrics, RouterOverview + +GatewayApiError = SelectelApiError + + +class GatewayClient(BaseApiClient): + """Клиент /aig/gateway/v1/*.""" + + def __init__( + self, + api_base_url: str, + token_provider: IamTokenManager, + project_id: str, + domain_id: str, + timeout: float = 30.0, + max_retries: int = 2, + transport: httpx.AsyncBaseTransport | None = None, + sleep: Any = None, + ) -> None: + super().__init__( + timeout=timeout, + max_retries=max_retries, + transport=transport, + sleep=sleep, + ) + self._base_url = api_base_url.rstrip("/") + self._token_provider = token_provider + self._project_id = project_id + self._domain_id = domain_id + + def _headers(self, token: str) -> dict[str, str]: + return { + "X-Auth-Token": token, + "X-Project-Id": self._project_id, + "X-Project-Domain-Id": self._domain_id, + "X-Project-Domain-Name": self._domain_id, + "X-Roles": "reader", + "Content-Type": "application/json", + } + + async def _get(self, path: str) -> dict[str, Any]: + url = f"{self._base_url}{path}" + token = await self._token_provider.get_token() + try: + response = await self.request("GET", url, headers=self._headers(token)) + except SelectelApiError as exc: + if exc.status_code == 401: + self._token_provider.invalidate() + token = await self._token_provider.get_token() + response = await self.request("GET", url, headers=self._headers(token)) + else: + raise + data = response.json() + if isinstance(data, dict) and data.get("error"): + raise GatewayApiError(f"Gateway API: {data['error']}") + return cast(dict[str, Any], data) + + async def list_gateways(self) -> list[dict[str, Any]]: + """Список ИИ-роутеров.""" + data = await self._get("/aig/gateway/v1/gateways") + return cast(list[dict[str, Any]], data.get("data", data.get("gateways", []))) + + async def get_overview(self, router_id: str) -> RouterOverview: + """Потребление роутера (вкладка «Обзор»).""" + data = await self._get(f"/aig/gateway/v1/gateways/{router_id}/overview") + return RouterOverview(raw=data) + + async def get_metrics(self, router_id: str) -> RouterMetrics: + """Метрики роутера за 24 часа (вкладка «Метрики»).""" + data = await self._get(f"/aig/gateway/v1/gateways/{router_id}/metrics") + return RouterMetrics(raw=data) + + async def list_models(self) -> list[dict[str, Any]]: + """Каталог моделей.""" + data = await self._get("/aig/gateway/v1/models") + return cast(list[dict[str, Any]], data.get("data", data.get("models", []))) + + async def list_keys(self) -> list[dict[str, Any]]: + """Список API-ключей роутера с лимитами.""" + data = await self._get("/aig/gateway/v1/keys") + return cast(list[dict[str, Any]], data.get("data", data.get("keys", []))) From 2ebd852998acb94415663bf9d764523c4f7bcba2 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:44:12 +0300 Subject: [PATCH 29/79] Add src/selectel_ml_tui/ui/__init__.py --- src/selectel_ml_tui/ui/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/selectel_ml_tui/ui/__init__.py diff --git a/src/selectel_ml_tui/ui/__init__.py b/src/selectel_ml_tui/ui/__init__.py new file mode 100644 index 0000000..ea8cd86 --- /dev/null +++ b/src/selectel_ml_tui/ui/__init__.py @@ -0,0 +1,5 @@ +"""TUI-интерфейс selectel-ml-tui.""" + +from selectel_ml_tui.ui.app import SelectelMLTUI, run_app + +__all__ = ["SelectelMLTUI", "run_app"] From 09935d55ef86a27da27d1c2f5ab3536547f74d2f Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:44:57 +0300 Subject: [PATCH 30/79] Add src/selectel_ml_tui/ui/app.py --- src/selectel_ml_tui/ui/app.py | 589 ++++++++++++++++++++++++++++++++++ 1 file changed, 589 insertions(+) create mode 100644 src/selectel_ml_tui/ui/app.py diff --git a/src/selectel_ml_tui/ui/app.py b/src/selectel_ml_tui/ui/app.py new file mode 100644 index 0000000..2d6880d --- /dev/null +++ b/src/selectel_ml_tui/ui/app.py @@ -0,0 +1,589 @@ +"""TUI-приложение selectel-ml-tui на Textual. + +Экраны: Dashboard (главный), History (история), Settings (настройки). +Автообновление по таймеру, асинхронная загрузка через workers, офлайн-режим +с показом последних данных из SQLite-кэша. +""" + +from __future__ import annotations + +from typing import Any + +from textual import work +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal, Vertical +from textual.reactive import reactive +from textual.screen import Screen +from textual.widgets import ( + Button, + DataTable, + Footer, + Header, + Input, + Static, +) + +from selectel_ml_tui.config import ( + config_dir, + data_dir, + load_credentials, + load_settings, + save_settings, +) +from selectel_ml_tui.models import ( + ApiKeyUsage, + ModelMetrics, + format_percent, + format_rub, + format_tokens, +) +from selectel_ml_tui.services import DashboardData, DataService +from selectel_ml_tui.storage import Storage + +PERIOD_OPTIONS = (7, 30, 90) + + +def _mask_token(value: str) -> str: + if not value: + return "(не задан)" + return f"{value[:4]}…({len(value)})" + + +class ConsumptionBars(Static): + """Unicode-бар-график потребления (столбцы).""" + + data: reactive[list[float]] = reactive([]) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.height = 7 + + def render(self) -> Any: + values = self.data + if not values: + return "Нет данных для графика" + maximum = max(values) + if maximum <= 0: + return "Нет данных для графика" + lines: list[str] = [] + for level in range(self.height, 0, -1): + row = "" + for value in values: + bar_height = max(1, int(value / maximum * self.height)) + row += "█" if bar_height >= level else " " + lines.append(row) + return "\n".join(lines) + f"\nmax: {maximum:.0f} ₽" + + +class DashboardScreen(Screen[Any]): + """Главный экран: потребление за месяц, метрики, модели.""" + + BINDINGS = [ + Binding("r", "refresh", "Обновить", show=True), + Binding("h", "history", "История", show=True), + Binding("k", "keys", "Ключи", show=True), + Binding("s", "settings", "Настройки", show=True), + ] + + data = reactive(DashboardData()) + loading = reactive(False) + + def __init__( + self, + service: DataService, + refresh_interval: int, + storage: Storage, + settings: Any, + credentials: Any, + ) -> None: + super().__init__() + self._service = service + self._refresh_interval = refresh_interval + self._storage = storage + self._settings = settings + self._credentials = credentials + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + with Vertical(id="dashboard"): + with Horizontal(id="panels"): + yield Static("", id="month-panel", classes="panel") + yield Static("", id="metrics-panel", classes="panel") + yield Static("По моделям", id="models-title") + yield DataTable(id="models-table") + yield Footer() + + def on_mount(self) -> None: + self.set_interval(self._refresh_interval, self._refresh) + self._refresh() + + @work(exclusive=True, group="refresh") + async def _refresh(self) -> None: + self.loading = True + try: + self.data = await self._service.refresh() + except Exception as exc: # noqa: BLE001 - показываем toast при ошибке + self.notify(f"Ошибка обновления: {exc}", severity="error") + finally: + self.loading = False + + def watch_data(self, data: DashboardData) -> None: + status = "offline" if data.offline else "online" + amount = ( + format_rub(data.month_consumption.amount_rub) + if data.month_consumption + else "—" + ) + self.app.sub_title = f"{status} · {amount}" + self._update_month_panel(data) + self._update_metrics_panel(data) + self._update_models_table(data) + + def _update_month_panel(self, data: DashboardData) -> None: + text = "[bold]Потребление за месяц[/]\n\n" + if data.month_consumption is None: + text += "Нет данных" + else: + c = data.month_consumption + text += f"Сумма: [b]{format_rub(c.amount_rub)}[/]\n" + text += f"Запросы: {format_tokens(c.requests)}\n" + text += f"Токены: {format_tokens(c.tokens_total)}\n" + if data.today_amount_rub is not None: + text += f"[dim]Сегодня: {format_rub(data.today_amount_rub)}[/]\n" + if data.metrics is not None: + m = data.metrics + text += f"Input: {format_tokens(m.input_tokens)}\n" + text += f"Output: {format_tokens(m.output_tokens)}\n" + text += f"Cache read: {format_tokens(m.cache_read_tokens)}\n" + text += f"Cache write: {format_tokens(m.cache_write_tokens)}" + else: + text += "\n[dim](токены — только через IAM)[/]" + if data.budget is not None: + b = data.budget + text += ( + f"\n\n[bold]Бюджет[/]\n" + f"Лимит: {format_rub(b.spend_limit_rub)}\n" + f"Потрачено: {format_rub(b.spend_rub)}\n" + f"Остаток: {format_rub(b.remaining_rub)}" + ) + if data.offline: + text += "\n\n[dim]офлайн (данные из кэша)[/]" + if data.error: + text += f"\n\n[red]{data.error}[/]" + self.query_one("#month-panel", Static).update(text) + + def _update_metrics_panel(self, data: DashboardData) -> None: + text = "[bold]Метрики за 24 часа[/]\n\n" + if data.metrics is None: + text += "Нет данных" + else: + m = data.metrics + text += f"Доступность: {format_percent(m.availability_pct)}\n" + text += f"Средний TTFT: {m.avg_ttft_ms:.1f} мс\n" + text += f"Средняя задержка: {m.avg_latency_ms:.1f} мс\n" + text += f"Запросы: {format_tokens(m.requests)}\n" + text += f"Input: {format_tokens(m.input_tokens)}\n" + text += f"Output: {format_tokens(m.output_tokens)}\n" + text += f"Cache read: {format_tokens(m.cache_read_tokens)}\n" + text += f"Cache write: {format_tokens(m.cache_write_tokens)}" + self.query_one("#metrics-panel", Static).update(text) + + def _update_models_table(self, data: DashboardData) -> None: + table = self.query_one("#models-table", DataTable) + if not table.columns: + table.add_columns( + "Модель", + "Запросы", + "Доступность", + "TTFT, мс", + "Задержка, мс", + ) + table.cursor_type = "row" + table.clear() + for model in data.models: + table.add_row( + model.model_name, + format_tokens(model.requests), + format_percent(model.availability_pct), + f"{model.avg_ttft_ms:.1f}", + f"{model.avg_latency_ms:.1f}", + ) + + def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + event.stop() + if not self.data.models: + return + row = event.cursor_row + if row is None or row >= len(self.data.models): + return + self.app.push_screen(ModelDetailScreen(self.data.models[row])) + + def action_refresh(self) -> None: + self._refresh() + + def action_history(self) -> None: + self.app.push_screen(HistoryScreen(self._storage)) + + def action_keys(self) -> None: + self.app.push_screen(KeysScreen(self.data.keys, self.data.budget)) + + def action_settings(self) -> None: + self.app.push_screen( + SettingsScreen(self._settings, self._credentials, self._storage) + ) + + def action_model_detail(self) -> None: + if not self.data.models: + return + table = self.query_one("#models-table", DataTable) + row = table.cursor_row + if row is None: + row = 0 + if row >= len(self.data.models): + return + self.app.push_screen(ModelDetailScreen(self.data.models[row])) + + +class KeysScreen(Screen[Any]): + """Лимиты и расход по API-ключам роутера.""" + + BINDINGS = [ + Binding("escape", "back", "Назад", show=True), + ] + + def __init__(self, keys: list[ApiKeyUsage], budget: Any | None = None) -> None: + super().__init__() + self._keys = keys + self._budget = budget + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("API-ключи и лимиты", id="keys-title") + if self._budget is not None: + yield Static("", id="keys-budget") + yield DataTable(id="keys-table") + yield Footer() + + def on_mount(self) -> None: + if self._budget is not None: + b = self._budget + self.query_one("#keys-budget", Static).update( + f"[bold]Бюджет роутера[/]\n" + f"Лимит: {format_rub(b.spend_limit_rub)} " + f"Потрачено: {format_rub(b.spend_rub)} " + f"Остаток: {format_rub(b.remaining_rub)}" + ) + table = self.query_one("#keys-table", DataTable) + table.add_columns("Ключ", "Префикс", "Статус", "Лимит", "Потрачено", "Остаток") + for key in self._keys: + table.add_row( + key.name, + key.prefix, + "активен" if key.is_active else "неактивен", + format_rub(key.spend_limit_rub), + format_rub(key.spend_rub), + format_rub(key.remaining_rub), + ) + + def action_back(self) -> None: + self.app.pop_screen() + + +class HistoryScreen(Screen[Any]): + """История потребления: бар-график и таблица по дням.""" + + BINDINGS = [ + Binding("escape", "back", "Назад", show=True), + Binding("r", "refresh", "Обновить", show=True), + ] + + def __init__(self, storage: Storage) -> None: + super().__init__() + self._storage = storage + self._days = 30 + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("История потребления", id="history-title") + with Horizontal(id="period-buttons"): + for days in PERIOD_OPTIONS: + yield Button( + f"{days} дней", + id=f"period-{days}", + variant="primary" if days == self._days else "default", + ) + yield Static("Стоимость, руб. (по дням)", id="bars-title") + yield ConsumptionBars("", id="history-bars") + yield DataTable(id="history-table") + yield Footer() + + def on_mount(self) -> None: + self._reload() + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id and button_id.startswith("period-"): + self._days = int(button_id.split("-", 1)[1]) + for days in PERIOD_OPTIONS: + button = self.query_one(f"#period-{days}", Button) + button.variant = "primary" if days == self._days else "default" + self._reload() + + def action_refresh(self) -> None: + self._reload() + + def _reload(self) -> None: + points = self._storage.get_history(days=self._days) + self.query_one("#history-title", Static).update( + f"История потребления (последние {self._days} дней)" + ) + values = [float(point.amount_rub) for point in points] + self.query_one("#history-bars", ConsumptionBars).data = values + + table = self.query_one("#history-table", DataTable) + if not table.columns: + table.add_columns("Дата", "Сумма", "Input", "Output", "Cache") + table.clear() + for point in reversed(points): + table.add_row( + str(point.timestamp.date()), + format_rub(point.amount_rub), + format_tokens(point.tokens.input_tokens), + format_tokens(point.tokens.output_tokens), + format_tokens( + point.tokens.cache_read_tokens + point.tokens.cache_write_tokens + ), + ) + + def action_back(self) -> None: + self.app.pop_screen() + + +class ModelDetailScreen(Screen[Any]): + """Детализация метрик конкретной модели.""" + + BINDINGS = [ + Binding("escape", "back", "Назад", show=True), + ] + + def __init__(self, model: ModelMetrics) -> None: + super().__init__() + self._model = model + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("", id="model-detail") + yield Footer() + + def on_mount(self) -> None: + m = self._model + body = f"[b]{m.model_name}[/b]\nID: {m.model_id}\n\n" + body += f"Доступность: {format_percent(m.availability_pct)}\n" + body += f"Запросы: {format_tokens(m.requests)}\n" + body += f"Средний TTFT: {m.avg_ttft_ms:.1f} мс\n" + body += f"Средняя задержка: {m.avg_latency_ms:.1f} мс" + self.query_one("#model-detail", Static).update(body) + + def action_back(self) -> None: + self.app.pop_screen() + + +class SettingsScreen(Screen[Any]): + """Настройки: интервал обновления, очистка кэша, пути и токен.""" + + BINDINGS = [ + Binding("escape", "back", "Назад", show=True), + ] + + def __init__(self, settings: Any, credentials: Any, storage: Storage) -> None: + super().__init__() + self._settings = settings + self._credentials = credentials + self._storage = storage + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("Настройки", id="settings-title") + with Vertical(id="settings-body"): + yield Static("Интервал обновления, сек:", id="interval-label") + yield Input( + value=str(self._settings.refresh_interval), + id="interval-input", + type="integer", + ) + with Horizontal(id="settings-actions"): + yield Button("Сохранить", id="save-settings", variant="primary") + yield Button("Очистить кэш", id="clear-cache", variant="error") + yield Static("", id="settings-info") + yield Footer() + + def on_mount(self) -> None: + info = ( + f"API base URL: {self._settings.api_base_url}\n" + f"Router id: {self._settings.router_id or '(авто)'}\n" + f"Сервисный пользователь: " + f"{self._credentials.service_user or '(не задан)'}\n" + f"Токен: {_mask_token(self._credentials.static_token)}\n" + f"Config dir: {config_dir()}\n" + f"Data dir: {data_dir()}" + ) + self.query_one("#settings-info", Static).update(info) + + def on_button_pressed(self, event: Button.Pressed) -> None: + button_id = event.button.id + if button_id == "save-settings": + self._save_interval() + elif button_id == "clear-cache": + self._clear_cache() + + def _save_interval(self) -> None: + raw = self.query_one("#interval-input", Input).value + try: + interval = int(raw) + except ValueError: + self.notify("Некорректное значение интервала", severity="error") + return + if interval < 10: + self.notify("Интервал должен быть не менее 10 секунд", severity="error") + return + if interval == self._settings.refresh_interval: + return + self._settings.refresh_interval = interval + save_settings(self._settings) + self.notify("Настройки сохранены") + + def _clear_cache(self) -> None: + deleted = self._storage.clear() + self.notify(f"Кэш очищен: удалено записей: {deleted}", severity="information") + + def action_back(self) -> None: + self._save_interval() + self.app.pop_screen() + + +class SelectelMLTUI(App[Any]): + """Главное приложение.""" + + TITLE = "Selectel ML Tokens" + BINDINGS = [ + Binding("q", "quit", "Выход", show=False), + ] + + async def action_quit(self) -> None: + self.exit() + + CSS = """ + #panels { + height: 10; + } + .panel { + width: 1fr; + height: 100%; + border: round $primary; + padding: 1 2; + margin: 0 1 0 0; + } + #models-title { + margin: 1 0 0 0; + text-style: bold; + } + #models-table { + height: 8; + } + #keys-title { + margin: 1 0 0 0; + text-style: bold; + } + #keys-budget { + margin: 1 0; + padding: 1 2; + border: round $primary; + } + #keys-table { + height: 60%; + margin: 1 0 0 0; + } + #history-bars { + height: 9; + margin: 1 0; + border: round $secondary; + padding: 0 1; + } + #history-table { + height: 40%; + margin: 1 0 0 0; + } + #period-buttons { + height: 3; + margin: 1 0 0 0; + } + #period-buttons Button { + width: 10; + margin: 0 1 0 0; + } + #bars-title { + margin: 1 0 0 0; + text-style: bold; + } + #settings-body { + margin: 1 2; + } + #interval-label { + margin: 1 0 0 0; + } + #interval-input { + width: 20; + } + #settings-actions { + margin: 1 0; + } + #settings-actions Button { + width: 16; + margin: 0 1 0 0; + } + #settings-info { + margin: 1 0 0 0; + } + #model-detail { + margin: 2 4; + padding: 1 2; + border: round $primary; + } + """ + + def __init__( + self, + settings: Any | None = None, + credentials: Any | None = None, + storage: Storage | None = None, + service: DataService | None = None, + ) -> None: + super().__init__() + self._settings = settings if settings is not None else load_settings() + self._credentials = ( + credentials if credentials is not None else load_credentials() + ) + self._storage = storage if storage is not None else Storage() + self._service = ( + service + if service is not None + else DataService(self._settings, self._credentials, self._storage) + ) + + def on_mount(self) -> None: + self.push_screen( + DashboardScreen( + self._service, + self._settings.refresh_interval, + self._storage, + self._settings, + self._credentials, + ) + ) + + +def run_app() -> None: + """Запустить TUI-приложение (entry point).""" + app = SelectelMLTUI() + app.run() From bbd13f3bd8e275620512de726ad4be9649530d08 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:45:02 +0300 Subject: [PATCH 31/79] Add tests/__init__.py --- tests/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 From 686acb4423fe17f6b050775006985977bf0aa249 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:45:25 +0300 Subject: [PATCH 32/79] Add tests/test_api.py --- tests/test_api.py | 296 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 tests/test_api.py diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..59672c4 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,296 @@ +"""Unit-тесты API-клиентов с моками httpx.MockTransport.""" + +from __future__ import annotations + +from decimal import Decimal +from typing import Any + +import httpx +import pytest + +from selectel_ml_tui.api.billing import ( + BillingApiError, + BillingClient, + normalize_dt, +) +from selectel_ml_tui.api.client import SelectelApiError +from selectel_ml_tui.api.gateway import GatewayApiError, GatewayClient + + +class NoopSleep: + """Пауза-заглушка для ретраев (не ждём в тестах).""" + + def __init__(self) -> None: + self.calls: list[float] = [] + + async def __call__(self, seconds: float) -> None: + self.calls.append(seconds) + + +def _transport(handler: Any) -> httpx.MockTransport: + return httpx.MockTransport(handler) + + +def _billing_client( + handler: Any, max_retries: int = 2, sleep: NoopSleep | None = None +) -> BillingClient: + return BillingClient( + api_base_url="https://api.selectel.ru", + static_token="static-token", + max_retries=max_retries, + transport=_transport(handler), + sleep=sleep or NoopSleep(), + ) + + +def _ok_consumption() -> dict[str, Any]: + return { + "status": "success", + "data": [ + { + "account_id": "239633", + "period": "2026-08-01", + "provider_key": "aig", + "metric": { + "id": "air_tokens_cost", + "name": "Стоимость токенов", + "unit": "nanorub", + "quantity": "85483000000", + }, + "value": 854.83, + "project": "7bf2d2a328494e25b13d05a1926ca75a", + } + ], + } + + +async def test_billing_normalize_dt() -> None: + assert normalize_dt("2026-08-01") == "2026-08-01T00:00:00.000" + assert normalize_dt("2026-08-01T10:30:00") == "2026-08-01T10:30:00" + assert normalize_dt("2026-08-01T00:00:00.000") == "2026-08-01T00:00:00.000" + + +async def test_billing_gets_normalized_dates() -> None: + seen: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen.update(dict(request.url.params)) + return httpx.Response(200, json=_ok_consumption()) + + client = _billing_client(handler) + await client.get_consumption("2026-08-01", "2026-08-19") + assert seen["start"] == "2026-08-01T00:00:00.000" + assert seen["end"] == "2026-08-19T00:00:00.000" + await client.aclose() + + +async def test_billing_get_consumption() -> None: + client = _billing_client( + lambda request: httpx.Response(200, json=_ok_consumption()) + ) + items = await client.get_consumption("2026-08-01", "2026-08-19") + assert len(items) == 1 + assert items[0].value == Decimal("854.83") + assert items[0].metric is not None + assert items[0].metric.id == "air_tokens_cost" + assert items[0].project_id == "7bf2d2a328494e25b13d05a1926ca75a" + await client.aclose() + + +async def test_billing_get_summary_sums_values() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert "provider_keys=aig" in request.url.query.decode() + return httpx.Response( + 200, + json={ + "status": "success", + "data": [ + {"value": "100.25"}, + {"value": 42.5}, + {"value": 0}, + ], + }, + ) + + client = _billing_client(handler) + total = await client.get_summary("2026-08-01", "2026-08-19") + assert total == Decimal("142.75") + await client.aclose() + + +async def test_billing_5xx_retries_then_success() -> None: + sleep = NoopSleep() + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + if attempts["count"] == 1: + return httpx.Response(500, text="internal error") + return httpx.Response(200, json=_ok_consumption()) + + client = _billing_client(handler, sleep=sleep) + items = await client.get_consumption("2026-08-01", "2026-08-19") + assert attempts["count"] == 2 + assert len(items) == 1 + assert sleep.calls == [0.5] + await client.aclose() + + +async def test_billing_429_retries_then_raises() -> None: + sleep = NoopSleep() + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + return httpx.Response(429, text="rate limit") + + client = _billing_client(handler, max_retries=2, sleep=sleep) + with pytest.raises(SelectelApiError) as exc_info: + await client.get_consumption("2026-08-01", "2026-08-19") + assert exc_info.value.status_code == 429 + assert attempts["count"] == 3 + assert sleep.calls == [0.5, 1.0] + await client.aclose() + + +async def test_billing_4xx_no_retry() -> None: + sleep = NoopSleep() + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + return httpx.Response(400, text="bad request") + + client = _billing_client(handler, sleep=sleep) + with pytest.raises(BillingApiError) as exc_info: + await client.get_consumption("2026-08-01", "2026-08-19") + assert exc_info.value.status_code == 400 + assert "HTTP 400" in str(exc_info.value) + assert attempts["count"] == 1 + assert sleep.calls == [] + await client.aclose() + + +async def test_billing_network_error_retries() -> None: + sleep = NoopSleep() + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + if attempts["count"] == 1: + raise httpx.ConnectError("connection refused") + return httpx.Response(200, json=_ok_consumption()) + + client = _billing_client(handler, sleep=sleep) + items = await client.get_consumption("2026-08-01", "2026-08-19") + assert attempts["count"] == 2 + assert len(items) == 1 + await client.aclose() + + +async def test_billing_timeout_retries_then_raises() -> None: + sleep = NoopSleep() + attempts = {"count": 0} + + def handler(request: httpx.Request) -> httpx.Response: + attempts["count"] += 1 + raise httpx.ReadTimeout("timed out") + + client = _billing_client(handler, max_retries=2, sleep=sleep) + with pytest.raises(SelectelApiError) as exc_info: + await client.get_consumption("2026-08-01", "2026-08-19") + assert exc_info.value.status_code is None + assert "Ошибка сети" in str(exc_info.value) + assert attempts["count"] == 3 + assert sleep.calls == [0.5, 1.0] + await client.aclose() + + +async def test_billing_non_success_status_raises() -> None: + client = _billing_client( + lambda request: httpx.Response(200, json={"status": "error", "data": []}) + ) + with pytest.raises(BillingApiError): + await client.get_consumption("2026-08-01", "2026-08-19") + await client.aclose() + + +class FakeTokenProvider: + """Токен-провайдер для GatewayClient в тестах.""" + + def __init__(self, tokens: list[str] | None = None) -> None: + self._tokens = tokens or ["token-1"] + self._index = 0 + self.calls = 0 + + async def get_token(self) -> str: + self.calls += 1 + token = self._tokens[min(self._index, len(self._tokens) - 1)] + self._index += 1 + return token + + def invalidate(self) -> None: + pass + + +async def test_gateway_list_gateways() -> None: + provider = FakeTokenProvider() + + def handler(request: httpx.Request) -> httpx.Response: + assert request.headers["X-Auth-Token"] == "token-1" + assert request.headers["X-Project-Id"] == "proj-1" + assert request.headers["X-Roles"] == "reader" + return httpx.Response(200, json={"data": [{"id": "gw-1", "name": "Router"}]}) + + client = GatewayClient( + api_base_url="https://api.selectel.ru", + token_provider=provider, + project_id="proj-1", + domain_id="239633", + transport=_transport(handler), + ) + gateways = await client.list_gateways() + assert gateways == [{"id": "gw-1", "name": "Router"}] + assert provider.calls == 1 + await client.aclose() + + +async def test_gateway_401_refreshes_token() -> None: + provider = FakeTokenProvider(tokens=["token-old", "token-new"]) + + def handler(request: httpx.Request) -> httpx.Response: + if request.headers["X-Auth-Token"] == "token-old": + return httpx.Response(401, text="unauthorized") + return httpx.Response(200, json={"data": [{"id": "gw-2"}]}) + + client = GatewayClient( + api_base_url="https://api.selectel.ru", + token_provider=provider, + project_id="proj-1", + domain_id="239633", + transport=_transport(handler), + ) + gateways = await client.list_gateways() + assert gateways == [{"id": "gw-2"}] + assert provider.calls == 2 + await client.aclose() + + +async def test_gateway_401_without_refresh_raises() -> None: + provider = FakeTokenProvider(tokens=["token-only"]) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, text="unauthorized") + + client = GatewayClient( + api_base_url="https://api.selectel.ru", + token_provider=provider, + project_id="proj-1", + domain_id="239633", + max_retries=0, + transport=_transport(handler), + ) + with pytest.raises(GatewayApiError) as exc_info: + await client.list_gateways() + assert exc_info.value.status_code == 401 + await client.aclose() From 2dbdc571b96714d21240a22e51d76c9f30d1066e Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:45:34 +0300 Subject: [PATCH 33/79] Add tests/test_auth.py --- tests/test_auth.py | 119 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 tests/test_auth.py diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..13df81f --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,119 @@ +"""Unit-тесты IAM-авторизации и кэша токена.""" + +from __future__ import annotations + +import datetime as dt + +import httpx +import pytest + +from selectel_ml_tui.auth import ( + IamAuthError, + IamClient, + IamToken, + IamTokenManager, +) +from selectel_ml_tui.config import Credentials, Settings + + +class FakeIamClient: + """Заглушка IamClient для проверки логики кэширования.""" + + def __init__(self, token: IamToken) -> None: + self._token = token + self.calls = 0 + + async def get_token(self) -> IamToken: + self.calls += 1 + return self._token + + +def _far_future() -> dt.datetime: + return dt.datetime.now(dt.timezone.utc) + dt.timedelta(hours=23) + + +def _expired() -> dt.datetime: + return dt.datetime.now(dt.timezone.utc) - dt.timedelta(minutes=1) + + +async def test_iam_get_token_success() -> None: + def handler(request: httpx.Request) -> httpx.Response: + body = request.content.decode() + assert '"name":"svc-user"' in body + assert '"password":"secret"' in body + assert '"id":"proj-1"' in body + return httpx.Response( + 201, + headers={"X-Subject-Token": "iam-token-abc"}, + json={ + "token": { + "expires_at": "2026-08-20T10:00:00.000000Z", + } + }, + ) + + client = IamClient( + Settings(), + Credentials( + service_user="svc-user", + password="secret", + domain_id="239633", + project_id="proj-1", + ), + transport=httpx.MockTransport(handler), + ) + token = await client.get_token() + assert token.value == "iam-token-abc" + assert token.expires_at == dt.datetime(2026, 8, 20, 10, 0, tzinfo=dt.timezone.utc) + + +async def test_iam_get_token_missing_credentials() -> None: + client = IamClient(Settings(), Credentials()) + with pytest.raises(IamAuthError, match="логин/пароль"): + await client.get_token() + + +async def test_iam_get_token_missing_subject_token() -> None: + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 201, json={"token": {"expires_at": "2026-08-20T10:00:00Z"}} + ) + + client = IamClient( + Settings(), + Credentials( + service_user="svc", password="pwd", domain_id="239633", project_id="p" + ), + transport=httpx.MockTransport(handler), + ) + with pytest.raises(IamAuthError, match="X-Subject-Token"): + await client.get_token() + + +async def test_iam_token_manager_caches() -> None: + fake = FakeIamClient(IamToken(value="t1", expires_at=_far_future())) + manager = IamTokenManager(fake) # type: ignore[arg-type] + assert await manager.get_token() == "t1" + assert await manager.get_token() == "t1" + assert fake.calls == 1 + assert manager.is_valid() + + +async def test_iam_token_manager_refreshes_after_expiry() -> None: + fake = FakeIamClient(IamToken(value="t-expired", expires_at=_expired())) + manager = IamTokenManager(fake) # type: ignore[arg-type] + assert await manager.get_token() == "t-expired" + assert not manager.is_valid() + assert await manager.get_token() == "t-expired" + assert fake.calls == 2 + + +async def test_iam_token_manager_invalidate() -> None: + fake = FakeIamClient(IamToken(value="t1", expires_at=_far_future())) + manager = IamTokenManager(fake) # type: ignore[arg-type] + await manager.get_token() + assert manager.is_valid() + manager.invalidate() + assert not manager.is_valid() + await manager.get_token() + assert fake.calls == 2 From 7b95a48bc326943c5b105b2ce7bf635b5fc21e57 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:45:47 +0300 Subject: [PATCH 34/79] Add tests/test_cli.py --- tests/test_cli.py | 120 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 tests/test_cli.py diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..37bc2f9 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,120 @@ +"""Базовые тесты CLI.""" + +from __future__ import annotations + +import subprocess +import sys + +from click.testing import CliRunner + +from selectel_ml_tui import __version__ +from selectel_ml_tui.cli import main +from selectel_ml_tui.config import load_credentials, load_settings + + +def test_version() -> None: + runner = CliRunner() + result = runner.invoke(main, ["--version"]) + assert result.exit_code == 0 + assert __version__ in result.output + + +def test_main_module_help() -> None: + result = subprocess.run( + [sys.executable, "-m", "selectel_ml_tui", "--help"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0 + assert "doctor" in result.stdout + assert "run" in result.stdout + + +def test_config_paths(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path) + monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path) + runner = CliRunner() + result = runner.invoke(main, ["config", "--path"]) + assert result.exit_code == 0 + assert str(tmp_path) in result.output + + +def test_config_show_masks_secrets(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path) + monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path) + runner = CliRunner() + result = runner.invoke(main, ["config", "--show"]) + assert result.exit_code == 0 + assert "Static token" in result.output + assert "секрет" not in result.output.lower() + + +def test_config_wizard_saves(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path) + monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path) + input_data = "\n".join( + [ + "https://api.example.test", # API base URL + "30", # интервал обновления + "gw-test", # ID роутера + "ru-3", # регион + "svc-user", # сервисный пользователь + "239633", # домен (аккаунт) + "7bf2d2a328494e25b13d05a1926ca75a", # project id + "secret-pass", # пароль + "secret-token", # статический токен + ] + ) + runner = CliRunner() + result = runner.invoke(main, ["config"], input=input_data) + assert result.exit_code == 0, result.output + assert "Сохранено" in result.output + + settings = load_settings() + assert settings.router_id == "gw-test" + assert settings.refresh_interval == 30 + assert settings.api_base_url == "https://api.example.test" + assert settings.region == "ru-3" + + creds = load_credentials() + assert creds.service_user == "svc-user" + assert creds.domain_id == "239633" + assert creds.project_id == "7bf2d2a328494e25b13d05a1926ca75a" + assert creds.password == "secret-pass" + assert creds.static_token == "secret-token" + + +def test_run_requires_credentials(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path) + monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path) + runner = CliRunner() + result = runner.invoke(main, ["run"]) + assert result.exit_code == 1 + assert "не настроены" in result.output + + +def test_config_wizard_rejects_small_interval(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("selectel_ml_tui.config.config_dir", lambda: tmp_path) + monkeypatch.setattr("selectel_ml_tui.cli.config_dir", lambda: tmp_path) + input_data = "\n".join( + [ + "https://api.example.test", # API base URL + "5", # интервал < 10 — будет отклонён + "30", # повторный ввод, валидный + "gw-test", # ID роутера + "", # регион (оставить) + "", # сервисный пользователь (оставить) + "", # домен (оставить) + "", # project id (оставить) + "", # пароль (оставить) + "", # статический токен (оставить) + "", # хвостовая строка (CliRunner отбрасывает последний пустой элемент) + ] + ) + runner = CliRunner() + result = runner.invoke(main, ["config"], input=input_data) + assert result.exit_code == 0, result.output + assert "не может быть меньше 10" in result.output + settings = load_settings() + assert settings.refresh_interval == 30 From 53e6b6987e489246a61da8f2a00f1c1c36ba1275 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:45:56 +0300 Subject: [PATCH 35/79] Add tests/test_config.py --- tests/test_config.py | 89 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 tests/test_config.py diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..0143fda --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,89 @@ +"""Тесты модуля конфигурации.""" + +from __future__ import annotations + +from selectel_ml_tui.config import ( + Credentials, + Settings, + load_credentials, + load_settings, + save_credentials, + save_settings, +) + + +def test_settings_defaults() -> None: + settings = load_settings() + assert settings.api_base_url == "https://api.selectel.ru" + assert settings.refresh_interval >= 10 + + +def test_save_settings_roundtrip(tmp_path, monkeypatch) -> None: + cfg_file = tmp_path / "config.toml" + monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file) + settings = Settings(router_id="gw-test", refresh_interval=30) + save_settings(settings) + loaded = load_settings() + assert loaded.router_id == "gw-test" + assert loaded.refresh_interval == 30 + + +def test_load_settings_repairs_small_interval(tmp_path, monkeypatch) -> None: + cfg_file = tmp_path / "config.toml" + cfg_file.write_text("refresh_interval = 5\n", encoding="utf-8") + monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file) + settings = load_settings() + assert settings.refresh_interval == 10 + + +def test_load_settings_repairs_garbage_interval(tmp_path, monkeypatch) -> None: + cfg_file = tmp_path / "config.toml" + cfg_file.write_text('refresh_interval = "abc"\n', encoding="utf-8") + monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file) + settings = load_settings() + assert settings.refresh_interval == 60 + + +def test_load_settings_ignores_bad_env_interval(tmp_path, monkeypatch) -> None: + cfg_file = tmp_path / "config.toml" + monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file) + monkeypatch.setenv("SELCTEL_REFRESH_INTERVAL", "5") + settings = load_settings() + assert settings.refresh_interval == 60 + + +def test_credentials_defaults() -> None: + creds = Credentials() + assert creds.service_user == "" + assert creds.static_token == "" + + +def test_settings_env_api_url(monkeypatch, tmp_path) -> None: + cfg_file = tmp_path / "config.toml" + monkeypatch.setattr("selectel_ml_tui.config._config_file", lambda: cfg_file) + monkeypatch.setenv("SELCTEL_API_URL", "https://api.example.test") + settings = load_settings() + assert settings.api_base_url == "https://api.example.test" + + +def test_credentials_env(monkeypatch, tmp_path) -> None: + cred_file = tmp_path / "credentials.json" + monkeypatch.setattr("selectel_ml_tui.config._credentials_file", lambda: cred_file) + monkeypatch.setenv("SELCTEL_TOKEN", "tok123") + monkeypatch.setenv("SELCTEL_SERVICE_USER", "svc-user") + monkeypatch.setenv("SELCTEL_DOMAIN_ID", "239633") + creds = load_credentials() + assert creds.static_token == "tok123" + assert creds.service_user == "svc-user" + assert creds.domain_id == "239633" + + +def test_credentials_file_precedence(monkeypatch, tmp_path) -> None: + cred_file = tmp_path / "credentials.json" + monkeypatch.setattr("selectel_ml_tui.config._credentials_file", lambda: cred_file) + save_credentials(Credentials(static_token="from-file", service_user="file-user")) + monkeypatch.setenv("SELCTEL_TOKEN", "from-env") + creds = load_credentials() + # env имеет приоритет над файлом + assert creds.static_token == "from-env" + assert creds.service_user == "file-user" From 37d8a3702fa8e8b4daab84ee0426ec60766d5978 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:46:14 +0300 Subject: [PATCH 36/79] Add tests/test_models.py --- tests/test_models.py | 215 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 tests/test_models.py diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..ca07ce3 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,215 @@ +"""Unit-тесты pydantic-моделей данных.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal + +import pytest +from pydantic import ValidationError + +from selectel_ml_tui.models import ( + Consumption, + ConsumptionItem, + HistoryPoint, + Metrics, + ModelMetrics, + ProjectRef, + format_percent, + format_rub, + format_tokens, +) + + +class TestConsumption: + def test_parse_with_z_timestamp(self) -> None: + item = Consumption.model_validate( + { + "period": "2026-08-01T00:00:00.000Z", + "amount_rub": "854.83", + "input_tokens": 100, + "output_tokens": 50, + "cache_read_tokens": 10, + "cache_write_tokens": 5, + } + ) + assert item.period == datetime(2026, 8, 1, tzinfo=timezone.utc) + assert item.amount_rub == Decimal("854.83") + assert item.input_tokens == 100 + + def test_parse_naive_timestamp(self) -> None: + item = Consumption.model_validate({"period": "2026-08-01T00:00:00"}) + assert item.period == datetime(2026, 8, 1) + assert item.amount_rub == Decimal("0") + assert item.input_tokens == 0 + + def test_negative_amount_rejected(self) -> None: + with pytest.raises(ValidationError): + Consumption.model_validate( + {"period": "2026-08-01T00:00:00", "amount_rub": "-1"} + ) + + def test_negative_tokens_rejected(self) -> None: + with pytest.raises(ValidationError): + Consumption.model_validate( + { + "period": "2026-08-01T00:00:00", + "input_tokens": -5, + } + ) + + def test_invalid_date_rejected(self) -> None: + with pytest.raises(ValidationError): + Consumption.model_validate({"period": "not-a-date"}) + + +class TestMetrics: + def test_parse_metrics(self) -> None: + metrics = Metrics.model_validate( + { + "availability_pct": 99.912, + "avg_ttft_ms": 0.45, + "input_tokens": 1000, + "output_tokens": 900, + "cache_read_tokens": 800, + "cache_write_tokens": 700, + } + ) + assert metrics.availability_pct == 99.912 + assert metrics.avg_ttft_ms == 0.45 + assert metrics.input_tokens == 1000 + + def test_missing_fields_defaults(self) -> None: + metrics = Metrics.model_validate({}) + assert metrics.availability_pct == 100.0 + assert metrics.input_tokens == 0 + assert metrics.avg_ttft_ms == 0.0 + + def test_availability_bounds_rejected(self) -> None: + with pytest.raises(ValidationError): + Metrics.model_validate({"availability_pct": 101}) + with pytest.raises(ValidationError): + Metrics.model_validate({"availability_pct": -1}) + + +class TestModelMetrics: + def test_parse_model_metrics(self) -> None: + model = ModelMetrics.model_validate( + { + "model_id": "gpt-4o", + "model_name": "GPT-4o", + "availability_pct": 99.5, + "avg_ttft_ms": 0.3, + } + ) + assert model.model_id == "gpt-4o" + assert model.model_name == "GPT-4o" + assert model.availability_pct == 99.5 + + +class TestHistoryPoint: + def test_parse(self) -> None: + point = HistoryPoint.model_validate( + { + "timestamp": "2026-08-01T12:00:00Z", + "amount_rub": "12.5", + "tokens": { + "input_tokens": 100, + "output_tokens": 200, + "cache_read_tokens": 30, + "cache_write_tokens": 40, + }, + } + ) + assert point.timestamp == datetime(2026, 8, 1, 12, 0, tzinfo=timezone.utc) + assert point.amount_rub == Decimal("12.5") + assert point.tokens.output_tokens == 200 + + def test_missing_tokens_defaults(self) -> None: + point = HistoryPoint.model_validate({"timestamp": "2026-08-01T00:00:00"}) + assert point.tokens.input_tokens == 0 + assert point.amount_rub == Decimal("0") + + +class TestConsumptionItem: + def _base(self, **overrides: object) -> dict[str, object]: + data: dict[str, object] = { + "account_id": "239633", + "period": "2026-08-01T00:00:00", + "provider_key": "aig", + "value": 854.83, + } + data.update(overrides) + return data + + def test_project_as_string(self) -> None: + item = ConsumptionItem.model_validate( + self._base(project="7bf2d2a328494e25b13d05a1926ca75a") + ) + assert item.project_id == "7bf2d2a328494e25b13d05a1926ca75a" + + def test_project_as_object(self) -> None: + item = ConsumptionItem.model_validate( + self._base( + project={"id": "proj-1", "name": "239633"}, + ) + ) + assert isinstance(item.project, ProjectRef) + assert item.project_id == "proj-1" + assert item.project.name == "239633" + + def test_project_null(self) -> None: + item = ConsumptionItem.model_validate(self._base(project=None)) + assert item.project_id is None + + def test_object_as_object(self) -> None: + item = ConsumptionItem.model_validate( + self._base( + object={ + "id": "obj-1", + "name": "key-1", + "type": "ai_api_key", + "type_name": "Api ключ", + } + ) + ) + assert item.object_id == "obj-1" + assert item.object.type == "ai_api_key" # type: ignore[union-attr] + + def test_object_as_string(self) -> None: + item = ConsumptionItem.model_validate(self._base(object="obj-1")) + assert item.object_id == "obj-1" + + def test_metric_info(self) -> None: + item = ConsumptionItem.model_validate( + self._base( + metric={ + "id": "air_tokens_cost", + "name": "Стоимость", + "unit": "nanorub", + "quantity": "854857248637", + } + ) + ) + assert item.metric is not None + assert item.metric.id == "air_tokens_cost" + assert item.metric.quantity == Decimal("854857248637") + + def test_value_decimal(self) -> None: + item = ConsumptionItem.model_validate(self._base(value=42.5)) + assert item.value == Decimal("42.5") + + +class TestFormatting: + def test_format_rub(self) -> None: + assert format_rub(Decimal("854.83")) == "854,83 ₽" + assert format_rub(0) == "0,00 ₽" + assert format_rub("1234.5") == "1 234,50 ₽" + + def test_format_tokens(self) -> None: + assert format_tokens(0) == "0" + assert format_tokens(1234567) == "1 234 567" + + def test_format_percent(self) -> None: + assert format_percent(99.912) == "99,9%" + assert format_percent(100.0) == "100,0%" From 0b658b8cccdd7b251884932fdd3cf6336162883d Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:46:19 +0300 Subject: [PATCH 37/79] Add tests/test_platform.py --- tests/test_platform.py | 43 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 tests/test_platform.py diff --git a/tests/test_platform.py b/tests/test_platform.py new file mode 100644 index 0000000..583bed5 --- /dev/null +++ b/tests/test_platform.py @@ -0,0 +1,43 @@ +"""Тесты платформенно-зависимой диагностики (platform.py).""" + +from __future__ import annotations + +from selectel_ml_tui import platform as pl + + +def test_platform_info_fields(monkeypatch) -> None: + monkeypatch.setenv("TERM", "xterm-256color") + monkeypatch.setenv("LANG", "ru_RU.UTF-8") + info = pl.get_platform_info() + assert info.system in ("Linux", "Darwin", "Windows") + assert info.term == "xterm-256color" + assert info.lang == "ru_RU.UTF-8" + assert info.utf8 is True + assert info.python_version + + +def test_check_environment_ok(monkeypatch) -> None: + monkeypatch.setenv("TERM", "tmux-256color") + monkeypatch.setenv("LANG", "en_US.UTF-8") + results = {c.name: c for c in pl.check_environment()} + assert all(r.ok for r in results.values()) + assert results["python"].ok + + +def test_check_environment_warns_without_term_and_lang(monkeypatch) -> None: + monkeypatch.delenv("TERM", raising=False) + monkeypatch.delenv("LANG", raising=False) + results = {c.name: c for c in pl.check_environment()} + assert not results["TERM"].ok + assert not results["LANG"].ok + + +def test_check_environment_warns_wrong_term(monkeypatch) -> None: + monkeypatch.setenv("TERM", "dumb") + monkeypatch.setenv("LANG", "C") + results = {c.name: c for c in pl.check_environment()} + assert not results["TERM"].ok + + +def test_min_python_version() -> None: + assert pl.MIN_PYTHON == (3, 10) From ff7420e15398250ce6669301b95ff018efe51419 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:46:45 +0300 Subject: [PATCH 38/79] Add tests/test_services.py --- tests/test_services.py | 324 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 tests/test_services.py diff --git a/tests/test_services.py b/tests/test_services.py new file mode 100644 index 0000000..f293447 --- /dev/null +++ b/tests/test_services.py @@ -0,0 +1,324 @@ +"""Unit-тесты сервисного слоя.""" + +from __future__ import annotations + +import datetime as dt +from decimal import Decimal +from typing import Any + +import httpx + +from selectel_ml_tui.config import Credentials, Settings +from selectel_ml_tui.models import Consumption, Metrics +from selectel_ml_tui.services import ( + DashboardData, + DataService, + parse_keys, + parse_metrics, + parse_models, + parse_overview, + parse_tokens, +) +from selectel_ml_tui.storage import Storage + + +def _summary_response(value: str = "85483") -> dict[str, Any]: + return { + "status": "success", + "data": [{"value": value}], + } + + +async def test_parse_tokens() -> None: + tokens = parse_tokens( + {"input_tokens": 10, "output_tokens": 20, "cache_read": 30, "cache_write": 40} + ) + assert tokens.input_tokens == 10 + assert tokens.output_tokens == 20 + assert tokens.cache_read_tokens == 30 + assert tokens.cache_write_tokens == 40 + + +async def test_parse_tokens_missing_fields_defaults() -> None: + tokens = parse_tokens({}) + assert tokens.input_tokens == 0 + assert tokens.output_tokens == 0 + assert tokens.cache_read_tokens == 0 + assert tokens.cache_write_tokens == 0 + + +async def test_parse_metrics() -> None: + metrics = parse_metrics( + { + "global": { + "requests": 1035, + "success_rate_pct": 100, + "avg_ttft_seconds": 3.67, + "avg_latency_seconds": 11.85, + "tokens": { + "input": 228542299, + "output": 747268, + "cached_input": 222355840, + "cache_write": 0, + }, + } + } + ) + assert metrics is not None + assert metrics.availability_pct == 100 + assert metrics.avg_ttft_ms == 3670 + assert metrics.avg_latency_ms == 11850 + assert metrics.requests == 1035 + assert metrics.input_tokens == 228542299 + assert metrics.output_tokens == 747268 + assert metrics.cache_read_tokens == 222355840 + assert metrics.cache_write_tokens == 0 + + +async def test_parse_metrics_empty() -> None: + assert parse_metrics({}) is None + + +async def test_parse_models() -> None: + models = parse_models( + { + "by_model": [ + { + "model": "deepseek/deepseek-v4-flash", + "requests": 69, + "success_rate_pct": 100, + "avg_ttft_seconds": 3.35, + }, + { + "model": "deepseek/deepseek-v4-pro", + "requests": 966, + "success_rate_pct": 100, + "avg_ttft_seconds": 3.69, + }, + ] + } + ) + assert len(models) == 2 + assert models[0].model_name == "deepseek/deepseek-v4-flash" + assert models[0].requests == 69 + assert models[0].availability_pct == 100 + assert models[0].avg_ttft_ms == 3350 + assert models[1].model_id == "deepseek/deepseek-v4-pro" + + +async def test_parse_overview() -> None: + import datetime as _dt + + consumption = parse_overview( + { + "totals": { + "requests": 1034, + "cost_rub_nano": "1075311673934", + "tokens": { + "input": 228314832, + "output": 746702, + "cached_input": 222128640, + "cache_write": 0, + }, + "tokens_total": {"total": 451607518}, + } + }, + _dt.datetime(2026, 8, 19, tzinfo=_dt.timezone.utc), + ) + assert consumption.amount_rub == Decimal("1075.311673934") + assert consumption.requests == 1034 + assert consumption.tokens_total == 451607518 + assert consumption.input_tokens == 228314832 + assert consumption.cache_read_tokens == 222128640 + + +async def test_parse_keys() -> None: + keys = parse_keys( + [ + { + "name": "Mei", + "prefix": "sk-sl-v1-9dd61ec", + "is_active": True, + "budget": { + "spend_limit_rub_nano": "1300000000000", + "current_usage": {"spend_rub_nano": "1082949415164"}, + }, + }, + { + "name": "Ollama Mac Air", + "prefix": "sk-sl-v1-65fdb2e", + "is_active": False, + "budget": {"spend_limit_rub_nano": "100000000000"}, + }, + ] + ) + assert len(keys) == 2 + assert keys[0].name == "Mei" + assert keys[0].is_active is True + assert keys[0].spend_limit_rub == Decimal("1300") + assert keys[0].spend_rub == Decimal("1082.949415164") + assert keys[0].remaining_rub == Decimal("217.050584836") + assert keys[1].is_active is False + assert keys[1].spend_limit_rub == Decimal("100") + + +async def test_refresh_billing_only(tmp_path) -> None: + """Только billing (без IAM): стоимость за месяц есть, gateway отмечен.""" + settings = Settings() + creds = Credentials(static_token="static-token") + storage = Storage(tmp_path / "cache.db") + seen_end: dict[str, str] = {} + + def handler(request: httpx.Request) -> httpx.Response: + if "summary_stats" in request.url.path: + seen_end["summary"] = request.url.params.get("end", "") + return httpx.Response(200, json=_summary_response()) + if "consumption" in request.url.path: + seen_end["consumption"] = request.url.params.get("end", "") + return httpx.Response( + 200, + json={ + "status": "success", + "data": [{"period": "2026-08-19T00:00:00", "value": "7133"}], + }, + ) + return httpx.Response(200, json=_summary_response()) + + service = DataService( + settings, creds, storage, transport=httpx.MockTransport(handler) + ) + data = await service.refresh() + assert data.month_consumption is not None + assert data.month_consumption.amount_rub == Decimal("854.83") + # Пункт A: end должен содержать время (не усечённую дату) и быть naive + # (без смещения часового пояса — иначе API отвечает 422). + assert "T" in seen_end["summary"] + assert "T" in seen_end["consumption"] + assert "+" not in seen_end["summary"] + # Пункт B: дневная стоимость сегодня выделена отдельно. + assert data.today_amount_rub == Decimal("71.33") + assert not data.offline + assert data.error is not None + assert "сервисный пользователь" in data.error + assert storage.get_latest_consumption() is not None + history = storage.get_history(days=30) + assert len(history) == 1 + # История хранит дневную стоимость (не месячную сумму). + assert history[0].amount_rub == Decimal("71.33") + + +async def test_refresh_billing_failure_uses_cache(tmp_path) -> None: + """Сбой billing + кэш → офлайн-данные из Storage.""" + settings = Settings() + creds = Credentials(static_token="static-token") + storage = Storage(tmp_path / "cache.db") + storage.save_snapshot( + consumption=Consumption( + period=dt.datetime(2026, 8, 1, tzinfo=dt.timezone.utc), + amount_rub=Decimal("10.00"), + ) + ) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(500, text="boom") + + service = DataService( + settings, creds, storage, transport=httpx.MockTransport(handler) + ) + data = await service.refresh() + assert data.offline is True + assert data.source == "cache" + assert data.month_consumption is not None + assert data.month_consumption.amount_rub == Decimal("10.00") + assert data.error is not None + + +async def test_refresh_with_gateway(tmp_path) -> None: + """Billing + gateway (IAM): заполнены метрики, модели, ключи, бюджет.""" + settings = Settings() + creds = Credentials( + static_token="static-token", + service_user="svc", + password="pwd", + project_id="proj-1", + domain_id="239633", + ) + storage = Storage(tmp_path / "cache.db") + + def handler(request: httpx.Request) -> httpx.Response: + path = request.url.path + if path.endswith("/auth/tokens"): + return httpx.Response( + 201, + headers={"X-Subject-Token": "iam-token"}, + json={"token": {"expires_at": "2030-01-01T00:00:00Z"}}, + ) + if path.endswith("/gateways"): + return httpx.Response(200, json={"data": [{"id": "gw-1"}]}) + if path.endswith("/overview"): + return httpx.Response( + 200, + json={ + "totals": { + "requests": 1034, + "cost_rub_nano": "1075311673934", + "tokens": {"input": 100, "output": 200}, + "tokens_total": {"total": 300}, + }, + "budget": { + "spend_limit_rub_nano": "2000000000000", + "spend_rub_nano": "1077874156239", + }, + }, + ) + if path.endswith("/metrics"): + return httpx.Response( + 200, + json={ + "global": {"success_rate_pct": 99.5, "avg_ttft_seconds": 0.3}, + "by_model": [ + {"model": "deepseek/deepseek-v4-flash", "requests": 69} + ], + }, + ) + if path.endswith("/keys"): + return httpx.Response( + 200, + json={ + "data": [ + { + "name": "Mei", + "prefix": "sk-sl-v1-9dd61ec", + "is_active": True, + "budget": { + "spend_limit_rub_nano": "1300000000000", + "current_usage": {"spend_rub_nano": "1082949415164"}, + }, + } + ] + }, + ) + return httpx.Response(200, json=_summary_response()) + + service = DataService( + settings, creds, storage, transport=httpx.MockTransport(handler) + ) + data = await service.refresh() + assert data.month_consumption is not None + assert data.month_consumption.amount_rub == Decimal("1075.311673934") + assert data.metrics is not None + assert data.metrics.availability_pct == 99.5 + assert data.metrics.avg_ttft_ms == 300 + assert len(data.models) == 1 + assert data.models[0].model_name == "deepseek/deepseek-v4-flash" + assert len(data.keys) == 1 + assert data.keys[0].name == "Mei" + assert data.budget is not None + assert data.budget.spend_limit_rub == Decimal("2000") + + +async def test_dashboard_data_has_data() -> None: + empty = DashboardData() + assert not empty.has_data + full = DashboardData(metrics=Metrics()) + assert full.has_data From 58b640c088cedc0b269b2d1fcff2413a126f391c Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:47:03 +0300 Subject: [PATCH 39/79] Add tests/test_storage.py --- tests/test_storage.py | 238 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 tests/test_storage.py diff --git a/tests/test_storage.py b/tests/test_storage.py new file mode 100644 index 0000000..1b7ad21 --- /dev/null +++ b/tests/test_storage.py @@ -0,0 +1,238 @@ +"""Unit-тесты SQLite-кэша и истории.""" + +from __future__ import annotations + +import datetime as dt +import json +import sqlite3 +from decimal import Decimal + +import pytest + +from selectel_ml_tui.models import ( + Consumption, + HistoryPoint, + Metrics, + ModelMetrics, + TokenUsage, +) +from selectel_ml_tui.storage import Storage, default_db_path + + +def _consumption(period: str, amount: str) -> Consumption: + return Consumption.model_validate( + { + "period": period, + "amount_rub": amount, + "input_tokens": 100, + "output_tokens": 200, + "cache_read_tokens": 30, + "cache_write_tokens": 40, + } + ) + + +def _metrics() -> Metrics: + return Metrics.model_validate( + { + "availability_pct": 99.9, + "avg_ttft_ms": 0.4, + "input_tokens": 1000, + "output_tokens": 900, + "cache_read_tokens": 800, + "cache_write_tokens": 700, + } + ) + + +def _models() -> list[ModelMetrics]: + return [ + ModelMetrics.model_validate( + { + "model_id": "gpt-4o", + "model_name": "GPT-4o", + "availability_pct": 99.9, + "avg_ttft_ms": 0.3, + } + ), + ModelMetrics.model_validate({"model_id": "llama-3", "model_name": "Llama 3"}), + ] + + +def test_default_db_path(monkeypatch, tmp_path) -> None: + monkeypatch.setattr("selectel_ml_tui.storage.data_dir", lambda: tmp_path) + assert default_db_path() == tmp_path / "cache.db" + assert default_db_path().parent == tmp_path + + +def test_save_and_get_latest_consumption(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + item = _consumption("2026-08-01T00:00:00", "854.83") + storage.save_consumption(item) + latest = storage.get_latest_consumption() + assert latest is not None + assert latest.amount_rub == Decimal("854.83") + assert latest.period == dt.datetime(2026, 8, 1) + assert latest.input_tokens == 100 + storage.close() + + +def test_get_latest_empty(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + assert storage.get_latest_consumption() is None + assert storage.get_latest_metrics() is None + assert storage.get_latest_model_metrics() == [] + storage.close() + + +def test_save_and_get_latest_metrics(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_metrics(_metrics()) + latest = storage.get_latest_metrics() + assert latest is not None + assert latest.availability_pct == 99.9 + assert latest.input_tokens == 1000 + storage.close() + + +def test_save_and_get_latest_model_metrics(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_model_metrics(_models()) + latest = storage.get_latest_model_metrics() + assert len(latest) == 2 + assert {m.model_id for m in latest} == {"gpt-4o", "llama-3"} + gpt = next(m for m in latest if m.model_id == "gpt-4o") + assert gpt.availability_pct == 99.9 + storage.close() + + +def test_history_upsert_same_day(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + point = HistoryPoint( + timestamp=dt.datetime(2026, 8, 1, 12, 0), + amount_rub=Decimal("10.00"), + tokens=TokenUsage(input_tokens=5), + ) + storage.save_history(point) + point2 = HistoryPoint( + timestamp=dt.datetime(2026, 8, 1, 12, 0), + amount_rub=Decimal("15.00"), + tokens=TokenUsage(input_tokens=8), + ) + storage.save_history(point2) + history = storage.get_history(days=30) + assert len(history) == 1 # upsert, без дублей + assert history[0].amount_rub == Decimal("15.00") + storage.close() + + +def test_history_days_filter(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + now = dt.datetime.now(dt.timezone.utc) + storage.save_history( + HistoryPoint(timestamp=now, amount_rub=Decimal("1"), tokens=TokenUsage()) + ) + storage.save_history( + HistoryPoint( + timestamp=now - dt.timedelta(days=30), + amount_rub=Decimal("2"), + tokens=TokenUsage(), + ) + ) + assert len(storage.get_history(days=7)) == 1 + assert len(storage.get_history(days=60)) == 2 + storage.close() + + +def test_save_snapshot_combined(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_snapshot( + consumption=_consumption("2026-08-01T00:00:00", "854.83"), + metrics=_metrics(), + models=_models(), + history_point=HistoryPoint( + timestamp=dt.datetime(2026, 8, 1, 12, 0), + amount_rub=Decimal("854.83"), + tokens=TokenUsage(input_tokens=100), + ), + ) + assert storage.get_latest_consumption() is not None + assert storage.get_latest_metrics() is not None + assert len(storage.get_latest_model_metrics()) == 2 + assert len(storage.get_history(days=30)) == 1 + storage.close() + + +def test_cleanup_removes_old(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + old = dt.datetime(2020, 1, 1, 0, 0) + storage.save_history( + HistoryPoint(timestamp=old, amount_rub=Decimal("1"), tokens=TokenUsage()) + ) + storage.save_consumption(_consumption("2020-01-01T00:00:00", "1")) + deleted = storage.cleanup(max_age_days=30) + # history удаляется по timestamp; consumption_snapshots — по created_at (сейчас) + assert deleted == 1 + assert storage.get_history(days=3650) == [] + assert storage.get_latest_consumption() is not None + storage.close() + + +def test_export_json(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_history( + HistoryPoint( + timestamp=dt.datetime(2026, 8, 1, 12, 0), + amount_rub=Decimal("854.83"), + tokens=TokenUsage(input_tokens=100, output_tokens=200), + ) + ) + out = tmp_path / "history.json" + storage.export_json(out) + data = json.loads(out.read_text(encoding="utf-8")) + assert len(data) == 1 + assert data[0]["amount_rub"] == "854.83" + assert data[0]["input_tokens"] == 100 + storage.close() + + +def test_export_csv(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_history( + HistoryPoint( + timestamp=dt.datetime(2026, 8, 1, 12, 0), + amount_rub=Decimal("854.83"), + tokens=TokenUsage(input_tokens=100, output_tokens=200), + ) + ) + out = tmp_path / "history.csv" + storage.export_csv(out) + lines = out.read_text(encoding="utf-8").strip().splitlines() + assert lines[0].startswith("timestamp,amount_rub") + assert "854.83" in lines[1] + assert "100" in lines[1] + storage.close() + + +def test_close_idempotent(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.close() + with pytest.raises(sqlite3.ProgrammingError): + storage.get_history() + + +def test_clear_removes_all_rows(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_history( + HistoryPoint( + timestamp=dt.datetime(2026, 8, 18), + amount_rub=Decimal("1"), + tokens=TokenUsage(input_tokens=1), + ) + ) + storage.save_consumption(Consumption(period="2026-08-01T00:00:00")) + assert storage.get_history() != [] + deleted = storage.clear() + assert deleted >= 1 + assert storage.get_history() == [] + assert storage.get_latest_consumption() is None From e3936d75c3813e3cf99a572cc360a9adf2840054 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:47:27 +0300 Subject: [PATCH 40/79] Add tests/test_tui.py --- tests/test_tui.py | 296 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 tests/test_tui.py diff --git a/tests/test_tui.py b/tests/test_tui.py new file mode 100644 index 0000000..e10f13f --- /dev/null +++ b/tests/test_tui.py @@ -0,0 +1,296 @@ +"""Headless-тесты TUI (Textual pilot).""" + +from __future__ import annotations + +import datetime as dt +from decimal import Decimal + +from textual.widgets import DataTable, Input, Static + +from selectel_ml_tui.config import Credentials, Settings +from selectel_ml_tui.models import ( + ApiKeyUsage, + BudgetInfo, + Consumption, + HistoryPoint, + Metrics, + ModelMetrics, + TokenUsage, +) +from selectel_ml_tui.services import DashboardData +from selectel_ml_tui.storage import Storage +from selectel_ml_tui.ui.app import SelectelMLTUI + + +class FakeService: + """Сервис-заглушка: возвращает готовые данные.""" + + def __init__(self, data: DashboardData) -> None: + self.data = data + self.refresh_count = 0 + + async def refresh(self) -> DashboardData: + self.refresh_count += 1 + return self.data + + +def _dashboard_data() -> DashboardData: + return DashboardData( + month_consumption=Consumption.model_validate( + { + "period": "2026-08-01T00:00:00", + "amount_rub": "854.83", + "requests": 1034, + "tokens_total": 451607518, + "input_tokens": 100, + "output_tokens": 200, + "cache_read_tokens": 30, + "cache_write_tokens": 40, + } + ), + metrics=Metrics.model_validate( + { + "availability_pct": 99.9, + "avg_ttft_ms": 0.4, + "avg_latency_ms": 1.2, + "requests": 1035, + "input_tokens": 5, + } + ), + models=[ + ModelMetrics.model_validate( + { + "model_id": "gpt-4o", + "model_name": "GPT-4o", + "requests": 69, + "availability_pct": 100.0, + "avg_ttft_ms": 3.35, + } + ) + ], + keys=[ + ApiKeyUsage( + name="Mei", + prefix="sk-sl-v1-9dd61ec", + is_active=True, + spend_limit_rub=Decimal("1300"), + spend_rub=Decimal("1082.95"), + remaining_rub=Decimal("217.05"), + ) + ], + budget=BudgetInfo( + spend_limit_rub=Decimal("2000"), + spend_rub=Decimal("1077.87"), + remaining_rub=Decimal("922.13"), + ), + ) + + +def _make_app(tmp_path, data: DashboardData | None = None) -> SelectelMLTUI: + settings = Settings(refresh_interval=3600) + creds = Credentials(static_token="tok") + storage = Storage(tmp_path / "cache.db") + service = FakeService(data or _dashboard_data()) + return SelectelMLTUI( + settings=settings, + credentials=creds, + storage=storage, + service=service, + ) + + +async def test_dashboard_renders_data(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + month = app.screen.query_one("#month-panel", Static) + rendered = str(month.render()) + assert "854,83" in rendered + assert "Потребление за месяц" in rendered + metrics = app.screen.query_one("#metrics-panel", Static) + assert "99,9%" in str(metrics.render()) + table = app.screen.query_one("#models-table", DataTable) + assert table.row_count == 1 + assert "GPT-4o" in table.get_row_at(0) + + +async def test_refresh_hotkey_reruns_service(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + service = app._service + assert service.refresh_count == 1 + await pilot.press("r") + await pilot.pause() + assert service.refresh_count == 2 + + +async def test_navigate_to_history_and_settings(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + # history + await pilot.press("h") + await pilot.pause() + assert app.screen.__class__.__name__ == "HistoryScreen" + # назад + await pilot.press("escape") + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" + # settings + await pilot.press("s") + await pilot.pause() + assert app.screen.__class__.__name__ == "SettingsScreen" + info = app.screen.query_one("#settings-info", Static).render() + assert "API base URL" in str(info) + assert "tok" in str(info) + await pilot.press("escape") + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" + + +async def test_history_screen_shows_points(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_history( + HistoryPoint( + timestamp=dt.datetime(2026, 8, 18, 0, 0), + amount_rub=Decimal("854.83"), + tokens=TokenUsage(input_tokens=100), + ) + ) + app = _make_app(tmp_path) + app._storage = storage + async with app.run_test() as pilot: + await pilot.press("h") + await pilot.pause() + table = app.screen.query_one("#history-table", DataTable) + assert table.row_count == 1 + cells = [str(cell) for cell in table.get_row_at(0)] + assert any("854,83" in cell for cell in cells) + bars = app.screen.query_one("#history-bars") + assert bars.render() != "Нет данных для графика" + + +async def test_history_period_switch(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + for day in range(20): + storage.save_history( + HistoryPoint( + timestamp=dt.datetime(2026, 8, day + 1, 0, 0), + amount_rub=Decimal(str(day + 1)), + tokens=TokenUsage(input_tokens=day + 1), + ) + ) + app = _make_app(tmp_path) + app._storage = storage + async with app.run_test() as pilot: + await pilot.press("h") + await pilot.pause() + title = app.screen.query_one("#history-title", Static) + assert "30 дней" in str(title.render()) + table = app.screen.query_one("#history-table", DataTable) + rows_30 = table.row_count + await pilot.click("#period-7") + await pilot.pause() + title = app.screen.query_one("#history-title", Static) + assert "7 дней" in str(title.render()) + table = app.screen.query_one("#history-table", DataTable) + assert table.row_count < rows_30 + await pilot.click("#period-90") + await pilot.pause() + assert "90 дней" in str(app.screen.query_one("#history-title", Static).render()) + assert app.screen.query_one("#history-table", DataTable).row_count >= rows_30 + + +async def test_model_detail_on_enter(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + assert app.screen.__class__.__name__ == "ModelDetailScreen" + detail = app.screen.query_one("#model-detail", Static) + rendered = str(detail.render()) + assert "GPT-4o" in rendered + assert "Запросы" in rendered + await pilot.press("escape") + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" + + +async def test_settings_save_interval(tmp_path, monkeypatch) -> None: + saved = {} + + def fake_save(settings) -> None: + saved["interval"] = settings.refresh_interval + + monkeypatch.setattr("selectel_ml_tui.ui.app.save_settings", fake_save) + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.press("s") + await pilot.pause() + inp = app.screen.query_one("#interval-input", Input) + inp.value = "120" + await pilot.click("#save-settings") + await pilot.pause() + assert app._settings.refresh_interval == 120 + assert saved["interval"] == 120 + + +async def test_settings_clear_cache(tmp_path) -> None: + storage = Storage(tmp_path / "cache.db") + storage.save_history( + HistoryPoint( + timestamp=dt.datetime(2026, 8, 18, 0, 0), + amount_rub=Decimal("854.83"), + tokens=TokenUsage(input_tokens=100), + ) + ) + app = _make_app(tmp_path) + app._storage = storage + async with app.run_test() as pilot: + await pilot.press("s") + await pilot.pause() + await pilot.click("#clear-cache") + await pilot.pause() + assert storage.get_history(days=30) == [] + + +async def test_offline_status_in_subtitle(tmp_path) -> None: + data = _dashboard_data() + data.offline = True + app = _make_app(tmp_path, data) + async with app.run_test() as pilot: + await pilot.pause() + assert "offline" in app.sub_title + + +async def test_window_resize(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test(size=(120, 40)) as pilot: + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" + await pilot.resize_terminal(80, 24) + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" + month = app.screen.query_one("#month-panel", Static) + assert "854,83" in str(month.render()) + + +async def test_keys_screen(tmp_path) -> None: + app = _make_app(tmp_path) + async with app.run_test() as pilot: + await pilot.pause() + await pilot.press("k") + await pilot.pause() + assert app.screen.__class__.__name__ == "KeysScreen" + table = app.screen.query_one("#keys-table", DataTable) + assert table.row_count == 1 + cells = [str(cell) for cell in table.get_row_at(0)] + assert any("Mei" in cell for cell in cells) + assert any("1 300" in cell for cell in cells) + budget = app.screen.query_one("#keys-budget", Static) + assert "Бюджет роутера" in str(budget.render()) + await pilot.press("escape") + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" From c2fafac1573881d14db3971e5bfbbbb94bf61a4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:17:35 +0300 Subject: [PATCH 41/79] Add CI workflow for Gitea Actions --- .gitea/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .gitea/workflows/ci.yml diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..daa86ea --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,33 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +jobs: + checks: + runs-on: docker + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" + + - name: Lint (ruff) + run: ruff check src tests + + - name: Typecheck (mypy) + run: mypy src + + - name: Tests (pytest + coverage) + run: python -m pytest -v --cov=selectel_ml_tui --cov-report=term-missing \ No newline at end of file From 05404bd65d0ef9d15f43bef4c7015dbf8216b236 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:24:12 +0300 Subject: [PATCH 42/79] Fix CI: use shell steps instead of JS actions (Alpine runner has no node) --- .gitea/workflows/ci.yml | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index daa86ea..2f26323 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -10,24 +10,27 @@ jobs: checks: runs-on: docker steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Install Python and tools + run: | + apk add --no-cache python3 py3-pip git - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" + - name: Checkout + run: | + git init -q + git remote add origin "${GITHUB_SERVER_URL:-http://10.8.0.52:8080}/$GITHUB_REPOSITORY.git" + git fetch -q --depth 1 origin "$GITHUB_SHA" + git checkout -q FETCH_HEAD - name: Install dependencies run: | - python -m pip install --upgrade pip - python -m pip install -e ".[dev]" + python3 -m pip install --upgrade pip + python3 -m pip install -e ".[dev]" - name: Lint (ruff) - run: ruff check src tests + run: python3 -m ruff check src tests - name: Typecheck (mypy) - run: mypy src + run: python3 -m mypy src - name: Tests (pytest + coverage) - run: python -m pytest -v --cov=selectel_ml_tui --cov-report=term-missing \ No newline at end of file + run: python3 -m pytest -v --cov=selectel_ml_tui --cov-report=term-missing \ No newline at end of file From d25f885eecb458f4e055af7386adc13563940e0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:27:48 +0300 Subject: [PATCH 43/79] CI: add --break-system-packages for Alpine (PEP 668) --- .gitea/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 2f26323..125e34f 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: - name: Install dependencies run: | python3 -m pip install --upgrade pip - python3 -m pip install -e ".[dev]" + python3 -m pip install --break-system-packages -e ".[dev]" - name: Lint (ruff) run: python3 -m ruff check src tests From 9b94482373a9f5acce20393071a6cd675fa735e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:30:19 +0300 Subject: [PATCH 44/79] CI: use venv to avoid PEP 668 (externally-managed) --- .gitea/workflows/ci.yml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 125e34f..7f02c6b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -13,6 +13,8 @@ jobs: - name: Install Python and tools run: | apk add --no-cache python3 py3-pip git + python3 -m venv /opt/venv + echo "/opt/venv/bin" >> "$GITHUB_PATH" - name: Checkout run: | @@ -23,14 +25,14 @@ jobs: - name: Install dependencies run: | - python3 -m pip install --upgrade pip - python3 -m pip install --break-system-packages -e ".[dev]" + python -m pip install --upgrade pip + python -m pip install -e ".[dev]" - name: Lint (ruff) - run: python3 -m ruff check src tests + run: ruff check src tests - name: Typecheck (mypy) - run: python3 -m mypy src + run: mypy src - name: Tests (pytest + coverage) - run: python3 -m pytest -v --cov=selectel_ml_tui --cov-report=term-missing \ No newline at end of file + run: python -m pytest -v --cov=selectel_ml_tui --cov-report=term-missing \ No newline at end of file From 8a770d18a75aae1a56c6b567cb7e5e79df28cd7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:33:39 +0300 Subject: [PATCH 45/79] Remove redundant cast to str (fixes mypy strict warn_redundant_casts) --- src/selectel_ml_tui/cli.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/selectel_ml_tui/cli.py b/src/selectel_ml_tui/cli.py index 186ea90..6fc2b2d 100644 --- a/src/selectel_ml_tui/cli.py +++ b/src/selectel_ml_tui/cli.py @@ -3,7 +3,6 @@ from __future__ import annotations from pathlib import Path -from typing import cast import click @@ -52,7 +51,7 @@ def _prompt_str( value = click.prompt(prompt, default="", hide_input=hide_input, show_default=False) if value == "": return current - return cast(str, value) + return value @click.group() @@ -198,4 +197,4 @@ def run() -> None: if __name__ == "__main__": - main() + main() \ No newline at end of file From 8598f2711abf2dec3a135160a9128fad3c20a46d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:34:16 +0300 Subject: [PATCH 46/79] Annotate prompt result as str (works with old and new click/mypy) --- src/selectel_ml_tui/cli.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/selectel_ml_tui/cli.py b/src/selectel_ml_tui/cli.py index 6fc2b2d..1be8474 100644 --- a/src/selectel_ml_tui/cli.py +++ b/src/selectel_ml_tui/cli.py @@ -48,7 +48,9 @@ def _prompt_str( default_note: str = "(Enter — оставить)", ) -> str: prompt = f"{label} [{current or 'пусто'}] {default_note}: " - value = click.prompt(prompt, default="", hide_input=hide_input, show_default=False) + value: str = click.prompt( + prompt, default="", hide_input=hide_input, show_default=False + ) if value == "": return current return value From 409b70109d77ade551d7a6cdd167d40585e89534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:37:21 +0300 Subject: [PATCH 47/79] Add trailing newline to cli.py (fix ruff W292) From 3ed5e62dd617fadf351075e6b57b44705ef9670e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:40:15 +0300 Subject: [PATCH 48/79] Add trailing newline to cli.py From 42c1a3d97399a74cf926baf30c63859fb2137b5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:40:47 +0300 Subject: [PATCH 49/79] Add trailing newline to cli.py From 99591fe5ccb142ac8600b5c38735ce9fcc7b476d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:41:07 +0300 Subject: [PATCH 50/79] Add trailing newline to cli.py From d38415719f05bd7e1cd55b860e4846ad12a24c93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:41:32 +0300 Subject: [PATCH 51/79] Add trailing newline to cli.py (double newline to survive API trim) From 0920babb50e047590b2241661bd9de7ebeb85c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:43:55 +0300 Subject: [PATCH 52/79] Add trailing newline to cli.py From 0296ea501f8dedf45cb9886a676aff03faa59fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:47:17 +0300 Subject: [PATCH 53/79] CI: auto-fix trailing newlines in ruff lint step --- .gitea/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 7f02c6b..b4020a4 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -29,7 +29,9 @@ jobs: python -m pip install -e ".[dev]" - name: Lint (ruff) - run: ruff check src tests + run: | + ruff check --fix src tests + ruff check src tests - name: Typecheck (mypy) run: mypy src From 00cd64377aec7c4c52ec4f0a265b99a195a95d55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 3 Sep 2026 11:57:49 +0300 Subject: [PATCH 54/79] Fix date-dependent storage tests (use now-relative timestamps) --- tests/test_storage.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/test_storage.py b/tests/test_storage.py index 1b7ad21..d2d1db9 100644 --- a/tests/test_storage.py +++ b/tests/test_storage.py @@ -108,14 +108,15 @@ def test_save_and_get_latest_model_metrics(tmp_path) -> None: def test_history_upsert_same_day(tmp_path) -> None: storage = Storage(tmp_path / "cache.db") + timestamp = dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=1) point = HistoryPoint( - timestamp=dt.datetime(2026, 8, 1, 12, 0), + timestamp=timestamp, amount_rub=Decimal("10.00"), tokens=TokenUsage(input_tokens=5), ) storage.save_history(point) point2 = HistoryPoint( - timestamp=dt.datetime(2026, 8, 1, 12, 0), + timestamp=timestamp, amount_rub=Decimal("15.00"), tokens=TokenUsage(input_tokens=8), ) @@ -151,7 +152,7 @@ def test_save_snapshot_combined(tmp_path) -> None: metrics=_metrics(), models=_models(), history_point=HistoryPoint( - timestamp=dt.datetime(2026, 8, 1, 12, 0), + timestamp=dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=1), amount_rub=Decimal("854.83"), tokens=TokenUsage(input_tokens=100), ), @@ -225,7 +226,7 @@ def test_clear_removes_all_rows(tmp_path) -> None: storage = Storage(tmp_path / "cache.db") storage.save_history( HistoryPoint( - timestamp=dt.datetime(2026, 8, 18), + timestamp=dt.datetime.now(dt.timezone.utc) - dt.timedelta(days=1), amount_rub=Decimal("1"), tokens=TokenUsage(input_tokens=1), ) @@ -235,4 +236,4 @@ def test_clear_removes_all_rows(tmp_path) -> None: deleted = storage.clear() assert deleted >= 1 assert storage.get_history() == [] - assert storage.get_latest_consumption() is None + assert storage.get_latest_consumption() is None \ No newline at end of file From 74dfc03c6ad73dd892b71549737dc5ca7c2985f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 11:50:59 +0300 Subject: [PATCH 55/79] v1.0.4: add dog loader entry --- CHANGELOG.md | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a81beb5..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,7 +101,8 @@ - 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 -[1.0.0]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.0 +[1.0.0]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.0 \ No newline at end of file From 471eb4b6386a8da9e5f686827d59ea16dd79a571 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 11:51:00 +0300 Subject: [PATCH 56/79] v1.0.4: document dog loader --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index ca21510..2529cd6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре - Детализация метрик конкретной модели (Enter в таблице моделей) - Офлайн-режим: при недоступности API показывает последние данные из кэша - Автообновление по интервалу из конфигурации (по умолчанию 60 с) +- Анимация-«собачка» и строка прогресса во время обновления статистики - Экспорт истории в CSV/JSON, диагностика окружения (`doctor`) ## Интерфейс @@ -279,4 +280,4 @@ API принимает только полные ISO-таймстампы (`YYYY **«Нет данных для графика» в истории** Кэш пуст — данные появляются после первого успешного обновления (каждая загрузка сохраняет точку за день). Очистить кэш можно в экране «Настройки» -или через `export` (не очищает). +или через `export` (не очищает). \ No newline at end of file From 59bbfe15d221425dadc3bbc25e95b2d9539dbb7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 11:51:01 +0300 Subject: [PATCH 57/79] v1.0.4: bump version --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 89fc266..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" @@ -86,4 +86,4 @@ python_version = "3.10" strict = true files = ["src"] show_error_codes = true -warn_unused_ignores = true +warn_unused_ignores = true \ No newline at end of file From d9b1edde96225df847c36d030fdc46c98de59e29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 11:51:01 +0300 Subject: [PATCH 58/79] v1.0.4: bump version --- src/selectel_ml_tui/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From f6a155718fe180d3c11aff639e955f8129c852cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 11:51:02 +0300 Subject: [PATCH 59/79] Add DogLoader animation on refresh (task #1) --- src/selectel_ml_tui/ui/app.py | 85 +++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) 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__( From 60caaf1d292b1aef1da5ac49b240357553577afc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 11:51:02 +0300 Subject: [PATCH 60/79] Add DogLoader tests --- tests/test_tui.py | 72 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) 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 From adcb36059d0c79a91a3cf4ef77f2d26c03664b6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 11:55:51 +0300 Subject: [PATCH 61/79] CHANGELOG: v1.0.4 dog loader Closes #1 --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3797ed6..f2b5281 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ скрывается по завершении загрузки. ### Примечание -- Реализует задачу #1 («Новая загрузка обновления статистики»). +- Закрывает задачу #1 («Новая загрузка обновления статистики»). ## [1.0.3] — 2026-08-19 @@ -105,4 +105,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 -[1.0.0]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.0 \ No newline at end of file +[1.0.0]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.0 From cc52595c0bdcadc480ef8c96a144c0905da9c311 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 10 Sep 2026 11:56:55 +0300 Subject: [PATCH 62/79] docs: add loading indicator check to final checklist Closes #1 --- docs/FINAL_CHECKLIST.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/FINAL_CHECKLIST.md b/docs/FINAL_CHECKLIST.md index c3ec476..301e915 100644 --- a/docs/FINAL_CHECKLIST.md +++ b/docs/FINAL_CHECKLIST.md @@ -29,7 +29,8 @@ | 9 | Ресайз окна | интерфейс перерисовывается, нет обрезки | | 10 | Клавиши `r/h/s/q`, `Esc` | навигация работает | | 11 | Русский текст | корректное отображение (UTF-8) | -| 12 | `doctor` | все `[OK]`, пути существуют | +| 12 | Индикатор загрузки | при обновлении — анимация-собачка и строка прогресса | +| 13 | `doctor` | все `[OK]`, пути существуют | ## Отметка выполнения From 8c374ea4e8dd35e2a29f84dbcc70511a086bcc93 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 10 Sep 2026 11:59:26 +0300 Subject: [PATCH 63/79] chore: close task closes #1 From 36d2f5aac6c05b1be48546aa23881c344d9b1be8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 13:00:52 +0300 Subject: [PATCH 64/79] Fix: rename loading -> refreshing (avoid built-in dots indicator) --- src/selectel_ml_tui/ui/app.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/selectel_ml_tui/ui/app.py b/src/selectel_ml_tui/ui/app.py index 82d3586..ced99e5 100644 --- a/src/selectel_ml_tui/ui/app.py +++ b/src/selectel_ml_tui/ui/app.py @@ -134,7 +134,7 @@ class DashboardScreen(Screen[Any]): ] data = reactive(DashboardData()) - loading = reactive(False) + refreshing = reactive(False) def __init__( self, @@ -169,22 +169,22 @@ class DashboardScreen(Screen[Any]): @work(exclusive=True, group="refresh") async def _refresh(self) -> None: - self.loading = True + self.refreshing = True try: self.data = await self._service.refresh() except Exception as exc: # noqa: BLE001 - показываем toast при ошибке self.notify(f"Ошибка обновления: {exc}", severity="error") finally: - self.loading = False + self.refreshing = False - def watch_loading(self, loading: bool) -> None: + def watch_refreshing(self, refreshing: bool) -> None: """Показать/скрыть собачку и строку прогресса при обновлении.""" try: loader = self.query_one("#dog-loader", DogLoader) line = self.query_one("#progress-line", Static) except NoMatches: # watcher может сработать до монтирования детей return - if loading: + if refreshing: loader.add_class("visible") loader.start() line.update("[b]🐕 Собачка обновляет статистику…[/]") From 74c135a823ba062605e62d42b5e8704c2ccfe74a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 13:00:53 +0300 Subject: [PATCH 65/79] tests: assert built-in loading indicator stays off --- tests/test_tui.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_tui.py b/tests/test_tui.py index 2f0bd14..3c3570f 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -317,9 +317,12 @@ async def test_dog_loader_hidden_when_idle(tmp_path) -> None: await pilot.pause() loader = app.screen.query_one("#dog-loader", DogLoader) assert not loader.has_class("visible") + assert loader.display is False assert not loader.running line = app.screen.query_one("#progress-line", Static) assert str(line.render()) == "" + # встроенный loading Textual не должен активироваться (иначе — точки) + assert app.screen.loading is False async def test_dog_loader_shown_during_refresh(tmp_path) -> None: @@ -339,13 +342,16 @@ async def test_dog_loader_shown_during_refresh(tmp_path) -> None: await pilot.pause() loader = app.screen.query_one("#dog-loader", DogLoader) assert loader.has_class("visible") + assert loader.display is True assert loader.running line = app.screen.query_one("#progress-line", Static) assert "обновляет статистику" in str(line.render()) + assert app.screen.loading is False release.set() await pilot.pause(0.1) assert not loader.has_class("visible") + assert loader.display is False assert not loader.running assert str(line.render()) == "" From e33b2237a8ef5c8edad02045138427c839c09725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 13:30:41 +0300 Subject: [PATCH 66/79] Full-screen dog loader overlay: dim background, panel, input block --- src/selectel_ml_tui/ui/app.py | 96 ++++++++++++++++++++++++----------- 1 file changed, 67 insertions(+), 29 deletions(-) diff --git a/src/selectel_ml_tui/ui/app.py b/src/selectel_ml_tui/ui/app.py index ced99e5..f952080 100644 --- a/src/selectel_ml_tui/ui/app.py +++ b/src/selectel_ml_tui/ui/app.py @@ -9,11 +9,15 @@ from __future__ import annotations from typing import Any -from textual import work +from rich.align import Align +from rich.panel import Panel +from rich.text import Text +from textual import on, 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.events import InputEvent from textual.reactive import reactive from textual.screen import Screen from textual.timer import Timer @@ -53,19 +57,28 @@ def _mask_token(value: str) -> str: class DogLoader(Static): - """ASCII-«собачка» — анимация ожидания обновления статистики. + """Полноэкранный индикатор загрузки: бегущая ASCII-собачка. - Кадры сменяются по таймеру (виляет хвостом). Показывается как - центрированный overlay поверх дашборда на время загрузки данных. + overlay поверх всего дашборда с полупрозрачным затемнением; собака + «бежит» (переставляет лапы, виляет хвостом), под ней — дорожка из 🐾. + Показывается на время обновления статистики и блокирует ввод. """ - FRAMES = ( - " /^ ^\\\n ( o.o ) ~\n \\ - / ~~~\n |_|", - " /^ ^\\\n ( o.o ) ~~~\n \\ - / ~~~~\n |_|", - " /^ ^\\\n ( o.o ) ~~\n \\ - / ~\n |_|", + BODY = ( + " __", + " ____/ \\___", + " / oo \\____", + " | __ \\____", + " \\____/ \\____________/", + ) + LEGS = ( + (" || || \\ ~", " || || \\_/"), + (" // \\\\ \\ ~~", " // \\\\ \\_/"), + (" || || \\ ~", " || || \\_/"), + (" \\\\ // \\ ~~", " \\\\ // \\_/"), ) CAPTION = "Обновление статистики…" - INTERVAL = 0.15 + INTERVAL = 0.12 frame = reactive(0) @@ -90,11 +103,36 @@ class DogLoader(Static): self._timer = None def _next_frame(self) -> None: - self.frame = (self.frame + 1) % len(self.FRAMES) + self.frame = (self.frame + 1) % len(self.LEGS) + + def _art(self) -> str: + index = self.frame % len(self.LEGS) + legs1, legs2 = self.LEGS[index] + return "\n".join([*self.BODY, legs1, legs2]) + + def _paws(self) -> str: + index = self.frame % len(self.LEGS) + return " ".join("🐾" if (i + index) % 2 == 0 else " " for i in range(8)) def render(self) -> Any: - art = self.FRAMES[self.frame % len(self.FRAMES)] - return f"{art}\n\n{self.CAPTION}" + theme = self.app.current_theme + body = Text(self._art(), style=f"bold {theme.accent}", justify="center") + paws = Text(self._paws(), style=str(theme.secondary), justify="center") + caption = Text(self.CAPTION, style=f"bold {theme.foreground}", justify="center") + content = Text("\n").join([body, Text(), paws, Text(), caption]) + return Panel( + Align.center(content, vertical="middle"), + title="[b]🐕 selectel-ml-tui[/b]", + border_style=str(theme.primary), + padding=(1, 4), + ) + + @on(InputEvent) + def _block_input(self, event: InputEvent) -> None: + """Перехватить ввод, пока показывается индикатор загрузки.""" + if self.has_class("visible"): + event.stop() + event.prevent_default() class ConsumptionBars(Static): @@ -160,7 +198,6 @@ class DashboardScreen(Screen[Any]): 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: @@ -178,20 +215,28 @@ class DashboardScreen(Screen[Any]): self.refreshing = False def watch_refreshing(self, refreshing: bool) -> None: - """Показать/скрыть собачку и строку прогресса при обновлении.""" + """Показать/скрыть полноэкранную собачку при обновлении.""" try: loader = self.query_one("#dog-loader", DogLoader) - line = self.query_one("#progress-line", Static) except NoMatches: # watcher может сработать до монтирования детей return if refreshing: loader.add_class("visible") loader.start() - line.update("[b]🐕 Собачка обновляет статистику…[/]") + self.set_focus(None) else: loader.remove_class("visible") loader.stop() - line.update("") + try: + self.query_one("#models-table", DataTable).focus() + except NoMatches: # pragma: no cover - на всякий случай + pass + + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + """Заблокировать горячие клавиши на время обновления.""" + if self.refreshing: + return False + return super().check_action(action, parameters) def watch_data(self, data: DashboardData) -> None: status = "offline" if data.offline else "online" @@ -617,23 +662,16 @@ class SelectelMLTUI(App[Any]): } #dog-loader { display: none; - layer: overlay; - width: auto; - height: auto; - padding: 1 4; - border: round $accent; - background: $surface; + dock: top; + layer: _loading; + width: 100%; + height: 100%; + background: $background 80%; content-align: center middle; text-align: center; } #dog-loader.visible { display: block; - align: center middle; - } - #progress-line { - dock: bottom; - height: 1; - text-align: center; } """ From 53d9ad653a12f7e12fbe156715eb9c9235fc0a95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 13:30:42 +0300 Subject: [PATCH 67/79] tests: full-screen coverage + input blocking --- tests/test_tui.py | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/tests/test_tui.py b/tests/test_tui.py index 3c3570f..d5fa67b 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -319,8 +319,6 @@ async def test_dog_loader_hidden_when_idle(tmp_path) -> None: assert not loader.has_class("visible") assert loader.display is False assert not loader.running - line = app.screen.query_one("#progress-line", Static) - assert str(line.render()) == "" # встроенный loading Textual не должен активироваться (иначе — точки) assert app.screen.loading is False @@ -344,16 +342,43 @@ async def test_dog_loader_shown_during_refresh(tmp_path) -> None: assert loader.has_class("visible") assert loader.display is True assert loader.running - line = app.screen.query_one("#progress-line", Static) - assert "обновляет статистику" in str(line.render()) assert app.screen.loading is False + # overlay покрывает весь экран + assert loader.region == app.screen.region release.set() await pilot.pause(0.1) assert not loader.has_class("visible") assert loader.display is False assert not loader.running - assert str(line.render()) == "" + + +async def test_input_blocked_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() + # во время загрузки горячие клавиши заблокированы + await pilot.press("h") + await pilot.pause() + assert app.screen.__class__.__name__ == "DashboardScreen" + + release.set() + await pilot.pause(0.1) + # после загрузки навигация снова работает + await pilot.press("h") + await pilot.pause() + assert app.screen.__class__.__name__ == "HistoryScreen" async def test_dog_loader_animates(tmp_path) -> None: From e4cb33b35e3b39f02c5a58a3274ad44efd4b36a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 13:30:43 +0300 Subject: [PATCH 68/79] docs: full-screen loading indicator --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2529cd6..cac8d4c 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре - Детализация метрик конкретной модели (Enter в таблице моделей) - Офлайн-режим: при недоступности API показывает последние данные из кэша - Автообновление по интервалу из конфигурации (по умолчанию 60 с) -- Анимация-«собачка» и строка прогресса во время обновления статистики +- Полноэкранный индикатор загрузки: бегущая ASCII-собачка с затемнением фона - Экспорт истории в CSV/JSON, диагностика окружения (`doctor`) ## Интерфейс From baff464dc0c3942cebab35d137d919746f19b35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 13:30:44 +0300 Subject: [PATCH 69/79] docs: changelog for full-screen dog loader --- CHANGELOG.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2b5281..978a96a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,15 +7,22 @@ Анимация-«собачка» при обновлении статистики. ### Добавлено -- Экран **Dashboard**: ASCII-«собачка» (`DogLoader`) как индикатор загрузки — - центрированный overlay поверх дашборда с анимацией (виляет хвостом, - кадры меняются каждые 0,15 с) и подписью «Обновление статистики…». -- Индикация обновления в нижней строке (`#progress-line`, `dock: bottom`). +- Экран **Dashboard**: полноэкранный индикатор загрузки — бегущая ASCII-собачка + поверх всего окна с полупрозрачным затемнением (`$background 80%`), + панелью по центру (рамка `border: round`), дорожкой из 🐾 и подписью + «Обновление статистики…». Анимация: переставляет лапы и виляет хвостом + (4 кадра, 0,12 с). +- На время загрузки ввод блокируется (горячие клавиши и фокус), после + завершения — возвращается. - Показывается при автообновлении по таймеру и при ручном обновлении (`r`); скрывается по завершении загрузки. +### Исправлено +- Индикатор больше не конфликтует со встроенным `Widget.loading` Textual + (ранее из-за одноимённого reactive показывались встроенные «точки»). + ### Примечание -- Закрывает задачу #1 («Новая загрузка обновления статистики»). +- Реализует задачу #1 («Новая загрузка обновления статистики»). ## [1.0.3] — 2026-08-19 @@ -105,4 +112,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 -[1.0.0]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.0 +[1.0.0]: https://github.com/selectel-ml-tui/selectel-ml-tui/releases/tag/v1.0.0 \ No newline at end of file From 063b8bbaabb87321b74242203b314f8ed91659c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 14:13:23 +0300 Subject: [PATCH 70/79] Add embedded GIF dog animation frames --- src/selectel_ml_tui/ui/dog_art.py | 170 ++++++++++++++++++++++++++++++ 1 file changed, 170 insertions(+) create mode 100644 src/selectel_ml_tui/ui/dog_art.py diff --git a/src/selectel_ml_tui/ui/dog_art.py b/src/selectel_ml_tui/ui/dog_art.py new file mode 100644 index 0000000..40b5d8e --- /dev/null +++ b/src/selectel_ml_tui/ui/dog_art.py @@ -0,0 +1,170 @@ +"""Кадры анимации индикатора-собачки (авто-сгенерировано, не редактировать). + +Источник: 236205.gif; квантование в палитру, фон прозрачный (индекс 0). +Кадр хранится как 64x42 индексов палитры (по строке). +""" + +from __future__ import annotations + +import base64 +import zlib + +WIDTH = 64 +PX_HEIGHT = 42 +FRAME_COUNT = 12 +FRAME_MS = 40 + +# Индекс 0 — прозрачный фон; остальные — RGB кадров GIF. +PALETTE: tuple[tuple[int, int, int], ...] = ( + (0,0,0), (255,255,255), (255,243,255), (255,255,238), (255,255,227), (255,255,221), + (255,255,207), (255,250,188), (250,255,245), (250,255,226), (249,251,237), + (246,248,245), (250,251,216), (248,248,214), (244,244,216), (246,242,193), + (239,249,234), (238,241,219), (236,237,211), (237,236,201), (231,239,224), + (183,246,251), (238,228,215), (246,219,178), (253,205,185), (253,202,176), + (250,197,180), (249,195,173), (240,190,172), (237,185,168), (224,229,216), + (222,221,200), (220,206,188), (221,184,175), (204,207,201), (198,189,183), + (185,187,181), (164,194,224), (213,166,158), (189,157,150), (163,168,178), + (155,155,159), (183,136,134), (142,136,134), (231,93,136), (159,104,113), + (127,137,174), (122,115,122), (98,125,184), (103,106,127), (104,98,108), + (98,89,96), (83,92,132), (81,82,104), (233,74,126), (205,57,101), (145,65,86), + (145,41,56), (138,8,35), (130,6,27), (124,7,27), (123,0,17), (85,73,86), + (74,70,87), (69,71,98), (68,62,77), (82,50,69), (85,28,47), (111,6,22), (62,68,99), + (61,61,85), (59,57,79), (61,57,72), (57,53,72), (58,52,68), (57,51,66), (58,50,65), + (56,50,65), (55,50,66), (55,49,65), (55,49,64), (57,48,64), (55,48,64), (58,44,60), + (54,51,69), (54,49,67), (54,49,65), (53,49,65), (54,49,64), (54,48,65), (54,48,64), + (53,48,65), (53,48,64), (54,48,63), (53,48,63), (54,47,64), (53,47,64), (54,47,63), + (53,47,63), (53,47,62), (53,46,62), (53,45,60), (48,51,75), (51,49,65), (52,48,64), + (52,47,64), (50,48,64), (52,48,63), (52,47,63), (52,46,63), (51,47,63), (49,47,63), + (52,47,62), (52,46,61), (51,46,62), (48,46,61), (51,45,62), (51,45,59), (46,44,61), + (49,43,59), (47,42,59), (44,42,59), (41,37,70), (47,40,55), (42,38,55), (41,36,54), + (39,33,52), (28,23,41), +) + +_DATA_B64 = ( + "eNrtndtv2ti3x7srkcRATDCh3DEXc3WJiSENpAVKLgRQ82Cl0dF5qObJHb8gkKrEyWCbf/2stU06nfkVYrs6Opqj" + "8WjSqsqyt7e39/7s77r41asfD0JeeT1IAH4UKlGv5nkwJemU7LEFJJ05flVKxcWmR/tUQmq+SSTLV94aEE3G6+03" + "qXS6X/Jk/zqZKI/KqUyy1fZmH09nbiuJTLLx1dMNvEkl4mIllUnUHzzZv36Tq8lgn6obJx5OQLLS1FjV4+l42ViM" + "XJ+AFD5Z6o0+rFfEvq4tXHehf7DSNVV7MnXT0DTVbQOIsJJODE3XNE3RZvDD5SAk4klRGKK5fbhtAJGklSRZigr3" + "ANba0t0gJn5p0JLg+iqaazN1fttwegLSPiZZNtca6w9gSm9B1W4ve3WHU0nUqvm5LBeuDS9uFQWNtfnt5fteTvh6" + "3AwQOAIl8jy9/Oz6R2GOgxMwlcunJ8P4Bo1XwbweHprT6d3kenStzUcfo+3r6YZnQhiOHo3Tznmncw6jB8wbfsnS" + "1NlCVfV7+F+dLGb3V5t6gGEjHFM/fQfH287TxQe4ur9matrCfpgzeCrQtdONg5KQQ77xofz2Ldj3L9730BxHIX2Y" + "8EzgLwutXdrSn4VuN3/Avin3z6HtHypw9YU2fx5LKj3R1hH55jUJFgWOlcG8mwPz9bVp63FQ6Or2EUX2Q3l10O+e" + "9RosUzPU50tDH9rX17eOaOIL8Tvi6jxWr/iLsmlb0v4D6xk9hb6tAYTshHZD41YsxktL0742/bl4/utCnWybFAjJ" + "+kmJKbduTR0GoQazgGHah6XTYTlTp1sndgLnYIQVvP+ablktoVAUqtVKtSqIY8tugnod2HqCMLMfhHu/18ZSkWM5" + "lmUZenC1sa4t4c3Sj7baM8F9X/Hx7rp9AHdDDx4aUROlvmFYo9EXq8Vuf4ZBJhg8socZoUfp+Orasv6wzNWQJ19l" + "NvjCCch/vPP4T9GmMcztEuJngj5va2sgtxvagTMxfq9wEAF72hqvdPILYPTv4eJoerakT4gUop7ti2AqxEWvJ8jF" + "RYDYRKXt8fL5eL1JkqnMCfFonyhfFYDfZG9dCOiduaqlMvHGyKN9OtFvJIEfvfFnKZ2Ji2IykyrfeQLoaDyUq9WB" + "P8tK2wt/EkEer6R4JpGZ3rs/ATkQTUvV+/l0vq7OFNdjgIiwcizUh3F/Couneu2WPwvmePqI/GWoyCxTt/wp/F4o" + "fqLwtkAOVj+65E9R/iSJljLTKKtoS7f8KrbU1rGF6+wCDuDPd45PED0ihC2O+/0HJG9qrn69PHMKsOQPmWE5Lidb" + "F7dzRVksADnmAGHl4qhZovz5uoCnim7gLzLIBREfg7XL2/nDk76wzRtMy5gq0+vJ9fVUmYxKH7XJVv7k2Xr3XDk/" + "v1M1NO8Ea5YG+Hmv6jqccjHVZssN/En2GDTPPfMnwnOHFRAgFxSC6C3p+nVz49APswe5s9O31H5JzYufDNqXKg4H" + "FR6K0j7Y9vI03ncikXS507no4b3npmgO0KWvMQz4ddtDeNfJ+/2HDCPCrX+oM7mxQR8k2FNbaMb99hGF9CVJYrfX" + "65apOdIjjEQ4wYy2QH/BnkR2aiur3ujwDN+3lIXdavv+KY1OtsNbMBQKDcaxWIQpDgxFXWg/HDPoP+1+K0ADoIR2" + "BSkWi4n6Ehtv6MiflmkapkZbo0+24yfxhWGsHg1MuGPFMMScIFQrlD+XFgVhdfTyHLjXsm6QdQcVyp9Byp85SYep" + "CV7tFyYVwvh8RUNbql+PDw6+82eV8ufTV9Xq8+SFXagPANi4bgfW+Am3c/XVsqyVZQqlB5ljXrAHwmQKf5274RwH" + "JUsRgB6Z4A770gl+TnmB3E5oH04APexxbd8PBcmvMCT5lz//KccvCKh7ONbyJa/2xXL0FSknpAOvAJqRXpEkUKhH" + "+2RKbr5JpLwKqKVUotGMpzKZQcCT/ZtEon6VTmdSfW83EE+mKmomnUm0rrx1XyoeF8E+1fIEwNFCTBBltJefSl5m" + "iNrgyfqSSaTjkjVyfQJyKFmPylSXypX68OHLtWv72sqAlVY1Vd18UmYTtw3wD4cyIMNMWQtHLjdBpKBVC7JJARDt" + "XfOncDK0qmvhSNc1feruBkhx3GpJeH1bLJvfnpWJw4mhdAIAxknDoTFfC1dg/qHXyJ40S69x4s+WYPIn7Q2vFfk6" + "YAFAWWFwcQH8irSG5mecaE3vkD8n2vTuun2tLTcQWLNI+ZXlzy6enswHFH8/AP7yqj6bIX2qqH8C0aib+NOP5pFw" + "udvpo35KzSuAvygGI3yp+GP2eRTdyA1cJMLyHcqv735Hfq37JdgMzNQZJR8EsG2KOtnjgrmzbgb109OLD6ifisA8" + "sBtBAXcGxtCI7fJh430jEuTjjfMLqp+iOcCzArYL1B7h/rduyUgjtwfM4y9C28/yDIq/VPZVUT6170HNO3iHO2e9" + "bo4pLuB62OszqsfTVugOtoTkj3K9w3MsPHmFGsJoQD0ST7Z4cUdHSLEVi8FWqEiZ3bTxEwBUoYPy40vmO6FQrFwW" + "eX4IL7JiiEINjkpVaFmI0C++kWAfC4nTJkH5V7GkIPDnPuInK94gv75oTyKRCGmS0RP8tiGx6BTI8kWhUpNulrpu" + "GaWXux+Olo6ddrLmz9LJZ2u1Wi0/K7LgoP8BQAUTSP0HBiWB0VgqVnmfA+mSKriydX30V4SN7OwEHEmfBHZQDP/3" + "gQ5bkx3iDCDJT3+PRPZ/DT7/Zdd/FMB636WUqA5Y82peLpcAo2JeFViSzBy/OvQOsIVERm7mU8nyR2/2+VRKLmXS" + "qfLIUw8CQCbkYxQwr73ZA7pKrWQmlb8ueeu+eEhAB3550vQ0bHI1eVpPgP3Sg4QN/HpjGZYUT8XLxtS9hk6klTpV" + "dE2qi63H25HbLQSpriyqn6ListBcC7hEHtdw3aOL7otq00/4ty9WqwZ1d6pU7HG3iSOHLXlVHerAHMiPwGDd145f" + "a3SMscMTWTaom1Jf6Pr8otchTdv9TqKlEnl1NNnEbwejKuE4YaCb1FmrUn59f1kuGtO7yQT4U5lqo5PFbBN/tsc8" + "8isnXlxqMzioea/D9k3EJp0eS3W23KRpRwXbgc90Lp6MpyedmnfZGkZDzIHdbIhSr442L7vIr2yxe9rpnJ7+RumZ" + "LyB1ahTebA2zvW3ZR3ym/Pru7TmYf8hlYTCoKg2oWPfJlpeakD1/vgv8CgB7igCag5tXKHfiSShH6tOts2Kj2+D8" + "Qa6I4QNAkDLKeAsb3mdU1H/BJfCWPulsFgXUHCOiEo9+hMWMEuwcKHjqYFYmtW6vy7P8GInvhr4J2m82/zt7I3bL" + "DR4AlGrOFj1MDEWAw8mOkgRjZQA/FvcO+lhE/qwJNX25+KaqTpxaJFSuL/sFHjYuiiUywJ9BhvELU2yBgzWB+EKx" + "lmW1pSVsQAccjCrkz2qtby6ULw4mFH43Eor1r8g1biGPbBZrP6xW1lCxfndy+6GdUJC0j++Atr/TfrSvjiMwMxNH" + "czAch/DO3P85f5KdWM4X3HPqTGEYn2x++eFhkd2QL3DoOJjI7/Pl1B/XLwK35JxIyB48MLHkHWf+FVr/8ehKip6D" + "PNIiyqhx0eMJDuOV6KtAIuk1BDUXrxwjBXq0J5lkuQ0QHK97C0Eg8WT5Kp/KxEVvEQDFVLo8L6cyiYY3BTifjMfE" + "TCqTlDwJqITPVSQp7VWAJbw0Nlf9RDqd6uvuHwARFAt1JrGcqUzVidsGkIICSzbgjnkzVmDFP3avv7Y0XO81HQhI" + "dc2fRJYLNZOGb+Liq3926UTYax2vqn0AWB0ITgfm6vyNP8lRdOu8P5DvxbGhIrFQB3yvS45KaxWmAP9/vNt0S6RZ" + "InvB2rQ/fUCZDTsBEa7Oz5Xp9fVoNJqo19dXysYIyKZZZTg+XGyZF7b8C/h8CQApWZpu6Msl9OlyW5eWJNRPeY5t" + "XM4fngydtv5DTjDweWBAhDbDu9ocU0Ko/spznc7wvNO5nYN5r8wBQM7BTqexj2i/Bf5ZjucP1gGofRMQsM606MNE" + "erQpdJv+hgEATL33lgYAnK/lV021pV+4ffpAt0blkDedszobSZY755c0fNWyNU+76QihL/Dn0SkL6OFnJLw6U9Rx" + "E6IuFjqNZoEmLHSt9OLcDwB7BlcPcy1T0XXlhm7HaAt0hx6B3fqHejjC9ZfQeeYaQO1b0BxMCSQWq8iAfYUpuhBk" + "CqBVyaJP0cmUTHYz9ZVs4/fTOOKzHfAS+kWmR87wR15ZRrUFO1hL9Ie5bDZXLNZgXnG2nySRUG6w+r0JvK7f7dn8" + "aVmrsak7ikcnEQTYQfZoOteft0uktZI50fzdkSKBQRQceTW6n6vPM/jeToUP+nLHzqYz3MIQfqovngcb3NHufpB1" + "AZBMELp/+vyyEnaHc0WExBcM8uM/tXbXOEkIQ0q/hKD/8uv/3dH8RQEVnQyBotccKpJPl+AcibJXD348I6Ebvix7" + "swcAlIFfU2VvIawkk0r3S6m01wgCDJ6diJiB5C0CtZpKx2WaAfWHJ/tIJFfFAICE9IenANSKPF71kxngVw8ZWKTQ" + "ssylPiynkpmx/tE1f/JDWHQW6sOnltR61F3774m4ujcWyAumZagz9cSlvb/VKsgWxguqXxawXrmVwP0tsV+dIS0s" + "dCqZ1d1t+7N9eVWbUt1JX6iz+e37zl9f41Jz0wkJqqRMayD3DapUocP422Wvw34ctZulaIAuAaXP001d0vxa9QP+" + "SU/W7Xxmu4uBPz/wVetG1aYAsFej6/ZI1ZUNDWi29mn8qdC9/Kw+fIPrzwGiKtxwSflviQQLf042ASg5tPmT7Vw8" + "fTu/1TU7fFW2FKQvOwdnpt8dbeZXPzWvnL49BQQ9f6DmwDBoq657RN2Cf4CfQY7Jd9/a+unF+8sGUzMX6vcDoype" + "8N9n671GJFN+e9rB5C1GMP60pjru7IUdkdwtM2GG4VrUvHijY6tnf15e05XXL0pnhLzp9noNFvkTY2Yx6JJG4WIH" + "brdfH7Fur3HIFe5wE6Kh6k13Q/gYnPAr2d2N1ZkIz9UMlI7H4+lCf6IB4c4iwklml6CaHs4t4IaHOZ4vFouiSWNK" + "HIXEkz3SNmSB5RV98TjkGMYXZHxAo6pjfwp5WFnmERkvNWOANCdUhZah6NcOV0TCVIareal5fa+qQ3w3/hgWW9aX" + "O4eXxxiGkAS/jBIsKr6kwjOR/qPT6ZTsREI7fgzWWc5pDirg4D7DOtaTCAlF6LRzIOmY7EWyoZ19Vzix/mVfMHcS" + "pQ3y5rr/Hm7glWW82P0quP4Lvr8uoP4av76uoC8xX/bKr+kMBgDEvfJrIV4+IsVEonzi7fL5VLkdSHsOACDJZOZj" + "IZFO1r3xK+qnd97102g+Ec9JwJ9J2ZM9wQR+MYUBtFde/PdCa2quGvF0uvzJQ/4+wfwpRR1WAL+fvrh+gKSG+fuK" + "tlwO++PlYuI2BvpgcDMwFggsuqEvNM2lgk4KejV7Yiy+Jy27jj9ttYZVS6MJO7j4Txru+FUY9AeiRYkDAZb674mz" + "yZFIsM7y/WF//k2bzdby6/tenR2eNKPP+kX046Y9HbkahhmOi4jzi4v5DO/99nfqgBdXinL9+QoYuETIaLkppoO0" + "bf89U/xwMX8wMP4Urn7GF40ZjcVdTifT6ZU225i+TViM9eMx/vS/z2n8KZjneBgF2pr9AIK36q+I3xxny6fvbPNw" + "37Qd9+qMev/vt21ISJZhuQ7l17entnkL8Jd68FH9VV7K3iGkftbNsbl0o0P992iOoa/69yT62XYBMdqpZ7MHYX/h" + "otfr8gytBDFDhkTypduAF+I31/za6PU6fLBiojmNm1jAn3YAwtDJS5zpdiNcuGbqOJDRXlGg7Tq+FC/7lEgsFvOz" + "DJ/lioMl+lI0w7JMlXbD3MkbSdK7dDhkgwK8CpopFmtiCxMSZ5Yz+Zfu1GSBZ/2SCfYyE4SdhLbQP7VGjpe09mo1" + "FEl2fg/N5sP8wJxq5rELATInTVdwr+07GP5XAVZ4Mu5dTMckuLsTQ94kVzB2BXY/WHQ1myMB79gUMFqJvv191l3S" + "EwkBwa7H5MfIzv7+/o7LmZCQ5ve/wu5hx/cLWEf+def/UyHUG39S/7+Q9Wi+F681MY/eo/8/gJEDwJ8pyRv/kmRc" + "LJFk2msG/5tEXGzGE+lEy1v8Zz6VbLSp/9zT9TH+tCEnAUAHnuxfxyMRO/507KX9JFsUW1PA51TFg/se9dOlZZiV" + "eCresMbu+bW4NBeaog8blUrfUF0HYBwOV+iwnevQBt19+CgRLPHYBO5b0Poz8zv39aeqAuqXCyo8qfrIrf3xqiWu" + "EDTmaP9w64JfYY72i0NafwraDidQv80vew2HMzdpn2SzHC9Pn5Yqxq5qc7sAVLrwuV36HreW3ShfErPm42j+1OUt" + "7TxUb99f1oN968vk86jdxCWEfL7btIqSGkvlU6w/ZRgPD+pcv1wLoEBQS0WZXF21PwINvt6otdj5U43TTqdzev5N" + "o+WnaiaN/8QMrOW9dq9qW/gVT/Bcf+rUuuzB1WsGmqPzXJ0DCWpb9TtCDrlGr07zpzo9u/wUXHmhUxe+Tj3520c0" + "6XTzfobLSeeX77H8lHU7o/iLSvz8G5zkBf56F6fhp0z9sve+DMyF8aMYuLtuAYYvOInoJp3eh3yYG6D7H23tHsB8" + "PCcKIiG7sU4+zHOSgRtBrLik0PpXeKKJA/vY7i5h9rMcNzCUmTaYAr5iP8KQnL9Q++kZ3mCkFViWPYE3cZrjhZrU" + "Gj/hKzG7d+ZSg19qt6q8bChPQw7oKcjxKJ/qLgLio9ZKkqEDB4TsHR6y3GA51SfHLt5mWditWDfWwK63xEuWtXQ1" + "GwGC5sYr2RZCiT8oyO7ciYCguzUpGKTVAuB5+BiXBHq4E8FqAba9j9ascsuw2HB7/iIMQzwhDXkWYP8F2H8Kfq7D" + "T3NezfMVwM+K5wpUBIXTQCrhNf8pl8hLpJxOeA0fTacyJ4fJTKLisYBUEsgR+C9Z95b/VEhlMmNagNRb/lU8Ho/U" + "qPzqLfyUA/5sZTKZhKfwVeBP3bLGmUQq0TI/uudPwTBVTTFa9Up9bNy5Tl/itdUjrpSGYZr6bOm6/lRtJfVp+hN1" + "+rqvPyXJgjDW7QQiWLN/O3Frf7wSqcfalvz0T69dmfvlk7401u16lcgLl+/fOvX5nRwDPhX6Y305X8x06jP+ivWj" + "ssdRJ6coGVV02XK18eVvsxmtNLl2wD9ej44D61WguZHfosif2UgE2OnBMAxdofzZYWoWgIM2HZ00S+RVc7K5ohfF" + "R57Ldk5p/v4cowc6QWGJJAzwAgB7fXW3DeAwf59jGuv6U4btgFehJ37DDH6EP+jW0dG2eTMM/GnXPz23zTGAEp8F" + "7U7t20zbPifmu92cn32Tb53T7H9BX1J2RnijivZsWt2+dab8ecg2er0PwJ/6UsGU/9mz/302u3cUv/mm2zvLs/zQ" + "oOwLj9POXcIR6aSmCNnNdHJhrobjWFOUddlRuwLqRwfmsVhmL8gV6Rg2UDulKqqdPubo+uXYK4Jxz5pqiUWAz6Fu" + "4TZkps1mjmYUWADb7bGu6EaN3Q8Gw3yxJhu0+uvU8YTS/KwYqsBmDw/DGP4aFI0bTRsXnKswX62xuBNaC9p7h/zU" + "Mp5cTEdELEaCoe+qJTmAfmgRFyhGmJ394J+/Svy+oE2Aju2DP+b62yRKbybgDOL/eh3iQ4CNYvlkbyhpU82/GPq/" + "zo92R+eq3tOXSugDr3gsIEXycYlWEfVawDSZkKIk7r2AaSpeb75JpNMn3vg3l8L6T8Bv3uo/kWIqVZ9Q+dNTB5Cy" + "7X5Pp1qe+JfwvCBJwLDpvid+9ItDY9WKp1Lp4dwDPxaGFspvYrlcn6pu8e0VyQ5Xmvao6eZibi48pB9VVzJNeFFw" + "4Ve/ueZHsV8QFLv4Es2a/ujSXpKQH+1i6d9U3W39/D25NRYHBnXez2gGUNcpgJLmEYCHMB5On7SFhtbf7AKiDmfc" + "klnzA77xkn5xi5V+5nOdOtAjwteTox+WoY38KLGAbzwH/Kg/Gai8oQf8Q56/MdTJ3Vr/BIC82sqPQLCN0/55p/NJ" + "t83ZvvFf6gw47G46Gh237/S7bfn3gK+VZ37ExufDLRNrd36j3nt1Bs90c/4RgFuYfU5fan13oON2Rp9jAVDgj+XH" + "rf73152zChOJY/oSwC/H2VfXMH8Lc59murI9/6b0toDyJYP18xthrmVNcRfxDT3ftP7AtxcDoikfVDF9ieXoRoZm" + "QNF4ZLwPbeJoLGW6FZYrmusPB+BGhlYv0J19UAIAkmf4MNaBWDxahq1/2uX/HfnPSSpG9vwCXt4ctgY3iKCPlD4d" + "OhRgpDVr0PeK1efZbE6oifLAoAXVro8cvk4l01Rvnlqsj8Pk/SDLiUus6+F4RiQnQ8Na5oC1AnAvMLCY4vBJ1wdu" + "dIBajfyJYuR4+Gk6qP4AZy8UIfUxfxEsm2KR57nvRfkDh4HtJwBe/GuZUzgh/gsOsMBBIHAYDbgr4k929jGkldYC" + "OYR+ecH+JysT9aAEojYCBwKuU6L+pof+vybRX7i711FaQ8nzB5iSInkViGeOPdoXsPBSPpXw6v2OA3mSdCouepQf" + "86n0STaVTtQ9yo/5dLIF/JWoeC2/lE7JciKTKE+8ud8z8ZBQx+ynT9749Y0gDUQsv6SMPFSAJUIfpmvgx0TmZjlx" + "z49VawmrhSqJjb6mKm7jNwk/X9FyP4ZlPunq/cS1/mjVjjH9BghIeVRVt/WbiCTXhIFB64Vjsobr+vf4/SXgR5rx" + "/OLXbv7jjSeMNJTlKa2WTpfLrxdnb52aX8kkC/inWbbWpdMPKCE/Opuno08CE0H/d//yVvmi2vVDgaH2a19H7VL0" + "OYSKbBQzq7TUIvq/H4wn1a4f2jvLFZZLeI7Xd6Ork/YRKU2uN+Yf0fBNju+8w/JL5w+UXnOYvrTGH315d3291DeO" + "CcIGuXC4s+ZH0wZALKBk94VGfbnqdDPSE+LPd8/y6P9+hwDYjXBovtDV5/hLXR1tTcqt0vJL+7k+fvypwfH06jSJ" + "R11XgB+9uOjCwXV673v1IDW36z7N7AExUx1lwFQ6H87KbATwE9XPxXxudyGOasXJpERi9VwYyy89fz5JVxfUAa45" + "zX+PhXm2SMtmmcajbgeAajN9rjvyf+9kYhG2OHxcqNpSbg3HugHzAa1E5aj+06smG8pJYyxEvBIZjqfVlyTAOQyF" + "cCZg7keEqQ5bBxHZkd3fBxgrSEOYVu4dbcoBvnzFB3PStqtpZQ8PswWh1rIM0/qhAhPluJ8u9ITZ3w/mftyrkOjJ" + "atU/kQXfn0x5AGgaCPysJhNhfMzfE4bIcS0UCa694AhiaAt4+/MT/Kz+J8aE7vvtWQARcn3sOWbqnR0/TYMEgAz8" + "ebgIirUJkv54vjj8R1xpbvZ8iPxJGZoEXGfmP7clenAY+BUM/V9i2KYN255bhcnnQlzyal5MHdkfMfIqPyakNkl6" + "rr5J0nDp14l0qu+NP/eSifJVEb+f5I0/S8lUZvwL39/MJjOpVj3l+ftJhXgkUkH3d8Xwxo85sW/Q7yctRx7KHxFR" + "tx5NOZFK1fV79wVIibjCei16S+5/g/nXLcKTgmXQ6pWGieWfXdefJ7V5oWYoM11bF6B3zY9SqyabtvHMff1Pmx9h" + "qZvTr9XM3OS/A5kRpjaV5YfZd9wAfkw6NX+QwmGOqw2t25k+W3/+E/N/9pyVIQmMc7R8fFa6vMVYt7V5Nygao1H7" + "uFk6ajaBHUnzeJt+yPFM/Ux5+DSGJvyO5m8Ek6avqHd3mjYZtT+rk6NN9iyW78x13707xe8f3V3Y+T+PsB2Z04Ki" + "dhXOjZsS4mfYA777g/+6y2f7WPbczoGhf2rX2/iR1N93+WTm7WnjggqIYH5DC6E+fwdTn2ztzaNu2R8+DEdkrF7f" + "YPELRPgsn9O/UcJsvsiPPq7Y7fU+VBiadLKg3me7hCje/YvTKgmFKo0uoHOEfgRIs5Of9PUXYV/M/yH7oVAoluHZ" + "YNGw79quG08/ATDT9BfnhL3dUCyWifDikI5CE95o43H5+Gi/krMXv4kGDYg1JF5ePeJtm7Lc6veHw+HYsMvq6i++" + "0ySSEyI+wVB01TLFIBvGbQ3PV/p2TV71xRBQ4vPtR3ySNZ3KBT9jfzyJYRleXKjGUnvSqy/S434wGPTJbfLDIbbG" + "qrE6OTF14S9r+89qtcNI9v/9H9ulK2vVCpGTHPMDQtpynkMO+ShiUhAJ7qwZkhza3+UNOKQg+nVW+F0/RUC4NDKY" + "K4YkkR1ih1UGAj8iZCDgtDAAee6xv1gHDqNu1riDZwkTb51C9IGb3Kp1RGhgb2/dgYeBqKfIUBKl+3L8+SsqI/35" + "PwncIAw=" +) + + +def decode_frames() -> list[bytes]: + """Вернуть список кадров: каждый — 64*42 байт индексов палитры.""" + raw = zlib.decompress(base64.b64decode(_DATA_B64)) + size = WIDTH * PX_HEIGHT + return [raw[i * size:(i + 1) * size] for i in range(FRAME_COUNT)] From b66bb925481bf32278ca18d8ebb9584f1516be5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 14:13:23 +0300 Subject: [PATCH 71/79] Dog loader: truecolor GIF frames + paw trail --- src/selectel_ml_tui/ui/app.py | 93 ++++++++++++++++++++--------------- 1 file changed, 54 insertions(+), 39 deletions(-) diff --git a/src/selectel_ml_tui/ui/app.py b/src/selectel_ml_tui/ui/app.py index f952080..94fdd5b 100644 --- a/src/selectel_ml_tui/ui/app.py +++ b/src/selectel_ml_tui/ui/app.py @@ -9,8 +9,6 @@ from __future__ import annotations from typing import Any -from rich.align import Align -from rich.panel import Panel from rich.text import Text from textual import on, work from textual.app import App, ComposeResult @@ -46,6 +44,7 @@ from selectel_ml_tui.models import ( ) from selectel_ml_tui.services import DashboardData, DataService from selectel_ml_tui.storage import Storage +from selectel_ml_tui.ui import dog_art PERIOD_OPTIONS = (7, 30, 90) @@ -57,40 +56,32 @@ def _mask_token(value: str) -> str: class DogLoader(Static): - """Полноэкранный индикатор загрузки: бегущая ASCII-собачка. + """Полноэкранный индикатор загрузки: анимация собачки из GIF. - overlay поверх всего дашборда с полупрозрачным затемнением; собака - «бежит» (переставляет лапы, виляет хвостом), под ней — дорожка из 🐾. - Показывается на время обновления статистики и блокирует ввод. + Рисуется truecolor half-block'ами (2 пикселя на знакоместо), фон + прозрачный — просвечивает затемнённый дашборд. Под собачкой бежит + дорожка из 🐾. Показывается на время обновления и блокирует ввод. """ - BODY = ( - " __", - " ____/ \\___", - " / oo \\____", - " | __ \\____", - " \\____/ \\____________/", - ) - LEGS = ( - (" || || \\ ~", " || || \\_/"), - (" // \\\\ \\ ~~", " // \\\\ \\_/"), - (" || || \\ ~", " || || \\_/"), - (" \\\\ // \\ ~~", " \\\\ // \\_/"), - ) - CAPTION = "Обновление статистики…" - INTERVAL = 0.12 + INTERVAL = dog_art.FRAME_MS / 1000 + PAW_SLOTS = 9 frame = reactive(0) def __init__(self, *args: Any, **kwargs: Any) -> None: super().__init__(*args, **kwargs) self._timer: Timer | None = None + self._frames: list[bytes] | None = None @property def running(self) -> bool: """Идёт ли анимация.""" return self._timer is not None + @property + def frame_count(self) -> int: + return dog_art.FRAME_COUNT + def start(self) -> None: """Запустить анимацию (идемпотентно).""" if self._timer is None: @@ -103,29 +94,53 @@ class DogLoader(Static): self._timer = None def _next_frame(self) -> None: - self.frame = (self.frame + 1) % len(self.LEGS) + self.frame = (self.frame + 1) % dog_art.FRAME_COUNT - def _art(self) -> str: - index = self.frame % len(self.LEGS) - legs1, legs2 = self.LEGS[index] - return "\n".join([*self.BODY, legs1, legs2]) + def _get_frames(self) -> list[bytes]: + if self._frames is None: + self._frames = dog_art.decode_frames() + return self._frames - def _paws(self) -> str: - index = self.frame % len(self.LEGS) - return " ".join("🐾" if (i + index) % 2 == 0 else " " for i in range(8)) + def _paws(self, index: int, width: int) -> str: + cells = "".join( + "🐾 " if (i + index) % 2 == 0 else " " + for i in range(self.PAW_SLOTS) + ).rstrip() + pad = max(0, (width - len(cells)) // 2) + return " " * pad + cells def render(self) -> Any: + frames = self._get_frames() + data = frames[self.frame % len(frames)] + width, px_height = dog_art.WIDTH, dog_art.PX_HEIGHT + palette = dog_art.PALETTE theme = self.app.current_theme - body = Text(self._art(), style=f"bold {theme.accent}", justify="center") - paws = Text(self._paws(), style=str(theme.secondary), justify="center") - caption = Text(self.CAPTION, style=f"bold {theme.foreground}", justify="center") - content = Text("\n").join([body, Text(), paws, Text(), caption]) - return Panel( - Align.center(content, vertical="middle"), - title="[b]🐕 selectel-ml-tui[/b]", - border_style=str(theme.primary), - padding=(1, 4), - ) + + text = Text(justify="center", no_wrap=True) + for y in range(0, px_height, 2): + for x in range(width): + top = data[y * width + x] + bottom = data[(y + 1) * width + x] if y + 1 < px_height else 0 + if top == 0 and bottom == 0: + text.append(" ") + elif top == 0: + r, g, b = palette[bottom] + text.append("▄", style=f"#{r:02x}{g:02x}{b:02x}") + elif bottom == 0: + r, g, b = palette[top] + text.append("▀", style=f"#{r:02x}{g:02x}{b:02x}") + else: + rt, gt, bt = palette[top] + rb, gb, bb = palette[bottom] + text.append( + "▀", + style=f"#{rt:02x}{gt:02x}{bt:02x} on #{rb:02x}{gb:02x}{bb:02x}", + ) + if y + 2 < px_height: + text.append("\n") + text.append("\n\n") + text.append(self._paws(self.frame, width), style=str(theme.secondary)) + return text @on(InputEvent) def _block_input(self, event: InputEvent) -> None: From 835b82cb79a68ef5b04c346d73ae84e2b36cbebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 14:13:24 +0300 Subject: [PATCH 72/79] tests: dog_art frames + animation --- tests/test_tui.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/tests/test_tui.py b/tests/test_tui.py index d5fa67b..74217cd 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -20,6 +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 import dog_art from selectel_ml_tui.ui.app import DogLoader, SelectelMLTUI @@ -389,9 +390,25 @@ async def test_dog_loader_animates(tmp_path) -> None: loader.add_class("visible") loader.start() assert loader.running - first = loader.render() - await pilot.pause(0.4) - second = loader.render() - assert first != second + assert loader.frame_count == dog_art.FRAME_COUNT + first_frame = loader.frame + first = str(loader.render()) + await pilot.pause(0.3) + assert loader.frame != first_frame + assert str(loader.render()) != first + # дорожка из лапок присутствует + assert "🐾" in str(loader.render()) loader.stop() assert not loader.running + + +def test_dog_art_frames() -> None: + frames = dog_art.decode_frames() + assert len(frames) == dog_art.FRAME_COUNT + size = dog_art.WIDTH * dog_art.PX_HEIGHT + assert all(len(f) == size for f in frames) + # индексы палитры в допустимом диапазоне, фон = 0 + assert all(b < len(dog_art.PALETTE) for f in frames for b in f) + assert dog_art.FRAME_MS == 40 + # кадры различаются (анимация) + assert len({bytes(f) for f in frames}) > 1 From b595d003bdb413b5d7e579b76f2417ab087ea9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 14:13:24 +0300 Subject: [PATCH 73/79] docs: GIF dog loader --- CHANGELOG.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 978a96a..77b2020 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,10 @@ Анимация-«собачка» при обновлении статистики. ### Добавлено -- Экран **Dashboard**: полноэкранный индикатор загрузки — бегущая ASCII-собачка - поверх всего окна с полупрозрачным затемнением (`$background 80%`), - панелью по центру (рамка `border: round`), дорожкой из 🐾 и подписью - «Обновление статистики…». Анимация: переставляет лапы и виляет хвостом - (4 кадра, 0,12 с). +- Экран **Dashboard**: полноэкранный индикатор загрузки — анимированная + **собачка из GIF** (12 кадров, 40 мс), отрисованная truecolor + half-block'ами (▀) с прозрачным фоном поверх затемнённого дашборда + (`$background 80%`), под ней — бегущая дорожка из 🐾. - На время загрузки ввод блокируется (горячие клавиши и фокус), после завершения — возвращается. - Показывается при автообновлении по таймеру и при ручном обновлении (`r`); From 3cc3a06b756b6a189cde5fc5c4c1df7a585260b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 14:13:28 +0300 Subject: [PATCH 74/79] docs: GIF dog loader --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cac8d4c..25fc577 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре - Детализация метрик конкретной модели (Enter в таблице моделей) - Офлайн-режим: при недоступности API показывает последние данные из кэша - Автообновление по интервалу из конфигурации (по умолчанию 60 с) -- Полноэкранный индикатор загрузки: бегущая ASCII-собачка с затемнением фона +- Полноэкранный индикатор загрузки: анимированная собачка (truecolor) с 🐾 - Экспорт истории в CSV/JSON, диагностика окружения (`doctor`) ## Интерфейс From 1a45ac5257eaa7026a6875d939e9625a59ce9983 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 14:24:17 +0300 Subject: [PATCH 75/79] Halve dog art to 32x22 --- src/selectel_ml_tui/ui/dog_art.py | 211 ++++++++++-------------------- 1 file changed, 66 insertions(+), 145 deletions(-) diff --git a/src/selectel_ml_tui/ui/dog_art.py b/src/selectel_ml_tui/ui/dog_art.py index 40b5d8e..6113a05 100644 --- a/src/selectel_ml_tui/ui/dog_art.py +++ b/src/selectel_ml_tui/ui/dog_art.py @@ -1,7 +1,7 @@ """Кадры анимации индикатора-собачки (авто-сгенерировано, не редактировать). Источник: 236205.gif; квантование в палитру, фон прозрачный (индекс 0). -Кадр хранится как 64x42 индексов палитры (по строке). +Кадр хранится как 32x22 индексов палитры (по строке). """ from __future__ import annotations @@ -9,162 +9,83 @@ from __future__ import annotations import base64 import zlib -WIDTH = 64 -PX_HEIGHT = 42 +WIDTH = 32 +PX_HEIGHT = 22 FRAME_COUNT = 12 FRAME_MS = 40 # Индекс 0 — прозрачный фон; остальные — RGB кадров GIF. PALETTE: tuple[tuple[int, int, int], ...] = ( - (0,0,0), (255,255,255), (255,243,255), (255,255,238), (255,255,227), (255,255,221), - (255,255,207), (255,250,188), (250,255,245), (250,255,226), (249,251,237), - (246,248,245), (250,251,216), (248,248,214), (244,244,216), (246,242,193), - (239,249,234), (238,241,219), (236,237,211), (237,236,201), (231,239,224), - (183,246,251), (238,228,215), (246,219,178), (253,205,185), (253,202,176), - (250,197,180), (249,195,173), (240,190,172), (237,185,168), (224,229,216), - (222,221,200), (220,206,188), (221,184,175), (204,207,201), (198,189,183), - (185,187,181), (164,194,224), (213,166,158), (189,157,150), (163,168,178), - (155,155,159), (183,136,134), (142,136,134), (231,93,136), (159,104,113), - (127,137,174), (122,115,122), (98,125,184), (103,106,127), (104,98,108), - (98,89,96), (83,92,132), (81,82,104), (233,74,126), (205,57,101), (145,65,86), - (145,41,56), (138,8,35), (130,6,27), (124,7,27), (123,0,17), (85,73,86), - (74,70,87), (69,71,98), (68,62,77), (82,50,69), (85,28,47), (111,6,22), (62,68,99), - (61,61,85), (59,57,79), (61,57,72), (57,53,72), (58,52,68), (57,51,66), (58,50,65), - (56,50,65), (55,50,66), (55,49,65), (55,49,64), (57,48,64), (55,48,64), (58,44,60), - (54,51,69), (54,49,67), (54,49,65), (53,49,65), (54,49,64), (54,48,65), (54,48,64), - (53,48,65), (53,48,64), (54,48,63), (53,48,63), (54,47,64), (53,47,64), (54,47,63), - (53,47,63), (53,47,62), (53,46,62), (53,45,60), (48,51,75), (51,49,65), (52,48,64), - (52,47,64), (50,48,64), (52,48,63), (52,47,63), (52,46,63), (51,47,63), (49,47,63), - (52,47,62), (52,46,61), (51,46,62), (48,46,61), (51,45,62), (51,45,59), (46,44,61), - (49,43,59), (47,42,59), (44,42,59), (41,37,70), (47,40,55), (42,38,55), (41,36,54), - (39,33,52), (28,23,41), + (0,0,0), (255,255,255), (255,253,255), (255,255,249), (255,255,237), (255,250,241), + (255,255,227), (255,255,205), (251,254,245), (250,252,222), (245,253,237), + (161,253,251), (248,243,233), (247,241,202), (238,240,213), (224,235,231), + (254,218,208), (253,213,181), (227,225,205), (215,222,212), (216,215,204), + (253,204,185), (248,196,180), (225,199,179), (207,202,184), (236,183,173), + (213,181,176), (216,169,159), (213,153,148), (195,218,240), (196,198,188), + (191,193,179), (186,184,180), (192,170,157), (175,187,192), (171,169,157), + (158,172,207), (102,188,250), (190,155,145), (156,155,159), (183,137,135), + (161,135,133), (142,146,165), (137,132,135), (121,138,193), (121,126,151), + (227,104,135), (187,107,111), (223,77,125), (134,118,124), (165,84,91), + (111,116,159), (119,118,123), (117,114,115), (105,105,126), (106,102,104), + (106,91,101), (91,107,177), (88,94,138), (93,92,107), (84,83,114), (87,82,88), + (83,75,91), (75,76,104), (76,70,84), (68,68,94), (69,65,77), (176,44,87), + (135,20,39), (109,27,51), (109,0,13), (69,59,76), (64,59,78), (62,58,79), + (73,43,62), (86,14,32), (59,57,81), (58,54,74), (56,53,74), (59,54,69), (57,53,68), + (57,52,69), (58,51,66), (57,51,67), (56,51,68), (56,50,66), (57,45,61), + (48,59,108), (51,52,69), (53,51,68), (53,50,71), (53,50,65), (55,49,65), + (55,49,64), (50,49,65), (54,48,64), (54,48,63), (53,48,64), (53,48,63), (51,48,65), + (54,47,63), (53,47,65), (53,47,63), (53,47,62), (52,47,65), (52,47,63), (52,47,62), + (48,47,64), (53,46,62), (52,46,62), (51,46,62), (51,45,61), (50,45,61), (51,43,60), + (48,44,63), (47,43,60), (47,43,59), (46,42,58), (44,40,63), (44,39,57), (44,38,56), + (28,24,119), (41,36,54), (41,34,53), (42,32,51), (38,31,51), (36,31,50), + (33,26,47), ) _DATA_B64 = ( - "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" - "PwncIAw=" + "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" + "Sw0=" ) def decode_frames() -> list[bytes]: - """Вернуть список кадров: каждый — 64*42 байт индексов палитры.""" + """Вернуть список кадров: каждый — 32*22 байт индексов палитры.""" raw = zlib.decompress(base64.b64decode(_DATA_B64)) size = WIDTH * PX_HEIGHT return [raw[i * size:(i + 1) * size] for i in range(FRAME_COUNT)] From 9db4064a2639a0760b8192dc3ae599374bd37d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 14:24:17 +0300 Subject: [PATCH 76/79] Dog loader: drop paw trail --- src/selectel_ml_tui/ui/app.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/selectel_ml_tui/ui/app.py b/src/selectel_ml_tui/ui/app.py index 94fdd5b..8e1863f 100644 --- a/src/selectel_ml_tui/ui/app.py +++ b/src/selectel_ml_tui/ui/app.py @@ -59,12 +59,11 @@ class DogLoader(Static): """Полноэкранный индикатор загрузки: анимация собачки из GIF. Рисуется truecolor half-block'ами (2 пикселя на знакоместо), фон - прозрачный — просвечивает затемнённый дашборд. Под собачкой бежит - дорожка из 🐾. Показывается на время обновления и блокирует ввод. + прозрачный — просвечивает затемнённый дашборд. Показывается на время + обновления и блокирует ввод. """ INTERVAL = dog_art.FRAME_MS / 1000 - PAW_SLOTS = 9 frame = reactive(0) @@ -101,20 +100,11 @@ class DogLoader(Static): self._frames = dog_art.decode_frames() return self._frames - def _paws(self, index: int, width: int) -> str: - cells = "".join( - "🐾 " if (i + index) % 2 == 0 else " " - for i in range(self.PAW_SLOTS) - ).rstrip() - pad = max(0, (width - len(cells)) // 2) - return " " * pad + cells - def render(self) -> Any: frames = self._get_frames() data = frames[self.frame % len(frames)] width, px_height = dog_art.WIDTH, dog_art.PX_HEIGHT palette = dog_art.PALETTE - theme = self.app.current_theme text = Text(justify="center", no_wrap=True) for y in range(0, px_height, 2): @@ -138,8 +128,6 @@ class DogLoader(Static): ) if y + 2 < px_height: text.append("\n") - text.append("\n\n") - text.append(self._paws(self.frame, width), style=str(theme.secondary)) return text @on(InputEvent) From c3b432252b7179a8d6b66f1d86d93b45cdf94be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 14:24:18 +0300 Subject: [PATCH 77/79] tests: 32x22 art --- tests/test_tui.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_tui.py b/tests/test_tui.py index 74217cd..b78bed3 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -396,8 +396,6 @@ async def test_dog_loader_animates(tmp_path) -> None: await pilot.pause(0.3) assert loader.frame != first_frame assert str(loader.render()) != first - # дорожка из лапок присутствует - assert "🐾" in str(loader.render()) loader.stop() assert not loader.running @@ -407,6 +405,9 @@ def test_dog_art_frames() -> None: assert len(frames) == dog_art.FRAME_COUNT size = dog_art.WIDTH * dog_art.PX_HEIGHT assert all(len(f) == size for f in frames) + # уменьшенный размер и чётная высота для half-block + assert (dog_art.WIDTH, dog_art.PX_HEIGHT) == (32, 22) + assert dog_art.PX_HEIGHT % 2 == 0 # индексы палитры в допустимом диапазоне, фон = 0 assert all(b < len(dog_art.PALETTE) for f in frames for b in f) assert dog_art.FRAME_MS == 40 From 3baa8be47fa00269ed8cc6441cf211e0b295d987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 14:24:18 +0300 Subject: [PATCH 78/79] docs: smaller dog, no paw trail --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b2020..27f5b10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ - Экран **Dashboard**: полноэкранный индикатор загрузки — анимированная **собачка из GIF** (12 кадров, 40 мс), отрисованная truecolor half-block'ами (▀) с прозрачным фоном поверх затемнённого дашборда - (`$background 80%`), под ней — бегущая дорожка из 🐾. + (`$background 80%`). - На время загрузки ввод блокируется (горячие клавиши и фокус), после завершения — возвращается. - Показывается при автообновлении по таймеру и при ручном обновлении (`r`); From 65b5b45389676f4d199c860c72afe03dad18e04a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D0=B4=D1=80=D0=B5=D0=B9=20=D0=95=D0=BC=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=D1=8F=D0=BD=D0=B5=D0=BD=D0=BA=D0=BE?= Date: Thu, 10 Sep 2026 14:24:19 +0300 Subject: [PATCH 79/79] docs: smaller dog, no paw trail --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 25fc577..2a5bc53 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ TUI-приложение (Python 3.10+) для отображения потре - Детализация метрик конкретной модели (Enter в таблице моделей) - Офлайн-режим: при недоступности API показывает последние данные из кэша - Автообновление по интервалу из конфигурации (по умолчанию 60 с) -- Полноэкранный индикатор загрузки: анимированная собачка (truecolor) с 🐾 +- Полноэкранный индикатор загрузки: анимированная собачка (truecolor) - Экспорт истории в CSV/JSON, диагностика окружения (`doctor`) ## Интерфейс