feat(ui): implement approved three-theme mockup redesign
125
CODEX_MOCKUP_REDESIGN_REPORT.md
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
# Hermes Hub — редизайн по утверждённому макету B5
|
||||||
|
|
||||||
|
Дата: 2026-08-21
|
||||||
|
|
||||||
|
Ветка: `codex/mockup-redesign`
|
||||||
|
|
||||||
|
BASE_SHA: `69cbefcab6d566929d99b7ad7dc1b6b9824bfb2b` (`origin/main`, обновлён перед финальной интеграцией)
|
||||||
|
|
||||||
|
FINAL_SHA: `git rev-parse codex/mockup-redesign` в момент handoff. Точный SHA указан в итоговом сообщении, поскольку commit не может содержать собственный SHA.
|
||||||
|
|
||||||
|
## Реализовано
|
||||||
|
|
||||||
|
- Один утверждённый layout для трёх схем: `dark`, `hybrid`, `light`.
|
||||||
|
- Все палитры находятся в `theme.py`, имеют одинаковый набор токенов и меняют всё окно после сохранения настройки.
|
||||||
|
- Выбор темы сохраняется в `hub_settings.json`; приложение применяет его при следующем запуске.
|
||||||
|
- Общий каркас соответствует макету: брендовая левая панель, прокручиваемая навигация, пользователь и версия снизу, глобальная верхняя строка с состоянием, `Ctrl + K`, добавлением аккаунта и служебными действиями.
|
||||||
|
- Dashboard: пять компактных KPI, провайдеры → оркестратор → роли, правая панель состояния, реальные события снизу.
|
||||||
|
- После обновления контракта подключены реальные доли/число вызовов и P50 по провайдерам, вызовы по ролям, active calls и локальные CPU/RAM/disk/network.
|
||||||
|
- Добавлены отдельные вкладки «Квоты и лимиты» и «Аналитика» на данных `HubSnapshot`/`TelemetryService`.
|
||||||
|
- Журнал получает реальные события через action layer приложения, поддерживает поиск и фильтр уровня.
|
||||||
|
- На «Аккаунты» возвращены `test`, `set_main`, `set_orchestrator`, `assign_role`; механический и фактический UI-тесты проверяют все четыре триггера.
|
||||||
|
- Сохранена keyed-дельта карточек и квотных корзин.
|
||||||
|
- Цветовые литералы вне `theme.py` удалены; отдельный тест запрещает их повторное появление.
|
||||||
|
|
||||||
|
## Изменённые файлы
|
||||||
|
|
||||||
|
- `src/antigravity_provider/router/hermes_hub_app.py`
|
||||||
|
- `src/antigravity_provider/router/ui/theme.py`
|
||||||
|
- `src/antigravity_provider/router/ui/components.py`
|
||||||
|
- `src/antigravity_provider/router/ui/assets.py`
|
||||||
|
- `src/antigravity_provider/router/ui/add_account_wizard.py`
|
||||||
|
- `src/antigravity_provider/router/ui/views/dashboard_view.py`
|
||||||
|
- `src/antigravity_provider/router/ui/views/logs_view.py`
|
||||||
|
- `src/antigravity_provider/router/ui/views/settings_view.py`
|
||||||
|
- `src/antigravity_provider/router/ui/views/team_view.py`
|
||||||
|
- `src/antigravity_provider/router/ui/views/analytics_view.py`
|
||||||
|
- `src/antigravity_provider/router/ui/views/quotas_view.py`
|
||||||
|
- `tests/test_ui_mockup_redesign.py`
|
||||||
|
- `tests/test_ui_screenshot_harness.py`
|
||||||
|
- `artifacts/mockup-redesign/*.png`
|
||||||
|
- `CODEX_MOCKUP_REDESIGN_REPORT.md`
|
||||||
|
|
||||||
|
Backend/state/adapters, installer, scripts, config, legacy и чужие тесты не изменялись.
|
||||||
|
|
||||||
|
## Проверка размеров и scaling
|
||||||
|
|
||||||
|
Проверены `1280×720`, `1366×768`, `1920×1080`. На наименьшем размере дополнительно проверены 100%, 125% и 150% widget scaling. Правая граница поиска и кнопки добавления оставалась внутри верхней строки:
|
||||||
|
|
||||||
|
```text
|
||||||
|
scale 1.00: search_right=539, add_right=1074, header_width=1090
|
||||||
|
scale 1.25: search_right=661, add_right=1023, header_width=1043
|
||||||
|
scale 1.50: search_right=781, add_right=971, header_width=995
|
||||||
|
```
|
||||||
|
|
||||||
|
Навигация помещена в прокручиваемую область, поэтому нижние разделы доступны при 150%.
|
||||||
|
|
||||||
|
## Скриншоты — все вкладки и темы
|
||||||
|
|
||||||
|
| Вкладка | Тёмная | Гибрид | Светлая |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Обзор | [dark](artifacts/mockup-redesign/overview-dark.png) | [hybrid](artifacts/mockup-redesign/overview-hybrid.png) | [light](artifacts/mockup-redesign/overview-light.png) |
|
||||||
|
| Команда | [dark](artifacts/mockup-redesign/team-dark.png) | [hybrid](artifacts/mockup-redesign/team-hybrid.png) | [light](artifacts/mockup-redesign/team-light.png) |
|
||||||
|
| Аккаунты | [dark](artifacts/mockup-redesign/accounts-dark.png) | [hybrid](artifacts/mockup-redesign/accounts-hybrid.png) | [light](artifacts/mockup-redesign/accounts-light.png) |
|
||||||
|
| Маршрутизация | [dark](artifacts/mockup-redesign/routing-dark.png) | [hybrid](artifacts/mockup-redesign/routing-hybrid.png) | [light](artifacts/mockup-redesign/routing-light.png) |
|
||||||
|
| Провайдеры | [dark](artifacts/mockup-redesign/providers-dark.png) | [hybrid](artifacts/mockup-redesign/providers-hybrid.png) | [light](artifacts/mockup-redesign/providers-light.png) |
|
||||||
|
| Квоты | [dark](artifacts/mockup-redesign/quotas-dark.png) | [hybrid](artifacts/mockup-redesign/quotas-hybrid.png) | [light](artifacts/mockup-redesign/quotas-light.png) |
|
||||||
|
| Аналитика | [dark](artifacts/mockup-redesign/analytics-dark.png) | [hybrid](artifacts/mockup-redesign/analytics-hybrid.png) | [light](artifacts/mockup-redesign/analytics-light.png) |
|
||||||
|
| Состояние | [dark](artifacts/mockup-redesign/health-dark.png) | [hybrid](artifacts/mockup-redesign/health-hybrid.png) | [light](artifacts/mockup-redesign/health-light.png) |
|
||||||
|
| Журнал | [dark](artifacts/mockup-redesign/logs-dark.png) | [hybrid](artifacts/mockup-redesign/logs-hybrid.png) | [light](artifacts/mockup-redesign/logs-light.png) |
|
||||||
|
| Настройки | [dark](artifacts/mockup-redesign/settings-dark.png) | [hybrid](artifacts/mockup-redesign/settings-hybrid.png) | [light](artifacts/mockup-redesign/settings-light.png) |
|
||||||
|
| О программе | [dark](artifacts/mockup-redesign/about-dark.png) | [hybrid](artifacts/mockup-redesign/about-hybrid.png) | [light](artifacts/mockup-redesign/about-light.png) |
|
||||||
|
|
||||||
|
## Расхождения с макетом
|
||||||
|
|
||||||
|
| Элемент макета | Реализация и причина |
|
||||||
|
|---|---|
|
||||||
|
| «Квота сегодня 78%» | Показано `Н/Д`, пока нет хотя бы одной реально измеренной корзины. Baseline `None` не превращается в процент. |
|
||||||
|
| «Активные задачи 24» | Заменено на реальные вызовы роутера из telemetry. Подсистемы задач/очередей нет. |
|
||||||
|
| «Окно обслуживания 09:00–21:00» | Заменено количеством реальных failover-переключений. Понятия окна обслуживания нет. |
|
||||||
|
| Доли провайдеров 45/35/20% и 128/74/56 запросов по ролям | Показываются только из реального `metrics.telemetry.by_provider/by_role`; без вызовов отображаются online/`Н/Д`, а не цифры макета. |
|
||||||
|
| Латентность каждого провайдера | Показывается реальный P50 из `by_provider`; без измерений значение скрыто. |
|
||||||
|
| CPU, память, диск, сеть | Показываются реальные локальные измерения `psutil`; при недоступном `psutil` — `Н/Д`. |
|
||||||
|
| Очереди задач | Блок исключён: подсистемы очередей в продукте нет. |
|
||||||
|
| Раздел «Инциденты» | Не создан: вместо него используется реальный журнал с фильтром ошибок. |
|
||||||
|
| Кривые соединения | В native CustomTkinter используются адаптивные направленные связи между тремя колонками; данные и порядок цепочки сохранены без canvas-зависимости. |
|
||||||
|
| Версия `2.8.1` на макете | Показывается реальная версия пакета `0.1.1`. |
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
### Headless без UI-зависимостей
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --isolated --no-project --with pytest --with pyyaml --with pydantic --with requests --with httpx --with psutil python -m pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат: `193 passed, 26 skipped, 3 deselected in 14.32s`.
|
||||||
|
|
||||||
|
### UI-enabled
|
||||||
|
|
||||||
|
Из-за известной повторной инициализации Tcl/Tk набор выполнен в трёх свежих процессах:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run --extra dev python -m pytest -q --ignore=tests/test_ui_phase2_6.py --deselect=tests/test_oauth_lifecycle.py::test_f_copy_before_open_browser
|
||||||
|
uv run --extra dev python -m pytest -q tests/test_ui_phase2_6.py
|
||||||
|
uv run --extra dev python -m pytest -q tests/test_oauth_lifecycle.py::test_f_copy_before_open_browser
|
||||||
|
```
|
||||||
|
|
||||||
|
Результаты: `243 passed, 2 skipped, 4 deselected`; `4 passed`; `1 passed`. Итог уникального набора: `248 passed, 2 skipped, 3 deselected`, ошибок нет.
|
||||||
|
|
||||||
|
### Статика и release gate
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
uv run ruff check .
|
||||||
|
uv run --extra dev python scripts/release_gate.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат: Ruff — `All checks passed!`; release gate — `PASSED`, публичный package и hash подтверждены.
|
||||||
|
|
||||||
|
## Backend gaps
|
||||||
|
|
||||||
|
1. Внешние provider RPS/SLA, task queues и maintenance windows отсутствуют в продукте; UI их не синтезирует.
|
||||||
|
2. Baseline-квоты остаются `None`; реальное число появляется только из provider claim/runtime event.
|
||||||
|
3. `HubSnapshot` не содержит журнал событий; app action layer передаёт в views безопасные presentation-объекты из `EventLogService`, сами views backend не читают.
|
||||||
|
4. Network contract содержит накопительные bytes с момента загрузки ОС, а не мгновенную скорость; UI показывает объём, не выдуманный Мбит/с.
|
||||||
|
5. Тег `v0.1.1`, release и manifest не создавались и не изменялись.
|
||||||
BIN
artifacts/mockup-redesign/about-dark.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
artifacts/mockup-redesign/about-hybrid.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
artifacts/mockup-redesign/about-light.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
artifacts/mockup-redesign/accounts-dark.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
artifacts/mockup-redesign/accounts-hybrid.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
artifacts/mockup-redesign/accounts-light.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
artifacts/mockup-redesign/analytics-dark.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
artifacts/mockup-redesign/analytics-hybrid.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
artifacts/mockup-redesign/analytics-light.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
artifacts/mockup-redesign/health-dark.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
artifacts/mockup-redesign/health-hybrid.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
artifacts/mockup-redesign/health-light.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
artifacts/mockup-redesign/logs-dark.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
artifacts/mockup-redesign/logs-hybrid.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
artifacts/mockup-redesign/logs-light.png
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
artifacts/mockup-redesign/overview-dark.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
artifacts/mockup-redesign/overview-hybrid.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
artifacts/mockup-redesign/overview-light.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
artifacts/mockup-redesign/providers-dark.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
artifacts/mockup-redesign/providers-hybrid.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
artifacts/mockup-redesign/providers-light.png
Normal file
|
After Width: | Height: | Size: 63 KiB |
BIN
artifacts/mockup-redesign/quotas-dark.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
artifacts/mockup-redesign/quotas-hybrid.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
artifacts/mockup-redesign/quotas-light.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
artifacts/mockup-redesign/routing-dark.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
artifacts/mockup-redesign/routing-hybrid.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
artifacts/mockup-redesign/routing-light.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
artifacts/mockup-redesign/settings-dark.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
artifacts/mockup-redesign/settings-hybrid.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
artifacts/mockup-redesign/settings-light.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
artifacts/mockup-redesign/team-dark.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
artifacts/mockup-redesign/team-hybrid.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
artifacts/mockup-redesign/team-light.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
|
|
@ -47,6 +47,7 @@ from antigravity_provider.router.profile_manager import ProfileAuthManager
|
||||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||||
from antigravity_provider.router.adapters import get_adapter
|
from antigravity_provider.router.adapters import get_adapter
|
||||||
from antigravity_provider import paths
|
from antigravity_provider import paths
|
||||||
|
from antigravity_provider.version import __version__
|
||||||
|
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
from antigravity_provider.router.ui.assets import AssetManager
|
from antigravity_provider.router.ui.assets import AssetManager
|
||||||
|
|
@ -69,10 +70,21 @@ from antigravity_provider.router.ui.views.health_view import HealthView
|
||||||
from antigravity_provider.router.ui.views.logs_view import LogsView
|
from antigravity_provider.router.ui.views.logs_view import LogsView
|
||||||
from antigravity_provider.router.ui.views.settings_view import SettingsView
|
from antigravity_provider.router.ui.views.settings_view import SettingsView
|
||||||
from antigravity_provider.router.ui.views.about_view import AboutView
|
from antigravity_provider.router.ui.views.about_view import AboutView
|
||||||
|
from antigravity_provider.router.ui.views.analytics_view import AnalyticsView
|
||||||
|
from antigravity_provider.router.ui.views.quotas_view import QuotasView
|
||||||
|
|
||||||
logger = logging.getLogger("hermes.hub.gui")
|
logger = logging.getLogger("hermes.hub.gui")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_saved_theme() -> str:
|
||||||
|
settings_file = paths.get_hermes_home() / "hub_settings.json"
|
||||||
|
try:
|
||||||
|
settings = json.loads(settings_file.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
return "dark"
|
||||||
|
return str(settings.get("theme", "dark"))
|
||||||
|
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
# Actions Layer (Safe & Non-blocking)
|
# Actions Layer (Safe & Non-blocking)
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
@ -166,12 +178,13 @@ def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]:
|
||||||
|
|
||||||
class HermesHubApp(ctk.CTk):
|
class HermesHubApp(ctk.CTk):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
|
self._theme_name = Theme.apply_scheme(_load_saved_theme())
|
||||||
|
ctk.set_appearance_mode("dark" if self._theme_name == "dark" else "light")
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.title("Hermes Hub")
|
self.title("Hermes Hub")
|
||||||
self.geometry("1380x880")
|
self.geometry("1380x880")
|
||||||
self.minsize(1100, 700)
|
self.minsize(1100, 700)
|
||||||
|
|
||||||
ctk.set_appearance_mode("dark")
|
|
||||||
ctk.set_default_color_theme("blue")
|
ctk.set_default_color_theme("blue")
|
||||||
self.configure(fg_color=Theme.BG_WINDOW)
|
self.configure(fg_color=Theme.BG_WINDOW)
|
||||||
|
|
||||||
|
|
@ -219,9 +232,9 @@ class HermesHubApp(ctk.CTk):
|
||||||
|
|
||||||
# Top Centered Brand Logo
|
# Top Centered Brand Logo
|
||||||
brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent")
|
brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent")
|
||||||
brand_container.pack(fill="x", padx=16, pady=(20, 14))
|
brand_container.pack(fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_LG, Theme.SPACE_SM))
|
||||||
|
|
||||||
logo_img = AssetManager.get().get_logo_image(size=(72, 72))
|
logo_img = AssetManager.get().get_logo_image(size=(58, 58))
|
||||||
if logo_img:
|
if logo_img:
|
||||||
logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="")
|
logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="")
|
||||||
logo_lbl.pack(anchor="center", pady=(0, 6))
|
logo_lbl.pack(anchor="center", pady=(0, 6))
|
||||||
|
|
@ -229,72 +242,136 @@ class HermesHubApp(ctk.CTk):
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
brand_container, text="HERMES HUB", font=Theme.font_title_hero(), text_color=Theme.TEXT_ACCENT
|
brand_container, text="HERMES HUB", font=Theme.font_title_hero(), text_color=Theme.TEXT_ACCENT
|
||||||
).pack(anchor="center")
|
).pack(anchor="center")
|
||||||
ctk.CTkLabel(
|
ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER_ACCENT).pack(
|
||||||
brand_container, text="Control Center", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED
|
fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_XS, Theme.SPACE_SM)
|
||||||
).pack(anchor="center")
|
)
|
||||||
|
|
||||||
# Divider
|
|
||||||
ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER).pack(fill="x", padx=14, pady=(6, 12))
|
|
||||||
|
|
||||||
# Nav Items with clean Fluent glyphs
|
# Nav Items with clean Fluent glyphs
|
||||||
self._nav_items = [
|
self._nav_items = [
|
||||||
("overview", "Обзор", "⌂"),
|
("overview", "Обзор", "⌂"),
|
||||||
("team", "Команда", "👥"),
|
("team", "Команда", "♙"),
|
||||||
("accounts", "Аккаунты", "🔍"),
|
("accounts", "Аккаунты", "◎"),
|
||||||
("routing", "Маршрутизация", "🔀"),
|
("routing", "Маршрутизация", "⌘"),
|
||||||
("providers", "Провайдеры", "🌐"),
|
("providers", "Провайдеры", "◉"),
|
||||||
("health", "Состояние системы", "🛡️"),
|
("quotas", "Квоты и лимиты", "▥"),
|
||||||
("logs", "Журнал событий", "📜"),
|
("analytics", "Аналитика", "⌁"),
|
||||||
("settings", "Настройки", "⚙️"),
|
("health", "Состояние", "◇"),
|
||||||
("about", "О программе", "ℹ️"),
|
("logs", "Журнал событий", "▤"),
|
||||||
|
("settings", "Настройки", "⚙"),
|
||||||
|
("about", "О программе", "ⓘ"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
self.nav_frame = ctk.CTkScrollableFrame(
|
||||||
|
self.sidebar,
|
||||||
|
fg_color="transparent",
|
||||||
|
corner_radius=0,
|
||||||
|
scrollbar_fg_color=Theme.BG_SIDEBAR,
|
||||||
|
scrollbar_button_color=Theme.BG_SIDEBAR,
|
||||||
|
scrollbar_button_hover_color=Theme.BORDER_HOVER,
|
||||||
|
)
|
||||||
|
self.nav_frame.pack(fill="both", expand=True)
|
||||||
self._nav_buttons: Dict[str, ctk.CTkButton] = {}
|
self._nav_buttons: Dict[str, ctk.CTkButton] = {}
|
||||||
for key, label, icon in self._nav_items:
|
for key, label, icon in self._nav_items:
|
||||||
btn = ctk.CTkButton(
|
btn = ctk.CTkButton(
|
||||||
self.sidebar,
|
self.nav_frame,
|
||||||
text=f" {icon} {label}",
|
text=f" {icon} {label}",
|
||||||
font=Theme.font_body(),
|
font=Theme.font_body(),
|
||||||
height=Theme.HEIGHT_NAV_ITEM,
|
height=Theme.HEIGHT_NAV_ITEM,
|
||||||
fg_color="transparent",
|
fg_color="transparent",
|
||||||
hover_color=Theme.SURFACE_HOVER,
|
hover_color=Theme.SIDEBAR_HOVER,
|
||||||
text_color=Theme.TEXT_PRIMARY,
|
text_color=Theme.SIDEBAR_TEXT,
|
||||||
anchor="w",
|
anchor="w",
|
||||||
corner_radius=Theme.RADIUS_SM,
|
corner_radius=Theme.RADIUS_SM,
|
||||||
command=lambda k=key: self._show_view(k),
|
command=lambda k=key: self._show_view(k),
|
||||||
)
|
)
|
||||||
btn.pack(fill="x", padx=12, pady=2)
|
btn.pack(fill="x", padx=Theme.SPACE_SM, pady=1)
|
||||||
self._nav_buttons[key] = btn
|
self._nav_buttons[key] = btn
|
||||||
|
|
||||||
# Refresh button at bottom of sidebar
|
self.sidebar_version = ctk.CTkLabel(
|
||||||
refresh_btn = HubButton(
|
|
||||||
self.sidebar,
|
self.sidebar,
|
||||||
text="🔄 Обновить",
|
text=f"Hermes Hub v{__version__}",
|
||||||
variant="secondary",
|
font=Theme.font_micro(),
|
||||||
height=38,
|
text_color=Theme.SIDEBAR_MUTED,
|
||||||
command=self._refresh_data,
|
|
||||||
)
|
)
|
||||||
refresh_btn.pack(side="bottom", fill="x", padx=14, pady=16)
|
self.sidebar_version.pack(side="bottom", pady=(0, Theme.SPACE_SM))
|
||||||
|
user_card = ctk.CTkFrame(
|
||||||
|
self.sidebar,
|
||||||
|
fg_color=Theme.SIDEBAR_SELECTED,
|
||||||
|
border_width=1,
|
||||||
|
border_color=Theme.BORDER,
|
||||||
|
corner_radius=Theme.RADIUS_MD,
|
||||||
|
)
|
||||||
|
user_card.pack(side="bottom", fill="x", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
user_card,
|
||||||
|
text="AD",
|
||||||
|
width=30,
|
||||||
|
height=30,
|
||||||
|
corner_radius=15,
|
||||||
|
fg_color=Theme.ACCENT,
|
||||||
|
text_color=Theme.TEXT_ON_ACCENT,
|
||||||
|
font=Theme.font_badge_bold(),
|
||||||
|
).pack(side="left", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
user_card,
|
||||||
|
text="Administrator\nОсновная команда",
|
||||||
|
justify="left",
|
||||||
|
font=Theme.font_micro(),
|
||||||
|
text_color=Theme.SIDEBAR_TEXT,
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
# ── Status Bar (Bottom) ──
|
# ── Global top bar ──
|
||||||
self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_STATUSBAR, fg_color=Theme.BG_STATUSBAR, corner_radius=0)
|
self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_HEADER, fg_color=Theme.BG_HEADER, corner_radius=0)
|
||||||
self.statusbar.pack(side="bottom", fill="x")
|
self.statusbar.pack(side="top", fill="x")
|
||||||
|
self.statusbar.pack_propagate(False)
|
||||||
|
|
||||||
self.status_left = ctk.CTkLabel(
|
self.status_left = ctk.CTkLabel(
|
||||||
self.statusbar,
|
self.statusbar,
|
||||||
text="Аккаунты: ... | Роли: ... | Обновлено: ...",
|
text="● Состояние загружается",
|
||||||
font=Theme.font_micro(),
|
font=Theme.font_caption(),
|
||||||
text_color=Theme.TEXT_MUTED,
|
text_color=Theme.STATUS_HEALTHY,
|
||||||
)
|
)
|
||||||
self.status_left.pack(side="left", padx=16)
|
self.status_left.pack(side="left", padx=Theme.SPACE_LG)
|
||||||
|
|
||||||
|
self.global_search = ctk.CTkEntry(
|
||||||
|
self.statusbar,
|
||||||
|
placeholder_text="Поиск по агентам, аккаунтам, задачам… Ctrl + K",
|
||||||
|
width=360,
|
||||||
|
height=Theme.HEIGHT_INPUT,
|
||||||
|
fg_color=Theme.SURFACE,
|
||||||
|
border_color=Theme.BORDER,
|
||||||
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
|
)
|
||||||
|
self.global_search.pack(side="left", padx=Theme.SPACE_MD)
|
||||||
|
self.global_search.bind("<Return>", self._run_global_search)
|
||||||
|
self.bind_all("<Control-k>", self._focus_global_search)
|
||||||
|
|
||||||
|
self.add_account_button = HubButton(
|
||||||
|
self.statusbar,
|
||||||
|
text="+ Добавить аккаунт",
|
||||||
|
variant="primary",
|
||||||
|
command=lambda: self._handle_action("add_account", {}),
|
||||||
|
)
|
||||||
|
self.add_account_button.pack(side="right", padx=(Theme.SPACE_XS, Theme.SPACE_LG))
|
||||||
|
for text, command in (
|
||||||
|
("⚙", lambda: self._show_view("settings")),
|
||||||
|
("?", lambda: self._show_view("about")),
|
||||||
|
("♧", lambda: self._show_view("logs")),
|
||||||
|
):
|
||||||
|
HubButton(
|
||||||
|
self.statusbar,
|
||||||
|
text=text,
|
||||||
|
variant="ghost",
|
||||||
|
width=Theme.HEIGHT_BTN_MD,
|
||||||
|
command=command,
|
||||||
|
).pack(side="right", padx=Theme.SPACE_XS)
|
||||||
|
|
||||||
self.status_right = ctk.CTkLabel(
|
self.status_right = ctk.CTkLabel(
|
||||||
self.statusbar,
|
self.statusbar,
|
||||||
text="● Hermes работает",
|
text="Snapshot: Н/Д",
|
||||||
font=Theme.font_micro(),
|
font=Theme.font_micro(),
|
||||||
text_color=Theme.STATUS_HEALTHY,
|
text_color=Theme.TEXT_MUTED,
|
||||||
)
|
)
|
||||||
self.status_right.pack(side="right", padx=16)
|
|
||||||
|
|
||||||
# ── Main Content Area ──
|
# ── Main Content Area ──
|
||||||
self.content = ctk.CTkFrame(self, fg_color=Theme.BG_WINDOW, corner_radius=0)
|
self.content = ctk.CTkFrame(self, fg_color=Theme.BG_WINDOW, corner_radius=0)
|
||||||
|
|
@ -304,6 +381,20 @@ class HermesHubApp(ctk.CTk):
|
||||||
for key, _, _ in self._nav_items:
|
for key, _, _ in self._nav_items:
|
||||||
self._views[key] = self._create_view(key)
|
self._views[key] = self._create_view(key)
|
||||||
|
|
||||||
|
def _focus_global_search(self, _event=None) -> str:
|
||||||
|
self.global_search.focus_set()
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
def _run_global_search(self, _event=None) -> str:
|
||||||
|
query = self.global_search.get().strip()
|
||||||
|
self._show_view("accounts")
|
||||||
|
accounts = self._views.get("accounts")
|
||||||
|
if accounts and hasattr(accounts, "search"):
|
||||||
|
accounts.search.delete(0, "end")
|
||||||
|
accounts.search.insert(0, query)
|
||||||
|
accounts._set_search(query)
|
||||||
|
return "break"
|
||||||
|
|
||||||
def _create_view(self, view_name: str) -> ctk.CTkFrame:
|
def _create_view(self, view_name: str) -> ctk.CTkFrame:
|
||||||
"""Create view widget instance."""
|
"""Create view widget instance."""
|
||||||
if view_name == "overview":
|
if view_name == "overview":
|
||||||
|
|
@ -321,12 +412,16 @@ class HermesHubApp(ctk.CTk):
|
||||||
return ProvidersView(self.content, app_state={}, on_action=self._handle_action)
|
return ProvidersView(self.content, app_state={}, on_action=self._handle_action)
|
||||||
elif view_name == "routing":
|
elif view_name == "routing":
|
||||||
return RoutingView(self.content, on_action=self._handle_action)
|
return RoutingView(self.content, on_action=self._handle_action)
|
||||||
|
elif view_name == "quotas":
|
||||||
|
return QuotasView(self.content)
|
||||||
|
elif view_name == "analytics":
|
||||||
|
return AnalyticsView(self.content)
|
||||||
elif view_name == "health":
|
elif view_name == "health":
|
||||||
return HealthView(self.content, app_state={}, on_refresh=self._refresh_data)
|
return HealthView(self.content, app_state={}, on_refresh=self._refresh_data)
|
||||||
elif view_name == "logs":
|
elif view_name == "logs":
|
||||||
return LogsView(self.content)
|
return LogsView(self.content)
|
||||||
elif view_name == "settings":
|
elif view_name == "settings":
|
||||||
return SettingsView(self.content, on_action=self._handle_action)
|
return SettingsView(self.content, on_action=self._handle_action, theme_name=self._theme_name)
|
||||||
elif view_name == "about":
|
elif view_name == "about":
|
||||||
return AboutView(self.content)
|
return AboutView(self.content)
|
||||||
else:
|
else:
|
||||||
|
|
@ -342,7 +437,7 @@ class HermesHubApp(ctk.CTk):
|
||||||
for key, btn in self._nav_buttons.items():
|
for key, btn in self._nav_buttons.items():
|
||||||
if key == view_name:
|
if key == view_name:
|
||||||
btn.configure(
|
btn.configure(
|
||||||
fg_color=Theme.SURFACE_SELECTED,
|
fg_color=Theme.SIDEBAR_SELECTED,
|
||||||
text_color=Theme.TEXT_ACCENT,
|
text_color=Theme.TEXT_ACCENT,
|
||||||
border_width=1,
|
border_width=1,
|
||||||
border_color=Theme.BORDER_ACCENT,
|
border_color=Theme.BORDER_ACCENT,
|
||||||
|
|
@ -350,7 +445,7 @@ class HermesHubApp(ctk.CTk):
|
||||||
else:
|
else:
|
||||||
btn.configure(
|
btn.configure(
|
||||||
fg_color="transparent",
|
fg_color="transparent",
|
||||||
text_color=Theme.TEXT_PRIMARY,
|
text_color=Theme.SIDEBAR_TEXT,
|
||||||
border_width=0,
|
border_width=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -374,6 +469,7 @@ class HermesHubApp(ctk.CTk):
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning("Error in lazy view update for %s: %s", view_name, ex)
|
logger.warning("Error in lazy view update for %s: %s", view_name, ex)
|
||||||
self._view_generations[view_name] = snap.generation
|
self._view_generations[view_name] = snap.generation
|
||||||
|
self._update_auxiliary_data(target_view)
|
||||||
|
|
||||||
# Instrument tab switch latency
|
# Instrument tab switch latency
|
||||||
el_ms = round((time.time() - t0) * 1000, 2)
|
el_ms = round((time.time() - t0) * 1000, 2)
|
||||||
|
|
@ -439,15 +535,18 @@ class HermesHubApp(ctk.CTk):
|
||||||
|
|
||||||
freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}"
|
freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}"
|
||||||
self.status_left.configure(
|
self.status_left.configure(
|
||||||
text=f"{freshness} | Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}"
|
text=f"● {readiness.title_ru}",
|
||||||
|
text_color=Theme.STATUS_HEALTHY
|
||||||
|
if readiness.state == "healthy"
|
||||||
|
else Theme.STATUS_WARNING
|
||||||
|
if readiness.state in ("limited", "degraded")
|
||||||
|
else Theme.STATUS_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
r_color = (
|
self.status_right.configure(
|
||||||
Theme.STATUS_HEALTHY
|
text=f"{freshness} • {readiness.accounts_connected_count} аккаунтов • {readiness.roles_ready_count} ролей",
|
||||||
if readiness.state == "healthy"
|
text_color=Theme.STATUS_WARNING if snap.is_stale else Theme.TEXT_MUTED,
|
||||||
else (Theme.STATUS_WARNING if readiness.state in ("limited", "degraded") else Theme.STATUS_ERROR)
|
|
||||||
)
|
)
|
||||||
self.status_right.configure(text=f"● {readiness.title_ru}", text_color=r_color)
|
|
||||||
|
|
||||||
# Update ONLY the currently visible view (others are updated lazily on tab switch)
|
# Update ONLY the currently visible view (others are updated lazily on tab switch)
|
||||||
curr_view = self._views.get(self._current_view)
|
curr_view = self._views.get(self._current_view)
|
||||||
|
|
@ -457,6 +556,15 @@ class HermesHubApp(ctk.CTk):
|
||||||
except Exception as ex:
|
except Exception as ex:
|
||||||
logger.warning("Error updating current view %s: %s", self._current_view, ex)
|
logger.warning("Error updating current view %s: %s", self._current_view, ex)
|
||||||
self._view_generations[self._current_view] = snap.generation
|
self._view_generations[self._current_view] = snap.generation
|
||||||
|
self._update_auxiliary_data(curr_view)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _update_auxiliary_data(view: Any) -> None:
|
||||||
|
if hasattr(view, "update_events"):
|
||||||
|
try:
|
||||||
|
view.update_events(EventLogService.get().get_events(limit=20))
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("Error updating event presentation: %s", ex)
|
||||||
|
|
||||||
def _on_data_error(self, error: str):
|
def _on_data_error(self, error: str):
|
||||||
if self._shutting_down:
|
if self._shutting_down:
|
||||||
|
|
@ -517,9 +625,16 @@ class HermesHubApp(ctk.CTk):
|
||||||
elif action == "edit_route":
|
elif action == "edit_route":
|
||||||
self._show_toast("Редактор цепочки использует кнопки и селекторы; drag-and-drop отключён.")
|
self._show_toast("Редактор цепочки использует кнопки и селекторы; drag-and-drop отключён.")
|
||||||
elif action == "save_settings":
|
elif action == "save_settings":
|
||||||
|
|
||||||
|
def _settings_saved(result: Tuple[bool, str]) -> None:
|
||||||
|
requested_theme = str(data.get("theme", self._theme_name))
|
||||||
|
if requested_theme != self._theme_name:
|
||||||
|
self._apply_theme(requested_theme)
|
||||||
|
self._show_toast(f"✅ {result[1]}")
|
||||||
|
|
||||||
self._run_in_thread(
|
self._run_in_thread(
|
||||||
lambda: do_save_settings(data),
|
lambda: do_save_settings(data),
|
||||||
on_success=lambda result: self._show_toast(f"✅ {result[1]}"),
|
on_success=_settings_saved,
|
||||||
)
|
)
|
||||||
elif action == "check_updates":
|
elif action == "check_updates":
|
||||||
from antigravity_provider.updater import UpdateManager
|
from antigravity_provider.updater import UpdateManager
|
||||||
|
|
@ -592,6 +707,19 @@ class HermesHubApp(ctk.CTk):
|
||||||
msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
|
msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
|
||||||
self._show_toast(msg)
|
self._show_toast(msg)
|
||||||
|
|
||||||
|
def _apply_theme(self, scheme: str) -> None:
|
||||||
|
"""Apply all palette tokens by rebuilding presentation widgets in place."""
|
||||||
|
current_view = self._current_view
|
||||||
|
self._theme_name = Theme.apply_scheme(scheme)
|
||||||
|
ctk.set_appearance_mode("dark" if self._theme_name == "dark" else "light")
|
||||||
|
for child in list(self.winfo_children()):
|
||||||
|
child.destroy()
|
||||||
|
self._views.clear()
|
||||||
|
self._view_generations.clear()
|
||||||
|
self.configure(fg_color=Theme.BG_WINDOW)
|
||||||
|
self._build_layout()
|
||||||
|
self._show_view(current_view)
|
||||||
|
|
||||||
def _run_in_thread(self, func, on_success=None, on_error=None):
|
def _run_in_thread(self, func, on_success=None, on_error=None):
|
||||||
def _worker():
|
def _worker():
|
||||||
if self._shutting_down:
|
if self._shutting_down:
|
||||||
|
|
@ -627,7 +755,12 @@ class HermesHubApp(ctk.CTk):
|
||||||
|
|
||||||
readiness = HubStateStore.get().get_snapshot().readiness
|
readiness = HubStateStore.get().get_snapshot().readiness
|
||||||
self.status_left.configure(
|
self.status_left.configure(
|
||||||
text=f"Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}"
|
text=f"● {readiness.title_ru}",
|
||||||
|
text_color=Theme.STATUS_HEALTHY
|
||||||
|
if readiness.state == "healthy"
|
||||||
|
else Theme.STATUS_WARNING
|
||||||
|
if readiness.state in ("limited", "degraded")
|
||||||
|
else Theme.STATUS_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_close(self):
|
def _on_close(self):
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Hermes Hub UI Package — Native Dark-First Design System."""
|
"""Hermes Hub UI Package — Native Dark-First Design System."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ Supports 5 AI Providers:
|
||||||
|
|
||||||
Includes multi-account profile isolation, duplicate detection, and auto-assignment to router roles.
|
Includes multi-account profile isolation, duplicate detection, and auto-assignment to router roles.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
@ -80,6 +81,7 @@ class AddAccountWizard(HubModal):
|
||||||
if self.oauth_session_id:
|
if self.oauth_session_id:
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.profile_oauth import cancel_oauth_session
|
from antigravity_provider.router.profile_oauth import cancel_oauth_session
|
||||||
|
|
||||||
cancel_oauth_session(self.oauth_session_id)
|
cancel_oauth_session(self.oauth_session_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -89,6 +91,7 @@ class AddAccountWizard(HubModal):
|
||||||
if self.codex_session_id:
|
if self.codex_session_id:
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.codex_oauth import cancel_codex_oauth_session
|
from antigravity_provider.router.codex_oauth import cancel_codex_oauth_session
|
||||||
|
|
||||||
cancel_codex_oauth_session(self.codex_session_id)
|
cancel_codex_oauth_session(self.codex_session_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -97,6 +100,7 @@ class AddAccountWizard(HubModal):
|
||||||
if self.claude_session_id:
|
if self.claude_session_id:
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.claude_oauth import cancel_claude_oauth_session
|
from antigravity_provider.router.claude_oauth import cancel_claude_oauth_session
|
||||||
|
|
||||||
cancel_claude_oauth_session(self.claude_session_id)
|
cancel_claude_oauth_session(self.claude_session_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -105,6 +109,7 @@ class AddAccountWizard(HubModal):
|
||||||
if self.grok_session_id:
|
if self.grok_session_id:
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.grok_oauth import cancel_grok_oauth_session
|
from antigravity_provider.router.grok_oauth import cancel_grok_oauth_session
|
||||||
|
|
||||||
cancel_grok_oauth_session(self.grok_session_id)
|
cancel_grok_oauth_session(self.grok_session_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -129,10 +134,10 @@ class AddAccountWizard(HubModal):
|
||||||
|
|
||||||
providers = [
|
providers = [
|
||||||
("antigravity", "Google Antigravity", "OAuth 2.0 (Google Account)", Theme.ACCENT),
|
("antigravity", "Google Antigravity", "OAuth 2.0 (Google Account)", Theme.ACCENT),
|
||||||
("openai-codex", "OpenAI Codex", "OAuth (ChatGPT) или API Key", "#10a37f"),
|
("openai-codex", "OpenAI Codex", "OAuth (ChatGPT) или API Key", Theme.PROVIDER_CODEX),
|
||||||
("claude", "Claude (Anthropic)", "OAuth (Claude Pro/Max) или API Key", "#d97706"),
|
("claude", "Claude (Anthropic)", "OAuth (Claude Pro/Max) или API Key", Theme.PROVIDER_CLAUDE),
|
||||||
("grok", "Grok (xAI)", "OAuth (SuperGrok) или API Key", "#3b82f6"),
|
("grok", "Grok (xAI)", "OAuth (SuperGrok) или API Key", Theme.PROVIDER_GROK),
|
||||||
("opencode-go", "OpenCode Go", "API Key / Subscription", "#8b5cf6"),
|
("opencode-go", "OpenCode Go", "API Key / Subscription", Theme.PROVIDER_GENERIC),
|
||||||
]
|
]
|
||||||
|
|
||||||
self.provider_var = ctk.StringVar(value=self.selected_provider)
|
self.provider_var = ctk.StringVar(value=self.selected_provider)
|
||||||
|
|
@ -193,7 +198,9 @@ class AddAccountWizard(HubModal):
|
||||||
self._clear_body()
|
self._clear_body()
|
||||||
self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи")
|
self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи")
|
||||||
|
|
||||||
self.target_slot = AutoAssigner.find_free_slot(self.selected_provider) or f"{self.selected_provider[:3]}-spare-1"
|
self.target_slot = (
|
||||||
|
AutoAssigner.find_free_slot(self.selected_provider) or f"{self.selected_provider[:3]}-spare-1"
|
||||||
|
)
|
||||||
|
|
||||||
if self.selected_provider == "antigravity":
|
if self.selected_provider == "antigravity":
|
||||||
self._build_antigravity_oauth_flow()
|
self._build_antigravity_oauth_flow()
|
||||||
|
|
@ -342,6 +349,7 @@ class AddAccountWizard(HubModal):
|
||||||
def _init_antigravity_oauth(self):
|
def _init_antigravity_oauth(self):
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.profile_oauth import start_profile_oauth
|
from antigravity_provider.router.profile_oauth import start_profile_oauth
|
||||||
|
|
||||||
session_id, auth_url, port = start_profile_oauth(self.target_slot)
|
session_id, auth_url, port = start_profile_oauth(self.target_slot)
|
||||||
self.oauth_session_id = session_id
|
self.oauth_session_id = session_id
|
||||||
self.oauth_url = auth_url
|
self.oauth_url = auth_url
|
||||||
|
|
@ -381,6 +389,7 @@ class AddAccountWizard(HubModal):
|
||||||
return
|
return
|
||||||
|
|
||||||
from antigravity_provider.router.profile_oauth import get_oauth_session
|
from antigravity_provider.router.profile_oauth import get_oauth_session
|
||||||
|
|
||||||
session = get_oauth_session(self.oauth_session_id)
|
session = get_oauth_session(self.oauth_session_id)
|
||||||
if not session:
|
if not session:
|
||||||
self.oauth_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
|
self.oauth_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
|
||||||
|
|
@ -399,6 +408,7 @@ class AddAccountWizard(HubModal):
|
||||||
|
|
||||||
def _poll_antigravity_oauth(self):
|
def _poll_antigravity_oauth(self):
|
||||||
from antigravity_provider.router.profile_oauth import get_oauth_session
|
from antigravity_provider.router.profile_oauth import get_oauth_session
|
||||||
|
|
||||||
for _ in range(300):
|
for _ in range(300):
|
||||||
if not self._polling_active:
|
if not self._polling_active:
|
||||||
return
|
return
|
||||||
|
|
@ -418,7 +428,9 @@ class AddAccountWizard(HubModal):
|
||||||
return
|
return
|
||||||
elif status in ("error", "failed", "cancelled"):
|
elif status in ("error", "failed", "cancelled"):
|
||||||
err_msg = getattr(session, "error_msg", None) or "Авторизация не удалась"
|
err_msg = getattr(session, "error_msg", None) or "Авторизация не удалась"
|
||||||
self.after(0, lambda m=err_msg: self.oauth_status_lbl.configure(text=f"❌ {m}", text_color=Theme.STATUS_ERROR))
|
self.after(
|
||||||
|
0, lambda m=err_msg: self.oauth_status_lbl.configure(text=f"❌ {m}", text_color=Theme.STATUS_ERROR)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
@ -585,6 +597,7 @@ class AddAccountWizard(HubModal):
|
||||||
def _init_codex_oauth(self):
|
def _init_codex_oauth(self):
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.codex_oauth import start_codex_oauth, get_codex_oauth_session
|
from antigravity_provider.router.codex_oauth import start_codex_oauth, get_codex_oauth_session
|
||||||
|
|
||||||
session_id, url, code = start_codex_oauth(self.target_slot)
|
session_id, url, code = start_codex_oauth(self.target_slot)
|
||||||
self.codex_session_id = session_id
|
self.codex_session_id = session_id
|
||||||
self.codex_url = url
|
self.codex_url = url
|
||||||
|
|
@ -637,6 +650,7 @@ class AddAccountWizard(HubModal):
|
||||||
return
|
return
|
||||||
|
|
||||||
from antigravity_provider.router.codex_oauth import get_codex_oauth_session
|
from antigravity_provider.router.codex_oauth import get_codex_oauth_session
|
||||||
|
|
||||||
session = get_codex_oauth_session(self.codex_session_id)
|
session = get_codex_oauth_session(self.codex_session_id)
|
||||||
if not session:
|
if not session:
|
||||||
self.codex_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
|
self.codex_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
|
||||||
|
|
@ -655,6 +669,7 @@ class AddAccountWizard(HubModal):
|
||||||
|
|
||||||
def _poll_codex_oauth(self):
|
def _poll_codex_oauth(self):
|
||||||
from antigravity_provider.router.codex_oauth import get_codex_oauth_session
|
from antigravity_provider.router.codex_oauth import get_codex_oauth_session
|
||||||
|
|
||||||
for _ in range(900):
|
for _ in range(900):
|
||||||
if not self._polling_active:
|
if not self._polling_active:
|
||||||
return
|
return
|
||||||
|
|
@ -689,7 +704,7 @@ class AddAccountWizard(HubModal):
|
||||||
value="oauth",
|
value="oauth",
|
||||||
variable=self.claude_mode_var,
|
variable=self.claude_mode_var,
|
||||||
font=Theme.font_body_bold(),
|
font=Theme.font_body_bold(),
|
||||||
fg_color="#d97706",
|
fg_color=Theme.PROVIDER_CLAUDE,
|
||||||
command=self._on_claude_mode_toggle,
|
command=self._on_claude_mode_toggle,
|
||||||
)
|
)
|
||||||
rb1.pack(anchor="w", padx=4, pady=2)
|
rb1.pack(anchor="w", padx=4, pady=2)
|
||||||
|
|
@ -700,7 +715,7 @@ class AddAccountWizard(HubModal):
|
||||||
value="api_key",
|
value="api_key",
|
||||||
variable=self.claude_mode_var,
|
variable=self.claude_mode_var,
|
||||||
font=Theme.font_body(),
|
font=Theme.font_body(),
|
||||||
fg_color="#d97706",
|
fg_color=Theme.PROVIDER_CLAUDE,
|
||||||
command=self._on_claude_mode_toggle,
|
command=self._on_claude_mode_toggle,
|
||||||
)
|
)
|
||||||
rb2.pack(anchor="w", padx=4, pady=2)
|
rb2.pack(anchor="w", padx=4, pady=2)
|
||||||
|
|
@ -810,6 +825,7 @@ class AddAccountWizard(HubModal):
|
||||||
def _init_claude_oauth(self):
|
def _init_claude_oauth(self):
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.claude_oauth import start_claude_oauth
|
from antigravity_provider.router.claude_oauth import start_claude_oauth
|
||||||
|
|
||||||
session_id, url = start_claude_oauth(self.target_slot)
|
session_id, url = start_claude_oauth(self.target_slot)
|
||||||
self.claude_session_id = session_id
|
self.claude_session_id = session_id
|
||||||
self.claude_url = url
|
self.claude_url = url
|
||||||
|
|
@ -836,6 +852,7 @@ class AddAccountWizard(HubModal):
|
||||||
return
|
return
|
||||||
|
|
||||||
from antigravity_provider.router.claude_oauth import get_claude_oauth_session
|
from antigravity_provider.router.claude_oauth import get_claude_oauth_session
|
||||||
|
|
||||||
session = get_claude_oauth_session(self.claude_session_id)
|
session = get_claude_oauth_session(self.claude_session_id)
|
||||||
if not session:
|
if not session:
|
||||||
self.claude_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
|
self.claude_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
|
||||||
|
|
@ -868,7 +885,7 @@ class AddAccountWizard(HubModal):
|
||||||
value="oauth",
|
value="oauth",
|
||||||
variable=self.grok_mode_var,
|
variable=self.grok_mode_var,
|
||||||
font=Theme.font_body_bold(),
|
font=Theme.font_body_bold(),
|
||||||
fg_color="#3b82f6",
|
fg_color=Theme.PROVIDER_GROK,
|
||||||
command=self._on_grok_mode_toggle,
|
command=self._on_grok_mode_toggle,
|
||||||
)
|
)
|
||||||
rb1.pack(anchor="w", padx=4, pady=2)
|
rb1.pack(anchor="w", padx=4, pady=2)
|
||||||
|
|
@ -879,7 +896,7 @@ class AddAccountWizard(HubModal):
|
||||||
value="api_key",
|
value="api_key",
|
||||||
variable=self.grok_mode_var,
|
variable=self.grok_mode_var,
|
||||||
font=Theme.font_body(),
|
font=Theme.font_body(),
|
||||||
fg_color="#3b82f6",
|
fg_color=Theme.PROVIDER_GROK,
|
||||||
command=self._on_grok_mode_toggle,
|
command=self._on_grok_mode_toggle,
|
||||||
)
|
)
|
||||||
rb2.pack(anchor="w", padx=4, pady=2)
|
rb2.pack(anchor="w", padx=4, pady=2)
|
||||||
|
|
@ -930,7 +947,7 @@ class AddAccountWizard(HubModal):
|
||||||
code_card,
|
code_card,
|
||||||
text="...",
|
text="...",
|
||||||
font=Theme.font_mono_bold(),
|
font=Theme.font_mono_bold(),
|
||||||
text_color="#3b82f6",
|
text_color=Theme.PROVIDER_GROK,
|
||||||
)
|
)
|
||||||
self.grok_code_lbl.pack(side="left", padx=(0, 8))
|
self.grok_code_lbl.pack(side="left", padx=(0, 8))
|
||||||
|
|
||||||
|
|
@ -1016,6 +1033,7 @@ class AddAccountWizard(HubModal):
|
||||||
def _init_grok_oauth(self):
|
def _init_grok_oauth(self):
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.grok_oauth import start_grok_oauth, get_grok_oauth_session
|
from antigravity_provider.router.grok_oauth import start_grok_oauth, get_grok_oauth_session
|
||||||
|
|
||||||
session_id, url, code = start_grok_oauth(self.target_slot)
|
session_id, url, code = start_grok_oauth(self.target_slot)
|
||||||
self.grok_session_id = session_id
|
self.grok_session_id = session_id
|
||||||
self.grok_url = url
|
self.grok_url = url
|
||||||
|
|
@ -1068,6 +1086,7 @@ class AddAccountWizard(HubModal):
|
||||||
return
|
return
|
||||||
|
|
||||||
from antigravity_provider.router.grok_oauth import get_grok_oauth_session
|
from antigravity_provider.router.grok_oauth import get_grok_oauth_session
|
||||||
|
|
||||||
session = get_grok_oauth_session(self.grok_session_id)
|
session = get_grok_oauth_session(self.grok_session_id)
|
||||||
if not session:
|
if not session:
|
||||||
self.grok_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
|
self.grok_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
|
||||||
|
|
@ -1086,6 +1105,7 @@ class AddAccountWizard(HubModal):
|
||||||
|
|
||||||
def _poll_grok_oauth(self):
|
def _poll_grok_oauth(self):
|
||||||
from antigravity_provider.router.grok_oauth import get_grok_oauth_session
|
from antigravity_provider.router.grok_oauth import get_grok_oauth_session
|
||||||
|
|
||||||
for _ in range(900):
|
for _ in range(900):
|
||||||
if not self._polling_active:
|
if not self._polling_active:
|
||||||
return
|
return
|
||||||
|
|
@ -1114,20 +1134,68 @@ class AddAccountWizard(HubModal):
|
||||||
mode_card = HubCard(self.body, fg_color="transparent")
|
mode_card = HubCard(self.body, fg_color="transparent")
|
||||||
mode_card.pack(fill="x", pady=(0, 8))
|
mode_card.pack(fill="x", pady=(0, 8))
|
||||||
self.codex_mode_var = ctk.StringVar(value="api_key")
|
self.codex_mode_var = ctk.StringVar(value="api_key")
|
||||||
ctk.CTkRadioButton(mode_card, text="OAuth — OpenAI / ChatGPT", value="oauth", variable=self.codex_mode_var, font=Theme.font_body(), fg_color=Theme.ACCENT, command=self._on_codex_mode_toggle).pack(anchor="w", padx=4, pady=2)
|
ctk.CTkRadioButton(
|
||||||
ctk.CTkRadioButton(mode_card, text="API Key — OpenAI API (sk-...)", value="api_key", variable=self.codex_mode_var, font=Theme.font_body_bold(), fg_color=Theme.ACCENT, command=self._on_codex_mode_toggle).pack(anchor="w", padx=4, pady=2)
|
mode_card,
|
||||||
|
text="OAuth — OpenAI / ChatGPT",
|
||||||
|
value="oauth",
|
||||||
|
variable=self.codex_mode_var,
|
||||||
|
font=Theme.font_body(),
|
||||||
|
fg_color=Theme.ACCENT,
|
||||||
|
command=self._on_codex_mode_toggle,
|
||||||
|
).pack(anchor="w", padx=4, pady=2)
|
||||||
|
ctk.CTkRadioButton(
|
||||||
|
mode_card,
|
||||||
|
text="API Key — OpenAI API (sk-...)",
|
||||||
|
value="api_key",
|
||||||
|
variable=self.codex_mode_var,
|
||||||
|
font=Theme.font_body_bold(),
|
||||||
|
fg_color=Theme.ACCENT,
|
||||||
|
command=self._on_codex_mode_toggle,
|
||||||
|
).pack(anchor="w", padx=4, pady=2)
|
||||||
elif self.selected_provider == "claude":
|
elif self.selected_provider == "claude":
|
||||||
mode_card = HubCard(self.body, fg_color="transparent")
|
mode_card = HubCard(self.body, fg_color="transparent")
|
||||||
mode_card.pack(fill="x", pady=(0, 8))
|
mode_card.pack(fill="x", pady=(0, 8))
|
||||||
self.claude_mode_var = ctk.StringVar(value="api_key")
|
self.claude_mode_var = ctk.StringVar(value="api_key")
|
||||||
ctk.CTkRadioButton(mode_card, text="OAuth — Claude Pro/Max", value="oauth", variable=self.claude_mode_var, font=Theme.font_body(), fg_color="#d97706", command=self._on_claude_mode_toggle).pack(anchor="w", padx=4, pady=2)
|
ctk.CTkRadioButton(
|
||||||
ctk.CTkRadioButton(mode_card, text="API Key — Anthropic API (sk-ant-...)", value="api_key", variable=self.claude_mode_var, font=Theme.font_body_bold(), fg_color="#d97706", command=self._on_claude_mode_toggle).pack(anchor="w", padx=4, pady=2)
|
mode_card,
|
||||||
|
text="OAuth — Claude Pro/Max",
|
||||||
|
value="oauth",
|
||||||
|
variable=self.claude_mode_var,
|
||||||
|
font=Theme.font_body(),
|
||||||
|
fg_color=Theme.PROVIDER_CLAUDE,
|
||||||
|
command=self._on_claude_mode_toggle,
|
||||||
|
).pack(anchor="w", padx=4, pady=2)
|
||||||
|
ctk.CTkRadioButton(
|
||||||
|
mode_card,
|
||||||
|
text="API Key — Anthropic API (sk-ant-...)",
|
||||||
|
value="api_key",
|
||||||
|
variable=self.claude_mode_var,
|
||||||
|
font=Theme.font_body_bold(),
|
||||||
|
fg_color=Theme.PROVIDER_CLAUDE,
|
||||||
|
command=self._on_claude_mode_toggle,
|
||||||
|
).pack(anchor="w", padx=4, pady=2)
|
||||||
elif self.selected_provider == "grok":
|
elif self.selected_provider == "grok":
|
||||||
mode_card = HubCard(self.body, fg_color="transparent")
|
mode_card = HubCard(self.body, fg_color="transparent")
|
||||||
mode_card.pack(fill="x", pady=(0, 8))
|
mode_card.pack(fill="x", pady=(0, 8))
|
||||||
self.grok_mode_var = ctk.StringVar(value="api_key")
|
self.grok_mode_var = ctk.StringVar(value="api_key")
|
||||||
ctk.CTkRadioButton(mode_card, text="OAuth — xAI / SuperGrok", value="oauth", variable=self.grok_mode_var, font=Theme.font_body(), fg_color="#3b82f6", command=self._on_grok_mode_toggle).pack(anchor="w", padx=4, pady=2)
|
ctk.CTkRadioButton(
|
||||||
ctk.CTkRadioButton(mode_card, text="API Key — xAI API (xai-...)", value="api_key", variable=self.grok_mode_var, font=Theme.font_body_bold(), fg_color="#3b82f6", command=self._on_grok_mode_toggle).pack(anchor="w", padx=4, pady=2)
|
mode_card,
|
||||||
|
text="OAuth — xAI / SuperGrok",
|
||||||
|
value="oauth",
|
||||||
|
variable=self.grok_mode_var,
|
||||||
|
font=Theme.font_body(),
|
||||||
|
fg_color=Theme.PROVIDER_GROK,
|
||||||
|
command=self._on_grok_mode_toggle,
|
||||||
|
).pack(anchor="w", padx=4, pady=2)
|
||||||
|
ctk.CTkRadioButton(
|
||||||
|
mode_card,
|
||||||
|
text="API Key — xAI API (xai-...)",
|
||||||
|
value="api_key",
|
||||||
|
variable=self.grok_mode_var,
|
||||||
|
font=Theme.font_body_bold(),
|
||||||
|
fg_color=Theme.PROVIDER_GROK,
|
||||||
|
command=self._on_grok_mode_toggle,
|
||||||
|
).pack(anchor="w", padx=4, pady=2)
|
||||||
|
|
||||||
prompt_map = {
|
prompt_map = {
|
||||||
"openai-codex": "Введите ключ OpenAI API (sk-...):",
|
"openai-codex": "Введите ключ OpenAI API (sk-...):",
|
||||||
|
|
@ -1274,25 +1342,38 @@ class AddAccountWizard(HubModal):
|
||||||
# Identity line
|
# Identity line
|
||||||
id_row = ctk.CTkFrame(card, fg_color="transparent")
|
id_row = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
id_row.pack(fill="x", padx=14, pady=2)
|
id_row.pack(fill="x", padx=14, pady=2)
|
||||||
ctk.CTkLabel(id_row, text="Идентификатор:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="left")
|
ctk.CTkLabel(id_row, text="Идентификатор:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
|
||||||
ctk.CTkLabel(id_row, text=self.discovered_identity or "—", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY).pack(side="right")
|
side="left"
|
||||||
|
)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
id_row, text=self.discovered_identity or "—", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
# Plan line
|
# Plan line
|
||||||
plan_row = ctk.CTkFrame(card, fg_color="transparent")
|
plan_row = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
plan_row.pack(fill="x", padx=14, pady=2)
|
plan_row.pack(fill="x", padx=14, pady=2)
|
||||||
ctk.CTkLabel(plan_row, text="Тариф:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="left")
|
ctk.CTkLabel(plan_row, text="Тариф:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
|
||||||
ctk.CTkLabel(plan_row, text=self.discovered_plan or "Тариф: неизвестен", font=Theme.font_body_bold(), text_color=Theme.ACCENT).pack(side="right")
|
side="left"
|
||||||
|
)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
plan_row,
|
||||||
|
text=self.discovered_plan or "Тариф: неизвестен",
|
||||||
|
font=Theme.font_body_bold(),
|
||||||
|
text_color=Theme.ACCENT,
|
||||||
|
).pack(side="right")
|
||||||
|
|
||||||
# Status line
|
# Status line
|
||||||
stat_row = ctk.CTkFrame(card, fg_color="transparent")
|
stat_row = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
stat_row.pack(fill="x", padx=14, pady=2)
|
stat_row.pack(fill="x", padx=14, pady=2)
|
||||||
ctk.CTkLabel(stat_row, text="Статус проверки:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="left")
|
ctk.CTkLabel(
|
||||||
|
stat_row, text="Статус проверки:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY
|
||||||
|
).pack(side="left")
|
||||||
stat_lbl = "✓ Успешно авторизован" if self.is_verified else "Подключен (без проверки)"
|
stat_lbl = "✓ Успешно авторизован" if self.is_verified else "Подключен (без проверки)"
|
||||||
stat_col = Theme.STATUS_HEALTHY if self.is_verified else Theme.STATUS_WARNING
|
stat_col = Theme.STATUS_HEALTHY if self.is_verified else Theme.STATUS_WARNING
|
||||||
ctk.CTkLabel(stat_row, text=stat_lbl, font=Theme.font_body_bold(), text_color=stat_col).pack(side="right")
|
ctk.CTkLabel(stat_row, text=stat_lbl, font=Theme.font_body_bold(), text_color=stat_col).pack(side="right")
|
||||||
|
|
||||||
if dup_profile:
|
if dup_profile:
|
||||||
warn_card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color="#2A2215")
|
warn_card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color=Theme.ACCENT_DIM)
|
||||||
warn_card.pack(fill="x", pady=6)
|
warn_card.pack(fill="x", pady=6)
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
warn_card,
|
warn_card,
|
||||||
|
|
@ -1338,13 +1419,19 @@ class AddAccountWizard(HubModal):
|
||||||
|
|
||||||
r_row = ctk.CTkFrame(card, fg_color="transparent")
|
r_row = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
r_row.pack(fill="x", padx=14, pady=4)
|
r_row.pack(fill="x", padx=14, pady=4)
|
||||||
ctk.CTkLabel(r_row, text="Роль агента:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="left")
|
ctk.CTkLabel(r_row, text="Роль агента:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
|
||||||
|
side="left"
|
||||||
|
)
|
||||||
ctk.CTkLabel(r_row, text=disp_name, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(side="right")
|
ctk.CTkLabel(r_row, text=disp_name, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(side="right")
|
||||||
|
|
||||||
s_row = ctk.CTkFrame(card, fg_color="transparent")
|
s_row = ctk.CTkFrame(card, fg_color="transparent")
|
||||||
s_row.pack(fill="x", padx=14, pady=4)
|
s_row.pack(fill="x", padx=14, pady=4)
|
||||||
ctk.CTkLabel(s_row, text="Внутренний слот:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="left")
|
ctk.CTkLabel(s_row, text="Внутренний слот:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
|
||||||
ctk.CTkLabel(s_row, text=self.target_slot, font=Theme.font_mono(), text_color=Theme.TEXT_MUTED).pack(side="right")
|
side="left"
|
||||||
|
)
|
||||||
|
ctk.CTkLabel(s_row, text=self.target_slot, font=Theme.font_mono(), text_color=Theme.TEXT_MUTED).pack(
|
||||||
|
side="right"
|
||||||
|
)
|
||||||
|
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
self.body,
|
self.body,
|
||||||
|
|
@ -1370,9 +1457,11 @@ class AddAccountWizard(HubModal):
|
||||||
profile_id=self.target_slot,
|
profile_id=self.target_slot,
|
||||||
)
|
)
|
||||||
if self.on_complete:
|
if self.on_complete:
|
||||||
self.on_complete({
|
self.on_complete(
|
||||||
"provider": self.selected_provider,
|
{
|
||||||
"profile_id": self.target_slot,
|
"provider": self.selected_provider,
|
||||||
"identity": self.discovered_identity,
|
"profile_id": self.target_slot,
|
||||||
})
|
"identity": self.discovered_identity,
|
||||||
|
}
|
||||||
|
)
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Hermes Hub — Asset and Icon Manager with Provider Icon Caching."""
|
"""Hermes Hub — Asset and Icon Manager with Provider Icon Caching."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
@ -14,6 +15,7 @@ try:
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import unittest.mock as _mock
|
import unittest.mock as _mock
|
||||||
|
|
||||||
ctk = _mock.MagicMock()
|
ctk = _mock.MagicMock()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -71,7 +73,21 @@ class AssetManager:
|
||||||
if logo_path.exists():
|
if logo_path.exists():
|
||||||
try:
|
try:
|
||||||
pil_img = Image.open(logo_path).convert("RGBA")
|
pil_img = Image.open(logo_path).convert("RGBA")
|
||||||
return ctk.CTkImage(light_image=pil_img, dark_image=pil_img, size=size)
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
|
||||||
|
transparent = pil_img.copy()
|
||||||
|
pixels = []
|
||||||
|
gold = tuple(int(Theme.ACCENT[index : index + 2], 16) for index in (1, 3, 5))
|
||||||
|
tint_gold = Theme.current_scheme in {"dark", "hybrid"}
|
||||||
|
for red, green, blue, alpha in transparent.getdata():
|
||||||
|
if red > 238 and green > 238 and blue > 232:
|
||||||
|
pixels.append((red, green, blue, 0))
|
||||||
|
elif tint_gold:
|
||||||
|
pixels.append((*gold, alpha))
|
||||||
|
else:
|
||||||
|
pixels.append((red, green, blue, alpha))
|
||||||
|
transparent.putdata(pixels)
|
||||||
|
return ctk.CTkImage(light_image=transparent, dark_image=transparent, size=size)
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -92,16 +92,16 @@ class HubCard(ctk.CTkFrame):
|
||||||
master: Any,
|
master: Any,
|
||||||
corner_radius: int = Theme.RADIUS_MD,
|
corner_radius: int = Theme.RADIUS_MD,
|
||||||
border_width: int = 1,
|
border_width: int = 1,
|
||||||
border_color: str = Theme.BORDER,
|
border_color: Optional[str] = None,
|
||||||
fg_color: str = Theme.SURFACE,
|
fg_color: Optional[str] = None,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
master=master,
|
master=master,
|
||||||
corner_radius=corner_radius,
|
corner_radius=corner_radius,
|
||||||
border_width=border_width,
|
border_width=border_width,
|
||||||
border_color=border_color,
|
border_color=border_color or Theme.BORDER,
|
||||||
fg_color=fg_color,
|
fg_color=fg_color or Theme.SURFACE,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -257,19 +257,19 @@ class HubSectionHeader(ctk.CTkFrame):
|
||||||
class HubStatusBadge(ctk.CTkFrame):
|
class HubStatusBadge(ctk.CTkFrame):
|
||||||
"""Status pill with dot and localized label for all normalized health states."""
|
"""Status pill with dot and localized label for all normalized health states."""
|
||||||
|
|
||||||
STATUS_MAP = {
|
STATUS_LABELS = {
|
||||||
"healthy": (Theme.STATUS_HEALTHY, "Работает"),
|
"healthy": "Работает",
|
||||||
"quota_low": (Theme.STATUS_WARNING, "Квота заканчивается"),
|
"quota_low": "Квота заканчивается",
|
||||||
"quota_exhausted": (Theme.STATUS_ERROR, "Квота исчерпана"),
|
"quota_exhausted": "Квота исчерпана",
|
||||||
"cooldown": (Theme.STATUS_WARNING, "Ожидание сброса"),
|
"cooldown": "Ожидание сброса",
|
||||||
"rate_limited": (Theme.STATUS_WARNING, "Лимит запросов"),
|
"rate_limited": "Лимит запросов",
|
||||||
"not_configured": (Theme.TEXT_MUTED, "Аккаунт не добавлен"),
|
"not_configured": "Аккаунт не добавлен",
|
||||||
"auth_required": (Theme.STATUS_AUTH_REQUIRED, "Требуется вход"),
|
"auth_required": "Требуется вход",
|
||||||
"auth_expired": (Theme.STATUS_AUTH_REQUIRED, "Требуется повторная авторизация"),
|
"auth_expired": "Требуется повторная авторизация",
|
||||||
"disabled": (Theme.STATUS_DISABLED, "Отключён"),
|
"disabled": "Отключён",
|
||||||
"cold_spare": (Theme.TEXT_MUTED, "Холодный резерв"),
|
"cold_spare": "Холодный резерв",
|
||||||
"unhealthy": (Theme.STATUS_ERROR, "Ошибка"),
|
"unhealthy": "Ошибка",
|
||||||
"not_tested": (Theme.TEXT_MUTED, "Не проверен"),
|
"not_tested": "Не проверен",
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, master: Any, status_key: str, **kwargs):
|
def __init__(self, master: Any, status_key: str, **kwargs):
|
||||||
|
|
@ -295,7 +295,8 @@ class HubStatusBadge(ctk.CTkFrame):
|
||||||
|
|
||||||
def set_status(self, status_key: str, label: Optional[str] = None) -> None:
|
def set_status(self, status_key: str, label: Optional[str] = None) -> None:
|
||||||
clean_key = (status_key or "unknown").lower().replace("-", "_")
|
clean_key = (status_key or "unknown").lower().replace("-", "_")
|
||||||
color, default_label = self.STATUS_MAP.get(clean_key, (Theme.TEXT_MUTED, status_key or "Н/Д"))
|
default_label = self.STATUS_LABELS.get(clean_key, status_key or "Н/Д")
|
||||||
|
color = _semantic_color(clean_key)
|
||||||
self.dot.configure(text_color=color)
|
self.dot.configure(text_color=color)
|
||||||
self.label.configure(text=label or default_label)
|
self.label.configure(text=label or default_label)
|
||||||
|
|
||||||
|
|
@ -513,9 +514,9 @@ def _semantic_color(status: str) -> str:
|
||||||
key = (status or "unknown").lower().replace("-", "_")
|
key = (status or "unknown").lower().replace("-", "_")
|
||||||
if key in {"healthy", "active", "authenticated", "online", "ready"}:
|
if key in {"healthy", "active", "authenticated", "online", "ready"}:
|
||||||
return Theme.COLOR_POSITIVE
|
return Theme.COLOR_POSITIVE
|
||||||
if key in {"warning", "quota_low", "cooldown", "reserve", "rate_limited"}:
|
if key in {"warning", "quota_low", "cooldown", "reserve", "rate_limited", "auth_required", "auth_expired"}:
|
||||||
return Theme.COLOR_CAUTION
|
return Theme.COLOR_CAUTION
|
||||||
if key in {"error", "unhealthy", "quota_exhausted", "offline", "auth_expired"}:
|
if key in {"error", "unhealthy", "quota_exhausted", "offline"}:
|
||||||
return Theme.COLOR_NEGATIVE
|
return Theme.COLOR_NEGATIVE
|
||||||
return Theme.COLOR_NEUTRAL
|
return Theme.COLOR_NEUTRAL
|
||||||
|
|
||||||
|
|
@ -582,7 +583,7 @@ class EllipsizedLabel(ctk.CTkLabel):
|
||||||
class _TextBadge(ctk.CTkFrame):
|
class _TextBadge(ctk.CTkFrame):
|
||||||
"""Small neutral badge used for plans and metadata, not health."""
|
"""Small neutral badge used for plans and metadata, not health."""
|
||||||
|
|
||||||
def __init__(self, master: Any, text: str, text_color: str = Theme.TEXT_SECONDARY, **kwargs):
|
def __init__(self, master: Any, text: str, text_color: Optional[str] = None, **kwargs):
|
||||||
super().__init__(
|
super().__init__(
|
||||||
master=master,
|
master=master,
|
||||||
height=Theme.HEIGHT_BADGE,
|
height=Theme.HEIGHT_BADGE,
|
||||||
|
|
@ -592,7 +593,12 @@ class _TextBadge(ctk.CTkFrame):
|
||||||
corner_radius=Theme.RADIUS_PILL,
|
corner_radius=Theme.RADIUS_PILL,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
self.label = ctk.CTkLabel(self, text=text, font=Theme.font_micro(), text_color=text_color)
|
self.label = ctk.CTkLabel(
|
||||||
|
self,
|
||||||
|
text=text,
|
||||||
|
font=Theme.font_micro(),
|
||||||
|
text_color=text_color or Theme.TEXT_SECONDARY,
|
||||||
|
)
|
||||||
self.label.pack(padx=Theme.SPACE_SM, pady=Theme.SPACE_XS)
|
self.label.pack(padx=Theme.SPACE_SM, pady=Theme.SPACE_XS)
|
||||||
|
|
||||||
def set_text(self, text: str) -> None:
|
def set_text(self, text: str) -> None:
|
||||||
|
|
@ -839,6 +845,13 @@ class RouteTargetWidget(HubCard):
|
||||||
class AccountCardWidget(HubCard):
|
class AccountCardWidget(HubCard):
|
||||||
"""Keyed account card whose quota buckets are updated without rebuilding the card."""
|
"""Keyed account card whose quota buckets are updated without rebuilding the card."""
|
||||||
|
|
||||||
|
MANAGEMENT_ACTIONS = (
|
||||||
|
("test", "⚡ Тест"),
|
||||||
|
("set_main", "★ Основной"),
|
||||||
|
("set_orchestrator", "♛ Оркестратор"),
|
||||||
|
("assign_role", "Назначить роль"),
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
master: Any,
|
master: Any,
|
||||||
|
|
@ -883,15 +896,31 @@ class AccountCardWidget(HubCard):
|
||||||
)
|
)
|
||||||
self.actions = ctk.CTkFrame(self.details, fg_color="transparent")
|
self.actions = ctk.CTkFrame(self.details, fg_color="transparent")
|
||||||
self.actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
|
self.actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
|
||||||
|
self.action_buttons: Dict[str, ActionButton] = {}
|
||||||
|
management = ctk.CTkFrame(self.actions, fg_color="transparent")
|
||||||
|
management.pack(fill="x", pady=(0, Theme.SPACE_XS))
|
||||||
|
for index, (action, label) in enumerate(self.MANAGEMENT_ACTIONS):
|
||||||
|
button = ActionButton(
|
||||||
|
management,
|
||||||
|
text=label,
|
||||||
|
variant="secondary" if index < 2 else "ghost",
|
||||||
|
height=Theme.HEIGHT_BTN_SM,
|
||||||
|
command=lambda action_name=action: self._trigger(action_name),
|
||||||
|
)
|
||||||
|
button.grid(row=index // 2, column=index % 2, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="ew")
|
||||||
|
management.grid_columnconfigure(index % 2, weight=1)
|
||||||
|
self.action_buttons[action] = button
|
||||||
|
utility = ctk.CTkFrame(self.actions, fg_color="transparent")
|
||||||
|
utility.pack(fill="x")
|
||||||
ActionButton(
|
ActionButton(
|
||||||
self.actions,
|
utility,
|
||||||
text="Обновить",
|
text="Обновить",
|
||||||
variant="secondary",
|
variant="secondary",
|
||||||
width=86,
|
width=86,
|
||||||
command=lambda: self._trigger("refresh_account"),
|
command=lambda: self._trigger("refresh_account"),
|
||||||
).pack(side="left")
|
).pack(side="left")
|
||||||
ActionButton(
|
ActionButton(
|
||||||
self.actions,
|
utility,
|
||||||
text="Удалить",
|
text="Удалить",
|
||||||
variant="ghost",
|
variant="ghost",
|
||||||
width=72,
|
width=72,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Hermes Hub — Native Brand Splash Screen."""
|
"""Hermes Hub — Native Brand Splash Screen."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ Primary Palette:
|
||||||
LIGHT: #F7F1E3 (Warm Ivory)
|
LIGHT: #F7F1E3 (Warm Ivory)
|
||||||
ACCENT: #CDAA64 (Ancient Gold)
|
ACCENT: #CDAA64 (Ancient Gold)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -58,15 +59,15 @@ class Theme:
|
||||||
DANGER_TEXT = "#FFD6D6"
|
DANGER_TEXT = "#FFD6D6"
|
||||||
|
|
||||||
# ── Operational / Status Colors ──
|
# ── Operational / Status Colors ──
|
||||||
STATUS_HEALTHY = "#2E7D32" # Vibrant Forest Green
|
STATUS_HEALTHY = "#2E7D32" # Vibrant Forest Green
|
||||||
STATUS_WARNING = "#D97706" # Amber Gold
|
STATUS_WARNING = "#D97706" # Amber Gold
|
||||||
STATUS_ERROR = "#DC2626" # Ruby Crimson
|
STATUS_ERROR = "#DC2626" # Ruby Crimson
|
||||||
STATUS_INFO = "#2563EB" # Azure
|
STATUS_INFO = "#2563EB" # Azure
|
||||||
STATUS_AUTH_REQUIRED = "#D97706"
|
STATUS_AUTH_REQUIRED = "#D97706"
|
||||||
STATUS_DISABLED = "#5A6B5D"
|
STATUS_DISABLED = "#5A6B5D"
|
||||||
STATUS_FAILOVER = "#3B82F6"
|
STATUS_FAILOVER = "#3B82F6"
|
||||||
STATUS_MAIN = "#CDAA64" # Gold Badge
|
STATUS_MAIN = "#CDAA64" # Gold Badge
|
||||||
STATUS_ORCHESTRATOR = "#E5C158" # Imperial Gold
|
STATUS_ORCHESTRATOR = "#E5C158" # Imperial Gold
|
||||||
|
|
||||||
# Semantic roles. Brand gold is intentionally not a health colour.
|
# Semantic roles. Brand gold is intentionally not a health colour.
|
||||||
COLOR_POSITIVE = STATUS_HEALTHY
|
COLOR_POSITIVE = STATUS_HEALTHY
|
||||||
|
|
@ -76,9 +77,198 @@ class Theme:
|
||||||
COLOR_BRAND = ACCENT
|
COLOR_BRAND = ACCENT
|
||||||
|
|
||||||
# ── Provider Specific Accent Colors ──
|
# ── Provider Specific Accent Colors ──
|
||||||
PROVIDER_ANTIGRAVITY = "#4285F4" # Google Blue
|
PROVIDER_ANTIGRAVITY = "#4285F4" # Google Blue
|
||||||
PROVIDER_CODEX = "#10A37F" # OpenAI Emerald
|
PROVIDER_CODEX = "#10A37F" # OpenAI Emerald
|
||||||
PROVIDER_OPENCODE = "#F97316" # OpenCode Orange
|
PROVIDER_OPENCODE = "#F97316" # OpenCode Orange
|
||||||
|
PROVIDER_CLAUDE = "#D97706"
|
||||||
|
PROVIDER_GROK = "#3B82F6"
|
||||||
|
PROVIDER_GENERIC = "#8B5CF6"
|
||||||
|
|
||||||
|
SCHEMES = ("dark", "hybrid", "light")
|
||||||
|
SCHEME_LABELS = {
|
||||||
|
"dark": "Тёмная",
|
||||||
|
"hybrid": "Средняя (гибрид)",
|
||||||
|
"light": "Светлая (бежевая)",
|
||||||
|
}
|
||||||
|
PALETTES = {
|
||||||
|
"dark": {
|
||||||
|
"PRIMARY": "#071A17",
|
||||||
|
"DARK": "#0B211D",
|
||||||
|
"SECONDARY": "#244B3C",
|
||||||
|
"LIGHT": "#F4EAD4",
|
||||||
|
"ACCENT": "#C89A2B",
|
||||||
|
"BG_WINDOW": "#061916",
|
||||||
|
"BG_SIDEBAR": "#08221E",
|
||||||
|
"BG_HEADER": "#071B18",
|
||||||
|
"BG_STATUSBAR": "#061512",
|
||||||
|
"BG_MODAL_BACKDROP": "#020B09",
|
||||||
|
"SURFACE": "#0B2520",
|
||||||
|
"SURFACE_HOVER": "#12342C",
|
||||||
|
"SURFACE_ACTIVE": "#194535",
|
||||||
|
"SURFACE_SELECTED": "#173B2E",
|
||||||
|
"SURFACE_MUTED": "#091E1A",
|
||||||
|
"BORDER": "#36513B",
|
||||||
|
"BORDER_SUBTLE": "#203A2D",
|
||||||
|
"BORDER_ACCENT": "#B78525",
|
||||||
|
"BORDER_HOVER": "#537456",
|
||||||
|
"TEXT_PRIMARY": "#F8F0DC",
|
||||||
|
"TEXT_SECONDARY": "#D1C7AE",
|
||||||
|
"TEXT_MUTED": "#8FA395",
|
||||||
|
"TEXT_ON_ACCENT": "#102018",
|
||||||
|
"TEXT_ACCENT": "#E0B84E",
|
||||||
|
"TEXT_DISABLED": "#617367",
|
||||||
|
"ACCENT_HOVER": "#E0B84E",
|
||||||
|
"ACCENT_PRESSED": "#A9781E",
|
||||||
|
"ACCENT_DIM": "#3D3218",
|
||||||
|
"ACCENT_GLOW": "#D9AA37",
|
||||||
|
"DANGER_SURFACE": "#4A2020",
|
||||||
|
"DANGER_SURFACE_HOVER": "#692A2A",
|
||||||
|
"DANGER_BORDER": "#8A3B36",
|
||||||
|
"DANGER_TEXT": "#FFD8D2",
|
||||||
|
"STATUS_HEALTHY": "#72C943",
|
||||||
|
"STATUS_WARNING": "#E1A62B",
|
||||||
|
"STATUS_ERROR": "#E45C4F",
|
||||||
|
"STATUS_INFO": "#4C8DD8",
|
||||||
|
"STATUS_AUTH_REQUIRED": "#E1A62B",
|
||||||
|
"STATUS_DISABLED": "#708078",
|
||||||
|
"STATUS_FAILOVER": "#4C8DD8",
|
||||||
|
"STATUS_MAIN": "#C89A2B",
|
||||||
|
"STATUS_ORCHESTRATOR": "#E0B84E",
|
||||||
|
"PROVIDER_ANTIGRAVITY": "#74A9FF",
|
||||||
|
"PROVIDER_CODEX": "#46BE8A",
|
||||||
|
"PROVIDER_OPENCODE": "#F39A50",
|
||||||
|
"PROVIDER_CLAUDE": "#DF9C63",
|
||||||
|
"PROVIDER_GROK": "#6DA6F2",
|
||||||
|
"PROVIDER_GENERIC": "#A99DD8",
|
||||||
|
"SIDEBAR_TEXT": "#F8F0DC",
|
||||||
|
"SIDEBAR_MUTED": "#8FA395",
|
||||||
|
"SIDEBAR_HOVER": "#12342C",
|
||||||
|
"SIDEBAR_SELECTED": "#173B2E",
|
||||||
|
},
|
||||||
|
"hybrid": {
|
||||||
|
"PRIMARY": "#F7F2E8",
|
||||||
|
"DARK": "#0A2721",
|
||||||
|
"SECONDARY": "#DCE6D7",
|
||||||
|
"LIGHT": "#FCF8F0",
|
||||||
|
"ACCENT": "#B98118",
|
||||||
|
"BG_WINDOW": "#F5F0E7",
|
||||||
|
"BG_SIDEBAR": "#08251F",
|
||||||
|
"BG_HEADER": "#FBF8F1",
|
||||||
|
"BG_STATUSBAR": "#EFE8DB",
|
||||||
|
"BG_MODAL_BACKDROP": "#E8E0D3",
|
||||||
|
"SURFACE": "#FBF8F1",
|
||||||
|
"SURFACE_HOVER": "#EEE8DA",
|
||||||
|
"SURFACE_ACTIVE": "#E1EAD9",
|
||||||
|
"SURFACE_SELECTED": "#E4EBD8",
|
||||||
|
"SURFACE_MUTED": "#F1EBE0",
|
||||||
|
"BORDER": "#D8CCB8",
|
||||||
|
"BORDER_SUBTLE": "#E7DDCE",
|
||||||
|
"BORDER_ACCENT": "#BD8B2F",
|
||||||
|
"BORDER_HOVER": "#AFA188",
|
||||||
|
"TEXT_PRIMARY": "#17241D",
|
||||||
|
"TEXT_SECONDARY": "#4C584F",
|
||||||
|
"TEXT_MUTED": "#7B817B",
|
||||||
|
"TEXT_ON_ACCENT": "#FFFFFF",
|
||||||
|
"TEXT_ACCENT": "#9B6C13",
|
||||||
|
"TEXT_DISABLED": "#A9AAA4",
|
||||||
|
"ACCENT_HOVER": "#CF9C39",
|
||||||
|
"ACCENT_PRESSED": "#936514",
|
||||||
|
"ACCENT_DIM": "#F0E4C8",
|
||||||
|
"ACCENT_GLOW": "#C59432",
|
||||||
|
"DANGER_SURFACE": "#F8E2DE",
|
||||||
|
"DANGER_SURFACE_HOVER": "#F1CBC5",
|
||||||
|
"DANGER_BORDER": "#C86155",
|
||||||
|
"DANGER_TEXT": "#812E28",
|
||||||
|
"STATUS_HEALTHY": "#397B35",
|
||||||
|
"STATUS_WARNING": "#BE7B13",
|
||||||
|
"STATUS_ERROR": "#C6473D",
|
||||||
|
"STATUS_INFO": "#326DAD",
|
||||||
|
"STATUS_AUTH_REQUIRED": "#BE7B13",
|
||||||
|
"STATUS_DISABLED": "#969B94",
|
||||||
|
"STATUS_FAILOVER": "#326DAD",
|
||||||
|
"STATUS_MAIN": "#B98118",
|
||||||
|
"STATUS_ORCHESTRATOR": "#A87817",
|
||||||
|
"PROVIDER_ANTIGRAVITY": "#326DAD",
|
||||||
|
"PROVIDER_CODEX": "#247A50",
|
||||||
|
"PROVIDER_OPENCODE": "#B96324",
|
||||||
|
"PROVIDER_CLAUDE": "#A55C2C",
|
||||||
|
"PROVIDER_GROK": "#326DAD",
|
||||||
|
"PROVIDER_GENERIC": "#7365A5",
|
||||||
|
"SIDEBAR_TEXT": "#F8F0DC",
|
||||||
|
"SIDEBAR_MUTED": "#98A89D",
|
||||||
|
"SIDEBAR_HOVER": "#12342C",
|
||||||
|
"SIDEBAR_SELECTED": "#173B2E",
|
||||||
|
},
|
||||||
|
"light": {
|
||||||
|
"PRIMARY": "#FFF9EE",
|
||||||
|
"DARK": "#FFF8EC",
|
||||||
|
"SECONDARY": "#E9DFC9",
|
||||||
|
"LIGHT": "#FFFDF8",
|
||||||
|
"ACCENT": "#A96F12",
|
||||||
|
"BG_WINDOW": "#FFF9EF",
|
||||||
|
"BG_SIDEBAR": "#FBF0DE",
|
||||||
|
"BG_HEADER": "#FFFDF8",
|
||||||
|
"BG_STATUSBAR": "#F7EDDE",
|
||||||
|
"BG_MODAL_BACKDROP": "#EDE2D2",
|
||||||
|
"SURFACE": "#FFFDF8",
|
||||||
|
"SURFACE_HOVER": "#F4E9D9",
|
||||||
|
"SURFACE_ACTIVE": "#DFE8D7",
|
||||||
|
"SURFACE_SELECTED": "#E7EEDC",
|
||||||
|
"SURFACE_MUTED": "#F8F0E5",
|
||||||
|
"BORDER": "#DDCFB9",
|
||||||
|
"BORDER_SUBTLE": "#EDE2D2",
|
||||||
|
"BORDER_ACCENT": "#B57A1C",
|
||||||
|
"BORDER_HOVER": "#B8A78D",
|
||||||
|
"TEXT_PRIMARY": "#1E281F",
|
||||||
|
"TEXT_SECONDARY": "#505A51",
|
||||||
|
"TEXT_MUTED": "#7E837C",
|
||||||
|
"TEXT_ON_ACCENT": "#FFFFFF",
|
||||||
|
"TEXT_ACCENT": "#90600F",
|
||||||
|
"TEXT_DISABLED": "#ADAEA7",
|
||||||
|
"ACCENT_HOVER": "#C58B2D",
|
||||||
|
"ACCENT_PRESSED": "#835609",
|
||||||
|
"ACCENT_DIM": "#F2E3C4",
|
||||||
|
"ACCENT_GLOW": "#BA8428",
|
||||||
|
"DANGER_SURFACE": "#F9E5E0",
|
||||||
|
"DANGER_SURFACE_HOVER": "#F3D0C9",
|
||||||
|
"DANGER_BORDER": "#C85A4E",
|
||||||
|
"DANGER_TEXT": "#812E28",
|
||||||
|
"STATUS_HEALTHY": "#367B36",
|
||||||
|
"STATUS_WARNING": "#B97612",
|
||||||
|
"STATUS_ERROR": "#C6473D",
|
||||||
|
"STATUS_INFO": "#326DAD",
|
||||||
|
"STATUS_AUTH_REQUIRED": "#B97612",
|
||||||
|
"STATUS_DISABLED": "#999D96",
|
||||||
|
"STATUS_FAILOVER": "#326DAD",
|
||||||
|
"STATUS_MAIN": "#A96F12",
|
||||||
|
"STATUS_ORCHESTRATOR": "#9D6810",
|
||||||
|
"PROVIDER_ANTIGRAVITY": "#326DAD",
|
||||||
|
"PROVIDER_CODEX": "#247A50",
|
||||||
|
"PROVIDER_OPENCODE": "#B96324",
|
||||||
|
"PROVIDER_CLAUDE": "#A55C2C",
|
||||||
|
"PROVIDER_GROK": "#326DAD",
|
||||||
|
"PROVIDER_GENERIC": "#7365A5",
|
||||||
|
"SIDEBAR_TEXT": "#283129",
|
||||||
|
"SIDEBAR_MUTED": "#767D76",
|
||||||
|
"SIDEBAR_HOVER": "#F0E3D0",
|
||||||
|
"SIDEBAR_SELECTED": "#E6EEDB",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
current_scheme = "dark"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def apply_scheme(cls, scheme: str) -> str:
|
||||||
|
"""Activate a complete palette and return its normalized key."""
|
||||||
|
normalized = scheme if scheme in cls.PALETTES else "dark"
|
||||||
|
for token, value in cls.PALETTES[normalized].items():
|
||||||
|
setattr(cls, token, value)
|
||||||
|
cls.current_scheme = normalized
|
||||||
|
cls.COLOR_POSITIVE = cls.STATUS_HEALTHY
|
||||||
|
cls.COLOR_CAUTION = cls.STATUS_WARNING
|
||||||
|
cls.COLOR_NEGATIVE = cls.STATUS_ERROR
|
||||||
|
cls.COLOR_NEUTRAL = cls.STATUS_DISABLED
|
||||||
|
cls.COLOR_BRAND = cls.ACCENT
|
||||||
|
return normalized
|
||||||
|
|
||||||
# ── Spacing Scale (px) ──
|
# ── Spacing Scale (px) ──
|
||||||
SPACE_XS = 4
|
SPACE_XS = 4
|
||||||
|
|
@ -106,10 +296,10 @@ class Theme:
|
||||||
HEIGHT_BTN_SM = 30
|
HEIGHT_BTN_SM = 30
|
||||||
HEIGHT_BTN_MD = 38
|
HEIGHT_BTN_MD = 38
|
||||||
HEIGHT_BTN_LG = 44
|
HEIGHT_BTN_LG = 44
|
||||||
HEIGHT_NAV_ITEM = 42
|
HEIGHT_NAV_ITEM = 34
|
||||||
HEIGHT_HEADER = 60
|
HEIGHT_HEADER = 56
|
||||||
HEIGHT_STATUSBAR = 30
|
HEIGHT_STATUSBAR = 30
|
||||||
WIDTH_SIDEBAR = 240
|
WIDTH_SIDEBAR = 190
|
||||||
HEIGHT_INPUT = 36
|
HEIGHT_INPUT = 36
|
||||||
HEIGHT_BADGE = 24
|
HEIGHT_BADGE = 24
|
||||||
ACCOUNT_CARD_MIN_HEIGHT = 188
|
ACCOUNT_CARD_MIN_HEIGHT = 188
|
||||||
|
|
@ -180,3 +370,6 @@ class Theme:
|
||||||
@classmethod
|
@classmethod
|
||||||
def font_metric(cls):
|
def font_metric(cls):
|
||||||
return (cls.FONT_FAMILY_UI, 24, "bold")
|
return (cls.FONT_FAMILY_UI, 24, "bold")
|
||||||
|
|
||||||
|
|
||||||
|
Theme.apply_scheme("dark")
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,3 @@
|
||||||
"""Hermes Hub UI Views Package."""
|
"""Hermes Hub UI Views Package."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Hermes Hub — About View (О программе)."""
|
"""Hermes Hub — About View (О программе)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
@ -83,15 +84,27 @@ class AboutView(ctk.CTkFrame):
|
||||||
|
|
||||||
p1 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
p1 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||||
p1.grid(row=0, column=0, padx=4, sticky="nsew")
|
p1.grid(row=0, column=0, padx=4, sticky="nsew")
|
||||||
ctk.CTkLabel(p1, text="🛡️ Fail-Closed", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2))
|
ctk.CTkLabel(p1, text="🛡️ Fail-Closed", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
|
||||||
ctk.CTkLabel(p1, text="Защита credentials и квот", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 8))
|
pady=(8, 2)
|
||||||
|
)
|
||||||
|
ctk.CTkLabel(p1, text="Защита credentials и квот", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
|
||||||
|
pady=(0, 8)
|
||||||
|
)
|
||||||
|
|
||||||
p2 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
p2 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||||
p2.grid(row=0, column=1, padx=4, sticky="nsew")
|
p2.grid(row=0, column=1, padx=4, sticky="nsew")
|
||||||
ctk.CTkLabel(p2, text="🔄 Auto Failover", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2))
|
ctk.CTkLabel(p2, text="🔄 Auto Failover", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
|
||||||
ctk.CTkLabel(p2, text="Мгновенное переключение ролей", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 8))
|
pady=(8, 2)
|
||||||
|
)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
p2, text="Мгновенное переключение ролей", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
|
||||||
|
).pack(pady=(0, 8))
|
||||||
|
|
||||||
p3 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
p3 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
||||||
p3.grid(row=0, column=2, padx=4, sticky="nsew")
|
p3.grid(row=0, column=2, padx=4, sticky="nsew")
|
||||||
ctk.CTkLabel(p3, text="👥 Auto Assignment", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2))
|
ctk.CTkLabel(p3, text="👥 Auto Assignment", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
|
||||||
ctk.CTkLabel(p3, text="Интеллектуальное распределение", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 8))
|
pady=(8, 2)
|
||||||
|
)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
p3, text="Интеллектуальное распределение", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
|
||||||
|
).pack(pady=(0, 8))
|
||||||
|
|
|
||||||
75
src/antigravity_provider/router/ui/views/analytics_view.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
"""Empirical router telemetry and local host measurements."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from antigravity_provider.router.state_store import HubSnapshot
|
||||||
|
from antigravity_provider.router.ui.components import HubMetricCard, SectionHeader
|
||||||
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
|
||||||
|
|
||||||
|
class AnalyticsView(ctk.CTkFrame):
|
||||||
|
def __init__(self, master: Any, **kwargs):
|
||||||
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
|
SectionHeader(
|
||||||
|
self,
|
||||||
|
title="Аналитика",
|
||||||
|
subtitle="Только собственные измерения Hermes Router",
|
||||||
|
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||||
|
grid = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
grid.pack(fill="x", padx=Theme.PAGE_PAD_X)
|
||||||
|
for column in range(3):
|
||||||
|
grid.grid_columnconfigure(column, weight=1)
|
||||||
|
definitions = (
|
||||||
|
("calls", "Вызовы", "◎"),
|
||||||
|
("latency", "P50 задержка", "⌁"),
|
||||||
|
("errors", "Доля ошибок", "△"),
|
||||||
|
("tokens", "Токены", "◇"),
|
||||||
|
("failovers", "Переключения", "⇄"),
|
||||||
|
("cost", "Стоимость", "$"),
|
||||||
|
)
|
||||||
|
self.cards = {}
|
||||||
|
for index, (key, title, icon) in enumerate(definitions):
|
||||||
|
card = HubMetricCard(grid, title, "Н/Д", "нет измерений", icon=icon)
|
||||||
|
card.grid(row=index // 3, column=index % 3, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew")
|
||||||
|
self.cards[key] = card
|
||||||
|
self.note = ctk.CTkLabel(
|
||||||
|
self,
|
||||||
|
text="Вызовы измерены Hermes Router; аппаратные показатели — локально через psutil. Внешний SLA не заявляется.",
|
||||||
|
font=Theme.font_caption(),
|
||||||
|
text_color=Theme.TEXT_MUTED,
|
||||||
|
)
|
||||||
|
self.note.pack(anchor="w", padx=Theme.PAGE_PAD_X, pady=Theme.SPACE_LG)
|
||||||
|
|
||||||
|
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||||
|
if not isinstance(snapshot, HubSnapshot):
|
||||||
|
return
|
||||||
|
telemetry = dict(snapshot.metrics.get("telemetry") or {})
|
||||||
|
global_telemetry = dict(telemetry.get("global") or {})
|
||||||
|
if not telemetry.get("has_data"):
|
||||||
|
for card in self.cards.values():
|
||||||
|
card.val_label.configure(text="Н/Д")
|
||||||
|
card.sub_label.configure(text="нет измерений")
|
||||||
|
return
|
||||||
|
values = {
|
||||||
|
"calls": str(global_telemetry.get("total_calls", "Н/Д")),
|
||||||
|
"latency": f"{global_telemetry['latency_p50_ms']:.0f} мс"
|
||||||
|
if global_telemetry.get("latency_p50_ms") is not None
|
||||||
|
else "Н/Д",
|
||||||
|
"errors": f"{global_telemetry['error_rate']:.1%}"
|
||||||
|
if global_telemetry.get("error_rate") is not None
|
||||||
|
else "Н/Д",
|
||||||
|
"tokens": str(global_telemetry.get("total_tokens"))
|
||||||
|
if global_telemetry.get("total_tokens") is not None
|
||||||
|
else "Н/Д",
|
||||||
|
"failovers": str(global_telemetry.get("failovers_count", 0)),
|
||||||
|
"cost": f"${global_telemetry['total_cost_usd']:.4f}"
|
||||||
|
if global_telemetry.get("total_cost_usd") is not None
|
||||||
|
else "Н/Д",
|
||||||
|
}
|
||||||
|
for key, value in values.items():
|
||||||
|
self.cards[key].val_label.configure(text=value)
|
||||||
|
self.cards[key].sub_label.configure(text="own_measurement")
|
||||||
|
|
@ -1,36 +1,120 @@
|
||||||
"""Snapshot-driven overview of readiness, routes and actionable account warnings."""
|
"""Approved Hermes Hub overview layout backed only by contract data."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Callable, Dict, Optional
|
from typing import Any, Callable, Dict, Iterable, Optional
|
||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from antigravity_provider.router.state_store import HubSnapshot
|
from antigravity_provider.router.state_store import HubSnapshot
|
||||||
from antigravity_provider.router.ui.components import ActionButton, EmptyState, HubCard, HubMetricCard, SectionHeader
|
from antigravity_provider.router.ui.components import HubCard
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
|
||||||
|
|
||||||
class _SummaryRow(HubCard):
|
class _FlowCard(HubCard):
|
||||||
def __init__(self, master: Any, title: str = "", detail: str = ""):
|
def __init__(self, master: Any, **kwargs):
|
||||||
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE)
|
kwargs.setdefault("corner_radius", Theme.RADIUS_MD)
|
||||||
self.title = ctk.CTkLabel(self, text=title, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
|
kwargs.setdefault("border_color", Theme.BORDER)
|
||||||
self.title.pack(side="left", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
kwargs.setdefault("height", 76)
|
||||||
self.detail = ctk.CTkLabel(self, text=detail, font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
|
super().__init__(master, **kwargs)
|
||||||
self.detail.pack(side="right", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
self.pack_propagate(False)
|
||||||
|
self.title = ctk.CTkLabel(self, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
|
||||||
|
self.title.pack(anchor="w", padx=Theme.SPACE_SM, pady=(Theme.SPACE_SM, 0))
|
||||||
|
self.subtitle = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||||
|
self.subtitle.pack(anchor="w", padx=Theme.SPACE_SM, pady=2)
|
||||||
|
self.meta = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
|
||||||
|
self.meta.pack(anchor="w", padx=Theme.SPACE_SM, pady=(0, Theme.SPACE_SM))
|
||||||
|
|
||||||
def update_row(self, title: str, detail: str, status: str = "unknown") -> None:
|
def update_card(self, title: str, subtitle: str, meta: str, status: str = "unknown") -> None:
|
||||||
|
self.title.configure(text=title)
|
||||||
|
self.subtitle.configure(text=subtitle)
|
||||||
|
self.meta.configure(text=meta)
|
||||||
|
colors = {
|
||||||
|
"healthy": Theme.STATUS_HEALTHY,
|
||||||
|
"warning": Theme.STATUS_WARNING,
|
||||||
|
"error": Theme.STATUS_ERROR,
|
||||||
|
"active": Theme.BORDER_ACCENT,
|
||||||
|
}
|
||||||
|
self.configure(border_color=colors.get(status, Theme.BORDER))
|
||||||
|
|
||||||
|
|
||||||
|
class _KpiCard(HubCard):
|
||||||
|
def __init__(self, master: Any, title: str, value: str, subtext: str, icon: str):
|
||||||
|
super().__init__(master, corner_radius=Theme.RADIUS_SM)
|
||||||
|
header = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
header.pack(fill="x", padx=Theme.SPACE_SM, pady=(Theme.SPACE_SM, 0))
|
||||||
|
ctk.CTkLabel(header, text=icon, font=Theme.font_micro(), text_color=Theme.TEXT_ACCENT).pack(side="left")
|
||||||
|
ctk.CTkLabel(header, text=title.upper(), font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
|
||||||
|
side="left", padx=Theme.SPACE_XS
|
||||||
|
)
|
||||||
|
value_row = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
value_row.pack(fill="x", padx=Theme.SPACE_SM, pady=(0, Theme.SPACE_SM))
|
||||||
|
self.val_label = ctk.CTkLabel(value_row, text=value, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
|
||||||
|
self.val_label.pack(side="left")
|
||||||
|
self.sub_label = ctk.CTkLabel(value_row, text=subtext, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||||
|
self.sub_label.pack(side="right")
|
||||||
|
|
||||||
|
|
||||||
|
class _StatusRow(ctk.CTkFrame):
|
||||||
|
def __init__(self, master: Any):
|
||||||
|
super().__init__(master, fg_color="transparent")
|
||||||
|
self.dot = ctk.CTkLabel(self, text="●", width=14, font=Theme.font_micro())
|
||||||
|
self.dot.pack(side="left")
|
||||||
|
self.title = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY)
|
||||||
|
self.title.pack(side="left", padx=Theme.SPACE_XS)
|
||||||
|
self.detail = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||||
|
self.detail.pack(side="right")
|
||||||
|
|
||||||
|
def update_row(self, title: str, detail: str, status: str) -> None:
|
||||||
self.title.configure(text=title)
|
self.title.configure(text=title)
|
||||||
self.detail.configure(text=detail)
|
self.detail.configure(text=detail)
|
||||||
color = (
|
color = (
|
||||||
Theme.COLOR_POSITIVE
|
Theme.STATUS_HEALTHY
|
||||||
if status == "healthy"
|
if status == "healthy"
|
||||||
else (Theme.COLOR_CAUTION if status in {"warning", "quota_low", "auth_expired"} else Theme.COLOR_NEUTRAL)
|
else Theme.STATUS_WARNING
|
||||||
|
if status == "warning"
|
||||||
|
else Theme.STATUS_ERROR
|
||||||
|
if status == "error"
|
||||||
|
else Theme.STATUS_DISABLED
|
||||||
)
|
)
|
||||||
self.configure(border_color=color)
|
self.dot.configure(text_color=color)
|
||||||
|
|
||||||
|
|
||||||
|
class _EventRow(ctk.CTkFrame):
|
||||||
|
def __init__(self, master: Any):
|
||||||
|
super().__init__(master, fg_color="transparent")
|
||||||
|
self.time = ctk.CTkLabel(self, width=58, text="", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED)
|
||||||
|
self.time.pack(side="left")
|
||||||
|
self.dot = ctk.CTkLabel(self, width=18, text="●", font=Theme.font_micro())
|
||||||
|
self.dot.pack(side="left")
|
||||||
|
self.message = ctk.CTkLabel(self, text="", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY)
|
||||||
|
self.message.pack(side="left", fill="x", expand=True, padx=Theme.SPACE_XS)
|
||||||
|
self.tag = ctk.CTkLabel(
|
||||||
|
self,
|
||||||
|
text="",
|
||||||
|
font=Theme.font_micro(),
|
||||||
|
text_color=Theme.TEXT_SECONDARY,
|
||||||
|
fg_color=Theme.SURFACE_MUTED,
|
||||||
|
corner_radius=Theme.RADIUS_PILL,
|
||||||
|
)
|
||||||
|
self.tag.pack(side="right", padx=Theme.SPACE_XS)
|
||||||
|
|
||||||
|
def update_event(self, event: Any) -> None:
|
||||||
|
level = str(getattr(event, "level", "info"))
|
||||||
|
color = {
|
||||||
|
"success": Theme.STATUS_HEALTHY,
|
||||||
|
"warning": Theme.STATUS_WARNING,
|
||||||
|
"error": Theme.STATUS_ERROR,
|
||||||
|
}.get(level, Theme.STATUS_INFO)
|
||||||
|
self.time.configure(text=str(getattr(event, "timestamp", "Н/Д")))
|
||||||
|
self.dot.configure(text_color=color)
|
||||||
|
self.message.configure(text=str(getattr(event, "message", "Событие без описания")))
|
||||||
|
self.tag.configure(text=f" {getattr(event, 'category', 'system')} ")
|
||||||
|
|
||||||
|
|
||||||
class DashboardView(ctk.CTkFrame):
|
class DashboardView(ctk.CTkFrame):
|
||||||
|
"""Dense overview matching the approved composition without fictional metrics."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
master: Any,
|
master: Any,
|
||||||
|
|
@ -42,144 +126,307 @@ class DashboardView(ctk.CTkFrame):
|
||||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
self.on_navigate = on_navigate
|
self.on_navigate = on_navigate
|
||||||
self.on_action = on_action
|
self.on_action = on_action
|
||||||
self._provider_rows: Dict[str, _SummaryRow] = {}
|
self._provider_cards: Dict[str, _FlowCard] = {}
|
||||||
self._route_rows: Dict[str, _SummaryRow] = {}
|
self._agent_cards: Dict[str, _FlowCard] = {}
|
||||||
self._alert_rows: Dict[str, _SummaryRow] = {}
|
self._provider_status_rows: Dict[str, _StatusRow] = {}
|
||||||
|
self._event_rows: list[_EventRow] = []
|
||||||
self._build()
|
self._build()
|
||||||
|
|
||||||
def _build(self) -> None:
|
def _build(self) -> None:
|
||||||
SectionHeader(
|
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent", corner_radius=0)
|
||||||
self,
|
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=Theme.PAGE_PAD_Y)
|
||||||
title="Обзор Hermes Hub",
|
|
||||||
subtitle="Кто работает сейчас, через какой маршрут и где требуется внимание",
|
|
||||||
action_text="Обновить",
|
|
||||||
action_cmd=lambda: self.on_action and self.on_action("refresh_data", {}),
|
|
||||||
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
|
||||||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
|
||||||
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
|
||||||
|
|
||||||
self.snapshot_freshness = ctk.CTkLabel(
|
self.snapshot_freshness = ctk.CTkLabel(
|
||||||
self.scroll,
|
self.scroll,
|
||||||
text="Snapshot: Н/Д",
|
text="Snapshot: Н/Д",
|
||||||
font=Theme.font_caption(),
|
font=Theme.font_micro(),
|
||||||
text_color=Theme.TEXT_MUTED,
|
text_color=Theme.TEXT_MUTED,
|
||||||
anchor="w",
|
anchor="w",
|
||||||
)
|
)
|
||||||
self.snapshot_freshness.pack(fill="x", pady=(0, Theme.SPACE_SM))
|
self.snapshot_freshness.pack(fill="x", pady=(0, Theme.SPACE_XS))
|
||||||
|
|
||||||
metrics = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
self.metrics = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
||||||
metrics.pack(fill="x", pady=(0, Theme.SECTION_GAP))
|
self.metrics.pack(fill="x", pady=(0, Theme.SPACE_SM))
|
||||||
for column in range(4):
|
for column in range(5):
|
||||||
metrics.grid_columnconfigure(column, weight=1)
|
self.metrics.grid_columnconfigure(column, weight=1)
|
||||||
self.system_metric = HubMetricCard(metrics, "Состояние", "Н/Д", "Ожидание snapshot", icon="◈", accent=True)
|
self.quota_metric = _KpiCard(self.metrics, "Квоты", "Н/Д", "нет измерения", icon="◔")
|
||||||
self.provider_metric = HubMetricCard(metrics, "Провайдеры", "0/0", "доступно", icon="◉")
|
self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н/Д", "измерения", icon="◎")
|
||||||
self.account_metric = HubMetricCard(metrics, "Аккаунты", "0/0", "подключено", icon="◎")
|
self.agents_metric = _KpiCard(self.metrics, "Агенты онлайн", "0/0", "готовые роли", icon="◇")
|
||||||
self.role_metric = HubMetricCard(metrics, "Роли", "0/0", "готово", icon="◇")
|
self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н/Д", "P50", icon="⌁")
|
||||||
|
self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н/Д", "failover", icon="⇄")
|
||||||
for index, metric in enumerate(
|
for index, metric in enumerate(
|
||||||
(self.system_metric, self.provider_metric, self.account_metric, self.role_metric)
|
(self.quota_metric, self.calls_metric, self.agents_metric, self.latency_metric, self.failover_metric)
|
||||||
):
|
):
|
||||||
metric.grid(row=0, column=index, padx=Theme.SPACE_XS, sticky="nsew")
|
metric.grid(row=0, column=index, padx=Theme.SPACE_XS, sticky="nsew")
|
||||||
|
|
||||||
columns = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
body = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
||||||
columns.pack(fill="x")
|
body.pack(fill="x")
|
||||||
columns.grid_columnconfigure((0, 1), weight=1)
|
body.grid_columnconfigure(0, weight=4)
|
||||||
self.providers_card = self._section(columns, "Провайдеры", 0, 0)
|
body.grid_columnconfigure(1, weight=1)
|
||||||
self.routes_card = self._section(columns, "Активные маршруты", 0, 1)
|
|
||||||
self.alerts_card = self._section(columns, "Требует внимания", 1, 0, columnspan=2)
|
|
||||||
self.events_gap = EmptyState(
|
|
||||||
self.scroll,
|
|
||||||
title="Последние события: Н/Д",
|
|
||||||
message="Журнал событий не входит в HubSnapshot; выдуманные события не отображаются.",
|
|
||||||
)
|
|
||||||
self.events_gap.pack(fill="x", pady=(Theme.SECTION_GAP, 0))
|
|
||||||
|
|
||||||
def _section(self, master: Any, title: str, row: int, column: int, columnspan: int = 1) -> ctk.CTkFrame:
|
route_card = HubCard(body)
|
||||||
card = HubCard(master)
|
route_card.grid(row=0, column=0, sticky="nsew", padx=(0, Theme.SPACE_SM))
|
||||||
card.grid(
|
ctk.CTkLabel(
|
||||||
row=row, column=column, columnspan=columnspan, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew"
|
route_card,
|
||||||
|
text="Маршрутизация запросов",
|
||||||
|
font=Theme.font_heading(),
|
||||||
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
|
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM))
|
||||||
|
flow = ctk.CTkFrame(route_card, fg_color="transparent")
|
||||||
|
flow.pack(fill="both", expand=True, padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
|
||||||
|
flow.grid_columnconfigure(0, weight=3)
|
||||||
|
flow.grid_columnconfigure(1, weight=1)
|
||||||
|
flow.grid_columnconfigure(2, weight=3)
|
||||||
|
flow.grid_columnconfigure(3, weight=1)
|
||||||
|
flow.grid_columnconfigure(4, weight=3)
|
||||||
|
self.provider_column = ctk.CTkFrame(flow, fg_color="transparent")
|
||||||
|
self.provider_column.grid(row=0, column=0, sticky="nsew")
|
||||||
|
ctk.CTkLabel(flow, text="→", font=Theme.font_metric(), text_color=Theme.TEXT_ACCENT).grid(row=0, column=1)
|
||||||
|
self.orchestrator_card = _FlowCard(flow, border_color=Theme.BORDER_ACCENT)
|
||||||
|
self.orchestrator_card.grid(row=0, column=2, sticky="ew", padx=Theme.SPACE_XS)
|
||||||
|
ctk.CTkLabel(flow, text="→", font=Theme.font_metric(), text_color=Theme.TEXT_ACCENT).grid(row=0, column=3)
|
||||||
|
self.agent_column = ctk.CTkFrame(flow, fg_color="transparent")
|
||||||
|
self.agent_column.grid(row=0, column=4, sticky="nsew")
|
||||||
|
self.context_card = _FlowCard(flow)
|
||||||
|
self.context_card.grid(row=1, column=2, sticky="ew", padx=Theme.SPACE_XS, pady=(Theme.SPACE_SM, 0))
|
||||||
|
self.context_card.update_card("Хранилище контекста", "Постоянная память", "Состояние: Н/Д")
|
||||||
|
|
||||||
|
self.realtime = HubCard(body)
|
||||||
|
self.realtime.grid(row=0, column=1, sticky="nsew")
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self.realtime,
|
||||||
|
text="Статус в реальном времени",
|
||||||
|
font=Theme.font_heading(),
|
||||||
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
|
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM))
|
||||||
|
ctk.CTkLabel(self.realtime, text="ПРОВАЙДЕРЫ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
|
||||||
|
anchor="w", padx=Theme.CARD_PAD_X
|
||||||
)
|
)
|
||||||
ctk.CTkLabel(card, text=title, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(
|
self.provider_status = ctk.CTkFrame(self.realtime, fg_color="transparent")
|
||||||
anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM)
|
self.provider_status.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_XS)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self.realtime, text="СИСТЕМНЫЕ ПОКАЗАТЕЛИ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
|
||||||
|
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, 0))
|
||||||
|
self.host_metrics = ctk.CTkLabel(
|
||||||
|
self.realtime,
|
||||||
|
text="CPU Н/Д\nПамять Н/Д\nДиск Н/Д\nСеть Н/Д",
|
||||||
|
justify="left",
|
||||||
|
anchor="w",
|
||||||
|
font=Theme.font_caption(),
|
||||||
|
text_color=Theme.TEXT_SECONDARY,
|
||||||
)
|
)
|
||||||
body = ctk.CTkFrame(card, fg_color="transparent")
|
self.host_metrics.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
||||||
body.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
|
ctk.CTkLabel(
|
||||||
return body
|
self.realtime,
|
||||||
|
text="Локальные измерения • psutil",
|
||||||
|
wraplength=210,
|
||||||
|
justify="left",
|
||||||
|
font=Theme.font_micro(),
|
||||||
|
text_color=Theme.TEXT_MUTED,
|
||||||
|
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
|
||||||
|
|
||||||
|
self.events_card = HubCard(self.scroll)
|
||||||
|
self.events_card.pack(fill="x", pady=(Theme.SPACE_SM, 0))
|
||||||
|
ctk.CTkLabel(
|
||||||
|
self.events_card,
|
||||||
|
text="Последние события",
|
||||||
|
font=Theme.font_heading(),
|
||||||
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
|
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
|
||||||
|
self.events_body = ctk.CTkFrame(self.events_card, fg_color="transparent")
|
||||||
|
self.events_body.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
|
||||||
|
self.events_empty = ctk.CTkLabel(
|
||||||
|
self.events_body,
|
||||||
|
text="События: Н/Д",
|
||||||
|
font=Theme.font_caption(),
|
||||||
|
text_color=Theme.TEXT_MUTED,
|
||||||
|
)
|
||||||
|
self.events_empty.pack(anchor="w", pady=Theme.SPACE_SM)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _sync_rows(container: Any, cache: Dict[str, _SummaryRow], rows: Dict[str, tuple[str, str, str]]) -> None:
|
def _quota_text(snapshot: HubSnapshot, provider_id: str) -> str:
|
||||||
|
profiles = snapshot.profiles_by_provider.get(provider_id, [])
|
||||||
|
measured: list[float] = []
|
||||||
|
for profile in profiles:
|
||||||
|
quota = snapshot.quotas.get(profile.profile_id)
|
||||||
|
if not quota or getattr(quota, "is_estimated", True):
|
||||||
|
continue
|
||||||
|
measured.extend(
|
||||||
|
float(bucket.remaining_percent) for bucket in quota.buckets if bucket.remaining_percent is not None
|
||||||
|
)
|
||||||
|
if not measured:
|
||||||
|
return "Квота: Н/Д"
|
||||||
|
return f"Корзины: {len(measured)} измерено"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sync_cards(
|
||||||
|
container: Any,
|
||||||
|
cache: Dict[str, _FlowCard],
|
||||||
|
items: Iterable[tuple[str, str, str, str, str]],
|
||||||
|
) -> None:
|
||||||
|
prepared = list(items)
|
||||||
|
live = {key for key, *_rest in prepared}
|
||||||
for key in list(cache):
|
for key in list(cache):
|
||||||
if key not in rows:
|
if key not in live:
|
||||||
cache.pop(key).destroy()
|
cache.pop(key).destroy()
|
||||||
for key, (title, detail, status) in rows.items():
|
for index, (key, title, subtitle, meta, status) in enumerate(prepared):
|
||||||
if key not in cache:
|
card = cache.get(key)
|
||||||
cache[key] = _SummaryRow(container)
|
if card is None:
|
||||||
cache[key].pack(fill="x", pady=Theme.SPACE_XS)
|
card = _FlowCard(container)
|
||||||
cache[key].update_row(title, detail, status)
|
cache[key] = card
|
||||||
|
card.update_card(title, subtitle, meta, status)
|
||||||
|
card.pack(fill="x", pady=Theme.SPACE_XS)
|
||||||
|
|
||||||
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||||
if not isinstance(snapshot, HubSnapshot):
|
if not isinstance(snapshot, HubSnapshot):
|
||||||
return
|
return
|
||||||
if snapshot.is_stale:
|
self.snapshot_freshness.configure(
|
||||||
self.snapshot_freshness.configure(
|
text=f"{'⚠ данные устарели' if snapshot.is_stale else '● данные актуальны'} • snapshot #{snapshot.seq}",
|
||||||
text=f"⚠ Snapshot #{snapshot.seq}: данные устарели",
|
text_color=Theme.STATUS_WARNING if snapshot.is_stale else Theme.STATUS_HEALTHY,
|
||||||
text_color=Theme.STATUS_WARNING,
|
)
|
||||||
|
readiness = snapshot.readiness
|
||||||
|
telemetry = dict(snapshot.metrics.get("telemetry") or {})
|
||||||
|
global_telemetry = dict(telemetry.get("global") or {})
|
||||||
|
provider_telemetry = dict(telemetry.get("by_provider") or {})
|
||||||
|
role_telemetry = dict(telemetry.get("by_role") or {})
|
||||||
|
has_telemetry = bool(telemetry.get("has_data"))
|
||||||
|
self.calls_metric.val_label.configure(text=str(global_telemetry.get("total_calls")) if has_telemetry else "Н/Д")
|
||||||
|
self.latency_metric.val_label.configure(
|
||||||
|
text=f"{global_telemetry['latency_p50_ms']:.0f} мс"
|
||||||
|
if has_telemetry and global_telemetry.get("latency_p50_ms") is not None
|
||||||
|
else "Н/Д"
|
||||||
|
)
|
||||||
|
self.failover_metric.val_label.configure(
|
||||||
|
text=str(global_telemetry.get("failovers_count")) if has_telemetry else "Н/Д"
|
||||||
|
)
|
||||||
|
active_calls = snapshot.metrics.get("active_calls_total")
|
||||||
|
self.calls_metric.sub_label.configure(
|
||||||
|
text=f"активно: {active_calls}" if isinstance(active_calls, int) else "own_measurement"
|
||||||
|
)
|
||||||
|
self.agents_metric.val_label.configure(text=f"{readiness.roles_ready_count}/{readiness.total_roles}")
|
||||||
|
measured_buckets = sum(
|
||||||
|
1
|
||||||
|
for quota in snapshot.quotas.values()
|
||||||
|
if not getattr(quota, "is_estimated", True)
|
||||||
|
for bucket in quota.buckets
|
||||||
|
if bucket.remaining_percent is not None
|
||||||
|
)
|
||||||
|
self.quota_metric.val_label.configure(text=str(measured_buckets) if measured_buckets else "Н/Д")
|
||||||
|
self.quota_metric.sub_label.configure(text="измеренных корзин" if measured_buckets else "нет измерения")
|
||||||
|
|
||||||
|
providers = list(snapshot.providers)[:3]
|
||||||
|
|
||||||
|
def provider_activity(provider_id: str, online: int, connected: int) -> str:
|
||||||
|
measured = dict(provider_telemetry.get(provider_id) or {})
|
||||||
|
share = measured.get("call_share")
|
||||||
|
calls = measured.get("total_calls")
|
||||||
|
if share is not None and calls is not None:
|
||||||
|
return f"{share:.0%} потока • {calls} вызовов"
|
||||||
|
return f"{online}/{connected} онлайн"
|
||||||
|
|
||||||
|
self._sync_cards(
|
||||||
|
self.provider_column,
|
||||||
|
self._provider_cards,
|
||||||
|
(
|
||||||
|
(
|
||||||
|
provider.provider_id,
|
||||||
|
provider.provider_name,
|
||||||
|
provider_activity(
|
||||||
|
provider.provider_id,
|
||||||
|
provider.online_count,
|
||||||
|
provider.connected_count,
|
||||||
|
),
|
||||||
|
self._quota_text(snapshot, provider.provider_id),
|
||||||
|
"healthy" if provider.online_count else "warning",
|
||||||
|
)
|
||||||
|
for provider in providers
|
||||||
|
),
|
||||||
|
)
|
||||||
|
orchestrator = next((agent for agent in snapshot.agents if agent.is_main_orchestrator), None)
|
||||||
|
if orchestrator:
|
||||||
|
self.orchestrator_card.update_card(
|
||||||
|
"Главный оркестратор",
|
||||||
|
f"{orchestrator.provider_display_name} • {orchestrator.model}",
|
||||||
|
f"{orchestrator.account_identity or 'Аккаунт: Н/Д'} • {orchestrator.status_label_ru}",
|
||||||
|
"active" if orchestrator.is_active else "warning",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
self.snapshot_freshness.configure(
|
self.orchestrator_card.update_card("Главный оркестратор", "Не назначен", "Аккаунт: Н/Д", "warning")
|
||||||
text=f"Snapshot #{snapshot.seq}: актуальные данные",
|
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:3]
|
||||||
text_color=Theme.TEXT_MUTED,
|
self._sync_cards(
|
||||||
)
|
self.agent_column,
|
||||||
readiness = snapshot.readiness
|
self._agent_cards,
|
||||||
self.system_metric.val_label.configure(text=readiness.title_ru)
|
(
|
||||||
self.system_metric.sub_label.configure(text=readiness.summary_ru)
|
(
|
||||||
self.provider_metric.val_label.configure(text=f"{readiness.providers_ready_count}/{readiness.total_providers}")
|
agent.role_id,
|
||||||
self.account_metric.val_label.configure(text=f"{readiness.accounts_connected_count}/{readiness.total_accounts}")
|
agent.role_name_ru,
|
||||||
self.role_metric.val_label.configure(text=f"{readiness.roles_ready_count}/{readiness.total_roles}")
|
f"{agent.provider_display_name} • {agent.model}",
|
||||||
|
(
|
||||||
|
f"{agent.active_quota_label or 'Квота: Н/Д'}"
|
||||||
|
+ (
|
||||||
|
f" • {role_telemetry[agent.role_id]['total_calls']} вызовов"
|
||||||
|
if role_telemetry.get(agent.role_id, {}).get("has_data")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"healthy" if agent.is_active else "warning",
|
||||||
|
)
|
||||||
|
for agent in agents
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
provider_rows = {
|
live_provider_ids = {provider.provider_id for provider in snapshot.providers}
|
||||||
provider.provider_id: (
|
for provider_id in list(self._provider_status_rows):
|
||||||
|
if provider_id not in live_provider_ids:
|
||||||
|
self._provider_status_rows.pop(provider_id).destroy()
|
||||||
|
for provider in snapshot.providers:
|
||||||
|
row = self._provider_status_rows.get(provider.provider_id)
|
||||||
|
if row is None:
|
||||||
|
row = _StatusRow(self.provider_status)
|
||||||
|
row.pack(fill="x", pady=Theme.SPACE_XS)
|
||||||
|
self._provider_status_rows[provider.provider_id] = row
|
||||||
|
measured = dict(provider_telemetry.get(provider.provider_id) or {})
|
||||||
|
latency = measured.get("latency_p50_ms")
|
||||||
|
row.update_row(
|
||||||
provider.provider_name,
|
provider.provider_name,
|
||||||
f"{provider.online_count}/{provider.connected_count} онлайн • {provider.auth_required_count} требуют входа",
|
(
|
||||||
|
f"{provider.online_count}/{provider.connected_count} • {latency:.0f} мс"
|
||||||
|
if latency is not None
|
||||||
|
else f"{provider.online_count}/{provider.connected_count}"
|
||||||
|
),
|
||||||
"healthy" if provider.online_count else "warning",
|
"healthy" if provider.online_count else "warning",
|
||||||
)
|
)
|
||||||
for provider in snapshot.providers
|
|
||||||
}
|
|
||||||
self._sync_rows(self.providers_card, self._provider_rows, provider_rows)
|
|
||||||
|
|
||||||
route_rows = {}
|
host = dict(snapshot.metrics.get("host") or {})
|
||||||
for role_id, pipeline in snapshot.routing.items():
|
if host.get("has_data"):
|
||||||
active = next((node for node in pipeline.nodes if node.is_active), None)
|
network_total = sum(
|
||||||
detail = f"{active.provider} • {active.model}" if active else "Активный узел: Н/Д"
|
value for value in (host.get("net_bytes_sent"), host.get("net_bytes_recv")) if isinstance(value, int)
|
||||||
route_rows[role_id] = (pipeline.role_name_ru, detail, "healthy" if active else "warning")
|
)
|
||||||
self._sync_rows(self.routes_card, self._route_rows, route_rows)
|
network_text = f"{network_total / (1024 * 1024):.0f} МБ" if network_total else "Н/Д"
|
||||||
|
self.host_metrics.configure(
|
||||||
alerts: Dict[str, tuple[str, str, str]] = {}
|
text=(
|
||||||
for profile in snapshot.all_profiles.values():
|
f"CPU {host.get('cpu_percent', 'Н/Д')}%\n"
|
||||||
if profile.auth_state == "AUTH_EXPIRED":
|
f"Память {host.get('memory_percent', 'Н/Д')}%\n"
|
||||||
alerts[f"auth:{profile.profile_id}"] = (
|
f"Диск {host.get('disk_percent', 'Н/Д')}%\n"
|
||||||
profile.account_identity or profile.profile_id,
|
f"Сеть {network_text}"
|
||||||
"Авторизация истекла",
|
|
||||||
"auth_expired",
|
|
||||||
)
|
)
|
||||||
quota = snapshot.quotas.get(profile.profile_id)
|
)
|
||||||
if quota and not getattr(quota, "is_estimated", True):
|
else:
|
||||||
low = [
|
self.host_metrics.configure(text="CPU Н/Д\nПамять Н/Д\nДиск Н/Д\nСеть Н/Д")
|
||||||
bucket
|
|
||||||
for bucket in quota.buckets
|
|
||||||
if bucket.remaining_percent is not None and bucket.remaining_percent <= 20
|
|
||||||
]
|
|
||||||
if low:
|
|
||||||
alerts[f"quota:{profile.profile_id}"] = (
|
|
||||||
profile.account_identity or profile.profile_id,
|
|
||||||
", ".join(f"{bucket.display_name}: {bucket.remaining_percent:.0f}%" for bucket in low),
|
|
||||||
"quota_low",
|
|
||||||
)
|
|
||||||
for index, warning in enumerate(readiness.warnings):
|
|
||||||
alerts[f"warning:{index}"] = ("Системное предупреждение", warning, "warning")
|
|
||||||
if not alerts:
|
|
||||||
alerts["none"] = ("Критичных предупреждений нет", "По данным текущего snapshot", "healthy")
|
|
||||||
self._sync_rows(self.alerts_card, self._alert_rows, alerts)
|
|
||||||
|
|
||||||
def _navigate(self, target: str) -> None:
|
def update_events(self, events: Iterable[Any]) -> None:
|
||||||
if self.on_navigate:
|
items = list(events)[:5]
|
||||||
self.on_navigate(target)
|
while len(self._event_rows) < len(items):
|
||||||
|
row = _EventRow(self.events_body)
|
||||||
|
row.pack(fill="x", pady=Theme.SPACE_XS)
|
||||||
|
self._event_rows.append(row)
|
||||||
|
for index, row in enumerate(self._event_rows):
|
||||||
|
if index < len(items):
|
||||||
|
row.update_event(items[index])
|
||||||
|
row.pack(fill="x", pady=Theme.SPACE_XS)
|
||||||
|
else:
|
||||||
|
row.pack_forget()
|
||||||
|
if items:
|
||||||
|
self.events_empty.pack_forget()
|
||||||
|
else:
|
||||||
|
self.events_empty.pack(anchor="w", pady=Theme.SPACE_SM)
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,126 @@
|
||||||
"""Event-log screen with honest handling of the current snapshot contract gap."""
|
"""Keyed event timeline populated by the application action layer."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Iterable, Optional
|
||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from antigravity_provider.router.state_store import HubSnapshot
|
from antigravity_provider.router.state_store import HubSnapshot
|
||||||
from antigravity_provider.router.ui.components import EmptyState, SectionHeader
|
from antigravity_provider.router.ui.components import FilterButton, HubCard, SearchField, SectionHeader
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
|
||||||
|
|
||||||
|
class _LogRow(HubCard):
|
||||||
|
def __init__(self, master: Any):
|
||||||
|
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE)
|
||||||
|
self.time = ctk.CTkLabel(self, text="", width=70, font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED)
|
||||||
|
self.time.pack(side="left", padx=(Theme.CARD_PAD_X, 0), pady=Theme.SPACE_SM)
|
||||||
|
self.dot = ctk.CTkLabel(self, text="●", width=18, font=Theme.font_micro())
|
||||||
|
self.dot.pack(side="left")
|
||||||
|
self.message = ctk.CTkLabel(self, text="", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY)
|
||||||
|
self.message.pack(side="left", fill="x", expand=True, padx=Theme.SPACE_SM)
|
||||||
|
self.category = ctk.CTkLabel(
|
||||||
|
self,
|
||||||
|
text="",
|
||||||
|
font=Theme.font_micro(),
|
||||||
|
text_color=Theme.TEXT_SECONDARY,
|
||||||
|
fg_color=Theme.SURFACE_MUTED,
|
||||||
|
corner_radius=Theme.RADIUS_PILL,
|
||||||
|
)
|
||||||
|
self.category.pack(side="right", padx=Theme.CARD_PAD_X)
|
||||||
|
|
||||||
|
def update_event(self, event: Any) -> None:
|
||||||
|
level = str(getattr(event, "level", "info"))
|
||||||
|
color = {
|
||||||
|
"success": Theme.STATUS_HEALTHY,
|
||||||
|
"warning": Theme.STATUS_WARNING,
|
||||||
|
"error": Theme.STATUS_ERROR,
|
||||||
|
}.get(level, Theme.STATUS_INFO)
|
||||||
|
self.time.configure(text=str(getattr(event, "timestamp", "Н/Д")))
|
||||||
|
self.dot.configure(text_color=color)
|
||||||
|
self.message.configure(text=str(getattr(event, "message", "Н/Д")))
|
||||||
|
self.category.configure(text=f" {getattr(event, 'category', 'system')} ")
|
||||||
|
|
||||||
|
|
||||||
class LogsView(ctk.CTkFrame):
|
class LogsView(ctk.CTkFrame):
|
||||||
def __init__(self, master: Any, **kwargs):
|
def __init__(self, master: Any, **kwargs):
|
||||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
|
self._events: list[Any] = []
|
||||||
|
self._rows: list[_LogRow] = []
|
||||||
|
self._query = ""
|
||||||
|
self._level = "Все уровни"
|
||||||
SectionHeader(
|
SectionHeader(
|
||||||
self,
|
self,
|
||||||
title="Журнал событий",
|
title="Журнал событий",
|
||||||
subtitle="Только события, переданные через единый snapshot-контракт",
|
subtitle="Реальные события Hermes Hub с уровнями и категориями",
|
||||||
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||||
self.empty = EmptyState(
|
toolbar = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
self,
|
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, Theme.SPACE_SM))
|
||||||
title="Журнал: Н/Д",
|
self.search = SearchField(toolbar, placeholder_text="Поиск по журналу…", width=320)
|
||||||
message=(
|
self.search.pack(side="left", padx=(0, Theme.SPACE_SM))
|
||||||
"HubSnapshot пока не содержит события журнала. Экран не читает файлы и backend "
|
self.search.bind("<KeyRelease>", lambda _event: self._set_query(self.search.get()))
|
||||||
"напрямую и не показывает выдуманную историю."
|
self.level = FilterButton(
|
||||||
),
|
toolbar,
|
||||||
|
values=["Все уровни", "Информация", "Успех", "Предупреждение", "Ошибка"],
|
||||||
|
command=self._set_level,
|
||||||
|
width=170,
|
||||||
)
|
)
|
||||||
self.empty.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=Theme.SPACE_XL)
|
self.level.pack(side="left")
|
||||||
|
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||||
|
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||||
|
self.empty = ctk.CTkLabel(
|
||||||
|
self.scroll,
|
||||||
|
text="События: Н/Д",
|
||||||
|
font=Theme.font_caption(),
|
||||||
|
text_color=Theme.TEXT_MUTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _set_query(self, value: str) -> None:
|
||||||
|
self._query = value.strip().lower()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _set_level(self, value: str) -> None:
|
||||||
|
self._level = value
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _filtered(self) -> list[Any]:
|
||||||
|
levels = {
|
||||||
|
"Информация": "info",
|
||||||
|
"Успех": "success",
|
||||||
|
"Предупреждение": "warning",
|
||||||
|
"Ошибка": "error",
|
||||||
|
}
|
||||||
|
expected = levels.get(self._level)
|
||||||
|
result = []
|
||||||
|
for event in self._events:
|
||||||
|
if expected and str(getattr(event, "level", "info")) != expected:
|
||||||
|
continue
|
||||||
|
haystack = f"{getattr(event, 'category', '')} {getattr(event, 'message', '')}".lower()
|
||||||
|
if self._query and self._query not in haystack:
|
||||||
|
continue
|
||||||
|
result.append(event)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
events = self._filtered()
|
||||||
|
while len(self._rows) < len(events):
|
||||||
|
row = _LogRow(self.scroll)
|
||||||
|
self._rows.append(row)
|
||||||
|
for index, row in enumerate(self._rows):
|
||||||
|
if index < len(events):
|
||||||
|
row.update_event(events[index])
|
||||||
|
row.pack(fill="x", pady=Theme.SPACE_XS)
|
||||||
|
else:
|
||||||
|
row.pack_forget()
|
||||||
|
if events:
|
||||||
|
self.empty.pack_forget()
|
||||||
|
else:
|
||||||
|
self.empty.pack(anchor="w", pady=Theme.SPACE_XL)
|
||||||
|
|
||||||
|
def update_events(self, events: Iterable[Any]) -> None:
|
||||||
|
self._events = list(events)
|
||||||
|
self._render()
|
||||||
|
|
||||||
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||||
if not isinstance(snapshot, HubSnapshot):
|
if not isinstance(snapshot, HubSnapshot):
|
||||||
|
|
|
||||||
83
src/antigravity_provider/router/ui/views/quotas_view.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
"""Truthful multi-bucket quota overview driven by HubSnapshot."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from antigravity_provider.router.state_store import HubSnapshot
|
||||||
|
from antigravity_provider.router.ui.components import EmptyState, HubCard, QuotaBar, SectionHeader
|
||||||
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
|
||||||
|
|
||||||
|
class _QuotaRow(HubCard):
|
||||||
|
def __init__(self, master: Any):
|
||||||
|
super().__init__(master, corner_radius=Theme.RADIUS_SM)
|
||||||
|
header = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
|
header.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
|
||||||
|
self.title = ctk.CTkLabel(header, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
|
||||||
|
self.title.pack(side="left")
|
||||||
|
self.source = ctk.CTkLabel(header, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||||
|
self.source.pack(side="right")
|
||||||
|
self.identity = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
|
||||||
|
self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X)
|
||||||
|
self.bar = QuotaBar(self, label="Остаток")
|
||||||
|
self.bar.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
||||||
|
self.reset = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
||||||
|
self.reset.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
|
||||||
|
|
||||||
|
def update_bucket(self, profile: Any, quota: Any, bucket: Any) -> None:
|
||||||
|
remaining = getattr(bucket, "remaining_percent", None)
|
||||||
|
ratio = float(remaining) / 100.0 if remaining is not None else None
|
||||||
|
detail = bucket.formatted_remaining() if hasattr(bucket, "formatted_remaining") else "Н/Д"
|
||||||
|
reset = bucket.formatted_reset() if hasattr(bucket, "formatted_reset") else None
|
||||||
|
estimated = bool(getattr(quota, "is_estimated", True))
|
||||||
|
self.title.configure(text=f"{bucket.display_name} • {profile.provider_display_name}")
|
||||||
|
self.identity.configure(text=profile.account_identity or profile.display_name)
|
||||||
|
self.source.configure(text="оценка" if estimated else str(getattr(quota, "source", "измерено")))
|
||||||
|
self.bar.set_value(ratio, detail)
|
||||||
|
reason = getattr(quota, "unavailable_reason", None)
|
||||||
|
self.reset.configure(text=reason or reset or "Сброс: Н/Д")
|
||||||
|
|
||||||
|
|
||||||
|
class QuotasView(ctk.CTkFrame):
|
||||||
|
def __init__(self, master: Any, **kwargs):
|
||||||
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
|
self._rows: Dict[str, _QuotaRow] = {}
|
||||||
|
SectionHeader(
|
||||||
|
self,
|
||||||
|
title="Квоты и лимиты",
|
||||||
|
subtitle="Независимые корзины провайдеров; отсутствие измерения не считается нулём",
|
||||||
|
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||||
|
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||||
|
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||||
|
self.empty = EmptyState(
|
||||||
|
self.scroll,
|
||||||
|
title="Квоты: Н/Д",
|
||||||
|
message="Провайдеры ещё не опубликовали ни одной квотной корзины.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||||
|
if not isinstance(snapshot, HubSnapshot):
|
||||||
|
return
|
||||||
|
incoming: list[tuple[str, Any, Any, Any]] = []
|
||||||
|
for profile in snapshot.all_profiles.values():
|
||||||
|
quota = snapshot.quotas.get(profile.profile_id)
|
||||||
|
for bucket in list(getattr(quota, "buckets", None) or []):
|
||||||
|
incoming.append((f"{profile.profile_id}:{bucket.id}", profile, quota, bucket))
|
||||||
|
live = {key for key, *_rest in incoming}
|
||||||
|
for key in list(self._rows):
|
||||||
|
if key not in live:
|
||||||
|
self._rows.pop(key).destroy()
|
||||||
|
for key, profile, quota, bucket in incoming:
|
||||||
|
row = self._rows.get(key)
|
||||||
|
if row is None:
|
||||||
|
row = _QuotaRow(self.scroll)
|
||||||
|
row.pack(fill="x", pady=Theme.SPACE_XS)
|
||||||
|
self._rows[key] = row
|
||||||
|
row.update_bucket(profile, quota, bucket)
|
||||||
|
if incoming:
|
||||||
|
self.empty.pack_forget()
|
||||||
|
else:
|
||||||
|
self.empty.pack(fill="x", pady=Theme.SPACE_XL)
|
||||||
|
|
@ -12,7 +12,13 @@ from antigravity_provider.version import CHANNEL, __version__
|
||||||
|
|
||||||
|
|
||||||
class SettingsView(ctk.CTkFrame):
|
class SettingsView(ctk.CTkFrame):
|
||||||
def __init__(self, master: Any, on_action: Optional[Callable] = None, **kwargs):
|
def __init__(
|
||||||
|
self,
|
||||||
|
master: Any,
|
||||||
|
on_action: Optional[Callable] = None,
|
||||||
|
theme_name: str = "dark",
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
self.on_action = on_action
|
self.on_action = on_action
|
||||||
SectionHeader(
|
SectionHeader(
|
||||||
|
|
@ -24,6 +30,24 @@ class SettingsView(ctk.CTkFrame):
|
||||||
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||||
scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||||
scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||||
|
appearance = self._section(scroll, "Оформление")
|
||||||
|
theme_row = ctk.CTkFrame(appearance, fg_color="transparent")
|
||||||
|
theme_row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
theme_row,
|
||||||
|
text="Цветовая схема",
|
||||||
|
font=Theme.font_body(),
|
||||||
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
|
).pack(side="left")
|
||||||
|
self.theme = ctk.CTkOptionMenu(
|
||||||
|
theme_row,
|
||||||
|
values=list(Theme.SCHEME_LABELS.values()),
|
||||||
|
fg_color=Theme.SURFACE_MUTED,
|
||||||
|
button_color=Theme.SECONDARY,
|
||||||
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
|
)
|
||||||
|
self.theme.set(Theme.SCHEME_LABELS.get(theme_name, Theme.SCHEME_LABELS["dark"]))
|
||||||
|
self.theme.pack(side="right")
|
||||||
routing = self._section(scroll, "Маршрутизация и отказоустойчивость")
|
routing = self._section(scroll, "Маршрутизация и отказоустойчивость")
|
||||||
self.affinity = self._switch(routing, "Сессионная привязка", True)
|
self.affinity = self._switch(routing, "Сессионная привязка", True)
|
||||||
self.failover = self._switch(routing, "Автоматический failover", True)
|
self.failover = self._switch(routing, "Автоматический failover", True)
|
||||||
|
|
@ -81,6 +105,10 @@ class SettingsView(ctk.CTkFrame):
|
||||||
if not self.on_action:
|
if not self.on_action:
|
||||||
return
|
return
|
||||||
intervals = {"Выкл": 0, "1 мин": 60, "5 мин": 300, "10 мин": 600, "30 мин": 1800}
|
intervals = {"Выкл": 0, "1 мин": 60, "5 мин": 300, "10 мин": 600, "30 мин": 1800}
|
||||||
|
theme_key = next(
|
||||||
|
(key for key, label in Theme.SCHEME_LABELS.items() if label == self.theme.get()),
|
||||||
|
"dark",
|
||||||
|
)
|
||||||
self.on_action(
|
self.on_action(
|
||||||
"save_settings",
|
"save_settings",
|
||||||
{
|
{
|
||||||
|
|
@ -89,5 +117,6 @@ class SettingsView(ctk.CTkFrame):
|
||||||
"prefer_same_account_model_fallback": bool(self.same_account.get()),
|
"prefer_same_account_model_fallback": bool(self.same_account.get()),
|
||||||
"quota_refresh_interval_label": self.interval.get(),
|
"quota_refresh_interval_label": self.interval.get(),
|
||||||
"quota_refresh_interval_sec": intervals.get(self.interval.get(), 300),
|
"quota_refresh_interval_sec": intervals.get(self.interval.get(), 300),
|
||||||
|
"theme": theme_key,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ class AgentCardWidget(HubCard):
|
||||||
self.role_lbl = ctk.CTkLabel(self.line1, text="—", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
|
self.role_lbl = ctk.CTkLabel(self.line1, text="—", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
|
||||||
self.role_lbl.pack(side="left")
|
self.role_lbl.pack(side="left")
|
||||||
|
|
||||||
self.orch_pill = ctk.CTkFrame(self.line1, fg_color="#3D3522", corner_radius=Theme.RADIUS_SM)
|
self.orch_pill = ctk.CTkFrame(self.line1, fg_color=Theme.ACCENT_DIM, corner_radius=Theme.RADIUS_SM)
|
||||||
self.orch_pill_lbl = ctk.CTkLabel(
|
self.orch_pill_lbl = ctk.CTkLabel(
|
||||||
self.orch_pill, text="👑 ЛИДЕР РОУТЕРА", font=Theme.font_micro(), text_color=Theme.ACCENT
|
self.orch_pill, text="👑 ЛИДЕР РОУТЕРА", font=Theme.font_micro(), text_color=Theme.ACCENT
|
||||||
)
|
)
|
||||||
|
|
@ -160,9 +160,9 @@ class AgentCardWidget(HubCard):
|
||||||
elif "opencode" in prov:
|
elif "opencode" in prov:
|
||||||
self.prov_lbl.configure(text="OpenCode Go", text_color=Theme.PROVIDER_OPENCODE)
|
self.prov_lbl.configure(text="OpenCode Go", text_color=Theme.PROVIDER_OPENCODE)
|
||||||
elif "claude" in prov or "anthropic" in prov:
|
elif "claude" in prov or "anthropic" in prov:
|
||||||
self.prov_lbl.configure(text="Claude", text_color="#d97706")
|
self.prov_lbl.configure(text="Claude", text_color=Theme.PROVIDER_CLAUDE)
|
||||||
elif "grok" in prov or "xai" in prov:
|
elif "grok" in prov or "xai" in prov:
|
||||||
self.prov_lbl.configure(text="Grok", text_color="#3b82f6")
|
self.prov_lbl.configure(text="Grok", text_color=Theme.PROVIDER_GROK)
|
||||||
else:
|
else:
|
||||||
self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED)
|
self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED)
|
||||||
|
|
||||||
|
|
|
||||||
116
tests/test_ui_mockup_redesign.py
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
"""Acceptance tests for the approved three-theme mockup redesign."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("customtkinter")
|
||||||
|
|
||||||
|
import customtkinter as ctk
|
||||||
|
|
||||||
|
from antigravity_provider.router.ui.components import AccountCardWidget
|
||||||
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def ui_root():
|
||||||
|
root = ctk.CTk()
|
||||||
|
root.withdraw()
|
||||||
|
yield root
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_every_restored_account_handler_has_a_ui_trigger() -> None:
|
||||||
|
required = {"test", "set_main", "set_orchestrator", "assign_role"}
|
||||||
|
configured = {action for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS}
|
||||||
|
assert configured == required
|
||||||
|
|
||||||
|
app_path = Path("src/antigravity_provider/router/hermes_hub_app.py")
|
||||||
|
tree = ast.parse(app_path.read_text(encoding="utf-8"))
|
||||||
|
handled = {
|
||||||
|
node.comparators[0].value
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
if isinstance(node, ast.Compare)
|
||||||
|
and isinstance(node.left, ast.Name)
|
||||||
|
and node.left.id == "action"
|
||||||
|
and len(node.ops) == 1
|
||||||
|
and isinstance(node.ops[0], ast.Eq)
|
||||||
|
and len(node.comparators) == 1
|
||||||
|
and isinstance(node.comparators[0], ast.Constant)
|
||||||
|
and isinstance(node.comparators[0].value, str)
|
||||||
|
}
|
||||||
|
assert required <= handled
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_three_schemes_have_identical_token_coverage() -> None:
|
||||||
|
assert set(Theme.PALETTES) == {"dark", "hybrid", "light"}
|
||||||
|
token_sets = [set(palette) for palette in Theme.PALETTES.values()]
|
||||||
|
assert token_sets[0] == token_sets[1] == token_sets[2]
|
||||||
|
backgrounds = {palette["BG_WINDOW"] for palette in Theme.PALETTES.values()}
|
||||||
|
assert len(backgrounds) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_ui_colors_are_centralized_in_theme_tokens() -> None:
|
||||||
|
roots = [
|
||||||
|
Path("src/antigravity_provider/router/ui"),
|
||||||
|
Path("src/antigravity_provider/router/hermes_hub_app.py"),
|
||||||
|
]
|
||||||
|
offenders = []
|
||||||
|
for root in roots:
|
||||||
|
files = [root] if root.is_file() else list(root.rglob("*.py"))
|
||||||
|
for file in files:
|
||||||
|
if file.name == "theme.py":
|
||||||
|
continue
|
||||||
|
if re.search(r"#[0-9A-Fa-f]{3,8}", file.read_text(encoding="utf-8")):
|
||||||
|
offenders.append(str(file))
|
||||||
|
assert offenders == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_approved_mockup_numbers_are_not_shipped_as_placeholders() -> None:
|
||||||
|
source_files = list(Path("src/antigravity_provider/router/ui").rglob("*.py"))
|
||||||
|
source_files.append(Path("src/antigravity_provider/router/hermes_hub_app.py"))
|
||||||
|
source = "\n".join(file.read_text(encoding="utf-8") for file in source_files)
|
||||||
|
for fictional_value in ("78%", "09:00–21:00", "842 rps", "99.98%", "42 Мбит/с"):
|
||||||
|
assert fictional_value not in source
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
def test_apply_scheme_updates_shared_semantic_aliases() -> None:
|
||||||
|
original = Theme.current_scheme
|
||||||
|
try:
|
||||||
|
for scheme in Theme.SCHEMES:
|
||||||
|
assert Theme.apply_scheme(scheme) == scheme
|
||||||
|
assert Theme.COLOR_POSITIVE == Theme.STATUS_HEALTHY
|
||||||
|
assert Theme.COLOR_CAUTION == Theme.STATUS_WARNING
|
||||||
|
assert Theme.COLOR_BRAND == Theme.ACCENT
|
||||||
|
finally:
|
||||||
|
Theme.apply_scheme(original)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.ui
|
||||||
|
def test_restored_account_buttons_invoke_each_action(ui_root) -> None:
|
||||||
|
calls = []
|
||||||
|
card = AccountCardWidget(
|
||||||
|
ui_root,
|
||||||
|
"profile-1",
|
||||||
|
"user@example.test",
|
||||||
|
"OpenAI Codex",
|
||||||
|
on_action=lambda action, profile: calls.append((action, profile.profile_id)),
|
||||||
|
)
|
||||||
|
card.profile_model = SimpleNamespace(profile_id="profile-1")
|
||||||
|
try:
|
||||||
|
card.pack()
|
||||||
|
for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS:
|
||||||
|
card.action_buttons[action].invoke()
|
||||||
|
assert calls == [(action, "profile-1") for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS]
|
||||||
|
finally:
|
||||||
|
card.destroy()
|
||||||
54
tests/test_ui_screenshot_harness.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""Manual screenshot harness for every tab in all approved color schemes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("customtkinter")
|
||||||
|
pytest.importorskip("PIL")
|
||||||
|
|
||||||
|
from PIL import ImageGrab
|
||||||
|
|
||||||
|
from antigravity_provider.router.hermes_hub_app import HermesHubApp
|
||||||
|
|
||||||
|
|
||||||
|
def capture_all(output_dir: Path) -> list[Path]:
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
app = HermesHubApp()
|
||||||
|
app.geometry("1366x768+20+20")
|
||||||
|
app.deiconify()
|
||||||
|
app.lift()
|
||||||
|
app.attributes("-topmost", True)
|
||||||
|
for _index in range(20):
|
||||||
|
app.update()
|
||||||
|
time.sleep(0.04)
|
||||||
|
|
||||||
|
captured = []
|
||||||
|
try:
|
||||||
|
for scheme in ("dark", "hybrid", "light"):
|
||||||
|
app._apply_theme(scheme)
|
||||||
|
for view_name, _label, _icon in app._nav_items:
|
||||||
|
app._show_view(view_name)
|
||||||
|
app.update_idletasks()
|
||||||
|
app.update()
|
||||||
|
time.sleep(0.08)
|
||||||
|
left = app.winfo_rootx()
|
||||||
|
top = app.winfo_rooty()
|
||||||
|
target = output_dir / f"{view_name}-{scheme}.png"
|
||||||
|
ImageGrab.grab(bbox=(left, top, left + app.winfo_width(), top + app.winfo_height())).save(target)
|
||||||
|
captured.append(target)
|
||||||
|
finally:
|
||||||
|
app.attributes("-topmost", False)
|
||||||
|
app._shutting_down = True
|
||||||
|
app.destroy()
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
destination = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("artifacts/mockup-redesign")
|
||||||
|
for screenshot in capture_all(destination):
|
||||||
|
print(screenshot)
|
||||||