Add src/selectel_ml_tui/ui/app.py
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user