fix(ui): align dashboard and navigation with approved mockup
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 88 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 70 KiB |
BIN
assets/branding/logo/logo_approved.png
Normal file
|
After Width: | Height: | Size: 2 MiB |
|
|
@ -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():
|
||||||
|
|
|
||||||
|
|
@ -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,6 +65,8 @@ 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_approved.png"
|
||||||
|
if not logo_path.exists():
|
||||||
logo_path = self.logo_dir / "logo_256.png"
|
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"
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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_name,
|
|
||||||
provider_activity(
|
|
||||||
provider.provider_id,
|
provider.provider_id,
|
||||||
provider.online_count,
|
provider.provider_name,
|
||||||
provider.connected_count,
|
next(
|
||||||
|
(
|
||||||
|
profile.preferred_models[0]
|
||||||
|
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
|
||||||
(
|
|
||||||
f"{provider.online_count}/{provider.connected_count} • {latency:.0f} мс"
|
|
||||||
if latency is not None
|
|
||||||
else f"{provider.online_count}/{provider.connected_count}"
|
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"))
|
||||||
|
cpu = host.get("cpu_percent") if host_available else None
|
||||||
|
memory = host.get("memory_percent") if host_available else None
|
||||||
|
disk = host.get("disk_percent") if host_available else None
|
||||||
network_total = sum(
|
network_total = sum(
|
||||||
value for value in (host.get("net_bytes_sent"), host.get("net_bytes_recv")) if isinstance(value, int)
|
value for value in (host.get("net_bytes_sent"), host.get("net_bytes_recv")) if isinstance(value, int)
|
||||||
)
|
)
|
||||||
network_text = f"{network_total / (1024 * 1024):.0f} МБ" if network_total else "Н/Д"
|
network_mb = network_total / (1024 * 1024) if network_total else None
|
||||||
self.host_metrics.configure(
|
self.system_rows["cpu"].update_metric(f"{cpu:.0f}%" if cpu is not None else "Н/Д", cpu)
|
||||||
text=(
|
self.system_rows["memory"].update_metric(f"{memory:.0f}%" if memory is not None else "Н/Д", memory)
|
||||||
f"CPU {host.get('cpu_percent', 'Н/Д')}%\n"
|
self.system_rows["disk"].update_metric(f"{disk:.0f}%" if disk is not None else "Н/Д", disk)
|
||||||
f"Память {host.get('memory_percent', 'Н/Д')}%\n"
|
self.system_rows["network"].update_metric(
|
||||||
f"Диск {host.get('disk_percent', 'Н/Д')}%\n"
|
f"{network_mb:.0f} МБ" if network_mb is not None else "Н/Д", network_mb
|
||||||
f"Сеть {network_text}"
|
|
||||||
)
|
)
|
||||||
|
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",
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
self.host_metrics.configure(text="CPU Н/Д\nПамять Н/Д\nДиск Н/Д\nСеть Н/Д")
|
|
||||||
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||