feat(ui): complete redesign phases 2 through 6

This commit is contained in:
Hermes Team 2026-08-21 08:40:49 +07:00
parent e8a404be03
commit d0d15ae99a
12 changed files with 1406 additions and 1308 deletions

129
CODEX_UI_REDESIGN_REPORT.md Normal file
View file

@ -0,0 +1,129 @@
# Hermes Hub UI redesign — фазы 26
Дата: 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 не создавались и не изменялись.

View file

@ -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("<Configure>", 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")

View file

@ -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 ("<Control-v>", "<Control-V>", "<Control-KeyPress-v>", "<Control-KeyPress-V>", "<Shift-Insert>", "<Shift-KeyPress-Insert>"):
try: inner.bind(p, _paste_handler, add=False)
except Exception: pass
for p in (
"<Control-v>",
"<Control-V>",
"<Control-KeyPress-v>",
"<Control-KeyPress-V>",
"<Shift-Insert>",
"<Shift-KeyPress-Insert>",
):
try:
inner.bind(p, _paste_handler, add=False)
except Exception:
pass
for a in ("<Control-a>", "<Control-A>", "<Control-KeyPress-a>", "<Control-KeyPress-A>"):
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 ("<Control-c>", "<Control-C>", "<Control-KeyPress-c>", "<Control-KeyPress-C>"):
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 ("<Control-x>", "<Control-X>", "<Control-KeyPress-x>", "<Control-KeyPress-X>"):
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 ("<Control-KeyPress-1084>", "<Control-KeyPress-1052>", "<Control-cyrillic_em>", "<Control-Cyrillic_EM>"):
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 ("<Control-KeyPress-1092>", "<Control-KeyPress-1060>", "<Control-cyrillic_ef>", "<Control-Cyrillic_EF>"):
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 ("<Control-KeyPress-1089>", "<Control-KeyPress-1057>", "<Control-cyrillic_es>", "<Control-Cyrillic_ES>"):
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 ("<Control-KeyPress-1095>", "<Control-KeyPress-1063>", "<Control-cyrillic_che>", "<Control-Cyrillic_CHE>"):
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."""

View file

@ -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("<KeyRelease>", 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()),
}

View file

@ -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, {})

View file

@ -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)

View file

@ -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")

View file

@ -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)

View file

@ -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)

View file

@ -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),
},
)

View file

@ -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:

190
tests/test_ui_phase2_6.py Normal file
View file

@ -0,0 +1,190 @@
"""Acceptance coverage for the phase 26 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"