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 json
|
||||
import os
|
||||
import logging
|
||||
import threading
|
||||
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.updater import UpdateManager
|
||||
from antigravity_provider import paths
|
||||
from antigravity_provider.router.adapters import get_adapter
|
||||
|
||||
logger = logging.getLogger('hermes.router.actions')
|
||||
|
||||
|
|
@ -31,7 +33,6 @@ def do_set_orchestrator(profile_id: str) -> Tuple[bool, str]:
|
|||
return ok, msg
|
||||
|
||||
def do_test_profile(provider: str, profile_id: str) -> Dict[str, Any]:
|
||||
from antigravity_provider.router.adapters import get_adapter
|
||||
config = load_router_config()
|
||||
pcfg = config.get_profile(profile_id)
|
||||
if not pcfg:
|
||||
|
|
@ -84,21 +85,30 @@ def do_delete_credentials(provider: str, profile_id: str) -> Tuple[bool, str]:
|
|||
return True, 'Учетные данные отсутствовали'
|
||||
|
||||
def do_save_settings(settings: Dict[str, Any]) -> Tuple[bool, str]:
|
||||
settings_file = paths.get_hermes_home() / 'hub_settings.json'
|
||||
settings_file = 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'))
|
||||
existing = json.loads(settings_file.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
for k, v in settings.items():
|
||||
existing[k] = v
|
||||
existing = {}
|
||||
existing.update(settings)
|
||||
try:
|
||||
settings_file.write_text(json.dumps(existing, indent=2), encoding='utf-8')
|
||||
return True, 'Настройки сохранены'
|
||||
# Запись через временный файл и os.replace: обрыв на середине не
|
||||
# должен оставить настройки битыми. В домашнем каталоге 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:
|
||||
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:
|
||||
"""Shared execution layer for Desktop and Web actions."""
|
||||
|
|
|
|||
|
|
@ -34,14 +34,10 @@ if sys.platform == "win32":
|
|||
pass
|
||||
|
||||
# ── Ensure plugin and repo paths are on sys.path ──
|
||||
_SRC_DIR = Path(__file__).resolve().parent.parent.parent
|
||||
if str(_SRC_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC_DIR))
|
||||
import antigravity_provider.paths as _paths
|
||||
_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]:
|
||||
_LOCAL = Path(os.environ.get("LOCALAPPDATA", ""))
|
||||
_PLUGIN_SRC = _LOCAL / "hermes" / "plugins" / "antigravity-provider" / "src"
|
||||
_AGENT_DIR = _LOCAL / "hermes" / "hermes-agent"
|
||||
for _p in [_PLUGIN_SRC, _AGENT_DIR, Path(__file__).resolve().parent.parent.parent]:
|
||||
_ps = str(_p)
|
||||
if _p.exists() and _ps not in sys.path:
|
||||
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.auto_assigner import AutoAssigner
|
||||
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.version import __version__
|
||||
|
||||
|
|
@ -89,78 +94,513 @@ def _load_saved_theme() -> str:
|
|||
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 ───────
|
||||
|
||||
def _handle_action(self, action: str, data: dict):
|
||||
def _handle_action(self, action: str, data: Dict[str, Any]):
|
||||
pid = data.get("profile_id", "")
|
||||
|
||||
# 1. UI Navigation
|
||||
if action in ["oauth", "add_account"]:
|
||||
prov = data.get("provider", "")
|
||||
|
||||
if action == "set_main":
|
||||
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()
|
||||
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":
|
||||
self._open_assign_role_modal(pid, data.get("display_name", pid))
|
||||
return
|
||||
elif action == "account_details":
|
||||
self._open_account_details_modal(pid)
|
||||
return
|
||||
elif action == "agent_settings":
|
||||
self._open_agent_settings_modal(data.get("role_id", ""), pid)
|
||||
return
|
||||
self._open_agent_settings_modal(
|
||||
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":
|
||||
self._open_route_editor_modal(data.get("role_id", ""))
|
||||
return
|
||||
role_id = data.get("role_id", "")
|
||||
self._open_route_editor_modal(role_id)
|
||||
elif action == "open_routing":
|
||||
self._show_view("routing")
|
||||
routing = self._views.get("routing")
|
||||
if routing and hasattr(routing, "focus_role"):
|
||||
routing.focus_role(data.get("role_id", ""))
|
||||
return
|
||||
elif action == "refresh_data":
|
||||
self._refresh_data()
|
||||
return
|
||||
|
||||
# 2. Execution logic via shared handler
|
||||
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":
|
||||
elif action == "save_settings":
|
||||
|
||||
def _settings_saved(result: Tuple[bool, str]) -> None:
|
||||
requested_theme = str(data.get("theme", self._theme_name))
|
||||
if requested_theme != self._theme_name:
|
||||
self._apply_theme(requested_theme)
|
||||
self._show_toast(f"✅ {msg}")
|
||||
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(
|
||||
lambda: ActionExecutor.execute(action, data),
|
||||
on_success=_on_success
|
||||
)
|
||||
self._show_toast(f"✅ {result[1]}")
|
||||
|
||||
self._run_in_thread(
|
||||
lambda: do_save_settings(data),
|
||||
on_success=_settings_saved,
|
||||
)
|
||||
elif action == "check_updates":
|
||||
from antigravity_provider.updater import UpdateManager
|
||||
|
||||
self._run_in_thread(
|
||||
lambda: UpdateManager().check_for_updates(),
|
||||
on_success=lambda result: self._show_toast(
|
||||
f"Доступна версия {result.manifest.version}"
|
||||
if result.update_available and result.manifest
|
||||
else (f"Ошибка: {result.error}" if result.error else "Установлена актуальная версия")
|
||||
),
|
||||
)
|
||||
|
||||
def _open_assign_role_modal(self, profile_id: str, display_name: str):
|
||||
modal = HubModal(self, title=f"Назначение роли: {display_name}", width=500, height=420)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import dataclasses
|
||||
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.responses import JSONResponse
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
|
@ -26,12 +29,28 @@ app.add_middleware(
|
|||
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:
|
||||
config = load_router_config()
|
||||
server_host = config.hub.get('web_api_host', '127.0.0.1')
|
||||
settings = _web_settings()
|
||||
server_host = settings.get('web_api_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:
|
||||
raise HTTPException(status_code=500, detail="Server misconfigured: external bind requires a 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():
|
||||
return {
|
||||
"ok": True,
|
||||
"version": "1.0.0",
|
||||
"version": __version__,
|
||||
"auth_flows": {
|
||||
"openai-codex": {"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():
|
||||
import uvicorn
|
||||
config = load_router_config()
|
||||
host = config.hub.get('web_api_host', '127.0.0.1')
|
||||
port = int(config.hub.get('web_api_port', 5800))
|
||||
token = config.hub.get('web_api_token', '')
|
||||
settings = _web_settings()
|
||||
host = settings.get('web_api_host', '127.0.0.1')
|
||||
port = int(settings.get('web_api_port', 5800))
|
||||
token = settings.get('web_api_token', '')
|
||||
|
||||
if host != '127.0.0.1' and not token:
|
||||
logger.error("Cannot bind Web API externally without web_api_token")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ from unittest.mock import MagicMock, patch
|
|||
import pytest
|
||||
|
||||
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.ui.add_account_wizard import ensure_profile_in_routing
|
||||
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.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.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")
|
||||
assert res_grok["success"] is True
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import pytest
|
|||
pytest.importorskip("customtkinter")
|
||||
|
||||
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.views import team_view as team_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):
|
||||
raise AssertionError("profile test must never invoke inference")
|
||||
|
||||
monkeypatch.setattr(app_module, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(app_module.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True})
|
||||
monkeypatch.setattr(app_module.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
|
||||
monkeypatch.setattr(app_module, "get_adapter", lambda _provider: Adapter())
|
||||
monkeypatch.setattr(app_module.EventLogService, "get", lambda: SimpleNamespace(log=lambda *_args, **_kwargs: None))
|
||||
monkeypatch.setattr(action_handler, "load_router_config", lambda: config)
|
||||
monkeypatch.setattr(action_handler.ProfileAuthManager, "get_profile_status", lambda *_args: {"authenticated": True})
|
||||
monkeypatch.setattr(action_handler.ProfileAuthManager, "load_profile_auth", lambda *_args: {"token": "present"})
|
||||
monkeypatch.setattr(action_handler, "get_adapter", lambda _provider: Adapter())
|
||||
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 "runtime" in result["response"]
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue