hermes-hub/src/antigravity_provider/router/ui/views/team_view.py
2026-08-23 10:54:08 +07:00

996 lines
44 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Hermes Hub — Team View (Команда агентов и Dashboard с Unified Health v3)."""
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional
import tkinter as tk
import customtkinter as ctk
from antigravity_provider.router.ui.theme import Theme
from antigravity_provider.router.ui.assets import AssetManager
from antigravity_provider.router.ui.components import (
HubButton,
HubCard,
HubMetricCard,
HubSectionHeader,
HubStatusBadge,
)
from antigravity_provider.router.unified_health import (
AgentViewModel,
STATUS_HEALTHY,
STATUS_QUOTA_LOW,
STATUS_QUOTA_EXHAUSTED,
STATUS_AUTH_REQUIRED,
STATUS_NOT_CONFIGURED,
STATUS_AUTH_EXPIRED,
)
from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.event_bus import (
EVENT_ACCOUNT_ADDED,
EVENT_ACCOUNT_AUTH_CHANGED,
EVENT_ACCOUNT_REMOVED,
EVENT_ACCOUNT_UPDATED,
EVENT_QUOTA_UPDATED,
EVENT_ROUTING_UPDATED,
EventBus,
)
from antigravity_provider.router.router_config import load_router_config
from antigravity_provider.router.ui.routing_graph import EDGE_TYPES, GraphIssue, RoutingGraphController
def persist_role_chain(role_id: str, desired_chain: List[str]) -> tuple[bool, str]:
"""Persist one ordered chain using AutoAssigner while preserving other roles."""
config = load_router_config()
policy = config.roles.get(role_id)
if policy is None:
return False, f"Роль '{role_id}' не найдена"
if len(desired_chain) != len(set(desired_chain)):
return False, "Профиль не может повторяться в одной цепочке"
missing = [profile_id for profile_id in desired_chain if profile_id not in config.profiles]
if missing:
return False, f"Профиль '{missing[0]}' не найден"
original = {key: list(value.preferred_chain) for key, value in config.roles.items()}
removed = set(original[role_id]) - set(desired_chain)
affected = {role_id}
for profile_id in removed:
affected.update(key for key, chain in original.items() if profile_id in chain)
ok, message = AutoAssigner.assign_profile_to_role(profile_id, "spare", is_primary=False)
if not ok:
return False, message
chains = {key: original[key] for key in affected}
chains[role_id] = list(desired_chain)
for target_role, chain in chains.items():
for profile_id in reversed(chain):
ok, message = AutoAssigner.assign_profile_to_role(profile_id, target_role, is_primary=True)
if not ok:
return False, message
return True, f"Цепочка '{role_id}' сохранена"
class AgentCardWidget(HubCard):
"""Reusable agent card that updates in-place without rebuilding widgets."""
def __init__(self, master: Any, on_action: Optional[Callable] = None, **kwargs):
super().__init__(master=master, border_color=Theme.BORDER, fg_color=Theme.SURFACE, **kwargs)
self.on_action = on_action
self.agent_data: Optional[AgentViewModel] = None
# ── Line 1: Role Title + Badges + Status Dot ──
self.line1 = ctk.CTkFrame(self, fg_color="transparent")
self.line1.pack(fill="x", padx=14, pady=(12, 2))
self.role_lbl = ctk.CTkLabel(self.line1, text="", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.role_lbl.pack(side="left")
self.orch_pill = ctk.CTkFrame(self.line1, fg_color=Theme.ACCENT_DIM, corner_radius=Theme.RADIUS_SM)
self.orch_pill_lbl = ctk.CTkLabel(
self.orch_pill, text="👑 ЛИДЕР РОУТЕРА", font=Theme.font_micro(), text_color=Theme.ACCENT
)
self.orch_pill_lbl.pack(padx=5, pady=1)
self.status_dot = ctk.CTkLabel(
self.line1, text="", font=("Segoe UI", 13, "bold"), text_color=Theme.STATUS_HEALTHY
)
self.status_dot.pack(side="right")
# ── Line 2: Internal ID + Model ──
self.line2 = ctk.CTkFrame(self, fg_color="transparent")
self.line2.pack(fill="x", padx=14, pady=(0, 4))
self.id_model_lbl = ctk.CTkLabel(self.line2, text="", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED)
self.id_model_lbl.pack(anchor="w")
# ── Line 3: Provider with Real Logo ──
self.line3 = ctk.CTkFrame(self, fg_color="transparent")
self.line3.pack(fill="x", padx=14, pady=(2, 2))
self.prov_icon = ctk.CTkLabel(self.line3, text="")
self.prov_icon.pack(side="left", padx=(0, 6))
self.prov_lbl = ctk.CTkLabel(self.line3, text="", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
self.prov_lbl.pack(side="left")
# ── Line 4: Identity / Account ──
self.line4 = ctk.CTkFrame(self, fg_color="transparent")
self.line4.pack(fill="x", padx=14, pady=(2, 4))
self.identity_lbl = ctk.CTkLabel(
self.line4, text="", font=Theme.font_mono_sm(), text_color=Theme.TEXT_SECONDARY
)
self.identity_lbl.pack(anchor="w")
self.quota_lbl = ctk.CTkLabel(
self.line4, text="Квота: Н", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
)
self.quota_lbl.pack(anchor="w", pady=(Theme.SPACE_XS, 0))
# ── Line 5: Role Tag Pills ──
self.line5 = ctk.CTkFrame(self, fg_color="transparent")
self.line5.pack(fill="x", padx=14, pady=(4, 6))
self.pill1 = ctk.CTkFrame(self.line5, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
self.pill1.pack(side="left", padx=(0, 6))
self.pill1_lbl = ctk.CTkLabel(self.pill1, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.pill1_lbl.pack(padx=6, pady=2)
self.pill2 = ctk.CTkFrame(self.line5, fg_color=Theme.SURFACE_MUTED, corner_radius=Theme.RADIUS_SM)
self.pill2.pack(side="left", padx=(0, 6))
self.pill2_lbl = ctk.CTkLabel(self.pill2, text="Primary", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.pill2_lbl.pack(padx=6, pady=2)
# ── Line 6: Status & Menu Action ──
self.line6 = ctk.CTkFrame(self, fg_color="transparent")
self.line6.pack(fill="x", padx=14, pady=(4, 12))
self.status_str_lbl = ctk.CTkLabel(
self.line6, text="● Работает", font=Theme.font_caption(), text_color=Theme.STATUS_HEALTHY
)
self.status_str_lbl.pack(side="left")
self.menu_btn = ctk.CTkButton(
self.line6,
text="",
width=28,
height=24,
fg_color="transparent",
hover_color=Theme.SURFACE_HOVER,
text_color=Theme.TEXT_SECONDARY,
font=("Segoe UI", 12, "bold"),
command=self._open_menu,
)
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)
def _open_settings(self, _event: Any = None) -> None:
if not self.agent_data or not self.on_action:
return
agent = self.agent_data
self.on_action(
"agent_settings",
{
"role_id": agent.role_id,
"profile_id": agent.assigned_profile_id or "",
"provider": agent.provider,
},
)
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
self.configure(border_color=Theme.BORDER_ACCENT if is_orch else Theme.BORDER)
self.role_lbl.configure(text=a.role_name_ru)
if is_orch:
self.orch_pill.pack(side="left", padx=(8, 0))
else:
self.orch_pill.pack_forget()
# Dot color
dot_color = (
Theme.STATUS_HEALTHY
if a.status == STATUS_HEALTHY
else (
Theme.STATUS_WARNING
if "quota" in a.status or "auth" in a.status or "not_configured" in a.status
else Theme.STATUS_ERROR
)
)
self.status_dot.configure(text_color=dot_color)
# ID + Model
self.id_model_lbl.configure(text=f"{a.assigned_profile_id}{a.model}")
# Provider Icon + Label
p_img = AssetManager.get().get_provider_image(a.provider, size=(18, 18))
if p_img:
self.prov_icon.configure(image=p_img)
self.prov_icon.pack(side="left", padx=(0, 6))
else:
self.prov_icon.pack_forget()
prov = a.provider.lower()
if "antigravity" in prov:
self.prov_lbl.configure(text="Google Antigravity", text_color=Theme.PROVIDER_ANTIGRAVITY)
elif "codex" in prov:
self.prov_lbl.configure(text="OpenAI Codex", text_color=Theme.PROVIDER_CODEX)
elif "opencode" in prov:
self.prov_lbl.configure(text="OpenCode Go", text_color=Theme.PROVIDER_OPENCODE)
elif "claude" in prov or "anthropic" in prov:
self.prov_lbl.configure(text="Claude", text_color=Theme.PROVIDER_CLAUDE)
elif "grok" in prov or "xai" in prov:
self.prov_lbl.configure(text="Grok", text_color=Theme.PROVIDER_GROK)
else:
self.prov_lbl.configure(text=a.provider_display_name, text_color=Theme.TEXT_MUTED)
self.identity_lbl.configure(text=a.account_identity or "Аккаунт: Н")
quota_color = (
Theme.STATUS_HEALTHY
if a.active_quota_status == "healthy"
else (
Theme.STATUS_WARNING
if a.active_quota_status == "warning"
else Theme.STATUS_ERROR
if a.active_quota_status == "exhausted"
else Theme.TEXT_MUTED
)
)
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)
# Pills
self.pill1_lbl.configure(text=a.role_id)
self.pill2_lbl.configure(text=a.routing_position)
# Status text
cd_str = f" ({a.cooldown_remaining_sec}s)" if a.cooldown_remaining_sec > 0 else ""
self.status_str_lbl.configure(text=f"{a.status_label_ru.upper()}{cd_str}", text_color=dot_color)
def _open_menu(self):
if not self.agent_data:
return
a = self.agent_data
pid = a.assigned_profile_id or ""
popup = ctk.CTkToplevel(self.winfo_toplevel())
popup.title(f"Действия: {a.role_name_ru}")
popup.geometry("320x260")
popup.configure(fg_color=Theme.DARK)
popup.resizable(False, False)
popup.transient(self.winfo_toplevel())
popup.grab_set()
popup.update_idletasks()
px = self.winfo_toplevel().winfo_x() + 300
py = self.winfo_toplevel().winfo_y() + 200
popup.geometry(f"+{px}+{py}")
c = HubCard(popup, fg_color=Theme.DARK, border_color=Theme.BORDER_ACCENT)
c.pack(fill="both", expand=True, padx=10, pady=10)
ctk.CTkLabel(c, text=a.role_name_ru, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY).pack(pady=(8, 2))
ctk.CTkLabel(
c, text=f"Аккаунт: {a.account_identity} ({pid})", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED
).pack(pady=(0, 10))
def _do(action_name: str):
popup.destroy()
if self.on_action:
self.on_action(
action_name, {"profile_id": pid, "provider": a.provider, "display_name": a.assigned_display_name}
)
HubButton(c, text="⚡ Проверить (Тест)", variant="secondary", height=30, command=lambda: _do("test")).pack(
fill="x", padx=12, pady=2
)
HubButton(
c,
text="👑 Назначить главным оркестратором",
variant="secondary",
height=30,
command=lambda: _do("set_orchestrator"),
).pack(fill="x", padx=12, pady=2)
HubButton(
c,
text="★ Сделать основным аккаунтом Hermes",
variant="secondary",
height=30,
command=lambda: _do("set_main"),
).pack(fill="x", padx=12, pady=2)
HubButton(
c, text="🔄 Перераспределить роли", variant="ghost", height=28, command=lambda: _do("auto_assign_all")
).pack(fill="x", padx=12, pady=(2, 0))
class TeamView(ctk.CTkFrame):
"""Interactive canvas over the existing router role/profile chains."""
NODE_W = 218
NODE_H = 104
def __init__(
self, master: Any, app_state: Optional[Dict[str, Any]] = None, on_action: Optional[Callable] = None, **kwargs
):
super().__init__(master=master, fg_color="transparent", **kwargs)
self.app_state = app_state or {}
self.on_action = on_action
self.controller = RoutingGraphController()
self.snapshot: Optional[HubSnapshot] = None
self._live_pipelines: Dict[str, Any] = {}
self._quota_overrides: Dict[str, Any] = {}
self.selected_role = "orchestrator"
self.selected_edge = ""
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()
self._subscribed = False
self._build_static_layout()
self._subscribe_runtime_events()
self._draw_graph(rebuild=True)
self.after(20, self._restore_viewport)
def destroy(self):
if self._subscribed:
for name in self._runtime_events():
self._event_bus.unsubscribe(name, self._on_runtime_event)
self._subscribed = False
super().destroy()
@staticmethod
def _runtime_events() -> tuple[str, ...]:
return (
EVENT_ROUTING_UPDATED,
EVENT_QUOTA_UPDATED,
EVENT_ACCOUNT_ADDED,
EVENT_ACCOUNT_UPDATED,
EVENT_ACCOUNT_REMOVED,
EVENT_ACCOUNT_AUTH_CHANGED,
)
def _subscribe_runtime_events(self) -> None:
if self._subscribed:
return
for name in self._runtime_events():
self._event_bus.subscribe(name, self._on_runtime_event)
self._subscribed = True
def _build_static_layout(self) -> None:
header = ctk.CTkFrame(self, fg_color="transparent")
header.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(12, 8))
titles = ctk.CTkFrame(header, fg_color="transparent")
titles.pack(side="left")
ctk.CTkLabel(titles, text="Граф маршрутизации", font=Theme.font_title_page(), text_color=Theme.TEXT_PRIMARY).pack(
anchor="w"
)
self.state_label = ctk.CTkLabel(
titles, text="Роли и реальные failover-цепочки", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED
)
self.state_label.pack(anchor="w")
actions = ctk.CTkFrame(header, fg_color="transparent")
actions.pack(side="right")
for text, command in (
("", self._undo),
("", self._redo),
("Авто", self._auto_layout),
("Вписать", self.fit_to_screen),
("Сохранить", self._save),
):
HubButton(actions, text=text, variant="primary" if text == "Сохранить" else "secondary", command=command).pack(
side="left", padx=3
)
toolbar = ctk.CTkFrame(self, fg_color=Theme.SURFACE, corner_radius=Theme.RADIUS_SM)
toolbar.pack(fill="x", padx=Theme.PAGE_PAD_X, pady=(0, 7))
self.search = ctk.CTkEntry(toolbar, placeholder_text="Найти роль или профиль…", width=250)
self.search.pack(side="left", padx=8, pady=6)
self.search.bind("<Return>", self._search)
ctk.CTkLabel(toolbar, text="Связь", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED).pack(
side="left", padx=(8, 4)
)
self.edge_type = ctk.CTkOptionMenu(toolbar, values=list(EDGE_TYPES), width=112)
self.edge_type.set("DELEGATE")
self.edge_type.pack(side="left", pady=6)
roles = [node.role_id for node in self.controller.graph.nodes] or ["orchestrator"]
self.edge_target = ctk.CTkOptionMenu(toolbar, values=roles, width=135)
self.edge_target.set(next((role for role in roles if role != self.selected_role), roles[0]))
self.edge_target.pack(side="left", padx=(5, 0), pady=6)
profile_ids = list(load_router_config().profiles) or [""]
self.edge_profile = ctk.CTkOptionMenu(toolbar, values=["", *profile_ids], width=140)
self.edge_profile.set("")
self.edge_profile.pack(side="left", padx=(5, 0), pady=6)
HubButton(toolbar, text="Соединить", variant="secondary", command=self._connect_selected).pack(
side="left", padx=5
)
HubButton(toolbar, text="Изменить", variant="secondary", command=self._change_selected_edge).pack(
side="left", padx=(0, 5)
)
self.zoom_label = ctk.CTkLabel(toolbar, text="100%", font=Theme.font_caption(), text_color=Theme.TEXT_SECONDARY)
self.zoom_label.pack(side="right", padx=10)
body = ctk.CTkFrame(self, fg_color="transparent")
body.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(0, Theme.PAGE_PAD_Y))
body.grid_rowconfigure(0, weight=1)
body.grid_columnconfigure(0, weight=4)
body.grid_columnconfigure(1, weight=1)
canvas_frame = ctk.CTkFrame(body, fg_color=Theme.SURFACE_MUTED, border_width=1, border_color=Theme.BORDER)
canvas_frame.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
self.canvas = tk.Canvas(
canvas_frame,
bg=Theme.SURFACE_MUTED,
highlightthickness=0,
xscrollincrement=1,
yscrollincrement=1,
scrollregion=(0, 0, 1800, 1200),
)
self.canvas.pack(fill="both", expand=True)
self.canvas.bind("<ButtonPress-1>", self._on_press)
self.canvas.bind("<B1-Motion>", self._on_drag)
self.canvas.bind("<ButtonRelease-1>", self._on_release)
self.canvas.bind("<MouseWheel>", self._on_zoom)
self.canvas.bind("<ButtonPress-2>", self._start_pan)
self.canvas.bind("<B2-Motion>", self._pan)
self.canvas.bind("<ButtonRelease-2>", self._end_pan)
self.canvas.bind("<Control-z>", lambda _e: self._undo())
self.canvas.bind("<Control-y>", lambda _e: self._redo())
self.canvas.bind("<Delete>", self._delete_selected_edge)
self.canvas.focus_set()
self.minimap = tk.Canvas(canvas_frame, width=155, height=95, bg=Theme.SURFACE, highlightthickness=1)
self.minimap.place(relx=1.0, rely=1.0, x=-12, y=-12, anchor="se")
self.inspector = ctk.CTkFrame(body, fg_color=Theme.SURFACE, border_width=1, border_color=Theme.BORDER)
self.inspector.grid(row=0, column=1, sticky="nsew")
self.inspector_title = ctk.CTkLabel(
self.inspector, text="Инспектор роли", font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY
)
self.inspector_title.pack(anchor="w", padx=12, pady=(12, 2))
self.inspector_status = ctk.CTkLabel(
self.inspector, text="", justify="left", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_MUTED
)
self.inspector_status.pack(fill="x", padx=12, pady=(0, 8))
self.chain_frame = ctk.CTkScrollableFrame(self.inspector, fg_color="transparent")
self.chain_frame.pack(fill="both", expand=True, padx=8)
HubButton(
self.inspector,
text="Открыть маршрутизацию",
variant="primary",
command=lambda: self._trigger_action("open_routing", {"role_id": self.selected_role}),
).pack(fill="x", padx=10, pady=10)
def _world(self, x: float, y: float) -> tuple[float, float]:
zoom = self.controller.graph.zoom
return self.canvas.canvasx(x) / zoom, self.canvas.canvasy(y) / zoom
def _on_press(self, event: Any) -> None:
item = self.canvas.find_closest(self.canvas.canvasx(event.x), self.canvas.canvasy(event.y))
tags = self.canvas.gettags(item)
edge = next((tag[5:] for tag in tags if tag.startswith("edge:")), "")
if edge:
self.selected_edge = edge
selected = next((item for item in self.controller.graph.edges if item.edge_id == edge), None)
if selected:
self.edge_type.set(selected.edge_type)
self.edge_target.set(selected.target)
self.edge_profile.set(selected.profile_id or "")
self._draw_graph(rebuild=True)
return
role = next((tag[5:] for tag in tags if tag.startswith("role:")), "")
if role:
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)
self._update_inspector()
self._update_live_styles()
def _on_drag(self, event: Any) -> None:
if not self._drag_role:
return
node = next((n for n in self.controller.graph.nodes if n.role_id == self._drag_role), None)
if not node:
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
self.controller.dirty = True
self._draw_graph(rebuild=True)
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
node.x, node.y = self._drag_node_origin
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
self.controller.graph.zoom = max(0.45, min(1.8, self.controller.graph.zoom * factor))
self.controller.dirty = True
self._draw_graph(rebuild=True)
self._set_dirty_text()
return "break"
def _start_pan(self, event: Any) -> None:
self.canvas.scan_mark(event.x, event.y)
def _pan(self, event: Any) -> None:
self.canvas.scan_dragto(event.x, event.y, gain=1)
def _end_pan(self, _event: Any) -> None:
zoom = self.controller.graph.zoom
self.controller.graph.viewport_x = self.canvas.canvasx(0) / zoom
self.controller.graph.viewport_y = self.canvas.canvasy(0) / zoom
self.controller.dirty = True
self._set_dirty_text()
def _restore_viewport(self) -> None:
graph = self.controller.graph
z = graph.zoom
self.canvas.xview_moveto(max(0.0, graph.viewport_x * z / 1800.0))
self.canvas.yview_moveto(max(0.0, graph.viewport_y * z / 1200.0))
def _node_coords(self, role_id: str) -> tuple[float, float, float, float]:
node = next(item for item in self.controller.graph.nodes if item.role_id == role_id)
z = self.controller.graph.zoom
return node.x * z, node.y * z, (node.x + self.NODE_W) * z, (node.y + self.NODE_H) * z
def _draw_graph(self, rebuild: bool = True) -> None:
if rebuild:
self.canvas.delete("all")
self._node_items.clear()
self._edge_items.clear()
z = self.controller.graph.zoom
for edge in self.controller.graph.edges:
try:
sx1, sy1, sx2, sy2 = self._node_coords(edge.source)
tx1, ty1, _tx2, ty2 = self._node_coords(edge.target)
except StopIteration:
continue
start = (sx2, (sy1 + sy2) / 2)
end = (tx1, (ty1 + ty2) / 2)
dash = () if edge.edge_type == "PRIMARY" else (7, 4) if edge.edge_type == "FALLBACK" else (2, 4)
width = 3 if edge.edge_type == "PRIMARY" else 2
line = self.canvas.create_line(
*start,
*end,
smooth=True,
arrow="last",
width=width,
dash=dash,
fill=Theme.ACCENT if edge.edge_id == self.selected_edge else Theme.TEXT_ACCENT,
tags=(f"edge:{edge.edge_id}", "edge"),
)
label = self.canvas.create_text(
(start[0] + end[0]) / 2,
(start[1] + end[1]) / 2 - 9,
text=edge.edge_type,
fill=Theme.TEXT_MUTED,
font=("Segoe UI", max(7, int(8 * z)), "bold"),
tags=(f"edge:{edge.edge_id}", "edge"),
)
self._edge_items[edge.edge_id] = (line, label)
config = load_router_config()
for node in self.controller.graph.nodes:
x1, y1, x2, y2 = self._node_coords(node.role_id)
pipeline = self._pipeline_for(node.role_id)
active = pipeline.active_profile_id if pipeline else ""
chain = list(config.roles.get(node.role_id).preferred_chain) if node.role_id in config.roles else []
selected = node.role_id == self.selected_role
rect = self.canvas.create_rectangle(
x1,
y1,
x2,
y2,
width=2 if selected else 1,
outline=Theme.ACCENT if selected else Theme.BORDER,
fill=Theme.SURFACE,
tags=(f"role:{node.role_id}", "node"),
)
title = self.canvas.create_text(
x1 + 12 * z,
y1 + 17 * z,
anchor="w",
text=node.label or node.role_id,
fill=Theme.TEXT_PRIMARY,
font=("Segoe UI", max(9, int(11 * z)), "bold"),
tags=(f"role:{node.role_id}", "node"),
)
active_text = f"Активен: {active}" if active else "Активный профиль: Н"
meta = self.canvas.create_text(
x1 + 12 * z,
y1 + 43 * z,
anchor="w",
text=active_text,
fill=Theme.STATUS_HEALTHY if active else Theme.TEXT_MUTED,
font=("Segoe UI", max(7, int(8 * z))),
tags=(f"role:{node.role_id}", "node"),
)
chain_text = "".join(chain[:3]) if chain else "Нет профилей"
chain_item = self.canvas.create_text(
x1 + 12 * z,
y1 + 68 * z,
anchor="w",
width=(self.NODE_W - 24) * z,
text=chain_text,
fill=Theme.TEXT_SECONDARY,
font=("Segoe UI", max(7, int(8 * z))),
tags=(f"role:{node.role_id}", "node"),
)
self._node_items[node.role_id] = (rect, title, meta, chain_item)
self.zoom_label.configure(text=f"{self.controller.graph.zoom * 100:.0f}%")
bounds = self.canvas.bbox("all")
if bounds:
self.canvas.configure(scrollregion=(0, 0, max(1800, bounds[2] + 120), max(1200, bounds[3] + 120)))
self._draw_minimap()
self._update_inspector()
def _draw_minimap(self) -> None:
self.minimap.delete("all")
if not self.controller.graph.nodes:
return
max_x = max(node.x for node in self.controller.graph.nodes) + self.NODE_W
max_y = max(node.y for node in self.controller.graph.nodes) + self.NODE_H
scale = min(145 / max(max_x, 1), 85 / max(max_y, 1))
for node in self.controller.graph.nodes:
self.minimap.create_rectangle(
5 + node.x * scale,
5 + node.y * scale,
5 + (node.x + self.NODE_W) * scale,
5 + (node.y + self.NODE_H) * scale,
outline=Theme.ACCENT if node.role_id == self.selected_role else Theme.BORDER,
fill=Theme.SURFACE_MUTED,
)
def _update_inspector(self) -> None:
for child in self.chain_frame.winfo_children():
child.destroy()
role = self.selected_role
pipeline = self._pipeline_for(role)
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 "Активный: Н/Д — профиль не назначен"
)
)
config = load_router_config()
policy = config.roles.get(role)
chain = list(policy.preferred_chain) if policy else []
live_nodes = {item.profile_id: item for item in pipeline.nodes} if pipeline else {}
for index, profile_id in enumerate(chain):
profile = config.profiles.get(profile_id)
live = live_nodes.get(profile_id)
card = HubCard(self.chain_frame)
card.pack(fill="x", pady=4)
rank = "PRIMARY" if index == 0 else f"FALLBACK {index}"
ctk.CTkLabel(card, text=rank, font=Theme.font_micro(), text_color=Theme.TEXT_ACCENT).pack(
anchor="w", padx=8, pady=(6, 0)
)
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 "Н/Д — список моделей ещё не получен"
)
identity = live.account_identity if live and live.account_identity 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 "переключений ещё не было"
ctk.CTkLabel(
card,
text=f"{provider}{model}\n{identity}\nКвота: {quota}\nFailover: {reason}",
justify="left",
anchor="w",
font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY,
).pack(fill="x", padx=8, pady=(2, 7))
controls = ctk.CTkFrame(card, fg_color="transparent")
controls.pack(fill="x", padx=6, pady=(0, 6))
HubButton(
controls,
text="",
variant="secondary",
width=34,
height=26,
command=lambda pid=profile_id: self._move_chain_profile(pid, -1),
).pack(side="left", padx=2)
HubButton(
controls,
text="",
variant="secondary",
width=34,
height=26,
command=lambda pid=profile_id: self._move_chain_profile(pid, 1),
).pack(side="left", padx=2)
HubButton(
controls,
text="Удалить",
variant="ghost",
width=76,
height=26,
command=lambda pid=profile_id: self._remove_chain_profile(pid),
).pack(side="right", padx=2)
if not chain:
ctk.CTkLabel(
self.chain_frame, text="Профили не назначены", font=Theme.font_caption(), text_color=Theme.STATUS_WARNING
).pack(anchor="w", padx=6, pady=8)
available = [profile_id for profile_id in config.profiles if profile_id not in chain]
add_row = ctk.CTkFrame(self.chain_frame, fg_color="transparent")
add_row.pack(fill="x", pady=(8, 2))
add_menu = ctk.CTkOptionMenu(
add_row,
values=available or [""],
font=Theme.font_caption(),
fg_color=Theme.SURFACE_MUTED,
button_color=Theme.SECONDARY,
button_hover_color=Theme.SURFACE_HOVER,
text_color=Theme.TEXT_PRIMARY,
)
add_menu.pack(side="left", fill="x", expand=True, padx=(0, 4))
HubButton(
add_row,
text="Добавить",
variant="secondary",
width=88,
command=lambda: self._add_chain_profile(add_menu.get()),
state="normal" if available else "disabled",
).pack(side="right")
def _persist_selected_chain(self, chain: List[str]) -> None:
ok, message = persist_role_chain(self.selected_role, chain)
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
if ok:
self._update_inspector()
def _move_chain_profile(self, profile_id: str, direction: int) -> None:
chain = list(self.controller.role_chain(self.selected_role))
if profile_id not in chain:
return
source = chain.index(profile_id)
target = source + direction
if target < 0 or target >= len(chain):
self.state_label.configure(text="Профиль уже на границе цепочки", text_color=Theme.STATUS_WARNING)
return
chain[source], chain[target] = chain[target], chain[source]
self._persist_selected_chain(chain)
def _remove_chain_profile(self, profile_id: str) -> None:
chain = [item for item in self.controller.role_chain(self.selected_role) if item != profile_id]
self._persist_selected_chain(chain)
def _add_chain_profile(self, profile_id: str) -> None:
if not profile_id or profile_id == "":
return
chain = list(self.controller.role_chain(self.selected_role))
if profile_id not in chain:
chain.append(profile_id)
self._persist_selected_chain(chain)
def _on_runtime_event(self, name: str, data: Any) -> None:
# EventBus may publish from a worker. Tk mutation is always marshalled.
try:
self.after(0, lambda: self._apply_runtime_event(name, data))
except Exception:
pass
def _apply_runtime_event(self, name: str, data: Any) -> None:
payload = data if isinstance(data, dict) else {}
if name == EVENT_ROUTING_UPDATED and payload.get("pipeline") is not None:
self._live_pipelines[str(payload.get("role_id", ""))] = payload["pipeline"]
elif name == EVENT_QUOTA_UPDATED and payload.get("profile_id"):
self._quota_overrides[str(payload["profile_id"])] = payload.get("quota_snapshot") or payload.get("snapshot")
self._update_live_styles()
def _pipeline_for(self, role_id: str) -> Any:
if role_id in self._live_pipelines:
return self._live_pipelines[role_id]
return self.snapshot.routing.get(role_id) if self.snapshot else None
def _update_live_styles(self) -> None:
"""Update existing canvas items; runtime events never rebuild the canvas."""
if not self.snapshot:
return
for role_id, items in self._node_items.items():
pipeline = self._pipeline_for(role_id)
active = pipeline.active_profile_id if pipeline else ""
self.canvas.itemconfigure(items[0], outline=Theme.ACCENT if role_id == self.selected_role else Theme.BORDER)
self.canvas.itemconfigure(items[2], text=f"Активен: {active}" if active else "Активный профиль: Н")
self._update_inspector()
def update_data(self, snapshot: Optional[Any] = None) -> None:
if not isinstance(snapshot, HubSnapshot):
return
self.snapshot = snapshot
self._live_pipelines = {
role_id: pipeline
for role_id, pipeline in self._live_pipelines.items()
if role_id in snapshot.routing and pipeline is not snapshot.routing[role_id]
}
if set(snapshot.routing) != set(self._node_items):
self._draw_graph(rebuild=True)
else:
self._update_live_styles()
def _search(self, _event: Any = None) -> str:
query = self.search.get().strip().lower()
config = load_router_config()
for node in self.controller.graph.nodes:
chain = config.roles.get(node.role_id).preferred_chain if node.role_id in config.roles else []
if query in node.role_id.lower() or query in node.label.lower() or any(query in item.lower() for item in chain):
self.selected_role = node.role_id
self._draw_graph(rebuild=True)
break
return "break"
def focus_role(self, role_id: str) -> None:
"""Select a role when Routing delegates editing to this single editor."""
if not any(node.role_id == role_id for node in self.controller.graph.nodes):
self.state_label.configure(text=f"Роль {role_id} не найдена", text_color=Theme.STATUS_WARNING)
return
self.selected_role = role_id
self.selected_edge = ""
target = next((node.role_id for node in self.controller.graph.nodes if node.role_id != role_id), role_id)
self.edge_target.set(target)
self._draw_graph(rebuild=True)
self.state_label.configure(text=f"Редактируется цепочка: {role_id}", text_color=Theme.TEXT_ACCENT)
def _connect_selected(self) -> None:
if len(self.controller.graph.nodes) < 2:
return
source = self.selected_role
target = self.edge_target.get()
profile_id = self.edge_profile.get()
profile_id = "" if profile_id == "" else profile_id
ok, message = self.controller.add_edge(source, target, self.edge_type.get(), profile_id)
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _change_selected_edge(self) -> None:
if not self.selected_edge:
self.state_label.configure(text="Сначала выберите связь на графе", text_color=Theme.STATUS_WARNING)
return
profile_id = self.edge_profile.get()
ok, message = self.controller.set_edge_type(
self.selected_edge,
self.edge_type.get(),
"" if profile_id == "" else profile_id,
)
self.state_label.configure(text=message, text_color=Theme.STATUS_HEALTHY if ok else Theme.STATUS_ERROR)
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _delete_selected_edge(self, _event: Any = None) -> str:
selected = self.canvas.find_withtag("current")
edge_id = self.selected_edge
if selected:
edge_id = next((tag[5:] for tag in self.canvas.gettags(selected[0]) if tag.startswith("edge:")), "")
if edge_id:
self.controller.delete_edge(edge_id)
self.selected_edge = ""
self._draw_graph(rebuild=True)
self._set_dirty_text()
return "break"
def _auto_layout(self) -> None:
self.controller.auto_layout()
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _undo(self) -> None:
if self.controller.undo():
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _redo(self) -> None:
if self.controller.redo():
self._draw_graph(rebuild=True)
self._set_dirty_text()
def fit_to_screen(self) -> None:
if not self.controller.graph.nodes:
return
self.update_idletasks()
max_x = max(node.x for node in self.controller.graph.nodes) + self.NODE_W
max_y = max(node.y for node in self.controller.graph.nodes) + self.NODE_H
self.controller.graph.zoom = max(0.45, min(1.4, min(self.canvas.winfo_width() / max_x, self.canvas.winfo_height() / max_y) * 0.9))
self.controller.dirty = True
self._draw_graph(rebuild=True)
self._set_dirty_text()
def _save(self) -> None:
zoom = self.controller.graph.zoom
self.controller.graph.viewport_x = self.canvas.canvasx(0) / zoom
self.controller.graph.viewport_y = self.canvas.canvasy(0) / zoom
issues = self.controller.save()
if issues:
self._show_issues(issues)
return
self.state_label.configure(text="Сохранено • позиции и масштаб переживут перезапуск", text_color=Theme.STATUS_HEALTHY)
def _show_issues(self, issues: List[GraphIssue]) -> None:
bad_nodes = {issue.node_id for issue in issues if issue.node_id}
for role_id, items in self._node_items.items():
self.canvas.itemconfigure(items[0], outline=Theme.STATUS_ERROR if role_id in bad_nodes else Theme.BORDER)
message = "".join(issue.message for issue in issues[:3])
if len(issues) > 3:
message += f" • ещё {len(issues) - 3}"
self.state_label.configure(text=message, text_color=Theme.STATUS_ERROR)
def _set_dirty_text(self) -> None:
self.state_label.configure(
text="● Есть несохранённые изменения" if self.controller.dirty else "Роли и реальные failover-цепочки",
text_color=Theme.STATUS_WARNING if self.controller.dirty else Theme.TEXT_MUTED,
)
def _trigger_action(self, action: str, profile: Dict[str, Any]) -> None:
if self.on_action:
self.on_action(action, profile)