diff --git a/CODEX_MOCKUP_REDESIGN_REPORT.md b/CODEX_MOCKUP_REDESIGN_REPORT.md new file mode 100644 index 0000000..3c40eee --- /dev/null +++ b/CODEX_MOCKUP_REDESIGN_REPORT.md @@ -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 не создавались и не изменялись. diff --git a/artifacts/mockup-redesign/about-dark.png b/artifacts/mockup-redesign/about-dark.png new file mode 100644 index 0000000..23ef0de Binary files /dev/null and b/artifacts/mockup-redesign/about-dark.png differ diff --git a/artifacts/mockup-redesign/about-hybrid.png b/artifacts/mockup-redesign/about-hybrid.png new file mode 100644 index 0000000..f00fc78 Binary files /dev/null and b/artifacts/mockup-redesign/about-hybrid.png differ diff --git a/artifacts/mockup-redesign/about-light.png b/artifacts/mockup-redesign/about-light.png new file mode 100644 index 0000000..c91c648 Binary files /dev/null and b/artifacts/mockup-redesign/about-light.png differ diff --git a/artifacts/mockup-redesign/accounts-dark.png b/artifacts/mockup-redesign/accounts-dark.png new file mode 100644 index 0000000..c8dfd64 Binary files /dev/null and b/artifacts/mockup-redesign/accounts-dark.png differ diff --git a/artifacts/mockup-redesign/accounts-hybrid.png b/artifacts/mockup-redesign/accounts-hybrid.png new file mode 100644 index 0000000..8dcf2ec Binary files /dev/null and b/artifacts/mockup-redesign/accounts-hybrid.png differ diff --git a/artifacts/mockup-redesign/accounts-light.png b/artifacts/mockup-redesign/accounts-light.png new file mode 100644 index 0000000..6386c3a Binary files /dev/null and b/artifacts/mockup-redesign/accounts-light.png differ diff --git a/artifacts/mockup-redesign/analytics-dark.png b/artifacts/mockup-redesign/analytics-dark.png new file mode 100644 index 0000000..66356e1 Binary files /dev/null and b/artifacts/mockup-redesign/analytics-dark.png differ diff --git a/artifacts/mockup-redesign/analytics-hybrid.png b/artifacts/mockup-redesign/analytics-hybrid.png new file mode 100644 index 0000000..8596409 Binary files /dev/null and b/artifacts/mockup-redesign/analytics-hybrid.png differ diff --git a/artifacts/mockup-redesign/analytics-light.png b/artifacts/mockup-redesign/analytics-light.png new file mode 100644 index 0000000..9609ab4 Binary files /dev/null and b/artifacts/mockup-redesign/analytics-light.png differ diff --git a/artifacts/mockup-redesign/health-dark.png b/artifacts/mockup-redesign/health-dark.png new file mode 100644 index 0000000..a52fb39 Binary files /dev/null and b/artifacts/mockup-redesign/health-dark.png differ diff --git a/artifacts/mockup-redesign/health-hybrid.png b/artifacts/mockup-redesign/health-hybrid.png new file mode 100644 index 0000000..ac80f38 Binary files /dev/null and b/artifacts/mockup-redesign/health-hybrid.png differ diff --git a/artifacts/mockup-redesign/health-light.png b/artifacts/mockup-redesign/health-light.png new file mode 100644 index 0000000..43bd5a4 Binary files /dev/null and b/artifacts/mockup-redesign/health-light.png differ diff --git a/artifacts/mockup-redesign/logs-dark.png b/artifacts/mockup-redesign/logs-dark.png new file mode 100644 index 0000000..a1831a4 Binary files /dev/null and b/artifacts/mockup-redesign/logs-dark.png differ diff --git a/artifacts/mockup-redesign/logs-hybrid.png b/artifacts/mockup-redesign/logs-hybrid.png new file mode 100644 index 0000000..712d259 Binary files /dev/null and b/artifacts/mockup-redesign/logs-hybrid.png differ diff --git a/artifacts/mockup-redesign/logs-light.png b/artifacts/mockup-redesign/logs-light.png new file mode 100644 index 0000000..9edb77c Binary files /dev/null and b/artifacts/mockup-redesign/logs-light.png differ diff --git a/artifacts/mockup-redesign/overview-dark.png b/artifacts/mockup-redesign/overview-dark.png new file mode 100644 index 0000000..8cc912e Binary files /dev/null and b/artifacts/mockup-redesign/overview-dark.png differ diff --git a/artifacts/mockup-redesign/overview-hybrid.png b/artifacts/mockup-redesign/overview-hybrid.png new file mode 100644 index 0000000..d424b95 Binary files /dev/null and b/artifacts/mockup-redesign/overview-hybrid.png differ diff --git a/artifacts/mockup-redesign/overview-light.png b/artifacts/mockup-redesign/overview-light.png new file mode 100644 index 0000000..3c7122a Binary files /dev/null and b/artifacts/mockup-redesign/overview-light.png differ diff --git a/artifacts/mockup-redesign/providers-dark.png b/artifacts/mockup-redesign/providers-dark.png new file mode 100644 index 0000000..f0e6ae2 Binary files /dev/null and b/artifacts/mockup-redesign/providers-dark.png differ diff --git a/artifacts/mockup-redesign/providers-hybrid.png b/artifacts/mockup-redesign/providers-hybrid.png new file mode 100644 index 0000000..3d9d481 Binary files /dev/null and b/artifacts/mockup-redesign/providers-hybrid.png differ diff --git a/artifacts/mockup-redesign/providers-light.png b/artifacts/mockup-redesign/providers-light.png new file mode 100644 index 0000000..5e36bd2 Binary files /dev/null and b/artifacts/mockup-redesign/providers-light.png differ diff --git a/artifacts/mockup-redesign/quotas-dark.png b/artifacts/mockup-redesign/quotas-dark.png new file mode 100644 index 0000000..6a3ed2b Binary files /dev/null and b/artifacts/mockup-redesign/quotas-dark.png differ diff --git a/artifacts/mockup-redesign/quotas-hybrid.png b/artifacts/mockup-redesign/quotas-hybrid.png new file mode 100644 index 0000000..06e3cba Binary files /dev/null and b/artifacts/mockup-redesign/quotas-hybrid.png differ diff --git a/artifacts/mockup-redesign/quotas-light.png b/artifacts/mockup-redesign/quotas-light.png new file mode 100644 index 0000000..c47e59d Binary files /dev/null and b/artifacts/mockup-redesign/quotas-light.png differ diff --git a/artifacts/mockup-redesign/routing-dark.png b/artifacts/mockup-redesign/routing-dark.png new file mode 100644 index 0000000..aa37ec3 Binary files /dev/null and b/artifacts/mockup-redesign/routing-dark.png differ diff --git a/artifacts/mockup-redesign/routing-hybrid.png b/artifacts/mockup-redesign/routing-hybrid.png new file mode 100644 index 0000000..efd2b54 Binary files /dev/null and b/artifacts/mockup-redesign/routing-hybrid.png differ diff --git a/artifacts/mockup-redesign/routing-light.png b/artifacts/mockup-redesign/routing-light.png new file mode 100644 index 0000000..1dcba76 Binary files /dev/null and b/artifacts/mockup-redesign/routing-light.png differ diff --git a/artifacts/mockup-redesign/settings-dark.png b/artifacts/mockup-redesign/settings-dark.png new file mode 100644 index 0000000..b7ff698 Binary files /dev/null and b/artifacts/mockup-redesign/settings-dark.png differ diff --git a/artifacts/mockup-redesign/settings-hybrid.png b/artifacts/mockup-redesign/settings-hybrid.png new file mode 100644 index 0000000..89554e7 Binary files /dev/null and b/artifacts/mockup-redesign/settings-hybrid.png differ diff --git a/artifacts/mockup-redesign/settings-light.png b/artifacts/mockup-redesign/settings-light.png new file mode 100644 index 0000000..2165728 Binary files /dev/null and b/artifacts/mockup-redesign/settings-light.png differ diff --git a/artifacts/mockup-redesign/team-dark.png b/artifacts/mockup-redesign/team-dark.png new file mode 100644 index 0000000..fcf30ba Binary files /dev/null and b/artifacts/mockup-redesign/team-dark.png differ diff --git a/artifacts/mockup-redesign/team-hybrid.png b/artifacts/mockup-redesign/team-hybrid.png new file mode 100644 index 0000000..fdeba5e Binary files /dev/null and b/artifacts/mockup-redesign/team-hybrid.png differ diff --git a/artifacts/mockup-redesign/team-light.png b/artifacts/mockup-redesign/team-light.png new file mode 100644 index 0000000..88e63e8 Binary files /dev/null and b/artifacts/mockup-redesign/team-light.png differ diff --git a/src/antigravity_provider/router/hermes_hub_app.py b/src/antigravity_provider/router/hermes_hub_app.py index 2122cdd..1a2b7e8 100644 --- a/src/antigravity_provider/router/hermes_hub_app.py +++ b/src/antigravity_provider/router/hermes_hub_app.py @@ -47,6 +47,7 @@ from antigravity_provider.router.profile_manager import ProfileAuthManager from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.adapters import get_adapter from antigravity_provider import paths +from antigravity_provider.version import __version__ from antigravity_provider.router.ui.theme import Theme 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.settings_view import SettingsView 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") +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) # ═══════════════════════════════════════════════════════════════ @@ -166,12 +178,13 @@ def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]: class HermesHubApp(ctk.CTk): 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__() self.title("Hermes Hub") self.geometry("1380x880") self.minsize(1100, 700) - ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") self.configure(fg_color=Theme.BG_WINDOW) @@ -219,9 +232,9 @@ class HermesHubApp(ctk.CTk): # Top Centered Brand Logo 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: logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="") logo_lbl.pack(anchor="center", pady=(0, 6)) @@ -229,72 +242,136 @@ class HermesHubApp(ctk.CTk): ctk.CTkLabel( brand_container, text="HERMES HUB", font=Theme.font_title_hero(), text_color=Theme.TEXT_ACCENT ).pack(anchor="center") - ctk.CTkLabel( - brand_container, text="Control Center", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED - ).pack(anchor="center") - - # Divider - ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER).pack(fill="x", padx=14, pady=(6, 12)) + ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER_ACCENT).pack( + fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_XS, Theme.SPACE_SM) + ) # Nav Items with clean Fluent glyphs self._nav_items = [ ("overview", "Обзор", "⌂"), - ("team", "Команда", "👥"), - ("accounts", "Аккаунты", "🔍"), - ("routing", "Маршрутизация", "🔀"), - ("providers", "Провайдеры", "🌐"), - ("health", "Состояние системы", "🛡️"), - ("logs", "Журнал событий", "📜"), - ("settings", "Настройки", "⚙️"), - ("about", "О программе", "ℹ️"), + ("team", "Команда", "♙"), + ("accounts", "Аккаунты", "◎"), + ("routing", "Маршрутизация", "⌘"), + ("providers", "Провайдеры", "◉"), + ("quotas", "Квоты и лимиты", "▥"), + ("analytics", "Аналитика", "⌁"), + ("health", "Состояние", "◇"), + ("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] = {} for key, label, icon in self._nav_items: btn = ctk.CTkButton( - self.sidebar, + self.nav_frame, text=f" {icon} {label}", font=Theme.font_body(), height=Theme.HEIGHT_NAV_ITEM, fg_color="transparent", - hover_color=Theme.SURFACE_HOVER, - text_color=Theme.TEXT_PRIMARY, + hover_color=Theme.SIDEBAR_HOVER, + text_color=Theme.SIDEBAR_TEXT, anchor="w", corner_radius=Theme.RADIUS_SM, 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 - # Refresh button at bottom of sidebar - refresh_btn = HubButton( + self.sidebar_version = ctk.CTkLabel( self.sidebar, - text="🔄 Обновить", - variant="secondary", - height=38, - command=self._refresh_data, + text=f"Hermes Hub v{__version__}", + font=Theme.font_micro(), + text_color=Theme.SIDEBAR_MUTED, ) - 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) ── - self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_STATUSBAR, fg_color=Theme.BG_STATUSBAR, corner_radius=0) - self.statusbar.pack(side="bottom", fill="x") + # ── Global top bar ── + self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_HEADER, fg_color=Theme.BG_HEADER, corner_radius=0) + self.statusbar.pack(side="top", fill="x") + self.statusbar.pack_propagate(False) self.status_left = ctk.CTkLabel( self.statusbar, - text="Аккаунты: ... | Роли: ... | Обновлено: ...", - font=Theme.font_micro(), - text_color=Theme.TEXT_MUTED, + text="● Состояние загружается", + font=Theme.font_caption(), + 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("", self._run_global_search) + self.bind_all("", 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.statusbar, - text="● Hermes работает", + text="Snapshot: Н/Д", 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 ── 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: 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: """Create view widget instance.""" if view_name == "overview": @@ -321,12 +412,16 @@ class HermesHubApp(ctk.CTk): return ProvidersView(self.content, app_state={}, on_action=self._handle_action) elif view_name == "routing": 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": return HealthView(self.content, app_state={}, on_refresh=self._refresh_data) elif view_name == "logs": return LogsView(self.content) 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": return AboutView(self.content) else: @@ -342,7 +437,7 @@ class HermesHubApp(ctk.CTk): for key, btn in self._nav_buttons.items(): if key == view_name: btn.configure( - fg_color=Theme.SURFACE_SELECTED, + fg_color=Theme.SIDEBAR_SELECTED, text_color=Theme.TEXT_ACCENT, border_width=1, border_color=Theme.BORDER_ACCENT, @@ -350,7 +445,7 @@ class HermesHubApp(ctk.CTk): else: btn.configure( fg_color="transparent", - text_color=Theme.TEXT_PRIMARY, + text_color=Theme.SIDEBAR_TEXT, border_width=0, ) @@ -374,6 +469,7 @@ class HermesHubApp(ctk.CTk): except Exception as ex: logger.warning("Error in lazy view update for %s: %s", view_name, ex) self._view_generations[view_name] = snap.generation + self._update_auxiliary_data(target_view) # Instrument tab switch latency 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}" 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 = ( - Theme.STATUS_HEALTHY - if readiness.state == "healthy" - else (Theme.STATUS_WARNING if readiness.state in ("limited", "degraded") else Theme.STATUS_ERROR) + self.status_right.configure( + text=f"{freshness} • {readiness.accounts_connected_count} аккаунтов • {readiness.roles_ready_count} ролей", + text_color=Theme.STATUS_WARNING if snap.is_stale else Theme.TEXT_MUTED, ) - 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) curr_view = self._views.get(self._current_view) @@ -457,6 +556,15 @@ class HermesHubApp(ctk.CTk): except Exception as ex: logger.warning("Error updating current view %s: %s", self._current_view, ex) 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): if self._shutting_down: @@ -517,9 +625,16 @@ class HermesHubApp(ctk.CTk): elif action == "edit_route": self._show_toast("Редактор цепочки использует кнопки и селекторы; drag-and-drop отключён.") 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( lambda: do_save_settings(data), - on_success=lambda result: self._show_toast(f"✅ {result[1]}"), + on_success=_settings_saved, ) elif action == "check_updates": from antigravity_provider.updater import UpdateManager @@ -592,6 +707,19 @@ class HermesHubApp(ctk.CTk): msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}" 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 _worker(): if self._shutting_down: @@ -627,7 +755,12 @@ class HermesHubApp(ctk.CTk): readiness = HubStateStore.get().get_snapshot().readiness 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): diff --git a/src/antigravity_provider/router/ui/__init__.py b/src/antigravity_provider/router/ui/__init__.py index 9b91ef6..633d752 100644 --- a/src/antigravity_provider/router/ui/__init__.py +++ b/src/antigravity_provider/router/ui/__init__.py @@ -1,4 +1,5 @@ """Hermes Hub UI Package — Native Dark-First Design System.""" + from __future__ import annotations from antigravity_provider.router.ui.theme import Theme diff --git a/src/antigravity_provider/router/ui/add_account_wizard.py b/src/antigravity_provider/router/ui/add_account_wizard.py index abd3664..2274317 100644 --- a/src/antigravity_provider/router/ui/add_account_wizard.py +++ b/src/antigravity_provider/router/ui/add_account_wizard.py @@ -9,6 +9,7 @@ Supports 5 AI Providers: Includes multi-account profile isolation, duplicate detection, and auto-assignment to router roles. """ + from __future__ import annotations import json @@ -80,6 +81,7 @@ class AddAccountWizard(HubModal): if self.oauth_session_id: try: from antigravity_provider.router.profile_oauth import cancel_oauth_session + cancel_oauth_session(self.oauth_session_id) except Exception: pass @@ -89,6 +91,7 @@ class AddAccountWizard(HubModal): if self.codex_session_id: try: from antigravity_provider.router.codex_oauth import cancel_codex_oauth_session + cancel_codex_oauth_session(self.codex_session_id) except Exception: pass @@ -97,6 +100,7 @@ class AddAccountWizard(HubModal): if self.claude_session_id: try: from antigravity_provider.router.claude_oauth import cancel_claude_oauth_session + cancel_claude_oauth_session(self.claude_session_id) except Exception: pass @@ -105,6 +109,7 @@ class AddAccountWizard(HubModal): if self.grok_session_id: try: from antigravity_provider.router.grok_oauth import cancel_grok_oauth_session + cancel_grok_oauth_session(self.grok_session_id) except Exception: pass @@ -129,10 +134,10 @@ class AddAccountWizard(HubModal): providers = [ ("antigravity", "Google Antigravity", "OAuth 2.0 (Google Account)", Theme.ACCENT), - ("openai-codex", "OpenAI Codex", "OAuth (ChatGPT) или API Key", "#10a37f"), - ("claude", "Claude (Anthropic)", "OAuth (Claude Pro/Max) или API Key", "#d97706"), - ("grok", "Grok (xAI)", "OAuth (SuperGrok) или API Key", "#3b82f6"), - ("opencode-go", "OpenCode Go", "API Key / Subscription", "#8b5cf6"), + ("openai-codex", "OpenAI Codex", "OAuth (ChatGPT) или API Key", Theme.PROVIDER_CODEX), + ("claude", "Claude (Anthropic)", "OAuth (Claude Pro/Max) или API Key", Theme.PROVIDER_CLAUDE), + ("grok", "Grok (xAI)", "OAuth (SuperGrok) или API Key", Theme.PROVIDER_GROK), + ("opencode-go", "OpenCode Go", "API Key / Subscription", Theme.PROVIDER_GENERIC), ] self.provider_var = ctk.StringVar(value=self.selected_provider) @@ -193,7 +198,9 @@ class AddAccountWizard(HubModal): self._clear_body() 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": self._build_antigravity_oauth_flow() @@ -342,6 +349,7 @@ class AddAccountWizard(HubModal): def _init_antigravity_oauth(self): try: from antigravity_provider.router.profile_oauth import start_profile_oauth + session_id, auth_url, port = start_profile_oauth(self.target_slot) self.oauth_session_id = session_id self.oauth_url = auth_url @@ -381,6 +389,7 @@ class AddAccountWizard(HubModal): return from antigravity_provider.router.profile_oauth import get_oauth_session + session = get_oauth_session(self.oauth_session_id) if not session: self.oauth_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR) @@ -399,6 +408,7 @@ class AddAccountWizard(HubModal): def _poll_antigravity_oauth(self): from antigravity_provider.router.profile_oauth import get_oauth_session + for _ in range(300): if not self._polling_active: return @@ -418,7 +428,9 @@ class AddAccountWizard(HubModal): return elif status in ("error", "failed", "cancelled"): 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 # ───────────────────────────────────────────────────────────── @@ -585,6 +597,7 @@ class AddAccountWizard(HubModal): def _init_codex_oauth(self): try: from antigravity_provider.router.codex_oauth import start_codex_oauth, get_codex_oauth_session + session_id, url, code = start_codex_oauth(self.target_slot) self.codex_session_id = session_id self.codex_url = url @@ -637,6 +650,7 @@ class AddAccountWizard(HubModal): return from antigravity_provider.router.codex_oauth import get_codex_oauth_session + session = get_codex_oauth_session(self.codex_session_id) if not session: self.codex_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR) @@ -655,6 +669,7 @@ class AddAccountWizard(HubModal): def _poll_codex_oauth(self): from antigravity_provider.router.codex_oauth import get_codex_oauth_session + for _ in range(900): if not self._polling_active: return @@ -689,7 +704,7 @@ class AddAccountWizard(HubModal): value="oauth", variable=self.claude_mode_var, font=Theme.font_body_bold(), - fg_color="#d97706", + fg_color=Theme.PROVIDER_CLAUDE, command=self._on_claude_mode_toggle, ) rb1.pack(anchor="w", padx=4, pady=2) @@ -700,7 +715,7 @@ class AddAccountWizard(HubModal): value="api_key", variable=self.claude_mode_var, font=Theme.font_body(), - fg_color="#d97706", + fg_color=Theme.PROVIDER_CLAUDE, command=self._on_claude_mode_toggle, ) rb2.pack(anchor="w", padx=4, pady=2) @@ -810,6 +825,7 @@ class AddAccountWizard(HubModal): def _init_claude_oauth(self): try: from antigravity_provider.router.claude_oauth import start_claude_oauth + session_id, url = start_claude_oauth(self.target_slot) self.claude_session_id = session_id self.claude_url = url @@ -836,6 +852,7 @@ class AddAccountWizard(HubModal): return from antigravity_provider.router.claude_oauth import get_claude_oauth_session + session = get_claude_oauth_session(self.claude_session_id) if not session: self.claude_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR) @@ -868,7 +885,7 @@ class AddAccountWizard(HubModal): value="oauth", variable=self.grok_mode_var, font=Theme.font_body_bold(), - fg_color="#3b82f6", + fg_color=Theme.PROVIDER_GROK, command=self._on_grok_mode_toggle, ) rb1.pack(anchor="w", padx=4, pady=2) @@ -879,7 +896,7 @@ class AddAccountWizard(HubModal): value="api_key", variable=self.grok_mode_var, font=Theme.font_body(), - fg_color="#3b82f6", + fg_color=Theme.PROVIDER_GROK, command=self._on_grok_mode_toggle, ) rb2.pack(anchor="w", padx=4, pady=2) @@ -930,7 +947,7 @@ class AddAccountWizard(HubModal): code_card, text="...", font=Theme.font_mono_bold(), - text_color="#3b82f6", + text_color=Theme.PROVIDER_GROK, ) self.grok_code_lbl.pack(side="left", padx=(0, 8)) @@ -1016,6 +1033,7 @@ class AddAccountWizard(HubModal): def _init_grok_oauth(self): try: from antigravity_provider.router.grok_oauth import start_grok_oauth, get_grok_oauth_session + session_id, url, code = start_grok_oauth(self.target_slot) self.grok_session_id = session_id self.grok_url = url @@ -1068,6 +1086,7 @@ class AddAccountWizard(HubModal): return from antigravity_provider.router.grok_oauth import get_grok_oauth_session + session = get_grok_oauth_session(self.grok_session_id) if not session: self.grok_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR) @@ -1086,6 +1105,7 @@ class AddAccountWizard(HubModal): def _poll_grok_oauth(self): from antigravity_provider.router.grok_oauth import get_grok_oauth_session + for _ in range(900): if not self._polling_active: return @@ -1114,20 +1134,68 @@ class AddAccountWizard(HubModal): mode_card = HubCard(self.body, fg_color="transparent") mode_card.pack(fill="x", pady=(0, 8)) 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(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) + 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( + 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": mode_card = HubCard(self.body, fg_color="transparent") mode_card.pack(fill="x", pady=(0, 8)) 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(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) + ctk.CTkRadioButton( + 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": mode_card = HubCard(self.body, fg_color="transparent") mode_card.pack(fill="x", pady=(0, 8)) 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(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) + ctk.CTkRadioButton( + 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 = { "openai-codex": "Введите ключ OpenAI API (sk-...):", @@ -1274,25 +1342,38 @@ class AddAccountWizard(HubModal): # Identity line id_row = ctk.CTkFrame(card, fg_color="transparent") 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=self.discovered_identity or "—", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY).pack(side="right") + ctk.CTkLabel(id_row, text="Идентификатор:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack( + 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_row = ctk.CTkFrame(card, fg_color="transparent") 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=self.discovered_plan or "Тариф: неизвестен", font=Theme.font_body_bold(), text_color=Theme.ACCENT).pack(side="right") + ctk.CTkLabel(plan_row, text="Тариф:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack( + 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 stat_row = ctk.CTkFrame(card, fg_color="transparent") 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_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") 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) ctk.CTkLabel( warn_card, @@ -1338,13 +1419,19 @@ class AddAccountWizard(HubModal): r_row = ctk.CTkFrame(card, fg_color="transparent") 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") s_row = ctk.CTkFrame(card, fg_color="transparent") 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=self.target_slot, font=Theme.font_mono(), text_color=Theme.TEXT_MUTED).pack(side="right") + ctk.CTkLabel(s_row, text="Внутренний слот:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack( + side="left" + ) + ctk.CTkLabel(s_row, text=self.target_slot, font=Theme.font_mono(), text_color=Theme.TEXT_MUTED).pack( + side="right" + ) ctk.CTkLabel( self.body, @@ -1370,9 +1457,11 @@ class AddAccountWizard(HubModal): profile_id=self.target_slot, ) if self.on_complete: - self.on_complete({ - "provider": self.selected_provider, - "profile_id": self.target_slot, - "identity": self.discovered_identity, - }) + self.on_complete( + { + "provider": self.selected_provider, + "profile_id": self.target_slot, + "identity": self.discovered_identity, + } + ) self.destroy() diff --git a/src/antigravity_provider/router/ui/assets.py b/src/antigravity_provider/router/ui/assets.py index 6f1d53d..a79e89e 100644 --- a/src/antigravity_provider/router/ui/assets.py +++ b/src/antigravity_provider/router/ui/assets.py @@ -1,4 +1,5 @@ """Hermes Hub — Asset and Icon Manager with Provider Icon Caching.""" + from __future__ import annotations import os @@ -14,6 +15,7 @@ try: import customtkinter as ctk except ImportError: import unittest.mock as _mock + ctk = _mock.MagicMock() @@ -71,7 +73,21 @@ class AssetManager: if logo_path.exists(): try: 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: return None return None diff --git a/src/antigravity_provider/router/ui/components.py b/src/antigravity_provider/router/ui/components.py index 84be929..418865f 100644 --- a/src/antigravity_provider/router/ui/components.py +++ b/src/antigravity_provider/router/ui/components.py @@ -92,16 +92,16 @@ class HubCard(ctk.CTkFrame): master: Any, corner_radius: int = Theme.RADIUS_MD, border_width: int = 1, - border_color: str = Theme.BORDER, - fg_color: str = Theme.SURFACE, + border_color: Optional[str] = None, + fg_color: Optional[str] = None, **kwargs, ): super().__init__( master=master, corner_radius=corner_radius, border_width=border_width, - border_color=border_color, - fg_color=fg_color, + border_color=border_color or Theme.BORDER, + fg_color=fg_color or Theme.SURFACE, **kwargs, ) @@ -257,19 +257,19 @@ class HubSectionHeader(ctk.CTkFrame): class HubStatusBadge(ctk.CTkFrame): """Status pill with dot and localized label for all normalized health states.""" - STATUS_MAP = { - "healthy": (Theme.STATUS_HEALTHY, "Работает"), - "quota_low": (Theme.STATUS_WARNING, "Квота заканчивается"), - "quota_exhausted": (Theme.STATUS_ERROR, "Квота исчерпана"), - "cooldown": (Theme.STATUS_WARNING, "Ожидание сброса"), - "rate_limited": (Theme.STATUS_WARNING, "Лимит запросов"), - "not_configured": (Theme.TEXT_MUTED, "Аккаунт не добавлен"), - "auth_required": (Theme.STATUS_AUTH_REQUIRED, "Требуется вход"), - "auth_expired": (Theme.STATUS_AUTH_REQUIRED, "Требуется повторная авторизация"), - "disabled": (Theme.STATUS_DISABLED, "Отключён"), - "cold_spare": (Theme.TEXT_MUTED, "Холодный резерв"), - "unhealthy": (Theme.STATUS_ERROR, "Ошибка"), - "not_tested": (Theme.TEXT_MUTED, "Не проверен"), + STATUS_LABELS = { + "healthy": "Работает", + "quota_low": "Квота заканчивается", + "quota_exhausted": "Квота исчерпана", + "cooldown": "Ожидание сброса", + "rate_limited": "Лимит запросов", + "not_configured": "Аккаунт не добавлен", + "auth_required": "Требуется вход", + "auth_expired": "Требуется повторная авторизация", + "disabled": "Отключён", + "cold_spare": "Холодный резерв", + "unhealthy": "Ошибка", + "not_tested": "Не проверен", } 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: 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.label.configure(text=label or default_label) @@ -513,9 +514,9 @@ def _semantic_color(status: str) -> str: key = (status or "unknown").lower().replace("-", "_") if key in {"healthy", "active", "authenticated", "online", "ready"}: 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 - 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_NEUTRAL @@ -582,7 +583,7 @@ class EllipsizedLabel(ctk.CTkLabel): class _TextBadge(ctk.CTkFrame): """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__( master=master, height=Theme.HEIGHT_BADGE, @@ -592,7 +593,12 @@ class _TextBadge(ctk.CTkFrame): corner_radius=Theme.RADIUS_PILL, **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) def set_text(self, text: str) -> None: @@ -839,6 +845,13 @@ class RouteTargetWidget(HubCard): class AccountCardWidget(HubCard): """Keyed account card whose quota buckets are updated without rebuilding the card.""" + MANAGEMENT_ACTIONS = ( + ("test", "⚡ Тест"), + ("set_main", "★ Основной"), + ("set_orchestrator", "♛ Оркестратор"), + ("assign_role", "Назначить роль"), + ) + def __init__( self, master: Any, @@ -883,15 +896,31 @@ class AccountCardWidget(HubCard): ) 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.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( - self.actions, + utility, text="Обновить", variant="secondary", width=86, command=lambda: self._trigger("refresh_account"), ).pack(side="left") ActionButton( - self.actions, + utility, text="Удалить", variant="ghost", width=72, diff --git a/src/antigravity_provider/router/ui/splash.py b/src/antigravity_provider/router/ui/splash.py index aefc143..62e8685 100644 --- a/src/antigravity_provider/router/ui/splash.py +++ b/src/antigravity_provider/router/ui/splash.py @@ -1,4 +1,5 @@ """Hermes Hub — Native Brand Splash Screen.""" + from __future__ import annotations import tkinter as tk diff --git a/src/antigravity_provider/router/ui/theme.py b/src/antigravity_provider/router/ui/theme.py index 33e4d9b..08a31b9 100644 --- a/src/antigravity_provider/router/ui/theme.py +++ b/src/antigravity_provider/router/ui/theme.py @@ -8,6 +8,7 @@ Primary Palette: LIGHT: #F7F1E3 (Warm Ivory) ACCENT: #CDAA64 (Ancient Gold) """ + from __future__ import annotations @@ -58,15 +59,15 @@ class Theme: DANGER_TEXT = "#FFD6D6" # ── Operational / Status Colors ── - STATUS_HEALTHY = "#2E7D32" # Vibrant Forest Green - STATUS_WARNING = "#D97706" # Amber Gold - STATUS_ERROR = "#DC2626" # Ruby Crimson - STATUS_INFO = "#2563EB" # Azure + STATUS_HEALTHY = "#2E7D32" # Vibrant Forest Green + STATUS_WARNING = "#D97706" # Amber Gold + STATUS_ERROR = "#DC2626" # Ruby Crimson + STATUS_INFO = "#2563EB" # Azure STATUS_AUTH_REQUIRED = "#D97706" STATUS_DISABLED = "#5A6B5D" STATUS_FAILOVER = "#3B82F6" - STATUS_MAIN = "#CDAA64" # Gold Badge - STATUS_ORCHESTRATOR = "#E5C158" # Imperial Gold + STATUS_MAIN = "#CDAA64" # Gold Badge + STATUS_ORCHESTRATOR = "#E5C158" # Imperial Gold # Semantic roles. Brand gold is intentionally not a health colour. COLOR_POSITIVE = STATUS_HEALTHY @@ -76,9 +77,198 @@ class Theme: COLOR_BRAND = ACCENT # ── Provider Specific Accent Colors ── - PROVIDER_ANTIGRAVITY = "#4285F4" # Google Blue - PROVIDER_CODEX = "#10A37F" # OpenAI Emerald - PROVIDER_OPENCODE = "#F97316" # OpenCode Orange + PROVIDER_ANTIGRAVITY = "#4285F4" # Google Blue + PROVIDER_CODEX = "#10A37F" # OpenAI Emerald + 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) ── SPACE_XS = 4 @@ -106,10 +296,10 @@ class Theme: HEIGHT_BTN_SM = 30 HEIGHT_BTN_MD = 38 HEIGHT_BTN_LG = 44 - HEIGHT_NAV_ITEM = 42 - HEIGHT_HEADER = 60 + HEIGHT_NAV_ITEM = 34 + HEIGHT_HEADER = 56 HEIGHT_STATUSBAR = 30 - WIDTH_SIDEBAR = 240 + WIDTH_SIDEBAR = 190 HEIGHT_INPUT = 36 HEIGHT_BADGE = 24 ACCOUNT_CARD_MIN_HEIGHT = 188 @@ -180,3 +370,6 @@ class Theme: @classmethod def font_metric(cls): return (cls.FONT_FAMILY_UI, 24, "bold") + + +Theme.apply_scheme("dark") diff --git a/src/antigravity_provider/router/ui/views/__init__.py b/src/antigravity_provider/router/ui/views/__init__.py index 652e297..78987d4 100644 --- a/src/antigravity_provider/router/ui/views/__init__.py +++ b/src/antigravity_provider/router/ui/views/__init__.py @@ -1,2 +1,3 @@ """Hermes Hub UI Views Package.""" + from __future__ import annotations diff --git a/src/antigravity_provider/router/ui/views/about_view.py b/src/antigravity_provider/router/ui/views/about_view.py index 2e64083..8bef094 100644 --- a/src/antigravity_provider/router/ui/views/about_view.py +++ b/src/antigravity_provider/router/ui/views/about_view.py @@ -1,4 +1,5 @@ """Hermes Hub — About View (О программе).""" + from __future__ import annotations 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.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="Защита credentials и квот", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 8)) + ctk.CTkLabel(p1, text="🛡️ Fail-Closed", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack( + 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.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="Мгновенное переключение ролей", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 8)) + ctk.CTkLabel(p2, text="🔄 Auto Failover", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack( + 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.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="Интеллектуальное распределение", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 8)) + ctk.CTkLabel(p3, text="👥 Auto Assignment", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack( + pady=(8, 2) + ) + ctk.CTkLabel( + p3, text="Интеллектуальное распределение", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED + ).pack(pady=(0, 8)) diff --git a/src/antigravity_provider/router/ui/views/analytics_view.py b/src/antigravity_provider/router/ui/views/analytics_view.py new file mode 100644 index 0000000..3388b7e --- /dev/null +++ b/src/antigravity_provider/router/ui/views/analytics_view.py @@ -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") diff --git a/src/antigravity_provider/router/ui/views/dashboard_view.py b/src/antigravity_provider/router/ui/views/dashboard_view.py index 9e7ceb3..94a58cf 100644 --- a/src/antigravity_provider/router/ui/views/dashboard_view.py +++ b/src/antigravity_provider/router/ui/views/dashboard_view.py @@ -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 typing import Any, Callable, Dict, Optional +from typing import Any, Callable, Dict, Iterable, Optional import customtkinter as ctk 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 -class _SummaryRow(HubCard): - def __init__(self, master: Any, title: str = "", detail: str = ""): - super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE) - self.title = ctk.CTkLabel(self, text=title, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY) - self.title.pack(side="left", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) - self.detail = ctk.CTkLabel(self, text=detail, font=Theme.font_caption(), text_color=Theme.TEXT_MUTED) - self.detail.pack(side="right", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) +class _FlowCard(HubCard): + def __init__(self, master: Any, **kwargs): + kwargs.setdefault("corner_radius", Theme.RADIUS_MD) + kwargs.setdefault("border_color", Theme.BORDER) + kwargs.setdefault("height", 76) + super().__init__(master, **kwargs) + 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.detail.configure(text=detail) color = ( - Theme.COLOR_POSITIVE + Theme.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): + """Dense overview matching the approved composition without fictional metrics.""" + def __init__( self, master: Any, @@ -42,144 +126,307 @@ class DashboardView(ctk.CTkFrame): super().__init__(master=master, fg_color="transparent", **kwargs) self.on_navigate = on_navigate self.on_action = on_action - self._provider_rows: Dict[str, _SummaryRow] = {} - self._route_rows: Dict[str, _SummaryRow] = {} - self._alert_rows: Dict[str, _SummaryRow] = {} + self._provider_cards: Dict[str, _FlowCard] = {} + self._agent_cards: Dict[str, _FlowCard] = {} + self._provider_status_rows: Dict[str, _StatusRow] = {} + self._event_rows: list[_EventRow] = [] self._build() def _build(self) -> None: - SectionHeader( - self, - 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.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent", corner_radius=0) + self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=Theme.PAGE_PAD_Y) self.snapshot_freshness = ctk.CTkLabel( self.scroll, text="Snapshot: Н/Д", - font=Theme.font_caption(), + font=Theme.font_micro(), text_color=Theme.TEXT_MUTED, 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") - metrics.pack(fill="x", pady=(0, Theme.SECTION_GAP)) - for column in range(4): - metrics.grid_columnconfigure(column, weight=1) - self.system_metric = HubMetricCard(metrics, "Состояние", "Н/Д", "Ожидание snapshot", icon="◈", accent=True) - self.provider_metric = HubMetricCard(metrics, "Провайдеры", "0/0", "доступно", icon="◉") - self.account_metric = HubMetricCard(metrics, "Аккаунты", "0/0", "подключено", icon="◎") - self.role_metric = HubMetricCard(metrics, "Роли", "0/0", "готово", icon="◇") + self.metrics = ctk.CTkFrame(self.scroll, fg_color="transparent") + self.metrics.pack(fill="x", pady=(0, Theme.SPACE_SM)) + for column in range(5): + self.metrics.grid_columnconfigure(column, weight=1) + self.quota_metric = _KpiCard(self.metrics, "Квоты", "Н/Д", "нет измерения", icon="◔") + self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н/Д", "измерения", icon="◎") + self.agents_metric = _KpiCard(self.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( - (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") - columns = ctk.CTkFrame(self.scroll, fg_color="transparent") - columns.pack(fill="x") - columns.grid_columnconfigure((0, 1), weight=1) - self.providers_card = self._section(columns, "Провайдеры", 0, 0) - 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)) + body = ctk.CTkFrame(self.scroll, fg_color="transparent") + body.pack(fill="x") + body.grid_columnconfigure(0, weight=4) + body.grid_columnconfigure(1, weight=1) - def _section(self, master: Any, title: str, row: int, column: int, columnspan: int = 1) -> ctk.CTkFrame: - card = HubCard(master) - card.grid( - row=row, column=column, columnspan=columnspan, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew" + route_card = HubCard(body) + route_card.grid(row=0, column=0, sticky="nsew", padx=(0, Theme.SPACE_SM)) + ctk.CTkLabel( + 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( - anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM) + self.provider_status = ctk.CTkFrame(self.realtime, fg_color="transparent") + 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") - body.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y)) - return body + self.host_metrics.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) + ctk.CTkLabel( + 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 - 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): - if key not in rows: + if key not in live: cache.pop(key).destroy() - for key, (title, detail, status) in rows.items(): - if key not in cache: - cache[key] = _SummaryRow(container) - cache[key].pack(fill="x", pady=Theme.SPACE_XS) - cache[key].update_row(title, detail, status) + for index, (key, title, subtitle, meta, status) in enumerate(prepared): + card = cache.get(key) + if card is None: + card = _FlowCard(container) + 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: if not isinstance(snapshot, HubSnapshot): return - if snapshot.is_stale: - self.snapshot_freshness.configure( - text=f"⚠ Snapshot #{snapshot.seq}: данные устарели", - text_color=Theme.STATUS_WARNING, + self.snapshot_freshness.configure( + text=f"{'⚠ данные устарели' if snapshot.is_stale else '● данные актуальны'} • snapshot #{snapshot.seq}", + text_color=Theme.STATUS_WARNING if snapshot.is_stale else Theme.STATUS_HEALTHY, + ) + 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: - self.snapshot_freshness.configure( - text=f"Snapshot #{snapshot.seq}: актуальные данные", - text_color=Theme.TEXT_MUTED, - ) - readiness = snapshot.readiness - 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}") - self.account_metric.val_label.configure(text=f"{readiness.accounts_connected_count}/{readiness.total_accounts}") - self.role_metric.val_label.configure(text=f"{readiness.roles_ready_count}/{readiness.total_roles}") + self.orchestrator_card.update_card("Главный оркестратор", "Не назначен", "Аккаунт: Н/Д", "warning") + agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:3] + self._sync_cards( + self.agent_column, + self._agent_cards, + ( + ( + agent.role_id, + agent.role_name_ru, + 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 = { - provider.provider_id: ( + live_provider_ids = {provider.provider_id for provider in snapshot.providers} + 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, - 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", ) - for provider in snapshot.providers - } - self._sync_rows(self.providers_card, self._provider_rows, provider_rows) - route_rows = {} - for role_id, pipeline in snapshot.routing.items(): - active = next((node for node in pipeline.nodes if node.is_active), None) - detail = f"{active.provider} • {active.model}" if active else "Активный узел: Н/Д" - 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) - - alerts: Dict[str, tuple[str, str, str]] = {} - for profile in snapshot.all_profiles.values(): - if profile.auth_state == "AUTH_EXPIRED": - alerts[f"auth:{profile.profile_id}"] = ( - profile.account_identity or profile.profile_id, - "Авторизация истекла", - "auth_expired", + host = dict(snapshot.metrics.get("host") or {}) + if host.get("has_data"): + network_total = sum( + value for value in (host.get("net_bytes_sent"), host.get("net_bytes_recv")) if isinstance(value, int) + ) + network_text = f"{network_total / (1024 * 1024):.0f} МБ" if network_total else "Н/Д" + self.host_metrics.configure( + text=( + f"CPU {host.get('cpu_percent', 'Н/Д')}%\n" + f"Память {host.get('memory_percent', 'Н/Д')}%\n" + f"Диск {host.get('disk_percent', 'Н/Д')}%\n" + f"Сеть {network_text}" ) - quota = snapshot.quotas.get(profile.profile_id) - if quota and not getattr(quota, "is_estimated", True): - low = [ - 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) + ) + else: + self.host_metrics.configure(text="CPU Н/Д\nПамять Н/Д\nДиск Н/Д\nСеть Н/Д") - def _navigate(self, target: str) -> None: - if self.on_navigate: - self.on_navigate(target) + def update_events(self, events: Iterable[Any]) -> None: + items = list(events)[:5] + 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) diff --git a/src/antigravity_provider/router/ui/views/logs_view.py b/src/antigravity_provider/router/ui/views/logs_view.py index 16257ef..c27db3e 100644 --- a/src/antigravity_provider/router/ui/views/logs_view.py +++ b/src/antigravity_provider/router/ui/views/logs_view.py @@ -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 typing import Any, Optional +from typing import Any, Iterable, Optional import customtkinter as ctk 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 +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): def __init__(self, master: Any, **kwargs): super().__init__(master=master, fg_color="transparent", **kwargs) + self._events: list[Any] = [] + self._rows: list[_LogRow] = [] + self._query = "" + self._level = "Все уровни" SectionHeader( self, title="Журнал событий", - subtitle="Только события, переданные через единый snapshot-контракт", + subtitle="Реальные события Hermes Hub с уровнями и категориями", ).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM)) - self.empty = EmptyState( - self, - title="Журнал: Н/Д", - message=( - "HubSnapshot пока не содержит события журнала. Экран не читает файлы и backend " - "напрямую и не показывает выдуманную историю." - ), + toolbar = ctk.CTkFrame(self, fg_color="transparent") + toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, Theme.SPACE_SM)) + self.search = SearchField(toolbar, placeholder_text="Поиск по журналу…", width=320) + self.search.pack(side="left", padx=(0, Theme.SPACE_SM)) + self.search.bind("", 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: if not isinstance(snapshot, HubSnapshot): diff --git a/src/antigravity_provider/router/ui/views/quotas_view.py b/src/antigravity_provider/router/ui/views/quotas_view.py new file mode 100644 index 0000000..6a5f46e --- /dev/null +++ b/src/antigravity_provider/router/ui/views/quotas_view.py @@ -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) diff --git a/src/antigravity_provider/router/ui/views/settings_view.py b/src/antigravity_provider/router/ui/views/settings_view.py index 658f9c3..116ea07 100644 --- a/src/antigravity_provider/router/ui/views/settings_view.py +++ b/src/antigravity_provider/router/ui/views/settings_view.py @@ -12,7 +12,13 @@ from antigravity_provider.version import CHANNEL, __version__ 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) self.on_action = on_action 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)) scroll = ctk.CTkScrollableFrame(self, fg_color="transparent") 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, "Маршрутизация и отказоустойчивость") self.affinity = self._switch(routing, "Сессионная привязка", True) self.failover = self._switch(routing, "Автоматический failover", True) @@ -81,6 +105,10 @@ class SettingsView(ctk.CTkFrame): if not self.on_action: return 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( "save_settings", { @@ -89,5 +117,6 @@ class SettingsView(ctk.CTkFrame): "prefer_same_account_model_fallback": bool(self.same_account.get()), "quota_refresh_interval_label": self.interval.get(), "quota_refresh_interval_sec": intervals.get(self.interval.get(), 300), + "theme": theme_key, }, ) diff --git a/src/antigravity_provider/router/ui/views/team_view.py b/src/antigravity_provider/router/ui/views/team_view.py index eaf31ab..1cecf60 100644 --- a/src/antigravity_provider/router/ui/views/team_view.py +++ b/src/antigravity_provider/router/ui/views/team_view.py @@ -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.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, text="👑 ЛИДЕР РОУТЕРА", font=Theme.font_micro(), text_color=Theme.ACCENT ) @@ -160,9 +160,9 @@ class AgentCardWidget(HubCard): elif "opencode" in prov: self.prov_lbl.configure(text="OpenCode Go", text_color=Theme.PROVIDER_OPENCODE) 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: - self.prov_lbl.configure(text="Grok", text_color="#3b82f6") + self.prov_lbl.configure(text="Grok", text_color=Theme.PROVIDER_GROK) else: self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED) diff --git a/tests/test_ui_mockup_redesign.py b/tests/test_ui_mockup_redesign.py new file mode 100644 index 0000000..6b2b48f --- /dev/null +++ b/tests/test_ui_mockup_redesign.py @@ -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() diff --git a/tests/test_ui_screenshot_harness.py b/tests/test_ui_screenshot_harness.py new file mode 100644 index 0000000..60643f2 --- /dev/null +++ b/tests/test_ui_screenshot_harness.py @@ -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)