From 7468c7cc8bf406bc1456f686e3452109bc5c1908 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Sun, 23 Aug 2026 10:54:08 +0700 Subject: [PATCH] feat: add cockpit-style model and card controls --- .../router/hermes_hub_app.py | 270 +++++++++++++++--- .../router/ui/components.py | 87 ++++-- .../router/ui/model_catalog.py | 99 +++++++ .../router/ui/views/dashboard_view.py | 197 ++++++------- .../router/ui/views/providers_view.py | 9 +- .../router/ui/views/routing_view.py | 4 +- .../router/ui/views/team_view.py | 61 +++- tests/test_ui_contract_v11.py | 3 +- tests/test_ui_routing_graph.py | 20 +- 9 files changed, 575 insertions(+), 175 deletions(-) create mode 100644 src/antigravity_provider/router/ui/model_catalog.py diff --git a/src/antigravity_provider/router/hermes_hub_app.py b/src/antigravity_provider/router/hermes_hub_app.py index 5237928..6b50cfd 100644 --- a/src/antigravity_provider/router/hermes_hub_app.py +++ b/src/antigravity_provider/router/hermes_hub_app.py @@ -51,7 +51,7 @@ from antigravity_provider.version import __version__ from antigravity_provider.router.ui.theme import Theme from antigravity_provider.router.ui.assets import AssetManager -from antigravity_provider.router.ui.components import HubButton, HubModal +from antigravity_provider.router.ui.components import AccountCardWidget, HubButton, HubModal from antigravity_provider.router.ui.add_account_wizard import AddAccountWizard from antigravity_provider.router.unified_health import ( @@ -60,6 +60,7 @@ from antigravity_provider.router.unified_health import ( SystemReadiness, STATUS_HEALTHY, ) +from antigravity_provider.router.state_store import HubStateStore from antigravity_provider.router.ui.views.team_view import TeamView, persist_role_chain from antigravity_provider.router.ui.views.dashboard_view import DashboardView @@ -71,29 +72,10 @@ from antigravity_provider.router.ui.views.logs_view import LogsView from antigravity_provider.router.ui.views.settings_view import SettingsView from antigravity_provider.router.ui.views.about_view import AboutView from antigravity_provider.router.ui.views.analytics_view import AnalyticsView -from antigravity_provider.router.ui.views.quotas_view import QuotasView +from antigravity_provider.router.ui.model_catalog import get_cached_models, refresh_models_async logger = logging.getLogger("hermes.hub.gui") -AGENT_MODEL_OPTIONS = { - "antigravity": [ - # User-facing logical IDs. The provider layer maps Gemini 3.1 Pro - # to the current low/high wire variants according to reasoning effort. - "gemini-3.1-pro", - "gemini-2.5-pro", - "gemini-3.7-flash", - "gemini-3.6-flash-high", - "gemini-3.5-flash", - "claude-sonnet-4-6", - "claude-opus-4-6-thinking", - ], - "openai-codex": ["gpt-4o", "o3-mini", "codex"], - "opencode-go": ["qwen3.8-max", "kimi-k2.7-code", "deepseek-v3"], - "claude": ["claude-sonnet-4-6", "claude-3-7-sonnet", "claude-3-5-haiku"], - "grok": ["grok-3", "grok-3-mini", "grok-2"], -} - - def _load_saved_theme() -> str: settings_file = paths.get_hermes_home() / "hub_settings.json" try: @@ -300,7 +282,6 @@ class HermesHubApp(ctk.CTk): ("accounts", "Аккаунты", "accounts"), ("routing", "Маршрутизация", "routing"), ("providers", "Модели и провайдеры", "providers"), - ("quotas", "Сводка лимитов", "quotas"), ("analytics", "Аналитика", "analytics"), ("health", "Состояние", "health"), ("logs", "Журнал событий", "logs"), @@ -465,8 +446,6 @@ class HermesHubApp(ctk.CTk): 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 == "quotas": - return QuotasView(self.content) elif view_name == "analytics": return AnalyticsView(self.content) elif view_name == "health": @@ -655,6 +634,8 @@ class HermesHubApp(ctk.CTk): ) elif action == "assign_role": self._open_assign_role_modal(pid, data.get("display_name", pid)) + elif action == "account_details": + self._open_account_details_modal(pid) elif action == "agent_settings": self._open_agent_settings_modal( data.get("role_id", ""), @@ -799,6 +780,146 @@ class HermesHubApp(ctk.CTk): modal.save_button = save_button return modal + def _open_account_details_modal(self, profile_id: str): + """Open account details from a click anywhere on its compact row.""" + snapshot = HubStateStore.get().get_snapshot() + profile = snapshot.all_profiles.get(profile_id) + if profile is None: + self._show_toast(f"❌ Профиль '{profile_id}' не найден") + return None + modal = HubModal(self, title=AccountCardWidget.resolve_identity(profile), width=680, height=520) + summary = ctk.CTkFrame(modal.body, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD) + summary.pack(fill="x", pady=(0, Theme.SPACE_MD)) + roles = ", ".join(profile.assigned_roles or []) or "роль не назначена" + ctk.CTkLabel( + summary, + text=f"{profile.provider_display_name} • {profile.display_name}", + font=Theme.font_heading(), + text_color=Theme.TEXT_PRIMARY, + ).pack(anchor="w", padx=12, pady=(10, 2)) + ctk.CTkLabel( + summary, + text=f"Роли: {roles} • Авторизация: {profile.auth_label_ru} • {profile.health_label_ru}", + font=Theme.font_caption(), + text_color=Theme.TEXT_SECONDARY, + ).pack(anchor="w", padx=12, pady=(0, 10)) + + config = load_router_config() + profile_config = config.profiles.get(profile_id) + catalog = get_cached_models(profile.provider) + configured_model = ( + profile_config.preferred_models[0] if profile_config and profile_config.preferred_models else "" + ) + model_values = list(catalog.models) + if configured_model and configured_model not in model_values: + model_values.insert(0, configured_model) + model_var = ctk.StringVar( + value=configured_model or (model_values[0] if model_values else "Список моделей ещё не получен") + ) + model_row = ctk.CTkFrame(modal.body, fg_color="transparent") + model_row.pack(fill="x", pady=(0, Theme.SPACE_SM)) + model_menu = ctk.CTkOptionMenu( + model_row, + values=model_values or ["Список моделей ещё не получен"], + variable=model_var, + fg_color=Theme.SURFACE_MUTED, + button_color=Theme.ACCENT, + button_hover_color=Theme.ACCENT_HOVER, + text_color=Theme.TEXT_PRIMARY, + ) + model_menu.pack(side="left", fill="x", expand=True) + model_note = catalog.unavailable_reason if not catalog.models else ( + f"⚠ «{configured_model}» отсутствует в обнаруженном списке" + if configured_model and configured_model not in catalog.models + else f"{len(catalog.models)} моделей из кэша" + ) + ctk.CTkLabel( + modal.body, + text=model_note, + font=Theme.font_micro(), + text_color=Theme.STATUS_WARNING if not catalog.models or configured_model not in catalog.models else Theme.TEXT_MUTED, + ).pack(anchor="w", pady=(0, Theme.SPACE_SM)) + + def _save_profile_model() -> None: + selected = model_var.get() + if selected == "Список моделей ещё не получен" or not profile_config: + self._show_toast("❌ Сначала получите список моделей") + return + updated = load_router_config() + target = updated.profiles[profile_id] + target.preferred_models = [selected] + [item for item in target.preferred_models if item != selected] + updated.profiles[profile_id] = target + if save_router_config(updated): + self._show_toast(f"✅ Модель профиля сохранена: {selected}") + self._refresh_data() + else: + self._show_toast("❌ Не удалось сохранить модель профиля") + + HubButton( + model_row, + text="Сохранить модель", + variant="secondary", + width=140, + command=_save_profile_model, + ).pack(side="right", padx=(Theme.SPACE_SM, 0)) + HubButton( + model_row, + text="Обновить список", + variant="ghost", + width=120, + command=lambda: refresh_models_async( + profile.provider, + lambda _result: self.after( + 0, + lambda: (modal.destroy(), self._open_account_details_modal(profile_id)), + ), + ), + ).pack(side="right", padx=(Theme.SPACE_SM, 0)) + + ctk.CTkLabel( + modal.body, text="Квоты и периоды", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY + ).pack(anchor="w") + quota_box = ctk.CTkFrame(modal.body, fg_color="transparent") + quota_box.pack(fill="both", expand=True, pady=(4, 8)) + quota = snapshot.quotas.get(profile_id) + buckets = list(getattr(quota, "buckets", None) or []) + if not buckets: + reason = getattr(quota, "unavailable_reason", None) or "Провайдер не отдал данные о лимитах" + ctk.CTkLabel( + quota_box, text=f"Н/Д — {reason}", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED + ).pack(anchor="w", pady=8) + for bucket in buckets: + row = ctk.CTkFrame(quota_box, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM) + row.pack(fill="x", pady=2) + ctk.CTkLabel( + row, text=bucket.display_name, font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY + ).pack(side="left", padx=10, pady=7) + detail = bucket.formatted_remaining() + if detail == "Н/Д": + detail = getattr(bucket, "unavailable_reason", None) or getattr(quota, "unavailable_reason", None) or detail + reset = bucket.formatted_reset() or "время сброса не предоставлено" + ctk.CTkLabel( + row, + text=f"{detail} • {reset}", + font=Theme.font_caption(), + text_color=Theme.TEXT_SECONDARY, + ).pack(side="right", padx=10, pady=7) + + HubButton( + modal.footer, + text="Проверить", + variant="secondary", + command=lambda: (modal.destroy(), self._handle_action("test", profile.__dict__)), + ).pack(side="left", padx=(0, 5)) + HubButton( + modal.footer, + text="Назначить роль", + variant="secondary", + command=lambda: (modal.destroy(), self._open_assign_role_modal(profile_id, profile.display_name)), + ).pack(side="left") + HubButton(modal.footer, text="Закрыть", variant="primary", command=modal.destroy).pack(side="right") + return modal + def _open_route_editor_modal(self, role_id: str): """Direct, visible editor for one ordered failover chain.""" config = load_router_config() @@ -905,6 +1026,8 @@ class HermesHubApp(ctk.CTk): config = load_router_config() role = config.roles.get(role_id) + live_snapshot = HubStateStore.get().get_snapshot() + pipeline = live_snapshot.get_role_pipeline(role_id) role_labels = { "orchestrator": "Главный оркестратор", "coder-primary": "Кодер 1", @@ -913,7 +1036,7 @@ class HermesHubApp(ctk.CTk): "research": "Исследователь", "fast": "Быстрый агент", } - modal = HubModal(self, title=f"Настройки агента: {role_labels.get(role_id, role_id)}", width=560, height=540) + modal = HubModal(self, title=f"Настройки агента: {role_labels.get(role_id, role_id)}", width=620, height=720) ctk.CTkLabel( modal.body, text="Аккаунт, модель и реальные лимиты агента", @@ -921,6 +1044,32 @@ class HermesHubApp(ctk.CTk): text_color=Theme.TEXT_PRIMARY, ).pack(anchor="w", pady=(0, 12)) + chain_card = ctk.CTkFrame(modal.body, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD) + chain_card.pack(fill="x", pady=(0, 10)) + chain = list(role.preferred_chain) if role else [] + chain_text = " → ".join( + f"{'● ' if pipeline and node_id == pipeline.active_profile_id else ''}{node_id}" for node_id in chain + ) or "Профили не назначены" + active_node = next( + (node for node in list(getattr(pipeline, "nodes", []) or []) if node.profile_id == pipeline.active_profile_id), + None, + ) + failover_reason = getattr(active_node, "failover_reason", None) or "переключений ещё не было" + ctk.CTkLabel( + chain_card, + text=f"Цепочка: {chain_text}", + font=Theme.font_caption(), + text_color=Theme.TEXT_PRIMARY, + anchor="w", + ).pack(fill="x", padx=10, pady=(8, 2)) + ctk.CTkLabel( + chain_card, + text=f"Последнее переключение: {failover_reason}", + font=Theme.font_micro(), + text_color=Theme.TEXT_MUTED, + anchor="w", + ).pack(fill="x", padx=10, pady=(0, 8)) + choices: dict[str, str] = {} for pid, pcfg in config.profiles.items(): status = ProfileAuthManager.get_profile_status(pcfg.provider, pid) @@ -969,6 +1118,65 @@ class HermesHubApp(ctk.CTk): text_color=Theme.TEXT_PRIMARY, ) model_menu.pack(fill="x", pady=(3, 10)) + model_status = ctk.CTkLabel( + modal.body, + text="", + font=Theme.font_caption(), + text_color=Theme.TEXT_MUTED, + anchor="w", + justify="left", + ) + model_status.pack(fill="x", pady=(0, 5)) + + def _apply_cached_models() -> None: + current_pid = choices[account_var.get()] + current_cfg = load_router_config().profiles[current_pid] + catalog = get_cached_models(current_cfg.provider) + configured = ( + role.default_model + if role and role.default_model + else current_cfg.preferred_models[0] + if current_cfg.preferred_models + else "" + ) + values = list(catalog.models) + if configured and configured not in values: + values.insert(0, configured) + model_menu.configure(values=values or ["Список моделей ещё не получен"]) + model_var.set(configured or (values[0] if values else "Список моделей ещё не получен")) + if not catalog.models: + model_status.configure(text=catalog.unavailable_reason, text_color=Theme.STATUS_WARNING) + elif configured and configured not in catalog.models: + model_status.configure( + text=f"⚠ Настроенная модель «{configured}» отсутствует в обнаруженном списке. Она не изменена.", + text_color=Theme.STATUS_WARNING, + ) + else: + freshness = f" • получено {catalog.fetched_at}" if catalog.fetched_at else "" + stale = " • кэш устарел" if catalog.is_stale else "" + model_status.configure( + text=f"{len(catalog.models)} моделей из кэша{freshness}{stale}", + text_color=Theme.TEXT_MUTED if not catalog.is_stale else Theme.STATUS_WARNING, + ) + + def _refresh_models() -> None: + current_pid = choices[account_var.get()] + provider = load_router_config().profiles[current_pid].provider + model_status.configure(text="Обновление моделей запущено в фоне…", text_color=Theme.TEXT_SECONDARY) + started = refresh_models_async(provider, lambda _result: self.after(0, _apply_cached_models)) + if not started: + model_status.configure( + text="Фоновое обновление пока недоступно; показан последний кэш.", + text_color=Theme.STATUS_WARNING, + ) + + HubButton( + modal.body, + text="Обновить список моделей", + variant="secondary", + height=30, + command=_refresh_models, + ).pack(anchor="w", pady=(0, 8)) quota_card = ctk.CTkFrame(modal.body, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_MD) quota_card.pack(fill="both", expand=True, pady=(2, 10)) @@ -982,14 +1190,7 @@ class HermesHubApp(ctk.CTk): def _refresh_account_panel(_choice: Optional[str] = None) -> None: current_pid = choices[account_var.get()] current_cfg = load_router_config().profiles[current_pid] - available = list( - dict.fromkeys(current_cfg.preferred_models + AGENT_MODEL_OPTIONS.get(current_cfg.provider, [])) - ) - if not available: - available = ["default"] - model_menu.configure(values=available) - current_model = role.default_model if role and role.default_model in available else available[0] - model_var.set(current_model) + _apply_cached_models() for child in quota_rows.winfo_children(): child.destroy() snapshot = AccountQuotaService.get().get_snapshot(current_cfg.provider, current_pid) @@ -1021,6 +1222,9 @@ class HermesHubApp(ctk.CTk): def _save_agent() -> None: current_pid = choices[account_var.get()] current_model = model_var.get() + if current_model == "Список моделей ещё не получен": + result.configure(text="✕ Сначала получите список моделей", text_color=Theme.STATUS_ERROR) + return updated = load_router_config() profile = updated.profiles[current_pid] profile.preferred_models = [current_model] + [m for m in profile.preferred_models if m != current_model] diff --git a/src/antigravity_provider/router/ui/components.py b/src/antigravity_provider/router/ui/components.py index e2284c8..f6c0153 100644 --- a/src/antigravity_provider/router/ui/components.py +++ b/src/antigravity_provider/router/ui/components.py @@ -808,6 +808,7 @@ class RouteTargetWidget(HubCard): self.subtitle.pack(anchor="w", padx=Theme.CARD_PAD_X) self.quota = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) self.quota.pack(anchor="w", padx=Theme.CARD_PAD_X) + self.quota_tooltip = Tooltip(self.quota, "") self.failover = ctk.CTkLabel( self, text="", font=Theme.font_micro(), text_color=Theme.COLOR_CAUTION, wraplength=260, justify="left" ) @@ -830,7 +831,17 @@ class RouteTargetWidget(HubCard): "warning": "Квота: заканчивается", "exhausted": "Квота: исчерпана", } - self.quota.configure(text=quota_labels.get(quota_status, "Квота: Н/Д")) + if quota_status in quota_labels: + quota_text = quota_labels[quota_status] + quota_reason = "" + elif status in {"not_configured", "auth_required", "auth_expired"}: + quota_text = "Квота: Н/Д — аккаунт не подключён" + quota_reason = "Аккаунт не авторизован, поэтому лимиты запросить нельзя" + else: + quota_text = "Квота: Н/Д — провайдер не отдаёт лимиты" + quota_reason = "В текущем ответе провайдера отсутствуют измеримые данные квоты" + self.quota.configure(text=quota_text) + self.quota_tooltip.text = quota_reason if failover_reason: self.failover.configure(text=f"Переключено: {failover_reason}") self.failover.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM)) @@ -864,7 +875,9 @@ class AccountCardWidget(HubCard): on_action: Optional[Callable[[str, Any], None]] = None, **kwargs, ): - super().__init__(master, **kwargs) + super().__init__(master, height=148, **kwargs) + self.pack_propagate(False) + self.grid_propagate(False) self.profile_id = profile_id self.profile_model: Any = None self.on_action = on_action @@ -877,20 +890,31 @@ class AccountCardWidget(HubCard): 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.profile_mark = ctk.CTkLabel(top, text="◇", font=Theme.font_caption(), text_color=Theme.TEXT_ACCENT) + self.profile_mark = ctk.CTkButton( + top, + text="⋮", + width=28, + height=20, + fg_color="transparent", + hover_color=Theme.SURFACE_HOVER, + text_color=Theme.TEXT_SECONDARY, + command=lambda: self._trigger("account_details"), + ) self.profile_mark.pack(side="right") self.identity = EllipsizedLabel(self, text=identity, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY) self.identity.pack(anchor="w", padx=Theme.CARD_PAD_X) self.meta = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) self.meta.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, 0)) - self.plan_badge = PlanBadge(self, text="") - self.status = StatusBadge(self, status) - self.status.pack(anchor="w", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) + self.badges = ctk.CTkFrame(self, fg_color="transparent") + self.badges.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_XS, Theme.SPACE_SM)) + self.plan_badge = PlanBadge(self.badges, text="") + self.status = StatusBadge(self.badges, status) + self.status.pack(side="left") self.compact_quota = ctk.CTkFrame(self, fg_color="transparent") self.compact_quota_cells: list[dict[str, Any]] = [] - for index in range(4): + for index in range(1): cell = ctk.CTkFrame(self.compact_quota, fg_color="transparent") cell.grid(row=index // 2, column=index % 2, sticky="nsew", padx=(0, 10), pady=(2, 6)) title = ctk.CTkLabel(cell, text="", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY, anchor="w") @@ -978,6 +1002,35 @@ class AccountCardWidget(HubCard): command=lambda: self._trigger("delete_credentials"), ).pack(side="right") self.set_compact(True) + self._bind_details_click(self) + try: + self._canvas.configure(takefocus=1) + except (AttributeError, tk.TclError): + pass + self.bind("", self._open_details, add="+") + self.bind("", self._open_details, add="+") + self.bind("", self._hover_on, add="+") + self.bind("", self._hover_off, add="+") + + def _bind_details_click(self, widget: Any) -> None: + if isinstance(widget, ctk.CTkButton): + return + widget.configure(cursor="hand2") + widget.bind("", self._open_details, add="+") + for child in widget.winfo_children(): + self._bind_details_click(child) + + def _open_details(self, _event: Any = None) -> str: + self._trigger("account_details") + return "break" + + def _hover_on(self, _event: Any = None) -> None: + self.configure(fg_color=Theme.SURFACE_HOVER, border_color=Theme.BORDER_HOVER) + + def _hover_off(self, _event: Any = None) -> None: + profile = self.profile_model + border = Theme.BORDER_ACCENT if profile and getattr(profile, "is_main_account", False) else Theme.BORDER + self.configure(fg_color=Theme.SURFACE, border_color=border) @staticmethod def resolve_identity(profile: Any) -> str: @@ -1015,7 +1068,7 @@ class AccountCardWidget(HubCard): self.compact = True self.details.pack_forget() self.compact_quota.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.SPACE_XS)) - self.compact_actions.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y)) + self.compact_actions.pack_forget() def update_account(self, profile: Any, quota_snapshot: Optional[Any] = None) -> None: self.profile_model = profile @@ -1027,12 +1080,7 @@ class AccountCardWidget(HubCard): if self.plan_badge.set_plan( getattr(profile, "plan_code", "UNKNOWN"), getattr(profile, "plan_source", "unknown") ): - self.plan_badge.pack( - anchor="w", - padx=Theme.CARD_PAD_X, - pady=(Theme.SPACE_SM, 0), - before=self.status, - ) + self.plan_badge.pack(side="left", padx=(0, Theme.SPACE_XS), before=self.status) else: self.plan_badge.pack_forget() self.configure(border_color=Theme.BORDER_ACCENT if profile.is_main_account else Theme.BORDER) @@ -1057,9 +1105,16 @@ class AccountCardWidget(HubCard): card_health = profile.health_state card_label = getattr(profile, "health_label_ru", None) self.status.set_status(card_health, card_label) + visible_buckets = sorted( + buckets, + key=lambda bucket: ( + getattr(bucket, "remaining_percent", None) is None, + float(getattr(bucket, "remaining_percent", 101.0) or 0.0), + ), + )[:1] for index, cell in enumerate(self.compact_quota_cells): - if index < len(buckets): - bucket = buckets[index] + if index < len(visible_buckets): + bucket = visible_buckets[index] remaining = getattr(bucket, "remaining_percent", None) color = ( Theme.STATUS_HEALTHY diff --git a/src/antigravity_provider/router/ui/model_catalog.py b/src/antigravity_provider/router/ui/model_catalog.py new file mode 100644 index 0000000..c201bb7 --- /dev/null +++ b/src/antigravity_provider/router/ui/model_catalog.py @@ -0,0 +1,99 @@ +"""Non-blocking UI adapter for the backend model-discovery cache. + +The UI never discovers models synchronously and never invents model IDs. The +adapter deliberately tolerates the small naming differences used by backend +revisions so the presentation layer can be merged independently from A9. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Optional + + +@dataclass(frozen=True) +class CachedModels: + provider: str + models: tuple[str, ...] = () + fetched_at: str = "" + is_stale: bool = True + unavailable_reason: str = "Список моделей ещё не получен" + + +def _service() -> Any: + try: + from antigravity_provider.router.model_discovery import ModelDiscoveryService + + getter = getattr(ModelDiscoveryService, "get", None) + return getter() if callable(getter) else ModelDiscoveryService() + except (ImportError, AttributeError, TypeError): + return None + + +def _normalise(provider: str, raw: Any) -> CachedModels: + if raw is None: + return CachedModels(provider=provider) + if isinstance(raw, dict): + models = raw.get("models") or raw.get("discovered_models") or [] + fetched_at = raw.get("fetched_at") or raw.get("updated_at") or raw.get("last_refresh_at") or "" + stale = raw.get("is_stale", raw.get("stale", False)) + reason = raw.get("unavailable_reason") or raw.get("error") or "" + else: + models = getattr(raw, "models", None) or getattr(raw, "discovered_models", None) or [] + fetched_at = ( + getattr(raw, "fetched_at", None) + or getattr(raw, "updated_at", None) + or getattr(raw, "last_refresh_at", None) + or "" + ) + stale = getattr(raw, "is_stale", getattr(raw, "stale", False)) + reason = getattr(raw, "unavailable_reason", None) or getattr(raw, "error", None) or "" + clean = tuple(dict.fromkeys(str(model).strip() for model in models if str(model).strip())) + return CachedModels( + provider=provider, + models=clean, + fetched_at=str(fetched_at), + is_stale=bool(stale), + unavailable_reason=str(reason or ("" if clean else "Список моделей ещё не получен")), + ) + + +def get_cached_models(provider: str) -> CachedModels: + """Return cached models immediately; never performs provider I/O.""" + service = _service() + if service is None: + return CachedModels(provider=provider, unavailable_reason="Служба обнаружения моделей ещё не подключена") + for name in ("get_cached", "get_cached_models", "get_snapshot", "get_provider_models"): + method = getattr(service, name, None) + if not callable(method): + continue + try: + return _normalise(provider, method(provider)) + except Exception as exc: + return CachedModels(provider=provider, unavailable_reason=f"Кэш моделей недоступен: {exc}") + return CachedModels(provider=provider, unavailable_reason="Служба не предоставляет чтение кэша моделей") + + +def refresh_models_async(provider: str, on_complete: Callable[[CachedModels], None]) -> bool: + """Request a backend background refresh without blocking the Tk thread.""" + service = _service() + if service is None: + on_complete(get_cached_models(provider)) + return False + for name in ("refresh_provider_async", "refresh_async", "discover_async"): + method = getattr(service, name, None) + if not callable(method): + continue + try: + result: Optional[Any] = method(provider, on_complete=lambda *_args: on_complete(get_cached_models(provider))) + return result is not False + except TypeError: + try: + result = method(provider) + return result is not False + except Exception: + break + except Exception: + break + on_complete(get_cached_models(provider)) + return False diff --git a/src/antigravity_provider/router/ui/views/dashboard_view.py b/src/antigravity_provider/router/ui/views/dashboard_view.py index 40e1954..0b2b608 100644 --- a/src/antigravity_provider/router/ui/views/dashboard_view.py +++ b/src/antigravity_provider/router/ui/views/dashboard_view.py @@ -94,9 +94,19 @@ class _EndpointCard(HubCard): self.progress.pack(fill="x", pady=(5, 0)) self.progress.set(0) self._click_action: Optional[Callable[[], None]] = None + self._status = "unknown" self._bind_click_tree(self) + try: + self._canvas.configure(takefocus=1) + except (AttributeError, tk.TclError): + pass + self.bind("", self._activate, add="+") + self.bind("", self._activate, add="+") + self.bind("", self._hover_on, add="+") + self.bind("", self._hover_off, add="+") def _bind_click_tree(self, widget: Any) -> None: + widget.configure(cursor="hand2") widget.bind("", self._activate, add="+") for child in widget.winfo_children(): self._bind_click_tree(child) @@ -109,6 +119,16 @@ class _EndpointCard(HubCard): if self._click_action: self._click_action() + def _hover_on(self, _event: Any = None) -> None: + if self._click_action: + self.configure(fg_color=Theme.SURFACE_HOVER, border_color=Theme.BORDER_HOVER) + + def _hover_off(self, _event: Any = None) -> None: + self.configure( + fg_color=Theme.SURFACE, + border_color=Theme.BORDER_ACCENT if self._status == "healthy" else Theme.BORDER, + ) + def update_card( self, provider_key: str, @@ -119,6 +139,7 @@ class _EndpointCard(HubCard): quota_percent: Optional[float], status: str, ) -> None: + self._status = status image = AssetManager.get().get_provider_image(provider_key, size=(30, 30)) self.icon.configure(image=image, text="" if image else "◇") self.icon.image = image @@ -181,12 +202,8 @@ class _RouteDiagram(ctk.CTkFrame): self.pack_propagate(False) self.canvas = tk.Canvas(self, bg=Theme.SURFACE, highlightthickness=0, bd=0) self.canvas.place(relx=0, rely=0, relwidth=1, relheight=1) - self.provider_slots = [ctk.CTkFrame(self, height=76, fg_color="transparent") for _ in range(3)] - self.agent_slots = [ctk.CTkFrame(self, height=68, fg_color="transparent") for _ in range(5)] - for index, slot in enumerate(self.provider_slots): - slot.place(relx=0.012, rely=0.02 + index * 0.29, relwidth=0.30) - for index, slot in enumerate(self.agent_slots): - slot.place(relx=0.71, rely=0.01 + index * 0.185, relwidth=0.278) + self.provider_slots: list[Any] = [] + self.agent_slots: list[Any] = [] self.orchestrator = _OrchestratorNode(self) self.orchestrator.place(relx=0.51, rely=0.46, anchor="center") self.context = HubCard(self, corner_radius=Theme.RADIUS_MD, height=46) @@ -201,13 +218,33 @@ class _RouteDiagram(ctk.CTkFrame): text_color=Theme.TEXT_MUTED, ) self.context_status.pack() - self._left_labels = ["", "", ""] - self._right_labels = ["", "", "", "", ""] + self._left_labels: list[str] = [] + self._right_labels: list[str] = [] self.bind("", self._redraw) + def sync_slots(self, provider_count: int, agent_count: int) -> None: + """Grow/shrink endpoint slots to match the snapshot without hard caps.""" + while len(self.provider_slots) < provider_count: + self.provider_slots.append(ctk.CTkFrame(self, height=76, fg_color="transparent")) + while len(self.agent_slots) < agent_count: + self.agent_slots.append(ctk.CTkFrame(self, height=68, fg_color="transparent")) + for index, slot in enumerate(self.provider_slots): + if index >= provider_count: + slot.place_forget() + continue + y = 0.02 + index * (0.78 / max(provider_count - 1, 1)) + slot.place(relx=0.012, rely=y, relwidth=0.30) + for index, slot in enumerate(self.agent_slots): + if index >= agent_count: + slot.place_forget() + continue + y = 0.01 + index * (0.80 / max(agent_count - 1, 1)) + slot.place(relx=0.71, rely=y, relwidth=0.278) + self._redraw() + def set_labels(self, left: list[str], right: list[str]) -> None: - self._left_labels = (left + ["", "", ""])[:3] - self._right_labels = (right + ["", "", "", "", ""])[:5] + self._left_labels = list(left) + self._right_labels = list(right) self._redraw() def _redraw(self, _event: Any = None) -> None: @@ -215,7 +252,10 @@ class _RouteDiagram(ctk.CTkFrame): self.canvas.delete("route") center_x, center_y = width * 0.51, height * 0.40 left_x, right_x = width * 0.312, width * 0.71 - left_ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)] + left_count = len(self._left_labels) + left_ys = [ + height * (0.02 + index * (0.78 / max(left_count - 1, 1))) + 38 for index in range(left_count) + ] for index, y_pos in enumerate(left_ys): self.canvas.create_line( left_x, @@ -241,7 +281,10 @@ class _RouteDiagram(ctk.CTkFrame): anchor="w", tags="route", ) - right_ys = [height * (0.01 + index * 0.185) + 34 for index in range(5)] + right_count = len(self._right_labels) + right_ys = [ + height * (0.01 + index * (0.80 / max(right_count - 1, 1))) + 34 for index in range(right_count) + ] for index, y_pos in enumerate(right_ys): self.canvas.create_line( center_x + 48, @@ -376,7 +419,6 @@ class DashboardView(ctk.CTkFrame): self.on_action = on_action self._provider_cards: Dict[str, _EndpointCard] = {} self._agent_cards: Dict[str, _EndpointCard] = {} - self._provider_status_rows: Dict[str, _StatusRow] = {} self._event_rows: list[_EventRow] = [] self._build() @@ -417,17 +459,25 @@ class DashboardView(ctk.CTkFrame): self.metrics = ctk.CTkFrame(self.scroll, fg_color="transparent") self.metrics.pack(fill="x", pady=(0, 8)) - for column in range(5): + for column in range(6): self.metrics.grid_columnconfigure(column, weight=1, uniform="kpi") self.quota_metric = _KpiCard(self.metrics, "Квота сегодня", "Н/Д", "нет измерения") self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н/Д", "активно: 0") self.agents_metric = _KpiCard(self.metrics, "Подключено аккаунтов", "0", "авторизованы") self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н/Д", "P50") self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н/Д", "failover") + self.host_metric = _KpiCard(self.metrics, "Нагрузка CPU", "Н/Д", "нет измерения хоста") for index, metric in enumerate( - (self.quota_metric, self.calls_metric, self.agents_metric, self.latency_metric, self.failover_metric) + ( + self.quota_metric, + self.calls_metric, + self.agents_metric, + self.latency_metric, + self.failover_metric, + self.host_metric, + ) ): - metric.grid(row=0, column=index, padx=(0 if index == 0 else 3, 0 if index == 4 else 3), sticky="nsew") + metric.grid(row=0, column=index, padx=(0 if index == 0 else 3, 0 if index == 5 else 3), sticky="nsew") body = ctk.CTkFrame(self.scroll, fg_color="transparent") body.pack(fill="x") @@ -441,46 +491,6 @@ class DashboardView(ctk.CTkFrame): self.route_diagram = _RouteDiagram(route_card) self.route_diagram.pack(fill="both", expand=True, padx=6, pady=(2, 6)) - # Metrics still exist as data probes but are intentionally not shown on - # Overview. The previous right rail duplicated Accounts/Status data - # and made the useful routing canvas unnecessarily narrow. - self.realtime = HubCard(self, height=1) - self.realtime.grid_propagate(False) - ctk.CTkLabel( - self.realtime, - text="Статус в реальном времени", - font=Theme.font_body_bold(), - text_color=Theme.TEXT_PRIMARY, - ).pack(anchor="w", padx=10, pady=(8, 5)) - ctk.CTkLabel(self.realtime, text="ПРОВАЙДЕРЫ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack( - anchor="w", padx=10 - ) - self.provider_status = ctk.CTkFrame(self.realtime, fg_color="transparent") - self.provider_status.pack(fill="x", padx=10, pady=(2, 5)) - ctk.CTkFrame(self.realtime, height=1, fg_color=Theme.BORDER_SUBTLE).pack(fill="x", padx=10, pady=3) - ctk.CTkLabel( - self.realtime, text="СИСТЕМНЫЕ ПОКАЗАТЕЛИ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED - ).pack(anchor="w", padx=10, pady=(2, 1)) - systems = ctk.CTkFrame(self.realtime, fg_color="transparent") - systems.pack(fill="x", padx=10) - self.system_rows = { - "cpu": _SystemRow(systems, "CPU"), - "memory": _SystemRow(systems, "Память"), - "disk": _SystemRow(systems, "Диск"), - "network": _SystemRow(systems, "Сеть"), - } - for row in self.system_rows.values(): - row.pack(fill="x") - ctk.CTkFrame(self.realtime, height=1, fg_color=Theme.BORDER_SUBTLE).pack(fill="x", padx=10, pady=4) - ctk.CTkLabel(self.realtime, text="ТЕКУЩАЯ НАГРУЗКА", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack( - anchor="w", padx=10 - ) - load = ctk.CTkFrame(self.realtime, fg_color="transparent") - load.pack(fill="x", padx=10, pady=(2, 0)) - self.load_rows = {key: _StatusRow(load) for key in ("active", "failovers", "errors")} - for row in self.load_rows.values(): - row.pack(fill="x") - self.events_card = HubCard(self.scroll) self.events_card.pack(fill="x", pady=(8, 0)) events_header = ctk.CTkFrame(self.events_card, fg_color="transparent") @@ -526,7 +536,8 @@ class DashboardView(ctk.CTkFrame): if bucket and bucket.remaining_percent is not None: remaining = float(bucket.remaining_percent) return bucket.formatted_remaining(), remaining - return agent.active_quota_label or "Нет данных API", None + reason = getattr(quota, "unavailable_reason", None) if quota else None + return reason or agent.active_quota_label or "Нет телеметрии: роль ещё не вызывалась", None @staticmethod def _sync_endpoint_cards( @@ -573,13 +584,17 @@ class DashboardView(ctk.CTkFrame): latency = global_telemetry.get("latency_p50_ms") if has_telemetry else None failovers = global_telemetry.get("failovers_count") if has_telemetry else None active_calls = snapshot.metrics.get("active_calls_total") + host = dict(snapshot.metrics.get("host") or {}) + cpu = host.get("cpu_percent") if host.get("has_data") else None quota_text, quota_percent = self._quota_measurement(snapshot) self.quota_metric.set_metric( quota_text, "реальная корзина" if quota_percent is not None else "нет измерения", quota_percent ) self.calls_metric.set_metric( str(total_calls) if total_calls is not None else "Н/Д", - f"активно: {active_calls}" if isinstance(active_calls, int) else "Н/Д", + f"активно: {active_calls}" + if isinstance(active_calls, int) + else "нет телеметрии: запросы ещё не выполнялись", float(total_calls) if total_calls is not None else None, ) self.agents_metric.set_metric( @@ -589,16 +604,23 @@ class DashboardView(ctk.CTkFrame): ) self.latency_metric.set_metric( f"{latency:.0f} мс" if latency is not None else "Н/Д", - "P50", + "P50" if latency is not None else "нет телеметрии: запросы ещё не выполнялись", float(latency) if latency is not None else None, ) self.failover_metric.set_metric( str(failovers) if failovers is not None else "Н/Д", - "failover", + "failover" if failovers is not None else "нет телеметрии переключений", float(failovers) if failovers is not None else None, ) + self.host_metric.set_metric( + f"{cpu:.0f}%" if cpu is not None else "Н/Д", + "host_measurement" if cpu is not None else "psutil не вернул измерение", + float(cpu) if cpu is not None else None, + ) - providers = list(snapshot.providers)[:3] + providers = list(snapshot.providers) + agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator] + self.route_diagram.sync_slots(len(providers), len(agents)) self._sync_endpoint_cards( self.route_diagram.provider_slots, self._provider_cards, @@ -615,7 +637,7 @@ class DashboardView(ctk.CTkFrame): for profile in snapshot.profiles_by_provider.get(provider.provider_id, []) if profile.preferred_models ), - "модель: Н/Д", + "модели не обнаружены", ) ), "Онлайн" if provider.online_count else "Недоступен", @@ -633,9 +655,8 @@ class DashboardView(ctk.CTkFrame): orchestrator.is_active, ) else: - self.route_diagram.orchestrator.update_node("Не назначен", "Н/Д", False) + self.route_diagram.orchestrator.update_node("Аккаунт не подключён", "Роль не настроена", False) - agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:5] agent_items = [] for agent in agents: agent_quota_text, agent_quota_percent = self._agent_quota_measurement(snapshot, agent) @@ -674,58 +695,14 @@ class DashboardView(ctk.CTkFrame): left_labels: list[str] = [] for provider in providers: share = dict(provider_telemetry.get(provider.provider_id) or {}).get("call_share") - left_labels.append(f"{share:.0%}" if share is not None else "") + left_labels.append(f"{share:.0%}" if share is not None else "нет телеметрии") right_labels: list[str] = [] for agent in agents: measured = dict(role_telemetry.get(agent.role_id) or {}) calls = measured.get("total_calls") if measured.get("has_data") else None - right_labels.append(f"{calls} выз." if calls is not None else "") + right_labels.append(f"{calls} выз." if calls is not None else "нет вызовов") self.route_diagram.set_labels(left_labels, right_labels) - live_provider_ids = {provider.provider_id for provider in providers} - for provider_id in list(self._provider_status_rows): - if provider_id not in live_provider_ids: - self._provider_status_rows.pop(provider_id).destroy() - for provider in providers: - row = self._provider_status_rows.get(provider.provider_id) - if row is None: - row = _StatusRow(self.provider_status) - row.pack(fill="x") - self._provider_status_rows[provider.provider_id] = row - provider_latency = dict(provider_telemetry.get(provider.provider_id) or {}).get("latency_p50_ms") - detail = f"{provider.online_count}/{provider.connected_count} онлайн" - if provider_latency is not None: - detail += f" • {provider_latency:.0f} мс" - row.update_row(provider.provider_name, detail, "healthy" if provider.online_count else "warning") - - host = dict(snapshot.metrics.get("host") or {}) - host_available = bool(host.get("has_data")) - cpu = host.get("cpu_percent") if host_available else None - memory = host.get("memory_percent") if host_available else None - disk = host.get("disk_percent") if host_available else None - network_total = sum( - value for value in (host.get("net_bytes_sent"), host.get("net_bytes_recv")) if isinstance(value, int) - ) - network_mb = network_total / (1024 * 1024) if network_total else None - self.system_rows["cpu"].update_metric(f"{cpu:.0f}%" if cpu is not None else "Н/Д", cpu) - self.system_rows["memory"].update_metric(f"{memory:.0f}%" if memory is not None else "Н/Д", memory) - self.system_rows["disk"].update_metric(f"{disk:.0f}%" if disk is not None else "Н/Д", disk) - self.system_rows["network"].update_metric( - f"{network_mb:.0f} МБ" if network_mb is not None else "Н/Д", network_mb - ) - error_rate = global_telemetry.get("error_rate") if has_telemetry else None - self.load_rows["active"].update_row( - "Активные вызовы", str(active_calls) if isinstance(active_calls, int) else "Н/Д", "healthy" - ) - self.load_rows["failovers"].update_row( - "Переключения", str(failovers) if failovers is not None else "Н/Д", "warning" - ) - self.load_rows["errors"].update_row( - "Доля ошибок", - f"{error_rate:.1%}" if error_rate is not None else "Н/Д", - "error" if error_rate else "healthy", - ) - def update_events(self, events: Iterable[Any]) -> None: items = list(events)[:5] while len(self._event_rows) < len(items): diff --git a/src/antigravity_provider/router/ui/views/providers_view.py b/src/antigravity_provider/router/ui/views/providers_view.py index 94d014f..67dca91 100644 --- a/src/antigravity_provider/router/ui/views/providers_view.py +++ b/src/antigravity_provider/router/ui/views/providers_view.py @@ -34,7 +34,7 @@ class ProviderCard(HubCard): 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.updated.configure(text=f"Обновлено: {summary.last_refresh_at or 'Н/Д — обнаружение ещё не запускалось'}") self.stats.configure( text=( f"Онлайн {summary.online_count}/{summary.connected_count} • " @@ -44,7 +44,12 @@ class ProviderCard(HubCard): ) ) self.models.configure( - text="Модели: " + (" • ".join(summary.discovered_models) if summary.discovered_models else "Н/Д") + text="Модели: " + + ( + " • ".join(summary.discovered_models) + if summary.discovered_models + else "Н/Д — список моделей ещё не получен" + ) ) diff --git a/src/antigravity_provider/router/ui/views/routing_view.py b/src/antigravity_provider/router/ui/views/routing_view.py index 83dcef3..1f5aa35 100644 --- a/src/antigravity_provider/router/ui/views/routing_view.py +++ b/src/antigravity_provider/router/ui/views/routing_view.py @@ -69,9 +69,9 @@ class RoutingRoleWidget(HubCard): reasons = [node.failover_reason for node in pipeline.nodes if node.failover_reason] self.footer.configure( text=( - f"Активен: {active.display_name} • причина: {'; '.join(reasons) if reasons else 'Н/Д'}" + f"Активен: {active.display_name} • причина: {'; '.join(reasons) if reasons else 'переключений ещё не было'}" if active - else "Активный узел: Н/Д" + else "Активный узел: Н/Д — аккаунт не подключён либо все профили недоступны" ) ) diff --git a/src/antigravity_provider/router/ui/views/team_view.py b/src/antigravity_provider/router/ui/views/team_view.py index 92129f3..dc75bc5 100644 --- a/src/antigravity_provider/router/ui/views/team_view.py +++ b/src/antigravity_provider/router/ui/views/team_view.py @@ -161,10 +161,19 @@ class AgentCardWidget(HubCard): ) self.menu_btn.pack(side="right") self._bind_settings_click(self) + try: + self._canvas.configure(takefocus=1) + except (AttributeError, tk.TclError): + pass + self.bind("", self._open_settings, add="+") + self.bind("", self._open_settings, add="+") + self.bind("", self._hover_on, add="+") + self.bind("", self._hover_off, add="+") def _bind_settings_click(self, widget: Any) -> None: if isinstance(widget, ctk.CTkButton): return + widget.configure(cursor="hand2") widget.bind("", self._open_settings, add="+") for child in widget.winfo_children(): self._bind_settings_click(child) @@ -182,6 +191,16 @@ class AgentCardWidget(HubCard): }, ) + def _hover_on(self, _event: Any = None) -> None: + self.configure(fg_color=Theme.SURFACE_HOVER, border_color=Theme.BORDER_HOVER) + + def _hover_off(self, _event: Any = None) -> None: + is_orchestrator = bool(self.agent_data and self.agent_data.is_main_orchestrator) + self.configure( + fg_color=Theme.SURFACE, + border_color=Theme.BORDER_ACCENT if is_orchestrator else Theme.BORDER, + ) + def update_agent(self, a: AgentViewModel): self.agent_data = a is_orch = a.is_main_orchestrator @@ -243,7 +262,7 @@ class AgentCardWidget(HubCard): else Theme.TEXT_MUTED ) ) - quota_label = a.active_quota_label or "Н/Д" + quota_label = a.active_quota_label or "Н/Д — провайдер не отдал лимиты" session = f" • сессия {a.session_id}" if a.session_id else "" self.quota_lbl.configure(text=f"Квота: {quota_label}{session}", text_color=quota_color) @@ -332,6 +351,7 @@ class TeamView(ctk.CTkFrame): self._drag_role = "" self._drag_origin = (0.0, 0.0) self._drag_node_origin = (0.0, 0.0) + self._drag_moved = False self._node_items: Dict[str, tuple[int, ...]] = {} self._edge_items: Dict[str, tuple[int, ...]] = {} self._event_bus = EventBus.get() @@ -491,6 +511,7 @@ class TeamView(ctk.CTkFrame): self.selected_role = role self.selected_edge = "" self._drag_role = role + self._drag_moved = False self._drag_origin = self._world(event.x, event.y) node = next((item for item in self.controller.graph.nodes if item.role_id == role), None) self._drag_node_origin = (node.x, node.y) if node else (0.0, 0.0) @@ -505,6 +526,8 @@ class TeamView(ctk.CTkFrame): return x, y = self._world(event.x, event.y) dx, dy = x - self._drag_origin[0], y - self._drag_origin[1] + if abs(dx) + abs(dy) > 1.5: + self._drag_moved = True self._drag_origin = (x, y) node.x += dx node.y += dy @@ -513,6 +536,7 @@ class TeamView(ctk.CTkFrame): def _on_release(self, _event: Any) -> None: if self._drag_role: + clicked_role = self._drag_role node = next((n for n in self.controller.graph.nodes if n.role_id == self._drag_role), None) if node: final_x, final_y = node.x, node.y @@ -520,6 +544,21 @@ class TeamView(ctk.CTkFrame): self.controller.move_node(node.role_id, final_x, final_y) self._drag_role = "" self._set_dirty_text() + if not self._drag_moved and self.on_action: + pipeline = self._pipeline_for(clicked_role) + active_profile = pipeline.active_profile_id if pipeline else "" + active_node = next( + (item for item in list(getattr(pipeline, "nodes", []) or []) if item.profile_id == active_profile), + None, + ) + self.on_action( + "agent_settings", + { + "role_id": clicked_role, + "profile_id": active_profile, + "provider": getattr(active_node, "provider", ""), + }, + ) def _on_zoom(self, event: Any) -> str: factor = 1.1 if event.delta > 0 else 0.9 @@ -668,7 +707,11 @@ class TeamView(ctk.CTkFrame): node = next((item for item in self.controller.graph.nodes if item.role_id == role), None) self.inspector_title.configure(text=node.label if node else role) self.inspector_status.configure( - text=f"Активный: {pipeline.active_profile_id if pipeline and pipeline.active_profile_id else 'Н/Д'}" + text=( + f"Активный: {pipeline.active_profile_id}" + if pipeline and pipeline.active_profile_id + else "Активный: Н/Д — профиль не назначен" + ) ) config = load_router_config() policy = config.roles.get(role) @@ -686,16 +729,20 @@ class TeamView(ctk.CTkFrame): ctk.CTkLabel( card, text=profile_id, font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY ).pack(anchor="w", padx=8) - provider = profile.provider if profile else "Н/Д" - model = live.model if live else (profile.preferred_models[0] if profile and profile.preferred_models else "Н/Д") + provider = profile.provider if profile else "Н/Д — аккаунт не подключён" + model = live.model if live else ( + profile.preferred_models[0] + if profile and profile.preferred_models + else "Н/Д — список моделей ещё не получен" + ) identity = live.account_identity if live and live.account_identity else "Аккаунт: Н/Д" - quota = live.quota_status if live else "Н/Д" + quota = live.quota_status if live else "Н/Д — аккаунт не подключён" if profile_id in self._quota_overrides: raw_quota = self._quota_overrides[profile_id] quota = getattr(raw_quota, "status", None) or getattr(raw_quota, "quota_status", None) or quota if str(quota).strip().lower() in {"", "unknown", "none", "not_configured"}: - quota = "Н/Д" - reason = live.failover_reason if live and live.failover_reason else "Н/Д" + quota = "Н/Д — провайдер не отдал лимиты" + reason = live.failover_reason if live and live.failover_reason else "переключений ещё не было" ctk.CTkLabel( card, text=f"{provider} • {model}\n{identity}\nКвота: {quota}\nFailover: {reason}", diff --git a/tests/test_ui_contract_v11.py b/tests/test_ui_contract_v11.py index 8cb88b3..dbe6f8e 100644 --- a/tests/test_ui_contract_v11.py +++ b/tests/test_ui_contract_v11.py @@ -335,7 +335,8 @@ def test_dashboard_makes_all_connected_accounts_visible_in_provider_summary(ui_r ui_root.update_idletasks() assert view.agents_metric.val_label.cget("text") == "6" assert "6 аккаунт" in view._provider_cards["antigravity"].subtitle.cget("text") - assert "6/6 онлайн" in view._provider_status_rows["antigravity"].detail.cget("text") + assert not hasattr(view, "realtime") + assert view.route_diagram.provider_slots[0].winfo_manager() == "place" finally: view.destroy() diff --git a/tests/test_ui_routing_graph.py b/tests/test_ui_routing_graph.py index 658cac4..7ac7398 100644 --- a/tests/test_ui_routing_graph.py +++ b/tests/test_ui_routing_graph.py @@ -38,10 +38,22 @@ def _config(): return SimpleNamespace(roles=roles, profiles=profiles) -def test_antigravity_agent_catalog_keeps_gemini_pro_choices(): - choices = app_module.AGENT_MODEL_OPTIONS["antigravity"] - assert "gemini-3.1-pro" in choices - assert "gemini-2.5-pro" in choices +def test_agent_catalog_uses_backend_cache_without_literal_fallback(monkeypatch): + from antigravity_provider.router.ui import model_catalog + + service = SimpleNamespace(get_cached=lambda _provider: {"models": ["provider-model-a", "provider-model-b"]}) + monkeypatch.setattr(model_catalog, "_service", lambda: service) + cached = model_catalog.get_cached_models("antigravity") + assert cached.models == ("provider-model-a", "provider-model-b") + + +def test_agent_catalog_empty_cache_is_explicit(monkeypatch): + from antigravity_provider.router.ui import model_catalog + + monkeypatch.setattr(model_catalog, "_service", lambda: None) + cached = model_catalog.get_cached_models("antigravity") + assert cached.models == () + assert "ещё не подключена" in cached.unavailable_reason def test_default_graph_migrates_six_roles_without_changing_chains():