From 09935d55ef86a27da27d1c2f5ab3536547f74d2f Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:44:57 +0300 Subject: [PATCH] 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()