fix(A15): восстановить десктоп и починить веб-API
Правки при приёмке A15. Веб-API и вынесение действий приняты, но в сданном виде не работали ни то, ни другое. 1. Десктоп был уничтожен. При выносе действий из hermes_hub_app.py пропало объявление class HermesHubApp вместе с 13 методами каркаса: __init__, _build_layout, _create_view, _show_view, _refresh_data и другими. Оставшиеся 14 методов оказались вложены внутрь функции _load_saved_theme после её return — синтаксически валидный недостижимый код, поэтому модуль импортировался и дефект выглядел безобидно. launch_hub() при этом падал бы с NameError. hermes_hub_app.py восстановлен из main; задание прямо требовало десктоп не ломать. 2. Дублирование убрано правильным способом: десктоп импортирует пять do_* из action_handler, второй реализации в проекте нет. 3. Веб-API падал с 500 на обоих значимых эндпоинтах: get_auth_token и run_server читали config.hub, а такого атрибута у RouterConfig нет. Настройки живут в hub_settings.json. Работал только /api/health, у которого нет проверки авторизации, — из-за чего сервер и выглядел поднявшимся. 4. do_save_settings при переносе потеряла атомарную запись через os.replace, ensure_ascii=False и вызов set_refresh_interval, то есть интервал обновления квот из настроек перестал применяться. Восстановлено. 5. Импорт адаптера был убран внутрь do_test_profile, что делало функцию неподменяемой в тестах. Поднят на уровень модуля. 6. Версия в /api/health была зашита как "1.0.0" вместо настоящей. Проверено исполнением: /api/snapshot отдаёт 200 и 12 ключей, полностью совпадающих с docs/web-api/snapshot.example.json; секретов в ответе нет; неизвестное действие даёт 404. Тесты: 319 passed, ruff чисто. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
e42f262b6d
commit
eaae2f8ac4
5 changed files with 559 additions and 88 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
from typing import Any, Dict, Tuple, Optional, Callable
|
from typing import Any, Dict, Tuple, Optional, Callable
|
||||||
|
|
@ -11,6 +12,7 @@ from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||||
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||||||
from antigravity_provider.updater import UpdateManager
|
from antigravity_provider.updater import UpdateManager
|
||||||
from antigravity_provider import paths
|
from antigravity_provider import paths
|
||||||
|
from antigravity_provider.router.adapters import get_adapter
|
||||||
|
|
||||||
logger = logging.getLogger('hermes.router.actions')
|
logger = logging.getLogger('hermes.router.actions')
|
||||||
|
|
||||||
|
|
@ -31,7 +33,6 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
|
||||||
return ok, msg
|
return ok, msg
|
||||||
|
|
||||||
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
||||||
from antigravity_provider.router.adapters import get_adapter
|
|
||||||
config = load_router_config()
|
config = load_router_config()
|
||||||
pcfg = config.get_profile(profile_id)
|
pcfg = config.get_profile(profile_id)
|
||||||
if not pcfg:
|
if not pcfg:
|
||||||
|
|
@ -84,21 +85,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]:
|
def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]:
|
||||||
settings_file = paths.get_hermes_home() / 'hub_settings.json'
|
settings_file = paths.get_hermes_home() / "hub_settings.json"
|
||||||
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
settings_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
existing: Dict[str, Any] = {}
|
existing: Dict[str, Any] = {}
|
||||||
if settings_file.exists():
|
if settings_file.exists():
|
||||||
try:
|
try:
|
||||||
existing = json.loads(settings_file.read_text(encoding='utf-8'))
|
existing = json.loads(settings_file.read_text(encoding="utf-8"))
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
existing = {}
|
||||||
for k, v in settings.items():
|
existing.update(settings)
|
||||||
existing[k] = v
|
|
||||||
try:
|
try:
|
||||||
settings_file.write_text(json.dumps(existing, indent=2), encoding='utf-8')
|
# Запись через временный файл и os.replace: обрыв на середине не
|
||||||
return True, 'Настройки сохранены'
|
# должен оставить настройки битыми. В домашнем каталоге Hermes уже
|
||||||
|
# лежит config.yaml.corrupt.<дата>.bak — этот риск не теоретический.
|
||||||
|
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)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return False, f'Не удалось сохранить настройки: {e}'
|
return False, f"Не удалось сохранить настройки: {e}"
|
||||||
|
|
||||||
|
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||||||
|
|
||||||
|
AccountQuotaService.get().set_refresh_interval(int(settings.get("quota_refresh_interval_sec", 300)))
|
||||||
|
return True, "Настройки сохранены"
|
||||||
|
|
||||||
|
|
||||||
class ActionExecutor:
|
class ActionExecutor:
|
||||||
"""Shared execution layer for Desktop and Web actions."""
|
"""Shared execution layer for Desktop and Web actions."""
|
||||||
|
|
|
||||||
|
|
@ -34,14 +34,10 @@ if sys.platform == "win32":
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# ── Ensure plugin and repo paths are on sys.path ──
|
# ── Ensure plugin and repo paths are on sys.path ──
|
||||||
_SRC_DIR = Path(__file__).resolve().parent.parent.parent
|
_LOCAL = Path(os.environ.get("LOCALAPPDATA", ""))
|
||||||
if str(_SRC_DIR) not in sys.path:
|
_PLUGIN_SRC = _LOCAL / "hermes" / "plugins" / "antigravity-provider" / "src"
|
||||||
sys.path.insert(0, str(_SRC_DIR))
|
_AGENT_DIR = _LOCAL / "hermes" / "hermes-agent"
|
||||||
import antigravity_provider.paths as _paths
|
for _p in [_PLUGIN_SRC, _AGENT_DIR, Path(__file__).resolve().parent.parent.parent]:
|
||||||
_HERMES_HOME = _paths.get_hermes_home()
|
|
||||||
_PLUGIN_SRC = _HERMES_HOME / "plugins" / "antigravity-provider" / "src"
|
|
||||||
_AGENT_DIR = _HERMES_HOME / "hermes-agent"
|
|
||||||
for _p in [_PLUGIN_SRC, _AGENT_DIR]:
|
|
||||||
_ps = str(_p)
|
_ps = str(_p)
|
||||||
if _p.exists() and _ps not in sys.path:
|
if _p.exists() and _ps not in sys.path:
|
||||||
sys.path.insert(0, _ps)
|
sys.path.insert(0, _ps)
|
||||||
|
|
@ -50,6 +46,15 @@ from antigravity_provider.router.router_config import load_router_config, save_r
|
||||||
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
|
||||||
|
# Единственная реализация действий живёт в action_handler: её используют
|
||||||
|
# и десктоп, и веб-API. Второй копии в проекте быть не должно.
|
||||||
|
from antigravity_provider.router.action_handler import (
|
||||||
|
do_delete_credentials,
|
||||||
|
do_save_settings,
|
||||||
|
do_set_main,
|
||||||
|
do_set_orchestrator,
|
||||||
|
do_test_profile,
|
||||||
|
)
|
||||||
from antigravity_provider import paths
|
from antigravity_provider import paths
|
||||||
from antigravity_provider.version import __version__
|
from antigravity_provider.version import __version__
|
||||||
|
|
||||||
|
|
@ -89,78 +94,513 @@ def _load_saved_theme() -> str:
|
||||||
return str(settings.get("theme", "dark"))
|
return str(settings.get("theme", "dark"))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Actions Layer (Safe & Non-blocking)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class HermesHubApp(ctk.CTk):
|
||||||
|
def __init__(self):
|
||||||
|
self._theme_name = Theme.apply_scheme(_load_saved_theme())
|
||||||
|
ctk.set_appearance_mode("dark" if self._theme_name == "dark" else "light")
|
||||||
|
super().__init__()
|
||||||
|
self.title("Hermes Hub")
|
||||||
|
self.geometry("1380x880")
|
||||||
|
self.minsize(1100, 700)
|
||||||
|
|
||||||
|
ctk.set_default_color_theme("blue")
|
||||||
|
self.configure(fg_color=Theme.BG_WINDOW)
|
||||||
|
|
||||||
|
# Set Windows Multi-Resolution Icon
|
||||||
|
ico_path = AssetManager.get().get_ico_path()
|
||||||
|
if ico_path and os.path.exists(ico_path):
|
||||||
|
try:
|
||||||
|
self.iconbitmap(ico_path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._current_view = "overview"
|
||||||
|
self._views: Dict[str, ctk.CTkFrame] = {}
|
||||||
|
self._view_generations: Dict[str, int] = {}
|
||||||
|
self._shutting_down = False
|
||||||
|
self._resize_timer_id = None
|
||||||
|
|
||||||
|
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||||
|
self.bind("<Configure>", self._on_window_configure)
|
||||||
|
|
||||||
|
self._build_layout()
|
||||||
|
self._show_view("overview")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||||||
|
|
||||||
|
HermesRefreshScheduler.get().start()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.quota_collector import AccountQuotaService
|
||||||
|
|
||||||
|
AccountQuotaService.get().start_background_scheduler()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.after(50, self._refresh_data)
|
||||||
|
self.after(800, self._refresh_quotas_on_startup)
|
||||||
|
|
||||||
|
def _refresh_quotas_on_startup(self) -> None:
|
||||||
|
"""Populate measured quota cards immediately instead of after the 5-minute scheduler tick."""
|
||||||
|
if self._shutting_down:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||||||
|
|
||||||
|
HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Initial quota refresh could not start: %s", exc)
|
||||||
|
|
||||||
|
def _build_layout(self):
|
||||||
|
# ── Sidebar (Left) ──
|
||||||
|
self.sidebar = ctk.CTkFrame(self, width=Theme.WIDTH_SIDEBAR, fg_color=Theme.BG_SIDEBAR, corner_radius=0)
|
||||||
|
self.sidebar.pack(side="left", fill="y")
|
||||||
|
self.sidebar.pack_propagate(False)
|
||||||
|
|
||||||
|
# Top Centered Brand Logo
|
||||||
|
brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent")
|
||||||
|
brand_container.pack(fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_LG, Theme.SPACE_SM))
|
||||||
|
|
||||||
|
logo_img = AssetManager.get().get_logo_image(size=(78, 78))
|
||||||
|
if logo_img:
|
||||||
|
logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="")
|
||||||
|
logo_lbl.pack(anchor="center", pady=(0, 6))
|
||||||
|
|
||||||
|
ctk.CTkLabel(
|
||||||
|
brand_container,
|
||||||
|
text="HERMES HUB",
|
||||||
|
font=(Theme.FONT_FAMILY_TITLE, 17, "bold"),
|
||||||
|
text_color=Theme.TEXT_ACCENT,
|
||||||
|
).pack(anchor="center")
|
||||||
|
ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER_ACCENT).pack(
|
||||||
|
fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_XS, Theme.SPACE_SM)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Nav Items with clean Fluent glyphs
|
||||||
|
self._nav_items = [
|
||||||
|
("overview", "Обзор", "overview"),
|
||||||
|
("team", "Команда", "team"),
|
||||||
|
("accounts", "Аккаунты", "accounts"),
|
||||||
|
("routing", "Маршрутизация", "routing"),
|
||||||
|
("providers", "Модели и провайдеры", "providers"),
|
||||||
|
("analytics", "Аналитика", "analytics"),
|
||||||
|
("health", "Состояние", "health"),
|
||||||
|
("logs", "Журнал событий", "logs"),
|
||||||
|
("settings", "Настройки", "settings"),
|
||||||
|
("about", "О программе", "about"),
|
||||||
|
]
|
||||||
|
|
||||||
|
self.nav_frame = ctk.CTkScrollableFrame(
|
||||||
|
self.sidebar,
|
||||||
|
fg_color="transparent",
|
||||||
|
corner_radius=0,
|
||||||
|
scrollbar_fg_color=Theme.BG_SIDEBAR,
|
||||||
|
scrollbar_button_color=Theme.BG_SIDEBAR,
|
||||||
|
scrollbar_button_hover_color=Theme.BORDER_HOVER,
|
||||||
|
)
|
||||||
|
self.nav_frame.pack(fill="both", expand=True)
|
||||||
|
self._nav_buttons: Dict[str, ctk.CTkButton] = {}
|
||||||
|
for key, label, icon in self._nav_items:
|
||||||
|
icon_image = AssetManager.get().get_nav_icon(icon, size=19)
|
||||||
|
btn = ctk.CTkButton(
|
||||||
|
self.nav_frame,
|
||||||
|
text=label,
|
||||||
|
image=icon_image,
|
||||||
|
compound="left",
|
||||||
|
font=Theme.font_body(),
|
||||||
|
height=Theme.HEIGHT_NAV_ITEM,
|
||||||
|
fg_color="transparent",
|
||||||
|
hover_color=Theme.SIDEBAR_HOVER,
|
||||||
|
text_color=Theme.SIDEBAR_TEXT,
|
||||||
|
anchor="w",
|
||||||
|
corner_radius=Theme.RADIUS_SM,
|
||||||
|
command=lambda k=key: self._show_view(k),
|
||||||
|
)
|
||||||
|
btn.pack(fill="x", padx=Theme.SPACE_SM, pady=1)
|
||||||
|
self._nav_buttons[key] = btn
|
||||||
|
|
||||||
|
self.sidebar_version = ctk.CTkLabel(
|
||||||
|
self.sidebar,
|
||||||
|
text=f"Hermes Hub v{__version__}",
|
||||||
|
font=Theme.font_micro(),
|
||||||
|
text_color=Theme.SIDEBAR_MUTED,
|
||||||
|
)
|
||||||
|
self.sidebar_version.pack(side="bottom", pady=(0, Theme.SPACE_SM))
|
||||||
|
user_card = ctk.CTkFrame(
|
||||||
|
self.sidebar,
|
||||||
|
fg_color=Theme.SIDEBAR_SELECTED,
|
||||||
|
border_width=1,
|
||||||
|
border_color=Theme.BORDER,
|
||||||
|
corner_radius=Theme.RADIUS_MD,
|
||||||
|
)
|
||||||
|
user_card.pack(side="bottom", fill="x", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
user_card,
|
||||||
|
text="AD",
|
||||||
|
width=30,
|
||||||
|
height=30,
|
||||||
|
corner_radius=15,
|
||||||
|
fg_color=Theme.ACCENT,
|
||||||
|
text_color=Theme.TEXT_ON_ACCENT,
|
||||||
|
font=Theme.font_badge_bold(),
|
||||||
|
).pack(side="left", padx=Theme.SPACE_SM, pady=Theme.SPACE_SM)
|
||||||
|
ctk.CTkLabel(
|
||||||
|
user_card,
|
||||||
|
text="Administrator\nОсновная команда",
|
||||||
|
justify="left",
|
||||||
|
font=Theme.font_micro(),
|
||||||
|
text_color=Theme.SIDEBAR_TEXT,
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
|
# ── Global top bar ──
|
||||||
|
self.statusbar = ctk.CTkFrame(self, height=Theme.HEIGHT_HEADER, fg_color=Theme.BG_HEADER, corner_radius=0)
|
||||||
|
self.statusbar.pack(side="top", fill="x")
|
||||||
|
self.statusbar.pack_propagate(False)
|
||||||
|
|
||||||
|
self.status_left = ctk.CTkLabel(
|
||||||
|
self.statusbar,
|
||||||
|
text="● Состояние загружается",
|
||||||
|
font=Theme.font_caption(),
|
||||||
|
text_color=Theme.STATUS_HEALTHY,
|
||||||
|
)
|
||||||
|
self.status_left.pack(side="left", padx=Theme.SPACE_LG)
|
||||||
|
self.status_left.configure(cursor="hand2")
|
||||||
|
self.status_left.bind("<Button-1>", lambda _event: self._show_view("health"), add="+")
|
||||||
|
|
||||||
|
self.global_search = ctk.CTkEntry(
|
||||||
|
self.statusbar,
|
||||||
|
placeholder_text="Поиск по агентам, аккаунтам, задачам… Ctrl + K",
|
||||||
|
width=360,
|
||||||
|
height=Theme.HEIGHT_INPUT,
|
||||||
|
fg_color=Theme.SURFACE,
|
||||||
|
border_color=Theme.BORDER,
|
||||||
|
text_color=Theme.TEXT_PRIMARY,
|
||||||
|
)
|
||||||
|
self.global_search.pack(side="left", padx=Theme.SPACE_MD)
|
||||||
|
self.global_search.bind("<Return>", self._run_global_search)
|
||||||
|
self.bind_all("<Control-k>", self._focus_global_search)
|
||||||
|
|
||||||
|
for icon_name, command in (
|
||||||
|
("settings", lambda: self._show_view("settings")),
|
||||||
|
("about", lambda: self._show_view("about")),
|
||||||
|
("logs", lambda: self._show_view("logs")),
|
||||||
|
):
|
||||||
|
HubButton(
|
||||||
|
self.statusbar,
|
||||||
|
text="",
|
||||||
|
image=AssetManager.get().get_nav_icon(icon_name, size=18),
|
||||||
|
variant="ghost",
|
||||||
|
width=Theme.HEIGHT_BTN_MD,
|
||||||
|
command=command,
|
||||||
|
).pack(side="right", padx=Theme.SPACE_XS)
|
||||||
|
self.add_account_button = HubButton(
|
||||||
|
self.statusbar,
|
||||||
|
text="+ Добавить аккаунт",
|
||||||
|
variant="primary",
|
||||||
|
command=lambda: self._handle_action("add_account", {}),
|
||||||
|
)
|
||||||
|
self.add_account_button.pack(side="right", padx=(Theme.SPACE_XS, Theme.SPACE_MD))
|
||||||
|
|
||||||
|
self.status_right = ctk.CTkLabel(
|
||||||
|
self.statusbar,
|
||||||
|
text="Snapshot: Н/Д",
|
||||||
|
font=Theme.font_micro(),
|
||||||
|
text_color=Theme.TEXT_MUTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Main Content Area ──
|
||||||
|
self.content = ctk.CTkFrame(self, fg_color=Theme.BG_WINDOW, corner_radius=0)
|
||||||
|
self.content.pack(side="right", fill="both", expand=True)
|
||||||
|
|
||||||
|
# Pre-instantiate all views so switching is 100% instant (0-15 ms)
|
||||||
|
for key, _, _ in self._nav_items:
|
||||||
|
self._views[key] = self._create_view(key)
|
||||||
|
|
||||||
|
def _focus_global_search(self, _event=None) -> str:
|
||||||
|
self.global_search.focus_set()
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
def _run_global_search(self, _event=None) -> str:
|
||||||
|
query = self.global_search.get().strip()
|
||||||
|
self._show_view("accounts")
|
||||||
|
accounts = self._views.get("accounts")
|
||||||
|
if accounts and hasattr(accounts, "search"):
|
||||||
|
accounts.search.delete(0, "end")
|
||||||
|
accounts.search.insert(0, query)
|
||||||
|
accounts._set_search(query)
|
||||||
|
return "break"
|
||||||
|
|
||||||
|
def _create_view(self, view_name: str) -> ctk.CTkFrame:
|
||||||
|
"""Create view widget instance."""
|
||||||
|
if view_name == "overview":
|
||||||
|
return DashboardView(
|
||||||
|
self.content,
|
||||||
|
app_state={},
|
||||||
|
on_navigate=self._show_view,
|
||||||
|
on_action=self._handle_action,
|
||||||
|
)
|
||||||
|
elif view_name == "team":
|
||||||
|
return TeamView(self.content, app_state={}, on_action=self._handle_action)
|
||||||
|
elif view_name == "accounts":
|
||||||
|
return AccountsView(self.content, app_state={}, on_action=self._handle_action)
|
||||||
|
elif view_name == "providers":
|
||||||
|
return ProvidersView(self.content, app_state={}, on_action=self._handle_action)
|
||||||
|
elif view_name == "routing":
|
||||||
|
return RoutingView(self.content, on_action=self._handle_action)
|
||||||
|
elif view_name == "analytics":
|
||||||
|
return AnalyticsView(self.content)
|
||||||
|
elif view_name == "health":
|
||||||
|
return HealthView(self.content, app_state={}, on_refresh=self._refresh_data)
|
||||||
|
elif view_name == "logs":
|
||||||
|
return LogsView(self.content)
|
||||||
|
elif view_name == "settings":
|
||||||
|
return SettingsView(self.content, on_action=self._handle_action, theme_name=self._theme_name)
|
||||||
|
elif view_name == "about":
|
||||||
|
return AboutView(self.content)
|
||||||
|
else:
|
||||||
|
return TeamView(self.content, app_state={}, on_action=self._handle_action)
|
||||||
|
|
||||||
|
def _show_view(self, view_name: str):
|
||||||
|
"""Instant view switching using pack_forget() and cached widgets with lazy generation update."""
|
||||||
|
t0 = time.time()
|
||||||
|
prev_view = self._current_view
|
||||||
|
self._current_view = view_name
|
||||||
|
|
||||||
|
# Update sidebar button states
|
||||||
|
for key, btn in self._nav_buttons.items():
|
||||||
|
if key == view_name:
|
||||||
|
btn.configure(
|
||||||
|
fg_color=Theme.SIDEBAR_SELECTED,
|
||||||
|
text_color=Theme.TEXT_ACCENT,
|
||||||
|
border_width=1,
|
||||||
|
border_color=Theme.BORDER_ACCENT,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
btn.configure(
|
||||||
|
fg_color="transparent",
|
||||||
|
text_color=Theme.SIDEBAR_TEXT,
|
||||||
|
border_width=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Hide currently active views
|
||||||
|
for v in self._views.values():
|
||||||
|
v.pack_forget()
|
||||||
|
|
||||||
|
# Show target view instantly
|
||||||
|
target_view = self._views.get(view_name)
|
||||||
|
if target_view:
|
||||||
|
target_view.pack(fill="both", expand=True)
|
||||||
|
|
||||||
|
# Lazy update if view state is behind current snapshot generation
|
||||||
|
from antigravity_provider.router.state_store import HubStateStore
|
||||||
|
|
||||||
|
snap = HubStateStore.get().get_snapshot()
|
||||||
|
if self._view_generations.get(view_name, 0) < snap.generation:
|
||||||
|
if hasattr(target_view, "update_data"):
|
||||||
|
try:
|
||||||
|
target_view.update_data(snap)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("Error in lazy view update for %s: %s", view_name, ex)
|
||||||
|
self._view_generations[view_name] = snap.generation
|
||||||
|
self._update_auxiliary_data(target_view)
|
||||||
|
|
||||||
|
# Instrument tab switch latency
|
||||||
|
el_ms = round((time.time() - t0) * 1000, 2)
|
||||||
|
if el_ms > 100:
|
||||||
|
logger.warning(f"[TAB SWITCH SLOW] {prev_view} -> {view_name}: {el_ms} ms")
|
||||||
|
else:
|
||||||
|
logger.debug(f"[TAB SWITCH] {prev_view} -> {view_name}: {el_ms} ms")
|
||||||
|
|
||||||
|
def _on_window_configure(self, event):
|
||||||
|
"""Debounce window resize to maintain 60fps smoothness."""
|
||||||
|
if event.widget != self:
|
||||||
|
return
|
||||||
|
if self._resize_timer_id:
|
||||||
|
try:
|
||||||
|
self.after_cancel(self._resize_timer_id)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._resize_timer_id = self.after(100, self._handle_debounced_resize)
|
||||||
|
|
||||||
|
def _handle_debounced_resize(self):
|
||||||
|
self._resize_timer_id = None
|
||||||
|
|
||||||
|
# ─────── Data Refresh (Threaded via Scheduler & HubStateStore) ───────
|
||||||
|
|
||||||
|
def _refresh_data(self):
|
||||||
|
if self._shutting_down:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.status_left.configure(text="Обновление состояния...")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _load():
|
||||||
|
if self._shutting_down:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from antigravity_provider.router.state_store import HubStateStore
|
||||||
|
|
||||||
|
snap = HubStateStore.get().refresh(force_scan=True)
|
||||||
|
if not self._shutting_down:
|
||||||
|
self.after(0, lambda: self._on_data_loaded(snap))
|
||||||
|
except Exception as e:
|
||||||
|
if not self._shutting_down:
|
||||||
|
try:
|
||||||
|
self.after(0, lambda err=str(e): self._on_data_error(err))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
threading.Thread(target=_load, daemon=True).start()
|
||||||
|
|
||||||
|
def _on_data_loaded(self, snapshot_or_readiness: Any):
|
||||||
|
if self._shutting_down:
|
||||||
|
return
|
||||||
|
|
||||||
|
from antigravity_provider.router.state_store import HubSnapshot, HubStateStore
|
||||||
|
|
||||||
|
if isinstance(snapshot_or_readiness, HubSnapshot):
|
||||||
|
snap = snapshot_or_readiness
|
||||||
|
readiness = snap.readiness
|
||||||
|
else:
|
||||||
|
snap = HubStateStore.get().get_snapshot()
|
||||||
|
readiness = snapshot_or_readiness
|
||||||
|
|
||||||
|
freshness = "⚠ Данные устарели" if snap.is_stale else f"Snapshot #{snap.seq}"
|
||||||
|
self.status_left.configure(
|
||||||
|
text=f"● {readiness.title_ru}{' · Подробнее' if readiness.state != 'healthy' else ''}",
|
||||||
|
text_color=Theme.STATUS_HEALTHY
|
||||||
|
if readiness.state == "healthy"
|
||||||
|
else Theme.STATUS_WARNING
|
||||||
|
if readiness.state in ("limited", "degraded")
|
||||||
|
else Theme.STATUS_ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.status_right.configure(
|
||||||
|
text=f"{freshness} • {readiness.accounts_connected_count} аккаунтов • {readiness.roles_ready_count} ролей",
|
||||||
|
text_color=Theme.STATUS_WARNING if snap.is_stale else Theme.TEXT_MUTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update ONLY the currently visible view (others are updated lazily on tab switch)
|
||||||
|
curr_view = self._views.get(self._current_view)
|
||||||
|
if curr_view and hasattr(curr_view, "update_data"):
|
||||||
|
try:
|
||||||
|
curr_view.update_data(snap)
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("Error updating current view %s: %s", self._current_view, ex)
|
||||||
|
self._view_generations[self._current_view] = snap.generation
|
||||||
|
self._update_auxiliary_data(curr_view)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _update_auxiliary_data(view: Any) -> None:
|
||||||
|
if hasattr(view, "update_events"):
|
||||||
|
try:
|
||||||
|
view.update_events(EventLogService.get().get_events(limit=20))
|
||||||
|
except Exception as ex:
|
||||||
|
logger.warning("Error updating event presentation: %s", ex)
|
||||||
|
|
||||||
|
def _on_data_error(self, error: str):
|
||||||
|
if self._shutting_down:
|
||||||
|
return
|
||||||
|
self.status_left.configure(text=f"Ошибка: {error}")
|
||||||
|
|
||||||
# ─────── Action Handler ───────
|
# ─────── Action Handler ───────
|
||||||
|
|
||||||
def _handle_action(self, action: str, data: dict):
|
def _handle_action(self, action: str, data: Dict[str, Any]):
|
||||||
pid = data.get("profile_id", "")
|
pid = data.get("profile_id", "")
|
||||||
|
prov = data.get("provider", "")
|
||||||
|
|
||||||
# 1. UI Navigation
|
if action == "set_main":
|
||||||
if action in ["oauth", "add_account"]:
|
self._run_in_thread(
|
||||||
|
lambda: do_set_main(prov, pid),
|
||||||
|
on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
|
||||||
|
)
|
||||||
|
elif action == "set_orchestrator":
|
||||||
|
self._run_in_thread(
|
||||||
|
lambda: do_set_orchestrator(pid),
|
||||||
|
on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
|
||||||
|
)
|
||||||
|
elif action == "test":
|
||||||
|
self._show_account_action_result(pid, f"Тестирование {data.get('display_name', pid)}…", None)
|
||||||
|
self._run_in_thread(
|
||||||
|
lambda: do_test_profile(prov, pid),
|
||||||
|
on_success=lambda result: self._show_test_result(result, pid),
|
||||||
|
)
|
||||||
|
elif action == "oauth" or action == "add_account":
|
||||||
self._open_add_account_wizard()
|
self._open_add_account_wizard()
|
||||||
return
|
elif action == "delete_credentials":
|
||||||
|
self._run_in_thread(
|
||||||
|
lambda: do_delete_credentials(prov, pid),
|
||||||
|
on_success=lambda r: self._show_account_action_result(pid, r[1], r[0]),
|
||||||
|
)
|
||||||
elif action == "assign_role":
|
elif action == "assign_role":
|
||||||
self._open_assign_role_modal(pid, data.get("display_name", pid))
|
self._open_assign_role_modal(pid, data.get("display_name", pid))
|
||||||
return
|
|
||||||
elif action == "account_details":
|
elif action == "account_details":
|
||||||
self._open_account_details_modal(pid)
|
self._open_account_details_modal(pid)
|
||||||
return
|
|
||||||
elif action == "agent_settings":
|
elif action == "agent_settings":
|
||||||
self._open_agent_settings_modal(data.get("role_id", ""), pid)
|
self._open_agent_settings_modal(
|
||||||
return
|
data.get("role_id", ""),
|
||||||
|
pid,
|
||||||
|
)
|
||||||
|
elif action == "auto_assign_all":
|
||||||
|
self._show_toast("⚡ Автоматическое распределение ролей...")
|
||||||
|
self._run_in_thread(
|
||||||
|
lambda: AutoAssigner.auto_assign_all(),
|
||||||
|
on_success=lambda r: self._show_toast("✅ Роли успешно распределены"),
|
||||||
|
)
|
||||||
|
elif action == "refresh_data":
|
||||||
|
self._refresh_data()
|
||||||
|
elif action == "refresh_all":
|
||||||
|
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||||||
|
|
||||||
|
HermesRefreshScheduler.get().trigger_refresh_all(on_complete=lambda: self.after(0, self._refresh_data))
|
||||||
|
elif action == "refresh_account":
|
||||||
|
from antigravity_provider.router.scheduler import HermesRefreshScheduler
|
||||||
|
|
||||||
|
HermesRefreshScheduler.get().trigger_refresh_account(
|
||||||
|
prov,
|
||||||
|
pid,
|
||||||
|
on_complete=lambda: self.after(0, self._refresh_data),
|
||||||
|
)
|
||||||
elif action == "edit_route":
|
elif action == "edit_route":
|
||||||
self._open_route_editor_modal(data.get("role_id", ""))
|
role_id = data.get("role_id", "")
|
||||||
return
|
self._open_route_editor_modal(role_id)
|
||||||
elif action == "open_routing":
|
elif action == "open_routing":
|
||||||
self._show_view("routing")
|
self._show_view("routing")
|
||||||
routing = self._views.get("routing")
|
routing = self._views.get("routing")
|
||||||
if routing and hasattr(routing, "focus_role"):
|
if routing and hasattr(routing, "focus_role"):
|
||||||
routing.focus_role(data.get("role_id", ""))
|
routing.focus_role(data.get("role_id", ""))
|
||||||
return
|
elif action == "save_settings":
|
||||||
elif action == "refresh_data":
|
|
||||||
self._refresh_data()
|
|
||||||
return
|
|
||||||
|
|
||||||
# 2. Execution logic via shared handler
|
def _settings_saved(result: Tuple[bool, str]) -> None:
|
||||||
from antigravity_provider.router.action_handler import ActionExecutor
|
|
||||||
|
|
||||||
if action == "test":
|
|
||||||
self._show_account_action_result(pid, f"Тестирование {data.get('display_name', pid)}…", None)
|
|
||||||
elif action == "auto_assign_all":
|
|
||||||
self._show_toast("⚡ Автоматическое распределение ролей...")
|
|
||||||
|
|
||||||
def _on_success(res: dict):
|
|
||||||
ok = res.get("ok", False)
|
|
||||||
msg = res.get("message", "")
|
|
||||||
|
|
||||||
if action == "test":
|
|
||||||
self._show_test_result(res.get("data", {}), pid)
|
|
||||||
elif action == "check_updates":
|
|
||||||
upd_res = res.get("data")
|
|
||||||
if upd_res:
|
|
||||||
self._show_toast(
|
|
||||||
f"Доступна версия {upd_res.manifest.version}"
|
|
||||||
if upd_res.update_available and upd_res.manifest
|
|
||||||
else (f"Ошибка: {upd_res.error}" if upd_res.error else "Установлена актуальная версия")
|
|
||||||
)
|
|
||||||
elif action == "save_settings":
|
|
||||||
requested_theme = str(data.get("theme", self._theme_name))
|
requested_theme = str(data.get("theme", self._theme_name))
|
||||||
if requested_theme != self._theme_name:
|
if requested_theme != self._theme_name:
|
||||||
self._apply_theme(requested_theme)
|
self._apply_theme(requested_theme)
|
||||||
self._show_toast(f"✅ {msg}")
|
self._show_toast(f"✅ {result[1]}")
|
||||||
elif action in ["refresh_all", "refresh_account"]:
|
|
||||||
self.after(0, self._refresh_data)
|
|
||||||
else:
|
|
||||||
if msg and msg not in ["Навигация", "запущено"]:
|
|
||||||
prefix = "✅" if ok else "❌"
|
|
||||||
self._show_toast(f"{prefix} {msg}")
|
|
||||||
if action in ["set_main", "set_orchestrator", "delete_credentials"]:
|
|
||||||
self._show_account_action_result(pid, msg, ok)
|
|
||||||
|
|
||||||
# Execute in a thread since Desktop shouldn't block UI
|
self._run_in_thread(
|
||||||
self._run_in_thread(
|
lambda: do_save_settings(data),
|
||||||
lambda: ActionExecutor.execute(action, data),
|
on_success=_settings_saved,
|
||||||
on_success=_on_success
|
)
|
||||||
)
|
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)
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import logging
|
import logging
|
||||||
from typing import Dict, Any
|
from typing import Any, Dict
|
||||||
|
|
||||||
|
from antigravity_provider import paths
|
||||||
|
from antigravity_provider.version import __version__
|
||||||
from fastapi import FastAPI, Request, HTTPException, Depends, Header
|
from fastapi import FastAPI, Request, HTTPException, Depends, Header
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
@ -26,12 +29,28 @@ app.add_middleware(
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _web_settings() -> Dict[str, Any]:
|
||||||
|
"""Настройки веб-API живут в hub_settings.json, а не в RouterConfig.
|
||||||
|
|
||||||
|
Прежняя версия читала config.hub — такого атрибута у RouterConfig нет,
|
||||||
|
поэтому /api/snapshot и /api/action падали с 500, а run_server не
|
||||||
|
поднимался вовсе. Работал только /api/health, у которого нет проверки
|
||||||
|
авторизации, — из-за чего дефект и выглядел как рабочий сервер.
|
||||||
|
"""
|
||||||
|
settings_file = paths.get_hermes_home() / "hub_settings.json"
|
||||||
|
try:
|
||||||
|
return json.loads(settings_file.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def get_auth_token(x_hub_token: str = Header(None)) -> bool:
|
def get_auth_token(x_hub_token: str = Header(None)) -> bool:
|
||||||
config = load_router_config()
|
settings = _web_settings()
|
||||||
server_host = config.hub.get('web_api_host', '127.0.0.1')
|
server_host = settings.get('web_api_host', '127.0.0.1')
|
||||||
|
|
||||||
if server_host != '127.0.0.1':
|
if server_host != '127.0.0.1':
|
||||||
required_token = config.hub.get('web_api_token', '')
|
required_token = settings.get('web_api_token', '')
|
||||||
if not required_token:
|
if not required_token:
|
||||||
raise HTTPException(status_code=500, detail="Server misconfigured: external bind requires a token")
|
raise HTTPException(status_code=500, detail="Server misconfigured: external bind requires a token")
|
||||||
if x_hub_token != required_token:
|
if x_hub_token != required_token:
|
||||||
|
|
@ -42,7 +61,7 @@ def get_auth_token(x_hub_token: str = Header(None)) -> bool:
|
||||||
def health_check():
|
def health_check():
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"version": "1.0.0",
|
"version": __version__,
|
||||||
"auth_flows": {
|
"auth_flows": {
|
||||||
"openai-codex": {"supported": True, "reason": "device-code"},
|
"openai-codex": {"supported": True, "reason": "device-code"},
|
||||||
"grok": {"supported": True, "reason": "device-code"},
|
"grok": {"supported": True, "reason": "device-code"},
|
||||||
|
|
@ -101,10 +120,10 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to
|
||||||
|
|
||||||
def run_server():
|
def run_server():
|
||||||
import uvicorn
|
import uvicorn
|
||||||
config = load_router_config()
|
settings = _web_settings()
|
||||||
host = config.hub.get('web_api_host', '127.0.0.1')
|
host = settings.get('web_api_host', '127.0.0.1')
|
||||||
port = int(config.hub.get('web_api_port', 5800))
|
port = int(settings.get('web_api_port', 5800))
|
||||||
token = config.hub.get('web_api_token', '')
|
token = settings.get('web_api_token', '')
|
||||||
|
|
||||||
if host != '127.0.0.1' and not token:
|
if host != '127.0.0.1' and not token:
|
||||||
logger.error("Cannot bind Web API externally without web_api_token")
|
logger.error("Cannot bind Web API externally without web_api_token")
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from antigravity_provider.router.auto_assigner import AutoAssigner
|
from antigravity_provider.router.auto_assigner import AutoAssigner
|
||||||
|
from antigravity_provider.router import action_handler
|
||||||
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy
|
from antigravity_provider.router.router_config import RouterConfig, RouterProfileConfig, RolePolicy
|
||||||
from antigravity_provider.router.ui.add_account_wizard import ensure_profile_in_routing
|
from antigravity_provider.router.ui.add_account_wizard import ensure_profile_in_routing
|
||||||
from antigravity_provider.router.hermes_hub_app import do_test_profile
|
from antigravity_provider.router.hermes_hub_app import do_test_profile
|
||||||
|
|
@ -83,10 +84,10 @@ def test_do_test_profile_for_grok_and_claude():
|
||||||
mock_adapter = MagicMock()
|
mock_adapter = MagicMock()
|
||||||
mock_adapter.health_check.return_value = True
|
mock_adapter.health_check.return_value = True
|
||||||
|
|
||||||
with patch("antigravity_provider.router.hermes_hub_app.load_router_config", return_value=config), \
|
with patch("antigravity_provider.router.action_handler.load_router_config", return_value=config), \
|
||||||
patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status", return_value={"authenticated": True, "is_expired": False}), \
|
patch("antigravity_provider.router.profile_manager.ProfileAuthManager.get_profile_status", return_value={"authenticated": True, "is_expired": False}), \
|
||||||
patch("antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"api_key": "test"}), \
|
patch("antigravity_provider.router.profile_manager.ProfileAuthManager.load_profile_auth", return_value={"api_key": "test"}), \
|
||||||
patch("antigravity_provider.router.hermes_hub_app.get_adapter", return_value=mock_adapter):
|
patch("antigravity_provider.router.action_handler.get_adapter", return_value=mock_adapter):
|
||||||
|
|
||||||
res_grok = do_test_profile("grok", "grok-worker-1")
|
res_grok = do_test_profile("grok", "grok-worker-1")
|
||||||
assert res_grok["success"] is True
|
assert res_grok["success"] is True
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import pytest
|
||||||
pytest.importorskip("customtkinter")
|
pytest.importorskip("customtkinter")
|
||||||
|
|
||||||
from antigravity_provider.router.ui import routing_graph as graph_module
|
from antigravity_provider.router.ui import routing_graph as graph_module
|
||||||
|
from antigravity_provider.router import action_handler
|
||||||
from antigravity_provider.router.ui import add_account_wizard as wizard_module
|
from antigravity_provider.router.ui import add_account_wizard as wizard_module
|
||||||
from antigravity_provider.router.ui.views import team_view as team_module
|
from antigravity_provider.router.ui.views import team_view as team_module
|
||||||
from antigravity_provider.router import hermes_hub_app as app_module
|
from antigravity_provider.router import hermes_hub_app as app_module
|
||||||
|
|
@ -164,13 +165,13 @@ def test_profile_test_does_not_invoke_model_or_oauth(monkeypatch):
|
||||||
def invoke(*_args, **_kwargs):
|
def invoke(*_args, **_kwargs):
|
||||||
raise AssertionError("profile test must never invoke inference")
|
raise AssertionError("profile test must never invoke inference")
|
||||||
|
|
||||||
monkeypatch.setattr(app_module, "load_router_config", lambda: config)
|
monkeypatch.setattr(action_handler, "load_router_config", lambda: config)
|
||||||
monkeypatch.setattr(app_module.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True})
|
monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True})
|
||||||
monkeypatch.setattr(app_module.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
|
monkeypatch.setattr(action_handler.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
|
||||||
monkeypatch.setattr(app_module, "get_adapter", lambda _provider: Adapter())
|
monkeypatch.setattr(action_handler, "get_adapter", lambda _provider: Adapter())
|
||||||
monkeypatch.setattr(app_module.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None))
|
monkeypatch.setattr(action_handler.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None))
|
||||||
|
|
||||||
result = app_module.do_test_profile("antigravity", "connected")
|
result = action_handler.do_test_profile("antigravity", "connected")
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert "runtime" in result["response"]
|
assert "runtime" in result["response"]
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue