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