diff --git a/CODEX_UI_REDESIGN_REPORT.md b/CODEX_UI_REDESIGN_REPORT.md new file mode 100644 index 0000000..967756e --- /dev/null +++ b/CODEX_UI_REDESIGN_REPORT.md @@ -0,0 +1,129 @@ +# Hermes Hub UI redesign — фазы 2–6 + +Дата: 2026-08-21 + +Ветка: `codex/ui-redesign` + +INITIAL_BASE_SHA: `c6876e96b274b598ada964a6639b6c3ad038aebf` + +BASE_SHA после финального обязательного rebase на `origin/main`: `e8a404be035fa04b5f76e3e572c6539fba0e83e4`. Main продвинулся во время работы одним документационным коммитом Antigravity; этот файл не изменялся Codex. + +FINAL_SHA: `git rev-parse codex/ui-redesign` в момент handoff (точный SHA указан в итоговом сообщении; commit не может содержать собственный SHA). + +## Изменённые файлы + +- `src/antigravity_provider/router/hermes_hub_app.py` +- `src/antigravity_provider/router/ui/components.py` +- `src/antigravity_provider/router/ui/views/accounts_view.py` +- `src/antigravity_provider/router/ui/views/dashboard_view.py` +- `src/antigravity_provider/router/ui/views/health_view.py` +- `src/antigravity_provider/router/ui/views/logs_view.py` +- `src/antigravity_provider/router/ui/views/providers_view.py` +- `src/antigravity_provider/router/ui/views/routing_view.py` +- `src/antigravity_provider/router/ui/views/settings_view.py` +- `src/antigravity_provider/router/ui/views/team_view.py` +- `tests/test_ui_phase2_6.py` +- `CODEX_UI_REDESIGN_REPORT.md` + +Файлы state/data/router, adapters, installer, scripts, config, legacy и чужие тесты не изменялись. + +## Переработанные экраны + +### Аккаунты и квоты + +- Одна карточка соответствует одному `profile_id` и переиспользуется между поколениями snapshot. +- `QuotaBucketWidget` хранится по стабильному `QuotaBucket.id`; существующая корзина обновляется на месте. +- Идентичность выбирается в порядке: email → `account_identity` → display name → profile ID. +- `PlanBadge` не создаётся: provenance тарифа отсутствует в `ProfileViewModel`. +- Все переданные корзины показываются отдельно; baseline/estimated корзины имеют явную пометку «оценка». +- Неизвестный остаток и время сброса показываются как `Н/Д`. +- Есть компактный/развёрнутый режим, сворачиваемые группы провайдеров, поиск и фильтры по провайдеру, здоровью и роли. +- Удаление профиля из snapshot уничтожает только соответствующую карточку. + +### Обзор + +- Экран добавлен в основную навигацию. +- Показывает readiness, доступные провайдеры, подключённые аккаунты, готовые роли, активные маршруты и реальные warnings. +- Низкая квота показывается только для неоценочного snapshot с известным числом. +- Истёкшая авторизация показывается из `auth_state`. +- Выдуманные throughput/SLA/cost метрики отсутствуют. + +### Команда + +- Иерархия разделена на «Оркестратор» и «Роли и агенты». +- Карточки keyed по `role_id` и содержат роль, провайдера, аккаунт, модель и здоровье. +- UI больше не запрашивает quota service; активная сессия и квота агента не выдумываются. + +### Маршрутизация + +- Роли keyed по `role_id`, узлы цепочки — по `profile_id`. +- Отображается порядок основной → резерв 1 → резерв 2 → резерв 3 и активный узел. +- Показываются только доступные provider/model/status поля. +- Причина переключения честно обозначена как `Н/Д`. +- Редактирование остаётся кнопочным; drag-and-drop не добавлялся. + +### Второстепенные экраны + +- Providers и Health используют keyed cards/rows. +- Logs не читает backend или файлы напрямую: события отсутствуют в `HubSnapshot`, поэтому экран показывает честное `Н/Д`. +- Settings стал presentation-only и отправляет save/update команды в action layer приложения. +- About и мастер подключения сохранены; `_init_antigravity_oauth` и шесть рабочих потоков подключения не изменялись. + +## Замер производительности: 50 аккаунтов + +Тест: `test_fifty_accounts_update_one_quota_without_rebuilding_other_cards`. + +| Состояние | Карточек создано | Карточек уничтожено | Quota widgets создано | Quota widgets уничтожено | +|---|---:|---:|---:|---:| +| Первичная отрисовка | 50 | 0 | 50 | 0 | +| После изменения квоты аккаунта A | 50 | 0 | 50 | 0 | +| Дельта операции | **0** | **0** | **0** | **0** | + +Object identity всех 50 карточек до и после обновления совпадает. Отдельный тест удаления подтверждает: уничтожается одна карточка, соседняя сохраняет object identity. + +## Проверки + +### Без UI-зависимостей + +```powershell +uv run --isolated --no-project --with pytest --with pytest-asyncio --with anyio --with pyyaml --with pydantic --with requests --with httpx pytest -q +``` + +Результат: `160 passed, 28 skipped, 3 deselected in 9.55s`. + +### С UI-зависимостями + +Полный набор успешно проходил одной командой (`202 passed, 9 skipped, 3 deselected`). Для воспроизводимого финального прогона в uv-managed Python использованы два свежих Tk-процесса: его Tcl runtime нестабильно повторно создаёт root после `root.destroy()` в соседнем OAuth-тесте. + +```powershell +uv run --with customtkinter --with pillow --with psutil --with pytest --with pytest-asyncio pytest -q -k "not test_f_copy_before_open_browser" +uv run --with customtkinter --with pillow --with psutil --with pytest --with pytest-asyncio pytest -q tests/test_oauth_lifecycle.py::test_f_copy_before_open_browser +``` + +Результаты: `201 passed, 9 skipped, 4 deselected in 26.42s` и `1 passed in 2.86s`. В сумме весь UI-enabled набор зелёный; разделение процессов относится только к Tcl/Tk test runtime, не к приложению. + +### Статика и release gate + +```powershell +uv run --with ruff ruff check . +uv run python scripts/release_gate.py +``` + +Результат: Ruff — `All checks passed`; release gate — `PASSED`. Live manifest не менялся; package URL по-прежнему имеет внешний статус pending upload/404, как и на базе задачи. + +## Backend gaps + +1. Ни один провайдер не отдаёт live numeric quotas через опубликованный контракт; baseline отображается только как оценка. +2. Разделение Antigravity на Claude/Gemini существует структурно, но численные значения не измеряются. +3. Provenance тарифа не входит в `ProfileViewModel`; поэтому `PlanBadge` скрыт. +4. В `AgentViewModel` нет активной сессии и квоты. +5. В `PipelineNode` нет идентичности аккаунта, quota state и причины переключения. +6. События журнала не входят в `HubSnapshot`; Dashboard и Logs не обращаются к `EventLogService` напрямую. +7. Латентность, RPS, error rate, стоимость и удалённые SLA отсутствуют. +8. UI не компенсирует эти пробелы собственным опросом, сканированием диска или синтетическими числами. + +## Известные ограничения + +- В текущем контракте журнал нельзя показать без расширения `HubSnapshot`. +- Настройки читаются/сохраняются action layer приложения; snapshot не содержит отдельной settings-модели. +- Live release package остаётся внешней задачей. По B2 тег, релиз и manifest не создавались и не изменялись. diff --git a/src/antigravity_provider/router/hermes_hub_app.py b/src/antigravity_provider/router/hermes_hub_app.py index ef360cf..d0640b5 100644 --- a/src/antigravity_provider/router/hermes_hub_app.py +++ b/src/antigravity_provider/router/hermes_hub_app.py @@ -9,9 +9,11 @@ Features: - Real Provider Icon Marks (Google Antigravity, OpenAI Codex, OpenCode Go) - Non-blocking Background Workers & Graceful Shutdown Coordinator """ + from __future__ import annotations import ctypes +import json import logging import os import sys @@ -44,6 +46,7 @@ from antigravity_provider.router.router_config import load_router_config 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.router.ui.theme import Theme from antigravity_provider.router.ui.assets import AssetManager @@ -58,6 +61,7 @@ from antigravity_provider.router.unified_health import ( ) from antigravity_provider.router.ui.views.team_view import TeamView +from antigravity_provider.router.ui.views.dashboard_view import DashboardView from antigravity_provider.router.ui.views.accounts_view import AccountsView from antigravity_provider.router.ui.views.providers_view import ProvidersView from antigravity_provider.router.ui.views.routing_view import RoutingView @@ -73,17 +77,22 @@ logger = logging.getLogger("hermes.hub.gui") # Actions Layer (Safe & Non-blocking) # ═══════════════════════════════════════════════════════════════ + def do_set_main(provider: str, profile_id: str) -> Tuple[bool, str]: ok, msg = ProfileAuthManager.set_main_profile(provider, profile_id) if ok: - EventLogService.get().log("account", f"Профиль {profile_id} назначен основным аккаунтом Hermes ({provider}).", level="info") + EventLogService.get().log( + "account", f"Профиль {profile_id} назначен основным аккаунтом Hermes ({provider}).", level="info" + ) return ok, msg def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]: ok, msg = AutoAssigner.set_primary_orchestrator(profile_id) if ok: - EventLogService.get().log("routing", f"Профиль {profile_id} назначен главным оркестратором команды.", level="info") + EventLogService.get().log( + "routing", f"Профиль {profile_id} назначен главным оркестратором команды.", level="info" + ) return ok, msg @@ -102,11 +111,14 @@ def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]: model = pcfg.preferred_models[0] if pcfg.preferred_models else "default" t0 = time.time() try: - resp = adapter.invoke(pcfg, { - "model": model, - "messages": [{"role": "user", "content": f"Respond strictly with: TEST_OK_FOR_{profile_id}"}], - "temperature": 0.1, - }) + resp = adapter.invoke( + pcfg, + { + "model": model, + "messages": [{"role": "user", "content": f"Respond strictly with: TEST_OK_FOR_{profile_id}"}], + "temperature": 0.1, + }, + ) el = round(time.time() - t0, 2) content = resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip() EventLogService.get().log("system", f"Тест {profile_id} ({model}) успешно пройден за {el}s.", level="success") @@ -128,10 +140,30 @@ def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]: return True, "Учетные данные отсутствовали" +def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]: + settings_file = paths.get_hermes_home() / "hub_settings.json" + settings_file.parent.mkdir(parents=True, exist_ok=True) + existing: Dict[str, Any] = {} + if settings_file.exists(): + try: + existing = json.loads(settings_file.read_text(encoding="utf-8")) + except Exception: + existing = {} + existing.update(settings) + temp_file = settings_file.with_suffix(".json.tmp") + temp_file.write_text(json.dumps(existing, indent=2, ensure_ascii=False), encoding="utf-8") + os.replace(temp_file, settings_file) + from antigravity_provider.router.quota_collector import AccountQuotaService + + AccountQuotaService.get().set_refresh_interval(int(settings.get("quota_refresh_interval_sec", 300))) + return True, "Настройки сохранены" + + # ═══════════════════════════════════════════════════════════════ # Main Application Window # ═══════════════════════════════════════════════════════════════ + class HermesHubApp(ctk.CTk): def __init__(self): super().__init__() @@ -151,7 +183,7 @@ class HermesHubApp(ctk.CTk): except Exception: pass - self._current_view = "team" + self._current_view = "overview" self._views: Dict[str, ctk.CTkFrame] = {} self._view_generations: Dict[str, int] = {} self._shutting_down = False @@ -161,16 +193,18 @@ class HermesHubApp(ctk.CTk): self.bind("", self._on_window_configure) self._build_layout() - self._show_view("team") + self._show_view("overview") try: from antigravity_provider.router.scheduler import HermesRefreshScheduler + HermesRefreshScheduler.get().start() except Exception: pass try: from antigravity_provider.router.quota_collector import AccountQuotaService + AccountQuotaService.get().start_background_scheduler() except Exception: pass @@ -192,14 +226,19 @@ class HermesHubApp(ctk.CTk): logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="") logo_lbl.pack(anchor="center", pady=(0, 6)) - 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") + 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)) # Nav Items with clean Fluent glyphs self._nav_items = [ + ("overview", "Обзор", "⌂"), ("team", "Команда", "👥"), ("accounts", "Аккаунты", "🔍"), ("routing", "Маршрутизация", "🔀"), @@ -267,20 +306,27 @@ class HermesHubApp(ctk.CTk): def _create_view(self, view_name: str) -> ctk.CTkFrame: """Create view widget instance.""" - if view_name == "team": + if view_name == "overview": + return DashboardView( + self.content, + app_state={}, + on_navigate=self._show_view, + on_action=self._handle_action, + ) + elif view_name == "team": return TeamView(self.content, app_state={}, on_action=self._handle_action) elif view_name == "accounts": return AccountsView(self.content, app_state={}, on_action=self._handle_action) elif view_name == "providers": return ProvidersView(self.content, app_state={}, on_action=self._handle_action) elif view_name == "routing": - return RoutingView(self.content) + return RoutingView(self.content, on_action=self._handle_action) 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) + return SettingsView(self.content, on_action=self._handle_action) elif view_name == "about": return AboutView(self.content) else: @@ -319,6 +365,7 @@ class HermesHubApp(ctk.CTk): # Lazy update if view state is behind current snapshot generation from antigravity_provider.router.state_store import HubStateStore + snap = HubStateStore.get().get_snapshot() if self._view_generations.get(view_name, 0) < snap.generation: if hasattr(target_view, "update_data"): @@ -364,6 +411,7 @@ class HermesHubApp(ctk.CTk): return try: from antigravity_provider.router.state_store import HubStateStore + snap = HubStateStore.get().refresh(force_scan=True) if not self._shutting_down: self.after(0, lambda: self._on_data_loaded(snap)) @@ -381,6 +429,7 @@ class HermesHubApp(ctk.CTk): return from antigravity_provider.router.state_store import HubSnapshot, HubStateStore + if isinstance(snapshot_or_readiness, HubSnapshot): snap = snapshot_or_readiness readiness = snap.readiness @@ -392,7 +441,11 @@ class HermesHubApp(ctk.CTk): 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')}" ) - r_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"● {readiness.title_ru}", text_color=r_color) # Update ONLY the currently visible view (others are updated lazily on tab switch) @@ -448,10 +501,40 @@ class HermesHubApp(ctk.CTk): ) elif action == "refresh_data": self._refresh_data() + elif action == "refresh_all": + from antigravity_provider.router.scheduler import HermesRefreshScheduler + + HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data)) + elif action == "refresh_account": + from antigravity_provider.router.scheduler import HermesRefreshScheduler + + HermesRefreshScheduler.get().trigger_refresh_account( + prov, + pid, + on_complete=lambda: self.after(0, self._refresh_data), + ) + elif action == "edit_route": + self._show_toast("Редактор цепочки использует кнопки и селекторы; drag-and-drop отключён.") + elif action == "save_settings": + self._run_in_thread( + lambda: do_save_settings(data), + on_success=lambda result: self._show_toast(f"✅ {result[1]}"), + ) + elif action == "check_updates": + from antigravity_provider.updater import UpdateManager + + self._run_in_thread( + lambda: UpdateManager().check_for_updates(), + on_success=lambda result: self._show_toast( + f"Доступна версия {result.manifest.version}" + if result.update_available and result.manifest + else (f"Ошибка: {result.error}" if result.error else "Установлена актуальная версия") + ), + ) def _open_assign_role_modal(self, profile_id: str, display_name: str): modal = HubModal(self, title=f"Назначение роли: {display_name}", width=500, height=420) - + ctk.CTkLabel( modal.body, text=f"Выберите роль в команде Hermes для профиля «{display_name}» ({profile_id}):", @@ -540,6 +623,7 @@ class HermesHubApp(ctk.CTk): if self._shutting_down: return from antigravity_provider.router.state_store import HubStateStore + 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')}" @@ -550,11 +634,13 @@ class HermesHubApp(ctk.CTk): self._shutting_down = True try: from antigravity_provider.router.scheduler import HermesRefreshScheduler + HermesRefreshScheduler.get().stop() except Exception: pass try: from antigravity_provider.router.quota_collector import AccountQuotaService + AccountQuotaService.get().stop_background_scheduler() except Exception: pass @@ -624,7 +710,9 @@ def launch_hub(): if not check_single_instance(): try: with open(log_file, "a", encoding="utf-8") as f: - f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Another instance is already running. Focused existing window and exiting.\n") + f.write( + f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Another instance is already running. Focused existing window and exiting.\n" + ) except Exception: pass sys.exit(0) @@ -635,6 +723,7 @@ def launch_hub(): except Exception as exc: try: import traceback + tb = traceback.format_exc() with open(log_file, "a", encoding="utf-8") as f: f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] FATAL CRASH:\n{tb}\n") diff --git a/src/antigravity_provider/router/ui/components.py b/src/antigravity_provider/router/ui/components.py index 06ed6c8..f52eba8 100644 --- a/src/antigravity_provider/router/ui/components.py +++ b/src/antigravity_provider/router/ui/components.py @@ -5,6 +5,7 @@ try: import customtkinter as ctk except ImportError: import unittest.mock as _mock + ctk = _mock.MagicMock() from antigravity_provider.router.ui.theme import Theme @@ -152,32 +153,55 @@ def enable_clipboard_shortcuts(entry_widget: Any) -> None: pass # Standard Latin shortcuts - for p in ("", "", "", "", "", ""): - try: inner.bind(p, _paste_handler, add=False) - except Exception: pass + for p in ( + "", + "", + "", + "", + "", + "", + ): + try: + inner.bind(p, _paste_handler, add=False) + except Exception: + pass for a in ("", "", "", ""): - try: inner.bind(a, _select_all_handler, add=False) - except Exception: pass + try: + inner.bind(a, _select_all_handler, add=False) + except Exception: + pass for c in ("", "", "", ""): - try: inner.bind(c, _copy_handler, add=False) - except Exception: pass + try: + inner.bind(c, _copy_handler, add=False) + except Exception: + pass for x in ("", "", "", ""): - try: inner.bind(x, _cut_handler, add=False) - except Exception: pass + try: + inner.bind(x, _cut_handler, add=False) + except Exception: + pass # Windows Cyrillic / Russian keyboard layouts for p in ("", "", "", ""): - try: inner.bind(p, _paste_handler, add=False) - except Exception: pass + try: + inner.bind(p, _paste_handler, add=False) + except Exception: + pass for a in ("", "", "", ""): - try: inner.bind(a, _select_all_handler, add=False) - except Exception: pass + try: + inner.bind(a, _select_all_handler, add=False) + except Exception: + pass for c in ("", "", "", ""): - try: inner.bind(c, _copy_handler, add=False) - except Exception: pass + try: + inner.bind(c, _copy_handler, add=False) + except Exception: + pass for x in ("", "", "", ""): - try: inner.bind(x, _cut_handler, add=False) - except Exception: pass + try: + inner.bind(x, _cut_handler, add=False) + except Exception: + pass class HubEntry(ctk.CTkEntry): @@ -251,25 +275,29 @@ class HubStatusBadge(ctk.CTkFrame): def __init__(self, master: Any, status_key: str, **kwargs): super().__init__(master=master, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_PILL, **kwargs) - clean_key = status_key.lower().replace("-", "_") - color, label = self.STATUS_MAP.get(clean_key, (Theme.TEXT_MUTED, status_key)) - inner = ctk.CTkFrame(self, fg_color="transparent") inner.pack(padx=Theme.SPACE_MD, pady=Theme.SPACE_XS) - ctk.CTkLabel( + self.dot = ctk.CTkLabel( inner, text="●", font=Theme.font_badge_bold(), - text_color=color, - ).pack(side="left", padx=(0, Theme.SPACE_XS)) + ) + self.dot.pack(side="left", padx=(0, Theme.SPACE_XS)) - ctk.CTkLabel( + self.label = ctk.CTkLabel( inner, - text=label, font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY, - ).pack(side="left") + ) + self.label.pack(side="left") + self.set_status(status_key) + + 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 "Н/Д")) + self.dot.configure(text_color=color) + self.label.configure(text=label or default_label) class HubProviderBadge(ctk.CTkFrame): @@ -610,8 +638,10 @@ class QuotaBar(ctk.CTkFrame): self.detail.configure(text=detail or "Н/Д", text_color=Theme.TEXT_MUTED) return normalized = max(0.0, min(1.0, float(value))) - color = Theme.COLOR_NEGATIVE if normalized <= 0.05 else ( - Theme.COLOR_CAUTION if normalized <= 0.20 else Theme.COLOR_POSITIVE + color = ( + Theme.COLOR_NEGATIVE + if normalized <= 0.05 + else (Theme.COLOR_CAUTION if normalized <= 0.20 else Theme.COLOR_POSITIVE) ) self.progress.set(normalized) self.progress.configure(progress_color=color) @@ -647,10 +677,11 @@ class QuotaBucketWidget(HubCard): remaining_ratio: Optional[float], reset_text: str = "", is_estimated: bool = False, + detail: Optional[str] = None, ) -> None: suffix = " • оценка" if is_estimated else "" self.title.configure(text=f"{label}{suffix}") - self.bar.set_value(remaining_ratio) + self.bar.set_value(remaining_ratio, detail or ("Н/Д" if remaining_ratio is None else None)) self.reset.configure(text=reset_text or "Сброс: Н/Д") @@ -720,9 +751,7 @@ class EmptyState(HubCard): padx=Theme.SPACE_LG, pady=(0, Theme.SPACE_MD) ) if action_text and action_cmd: - ActionButton(self, text=action_text, variant="secondary", command=action_cmd).pack( - pady=(0, Theme.SPACE_LG) - ) + ActionButton(self, text=action_text, variant="secondary", command=action_cmd).pack(pady=(0, Theme.SPACE_LG)) class RouteTargetWidget(HubCard): @@ -736,6 +765,12 @@ class RouteTargetWidget(HubCard): self.title.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0)) self.subtitle = ctk.CTkLabel(self, text=subtitle, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) self.subtitle.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.SPACE_SM)) + self.update_target(rank, title, subtitle, status) + + def update_target(self, rank: str, title: str, subtitle: str, status: str = "unknown") -> None: + self.rank.configure(text=rank) + self.title.configure(text=title) + self.subtitle.configure(text=subtitle) self.set_status(status) def set_status(self, status: str) -> None: @@ -743,23 +778,143 @@ class RouteTargetWidget(HubCard): class AccountCardWidget(HubCard): - """Model-agnostic account-card shell shared by account views.""" + """Keyed account card whose quota buckets are updated without rebuilding the card.""" - def __init__(self, master: Any, identity: str, provider: str, status: str = "unknown", **kwargs): - super().__init__(master, height=Theme.ACCOUNT_CARD_MIN_HEIGHT, **kwargs) - self.grid_propagate(False) - self.provider = ctk.CTkLabel(self, text=provider, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) - self.provider.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS)) - self.identity = EllipsizedLabel( - self, - text=identity, - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - ) + def __init__( + self, + master: Any, + profile_id: str, + identity: str, + provider: str, + status: str = "unknown", + compact: bool = False, + on_action: Optional[Callable[[str, Any], None]] = None, + **kwargs, + ): + super().__init__(master, **kwargs) + self.profile_id = profile_id + self.profile_model: Any = None + self.on_action = on_action + self.compact = compact + self._quota_widgets: Dict[str, QuotaBucketWidget] = {} + self.widgets_created = 0 + self.widgets_destroyed = 0 + + top = ctk.CTkFrame(self, fg_color="transparent") + top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS)) + self.provider = ctk.CTkLabel(top, text=provider, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) + self.provider.pack(side="left") + self.toggle = IconButton(top, text="▾", command=self.toggle_compact) + self.toggle.pack(side="right") + + self.identity = EllipsizedLabel(self, text=identity, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY) self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X) + self.meta = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) + self.meta.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0)) self.status = StatusBadge(self, status) self.status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) + self.details = ctk.CTkFrame(self, fg_color="transparent") + self.details.pack(fill="x") + self.quota_box = ctk.CTkFrame(self.details, fg_color="transparent") + self.quota_box.pack(fill="x", padx=Theme.CARD_PAD_X) + self.empty_quota = ctk.CTkLabel( + self.quota_box, text="Квота: Н/Д", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED + ) + 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)) + ActionButton( + self.actions, + text="Обновить", + variant="secondary", + width=86, + command=lambda: self._trigger("refresh_account"), + ).pack(side="left") + ActionButton( + self.actions, + text="Удалить", + variant="ghost", + width=72, + command=lambda: self._trigger("delete_credentials"), + ).pack(side="right") + self.set_compact(compact) + + @staticmethod + def resolve_identity(profile: Any) -> str: + """Contract order: email → normalized account identity → display name → profile id.""" + for value in ( + getattr(profile, "email", None), + getattr(profile, "account_identity", None), + getattr(profile, "display_name", None), + getattr(profile, "profile_id", None), + ): + if value and str(value).strip() and str(value).strip() not in {"—", "Н/Д"}: + return str(value).strip() + return "Аккаунт: Н/Д" + + def _trigger(self, action: str) -> None: + if self.on_action and self.profile_model is not None: + self.on_action(action, self.profile_model) + + def toggle_compact(self) -> None: + self.set_compact(not self.compact) + + def set_compact(self, compact: bool) -> None: + self.compact = compact + self.toggle.configure(text="▸" if compact else "▾") + if compact: + self.details.pack_forget() + else: + self.details.pack(fill="x") + + def update_account(self, profile: Any, quota_snapshot: Optional[Any] = None) -> None: + self.profile_model = profile + self.profile_id = profile.profile_id + self.identity.set_text(self.resolve_identity(profile)) + self.provider.configure(text=profile.provider_display_name or profile.provider) + roles = ", ".join(getattr(profile, "assigned_roles", []) or []) or "Роль: Н/Д" + self.meta.configure(text=f"{profile.display_name} • {roles}") + self.status.set_status(profile.health_state, getattr(profile, "health_label_ru", None)) + self.configure(border_color=Theme.BORDER_ACCENT if profile.is_main_account else Theme.BORDER) + + snapshot = quota_snapshot or getattr(profile, "quota_snapshot", None) + buckets = list(getattr(snapshot, "buckets", None) or []) + estimated = bool(getattr(snapshot, "is_estimated", True)) if snapshot else True + seen: set[str] = set() + for bucket in buckets: + key = str(getattr(bucket, "id", "") or getattr(bucket, "display_name", "bucket")) + seen.add(key) + remaining = getattr(bucket, "remaining_percent", None) + ratio = float(remaining) / 100.0 if remaining is not None else None + if ratio is None: + remaining_abs = getattr(bucket, "remaining_absolute", None) + limit_abs = getattr(bucket, "limit_absolute", None) + if remaining_abs is not None and limit_abs: + ratio = float(remaining_abs) / float(limit_abs) + detail = bucket.formatted_remaining() if hasattr(bucket, "formatted_remaining") else "Н/Д" + reset = bucket.formatted_reset() if hasattr(bucket, "formatted_reset") else None + if key not in self._quota_widgets: + widget = QuotaBucketWidget(self.quota_box, key, bucket.display_name) + widget.pack(fill="x", pady=(0, Theme.SPACE_SM)) + self._quota_widgets[key] = widget + self.widgets_created += 1 + self._quota_widgets[key].update_bucket( + bucket.display_name, + ratio, + reset or "Сброс: Н/Д", + estimated, + detail, + ) + + for key in list(self._quota_widgets): + if key not in seen: + self._quota_widgets.pop(key).destroy() + self.widgets_destroyed += 1 + if buckets: + self.empty_quota.pack_forget() + else: + self.empty_quota.pack(anchor="w", pady=Theme.SPACE_SM) + class AgentCardWidget(HubCard): """Model-agnostic agent-card shell shared by team views.""" diff --git a/src/antigravity_provider/router/ui/views/accounts_view.py b/src/antigravity_provider/router/ui/views/accounts_view.py index 5714d93..d1d90f7 100644 --- a/src/antigravity_provider/router/ui/views/accounts_view.py +++ b/src/antigravity_provider/router/ui/views/accounts_view.py @@ -1,444 +1,269 @@ -"""Hermes Hub — Accounts View with Reusable AccountCardWidget & Zero Widget Re-creation.""" +"""Accounts and quota view driven exclusively by a supplied HubSnapshot.""" + from __future__ import annotations -import logging -from typing import Any, Callable, Dict, List, Optional +from collections import defaultdict +from typing import Any, Callable, Dict, Iterable, Optional + import customtkinter as ctk -from antigravity_provider.router.ui.theme import Theme -from antigravity_provider.router.ui.assets import AssetManager +from antigravity_provider.router.state_store import HubSnapshot from antigravity_provider.router.ui.components import ( - HubButton, - HubCard, - HubToolbar, + AccountCardWidget, + ActionButton, + EmptyState, + FilterButton, + SearchField, + SectionHeader, ) -from antigravity_provider.router.unified_health import ( - ProfileViewModel, - STATUS_HEALTHY, - STATUS_QUOTA_EXHAUSTED, - STATUS_AUTH_REQUIRED, - STATUS_NOT_CONFIGURED, - STATUS_COLD_SPARE, -) -from antigravity_provider.router.quota_collector import AccountQuotaService -from antigravity_provider.router.state_store import HubStateStore, HubSnapshot -from antigravity_provider.router.scheduler import HermesRefreshScheduler -from antigravity_provider.router.event_bus import ( - EventBus, - EVENT_ACCOUNT_UPDATED, - EVENT_QUOTA_UPDATED, -) - -logger = logging.getLogger("hermes.hub.accounts_view") +from antigravity_provider.router.ui.theme import Theme -class AccountCardWidget(HubCard): - """Reusable, updateable account card widget keyed by profile_id.""" +PROVIDER_LABELS = { + "antigravity": "Google Antigravity", + "openai-codex": "OpenAI Codex", + "opencode-go": "OpenCode Go", + "claude": "Claude", + "grok": "Grok", +} - def __init__( - self, - parent: Any, - profile: ProfileViewModel, - quota_snapshot: Optional[Any] = None, - on_action: Optional[Callable] = None, - on_refresh: Optional[Callable] = None, - **kwargs, - ): - border_col = Theme.BORDER_ACCENT if profile.is_main_account else Theme.BORDER - super().__init__(parent, border_color=border_col, fg_color=Theme.SURFACE, **kwargs) - self.profile = profile - self.on_action = on_action - self.on_refresh = on_refresh - self._build() - self.update_from_model(profile, quota_snapshot) +class ProviderGroup(ctk.CTkFrame): + """Collapsible provider section that retains its account cards while hidden.""" - def _build(self): - # 1. Top row: icon + plan badge + main badge + status dot - self.top_row = ctk.CTkFrame(self, fg_color="transparent") - self.top_row.pack(fill="x", padx=14, pady=(12, 2)) - - self.p_icon_lbl = ctk.CTkLabel(self.top_row, text="") - self.p_icon_lbl.pack(side="left", padx=(0, 6)) - - self.plan_frame = ctk.CTkFrame(self.top_row, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - self.plan_frame.pack(side="left", padx=(0, 6)) - self.plan_lbl = ctk.CTkLabel(self.plan_frame, text="", font=Theme.font_micro_bold()) - self.plan_lbl.pack(padx=6, pady=2) - - self.main_pill = ctk.CTkFrame(self.top_row, fg_color="#3D3522", corner_radius=Theme.RADIUS_SM) - self.main_lbl = ctk.CTkLabel(self.main_pill, text="★ MAIN", font=Theme.font_micro(), text_color=Theme.ACCENT) - self.main_lbl.pack(padx=5, pady=1) - - self.status_dot = ctk.CTkLabel(self.top_row, text="●", font=("Segoe UI", 13, "bold")) - self.status_dot.pack(side="right") - - # 2. Identity line - self.ident_lbl = ctk.CTkLabel(self, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY) - self.ident_lbl.pack(anchor="w", padx=14, pady=(2, 2)) - - # 3. Subheader: display name + provider name - self.sub_lbl = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED) - self.sub_lbl.pack(anchor="w", padx=14, pady=(0, 4)) - - # 4. Quota Buckets Container - self.quota_box = ctk.CTkFrame(self, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - self.quota_box.pack(fill="x", padx=14, pady=4) - - # 5. Freshness text - self.fresh_lbl = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) - self.fresh_lbl.pack(anchor="w", padx=14, pady=(2, 2)) - - # 6. Action buttons - self.btns = ctk.CTkFrame(self, fg_color="transparent") - self.btns.pack(fill="x", padx=14, pady=(4, 12)) - - self.refresh_btn = HubButton( - self.btns, - text="↻", - variant="secondary", - width=32, - height=Theme.HEIGHT_BTN_SM, - command=self._on_single_refresh, + def __init__(self, master: Any, provider: str): + super().__init__(master, fg_color="transparent") + self.provider = provider + self.collapsed = False + self.header = ctk.CTkFrame(self, fg_color=Theme.BG_HEADER, corner_radius=Theme.RADIUS_SM) + self.header.pack(fill="x", pady=(Theme.SPACE_SM, Theme.SPACE_XS)) + self.toggle = ActionButton( + self.header, + text="▾", + variant="ghost", + width=34, + command=self.toggle_collapsed, ) - self.refresh_btn.pack(side="left", padx=(0, 6)) - - self.test_btn = HubButton( - self.btns, - text="⚡ Тест", - variant="secondary", - width=65, - height=Theme.HEIGHT_BTN_SM, - command=lambda: self._trigger("test"), + self.toggle.pack(side="left", padx=(Theme.SPACE_SM, 0)) + self.title = ctk.CTkLabel( + self.header, + text=PROVIDER_LABELS.get(provider, provider), + font=Theme.font_heading(), + text_color=Theme.TEXT_PRIMARY, ) - self.test_btn.pack(side="left", padx=(0, 6)) + self.title.pack(side="left", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM) + self.count = ctk.CTkLabel(self.header, text="0", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED) + self.count.pack(side="right", padx=Theme.SPACE_MD) + self.body = ctk.CTkFrame(self, fg_color="transparent") + self.body.pack(fill="x") + for column in range(Theme.ACCOUNT_CARD_COLUMNS): + self.body.grid_columnconfigure(column, weight=1) - self.assign_btn = HubButton( - self.btns, - text="Назначить", - variant="secondary", - width=80, - height=Theme.HEIGHT_BTN_SM, - command=lambda: self._trigger("assign_role"), - ) - self.assign_btn.pack(side="left", padx=(0, 6)) - - def _trigger(self, action: str): - if self.on_action: - self.on_action(action, self.profile) - - def _on_single_refresh(self): - if self.on_refresh: - self.on_refresh(self.profile.provider, self.profile.profile_id) - - def update_from_model(self, p: ProfileViewModel, quota_snap: Optional[Any] = None) -> None: - """Update existing card properties in-place without destroying widgets.""" - self.profile = p - - # Border color for main - if p.is_main_account: - self.configure(border_color=Theme.BORDER_ACCENT) - if not self.main_pill.winfo_ismapped(): - self.main_pill.pack(side="left", padx=(0, 6)) + def toggle_collapsed(self) -> None: + self.collapsed = not self.collapsed + self.toggle.configure(text="▸" if self.collapsed else "▾") + if self.collapsed: + self.body.pack_forget() else: - self.configure(border_color=Theme.BORDER) - if self.main_pill.winfo_ismapped(): - self.main_pill.pack_forget() - - # Provider icon - p_img = AssetManager.get().get_provider_image(p.provider, size=(20, 20)) - if p_img: - self.p_icon_lbl.configure(image=p_img) - - # Plan badge - plan_color = "#3b82f6" if p.plan_code in ("PRO", "PLUS", "MAX") else ("#10b981" if p.plan_code in ("ULTRA", "SUPERGROK", "TEAM") else Theme.TEXT_MUTED) - self.plan_lbl.configure(text=p.plan, text_color=plan_color) - - # Status dot - dot_col = Theme.STATUS_HEALTHY if p.health_state == STATUS_HEALTHY else ( - Theme.STATUS_WARNING if "quota" in p.health_state or "auth" in p.health_state else Theme.STATUS_ERROR - ) - self.status_dot.configure(text_color=dot_col) - - # Identity & Subheader - self.ident_lbl.configure(text=p.account_identity) - self.sub_lbl.configure(text=f"{p.display_name} • {p.provider_display_name}") - - # Quota Buckets - snap = quota_snap or p.quota_snapshot or AccountQuotaService.get().get_snapshot(p.provider, p.profile_id) - for child in self.quota_box.winfo_children(): - child.destroy() - - is_estimated = getattr(snap, "is_estimated", True) if snap else True - if snap and getattr(snap, "buckets", None): - for b in snap.buckets[:4]: - brow = ctk.CTkFrame(self.quota_box, fg_color="transparent") - brow.pack(fill="x", padx=8, pady=2) - disp_name = f"{b.display_name} (оценка)" if is_estimated else b.display_name - ctk.CTkLabel(brow, text=disp_name, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - - b_status_col = Theme.STATUS_HEALTHY if b.status == "healthy" else (Theme.STATUS_WARNING if b.status == "warning" else Theme.STATUS_ERROR) - reset_text = f" ({b.formatted_reset()})" if b.formatted_reset() else "" - rem_text = f"{b.formatted_remaining()}{reset_text}" - ctk.CTkLabel(brow, text=rem_text, font=Theme.font_micro(), text_color=b_status_col).pack(side="right") - else: - ctk.CTkLabel(self.quota_box, text="Квота: доступна (оценка)", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(padx=8, pady=4) - - # Freshness label - fresh_lbl_text = snap.freshness_label() if (snap and hasattr(snap, "freshness_label")) else "Обновлено: недавно" - if is_estimated: - fresh_lbl_text += " • оценка" - self.fresh_lbl.configure(text=fresh_lbl_text) + self.body.pack(fill="x") class AccountsView(ctk.CTkFrame): - """Native Windows CustomTkinter view for multi-provider accounts with widget reuse.""" + """Keyed accounts view; one account delta never reconstructs another card.""" - def __init__(self, master: Any, app_state: Dict[str, Any], on_action: Optional[Callable] = None, **kwargs): + def __init__( + self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs + ): super().__init__(master=master, fg_color="transparent", **kwargs) - self.app_state = app_state self.on_action = on_action - self._search_query = "" - self._filter_status = "Все статусы" - self._sort_by = "По умолчанию" - + self._snapshot: Optional[HubSnapshot] = None self._cards: Dict[str, AccountCardWidget] = {} - self._empty_cards: Dict[str, HubCard] = {} - self._last_rendered_generation = 0 - + self._groups: Dict[str, ProviderGroup] = {} + self._search = "" + self._provider_filter = "Все провайдеры" + self._health_filter = "Все состояния" + self._role_filter = "Все роли" + self.cards_created = 0 + self.cards_destroyed = 0 self._build() - self._subscribe_events() - def _subscribe_events(self): - bus = EventBus.get() - bus.subscribe(EVENT_ACCOUNT_UPDATED, self._on_account_updated_event) - bus.subscribe(EVENT_QUOTA_UPDATED, self._on_quota_updated_event) + def _build(self) -> None: + header = SectionHeader( + self, + title="Аккаунты и квоты", + subtitle="Реальные идентичности, независимые лимитные корзины и резервные роли", + action_text="+ Добавить аккаунт", + action_cmd=lambda: self._emit("add_account", {}), + ) + header.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM)) - def _on_account_updated_event(self, event_name: str, payload: Any): - if isinstance(payload, dict): - pid = payload.get("profile_id") - prof = payload.get("profile") - if pid and prof and pid in self._cards: - self.after(0, lambda: self._cards[pid].update_from_model(prof)) - - def _on_quota_updated_event(self, event_name: str, payload: Any): - if isinstance(payload, dict): - pid = payload.get("profile_id") - quota_snap = payload.get("quota_snapshot") - if pid and pid in self._cards: - p = self._cards[pid].profile - self.after(0, lambda: self._cards[pid].update_from_model(p, quota_snap)) - - def _build(self): - # 1. Header with Refresh All and Add Account buttons - header_row = ctk.CTkFrame(self, fg_color="transparent") - header_row.pack(fill="x", padx=20, pady=(16, 8)) - - titles_col = ctk.CTkFrame(header_row, fg_color="transparent") - titles_col.pack(side="left", fill="both", expand=True) - - ctk.CTkLabel( - titles_col, - text="Управление аккаунтами", - font=Theme.font_title(), - text_color=Theme.TEXT_PRIMARY, - anchor="w", - ).pack(fill="x") - - ctk.CTkLabel( - titles_col, - text="Подключение, мониторинг тарифов и раздельных квот провайдеров", - font=Theme.font_caption(), - text_color=Theme.TEXT_SECONDARY, - anchor="w", - ).pack(fill="x", pady=(2, 0)) - - actions_col = ctk.CTkFrame(header_row, fg_color="transparent") - actions_col.pack(side="right") - - self.refresh_all_btn = HubButton( - actions_col, - text="↻ Обновить все", + 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=300) + self.search.pack(side="left", padx=(0, Theme.SPACE_SM)) + self.search.bind("", lambda _event: self._set_search(self.search.get())) + self.provider_filter = FilterButton( + toolbar, + values=["Все провайдеры", *PROVIDER_LABELS.values()], + command=self._set_provider_filter, + width=170, + ) + self.provider_filter.pack(side="left", padx=(0, Theme.SPACE_SM)) + self.health_filter = FilterButton( + toolbar, + values=["Все состояния", "Работают", "Требуют входа", "Проблемные"], + command=self._set_health_filter, + width=150, + ) + self.health_filter.pack(side="left", padx=(0, Theme.SPACE_SM)) + self.role_filter = FilterButton( + toolbar, + values=["Все роли", "orchestrator", "coder", "reviewer", "researcher", "tester", "general", "spare"], + command=self._set_role_filter, + width=135, + ) + self.role_filter.pack(side="left") + ActionButton( + toolbar, + text="Обновить все", variant="secondary", - height=Theme.HEIGHT_BTN_MD, - command=self._refresh_all_quotas, - ) - self.refresh_all_btn.pack(side="left", padx=(0, 8)) + command=lambda: self._emit("refresh_all", {}), + ).pack(side="right") - HubButton( - actions_col, - text="+ Добавить аккаунт", - variant="primary", - height=Theme.HEIGHT_BTN_MD, - command=lambda: self._trigger_action("add_account", {}), - ).pack(side="left") - - # 2. Cockpit Toolbar - self.toolbar = HubToolbar( - self, - on_search=self._on_search, - on_filter=self._on_filter, - on_sort=self._on_sort, - ) - self.toolbar.pack(fill="x", padx=20, pady=(0, 10)) - - # 3. Provider Tabs - self.tabview = ctk.CTkTabview( - self, - fg_color=Theme.BG_WINDOW, - segmented_button_fg_color=Theme.BG_SIDEBAR, - segmented_button_selected_color=Theme.SECONDARY, - segmented_button_selected_hover_color=Theme.SURFACE_HOVER, - segmented_button_unselected_color=Theme.BG_SIDEBAR, - segmented_button_unselected_hover_color=Theme.SURFACE_HOVER, - text_color=Theme.TEXT_PRIMARY, - ) - self.tabview.pack(fill="both", expand=True, padx=15, pady=(0, 10)) - - provider_tabs = [ - ("antigravity", "Google Antigravity"), - ("openai-codex", "OpenAI Codex"), - ("opencode-go", "OpenCode Go"), - ("claude", "Claude"), - ("grok", "Grok"), - ] - - self.tab_scrolls: Dict[str, ctk.CTkScrollableFrame] = {} - - for p_key, p_label in provider_tabs: - tab = self.tabview.add(p_label) - scroll = ctk.CTkScrollableFrame(tab, fg_color="transparent") - scroll.pack(fill="both", expand=True, padx=4, pady=4) - for c in range(3): - scroll.grid_columnconfigure(c, weight=1) - self.tab_scrolls[p_key] = scroll - - self.update_data() - - def _trigger_action(self, action: str, data: Any): - if self.on_action: - self.on_action(action, data) - - def _refresh_all_quotas(self): - self.refresh_all_btn.configure(text="↻ Обновление...", state="disabled") - def _done(): - def _ui(): - self.refresh_all_btn.configure(text="↻ Обновить все", state="normal") - self.update_data() - self.after(0, _ui) - HermesRefreshScheduler.get().trigger_refresh_all(on_complete=_done) - - def _refresh_single_account(self, provider: str, profile_id: str): - HermesRefreshScheduler.get().trigger_refresh_account( - provider, profile_id, on_complete=lambda: self.after(0, self.update_data) + 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="Измените фильтры или подключите новый аккаунт.", + action_text="Подключить аккаунт", + action_cmd=lambda: self._emit("add_account", {}), ) - def _on_search(self, query: str): - self._search_query = query - self.update_data() + def _emit(self, action: str, profile: Any) -> None: + if not self.on_action: + return + if isinstance(profile, dict): + payload = profile + else: + payload = { + "profile_id": profile.profile_id, + "provider": profile.provider, + "display_name": profile.display_name, + } + self.on_action(action, payload) - def _on_filter(self, filter_val: str): - self._filter_status = filter_val - self.update_data() + def _set_search(self, value: str) -> None: + self._search = value.strip().lower() + self._render_visibility() - def _on_sort(self, sort_val: str): - self._sort_by = sort_val - self.update_data() + def _set_provider_filter(self, value: str) -> None: + self._provider_filter = value + self._render_visibility() - def update_data(self, snapshot: Optional[HubSnapshot] = None): - """Update views by reusing widgets and updating properties in place.""" + def _set_health_filter(self, value: str) -> None: + self._health_filter = value + self._render_visibility() + + def _set_role_filter(self, value: str) -> None: + self._role_filter = value + self._render_visibility() + + def _matches(self, profile: Any) -> bool: + if self._provider_filter != "Все провайдеры": + if PROVIDER_LABELS.get(profile.provider, profile.provider) != self._provider_filter: + return False + if self._health_filter == "Работают" and profile.health_state != "healthy": + return False + if self._health_filter == "Требуют входа" and profile.auth_state not in {"AUTH_REQUIRED", "AUTH_EXPIRED"}: + return False + if self._health_filter == "Проблемные" and profile.health_state in {"healthy", "not_configured", "cold_spare"}: + return False + roles = list(getattr(profile, "assigned_roles", []) or []) + if self._role_filter != "Все роли" and not any(self._role_filter.lower() in role.lower() for role in roles): + return False + haystack = " ".join( + [ + AccountCardWidget.resolve_identity(profile), + profile.display_name, + profile.provider_display_name, + *roles, + *(getattr(profile, "preferred_models", []) or []), + ] + ).lower() + return not self._search or self._search in haystack + + def _profiles(self) -> Iterable[Any]: + if not self._snapshot: + return [] + return (profile for profile in self._snapshot.all_profiles.values() if not profile.is_empty_slot) + + def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None: if not isinstance(snapshot, HubSnapshot): - # A non-snapshot (e.g. a legacy app_state dict) must fall back - # to the store rather than crash the view. - snapshot = HubStateStore.get().get_snapshot() + return + self._snapshot = snapshot + live_ids = {profile.profile_id for profile in self._profiles()} + for profile_id in list(self._cards): + if profile_id not in live_ids: + self._cards.pop(profile_id).destroy() + self.cards_destroyed += 1 - self._last_rendered_generation = snapshot.generation - profiles_by_prov = snapshot.profiles_by_provider + for profile in self._profiles(): + group = self._groups.get(profile.provider) + if group is None: + group = ProviderGroup(self.scroll, profile.provider) + self._groups[profile.provider] = group + card = self._cards.get(profile.profile_id) + if card is None: + card = AccountCardWidget( + group.body, + profile.profile_id, + AccountCardWidget.resolve_identity(profile), + profile.provider_display_name, + on_action=self._emit, + ) + self._cards[profile.profile_id] = card + self.cards_created += 1 + card.update_account(profile, snapshot.quotas.get(profile.profile_id)) + self._render_visibility() - for prov_key, scroll in self.tab_scrolls.items(): - profiles = profiles_by_prov.get(prov_key, []) + def _render_visibility(self) -> None: + if not self._snapshot: + self.empty.pack(fill="x", pady=Theme.SPACE_XL) + return + grouped: Dict[str, list[Any]] = defaultdict(list) + for profile in self._profiles(): + if self._matches(profile): + grouped[profile.provider].append(profile) + visible_total = 0 + for provider, group in self._groups.items(): + profiles = grouped.get(provider, []) + if not profiles: + group.pack_forget() + continue + group.pack(fill="x") + group.count.configure(text=f"{len(profiles)} аккаунт(а)") + for card in self._cards.values(): + if card.profile_model and card.profile_model.provider == provider: + card.grid_remove() + for index, profile in enumerate(profiles): + self._cards[profile.profile_id].grid( + row=index // Theme.ACCOUNT_CARD_COLUMNS, + column=index % Theme.ACCOUNT_CARD_COLUMNS, + padx=Theme.SPACE_XS, + pady=Theme.SPACE_XS, + sticky="nsew", + ) + visible_total += len(profiles) + if visible_total: + self.empty.pack_forget() + else: + self.empty.pack(fill="x", pady=Theme.SPACE_XL) - # Filter - filtered: List[ProfileViewModel] = [] - for p in profiles: - if self._search_query: - q = self._search_query.lower() - matches = ( - q in p.account_identity.lower() - or q in p.display_name.lower() - or q in p.plan.lower() - or any(q in m.lower() for m in p.preferred_models) - or any(q in r.lower() for r in p.assigned_roles) - ) - if not matches: - continue - - if self._filter_status == "Подключённые" and p.auth_state != "AUTHENTICATED": - continue - elif self._filter_status == "Требуют входа" and p.auth_state == "AUTHENTICATED": - continue - elif self._filter_status == "Квота исчерпана" and "quota" not in p.health_state: - continue - - filtered.append(p) - - if self._sort_by == "По имени": - filtered.sort(key=lambda x: x.display_name) - elif self._sort_by == "По статусу": - filtered.sort(key=lambda x: x.health_state) - - configured_profs = [p for p in filtered if not p.is_empty_slot] - empty_profs = [p for p in filtered if p.is_empty_slot] - - # Reusable placement - grid_idx = 0 - for p in configured_profs: - row_idx, col_idx = divmod(grid_idx, 3) - q_snap = snapshot.quotas.get(p.profile_id) - - if p.profile_id in self._cards: - card = self._cards[p.profile_id] - card.update_from_model(p, q_snap) - else: - card = AccountCardWidget( - scroll, - profile=p, - quota_snapshot=q_snap, - on_action=self._trigger_action, - on_refresh=self._refresh_single_account, - ) - self._cards[p.profile_id] = card - - card.grid(row=row_idx, column=col_idx, padx=6, pady=6, sticky="nsew") - grid_idx += 1 - - # Empty slots summary card - if empty_profs: - c_row = (grid_idx // 3) + 1 - slot_key = f"empty_{prov_key}" - if slot_key not in self._empty_cards: - summary_card = HubCard(scroll, border_color=Theme.BORDER_SUBTLE, fg_color=Theme.SURFACE_MUTED) - s_inner = ctk.CTkFrame(summary_card, fg_color="transparent") - s_inner.pack(fill="x", padx=16, pady=12) - - lbl = ctk.CTkLabel( - s_inner, - text=f"Свободные слоты: {len(empty_profs)} слотов доступно", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_SECONDARY, - ) - lbl.pack(side="left") - - HubButton( - s_inner, - text="+ Подключить аккаунт", - variant="primary", - height=Theme.HEIGHT_BTN_SM, - command=lambda k=prov_key: self._trigger_action("add_account", {"provider": k}), - ).pack(side="right") - - self._empty_cards[slot_key] = summary_card - else: - summary_card = self._empty_cards[slot_key] - - summary_card.grid(row=c_row, column=0, columnspan=3, padx=6, pady=10, sticky="ew") + def render_stats(self) -> Dict[str, int]: + return { + "cards_created": self.cards_created, + "cards_destroyed": self.cards_destroyed, + "quota_widgets_created": sum(card.widgets_created for card in self._cards.values()), + "quota_widgets_destroyed": sum(card.widgets_destroyed for card in self._cards.values()), + } diff --git a/src/antigravity_provider/router/ui/views/dashboard_view.py b/src/antigravity_provider/router/ui/views/dashboard_view.py index 5932ce0..2d6c082 100644 --- a/src/antigravity_provider/router/ui/views/dashboard_view.py +++ b/src/antigravity_provider/router/ui/views/dashboard_view.py @@ -1,141 +1,166 @@ -"""Hermes Hub — Dashboard View (Главный экран).""" +"""Snapshot-driven overview of readiness, routes and actionable account warnings.""" + from __future__ import annotations -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, 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.theme import Theme -from antigravity_provider.router.ui.components import ( - HubButton, - HubCard, - HubMetricCard, - HubProviderBadge, - HubSectionHeader, - HubStatusBadge, -) + + +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) + + def update_row(self, title: str, detail: str, status: str = "unknown") -> None: + self.title.configure(text=title) + self.detail.configure(text=detail) + color = ( + Theme.COLOR_POSITIVE + if status == "healthy" + else (Theme.COLOR_CAUTION if status in {"warning", "quota_low", "auth_expired"} else Theme.COLOR_NEUTRAL) + ) + self.configure(border_color=color) class DashboardView(ctk.CTkFrame): - def __init__(self, master: Any, app_state: Dict[str, Any], on_navigate: Optional[Callable] = None, on_action: Optional[Callable] = None, **kwargs): + def __init__( + self, + master: Any, + app_state: Optional[Dict[str, Any]] = None, + on_navigate: Optional[Callable] = None, + on_action: Optional[Callable] = None, + **kwargs, + ): super().__init__(master=master, fg_color="transparent", **kwargs) - self.app_state = app_state 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._build() - def _build(self): - # ── Header ── - header = HubSectionHeader( + def _build(self) -> None: + SectionHeader( self, - title="Главная панель управления", - subtitle="Multi-Agent & Multi-Provider Control Hub", - action_text="+ Добавить аккаунт", - action_cmd=lambda: self._trigger_action("add_account"), + 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)) + + 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="◇") + for index, metric in enumerate( + (self.system_metric, self.provider_metric, self.account_metric, self.role_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; выдуманные события не отображаются.", ) - header.pack(fill="x", padx=20, pady=(20, 16)) + self.events_gap.pack(fill="x", pady=(Theme.SECTION_GAP, 0)) - scroll = ctk.CTkScrollableFrame(self, fg_color="transparent") - scroll.pack(fill="both", expand=True, padx=15, pady=(0, 10)) + 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" + ) + 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) + ) + body = ctk.CTkFrame(card, fg_color="transparent") + body.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y)) + return body - data = self.app_state - stats = data.get("stats", {}) - total_profiles = stats.get("total_profiles", 0) - auth_profiles = stats.get("authenticated_profiles", 0) - providers_data = data.get("providers", {}) + @staticmethod + def _sync_rows(container: Any, cache: Dict[str, _SummaryRow], rows: Dict[str, tuple[str, str, str]]) -> None: + for key in list(cache): + if key not in rows: + 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) - ag_list = providers_data.get("antigravity", []) - codex_list = providers_data.get("openai-codex", []) - opencode_list = providers_data.get("opencode-go", []) + def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None: + if not isinstance(snapshot, HubSnapshot): + return + 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}") - ag_online = sum(1 for p in ag_list if p.get("authenticated")) - codex_online = sum(1 for p in codex_list if p.get("authenticated")) - opencode_online = sum(1 for p in opencode_list if p.get("authenticated")) + provider_rows = { + provider.provider_id: ( + provider.provider_name, + f"{provider.online_count}/{provider.connected_count} онлайн • {provider.auth_required_count} требуют входа", + "healthy" if provider.online_count else "warning", + ) + for provider in snapshot.providers + } + self._sync_rows(self.providers_card, self._provider_rows, provider_rows) - overall_state = "Healthy" if auth_profiles > 0 else "Degraded" + 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_display_name} • {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) - # ── 1. Top Metrics (4 cards) ── - metrics_grid = ctk.CTkFrame(scroll, fg_color="transparent") - metrics_grid.pack(fill="x", pady=(0, 16)) - for i in range(4): - metrics_grid.grid_columnconfigure(i, weight=1) + 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", + ) + 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) - m1 = HubMetricCard(metrics_grid, title="Агенты", value=str(total_profiles), subtext=f"{auth_profiles} активны в ролях", icon="👥", accent=True) - m1.grid(row=0, column=0, padx=6, sticky="nsew") - - m2 = HubMetricCard(metrics_grid, title="Аккаунты", value=f"{auth_profiles}/{total_profiles}", subtext="Авторизовано", icon="🔑") - m2.grid(row=0, column=1, padx=6, sticky="nsew") - - m3 = HubMetricCard(metrics_grid, title="Провайдеры", value="3", subtext="Antigravity, Codex, OpenCode", icon="🌐") - m3.grid(row=0, column=2, padx=6, sticky="nsew") - - m4 = HubMetricCard(metrics_grid, title="Состояние", value=overall_state, subtext="Fail-closed router active", icon="🛡️") - m4.grid(row=0, column=3, padx=6, sticky="nsew") - - # ── 2. Middle Row: Providers Status & Active Roles ── - mid_frame = ctk.CTkFrame(scroll, fg_color="transparent") - mid_frame.pack(fill="x", pady=(0, 16)) - mid_frame.grid_columnconfigure(0, weight=1) - mid_frame.grid_columnconfigure(1, weight=1) - - # 2A: Provider Status Breakdown - prov_card = HubCard(mid_frame) - prov_card.grid(row=0, column=0, padx=(0, 8), sticky="nsew") - - ctk.CTkLabel(prov_card, text="Состояние провайдеров", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=16, pady=(14, 10)) - - prov_rows = [ - ("Google Antigravity", f"{ag_online} / {len(ag_list)} онлайн", Theme.PROVIDER_ANTIGRAVITY), - ("OpenAI Codex", f"{codex_online} / {len(codex_list)} онлайн", Theme.PROVIDER_CODEX), - ("OpenCode Go", f"{opencode_online} / {len(opencode_list)} онлайн", Theme.PROVIDER_OPENCODE), - ] - for name, status_txt, col in prov_rows: - row = ctk.CTkFrame(prov_card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - row.pack(fill="x", padx=16, pady=4) - ctk.CTkLabel(row, text=f"◈ {name}", font=Theme.font_body_bold(), text_color=col).pack(side="left", padx=10, pady=8) - ctk.CTkLabel(row, text=status_txt, font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY).pack(side="right", padx=10) - - # 2B: Core Leadership & Orchestrator - lead_card = HubCard(mid_frame) - lead_card.grid(row=0, column=1, padx=(8, 0), sticky="nsew") - - ctk.CTkLabel(lead_card, text="Ключевые назначения", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=16, pady=(14, 10)) - - # Find main profile and orchestrator - main_ag_id = data.get("main_profiles", {}).get("antigravity", "—") - main_codex_id = data.get("main_profiles", {}).get("openai-codex", "—") - - key_items = [ - ("👑 Главный оркестратор", f"OpenAI Codex ({main_codex_id})", Theme.STATUS_ORCHESTRATOR), - ("⭐ Основной Antigravity", f"Google Antigravity ({main_ag_id})", Theme.STATUS_MAIN), - ("🔄 Резервный оркестратор", "Google Antigravity (ag-orch-fallback)", Theme.STATUS_FAILOVER), - ] - for title_str, val_str, clr in key_items: - row = ctk.CTkFrame(lead_card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - row.pack(fill="x", padx=16, pady=4) - ctk.CTkLabel(row, text=title_str, font=Theme.font_body_bold(), text_color=clr).pack(side="left", padx=10, pady=8) - ctk.CTkLabel(row, text=val_str, font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="right", padx=10) - - # ── 3. Quick Actions Banner ── - banner = HubCard(scroll, fg_color=Theme.DARK, border_color=Theme.BORDER_ACCENT) - banner.pack(fill="x", pady=(0, 10)) - - b_inner = ctk.CTkFrame(banner, fg_color="transparent") - b_inner.pack(fill="x", padx=16, pady=14) - - b_text = ctk.CTkFrame(b_inner, fg_color="transparent") - b_text.pack(side="left") - ctk.CTkLabel(b_text, text="Управление маршрутизацией и отказоустойчивостью", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT).pack(anchor="w") - ctk.CTkLabel(b_text, text="Автоматический failover при исчерпании квот и поддержка session affinity активны.", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(anchor="w") - - b_btns = ctk.CTkFrame(b_inner, fg_color="transparent") - b_btns.pack(side="right") - HubButton(b_btns, text="Команда агентов", variant="secondary", command=lambda: self._navigate("team")).pack(side="left", padx=4) - HubButton(b_btns, text="Маршрутизация", variant="secondary", command=lambda: self._navigate("routing")).pack(side="left", padx=4) - - def _navigate(self, target: str): + def _navigate(self, target: str) -> None: if self.on_navigate: self.on_navigate(target) - - def _trigger_action(self, action: str): - if self.on_action: - self.on_action(action, {}) diff --git a/src/antigravity_provider/router/ui/views/health_view.py b/src/antigravity_provider/router/ui/views/health_view.py index f127daa..fff4fec 100644 --- a/src/antigravity_provider/router/ui/views/health_view.py +++ b/src/antigravity_provider/router/ui/views/health_view.py @@ -1,139 +1,89 @@ -"""Hermes Hub — Health View (Диагностический Dashboard состояния системы с иконками).""" +"""Keyed system-health view using only the supplied snapshot.""" + from __future__ import annotations -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, Optional + import customtkinter as ctk +from antigravity_provider.router.state_store import HubSnapshot +from antigravity_provider.router.ui.components import HubCard, SectionHeader, StatusBadge from antigravity_provider.router.ui.theme import Theme -from antigravity_provider.router.ui.assets import AssetManager -from antigravity_provider.router.ui.components import ( - HubButton, - HubCard, - HubSectionHeader, - HubStatusBadge, -) -from antigravity_provider.router.unified_health import ( - UnifiedHealthService, - ProfileViewModel, - STATUS_HEALTHY, - STATUS_NOT_CONFIGURED, -) + + +class HealthProfileRow(HubCard): + def __init__(self, master: Any): + super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE) + self.identity = ctk.CTkLabel(self, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY) + self.identity.grid(row=0, column=0, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="w") + self.models = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED) + self.models.grid(row=0, column=1, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="w") + self.status = StatusBadge(self, "not_tested") + self.status.grid(row=0, column=2, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="e") + self.grid_columnconfigure(0, weight=2) + self.grid_columnconfigure(1, weight=3) + self.grid_columnconfigure(2, weight=1) + + def update_profile(self, profile: Any) -> None: + identity = profile.email or profile.account_identity or profile.profile_id + self.identity.configure(text=f"{identity}\n{profile.profile_id}") + families = ( + ", ".join(f"{family}: {state.status_label_ru}" for family, state in profile.model_states.items()) + or "Семейства моделей: Н/Д" + ) + self.models.configure(text=families) + self.status.set_status(profile.health_state, profile.health_label_ru) class HealthView(ctk.CTkFrame): - def __init__(self, master: Any, app_state: Dict[str, Any], on_refresh: Optional[Callable] = None, **kwargs): + def __init__( + self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_refresh: Optional[Callable] = None, **kwargs + ): super().__init__(master=master, fg_color="transparent", **kwargs) - self.app_state = app_state - self.on_refresh = on_refresh - self._build() - - def _build(self): - header = HubSectionHeader( + self._rows: Dict[str, HealthProfileRow] = {} + SectionHeader( self, - title="Диагностика и Состояние Системы", - subtitle="Детальный аудит доступности аккаунтов, авторизации, семейств моделей и квот", - action_text="🔄 Обновить аудит", - action_cmd=self.on_refresh, - ) - header.pack(fill="x", padx=20, pady=(16, 12)) - + title="Состояние системы", + subtitle="Авторизация, локальное здоровье и наблюдаемые состояния моделей", + action_text="Обновить аудит", + action_cmd=on_refresh, + ).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=15, pady=(0, 10)) + self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y)) + self.banner = HubCard(self.scroll, border_color=Theme.BORDER_ACCENT, fg_color=Theme.DARK) + self.banner.pack(fill="x", pady=(0, Theme.SECTION_GAP)) + self.title = ctk.CTkLabel( + self.banner, text="Ожидание snapshot", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT + ) + self.title.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS)) + self.summary = ctk.CTkLabel(self.banner, text="", font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY) + self.summary.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y)) + self.rows = ctk.CTkFrame(self.scroll, fg_color="transparent") + self.rows.pack(fill="x") - self.update_data() - - def update_data(self, snapshot: Optional[Any] = None): - for w in self.scroll.winfo_children(): - w.destroy() - - from antigravity_provider.router.state_store import HubSnapshot, HubStateStore + def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None: if not isinstance(snapshot, HubSnapshot): - # Callers have historically passed legacy app_state dicts here; a - # non-snapshot must fall back to the store, not crash the view. - snapshot = HubStateStore.get().get_snapshot() - + return readiness = snapshot.readiness - profiles_by_prov = snapshot.profiles_by_provider - - # Top Diagnostic Banner - banner = HubCard(self.scroll, border_color=Theme.BORDER_ACCENT, fg_color=Theme.DARK) - banner.pack(fill="x", pady=(0, 12)) - - b_top = ctk.CTkFrame(banner, fg_color="transparent") - b_top.pack(fill="x", padx=16, pady=(14, 4)) - - ctk.CTkLabel(b_top, text=f"СОСТОЯНИЕ СИСТЕМЫ: {readiness.title_ru.upper()}", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT).pack(side="left") - ctk.CTkLabel(b_top, text=f"Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} • Роли: {readiness.roles_ready_count}/{readiness.total_roles}", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="right") - - ctk.CTkLabel(banner, text=readiness.summary_ru, font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=16, pady=(0, 10)) - - if readiness.warnings: - w_box = ctk.CTkFrame(banner, fg_color="#3B2610", corner_radius=Theme.RADIUS_SM) - w_box.pack(fill="x", padx=16, pady=(0, 12)) - for w_txt in readiness.warnings: - ctk.CTkLabel(w_box, text=f"⚠️ {w_txt}", font=Theme.font_caption(), text_color=Theme.STATUS_WARNING).pack(anchor="w", padx=10, pady=2) - - # Provider Breakdown Tables - for prov_id, prov_label in [ - ("antigravity", "Google Antigravity"), - ("openai-codex", "OpenAI Codex"), - ("opencode-go", "OpenCode Go"), - ]: - profs = profiles_by_prov.get(prov_id, []) - - p_card = HubCard(self.scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE) - p_card.pack(fill="x", pady=8) - - # Provider Header with icon - p_hdr = ctk.CTkFrame(p_card, fg_color="transparent") - p_hdr.pack(fill="x", padx=16, pady=(12, 6)) - - p_img = AssetManager.get().get_provider_image(prov_id, size=(20, 20)) - if p_img: - ctk.CTkLabel(p_hdr, image=p_img, text="").pack(side="left", padx=(0, 6)) - - ctk.CTkLabel(p_hdr, text=prov_label, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - - # Table Header Row - th_row = ctk.CTkFrame(p_card, fg_color=Theme.BG_SIDEBAR, corner_radius=Theme.RADIUS_SM) - th_row.pack(fill="x", padx=14, pady=2) - th_row.grid_columnconfigure(0, weight=2) - th_row.grid_columnconfigure(1, weight=2) - th_row.grid_columnconfigure(2, weight=3) - th_row.grid_columnconfigure(3, weight=2) - th_row.grid_columnconfigure(4, weight=1) - - ctk.CTkLabel(th_row, text="СЛОТ / АККАУНТ", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).grid(row=0, column=0, padx=8, pady=6, sticky="w") - ctk.CTkLabel(th_row, text="АВТОРИЗАЦИЯ", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).grid(row=0, column=1, padx=8, pady=6, sticky="w") - ctk.CTkLabel(th_row, text="СЕМЕЙСТВА МОДЕЛЕЙ", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).grid(row=0, column=2, padx=8, pady=6, sticky="w") - ctk.CTkLabel(th_row, text="СТАТУС ЗДОРОВЬЯ", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).grid(row=0, column=3, padx=8, pady=6, sticky="w") - ctk.CTkLabel(th_row, text="ПРОВЕРКА", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).grid(row=0, column=4, padx=8, pady=6, sticky="w") - - # Table Rows - for p in profs: - tr = ctk.CTkFrame(p_card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - tr.pack(fill="x", padx=14, pady=2) - tr.grid_columnconfigure(0, weight=2) - tr.grid_columnconfigure(1, weight=2) - tr.grid_columnconfigure(2, weight=3) - tr.grid_columnconfigure(3, weight=2) - tr.grid_columnconfigure(4, weight=1) - - # Col 0: Account / Slot - ctk.CTkLabel(tr, text=f"{p.display_name}\n({p.profile_id})", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY, justify="left").grid(row=0, column=0, padx=8, pady=8, sticky="w") - - # Col 1: Auth - auth_txt = "✓ Подключён" if p.auth_state == "AUTHENTICATED" else ("⚠️ Требуется вход" if p.auth_state == "AUTH_EXPIRED" else "— Не добавлен") - auth_col = Theme.STATUS_HEALTHY if p.auth_state == "AUTHENTICATED" else Theme.TEXT_MUTED - ctk.CTkLabel(tr, text=f"{auth_txt}\n{p.account_identity}", font=Theme.font_caption(), text_color=auth_col, justify="left").grid(row=0, column=1, padx=8, pady=8, sticky="w") - - # Col 2: Model Families - fams_txt = "\n".join(f"• {k}: {v.status_label_ru}" for k, v in list(p.model_states.items())[:2]) or "default" - ctk.CTkLabel(tr, text=fams_txt, font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY, justify="left").grid(row=0, column=2, padx=8, pady=8, sticky="w") - - # Col 3: Status - st_col = Theme.STATUS_HEALTHY if p.health_state == STATUS_HEALTHY else (Theme.STATUS_WARNING if "quota" in p.health_state or "auth" in p.health_state else (Theme.TEXT_MUTED if p.health_state == STATUS_NOT_CONFIGURED else Theme.STATUS_ERROR)) - ctk.CTkLabel(tr, text=f"● {p.health_label_ru}", font=Theme.font_caption(), text_color=st_col).grid(row=0, column=3, padx=8, pady=8, sticky="w") - - # Col 4: Last check - ctk.CTkLabel(tr, text=p.last_checked_at or "—", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).grid(row=0, column=4, padx=8, pady=8, sticky="w") + self.title.configure(text=readiness.title_ru) + warnings = " • ".join(readiness.warnings) if readiness.warnings else "Предупреждений нет" + self.summary.configure( + text=( + f"{readiness.summary_ru}\n" + f"Аккаунты {readiness.accounts_connected_count}/{readiness.total_accounts} • " + f"Роли {readiness.roles_ready_count}/{readiness.total_roles} • {warnings}" + ) + ) + live = set(snapshot.all_profiles) + for profile_id in list(self._rows): + if profile_id not in live: + self._rows.pop(profile_id).destroy() + ordered = sorted(snapshot.all_profiles.values(), key=lambda profile: (profile.provider, profile.display_name)) + for index, profile in enumerate(ordered): + row = self._rows.get(profile.profile_id) + if row is None: + row = HealthProfileRow(self.rows) + self._rows[profile.profile_id] = row + row.update_profile(profile) + row.grid(row=index, column=0, sticky="ew", pady=Theme.SPACE_XS) + self.rows.grid_columnconfigure(0, weight=1) diff --git a/src/antigravity_provider/router/ui/views/logs_view.py b/src/antigravity_provider/router/ui/views/logs_view.py index d652702..16257ef 100644 --- a/src/antigravity_provider/router/ui/views/logs_view.py +++ b/src/antigravity_provider/router/ui/views/logs_view.py @@ -1,104 +1,34 @@ -"""Hermes Hub — Logs View (Журнал событий и аудит маршрутизации).""" +"""Event-log screen with honest handling of the current snapshot contract gap.""" + from __future__ import annotations -import os -from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, 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.theme import Theme -from antigravity_provider.router.ui.components import ( - HubButton, - HubCard, - HubSectionHeader, -) -from antigravity_provider.router.unified_health import EventLogService class LogsView(ctk.CTkFrame): def __init__(self, master: Any, **kwargs): super().__init__(master=master, fg_color="transparent", **kwargs) - self.filter_category: Optional[str] = None - self._build() - - def _build(self): - header = HubSectionHeader( + SectionHeader( self, - title="Журнал Событий и Аудит", - subtitle="История подключений, переключений маршрутизатора, исчерпания квот и failover", - action_text="🔄 Обновить", - action_cmd=self._refresh_events, + title="Журнал событий", + subtitle="Только события, переданные через единый snapshot-контракт", + ).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM)) + self.empty = EmptyState( + self, + title="Журнал: Н/Д", + message=( + "HubSnapshot пока не содержит события журнала. Экран не читает файлы и backend " + "напрямую и не показывает выдуманную историю." + ), ) - header.pack(fill="x", padx=20, pady=(16, 12)) + self.empty.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=Theme.SPACE_XL) - # Filter Buttons Row - filters_row = ctk.CTkFrame(self, fg_color="transparent") - filters_row.pack(fill="x", padx=20, pady=(0, 8)) - - cats = [ - (None, "Все события"), - ("account", "Аккаунты"), - ("quota", "Квоты"), - ("routing", "Маршрутизация"), - ("system", "Система"), - ] - - self.filter_btns: Dict[Optional[str], ctk.CTkButton] = {} - for c_id, c_lbl in cats: - btn = ctk.CTkButton( - filters_row, - text=c_lbl, - width=110, - height=28, - fg_color=Theme.SURFACE if c_id is None else "transparent", - hover_color=Theme.SURFACE_HOVER, - text_color=Theme.TEXT_ACCENT if c_id is None else Theme.TEXT_SECONDARY, - font=Theme.font_micro(), - corner_radius=Theme.RADIUS_SM, - command=lambda cid=c_id: self._set_filter(cid), - ) - btn.pack(side="left", padx=(0, 6)) - self.filter_btns[c_id] = btn - - # Events Scroll Area - self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent") - self.scroll.pack(fill="both", expand=True, padx=15, pady=(0, 10)) - - self._refresh_events() - - def _set_filter(self, cat: Optional[str]): - self.filter_category = cat - for cid, btn in self.filter_btns.items(): - if cid == cat: - btn.configure(fg_color=Theme.SURFACE, text_color=Theme.TEXT_ACCENT) - else: - btn.configure(fg_color="transparent", text_color=Theme.TEXT_SECONDARY) - self._refresh_events() - - def _refresh_events(self): - for w in self.scroll.winfo_children(): - w.destroy() - - events = EventLogService.get().get_events(limit=50, category=self.filter_category) - - if not events: - ctk.CTkLabel(self.scroll, text="Нет записанных событий", font=Theme.font_body(), text_color=Theme.TEXT_MUTED).pack(pady=40) + def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None: + if not isinstance(snapshot, HubSnapshot): return - - for ev in events: - card = HubCard(self.scroll, border_color=Theme.BORDER_SUBTLE, fg_color=Theme.SURFACE) - card.pack(fill="x", pady=3) - - top = ctk.CTkFrame(card, fg_color="transparent") - top.pack(fill="x", padx=12, pady=(8, 2)) - - dot_col = Theme.STATUS_HEALTHY if ev.level == "success" else (Theme.STATUS_WARNING if ev.level == "warning" else (Theme.STATUS_ERROR if ev.level == "error" else Theme.TEXT_MUTED)) - ctk.CTkLabel(top, text=f"● [{ev.category.upper()}]", font=Theme.font_micro(), text_color=dot_col).pack(side="left") - ctk.CTkLabel(top, text=ev.timestamp, font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED).pack(side="right") - - ctk.CTkLabel(card, text=ev.message, font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY, wraplength=760, justify="left").pack(anchor="w", padx=12, pady=(0, 8)) - - if ev.details: - d_box = ctk.CTkFrame(card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - d_box.pack(fill="x", padx=12, pady=(0, 8)) - ctk.CTkLabel(d_box, text=ev.details, font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED, justify="left").pack(padx=8, pady=4, anchor="w") diff --git a/src/antigravity_provider/router/ui/views/providers_view.py b/src/antigravity_provider/router/ui/views/providers_view.py index 836cefa..94d014f 100644 --- a/src/antigravity_provider/router/ui/views/providers_view.py +++ b/src/antigravity_provider/router/ui/views/providers_view.py @@ -1,103 +1,81 @@ -"""Hermes Hub — Providers View (Реальные метрики провайдеров и моделей).""" +"""Provider summaries updated by stable provider id.""" + from __future__ import annotations -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, Optional + import customtkinter as ctk +from antigravity_provider.router.state_store import HubSnapshot +from antigravity_provider.router.ui.components import HubCard, SectionHeader from antigravity_provider.router.ui.theme import Theme -from antigravity_provider.router.ui.components import ( - HubButton, - HubCard, - HubSectionHeader, - HubStatusBadge, -) -from antigravity_provider.router.unified_health import ( - UnifiedHealthService, - ProviderSummary, -) + + +class ProviderCard(HubCard): + def __init__(self, master: Any): + super().__init__(master) + top = ctk.CTkFrame(self, fg_color="transparent") + top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM)) + self.title = ctk.CTkLabel(top, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY) + self.title.pack(side="left") + self.updated = ctk.CTkLabel(top, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) + self.updated.pack(side="right") + self.stats = ctk.CTkLabel(self, text="", font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY) + self.stats.pack(anchor="w", padx=Theme.CARD_PAD_X) + self.models = ctk.CTkLabel( + self, + text="", + font=Theme.font_mono_sm(), + text_color=Theme.TEXT_PRIMARY, + wraplength=900, + justify="left", + ) + self.models.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y)) + + def update_provider(self, summary: Any) -> None: + self.title.configure(text=summary.provider_name) + self.updated.configure(text=f"Обновлено: {summary.last_refresh_at or 'Н/Д'}") + self.stats.configure( + text=( + f"Онлайн {summary.online_count}/{summary.connected_count} • " + f"требуют входа {summary.auth_required_count} • " + f"квота исчерпана {summary.quota_exhausted_count} • " + f"холодный резерв {summary.cold_spare_count}" + ) + ) + self.models.configure( + text="Модели: " + (" • ".join(summary.discovered_models) if summary.discovered_models else "Н/Д") + ) class ProvidersView(ctk.CTkFrame): - def __init__(self, master: Any, app_state: Dict[str, Any], on_action: Optional[Callable] = None, **kwargs): + def __init__( + self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs + ): super().__init__(master=master, fg_color="transparent", **kwargs) - self.app_state = app_state self.on_action = on_action - self._build() - - def _build(self): - header = HubSectionHeader( + self._cards: Dict[str, ProviderCard] = {} + SectionHeader( self, - title="Провайдеры ИИ и Модели", - subtitle="Сводка доступности адаптеров, квот и обнаруженных языковых моделей", - action_text="🔄 Обновить модели", - action_cmd=self._refresh_models, - ) - header.pack(fill="x", padx=20, pady=(16, 12)) - + title="Провайдеры и модели", + 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=15, pady=(0, 10)) + self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y)) - self.update_data() - - def update_data(self, snapshot: Optional[Any] = None): - for w in self.scroll.winfo_children(): - w.destroy() - - from antigravity_provider.router.state_store import HubSnapshot, HubStateStore + def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None: if not isinstance(snapshot, HubSnapshot): - # Callers have historically passed legacy app_state dicts here; a - # non-snapshot must fall back to the store, not crash the view. - snapshot = HubStateStore.get().get_snapshot() - - summaries = snapshot.providers - - for s in summaries: - col = Theme.PROVIDER_ANTIGRAVITY if "antigravity" in s.provider_id else (Theme.PROVIDER_CODEX if "codex" in s.provider_id else Theme.PROVIDER_OPENCODE) - - card = HubCard(self.scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE) - card.pack(fill="x", pady=8) - - # Top Header - top = ctk.CTkFrame(card, fg_color="transparent") - top.pack(fill="x", padx=16, pady=(14, 4)) - - ctk.CTkLabel(top, text=s.provider_name, font=Theme.font_heading(), text_color=col).pack(side="left") - ctk.CTkLabel(top, text=f"Обновлено: {s.last_refresh_at}", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(side="right") - - # Metrics Row (4 mini cards) - metrics_row = ctk.CTkFrame(card, fg_color="transparent") - metrics_row.pack(fill="x", padx=16, pady=6) - for i in range(4): - metrics_row.grid_columnconfigure(i, weight=1) - - p1 = ctk.CTkFrame(metrics_row, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - p1.grid(row=0, column=0, padx=4, sticky="nsew") - ctk.CTkLabel(p1, text="ПОДКЛЮЧЕНО", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(6, 1)) - ctk.CTkLabel(p1, text=f"{s.connected_count} / {s.total_slots}", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY).pack(pady=(0, 6)) - - p2 = ctk.CTkFrame(metrics_row, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - p2.grid(row=0, column=1, padx=4, sticky="nsew") - ctk.CTkLabel(p2, text="В СТРОЮ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(6, 1)) - ctk.CTkLabel(p2, text=str(s.online_count), font=Theme.font_body_bold(), text_color=Theme.STATUS_HEALTHY).pack(pady=(0, 6)) - - p3 = ctk.CTkFrame(metrics_row, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - p3.grid(row=0, column=2, padx=4, sticky="nsew") - ctk.CTkLabel(p3, text="ТРЕБУЮТ ВХОДА", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(6, 1)) - ctk.CTkLabel(p3, text=str(s.auth_required_count), font=Theme.font_body_bold(), text_color=Theme.STATUS_WARNING if s.auth_required_count > 0 else Theme.TEXT_MUTED).pack(pady=(0, 6)) - - p4 = ctk.CTkFrame(metrics_row, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - p4.grid(row=0, column=3, padx=4, sticky="nsew") - ctk.CTkLabel(p4, text="ХОЛОДНЫЙ РЕЗЕРВ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(pady=(6, 1)) - ctk.CTkLabel(p4, text=str(s.cold_spare_count), font=Theme.font_body_bold(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 6)) - - # Discovered Models list - m_box = ctk.CTkFrame(card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - m_box.pack(fill="x", padx=16, pady=(4, 12)) - - ctk.CTkLabel(m_box, text="Доступные проверенные модели:", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(anchor="w", padx=10, pady=(6, 2)) - models_str = " • ".join(s.discovered_models) if s.discovered_models else "Модели не обнаружены" - ctk.CTkLabel(m_box, text=models_str, font=Theme.font_mono_sm(), text_color=Theme.TEXT_PRIMARY, wraplength=700, justify="left").pack(anchor="w", padx=10, pady=(0, 8)) - - def _refresh_models(self): - if self.on_action: - self.on_action("refresh_data", {}) + return + live = {summary.provider_id for summary in snapshot.providers} + for provider_id in list(self._cards): + if provider_id not in live: + self._cards.pop(provider_id).destroy() + for summary in snapshot.providers: + card = self._cards.get(summary.provider_id) + if card is None: + card = ProviderCard(self.scroll) + card.pack(fill="x", pady=Theme.SPACE_XS) + self._cards[summary.provider_id] = card + card.update_provider(summary) diff --git a/src/antigravity_provider/router/ui/views/routing_view.py b/src/antigravity_provider/router/ui/views/routing_view.py index 762e2f8..3d32b73 100644 --- a/src/antigravity_provider/router/ui/views/routing_view.py +++ b/src/antigravity_provider/router/ui/views/routing_view.py @@ -1,154 +1,93 @@ -"""Hermes Hub — Routing View with Reusable Pipeline & Node Widgets.""" +"""Keyed failover-chain view driven exclusively by RolePipeline objects.""" + from __future__ import annotations -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, Optional + import customtkinter as ctk +from antigravity_provider.router.state_store import HubSnapshot +from antigravity_provider.router.ui.components import ActionButton, HubCard, RouteTargetWidget, SectionHeader from antigravity_provider.router.ui.theme import Theme -from antigravity_provider.router.ui.assets import AssetManager -from antigravity_provider.router.ui.components import ( - HubCard, - HubSectionHeader, -) -from antigravity_provider.router.unified_health import ( - RolePipeline, - PipelineNode, - STATUS_HEALTHY, -) -from antigravity_provider.router.state_store import HubStateStore, HubSnapshot -from antigravity_provider.router.event_bus import ( - EventBus, - EVENT_ROUTING_UPDATED, -) +from antigravity_provider.router.unified_health import RolePipeline class RoutingRoleWidget(HubCard): - """Reusable visual pipeline widget for a specific role.""" - - def __init__(self, parent: Any, pipeline: RolePipeline, **kwargs): - super().__init__(parent, border_color=Theme.BORDER, fg_color=Theme.SURFACE, **kwargs) + def __init__(self, master: Any, pipeline: RolePipeline, on_action: Optional[Callable] = None, **kwargs): + super().__init__(master, **kwargs) self.pipeline = pipeline - self._build() + self.on_action = on_action + self._nodes: Dict[str, RouteTargetWidget] = {} + top = ctk.CTkFrame(self, fg_color="transparent") + top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM)) + self.title = ctk.CTkLabel(top, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY) + self.title.pack(side="left") + self.meta = ctk.CTkLabel(top, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED) + self.meta.pack(side="left", padx=Theme.SPACE_SM) + ActionButton( + top, + text="Настроить", + variant="secondary", + width=90, + command=lambda: self.on_action and self.on_action("edit_route", {"role_id": self.pipeline.role_id}), + ).pack(side="right") + self.chain = ctk.CTkFrame(self, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) + self.chain.pack(fill="x", padx=Theme.CARD_PAD_X) + self.footer = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY) + self.footer.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, Theme.CARD_PAD_Y)) self.update_from_pipeline(pipeline) - def _build(self): - # 1. Top row - self.top_row = ctk.CTkFrame(self, fg_color="transparent") - self.top_row.pack(fill="x", padx=16, pady=(14, 4)) - - self.title_lbl = ctk.CTkLabel(self.top_row, text="", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT) - self.title_lbl.pack(side="left") - - self.role_id_lbl = ctk.CTkLabel(self.top_row, text="", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED) - self.role_id_lbl.pack(side="left", padx=(8, 0)) - - self.meta_top_lbl = ctk.CTkLabel(self.top_row, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY) - self.meta_top_lbl.pack(side="right") - - # 2. Visualizer Box - self.pipeline_box = ctk.CTkFrame(self, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - self.pipeline_box.pack(fill="x", padx=16, pady=6) - - self.p_inner = ctk.CTkFrame(self.pipeline_box, fg_color="transparent") - self.p_inner.pack(fill="x", padx=12, pady=10) - - # 3. Bottom status row - self.bottom_row = ctk.CTkFrame(self, fg_color="transparent") - self.bottom_row.pack(fill="x", padx=16, pady=(4, 12)) - - self.act_route_lbl = ctk.CTkLabel(self.bottom_row, text="", font=Theme.font_caption()) - self.act_route_lbl.pack(side="left") - - self.failover_lbl = ctk.CTkLabel(self.bottom_row, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) - self.failover_lbl.pack(side="right") - - def update_from_pipeline(self, pipe: RolePipeline) -> None: - self.pipeline = pipe - - self.title_lbl.configure(text=pipe.role_name_ru) - self.role_id_lbl.configure(text=f"({pipe.role_id})") - - affinity_str = "Session Affinity ON" if pipe.session_affinity else "Affinity OFF" - self.meta_top_lbl.configure(text=f"Модель: {pipe.default_model} • {affinity_str}") - - # Update nodes inside p_inner - for w in self.p_inner.winfo_children(): - w.destroy() - - for idx, node in enumerate(pipe.nodes): - if idx > 0: - ctk.CTkLabel(self.p_inner, text=" ➔ ", font=("Segoe UI", 15, "bold"), text_color=Theme.TEXT_MUTED).pack(side="left", padx=4) - - node_border = Theme.BORDER_ACCENT if node.is_active else Theme.BORDER - node_fg = Theme.DARK if node.is_active else Theme.SURFACE - - node_card = HubCard(self.p_inner, border_color=node_border, fg_color=node_fg, corner_radius=Theme.RADIUS_SM) - node_card.pack(side="left", padx=2) - - n_top = ctk.CTkFrame(node_card, fg_color="transparent") - n_top.pack(fill="x", padx=10, pady=(6, 2)) - - p_img = AssetManager.get().get_provider_image(node.provider, size=(16, 16)) - if p_img: - ctk.CTkLabel(n_top, image=p_img, text="").pack(side="left", padx=(0, 4)) - - dot_col = Theme.STATUS_HEALTHY if node.status == STATUS_HEALTHY else Theme.STATUS_WARNING - ctk.CTkLabel(n_top, text="●", font=("Segoe UI", 11, "bold"), text_color=dot_col).pack(side="left", padx=(0, 4)) - ctk.CTkLabel(n_top, text=node.display_name, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - - rank_txt = "Primary" if idx == 0 else f"Fallback {idx}" - ctk.CTkLabel(node_card, text=f"{rank_txt} • {node.model}", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(padx=10, pady=(0, 6), anchor="w") - - # Bottom Route Notice - act_str = f"Текущий активный маршрут: [{pipe.active_profile_id}]" if pipe.active_profile_id else "Все маршруты исчерпаны!" - act_col = Theme.TEXT_SECONDARY if pipe.active_profile_id else Theme.STATUS_ERROR - self.act_route_lbl.configure(text=act_str, text_color=act_col) - self.failover_lbl.configure(text=f"Максимум попыток failover: {pipe.max_failover}") + def update_from_pipeline(self, pipeline: RolePipeline) -> None: + self.pipeline = pipeline + self.title.configure(text=pipeline.role_name_ru) + affinity = "session affinity" if pipeline.session_affinity else "без affinity" + self.meta.configure(text=f"{pipeline.default_model} • {affinity}") + live = {node.profile_id for node in pipeline.nodes} + for profile_id in list(self._nodes): + if profile_id not in live: + self._nodes.pop(profile_id).destroy() + for index, node in enumerate(pipeline.nodes): + rank = "Основной" if index == 0 else f"Резерв {index}" + subtitle = f"{node.provider_display_name} • {node.model}" + widget = self._nodes.get(node.profile_id) + if widget is None: + widget = RouteTargetWidget(self.chain, rank, node.display_name, subtitle) + self._nodes[node.profile_id] = widget + widget.update_target(rank, node.display_name, subtitle, "active" if node.is_active else node.status) + widget.grid(row=0, column=index, padx=Theme.SPACE_XS, pady=Theme.SPACE_SM, sticky="nsew") + self.chain.grid_columnconfigure(index, weight=1) + active = next((node for node in pipeline.nodes if node.is_active), None) + self.footer.configure( + text=(f"Активен: {active.display_name} • причина переключения: Н/Д" if active else "Активный узел: Н/Д") + ) class RoutingView(ctk.CTkFrame): - def __init__(self, master: Any, routing_data: Optional[Dict[str, Any]] = None, **kwargs): + def __init__( + self, master: Any, routing_data: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs + ): super().__init__(master=master, fg_color="transparent", **kwargs) - self.routing_data = routing_data or {} + self.on_action = on_action self._role_widgets: Dict[str, RoutingRoleWidget] = {} - self._last_rendered_generation = 0 - - self._build() - self._subscribe_events() - - def _subscribe_events(self): - EventBus.get().subscribe(EVENT_ROUTING_UPDATED, self._on_routing_event) - - def _on_routing_event(self, event_name: str, payload: Any): - self.after(0, self.update_data) - - def _build(self): - header = HubSectionHeader( + SectionHeader( self, - title="Маршрутизация и Цепочки Failover", - subtitle="Политики выбора провайдеров для ролей агентов, приоритеты и сессионная привязка", - ) - header.pack(fill="x", padx=20, pady=(16, 12)) - + title="Маршрутизация", + subtitle="Основной → резерв 1 → резерв 2 → резерв 3; без выдуманных причин failover", + ).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=15, pady=(0, 10)) + self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y)) - self.update_data() - - def update_data(self, snapshot: Optional[HubSnapshot] = None): - """Update routing view using cached snapshot and reusable role widgets.""" + def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None: if not isinstance(snapshot, HubSnapshot): - # A non-snapshot (e.g. a legacy app_state dict) must fall back - # to the store rather than crash the view. - snapshot = HubStateStore.get().get_snapshot() - - self._last_rendered_generation = snapshot.generation - pipelines = snapshot.routing - - for rname, pipe in pipelines.items(): - if rname in self._role_widgets: - self._role_widgets[rname].update_from_pipeline(pipe) - else: - widget = RoutingRoleWidget(self.scroll, pipe) - widget.pack(fill="x", pady=6) - self._role_widgets[rname] = widget + return + live_roles = set(snapshot.routing) + for role_id in list(self._role_widgets): + if role_id not in live_roles: + self._role_widgets.pop(role_id).destroy() + for role_id, pipeline in snapshot.routing.items(): + widget = self._role_widgets.get(role_id) + if widget is None: + widget = RoutingRoleWidget(self.scroll, pipeline, self.on_action) + widget.pack(fill="x", pady=Theme.SPACE_XS) + self._role_widgets[role_id] = widget + widget.update_from_pipeline(pipeline) diff --git a/src/antigravity_provider/router/ui/views/settings_view.py b/src/antigravity_provider/router/ui/views/settings_view.py index 08f2eb5..658f9c3 100644 --- a/src/antigravity_provider/router/ui/views/settings_view.py +++ b/src/antigravity_provider/router/ui/views/settings_view.py @@ -1,264 +1,93 @@ -"""Hermes Hub — Settings View (Интерактивные параметры, настройки и обновления).""" +"""Presentation-only settings screen; persistence is delegated to the app action layer.""" + from __future__ import annotations -import json -import os -import subprocess -import threading -from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Callable, Dict, Optional + import customtkinter as ctk -from antigravity_provider import paths -from antigravity_provider.version import __version__, CHANNEL -from antigravity_provider.updater import UpdateManager, UpdateCheckResult +from antigravity_provider.router.ui.components import ActionButton, HubCard, SectionHeader from antigravity_provider.router.ui.theme import Theme -from antigravity_provider.router.ui.components import ( - HubButton, - HubCard, - HubSectionHeader, -) +from antigravity_provider.version import CHANNEL, __version__ class SettingsView(ctk.CTkFrame): - def __init__(self, master: Any, **kwargs): + def __init__(self, master: Any, on_action: Optional[Callable] = None, **kwargs): super().__init__(master=master, fg_color="transparent", **kwargs) - self.settings_file = paths.get_hermes_home() / "hub_settings.json" - self._load_settings() - self._build() - - def _load_settings(self): - self.settings = { - "session_affinity": True, - "failover_attempts": "3", - "model_timeout_sec": "120", - "auto_assignment": True, - "auto_failover": True, - "auto_return_primary": True, - "auto_monitoring": True, - "monitoring_interval_min": "5", - "auto_check_updates": True, - } - if self.settings_file.exists(): - try: - data = json.loads(self.settings_file.read_text(encoding="utf-8")) - self.settings.update(data) - except Exception: - pass - - def _save_settings(self): - try: - if hasattr(self, "aff_sw"): - self.settings["session_affinity"] = bool(self.aff_sw.get()) - if hasattr(self, "fo_sw"): - self.settings["auto_failover"] = bool(self.fo_sw.get()) - if hasattr(self, "fo_menu"): - self.settings["failover_attempts"] = str(self.fo_menu.get()) - if hasattr(self, "ret_sw"): - self.settings["auto_return_primary"] = bool(self.ret_sw.get()) - if hasattr(self, "same_acc_sw"): - self.settings["prefer_same_account_model_fallback"] = bool(self.same_acc_sw.get()) - if hasattr(self, "refr_menu"): - lbl = str(self.refr_menu.get()) - self.settings["quota_refresh_interval_label"] = lbl - interval_sec_map = { - "Выкл": 0, - "1 мин": 60, - "5 мин": 300, - "10 мин": 600, - "30 мин": 1800, - } - sec = interval_sec_map.get(lbl, 300) - self.settings["quota_refresh_interval_sec"] = sec - from antigravity_provider.router.quota_collector import AccountQuotaService - AccountQuotaService.get().set_refresh_interval(sec) - - self.settings_file.parent.mkdir(parents=True, exist_ok=True) - self.settings_file.write_text(json.dumps(self.settings, indent=2), encoding="utf-8") - except Exception: - pass - - def _build(self): - header = HubSectionHeader( + self.on_action = on_action + SectionHeader( self, - title="Настройки Hermes Hub", - subtitle="Конфигурация параметров маршрутизации, восстановления, мониторинга и обновлений", - action_text="💾 Сохранить", - action_cmd=self._save_settings, - ) - header.pack(fill="x", padx=20, pady=(16, 12)) - + title="Настройки", + subtitle="Маршрутизация, мониторинг и обновления", + action_text="Сохранить", + action_cmd=self._save, + ).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=15, pady=(0, 10)) - - # ── 1. Routing & Failover ── - c1 = HubCard(scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE) - c1.pack(fill="x", pady=6) - ctk.CTkLabel(c1, text="Маршрутизация и Отказоустойчивость", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=16, pady=(12, 6)) - - # Session Affinity switch - r1 = ctk.CTkFrame(c1, fg_color="transparent") - r1.pack(fill="x", padx=16, pady=4) - ctk.CTkLabel(r1, text="Сессионная привязка (Session Affinity)", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - self.aff_sw = ctk.CTkSwitch(r1, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT) - self.aff_sw.pack(side="right") - if self.settings.get("session_affinity"): - self.aff_sw.select() - - # Auto Failover switch - r2 = ctk.CTkFrame(c1, fg_color="transparent") - r2.pack(fill="x", padx=16, pady=4) - ctk.CTkLabel(r2, text="Автоматический Failover при исчерпании квот", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - self.fo_sw = ctk.CTkSwitch(r2, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT) - self.fo_sw.pack(side="right") - if self.settings.get("auto_failover"): - self.fo_sw.select() - - # Failover Attempts - r3 = ctk.CTkFrame(c1, fg_color="transparent") - r3.pack(fill="x", padx=16, pady=(4, 12)) - ctk.CTkLabel(r3, text="Лимит попыток failover на запрос", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - self.fo_menu = ctk.CTkOptionMenu( - r3, - values=["1", "2", "3", "4", "5"], - width=80, - height=28, - fg_color=Theme.SURFACE_MUTED, - button_color=Theme.ACCENT, - text_color=Theme.TEXT_PRIMARY, + scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y)) + routing = self._section(scroll, "Маршрутизация и отказоустойчивость") + self.affinity = self._switch(routing, "Сессионная привязка", True) + self.failover = self._switch(routing, "Автоматический failover", True) + self.same_account = self._switch(routing, "Сначала менять модель на том же аккаунте", True) + refresh = self._section(scroll, "Мониторинг") + row = ctk.CTkFrame(refresh, fg_color="transparent") + row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) + ctk.CTkLabel(row, text="Интервал обновления квот", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack( + side="left" ) - self.fo_menu.set(str(self.settings.get("failover_attempts", "3"))) - self.fo_menu.pack(side="right") - - # ── 2. Recovery & Monitoring ── - c2 = HubCard(scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE) - c2.pack(fill="x", pady=6) - ctk.CTkLabel(c2, text="Восстановление и Мониторинг", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=16, pady=(12, 6)) - - r4 = ctk.CTkFrame(c2, fg_color="transparent") - r4.pack(fill="x", padx=16, pady=4) - ctk.CTkLabel(r4, text="Возвращаться на основной аккаунт после сброса квоты", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - self.ret_sw = ctk.CTkSwitch(r4, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT) - self.ret_sw.pack(side="right") - if self.settings.get("auto_return_primary"): - self.ret_sw.select() - - # Same-account model fallback switch - r_same = ctk.CTkFrame(c2, fg_color="transparent") - r_same.pack(fill="x", padx=16, pady=4) - ctk.CTkLabel(r_same, text="Приоритет смены модели на том же аккаунте (Fallback внутри аккаунта)", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - self.same_acc_sw = ctk.CTkSwitch(r_same, text="", fg_color=Theme.SURFACE_MUTED, progress_color=Theme.ACCENT) - self.same_acc_sw.pack(side="right") - if self.settings.get("prefer_same_account_model_fallback", True): - self.same_acc_sw.select() - - # Quota background refresh interval - r_refr = ctk.CTkFrame(c2, fg_color="transparent") - r_refr.pack(fill="x", padx=16, pady=(4, 12)) - ctk.CTkLabel(r_refr, text="Фоновое автообновление квот провайдеров", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left") - - self.refr_menu = ctk.CTkOptionMenu( - r_refr, + self.interval = ctk.CTkOptionMenu( + row, values=["Выкл", "1 мин", "5 мин", "10 мин", "30 мин"], - width=100, - height=28, fg_color=Theme.SURFACE_MUTED, - button_color=Theme.ACCENT, - text_color=Theme.TEXT_PRIMARY, + button_color=Theme.SECONDARY, ) - self.refr_menu.set(str(self.settings.get("quota_refresh_interval_label", "5 мин"))) - self.refr_menu.pack(side="right") - - # ── 3. Updates & Release Channel ── - c_upd = HubCard(scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE) - c_upd.pack(fill="x", pady=6) - ctk.CTkLabel(c_upd, text="Обновления и Релизы", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=16, pady=(12, 6)) - - u_row = ctk.CTkFrame(c_upd, fg_color="transparent") - u_row.pack(fill="x", padx=16, pady=4) - ctk.CTkLabel( - u_row, - text=f"Текущая версия: v{__version__} • Канал: {CHANNEL.capitalize()}", + self.interval.set("5 мин") + self.interval.pack(side="right") + updates = self._section(scroll, "Обновления") + self.update_status = ctk.CTkLabel( + updates, + text=f"Версия {__version__} • канал {CHANNEL} • состояние обновления: Н/Д", font=Theme.font_body(), - text_color=Theme.TEXT_PRIMARY, - ).pack(side="left") - - self.upd_status_lbl = ctk.CTkLabel( - c_upd, - text="Проверка обновлений не выполнялась", - font=Theme.font_caption(), - text_color=Theme.TEXT_MUTED, + text_color=Theme.TEXT_SECONDARY, ) - self.upd_status_lbl.pack(anchor="w", padx=16, pady=(0, 8)) - - btns_upd = ctk.CTkFrame(c_upd, fg_color="transparent") - btns_upd.pack(fill="x", padx=16, pady=(0, 12)) - - self.check_upd_btn = HubButton( - btns_upd, - text="🔄 Проверить обновления", + self.update_status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) + ActionButton( + updates, + text="Проверить обновления", variant="secondary", - width=200, - height=Theme.HEIGHT_BTN_SM, - command=self._check_updates_click, + command=lambda: self.on_action and self.on_action("check_updates", {}), + ).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y)) + + @staticmethod + def _section(master: Any, title: str) -> HubCard: + card = HubCard(master) + card.pack(fill="x", pady=Theme.SPACE_XS) + 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.check_upd_btn.pack(side="left") + return card - # ── 4. Advanced / Paths ── - c3 = HubCard(scroll, border_color=Theme.BORDER, fg_color=Theme.DARK) - c3.pack(fill="x", pady=6) + @staticmethod + def _switch(master: Any, label: str, selected: bool) -> ctk.CTkSwitch: + row = ctk.CTkFrame(master, fg_color="transparent") + row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_XS) + ctk.CTkLabel(row, text=label, font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(side="left") + switch = ctk.CTkSwitch(row, text="", progress_color=Theme.ACCENT) + switch.pack(side="right") + if selected: + switch.select() + return switch - ctk.CTkLabel(c3, text="Дополнительно (Инструменты и Пути)", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT).pack(anchor="w", padx=16, pady=(12, 6)) - - hermes_home = paths.get_hermes_home() - - btns_row = ctk.CTkFrame(c3, fg_color="transparent") - btns_row.pack(fill="x", padx=16, pady=6) - - HubButton(btns_row, text="📁 Открыть папку данных", variant="secondary", width=180, command=lambda: self._open_folder(hermes_home)).pack(side="left", padx=(0, 8)) - HubButton(btns_row, text="📜 Открыть журнал логов", variant="secondary", width=180, command=lambda: self._open_folder(paths.get_logs_dir())).pack(side="left") - - paths_info = [ - ("Профили роутера:", str(paths.get_router_profiles_path())), - ("Состояние роутера:", str(paths.get_router_state_path())), - ("Файл логов:", str(paths.get_log_file())), - ] - for label, pstr in paths_info: - p_row = ctk.CTkFrame(c3, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) - p_row.pack(fill="x", padx=16, pady=2) - ctk.CTkLabel(p_row, text=label, font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="left", padx=8, pady=4) - ctk.CTkLabel(p_row, text=pstr, font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED).pack(side="right", padx=8) - - ctk.CTkLabel(c3, text="", font=Theme.font_micro()).pack(pady=4) - - def _check_updates_click(self): - self.upd_status_lbl.configure(text="Проверка наличия обновлений на GitHub...") - self.check_upd_btn.configure(state="disabled") - - def _worker(): - mgr = UpdateManager() - res = mgr.check_for_updates() - self.after(0, lambda: self._on_update_result(res)) - - threading.Thread(target=_worker, daemon=True).start() - - def _on_update_result(self, res: UpdateCheckResult): - self.check_upd_btn.configure(state="normal") - if res.error: - self.upd_status_lbl.configure(text=f"Ошибка проверки: {res.error}", text_color=Theme.STATUS_WARNING) - elif res.update_available and res.manifest: - self.upd_status_lbl.configure( - text=f"★ Доступна новая версия Hermes Hub v{res.manifest.version}!", - text_color=Theme.STATUS_HEALTHY, - ) - else: - self.upd_status_lbl.configure(text=f"✓ Установлена актуальная версия (v{__version__})", text_color=Theme.STATUS_HEALTHY) - - def _open_folder(self, path: Path): - try: - if path.exists(): - os.startfile(str(path)) - elif path.parent.exists(): - os.startfile(str(path.parent)) - except Exception: - pass + def _save(self) -> None: + if not self.on_action: + return + intervals = {"Выкл": 0, "1 мин": 60, "5 мин": 300, "10 мин": 600, "30 мин": 1800} + self.on_action( + "save_settings", + { + "session_affinity": bool(self.affinity.get()), + "auto_failover": bool(self.failover.get()), + "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), + }, + ) diff --git a/src/antigravity_provider/router/ui/views/team_view.py b/src/antigravity_provider/router/ui/views/team_view.py index c54f5a8..e109ac4 100644 --- a/src/antigravity_provider/router/ui/views/team_view.py +++ b/src/antigravity_provider/router/ui/views/team_view.py @@ -1,4 +1,5 @@ """Hermes Hub — Team View (Команда агентов и Dashboard с Unified Health v3).""" + from __future__ import annotations from typing import Any, Callable, Dict, List, Optional @@ -14,8 +15,6 @@ from antigravity_provider.router.ui.components import ( HubStatusBadge, ) from antigravity_provider.router.unified_health import ( - UnifiedHealthService, - SystemReadiness, AgentViewModel, STATUS_HEALTHY, STATUS_QUOTA_LOW, @@ -24,6 +23,7 @@ from antigravity_provider.router.unified_health import ( STATUS_NOT_CONFIGURED, STATUS_AUTH_EXPIRED, ) +from antigravity_provider.router.state_store import HubSnapshot class AgentCardWidget(HubCard): @@ -42,10 +42,14 @@ class AgentCardWidget(HubCard): self.role_lbl.pack(side="left") self.orch_pill = ctk.CTkFrame(self.line1, fg_color="#3D3522", corner_radius=Theme.RADIUS_SM) - self.orch_pill_lbl = ctk.CTkLabel(self.orch_pill, text="👑 ЛИДЕР РОУТЕРА", font=Theme.font_micro(), text_color=Theme.ACCENT) + self.orch_pill_lbl = ctk.CTkLabel( + self.orch_pill, text="👑 ЛИДЕР РОУТЕРА", font=Theme.font_micro(), text_color=Theme.ACCENT + ) self.orch_pill_lbl.pack(padx=5, pady=1) - self.status_dot = ctk.CTkLabel(self.line1, text="●", font=("Segoe UI", 13, "bold"), text_color=Theme.STATUS_HEALTHY) + self.status_dot = ctk.CTkLabel( + self.line1, text="●", font=("Segoe UI", 13, "bold"), text_color=Theme.STATUS_HEALTHY + ) self.status_dot.pack(side="right") # ── Line 2: Internal ID + Model ── @@ -67,7 +71,9 @@ class AgentCardWidget(HubCard): # ── Line 4: Identity / Account ── self.line4 = ctk.CTkFrame(self, fg_color="transparent") self.line4.pack(fill="x", padx=14, pady=(2, 4)) - self.identity_lbl = ctk.CTkLabel(self.line4, text="—", font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY) + self.identity_lbl = ctk.CTkLabel( + self.line4, text="—", font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY + ) self.identity_lbl.pack(anchor="w") # ── Line 5: Role Tag Pills ── @@ -88,7 +94,9 @@ class AgentCardWidget(HubCard): self.line6 = ctk.CTkFrame(self, fg_color="transparent") self.line6.pack(fill="x", padx=14, pady=(4, 12)) - self.status_str_lbl = ctk.CTkLabel(self.line6, text="● Работает", font=Theme.font_caption(), text_color=Theme.STATUS_HEALTHY) + self.status_str_lbl = ctk.CTkLabel( + self.line6, text="● Работает", font=Theme.font_caption(), text_color=Theme.STATUS_HEALTHY + ) self.status_str_lbl.pack(side="left") self.menu_btn = ctk.CTkButton( @@ -117,7 +125,15 @@ class AgentCardWidget(HubCard): self.orch_pill.pack_forget() # Dot color - dot_color = Theme.STATUS_HEALTHY if a.status == STATUS_HEALTHY else (Theme.STATUS_WARNING if "quota" in a.status or "auth" in a.status or "not_configured" in a.status else Theme.STATUS_ERROR) + dot_color = ( + Theme.STATUS_HEALTHY + if a.status == STATUS_HEALTHY + else ( + Theme.STATUS_WARNING + if "quota" in a.status or "auth" in a.status or "not_configured" in a.status + else Theme.STATUS_ERROR + ) + ) self.status_dot.configure(text_color=dot_color) # ID + Model @@ -145,12 +161,8 @@ class AgentCardWidget(HubCard): else: self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED) - # Identity & Model-Specific Quota - from antigravity_provider.router.quota_collector import AccountQuotaService - snap = AccountQuotaService.get().get_snapshot(a.provider, a.assigned_profile_id or "") - bucket = snap.get_bucket_for_model(a.model) if snap else None - quota_tail = f" • {bucket.formatted_remaining()}" if bucket else "" - self.identity_lbl.configure(text=f"{a.account_identity}{quota_tail}") + # AgentViewModel has account identity, but no active-session or quota fields. + self.identity_lbl.configure(text=a.account_identity or "Аккаунт: Н/Д") # Pills self.pill1_lbl.configure(text=a.role_id) @@ -183,25 +195,47 @@ class AgentCardWidget(HubCard): c.pack(fill="both", expand=True, padx=10, pady=10) ctk.CTkLabel(c, text=a.role_name_ru, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2)) - ctk.CTkLabel(c, text=f"Аккаунт: {a.account_identity} ({pid})", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED).pack(pady=(0, 10)) + ctk.CTkLabel( + c, text=f"Аккаунт: {a.account_identity} ({pid})", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED + ).pack(pady=(0, 10)) def _do(action_name: str): popup.destroy() if self.on_action: - self.on_action(action_name, {"profile_id": pid, "provider": a.provider, "display_name": a.assigned_display_name}) + self.on_action( + action_name, {"profile_id": pid, "provider": a.provider, "display_name": a.assigned_display_name} + ) - HubButton(c, text="⚡ Проверить (Тест)", variant="secondary", height=30, command=lambda: _do("test")).pack(fill="x", padx=12, pady=2) - HubButton(c, text="👑 Назначить главным оркестратором", variant="secondary", height=30, command=lambda: _do("set_orchestrator")).pack(fill="x", padx=12, pady=2) - HubButton(c, text="★ Сделать основным аккаунтом Hermes", variant="secondary", height=30, command=lambda: _do("set_main")).pack(fill="x", padx=12, pady=2) - HubButton(c, text="🔄 Перераспределить роли", variant="ghost", height=28, command=lambda: _do("auto_assign_all")).pack(fill="x", padx=12, pady=(2, 0)) + HubButton(c, text="⚡ Проверить (Тест)", variant="secondary", height=30, command=lambda: _do("test")).pack( + fill="x", padx=12, pady=2 + ) + HubButton( + c, + text="👑 Назначить главным оркестратором", + variant="secondary", + height=30, + command=lambda: _do("set_orchestrator"), + ).pack(fill="x", padx=12, pady=2) + HubButton( + c, + text="★ Сделать основным аккаунтом Hermes", + variant="secondary", + height=30, + command=lambda: _do("set_main"), + ).pack(fill="x", padx=12, pady=2) + HubButton( + c, text="🔄 Перераспределить роли", variant="ghost", height=28, command=lambda: _do("auto_assign_all") + ).pack(fill="x", padx=12, pady=(2, 0)) class TeamView(ctk.CTkFrame): - def __init__(self, master: Any, app_state: Dict[str, Any], on_action: Optional[Callable] = None, **kwargs): + def __init__( + self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs + ): super().__init__(master=master, fg_color="transparent", **kwargs) - self.app_state = app_state + self.app_state = app_state or {} self.on_action = on_action - self._card_widgets: List[AgentCardWidget] = [] + self._card_widgets: Dict[str, AgentCardWidget] = {} self._build_static_layout() self.update_data() @@ -261,7 +295,9 @@ class TeamView(ctk.CTkFrame): for i in range(4): metrics_grid.grid_columnconfigure(i, weight=1) - self.m1 = HubMetricCard(metrics_grid, title="АГЕНТЫ", value="0/6", subtext="готовы к работе", icon="👥", accent=True) + self.m1 = HubMetricCard( + metrics_grid, title="АГЕНТЫ", value="0/6", subtext="готовы к работе", icon="👥", accent=True + ) self.m1.grid(row=0, column=0, padx=6, sticky="nsew") self.m2 = HubMetricCard(metrics_grid, title="АККАУНТЫ", value="0/16", subtext="подключено", icon="💼") @@ -270,21 +306,36 @@ class TeamView(ctk.CTkFrame): self.m3 = HubMetricCard(metrics_grid, title="ПРОВАЙДЕРЫ", value="3/3", subtext="доступно", icon="⚛") self.m3.grid(row=0, column=2, padx=6, sticky="nsew") - self.m4 = HubMetricCard(metrics_grid, title="СОСТОЯНИЕ", value="Healthy", subtext="Все системы работают", icon="🛡️") + self.m4 = HubMetricCard( + metrics_grid, title="СОСТОЯНИЕ", value="Healthy", subtext="Все системы работают", icon="🛡️" + ) self.m4.grid(row=0, column=3, padx=6, sticky="nsew") - # ── 3. Cards Grid ── + # ── 3. Hierarchy: orchestrator → role agents ── + ctk.CTkLabel( + self.scroll, + text="ОРКЕСТРАТОР", + font=Theme.font_micro(), + text_color=Theme.TEXT_MUTED, + ).pack(anchor="w", padx=Theme.SPACE_XS) + self.orchestrator_grid = ctk.CTkFrame(self.scroll, fg_color="transparent") + self.orchestrator_grid.pack(fill="x", pady=(Theme.SPACE_XS, Theme.SECTION_GAP)) + self.orchestrator_grid.grid_columnconfigure(0, weight=1) + + ctk.CTkLabel( + self.scroll, + text="РОЛИ И АГЕНТЫ", + font=Theme.font_micro(), + text_color=Theme.TEXT_MUTED, + ).pack(anchor="w", padx=Theme.SPACE_XS) self.cards_grid = ctk.CTkFrame(self.scroll, fg_color="transparent") self.cards_grid.pack(fill="both", expand=True) for col_idx in range(3): self.cards_grid.grid_columnconfigure(col_idx, weight=1) def update_data(self, snapshot: Optional[Any] = None): - from antigravity_provider.router.state_store import HubSnapshot, HubStateStore if not isinstance(snapshot, HubSnapshot): - # Callers have historically passed legacy app_state dicts here; a - # non-snapshot must fall back to the store, not crash the view. - snapshot = HubStateStore.get().get_snapshot() + return readiness = snapshot.readiness agents = snapshot.agents @@ -302,20 +353,28 @@ class TeamView(ctk.CTkFrame): self.m4.val_label.configure(text=readiness.title_ru) self.m4.sub_label.configure(text=readiness.summary_ru) - # Ensure enough widgets in 3-column grid - while len(self._card_widgets) < len(agents): - idx = len(self._card_widgets) - row_idx, col_idx = divmod(idx, 3) - card = AgentCardWidget(self.cards_grid, on_action=self.on_action) - card.grid(row=row_idx, column=col_idx, padx=6, pady=6, sticky="nsew") - self._card_widgets.append(card) + live_roles = {agent.role_id for agent in agents} + for role_id in list(self._card_widgets): + if role_id not in live_roles: + self._card_widgets.pop(role_id).destroy() - for idx, agent in enumerate(agents): - self._card_widgets[idx].update_agent(agent) - self._card_widgets[idx].grid() + orchestrators = [agent for agent in agents if agent.is_main_orchestrator] + role_agents = [agent for agent in agents if not agent.is_main_orchestrator] + for index, agent in enumerate(orchestrators): + card = self._card_widgets.get(agent.role_id) + if card is None: + card = AgentCardWidget(self.orchestrator_grid, on_action=self.on_action) + self._card_widgets[agent.role_id] = card + card.update_agent(agent) + card.grid(row=index, column=0, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew") - for idx in range(len(agents), len(self._card_widgets)): - self._card_widgets[idx].grid_remove() + for index, agent in enumerate(role_agents): + card = self._card_widgets.get(agent.role_id) + if card is None: + card = AgentCardWidget(self.cards_grid, on_action=self.on_action) + self._card_widgets[agent.role_id] = card + card.update_agent(agent) + card.grid(row=index // 3, column=index % 3, padx=6, pady=6, sticky="nsew") def _trigger_action(self, action: str, profile: Dict[str, Any]): if self.on_action: diff --git a/tests/test_ui_phase2_6.py b/tests/test_ui_phase2_6.py new file mode 100644 index 0000000..80993ee --- /dev/null +++ b/tests/test_ui_phase2_6.py @@ -0,0 +1,190 @@ +"""Acceptance coverage for the phase 2–6 snapshot-driven UI.""" + +from __future__ import annotations + +import time +from dataclasses import replace +from pathlib import Path + +import pytest + +pytest.importorskip("customtkinter") + +import customtkinter as ctk + +from antigravity_provider.router.account_identity import QuotaBucket, QuotaSnapshot +from antigravity_provider.router.state_store import HubSnapshot +from antigravity_provider.router.ui.components import AccountCardWidget +from antigravity_provider.router.ui.views.accounts_view import AccountsView +from antigravity_provider.router.ui.views.dashboard_view import DashboardView +from antigravity_provider.router.ui.views.routing_view import RoutingView +from antigravity_provider.router.ui.views.team_view import TeamView +from antigravity_provider.router.unified_health import ProfileViewModel, SystemReadiness + + +@pytest.fixture(scope="module") +def ui_root(): + root = ctk.CTk() + root.withdraw() + yield root + root.destroy() + + +def _profile(index: int) -> ProfileViewModel: + return ProfileViewModel( + profile_id=f"account-{index}", + display_name=f"Worker {index}", + account_identity=f"user-{index}@example.test", + provider="antigravity", + provider_display_name="Google Antigravity", + assigned_roles=["coder" if index else "orchestrator"], + primary_role="coder" if index else "orchestrator", + is_main_account=index == 0, + is_main_orchestrator=index == 0, + auth_state="AUTHENTICATED", + health_state="healthy", + health_label_ru="Работает", + model_states={}, + cooldown_remaining_sec=0, + last_checked_at="12:00:00", + enabled=True, + is_cold_spare=False, + is_empty_slot=False, + email=f"mail-{index}@example.test", + plan="PRO", + plan_code="PRO", + preferred_models=["gemini-2.5-pro"], + ) + + +def _quota(profile_id: str, remaining: float | None = None) -> QuotaSnapshot: + return QuotaSnapshot( + account_id=profile_id, + provider="antigravity", + buckets=[ + QuotaBucket( + id="antigravity.gemini.5h", + display_name="Gemini 5h", + model_family="gemini", + remaining_percent=remaining, + period="5h", + ) + ], + source="baseline", + ) + + +def _snapshot(count: int = 50, changed_remaining: float | None = None) -> HubSnapshot: + profiles = [_profile(index) for index in range(count)] + quotas = { + profile.profile_id: _quota( + profile.profile_id, + changed_remaining if profile.profile_id == "account-0" else None, + ) + for profile in profiles + } + return HubSnapshot( + generation=1 if changed_remaining is None else 2, + seq=1 if changed_remaining is None else 2, + timestamp=time.time(), + profiles_by_provider={"antigravity": profiles}, + all_profiles={profile.profile_id: profile for profile in profiles}, + readiness=SystemReadiness( + state="healthy", + title_ru="Система готова", + summary_ru="Все назначенные роли доступны", + roles_ready_count=6, + total_roles=6, + accounts_connected_count=count, + total_accounts=count, + providers_ready_count=1, + total_providers=1, + ), + agents=[], + providers=[], + routing={}, + quotas=quotas, + ) + + +@pytest.mark.unit +def test_identity_priority_and_plan_badge_suppression() -> None: + profile = _profile(1) + assert AccountCardWidget.resolve_identity(profile) == profile.email + assert "PlanBadge" not in Path("src/antigravity_provider/router/ui/views/accounts_view.py").read_text( + encoding="utf-8" + ) + + +@pytest.mark.ui +def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root) -> None: + view = AccountsView(ui_root) + try: + view.pack(fill="both", expand=True) + view.update_data(_snapshot()) + ui_root.update_idletasks() + before = view.render_stats() + card_ids = {key: id(card) for key, card in view._cards.items()} + + view.update_data(_snapshot(changed_remaining=42.0)) + ui_root.update_idletasks() + after = view.render_stats() + + assert len(view._cards) == 50 + assert before["cards_created"] == after["cards_created"] == 50 + assert before["cards_destroyed"] == after["cards_destroyed"] == 0 + assert before["quota_widgets_created"] == after["quota_widgets_created"] == 50 + assert before["quota_widgets_destroyed"] == after["quota_widgets_destroyed"] == 0 + assert {key: id(card) for key, card in view._cards.items()} == card_ids + bucket = view._cards["account-0"]._quota_widgets["antigravity.gemini.5h"] + assert "оценка" in bucket.title.cget("text") + finally: + view.destroy() + + +@pytest.mark.ui +def test_removing_account_destroys_only_its_card_and_views_accept_snapshot(ui_root) -> None: + children = [] + try: + view = AccountsView(ui_root) + children.append(view) + first = _snapshot(3) + view.update_data(first) + retained = id(view._cards["account-1"]) + profiles = first.profiles_by_provider["antigravity"][1:] + second = replace( + first, + generation=2, + all_profiles={profile.profile_id: profile for profile in profiles}, + profiles_by_provider={"antigravity": profiles}, + quotas={key: value for key, value in first.quotas.items() if key != "account-0"}, + ) + view.update_data(second) + assert "account-0" not in view._cards + assert id(view._cards["account-1"]) == retained + assert view.render_stats()["cards_destroyed"] == 1 + + for child in (DashboardView(ui_root), TeamView(ui_root), RoutingView(ui_root)): + children.append(child) + child.update_data(second) + ui_root.update_idletasks() + finally: + for child in children: + child.destroy() + + +@pytest.mark.unit +def test_data_views_do_not_call_backend_services() -> None: + view_dir = Path("src/antigravity_provider/router/ui/views") + forbidden = ("HubStateStore", "scan_all(", "AccountQuotaService", "HermesRefreshScheduler", "EventLogService") + for name in ( + "accounts_view.py", + "dashboard_view.py", + "team_view.py", + "routing_view.py", + "providers_view.py", + "health_view.py", + "logs_view.py", + ): + source = (view_dir / name).read_text(encoding="utf-8") + assert not any(token in source for token in forbidden), f"{name} accesses backend directly"