feat: add cockpit-style model and card controls
This commit is contained in:
parent
6494c5198a
commit
7468c7cc8b
9 changed files with 575 additions and 175 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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("<Return>", self._open_details, add="+")
|
||||
self.bind("<space>", self._open_details, add="+")
|
||||
self.bind("<Enter>", self._hover_on, add="+")
|
||||
self.bind("<Leave>", self._hover_off, add="+")
|
||||
|
||||
def _bind_details_click(self, widget: Any) -> None:
|
||||
if isinstance(widget, ctk.CTkButton):
|
||||
return
|
||||
widget.configure(cursor="hand2")
|
||||
widget.bind("<Button-1>", 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
|
||||
|
|
|
|||
99
src/antigravity_provider/router/ui/model_catalog.py
Normal file
99
src/antigravity_provider/router/ui/model_catalog.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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("<Return>", self._activate, add="+")
|
||||
self.bind("<space>", self._activate, add="+")
|
||||
self.bind("<Enter>", self._hover_on, add="+")
|
||||
self.bind("<Leave>", self._hover_off, add="+")
|
||||
|
||||
def _bind_click_tree(self, widget: Any) -> None:
|
||||
widget.configure(cursor="hand2")
|
||||
widget.bind("<Button-1>", 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("<Configure>", 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):
|
||||
|
|
|
|||
|
|
@ -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 "Н/Д — список моделей ещё не получен"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 "Активный узел: Н/Д — аккаунт не подключён либо все профили недоступны"
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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("<Return>", self._open_settings, add="+")
|
||||
self.bind("<space>", self._open_settings, add="+")
|
||||
self.bind("<Enter>", self._hover_on, add="+")
|
||||
self.bind("<Leave>", self._hover_off, add="+")
|
||||
|
||||
def _bind_settings_click(self, widget: Any) -> None:
|
||||
if isinstance(widget, ctk.CTkButton):
|
||||
return
|
||||
widget.configure(cursor="hand2")
|
||||
widget.bind("<Button-1>", 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}",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
Loading…
Reference in a new issue