feat(ui): A48 workspace layout draft awaiting authenticated visual QA

This commit is contained in:
ochenstarik-ui 2026-08-31 15:25:15 +07:00
parent 80aab00ee2
commit c3bfcee846
64 changed files with 1016 additions and 307 deletions

View file

@ -0,0 +1,189 @@
# Задание A48: интерфейс по макетам — исполнитель Codex
## Дата поступления
2026-08-31
## Исполнитель
**Codex на сервере `192.168.1.81`.** Не Antigravity.
Задание **отменяет A46**: там та же работа была поставлена Antigravity. Два агента в одних файлах уже приводили к тому, что один переключал ветку под работой другого. Владелец фронтенда — только это задание.
## База
```
репозиторий: /srv/projects/Agent projects/hermes-hub (уже на 80aab00)
макеты: /srv/projects/Agent projects/Разное/фронтенд
общая память: /srv/projects/AI-Memory (см. корневой AGENTS.md)
живой хаб: http://127.0.0.1:8080
```
```
git fetch origin --prune
git checkout -b codex/a48-interface-by-mockup origin/main
```
В `main` напрямую не пушить.
## Порядок
Codex работает одним исполнителем, поэтому второго прохода внутри задания нет. Вместо него — **самопроверка по пункту P0-6** и приёмка ревьюером. Пункты, помеченные «приложить», — это то, по чему работу будут принимать.
Перед началом прочитать общую память по правилам корневого `AGENTS.md`: протокол, состояние и задачи проекта. После работы обновить их.
---
## Почему это задание существует
Задание A43 объявили выполненным. Владелец поставил сборку на две машины и написал: «интерфейс вообще не изменился, какой был, такой и остался. Зачем тогда было задание на изменение по макетам?»
Он прав. Вот что изменил A43:
```
app.js +297 шесть карточек показателей, обвязка холста
workflow.js +136 панорамирование, зум колесом
index.html +8
workflow.css 15 снят потолок высоты 430 px
style.css НЕ ОТКРЫВАЛСЯ НИ РАЗУ
```
`style.css` — это и есть внешний вид. Привести экраны к макетам, не тронув его, нельзя.
Работу приняли по зелёным тестам. **Тесты внешний вид не видят.** Отсюда главное требование этого задания — проверка глазами, см. P0-3.
**Холст переделывать не нужно.** Панорамирование, зум колесом и снятый потолок высоты работают и остаются.
---
## P0-1. Первоисточник — текстовые ТЗ, а не только картинки
Рядом с макетами лежат два документа, и в них требования подробнее, чем видно на изображении:
```
Разное/фронтенд/1.txt ТЗ по главному экрану «Обзор»: рабочее пространство
агентной системы, визуальные workflow, Agent Files,
LIVE-мониторинг
Разное/фронтенд/3.txt ТЗ по вкладке «Маршрутизация»
```
Из `3.txt` важное разграничение, которое легко нарушить: **на «Маршрутизации» настраивается очерёдность аккаунтов внутри агента; связи между агентами живут на «Обзоре».** Не смешивать.
Прочитать оба целиком до начала работы. Расхождения между текстом и картинкой — вынести владельцу, а не решать молча.
## P0-2. Вёрстка
Экраны привести к макетам `1.1``7.1`: сетка, отступы, типографика, состояния карточек, расположение панелей, палитра.
1. **Открыть `style.css`.** Если по итогам работы он не изменён — задание не выполнено.
2. Правки в `index.html` и `app.js` — по необходимости; клиент **без сборки, без npm, без фреймворка** (решение зафиксировано в `docs/web-api/CONTRACT.md` §1). Tailwind, React и генераторы страниц не вносить.
3. **Три темы** — светлая, средняя, тёмная — работают на каждом экране. Цвета через переменные, не литералами.
Заметные расхождения, найденные ревьюером на экране «Обзор»:
```
шапка в макете поиск (Ctrl+K), уведомления, настройки, карточка
пользователя; в сборке две кнопки
логотип вензель и подпись против значка молнии
панель холста вертикальный столбец инструментов слева — отсутствует
карточки узлов в макете иконка, файл .md, строка «модель • аккаунт»;
в сборке три обрезанные строки без модели и аккаунта
инспектор в макете вкладки, конфигурация исполнения, Agent File,
инструменты, быстрые действия; в сборке пустая заглушка
нижний ряд в макете три карточки, в сборке две
подписи связей режутся карточками узлов: «ТАНОВКА ЗАД», «РАЙПРИЁМКА»
```
Про подписи: центрирование уже исправлено в `workflow.css`, дело **не в стиле текста**. Их перекрывают карточки узлов — лечится порядком слоёв и расстоянием между узлами в раскладке.
## P0-3. Проверка глазами — обязательная часть работы
Codex работает на том же сервере, где запущен хаб. Это ключевое отличие от прошлого захода: **сверять есть с чем прямо на месте.**
1. Открыть `http://127.0.0.1:8080` и пройти все экраны.
2. **Скриншот до и после по каждому экрану**, рядом фрагмент макета. Приложить.
3. Проверить каждую тему.
4. Холст: подвигать полотно, покрутить колесо, утащить узел за край и вернуть кнопкой «вписать».
5. Подписи связей читаются на нескольких масштабах и не перекрываются.
Отчёт без скриншотов приниматься не будет: другого способа проверить эту работу нет.
## P0-4. Что из макета не переносить
Макеты нарисованы с правдоподобными данными. В живой интерфейс они попасть не должны:
```
кольцевая диаграмма с числом 42 пример, а не измерение
«Gemini 1.5 Flash • acc-02» подпись примера
версия v2.9.0 в проекте 0.1.1, заморожена намеренно
«94.2%», «+20% к вчера» выдуманные показатели
```
Правило проекта: **число появляется только после измерения.** Нет данных под элемент — `Н/Д` с причиной. Загрузка и отсутствие пишутся разными словами: «список ещё не получен» и «моделей нет» — разное.
Модель и аккаунт для карточки узла **в снапшоте есть** — брать оттуда.
Элемент, которому нечего обслуживать (кнопки несуществующих приложений экосистемы внизу макета), не рисовать и **назвать пропущенным** в отчёте.
## P0-5. Данные берутся из снапшота
Единственный источник — `GET /api/snapshot`. Это `dataclasses.asdict(HubSnapshot)`, имена полей ровно как в датаклассе:
```
all_profiles профили; ключа "profiles" НЕ существует
providers сводки провайдеров, у них discovered_models
routing, agents, workflow, readiness, quotas, metrics
```
Перед чтением поля перечислить поля датакласса. Обращение к несуществующему ключу молча даёт пустоту: именно так экран маршрутизации показывал «0 аккаунтов» при шести подключённых.
Список моделей провайдера — `discovered_models` **у провайдера**. У профиля `preferred_models` — это настройки владельца, и `model_states` строится из них же.
## P0-6. Самопроверка перед сдачей
Пройти по списку и приложить результат:
1. `git diff --stat` — есть ли в списке `style.css`.
2. `python -m pytest -q` — сравнить с базой, на `origin/main` сейчас **496 passed**.
3. `python -m ruff check .`
4. `PYTHONPATH=src python scripts/verify_multi_provider_router.py`
5. Скриншоты собраны по всем экранам и всем темам.
6. Поиск по клиенту: не осталось ли зашитых чисел и процентов.
7. Пункт, который не сделан, **назван пропущенным**.
---
## Ограничения
- Без сборки, без npm, без фреймворка.
- Python не трогать: провайдеры и квоты — задание A42.
- Холст A43 не переделывать, только доводить.
- Учётные данные и `~/.hermes/agy_profiles/` не трогать.
- Службы `qwen-coder` и `qwen-compressor` не трогать: они только что восстановлены после того, как их подменили заглушкой.
- Версию `0.1.1` не поднимать.
- Правило честности без исключений.
## Критерии приёмки
1. Ветка в `origin`, `git status` чист.
2. `style.css` изменён; вёрстка приведена к макетам.
3. Оба текстовых ТЗ прочитаны; разграничение из `3.txt` соблюдено — очерёдность аккаунтов на «Маршрутизации», связи агентов на «Обзоре».
4. По каждому экрану приложены скриншоты до и после рядом с фрагментом макета.
5. Пройдены все макеты `1.1``7.1`.
6. Инспектор реализован по макету; отсутствующие данные показаны как `Н/Д` с причиной.
7. Карточки узлов показывают модель и аккаунт из снапшота.
8. Подписи связей не перекрываются карточками; проверено на нескольких масштабах.
9. Ни одного числа из макета в живом интерфейсе.
10. Три темы работают на всех экранах.
11. Холст не сломан: панорамирование, зум, «вписать».
12. `ruff check .` чисто; релизный гейт 10/10; тестов не меньше 496.
13. Память проекта в AI-Memory обновлена: состояние, задачи, worklog.
14. Отчёт: `START_HEAD`, `FINAL_HEAD`, `origin/main`, `git status`, `X passed / Y skipped / Z failed`.
## Главное
Владелец ждал сборку, поставил её на две машины и увидел прежний интерфейс. Холст стал лучше — но это один экран из семи, а вёрстку не начинали.
У этого задания есть преимущество, которого не было у прошлого: исполнитель сидит на том же сервере, где работает хаб, и может открыть его и сравнить с макетом сам. Поэтому сдача — скриншоты, а не описание сделанного.
## Порядок сдачи
Передать точный `FINAL_COMMIT_SHA`.

59
docs/reports/A48.md Normal file
View file

@ -0,0 +1,59 @@
# A48 — промежуточный отчёт, НЕ ГОТОВО К ПРИЁМКЕ
Дата: 2026-08-31. Исполнитель: Codex, один исполнитель, без субагентов.
## Контрольные точки
- START_HEAD: `80aab00ee2bef353e12d128bd8ba6a6dde503abd`.
- origin/main перед началом: `80aab00ee2bef353e12d128bd8ba6a6dde503abd`.
- Ветка: `codex/a48-interface-by-mockup`, отдельный worktree `hermes-hub-a48`.
- FINAL_HEAD / FINAL_COMMIT_SHA: **ещё не определён — визуальная приёмка не завершена**. SHA промежуточного коммита передаётся в сообщении исполнителя.
- Главный checkout и незакоммиченная `.agents/` сохранены; ветка A47 не переключалась.
- Живой хаб фактически слушает порт 5800, сообщает сборку `81a58f6`. На 8080 работает другое приложение; оно не изменялось.
- Предпросмотр клиента A48: loopback 5801, защищённый API пересылается в существующий хаб без добавления авторизации и без изменения служб. Для другого порта браузеру требуется отдельный вход владельца.
## Что изменено в клиенте
- `style.css`: общая сетка семи страниц, навигация, типографика, состояния карточек, отдельные цвета поверхностей/обрамления для трёх тем, адаптивность.
- Утверждённый логотип из существующих assets; SVG-значки меню, поиск по экранам/агентам/аккаунтам с Ctrl+K, переходы к событиям и настройкам. Вместо вымышленного Administrator указано отсутствие профиля пользователя в API.
- Обзор: столбец инструментов, модель и аккаунт на узле, автоматический первоначальный выбор агента, сводка инспектора и переходы к существующему редактору Agent File. Три нижние карточки; кольцевая статистика не выдумана.
- Сохранены механизмы панорамирования и масштабирования A43. Подписи связей выделены в отдельный SVG-слой; размещение учитывает прямоугольники узлов и других подписей. Добавлено разнесение узлов как явно сохраняемый черновик.
- Маршрутизация читает `RolePipeline.nodes` вместо отсутствующего `preferred_chain`; порядок сохраняется существующим `save_chain`. Фильтры провайдеров/состояний, поиск, выбор модели, кнопочное добавление и удаление профиля из цепочки. Связи агентов остаются на Обзоре.
- Списки моделей — `ProviderSummary.discovered_models`. Настроенная, но не обнаруженная модель отмечается неподтверждённой. «Список ещё не получен» и «доступных моделей нет» различаются по времени обновления провайдера.
- Аналитика исправлена на `telemetry.by_provider` / `by_role`. Состояние — на `metrics.host`. Нулевые измерения не заменяются отсутствием данных.
- Настройки сгруппированы; не полученные настройки не выдаются за текущие значения. Сохраняются только явно выбранные изменения. Поля, не имеющие источника/обработчика в этом потоке, помечены Н/Д.
- Журнал показывает события workflow из снапшота (не более 60). Это **не полный журнал сервера**: при требовании единственного источника `/api/snapshot` более широкий журнал недоступен. Детали события и фильтры предусмотрены.
## Источники проверены до изменения чтения
HubSnapshot: generation, seq, timestamp, profiles_by_provider, all_profiles, readiness, agents, providers, routing, quotas, metrics, is_stale, workflow.
Прочитаны определения ProfileViewModel, ProviderSummary, RolePipeline, SystemReadiness, HostMetricsSnapshot, TelemetryAggregates, WorkflowEvent и реализация WorkflowService.snapshot. Текстовые ТЗ `1.txt` и `3.txt` прочитаны полностью. Все изображения `1.1``7.1` просмотрены.
## Проверки
- База origin/main: **495 passed / 1 skipped / 0 failed**, 4 deselected.
- Текущий клиент: **495 passed / 1 skipped / 0 failed**, 4 deselected. В A48 указано 496 passed, но это не воспроизводится даже на исходной базе; Python и тестовые маркеры не изменены.
- `python -m ruff check .`: чисто.
- `PYTHONPATH=src python scripts/verify_multi_provider_router.py`: **10/10**.
- `scripts/release_gate.py`: **PASSED**. Сам скрипт содержит 7 групп проверок; 10/10 относится к отдельному router verifier.
- `node tests/test_workspace_a48.js`: прошёл (модели, отсутствие/нулевая квота, host metrics, telemetry keys, экранирование).
- `node tests/test_web_handlers_dom_contract.js`: прошёл.
- `node --check` для трёх файлов клиента и `git diff --check`: чисто.
- Первый промежуточный запуск pytest дал 1 сбой: новый стартовый обработчик вызывался из файла, который тест загружает отдельно. Инициализация перенесена в собственный JS-файл; повторный тест прошёл.
- Поиск демонстрационных чисел/версий/моделей: совпадения только с геометрией кривой (0.42) и настройкой порога (20%), не со статистикой.
## Визуальные доказательства и пропуски
[Галерея макет / до / промежуточная вёрстка](../screenshots/a48/index.html).
- Собраны **21 исходный снимок**: 7 экранов × 3 темы, авторизованный живой хаб.
- Собраны 21 снимок `layout-only` без входа для проверки базовой сетки/тем. Они НЕ заменяют требуемые скриншоты после с реальными данными; часть последних мелких CSS-правок требует повторной съёмки.
- **ПРОПУЩЕНО / ожидает входа:** 21 авторизованный снимок после, инспектор на живом снапшоте, панорамирование/зум/узел за край/вписать, читаемость подписей на нескольких масштабах, проверка действий маршрутизации и выбора модели с подключёнными аккаунтами, повторный полный визуальный проход.
- **ПРОПУЩЕНО намеренно:** кнопки несуществующих приложений экосистемы, выдуманные графики/рост процентов/кольцо 42, версия v2.9.0, неподдерживаемые настройки уведомлений/backup/2FA, история температур и сетевых замеров без источника, вымышленный профиль Administrator.
- Отличия от макета: используется существующий утверждённый растровый логотип с кремовой подложкой, а не генерация нового вензеля; нет фиктивных аккаунтов и назначений. Точные плотность и отступы окончательно не приняты.
- Ограничение backend: `set_model` меняет предпочтение профиля, общее для его использования в разных ролях. Независимые модели на каждую пару роль/аккаунт нельзя честно обещать без отдельного изменения backend. Python в A48 не менялся.
## Продолжение
После входа владельца на 5801: перезагрузить клиент, проверить консоль и DOM, исправить найденные дефекты, снять авторизованные после для всех тем, пройти холст, обновить галерею и отчёт. Только затем назначить FINAL_COMMIT_SHA и пометить A48 выполненным.

