fix(ui): align dashboard and navigation with approved mockup

This commit is contained in:
Hermes Team 2026-08-21 19:32:53 +07:00
parent e4843b5a39
commit 312f699085
39 changed files with 639 additions and 271 deletions

View file

@ -15,6 +15,8 @@ FINAL_SHA: `git rev-parse codex/mockup-redesign` в момент handoff. Точ
- Выбор темы сохраняется в `hub_settings.json`; приложение применяет его при следующем запуске. - Выбор темы сохраняется в `hub_settings.json`; приложение применяет его при следующем запуске.
- Общий каркас соответствует макету: брендовая левая панель, прокручиваемая навигация, пользователь и версия снизу, глобальная верхняя строка с состоянием, `Ctrl + K`, добавлением аккаунта и служебными действиями. - Общий каркас соответствует макету: брендовая левая панель, прокручиваемая навигация, пользователь и версия снизу, глобальная верхняя строка с состоянием, `Ctrl + K`, добавлением аккаунта и служебными действиями.
- Dashboard: пять компактных KPI, провайдеры → оркестратор → роли, правая панель состояния, реальные события снизу. - Dashboard: пять компактных KPI, провайдеры → оркестратор → роли, правая панель состояния, реальные события снизу.
- Второй визуальный проход заменил прямоугольный центр круглым брендированным оркестратором, добавил плавные связи со стрелками и реальными подписями, provider logos, quota-bars, sparklines и табличную ленту событий.
- Подключён утверждённый пользователем `logo_approved.png`; боковое меню и кнопки header используют единый рисуемый контурный набор иконок вместо разнородных Unicode-глифов.
- После обновления контракта подключены реальные доли/число вызовов и P50 по провайдерам, вызовы по ролям, active calls и локальные CPU/RAM/disk/network. - После обновления контракта подключены реальные доли/число вызовов и P50 по провайдерам, вызовы по ролям, active calls и локальные CPU/RAM/disk/network.
- Добавлены отдельные вкладки «Квоты и лимиты» и «Аналитика» на данных `HubSnapshot`/`TelemetryService`. - Добавлены отдельные вкладки «Квоты и лимиты» и «Аналитика» на данных `HubSnapshot`/`TelemetryService`.
- Журнал получает реальные события через action layer приложения, поддерживает поиск и фильтр уровня. - Журнал получает реальные события через action layer приложения, поддерживает поиск и фильтр уровня.
@ -29,6 +31,7 @@ FINAL_SHA: `git rev-parse codex/mockup-redesign` в момент handoff. Точ
- `src/antigravity_provider/router/ui/components.py` - `src/antigravity_provider/router/ui/components.py`
- `src/antigravity_provider/router/ui/assets.py` - `src/antigravity_provider/router/ui/assets.py`
- `src/antigravity_provider/router/ui/add_account_wizard.py` - `src/antigravity_provider/router/ui/add_account_wizard.py`
- `assets/branding/logo/logo_approved.png`
- `src/antigravity_provider/router/ui/views/dashboard_view.py` - `src/antigravity_provider/router/ui/views/dashboard_view.py`
- `src/antigravity_provider/router/ui/views/logs_view.py` - `src/antigravity_provider/router/ui/views/logs_view.py`
- `src/antigravity_provider/router/ui/views/settings_view.py` - `src/antigravity_provider/router/ui/views/settings_view.py`
@ -82,7 +85,7 @@ scale 1.50: search_right=781, add_right=971, header_width=995
| CPU, память, диск, сеть | Показываются реальные локальные измерения `psutil`; при недоступном `psutil``Н/Д`. | | CPU, память, диск, сеть | Показываются реальные локальные измерения `psutil`; при недоступном `psutil``Н/Д`. |
| Очереди задач | Блок исключён: подсистемы очередей в продукте нет. | | Очереди задач | Блок исключён: подсистемы очередей в продукте нет. |
| Раздел «Инциденты» | Не создан: вместо него используется реальный журнал с фильтром ошибок. | | Раздел «Инциденты» | Не создан: вместо него используется реальный журнал с фильтром ошибок. |
| Кривые соединения | В native CustomTkinter используются адаптивные направленные связи между тремя колонками; данные и порядок цепочки сохранены без canvas-зависимости. | | Кривые соединения | Реализованы адаптивные сглаженные линии со стрелками и подписями реальной доли/числа вызовов; при отсутствии telemetry показывается `Н/Д`. |
| Версия `2.8.1` на макете | Показывается реальная версия пакета `0.1.1`. | | Версия `2.8.1` на макете | Показывается реальная версия пакета `0.1.1`. |
## Проверки ## Проверки
@ -105,7 +108,7 @@ uv run --extra dev python -m pytest -q tests/test_ui_phase2_6.py
uv run --extra dev python -m pytest -q tests/test_oauth_lifecycle.py::test_f_copy_before_open_browser uv run --extra dev python -m pytest -q tests/test_oauth_lifecycle.py::test_f_copy_before_open_browser
``` ```
Результаты: `243 passed, 2 skipped, 4 deselected`; `4 passed`; `1 passed`. Итог уникального набора: `248 passed, 2 skipped, 3 deselected`, ошибок нет. Результаты: `244 passed, 2 skipped, 4 deselected`; `4 passed`; `1 passed`. Итог уникального набора: `249 passed, 2 skipped, 3 deselected`, ошибок нет.
### Статика и release gate ### Статика и release gate

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 MiB

View file

