feat(ui): implement approved three-theme mockup redesign

This commit is contained in:
Hermes Team 2026-08-21 18:55:32 +07:00
parent 69cbefcab6
commit e4843b5a39
51 changed files with 1557 additions and 259 deletions

View file

@ -0,0 +1,125 @@
# Hermes Hub — редизайн по утверждённому макету B5
Дата: 2026-08-21
Ветка: `codex/mockup-redesign`
BASE_SHA: `69cbefcab6d566929d99b7ad7dc1b6b9824bfb2b` (`origin/main`, обновлён перед финальной интеграцией)
FINAL_SHA: `git rev-parse codex/mockup-redesign` в момент handoff. Точный SHA указан в итоговом сообщении, поскольку commit не может содержать собственный SHA.
## Реализовано
- Один утверждённый layout для трёх схем: `dark`, `hybrid`, `light`.
- Все палитры находятся в `theme.py`, имеют одинаковый набор токенов и меняют всё окно после сохранения настройки.
- Выбор темы сохраняется в `hub_settings.json`; приложение применяет его при следующем запуске.
- Общий каркас соответствует макету: брендовая левая панель, прокручиваемая навигация, пользователь и версия снизу, глобальная верхняя строка с состоянием, `Ctrl + K`, добавлением аккаунта и служебными действиями.
- Dashboard: пять компактных KPI, провайдеры → оркестратор → роли, правая панель состояния, реальные события снизу.
- После обновления контракта подключены реальные доли/число вызовов и P50 по провайдерам, вызовы по ролям, active calls и локальные CPU/RAM/disk/network.
- Добавлены отдельные вкладки «Квоты и лимиты» и «Аналитика» на данных `HubSnapshot`/`TelemetryService`.
- Журнал получает реальные события через action layer приложения, поддерживает поиск и фильтр уровня.
- На «Аккаунты» возвращены `test`, `set_main`, `set_orchestrator`, `assign_role`; механический и фактический UI-тесты проверяют все четыре триггера.
- Сохранена keyed-дельта карточек и квотных корзин.
- Цветовые литералы вне `theme.py` удалены; отдельный тест запрещает их повторное появление.
## Изменённые файлы
- `src/antigravity_provider/router/hermes_hub_app.py`
- `src/antigravity_provider/router/ui/theme.py`
- `src/antigravity_provider/router/ui/components.py`
- `src/antigravity_provider/router/ui/assets.py`
- `src/antigravity_provider/router/ui/add_account_wizard.py`
- `src/antigravity_provider/router/ui/views/dashboard_view.py`
- `src/antigravity_provider/router/ui/views/logs_view.py`
- `src/antigravity_provider/router/ui/views/settings_view.py`
- `src/antigravity_provider/router/ui/views/team_view.py`
- `src/antigravity_provider/router/ui/views/analytics_view.py`
- `src/antigravity_provider/router/ui/views/quotas_view.py`
- `tests/test_ui_mockup_redesign.py`
- `tests/test_ui_screenshot_harness.py`
- `artifacts/mockup-redesign/*.png`
- `CODEX_MOCKUP_REDESIGN_REPORT.md`
Backend/state/adapters, installer, scripts, config, legacy и чужие тесты не изменялись.
## Проверка размеров и scaling
Проверены `1280×720`, `1366×768`, `1920×1080`. На наименьшем размере дополнительно проверены 100%, 125% и 150% widget scaling. Правая граница поиска и кнопки добавления оставалась внутри верхней строки:
```text
scale 1.00: search_right=539, add_right=1074, header_width=1090
scale 1.25: search_right=661, add_right=1023, header_width=1043
scale 1.50: search_right=781, add_right=971, header_width=995
```
Навигация помещена в прокручиваемую область, поэтому нижние разделы доступны при 150%.
## Скриншоты — все вкладки и темы
| Вкладка | Тёмная | Гибрид | Светлая |
|---|---|---|---|
| Обзор | [dark](artifacts/mockup-redesign/overview-dark.png) | [hybrid](artifacts/mockup-redesign/overview-hybrid.png) | [light](artifacts/mockup-redesign/overview-light.png) |
| Команда | [dark](artifacts/mockup-redesign/team-dark.png) | [hybrid](artifacts/mockup-redesign/team-hybrid.png) | [light](artifacts/mockup-redesign/team-light.png) |
| Аккаунты | [dark](artifacts/mockup-redesign/accounts-dark.png) | [hybrid](artifacts/mockup-redesign/accounts-hybrid.png) | [light](artifacts/mockup-redesign/accounts-light.png) |
| Маршрутизация | [dark](artifacts/mockup-redesign/routing-dark.png) | [hybrid](artifacts/mockup-redesign/routing-hybrid.png) | [light](artifacts/mockup-redesign/routing-light.png) |
| Провайдеры | [dark](artifacts/mockup-redesign/providers-dark.png) | [hybrid](artifacts/mockup-redesign/providers-hybrid.png) | [light](artifacts/mockup-redesign/providers-light.png) |
| Квоты | [dark](artifacts/mockup-redesign/quotas-dark.png) | [hybrid](artifacts/mockup-redesign/quotas-hybrid.png) | [light](artifacts/mockup-redesign/quotas-light.png) |
| Аналитика | [dark](artifacts/mockup-redesign/analytics-dark.png) | [hybrid](artifacts/mockup-redesign/analytics-hybrid.png) | [light](artifacts/mockup-redesign/analytics-light.png) |
| Состояние | [dark](artifacts/mockup-redesign/health-dark.png) | [hybrid](artifacts/mockup-redesign/health-hybrid.png) | [light](artifacts/mockup-redesign/health-light.png) |
| Журнал | [dark](artifacts/mockup-redesign/logs-dark.png) | [hybrid](artifacts/mockup-redesign/logs-hybrid.png) | [light](artifacts/mockup-redesign/logs-light.png) |
| Настройки | [dark](artifacts/mockup-redesign/settings-dark.png) | [hybrid](artifacts/mockup-redesign/settings-hybrid.png) | [light](artifacts/mockup-redesign/settings-light.png) |
| О программе | [dark](artifacts/mockup-redesign/about-dark.png) | [hybrid](artifacts/mockup-redesign/about-hybrid.png) | [light](artifacts/mockup-redesign/about-light.png) |
## Расхождения с макетом
| Элемент макета | Реализация и причина |
|---|---|
| «Квота сегодня 78%» | Показано `Н/Д`, пока нет хотя бы одной реально измеренной корзины. Baseline `None` не превращается в процент. |
| «Активные задачи 24» | Заменено на реальные вызовы роутера из telemetry. Подсистемы задач/очередей нет. |
| «Окно обслуживания 09:00–21:00» | Заменено количеством реальных failover-переключений. Понятия окна обслуживания нет. |
| Доли провайдеров 45/35/20% и 128/74/56 запросов по ролям | Показываются только из реального `metrics.telemetry.by_provider/by_role`; без вызовов отображаются online/`Н/Д`, а не цифры макета. |
| Латентность каждого провайдера | Показывается реальный P50 из `by_provider`; без измерений значение скрыто. |
| CPU, память, диск, сеть | Показываются реальные локальные измерения `psutil`; при недоступном `psutil` — `Н/Д`. |
| Очереди задач | Блок исключён: подсистемы очередей в продукте нет. |
| Раздел «Инциденты» | Не создан: вместо него используется реальный журнал с фильтром ошибок. |
| Кривые соединения | В native CustomTkinter используются адаптивные направленные связи между тремя колонками; данные и порядок цепочки сохранены без canvas-зависимости. |
| Версия `2.8.1` на макете | Показывается реальная версия пакета `0.1.1`. |
## Проверки
### Headless без UI-зависимостей
```powershell
uv run --isolated --no-project --with pytest --with pyyaml --with pydantic --with requests --with httpx --with psutil python -m pytest -q
```
Результат: `193 passed, 26 skipped, 3 deselected in 14.32s`.
### UI-enabled
Из-за известной повторной инициализации Tcl/Tk набор выполнен в трёх свежих процессах:
```powershell
uv run --extra dev python -m pytest -q --ignore=tests/test_ui_phase2_6.py --deselect=tests/test_oauth_lifecycle.py::test_f_copy_before_open_browser
uv run --extra dev python -m pytest -q tests/test_ui_phase2_6.py
uv run --extra dev python -m pytest -q tests/test_oauth_lifecycle.py::test_f_copy_before_open_browser
```
Результаты: `243 passed, 2 skipped, 4 deselected`; `4 passed`; `1 passed`. Итог уникального набора: `248 passed, 2 skipped, 3 deselected`, ошибок нет.
### Статика и release gate
```powershell
uv run ruff check .
uv run --extra dev python scripts/release_gate.py
```
Результат: Ruff — `All checks passed!`; release gate — `PASSED`, публичный package и hash подтверждены.
## Backend gaps
1. Внешние provider RPS/SLA, task queues и maintenance windows отсутствуют в продукте; UI их не синтезирует.
2. Baseline-квоты остаются `None`; реальное число появляется только из provider claim/runtime event.
3. `HubSnapshot` не содержит журнал событий; app action layer передаёт в views безопасные presentation-объекты из `EventLogService`, сами views backend не читают.
4. Network contract содержит накопительные bytes с момента загрузки ОС, а не мгновенную скорость; UI показывает объём, не выдуманный Мбит/с.
5. Тег `v0.1.1`, release и manifest не создавались и не изменялись.

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View file