View file

@ -0,0 +1,32 @@
........................................................................ [ 14%]
........................................................................ [ 29%]
........................................................................ [ 43%]
........................................................................ [ 58%]
...s.................................................................... [ 72%]
........................................................................ [ 87%]
................................................................ [100%]
=============================== warnings summary ===============================
src/antigravity_provider/router/web/server.py:437
/srv/projects/Agent projects/hermes-hub-a48/src/antigravity_provider/router/web/server.py:437: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("startup")
../hermes-hub/.venv/lib/python3.11/site-packages/fastapi/applications.py:4681
/srv/projects/Agent projects/hermes-hub/.venv/lib/python3.11/site-packages/fastapi/applications.py:4681: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
return self.router.on_event(event_type) # ty: ignore[deprecated]
../hermes-hub/.venv/lib/python3.11/site-packages/fastapi/testclient.py:1
/srv/projects/Agent projects/hermes-hub/.venv/lib/python3.11/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
495 passed, 1 skipped, 4 deselected, 3 warnings in 22.69s

View file

@ -0,0 +1,28 @@
======================================================================
Hermes Hub — Release Gate Verification Suite (Target: v0.1.1)
======================================================================
Running 1. Version Consistency ([UNIT VERIFIED])...
[UNIT VERIFIED] Version 0.1.1 is consistent across all manifests
Running 2. P0 Release Blockers (16/16) ([UNIT VERIFIED])...
[UNIT VERIFIED] 16/16 P0 release blockers & regression checks verified
Running 3. Auto-Updater & Rollback ([INTEGRATION VERIFIED])...
[INTEGRATION VERIFIED] Auto-updater, SHA-256 verification, and rollback verified
Running 4. Full Offline Pytest Suite ([INTEGRATION VERIFIED])...
[INTEGRATION VERIFIED] All unit and integration tests passed offline
Running 5. Zero Hardcoded Developer Paths ([STATIC VERIFIED])...
[STATIC VERIFIED] Zero hardcoded developer paths in src/
Running 6. Zero Credentials & AST Secret Scan ([SECURITY VERIFIED])...
[SECURITY VERIFIED] Zero secret files, live tokens, or obfuscated secret assignments in src/
Running 7. Public Production Update Feed ([LIVE STATUS])...
[LIVE STATUS] [MANIFEST_LIVE=True, PACKAGE_LIVE=True, PACKAGE_HASH_VERIFIED=True] Manifest live (vbuild-2026.08.31) and release asset verified at https://github.com/ochenstarik-ui/hermes-hub/releases/download/build-2026.08.31/checksums.txt
======================================================================
[RELEASE GATE: PASSED] All criteria verified. Ready for Candidate v0.1.1
======================================================================

View file

@ -0,0 +1,28 @@
Router failover for role 'manager': switched from failed profile 'synth-codex' to 'synth-ag'
======================================================================
HERMES MULTI-PROVIDER ACCOUNT ROUTER: AUTOMATED VERIFICATION
======================================================================
1. Checking profile inventory and provider counts...
[PASS] Чистая конфигурация по умолчанию (0 профилей, добавление по мере подключения)
2. Checking role fallback policies...
[INFO] Роли с пустыми цепочками (чистый старт / не назначены): analyst, code-reviewer, cost-controller, dependency-agent, developer-1, developer-2, guardian, integration-expert, manager, researcher, security-expert, tech-writer, tester
[PASS] Все 13 ролей валидны и ссылаются только на существующие профили
3. Checking model family extraction...
[PASS] Model family parsing correct
4. Checking health states and simulated quota...
[PASS] Quota simulation and recovery verified
5. Checking session affinity engine...
[PASS] Session affinity tracking & update verified
6. Checking concurrency lease limits...
[PASS] Concurrency leases correctly enforced
7. Checking Antigravity profile environment directory isolation...
[PASS] Profile directory isolated at /home/ochenstarik/.hermes/agy_profiles/ag-w2
8. Checking provider error classification...
[PASS] Error classifications for all 3 providers verified
9. Checking full failover execution loop...
[PASS] Отказоустойчивость прошла цепочку synth-codex -> synth-ag
10. Checking disabled router passthrough...
[PASS] Router clean bypass mode verified
----------------------------------------------------------------------
VERIFICATION COMPLETE: 10/10 CHECKS PASSED (0 errors, 0 warnings)
======================================================================

View file