@ -234,13 +234,16 @@ class HermesHubApp(ctk.CTk):
brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent") brand_container = ctk.CTkFrame(self.sidebar, fg_color="transparent")
brand_container.pack(fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_LG, Theme.SPACE_SM)) brand_container.pack(fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_LG, Theme.SPACE_SM))
logo_img = AssetManager.get().get_logo_image(size=(58, 58)) logo_img = AssetManager.get().get_logo_image(size=(78, 78))
if logo_img: if logo_img:
logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="") logo_lbl = ctk.CTkLabel(brand_container, image=logo_img, text="")
logo_lbl.pack(anchor="center", pady=(0, 6)) logo_lbl.pack(anchor="center", pady=(0, 6))
ctk.CTkLabel( ctk.CTkLabel(
brand_container, text="HERMES HUB", font=Theme.font_title_hero(), text_color=Theme.TEXT_ACCENT brand_container,
text="HERMES HUB",
font=(Theme.FONT_FAMILY_TITLE, 17, "bold"),
text_color=Theme.TEXT_ACCENT,
).pack(anchor="center") ).pack(anchor="center")
ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER_ACCENT).pack( ctk.CTkFrame(self.sidebar, height=1, fg_color=Theme.BORDER_ACCENT).pack(
fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_XS, Theme.SPACE_SM) fill="x", padx=Theme.SPACE_MD, pady=(Theme.SPACE_XS, Theme.SPACE_SM)
@ -248,17 +251,17 @@ class HermesHubApp(ctk.CTk):
# Nav Items with clean Fluent glyphs # Nav Items with clean Fluent glyphs
self._nav_items = [ self._nav_items = [
("overview", "Обзор", ""), ("overview", "Обзор", "overview"),
("team", "Команда", ""), ("team", "Команда", "team"),
("accounts", "Аккаунты", ""), ("accounts", "Аккаунты", "accounts"),
("routing", "Маршрутизация", ""), ("routing", "Маршрутизация", "routing"),
("providers", "Провайдеры", ""), ("providers", "Провайдеры", "providers"),
("quotas", "Квоты и лимиты", ""), ("quotas", "Квоты и лимиты", "quotas"),
("analytics", "Аналитика", ""), ("analytics", "Аналитика", "analytics"),
("health", "Состояние", ""), ("health", "Состояние", "health"),
("logs", "Журнал событий", ""), ("logs", "Журнал событий", "logs"),
("settings", "Настройки", ""), ("settings", "Настройки", "settings"),
("about", "О программе", ""), ("about", "О программе", "about"),
] ]
self.nav_frame = ctk.CTkScrollableFrame( self.nav_frame = ctk.CTkScrollableFrame(
@ -272,9 +275,12 @@ class HermesHubApp(ctk.CTk):
self.nav_frame.pack(fill="both", expand=True) self.nav_frame.pack(fill="both", expand=True)
self._nav_buttons: Dict[str, ctk.CTkButton] = {} self._nav_buttons: Dict[str, ctk.CTkButton] = {}
for key, label, icon in self._nav_items: for key, label, icon in self._nav_items:
icon_image = AssetManager.get().get_nav_icon(icon, size=19)
btn = ctk.CTkButton( btn = ctk.CTkButton(
self.nav_frame, self.nav_frame,
text=f" {icon} {label}", text=label,
image=icon_image,
compound="left",
font=Theme.font_body(), font=Theme.font_body(),
height=Theme.HEIGHT_NAV_ITEM, height=Theme.HEIGHT_NAV_ITEM,
fg_color="transparent", fg_color="transparent",
@ -346,25 +352,26 @@ class HermesHubApp(ctk.CTk):
self.global_search.bind("<Return>", self._run_global_search) self.global_search.bind("<Return>", self._run_global_search)
self.bind_all("<Control-k>", self._focus_global_search) self.bind_all("<Control-k>", self._focus_global_search)
for icon_name, command in (
("settings", lambda: self._show_view("settings")),
("about", lambda: self._show_view("about")),
("logs", lambda: self._show_view("logs")),
):
HubButton(
self.statusbar,
text="",
image=AssetManager.get().get_nav_icon(icon_name, size=18),
variant="ghost",
width=Theme.HEIGHT_BTN_MD,
command=command,
).pack(side="right", padx=Theme.SPACE_XS)
self.add_account_button = HubButton( self.add_account_button = HubButton(
self.statusbar, self.statusbar,
text="+ Добавить аккаунт", text="+ Добавить аккаунт",
variant="primary", variant="primary",
command=lambda: self._handle_action("add_account", {}), command=lambda: self._handle_action("add_account", {}),
) )
self.add_account_button.pack(side="right", padx=(Theme.SPACE_XS, Theme.SPACE_LG)) self.add_account_button.pack(side="right", padx=(Theme.SPACE_XS, Theme.SPACE_MD))
for text, command in (
("", lambda: self._show_view("settings")),
("?", lambda: self._show_view("about")),
("", lambda: self._show_view("logs")),
):
HubButton(
self.statusbar,
text=text,
variant="ghost",
width=Theme.HEIGHT_BTN_MD,
command=command,
).pack(side="right", padx=Theme.SPACE_XS)
self.status_right = ctk.CTkLabel( self.status_right = ctk.CTkLabel(
self.statusbar, self.statusbar,
@ -719,6 +726,9 @@ class HermesHubApp(ctk.CTk):
self.configure(fg_color=Theme.BG_WINDOW) self.configure(fg_color=Theme.BG_WINDOW)
self._build_layout() self._build_layout()
self._show_view(current_view) self._show_view(current_view)
from antigravity_provider.router.state_store import HubStateStore
self._on_data_loaded(HubStateStore.get().get_snapshot())
def _run_in_thread(self, func, on_success=None, on_error=None): def _run_in_thread(self, func, on_success=None, on_error=None):
def _worker(): def _worker():

View file

@ -7,9 +7,10 @@ from pathlib import Path
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
try: try:
from PIL import Image from PIL import Image, ImageDraw
except ImportError: except ImportError:
Image = None Image = None
ImageDraw = None
try: try:
import customtkinter as ctk import customtkinter as ctk
@ -64,7 +65,9 @@ class AssetManager:
cls._pil_cache.clear() cls._pil_cache.clear()
def get_logo_image(self, size: Tuple[int, int] = (120, 120)) -> Optional[ctk.CTkImage]: def get_logo_image(self, size: Tuple[int, int] = (120, 120)) -> Optional[ctk.CTkImage]:
logo_path = self.logo_dir / "logo_256.png" logo_path = self.logo_dir / "logo_approved.png"
if not logo_path.exists():
logo_path = self.logo_dir / "logo_256.png"
if not logo_path.exists(): if not logo_path.exists():
logo_path = self.logo_dir / "logo_master.png" logo_path = self.logo_dir / "logo_master.png"
if not logo_path.exists(): if not logo_path.exists():
@ -77,13 +80,18 @@ class AssetManager:
transparent = pil_img.copy() transparent = pil_img.copy()
pixels = [] pixels = []
gold = tuple(int(Theme.ACCENT[index : index + 2], 16) for index in (1, 3, 5))
tint_gold = Theme.current_scheme in {"dark", "hybrid"} tint_gold = Theme.current_scheme in {"dark", "hybrid"}
for red, green, blue, alpha in transparent.getdata(): dark_fill = tuple(int(Theme.BG_SIDEBAR[index : index + 2], 16) for index in (1, 3, 5))
if red > 238 and green > 238 and blue > 232: pixel_data = (
transparent.get_flattened_data()
if hasattr(transparent, "get_flattened_data")
else transparent.getdata()
)
for red, green, blue, alpha in pixel_data:
if red > 232 and green > 230 and blue > 224:
pixels.append((red, green, blue, 0)) pixels.append((red, green, blue, 0))
elif tint_gold: elif tint_gold and max(red, green, blue) < 105:
pixels.append((*gold, alpha)) pixels.append((*dark_fill, alpha))
else: else:
pixels.append((red, green, blue, alpha)) pixels.append((red, green, blue, alpha))
transparent.putdata(pixels) transparent.putdata(pixels)
@ -123,3 +131,104 @@ class AssetManager:
except Exception: except Exception:
return None return None
return None return None
def get_nav_icon(self, name: str, size: int = 20) -> Optional[ctk.CTkImage]:
"""Return a consistent gold outline icon for the sidebar."""
if Image is None or ImageDraw is None:
return None
from antigravity_provider.router.ui.theme import Theme
cache_key = f"nav_{Theme.current_scheme}_{name}_{size}"
if cache_key in self._image_cache:
return self._image_cache[cache_key]
scale = 4
canvas_size = size * scale
image = Image.new("RGBA", (canvas_size, canvas_size), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
color = Theme.TEXT_ACCENT
width = 2 * scale
def points(values):
return tuple(tuple(int(coord * scale) for coord in point) for point in values)
def line(values, **kwargs):
draw.line(points(values), fill=color, width=width, joint="curve", **kwargs)
def ellipse(box):
draw.ellipse(tuple(int(value * scale) for value in box), outline=color, width=width)
def rectangle(box, radius=0):
scaled = tuple(int(value * scale) for value in box)
if radius:
draw.rounded_rectangle(scaled, radius=radius * scale, outline=color, width=width)
else:
draw.rectangle(scaled, outline=color, width=width)
if name == "overview":
line(((3, 10), (10, 4), (17, 10)))
line(((5, 9), (5, 17), (15, 17), (15, 9)))
line(((8, 17), (8, 12), (12, 12), (12, 17)))
elif name == "team":
ellipse((3, 4, 9, 10))
ellipse((11, 5, 16, 10))
draw.arc((1 * scale, 9 * scale, 12 * scale, 19 * scale), 190, 350, fill=color, width=width)
draw.arc((8 * scale, 10 * scale, 19 * scale, 19 * scale), 190, 350, fill=color, width=width)
elif name == "accounts":
draw.ellipse((3 * scale, 3 * scale, 17 * scale, 8 * scale), outline=color, width=width)
line(((3, 5.5), (3, 15), (5, 17), (15, 17), (17, 15), (17, 5.5)))
draw.arc((3 * scale, 8 * scale, 17 * scale, 13 * scale), 0, 180, fill=color, width=width)
elif name == "routing":
ellipse((2, 8, 6, 12))
ellipse((8, 2, 12, 6))
ellipse((14, 8, 18, 12))
ellipse((8, 14, 12, 18))
line(((6, 10), (14, 10)))
line(((10, 6), (10, 14)))
elif name == "orchestrator":
ellipse((6, 6, 14, 14))
for box in ((2, 2, 5, 5), (15, 2, 18, 5), (2, 15, 5, 18), (15, 15, 18, 18)):
ellipse(box)
line(((5, 5), (7, 7)))
line(((15, 5), (13, 7)))
line(((5, 15), (7, 13)))
line(((15, 15), (13, 13)))
elif name == "providers":
draw.arc((2 * scale, 7 * scale, 18 * scale, 17 * scale), 175, 365, fill=color, width=width)
draw.arc((5 * scale, 2 * scale, 14 * scale, 13 * scale), 190, 350, fill=color, width=width)
line(((4, 16), (16, 16)))
elif name == "quotas":
line(((10, 2), (17, 5), (16, 13), (10, 18), (4, 13), (3, 5), (10, 2)))
line(((7, 10), (9, 12), (13, 7)))
elif name == "analytics":
rectangle((3, 11, 6, 17))
rectangle((8.5, 7, 11.5, 17))
rectangle((14, 3, 17, 17))
line(((2, 18), (18, 18)))
elif name == "health":
line(((2, 11), (6, 11), (8, 5), (11, 16), (13, 9), (18, 9)))
elif name == "logs":
rectangle((4, 2, 16, 18), radius=1)
line(((7, 7), (13, 7)))
line(((7, 11), (13, 11)))
line(((7, 15), (11, 15)))
elif name == "incidents":
line(((10, 2), (18, 17), (2, 17), (10, 2)))
line(((10, 7), (10, 12)))
ellipse((9.3, 14, 10.7, 15.4))
elif name == "settings":
ellipse((7, 7, 13, 13))
for angle in range(0, 360, 45):
import math
start = (10 + 5 * math.cos(math.radians(angle)), 10 + 5 * math.sin(math.radians(angle)))
end = (10 + 8 * math.cos(math.radians(angle)), 10 + 8 * math.sin(math.radians(angle)))
line((start, end))
else:
ellipse((3, 3, 17, 17))
line(((10, 8), (10, 15)))
ellipse((9.3, 5, 10.7, 6.4))
image = image.resize((size, size), Image.Resampling.LANCZOS)
result = ctk.CTkImage(light_image=image, dark_image=image, size=(size, size))
self._image_cache[cache_key] = result
return result

View file

@ -1,103 +1,329 @@
"""Approved Hermes Hub overview layout backed only by contract data.""" """Approved Hermes Hub overview, visually aligned with the B5 reference."""
from __future__ import annotations from __future__ import annotations
import tkinter as tk
from typing import Any, Callable, Dict, Iterable, Optional from typing import Any, Callable, Dict, Iterable, Optional
import customtkinter as ctk import customtkinter as ctk
from antigravity_provider.router.state_store import HubSnapshot from antigravity_provider.router.state_store import HubSnapshot
from antigravity_provider.router.ui.assets import AssetManager
from antigravity_provider.router.ui.components import HubCard from antigravity_provider.router.ui.components import HubCard
from antigravity_provider.router.ui.theme import Theme from antigravity_provider.router.ui.theme import Theme
class _FlowCard(HubCard): class _Sparkline(ctk.CTkFrame):
def __init__(self, master: Any, **kwargs): """Tiny chart built only from values observed during this UI session."""
kwargs.setdefault("corner_radius", Theme.RADIUS_MD)
kwargs.setdefault("border_color", Theme.BORDER)
kwargs.setdefault("height", 76)
super().__init__(master, **kwargs)
self.pack_propagate(False)
self.title = ctk.CTkLabel(self, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(anchor="w", padx=Theme.SPACE_SM, pady=(Theme.SPACE_SM, 0))
self.subtitle = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.subtitle.pack(anchor="w", padx=Theme.SPACE_SM, pady=2)
self.meta = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.meta.pack(anchor="w", padx=Theme.SPACE_SM, pady=(0, Theme.SPACE_SM))
def update_card(self, title: str, subtitle: str, meta: str, status: str = "unknown") -> None: def __init__(self, master: Any, width: int = 58, height: int = 18):
self.title.configure(text=title) super().__init__(master, width=width, height=height, fg_color="transparent")
self.subtitle.configure(text=subtitle) self.pack_propagate(False)
self.meta.configure(text=meta) self._width, self._height = width, height
colors = { self._values: list[float] = []
"healthy": Theme.STATUS_HEALTHY, self.canvas = tk.Canvas(self, width=width, height=height, bg=Theme.SURFACE, highlightthickness=0, bd=0)
"warning": Theme.STATUS_WARNING, self.canvas.pack(fill="both", expand=True)
"error": Theme.STATUS_ERROR,
"active": Theme.BORDER_ACCENT, def update_value(self, value: Optional[float]) -> None:
} if value is not None:
self.configure(border_color=colors.get(status, Theme.BORDER)) self._values.append(float(value))
self._values = self._values[-18:]
self.canvas.delete("all")
if not self._values:
self.canvas.create_line(3, self._height - 4, self._width - 3, self._height - 4, fill=Theme.BORDER_SUBTLE)
return
values = self._values if len(self._values) > 1 else [self._values[0], self._values[0]]
low, high = min(values), max(values)
span = max(high - low, max(abs(high), 1.0) * 0.08)
points: list[float] = []
for index, current in enumerate(values):
points.extend(
(
3 + index * (self._width - 6) / max(len(values) - 1, 1),
self._height - 3 - ((current - low) / span) * (self._height - 7),
)
)
self.canvas.create_line(*points, fill=Theme.STATUS_HEALTHY, width=1.4, smooth=True)
class _KpiCard(HubCard): class _KpiCard(HubCard):
def __init__(self, master: Any, title: str, value: str, subtext: str, icon: str): def __init__(self, master: Any, title: str, value: str, subtext: str):
super().__init__(master, corner_radius=Theme.RADIUS_SM) super().__init__(master, corner_radius=Theme.RADIUS_SM, height=62)
header = ctk.CTkFrame(self, fg_color="transparent") self.grid_propagate(False)
header.pack(fill="x", padx=Theme.SPACE_SM, pady=(Theme.SPACE_SM, 0)) self.grid_columnconfigure(0, weight=1)
ctk.CTkLabel(header, text=icon, font=Theme.font_micro(), text_color=Theme.TEXT_ACCENT).pack(side="left") ctk.CTkLabel(self, text=title, font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY).grid(
ctk.CTkLabel(header, text=title.upper(), font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack( row=0, column=0, columnspan=3, sticky="w", padx=10, pady=(7, 0)
side="left", padx=Theme.SPACE_XS )
self.val_label = ctk.CTkLabel(self, text=value, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.val_label.grid(row=1, column=0, sticky="w", padx=10, pady=(0, 6))
self.sub_label = ctk.CTkLabel(self, text=subtext, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.sub_label.grid(row=1, column=1, sticky="e", padx=(2, 5), pady=(0, 6))
self.spark = _Sparkline(self, width=48, height=16)
self.spark.grid(row=1, column=2, sticky="e", padx=(0, 7), pady=(0, 5))
def set_metric(self, value: str, subtext: str, numeric: Optional[float] = None) -> None:
self.val_label.configure(text=value)
self.sub_label.configure(text=subtext)
self.spark.update_value(numeric)
class _EndpointCard(HubCard):
"""Compact provider/agent card with brand icon and a real quota bar."""
def __init__(self, master: Any):
super().__init__(master, corner_radius=Theme.RADIUS_MD, height=76)
self.pack_propagate(False)
self.icon = ctk.CTkLabel(self, text="", width=38, font=Theme.font_heading(), text_color=Theme.TEXT_ACCENT)
self.icon.pack(side="left", padx=(8, 5))
text = ctk.CTkFrame(self, fg_color="transparent")
text.pack(side="left", fill="both", expand=True, pady=3)
self.title = ctk.CTkLabel(text, text="", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(anchor="w")
self.subtitle = ctk.CTkLabel(text, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.subtitle.pack(anchor="w", pady=(0, 1))
self.status = ctk.CTkLabel(text, text="", font=Theme.font_micro(), text_color=Theme.STATUS_HEALTHY)
self.status.pack(anchor="w")
quota = ctk.CTkFrame(self, fg_color="transparent", width=58)
quota.pack(side="right", fill="y", padx=(3, 8), pady=8)
quota.pack_propagate(False)
self.quota_label = ctk.CTkLabel(quota, text="Н", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.quota_label.pack(anchor="e")
self.progress = ctk.CTkProgressBar(
quota, height=4, corner_radius=2, progress_color=Theme.STATUS_HEALTHY, fg_color=Theme.SURFACE_MUTED
)
self.progress.pack(fill="x", pady=(5, 0))
self.progress.set(0)
def update_card(
self,
provider_key: str,
title: str,
subtitle: str,
status_text: str,
quota_text: str,
quota_percent: Optional[float],
status: str,
) -> None:
image = AssetManager.get().get_provider_image(provider_key, size=(30, 30))
self.icon.configure(image=image, text="" if image else "")
self.icon.image = image
self.title.configure(text=title)
self.subtitle.configure(text=subtitle)
self.status.configure(text=f"{status_text}")
self.quota_label.configure(text=quota_text)
color = {
"healthy": Theme.STATUS_HEALTHY,
"warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR,
}.get(status, Theme.STATUS_DISABLED)
self.status.configure(text_color=color)
self.configure(border_color=Theme.BORDER_ACCENT if status == "healthy" else Theme.BORDER)
self.progress.configure(progress_color=color)
self.progress.set(max(0.0, min(1.0, quota_percent / 100.0)) if quota_percent is not None else 0)
class _OrchestratorNode(ctk.CTkFrame):
def __init__(self, master: Any):
super().__init__(master, width=174, height=150, fg_color="transparent")
self.pack_propagate(False)
self.circle = ctk.CTkFrame(
self,
width=96,
height=96,
corner_radius=48,
fg_color=Theme.BG_SIDEBAR if Theme.current_scheme != "light" else Theme.SURFACE,
border_width=2,
border_color=Theme.BORDER_ACCENT,
)
self.circle.pack(pady=(0, 3))
self.circle.pack_propagate(False)
logo = AssetManager.get().get_logo_image(size=(76, 76))
self.logo = ctk.CTkLabel(
self.circle, image=logo, text="H" if logo is None else "", text_color=Theme.TEXT_ACCENT
)
self.logo.image = logo
self.logo.place(relx=0.5, rely=0.5, anchor="center")
ctk.CTkLabel(
self, text="Главный оркестратор", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
).pack()
self.subtitle = ctk.CTkLabel(self, text="Не назначен", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.subtitle.pack()
self.status = ctk.CTkLabel(self, text="Н", font=Theme.font_micro(), text_color=Theme.STATUS_WARNING)
self.status.pack()
def update_node(self, subtitle: str, status_text: str, active: bool) -> None:
self.subtitle.configure(text=subtitle)
self.status.configure(
text=f"{status_text}", text_color=Theme.STATUS_HEALTHY if active else Theme.STATUS_WARNING
)
class _RouteDiagram(ctk.CTkFrame):
"""Responsive diagram with smooth connections behind native widgets."""
def __init__(self, master: Any):
super().__init__(master, height=330, fg_color="transparent")
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=76, fg_color="transparent") for _ in range(3)]
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.02 + index * 0.29, relwidth=0.278)
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)
self.context.place(relx=0.51, rely=0.91, relwidth=0.25, anchor="center")
ctk.CTkLabel(
self.context, text="▤ Хранилище контекста", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY
).pack(pady=(6, 0))
self.context_status = ctk.CTkLabel(
self.context, text="● Состояние: Н", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
)
self.context_status.pack()
self._left_labels = ["", "", ""]
self._right_labels = ["", "", ""]
self.bind("<Configure>", self._redraw)
def set_labels(self, left: list[str], right: list[str]) -> None:
self._left_labels = (left + ["", "", ""])[:3]
self._right_labels = (right + ["", "", ""])[:3]
self._redraw()
def _redraw(self, _event: Any = None) -> None:
width, height = max(self.winfo_width(), 600), max(self.winfo_height(), 300)
self.canvas.delete("route")
center_x, center_y = width * 0.51, height * 0.40
left_x, right_x = width * 0.312, width * 0.71
ys = [height * (0.02 + index * 0.29) + 38 for index in range(3)]
for index, y_pos in enumerate(ys):
self.canvas.create_line(
left_x,
y_pos,
left_x + 44,
y_pos,
center_x - 78,
center_y,
center_x - 48,
center_y,
fill=Theme.BORDER_ACCENT,
width=1.35,
smooth=True,
arrow=tk.LAST,
tags="route",
)
self.canvas.create_text(
left_x + 50,
y_pos - 8,
text=self._left_labels[index],
fill=Theme.TEXT_SECONDARY,
font=(Theme.FONT_FAMILY_UI, 8),
anchor="w",
tags="route",
)
self.canvas.create_line(
center_x + 48,
center_y,
right_x - 34,
y_pos,
right_x,
y_pos,
fill=Theme.BORDER_ACCENT,
width=1.35,
smooth=True,
arrow=tk.LAST,
tags="route",
)
self.canvas.create_text(
right_x - 39,
y_pos - 8,
text=self._right_labels[index],
fill=Theme.TEXT_SECONDARY,
font=(Theme.FONT_FAMILY_UI, 8),
anchor="e",
tags="route",
)
self.canvas.create_line(
center_x,
center_y + 48,
center_x,
height * 0.83,
fill=Theme.BORDER_ACCENT,
width=1.2,
dash=(3, 3),
arrow=tk.LAST,
tags="route",
) )
value_row = ctk.CTkFrame(self, fg_color="transparent")
value_row.pack(fill="x", padx=Theme.SPACE_SM, pady=(0, Theme.SPACE_SM))
self.val_label = ctk.CTkLabel(value_row, text=value, font=Theme.font_heading(), text_color=Theme.TEXT_PRIMARY)
self.val_label.pack(side="left")
self.sub_label = ctk.CTkLabel(value_row, text=subtext, font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.sub_label.pack(side="right")
class _StatusRow(ctk.CTkFrame): class _StatusRow(ctk.CTkFrame):
def __init__(self, master: Any): def __init__(self, master: Any):
super().__init__(master, fg_color="transparent") super().__init__(master, fg_color="transparent", height=24)
self.dot = ctk.CTkLabel(self, text="", width=14, font=Theme.font_micro()) self.pack_propagate(False)
self.dot = ctk.CTkLabel(self, text="", width=12, font=Theme.font_micro())
self.dot.pack(side="left") self.dot.pack(side="left")
self.title = ctk.CTkLabel(self, text="", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY) self.title = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY)
self.title.pack(side="left", padx=Theme.SPACE_XS) self.title.pack(side="left", padx=4)
self.detail = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED) self.detail = ctk.CTkLabel(self, text="", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.detail.pack(side="right") self.detail.pack(side="right")
def update_row(self, title: str, detail: str, status: str) -> None: def update_row(self, title: str, detail: str, status: str) -> None:
self.title.configure(text=title) self.title.configure(text=title)
self.detail.configure(text=detail) self.detail.configure(text=detail)
color = ( self.dot.configure(
Theme.STATUS_HEALTHY text_color={
if status == "healthy" "healthy": Theme.STATUS_HEALTHY,
else Theme.STATUS_WARNING "warning": Theme.STATUS_WARNING,
if status == "warning" "error": Theme.STATUS_ERROR,
else Theme.STATUS_ERROR }.get(status, Theme.STATUS_DISABLED)
if status == "error"
else Theme.STATUS_DISABLED
) )
self.dot.configure(text_color=color)
class _SystemRow(ctk.CTkFrame):
def __init__(self, master: Any, title: str):
super().__init__(master, fg_color="transparent", height=25)
self.pack_propagate(False)
ctk.CTkLabel(
self, text=title, width=47, anchor="w", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY
).pack(side="left")
self.value = ctk.CTkLabel(
self, text="Н", width=48, anchor="e", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY
)
self.value.pack(side="left")
self.spark = _Sparkline(self, width=54, height=16)
self.spark.pack(side="right")
def update_metric(self, text: str, numeric: Optional[float]) -> None:
self.value.configure(text=text)
self.spark.update_value(numeric)
class _EventRow(ctk.CTkFrame): class _EventRow(ctk.CTkFrame):
def __init__(self, master: Any): def __init__(self, master: Any):
super().__init__(master, fg_color="transparent") super().__init__(master, fg_color="transparent", height=23)
self.time = ctk.CTkLabel(self, width=58, text="", font=Theme.font_mono_sm(), text_color=Theme.TEXT_MUTED) self.pack_propagate(False)
self.time = ctk.CTkLabel(self, width=58, text="", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED)
self.time.pack(side="left") self.time.pack(side="left")
self.dot = ctk.CTkLabel(self, width=18, text="", font=Theme.font_micro()) self.dot = ctk.CTkLabel(self, width=18, text="", font=Theme.font_micro())
self.dot.pack(side="left") self.dot.pack(side="left")
self.message = ctk.CTkLabel(self, text="", anchor="w", font=Theme.font_caption(), text_color=Theme.TEXT_PRIMARY) self.category = ctk.CTkLabel(
self.message.pack(side="left", fill="x", expand=True, padx=Theme.SPACE_XS) self, width=112, text="", anchor="w", font=Theme.font_micro(), text_color=Theme.TEXT_PRIMARY
)
self.category.pack(side="left", padx=(0, 6))
self.message = ctk.CTkLabel(self, text="", anchor="w", font=Theme.font_micro(), text_color=Theme.TEXT_SECONDARY)
self.message.pack(side="left", fill="x", expand=True)
self.tag = ctk.CTkLabel( self.tag = ctk.CTkLabel(
self, self,
text="", text="",
width=82,
font=Theme.font_micro(), font=Theme.font_micro(),
text_color=Theme.TEXT_SECONDARY, text_color=Theme.TEXT_SECONDARY,
fg_color=Theme.SURFACE_MUTED, fg_color=Theme.SURFACE_MUTED,
corner_radius=Theme.RADIUS_PILL, corner_radius=Theme.RADIUS_PILL,
) )
self.tag.pack(side="right", padx=Theme.SPACE_XS) self.tag.pack(side="right")
def update_event(self, event: Any) -> None: def update_event(self, event: Any) -> None:
level = str(getattr(event, "level", "info")) level = str(getattr(event, "level", "info"))
@ -106,14 +332,16 @@ class _EventRow(ctk.CTkFrame):
"warning": Theme.STATUS_WARNING, "warning": Theme.STATUS_WARNING,
"error": Theme.STATUS_ERROR, "error": Theme.STATUS_ERROR,
}.get(level, Theme.STATUS_INFO) }.get(level, Theme.STATUS_INFO)
category = str(getattr(event, "category", "system"))
self.time.configure(text=str(getattr(event, "timestamp", "Н"))) self.time.configure(text=str(getattr(event, "timestamp", "Н")))
self.dot.configure(text_color=color) self.dot.configure(text_color=color)
self.category.configure(text=category.replace("_", " ").title())
self.message.configure(text=str(getattr(event, "message", "Событие без описания"))) self.message.configure(text=str(getattr(event, "message", "Событие без описания")))
self.tag.configure(text=f" {getattr(event, 'category', 'system')} ") self.tag.configure(text=category)
class DashboardView(ctk.CTkFrame): class DashboardView(ctk.CTkFrame):
"""Dense overview matching the approved composition without fictional metrics.""" """Dense overview following the approved dashboard composition."""
def __init__( def __init__(
self, self,
@ -126,162 +354,156 @@ class DashboardView(ctk.CTkFrame):
super().__init__(master=master, fg_color="transparent", **kwargs) super().__init__(master=master, fg_color="transparent", **kwargs)
self.on_navigate = on_navigate self.on_navigate = on_navigate
self.on_action = on_action self.on_action = on_action
self._provider_cards: Dict[str, _FlowCard] = {} self._provider_cards: Dict[str, _EndpointCard] = {}
self._agent_cards: Dict[str, _FlowCard] = {} self._agent_cards: Dict[str, _EndpointCard] = {}
self._provider_status_rows: Dict[str, _StatusRow] = {} self._provider_status_rows: Dict[str, _StatusRow] = {}
self._event_rows: list[_EventRow] = [] self._event_rows: list[_EventRow] = []
self._build() self._build()
def _build(self) -> None: def _build(self) -> None:
self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent", corner_radius=0) self.scroll = ctk.CTkScrollableFrame(self, fg_color="transparent", corner_radius=0)
self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=Theme.PAGE_PAD_Y) self.scroll.pack(fill="both", expand=True, padx=Theme.PAGE_PAD_X, pady=(8, 10))
self.snapshot_freshness = ctk.CTkLabel( self.snapshot_freshness = ctk.CTkLabel(
self.scroll, self.scroll,
text="Snapshot: Н", text="● Система загружается",
font=Theme.font_micro(), font=Theme.font_micro(),
text_color=Theme.TEXT_MUTED, text_color=Theme.STATUS_HEALTHY,
anchor="w", anchor="w",
) )
self.snapshot_freshness.pack(fill="x", pady=(0, Theme.SPACE_XS)) # Kept as a presentation-state probe for tests/accessibility; the same
# status is rendered once in the global header, as in the approved mockup.
self.metrics = ctk.CTkFrame(self.scroll, fg_color="transparent") self.metrics = ctk.CTkFrame(self.scroll, fg_color="transparent")
self.metrics.pack(fill="x", pady=(0, Theme.SPACE_SM)) self.metrics.pack(fill="x", pady=(0, 8))
for column in range(5): for column in range(5):
self.metrics.grid_columnconfigure(column, weight=1) self.metrics.grid_columnconfigure(column, weight=1, uniform="kpi")
self.quota_metric = _KpiCard(self.metrics, "Квоты", "Н", "нет измерения", icon="") self.quota_metric = _KpiCard(self.metrics, "Квота сегодня", "Н", "нет измерения")
self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н", "измерения", icon="") self.calls_metric = _KpiCard(self.metrics, "Вызовы", "Н", "активно: 0")
self.agents_metric = _KpiCard(self.metrics, "Агенты онлайн", "0/0", "готовые роли", icon="") self.agents_metric = _KpiCard(self.metrics, "Агенты онлайн", "0/0", "готовые роли")
self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н", "P50", icon="") self.latency_metric = _KpiCard(self.metrics, "Время отклика", "Н", "P50")
self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н", "failover", icon="") self.failover_metric = _KpiCard(self.metrics, "Переключения", "Н", "failover")
for index, metric in enumerate( 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)
): ):
metric.grid(row=0, column=index, padx=Theme.SPACE_XS, sticky="nsew") metric.grid(row=0, column=index, padx=(0 if index == 0 else 3, 0 if index == 4 else 3), sticky="nsew")
body = ctk.CTkFrame(self.scroll, fg_color="transparent") body = ctk.CTkFrame(self.scroll, fg_color="transparent")
body.pack(fill="x") body.pack(fill="x")
body.grid_columnconfigure(0, weight=4) body.grid_columnconfigure(0, weight=4)
body.grid_columnconfigure(1, weight=1) body.grid_columnconfigure(1, weight=1)
route_card = HubCard(body, height=370)
route_card = HubCard(body) route_card.grid(row=0, column=0, sticky="nsew", padx=(0, 8))
route_card.grid(row=0, column=0, sticky="nsew", padx=(0, Theme.SPACE_SM)) route_card.grid_propagate(False)
ctk.CTkLabel( ctk.CTkLabel(
route_card, route_card, text="Маршрутизация запросов", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
text="Маршрутизация запросов", ).pack(anchor="w", padx=10, pady=(8, 0))
font=Theme.font_heading(), self.route_diagram = _RouteDiagram(route_card)
text_color=Theme.TEXT_PRIMARY, self.route_diagram.pack(fill="both", expand=True, padx=6, pady=(2, 6))
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM))
flow = ctk.CTkFrame(route_card, fg_color="transparent")
flow.pack(fill="both", expand=True, padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
flow.grid_columnconfigure(0, weight=3)
flow.grid_columnconfigure(1, weight=1)
flow.grid_columnconfigure(2, weight=3)
flow.grid_columnconfigure(3, weight=1)
flow.grid_columnconfigure(4, weight=3)
self.provider_column = ctk.CTkFrame(flow, fg_color="transparent")
self.provider_column.grid(row=0, column=0, sticky="nsew")
ctk.CTkLabel(flow, text="", font=Theme.font_metric(), text_color=Theme.TEXT_ACCENT).grid(row=0, column=1)
self.orchestrator_card = _FlowCard(flow, border_color=Theme.BORDER_ACCENT)
self.orchestrator_card.grid(row=0, column=2, sticky="ew", padx=Theme.SPACE_XS)
ctk.CTkLabel(flow, text="", font=Theme.font_metric(), text_color=Theme.TEXT_ACCENT).grid(row=0, column=3)
self.agent_column = ctk.CTkFrame(flow, fg_color="transparent")
self.agent_column.grid(row=0, column=4, sticky="nsew")
self.context_card = _FlowCard(flow)
self.context_card.grid(row=1, column=2, sticky="ew", padx=Theme.SPACE_XS, pady=(Theme.SPACE_SM, 0))
self.context_card.update_card("Хранилище контекста", "Постоянная память", "Состояние: Н")
self.realtime = HubCard(body) self.realtime = HubCard(body, height=370)
self.realtime.grid(row=0, column=1, sticky="nsew") self.realtime.grid(row=0, column=1, sticky="nsew")
self.realtime.grid_propagate(False)
ctk.CTkLabel( ctk.CTkLabel(
self.realtime, self.realtime,
text="Статус в реальном времени", text="Статус в реальном времени",
font=Theme.font_heading(), font=Theme.font_body_bold(),
text_color=Theme.TEXT_PRIMARY, text_color=Theme.TEXT_PRIMARY,
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_SM)) ).pack(anchor="w", padx=10, pady=(8, 5))
ctk.CTkLabel(self.realtime, text="ПРОВАЙДЕРЫ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack( ctk.CTkLabel(self.realtime, text="ПРОВАЙДЕРЫ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED).pack(
anchor="w", padx=Theme.CARD_PAD_X anchor="w", padx=10
) )
self.provider_status = ctk.CTkFrame(self.realtime, fg_color="transparent") self.provider_status = ctk.CTkFrame(self.realtime, fg_color="transparent")
self.provider_status.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_XS) 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( ctk.CTkLabel(
self.realtime, text="СИСТЕМНЫЕ ПОКАЗАТЕЛИ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED self.realtime, text="СИСТЕМНЫЕ ПОКАЗАТЕЛИ", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.SPACE_SM, 0)) ).pack(anchor="w", padx=10, pady=(2, 1))
self.host_metrics = ctk.CTkLabel( systems = ctk.CTkFrame(self.realtime, fg_color="transparent")
self.realtime, systems.pack(fill="x", padx=10)
text="CPU Н\nПамять Н\nДиск Н\nСеть Н", self.system_rows = {
justify="left", "cpu": _SystemRow(systems, "CPU"),
anchor="w", "memory": _SystemRow(systems, "Память"),
font=Theme.font_caption(), "disk": _SystemRow(systems, "Диск"),
text_color=Theme.TEXT_SECONDARY, "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
) )
self.host_metrics.pack(fill="x", padx=Theme.CARD_PAD_X, pady=Theme.SPACE_SM) load = ctk.CTkFrame(self.realtime, fg_color="transparent")
ctk.CTkLabel( load.pack(fill="x", padx=10, pady=(2, 0))
self.realtime, self.load_rows = {key: _StatusRow(load) for key in ("active", "failovers", "errors")}
text="Локальные измерения • psutil", for row in self.load_rows.values():
wraplength=210, row.pack(fill="x")
justify="left",
font=Theme.font_micro(),
text_color=Theme.TEXT_MUTED,
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y))
self.events_card = HubCard(self.scroll) self.events_card = HubCard(self.scroll)
self.events_card.pack(fill="x", pady=(Theme.SPACE_SM, 0)) self.events_card.pack(fill="x", pady=(8, 0))
events_header = ctk.CTkFrame(self.events_card, fg_color="transparent")
events_header.pack(fill="x", padx=10, pady=(7, 3))
ctk.CTkLabel( ctk.CTkLabel(
self.events_card, events_header, text="Последние события", font=Theme.font_body_bold(), text_color=Theme.TEXT_PRIMARY
text="Последние события", ).pack(side="left")
font=Theme.font_heading(), ctk.CTkButton(
text_color=Theme.TEXT_PRIMARY, events_header,
).pack(anchor="w", padx=Theme.CARD_PAD_X, pady=(Theme.CARD_PAD_Y, Theme.SPACE_XS)) text="Все события →",
width=82,
height=22,
fg_color="transparent",
hover_color=Theme.SURFACE_HOVER,
text_color=Theme.TEXT_ACCENT,
font=Theme.font_micro(),
command=lambda: self.on_navigate("logs") if self.on_navigate else None,
).pack(side="right")
self.events_body = ctk.CTkFrame(self.events_card, fg_color="transparent") self.events_body = ctk.CTkFrame(self.events_card, fg_color="transparent")
self.events_body.pack(fill="x", padx=Theme.CARD_PAD_X, pady=(0, Theme.CARD_PAD_Y)) self.events_body.pack(fill="x", padx=10, pady=(0, 7))
self.events_empty = ctk.CTkLabel( self.events_empty = ctk.CTkLabel(
self.events_body, self.events_body, text="События: Н", font=Theme.font_micro(), text_color=Theme.TEXT_MUTED
text="События: Н",
font=Theme.font_caption(),
text_color=Theme.TEXT_MUTED,
) )
self.events_empty.pack(anchor="w", pady=Theme.SPACE_SM) self.events_empty.pack(anchor="w", pady=5)
@staticmethod @staticmethod
def _quota_text(snapshot: HubSnapshot, provider_id: str) -> str: def _quota_measurement(snapshot: HubSnapshot, provider_id: Optional[str] = None) -> tuple[str, Optional[float]]:
profiles = snapshot.profiles_by_provider.get(provider_id, []) profiles = snapshot.profiles_by_provider.get(provider_id, []) if provider_id else snapshot.all_profiles.values()
measured: list[float] = []
for profile in profiles: for profile in profiles:
quota = snapshot.quotas.get(profile.profile_id) quota = snapshot.quotas.get(profile.profile_id)
if not quota or getattr(quota, "is_estimated", True): if not quota or getattr(quota, "is_estimated", True):
continue continue
measured.extend( for bucket in quota.buckets:
float(bucket.remaining_percent) for bucket in quota.buckets if bucket.remaining_percent is not None if bucket.remaining_percent is not None:
) return f"{bucket.remaining_percent:.0f}%", float(bucket.remaining_percent)
if not measured: return "Н", None
return "Квота: Н"
return f"Корзины: {len(measured)} измерено"
@staticmethod @staticmethod
def _sync_cards( def _sync_endpoint_cards(
container: Any, slots: list[Any],
cache: Dict[str, _FlowCard], cache: Dict[str, _EndpointCard],
items: Iterable[tuple[str, str, str, str, str]], items: Iterable[tuple[str, str, str, str, str, str, Optional[float], str]],
) -> None: ) -> None:
prepared = list(items) prepared = list(items)[:3]
live = {key for key, *_rest in prepared} live = {key for key, *_rest in prepared}
for key in list(cache): for key in list(cache):
if key not in live: if key not in live:
cache.pop(key).destroy() cache.pop(key).destroy()
for index, (key, title, subtitle, meta, status) in enumerate(prepared): for index, item in enumerate(prepared):
key, provider_key, title, subtitle, status_text, quota_text, quota_percent, status = item
card = cache.get(key) card = cache.get(key)
if card is None: if card is None:
card = _FlowCard(container) card = _EndpointCard(slots[index])
card.pack(fill="both", expand=True)
cache[key] = card cache[key] = card
card.update_card(title, subtitle, meta, status) card.update_card(provider_key, title, subtitle, status_text, quota_text, quota_percent, status)
card.pack(fill="x", pady=Theme.SPACE_XS)
def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None: def update_data(self, snapshot: Optional[HubSnapshot] = None) -> None:
if not isinstance(snapshot, HubSnapshot): if not isinstance(snapshot, HubSnapshot):
return return
self.snapshot_freshness.configure( self.snapshot_freshness.configure(
text=f"{'⚠ данные устарели' if snapshot.is_stale else '● данные актуальны'} • snapshot #{snapshot.seq}", text=(
f"⚠ Данные устарели • snapshot #{snapshot.seq}"
if snapshot.is_stale
else f"● Система работает штатно • snapshot #{snapshot.seq}"
),
text_color=Theme.STATUS_WARNING if snapshot.is_stale else Theme.STATUS_HEALTHY, text_color=Theme.STATUS_WARNING if snapshot.is_stale else Theme.STATUS_HEALTHY,
) )
readiness = snapshot.readiness readiness = snapshot.readiness
@ -290,53 +512,54 @@ class DashboardView(ctk.CTkFrame):
provider_telemetry = dict(telemetry.get("by_provider") or {}) provider_telemetry = dict(telemetry.get("by_provider") or {})
role_telemetry = dict(telemetry.get("by_role") or {}) role_telemetry = dict(telemetry.get("by_role") or {})
has_telemetry = bool(telemetry.get("has_data")) has_telemetry = bool(telemetry.get("has_data"))
self.calls_metric.val_label.configure(text=str(global_telemetry.get("total_calls")) if has_telemetry else "Н") total_calls = global_telemetry.get("total_calls") if has_telemetry else None
self.latency_metric.val_label.configure( latency = global_telemetry.get("latency_p50_ms") if has_telemetry else None
text=f"{global_telemetry['latency_p50_ms']:.0f} мс" failovers = global_telemetry.get("failovers_count") if has_telemetry else None
if has_telemetry and global_telemetry.get("latency_p50_ms") is not None
else "Н"
)
self.failover_metric.val_label.configure(
text=str(global_telemetry.get("failovers_count")) if has_telemetry else "Н"
)
active_calls = snapshot.metrics.get("active_calls_total") active_calls = snapshot.metrics.get("active_calls_total")
self.calls_metric.sub_label.configure( quota_text, quota_percent = self._quota_measurement(snapshot)
text=f"активно: {active_calls}" if isinstance(active_calls, int) else "own_measurement" self.quota_metric.set_metric(
quota_text, "реальная корзина" if quota_percent is not None else "нет измерения", quota_percent
) )
self.agents_metric.val_label.configure(text=f"{readiness.roles_ready_count}/{readiness.total_roles}") self.calls_metric.set_metric(
measured_buckets = sum( str(total_calls) if total_calls is not None else "Н",
1 f"активно: {active_calls}" if isinstance(active_calls, int) else "Н",
for quota in snapshot.quotas.values() float(total_calls) if total_calls is not None else None,
if not getattr(quota, "is_estimated", True) )
for bucket in quota.buckets self.agents_metric.set_metric(
if bucket.remaining_percent is not None f"{readiness.roles_ready_count}/{readiness.total_roles}",
"готовые роли",
float(readiness.roles_ready_count),
)
self.latency_metric.set_metric(
f"{latency:.0f} мс" if latency is not None else "Н",
"P50",
float(latency) if latency is not None else None,
)
self.failover_metric.set_metric(
str(failovers) if failovers is not None else "Н",
"failover",
float(failovers) if failovers is not None else None,
) )
self.quota_metric.val_label.configure(text=str(measured_buckets) if measured_buckets else "Н")
self.quota_metric.sub_label.configure(text="измеренных корзин" if measured_buckets else "нет измерения")
providers = list(snapshot.providers)[:3] providers = list(snapshot.providers)[:3]
self._sync_endpoint_cards(
def provider_activity(provider_id: str, online: int, connected: int) -> str: self.route_diagram.provider_slots,
measured = dict(provider_telemetry.get(provider_id) or {})
share = measured.get("call_share")
calls = measured.get("total_calls")
if share is not None and calls is not None:
return f"{share:.0%} потока • {calls} вызовов"
return f"{online}/{connected} онлайн"
self._sync_cards(
self.provider_column,
self._provider_cards, self._provider_cards,
( (
( (
provider.provider_id,
provider.provider_id, provider.provider_id,
provider.provider_name, provider.provider_name,
provider_activity( next(
provider.provider_id, (
provider.online_count, profile.preferred_models[0]
provider.connected_count, for profile in snapshot.profiles_by_provider.get(provider.provider_id, [])
if profile.preferred_models
),
"Модель: Н",
), ),
self._quota_text(snapshot, provider.provider_id), "Онлайн" if provider.online_count else "Недоступен",
*self._quota_measurement(snapshot, provider.provider_id),
"healthy" if provider.online_count else "warning", "healthy" if provider.online_count else "warning",
) )
for provider in providers for provider in providers
@ -344,89 +567,102 @@ class DashboardView(ctk.CTkFrame):
) )
orchestrator = next((agent for agent in snapshot.agents if agent.is_main_orchestrator), None) orchestrator = next((agent for agent in snapshot.agents if agent.is_main_orchestrator), None)
if orchestrator: if orchestrator:
self.orchestrator_card.update_card( self.route_diagram.orchestrator.update_node(
"Главный оркестратор",
f"{orchestrator.provider_display_name}{orchestrator.model}", f"{orchestrator.provider_display_name}{orchestrator.model}",
f"{orchestrator.account_identity or 'Аккаунт: Н'}{orchestrator.status_label_ru}", "Онлайн" if orchestrator.is_active else orchestrator.status_label_ru,
"active" if orchestrator.is_active else "warning", orchestrator.is_active,
) )
else: else:
self.orchestrator_card.update_card("Главный оркестратор", "Не назначен", "Аккаунт: Н", "warning") self.route_diagram.orchestrator.update_node("Не назначен", "Н", False)
agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:3] agents = [agent for agent in snapshot.agents if not agent.is_main_orchestrator][:3]
self._sync_cards( self._sync_endpoint_cards(
self.agent_column, self.route_diagram.agent_slots,
self._agent_cards, self._agent_cards,
( (
( (
agent.role_id, agent.role_id,
agent.provider,
agent.role_name_ru, agent.role_name_ru,
f"{agent.provider_display_name}{agent.model}", f"{agent.provider_display_name}{agent.model}",
( "Здорово" if agent.is_active else agent.status_label_ru,
f"{agent.active_quota_label or 'Квота: Н'}" agent.active_quota_label or "Н",
+ ( None,
f"{role_telemetry[agent.role_id]['total_calls']} вызовов"
if role_telemetry.get(agent.role_id, {}).get("has_data")
else ""
)
),
"healthy" if agent.is_active else "warning", "healthy" if agent.is_active else "warning",
) )
for agent in agents for agent in agents
), ),
) )
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 "Н")
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 "Н")
self.route_diagram.set_labels(left_labels, right_labels)
live_provider_ids = {provider.provider_id for provider in snapshot.providers} live_provider_ids = {provider.provider_id for provider in providers}
for provider_id in list(self._provider_status_rows): for provider_id in list(self._provider_status_rows):
if provider_id not in live_provider_ids: if provider_id not in live_provider_ids:
self._provider_status_rows.pop(provider_id).destroy() self._provider_status_rows.pop(provider_id).destroy()
for provider in snapshot.providers: for provider in providers:
row = self._provider_status_rows.get(provider.provider_id) row = self._provider_status_rows.get(provider.provider_id)
if row is None: if row is None:
row = _StatusRow(self.provider_status) row = _StatusRow(self.provider_status)
row.pack(fill="x", pady=Theme.SPACE_XS) row.pack(fill="x")
self._provider_status_rows[provider.provider_id] = row self._provider_status_rows[provider.provider_id] = row
measured = dict(provider_telemetry.get(provider.provider_id) or {}) provider_latency = dict(provider_telemetry.get(provider.provider_id) or {}).get("latency_p50_ms")
latency = measured.get("latency_p50_ms") detail = (
row.update_row( f"{provider_latency:.0f} мс"
provider.provider_name, if provider_latency is not None
( else f"{provider.online_count}/{provider.connected_count}"
f"{provider.online_count}/{provider.connected_count}{latency:.0f} мс"
if latency is not None
else f"{provider.online_count}/{provider.connected_count}"
),
"healthy" if provider.online_count else "warning",
) )
row.update_row(provider.provider_name, detail, "healthy" if provider.online_count else "warning")
host = dict(snapshot.metrics.get("host") or {}) host = dict(snapshot.metrics.get("host") or {})
if host.get("has_data"): host_available = bool(host.get("has_data"))
network_total = sum( cpu = host.get("cpu_percent") if host_available else None
value for value in (host.get("net_bytes_sent"), host.get("net_bytes_recv")) if isinstance(value, int) memory = host.get("memory_percent") if host_available else None
) disk = host.get("disk_percent") if host_available else None
network_text = f"{network_total / (1024 * 1024):.0f} МБ" if network_total else "Н" network_total = sum(
self.host_metrics.configure( value for value in (host.get("net_bytes_sent"), host.get("net_bytes_recv")) if isinstance(value, int)
text=( )
f"CPU {host.get('cpu_percent', 'Н')}%\n" network_mb = network_total / (1024 * 1024) if network_total else None
f"Память {host.get('memory_percent', 'Н')}%\n" self.system_rows["cpu"].update_metric(f"{cpu:.0f}%" if cpu is not None else "Н", cpu)
f"Диск {host.get('disk_percent', 'Н')}%\n" self.system_rows["memory"].update_metric(f"{memory:.0f}%" if memory is not None else "Н", memory)
f"Сеть {network_text}" 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
else: )
self.host_metrics.configure(text="CPU Н\nПамять Н\nДиск Н\nСеть Н") 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: def update_events(self, events: Iterable[Any]) -> None:
items = list(events)[:5] items = list(events)[:5]
while len(self._event_rows) < len(items): while len(self._event_rows) < len(items):
row = _EventRow(self.events_body) row = _EventRow(self.events_body)
row.pack(fill="x", pady=Theme.SPACE_XS) row.pack(fill="x", pady=1)
self._event_rows.append(row) self._event_rows.append(row)
for index, row in enumerate(self._event_rows): for index, row in enumerate(self._event_rows):
if index < len(items): if index < len(items):
row.update_event(items[index]) row.update_event(items[index])
row.pack(fill="x", pady=Theme.SPACE_XS) row.pack(fill="x", pady=1)
else: else:
row.pack_forget() row.pack_forget()
if items: if items:
self.events_empty.pack_forget() self.events_empty.pack_forget()
else: else:
self.events_empty.pack(anchor="w", pady=Theme.SPACE_SM) self.events_empty.pack(anchor="w", pady=5)

View file

@ -83,6 +83,16 @@ def test_approved_mockup_numbers_are_not_shipped_as_placeholders() -> None:
assert fictional_value not in source assert fictional_value not in source
@pytest.mark.unit
def test_approved_logo_and_consistent_sidebar_icon_system_are_wired() -> None:
assert Path("assets/branding/logo/logo_approved.png").is_file()
assets_source = Path("src/antigravity_provider/router/ui/assets.py").read_text(encoding="utf-8")
app_source = Path("src/antigravity_provider/router/hermes_hub_app.py").read_text(encoding="utf-8")
assert 'self.logo_dir / "logo_approved.png"' in assets_source
assert "def get_nav_icon" in assets_source
assert "get_nav_icon(icon, size=19)" in app_source
@pytest.mark.unit @pytest.mark.unit
def test_apply_scheme_updates_shared_semantic_aliases() -> None: def test_apply_scheme_updates_shared_semantic_aliases() -> None:
original = Theme.current_scheme original = Theme.current_scheme