feat(ui): complete redesign phases 2 through 6
This commit is contained in:
parent
e8a404be03
commit
d0d15ae99a
12 changed files with 1406 additions and 1308 deletions
129
CODEX_UI_REDESIGN_REPORT.md
Normal file
129
CODEX_UI_REDESIGN_REPORT.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Hermes Hub UI redesign — фазы 2–6
|
||||
|
||||
Дата: 2026-08-21
|
||||
|
||||
Ветка: `codex/ui-redesign`
|
||||
|
||||
INITIAL_BASE_SHA: `c6876e96b274b598ada964a6639b6c3ad038aebf`
|
||||
|
||||
BASE_SHA после финального обязательного rebase на `origin/main`: `e8a404be035fa04b5f76e3e572c6539fba0e83e4`. Main продвинулся во время работы одним документационным коммитом Antigravity; этот файл не изменялся Codex.
|
||||
|
||||
FINAL_SHA: `git rev-parse codex/ui-redesign` в момент handoff (точный SHA указан в итоговом сообщении; commit не может содержать собственный SHA).
|
||||
|
||||
## Изменённые файлы
|
||||
|
||||
- `src/antigravity_provider/router/hermes_hub_app.py`
|
||||
- `src/antigravity_provider/router/ui/components.py`
|
||||
- `src/antigravity_provider/router/ui/views/accounts_view.py`
|
||||
- `src/antigravity_provider/router/ui/views/dashboard_view.py`
|
||||
- `src/antigravity_provider/router/ui/views/health_view.py`
|
||||
- `src/antigravity_provider/router/ui/views/logs_view.py`
|
||||
- `src/antigravity_provider/router/ui/views/providers_view.py`
|
||||
- `src/antigravity_provider/router/ui/views/routing_view.py`
|
||||
- `src/antigravity_provider/router/ui/views/settings_view.py`
|
||||
- `src/antigravity_provider/router/ui/views/team_view.py`
|
||||
- `tests/test_ui_phase2_6.py`
|
||||
- `CODEX_UI_REDESIGN_REPORT.md`
|
||||
|
||||
Файлы state/data/router, adapters, installer, scripts, config, legacy и чужие тесты не изменялись.
|
||||
|
||||
## Переработанные экраны
|
||||
|
||||
### Аккаунты и квоты
|
||||
|
||||
- Одна карточка соответствует одному `profile_id` и переиспользуется между поколениями snapshot.
|
||||
- `QuotaBucketWidget` хранится по стабильному `QuotaBucket.id`; существующая корзина обновляется на месте.
|
||||
- Идентичность выбирается в порядке: email → `account_identity` → display name → profile ID.
|
||||
- `PlanBadge` не создаётся: provenance тарифа отсутствует в `ProfileViewModel`.
|
||||
- Все переданные корзины показываются отдельно; baseline/estimated корзины имеют явную пометку «оценка».
|
||||
- Неизвестный остаток и время сброса показываются как `Н/Д`.
|
||||
- Есть компактный/развёрнутый режим, сворачиваемые группы провайдеров, поиск и фильтры по провайдеру, здоровью и роли.
|
||||
- Удаление профиля из snapshot уничтожает только соответствующую карточку.
|
||||
|
||||
### Обзор
|
||||
|
||||
- Экран добавлен в основную навигацию.
|
||||
- Показывает readiness, доступные провайдеры, подключённые аккаунты, готовые роли, активные маршруты и реальные warnings.
|
||||
- Низкая квота показывается только для неоценочного snapshot с известным числом.
|
||||
- Истёкшая авторизация показывается из `auth_state`.
|
||||
- Выдуманные throughput/SLA/cost метрики отсутствуют.
|
||||
|
||||
### Команда
|
||||
|
||||
- Иерархия разделена на «Оркестратор» и «Роли и агенты».
|
||||
- Карточки keyed по `role_id` и содержат роль, провайдера, аккаунт, модель и здоровье.
|
||||
- UI больше не запрашивает quota service; активная сессия и квота агента не выдумываются.
|
||||
|
||||
### Маршрутизация
|
||||
|
||||
- Роли keyed по `role_id`, узлы цепочки — по `profile_id`.
|
||||
- Отображается порядок основной → резерв 1 → резерв 2 → резерв 3 и активный узел.
|
||||
- Показываются только доступные provider/model/status поля.
|
||||
- Причина переключения честно обозначена как `Н/Д`.
|
||||
- Редактирование остаётся кнопочным; drag-and-drop не добавлялся.
|
||||
|
||||
### Второстепенные экраны
|
||||
|
||||
- Providers и Health используют keyed cards/rows.
|
||||
- Logs не читает backend или файлы напрямую: события отсутствуют в `HubSnapshot`, поэтому экран показывает честное `Н/Д`.
|
||||
- Settings стал presentation-only и отправляет save/update команды в action layer приложения.
|
||||
- About и мастер подключения сохранены; `_init_antigravity_oauth` и шесть рабочих потоков подключения не изменялись.
|
||||
|
||||
## Замер производительности: 50 аккаунтов
|
||||
|
||||
Тест: `test_fifty_accounts_update_one_quota_without_rebuilding_other_cards`.
|
||||
|
||||
| Состояние | Карточек создано | Карточек уничтожено | Quota widgets создано | Quota widgets уничтожено |
|
||||
|---|---:|---:|---:|---:|
|
||||
| Первичная отрисовка | 50 | 0 | 50 | 0 |
|
||||
| После изменения квоты аккаунта A | 50 | 0 | 50 | 0 |
|
||||
| Дельта операции | **0** | **0** | **0** | **0** |
|
||||
|
||||
Object identity всех 50 карточек до и после обновления совпадает. Отдельный тест удаления подтверждает: уничтожается одна карточка, соседняя сохраняет object identity.
|
||||
|
||||
## Проверки
|
||||
|
||||
### Без UI-зависимостей
|
||||
|
||||
```powershell
|
||||
uv run --isolated --no-project --with pytest --with pytest-asyncio --with anyio --with pyyaml --with pydantic --with requests --with httpx pytest -q
|
||||
```
|
||||
|
||||
Результат: `160 passed, 28 skipped, 3 deselected in 9.55s`.
|
||||
|
||||
### С UI-зависимостями
|
||||
|
||||
Полный набор успешно проходил одной командой (`202 passed, 9 skipped, 3 deselected`). Для воспроизводимого финального прогона в uv-managed Python использованы два свежих Tk-процесса: его Tcl runtime нестабильно повторно создаёт root после `root.destroy()` в соседнем OAuth-тесте.
|
||||
|
||||
```powershell
|
||||
uv run --with customtkinter --with pillow --with psutil --with pytest --with pytest-asyncio pytest -q -k "not test_f_copy_before_open_browser"
|
||||
uv run --with customtkinter --with pillow --with psutil --with pytest --with pytest-asyncio pytest -q tests/test_oauth_lifecycle.py::test_f_copy_before_open_browser
|
||||
```
|
||||
|
||||
Результаты: `201 passed, 9 skipped, 4 deselected in 26.42s` и `1 passed in 2.86s`. В сумме весь UI-enabled набор зелёный; разделение процессов относится только к Tcl/Tk test runtime, не к приложению.
|
||||
|
||||
### Статика и release gate
|
||||
|
||||
```powershell
|
||||
uv run --with ruff ruff check .
|
||||
uv run python scripts/release_gate.py
|
||||
```
|
||||
|
||||
Результат: Ruff — `All checks passed`; release gate — `PASSED`. Live manifest не менялся; package URL по-прежнему имеет внешний статус pending upload/404, как и на базе задачи.
|
||||
|
||||
## Backend gaps
|
||||
|
||||
1. Ни один провайдер не отдаёт live numeric quotas через опубликованный контракт; baseline отображается только как оценка.
|
||||
2. Разделение Antigravity на Claude/Gemini существует структурно, но численные значения не измеряются.
|
||||
3. Provenance тарифа не входит в `ProfileViewModel`; поэтому `PlanBadge` скрыт.
|
||||
4. В `AgentViewModel` нет активной сессии и квоты.
|
||||
5. В `PipelineNode` нет идентичности аккаунта, quota state и причины переключения.
|
||||
6. События журнала не входят в `HubSnapshot`; Dashboard и Logs не обращаются к `EventLogService` напрямую.
|
||||
7. Латентность, RPS, error rate, стоимость и удалённые SLA отсутствуют.
|
||||
8. UI не компенсирует эти пробелы собственным опросом, сканированием диска или синтетическими числами.
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
- В текущем контракте журнал нельзя показать без расширения `HubSnapshot`.
|
||||
- Настройки читаются/сохраняются action layer приложения; snapshot не содержит отдельной settings-модели.
|
||||
- Live release package остаётся внешней задачей. По B2 тег, релиз и manifest не создавались и не изменялись.
|
||||
|
|
@ -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, {
|
||||
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,6 +501,36 @@ 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)
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
def __init__(
|
||||
self,
|
||||
text=identity,
|
||||
font=Theme.font_body_bold(),
|
||||
text_color=Theme.TEXT_PRIMARY,
|
||||
)
|
||||
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."""
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
def _on_search(self, query: str):
|
||||
self._search_query = query
|
||||
self.update_data()
|
||||
|
||||
def _on_filter(self, filter_val: str):
|
||||
self._filter_status = filter_val
|
||||
self.update_data()
|
||||
|
||||
def _on_sort(self, sort_val: str):
|
||||
self._sort_by = sort_val
|
||||
self.update_data()
|
||||
|
||||
def update_data(self, snapshot: Optional[HubSnapshot] = None):
|
||||
"""Update views by reusing widgets and updating properties in place."""
|
||||
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
|
||||
profiles_by_prov = snapshot.profiles_by_provider
|
||||
|
||||
for prov_key, scroll in self.tab_scrolls.items():
|
||||
profiles = profiles_by_prov.get(prov_key, [])
|
||||
|
||||
# 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}),
|
||||
command=lambda: self._emit("refresh_all", {}),
|
||||
).pack(side="right")
|
||||
|
||||
self._empty_cards[slot_key] = summary_card
|
||||
else:
|
||||
summary_card = self._empty_cards[slot_key]
|
||||
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", {}),
|
||||
)
|
||||
|
||||
summary_card.grid(row=c_row, column=0, columnspan=3, padx=6, pady=10, sticky="ew")
|
||||
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 _set_search(self, value: str) -> None:
|
||||
self._search = value.strip().lower()
|
||||
self._render_visibility()
|
||||
|
||||
def _set_provider_filter(self, value: str) -> None:
|
||||
self._provider_filter = value
|
||||
self._render_visibility()
|
||||
|
||||
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):
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
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()),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
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),
|
||||
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
|
||||
]
|
||||
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)
|
||||
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)
|
||||
|
||||
# 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, {})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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),
|
||||
},
|
||||
)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
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()
|
||||
|
||||
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 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)
|
||||
card.grid(row=row_idx, column=col_idx, padx=6, pady=6, sticky="nsew")
|
||||
self._card_widgets.append(card)
|
||||
|
||||
for idx, agent in enumerate(agents):
|
||||
self._card_widgets[idx].update_agent(agent)
|
||||
self._card_widgets[idx].grid()
|
||||
|
||||
for idx in range(len(agents), len(self._card_widgets)):
|
||||
self._card_widgets[idx].grid_remove()
|
||||
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
190
tests/test_ui_phase2_6.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
"""Acceptance coverage for the phase 2–6 snapshot-driven UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("customtkinter")
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from antigravity_provider.router.account_identity import QuotaBucket, QuotaSnapshot
|
||||
from antigravity_provider.router.state_store import HubSnapshot
|
||||
from antigravity_provider.router.ui.components import AccountCardWidget
|
||||
from antigravity_provider.router.ui.views.accounts_view import AccountsView
|
||||
from antigravity_provider.router.ui.views.dashboard_view import DashboardView
|
||||
from antigravity_provider.router.ui.views.routing_view import RoutingView
|
||||
from antigravity_provider.router.ui.views.team_view import TeamView
|
||||
from antigravity_provider.router.unified_health import ProfileViewModel, SystemReadiness
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ui_root():
|
||||
root = ctk.CTk()
|
||||
root.withdraw()
|
||||
yield root
|
||||
root.destroy()
|
||||
|
||||
|
||||
def _profile(index: int) -> ProfileViewModel:
|
||||
return ProfileViewModel(
|
||||
profile_id=f"account-{index}",
|
||||
display_name=f"Worker {index}",
|
||||
account_identity=f"user-{index}@example.test",
|
||||
provider="antigravity",
|
||||
provider_display_name="Google Antigravity",
|
||||
assigned_roles=["coder" if index else "orchestrator"],
|
||||
primary_role="coder" if index else "orchestrator",
|
||||
is_main_account=index == 0,
|
||||
is_main_orchestrator=index == 0,
|
||||
auth_state="AUTHENTICATED",
|
||||
health_state="healthy",
|
||||
health_label_ru="Работает",
|
||||
model_states={},
|
||||
cooldown_remaining_sec=0,
|
||||
last_checked_at="12:00:00",
|
||||
enabled=True,
|
||||
is_cold_spare=False,
|
||||
is_empty_slot=False,
|
||||
email=f"mail-{index}@example.test",
|
||||
plan="PRO",
|
||||
plan_code="PRO",
|
||||
preferred_models=["gemini-2.5-pro"],
|
||||
)
|
||||
|
||||
|
||||
def _quota(profile_id: str, remaining: float | None = None) -> QuotaSnapshot:
|
||||
return QuotaSnapshot(
|
||||
account_id=profile_id,
|
||||
provider="antigravity",
|
||||
buckets=[
|
||||
QuotaBucket(
|
||||
id="antigravity.gemini.5h",
|
||||
display_name="Gemini 5h",
|
||||
model_family="gemini",
|
||||
remaining_percent=remaining,
|
||||
period="5h",
|
||||
)
|
||||
],
|
||||
source="baseline",
|
||||
)
|
||||
|
||||
|
||||
def _snapshot(count: int = 50, changed_remaining: float | None = None) -> HubSnapshot:
|
||||
profiles = [_profile(index) for index in range(count)]
|
||||
quotas = {
|
||||
profile.profile_id: _quota(
|
||||
profile.profile_id,
|
||||
changed_remaining if profile.profile_id == "account-0" else None,
|
||||
)
|
||||
for profile in profiles
|
||||
}
|
||||
return HubSnapshot(
|
||||
generation=1 if changed_remaining is None else 2,
|
||||
seq=1 if changed_remaining is None else 2,
|
||||
timestamp=time.time(),
|
||||
profiles_by_provider={"antigravity": profiles},
|
||||
all_profiles={profile.profile_id: profile for profile in profiles},
|
||||
readiness=SystemReadiness(
|
||||
state="healthy",
|
||||
title_ru="Система готова",
|
||||
summary_ru="Все назначенные роли доступны",
|
||||
roles_ready_count=6,
|
||||
total_roles=6,
|
||||
accounts_connected_count=count,
|
||||
total_accounts=count,
|
||||
providers_ready_count=1,
|
||||
total_providers=1,
|
||||
),
|
||||
agents=[],
|
||||
providers=[],
|
||||
routing={},
|
||||
quotas=quotas,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_identity_priority_and_plan_badge_suppression() -> None:
|
||||
profile = _profile(1)
|
||||
assert AccountCardWidget.resolve_identity(profile) == profile.email
|
||||
assert "PlanBadge" not in Path("src/antigravity_provider/router/ui/views/accounts_view.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_fifty_accounts_update_one_quota_without_rebuilding_other_cards(ui_root) -> None:
|
||||
view = AccountsView(ui_root)
|
||||
try:
|
||||
view.pack(fill="both", expand=True)
|
||||
view.update_data(_snapshot())
|
||||
ui_root.update_idletasks()
|
||||
before = view.render_stats()
|
||||
card_ids = {key: id(card) for key, card in view._cards.items()}
|
||||
|
||||
view.update_data(_snapshot(changed_remaining=42.0))
|
||||
ui_root.update_idletasks()
|
||||
after = view.render_stats()
|
||||
|
||||
assert len(view._cards) == 50
|
||||
assert before["cards_created"] == after["cards_created"] == 50
|
||||
assert before["cards_destroyed"] == after["cards_destroyed"] == 0
|
||||
assert before["quota_widgets_created"] == after["quota_widgets_created"] == 50
|
||||
assert before["quota_widgets_destroyed"] == after["quota_widgets_destroyed"] == 0
|
||||
assert {key: id(card) for key, card in view._cards.items()} == card_ids
|
||||
bucket = view._cards["account-0"]._quota_widgets["antigravity.gemini.5h"]
|
||||
assert "оценка" in bucket.title.cget("text")
|
||||
finally:
|
||||
view.destroy()
|
||||
|
||||
|
||||
@pytest.mark.ui
|
||||
def test_removing_account_destroys_only_its_card_and_views_accept_snapshot(ui_root) -> None:
|
||||
children = []
|
||||
try:
|
||||
view = AccountsView(ui_root)
|
||||
children.append(view)
|
||||
first = _snapshot(3)
|
||||
view.update_data(first)
|
||||
retained = id(view._cards["account-1"])
|
||||
profiles = first.profiles_by_provider["antigravity"][1:]
|
||||
second = replace(
|
||||
first,
|
||||
generation=2,
|
||||
all_profiles={profile.profile_id: profile for profile in profiles},
|
||||
profiles_by_provider={"antigravity": profiles},
|
||||
quotas={key: value for key, value in first.quotas.items() if key != "account-0"},
|
||||
)
|
||||
view.update_data(second)
|
||||
assert "account-0" not in view._cards
|
||||
assert id(view._cards["account-1"]) == retained
|
||||
assert view.render_stats()["cards_destroyed"] == 1
|
||||
|
||||
for child in (DashboardView(ui_root), TeamView(ui_root), RoutingView(ui_root)):
|
||||
children.append(child)
|
||||
child.update_data(second)
|
||||
ui_root.update_idletasks()
|
||||
finally:
|
||||
for child in children:
|
||||
child.destroy()
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
def test_data_views_do_not_call_backend_services() -> None:
|
||||
view_dir = Path("src/antigravity_provider/router/ui/views")
|
||||
forbidden = ("HubStateStore", "scan_all(", "AccountQuotaService", "HermesRefreshScheduler", "EventLogService")
|
||||
for name in (
|
||||
"accounts_view.py",
|
||||
"dashboard_view.py",
|
||||
"team_view.py",
|
||||
"routing_view.py",
|
||||
"providers_view.py",
|
||||
"health_view.py",
|
||||
"logs_view.py",
|
||||
):
|
||||
source = (view_dir / name).read_text(encoding="utf-8")
|
||||
assert not any(token in source for token in forbidden), f"{name} accesses backend directly"
|
||||
Loading…
Reference in a new issue