@ -0,0 +1,32 @@
........................................................................ [ 14%]
........................................................................ [ 29%]
........................................................................ [ 43%]
........................................................................ [ 58%]
...s.................................................................... [ 72%]
........................................................................ [ 87%]
................................................................ [100%]
=============================== warnings summary ===============================
src/antigravity_provider/router/web/server.py:437
/srv/projects/Agent projects/hermes-hub-a48/src/antigravity_provider/router/web/server.py:437: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
@app.on_event("startup")
../hermes-hub/.venv/lib/python3.11/site-packages/fastapi/applications.py:4681
/srv/projects/Agent projects/hermes-hub/.venv/lib/python3.11/site-packages/fastapi/applications.py:4681: DeprecationWarning:
on_event is deprecated, use lifespan event handlers instead.
Read more about it in the
[FastAPI docs for Lifespan Events](https://fastapi.tiangolo.com/advanced/events/).
return self.router.on_event(event_type) # ty: ignore[deprecated]
../hermes-hub/.venv/lib/python3.11/site-packages/fastapi/testclient.py:1
/srv/projects/Agent projects/hermes-hub/.venv/lib/python3.11/site-packages/fastapi/testclient.py:1: StarletteDeprecationWarning: Using `httpx` with `starlette.testclient` is deprecated; install `httpx2` instead.
from starlette.testclient import TestClient as TestClient # noqa
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
495 passed, 1 skipped, 4 deselected, 3 warnings in 21.28s

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

View file

@ -0,0 +1,4 @@
<!doctype html><html lang="ru"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>A48 — визуальная проверка, НЕ ПРИНЯТО</title>
<style>:root{--paper:#f7f1e3;--ink:#1a2a1f;--line:#c9bea4;--accent:#866019}*{box-sizing:border-box}body{margin:24px;background:var(--paper);color:var(--ink);font:15px/1.5 system-ui}header{max-width:1000px}h1{font-size:26px}strong{color:var(--accent)}select{padding:8px;background:var(--paper);color:var(--ink);border:1px solid var(--line)}.comparison{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px}figure{margin:0;border:1px solid var(--line);padding:8px}img{width:100%;height:auto}figcaption{min-height:48px;font-size:12px}section{margin:32px 0}a{color:var(--accent)}@media(max-width:800px){.comparison{grid-template-columns:1fr}}</style>
<header><h1>A48 · Сравнение с макетами</h1><p><strong>Промежуточный материал. A48 не принято.</strong> «До»: живой хаб, сборка 81a58f6, 21 снимок. Справа: только проверка вёрстки без авторизованных данных, НЕ приёмочные скриншоты «после». Полный проход инспектора, холста и тем после входа ещё не выполнен.</p><p>База кода: 80aab00. Контрольная ширина снимков: 1280 px. Макеты имеют разные исходные размеры; изображения показаны полностью, без подмены данных.</p><label>Тема снимков <select id="theme"><option value="dark">Тёмная</option><option value="medium">Средняя</option><option value="light">Светлая</option></select></label></header><main id="gallery"></main>
<script>const views=[['overview','Обзор'],['accounts','Аккаунты'],['routing','Маршрутизация'],['analytics','Аналитика'],['health','Состояние системы'],['logs','Журнал событий'],['settings','Настройки']];function render(){const theme=document.getElementById('theme').value;document.getElementById('gallery').innerHTML=views.map(([view,label],i)=>`<section><h2>${i+1}. ${label}</h2><div class="comparison">${[[`reference/${i+1}.1.png`,`Макет ${i+1}.1 — пример данных`],[`before/${view}-${theme}.png`,'ДО · живой API'],[`layout-only/${view}-${theme}.png`,'ПРОМЕЖУТОЧНО · вёрстка без входа, не приёмка']].map(([src,title])=>`<figure><figcaption>${title}</figcaption><a href="${src}"><img src="${src}" alt="${label} · ${title}" loading="lazy"></a></figure>`).join('')}</div></section>`).join('')}document.getElementById('theme').onchange=render;render();</script></html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

@ -88,6 +88,7 @@ function initNavigation() {
function switchView(viewName) {
activeView = viewName;
document.body.dataset.view = viewName;
document.querySelectorAll('.nav-item').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.view === viewName);
});
@ -98,9 +99,9 @@ function switchView(viewName) {
const titles = {
overview: 'Обзор системы',
accounts: 'Аккаунты и квоты',
routing: 'Главный экран управления маршрутизацией',
routing: 'Маршрутизация',
analytics: 'Аналитика и телеметрия',
health: 'Состояние и диагностика',
health: 'Состояние системы',
logs: 'Журнал событий',
settings: 'Настройки Hermes Hub',
};
@ -533,6 +534,7 @@ function updateGlobalHeader() {
if (!currentSnapshot) return;
const readiness = currentSnapshot.readiness || {};
renderAccountSummary(currentSnapshot);
const allProfiles = Object.values(currentSnapshot.all_profiles || {});
const connectedAccounts = readiness.accounts_connected_count ?? allProfiles.filter(
(p) => isConnectedProfile(p)
@ -725,7 +727,7 @@ function renderAccountCard(profile) {
const roles = (profile.assigned_roles || []).join(', ') || 'Роль: Н/Д';
const identity = profile.email || profile.account_identity || profile.display_name || profile.profile_id;
const healthState = profile.health_state || 'unknown';
const healthLabel = profile.health_label_ru || (profile.enabled ? 'Работает' : 'Отключён');
const healthLabel = profile.health_label_ru || 'Н/Д: состояние не проверялось';
const plan = profile.plan_code && profile.plan_code !== 'UNKNOWN' ? profile.plan_code : '';
const quotaSnap = profile.quota_snapshot || (currentSnapshot.quotas || {})[profile.profile_id];
@ -770,7 +772,7 @@ function renderAccountCard(profile) {
<div class="account-card ${isMain ? 'main-account' : ''}" data-profile-id="${escapeHtml(profile.profile_id)}">
<div class="account-card-header">
<div class="account-provider-tag">
<span>${escapeHtml(profile.provider_display_name || profile.provider)}</span>
<img src="/static/${getProviderIcon(profile.provider)}" alt=""><span>${escapeHtml(profile.provider_display_name || profile.provider)}</span>
</div>
<div class="account-badges">
${plan ? `<span class="badge badge-plan">${escapeHtml(plan)}</span>` : ''}
@ -999,171 +1001,7 @@ function getProviderIcon(provider) {
return map[provider] || 'llama.png';
}
function renderRoutingView() {
const leftCol = document.getElementById('routing-roles-container');
const rightCol = document.getElementById('routing-available-container');
if (!leftCol || !rightCol || !currentSnapshot) return;
const routing = currentSnapshot.routing || {};
const agents = currentSnapshot.agents || [];
// Снапшот отдаётся как dataclasses.asdict(HubSnapshot): профили лежат в
// all_profiles. Ключа profiles в ответе нет — правая колонка была всегда
// пуста, счётчик оставался литеральным «0 аккаунтов» из разметки, а строки
// цепочек получали пустой профиль и рисовались иконкой-заглушкой.
// Показываем только подключённые аккаунты — правило A26.
// Для строк цепочек берём полный список: цепочка может ссылаться на
// аккаунт, который сейчас не подключён, и его надо показать как есть,
// а не подменять пустым профилем.
const profiles = currentSnapshot.all_profiles || {};
// В колонку «доступные» идут только подключённые — правило A26.
const availableProfiles = Object.entries(profiles).filter(([, p]) => isConnectedProfile(p));
// Render Left Column (Roles)
let rolesHtml = '';
for (const [roleId, pipeline] of Object.entries(routing)) {
const chain = pipeline.preferred_chain || [];
const agentInfo = agents.find((a) => a.role_id === roleId);
let isImportant = ['manager', 'developer-1', 'developer-2'].includes(roleId);
let badgeHtml = isImportant ? `<span class="role-badge"><i class="fa-solid fa-star"></i> Важная роль</span>` : '';
let roleDesc = CANONICAL_ROLE_DESCRIPTIONS[roleId] || '';
rolesHtml += `
<div class="role-section" data-role="${escapeHtml(roleId)}">
<div class="role-header">
<div>
<h4><i class="fa-solid fa-network-wired" style="color:var(--accent);"></i> ${escapeHtml(pipeline.role_name_ru || roleId)} ${badgeHtml}</h4>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">${escapeHtml(roleDesc)}</div>
</div>
<div style="display:flex; align-items:center; gap:12px;">
<span style="font-size:12px; color:var(--text-secondary);">${chain.length} аккаунта</span>
<button class="btn btn-secondary btn-sm" onclick="openAddNodeToChainModal('${escapeHtml(roleId)}')">
+ Добавить аккаунт
</button>
<i class="fa-solid fa-ellipsis-vertical" style="color:var(--text-muted); cursor:pointer;"></i>
</div>
</div>
<div class="grid-header">
<div><!-- drag handle --></div>
<div>Приоритет</div>
<div>Аккаунт</div>
<div>Модель</div>
<div>Провайдер</div>
<div>Квоты</div>
<div>Сброс</div>
<div>Статус</div>
<div><!-- remove --></div>
</div>
<div class="role-chain-list" id="chain-${escapeHtml(roleId)}" style="min-height: 10px;">
`;
chain.forEach((pid, index) => {
const prof = profiles[pid] || {};
const prov = prof.provider || 'unknown';
const icon = getProviderIcon(prov);
const quotaSnap = prof.quota_snapshot || (currentSnapshot.quotas || {})[pid] || {};
const buckets = quotaSnap.buckets || [];
let quotaBarsHtml = '';
let resetHtml = '—';
if (buckets.length > 0) {
quotaBarsHtml = buckets.slice(0, 2).map((b) => {
const pct = typeof b.remaining_percent === 'number' ? Math.max(0, Math.min(100, b.remaining_percent)) : 0;
const color = pct < 20 ? 'var(--status-error)' : pct < 50 ? 'var(--status-warning)' : 'var(--status-healthy)';
return `<div class="cell-bar-track" style="margin-top:2px;" title="${escapeHtml(b.label || 'Квота')}: ${pct}%"><div class="cell-bar-fill" style="width:${pct}%; background:${color};"></div></div>`;
}).join('');
if (buckets[0].reset_time_formatted) {
resetHtml = escapeHtml(buckets[0].reset_time_formatted);
} else if (buckets[0].reset_after_formatted) {
resetHtml = escapeHtml(buckets[0].reset_after_formatted);
}
} else {
const reason = quotaSnap.unavailable_reason || (prof.enabled === false ? 'Отключён' : 'Провайдер не отдаёт лимиты');
quotaBarsHtml = `<div style="font-size:10px; color:var(--text-muted);" title="${escapeHtml(reason)}">Н/Д</div>`;
}
const healthClass = prof.health_state || (prof.enabled ? 'healthy' : 'disabled');
const healthLabel = prof.health_label_ru || (prof.enabled ? 'Активен' : 'Отключён');
rolesHtml += `
<div class="account-row draggable-item" draggable="true" data-pid="${escapeHtml(pid)}" data-role="${escapeHtml(roleId)}">
<div class="drag-handle"><i class="fa-solid fa-grip-vertical"></i></div>
<div style="font-weight:600; color:var(--text-primary); text-align:center;">${index + 1}</div>
<div style="display:flex; align-items:center; gap:8px;">
<img src="/static/${icon}" class="provider-logo-sm" onerror="this.src='/static/llama.png'">
<div>
<div style="font-weight:600;">${escapeHtml(pid)}</div>
<div style="font-size:10px; color:var(--text-muted);">${escapeHtml(prof.account_id || '')}</div>
</div>
</div>
<div>
<select class="form-control" style="padding:2px 4px; font-size:11px;" disabled>
<option>${escapeHtml(prof.default_model || 'Default')}</option>
</select>
</div>
<div style="display:flex; align-items:center; gap:4px;">
<img src="/static/${icon}" class="provider-logo-sm" onerror="this.src='/static/llama.png'">
${escapeHtml(prov)}
</div>
<div>
${quotaBarsHtml}
</div>
<div style="font-size:10px; color:var(--text-muted);">
${resetHtml}
</div>
<div>
<span class="status-dot ${escapeHtml(healthClass)}"></span>
<span style="font-size:11px; margin-left:4px;">${escapeHtml(healthLabel)}</span>
</div>
<div style="text-align:center; cursor:pointer; color:var(--status-warning);" onclick="removeProfileFromChain('${escapeHtml(roleId)}', '${escapeHtml(pid)}')">
<i class="fa-solid fa-xmark"></i>
</div>
</div>
`;
});
rolesHtml += `
</div>
<div class="drop-zone" data-role="${escapeHtml(roleId)}">
<i class="fa-solid fa-arrows-up-down"></i> Перетащите аккаунт сюда для добавления в конец списка
</div>
</div>
`;
}
leftCol.innerHTML = rolesHtml;
// Render Right Column (Available Accounts)
let availHtml = '';
const searchEl = document.getElementById('routing-account-search');
const q = searchEl ? searchEl.value.toLowerCase() : '';
let count = 0;
for (const [pid, prof] of availableProfiles) {
if (q && !pid.toLowerCase().includes(q) && !(prof.provider||'').toLowerCase().includes(q)) continue;
count++;
const icon = getProviderIcon(prof.provider);
availHtml += `
<div class="available-account-card draggable-item" draggable="true" data-pid="${escapeHtml(pid)}" data-source="available">
<img src="/static/${icon}" style="width:24px; height:24px; border-radius:4px;" onerror="this.src='/static/llama.png'">
<div style="flex:1;">
<div style="font-weight:600; font-size:12px;">${escapeHtml(pid)}</div>
<div style="font-size:10px; color:var(--text-muted);">${escapeHtml(prof.provider || '')}</div>
</div>
<div style="width:40px;">
<!-- Здесь стояла полоса с зашитым width:80% одинаковая у всех
аккаунтов и ни на чём не основанная. Показываем измеренное
состояние здоровья, а не выдуманный процент. -->
<span class="status-dot ${healthDotClass(prof.health_state)}" title="${escapeHtml(prof.health_label_ru || 'Н/Д: состояние не проверялось')}"></span>
</div>
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;" onclick="quickAddProfile('${escapeHtml(pid)}')"><i class="fa-solid fa-plus"></i></button>
</div>
`;
}
rightCol.innerHTML = availHtml;
const countEl = document.getElementById('available-accounts-count');
if (countEl) countEl.innerText = `${count} аккаунтов`;
setupDragAndDrop();
}
function renderRoutingView() { renderAccountRouting(); }
function quickAddProfile(pid) {
const routing = currentSnapshot.routing || {};
@ -1242,7 +1080,7 @@ function handleDropMove(pid, sourceRole, targetRole, insertIndex) {
const routing = currentSnapshot.routing;
if (!routing || !routing[targetRole]) return;
const targetChain = [...(routing[targetRole].preferred_chain || [])];
const targetChain = (routing[targetRole].nodes || []).map(node => node.profile_id);
if (sourceRole && sourceRole === targetRole) {
const oldIndex = targetChain.indexOf(pid);
@ -1272,24 +1110,14 @@ function handleDropMove(pid, sourceRole, targetRole, insertIndex) {
}
async function updateRoleChain(roleId, newChain) {
try {
const resp = await fetch(`/api/v1/router/roles/${encodeURIComponent(roleId)}/chain`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newChain)
});
if (!resp.ok) throw new Error(await resp.text());
showToast(`Цепочка для ${roleId} обновлена`, 'success');
await fetchSnapshot();
} catch (e) {
showToast(`Ошибка сохранения: ${e.message}`, 'error');
}
const result = await executeAction('save_chain', { role_id: roleId, chain: newChain });
if (result.ok) await fetchSnapshot();
}
async function removeProfileFromChain(roleId, pid) {
const routing = currentSnapshot.routing;
if (!routing || !routing[roleId]) return;
const chain = [...(routing[roleId].preferred_chain || [])];
const chain = (routing[roleId].nodes || []).map(node => node.profile_id);
const idx = chain.indexOf(pid);
if (idx > -1) {
chain.splice(idx, 1);
@ -1304,7 +1132,7 @@ function openAddNodeToChainModal(roleId) {
const currentChain = (pipeline.nodes || []).map((n) => n.profile_id);
const allProfiles = currentSnapshot.all_profiles || {};
const available = Object.values(allProfiles).filter((p) => !currentChain.includes(p.profile_id));
const available = Object.values(allProfiles).filter((p) => isConnectedProfile(p) && !currentChain.includes(p.profile_id));
elements.modalTitle.textContent = `Добавить профиль в цепочку: ${pipeline.role_name_ru || roleId}`;
if (available.length === 0) {
@ -1382,8 +1210,8 @@ function renderAnalyticsView() {
if (global.total_calls !== undefined) {
if (totalCallsEl) totalCallsEl.textContent = new Intl.NumberFormat('ru-RU').format(global.total_calls);
if (callsBreakdownEl) callsBreakdownEl.textContent = `Успешно: ${global.successful_calls ?? 0} · Сбоев: ${global.failed_calls ?? 0}`;
const errRate = global.total_calls > 0 ? (((global.failed_calls ?? 0) / global.total_calls) * 100).toFixed(1) : '0.0';
if (errorRateEl) errorRateEl.textContent = `${errRate}%`;
const errRate = global.total_calls > 0 && global.failed_calls != null ? ((global.failed_calls / global.total_calls) * 100).toFixed(1) : null;
if (errorRateEl) errorRateEl.textContent = errRate === null ? 'Н/Д' : `${errRate}%`;
if (errorRateSubEl) errorRateSubEl.textContent = `${global.failed_calls ?? 0} сбоев из ${global.total_calls} вызовов`;
} else {
if (totalCallsEl) totalCallsEl.textContent = '—';
@ -1392,7 +1220,7 @@ function renderAnalyticsView() {
if (errorRateSubEl) errorRateSubEl.textContent = 'Нет зарегистрированных сбоев';
}
if (global.latency_p50_ms !== undefined) {
if (Number.isFinite(global.latency_p50_ms)) {
if (latencyP50El) latencyP50El.textContent = `${global.latency_p50_ms} мс`;
if (latencySubEl) latencySubEl.textContent = `p95: ${global.latency_p95_ms ?? '—'} мс · max: ${global.latency_max_ms ?? '—'} мс`;
} else {
@ -1402,15 +1230,17 @@ function renderAnalyticsView() {
if (global.total_tokens !== undefined && global.total_tokens !== null) {
if (tokensTotalEl) tokensTotalEl.textContent = new Intl.NumberFormat('ru-RU').format(global.total_tokens);
if (tokensSubEl) tokensSubEl.textContent = `Вход: ${new Intl.NumberFormat('ru-RU').format(global.prompt_tokens ?? 0)} · Выход: ${new Intl.NumberFormat('ru-RU').format(global.completion_tokens ?? 0)}`;
if (tokensSubEl) tokensSubEl.textContent = `Вход: ${new Intl.NumberFormat('ru-RU').format(global.total_prompt_tokens ?? 'Н/Д')} · Выход: ${new Intl.NumberFormat('ru-RU').format(global.total_completion_tokens ?? 'Н/Д')}`;
} else {
if (tokensTotalEl) tokensTotalEl.textContent = 'Н/Д';
if (tokensSubEl) tokensSubEl.textContent = 'Н/Д: провайдеры не отдают usage';
}
renderAnalyticsCharts(telemetry);
// Providers telemetry table
const providersContainer = document.getElementById('analytics-providers-table');
const providersData = telemetry.providers || {};
const providersData = telemetry.by_provider || {};
if (providersContainer) {
const provKeys = Object.keys(providersData);
if (provKeys.length === 0) {
@ -1439,7 +1269,7 @@ function renderAnalyticsView() {
<td style="color:var(--status-healthy);">${item.successful_calls ?? 0}</td>
<td style="color:${(item.failed_calls ?? 0) > 0 ? 'var(--status-error)' : 'inherit'};">${item.failed_calls ?? 0}</td>
<td>${item.latency_p50_ms ? `${item.latency_p50_ms} мс` : '—'}</td>
<td>${item.total_tokens ? new Intl.NumberFormat('ru-RU').format(item.total_tokens) : 'Н/Д'}</td>
<td>${item.total_tokens != null ? new Intl.NumberFormat('ru-RU').format(item.total_tokens) : 'Н/Д'}</td>
</tr>
`;
});
@ -1450,7 +1280,7 @@ function renderAnalyticsView() {
// Roles telemetry table
const rolesContainer = document.getElementById('analytics-roles-table');
const rolesData = telemetry.roles || {};
const rolesData = telemetry.by_role || {};
if (rolesContainer) {
const roleKeys = Object.keys(rolesData);
if (roleKeys.length === 0) {
@ -1493,7 +1323,7 @@ function renderHealthView() {
const readiness = currentSnapshot.readiness || {};
const banner = document.getElementById('health-readiness-banner');
if (banner) {
const stateClass = readiness.state === 'READY' ? 'ready' : (readiness.state === 'DEGRADED' ? 'warning' : 'not-ready');
const stateClass = readiness.state === 'HEALTHY' ? 'ready' : (readiness.state === 'LIMITED' ? 'warning' : 'not-ready');
banner.className = `readiness-banner ${stateClass}`;
banner.innerHTML = `
<div class="readiness-banner-header">
@ -1502,7 +1332,7 @@ function renderHealthView() {
</div>
<p class="readiness-banner-desc">${escapeHtml(readiness.summary_ru || 'Проверка состояния маршрутизатора')}</p>
<div class="readiness-banner-metrics">
<span>Готовых ролей: <strong>${readiness.roles_ready_count ?? 0} из ${readiness.total_roles ?? 13}</strong></span>
<span>Готовых ролей: <strong>${readiness.roles_ready_count ?? 0} из ${readiness.total_roles ?? 'Н/Д'}</strong></span>
<span>Подключенных аккаунтов: <strong>${(currentSnapshot.all_profiles ? Object.values(currentSnapshot.all_profiles).filter(isConnectedProfile).length : 0)}</strong></span>
</div>
`;
@ -1510,31 +1340,10 @@ function renderHealthView() {
const resContainer = document.getElementById('health-host-resources');
if (resContainer) {
const host = currentSnapshot.host_resources || {};
resContainer.innerHTML = `
<div class="resource-card">
<div class="resource-label">CPU</div>
<div class="resource-value">${host.cpu_percent !== undefined ? `${host.cpu_percent}%` : 'Н/Д'}</div>
<div class="resource-sub">${host.cpu_count ? `${host.cpu_count} ядер` : 'Системный CPU'}</div>
</div>
<div class="resource-card">
<div class="resource-label">Память (RAM)</div>
<div class="resource-value">${host.memory_percent !== undefined ? `${host.memory_percent}%` : 'Н/Д'}</div>
<div class="resource-sub">${host.memory_used_gb ? `${host.memory_used_gb} ГБ / ${host.memory_total_gb} ГБ` : 'Оперативная память'}</div>
</div>
<div class="resource-card">
<div class="resource-label">Диск</div>
<div class="resource-value">${host.disk_percent !== undefined ? `${host.disk_percent}%` : 'Н/Д'}</div>
<div class="resource-sub">${host.disk_free_gb ? `Свободно ${host.disk_free_gb} ГБ` : 'Хранилище'}</div>
</div>
<div class="resource-card">
<div class="resource-label">Время работы (Uptime)</div>
<div class="resource-value">${host.uptime_formatted || host.uptime || 'В сети'}</div>
<div class="resource-sub">Процесс Hermes Hub</div>
</div>
`;
renderHostResources(resContainer, currentSnapshot.metrics?.host || {});
}
renderHealthPanels(currentSnapshot);
const warningsContainer = document.getElementById('health-warnings-list');
if (warningsContainer) {
const warnings = readiness.warnings || [];
@ -1554,21 +1363,9 @@ function renderHealthView() {
let cachedLogs = [];
async function fetchLogs() {
const container = document.getElementById('logs-container');
if (container) {
container.innerHTML = '<div style="padding:16px; text-align:center; color:var(--text-muted);">⏳ Загрузка журнала событий...</div>';
}
try {
const res = await fetch('/api/events');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
cachedLogs = Array.isArray(data) ? data : (data.events || []);
renderLogsList(cachedLogs);
} catch (err) {
if (container) {
container.innerHTML = `<div style="padding:16px; color:var(--status-error); text-align:center;">❌ Ошибка загрузки журнала: ${escapeHtml(err.message)}</div>`;
}
}
if (!currentSnapshot) return;
cachedLogs = [...(currentSnapshot.workflow?.events || [])].reverse();
renderLogsList(cachedLogs);
}
function renderLogsView() {
@ -1589,16 +1386,17 @@ function renderLogsList(events) {
const filtered = listToRender.filter((ev) => {
if (levelFilter !== 'all' && (ev.level || '').toLowerCase() !== levelFilter) return false;
if (catFilter !== 'all' && (ev.category || '').toLowerCase() !== catFilter) return false;
if (catFilter !== 'all' && (ev.type || '').toLowerCase() !== catFilter) return false;
if (q) {
const matchText = `${ev.message || ''} ${ev.details || ''} ${ev.category || ''} ${ev.profile_id || ''}`.toLowerCase();
const matchText = `${ev.message || ''} ${ev.details || ''} ${ev.type || ''} ${ev.account || ''}`.toLowerCase();
if (!matchText.includes(q)) return false;
}
return true;
});
if (filtered.length === 0) {
container.innerHTML = '<div style="padding:24px; text-align:center; color:var(--text-muted); font-size:12px;">События не найдены</div>';
renderLogDetail(null);
container.innerHTML = '<div style="padding:24px; text-align:center; color:var(--text-muted); font-size:12px;">События не найдены в текущем снапшоте</div>';
return;
}
@ -1613,14 +1411,14 @@ function renderLogsList(events) {
</tr>
</thead>
<tbody>
${filtered.map((ev) => {
${filtered.map((ev, index) => {
const lvl = (ev.level || 'info').toLowerCase();
const lvlClass = lvl === 'error' ? 'error' : (lvl === 'warning' || lvl === 'warn' ? 'warning' : (lvl === 'success' ? 'healthy' : 'info'));
return `
<tr>
<tr tabindex="0" data-event-index="${index}" aria-label="Открыть событие">
<td style="font-size:11px; color:var(--text-muted); font-family:var(--font-mono);">${escapeHtml(ev.timestamp || '—')}</td>
<td><span class="badge ${lvlClass}" style="font-size:10px; text-transform:uppercase;">${escapeHtml(ev.level || 'INFO')}</span></td>
<td style="font-size:11px; color:var(--text-secondary);">${escapeHtml(ev.category || 'system')}</td>
<td style="font-size:11px; color:var(--text-secondary);">${escapeHtml(ev.type || 'Н')}</td>
<td style="font-size:12px;">
<div>${escapeHtml(ev.message || '')}</div>
${ev.details ? `<div style="font-size:10px; color:var(--text-muted); font-family:var(--font-mono); margin-top:2px;">${escapeHtml(typeof ev.details === 'object' ? JSON.stringify(ev.details) : ev.details)}</div>` : ''}
@ -1631,21 +1429,27 @@ function renderLogsList(events) {
</tbody>
</table>
`;
container.querySelectorAll('[data-event-index]').forEach(row => {
const open = () => renderLogDetail(filtered[Number(row.dataset.eventIndex)]);
row.addEventListener('click', open);
row.addEventListener('keydown', event => { if (event.key === 'Enter') open(); });
});
renderLogDetail(filtered[0]);
}
// ── SETTINGS MANAGEMENT ──
function renderSettingsView() {
if (!currentSnapshot) return;
const paths = currentSnapshot.system_paths || {};
const s = currentSnapshot.settings || {};
const paths = {}; // HubSnapshot does not expose filesystem paths.
const s = currentSettings;
const elHome = document.getElementById('path-hermes-home');
const elConfig = document.getElementById('path-config-dir');
const elLog = document.getElementById('path-log-file');
if (elHome) elHome.textContent = paths.hermes_home || '';
if (elConfig) elConfig.textContent = paths.config_dir || '';
if (elLog) elLog.textContent = paths.log_file || '';
if (elHome) elHome.textContent = paths.hermes_home || 'Н/Д: API не передаёт путь';
if (elConfig) elConfig.textContent = paths.config_dir || 'Н/Д: API не передаёт путь';
if (elLog) elLog.textContent = paths.log_file || 'Н/Д: API не передаёт путь';
const quotaThresholdSel = document.getElementById('setting-quota-threshold-percent');
const quotaActionSel = document.getElementById('setting-quota-threshold-action');
@ -1672,12 +1476,12 @@ async function saveHubServerSettings() {
const emailMaskingSel = document.getElementById('setting-email-masking-mode');
const monitorIntervalInput = document.getElementById('setting-monitoring-interval');
const newSettings = {
quota_threshold_percent: quotaThresholdSel ? parseFloat(quotaThresholdSel.value) || 10.0 : 10.0,
quota_threshold_action: quotaActionSel ? quotaActionSel.value : 'notify',
email_masking_mode: emailMaskingSel ? emailMaskingSel.value : 'none',
monitoring_interval_seconds: monitorIntervalInput ? parseInt(monitorIntervalInput.value, 10) || 30 : 30,
};
const newSettings = {};
if (quotaThresholdSel?.value) newSettings.quota_threshold_percent = Number(quotaThresholdSel.value);
if (quotaActionSel?.value) newSettings.quota_threshold_action = quotaActionSel.value;
if (emailMaskingSel?.value) newSettings.email_masking_mode = emailMaskingSel.value;
if (monitorIntervalInput?.value) newSettings.monitoring_interval_seconds = Number(monitorIntervalInput.value);
if (!Object.keys(newSettings).length) { showToast('Нет выбранных изменений', 'info'); return; }
showToast('Сохранение настроек сервера...', 'info');
const res = await executeAction('save_settings', newSettings);

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

View file

@ -9,52 +9,53 @@
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@500;600;700;800&family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="static/workflow.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
<link rel="icon" href="static/hermes-logo.png">
</head>
<body>
<div id="app" class="app-layout">
<!-- Sidebar Navigation (9 Full Views) -->
<!-- Seven workspaces share the same navigation. -->
<aside class="sidebar">
<div class="brand">
<div class="brand-logo"></div>
<img class="brand-logo" src="static/hermes-logo.png" alt="Логотип Hermes Hub">
<div class="brand-text">
<span class="brand-title">HERMES HUB</span>
<span class="brand-subtitle">Multi-Account Router</span>
<span class="brand-subtitle">Крона · Бизнес-экосистема</span>
</div>
</div>
<nav class="nav-menu">
<button class="nav-item active" data-view="overview">
<span class="nav-icon">📊</span>
<svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 10 12 3l9 7v11h-6v-7H9v7H3Z"/></svg>
<span class="nav-label">Обзор</span>
</button>
<button class="nav-item" data-view="accounts">
<span class="nav-icon">👥</span>
<svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M16 21v-3a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v3 M9 10a4 4 0 1 0 0-8 4 4 0 0 0 0 8 M17 3a4 4 0 0 1 0 8 M22 21v-3a4 4 0 0 0-3-4"/></svg>
<span class="nav-label">Аккаунты</span>
<span class="nav-badge" id="nav-accounts-count">0</span>
<span class="nav-badge" id="nav-accounts-count"></span>
</button>
<button class="nav-item" data-view="routing">
<span class="nav-icon">🔀</span>
<svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M5 4v16 M5 8h14 M5 16h14 M16 5l3 3-3 3 M16 13l3 3-3 3"/></svg>
<span class="nav-label">Маршрутизация</span>
</button>
<button class="nav-item" data-view="analytics">
<span class="nav-icon">📈</span>
<svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M3 3v18h18 M7 16v-5 M12 16V7 M17 16v-8"/></svg>
<span class="nav-label">Аналитика</span>
</button>
<button class="nav-item" data-view="health">
<span class="nav-icon">🩺</span>
<span class="nav-label">Состояние</span>
<svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2 3 6v6c0 5 9 10 9 10s9-5 9-10V6Z M8 12l3 3 5-6"/></svg>
<span class="nav-label">Состояние системы</span>
</button>
<button class="nav-item" data-view="logs">
<span class="nav-icon">📜</span>
<svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M6 2h9l4 4v16H5V2Z M8 10h8 M8 14h8 M8 18h5"/></svg>
<span class="nav-label">Журнал событий</span>
</button>
<button class="nav-item" data-view="settings">
<span class="nav-icon">⚙️</span>
<svg class="nav-icon" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 3 3 1 2 5-2 5-3 1-1 3H9l-1-3-3-1-2-5 2-5 3-1Z M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8"/></svg>
<span class="nav-label">Настройки</span>
</button>
</nav>
<div class="brand-panel"><img src="static/hermes-logo.png" alt=""><p>Рабочее пространство<br>вашей агентной системы</p><span>Hermes Hub · Крона</span></div>
<div class="sidebar-footer">
<div class="source-indicator" id="source-indicator">
<span class="status-dot healthy"></span>
@ -80,7 +81,7 @@
<span id="header-update-text">Доступно обновление</span>
</button>
</div>
<div class="header-actions">
<div class="header-actions"><button class="header-search" id="hub-search-open" aria-label="Поиск по хабу"><span>⌕ Поиск</span><kbd>Ctrl K</kbd></button><button class="header-icon" id="hub-notifications" aria-label="Уведомления" title="Последние события"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0v6l2 3H4l2-3Z M9 20h6"/></svg></button><button class="header-icon" id="hub-settings" aria-label="Открыть настройки" title="Настройки"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 3 3 1 2 5-2 5-3 1-1 3H9l-1-3-3-1-2-5 2-5 3-1Z M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8"/></svg></button><div class="hub-user" title="Н/Д: API не передаёт профиль пользователя"><span>H</span><div>Локальный хаб<small>Профиль пользователя: Н</small></div></div></div><div class="header-page-actions">
<button class="btn btn-secondary" id="btn-refresh-all" title="Обновить квоты всех профилей">
<span class="btn-icon"></span>
<span>Обновить всё</span>
@ -110,10 +111,10 @@
<div class="workflow-kpis" aria-label="Оперативные показатели">
<article class="workflow-kpi"><span>Активные задачи</span><strong id="workflow-kpi-active">Загрузка…</strong><small id="workflow-kpi-active-reason">Получение снапшота</small></article>
<article class="workflow-kpi"><span>Агенты онлайн</span><strong id="workflow-kpi-online">Загрузка…</strong><small id="workflow-kpi-online-reason">Получение снапшота</small></article>
<article class="workflow-kpi"><span>Среднее время ответа</span><strong id="workflow-kpi-latency">Загрузка…</strong><small id="workflow-kpi-latency-reason">Получение телеметрии</small></article>
<article class="workflow-kpi"><span>Готовность ролей</span><strong id="workflow-kpi-online">Загрузка…</strong><small id="workflow-kpi-online-reason">Получение снапшота</small></article>
<article class="workflow-kpi"><span>Ответ модели · p50</span><strong id="workflow-kpi-latency">Загрузка…</strong><small id="workflow-kpi-latency-reason">Получение телеметрии</small></article>
<article class="workflow-kpi"><span>Использование токенов</span><strong id="workflow-kpi-tokens">Загрузка…</strong><small id="workflow-kpi-tokens-reason">Получение телеметрии</small></article>
<article class="workflow-kpi"><span>Успешность задач</span><strong id="workflow-kpi-success">Загрузка…</strong><small id="workflow-kpi-success-reason">Получение телеметрии</small></article>
<article class="workflow-kpi"><span>Успешность вызовов</span><strong id="workflow-kpi-success">Загрузка…</strong><small id="workflow-kpi-success-reason">Получение телеметрии</small></article>
<article class="workflow-kpi"><span>Статус системы</span><strong id="workflow-kpi-status">Загрузка…</strong><small id="workflow-kpi-status-reason">Проверка готовности</small></article>
</div>
@ -125,9 +126,9 @@
<div class="workflow-zoom"><button id="workflow-zoom-out" title="Уменьшить"></button><span id="workflow-zoom-value">100%</span><button id="workflow-zoom-in" title="Увеличить">+</button><button id="workflow-fit" title="Вписать граф"></button></div>
</header>
<div class="workflow-canvas" id="workflow-canvas" tabindex="0" aria-label="Редактор графа workflow">
<div id="workflow-viewport" class="workflow-viewport">
<div class="canvas-tools" role="toolbar" aria-label="Инструменты холста"><button id="canvas-select" title="Выбор агента" aria-label="Выбор агента"></button><button id="canvas-add" title="Добавить агента" aria-label="Добавить агента на холст">+</button><button id="canvas-connect" title="Соединить агентов в EDIT" aria-label="Создать связь агентов"></button><button id="canvas-fit" title="Вписать граф" aria-label="Вписать весь граф"></button><button id="canvas-arrange" title="Разнести узлы (черновик)" aria-label="Разнести узлы"></button></div><div id="workflow-viewport" class="workflow-viewport">
<svg id="workflow-edges" class="workflow-edges" aria-hidden="true"><defs><marker id="wf-arrow" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z"></path></marker></defs><g id="workflow-edge-layer"></g></svg>
<div id="workflow-node-layer" class="workflow-node-layer"></div>
<div id="workflow-node-layer" class="workflow-node-layer"></div><svg class="workflow-labels" aria-hidden="true"><g id="workflow-label-layer"></g></svg>
</div>
<div id="workflow-empty" class="workflow-empty hidden"><strong>В workflow пока нет агентов</strong><span>Добавьте агента, затем переключитесь в EDIT и соедините узлы.</span></div>
<div class="workflow-minimap" id="workflow-minimap" aria-label="Мини-карта"></div>
@ -145,7 +146,7 @@
<div class="workflow-bottom-grid">
<section class="workflow-events"><header><strong>Последние события</strong><button id="workflow-events-all">Журнал событий →</button></header><div id="workflow-events-list" class="workflow-events-list"><p>Загрузка событий…</p></div></section>
<section class="workflow-run-panel"><header><strong>Управление LIVE</strong></header><textarea id="workflow-task" placeholder="Опишите реальную задачу для workflow"></textarea><div><button class="btn btn-primary" id="btn-workflow-start">Запустить workflow</button><button class="btn btn-secondary" id="btn-workflow-stop">Остановить</button></div><p id="workflow-run-error" class="workflow-run-error"></p></section>
<section class="workflow-statistics"><header><strong>Статистика workflow</strong></header><div id="workflow-statistics-content">Н/Д: снапшот ещё не получен</div></section><section class="workflow-run-panel"><header><strong>Активные задачи · LIVE</strong></header><div id="workflow-active-task" class="inspector-value"></div><textarea aria-label="Задача workflow" id="workflow-task" placeholder="Опишите реальную задачу для workflow"></textarea><div><button class="btn btn-primary" id="btn-workflow-start">Запустить workflow</button><button class="btn btn-secondary" id="btn-workflow-stop">Остановить</button></div><p id="workflow-run-error" class="workflow-run-error"></p></section>
</div>
</section>
@ -181,12 +182,12 @@
<span>📥 Экспорт лимитов (JSON / CSV)</span>
</button>
<div class="toolbar-stats" id="accounts-stats-summary">
Показано: <strong>0</strong> из <strong>0</strong> аккаунтов
Список аккаунтов ещё не получен
</div>
</div>
</div>
<div id="accounts-container" class="accounts-groups-container">
<div class="account-summary" id="account-summary"></div><div id="accounts-container" class="accounts-groups-container">
<!-- Rendered by app.js -->
</div>
</section>
@ -214,7 +215,7 @@
<div style="padding: 16px; border-bottom: 1px solid var(--border-subtle);">
<div style="display: flex; justify-content: space-between; margin-bottom: 12px;">
<h3 style="font-family: var(--font-title); font-size: 14px; text-transform: uppercase;">Доступные аккаунты</h3>
<span id="available-accounts-count" class="text-muted" style="font-size: 12px;">0 аккаунтов</span>
<span id="available-accounts-count" class="text-muted" style="font-size: 12px;">Список ещё не получен</span>
</div>
<div class="input-group">
<input type="text" id="routing-account-search" placeholder="Поиск по аккаунтам, ролям или моделям..." style="width: 100%;">
@ -263,7 +264,7 @@
</div>
</div>
<div class="section-card">
<div class="analytics-workspace"><section class="section-card"><h2 class="section-card-title">Использование токенов</h2><div id="analytics-token-chart" class="metric-chart">Н/Д: снапшот ещё не получен</div></section><section class="section-card"><h2 class="section-card-title">Запросы по ролям</h2><div id="analytics-role-chart" class="metric-chart">Н/Д: снапшот ещё не получен</div></section><aside class="section-card"><h2 class="section-card-title">Окно измерений</h2><p class="inspector-value">Последние 24 часа</p><p class="inspector-value">Источник: telemetry из /api/snapshot. Произвольный период API не предоставляет.</p><button class="btn btn-secondary" id="analytics-refresh">Обновить</button></aside></div><div class="analytics-tables"><div class="section-card">
<div class="section-card-header">
<div class="section-card-title">Телеметрия по провайдерам ИИ</div>
<div class="section-card-subtitle">Распределение запросов, отказов и задержек в окне измерений</div>
@ -282,7 +283,7 @@
<!-- Rendered by app.js -->
</div>
</div>
</section>
</div></section>
<!-- 7. HEALTH VIEW (P0-2) -->
<section id="view-health" class="view-pane">
@ -323,6 +324,7 @@
<!-- Rendered by app.js -->
</div>
</div>
<div id="health-workspace" class="health-workspace"></div>
</section>
<!-- 8. LOGS / EVENTS VIEW (P0-3) -->
@ -342,11 +344,7 @@
</select>
<select id="logs-filter-category" class="select-filter">
<option value="all">Все категории</option>
<option value="system">Система (system)</option>
<option value="account">Аккаунты (account)</option>
<option value="quota">Квоты (quota)</option>
<option value="routing">Маршрутизация (routing)</option>
<option value="auth">Авторизация (auth)</option>
<option value="workflow_started">Запуск workflow</option><option value="agent_started">Запуск агента</option><option value="agent_completed">Завершение агента</option><option value="workflow_failed">Ошибка workflow</option>
</select>
<button class="btn btn-secondary btn-sm" id="btn-refresh-logs">↻ Обновить журнал</button>
</div>
@ -355,11 +353,11 @@
<div class="section-card" style="margin-top:12px;">
<div class="section-card-header">
<div class="section-card-title">Журнал событий Hermes Hub</div>
<div class="section-card-subtitle">События маршрутизатора, обновления квот, авторизации и тестов</div>
</div>
<div class="logs-container" id="logs-container">
<!-- Rendered by app.js from /api/events -->
<div class="section-card-subtitle">События workflow из текущего снапшота · до 60 последних записей</div>
</div>
<div class="logs-layout"><div class="logs-container" id="logs-container">
<!-- Workflow events from HubSnapshot -->
</div><aside id="log-detail" class="log-detail"><h2>Детали события</h2><p>Выберите событие в таблице.</p></aside></div>
</div>
</section>
@ -477,7 +475,7 @@
<div class="setting-desc" id="update-installed-info">Hermes Hub v0.1.1</div>
</div>
<div class="setting-control" style="display:flex; align-items:center; gap:8px;">
<span id="update-status-badge" class="badge healthy">Актуально</span>
<span id="update-status-badge" class="badge">Не проверено</span>
</div>
</div>
<div class="setting-row">
@ -603,6 +601,7 @@
<!-- Application Logic -->
<script src="app.js"></script>
<script src="static/workspace.js"></script>
<script src="static/workflow.js"></script>
<script>
const urlParams = new URLSearchParams(window.location.search);

View file

@ -1737,3 +1737,170 @@ body {
border-bottom: 1px solid var(--border-subtle);
background-color: var(--surface-muted);
}
/* A48 Крона workspace. Surface and chrome have separate ink tokens so
cream cards remain legible against the medium theme's green workspace. */
:root, body[data-theme="dark"] {
--bg-base:#07120f; --bg-sidebar:#06110e; --bg-header:#07120f;
--surface:#0d1b16; --surface-muted:#10231c; --surface-hover:#172e23;
--surface-active:#253b27; --surface-selected:#182e20;
--border:#3d3924; --border-subtle:#2d3423; --border-accent:#9f7938;
--text-primary:#f2eedf; --text-secondary:#c5cbbb; --text-muted:#a3b0a0;
--accent:#dab568; --text-accent:#dab568; --chrome-ink:#f2eedf;
--chrome-muted:#b5bca9; --canvas-bg:#081811; --canvas-ink:#d8d4b6;
--surface-card:var(--surface); --accent-ink:#211c0e;
--sidebar-width:244px; --header-height:76px;
}
body[data-theme="medium"] {
--bg-base:#1a2a1f; --bg-sidebar:#102219; --bg-header:#15271c;
--surface:#f7f1e3; --surface-muted:#eee6d3; --surface-hover:#e8dec6;
--surface-active:#e4d7b8; --surface-selected:#eae0c8;
--border:#c9bea4; --border-subtle:#dbcfb5; --border-accent:#9b7429;
--text-primary:#1c2b20; --text-secondary:#3a4d3d; --text-muted:#57624e;
--accent:#89631d; --text-accent:#89631d; --chrome-ink:#f7f1e3;
--chrome-muted:#c3cbb9; --canvas-bg:#1a2a1f; --canvas-ink:#e5d2a6;
--status-healthy:#42732c; --status-warning:#86580e; --status-error:#a93428;
--status-info:#396ba0; --accent-ink:#fff9e9;
}
body[data-theme="light"] {
--bg-base:#f7f1e3; --bg-sidebar:#f3ecdb; --bg-header:#f7f1e3;
--surface:#fcf8ee; --surface-muted:#f0e8d7; --surface-hover:#eee3ca;
--surface-active:#e7d7b2; --surface-selected:#f0e4cb;
--border:#d4c5a5; --border-subtle:#e3d7be; --border-accent:#a67b2e;
--text-primary:#202b21; --text-secondary:#414e3e; --text-muted:#606951;
--accent:#8b631c; --text-accent:#8b631c; --chrome-ink:#202b21;
--chrome-muted:#606951; --canvas-bg:#f5eedb; --canvas-ink:#5c593e;
--status-healthy:#42732c; --status-warning:#86580e; --status-error:#a93428;
--status-info:#396ba0; --accent-ink:#fff9e9;
}
body { font-size:13px; font-variant-numeric:tabular-nums; user-select:text; }
button,input,select,textarea { font:inherit; }
button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible,[tabindex]:focus-visible { outline:2px solid var(--accent); outline-offset:3px; }
button:disabled { opacity:.5; cursor:not-allowed; }
.brand { min-height:104px; padding:16px 12px; gap:6px; border:0; }
.brand-logo { width:68px; height:68px; object-fit:contain; flex-shrink:0; }
.brand-title { font-size:18px; font-weight:500; letter-spacing:.6px; }
.brand-subtitle { font-size:9px; letter-spacing:0; color:var(--chrome-muted); }
.nav-menu { padding:8px 12px; gap:5px; flex:0 1 auto; }
.nav-item { min-height:45px; padding:10px 12px; gap:14px; color:var(--chrome-ink); border-radius:6px; }
.nav-item.active { border-color:var(--border-accent); background:var(--surface-selected); color:var(--text-accent); }
.nav-icon { width:23px; height:23px; fill:none; stroke:var(--accent); stroke-width:1.4; stroke-linecap:round; stroke-linejoin:round; flex-shrink:0; }
body[data-theme="medium"] .nav-icon { stroke:var(--canvas-ink); }
.brand-panel { margin:auto 14px 18px; padding:20px 10px; border:1px solid var(--border-accent); border-radius:7px; text-align:center; color:var(--chrome-ink); }
.brand-panel img { width:110px; height:110px; object-fit:contain; }
.brand-panel p { font-size:12px; line-height:1.8; margin:12px 0; }
.brand-panel span { color:var(--chrome-muted); font-size:11px; }
.sidebar-footer { padding:14px 16px; border-color:var(--border-accent); }
.sidebar-footer .source-text,.sidebar-footer .version-tag { color:var(--chrome-muted); }
.main-wrapper { min-width:0; }
.top-header { min-height:var(--header-height); height:auto; flex-wrap:wrap; padding:14px 20px; gap:10px 18px; border-color:var(--border-accent); }
.header-left { flex:1; min-width:220px; gap:12px; flex-wrap:wrap; }
.header-title { color:var(--chrome-ink); font-size:20px; font-weight:600; }
.header-readiness-badge { font-weight:400; color:var(--text-secondary); background:var(--surface); }
.header-actions { gap:12px; }
.header-page-actions { display:flex; gap:10px; }
.header-search { min-width:178px; display:flex; align-items:center; justify-content:space-between; gap:16px; padding:9px 12px; border:1px solid var(--border-accent); border-radius:6px; background:transparent; color:var(--chrome-muted); cursor:pointer; }
.header-search kbd { font-size:10px; }
.header-icon { width:32px; height:34px; background:transparent; border:0; font-size:23px; color:var(--chrome-muted); cursor:pointer; }
.hub-user { display:flex; align-items:center; gap:9px; color:var(--chrome-ink); font-size:12px; }
.hub-user>span { display:grid; place-items:center; width:34px; height:34px; border:1px solid var(--border-accent); border-radius:50%; color:var(--chrome-muted); }
.hub-user small { display:block; font-size:9px; color:var(--chrome-muted); }
.content-area { padding:18px 20px; }
.btn { min-height:34px; box-shadow:none; transition:background-color .15s,border-color .15s; }
.btn-primary { background:var(--accent); color:var(--accent-ink); border-color:var(--accent); }
.btn-secondary { background:var(--surface); color:var(--text-accent); border-color:var(--border-accent); }
.status-dot.healthy { box-shadow:none; }
.view-header-note { color:var(--chrome-muted); background:transparent; border:0; padding:0 0 16px; line-height:1.6; }
.toolbar { gap:12px; flex-wrap:wrap; margin-bottom:16px; }
.search-box { min-width:220px; background:var(--surface); border-color:var(--border); }
.filters-row { flex-wrap:wrap; }
.section-card,.settings-card,.kpi-card,.account-card,.resource-card { background:var(--surface); border:1px solid var(--border); border-radius:7px; box-shadow:none; }
.section-card { padding:16px; margin-bottom:14px; min-width:0; }
.section-card-title,.settings-group-title { font-family:var(--font-ui); font-weight:500; color:var(--text-accent); font-size:13px; text-transform:uppercase; letter-spacing:.03em; }
.section-card-subtitle { margin-top:6px; color:var(--text-muted); line-height:1.5; }
.account-summary { display:grid; grid-template-columns:repeat(5,minmax(0,1fr)); gap:0; border:1px solid var(--border); background:var(--surface); border-radius:7px; margin-bottom:16px; }
.account-summary>div { padding:16px; border-right:1px solid var(--border-subtle); }
.account-summary strong { display:block; font-size:24px; font-weight:500; margin:6px 0; }
.account-summary span,.account-summary small { color:var(--text-muted); font-size:11px; }
.accounts-groups-container { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:12px; }
.provider-group,.accounts-grid { display:contents; }
.provider-group-header { display:none; }
.account-card { min-width:0; padding:16px; }
.account-card-header { gap:8px; }
.account-provider-tag { display:flex; align-items:center; gap:10px; }
.account-provider-tag img { width:30px; height:30px; object-fit:contain; }
.account-email,.account-meta { overflow-wrap:anywhere; white-space:normal; }
.account-quota-grid { gap:6px; }
.quota-cell { background:var(--surface-muted); border:1px solid var(--border-subtle); padding:10px; }
.quota-bar-track { height:5px; box-shadow:none; background:var(--border-subtle); }
.routing-grid { display:grid!important; grid-template-columns:minmax(0,2.15fr) minmax(280px,1fr); gap:16px!important; height:calc(100vh - 212px)!important; }
.routing-left-col { min-width:0; }
.routing-right-col { min-width:0; background:var(--surface)!important; }
.role-section { background:var(--surface); padding:12px; margin-bottom:14px; border:1px solid var(--border); border-radius:7px; overflow:auto; }
.role-header { gap:10px; }
.role-header h4 { font:600 14px var(--font-ui); }
.grid-header,.account-row { grid-template-columns:20px 56px minmax(120px,1.2fr) minmax(126px,1fr) minmax(100px,1fr) 88px 82px 26px; gap:8px; min-width:690px; }
.grid-header { font-size:10px; padding:10px 8px; }
.account-row { padding:10px 8px; border:1px solid var(--border-subtle); border-radius:5px; margin:4px 0; }
.account-row>div { min-width:0; overflow-wrap:anywhere; }
.account-row select { width:100%; background:var(--surface-muted); color:var(--text-primary); border:1px solid var(--border); padding:5px; border-radius:4px; }
.available-account-card { background:var(--surface); border:1px solid var(--border-subtle); padding:12px; }
.drop-zone { padding:12px; border:1px dashed var(--border-accent); color:var(--text-accent); }
.drag-over { outline:2px solid var(--accent); outline-offset:-2px; background:var(--surface-selected); }
.route-priority { font-size:9px; color:var(--text-accent); }
.route-quota { font-size:10px; color:var(--text-muted); margin-bottom:5px; }
.route-remove { color:var(--text-accent); background:transparent; border:0; cursor:pointer; padding:5px; }
.overview-grid { grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }
.kpi-card { padding:18px; }
.kpi-value { font-size:28px; font-weight:500; }
.analytics-workspace { display:grid; grid-template-columns:1.25fr 1fr .8fr; gap:14px; }
.analytics-tables { display:grid; grid-template-columns:1.1fr 1fr; gap:14px; }
.metric-chart { min-height:230px; display:flex; flex-direction:column; justify-content:center; gap:12px; color:var(--text-muted); }
.metric-bar { display:grid; grid-template-columns:minmax(80px,1fr) 2fr auto; gap:10px; align-items:center; font-size:11px; }
.metric-bar meter { width:100%; height:10px; accent-color:var(--status-healthy); }
.data-table th { color:var(--text-accent); background:var(--surface); font-weight:500; }
.data-table td { padding:13px 10px; border-color:var(--border-subtle); }
.data-table tr:hover { background:var(--surface-hover); }
.host-resources-grid { grid-template-columns:repeat(5,minmax(0,1fr)); }
.resource-card { padding:16px; }
.resource-value { font-size:24px; }
.logs-layout { display:grid; grid-template-columns:minmax(0,2.4fr) minmax(250px,1fr); gap:16px; }
.logs-container { min-width:0; overflow:auto; max-height:65vh; }
.log-detail { background:var(--surface-muted); border:1px solid var(--border); border-radius:6px; padding:18px; overflow-wrap:anywhere; }
.log-detail h2 { font-size:13px; font-weight:500; color:var(--text-accent); text-transform:uppercase; margin-bottom:20px; }
.log-detail pre { white-space:pre-wrap; font-size:11px; margin-top:16px; }
.log-detail p { margin:10px 0; color:var(--text-secondary); }
#view-settings.active { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:16px; align-items:start; }
#view-settings>.settings-card { margin:0!important; min-width:0; }
.setting-row { padding:14px 0; gap:12px; align-items:start; }
.setting-control { min-width:0; max-width:55%; }
.setting-control input,.setting-control select { max-width:100%; }
.command-results { display:flex; flex-direction:column; gap:8px; padding-top:12px; }
.command-results button { text-align:left; }
@media(min-width:1700px) { #view-settings.active { grid-template-columns:repeat(3,minmax(0,1fr)); } }
@media(max-width:1250px) { :root { --sidebar-width:210px; }.hub-user { display:none; }.accounts-groups-container { grid-template-columns:repeat(2,minmax(0,1fr)); }.routing-grid { grid-template-columns:minmax(0,1.6fr) 280px; }.brand-title { font-size:15px; }.brand-logo { width:50px;height:50px; } }
@media(max-width:950px) { :root { --sidebar-width:180px; }.analytics-workspace,.analytics-tables,.logs-layout { grid-template-columns:1fr; }#view-settings.active { grid-template-columns:1fr; }.host-resources-grid,.account-summary { grid-template-columns:repeat(2,minmax(0,1fr)); }.brand-panel { display:none; }.sidebar-footer { margin-top:auto; }.routing-grid { height:auto!important; grid-template-columns:1fr; }.routing-right-col { max-height:400px; } }
@media(max-width:650px) { :root { --sidebar-width:64px; }.brand { padding:8px; min-height:70px; }.brand-text,.nav-label,.nav-badge,.sidebar-footer { display:none; }.nav-menu { padding:6px; }.nav-item { padding:10px; }.content-area { padding:12px; }.header-title { font-size:17px; }.header-search { min-width:120px; }.header-page-actions { flex-wrap:wrap; }.accounts-groups-container,.overview-grid { grid-template-columns:1fr; }.setting-row { flex-direction:column; }.setting-control { max-width:100%; }.top-header { padding:12px; } }
@media(prefers-reduced-motion:reduce) { *,*::before,*::after { animation:none!important; transition:none!important; } }
/* Keep navigation reachable on shorter desktop windows. */
.brand,.sidebar-footer { flex-shrink:0; }
.nav-menu { flex:0 0 auto; overflow:visible; }
.brand-panel { min-height:0; }
.content-scroll { padding:18px 20px; min-height:0; }
.setting-control { display:flex; flex-wrap:wrap; gap:8px; flex:0 1 48%; }
.setting-control input { min-width:0; width:100%!important; }
.setting-info { min-width:0; flex:1; }
.setting-control select { width:100%; }
.header-left { flex-basis:260px; }
.header-title { white-space:nowrap; font-size:18px; }
@media(max-height:850px) { .brand-panel { display:none; }.sidebar-footer { margin-top:auto; } }
@media(max-width:1350px) { .hub-user { display:none; }.header-search { min-width:150px; } }
@media(max-width:650px) { .content-scroll { padding:12px; }.setting-control { flex-basis:auto; width:100%; } }
.health-workspace { display:grid; grid-template-columns:1.1fr 1fr 1fr; gap:14px; }
.health-workspace .section-card { margin:0; }
.health-workspace p { font-size:12px; color:var(--text-muted); line-height:1.6; margin:10px 0; }
@media(max-width:1100px) { .health-workspace { grid-template-columns:1fr; } }
.routing-right-col input,.routing-right-col select { min-width:0; padding:8px 10px; color:var(--text-primary); background:var(--surface-muted); border:1px solid var(--border); border-radius:4px; font:11px var(--font-ui); }
body[data-theme="medium"] .brand-title { color:var(--canvas-ink); }
body[data-theme="medium"] .nav-item.active .nav-icon { stroke:var(--text-accent); }
.header-icon svg { width:22px; height:22px; fill:none; stroke:currentColor; stroke-width:1.5; }

View file

@ -90,3 +90,61 @@
.workflow-dialog-actions { display:flex; justify-content:flex-end; gap:8px; margin-top:14px; }
.hidden { display:none!important; }
@media (max-width:1200px) { .workflow-kpis { grid-template-columns:repeat(3,1fr); }.workflow-main-layout { grid-template-columns:minmax(560px,1fr) 290px; } }
/* A48 refinement of the existing A43 canvas, not a replacement engine. */
#view-overview { min-width:0; }
.workflow-toolbar { margin-bottom:12px; }
.workflow-save-state { color:var(--chrome-muted); }
.workflow-mode { background:var(--surface); }
.workflow-kpis { grid-template-columns:repeat(6,minmax(0,1fr)); gap:8px; }
.workflow-kpi { min-height:92px; padding:14px 12px; background:var(--surface); border-color:var(--border); }
.workflow-kpi strong { font-size:19px; line-height:1.25; font-weight:500; }
.workflow-kpi span { font-size:11px; }
.workflow-kpi small { font-size:10px; line-height:1.5; }
.workflow-main-layout { grid-template-columns:minmax(0,1fr) 350px; min-height:560px; align-items:stretch; }
.workflow-board-card { border-color:var(--border); }
.workflow-board-header { flex-wrap:wrap; gap:8px 14px; min-height:48px; font-size:11px; }
.workflow-board-header>div:first-child { flex:1 1 220px; }
.workflow-board-header strong { font-weight:500; }
.workflow-board-header label { margin:0; }
.workflow-canvas { min-height:480px; height:58vh; background-color:var(--canvas-bg); background-image:radial-gradient(var(--border-accent) .6px,transparent .6px); background-size:22px 22px; }
.canvas-tools { position:absolute; left:12px; top:14px; z-index:5; display:flex; flex-direction:column; border:1px solid var(--border-accent); border-radius:5px; background:var(--surface); }
.canvas-tools button { width:32px; height:34px; background:transparent; color:var(--text-accent); border:0; border-bottom:1px solid var(--border-subtle); cursor:pointer; font-size:20px; }
.canvas-tools button:hover { background:var(--surface-hover); }
.workflow-node { width:250px; min-height:116px; padding:14px 14px 12px 50px; pointer-events:auto; background:var(--surface); box-shadow:none; border-radius:8px; }
.workflow-node h3 { font-size:13px; line-height:1.35; margin-bottom:4px; }
.workflow-node p { font-size:10px; line-height:1.5; }
.workflow-node .node-assignment { white-space:normal; overflow-wrap:anywhere; }
.agent-node-icon { position:absolute; left:12px; top:17px; display:grid; place-items:center; width:29px; height:29px; border:1px solid var(--border-accent); border-radius:50%; color:var(--text-accent); font-size:20px; }
.workflow-node.selected { outline:1px solid var(--accent); box-shadow:0 0 0 4px var(--accent-dim); }
.workflow-labels { position:absolute; top:0; left:0; width:5000px; height:5000px; overflow:visible; pointer-events:none; }
.workflow-label-bg { fill:var(--canvas-bg); stroke:var(--border-accent); stroke-width:.5; }
.workflow-edge-label { fill:var(--canvas-ink); stroke:none; font:10px var(--font-ui); }
.workflow-minimap { background:var(--canvas-bg); bottom:12px; left:12px; }
.workflow-inspector { padding:14px; max-height:calc(58vh + 100px); border-color:var(--border); }
.inspector-heading { display:flex; justify-content:space-between; align-items:start; gap:8px; }
.inspector-heading h2 { font:500 13px var(--font-ui); text-transform:uppercase; }
.inspector-heading button { border:0; color:var(--text-accent); background:transparent; font-size:20px; cursor:pointer; }
.inspector-tabs { gap:2px; flex-wrap:wrap; margin:10px 0 14px; }
.inspector-tabs button { font-size:10px; padding:8px 5px; }
.inspector-section { padding:12px; border:1px solid var(--border-subtle); border-radius:6px; margin:10px 0; font-size:11px; }
.inspector-section h3 { color:var(--text-accent); font-size:11px; font-weight:500; text-transform:uppercase; margin-bottom:10px; }
.inspector-section p { color:var(--text-secondary); margin:8px 0; line-height:1.6; }
.inspector-section code { overflow-wrap:anywhere; }
.inspector-section .btn { width:100%; margin-top:10px; }
dl { display:grid; grid-template-columns:1fr 1.2fr; gap:8px 14px; font-size:11px; margin:10px 0; }
dt { color:var(--text-muted); } dd { text-align:right; color:var(--text-secondary); overflow-wrap:anywhere; }
.tool-chips { display:flex; flex-wrap:wrap; gap:6px; color:var(--text-muted); }
.tool-chips span { border:1px solid var(--border-accent); border-radius:4px; padding:4px 7px; color:var(--text-accent); }
.workflow-bottom-grid { grid-template-columns:1fr 1fr 1fr; gap:10px; }
.workflow-statistics { border:1px solid var(--border); border-radius:6px; background:var(--surface); padding:12px; }
.workflow-statistics header,.workflow-events header,.workflow-run-panel header { font-size:11px; text-transform:uppercase; font-weight:500; }
.workflow-statistics p { color:var(--text-muted); font-size:11px; line-height:1.6; margin-top:12px; }
.workflow-events-list { max-height:180px; }
.workflow-events,.workflow-run-panel { padding:12px; border-color:var(--border); }
.workflow-run-panel>div { flex-wrap:wrap; }
.workflow-event { grid-template-columns:60px 1fr; font-size:11px; }
.workflow-event em { grid-column:2; }
@media(max-width:1250px) { .workflow-kpis { grid-template-columns:repeat(3,minmax(0,1fr)); }.workflow-main-layout { grid-template-columns:minmax(0,1fr) 300px; } }
@media(max-width:950px) { .workflow-main-layout { grid-template-columns:1fr; }.workflow-inspector { max-height:none; }.workflow-bottom-grid { grid-template-columns:1fr; } }
@media(max-width:650px) { .workflow-kpis { grid-template-columns:repeat(2,minmax(0,1fr)); }.workflow-toolbar { flex-wrap:wrap; }.workflow-toolbar-actions { flex-wrap:wrap; }.workflow-canvas { min-height:400px; } }

View file

@ -16,6 +16,8 @@ const workflowUi = {
draftPositions: {},
connectingFrom: null,
drag: null,
fitted: false,
inspectorClosed: false,
};
function wfEscape(value) {
@ -60,6 +62,11 @@ function initWorkflowOverview() {
document.getElementById('workflow-zoom-in')?.addEventListener('click', () => setWorkflowScale(workflowUi.scale + 0.1));
document.getElementById('workflow-zoom-out')?.addEventListener('click', () => setWorkflowScale(workflowUi.scale - 0.1));
document.getElementById('workflow-fit')?.addEventListener('click', fitWorkflowGraph);
document.getElementById('canvas-select').onclick = () => setWorkflowMode('live');
document.getElementById('canvas-add').onclick = openAgentCreateDialog;
document.getElementById('canvas-fit').onclick = fitWorkflowGraph;
document.getElementById('canvas-arrange').onclick = arrangeWorkflowNodes;
document.getElementById('canvas-connect').onclick = () => { setWorkflowMode('edit'); showToast('Потяните из правого порта агента в левый порт получателя', 'info'); };
const canvas = document.getElementById('workflow-canvas');
canvas?.addEventListener('wheel', (e) => {
e.preventDefault();
@ -70,7 +77,7 @@ function initWorkflowOverview() {
setWorkflowScale(workflowUi.scale * zoomFactor, mouseX, mouseY);
}, { passive: false });
canvas?.addEventListener('mousedown', (e) => {
if (e.target.closest('.workflow-node') || e.target.closest('.workflow-port') || e.target.closest('.workflow-minimap') || e.target.closest('.workflow-dialog')) return;
if (e.target.closest('.workflow-node') || e.target.closest('.workflow-port') || e.target.closest('.workflow-minimap') || e.target.closest('.workflow-dialog') || e.target.closest('.canvas-tools')) return;
if (e.button === 0 || e.button === 1) {
workflowUi.isPanning = true;
workflowUi.panStart = { x: e.clientX - workflowUi.panX, y: e.clientY - workflowUi.panY };
@ -108,9 +115,12 @@ function renderWorkflowOverview(snapshot) {
}
renderWorkflowKpis(snapshot);
renderWorkflowHeader(workflow);
if (!workflowUi.selectedAgentId && !workflowUi.inspectorClosed) workflowUi.selectedAgentId = workflow.definition?.start_agent_id || workflow.agents?.[0]?.id;
renderWorkflowNodes(workflow);
renderWorkflowInspector(snapshot, workflow);
renderWorkflowEvents(workflow.events || []);
renderWorkflowStatistics(workflow);
if (!workflowUi.fitted && workflow.agents?.length) { workflowUi.fitted = true; requestAnimationFrame(fitWorkflowGraph); }
requestAnimationFrame(drawWorkflowEdges);
}
@ -199,12 +209,10 @@ function renderWorkflowNodes(workflow) {
layer.innerHTML = agents.map((agent) => {
const pos = workflowUi.draftPositions[agent.id] || agent.position || { x: 80, y: 80 };
const cfg = agent.execution_config || {};
const assignment = cfg.unavailable_reason
? `Н/Д: ${cfg.unavailable_reason}`
: [cfg.provider, cfg.model, cfg.account].filter(Boolean).join(' · ');
const assignment = `${cfg.model || 'Модель: Н/Д'}${cfg.account || 'Аккаунт: Н/Д'}`;
return `<article class="workflow-node ${wfEscape(agent.runtime_state)} ${workflowUi.selectedAgentId === agent.id ? 'selected' : ''}" data-agent-id="${wfEscape(agent.id)}" style="left:${Number(pos.x) || 0}px;top:${Number(pos.y) || 0}px">
<button class="workflow-port in" aria-label="Вход"></button><button class="workflow-port out" aria-label="Создать связь"></button>
<h3>${wfEscape(agent.name)}</h3><p>${wfEscape(agent.role)}</p><p>${wfEscape(agent.agent_file)}</p><p title="${wfEscape(assignment)}">${wfEscape(assignment)}</p>
<span class="agent-node-icon" aria-hidden="true"></span><h3>${wfEscape(agent.name)}</h3><p title="${wfEscape(agent.agent_file)}">${wfEscape(agent.agent_file?.split('/').pop() || 'Agent File: Н/Д')}</p><p class="node-assignment" title="${wfEscape(cfg.unavailable_reason || assignment)}">${wfEscape(assignment)}</p>
<div class="workflow-node-status"><i></i><span>${wfEscape(runtimeStateLabel(agent.runtime_state))}</span></div>
</article>`;
}).join('');
@ -234,6 +242,7 @@ function setWorkflowMode(mode) {
function selectWorkflowAgent(agentId) {
if (workflowUi.drag?.moved) return;
workflowUi.inspectorClosed = false;
workflowUi.selectedAgentId = agentId;
renderWorkflowOverview(currentSnapshot);
}
@ -321,6 +330,8 @@ function drawWorkflowEdges() {
const layer = document.getElementById('workflow-edge-layer');
if (!svg || !layer) return;
const parts = [];
const labels = [];
const occupiedLabels = [];
workflowUi.draftEdges.forEach((edge) => {
const source = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.source)}"]`);
const target = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(edge.target)}"]`);
@ -332,11 +343,19 @@ function drawWorkflowEdges() {
const bend = Math.max(45, Math.abs(x2 - x1) * 0.42);
const path = `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`;
const klass = String(edge.condition || '').toLowerCase();
const label = edge.label || edge.condition;
const labelWidth = Math.max(70, String(label).length * 6 + 16);
const labelX = (x1 + x2) / 2;
const labelY = (y1 + y2) / 2 - 5;
parts.push(`<path class="${wfEscape(klass)}" d="${path}"></path><path class="workflow-edge-hit" data-edge-id="${wfEscape(edge.id)}" d="${path}"></path><text class="workflow-edge-label" x="${labelX}" y="${labelY}">${wfEscape(edge.label || edge.condition)}</text>`);
let labelY = (y1 + y2) / 2 - 12;
const boxes = [...document.querySelectorAll('.workflow-node')].map(node => ({left:node.offsetLeft,top:node.offsetTop,width:node.offsetWidth,height:node.offsetHeight}));
const overlaps = box => labelX + labelWidth/2 > box.left - 6 && labelX - labelWidth/2 < box.left + box.width + 6 && labelY + 10 > box.top - 6 && labelY - 12 < box.top + box.height + 6;
while ([...boxes,...occupiedLabels].some(overlaps)) labelY += 24;
occupiedLabels.push({left:labelX-labelWidth/2,top:labelY-12,width:labelWidth,height:22});
labels.push(`<g><rect class="workflow-label-bg" x="${labelX-labelWidth/2}" y="${labelY-12}" width="${labelWidth}" height="22" rx="4"></rect><text class="workflow-edge-label" x="${labelX}" y="${labelY+3}">${wfEscape(label)}</text></g>`);
parts.push(`<path class="${wfEscape(klass)}" d="${path}"></path><path class="workflow-edge-hit" data-edge-id="${wfEscape(edge.id)}" d="${path}"></path>`);
});
layer.innerHTML = parts.join('');
document.getElementById('workflow-label-layer').innerHTML = labels.join('');
layer.querySelectorAll('.workflow-edge-hit').forEach((path) => path.addEventListener('click', () => {
const edge = workflowUi.draftEdges.find((item) => item.id === path.dataset.edgeId);
if (workflowUi.mode === 'edit' && edge) openEdgeDialog(edge, false);
@ -384,16 +403,16 @@ function fitWorkflowGraph() {
agents.forEach((agent) => {
const pos = workflowUi.draftPositions[agent.id] || agent.position || { x: 80, y: 80 };
minX = Math.min(minX, pos.x);
maxX = Math.max(maxX, pos.x + 210);
maxX = Math.max(maxX, pos.x + 250);
minY = Math.min(minY, pos.y);
maxY = Math.max(maxY, pos.y + 110);
maxY = Math.max(maxY, pos.y + 124);
});
const width = maxX - minX || 200;
const height = maxY - minY || 120;
const pad = 60;
const cW = canvas.clientWidth || 800;
const cH = canvas.clientHeight || 500;
const fitScale = Math.max(0.4, Math.min(1.4, Math.min((cW - pad * 2) / width, (cH - pad * 2) / height)));
const fitScale = Math.max(0.1, Math.min(1.4, Math.min((cW - pad * 2) / width, (cH - pad * 2) / height)));
const viewport = document.getElementById('workflow-viewport');
if (viewport) viewport.style.transition = 'transform 0.25s ease-out';
@ -436,14 +455,16 @@ async function saveWorkflowDraft() {
function renderWorkflowInspector(snapshot, workflow) {
const box = document.getElementById('workflow-inspector');
if (box.contains(document.activeElement) && document.activeElement.matches('input,select,textarea')) return;
const agent = (workflow.agents || []).find((item) => item.id === workflowUi.selectedAgentId);
if (!agent) {
box.innerHTML = '<div class="workflow-inspector-empty"><strong>INSPECTOR</strong><span>Выберите агента на графе</span></div>';
return;
}
const cfg = agent.execution_config || {};
const tabs = [['main', 'Основное'], ['model', 'Модель'], ['instructions', 'Инструкции'], ['tools', 'Инструменты'], ['memory', 'Память'], ['history', 'История']];
box.innerHTML = `<h2>INSPECTOR: ${wfEscape(agent.name)}</h2><div class="inspector-tabs">${tabs.map(([id, label]) => `<button data-tab="${id}" class="${workflowUi.selectedTab === id ? 'active' : ''}">${label}</button>`).join('')}</div><div id="inspector-tab-content"></div>`;
const tabs = [['main', 'Основное'], ['model', 'Модель'], ['instructions', 'Инструкции'], ['tools', 'Инструменты'], ['memory', 'Память'], ['execution', 'Выполнение'], ['history', 'История']];
box.innerHTML = `<div class="inspector-heading"><h2>Инспектор: ${wfEscape(agent.name)}</h2><button id="inspector-close" aria-label="Закрыть инспектор">×</button></div><div class="inspector-tabs">${tabs.map(([id, label]) => `<button data-tab="${id}" class="${workflowUi.selectedTab === id ? 'active' : ''}">${label}</button>`).join('')}</div><div id="inspector-tab-content"></div>`;
document.getElementById('inspector-close').onclick = () => { workflowUi.selectedAgentId = null; workflowUi.inspectorClosed = true; renderWorkflowOverview(snapshot); };
box.querySelectorAll('.inspector-tabs button').forEach((button) => button.addEventListener('click', () => {
workflowUi.selectedTab = button.dataset.tab;
renderWorkflowInspector(snapshot, workflow);
@ -453,6 +474,9 @@ function renderWorkflowInspector(snapshot, workflow) {
content.innerHTML = `<label class="inspector-field">Название<input id="agent-edit-name" value="${wfEscape(agent.name)}"></label><label class="inspector-field">Роль<div class="inspector-value">${wfEscape(agent.role)}</div></label><label class="inspector-field">Описание<textarea id="agent-edit-description">${wfEscape(agent.description)}</textarea></label><label class="inspector-field">Runtime status<div class="inspector-value">${wfEscape(runtimeStateLabel(agent.runtime_state))}</div></label><label class="inspector-field">Текущая задача<div class="inspector-value">${wfEscape(workflow.run?.current_agent_id === agent.id ? workflow.run.current_task : 'Н/Д: агент сейчас не выполняется')}</div></label><div class="inspector-actions"><button class="btn btn-primary btn-sm" id="agent-save-main">Сохранить</button><button class="btn btn-secondary btn-sm" id="agent-delete">Удалить</button></div>`;
document.getElementById('agent-save-main').onclick = () => executeAction('update_agent', { agent_id: agent.id, name: document.getElementById('agent-edit-name').value, description: document.getElementById('agent-edit-description').value });
document.getElementById('agent-delete').onclick = () => deleteWorkflowAgent(agent);
appendAgentInspectorSummary(content, agent, workflow);
} else if (workflowUi.selectedTab === 'execution') {
content.innerHTML = `<h3>Политика выполнения</h3><pre class="inspector-value">${wfEscape(JSON.stringify(agent.execution_policy || {},null,2))}</pre><p class="inspector-value">Таймаут: ${wfEscape(cfg.timeout ?? 'Н/Д: не задан')} с</p><p class="inspector-value">${wfEscape(workflowRunLabel(workflow.run?.status))}</p>`;
} else if (workflowUi.selectedTab === 'model') {
renderAgentModelTab(content, snapshot, agent);
} else if (workflowUi.selectedTab === 'instructions') {
@ -484,18 +508,8 @@ function renderAgentModelTab(content, snapshot, agent) {
};
const refreshModels = () => {
const profile = profiles.find((item) => item.profile_id === accountSelect.value);
// Список моделей даёт провайдер. preferred_models — это настроенный владельцем
// список предпочтений профиля, а model_states строится перебором того же
// preferred_models (unified_health.py), поэтому обе прежние ветки показывали
// один и тот же куцый набор, и владелец видел «модели уже распределены».
const summary = (snapshot.providers || []).find((item) => item.provider_id === (profile?.provider || providerSelect.value));
const discovered = summary?.discovered_models || [];
const models = discovered.length ? [...discovered] : [...(profile?.preferred_models || [])];
// Настроенная у агента модель обязана присутствовать в списке. Иначе ни один
// option не получал selected, браузер показывал первый вариант, и нажатие
// «Изменить конфигурацию» молча записывало агенту не ту модель.
if (cfg.model && !models.includes(cfg.model)) models.unshift(cfg.model);
modelSelect.innerHTML = models.length ? models.map((model) => `<option value="${wfEscape(model)}" ${model === cfg.model ? 'selected' : ''}>${wfEscape(model)}</option>`).join('') : '<option value="">Н/Д: список моделей ещё не получен от провайдера</option>';
const selected = profile?.profile_id === cfg.account && providerSelect.value === cfg.provider ? cfg.model : '';
modelSelect.innerHTML = modelOptions(snapshot, profile || { provider: providerSelect.value }, selected);
};
providerSelect.onchange = refreshAccounts;
accountSelect.onchange = refreshModels;
@ -603,3 +617,46 @@ function formatWorkflowTime(value) {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleTimeString('ru-RU');
}
function appendAgentInspectorSummary(content, agent, workflow) {
const editor = document.createElement('details');
const summary = document.createElement('summary');
summary.textContent = 'Изменить имя и описание';
editor.append(summary);
while (content.firstChild) editor.append(content.firstChild);
const cfg = agent.execution_config || {};
const value = (item, reason) => wfEscape(item ?? `Н/Д: ${reason}`);
const events = (workflow.events || []).filter(event => event.agent_id === agent.id);
const latest = events.at(-1);
content.innerHTML = `<section class="inspector-section"><h3>${wfEscape(agent.name)}</h3><p>${wfEscape(agent.role)} · ${wfEscape(runtimeStateLabel(agent.runtime_state))}</p><p>${wfEscape(agent.description || 'Описание не задано')}</p><dl><dt>Текущая задача</dt><dd>${value(workflow.run?.current_agent_id === agent.id ? workflow.run.current_task : null, 'агент сейчас не выполняется')}</dd><dt>Итерация</dt><dd>${value(workflow.run?.current_agent_id === agent.id ? workflow.run.iteration : null,'нет текущего шага')}</dd><dt>Последнее событие</dt><dd>${latest ? wfEscape(formatWorkflowTime(latest.timestamp)) : 'Н/Д: событий нет'}</dd><dt>Время выполнения</dt><dd>${value(latest?.duration_seconds,'не измерено')}</dd></dl></section>
<section class="inspector-section"><h3>Конфигурация исполнения</h3><dl><dt>Провайдер</dt><dd>${value(cfg.provider || null,'не назначен')}</dd><dt>Аккаунт</dt><dd>${value(cfg.account || null,'не назначен')}</dd><dt>Модель</dt><dd>${value(cfg.model || null,'не назначена')}</dd><dt>Температура</dt><dd>${value(cfg.temperature,'не задана')}</dd><dt>Макс. токенов</dt><dd>${value(cfg.max_tokens,'не задано')}</dd><dt>Таймаут</dt><dd>${value(cfg.timeout,'не задан')} с</dd></dl><button class="btn btn-secondary btn-sm" id="inspector-model-open">Изменить конфигурацию</button></section>
<section class="inspector-section"><h3>Agent File</h3><code>${wfEscape(agent.agent_file || 'Н/Д: не задан')}</code><p>${agent.agent_file_exists ? 'Файл доступен' : 'Н/Д: файл отсутствует'}</p><button class="btn btn-secondary btn-sm" id="inspector-file-open">Открыть</button></section>
<section class="inspector-section"><h3>Инструменты</h3><div class="tool-chips">${agent.tools?.length ? agent.tools.map(tool => `<span>${wfEscape(tool)}</span>`).join('') : 'Н/Д: инструменты не назначены'}</div></section>
<section class="inspector-section"><h3>Быстрые действия</h3><button class="btn btn-secondary btn-sm" id="inspector-task-open">Перейти к запуску workflow</button></section>`;
content.append(editor);
document.getElementById('inspector-model-open').onclick = () => { workflowUi.selectedTab = 'model'; renderWorkflowInspector(currentSnapshot, workflow); };
document.getElementById('inspector-file-open').onclick = () => openAgentFileEditor(agent);
document.getElementById('inspector-task-open').onclick = () => document.getElementById('workflow-task').focus();
}
function renderWorkflowStatistics(workflow) {
const events = workflow.events || [];
const node = document.getElementById('workflow-statistics-content');
const run = workflow.run || {};
node.innerHTML = `<dl><dt>Текущий запуск</dt><dd>${wfEscape(workflowRunLabel(run.status))}</dd><dt>Событий в снапшоте</dt><dd>${events.length}</dd><dt>Событий с ошибкой</dt><dd>${events.filter(event => event.level === 'error').length}</dd></dl><p>Н/Д: API не передаёт агрегаты завершённых workflow. Кольцевая диаграмма не построена.</p>`;
document.getElementById('workflow-active-task').textContent = run.status === 'running' ? run.current_task || 'Н/Д: текст текущей задачи не передан' : 'Нет выполняющихся workflow';
}
function arrangeWorkflowNodes() {
if (workflowUi.mode !== 'edit') setWorkflowMode('edit');
if (workflowUi.mode !== 'edit') return;
const agents = currentSnapshot?.workflow?.agents || [];
const start = currentSnapshot?.workflow?.definition?.start_agent_id;
const ordered = [...agents].sort((a,b) => (a.id === start ? -1 : b.id === start ? 1 : 0));
ordered.forEach((agent,index) => {
workflowUi.draftPositions[agent.id] = index === 0 ? {x:430,y:60} : {x:70+((index-1)%3)*360,y:250+Math.floor((index-1)/3)*240};
});
markWorkflowDirty();
renderWorkflowOverview(currentSnapshot);
fitWorkflowGraph();
}

View file

@ -0,0 +1,223 @@
/* Shared workspace presentation. All measurements come from HubSnapshot. */
'use strict';
document.addEventListener('DOMContentLoaded', initWorkspaceChrome);
function initWorkspaceChrome() {
document.getElementById('hub-search-open').onclick = openWorkspaceSearch;
document.getElementById('hub-settings').onclick = () => switchView('settings');
document.getElementById('hub-notifications').onclick = () => switchView('logs');
document.getElementById('analytics-refresh').onclick = fetchSnapshot;
document.addEventListener('keydown', event => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
openWorkspaceSearch();
}
});
document.querySelectorAll('input,select,textarea').forEach(input => {
if (!input.labels?.length && !input.hasAttribute('aria-label')) {
const label = input.closest('.setting-row')?.querySelector('.setting-label')?.textContent;
input.setAttribute('aria-label', label || input.placeholder || input.id);
}
});
}
function openWorkspaceSearch() {
elements.modalTitle.textContent = 'Поиск по хабу';
elements.modalBody.innerHTML = '<input id="workspace-search" class="input-text" aria-label="Поиск экранов и агентов" placeholder="Экран, агент, аккаунт…"><div class="command-results" id="workspace-results"></div>';
elements.modalFooter.innerHTML = '<button class="btn btn-secondary" onclick="closeModal()">Закрыть</button>';
showModal();
const input = document.getElementById('workspace-search');
const render = () => {
const entries = [...document.querySelectorAll('.nav-item')].map(button => ({
label: button.querySelector('.nav-label').textContent, action: () => switchView(button.dataset.view),
}));
for (const agent of currentSnapshot?.workflow?.agents || []) entries.push({
label: `Агент · ${agent.name}`, action: () => { switchView('overview'); selectWorkflowAgent(agent.id); fitWorkflowGraph(); },
});
for (const profile of Object.values(currentSnapshot?.all_profiles || {})) entries.push({
label: `Аккаунт · ${profile.display_name || profile.profile_id}`, action: () => { switchView('accounts'); openAccountDetailsModal(profile.profile_id); },
});
const target = document.getElementById('workspace-results');
target.replaceChildren();
for (const entry of entries.filter(item => item.label.toLowerCase().includes(input.value.toLowerCase())).slice(0,15)) {
const button = document.createElement('button');
button.className = 'btn btn-secondary';
button.textContent = entry.label;
button.onclick = () => { closeModal(); entry.action(); };
target.append(button);
}
if (!target.children.length) target.textContent = 'Совпадений нет';
};
input.oninput = render;
render();
input.focus();
}
function renderAccountSummary(snapshot) {
const profiles = Object.values(snapshot.all_profiles || {}).filter(isConnectedProfile);
const items = [
['Всего аккаунтов', profiles.length, 'Подключённые профили'],
['Работает', profiles.filter(p => p.health_state === 'healthy').length, 'Проверенное состояние'],
['Лимит исчерпан', profiles.filter(p => p.health_state === 'quota_exhausted').length, 'По состоянию аккаунта'],
['Отключено', profiles.filter(p => !p.enabled).length, 'Сохранены в системе'],
['Стоимость', 'Н/Д', 'API не передаёт измеренную стоимость'],
];
document.getElementById('account-summary').innerHTML = items.map(([label,value,reason]) => `<div><span>${label}</span><strong>${value}</strong><small>${reason}</small></div>`).join('');
}
function renderAnalyticsCharts(telemetry) {
renderMetricBars('analytics-token-chart', telemetry.by_provider, 'total_tokens', 'Н/Д: провайдеры ещё не вернули usage');
renderMetricBars('analytics-role-chart', telemetry.by_role, 'total_calls', 'Н/Д: вызовы по ролям ещё не измерены');
}
function renderMetricBars(id, series, key, reason) {
const entries = Object.entries(series || {}).filter(([,item]) => Number.isFinite(item[key]));
const total = entries.reduce((sum,[,item]) => sum + item[key],0);
const node = document.getElementById(id);
if (!entries.length || total === 0) { node.textContent = reason; return; }
node.innerHTML = entries.map(([name,item]) => `<div class="metric-bar"><span>${escapeHtml(name)}</span><meter min="0" max="${total}" value="${item[key]}" aria-label="${escapeHtml(name)}"></meter><strong>${new Intl.NumberFormat('ru-RU').format(item[key])}</strong></div>`).join('');
}
function renderHostResources(container, host) {
const measured = (value,unit) => Number.isFinite(value) ? `${value}${unit}` : 'Н/Д';
const items = [
['CPU', measured(host.cpu_percent,'%'), 'Загрузка процессора'],
['Память', measured(host.memory_percent,'%'), Number.isFinite(host.memory_used_mb) ? `${Math.round(host.memory_used_mb)} / ${Math.round(host.memory_total_mb)} МБ` : 'Н/Д: RAM не измерена'],
['GPU', 'Н/Д', 'API не передаёт GPU'],
['Диск', measured(host.disk_percent,'%'), Number.isFinite(host.disk_used_gb) ? `${host.disk_used_gb} / ${host.disk_total_gb} ГБ` : 'Н/Д: диск не измерен'],
['Сеть', measured(host.net_speed_mbps,' Мбит/с'), 'Н/Д до второго замера счётчиков'],
];
container.innerHTML = items.map(([label,value,reason]) => `<div class="resource-card"><div class="resource-label">${label}</div><div class="resource-value">${value}</div><div class="resource-sub">${value === 'Н/Д' ? `Н/Д: ${label} не получен от API` : reason}</div></div>`).join('');
}
function renderLogDetail(event) {
const node = document.getElementById('log-detail');
if (!node) return;
if (!event) { node.innerHTML = '<h2>Детали события</h2><p>Н/Д: в снапшоте нет событий workflow.</p>'; return; }
node.innerHTML = `<h2>Детали события</h2><p>${escapeHtml(event.message)}</p><p>Уровень: ${escapeHtml(event.level || 'Н/Д')}</p><p>Агент: ${escapeHtml(event.agent_id || 'Н/Д: не указан')}</p><p>Время: ${escapeHtml(event.timestamp)}</p><pre>${escapeHtml(JSON.stringify({type:event.type,run_id:event.run_id,iteration:event.iteration,provider:event.provider,account:event.account,model:event.model,duration_seconds:event.duration_seconds,error:event.error},null,2))}</pre>`;
}
function providerModels(snapshot, provider) {
const summary = (snapshot.providers || []).find(item => item.provider_id === provider);
return { models: summary?.discovered_models || [], received: Boolean(summary?.last_refresh_at) };
}
function modelOptions(snapshot, profile, selected) {
const { models, received } = providerModels(snapshot, profile.provider);
let html = '';
if (selected && !models.includes(selected)) html += `<option value="${escapeHtml(selected)}" selected disabled>${escapeHtml(selected)} · доступность не подтверждена</option>`;
if (!models.length) html += `<option value="" disabled ${selected ? '' : 'selected'}>${received ? 'Доступных моделей нет' : 'Список моделей ещё не получен'}</option>`;
html += models.map(model => `<option value="${escapeHtml(model)}" ${model === selected ? 'selected' : ''}>${escapeHtml(model)}</option>`).join('');
if (!selected && models.length) html = '<option value="" selected disabled>Выберите модель</option>'+html;
return html;
}
function routeQuota(profile, snapshot) {
const quota = profile.quota_snapshot || snapshot.quotas?.[profile.profile_id] || {};
if (!quota.buckets?.length) return `<div class="route-quota">${quota.is_loading ? 'Квоты загружаются' : 'Н/Д: '+escapeHtml(quota.unavailable_reason || 'провайдер не передаёт квоту')}</div>`;
return quota.buckets.map(bucket => {
const pct = bucket.remaining_percent;
if (!Number.isFinite(pct)) return `<div class="route-quota">${escapeHtml(bucket.label || 'Квота')}: Н/Д</div>`;
return `<div class="route-quota">${escapeHtml(bucket.label || 'Квота')}: ${pct}%<div class="quota-bar-track"><div class="quota-bar-fill" style="width:${Math.max(0,Math.min(100,pct))}%;background:var(--status-${pct < 20 ? 'error' : pct < 50 ? 'warning' : 'healthy'})"></div></div></div>`;
}).join('');
}
function renderAccountRouting() {
if (!currentSnapshot) return;
const snapshot = currentSnapshot;
const profiles = snapshot.all_profiles || {};
const routing = snapshot.routing || {};
const left = document.getElementById('routing-roles-container');
left.innerHTML = Object.entries(routing).map(([roleId,pipeline]) => {
const agent = (snapshot.workflow?.agents || []).find(item => item.role === roleId);
const role = (snapshot.agents || []).find(item => item.role_id === roleId);
const rows = (pipeline.nodes || []).map((node,index) => {
const profile = profiles[node.profile_id] || {profile_id:node.profile_id,provider:node.provider};
const quota = profile.quota_snapshot || snapshot.quotas?.[node.profile_id] || {};
const reset = (quota.buckets || []).map(b => b.reset_time_formatted || b.reset_after_formatted).filter(Boolean).join('; ');
return `<div class="account-row draggable-item" draggable="true" data-pid="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}"><div class="drag-handle">⠿</div><div class="route-priority">${index ? 'Резерв '+index : 'Основной'}</div><div><strong>${escapeHtml(profile.display_name || node.profile_id)}</strong><div class="text-muted">${escapeHtml(node.account_identity || profile.account_identity || node.provider)}</div></div><div><select data-route-model="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}" aria-label="Модель ${escapeHtml(node.profile_id)}">${modelOptions(snapshot,profile,node.model)}</select></div><div>${routeQuota(profile,snapshot)}</div><div class="text-muted">${escapeHtml(reset || 'Н/Д: нет времени сброса')}</div><div><span class="status-dot ${healthDotClass(node.status)}"></span> ${escapeHtml(node.status_label_ru || 'Не проверялся')}${node.is_active ? '<br>Используется' : ''}</div><button class="route-remove" data-remove-profile="${escapeHtml(node.profile_id)}" data-role="${escapeHtml(roleId)}" aria-label="Удалить из маршрута">×</button></div>`;
}).join('');
return `<section class="role-section"><header class="role-header"><div><h4>${escapeHtml(agent?.name || pipeline.role_name_ru || roleId)}</h4><p class="text-muted">${escapeHtml(agent?.description || role?.role_description_ru || '')}</p></div><button class="btn btn-secondary btn-sm" data-add-role="${escapeHtml(roleId)}">+ Добавить аккаунт</button></header><div class="grid-header"><span></span><span>Приоритет</span><span>Аккаунт</span><span>Модель</span><span>Квота · остаток</span><span>Сброс</span><span>Статус</span><span></span></div><div class="role-chain-list">${rows || '<p class="inspector-value">Аккаунты не назначены. Добавьте подключённый аккаунт.</p>'}</div><div class="drop-zone" data-role="${escapeHtml(roleId)}">+ Перетащите аккаунт в конец маршрута</div><p class="inspector-value">Session Affinity: ${pipeline.session_affinity ? 'включена' : 'отключена'} · Порядок применяется к следующим назначениям</p></section>`;
}).join('') || '<p class="view-header-note">Агенты ещё не созданы. Добавьте агента на «Обзоре».</p>';
left.querySelectorAll('[data-add-role]').forEach(button => button.onclick = () => openAddNodeToChainModal(button.dataset.addRole));
left.querySelectorAll('[data-remove-profile]').forEach(button => button.onclick = () => removeProfileFromChain(button.dataset.role,button.dataset.removeProfile));
left.querySelectorAll('[data-route-model]').forEach(select => select.onchange = () => executeAction('set_model',{profile_id:select.dataset.routeModel,model:select.value}));
renderAvailableRoutingAccounts(snapshot);
setupDragAndDrop();
}
function renderAvailableRoutingAccounts(snapshot) {
const profiles = Object.values(snapshot.all_profiles || {}).filter(isConnectedProfile);
const provider = document.getElementById('routing-filter-provider');
const status = document.getElementById('routing-filter-status');
for (const [select,field,title] of [[provider,'provider','Все провайдеры'],[status,'health_state','Все состояния']]) {
const selected = select.value;
select.innerHTML = `<option value="all">${title}</option>`+[...new Set(profiles.map(p => p[field]))].map(value=>`<option value="${escapeHtml(value)}">${escapeHtml(value)}</option>`).join('');
select.value = [...select.options].some(option => option.value === selected) ? selected : 'all';
select.onchange = renderAccountRouting;
}
const search = document.getElementById('routing-account-search');
search.oninput = renderAccountRouting;
const query = search.value.toLowerCase();
const filtered = profiles.filter(p => (provider.value === 'all' || p.provider === provider.value) && (status.value === 'all' || p.health_state === status.value) && [p.profile_id,p.display_name,p.account_identity,p.provider,...providerModels(snapshot,p.provider).models].join(' ').toLowerCase().includes(query));
document.getElementById('available-accounts-count').textContent = `${filtered.length} из ${profiles.length}`;
const container = document.getElementById('routing-available-container');
container.innerHTML = filtered.map(p=>`<article class="available-account-card draggable-item" draggable="true" data-pid="${escapeHtml(p.profile_id)}" data-source="available"><img src="/static/${getProviderIcon(p.provider)}" width="28" height="28" alt=""><div><strong>${escapeHtml(p.display_name || p.profile_id)}</strong><p class="text-muted">${escapeHtml(p.account_identity || p.provider)}</p><p>${escapeHtml(p.health_label_ru || 'Не проверялся')}</p>${routeQuota(p,snapshot)}<small>Модели: ${providerModels(snapshot,p.provider).received ? providerModels(snapshot,p.provider).models.length : 'список ещё не получен'}</small></div><button class="btn btn-secondary btn-sm" data-route-add="${escapeHtml(p.profile_id)}" aria-label="Добавить аккаунт в маршрут">+</button></article>`).join('') || '<p class="inspector-value">Нет подходящих подключённых аккаунтов. Подключите аккаунт на странице «Аккаунты».</p>';
container.querySelectorAll('[data-route-add]').forEach(button=>button.onclick=()=>{
elements.modalTitle.textContent='Выберите агента';
elements.modalBody.innerHTML='<div class="command-results">'+Object.entries(snapshot.routing || {}).map(([id,route])=>`<button class="btn btn-secondary" data-target-role="${escapeHtml(id)}">${escapeHtml(route.role_name_ru || id)}</button>`).join('')+'</div>';
elements.modalFooter.innerHTML=''; showModal();
elements.modalBody.querySelectorAll('[data-target-role]').forEach(target=>target.onclick=()=>{closeModal();handleDropMove(button.dataset.routeAdd,null,target.dataset.targetRole,-1);});
});
}
function initSnapshotSettings() {
// The snapshot intentionally has no server settings. Never present defaults
// as fetched values or write untouched defaults back to the server.
for (const id of ['setting-server-host','setting-server-port','setting-server-token-input','setting-quota-interval']) {
const input = document.getElementById(id);
input.disabled = true;
input.title = 'Н/Д: настройки сервера не входят в /api/snapshot';
if (input.tagName === 'SELECT') input.innerHTML = '<option>Н/Д: нет в снапшоте</option>';
else input.placeholder = 'Н/Д: нет в снапшоте';
}
document.getElementById('setting-token-status-badge').textContent = 'Н/Д: не передаётся';
for (const id of ['setting-quota-threshold-percent','setting-quota-threshold-action','setting-email-masking-mode']) {
const input = document.getElementById(id);
const unknown = document.createElement('option');
unknown.value = ''; unknown.textContent = 'Н/Д: текущее значение не передано'; unknown.selected = true; unknown.disabled = true;
input.prepend(unknown);
}
document.getElementById('setting-source-mode').innerHTML = '<option>Live API (/api/snapshot)</option>';
document.getElementById('setting-source-mode').disabled = true;
document.getElementById('setting-source-mode').closest('.setting-row').querySelector('.setting-desc').textContent = 'Рабочие данные поступают только из снапшота. Автоматической подмены демонстрационными данными нет.';
}
document.addEventListener('DOMContentLoaded', initSnapshotSettings);
function arrangeSettingsPanels() {
const view = document.getElementById('view-settings');
const first = view.querySelector('.settings-card');
const groups = [
['Общие настройки',['setting-theme']],
['Управление квотами',['setting-quota-interval','setting-quota-threshold-percent','setting-quota-threshold-action']],
['Безопасность и API',['setting-server-host','setting-server-token-input','setting-email-masking-mode']],
];
for (const [title,ids] of groups) {
const card = document.createElement('section'); card.className='settings-card';
const heading=document.createElement('h2'); heading.className='settings-group-title'; heading.textContent=title; card.append(heading);
for (const id of ids) card.append(document.getElementById(id).closest('.setting-row'));
if (title === 'Управление квотами') card.append(document.getElementById('btn-save-hub-settings').closest('.settings-actions'));
view.insertBefore(card, first);
}
first.remove();
}
document.addEventListener('DOMContentLoaded', arrangeSettingsPanels);
function renderHealthPanels(snapshot) {
const box=document.getElementById('health-workspace');
if (!box) return;
const providers=snapshot.providers || [];
box.innerHTML=`<section class="section-card"><h2 class="section-card-title">Подключения провайдеров</h2>${providers.map(provider=>`<dl><dt>${escapeHtml(provider.provider_name)}</dt><dd>${provider.connected_count} подключено · ${provider.online_count} готово</dd></dl>`).join('') || '<p>Подключений нет</p>'}</section><section class="section-card"><h2 class="section-card-title">Хранилища и температуры</h2><p>Загрузка системного диска показана выше.</p><p>Н/Д: API не передаёт список томов, температуры и состояние резервного копирования.</p></section><section class="section-card"><h2 class="section-card-title">Состояние в реальном времени</h2><dl><dt>Активных вызовов</dt><dd>${snapshot.metrics?.active_calls_total ?? 'Н/Д'}</dd><dt>Снапшот</dt><dd>${snapshot.is_stale ? 'Устарел' : 'Актуален'}</dd></dl><p>Н/Д: API не передаёт uptime отдельных служб и историю сетевой активности.</p><button class="btn btn-secondary" id="health-events-open">Журнал событий →</button></section>`;
document.getElementById('health-events-open').onclick=()=>switchView('logs');
}

View file

@ -0,0 +1,29 @@
/* Pure client contract checks. Synthetic data stays in tests only. */
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const path = require('node:path');
const client = path.join(__dirname,'../src/antigravity_provider/router/web/static');
const elements = new Map();
const document = {addEventListener(){},getElementById(id){if(!elements.has(id)) elements.set(id,{innerHTML:'',textContent:''});return elements.get(id);}};
const context = vm.createContext({document,Intl,Number,console,escapeHtml:value=>String(value ?? '').replaceAll('<','&lt;').replaceAll('"','&quot;')});
vm.runInContext(fs.readFileSync(path.join(client,'workspace.js'),'utf8'),context);
const snapshot = {providers:[{provider_id:'test',discovered_models:['measured-model'],last_refresh_at:'2026-08-31'}]};
const choices = context.modelOptions(snapshot,{provider:'test',preferred_models:['invented-model']},'old-model');
assert(choices.includes('measured-model'));
assert(!choices.includes('invented-model'));
assert(choices.includes('selected disabled>old-model'));
assert(context.modelOptions({providers:[]},{provider:'test'},'').includes('ещё не получен'));
assert(context.modelOptions({providers:[{provider_id:'test',discovered_models:[],last_refresh_at:'today'}]},{provider:'test'},'').includes('Доступных моделей нет'));
assert(!context.routeQuota({profile_id:'a',quota_snapshot:{buckets:[{label:'quota',remaining_percent:null}]}},{}).includes('width:0%'));
assert(context.routeQuota({profile_id:'a',quota_snapshot:{buckets:[{label:'quota',remaining_percent:0}]}},{}).includes('0%'));
context.renderHostResources(document.getElementById('host'),{cpu_percent:0,memory_percent:52,disk_percent:null});
assert(document.getElementById('host').innerHTML.includes('>0%</div>'));
assert(document.getElementById('host').innerHTML.includes('>52%</div>'));
assert(!document.getElementById('host').innerHTML.includes('null%'));
context.renderAnalyticsCharts({by_provider:{test:{total_tokens:321}},by_role:{coder:{total_calls:7}}});
assert(document.getElementById('analytics-token-chart').innerHTML.includes('321'));
assert(document.getElementById('analytics-role-chart').innerHTML.includes('7'));
context.renderLogDetail({message:'<script>alert(1)</script>',level:'info'});
assert(!document.getElementById('log-detail').innerHTML.includes('<script>'));
console.log('A48 client contract: model discovery, missing/zero quotas, host metrics, telemetry keys, escaping — passed');