@ -47,6 +47,7 @@ from antigravity_provider.router.profile_manager import ProfileAuthManager
from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.adapters import get_adapter from antigravity_provider.router.adapters import get_adapter
from antigravity_provider import paths from antigravity_provider import paths
from antigravity_provider.version import __version__
from antigravity_provider.router.ui.theme import Theme from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.assets import AssetManager from antigravity_provider.router.ui.assets import AssetManager
@ -69,10 +70,21 @@ from antigravity_provider.router.ui.views.health_view import HealthView
from antigravity_provider.router.ui.views.logs_view import LogsView from antigravity_provider.router.ui.views.logs_view import LogsView
from antigravity_provider.router.ui.views.settings_view import SettingsView from antigravity_provider.router.ui.views.settings_view import SettingsView
from antigravity_provider.router.ui.views.about_view import AboutView from antigravity_provider.router.ui.views.about_view import AboutView
from antigravity_provider.router.ui.views.analytics_view import AnalyticsView
from antigravity_provider.router.ui.views.quotas_view import QuotasView
logger = logging.getLogger("hermes.hub.gui") logger = logging.getLogger("hermes.hub.gui")
def _load_saved_theme() -> str:
settings_file = paths.get_hermes_home() / "hub_settings.json"
try:
settings = json.loads(settings_file.read_text(encoding="utf-8"))
except (OSError, ValueError, TypeError):
return "dark"
return str(settings.get("theme", "dark"))
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
# Actions Layer (Safe & Non-blocking) # Actions Layer (Safe & Non-blocking)
# ═══════════════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════════════
@ -166,12 +178,13 @@ def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]:
class HermesHubApp(ctk.CTk): class HermesHubApp(ctk.CTk):
def __init__(self): def __init__(self):
self._theme_name = Theme.apply_scheme(_load_saved_theme())
ctk.set_appearance_mode("dark" if self._theme_name == "dark" else "light")
super().__init__() super().__init__()
self.title("Hermes Hub") self.title("Hermes Hub")
self.geometry("1380x880") self.geometry("1380x880")
self.minsize(1100, 700) self.minsize(1100, 700)
ctk.set_appearance_mode("dark")
ctk.set_default_color_theme("blue") ctk.set_default_color_theme("blue")
self.configure(fg_color=Theme.BG_WINDOW) self.configure(fg_color=Theme.BG_WINDOW)
@ -219,9 +232,9 @@ class HermesHubApp(ctk.CTk):
# Top Centered Brand Logo # Top Centered Brand Logo
brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent") brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent")
brand_container.pack(fill="x", padx=16, pady=(20, 14)) brand_container.pack(fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_LG, Theme.SPACE_SM))
logo_img = AssetManager.get().get_logo_image(size=(72, 72)) logo_img = AssetManager.get().get_logo_image(size=(58, 58))
if logo_img: if logo_img:
logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="") logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="")
logo_lbl.pack(anchor="center", pady=(0, 6)) logo_lbl.pack(anchor="center", pady=(0, 6))
@ -229,72 +242,136 @@ class HermesHubApp(ctk.CTk):
ctk.CTkLabel( ctk.CTkLabel(
brand_container, text="HERMES HUB", font=Theme.font_title_hero(), text_color=Theme.TEXT_ACCENT brand_container, text="HERMES HUB", font=Theme.font_title_hero(), text_color=Theme.TEXT_ACCENT
).pack(anchor="center") ).pack(anchor="center")
ctk.CTkLabel( ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER_ACCENT).pack(
brand_container, text="Control Center", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_XS, Theme.SPACE_SM)
).pack(anchor="center") )
# Divider
ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER).pack(fill="x", padx=14, pady=(6, 12))
# Nav Items with clean Fluent glyphs # Nav Items with clean Fluent glyphs
self._nav_items = [ self._nav_items = [
("overview", "Обзор", "⌂"), ("overview", "Обзор", "⌂"),
("team", "Команда", "👥"), ("team", "Команда", "♙"),
("accounts", "Аккаунты", "🔍"), ("accounts", "Аккаунты", "◎"),
("routing", "Маршрутизация", "🔀"), ("routing", "Маршрутизация", "⌘"),
("providers", "Провайдеры", "🌐"), ("providers", "Провайдеры", "◉"),
("health", "Состояние системы", "🛡️"), ("quotas", "Квоты и лимиты", "▥"),
("logs", "Журнал событий", "📜"), ("analytics", "Аналитика", "⌁"),
("settings", "Настройки", "⚙️"), ("health", "Состояние", "◇"),
("about", "О программе", "ℹ️"), ("logs", "Журнал событий", "▤"),
("settings", "Настройки", "⚙"),
("about", "О программе", "ⓘ"),
] ]
self.nav_frame = ctk.CTkScrollableFrame(
self.sidebar,
fg_color="transparent",
corner_radius=0,
scrollbar_fg_color=Theme.BG_SIDEBAR,
scrollbar_button_color=Theme.BG_SIDEBAR,
scrollbar_button_hover_color=Theme.BORDER_HOVER,
)
self.nav_frame.pack(fill="both", expand=True)
self._nav_buttons: Dict[str, ctk.CTkButton] = {} self._nav_buttons: Dict[str, ctk.CTkButton] = {}
for key, label, icon in self._nav_items: for key, label, icon in self._nav_items:
btn = ctk.CTkButton( btn = ctk.CTkButton(
self.sidebar, self.nav_frame,
text=f" {icon} {label}", text=f" {icon} {label}",
font=Theme.font_body(), font=Theme.font_body(),
height=Theme.HEIGHT_NAV_ITEM, height=Theme.HEIGHT_NAV_ITEM,
fg_color="transparent", fg_color="transparent",
hover_color=Theme.SURFACE_HOVER, hover_color=Theme.SIDEBAR_HOVER,
text_color=Theme.TEXT_PRIMARY, text_color=Theme.SIDEBAR_TEXT,
anchor="w", anchor="w",
corner_radius=Theme.RADIUS_SM, corner_radius=Theme.RADIUS_SM,
command=lambda k=key: self._show_view(k), command=lambda k=key: self._show_view(k),
) )
btn.pack(fill="x", padx=12, pady=2) btn.pack(fill="x", padx=Theme.SPACE_SM, pady=1)
self._nav_buttons[key] = btn self._nav_buttons[key] = btn
# Refresh button at bottom of sidebar self.sidebar_version = ctk.CTkLabel(
refresh_btn = HubButton(
self.sidebar, self.sidebar,
text="🔄 Обновить", text=f"Hermes Hub v{__version__}",
variant="secondary", font=Theme.font_micro(),
height=38, text_color=Theme.SIDEBAR_MUTED,
command=self._refresh_data,
) )
refresh_btn.pack(side="bottom", fill="x", padx=14, pady=16) self.sidebar_version.pack(side="bottom", pady=(0, Theme.SPACE_SM))
user_card = ctk.CTkFrame(
self.sidebar,
fg_color=Theme.SIDEBAR_SELECTED,
border_width=1,
border_color=Theme.BORDER,
corner_radius=Theme.RADIUS_MD,
)
user_card.pack(side="bottom", fill="x", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
ctk.CTkLabel(
user_card,
text="AD",
width=30,
height=30,
corner_radius=15,
fg_color=Theme.ACCENT,
text_color=Theme.TEXT_ON_ACCENT,
font=Theme.font_badge_bold(),
).pack(side="left", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
ctk.CTkLabel(
user_card,
text="Administrator\nОсновная команда",
justify="left",
font=Theme.font_micro(),
text_color=Theme.SIDEBAR_TEXT,
).pack(side="left")
# ── Status Bar (Bottom) ── # ── Global top bar ──
self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_STATUSBAR, fg_color=Theme.BG_STATUSBAR, corner_radius=0) self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_HEADER, fg_color=Theme.BG_HEADER, corner_radius=0)
self.statusbar.pack(side="bottom", fill="x") self.statusbar.pack(side="top", fill="x")
self.statusbar.pack_propagate(False)
self.status_left = ctk.CTkLabel( self.status_left = ctk.CTkLabel(
self.statusbar, self.statusbar,
text="Аккаунты: ... | Роли: ... | Обновлено: ...", text="● Состояние загружается",
font=Theme.font_micro(), font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED, text_color=Theme.STATUS_HEALTHY,
) )
self.status_left.pack(side="left", padx=16) self.status_left.pack(side="left", padx=Theme.SPACE_LG)
self.global_search = ctk.CTkEntry(
self.statusbar,
placeholder_text="Поиск по агентам, аккаунтам, задачам… Ctrl + K",
width=360,
height=Theme.HEIGHT_INPUT,
fg_color=Theme.SURFACE,
border_color=Theme.BORDER,
text_color=Theme.TEXT_PRIMARY,
)
self.global_search.pack(side="left", padx=Theme.SPACE_MD)
self.global_search.bind("<Return>", self._run_global_search)
self.bind_all("<Control-k>", self._focus_global_search)
self.add_account_button = HubButton(
self.statusbar,
text="+ Добавить аккаунт",
variant="primary",
command=lambda: self._handle_action("add_account", {}),
)
self.add_account_button.pack(side="right", padx=(Theme.SPACE_XS, Theme.SPACE_LG))
for text, command in (
("⚙", lambda: self._show_view("settings")),
("?", lambda: self._show_view("about")),
("♧", lambda: self._show_view("logs")),
):
HubButton(
self.statusbar,
text=text,
variant="ghost",
width=Theme.HEIGHT_BTN_MD,
command=command,
).pack(side="right", padx=Theme.SPACE_XS)
self.status_right = ctk.CTkLabel( self.status_right = ctk.CTkLabel(
self.statusbar, self.statusbar,
text="● Hermes работает", text="Snapshot: Н/Д",
font=Theme.font_micro(), font=Theme.font_micro(),
text_color=Theme.STATUS_HEALTHY, text_color=Theme.TEXT_MUTED,
) )
self.status_right.pack(side="right", padx=16)
# ── Main Content Area ── # ── Main Content Area ──
self.content = ctk.CTkFrame(self, fg_color=Theme.BG_WINDOW, corner_radius=0) self.content = ctk.CTkFrame(self, fg_color=Theme.BG_WINDOW, corner_radius=0)
@ -304,6 +381,20 @@ class HermesHubApp(ctk.CTk):
for key, _, _ in self._nav_items: for key, _, _ in self._nav_items:
self._views[key] = self._create_view(key) self._views[key] = self._create_view(key)
def _focus_global_search(self, _event=None) -> str:
self.global_search.focus_set()
return "break"
def _run_global_search(self, _event=None) -> str:
query = self.global_search.get().strip()
self._show_view("accounts")
accounts = self._views.get("accounts")
if accounts and hasattr(accounts, "search"):
accounts.search.delete(0, "end")
accounts.search.insert(0, query)
accounts._set_search(query)
return "break"
def _create_view(self, view_name: str) -> ctk.CTkFrame: def _create_view(self, view_name: str) -> ctk.CTkFrame:
"""Create view widget instance.""" """Create view widget instance."""
if view_name == "overview": if view_name == "overview":
@ -321,12 +412,16 @@ class HermesHubApp(ctk.CTk):
return ProvidersView(self.content, app_state={}, on_action=self._handle_action) return ProvidersView(self.content, app_state={}, on_action=self._handle_action)
elif view_name == "routing": elif view_name == "routing":
return RoutingView(self.content, on_action=self._handle_action) return RoutingView(self.content, on_action=self._handle_action)
elif view_name == "quotas":
return QuotasView(self.content)
elif view_name == "analytics":
return AnalyticsView(self.content)
elif view_name == "health": elif view_name == "health":
return HealthView(self.content, app_state={}, on_refresh=self._refresh_data) return HealthView(self.content, app_state={}, on_refresh=self._refresh_data)
elif view_name == "logs": elif view_name == "logs":
return LogsView(self.content) return LogsView(self.content)
elif view_name == "settings": elif view_name == "settings":
return SettingsView(self.content, on_action=self._handle_action) return SettingsView(self.content, on_action=self._handle_action, theme_name=self._theme_name)
elif view_name == "about": elif view_name == "about":
return AboutView(self.content) return AboutView(self.content)
else: else:
@ -342,7 +437,7 @@ class HermesHubApp(ctk.CTk):
for key, btn in self._nav_buttons.items(): for key, btn in self._nav_buttons.items():
if key == view_name: if key == view_name:
btn.configure( btn.configure(
fg_color=Theme.SURFACE_SELECTED, fg_color=Theme.SIDEBAR_SELECTED,
text_color=Theme.TEXT_ACCENT, text_color=Theme.TEXT_ACCENT,
border_width=1, border_width=1,
border_color=Theme.BORDER_ACCENT, border_color=Theme.BORDER_ACCENT,
@ -350,7 +445,7 @@ class HermesHubApp(ctk.CTk):
else: else:
btn.configure( btn.configure(
fg_color="transparent", fg_color="transparent",
text_color=Theme.TEXT_PRIMARY, text_color=Theme.SIDEBAR_TEXT,
border_width=0, border_width=0,
) )
@ -374,6 +469,7 @@ class HermesHubApp(ctk.CTk):
except Exception as ex: except Exception as ex:
logger.warning("Error in lazy view update for %s: %s", view_name, ex) logger.warning("Error in lazy view update for %s: %s", view_name, ex)
self._view_generations[view_name] = snap.generation self._view_generations[view_name] = snap.generation
self._update_auxiliary_data(target_view)
# Instrument tab switch latency # Instrument tab switch latency
el_ms = round((time.time() - t0) * 1000, 2) el_ms = round((time.time() - t0) * 1000, 2)
@ -439,15 +535,18 @@ class HermesHubApp(ctk.CTk):
freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}" freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}"
self.status_left.configure( self.status_left.configure(
text=f"{freshness} | Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}" text=f"● {readiness.title_ru}",
text_color=Theme.STATUS_HEALTHY
if readiness.state == "healthy"
else Theme.STATUS_WARNING
if readiness.state in ("limited", "degraded")
else Theme.STATUS_ERROR,
) )
r_color = ( self.status_right.configure(
Theme.STATUS_HEALTHY text=f"{freshness} • {readiness.accounts_connected_count} аккаунтов • {readiness.roles_ready_count} ролей",
if readiness.state == "healthy" text_color=Theme.STATUS_WARNING if snap.is_stale else Theme.TEXT_MUTED,
else (Theme.STATUS_WARNING if readiness.state in ("limited", "degraded") else Theme.STATUS_ERROR)
) )
self.status_right.configure(text=f"● {readiness.title_ru}", text_color=r_color)
# Update ONLY the currently visible view (others are updated lazily on tab switch) # Update ONLY the currently visible view (others are updated lazily on tab switch)
curr_view = self._views.get(self._current_view) curr_view = self._views.get(self._current_view)
@ -457,6 +556,15 @@ class HermesHubApp(ctk.CTk):
except Exception as ex: except Exception as ex:
logger.warning("Error updating current view %s: %s", self._current_view, ex) logger.warning("Error updating current view %s: %s", self._current_view, ex)
self._view_generations[self._current_view] = snap.generation self._view_generations[self._current_view] = snap.generation
self._update_auxiliary_data(curr_view)
@staticmethod
def _update_auxiliary_data(view: Any) -> None:
if hasattr(view, "update_events"):
try:
view.update_events(EventLogService.get().get_events(limit=20))
except Exception as ex:
logger.warning("Error updating event presentation: %s", ex)
def _on_data_error(self, error: str): def _on_data_error(self, error: str):
if self._shutting_down: if self._shutting_down:
@ -517,9 +625,16 @@ class HermesHubApp(ctk.CTk):
elif action == "edit_route": elif action == "edit_route":
self._show_toast("Редактор цепочки использует кнопки и селекторы; drag-and-drop отключён.") self._show_toast("Редактор цепочки использует кнопки и селекторы; drag-and-drop отключён.")
elif action == "save_settings": elif action == "save_settings":
def _settings_saved(result: Tuple[bool, str]) -> None:
requested_theme = str(data.get("theme", self._theme_name))
if requested_theme != self._theme_name:
self._apply_theme(requested_theme)
self._show_toast(f"✅ {result[1]}")
self._run_in_thread( self._run_in_thread(
lambda: do_save_settings(data), lambda: do_save_settings(data),
on_success=lambda result: self._show_toast(f"✅ {result[1]}"), on_success=_settings_saved,
) )
elif action == "check_updates": elif action == "check_updates":
from antigravity_provider.updater import UpdateManager from antigravity_provider.updater import UpdateManager
@ -592,6 +707,19 @@ class HermesHubApp(ctk.CTk):
msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}" msg = f"✕ Ошибка теста: {result.get('error', 'Неизвестная ошибка')}"
self._show_toast(msg) self._show_toast(msg)
def _apply_theme(self, scheme: str) -> None:
"""Apply all palette tokens by rebuilding presentation widgets in place."""
current_view = self._current_view
self._theme_name = Theme.apply_scheme(scheme)
ctk.set_appearance_mode("dark" if self._theme_name == "dark" else "light")
for child in list(self.winfo_children()):
child.destroy()
self._views.clear()
self._view_generations.clear()
self.configure(fg_color=Theme.BG_WINDOW)
self._build_layout()
self._show_view(current_view)
def _run_in_thread(self, func, on_success=None, on_error=None): def _run_in_thread(self, func, on_success=None, on_error=None):
def _worker(): def _worker():
if self._shutting_down: if self._shutting_down:
@ -627,7 +755,12 @@ class HermesHubApp(ctk.CTk):
readiness = HubStateStore.get().get_snapshot().readiness readiness = HubStateStore.get().get_snapshot().readiness
self.status_left.configure( self.status_left.configure(
text=f"Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}" text=f"● {readiness.title_ru}",
text_color=Theme.STATUS_HEALTHY
if readiness.state == "healthy"
else Theme.STATUS_WARNING
if readiness.state in ("limited", "degraded")
else Theme.STATUS_ERROR,
) )
def _on_close(self): def _on_close(self):

View file

@ -1,4 +1,5 @@
"""Hermes Hub UI Package — Native Dark-First Design System.""" """Hermes Hub UI Package — Native Dark-First Design System."""
from __future__ import annotations from __future__ import annotations
from antigravity_provider.router.ui.theme import Theme from antigravity_provider.router.ui.theme import Theme

View file

