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)
|
- Real Provider Icon Marks (Google Antigravity, OpenAI Codex, OpenCode Go)
|
||||||
- Non-blocking Background Workers & Graceful Shutdown Coordinator
|
- Non-blocking Background Workers & Graceful Shutdown Coordinator
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import ctypes
|
import ctypes
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sys
|
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.profile_manager import ProfileAuthManager
|
||||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||||
from antigravity_provider.router.adapters import get_adapter
|
from antigravity_provider.router.adapters import get_adapter
|
||||||
|
from antigravity_provider import paths
|
||||||
|
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
from antigravity_provider.router.ui.assets import AssetManager
|
from antigravity_provider.router.ui.assets import AssetManager
|
||||||
|
|
@ -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.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.accounts_view import AccountsView
|
||||||
from antigravity_provider.router.ui.views.providers_view import ProvidersView
|
from antigravity_provider.router.ui.views.providers_view import ProvidersView
|
||||||
from antigravity_provider.router.ui.views.routing_view import RoutingView
|
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)
|
# Actions Layer (Safe & Non-blocking)
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
def do_set_main(provider: str, profile_id: str) -> Tuple[bool, str]:
|
def do_set_main(provider: str, profile_id: str) -> Tuple[bool, str]:
|
||||||
ok, msg = ProfileAuthManager.set_main_profile(provider, profile_id)
|
ok, msg = ProfileAuthManager.set_main_profile(provider, profile_id)
|
||||||
if ok:
|
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
|
return ok, msg
|
||||||
|
|
||||||
|
|
||||||
def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
|
def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
|
||||||
ok, msg = AutoAssigner.set_primary_orchestrator(profile_id)
|
ok, msg = AutoAssigner.set_primary_orchestrator(profile_id)
|
||||||
if ok:
|
if ok:
|
||||||
EventLogService.get().log("routing", f"Профиль {profile_id} назначен главным оркестратором команды.", level="info")
|
EventLogService.get().log(
|
||||||
|
"routing", f"Профиль {profile_id} назначен главным оркестратором команды.", level="info"
|
||||||
|
)
|
||||||
return ok, msg
|
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"
|
model = pcfg.preferred_models[0] if pcfg.preferred_models else "default"
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
try:
|
try:
|
||||||
resp = adapter.invoke(pcfg, {
|
resp = adapter.invoke(
|
||||||
"model": model,
|
pcfg,
|
||||||
"messages": [{"role": "user", "content": f"Respond strictly with: TEST_OK_FOR_{profile_id}"}],
|
{
|
||||||
"temperature": 0.1,
|
"model": model,
|
||||||
})
|
"messages": [{"role": "user", "content": f"Respond strictly with: TEST_OK_FOR_{profile_id}"}],
|
||||||
|
"temperature": 0.1,
|
||||||
|
},
|
||||||
|
)
|
||||||
el = round(time.time() - t0, 2)
|
el = round(time.time() - t0, 2)
|
||||||
content = resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
content = resp.get("choices", [{}])[0].get("message", {}).get("content", "").strip()
|
||||||
EventLogService.get().log("system", f"Тест {profile_id} ({model}) успешно пройден за {el}s.", level="success")
|
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, "Учетные данные отсутствовали"
|
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
|
# Main Application Window
|
||||||
# ═══════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
class HermesHubApp(ctk.CTk):
|
class HermesHubApp(ctk.CTk):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
@ -151,7 +183,7 @@ class HermesHubApp(ctk.CTk):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
self._current_view = "team"
|
self._current_view = "overview"
|
||||||
self._views: Dict[str, ctk.CTkFrame] = {}
|
self._views: Dict[str, ctk.CTkFrame] = {}
|
||||||
self._view_generations: Dict[str, int] = {}
|
self._view_generations: Dict[str, int] = {}
|
||||||
self._shutting_down = False
|
self._shutting_down = False
|
||||||
|
|
@ -161,16 +193,18 @@ class HermesHubApp(ctk.CTk):
|
||||||
self.bind("<Configure>", self._on_window_configure)
|
self.bind("<Configure>", self._on_window_configure)
|
||||||
|
|
||||||
self._build_layout()
|
self._build_layout()
|
||||||
self._show_view("team")
|
self._show_view("overview")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||||||
|
|
||||||
HermesRefreshScheduler.get().start()
|
HermesRefreshScheduler.get().start()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||||||
|
|
||||||
AccountQuotaService.get().start_background_scheduler()
|
AccountQuotaService.get().start_background_scheduler()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -192,14 +226,19 @@ class HermesHubApp(ctk.CTk):
|
||||||
logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="")
|
logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="")
|
||||||
logo_lbl.pack(anchor="center", pady=(0, 6))
|
logo_lbl.pack(anchor="center", pady=(0, 6))
|
||||||
|
|
||||||
ctk.CTkLabel(brand_container, text="HERMES HUB", font=Theme.font_title_hero(), text_color=Theme.TEXT_ACCENT).pack(anchor="center")
|
ctk.CTkLabel(
|
||||||
ctk.CTkLabel(brand_container, text="Control Center", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(anchor="center")
|
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
|
# Divider
|
||||||
ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER).pack(fill="x", padx=14, pady=(6, 12))
|
ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER).pack(fill="x", padx=14, pady=(6, 12))
|
||||||
|
|
||||||
# Nav Items with clean Fluent glyphs
|
# Nav Items with clean Fluent glyphs
|
||||||
self._nav_items = [
|
self._nav_items = [
|
||||||
|
("overview", "Обзор", "⌂"),
|
||||||
("team", "Команда", "👥"),
|
("team", "Команда", "👥"),
|
||||||
("accounts", "Аккаунты", "🔍"),
|
("accounts", "Аккаунты", "🔍"),
|
||||||
("routing", "Маршрутизация", "🔀"),
|
("routing", "Маршрутизация", "🔀"),
|
||||||
|
|
@ -267,20 +306,27 @@ class HermesHubApp(ctk.CTk):
|
||||||
|
|
||||||
def _create_view(self, view_name: str) -> ctk.CTkFrame:
|
def _create_view(self, view_name: str) -> ctk.CTkFrame:
|
||||||
"""Create view widget instance."""
|
"""Create view widget instance."""
|
||||||
if view_name == "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)
|
return TeamView(self.content, app_state={}, on_action=self._handle_action)
|
||||||
elif view_name == "accounts":
|
elif view_name == "accounts":
|
||||||
return AccountsView(self.content, app_state={}, on_action=self._handle_action)
|
return AccountsView(self.content, app_state={}, on_action=self._handle_action)
|
||||||
elif view_name == "providers":
|
elif view_name == "providers":
|
||||||
return ProvidersView(self.content, app_state={}, on_action=self._handle_action)
|
return ProvidersView(self.content, app_state={}, on_action=self._handle_action)
|
||||||
elif view_name == "routing":
|
elif view_name == "routing":
|
||||||
return RoutingView(self.content)
|
return RoutingView(self.content, on_action=self._handle_action)
|
||||||
elif view_name == "health":
|
elif view_name == "health":
|
||||||
return HealthView(self.content, app_state={}, on_refresh=self._refresh_data)
|
return HealthView(self.content, app_state={}, on_refresh=self._refresh_data)
|
||||||
elif view_name == "logs":
|
elif view_name == "logs":
|
||||||
return LogsView(self.content)
|
return LogsView(self.content)
|
||||||
elif view_name == "settings":
|
elif view_name == "settings":
|
||||||
return SettingsView(self.content)
|
return SettingsView(self.content, on_action=self._handle_action)
|
||||||
elif view_name == "about":
|
elif view_name == "about":
|
||||||
return AboutView(self.content)
|
return AboutView(self.content)
|
||||||
else:
|
else:
|
||||||
|
|
@ -319,6 +365,7 @@ class HermesHubApp(ctk.CTk):
|
||||||
|
|
||||||
# Lazy update if view state is behind current snapshot generation
|
# Lazy update if view state is behind current snapshot generation
|
||||||
from antigravity_provider.router.state_store import HubStateStore
|
from antigravity_provider.router.state_store import HubStateStore
|
||||||
|
|
||||||
snap = HubStateStore.get().get_snapshot()
|
snap = HubStateStore.get().get_snapshot()
|
||||||
if self._view_generations.get(view_name, 0) < snap.generation:
|
if self._view_generations.get(view_name, 0) < snap.generation:
|
||||||
if hasattr(target_view, "update_data"):
|
if hasattr(target_view, "update_data"):
|
||||||
|
|
@ -364,6 +411,7 @@ class HermesHubApp(ctk.CTk):
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.state_store import HubStateStore
|
from antigravity_provider.router.state_store import HubStateStore
|
||||||
|
|
||||||
snap = HubStateStore.get().refresh(force_scan=True)
|
snap = HubStateStore.get().refresh(force_scan=True)
|
||||||
if not self._shutting_down:
|
if not self._shutting_down:
|
||||||
self.after(0, lambda: self._on_data_loaded(snap))
|
self.after(0, lambda: self._on_data_loaded(snap))
|
||||||
|
|
@ -381,6 +429,7 @@ class HermesHubApp(ctk.CTk):
|
||||||
return
|
return
|
||||||
|
|
||||||
from antigravity_provider.router.state_store import HubSnapshot, HubStateStore
|
from antigravity_provider.router.state_store import HubSnapshot, HubStateStore
|
||||||
|
|
||||||
if isinstance(snapshot_or_readiness, HubSnapshot):
|
if isinstance(snapshot_or_readiness, HubSnapshot):
|
||||||
snap = snapshot_or_readiness
|
snap = snapshot_or_readiness
|
||||||
readiness = snap.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')}"
|
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)
|
self.status_right.configure(text=f"● {readiness.title_ru}", text_color=r_color)
|
||||||
|
|
||||||
# Update ONLY the currently visible view (others are updated lazily on tab switch)
|
# Update ONLY the currently visible view (others are updated lazily on tab switch)
|
||||||
|
|
@ -448,10 +501,40 @@ class HermesHubApp(ctk.CTk):
|
||||||
)
|
)
|
||||||
elif action == "refresh_data":
|
elif action == "refresh_data":
|
||||||
self._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):
|
def _open_assign_role_modal(self, profile_id: str, display_name: str):
|
||||||
modal = HubModal(self, title=f"Назначение роли: {display_name}", width=500, height=420)
|
modal = HubModal(self, title=f"Назначение роли: {display_name}", width=500, height=420)
|
||||||
|
|
||||||
ctk.CTkLabel(
|
ctk.CTkLabel(
|
||||||
modal.body,
|
modal.body,
|
||||||
text=f"Выберите роль в команде Hermes для профиля «{display_name}» ({profile_id}):",
|
text=f"Выберите роль в команде Hermes для профиля «{display_name}» ({profile_id}):",
|
||||||
|
|
@ -540,6 +623,7 @@ class HermesHubApp(ctk.CTk):
|
||||||
if self._shutting_down:
|
if self._shutting_down:
|
||||||
return
|
return
|
||||||
from antigravity_provider.router.state_store import HubStateStore
|
from antigravity_provider.router.state_store import HubStateStore
|
||||||
|
|
||||||
readiness = HubStateStore.get().get_snapshot().readiness
|
readiness = HubStateStore.get().get_snapshot().readiness
|
||||||
self.status_left.configure(
|
self.status_left.configure(
|
||||||
text=f"Аккаунты: {readiness.accounts_connected_count}/{readiness.total_accounts} | Роли: {readiness.roles_ready_count}/{readiness.total_roles} | Провайдеры: {readiness.providers_ready_count}/{readiness.total_providers} | Обновлено: {time.strftime('%H:%M:%S')}"
|
text=f"Аккаунты: {readiness.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
|
self._shutting_down = True
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||||||
|
|
||||||
HermesRefreshScheduler.get().stop()
|
HermesRefreshScheduler.get().stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||||||
|
|
||||||
AccountQuotaService.get().stop_background_scheduler()
|
AccountQuotaService.get().stop_background_scheduler()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
@ -624,7 +710,9 @@ def launch_hub():
|
||||||
if not check_single_instance():
|
if not check_single_instance():
|
||||||
try:
|
try:
|
||||||
with open(log_file, "a", encoding="utf-8") as f:
|
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:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
@ -635,6 +723,7 @@ def launch_hub():
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
try:
|
try:
|
||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
tb = traceback.format_exc()
|
tb = traceback.format_exc()
|
||||||
with open(log_file, "a", encoding="utf-8") as f:
|
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")
|
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
|
import customtkinter as ctk
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import unittest.mock as _mock
|
import unittest.mock as _mock
|
||||||
|
|
||||||
ctk = _mock.MagicMock()
|
ctk = _mock.MagicMock()
|
||||||
|
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
|
|
@ -152,32 +153,55 @@ def enable_clipboard_shortcuts(entry_widget: Any) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Standard Latin shortcuts
|
# Standard Latin shortcuts
|
||||||
for p in ("<Control-v>", "<Control-V>", "<Control-KeyPress-v>", "<Control-KeyPress-V>", "<Shift-Insert>", "<Shift-KeyPress-Insert>"):
|
for p in (
|
||||||
try: inner.bind(p, _paste_handler, add=False)
|
"<Control-v>",
|
||||||
except Exception: pass
|
"<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>"):
|
for a in ("<Control-a>", "<Control-A>", "<Control-KeyPress-a>", "<Control-KeyPress-A>"):
|
||||||
try: inner.bind(a, _select_all_handler, add=False)
|
try:
|
||||||
except Exception: pass
|
inner.bind(a, _select_all_handler, add=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
for c in ("<Control-c>", "<Control-C>", "<Control-KeyPress-c>", "<Control-KeyPress-C>"):
|
for c in ("<Control-c>", "<Control-C>", "<Control-KeyPress-c>", "<Control-KeyPress-C>"):
|
||||||
try: inner.bind(c, _copy_handler, add=False)
|
try:
|
||||||
except Exception: pass
|
inner.bind(c, _copy_handler, add=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
for x in ("<Control-x>", "<Control-X>", "<Control-KeyPress-x>", "<Control-KeyPress-X>"):
|
for x in ("<Control-x>", "<Control-X>", "<Control-KeyPress-x>", "<Control-KeyPress-X>"):
|
||||||
try: inner.bind(x, _cut_handler, add=False)
|
try:
|
||||||
except Exception: pass
|
inner.bind(x, _cut_handler, add=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Windows Cyrillic / Russian keyboard layouts
|
# Windows Cyrillic / Russian keyboard layouts
|
||||||
for p in ("<Control-KeyPress-1084>", "<Control-KeyPress-1052>", "<Control-cyrillic_em>", "<Control-Cyrillic_EM>"):
|
for p in ("<Control-KeyPress-1084>", "<Control-KeyPress-1052>", "<Control-cyrillic_em>", "<Control-Cyrillic_EM>"):
|
||||||
try: inner.bind(p, _paste_handler, add=False)
|
try:
|
||||||
except Exception: pass
|
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>"):
|
for a in ("<Control-KeyPress-1092>", "<Control-KeyPress-1060>", "<Control-cyrillic_ef>", "<Control-Cyrillic_EF>"):
|
||||||
try: inner.bind(a, _select_all_handler, add=False)
|
try:
|
||||||
except Exception: pass
|
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>"):
|
for c in ("<Control-KeyPress-1089>", "<Control-KeyPress-1057>", "<Control-cyrillic_es>", "<Control-Cyrillic_ES>"):
|
||||||
try: inner.bind(c, _copy_handler, add=False)
|
try:
|
||||||
except Exception: pass
|
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>"):
|
for x in ("<Control-KeyPress-1095>", "<Control-KeyPress-1063>", "<Control-cyrillic_che>", "<Control-Cyrillic_CHE>"):
|
||||||
try: inner.bind(x, _cut_handler, add=False)
|
try:
|
||||||
except Exception: pass
|
inner.bind(x, _cut_handler, add=False)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class HubEntry(ctk.CTkEntry):
|
class HubEntry(ctk.CTkEntry):
|
||||||
|
|
@ -251,25 +275,29 @@ class HubStatusBadge(ctk.CTkFrame):
|
||||||
def __init__(self, master: Any, status_key: str, **kwargs):
|
def __init__(self, master: Any, status_key: str, **kwargs):
|
||||||
super().__init__(master=master, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_PILL, **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 = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
inner.pack(padx=Theme.SPACE_MD, pady=Theme.SPACE_XS)
|
inner.pack(padx=Theme.SPACE_MD, pady=Theme.SPACE_XS)
|
||||||
|
|
||||||
ctk.CTkLabel(
|
self.dot = ctk.CTkLabel(
|
||||||
inner,
|
inner,
|
||||||
text="●",
|
text="●",
|
||||||
font=Theme.font_badge_bold(),
|
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,
|
inner,
|
||||||
text=label,
|
|
||||||
font=Theme.font_caption(),
|
font=Theme.font_caption(),
|
||||||
text_color=Theme.TEXT_SECONDARY,
|
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):
|
class HubProviderBadge(ctk.CTkFrame):
|
||||||
|
|
@ -610,8 +638,10 @@ class QuotaBar(ctk.CTkFrame):
|
||||||
self.detail.configure(text=detail or "Н/Д", text_color=Theme.TEXT_MUTED)
|
self.detail.configure(text=detail or "Н/Д", text_color=Theme.TEXT_MUTED)
|
||||||
return
|
return
|
||||||
normalized = max(0.0, min(1.0, float(value)))
|
normalized = max(0.0, min(1.0, float(value)))
|
||||||
color = Theme.COLOR_NEGATIVE if normalized <= 0.05 else (
|
color = (
|
||||||
Theme.COLOR_CAUTION if normalized <= 0.20 else Theme.COLOR_POSITIVE
|
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.set(normalized)
|
||||||
self.progress.configure(progress_color=color)
|
self.progress.configure(progress_color=color)
|
||||||
|
|
@ -647,10 +677,11 @@ class QuotaBucketWidget(HubCard):
|
||||||
remaining_ratio: Optional[float],
|
remaining_ratio: Optional[float],
|
||||||
reset_text: str = "",
|
reset_text: str = "",
|
||||||
is_estimated: bool = False,
|
is_estimated: bool = False,
|
||||||
|
detail: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
suffix = " • оценка" if is_estimated else ""
|
suffix = " • оценка" if is_estimated else ""
|
||||||
self.title.configure(text=f"{label}{suffix}")
|
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 "Сброс: Н/Д")
|
self.reset.configure(text=reset_text or "Сброс: Н/Д")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -720,9 +751,7 @@ class EmptyState(HubCard):
|
||||||
padx=Theme.SPACE_LG, pady=(0, Theme.SPACE_MD)
|
padx=Theme.SPACE_LG, pady=(0, Theme.SPACE_MD)
|
||||||
)
|
)
|
||||||
if action_text and action_cmd:
|
if action_text and action_cmd:
|
||||||
ActionButton(self, text=action_text, variant="secondary", command=action_cmd).pack(
|
ActionButton(self, text=action_text, variant="secondary", command=action_cmd).pack(pady=(0, Theme.SPACE_LG))
|
||||||
pady=(0, Theme.SPACE_LG)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RouteTargetWidget(HubCard):
|
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.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 = 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.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)
|
self.set_status(status)
|
||||||
|
|
||||||
def set_status(self, status: str) -> None:
|
def set_status(self, status: str) -> None:
|
||||||
|
|
@ -743,23 +778,143 @@ class RouteTargetWidget(HubCard):
|
||||||
|
|
||||||
|
|
||||||
class AccountCardWidget(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):
|
def __init__(
|
||||||
super().__init__(master, height=Theme.ACCOUNT_CARD_MIN_HEIGHT, **kwargs)
|
self,
|
||||||
self.grid_propagate(False)
|
master: Any,
|
||||||
self.provider = ctk.CTkLabel(self, text=provider, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
|
profile_id: str,
|
||||||
self.provider.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS))
|
identity: str,
|
||||||
self.identity = EllipsizedLabel(
|
provider: str,
|
||||||
self,
|
status: str = "unknown",
|
||||||
text=identity,
|
compact: bool = False,
|
||||||
font=Theme.font_body_bold(),
|
on_action: Optional[Callable[[str, Any], None]] = None,
|
||||||
text_color=Theme.TEXT_PRIMARY,
|
**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.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 = StatusBadge(self, status)
|
||||||
self.status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
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):
|
class AgentCardWidget(HubCard):
|
||||||
"""Model-agnostic agent-card shell shared by team views."""
|
"""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
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
from collections import defaultdict
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, Iterable, Optional
|
||||||
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.state_store import HubSnapshot
|
||||||
from antigravity_provider.router.ui.assets import AssetManager
|
|
||||||
from antigravity_provider.router.ui.components import (
|
from antigravity_provider.router.ui.components import (
|
||||||
HubButton,
|
AccountCardWidget,
|
||||||
HubCard,
|
ActionButton,
|
||||||
HubToolbar,
|
EmptyState,
|
||||||
|
FilterButton,
|
||||||
|
SearchField,
|
||||||
|
SectionHeader,
|
||||||
)
|
)
|
||||||
from antigravity_provider.router.unified_health import (
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
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")
|
|
||||||
|
|
||||||
|
|
||||||
class AccountCardWidget(HubCard):
|
PROVIDER_LABELS = {
|
||||||
"""Reusable, updateable account card widget keyed by profile_id."""
|
"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()
|
class ProviderGroup(ctk.CTkFrame):
|
||||||
self.update_from_model(profile, quota_snapshot)
|
"""Collapsible provider section that retains its account cards while hidden."""
|
||||||
|
|
||||||
def _build(self):
|
def __init__(self, master: Any, provider: str):
|
||||||
# 1. Top row: icon + plan badge + main badge + status dot
|
super().__init__(master, fg_color="transparent")
|
||||||
self.top_row = ctk.CTkFrame(self, fg_color="transparent")
|
self.provider = provider
|
||||||
self.top_row.pack(fill="x", padx=14, pady=(12, 2))
|
self.collapsed = False
|
||||||
|
self.header = ctk.CTkFrame(self, fg_color=Theme.BG_HEADER, corner_radius=Theme.RADIUS_SM)
|
||||||
self.p_icon_lbl = ctk.CTkLabel(self.top_row, text="")
|
self.header.pack(fill="x", pady=(Theme.SPACE_SM, Theme.SPACE_XS))
|
||||||
self.p_icon_lbl.pack(side="left", padx=(0, 6))
|
self.toggle = ActionButton(
|
||||||
|
self.header,
|
||||||
self.plan_frame = ctk.CTkFrame(self.top_row, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
text="▾",
|
||||||
self.plan_frame.pack(side="left", padx=(0, 6))
|
variant="ghost",
|
||||||
self.plan_lbl = ctk.CTkLabel(self.plan_frame, text="", font=Theme.font_micro_bold())
|
width=34,
|
||||||
self.plan_lbl.pack(padx=6, pady=2)
|
command=self.toggle_collapsed,
|
||||||
|
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
self.refresh_btn.pack(side="left", padx=(0, 6))
|
self.toggle.pack(side="left", padx=(Theme.SPACE_SM, 0))
|
||||||
|
self.title = ctk.CTkLabel(
|
||||||
self.test_btn = HubButton(
|
self.header,
|
||||||
self.btns,
|
text=PROVIDER_LABELS.get(provider, provider),
|
||||||
text="⚡ Тест",
|
font=Theme.font_heading(),
|
||||||
variant="secondary",
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
width=65,
|
|
||||||
height=Theme.HEIGHT_BTN_SM,
|
|
||||||
command=lambda: self._trigger("test"),
|
|
||||||
)
|
)
|
||||||
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(
|
def toggle_collapsed(self) -> None:
|
||||||
self.btns,
|
self.collapsed = not self.collapsed
|
||||||
text="Назначить",
|
self.toggle.configure(text="▸" if self.collapsed else "▾")
|
||||||
variant="secondary",
|
if self.collapsed:
|
||||||
width=80,
|
self.body.pack_forget()
|
||||||
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))
|
|
||||||
else:
|
else:
|
||||||
self.configure(border_color=Theme.BORDER)
|
self.body.pack(fill="x")
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class AccountsView(ctk.CTkFrame):
|
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)
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
self.app_state = app_state
|
|
||||||
self.on_action = on_action
|
self.on_action = on_action
|
||||||
self._search_query = ""
|
self._snapshot: Optional[HubSnapshot] = None
|
||||||
self._filter_status = "Все статусы"
|
|
||||||
self._sort_by = "По умолчанию"
|
|
||||||
|
|
||||||
self._cards: Dict[str, AccountCardWidget] = {}
|
self._cards: Dict[str, AccountCardWidget] = {}
|
||||||
self._empty_cards: Dict[str, HubCard] = {}
|
self._groups: Dict[str, ProviderGroup] = {}
|
||||||
self._last_rendered_generation = 0
|
self._search = ""
|
||||||
|
self._provider_filter = "Все провайдеры"
|
||||||
|
self._health_filter = "Все состояния"
|
||||||
|
self._role_filter = "Все роли"
|
||||||
|
self.cards_created = 0
|
||||||
|
self.cards_destroyed = 0
|
||||||
self._build()
|
self._build()
|
||||||
self._subscribe_events()
|
|
||||||
|
|
||||||
def _subscribe_events(self):
|
def _build(self) -> None:
|
||||||
bus = EventBus.get()
|
header = SectionHeader(
|
||||||
bus.subscribe(EVENT_ACCOUNT_UPDATED, self._on_account_updated_event)
|
self,
|
||||||
bus.subscribe(EVENT_QUOTA_UPDATED, self._on_quota_updated_event)
|
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):
|
toolbar = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
if isinstance(payload, dict):
|
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, Theme.SPACE_SM))
|
||||||
pid = payload.get("profile_id")
|
self.search = SearchField(toolbar, placeholder_text="Поиск по аккаунту, модели или роли…", width=300)
|
||||||
prof = payload.get("profile")
|
self.search.pack(side="left", padx=(0, Theme.SPACE_SM))
|
||||||
if pid and prof and pid in self._cards:
|
self.search.bind("<KeyRelease>", lambda _event: self._set_search(self.search.get()))
|
||||||
self.after(0, lambda: self._cards[pid].update_from_model(prof))
|
self.provider_filter = FilterButton(
|
||||||
|
toolbar,
|
||||||
def _on_quota_updated_event(self, event_name: str, payload: Any):
|
values=["Все провайдеры", *PROVIDER_LABELS.values()],
|
||||||
if isinstance(payload, dict):
|
command=self._set_provider_filter,
|
||||||
pid = payload.get("profile_id")
|
width=170,
|
||||||
quota_snap = payload.get("quota_snapshot")
|
)
|
||||||
if pid and pid in self._cards:
|
self.provider_filter.pack(side="left", padx=(0, Theme.SPACE_SM))
|
||||||
p = self._cards[pid].profile
|
self.health_filter = FilterButton(
|
||||||
self.after(0, lambda: self._cards[pid].update_from_model(p, quota_snap))
|
toolbar,
|
||||||
|
values=["Все состояния", "Работают", "Требуют входа", "Проблемные"],
|
||||||
def _build(self):
|
command=self._set_health_filter,
|
||||||
# 1. Header with Refresh All and Add Account buttons
|
width=150,
|
||||||
header_row = ctk.CTkFrame(self, fg_color="transparent")
|
)
|
||||||
header_row.pack(fill="x", padx=20, pady=(16, 8))
|
self.health_filter.pack(side="left", padx=(0, Theme.SPACE_SM))
|
||||||
|
self.role_filter = FilterButton(
|
||||||
titles_col = ctk.CTkFrame(header_row, fg_color="transparent")
|
toolbar,
|
||||||
titles_col.pack(side="left", fill="both", expand=True)
|
values=["Все роли", "orchestrator", "coder", "reviewer", "researcher", "tester", "general", "spare"],
|
||||||
|
command=self._set_role_filter,
|
||||||
ctk.CTkLabel(
|
width=135,
|
||||||
titles_col,
|
)
|
||||||
text="Управление аккаунтами",
|
self.role_filter.pack(side="left")
|
||||||
font=Theme.font_title(),
|
ActionButton(
|
||||||
text_color=Theme.TEXT_PRIMARY,
|
toolbar,
|
||||||
anchor="w",
|
text="Обновить все",
|
||||||
).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="↻ Обновить все",
|
|
||||||
variant="secondary",
|
variant="secondary",
|
||||||
height=Theme.HEIGHT_BTN_MD,
|
command=lambda: self._emit("refresh_all", {}),
|
||||||
command=self._refresh_all_quotas,
|
).pack(side="right")
|
||||||
)
|
|
||||||
self.refresh_all_btn.pack(side="left", padx=(0, 8))
|
|
||||||
|
|
||||||
HubButton(
|
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||||
actions_col,
|
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||||
text="+ Добавить аккаунт",
|
self.empty = EmptyState(
|
||||||
variant="primary",
|
self.scroll,
|
||||||
height=Theme.HEIGHT_BTN_MD,
|
title="Нет подходящих аккаунтов",
|
||||||
command=lambda: self._trigger_action("add_account", {}),
|
message="Измените фильтры или подключите новый аккаунт.",
|
||||||
).pack(side="left")
|
action_text="Подключить аккаунт",
|
||||||
|
action_cmd=lambda: self._emit("add_account", {}),
|
||||||
# 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):
|
def _emit(self, action: str, profile: Any) -> None:
|
||||||
self._search_query = query
|
if not self.on_action:
|
||||||
self.update_data()
|
return
|
||||||
|
if isinstance(profile, dict):
|
||||||
|
payload = profile
|
||||||
|
else:
|
||||||
|
payload = {
|
||||||
|
"profile_id": profile.profile_id,
|
||||||
|
"provider": profile.provider,
|
||||||
|
"display_name": profile.display_name,
|
||||||
|
}
|
||||||
|
self.on_action(action, payload)
|
||||||
|
|
||||||
def _on_filter(self, filter_val: str):
|
def _set_search(self, value: str) -> None:
|
||||||
self._filter_status = filter_val
|
self._search = value.strip().lower()
|
||||||
self.update_data()
|
self._render_visibility()
|
||||||
|
|
||||||
def _on_sort(self, sort_val: str):
|
def _set_provider_filter(self, value: str) -> None:
|
||||||
self._sort_by = sort_val
|
self._provider_filter = value
|
||||||
self.update_data()
|
self._render_visibility()
|
||||||
|
|
||||||
def update_data(self, snapshot: Optional[HubSnapshot] = None):
|
def _set_health_filter(self, value: str) -> None:
|
||||||
"""Update views by reusing widgets and updating properties in place."""
|
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):
|
if not isinstance(snapshot, HubSnapshot):
|
||||||
# A non-snapshot (e.g. a legacy app_state dict) must fall back
|
return
|
||||||
# to the store rather than crash the view.
|
self._snapshot = snapshot
|
||||||
snapshot = HubStateStore.get().get_snapshot()
|
live_ids = {profile.profile_id for profile in self._profiles()}
|
||||||
|
for profile_id in list(self._cards):
|
||||||
|
if profile_id not in live_ids:
|
||||||
|
self._cards.pop(profile_id).destroy()
|
||||||
|
self.cards_destroyed += 1
|
||||||
|
|
||||||
self._last_rendered_generation = snapshot.generation
|
for profile in self._profiles():
|
||||||
profiles_by_prov = snapshot.profiles_by_provider
|
group = self._groups.get(profile.provider)
|
||||||
|
if group is None:
|
||||||
|
group = ProviderGroup(self.scroll, profile.provider)
|
||||||
|
self._groups[profile.provider] = group
|
||||||
|
card = self._cards.get(profile.profile_id)
|
||||||
|
if card is None:
|
||||||
|
card = AccountCardWidget(
|
||||||
|
group.body,
|
||||||
|
profile.profile_id,
|
||||||
|
AccountCardWidget.resolve_identity(profile),
|
||||||
|
profile.provider_display_name,
|
||||||
|
on_action=self._emit,
|
||||||
|
)
|
||||||
|
self._cards[profile.profile_id] = card
|
||||||
|
self.cards_created += 1
|
||||||
|
card.update_account(profile, snapshot.quotas.get(profile.profile_id))
|
||||||
|
self._render_visibility()
|
||||||
|
|
||||||
for prov_key, scroll in self.tab_scrolls.items():
|
def _render_visibility(self) -> None:
|
||||||
profiles = profiles_by_prov.get(prov_key, [])
|
if not self._snapshot:
|
||||||
|
self.empty.pack(fill="x", pady=Theme.SPACE_XL)
|
||||||
|
return
|
||||||
|
grouped: Dict[str, list[Any]] = defaultdict(list)
|
||||||
|
for profile in self._profiles():
|
||||||
|
if self._matches(profile):
|
||||||
|
grouped[profile.provider].append(profile)
|
||||||
|
visible_total = 0
|
||||||
|
for provider, group in self._groups.items():
|
||||||
|
profiles = grouped.get(provider, [])
|
||||||
|
if not profiles:
|
||||||
|
group.pack_forget()
|
||||||
|
continue
|
||||||
|
group.pack(fill="x")
|
||||||
|
group.count.configure(text=f"{len(profiles)} аккаунт(а)")
|
||||||
|
for card in self._cards.values():
|
||||||
|
if card.profile_model and card.profile_model.provider == provider:
|
||||||
|
card.grid_remove()
|
||||||
|
for index, profile in enumerate(profiles):
|
||||||
|
self._cards[profile.profile_id].grid(
|
||||||
|
row=index // Theme.ACCOUNT_CARD_COLUMNS,
|
||||||
|
column=index % Theme.ACCOUNT_CARD_COLUMNS,
|
||||||
|
padx=Theme.SPACE_XS,
|
||||||
|
pady=Theme.SPACE_XS,
|
||||||
|
sticky="nsew",
|
||||||
|
)
|
||||||
|
visible_total += len(profiles)
|
||||||
|
if visible_total:
|
||||||
|
self.empty.pack_forget()
|
||||||
|
else:
|
||||||
|
self.empty.pack(fill="x", pady=Theme.SPACE_XL)
|
||||||
|
|
||||||
# Filter
|
def render_stats(self) -> Dict[str, int]:
|
||||||
filtered: List[ProfileViewModel] = []
|
return {
|
||||||
for p in profiles:
|
"cards_created": self.cards_created,
|
||||||
if self._search_query:
|
"cards_destroyed": self.cards_destroyed,
|
||||||
q = self._search_query.lower()
|
"quota_widgets_created": sum(card.widgets_created for card in self._cards.values()),
|
||||||
matches = (
|
"quota_widgets_destroyed": sum(card.widgets_destroyed for card in self._cards.values()),
|
||||||
q in p.account_identity.lower()
|
}
|
||||||
or q in p.display_name.lower()
|
|
||||||
or q in p.plan.lower()
|
|
||||||
or any(q in m.lower() for m in p.preferred_models)
|
|
||||||
or any(q in r.lower() for r in p.assigned_roles)
|
|
||||||
)
|
|
||||||
if not matches:
|
|
||||||
continue
|
|
||||||
|
|
||||||
if self._filter_status == "Подключённые" and p.auth_state != "AUTHENTICATED":
|
|
||||||
continue
|
|
||||||
elif self._filter_status == "Требуют входа" and p.auth_state == "AUTHENTICATED":
|
|
||||||
continue
|
|
||||||
elif self._filter_status == "Квота исчерпана" and "quota" not in p.health_state:
|
|
||||||
continue
|
|
||||||
|
|
||||||
filtered.append(p)
|
|
||||||
|
|
||||||
if self._sort_by == "По имени":
|
|
||||||
filtered.sort(key=lambda x: x.display_name)
|
|
||||||
elif self._sort_by == "По статусу":
|
|
||||||
filtered.sort(key=lambda x: x.health_state)
|
|
||||||
|
|
||||||
configured_profs = [p for p in filtered if not p.is_empty_slot]
|
|
||||||
empty_profs = [p for p in filtered if p.is_empty_slot]
|
|
||||||
|
|
||||||
# Reusable placement
|
|
||||||
grid_idx = 0
|
|
||||||
for p in configured_profs:
|
|
||||||
row_idx, col_idx = divmod(grid_idx, 3)
|
|
||||||
q_snap = snapshot.quotas.get(p.profile_id)
|
|
||||||
|
|
||||||
if p.profile_id in self._cards:
|
|
||||||
card = self._cards[p.profile_id]
|
|
||||||
card.update_from_model(p, q_snap)
|
|
||||||
else:
|
|
||||||
card = AccountCardWidget(
|
|
||||||
scroll,
|
|
||||||
profile=p,
|
|
||||||
quota_snapshot=q_snap,
|
|
||||||
on_action=self._trigger_action,
|
|
||||||
on_refresh=self._refresh_single_account,
|
|
||||||
)
|
|
||||||
self._cards[p.profile_id] = card
|
|
||||||
|
|
||||||
card.grid(row=row_idx, column=col_idx, padx=6, pady=6, sticky="nsew")
|
|
||||||
grid_idx += 1
|
|
||||||
|
|
||||||
# Empty slots summary card
|
|
||||||
if empty_profs:
|
|
||||||
c_row = (grid_idx // 3) + 1
|
|
||||||
slot_key = f"empty_{prov_key}"
|
|
||||||
if slot_key not in self._empty_cards:
|
|
||||||
summary_card = HubCard(scroll, border_color=Theme.BORDER_SUBTLE, fg_color=Theme.SURFACE_MUTED)
|
|
||||||
s_inner = ctk.CTkFrame(summary_card, fg_color="transparent")
|
|
||||||
s_inner.pack(fill="x", padx=16, pady=12)
|
|
||||||
|
|
||||||
lbl = ctk.CTkLabel(
|
|
||||||
s_inner,
|
|
||||||
text=f"Свободные слоты: {len(empty_profs)} слотов доступно",
|
|
||||||
font=Theme.font_body_bold(),
|
|
||||||
text_color=Theme.TEXT_SECONDARY,
|
|
||||||
)
|
|
||||||
lbl.pack(side="left")
|
|
||||||
|
|
||||||
HubButton(
|
|
||||||
s_inner,
|
|
||||||
text="+ Подключить аккаунт",
|
|
||||||
variant="primary",
|
|
||||||
height=Theme.HEIGHT_BTN_SM,
|
|
||||||
command=lambda k=prov_key: self._trigger_action("add_account", {"provider": k}),
|
|
||||||
).pack(side="right")
|
|
||||||
|
|
||||||
self._empty_cards[slot_key] = summary_card
|
|
||||||
else:
|
|
||||||
summary_card = self._empty_cards[slot_key]
|
|
||||||
|
|
||||||
summary_card.grid(row=c_row, column=0, columnspan=3, padx=6, pady=10, sticky="ew")
|
|
||||||
|
|
|
||||||
|
|
@ -1,141 +1,166 @@
|
||||||
"""Hermes Hub — Dashboard View (Главный экран)."""
|
"""Snapshot-driven overview of readiness, routes and actionable account warnings."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
import customtkinter as ctk
|
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.theme import Theme
|
||||||
from antigravity_provider.router.ui.components import (
|
|
||||||
HubButton,
|
|
||||||
HubCard,
|
class _SummaryRow(HubCard):
|
||||||
HubMetricCard,
|
def __init__(self, master: Any, title: str = "", detail: str = ""):
|
||||||
HubProviderBadge,
|
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE)
|
||||||
HubSectionHeader,
|
self.title = ctk.CTkLabel(self, text=title, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
|
||||||
HubStatusBadge,
|
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):
|
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)
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
self.app_state = app_state
|
|
||||||
self.on_navigate = on_navigate
|
self.on_navigate = on_navigate
|
||||||
self.on_action = on_action
|
self.on_action = on_action
|
||||||
|
self._provider_rows: Dict[str, _SummaryRow] = {}
|
||||||
|
self._route_rows: Dict[str, _SummaryRow] = {}
|
||||||
|
self._alert_rows: Dict[str, _SummaryRow] = {}
|
||||||
self._build()
|
self._build()
|
||||||
|
|
||||||
def _build(self):
|
def _build(self) -> None:
|
||||||
# ── Header ──
|
SectionHeader(
|
||||||
header = HubSectionHeader(
|
|
||||||
self,
|
self,
|
||||||
title="Главная панель управления",
|
title="Обзор Hermes Hub",
|
||||||
subtitle="Multi-Agent & Multi-Provider Control Hub",
|
subtitle="Кто работает сейчас, через какой маршрут и где требуется внимание",
|
||||||
action_text="+ Добавить аккаунт",
|
action_text="Обновить",
|
||||||
action_cmd=lambda: self._trigger_action("add_account"),
|
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")
|
def _section(self, master: Any, title: str, row: int, column: int, columnspan: int = 1) -> ctk.CTkFrame:
|
||||||
scroll.pack(fill="both", expand=True, padx=15, pady=(0, 10))
|
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
|
@staticmethod
|
||||||
stats = data.get("stats", {})
|
def _sync_rows(container: Any, cache: Dict[str, _SummaryRow], rows: Dict[str, tuple[str, str, str]]) -> None:
|
||||||
total_profiles = stats.get("total_profiles", 0)
|
for key in list(cache):
|
||||||
auth_profiles = stats.get("authenticated_profiles", 0)
|
if key not in rows:
|
||||||
providers_data = data.get("providers", {})
|
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", [])
|
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||||
codex_list = providers_data.get("openai-codex", [])
|
if not isinstance(snapshot, HubSnapshot):
|
||||||
opencode_list = providers_data.get("opencode-go", [])
|
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"))
|
provider_rows = {
|
||||||
codex_online = sum(1 for p in codex_list if p.get("authenticated"))
|
provider.provider_id: (
|
||||||
opencode_online = sum(1 for p in opencode_list if p.get("authenticated"))
|
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) ──
|
alerts: Dict[str, tuple[str, str, str]] = {}
|
||||||
metrics_grid = ctk.CTkFrame(scroll, fg_color="transparent")
|
for profile in snapshot.all_profiles.values():
|
||||||
metrics_grid.pack(fill="x", pady=(0, 16))
|
if profile.auth_state == "AUTH_EXPIRED":
|
||||||
for i in range(4):
|
alerts[f"auth:{profile.profile_id}"] = (
|
||||||
metrics_grid.grid_columnconfigure(i, weight=1)
|
profile.account_identity or profile.profile_id,
|
||||||
|
"Авторизация истекла",
|
||||||
|
"auth_expired",
|
||||||
|
)
|
||||||
|
quota = snapshot.quotas.get(profile.profile_id)
|
||||||
|
if quota and not getattr(quota, "is_estimated", True):
|
||||||
|
low = [
|
||||||
|
bucket
|
||||||
|
for bucket in quota.buckets
|
||||||
|
if bucket.remaining_percent is not None and bucket.remaining_percent <= 20
|
||||||
|
]
|
||||||
|
if low:
|
||||||
|
alerts[f"quota:{profile.profile_id}"] = (
|
||||||
|
profile.account_identity or profile.profile_id,
|
||||||
|
", ".join(f"{bucket.display_name}: {bucket.remaining_percent:.0f}%" for bucket in low),
|
||||||
|
"quota_low",
|
||||||
|
)
|
||||||
|
for index, warning in enumerate(readiness.warnings):
|
||||||
|
alerts[f"warning:{index}"] = ("Системное предупреждение", warning, "warning")
|
||||||
|
if not alerts:
|
||||||
|
alerts["none"] = ("Критичных предупреждений нет", "По данным текущего snapshot", "healthy")
|
||||||
|
self._sync_rows(self.alerts_card, self._alert_rows, alerts)
|
||||||
|
|
||||||
m1 = HubMetricCard(metrics_grid, title="Агенты", value=str(total_profiles), subtext=f"{auth_profiles} активны в ролях", icon="👥", accent=True)
|
def _navigate(self, target: str) -> None:
|
||||||
m1.grid(row=0, column=0, padx=6, sticky="nsew")
|
|
||||||
|
|
||||||
m2 = HubMetricCard(metrics_grid, title="Аккаунты", value=f"{auth_profiles}/{total_profiles}", subtext="Авторизовано", icon="🔑")
|
|
||||||
m2.grid(row=0, column=1, padx=6, sticky="nsew")
|
|
||||||
|
|
||||||
m3 = HubMetricCard(metrics_grid, title="Провайдеры", value="3", subtext="Antigravity, Codex, OpenCode", icon="🌐")
|
|
||||||
m3.grid(row=0, column=2, padx=6, sticky="nsew")
|
|
||||||
|
|
||||||
m4 = HubMetricCard(metrics_grid, title="Состояние", value=overall_state, subtext="Fail-closed router active", icon="🛡️")
|
|
||||||
m4.grid(row=0, column=3, padx=6, sticky="nsew")
|
|
||||||
|
|
||||||
# ── 2. Middle Row: Providers Status & Active Roles ──
|
|
||||||
mid_frame = ctk.CTkFrame(scroll, fg_color="transparent")
|
|
||||||
mid_frame.pack(fill="x", pady=(0, 16))
|
|
||||||
mid_frame.grid_columnconfigure(0, weight=1)
|
|
||||||
mid_frame.grid_columnconfigure(1, weight=1)
|
|
||||||
|
|
||||||
# 2A: Provider Status Breakdown
|
|
||||||
prov_card = HubCard(mid_frame)
|
|
||||||
prov_card.grid(row=0, column=0, padx=(0, 8), sticky="nsew")
|
|
||||||
|
|
||||||
ctk.CTkLabel(prov_card, text="Состояние провайдеров", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=16, pady=(14, 10))
|
|
||||||
|
|
||||||
prov_rows = [
|
|
||||||
("Google Antigravity", f"{ag_online} / {len(ag_list)} онлайн", Theme.PROVIDER_ANTIGRAVITY),
|
|
||||||
("OpenAI Codex", f"{codex_online} / {len(codex_list)} онлайн", Theme.PROVIDER_CODEX),
|
|
||||||
("OpenCode Go", f"{opencode_online} / {len(opencode_list)} онлайн", Theme.PROVIDER_OPENCODE),
|
|
||||||
]
|
|
||||||
for name, status_txt, col in prov_rows:
|
|
||||||
row = ctk.CTkFrame(prov_card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
|
||||||
row.pack(fill="x", padx=16, pady=4)
|
|
||||||
ctk.CTkLabel(row, text=f"◈ {name}", font=Theme.font_body_bold(), text_color=col).pack(side="left", padx=10, pady=8)
|
|
||||||
ctk.CTkLabel(row, text=status_txt, font=Theme.font_body(), text_color=Theme.TEXT_SECONDARY).pack(side="right", padx=10)
|
|
||||||
|
|
||||||
# 2B: Core Leadership & Orchestrator
|
|
||||||
lead_card = HubCard(mid_frame)
|
|
||||||
lead_card.grid(row=0, column=1, padx=(8, 0), sticky="nsew")
|
|
||||||
|
|
||||||
ctk.CTkLabel(lead_card, text="Ключевые назначения", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=16, pady=(14, 10))
|
|
||||||
|
|
||||||
# Find main profile and orchestrator
|
|
||||||
main_ag_id = data.get("main_profiles", {}).get("antigravity", "—")
|
|
||||||
main_codex_id = data.get("main_profiles", {}).get("openai-codex", "—")
|
|
||||||
|
|
||||||
key_items = [
|
|
||||||
("👑 Главный оркестратор", f"OpenAI Codex ({main_codex_id})", Theme.STATUS_ORCHESTRATOR),
|
|
||||||
("⭐ Основной Antigravity", f"Google Antigravity ({main_ag_id})", Theme.STATUS_MAIN),
|
|
||||||
("🔄 Резервный оркестратор", "Google Antigravity (ag-orch-fallback)", Theme.STATUS_FAILOVER),
|
|
||||||
]
|
|
||||||
for title_str, val_str, clr in key_items:
|
|
||||||
row = ctk.CTkFrame(lead_card, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
|
||||||
row.pack(fill="x", padx=16, pady=4)
|
|
||||||
ctk.CTkLabel(row, text=title_str, font=Theme.font_body_bold(), text_color=clr).pack(side="left", padx=10, pady=8)
|
|
||||||
ctk.CTkLabel(row, text=val_str, font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="right", padx=10)
|
|
||||||
|
|
||||||
# ── 3. Quick Actions Banner ──
|
|
||||||
banner = HubCard(scroll, fg_color=Theme.DARK, border_color=Theme.BORDER_ACCENT)
|
|
||||||
banner.pack(fill="x", pady=(0, 10))
|
|
||||||
|
|
||||||
b_inner = ctk.CTkFrame(banner, fg_color="transparent")
|
|
||||||
b_inner.pack(fill="x", padx=16, pady=14)
|
|
||||||
|
|
||||||
b_text = ctk.CTkFrame(b_inner, fg_color="transparent")
|
|
||||||
b_text.pack(side="left")
|
|
||||||
ctk.CTkLabel(b_text, text="Управление маршрутизацией и отказоустойчивостью", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT).pack(anchor="w")
|
|
||||||
ctk.CTkLabel(b_text, text="Автоматический failover при исчерпании квот и поддержка session affinity активны.", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(anchor="w")
|
|
||||||
|
|
||||||
b_btns = ctk.CTkFrame(b_inner, fg_color="transparent")
|
|
||||||
b_btns.pack(side="right")
|
|
||||||
HubButton(b_btns, text="Команда агентов", variant="secondary", command=lambda: self._navigate("team")).pack(side="left", padx=4)
|
|
||||||
HubButton(b_btns, text="Маршрутизация", variant="secondary", command=lambda: self._navigate("routing")).pack(side="left", padx=4)
|
|
||||||
|
|
||||||
def _navigate(self, target: str):
|
|
||||||
if self.on_navigate:
|
if self.on_navigate:
|
||||||
self.on_navigate(target)
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
import customtkinter as ctk
|
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.theme import Theme
|
||||||
from antigravity_provider.router.ui.assets import AssetManager
|
|
||||||
from antigravity_provider.router.ui.components import (
|
|
||||||
HubButton,
|
class HealthProfileRow(HubCard):
|
||||||
HubCard,
|
def __init__(self, master: Any):
|
||||||
HubSectionHeader,
|
super().__init__(master, corner_radius=Theme.RADIUS_SM, border_color=Theme.BORDER_SUBTLE)
|
||||||
HubStatusBadge,
|
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")
|
||||||
from antigravity_provider.router.unified_health import (
|
self.models = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
|
||||||
UnifiedHealthService,
|
self.models.grid(row=0, column=1, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="w")
|
||||||
ProfileViewModel,
|
self.status = StatusBadge(self, "not_tested")
|
||||||
STATUS_HEALTHY,
|
self.status.grid(row=0, column=2, padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM, sticky="e")
|
||||||
STATUS_NOT_CONFIGURED,
|
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):
|
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)
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
self.app_state = app_state
|
self._rows: Dict[str, HealthProfileRow] = {}
|
||||||
self.on_refresh = on_refresh
|
SectionHeader(
|
||||||
self._build()
|
|
||||||
|
|
||||||
def _build(self):
|
|
||||||
header = HubSectionHeader(
|
|
||||||
self,
|
self,
|
||||||
title="Диагностика и Состояние Системы",
|
title="Состояние системы",
|
||||||
subtitle="Детальный аудит доступности аккаунтов, авторизации, семейств моделей и квот",
|
subtitle="Авторизация, локальное здоровье и наблюдаемые состояния моделей",
|
||||||
action_text="🔄 Обновить аудит",
|
action_text="Обновить аудит",
|
||||||
action_cmd=self.on_refresh,
|
action_cmd=on_refresh,
|
||||||
)
|
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||||
header.pack(fill="x", padx=20, pady=(16, 12))
|
|
||||||
|
|
||||||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
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[HubSnapshot] = None) -> None:
|
||||||
|
|
||||||
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
|
|
||||||
if not isinstance(snapshot, HubSnapshot):
|
if not isinstance(snapshot, HubSnapshot):
|
||||||
# Callers have historically passed legacy app_state dicts here; a
|
return
|
||||||
# non-snapshot must fall back to the store, not crash the view.
|
|
||||||
snapshot = HubStateStore.get().get_snapshot()
|
|
||||||
|
|
||||||
readiness = snapshot.readiness
|
readiness = snapshot.readiness
|
||||||
profiles_by_prov = snapshot.profiles_by_provider
|
self.title.configure(text=readiness.title_ru)
|
||||||
|
warnings = " • ".join(readiness.warnings) if readiness.warnings else "Предупреждений нет"
|
||||||
# Top Diagnostic Banner
|
self.summary.configure(
|
||||||
banner = HubCard(self.scroll, border_color=Theme.BORDER_ACCENT, fg_color=Theme.DARK)
|
text=(
|
||||||
banner.pack(fill="x", pady=(0, 12))
|
f"{readiness.summary_ru}\n"
|
||||||
|
f"Аккаунты {readiness.accounts_connected_count}/{readiness.total_accounts} • "
|
||||||
b_top = ctk.CTkFrame(banner, fg_color="transparent")
|
f"Роли {readiness.roles_ready_count}/{readiness.total_roles} • {warnings}"
|
||||||
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")
|
live = set(snapshot.all_profiles)
|
||||||
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")
|
for profile_id in list(self._rows):
|
||||||
|
if profile_id not in live:
|
||||||
ctk.CTkLabel(banner, text=readiness.summary_ru, font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=16, pady=(0, 10))
|
self._rows.pop(profile_id).destroy()
|
||||||
|
ordered = sorted(snapshot.all_profiles.values(), key=lambda profile: (profile.provider, profile.display_name))
|
||||||
if readiness.warnings:
|
for index, profile in enumerate(ordered):
|
||||||
w_box = ctk.CTkFrame(banner, fg_color="#3B2610", corner_radius=Theme.RADIUS_SM)
|
row = self._rows.get(profile.profile_id)
|
||||||
w_box.pack(fill="x", padx=16, pady=(0, 12))
|
if row is None:
|
||||||
for w_txt in readiness.warnings:
|
row = HealthProfileRow(self.rows)
|
||||||
ctk.CTkLabel(w_box, text=f"⚠️ {w_txt}", font=Theme.font_caption(), text_color=Theme.STATUS_WARNING).pack(anchor="w", padx=10, pady=2)
|
self._rows[profile.profile_id] = row
|
||||||
|
row.update_profile(profile)
|
||||||
# Provider Breakdown Tables
|
row.grid(row=index, column=0, sticky="ew", pady=Theme.SPACE_XS)
|
||||||
for prov_id, prov_label in [
|
self.rows.grid_columnconfigure(0, weight=1)
|
||||||
("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")
|
|
||||||
|
|
|
||||||
|
|
@ -1,104 +1,34 @@
|
||||||
"""Hermes Hub — Logs View (Журнал событий и аудит маршрутизации)."""
|
"""Event-log screen with honest handling of the current snapshot contract gap."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
from typing import Any, Optional
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
import customtkinter as ctk
|
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.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):
|
class LogsView(ctk.CTkFrame):
|
||||||
def __init__(self, master: Any, **kwargs):
|
def __init__(self, master: Any, **kwargs):
|
||||||
super().__init__(master=master, fg_color="transparent", **kwargs)
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
self.filter_category: Optional[str] = None
|
SectionHeader(
|
||||||
self._build()
|
|
||||||
|
|
||||||
def _build(self):
|
|
||||||
header = HubSectionHeader(
|
|
||||||
self,
|
self,
|
||||||
title="Журнал Событий и Аудит",
|
title="Журнал событий",
|
||||||
subtitle="История подключений, переключений маршрутизатора, исчерпания квот и failover",
|
subtitle="Только события, переданные через единый snapshot-контракт",
|
||||||
action_text="🔄 Обновить",
|
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||||
action_cmd=self._refresh_events,
|
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
|
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
|
||||||
filters_row = ctk.CTkFrame(self, fg_color="transparent")
|
if not isinstance(snapshot, HubSnapshot):
|
||||||
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)
|
|
||||||
return
|
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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
import customtkinter as ctk
|
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.theme import Theme
|
||||||
from antigravity_provider.router.ui.components import (
|
|
||||||
HubButton,
|
|
||||||
HubCard,
|
class ProviderCard(HubCard):
|
||||||
HubSectionHeader,
|
def __init__(self, master: Any):
|
||||||
HubStatusBadge,
|
super().__init__(master)
|
||||||
)
|
top = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
from antigravity_provider.router.unified_health import (
|
top.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM))
|
||||||
UnifiedHealthService,
|
self.title = ctk.CTkLabel(top, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
|
||||||
ProviderSummary,
|
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):
|
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)
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
self.app_state = app_state
|
|
||||||
self.on_action = on_action
|
self.on_action = on_action
|
||||||
self._build()
|
self._cards: Dict[str, ProviderCard] = {}
|
||||||
|
SectionHeader(
|
||||||
def _build(self):
|
|
||||||
header = HubSectionHeader(
|
|
||||||
self,
|
self,
|
||||||
title="Провайдеры ИИ и Модели",
|
title="Провайдеры и модели",
|
||||||
subtitle="Сводка доступности адаптеров, квот и обнаруженных языковых моделей",
|
subtitle="Локальная доступность адаптеров и реально обнаруженные модели",
|
||||||
action_text="🔄 Обновить модели",
|
action_text="Обновить",
|
||||||
action_cmd=self._refresh_models,
|
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))
|
||||||
header.pack(fill="x", padx=20, pady=(16, 12))
|
|
||||||
|
|
||||||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
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) -> None:
|
||||||
|
|
||||||
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
|
|
||||||
if not isinstance(snapshot, HubSnapshot):
|
if not isinstance(snapshot, HubSnapshot):
|
||||||
# Callers have historically passed legacy app_state dicts here; a
|
return
|
||||||
# non-snapshot must fall back to the store, not crash the view.
|
live = {summary.provider_id for summary in snapshot.providers}
|
||||||
snapshot = HubStateStore.get().get_snapshot()
|
for provider_id in list(self._cards):
|
||||||
|
if provider_id not in live:
|
||||||
summaries = snapshot.providers
|
self._cards.pop(provider_id).destroy()
|
||||||
|
for summary in snapshot.providers:
|
||||||
for s in summaries:
|
card = self._cards.get(summary.provider_id)
|
||||||
col = Theme.PROVIDER_ANTIGRAVITY if "antigravity" in s.provider_id else (Theme.PROVIDER_CODEX if "codex" in s.provider_id else Theme.PROVIDER_OPENCODE)
|
if card is None:
|
||||||
|
card = ProviderCard(self.scroll)
|
||||||
card = HubCard(self.scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE)
|
card.pack(fill="x", pady=Theme.SPACE_XS)
|
||||||
card.pack(fill="x", pady=8)
|
self._cards[summary.provider_id] = card
|
||||||
|
card.update_provider(summary)
|
||||||
# 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", {})
|
|
||||||
|
|
|
||||||
|
|
@ -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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Callable, Dict, Optional
|
||||||
|
|
||||||
import customtkinter as ctk
|
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.theme import Theme
|
||||||
from antigravity_provider.router.ui.assets import AssetManager
|
from antigravity_provider.router.unified_health import RolePipeline
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RoutingRoleWidget(HubCard):
|
class RoutingRoleWidget(HubCard):
|
||||||
"""Reusable visual pipeline widget for a specific role."""
|
def __init__(self, master: Any, pipeline: RolePipeline, on_action: Optional[Callable] = None, **kwargs):
|
||||||
|
super().__init__(master, **kwargs)
|
||||||
def __init__(self, parent: Any, pipeline: RolePipeline, **kwargs):
|
|
||||||
super().__init__(parent, border_color=Theme.BORDER, fg_color=Theme.SURFACE, **kwargs)
|
|
||||||
self.pipeline = pipeline
|
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)
|
self.update_from_pipeline(pipeline)
|
||||||
|
|
||||||
def _build(self):
|
def update_from_pipeline(self, pipeline: RolePipeline) -> None:
|
||||||
# 1. Top row
|
self.pipeline = pipeline
|
||||||
self.top_row = ctk.CTkFrame(self, fg_color="transparent")
|
self.title.configure(text=pipeline.role_name_ru)
|
||||||
self.top_row.pack(fill="x", padx=16, pady=(14, 4))
|
affinity = "session affinity" if pipeline.session_affinity else "без affinity"
|
||||||
|
self.meta.configure(text=f"{pipeline.default_model} • {affinity}")
|
||||||
self.title_lbl = ctk.CTkLabel(self.top_row, text="", font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT)
|
live = {node.profile_id for node in pipeline.nodes}
|
||||||
self.title_lbl.pack(side="left")
|
for profile_id in list(self._nodes):
|
||||||
|
if profile_id not in live:
|
||||||
self.role_id_lbl = ctk.CTkLabel(self.top_row, text="", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED)
|
self._nodes.pop(profile_id).destroy()
|
||||||
self.role_id_lbl.pack(side="left", padx=(8, 0))
|
for index, node in enumerate(pipeline.nodes):
|
||||||
|
rank = "Основной" if index == 0 else f"Резерв {index}"
|
||||||
self.meta_top_lbl = ctk.CTkLabel(self.top_row, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
|
subtitle = f"{node.provider_display_name} • {node.model}"
|
||||||
self.meta_top_lbl.pack(side="right")
|
widget = self._nodes.get(node.profile_id)
|
||||||
|
if widget is None:
|
||||||
# 2. Visualizer Box
|
widget = RouteTargetWidget(self.chain, rank, node.display_name, subtitle)
|
||||||
self.pipeline_box = ctk.CTkFrame(self, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
self._nodes[node.profile_id] = widget
|
||||||
self.pipeline_box.pack(fill="x", padx=16, pady=6)
|
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.p_inner = ctk.CTkFrame(self.pipeline_box, fg_color="transparent")
|
self.chain.grid_columnconfigure(index, weight=1)
|
||||||
self.p_inner.pack(fill="x", padx=12, pady=10)
|
active = next((node for node in pipeline.nodes if node.is_active), None)
|
||||||
|
self.footer.configure(
|
||||||
# 3. Bottom status row
|
text=(f"Активен: {active.display_name} • причина переключения: Н/Д" if active else "Активный узел: Н/Д")
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
class RoutingView(ctk.CTkFrame):
|
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)
|
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._role_widgets: Dict[str, RoutingRoleWidget] = {}
|
||||||
self._last_rendered_generation = 0
|
SectionHeader(
|
||||||
|
|
||||||
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(
|
|
||||||
self,
|
self,
|
||||||
title="Маршрутизация и Цепочки Failover",
|
title="Маршрутизация",
|
||||||
subtitle="Политики выбора провайдеров для ролей агентов, приоритеты и сессионная привязка",
|
subtitle="Основной → резерв 1 → резерв 2 → резерв 3; без выдуманных причин failover",
|
||||||
)
|
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||||
header.pack(fill="x", padx=20, pady=(16, 12))
|
|
||||||
|
|
||||||
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
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) -> None:
|
||||||
|
|
||||||
def update_data(self, snapshot: Optional[HubSnapshot] = None):
|
|
||||||
"""Update routing view using cached snapshot and reusable role widgets."""
|
|
||||||
if not isinstance(snapshot, HubSnapshot):
|
if not isinstance(snapshot, HubSnapshot):
|
||||||
# A non-snapshot (e.g. a legacy app_state dict) must fall back
|
return
|
||||||
# to the store rather than crash the view.
|
live_roles = set(snapshot.routing)
|
||||||
snapshot = HubStateStore.get().get_snapshot()
|
for role_id in list(self._role_widgets):
|
||||||
|
if role_id not in live_roles:
|
||||||
self._last_rendered_generation = snapshot.generation
|
self._role_widgets.pop(role_id).destroy()
|
||||||
pipelines = snapshot.routing
|
for role_id, pipeline in snapshot.routing.items():
|
||||||
|
widget = self._role_widgets.get(role_id)
|
||||||
for rname, pipe in pipelines.items():
|
if widget is None:
|
||||||
if rname in self._role_widgets:
|
widget = RoutingRoleWidget(self.scroll, pipeline, self.on_action)
|
||||||
self._role_widgets[rname].update_from_pipeline(pipe)
|
widget.pack(fill="x", pady=Theme.SPACE_XS)
|
||||||
else:
|
self._role_widgets[role_id] = widget
|
||||||
widget = RoutingRoleWidget(self.scroll, pipe)
|
widget.update_from_pipeline(pipeline)
|
||||||
widget.pack(fill="x", pady=6)
|
|
||||||
self._role_widgets[rname] = widget
|
|
||||||
|
|
|
||||||
|
|
@ -1,264 +1,93 @@
|
||||||
"""Hermes Hub — Settings View (Интерактивные параметры, настройки и обновления)."""
|
"""Presentation-only settings screen; persistence is delegated to the app action layer."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
from typing import Any, Callable, Dict, Optional
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import threading
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, Optional
|
|
||||||
import customtkinter as ctk
|
import customtkinter as ctk
|
||||||
|
|
||||||
from antigravity_provider import paths
|
from antigravity_provider.router.ui.components import ActionButton, HubCard, SectionHeader
|
||||||
from antigravity_provider.version import __version__, CHANNEL
|
|
||||||
from antigravity_provider.updater import UpdateManager, UpdateCheckResult
|
|
||||||
from antigravity_provider.router.ui.theme import Theme
|
from antigravity_provider.router.ui.theme import Theme
|
||||||
from antigravity_provider.router.ui.components import (
|
from antigravity_provider.version import CHANNEL, __version__
|
||||||
HubButton,
|
|
||||||
HubCard,
|
|
||||||
HubSectionHeader,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SettingsView(ctk.CTkFrame):
|
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)
|
super().__init__(master=master, fg_color="transparent", **kwargs)
|
||||||
self.settings_file = paths.get_hermes_home() / "hub_settings.json"
|
self.on_action = on_action
|
||||||
self._load_settings()
|
SectionHeader(
|
||||||
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,
|
self,
|
||||||
title="Настройки Hermes Hub",
|
title="Настройки",
|
||||||
subtitle="Конфигурация параметров маршрутизации, восстановления, мониторинга и обновлений",
|
subtitle="Маршрутизация, мониторинг и обновления",
|
||||||
action_text="💾 Сохранить",
|
action_text="Сохранить",
|
||||||
action_cmd=self._save_settings,
|
action_cmd=self._save,
|
||||||
)
|
).pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(Theme.PAGE_PAD_Y, Theme.SPACE_SM))
|
||||||
header.pack(fill="x", padx=20, pady=(16, 12))
|
|
||||||
|
|
||||||
scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
scroll = ctk.CTkScrollableFrame(self, fg_color="transparent")
|
||||||
scroll.pack(fill="both", expand=True, padx=15, pady=(0, 10))
|
scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
|
||||||
|
routing = self._section(scroll, "Маршрутизация и отказоустойчивость")
|
||||||
# ── 1. Routing & Failover ──
|
self.affinity = self._switch(routing, "Сессионная привязка", True)
|
||||||
c1 = HubCard(scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE)
|
self.failover = self._switch(routing, "Автоматический failover", True)
|
||||||
c1.pack(fill="x", pady=6)
|
self.same_account = self._switch(routing, "Сначала менять модель на том же аккаунте", True)
|
||||||
ctk.CTkLabel(c1, text="Маршрутизация и Отказоустойчивость", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(anchor="w", padx=16, pady=(12, 6))
|
refresh = self._section(scroll, "Мониторинг")
|
||||||
|
row = ctk.CTkFrame(refresh, fg_color="transparent")
|
||||||
# Session Affinity switch
|
row.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
||||||
r1 = ctk.CTkFrame(c1, fg_color="transparent")
|
ctk.CTkLabel(row, text="Интервал обновления квот", font=Theme.font_body(), text_color=Theme.TEXT_PRIMARY).pack(
|
||||||
r1.pack(fill="x", padx=16, pady=4)
|
side="left"
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
self.fo_menu.set(str(self.settings.get("failover_attempts", "3")))
|
self.interval = ctk.CTkOptionMenu(
|
||||||
self.fo_menu.pack(side="right")
|
row,
|
||||||
|
|
||||||
# ── 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,
|
|
||||||
values=["Выкл", "1 мин", "5 мин", "10 мин", "30 мин"],
|
values=["Выкл", "1 мин", "5 мин", "10 мин", "30 мин"],
|
||||||
width=100,
|
|
||||||
height=28,
|
|
||||||
fg_color=Theme.SURFACE_MUTED,
|
fg_color=Theme.SURFACE_MUTED,
|
||||||
button_color=Theme.ACCENT,
|
button_color=Theme.SECONDARY,
|
||||||
text_color=Theme.TEXT_PRIMARY,
|
|
||||||
)
|
)
|
||||||
self.refr_menu.set(str(self.settings.get("quota_refresh_interval_label", "5 мин")))
|
self.interval.set("5 мин")
|
||||||
self.refr_menu.pack(side="right")
|
self.interval.pack(side="right")
|
||||||
|
updates = self._section(scroll, "Обновления")
|
||||||
# ── 3. Updates & Release Channel ──
|
self.update_status = ctk.CTkLabel(
|
||||||
c_upd = HubCard(scroll, border_color=Theme.BORDER, fg_color=Theme.SURFACE)
|
updates,
|
||||||
c_upd.pack(fill="x", pady=6)
|
text=f"Версия {__version__} • канал {CHANNEL} • состояние обновления: Н/Д",
|
||||||
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()}",
|
|
||||||
font=Theme.font_body(),
|
font=Theme.font_body(),
|
||||||
text_color=Theme.TEXT_PRIMARY,
|
text_color=Theme.TEXT_SECONDARY,
|
||||||
).pack(side="left")
|
|
||||||
|
|
||||||
self.upd_status_lbl = ctk.CTkLabel(
|
|
||||||
c_upd,
|
|
||||||
text="Проверка обновлений не выполнялась",
|
|
||||||
font=Theme.font_caption(),
|
|
||||||
text_color=Theme.TEXT_MUTED,
|
|
||||||
)
|
)
|
||||||
self.upd_status_lbl.pack(anchor="w", padx=16, pady=(0, 8))
|
self.update_status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM)
|
||||||
|
ActionButton(
|
||||||
btns_upd = ctk.CTkFrame(c_upd, fg_color="transparent")
|
updates,
|
||||||
btns_upd.pack(fill="x", padx=16, pady=(0, 12))
|
text="Проверить обновления",
|
||||||
|
|
||||||
self.check_upd_btn = HubButton(
|
|
||||||
btns_upd,
|
|
||||||
text="🔄 Проверить обновления",
|
|
||||||
variant="secondary",
|
variant="secondary",
|
||||||
width=200,
|
command=lambda: self.on_action and self.on_action("check_updates", {}),
|
||||||
height=Theme.HEIGHT_BTN_SM,
|
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
|
||||||
command=self._check_updates_click,
|
|
||||||
|
@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 ──
|
@staticmethod
|
||||||
c3 = HubCard(scroll, border_color=Theme.BORDER, fg_color=Theme.DARK)
|
def _switch(master: Any, label: str, selected: bool) -> ctk.CTkSwitch:
|
||||||
c3.pack(fill="x", pady=6)
|
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))
|
def _save(self) -> None:
|
||||||
|
if not self.on_action:
|
||||||
hermes_home = paths.get_hermes_home()
|
return
|
||||||
|
intervals = {"Выкл": 0, "1 мин": 60, "5 мин": 300, "10 мин": 600, "30 мин": 1800}
|
||||||
btns_row = ctk.CTkFrame(c3, fg_color="transparent")
|
self.on_action(
|
||||||
btns_row.pack(fill="x", padx=16, pady=6)
|
"save_settings",
|
||||||
|
{
|
||||||
HubButton(btns_row, text="📁 Открыть папку данных", variant="secondary", width=180, command=lambda: self._open_folder(hermes_home)).pack(side="left", padx=(0, 8))
|
"session_affinity": bool(self.affinity.get()),
|
||||||
HubButton(btns_row, text="📜 Открыть журнал логов", variant="secondary", width=180, command=lambda: self._open_folder(paths.get_logs_dir())).pack(side="left")
|
"auto_failover": bool(self.failover.get()),
|
||||||
|
"prefer_same_account_model_fallback": bool(self.same_account.get()),
|
||||||
paths_info = [
|
"quota_refresh_interval_label": self.interval.get(),
|
||||||
("Профили роутера:", str(paths.get_router_profiles_path())),
|
"quota_refresh_interval_sec": intervals.get(self.interval.get(), 300),
|
||||||
("Состояние роутера:", str(paths.get_router_state_path())),
|
},
|
||||||
("Файл логов:", str(paths.get_log_file())),
|
)
|
||||||
]
|
|
||||||
for label, pstr in paths_info:
|
|
||||||
p_row = ctk.CTkFrame(c3, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
|
|
||||||
p_row.pack(fill="x", padx=16, pady=2)
|
|
||||||
ctk.CTkLabel(p_row, text=label, font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY).pack(side="left", padx=8, pady=4)
|
|
||||||
ctk.CTkLabel(p_row, text=pstr, font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED).pack(side="right", padx=8)
|
|
||||||
|
|
||||||
ctk.CTkLabel(c3, text="", font=Theme.font_micro()).pack(pady=4)
|
|
||||||
|
|
||||||
def _check_updates_click(self):
|
|
||||||
self.upd_status_lbl.configure(text="Проверка наличия обновлений на GitHub...")
|
|
||||||
self.check_upd_btn.configure(state="disabled")
|
|
||||||
|
|
||||||
def _worker():
|
|
||||||
mgr = UpdateManager()
|
|
||||||
res = mgr.check_for_updates()
|
|
||||||
self.after(0, lambda: self._on_update_result(res))
|
|
||||||
|
|
||||||
threading.Thread(target=_worker, daemon=True).start()
|
|
||||||
|
|
||||||
def _on_update_result(self, res: UpdateCheckResult):
|
|
||||||
self.check_upd_btn.configure(state="normal")
|
|
||||||
if res.error:
|
|
||||||
self.upd_status_lbl.configure(text=f"Ошибка проверки: {res.error}", text_color=Theme.STATUS_WARNING)
|
|
||||||
elif res.update_available and res.manifest:
|
|
||||||
self.upd_status_lbl.configure(
|
|
||||||
text=f"★ Доступна новая версия Hermes Hub v{res.manifest.version}!",
|
|
||||||
text_color=Theme.STATUS_HEALTHY,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.upd_status_lbl.configure(text=f"✓ Установлена актуальная версия (v{__version__})", text_color=Theme.STATUS_HEALTHY)
|
|
||||||
|
|
||||||
def _open_folder(self, path: Path):
|
|
||||||
try:
|
|
||||||
if path.exists():
|
|
||||||
os.startfile(str(path))
|
|
||||||
elif path.parent.exists():
|
|
||||||
os.startfile(str(path.parent))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
"""Hermes Hub — Team View (Команда агентов и Dashboard с Unified Health v3)."""
|
"""Hermes Hub — Team View (Команда агентов и Dashboard с Unified Health v3)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Callable, Dict, List, Optional
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
@ -14,8 +15,6 @@ from antigravity_provider.router.ui.components import (
|
||||||
HubStatusBadge,
|
HubStatusBadge,
|
||||||
)
|
)
|
||||||
from antigravity_provider.router.unified_health import (
|
from antigravity_provider.router.unified_health import (
|
||||||
UnifiedHealthService,
|
|
||||||
SystemReadiness,
|
|
||||||
AgentViewModel,
|
AgentViewModel,
|
||||||
STATUS_HEALTHY,
|
STATUS_HEALTHY,
|
||||||
STATUS_QUOTA_LOW,
|
STATUS_QUOTA_LOW,
|
||||||
|
|
@ -24,6 +23,7 @@ from antigravity_provider.router.unified_health import (
|
||||||
STATUS_NOT_CONFIGURED,
|
STATUS_NOT_CONFIGURED,
|
||||||
STATUS_AUTH_EXPIRED,
|
STATUS_AUTH_EXPIRED,
|
||||||
)
|
)
|
||||||
|
from antigravity_provider.router.state_store import HubSnapshot
|
||||||
|
|
||||||
|
|
||||||
class AgentCardWidget(HubCard):
|
class AgentCardWidget(HubCard):
|
||||||
|
|
@ -42,10 +42,14 @@ class AgentCardWidget(HubCard):
|
||||||
self.role_lbl.pack(side="left")
|
self.role_lbl.pack(side="left")
|
||||||
|
|
||||||
self.orch_pill = ctk.CTkFrame(self.line1, fg_color="#3D3522", corner_radius=Theme.RADIUS_SM)
|
self.orch_pill = ctk.CTkFrame(self.line1, fg_color="#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.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")
|
self.status_dot.pack(side="right")
|
||||||
|
|
||||||
# ── Line 2: Internal ID + Model ──
|
# ── Line 2: Internal ID + Model ──
|
||||||
|
|
@ -67,7 +71,9 @@ class AgentCardWidget(HubCard):
|
||||||
# ── Line 4: Identity / Account ──
|
# ── Line 4: Identity / Account ──
|
||||||
self.line4 = ctk.CTkFrame(self, fg_color="transparent")
|
self.line4 = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
self.line4.pack(fill="x", padx=14, pady=(2, 4))
|
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")
|
self.identity_lbl.pack(anchor="w")
|
||||||
|
|
||||||
# ── Line 5: Role Tag Pills ──
|
# ── Line 5: Role Tag Pills ──
|
||||||
|
|
@ -88,7 +94,9 @@ class AgentCardWidget(HubCard):
|
||||||
self.line6 = ctk.CTkFrame(self, fg_color="transparent")
|
self.line6 = ctk.CTkFrame(self, fg_color="transparent")
|
||||||
self.line6.pack(fill="x", padx=14, pady=(4, 12))
|
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.status_str_lbl.pack(side="left")
|
||||||
|
|
||||||
self.menu_btn = ctk.CTkButton(
|
self.menu_btn = ctk.CTkButton(
|
||||||
|
|
@ -117,7 +125,15 @@ class AgentCardWidget(HubCard):
|
||||||
self.orch_pill.pack_forget()
|
self.orch_pill.pack_forget()
|
||||||
|
|
||||||
# Dot color
|
# 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)
|
self.status_dot.configure(text_color=dot_color)
|
||||||
|
|
||||||
# ID + Model
|
# ID + Model
|
||||||
|
|
@ -145,12 +161,8 @@ class AgentCardWidget(HubCard):
|
||||||
else:
|
else:
|
||||||
self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED)
|
self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED)
|
||||||
|
|
||||||
# Identity & Model-Specific Quota
|
# AgentViewModel has account identity, but no active-session or quota fields.
|
||||||
from antigravity_provider.router.quota_collector import AccountQuotaService
|
self.identity_lbl.configure(text=a.account_identity or "Аккаунт: Н/Д")
|
||||||
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}")
|
|
||||||
|
|
||||||
# Pills
|
# Pills
|
||||||
self.pill1_lbl.configure(text=a.role_id)
|
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)
|
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=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):
|
def _do(action_name: str):
|
||||||
popup.destroy()
|
popup.destroy()
|
||||||
if self.on_action:
|
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("test")).pack(
|
||||||
HubButton(c, text="👑 Назначить главным оркестратором", variant="secondary", height=30, command=lambda: _do("set_orchestrator")).pack(fill="x", padx=12, pady=2)
|
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("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):
|
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)
|
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.on_action = on_action
|
||||||
self._card_widgets: List[AgentCardWidget] = []
|
self._card_widgets: Dict[str, AgentCardWidget] = {}
|
||||||
self._build_static_layout()
|
self._build_static_layout()
|
||||||
self.update_data()
|
self.update_data()
|
||||||
|
|
||||||
|
|
@ -261,7 +295,9 @@ class TeamView(ctk.CTkFrame):
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
metrics_grid.grid_columnconfigure(i, weight=1)
|
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.m1.grid(row=0, column=0, padx=6, sticky="nsew")
|
||||||
|
|
||||||
self.m2 = HubMetricCard(metrics_grid, title="АККАУНТЫ", value="0/16", subtext="подключено", icon="💼")
|
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 = HubMetricCard(metrics_grid, title="ПРОВАЙДЕРЫ", value="3/3", subtext="доступно", icon="⚛")
|
||||||
self.m3.grid(row=0, column=2, padx=6, sticky="nsew")
|
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")
|
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 = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
||||||
self.cards_grid.pack(fill="both", expand=True)
|
self.cards_grid.pack(fill="both", expand=True)
|
||||||
for col_idx in range(3):
|
for col_idx in range(3):
|
||||||
self.cards_grid.grid_columnconfigure(col_idx, weight=1)
|
self.cards_grid.grid_columnconfigure(col_idx, weight=1)
|
||||||
|
|
||||||
def update_data(self, snapshot: Optional[Any] = None):
|
def update_data(self, snapshot: Optional[Any] = None):
|
||||||
from antigravity_provider.router.state_store import HubSnapshot, HubStateStore
|
|
||||||
if not isinstance(snapshot, HubSnapshot):
|
if not isinstance(snapshot, HubSnapshot):
|
||||||
# Callers have historically passed legacy app_state dicts here; a
|
return
|
||||||
# non-snapshot must fall back to the store, not crash the view.
|
|
||||||
snapshot = HubStateStore.get().get_snapshot()
|
|
||||||
|
|
||||||
readiness = snapshot.readiness
|
readiness = snapshot.readiness
|
||||||
agents = snapshot.agents
|
agents = snapshot.agents
|
||||||
|
|
@ -302,20 +353,28 @@ class TeamView(ctk.CTkFrame):
|
||||||
self.m4.val_label.configure(text=readiness.title_ru)
|
self.m4.val_label.configure(text=readiness.title_ru)
|
||||||
self.m4.sub_label.configure(text=readiness.summary_ru)
|
self.m4.sub_label.configure(text=readiness.summary_ru)
|
||||||
|
|
||||||
# Ensure enough widgets in 3-column grid
|
live_roles = {agent.role_id for agent in agents}
|
||||||
while len(self._card_widgets) < len(agents):
|
for role_id in list(self._card_widgets):
|
||||||
idx = len(self._card_widgets)
|
if role_id not in live_roles:
|
||||||
row_idx, col_idx = divmod(idx, 3)
|
self._card_widgets.pop(role_id).destroy()
|
||||||
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):
|
orchestrators = [agent for agent in agents if agent.is_main_orchestrator]
|
||||||
self._card_widgets[idx].update_agent(agent)
|
role_agents = [agent for agent in agents if not agent.is_main_orchestrator]
|
||||||
self._card_widgets[idx].grid()
|
for index, agent in enumerate(orchestrators):
|
||||||
|
card = self._card_widgets.get(agent.role_id)
|
||||||
|
if card is None:
|
||||||
|
card = AgentCardWidget(self.orchestrator_grid, on_action=self.on_action)
|
||||||
|
self._card_widgets[agent.role_id] = card
|
||||||
|
card.update_agent(agent)
|
||||||
|
card.grid(row=index, column=0, padx=Theme.SPACE_XS, pady=Theme.SPACE_XS, sticky="nsew")
|
||||||
|
|
||||||
for idx in range(len(agents), len(self._card_widgets)):
|
for index, agent in enumerate(role_agents):
|
||||||
self._card_widgets[idx].grid_remove()
|
card = self._card_widgets.get(agent.role_id)
|
||||||
|
if card is None:
|
||||||
|
card = AgentCardWidget(self.cards_grid, on_action=self.on_action)
|
||||||
|
self._card_widgets[agent.role_id] = card
|
||||||
|
card.update_agent(agent)
|
||||||
|
card.grid(row=index // 3, column=index % 3, padx=6, pady=6, sticky="nsew")
|
||||||
|
|
||||||
def _trigger_action(self, action: str, profile: Dict[str, Any]):
|
def _trigger_action(self, action: str, profile: Dict[str, Any]):
|
||||||
if self.on_action:
|
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