@ -9,6 +9,7 @@ Supports 5 AI Providers:
Includes multi-account profile isolation, duplicate detection, and auto-assignment to router roles. Includes multi-account profile isolation, duplicate detection, and auto-assignment to router roles.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
@ -80,6 +81,7 @@ class AddAccountWizard(HubModal):
if self.oauth_session_id: if self.oauth_session_id:
try: try:
from antigravity_provider.router.profile_oauth import cancel_oauth_session from antigravity_provider.router.profile_oauth import cancel_oauth_session
cancel_oauth_session(self.oauth_session_id) cancel_oauth_session(self.oauth_session_id)
except Exception: except Exception:
pass pass
@ -89,6 +91,7 @@ class AddAccountWizard(HubModal):
if self.codex_session_id: if self.codex_session_id:
try: try:
from antigravity_provider.router.codex_oauth import cancel_codex_oauth_session from antigravity_provider.router.codex_oauth import cancel_codex_oauth_session
cancel_codex_oauth_session(self.codex_session_id) cancel_codex_oauth_session(self.codex_session_id)
except Exception: except Exception:
pass pass
@ -97,6 +100,7 @@ class AddAccountWizard(HubModal):
if self.claude_session_id: if self.claude_session_id:
try: try:
from antigravity_provider.router.claude_oauth import cancel_claude_oauth_session from antigravity_provider.router.claude_oauth import cancel_claude_oauth_session
cancel_claude_oauth_session(self.claude_session_id) cancel_claude_oauth_session(self.claude_session_id)
except Exception: except Exception:
pass pass
@ -105,6 +109,7 @@ class AddAccountWizard(HubModal):
if self.grok_session_id: if self.grok_session_id:
try: try:
from antigravity_provider.router.grok_oauth import cancel_grok_oauth_session from antigravity_provider.router.grok_oauth import cancel_grok_oauth_session
cancel_grok_oauth_session(self.grok_session_id) cancel_grok_oauth_session(self.grok_session_id)
except Exception: except Exception:
pass pass
@ -129,10 +134,10 @@ class AddAccountWizard(HubModal):
providers = [ providers = [
("antigravity", "Google Antigravity", "OAuth 2.0 (Google Account)", Theme.ACCENT), ("antigravity", "Google Antigravity", "OAuth 2.0 (Google Account)", Theme.ACCENT),
("openai-codex", "OpenAI Codex", "OAuth (ChatGPT) или API Key", "#10a37f"), ("openai-codex", "OpenAI Codex", "OAuth (ChatGPT) или API Key", Theme.PROVIDER_CODEX),
("claude", "Claude (Anthropic)", "OAuth (Claude Pro/Max) или API Key", "#d97706"), ("claude", "Claude (Anthropic)", "OAuth (Claude Pro/Max) или API Key", Theme.PROVIDER_CLAUDE),
("grok", "Grok (xAI)", "OAuth (SuperGrok) или API Key", "#3b82f6"), ("grok", "Grok (xAI)", "OAuth (SuperGrok) или API Key", Theme.PROVIDER_GROK),
("opencode-go", "OpenCode Go", "API Key / Subscription", "#8b5cf6"), ("opencode-go", "OpenCode Go", "API Key / Subscription", Theme.PROVIDER_GENERIC),
] ]
self.provider_var = ctk.StringVar(value=self.selected_provider) self.provider_var = ctk.StringVar(value=self.selected_provider)
@ -193,7 +198,9 @@ class AddAccountWizard(HubModal):
self._clear_body() self._clear_body()
self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи") self.title_lbl.configure(text="Шаг 2 из 4: Авторизация учетной записи")
self.target_slot = AutoAssigner.find_free_slot(self.selected_provider) or f"{self.selected_provider[:3]}-spare-1" self.target_slot = (
AutoAssigner.find_free_slot(self.selected_provider) or f"{self.selected_provider[:3]}-spare-1"
)
if self.selected_provider == "antigravity": if self.selected_provider == "antigravity":
self._build_antigravity_oauth_flow() self._build_antigravity_oauth_flow()
@ -342,6 +349,7 @@ class AddAccountWizard(HubModal):
def _init_antigravity_oauth(self): def _init_antigravity_oauth(self):
try: try:
from antigravity_provider.router.profile_oauth import start_profile_oauth from antigravity_provider.router.profile_oauth import start_profile_oauth
session_id, auth_url, port = start_profile_oauth(self.target_slot) session_id, auth_url, port = start_profile_oauth(self.target_slot)
self.oauth_session_id = session_id self.oauth_session_id = session_id
self.oauth_url = auth_url self.oauth_url = auth_url
@ -381,6 +389,7 @@ class AddAccountWizard(HubModal):
return return
from antigravity_provider.router.profile_oauth import get_oauth_session from antigravity_provider.router.profile_oauth import get_oauth_session
session = get_oauth_session(self.oauth_session_id) session = get_oauth_session(self.oauth_session_id)
if not session: if not session:
self.oauth_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR) self.oauth_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
@ -399,6 +408,7 @@ class AddAccountWizard(HubModal):
def _poll_antigravity_oauth(self): def _poll_antigravity_oauth(self):
from antigravity_provider.router.profile_oauth import get_oauth_session from antigravity_provider.router.profile_oauth import get_oauth_session
for _ in range(300): for _ in range(300):
if not self._polling_active: if not self._polling_active:
return return
@ -418,7 +428,9 @@ class AddAccountWizard(HubModal):
return return
elif status in ("error", "failed", "cancelled"): elif status in ("error", "failed", "cancelled"):
err_msg = getattr(session, "error_msg", None) or "Авторизация не удалась" err_msg = getattr(session, "error_msg", None) or "Авторизация не удалась"
self.after(0, lambda m=err_msg: self.oauth_status_lbl.configure(text=f"❌ {m}", text_color=Theme.STATUS_ERROR)) self.after(
0, lambda m=err_msg: self.oauth_status_lbl.configure(text=f"❌ {m}", text_color=Theme.STATUS_ERROR)
)
return return
# ───────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────
@ -585,6 +597,7 @@ class AddAccountWizard(HubModal):
def _init_codex_oauth(self): def _init_codex_oauth(self):
try: try:
from antigravity_provider.router.codex_oauth import start_codex_oauth, get_codex_oauth_session from antigravity_provider.router.codex_oauth import start_codex_oauth, get_codex_oauth_session
session_id, url, code = start_codex_oauth(self.target_slot) session_id, url, code = start_codex_oauth(self.target_slot)
self.codex_session_id = session_id self.codex_session_id = session_id
self.codex_url = url self.codex_url = url
@ -637,6 +650,7 @@ class AddAccountWizard(HubModal):
return return
from antigravity_provider.router.codex_oauth import get_codex_oauth_session from antigravity_provider.router.codex_oauth import get_codex_oauth_session
session = get_codex_oauth_session(self.codex_session_id) session = get_codex_oauth_session(self.codex_session_id)
if not session: if not session:
self.codex_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR) self.codex_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
@ -655,6 +669,7 @@ class AddAccountWizard(HubModal):
def _poll_codex_oauth(self): def _poll_codex_oauth(self):
from antigravity_provider.router.codex_oauth import get_codex_oauth_session from antigravity_provider.router.codex_oauth import get_codex_oauth_session
for _ in range(900): for _ in range(900):
if not self._polling_active: if not self._polling_active:
return return
@ -689,7 +704,7 @@ class AddAccountWizard(HubModal):
value="oauth", value="oauth",
variable=self.claude_mode_var, variable=self.claude_mode_var,
font=Theme.font_body_bold(), font=Theme.font_body_bold(),
fg_color="#d97706", fg_color=Theme.PROVIDER_CLAUDE,
command=self._on_claude_mode_toggle, command=self._on_claude_mode_toggle,
) )
rb1.pack(anchor="w", padx=4, pady=2) rb1.pack(anchor="w", padx=4, pady=2)
@ -700,7 +715,7 @@ class AddAccountWizard(HubModal):
value="api_key", value="api_key",
variable=self.claude_mode_var, variable=self.claude_mode_var,
font=Theme.font_body(), font=Theme.font_body(),
fg_color="#d97706", fg_color=Theme.PROVIDER_CLAUDE,
command=self._on_claude_mode_toggle, command=self._on_claude_mode_toggle,
) )
rb2.pack(anchor="w", padx=4, pady=2) rb2.pack(anchor="w", padx=4, pady=2)
@ -810,6 +825,7 @@ class AddAccountWizard(HubModal):
def _init_claude_oauth(self): def _init_claude_oauth(self):
try: try:
from antigravity_provider.router.claude_oauth import start_claude_oauth from antigravity_provider.router.claude_oauth import start_claude_oauth
session_id, url = start_claude_oauth(self.target_slot) session_id, url = start_claude_oauth(self.target_slot)
self.claude_session_id = session_id self.claude_session_id = session_id
self.claude_url = url self.claude_url = url
@ -836,6 +852,7 @@ class AddAccountWizard(HubModal):
return return
from antigravity_provider.router.claude_oauth import get_claude_oauth_session from antigravity_provider.router.claude_oauth import get_claude_oauth_session
session = get_claude_oauth_session(self.claude_session_id) session = get_claude_oauth_session(self.claude_session_id)
if not session: if not session:
self.claude_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR) self.claude_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
@ -868,7 +885,7 @@ class AddAccountWizard(HubModal):
value="oauth", value="oauth",
variable=self.grok_mode_var, variable=self.grok_mode_var,
font=Theme.font_body_bold(), font=Theme.font_body_bold(),
fg_color="#3b82f6", fg_color=Theme.PROVIDER_GROK,
command=self._on_grok_mode_toggle, command=self._on_grok_mode_toggle,
) )
rb1.pack(anchor="w", padx=4, pady=2) rb1.pack(anchor="w", padx=4, pady=2)
@ -879,7 +896,7 @@ class AddAccountWizard(HubModal):
value="api_key", value="api_key",
variable=self.grok_mode_var, variable=self.grok_mode_var,
font=Theme.font_body(), font=Theme.font_body(),
fg_color="#3b82f6", fg_color=Theme.PROVIDER_GROK,
command=self._on_grok_mode_toggle, command=self._on_grok_mode_toggle,
) )
rb2.pack(anchor="w", padx=4, pady=2) rb2.pack(anchor="w", padx=4, pady=2)
@ -930,7 +947,7 @@ class AddAccountWizard(HubModal):
code_card, code_card,
text="...", text="...",
font=Theme.font_mono_bold(), font=Theme.font_mono_bold(),
text_color="#3b82f6", text_color=Theme.PROVIDER_GROK,
) )
self.grok_code_lbl.pack(side="left", padx=(0, 8)) self.grok_code_lbl.pack(side="left", padx=(0, 8))
@ -1016,6 +1033,7 @@ class AddAccountWizard(HubModal):
def _init_grok_oauth(self): def _init_grok_oauth(self):
try: try:
from antigravity_provider.router.grok_oauth import start_grok_oauth, get_grok_oauth_session from antigravity_provider.router.grok_oauth import start_grok_oauth, get_grok_oauth_session
session_id, url, code = start_grok_oauth(self.target_slot) session_id, url, code = start_grok_oauth(self.target_slot)
self.grok_session_id = session_id self.grok_session_id = session_id
self.grok_url = url self.grok_url = url
@ -1068,6 +1086,7 @@ class AddAccountWizard(HubModal):
return return
from antigravity_provider.router.grok_oauth import get_grok_oauth_session from antigravity_provider.router.grok_oauth import get_grok_oauth_session
session = get_grok_oauth_session(self.grok_session_id) session = get_grok_oauth_session(self.grok_session_id)
if not session: if not session:
self.grok_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR) self.grok_status_lbl.configure(text="❌ Сессия не найдена", text_color=Theme.STATUS_ERROR)
@ -1086,6 +1105,7 @@ class AddAccountWizard(HubModal):
def _poll_grok_oauth(self): def _poll_grok_oauth(self):
from antigravity_provider.router.grok_oauth import get_grok_oauth_session from antigravity_provider.router.grok_oauth import get_grok_oauth_session
for _ in range(900): for _ in range(900):
if not self._polling_active: if not self._polling_active:
return return
@ -1114,20 +1134,68 @@ class AddAccountWizard(HubModal):
mode_card = HubCard(self.body, fg_color="transparent") mode_card = HubCard(self.body, fg_color="transparent")
mode_card.pack(fill="x", pady=(0, 8)) mode_card.pack(fill="x", pady=(0, 8))
self.codex_mode_var = ctk.StringVar(value="api_key") self.codex_mode_var = ctk.StringVar(value="api_key")
ctk.CTkRadioButton(mode_card, text="OAuth — OpenAI / ChatGPT", value="oauth", variable=self.codex_mode_var, font=Theme.font_body(), fg_color=Theme.ACCENT, command=self._on_codex_mode_toggle).pack(anchor="w", padx=4, pady=2) ctk.CTkRadioButton(
ctk.CTkRadioButton(mode_card, text="API Key — OpenAI API (sk-...)", value="api_key", variable=self.codex_mode_var, font=Theme.font_body_bold(), fg_color=Theme.ACCENT, command=self._on_codex_mode_toggle).pack(anchor="w", padx=4, pady=2) mode_card,
text="OAuth — OpenAI / ChatGPT",
value="oauth",
variable=self.codex_mode_var,
font=Theme.font_body(),
fg_color=Theme.ACCENT,
command=self._on_codex_mode_toggle,
).pack(anchor="w", padx=4, pady=2)
ctk.CTkRadioButton(
mode_card,
text="API Key — OpenAI API (sk-...)",
value="api_key",
variable=self.codex_mode_var,
font=Theme.font_body_bold(),
fg_color=Theme.ACCENT,
command=self._on_codex_mode_toggle,
).pack(anchor="w", padx=4, pady=2)
elif self.selected_provider == "claude": elif self.selected_provider == "claude":
mode_card = HubCard(self.body, fg_color="transparent") mode_card = HubCard(self.body, fg_color="transparent")
mode_card.pack(fill="x", pady=(0, 8)) mode_card.pack(fill="x", pady=(0, 8))
self.claude_mode_var = ctk.StringVar(value="api_key") self.claude_mode_var = ctk.StringVar(value="api_key")
ctk.CTkRadioButton(mode_card, text="OAuth — Claude Pro/Max", value="oauth", variable=self.claude_mode_var, font=Theme.font_body(), fg_color="#d97706", command=self._on_claude_mode_toggle).pack(anchor="w", padx=4, pady=2) ctk.CTkRadioButton(
ctk.CTkRadioButton(mode_card, text="API Key — Anthropic API (sk-ant-...)", value="api_key", variable=self.claude_mode_var, font=Theme.font_body_bold(), fg_color="#d97706", command=self._on_claude_mode_toggle).pack(anchor="w", padx=4, pady=2) mode_card,
text="OAuth — Claude Pro/Max",
value="oauth",
variable=self.claude_mode_var,
font=Theme.font_body(),
fg_color=Theme.PROVIDER_CLAUDE,
command=self._on_claude_mode_toggle,
).pack(anchor="w", padx=4, pady=2)
ctk.CTkRadioButton(
mode_card,
text="API Key — Anthropic API (sk-ant-...)",
value="api_key",
variable=self.claude_mode_var,
font=Theme.font_body_bold(),
fg_color=Theme.PROVIDER_CLAUDE,
command=self._on_claude_mode_toggle,
).pack(anchor="w", padx=4, pady=2)
elif self.selected_provider == "grok": elif self.selected_provider == "grok":
mode_card = HubCard(self.body, fg_color="transparent") mode_card = HubCard(self.body, fg_color="transparent")
mode_card.pack(fill="x", pady=(0, 8)) mode_card.pack(fill="x", pady=(0, 8))
self.grok_mode_var = ctk.StringVar(value="api_key") self.grok_mode_var = ctk.StringVar(value="api_key")
ctk.CTkRadioButton(mode_card, text="OAuth — xAI / SuperGrok", value="oauth", variable=self.grok_mode_var, font=Theme.font_body(), fg_color="#3b82f6", command=self._on_grok_mode_toggle).pack(anchor="w", padx=4, pady=2) ctk.CTkRadioButton(
ctk.CTkRadioButton(mode_card, text="API Key — xAI API (xai-...)", value="api_key", variable=self.grok_mode_var, font=Theme.font_body_bold(), fg_color="#3b82f6", command=self._on_grok_mode_toggle).pack(anchor="w", padx=4, pady=2) mode_card,
text="OAuth — xAI / SuperGrok",
value="oauth",
variable=self.grok_mode_var,
font=Theme.font_body(),
fg_color=Theme.PROVIDER_GROK,
command=self._on_grok_mode_toggle,
).pack(anchor="w", padx=4, pady=2)
ctk.CTkRadioButton(
mode_card,
text="API Key — xAI API (xai-...)",
value="api_key",
variable=self.grok_mode_var,
font=Theme.font_body_bold(),
fg_color=Theme.PROVIDER_GROK,
command=self._on_grok_mode_toggle,
).pack(anchor="w", padx=4, pady=2)
prompt_map = { prompt_map = {
"openai-codex": "Введите ключ OpenAI API (sk-...):", "openai-codex": "Введите ключ OpenAI API (sk-...):",
@ -1274,25 +1342,38 @@ class AddAccountWizard(HubModal):
# Identity line # Identity line
id_row = ctk.CTkFrame(card, fg_color="transparent") id_row = ctk.CTkFrame(card, fg_color="transparent")
id_row.pack(fill="x", padx=14, pady=2) id_row.pack(fill="x", padx=14, pady=2)
ctk.CTkLabel(id_row, text="Идентификатор:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="left") ctk.CTkLabel(id_row, text="Идентификатор:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
ctk.CTkLabel(id_row, text=self.discovered_identity or "—", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY).pack(side="right") side="left"
)
ctk.CTkLabel(
id_row, text=self.discovered_identity or "—", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
).pack(side="right")
# Plan line # Plan line
plan_row = ctk.CTkFrame(card, fg_color="transparent") plan_row = ctk.CTkFrame(card, fg_color="transparent")
plan_row.pack(fill="x", padx=14, pady=2) plan_row.pack(fill="x", padx=14, pady=2)
ctk.CTkLabel(plan_row, text="Тариф:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="left") ctk.CTkLabel(plan_row, text="Тариф:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
ctk.CTkLabel(plan_row, text=self.discovered_plan or "Тариф: неизвестен", font=Theme.font_body_bold(), text_color=Theme.ACCENT).pack(side="right") side="left"
)
ctk.CTkLabel(
plan_row,
text=self.discovered_plan or "Тариф: неизвестен",
font=Theme.font_body_bold(),
text_color=Theme.ACCENT,
).pack(side="right")
# Status line # Status line
stat_row = ctk.CTkFrame(card, fg_color="transparent") stat_row = ctk.CTkFrame(card, fg_color="transparent")
stat_row.pack(fill="x", padx=14, pady=2) stat_row.pack(fill="x", padx=14, pady=2)
ctk.CTkLabel(stat_row, text="Статус проверки:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="left") ctk.CTkLabel(
stat_row, text="Статус проверки:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY
).pack(side="left")
stat_lbl = "✓ Успешно авторизован" if self.is_verified else "Подключен (без проверки)" stat_lbl = "✓ Успешно авторизован" if self.is_verified else "Подключен (без проверки)"
stat_col = Theme.STATUS_HEALTHY if self.is_verified else Theme.STATUS_WARNING stat_col = Theme.STATUS_HEALTHY if self.is_verified else Theme.STATUS_WARNING
ctk.CTkLabel(stat_row, text=stat_lbl, font=Theme.font_body_bold(), text_color=stat_col).pack(side="right") ctk.CTkLabel(stat_row, text=stat_lbl, font=Theme.font_body_bold(), text_color=stat_col).pack(side="right")
if dup_profile: if dup_profile:
warn_card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color="#2A2215") warn_card = HubCard(self.body, border_color=Theme.STATUS_WARNING, fg_color=Theme.ACCENT_DIM)
warn_card.pack(fill="x", pady=6) warn_card.pack(fill="x", pady=6)
ctk.CTkLabel( ctk.CTkLabel(
warn_card, warn_card,
@ -1338,13 +1419,19 @@ class AddAccountWizard(HubModal):
r_row = ctk.CTkFrame(card, fg_color="transparent") r_row = ctk.CTkFrame(card, fg_color="transparent")
r_row.pack(fill="x", padx=14, pady=4) r_row.pack(fill="x", padx=14, pady=4)
ctk.CTkLabel(r_row, text="Роль агента:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="left") ctk.CTkLabel(r_row, text="Роль агента:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
side="left"
)
ctk.CTkLabel(r_row, text=disp_name, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(side="right") ctk.CTkLabel(r_row, text=disp_name, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(side="right")
s_row = ctk.CTkFrame(card, fg_color="transparent") s_row = ctk.CTkFrame(card, fg_color="transparent")
s_row.pack(fill="x", padx=14, pady=4) s_row.pack(fill="x", padx=14, pady=4)
ctk.CTkLabel(s_row, text="Внутренний слот:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="left") ctk.CTkLabel(s_row, text="Внутренний слот:", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(
ctk.CTkLabel(s_row, text=self.target_slot, font=Theme.font_mono(), text_color=Theme.TEXT_MUTED).pack(side="right") side="left"
)
ctk.CTkLabel(s_row, text=self.target_slot, font=Theme.font_mono(), text_color=Theme.TEXT_MUTED).pack(
side="right"
)
ctk.CTkLabel( ctk.CTkLabel(
self.body, self.body,
@ -1370,9 +1457,11 @@ class AddAccountWizard(HubModal):
profile_id=self.target_slot, profile_id=self.target_slot,
) )
if self.on_complete: if self.on_complete:
self.on_complete({ self.on_complete(
"provider": self.selected_provider, {
"profile_id": self.target_slot, "provider": self.selected_provider,
"identity": self.discovered_identity, "profile_id": self.target_slot,
}) "identity": self.discovered_identity,
}
)
self.destroy() self.destroy()

View file

@ -1,4 +1,5 @@
"""Hermes Hub — Asset and Icon Manager with Provider Icon Caching.""" """Hermes Hub — Asset and Icon Manager with Provider Icon Caching."""
from __future__ import annotations from __future__ import annotations
import os import os
@ -14,6 +15,7 @@ try:
import customtkinter as ctk import customtkinter as ctk
except ImportError: except ImportError:
import unittest.mock as _mock import unittest.mock as _mock
ctk = _mock.MagicMock() ctk = _mock.MagicMock()
@ -71,7 +73,21 @@ class AssetManager:
if logo_path.exists(): if logo_path.exists():
try: try:
pil_img = Image.open(logo_path).convert("RGBA") pil_img = Image.open(logo_path).convert("RGBA")
return ctk.CTkImage(light_image=pil_img, dark_image=pil_img, size=size) from antigravity_provider.router.ui.theme import Theme
transparent = pil_img.copy()
pixels = []
gold = tuple(int(Theme.ACCENT[index : index + 2], 16) for index in (1, 3, 5))
tint_gold = Theme.current_scheme in {"dark", "hybrid"}
for red, green, blue, alpha in transparent.getdata():
if red > 238 and green > 238 and blue > 232:
pixels.append((red, green, blue, 0))
elif tint_gold:
pixels.append((*gold, alpha))
else:
pixels.append((red, green, blue, alpha))
transparent.putdata(pixels)
return ctk.CTkImage(light_image=transparent, dark_image=transparent, size=size)
except Exception: except Exception:
return None return None
return None return None

View file

@ -92,16 +92,16 @@ class HubCard(ctk.CTkFrame):
master: Any, master: Any,
corner_radius: int = Theme.RADIUS_MD, corner_radius: int = Theme.RADIUS_MD,
border_width: int = 1, border_width: int = 1,
border_color: str = Theme.BORDER, border_color: Optional[str] = None,
fg_color: str = Theme.SURFACE, fg_color: Optional[str] = None,
**kwargs, **kwargs,
): ):
super().__init__( super().__init__(
master=master, master=master,
corner_radius=corner_radius, corner_radius=corner_radius,
border_width=border_width, border_width=border_width,
border_color=border_color, border_color=border_color or Theme.BORDER,
fg_color=fg_color, fg_color=fg_color or Theme.SURFACE,
**kwargs, **kwargs,
) )
@ -257,19 +257,19 @@ class HubSectionHeader(ctk.CTkFrame):
class HubStatusBadge(ctk.CTkFrame): class HubStatusBadge(ctk.CTkFrame):
"""Status pill with dot and localized label for all normalized health states.""" """Status pill with dot and localized label for all normalized health states."""
STATUS_MAP = { STATUS_LABELS = {
"healthy": (Theme.STATUS_HEALTHY, "Работает"), "healthy": "Работает",
"quota_low": (Theme.STATUS_WARNING, "Квота заканчивается"), "quota_low": "Квота заканчивается",
"quota_exhausted": (Theme.STATUS_ERROR, "Квота исчерпана"), "quota_exhausted": "Квота исчерпана",
"cooldown": (Theme.STATUS_WARNING, "Ожидание сброса"), "cooldown": "Ожидание сброса",
"rate_limited": (Theme.STATUS_WARNING, "Лимит запросов"), "rate_limited": "Лимит запросов",
"not_configured": (Theme.TEXT_MUTED, "Аккаунт не добавлен"), "not_configured": "Аккаунт не добавлен",
"auth_required": (Theme.STATUS_AUTH_REQUIRED, "Требуется вход"), "auth_required": "Требуется вход",
"auth_expired": (Theme.STATUS_AUTH_REQUIRED, "Требуется повторная авторизация"), "auth_expired": "Требуется повторная авторизация",
"disabled": (Theme.STATUS_DISABLED, "Отключён"), "disabled": "Отключён",
"cold_spare": (Theme.TEXT_MUTED, "Холодный резерв"), "cold_spare": "Холодный резерв",
"unhealthy": (Theme.STATUS_ERROR, "Ошибка"), "unhealthy": "Ошибка",
"not_tested": (Theme.TEXT_MUTED, "Не проверен"), "not_tested": "Не проверен",
} }
def __init__(self, master: Any, status_key: str, **kwargs): def __init__(self, master: Any, status_key: str, **kwargs):
@ -295,7 +295,8 @@ class HubStatusBadge(ctk.CTkFrame):
def set_status(self, status_key: str, label: Optional[str] = None) -> None: def set_status(self, status_key: str, label: Optional[str] = None) -> None:
clean_key = (status_key or "unknown").lower().replace("-", "_") clean_key = (status_key or "unknown").lower().replace("-", "_")
color, default_label = self.STATUS_MAP.get(clean_key, (Theme.TEXT_MUTED, status_key or "Н/Д")) default_label = self.STATUS_LABELS.get(clean_key, status_key or "Н/Д")
color = _semantic_color(clean_key)
self.dot.configure(text_color=color) self.dot.configure(text_color=color)
self.label.configure(text=label or default_label) self.label.configure(text=label or default_label)
@ -513,9 +514,9 @@ def _semantic_color(status: str) -> str:
key = (status or "unknown").lower().replace("-", "_") key = (status or "unknown").lower().replace("-", "_")
if key in {"healthy", "active", "authenticated", "online", "ready"}: if key in {"healthy", "active", "authenticated", "online", "ready"}:
return Theme.COLOR_POSITIVE return Theme.COLOR_POSITIVE
if key in {"warning", "quota_low", "cooldown", "reserve", "rate_limited"}: if key in {"warning", "quota_low", "cooldown", "reserve", "rate_limited", "auth_required", "auth_expired"}:
return Theme.COLOR_CAUTION return Theme.COLOR_CAUTION
if key in {"error", "unhealthy", "quota_exhausted", "offline", "auth_expired"}: if key in {"error", "unhealthy", "quota_exhausted", "offline"}:
return Theme.COLOR_NEGATIVE return Theme.COLOR_NEGATIVE
return Theme.COLOR_NEUTRAL return Theme.COLOR_NEUTRAL
@ -582,7 +583,7 @@ class EllipsizedLabel(ctk.CTkLabel):
class _TextBadge(ctk.CTkFrame): class _TextBadge(ctk.CTkFrame):
"""Small neutral badge used for plans and metadata, not health.""" """Small neutral badge used for plans and metadata, not health."""
def __init__(self, master: Any, text: str, text_color: str = Theme.TEXT_SECONDARY, **kwargs): def __init__(self, master: Any, text: str, text_color: Optional[str] = None, **kwargs):
super().__init__( super().__init__(
master=master, master=master,
height=Theme.HEIGHT_BADGE, height=Theme.HEIGHT_BADGE,
@ -592,7 +593,12 @@ class _TextBadge(ctk.CTkFrame):
corner_radius=Theme.RADIUS_PILL, corner_radius=Theme.RADIUS_PILL,
**kwargs, **kwargs,
) )
self.label = ctk.CTkLabel(self, text=text, font=Theme.font_micro(), text_color=text_color) self.label = ctk.CTkLabel(
self,
text=text,
font=Theme.font_micro(),
text_color=text_color or Theme.TEXT_SECONDARY,
)
self.label.pack(padx=Theme.SPACE_SM, pady=Theme.SPACE_XS) self.label.pack(padx=Theme.SPACE_SM, pady=Theme.SPACE_XS)
def set_text(self, text: str) -> None: def set_text(self, text: str) -> None:
@ -839,6 +845,13 @@ class RouteTargetWidget(HubCard):
class AccountCardWidget(HubCard): class AccountCardWidget(HubCard):
"""Keyed account card whose quota buckets are updated without rebuilding the card.""" """Keyed account card whose quota buckets are updated without rebuilding the card."""
MANAGEMENT_ACTIONS = (
("test", "⚡ Тест"),
("set_main", "★ Основной"),
("set_orchestrator", "♛ Оркестратор"),
("assign_role", "Назначить роль"),
)
def __init__( def __init__(
self, self,
master: Any, master: Any,
@ -883,15 +896,31 @@ class AccountCardWidget(HubCard):
) )
self.actions = ctk.CTkFrame(self.details, fg_color="transparent") self.actions = ctk.CTkFrame(self.details, fg_color="transparent")
self.actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y)) self.actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y))
self.action_buttons: Dict[str, ActionButton] = {}
management = ctk.CTkFrame(self.actions, fg_color="transparent")
management.pack(fill="x", pady=(0, Theme.SPACE_XS))
for index, (action, label) in enumerate(self.MANAGEMENT_ACTIONS):
button = ActionButton(
management,
text=label,
variant="secondary" if index < 2 else "ghost",
height=Theme.HEIGHT_BTN_SM,
command=lambda action_name=action: self._trigger(action_name),
)
button.grid(row=index // 2, column=index % 2, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="ew")
management.grid_columnconfigure(index % 2, weight=1)
self.action_buttons[action] = button
utility = ctk.CTkFrame(self.actions, fg_color="transparent")
utility.pack(fill="x")
ActionButton( ActionButton(
self.actions, utility,
text="Обновить", text="Обновить",
variant="secondary", variant="secondary",
width=86, width=86,
command=lambda: self._trigger("refresh_account"), command=lambda: self._trigger("refresh_account"),
).pack(side="left") ).pack(side="left")
ActionButton( ActionButton(
self.actions, utility,
text="Удалить", text="Удалить",
variant="ghost", variant="ghost",
width=72, width=72,

View file

@ -1,4 +1,5 @@
"""Hermes Hub — Native Brand Splash Screen.""" """Hermes Hub — Native Brand Splash Screen."""
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk

View file

@ -8,6 +8,7 @@ Primary Palette:
LIGHT: #F7F1E3 (Warm Ivory) LIGHT: #F7F1E3 (Warm Ivory)
ACCENT: #CDAA64 (Ancient Gold) ACCENT: #CDAA64 (Ancient Gold)
""" """
from __future__ import annotations from __future__ import annotations
@ -58,15 +59,15 @@ class Theme:
DANGER_TEXT = "#FFD6D6" DANGER_TEXT = "#FFD6D6"
# ── Operational / Status Colors ── # ── Operational / Status Colors ──
STATUS_HEALTHY = "#2E7D32" # Vibrant Forest Green STATUS_HEALTHY = "#2E7D32" # Vibrant Forest Green
STATUS_WARNING = "#D97706" # Amber Gold STATUS_WARNING = "#D97706" # Amber Gold
STATUS_ERROR = "#DC2626" # Ruby Crimson STATUS_ERROR = "#DC2626" # Ruby Crimson
STATUS_INFO = "#2563EB" # Azure STATUS_INFO = "#2563EB" # Azure
STATUS_AUTH_REQUIRED = "#D97706" STATUS_AUTH_REQUIRED = "#D97706"
STATUS_DISABLED = "#5A6B5D" STATUS_DISABLED = "#5A6B5D"
STATUS_FAILOVER = "#3B82F6" STATUS_FAILOVER = "#3B82F6"
STATUS_MAIN = "#CDAA64" # Gold Badge STATUS_MAIN = "#CDAA64" # Gold Badge
STATUS_ORCHESTRATOR = "#E5C158" # Imperial Gold STATUS_ORCHESTRATOR = "#E5C158" # Imperial Gold
# Semantic roles. Brand gold is intentionally not a health colour. # Semantic roles. Brand gold is intentionally not a health colour.
COLOR_POSITIVE = STATUS_HEALTHY COLOR_POSITIVE = STATUS_HEALTHY
@ -76,9 +77,198 @@ class Theme:
COLOR_BRAND = ACCENT COLOR_BRAND = ACCENT
# ── Provider Specific Accent Colors ── # ── Provider Specific Accent Colors ──
PROVIDER_ANTIGRAVITY = "#4285F4" # Google Blue PROVIDER_ANTIGRAVITY = "#4285F4" # Google Blue
PROVIDER_CODEX = "#10A37F" # OpenAI Emerald PROVIDER_CODEX = "#10A37F" # OpenAI Emerald
PROVIDER_OPENCODE = "#F97316" # OpenCode Orange PROVIDER_OPENCODE = "#F97316" # OpenCode Orange
PROVIDER_CLAUDE = "#D97706"
PROVIDER_GROK = "#3B82F6"
PROVIDER_GENERIC = "#8B5CF6"
SCHEMES = ("dark", "hybrid", "light")
SCHEME_LABELS = {
"dark": "Тёмная",
"hybrid": "Средняя (гибрид)",
"light": "Светлая (бежевая)",
}
PALETTES = {
"dark": {
"PRIMARY": "#071A17",
"DARK": "#0B211D",
"SECONDARY": "#244B3C",
"LIGHT": "#F4EAD4",
"ACCENT": "#C89A2B",
"BG_WINDOW": "#061916",
"BG_SIDEBAR": "#08221E",
"BG_HEADER": "#071B18",
"BG_STATUSBAR": "#061512",
"BG_MODAL_BACKDROP": "#020B09",
"SURFACE": "#0B2520",
"SURFACE_HOVER": "#12342C",
"SURFACE_ACTIVE": "#194535",
"SURFACE_SELECTED": "#173B2E",
"SURFACE_MUTED": "#091E1A",
"BORDER": "#36513B",
"BORDER_SUBTLE": "#203A2D",
"BORDER_ACCENT": "#B78525",
"BORDER_HOVER": "#537456",
"TEXT_PRIMARY": "#F8F0DC",
"TEXT_SECONDARY": "#D1C7AE",
"TEXT_MUTED": "#8FA395",
"TEXT_ON_ACCENT": "#102018",
"TEXT_ACCENT": "#E0B84E",
"TEXT_DISABLED": "#617367",
"ACCENT_HOVER": "#E0B84E",
"ACCENT_PRESSED": "#A9781E",
"ACCENT_DIM": "#3D3218",
"ACCENT_GLOW": "#D9AA37",
"DANGER_SURFACE": "#4A2020",
"DANGER_SURFACE_HOVER": "#692A2A",
"DANGER_BORDER": "#8A3B36",
"DANGER_TEXT": "#FFD8D2",
"STATUS_HEALTHY": "#72C943",
"STATUS_WARNING": "#E1A62B",
"STATUS_ERROR": "#E45C4F",
"STATUS_INFO": "#4C8DD8",
"STATUS_AUTH_REQUIRED": "#E1A62B",
"STATUS_DISABLED": "#708078",
"STATUS_FAILOVER": "#4C8DD8",
"STATUS_MAIN": "#C89A2B",
"STATUS_ORCHESTRATOR": "#E0B84E",
"PROVIDER_ANTIGRAVITY": "#74A9FF",
"PROVIDER_CODEX": "#46BE8A",
"PROVIDER_OPENCODE": "#F39A50",
"PROVIDER_CLAUDE": "#DF9C63",
"PROVIDER_GROK": "#6DA6F2",
"PROVIDER_GENERIC": "#A99DD8",
"SIDEBAR_TEXT": "#F8F0DC",
"SIDEBAR_MUTED": "#8FA395",
"SIDEBAR_HOVER": "#12342C",
"SIDEBAR_SELECTED": "#173B2E",
},
"hybrid": {
"PRIMARY": "#F7F2E8",
"DARK": "#0A2721",
"SECONDARY": "#DCE6D7",
"LIGHT": "#FCF8F0",
"ACCENT": "#B98118",
"BG_WINDOW": "#F5F0E7",
"BG_SIDEBAR": "#08251F",
"BG_HEADER": "#FBF8F1",
"BG_STATUSBAR": "#EFE8DB",
"BG_MODAL_BACKDROP": "#E8E0D3",
"SURFACE": "#FBF8F1",
"SURFACE_HOVER": "#EEE8DA",
"SURFACE_ACTIVE": "#E1EAD9",
"SURFACE_SELECTED": "#E4EBD8",
"SURFACE_MUTED": "#F1EBE0",
"BORDER": "#D8CCB8",
"BORDER_SUBTLE": "#E7DDCE",
"BORDER_ACCENT": "#BD8B2F",
"BORDER_HOVER": "#AFA188",
"TEXT_PRIMARY": "#17241D",
"TEXT_SECONDARY": "#4C584F",
"TEXT_MUTED": "#7B817B",
"TEXT_ON_ACCENT": "#FFFFFF",
"TEXT_ACCENT": "#9B6C13",
"TEXT_DISABLED": "#A9AAA4",
"ACCENT_HOVER": "#CF9C39",
"ACCENT_PRESSED": "#936514",
"ACCENT_DIM": "#F0E4C8",
"ACCENT_GLOW": "#C59432",
"DANGER_SURFACE": "#F8E2DE",
"DANGER_SURFACE_HOVER": "#F1CBC5",
"DANGER_BORDER": "#C86155",
"DANGER_TEXT": "#812E28",
"STATUS_HEALTHY": "#397B35",
"STATUS_WARNING": "#BE7B13",
"STATUS_ERROR": "#C6473D",
"STATUS_INFO": "#326DAD",
"STATUS_AUTH_REQUIRED": "#BE7B13",
"STATUS_DISABLED": "#969B94",
"STATUS_FAILOVER": "#326DAD",
"STATUS_MAIN": "#B98118",
"STATUS_ORCHESTRATOR": "#A87817",
"PROVIDER_ANTIGRAVITY": "#326DAD",
"PROVIDER_CODEX": "#247A50",
"PROVIDER_OPENCODE": "#B96324",
"PROVIDER_CLAUDE": "#A55C2C",
"PROVIDER_GROK": "#326DAD",
"PROVIDER_GENERIC": "#7365A5",
"SIDEBAR_TEXT": "#F8F0DC",
"SIDEBAR_MUTED": "#98A89D",
"SIDEBAR_HOVER": "#12342C",
"SIDEBAR_SELECTED": "#173B2E",
},
"light": {
"PRIMARY": "#FFF9EE",
"DARK": "#FFF8EC",
"SECONDARY": "#E9DFC9",
"LIGHT": "#FFFDF8",
"ACCENT": "#A96F12",
"BG_WINDOW": "#FFF9EF",
"BG_SIDEBAR": "#FBF0DE",
"BG_HEADER": "#FFFDF8",
"BG_STATUSBAR": "#F7EDDE",
"BG_MODAL_BACKDROP": "#EDE2D2",
"SURFACE": "#FFFDF8",
"SURFACE_HOVER": "#F4E9D9",
"SURFACE_ACTIVE": "#DFE8D7",
"SURFACE_SELECTED": "#E7EEDC",
"SURFACE_MUTED": "#F8F0E5",
"BORDER": "#DDCFB9",
"BORDER_SUBTLE": "#EDE2D2",
"BORDER_ACCENT": "#B57A1C",
"BORDER_HOVER": "#B8A78D",
"TEXT_PRIMARY": "#1E281F",
"TEXT_SECONDARY": "#505A51",
"TEXT_MUTED": "#7E837C",
"TEXT_ON_ACCENT": "#FFFFFF",
"TEXT_ACCENT": "#90600F",
"TEXT_DISABLED": "#ADAEA7",
"ACCENT_HOVER": "#C58B2D",
"ACCENT_PRESSED": "#835609",
"ACCENT_DIM": "#F2E3C4",
"ACCENT_GLOW": "#BA8428",
"DANGER_SURFACE": "#F9E5E0",
"DANGER_SURFACE_HOVER": "#F3D0C9",
"DANGER_BORDER": "#C85A4E",
"DANGER_TEXT": "#812E28",
"STATUS_HEALTHY": "#367B36",
"STATUS_WARNING": "#B97612",
"STATUS_ERROR": "#C6473D",
"STATUS_INFO": "#326DAD",
"STATUS_AUTH_REQUIRED": "#B97612",
"STATUS_DISABLED": "#999D96",
"STATUS_FAILOVER": "#326DAD",
"STATUS_MAIN": "#A96F12",
"STATUS_ORCHESTRATOR": "#9D6810",
"PROVIDER_ANTIGRAVITY": "#326DAD",
"PROVIDER_CODEX": "#247A50",
"PROVIDER_OPENCODE": "#B96324",
"PROVIDER_CLAUDE": "#A55C2C",
"PROVIDER_GROK": "#326DAD",
"PROVIDER_GENERIC": "#7365A5",
"SIDEBAR_TEXT": "#283129",
"SIDEBAR_MUTED": "#767D76",
"SIDEBAR_HOVER": "#F0E3D0",
"SIDEBAR_SELECTED": "#E6EEDB",
},
}
current_scheme = "dark"
@classmethod
def apply_scheme(cls, scheme: str) -> str:
"""Activate a complete palette and return its normalized key."""
normalized = scheme if scheme in cls.PALETTES else "dark"
for token, value in cls.PALETTES[normalized].items():
setattr(cls, token, value)
cls.current_scheme = normalized
cls.COLOR_POSITIVE = cls.STATUS_HEALTHY
cls.COLOR_CAUTION = cls.STATUS_WARNING
cls.COLOR_NEGATIVE = cls.STATUS_ERROR
cls.COLOR_NEUTRAL = cls.STATUS_DISABLED
cls.COLOR_BRAND = cls.ACCENT
return normalized
# ── Spacing Scale (px) ── # ── Spacing Scale (px) ──
SPACE_XS = 4 SPACE_XS = 4
@ -106,10 +296,10 @@ class Theme:
HEIGHT_BTN_SM = 30 HEIGHT_BTN_SM = 30
HEIGHT_BTN_MD = 38 HEIGHT_BTN_MD = 38
HEIGHT_BTN_LG = 44 HEIGHT_BTN_LG = 44
HEIGHT_NAV_ITEM = 42 HEIGHT_NAV_ITEM = 34
HEIGHT_HEADER = 60 HEIGHT_HEADER = 56
HEIGHT_STATUSBAR = 30 HEIGHT_STATUSBAR = 30
WIDTH_SIDEBAR = 240 WIDTH_SIDEBAR = 190
HEIGHT_INPUT = 36 HEIGHT_INPUT = 36
HEIGHT_BADGE = 24 HEIGHT_BADGE = 24
ACCOUNT_CARD_MIN_HEIGHT = 188 ACCOUNT_CARD_MIN_HEIGHT = 188
@ -180,3 +370,6 @@ class Theme:
@classmethod @classmethod
def font_metric(cls): def font_metric(cls):
return (cls.FONT_FAMILY_UI, 24, "bold") return (cls.FONT_FAMILY_UI, 24, "bold")
Theme.apply_scheme("dark")

View file

@ -1,2 +1,3 @@
"""Hermes Hub UI Views Package.""" """Hermes Hub UI Views Package."""
from __future__ import annotations from __future__ import annotations

View file

@ -1,4 +1,5 @@
"""Hermes Hub — About View (О программе).""" """Hermes Hub — About View (О программе)."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
@ -83,15 +84,27 @@ class AboutView(ctk.CTkFrame):
p1 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) p1 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
p1.grid(row=0, column=0, padx=4, sticky="nsew") p1.grid(row=0, column=0, padx=4, sticky="nsew")
ctk.CTkLabel(p1, text="🛡️ Fail-Closed", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2)) ctk.CTkLabel(p1, text="🛡️ Fail-Closed", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
ctk.CTkLabel(p1, text="Защита credentials и квот", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 8)) pady=(8, 2)
)
ctk.CTkLabel(p1, text="Защита credentials и квот", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
pady=(0, 8)
)
p2 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) p2 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
p2.grid(row=0, column=1, padx=4, sticky="nsew") p2.grid(row=0, column=1, padx=4, sticky="nsew")
ctk.CTkLabel(p2, text="🔄 Auto Failover", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2)) ctk.CTkLabel(p2, text="🔄 Auto Failover", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
ctk.CTkLabel(p2, text="Мгновенное переключение ролей", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 8)) pady=(8, 2)
)
ctk.CTkLabel(
p2, text="Мгновенное переключение ролей", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
).pack(pady=(0, 8))
p3 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) p3 = ctk.CTkFrame(pillars_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
p3.grid(row=0, column=2, padx=4, sticky="nsew") p3.grid(row=0, column=2, padx=4, sticky="nsew")
ctk.CTkLabel(p3, text="👥 Auto Assignment", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2)) ctk.CTkLabel(p3, text="👥 Auto Assignment", font=Theme.font_subheading(), text_color=Theme.TEXT_PRIMARY).pack(
ctk.CTkLabel(p3, text="Интеллектуальное распределение", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 8)) pady=(8, 2)
)
ctk.CTkLabel(
p3, text="Интеллектуальное распределение", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
).pack(pady=(0, 8))

View file

@ -0,0 +1,75 @@
"""Empirical router telemetry and local host measurements."""
from __future__ import annotations
from typing import Any, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import HubMetricCard, SectionHeader
from antigravity_provider.router.ui.theme import Theme
class AnalyticsView(ctk.CTkFrame):
def __init__(self, master: Any, **kwargs):
super().__init__(master=master, fg_color="transparent", **kwargs)
SectionHeader(
self,
title="Аналитика",
subtitle="Только собственные измерения Hermes Router",
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
grid = ctk.CTkFrame(self, fg_color="transparent")
grid.pack(fill="x", padx=Theme.PAGE_PAD_X)
for column in range(3):
grid.grid_columnconfigure(column, weight=1)
definitions = (
("calls", "Вызовы", "◎"),
("latency", "P50 задержка", "⌁"),
("errors", "Доля ошибок", "△"),
("tokens", "Токены", "◇"),
("failovers", "Переключения", "⇄"),
("cost", "Стоимость", "$"),
)
self.cards = {}
for index, (key, title, icon) in enumerate(definitions):
card = HubMetricCard(grid, title, "Н/Д", "нет измерений", icon=icon)
card.grid(row=index // 3, column=index % 3, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew")
self.cards[key] = card
self.note = ctk.CTkLabel(
self,
text="Вызовы измерены Hermes Router; аппаратные показатели — локально через psutil. Внешний SLA не заявляется.",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
)
self.note.pack(anchor="w", padx=Theme.PAGE_PAD_X, pady=Theme.SPACE_LG)
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
telemetry = dict(snapshot.metrics.get("telemetry") or {})
global_telemetry = dict(telemetry.get("global") or {})
if not telemetry.get("has_data"):
for card in self.cards.values():
card.val_label.configure(text="Н/Д")
card.sub_label.configure(text="нет измерений")
return
values = {
"calls": str(global_telemetry.get("total_calls", "Н/Д")),
"latency": f"{global_telemetry['latency_p50_ms']:.0f} мс"
if global_telemetry.get("latency_p50_ms") is not None
else "Н/Д",
"errors": f"{global_telemetry['error_rate']:.1%}"
if global_telemetry.get("error_rate") is not None
else "Н/Д",
"tokens": str(global_telemetry.get("total_tokens"))
if global_telemetry.get("total_tokens") is not None
else "Н/Д",
"failovers": str(global_telemetry.get("failovers_count", 0)),
"cost": f"${global_telemetry['total_cost_usd']:.4f}"
if global_telemetry.get("total_cost_usd") is not None
else "Н/Д",
}
for key, value in values.items():
self.cards[key].val_label.configure(text=value)
self.cards[key].sub_label.configure(text="own_measurement")

View file

@ -1,36 +1,120 @@
"""Snapshot-driven overview of readiness, routes and actionable account warnings.""" """Approved Hermes Hub overview layout backed only by contract data."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Callable, Dict, Optional from typing import Any, Callable, Dict, Iterable, Optional
import customtkinter as ctk import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import ActionButton, EmptyState, HubCard, HubMetricCard, SectionHeader from antigravity_provider.router.ui.components import HubCard
from antigravity_provider.router.ui.theme import Theme from antigravity_provider.router.ui.theme import Theme
class _SummaryRow(HubCard): class _FlowCard(HubCard):
def __init__(self, master: Any, title: str = "", detail: str = ""): def __init__(self, master: Any, **kwargs):
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE) kwargs.setdefault("corner_radius", Theme.RADIUS_MD)
self.title = ctk.CTkLabel(self, text=title, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY) kwargs.setdefault("border_color", Theme.BORDER)
self.title.pack(side="left", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) kwargs.setdefault("height", 76)
self.detail = ctk.CTkLabel(self, text=detail, font=Theme.font_caption(), text_color=Theme.TEXT_MUTED) super().__init__(master, **kwargs)
self.detail.pack(side="right", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) self.pack_propagate(False)
self.title = ctk.CTkLabel(self, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(anchor="w", padx=Theme.SPACE_SM, pady=(Theme.SPACE_SM, 0))
self.subtitle = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.subtitle.pack(anchor="w", padx=Theme.SPACE_SM, pady=2)
self.meta = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.meta.pack(anchor="w", padx=Theme.SPACE_SM, pady=(0, Theme.SPACE_SM))
def update_row(self, title: str, detail: str, status: str = "unknown") -> None: def update_card(self, title: str, subtitle: str, meta: str, status: str = "unknown") -> None:
self.title.configure(text=title)
self.subtitle.configure(text=subtitle)
self.meta.configure(text=meta)
colors = {
"healthy": Theme.STATUS_HEALTHY,
"warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR,
"active": Theme.BORDER_ACCENT,
}
self.configure(border_color=colors.get(status, Theme.BORDER))
class _KpiCard(HubCard):
def __init__(self, master: Any, title: str, value: str, subtext: str, icon: str):
super().__init__(master, corner_radius=Theme.RADIUS_SM)
header = ctk.CTkFrame(self, fg_color="transparent")
header.pack(fill="x", padx=Theme.SPACE_SM, pady=(Theme.SPACE_SM, 0))
ctk.CTkLabel(header, text=icon, font=Theme.font_micro(), text_color=Theme.TEXT_ACCENT).pack(side="left")
ctk.CTkLabel(header, text=title.upper(), font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
side="left", padx=Theme.SPACE_XS
)
value_row = ctk.CTkFrame(self, fg_color="transparent")
value_row.pack(fill="x", padx=Theme.SPACE_SM, pady=(0, Theme.SPACE_SM))
self.val_label = ctk.CTkLabel(value_row, text=value, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.val_label.pack(side="left")
self.sub_label = ctk.CTkLabel(value_row, text=subtext, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.sub_label.pack(side="right")
class _StatusRow(ctk.CTkFrame):
def __init__(self, master: Any):
super().__init__(master, fg_color="transparent")
self.dot = ctk.CTkLabel(self, text="●", width=14, font=Theme.font_micro())
self.dot.pack(side="left")
self.title = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(side="left", padx=Theme.SPACE_XS)
self.detail = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.detail.pack(side="right")
def update_row(self, title: str, detail: str, status: str) -> None:
self.title.configure(text=title) self.title.configure(text=title)
self.detail.configure(text=detail) self.detail.configure(text=detail)
color = ( color = (
Theme.COLOR_POSITIVE Theme.STATUS_HEALTHY
if status == "healthy" if status == "healthy"
else (Theme.COLOR_CAUTION if status in {"warning", "quota_low", "auth_expired"} else Theme.COLOR_NEUTRAL) else Theme.STATUS_WARNING
if status == "warning"
else Theme.STATUS_ERROR
if status == "error"
else Theme.STATUS_DISABLED
) )
self.configure(border_color=color) self.dot.configure(text_color=color)
class _EventRow(ctk.CTkFrame):
def __init__(self, master: Any):
super().__init__(master, fg_color="transparent")
self.time = ctk.CTkLabel(self, width=58, text="", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED)
self.time.pack(side="left")
self.dot = ctk.CTkLabel(self, width=18, text="●", font=Theme.font_micro())
self.dot.pack(side="left")
self.message = ctk.CTkLabel(self, text="", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY)
self.message.pack(side="left", fill="x", expand=True, padx=Theme.SPACE_XS)
self.tag = ctk.CTkLabel(
self,
text="",
font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY,
fg_color=Theme.SURFACE_MUTED,
corner_radius=Theme.RADIUS_PILL,
)
self.tag.pack(side="right", padx=Theme.SPACE_XS)
def update_event(self, event: Any) -> None:
level = str(getattr(event, "level", "info"))
color = {
"success": Theme.STATUS_HEALTHY,
"warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR,
}.get(level, Theme.STATUS_INFO)
self.time.configure(text=str(getattr(event, "timestamp", "Н/Д")))
self.dot.configure(text_color=color)
self.message.configure(text=str(getattr(event, "message", "Событие без описания")))
self.tag.configure(text=f" {getattr(event, 'category', 'system')} ")
class DashboardView(ctk.CTkFrame): class DashboardView(ctk.CTkFrame):
"""Dense overview matching the approved composition without fictional metrics."""
def __init__( def __init__(
self, self,
master: Any, master: Any,
@ -42,144 +126,307 @@ class DashboardView(ctk.CTkFrame):
super().__init__(master=master, fg_color="transparent", **kwargs) super().__init__(master=master, fg_color="transparent", **kwargs)
self.on_navigate = on_navigate self.on_navigate = on_navigate
self.on_action = on_action self.on_action = on_action
self._provider_rows: Dict[str, _SummaryRow] = {} self._provider_cards: Dict[str, _FlowCard] = {}
self._route_rows: Dict[str, _SummaryRow] = {} self._agent_cards: Dict[str, _FlowCard] = {}
self._alert_rows: Dict[str, _SummaryRow] = {} self._provider_status_rows: Dict[str, _StatusRow] = {}
self._event_rows: list[_EventRow] = []
self._build() self._build()
def _build(self) -> None: def _build(self) -> None:
SectionHeader( self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent", corner_radius=0)
self, self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=Theme.PAGE_PAD_Y)
title="Обзор Hermes Hub",
subtitle="Кто работает сейчас, через какой маршрут и где требуется внимание",
action_text="Обновить",
action_cmd=lambda: self.on_action and self.on_action("refresh_data", {}),
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
self.snapshot_freshness = ctk.CTkLabel( self.snapshot_freshness = ctk.CTkLabel(
self.scroll, self.scroll,
text="Snapshot: Н/Д", text="Snapshot: Н/Д",
font=Theme.font_caption(), font=Theme.font_micro(),
text_color=Theme.TEXT_MUTED, text_color=Theme.TEXT_MUTED,
anchor="w", anchor="w",
) )
self.snapshot_freshness.pack(fill="x", pady=(0, Theme.SPACE_SM)) self.snapshot_freshness.pack(fill="x", pady=(0, Theme.SPACE_XS))
metrics = ctk.CTkFrame(self.scroll, fg_color="transparent") self.metrics = ctk.CTkFrame(self.scroll, fg_color="transparent")
metrics.pack(fill="x", pady=(0, Theme.SECTION_GAP)) self.metrics.pack(fill="x", pady=(0, Theme.SPACE_SM))
for column in range(4): for column in range(5):
metrics.grid_columnconfigure(column, weight=1) self.metrics.grid_columnconfigure(column, weight=1)
self.system_metric = HubMetricCard(metrics, "Состояние", "Н/Д", "Ожидание snapshot", icon="◈", accent=True) self.quota_metric = _KpiCard(self.metrics, "Квоты", "Н/Д", "нет измерения", icon="◔")
self.provider_metric = HubMetricCard(metrics, "Провайдеры", "0/0", "доступно", icon="◉") self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н/Д", "измерения", icon="◎")
self.account_metric = HubMetricCard(metrics, "Аккаунты", "0/0", "подключено", icon="◎") self.agents_metric = _KpiCard(self.metrics, "Агенты онлайн", "0/0", "готовые роли", icon="◇")
self.role_metric = HubMetricCard(metrics, "Роли", "0/0", "готово", icon="◇") self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н/Д", "P50", icon="⌁")
self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н/Д", "failover", icon="⇄")
for index, metric in enumerate( for index, metric in enumerate(
(self.system_metric, self.provider_metric, self.account_metric, self.role_metric) (self.quota_metric, self.calls_metric, self.agents_metric, self.latency_metric, self.failover_metric)
): ):
metric.grid(row=0, column=index, padx=Theme.SPACE_XS, sticky="nsew") metric.grid(row=0, column=index, padx=Theme.SPACE_XS, sticky="nsew")
columns = ctk.CTkFrame(self.scroll, fg_color="transparent") body = ctk.CTkFrame(self.scroll, fg_color="transparent")
columns.pack(fill="x") body.pack(fill="x")
columns.grid_columnconfigure((0, 1), weight=1) body.grid_columnconfigure(0, weight=4)
self.providers_card = self._section(columns, "Провайдеры", 0, 0) body.grid_columnconfigure(1, weight=1)
self.routes_card = self._section(columns, "Активные маршруты", 0, 1)
self.alerts_card = self._section(columns, "Требует внимания", 1, 0, columnspan=2)
self.events_gap = EmptyState(
self.scroll,
title="Последние события: Н/Д",
message="Журнал событий не входит в HubSnapshot; выдуманные события не отображаются.",
)
self.events_gap.pack(fill="x", pady=(Theme.SECTION_GAP, 0))
def _section(self, master: Any, title: str, row: int, column: int, columnspan: int = 1) -> ctk.CTkFrame: route_card = HubCard(body)
card = HubCard(master) route_card.grid(row=0, column=0, sticky="nsew", padx=(0, Theme.SPACE_SM))
card.grid( ctk.CTkLabel(
row=row, column=column, columnspan=columnspan, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew" route_card,
text="Маршрутизация запросов",
font=Theme.font_heading(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM))
flow = ctk.CTkFrame(route_card, fg_color="transparent")
flow.pack(fill="both", expand=True, padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
flow.grid_columnconfigure(0, weight=3)
flow.grid_columnconfigure(1, weight=1)
flow.grid_columnconfigure(2, weight=3)
flow.grid_columnconfigure(3, weight=1)
flow.grid_columnconfigure(4, weight=3)
self.provider_column = ctk.CTkFrame(flow, fg_color="transparent")
self.provider_column.grid(row=0, column=0, sticky="nsew")
ctk.CTkLabel(flow, text="→", font=Theme.font_metric(), text_color=Theme.TEXT_ACCENT).grid(row=0, column=1)
self.orchestrator_card = _FlowCard(flow, border_color=Theme.BORDER_ACCENT)
self.orchestrator_card.grid(row=0, column=2, sticky="ew", padx=Theme.SPACE_XS)
ctk.CTkLabel(flow, text="→", font=Theme.font_metric(), text_color=Theme.TEXT_ACCENT).grid(row=0, column=3)
self.agent_column = ctk.CTkFrame(flow, fg_color="transparent")
self.agent_column.grid(row=0, column=4, sticky="nsew")
self.context_card = _FlowCard(flow)
self.context_card.grid(row=1, column=2, sticky="ew", padx=Theme.SPACE_XS, pady=(Theme.SPACE_SM, 0))
self.context_card.update_card("Хранилище контекста", "Постоянная память", "Состояние: Н/Д")
self.realtime = HubCard(body)
self.realtime.grid(row=0, column=1, sticky="nsew")
ctk.CTkLabel(
self.realtime,
text="Статус в реальном времени",
font=Theme.font_heading(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM))
ctk.CTkLabel(self.realtime, text="ПРОВАЙДЕРЫ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
anchor="w", padx=Theme.CARD_PAD_X
) )
ctk.CTkLabel(card, text=title, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack( self.provider_status = ctk.CTkFrame(self.realtime, fg_color="transparent")
anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM) self.provider_status.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_XS)
ctk.CTkLabel(
self.realtime, text="СИСТЕМНЫЕ ПОКАЗАТЕЛИ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, 0))
self.host_metrics = ctk.CTkLabel(
self.realtime,
text="CPU Н/Д\nПамять Н/Д\nДиск Н/Д\nСеть Н/Д",
justify="left",
anchor="w",
font=Theme.font_caption(),
text_color=Theme.TEXT_SECONDARY,
) )
body = ctk.CTkFrame(card, fg_color="transparent") self.host_metrics.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
body.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y)) ctk.CTkLabel(
return body self.realtime,
text="Локальные измерения • psutil",
wraplength=210,
justify="left",
font=Theme.font_micro(),
text_color=Theme.TEXT_MUTED,
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
self.events_card = HubCard(self.scroll)
self.events_card.pack(fill="x", pady=(Theme.SPACE_SM, 0))
ctk.CTkLabel(
self.events_card,
text="Последние события",
font=Theme.font_heading(),
text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
self.events_body = ctk.CTkFrame(self.events_card, fg_color="transparent")
self.events_body.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
self.events_empty = ctk.CTkLabel(
self.events_body,
text="События: Н/Д",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
)
self.events_empty.pack(anchor="w", pady=Theme.SPACE_SM)
@staticmethod @staticmethod
def _sync_rows(container: Any, cache: Dict[str, _SummaryRow], rows: Dict[str, tuple[str, str, str]]) -> None: def _quota_text(snapshot: HubSnapshot, provider_id: str) -> str:
profiles = snapshot.profiles_by_provider.get(provider_id, [])
measured: list[float] = []
for profile in profiles:
quota = snapshot.quotas.get(profile.profile_id)
if not quota or getattr(quota, "is_estimated", True):
continue
measured.extend(
float(bucket.remaining_percent) for bucket in quota.buckets if bucket.remaining_percent is not None
)
if not measured:
return "Квота: Н/Д"
return f"Корзины: {len(measured)} измерено"
@staticmethod
def _sync_cards(
container: Any,
cache: Dict[str, _FlowCard],
items: Iterable[tuple[str, str, str, str, str]],
) -> None:
prepared = list(items)
live = {key for key, *_rest in prepared}
for key in list(cache): for key in list(cache):
if key not in rows: if key not in live:
cache.pop(key).destroy() cache.pop(key).destroy()
for key, (title, detail, status) in rows.items(): for index, (key, title, subtitle, meta, status) in enumerate(prepared):
if key not in cache: card = cache.get(key)
cache[key] = _SummaryRow(container) if card is None:
cache[key].pack(fill="x", pady=Theme.SPACE_XS) card = _FlowCard(container)
cache[key].update_row(title, detail, status) cache[key] = card
card.update_card(title, subtitle, meta, status)
card.pack(fill="x", pady=Theme.SPACE_XS)
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None: def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot): if not isinstance(snapshot, HubSnapshot):
return return
if snapshot.is_stale: self.snapshot_freshness.configure(
self.snapshot_freshness.configure( text=f"{'⚠ данные устарели' if snapshot.is_stale else '● данные актуальны'} • snapshot #{snapshot.seq}",
text=f"⚠ Snapshot #{snapshot.seq}: данные устарели", text_color=Theme.STATUS_WARNING if snapshot.is_stale else Theme.STATUS_HEALTHY,
text_color=Theme.STATUS_WARNING, )
readiness = snapshot.readiness
telemetry = dict(snapshot.metrics.get("telemetry") or {})
global_telemetry = dict(telemetry.get("global") or {})
provider_telemetry = dict(telemetry.get("by_provider") or {})
role_telemetry = dict(telemetry.get("by_role") or {})
has_telemetry = bool(telemetry.get("has_data"))
self.calls_metric.val_label.configure(text=str(global_telemetry.get("total_calls")) if has_telemetry else "Н/Д")
self.latency_metric.val_label.configure(
text=f"{global_telemetry['latency_p50_ms']:.0f} мс"
if has_telemetry and global_telemetry.get("latency_p50_ms") is not None
else "Н/Д"
)
self.failover_metric.val_label.configure(
text=str(global_telemetry.get("failovers_count")) if has_telemetry else "Н/Д"
)
active_calls = snapshot.metrics.get("active_calls_total")
self.calls_metric.sub_label.configure(
text=f"активно: {active_calls}" if isinstance(active_calls, int) else "own_measurement"
)
self.agents_metric.val_label.configure(text=f"{readiness.roles_ready_count}/{readiness.total_roles}")
measured_buckets = sum(
1
for quota in snapshot.quotas.values()
if not getattr(quota, "is_estimated", True)
for bucket in quota.buckets
if bucket.remaining_percent is not None
)
self.quota_metric.val_label.configure(text=str(measured_buckets) if measured_buckets else "Н/Д")
self.quota_metric.sub_label.configure(text="измеренных корзин" if measured_buckets else "нет измерения")
providers = list(snapshot.providers)[:3]
def provider_activity(provider_id: str, online: int, connected: int) -> str:
measured = dict(provider_telemetry.get(provider_id) or {})
share = measured.get("call_share")
calls = measured.get("total_calls")
if share is not None and calls is not None:
return f"{share:.0%} потока • {calls} вызовов"
return f"{online}/{connected} онлайн"
self._sync_cards(
self.provider_column,
self._provider_cards,
(
(
provider.provider_id,
provider.provider_name,
provider_activity(
provider.provider_id,
provider.online_count,
provider.connected_count,
),
self._quota_text(snapshot, provider.provider_id),
"healthy" if provider.online_count else "warning",
)
for provider in providers
),
)
orchestrator = next((agent for agent in snapshot.agents if agent.is_main_orchestrator), None)
if orchestrator:
self.orchestrator_card.update_card(
"Главный оркестратор",
f"{orchestrator.provider_display_name} • {orchestrator.model}",
f"{orchestrator.account_identity or 'Аккаунт: Н/Д'} • {orchestrator.status_label_ru}",
"active" if orchestrator.is_active else "warning",
) )
else: else:
self.snapshot_freshness.configure( self.orchestrator_card.update_card("Главный оркестратор", "Не назначен", "Аккаунт: Н/Д", "warning")
text=f"Snapshot #{snapshot.seq}: актуальные данные", agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:3]
text_color=Theme.TEXT_MUTED, self._sync_cards(
) self.agent_column,
readiness = snapshot.readiness self._agent_cards,
self.system_metric.val_label.configure(text=readiness.title_ru) (
self.system_metric.sub_label.configure(text=readiness.summary_ru) (
self.provider_metric.val_label.configure(text=f"{readiness.providers_ready_count}/{readiness.total_providers}") agent.role_id,
self.account_metric.val_label.configure(text=f"{readiness.accounts_connected_count}/{readiness.total_accounts}") agent.role_name_ru,
self.role_metric.val_label.configure(text=f"{readiness.roles_ready_count}/{readiness.total_roles}") f"{agent.provider_display_name} • {agent.model}",
(
f"{agent.active_quota_label or 'Квота: Н/Д'}"
+ (
f" • {role_telemetry[agent.role_id]['total_calls']} вызовов"
if role_telemetry.get(agent.role_id, {}).get("has_data")
else ""
)
),
"healthy" if agent.is_active else "warning",
)
for agent in agents
),
)
provider_rows = { live_provider_ids = {provider.provider_id for provider in snapshot.providers}
provider.provider_id: ( for provider_id in list(self._provider_status_rows):
if provider_id not in live_provider_ids:
self._provider_status_rows.pop(provider_id).destroy()
for provider in snapshot.providers:
row = self._provider_status_rows.get(provider.provider_id)
if row is None:
row = _StatusRow(self.provider_status)
row.pack(fill="x", pady=Theme.SPACE_XS)
self._provider_status_rows[provider.provider_id] = row
measured = dict(provider_telemetry.get(provider.provider_id) or {})
latency = measured.get("latency_p50_ms")
row.update_row(
provider.provider_name, provider.provider_name,
f"{provider.online_count}/{provider.connected_count} онлайн • {provider.auth_required_count} требуют входа", (
f"{provider.online_count}/{provider.connected_count} • {latency:.0f} мс"
if latency is not None
else f"{provider.online_count}/{provider.connected_count}"
),
"healthy" if provider.online_count else "warning", "healthy" if provider.online_count else "warning",
) )
for provider in snapshot.providers
}
self._sync_rows(self.providers_card, self._provider_rows, provider_rows)
route_rows = {} host = dict(snapshot.metrics.get("host") or {})
for role_id, pipeline in snapshot.routing.items(): if host.get("has_data"):
active = next((node for node in pipeline.nodes if node.is_active), None) network_total = sum(
detail = f"{active.provider} • {active.model}" if active else "Активный узел: Н/Д" value for value in (host.get("net_bytes_sent"), host.get("net_bytes_recv")) if isinstance(value, int)
route_rows[role_id] = (pipeline.role_name_ru, detail, "healthy" if active else "warning") )
self._sync_rows(self.routes_card, self._route_rows, route_rows) network_text = f"{network_total / (1024 * 1024):.0f} МБ" if network_total else "Н/Д"
self.host_metrics.configure(
alerts: Dict[str, tuple[str, str, str]] = {} text=(
for profile in snapshot.all_profiles.values(): f"CPU {host.get('cpu_percent', 'Н/Д')}%\n"
if profile.auth_state == "AUTH_EXPIRED": f"Память {host.get('memory_percent', 'Н/Д')}%\n"
alerts[f"auth:{profile.profile_id}"] = ( f"Диск {host.get('disk_percent', 'Н/Д')}%\n"
profile.account_identity or profile.profile_id, f"Сеть {network_text}"
"Авторизация истекла",
"auth_expired",
) )
quota = snapshot.quotas.get(profile.profile_id) )
if quota and not getattr(quota, "is_estimated", True): else:
low = [ self.host_metrics.configure(text="CPU Н/Д\nПамять Н/Д\nДиск Н/Д\nСеть Н/Д")
bucket
for bucket in quota.buckets
if bucket.remaining_percent is not None and bucket.remaining_percent <= 20
]
if low:
alerts[f"quota:{profile.profile_id}"] = (
profile.account_identity or profile.profile_id,
", ".join(f"{bucket.display_name}: {bucket.remaining_percent:.0f}%" for bucket in low),
"quota_low",
)
for index, warning in enumerate(readiness.warnings):
alerts[f"warning:{index}"] = ("Системное предупреждение", warning, "warning")
if not alerts:
alerts["none"] = ("Критичных предупреждений нет", "По данным текущего snapshot", "healthy")
self._sync_rows(self.alerts_card, self._alert_rows, alerts)
def _navigate(self, target: str) -> None: def update_events(self, events: Iterable[Any]) -> None:
if self.on_navigate: items = list(events)[:5]
self.on_navigate(target) while len(self._event_rows) < len(items):
row = _EventRow(self.events_body)
row.pack(fill="x", pady=Theme.SPACE_XS)
self._event_rows.append(row)
for index, row in enumerate(self._event_rows):
if index < len(items):
row.update_event(items[index])
row.pack(fill="x", pady=Theme.SPACE_XS)
else:
row.pack_forget()
if items:
self.events_empty.pack_forget()
else:
self.events_empty.pack(anchor="w", pady=Theme.SPACE_SM)

View file

@ -1,33 +1,126 @@
"""Event-log screen with honest handling of the current snapshot contract gap.""" """Keyed event timeline populated by the application action layer."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional from typing import Any, Iterable, Optional
import customtkinter as ctk import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import EmptyState, SectionHeader from antigravity_provider.router.ui.components import FilterButton, HubCard, SearchField, SectionHeader
from antigravity_provider.router.ui.theme import Theme from antigravity_provider.router.ui.theme import Theme
class _LogRow(HubCard):
def __init__(self, master: Any):
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE)
self.time = ctk.CTkLabel(self, text="", width=70, font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED)
self.time.pack(side="left", padx=(Theme.CARD_PAD_X, 0), pady=Theme.SPACE_SM)
self.dot = ctk.CTkLabel(self, text="●", width=18, font=Theme.font_micro())
self.dot.pack(side="left")
self.message = ctk.CTkLabel(self, text="", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY)
self.message.pack(side="left", fill="x", expand=True, padx=Theme.SPACE_SM)
self.category = ctk.CTkLabel(
self,
text="",
font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY,
fg_color=Theme.SURFACE_MUTED,
corner_radius=Theme.RADIUS_PILL,
)
self.category.pack(side="right", padx=Theme.CARD_PAD_X)
def update_event(self, event: Any) -> None:
level = str(getattr(event, "level", "info"))
color = {
"success": Theme.STATUS_HEALTHY,
"warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR,
}.get(level, Theme.STATUS_INFO)
self.time.configure(text=str(getattr(event, "timestamp", "Н/Д")))
self.dot.configure(text_color=color)
self.message.configure(text=str(getattr(event, "message", "Н/Д")))
self.category.configure(text=f" {getattr(event, 'category', 'system')} ")
class LogsView(ctk.CTkFrame): class LogsView(ctk.CTkFrame):
def __init__(self, master: Any, **kwargs): def __init__(self, master: Any, **kwargs):
super().__init__(master=master, fg_color="transparent", **kwargs) super().__init__(master=master, fg_color="transparent", **kwargs)
self._events: list[Any] = []
self._rows: list[_LogRow] = []
self._query = ""
self._level = "Все уровни"
SectionHeader( SectionHeader(
self, self,
title="Журнал событий", title="Журнал событий",
subtitle="Только события, переданные через единый snapshot-контракт", subtitle="Реальные события Hermes Hub с уровнями и категориями",
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM)) ).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
self.empty = EmptyState( toolbar = ctk.CTkFrame(self, fg_color="transparent")
self, toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, Theme.SPACE_SM))
title="Журнал: Н/Д", self.search = SearchField(toolbar, placeholder_text="Поиск по журналу…", width=320)
message=( self.search.pack(side="left", padx=(0, Theme.SPACE_SM))
"HubSnapshot пока не содержит события журнала. Экран не читает файлы и backend " self.search.bind("<KeyRelease>", lambda _event: self._set_query(self.search.get()))
"напрямую и не показывает выдуманную историю." self.level = FilterButton(
), toolbar,
values=["Все уровни", "Информация", "Успех", "Предупреждение", "Ошибка"],
command=self._set_level,
width=170,
) )
self.empty.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=Theme.SPACE_XL) self.level.pack(side="left")
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
self.empty = ctk.CTkLabel(
self.scroll,
text="События: Н/Д",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
)
def _set_query(self, value: str) -> None:
self._query = value.strip().lower()
self._render()
def _set_level(self, value: str) -> None:
self._level = value
self._render()
def _filtered(self) -> list[Any]:
levels = {
"Информация": "info",
"Успех": "success",
"Предупреждение": "warning",
"Ошибка": "error",
}
expected = levels.get(self._level)
result = []
for event in self._events:
if expected and str(getattr(event, "level", "info")) != expected:
continue
haystack = f"{getattr(event, 'category', '')} {getattr(event, 'message', '')}".lower()
if self._query and self._query not in haystack:
continue
result.append(event)
return result
def _render(self) -> None:
events = self._filtered()
while len(self._rows) < len(events):
row = _LogRow(self.scroll)
self._rows.append(row)
for index, row in enumerate(self._rows):
if index < len(events):
row.update_event(events[index])
row.pack(fill="x", pady=Theme.SPACE_XS)
else:
row.pack_forget()
if events:
self.empty.pack_forget()
else:
self.empty.pack(anchor="w", pady=Theme.SPACE_XL)
def update_events(self, events: Iterable[Any]) -> None:
self._events = list(events)
self._render()
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None: def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot): if not isinstance(snapshot, HubSnapshot):

View file

@ -0,0 +1,83 @@
"""Truthful multi-bucket quota overview driven by HubSnapshot."""
from __future__ import annotations
from typing import Any, Dict, Optional
import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.components import EmptyState, HubCard, QuotaBar, SectionHeader
from antigravity_provider.router.ui.theme import Theme
class _QuotaRow(HubCard):
def __init__(self, master: Any):
super().__init__(master, corner_radius=Theme.RADIUS_SM)
header = ctk.CTkFrame(self, fg_color="transparent")
header.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
self.title = ctk.CTkLabel(header, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(side="left")
self.source = ctk.CTkLabel(header, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.source.pack(side="right")
self.identity = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X)
self.bar = QuotaBar(self, label="Остаток")
self.bar.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
self.reset = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.reset.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
def update_bucket(self, profile: Any, quota: Any, bucket: Any) -> None:
remaining = getattr(bucket, "remaining_percent", None)
ratio = float(remaining) / 100.0 if remaining is not None else None
detail = bucket.formatted_remaining() if hasattr(bucket, "formatted_remaining") else "Н/Д"
reset = bucket.formatted_reset() if hasattr(bucket, "formatted_reset") else None
estimated = bool(getattr(quota, "is_estimated", True))
self.title.configure(text=f"{bucket.display_name} • {profile.provider_display_name}")
self.identity.configure(text=profile.account_identity or profile.display_name)
self.source.configure(text="оценка" if estimated else str(getattr(quota, "source", "измерено")))
self.bar.set_value(ratio, detail)
reason = getattr(quota, "unavailable_reason", None)
self.reset.configure(text=reason or reset or "Сброс: Н/Д")
class QuotasView(ctk.CTkFrame):
def __init__(self, master: Any, **kwargs):
super().__init__(master=master, fg_color="transparent", **kwargs)
self._rows: Dict[str, _QuotaRow] = {}
SectionHeader(
self,
title="Квоты и лимиты",
subtitle="Независимые корзины провайдеров; отсутствие измерения не считается нулём",
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
self.empty = EmptyState(
self.scroll,
title="Квоты: Н/Д",
message="Провайдеры ещё не опубликовали ни одной квотной корзины.",
)
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
incoming: list[tuple[str, Any, Any, Any]] = []
for profile in snapshot.all_profiles.values():
quota = snapshot.quotas.get(profile.profile_id)
for bucket in list(getattr(quota, "buckets", None) or []):
incoming.append((f"{profile.profile_id}:{bucket.id}", profile, quota, bucket))
live = {key for key, *_rest in incoming}
for key in list(self._rows):
if key not in live:
self._rows.pop(key).destroy()
for key, profile, quota, bucket in incoming:
row = self._rows.get(key)
if row is None:
row = _QuotaRow(self.scroll)
row.pack(fill="x", pady=Theme.SPACE_XS)
self._rows[key] = row
row.update_bucket(profile, quota, bucket)
if incoming:
self.empty.pack_forget()
else:
self.empty.pack(fill="x", pady=Theme.SPACE_XL)

View file

@ -12,7 +12,13 @@ from antigravity_provider.version import CHANNEL, __version__
class SettingsView(ctk.CTkFrame): class SettingsView(ctk.CTkFrame):
def __init__(self, master: Any, on_action: Optional[Callable] = None, **kwargs): def __init__(
self,
master: Any,
on_action: Optional[Callable] = None,
theme_name: str = "dark",
**kwargs,
):
super().__init__(master=master, fg_color="transparent", **kwargs) super().__init__(master=master, fg_color="transparent", **kwargs)
self.on_action = on_action self.on_action = on_action
SectionHeader( SectionHeader(
@ -24,6 +30,24 @@ class SettingsView(ctk.CTkFrame):
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM)) ).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
scroll = ctk.CTkScrollableFrame(self, fg_color="transparent") scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y)) scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
appearance = self._section(scroll, "Оформление")
theme_row = ctk.CTkFrame(appearance, fg_color="transparent")
theme_row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
ctk.CTkLabel(
theme_row,
text="Цветовая схема",
font=Theme.font_body(),
text_color=Theme.TEXT_PRIMARY,
).pack(side="left")
self.theme = ctk.CTkOptionMenu(
theme_row,
values=list(Theme.SCHEME_LABELS.values()),
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.SECONDARY,
text_color=Theme.TEXT_PRIMARY,
)
self.theme.set(Theme.SCHEME_LABELS.get(theme_name, Theme.SCHEME_LABELS["dark"]))
self.theme.pack(side="right")
routing = self._section(scroll, "Маршрутизация и отказоустойчивость") routing = self._section(scroll, "Маршрутизация и отказоустойчивость")
self.affinity = self._switch(routing, "Сессионная привязка", True) self.affinity = self._switch(routing, "Сессионная привязка", True)
self.failover = self._switch(routing, "Автоматический failover", True) self.failover = self._switch(routing, "Автоматический failover", True)
@ -81,6 +105,10 @@ class SettingsView(ctk.CTkFrame):
if not self.on_action: if not self.on_action:
return return
intervals = {"Выкл": 0, "1 мин": 60, "5 мин": 300, "10 мин": 600, "30 мин": 1800} intervals = {"Выкл": 0, "1 мин": 60, "5 мин": 300, "10 мин": 600, "30 мин": 1800}
theme_key = next(
(key for key, label in Theme.SCHEME_LABELS.items() if label == self.theme.get()),
"dark",
)
self.on_action( self.on_action(
"save_settings", "save_settings",
{ {
@ -89,5 +117,6 @@ class SettingsView(ctk.CTkFrame):
"prefer_same_account_model_fallback": bool(self.same_account.get()), "prefer_same_account_model_fallback": bool(self.same_account.get()),
"quota_refresh_interval_label": self.interval.get(), "quota_refresh_interval_label": self.interval.get(),
"quota_refresh_interval_sec": intervals.get(self.interval.get(), 300), "quota_refresh_interval_sec": intervals.get(self.interval.get(), 300),
"theme": theme_key,
}, },
) )

View file

@ -41,7 +41,7 @@ class AgentCardWidget(HubCard):
self.role_lbl = ctk.CTkLabel(self.line1, text="—", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY) self.role_lbl = ctk.CTkLabel(self.line1, text="—", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.role_lbl.pack(side="left") self.role_lbl.pack(side="left")
self.orch_pill = ctk.CTkFrame(self.line1, fg_color="#3D3522", corner_radius=Theme.RADIUS_SM) self.orch_pill = ctk.CTkFrame(self.line1, fg_color=Theme.ACCENT_DIM, corner_radius=Theme.RADIUS_SM)
self.orch_pill_lbl = ctk.CTkLabel( self.orch_pill_lbl = ctk.CTkLabel(
self.orch_pill, text="👑 ЛИДЕР РОУТЕРА", font=Theme.font_micro(), text_color=Theme.ACCENT self.orch_pill, text="👑 ЛИДЕР РОУТЕРА", font=Theme.font_micro(), text_color=Theme.ACCENT
) )
@ -160,9 +160,9 @@ class AgentCardWidget(HubCard):
elif "opencode" in prov: elif "opencode" in prov:
self.prov_lbl.configure(text="OpenCode Go", text_color=Theme.PROVIDER_OPENCODE) self.prov_lbl.configure(text="OpenCode Go", text_color=Theme.PROVIDER_OPENCODE)
elif "claude" in prov or "anthropic" in prov: elif "claude" in prov or "anthropic" in prov:
self.prov_lbl.configure(text="Claude", text_color="#d97706") self.prov_lbl.configure(text="Claude", text_color=Theme.PROVIDER_CLAUDE)
elif "grok" in prov or "xai" in prov: elif "grok" in prov or "xai" in prov:
self.prov_lbl.configure(text="Grok", text_color="#3b82f6") self.prov_lbl.configure(text="Grok", text_color=Theme.PROVIDER_GROK)
else: else:
self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED) self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED)

View file

@ -0,0 +1,116 @@
"""Acceptance tests for the approved three-theme mockup redesign."""
from __future__ import annotations
import ast
from pathlib import Path
import re
from types import SimpleNamespace
import pytest
pytest.importorskip("customtkinter")
import customtkinter as ctk
from antigravity_provider.router.ui.components import AccountCardWidget
from antigravity_provider.router.ui.theme import Theme
@pytest.fixture(scope="module")
def ui_root():
root = ctk.CTk()
root.withdraw()
yield root
root.destroy()
@pytest.mark.unit
def test_every_restored_account_handler_has_a_ui_trigger() -> None:
required = {"test", "set_main", "set_orchestrator", "assign_role"}
configured = {action for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS}
assert configured == required
app_path = Path("src/antigravity_provider/router/hermes_hub_app.py")
tree = ast.parse(app_path.read_text(encoding="utf-8"))
handled = {
node.comparators[0].value
for node in ast.walk(tree)
if isinstance(node, ast.Compare)
and isinstance(node.left, ast.Name)
and node.left.id == "action"
and len(node.ops) == 1
and isinstance(node.ops[0], ast.Eq)
and len(node.comparators) == 1
and isinstance(node.comparators[0], ast.Constant)
and isinstance(node.comparators[0].value, str)
}
assert required <= handled
@pytest.mark.unit
def test_three_schemes_have_identical_token_coverage() -> None:
assert set(Theme.PALETTES) == {"dark", "hybrid", "light"}
token_sets = [set(palette) for palette in Theme.PALETTES.values()]
assert token_sets[0] == token_sets[1] == token_sets[2]
backgrounds = {palette["BG_WINDOW"] for palette in Theme.PALETTES.values()}
assert len(backgrounds) == 3
@pytest.mark.unit
def test_ui_colors_are_centralized_in_theme_tokens() -> None:
roots = [
Path("src/antigravity_provider/router/ui"),
Path("src/antigravity_provider/router/hermes_hub_app.py"),
]
offenders = []
for root in roots:
files = [root] if root.is_file() else list(root.rglob("*.py"))
for file in files:
if file.name == "theme.py":
continue
if re.search(r"#[0-9A-Fa-f]{3,8}", file.read_text(encoding="utf-8")):
offenders.append(str(file))
assert offenders == []
@pytest.mark.unit
def test_approved_mockup_numbers_are_not_shipped_as_placeholders() -> None:
source_files = list(Path("src/antigravity_provider/router/ui").rglob("*.py"))
source_files.append(Path("src/antigravity_provider/router/hermes_hub_app.py"))
source = "\n".join(file.read_text(encoding="utf-8") for file in source_files)
for fictional_value in ("78%", "09:00–21:00", "842 rps", "99.98%", "42 Мбит/с"):
assert fictional_value not in source
@pytest.mark.unit
def test_apply_scheme_updates_shared_semantic_aliases() -> None:
original = Theme.current_scheme
try:
for scheme in Theme.SCHEMES:
assert Theme.apply_scheme(scheme) == scheme
assert Theme.COLOR_POSITIVE == Theme.STATUS_HEALTHY
assert Theme.COLOR_CAUTION == Theme.STATUS_WARNING
assert Theme.COLOR_BRAND == Theme.ACCENT
finally:
Theme.apply_scheme(original)
@pytest.mark.ui
def test_restored_account_buttons_invoke_each_action(ui_root) -> None:
calls = []
card = AccountCardWidget(
ui_root,
"profile-1",
"user@example.test",
"OpenAI Codex",
on_action=lambda action, profile: calls.append((action, profile.profile_id)),
)
card.profile_model = SimpleNamespace(profile_id="profile-1")
try:
card.pack()
for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS:
card.action_buttons[action].invoke()
assert calls == [(action, "profile-1") for action, _label in AccountCardWidget.MANAGEMENT_ACTIONS]
finally:
card.destroy()

View file

@ -0,0 +1,54 @@
"""Manual screenshot harness for every tab in all approved color schemes."""
from __future__ import annotations
import sys
import time
from pathlib import Path
import pytest
pytest.importorskip("customtkinter")
pytest.importorskip("PIL")
from PIL import ImageGrab
from antigravity_provider.router.hermes_hub_app import HermesHubApp
def capture_all(output_dir: Path) -> list[Path]:
output_dir.mkdir(parents=True, exist_ok=True)
app = HermesHubApp()
app.geometry("1366x768+20+20")
app.deiconify()
app.lift()
app.attributes("-topmost", True)
for _index in range(20):
app.update()
time.sleep(0.04)
captured = []
try:
for scheme in ("dark", "hybrid", "light"):
app._apply_theme(scheme)
for view_name, _label, _icon in app._nav_items:
app._show_view(view_name)
app.update_idletasks()
app.update()
time.sleep(0.08)
left = app.winfo_rootx()
top = app.winfo_rooty()
target = output_dir / f"{view_name}-{scheme}.png"
ImageGrab.grab(bbox=(left, top, left + app.winfo_width(), top + app.winfo_height())).save(target)
captured.append(target)
finally:
app.attributes("-topmost", False)
app._shutting_down = True
app.destroy()
return captured
if __name__ == "__main__":
destination = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("artifacts/mockup-redesign")
for screenshot in capture_all(destination):
print(screenshot)