feat(ui): A48 workspace layout draft awaiting authenticated visual QA
189
agents/inbox/2026-08-31-A48-interface-by-mockup.md
Normal 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
|
|
@ -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 выполненным.
|
||||
32
docs/reports/a48-baseline-tests.txt
Normal 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
|
||||
28
docs/reports/a48-release-gate.txt
Normal 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
|
||||
======================================================================
|
||||
28
docs/reports/a48-router.txt
Normal 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)
|
||||
======================================================================
|
||||
32
docs/reports/a48-tests.txt
Normal 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
|
||||
BIN
docs/screenshots/a48/before/accounts-dark.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/screenshots/a48/before/accounts-light.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/screenshots/a48/before/accounts-medium.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/screenshots/a48/before/analytics-dark.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/screenshots/a48/before/analytics-light.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
docs/screenshots/a48/before/analytics-medium.png
Normal file
|
After Width: | Height: | Size: 66 KiB |
BIN
docs/screenshots/a48/before/health-dark.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
docs/screenshots/a48/before/health-light.png
Normal file
|
After Width: | Height: | Size: 95 KiB |
BIN
docs/screenshots/a48/before/health-medium.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
docs/screenshots/a48/before/logs-dark.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/screenshots/a48/before/logs-light.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/screenshots/a48/before/logs-medium.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/screenshots/a48/before/overview-dark.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
docs/screenshots/a48/before/overview-light.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
docs/screenshots/a48/before/overview-medium.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
docs/screenshots/a48/before/routing-dark.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
docs/screenshots/a48/before/routing-light.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
docs/screenshots/a48/before/routing-medium.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/screenshots/a48/before/settings-dark.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/screenshots/a48/before/settings-light.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
docs/screenshots/a48/before/settings-medium.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
4
docs/screenshots/a48/index.html
Normal 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>
|
||||
BIN
docs/screenshots/a48/layout-only/accounts-dark.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/screenshots/a48/layout-only/accounts-light.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
docs/screenshots/a48/layout-only/accounts-medium.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
docs/screenshots/a48/layout-only/analytics-dark.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
docs/screenshots/a48/layout-only/analytics-light.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
docs/screenshots/a48/layout-only/analytics-medium.png
Normal file
|
After Width: | Height: | Size: 80 KiB |
BIN
docs/screenshots/a48/layout-only/health-dark.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
docs/screenshots/a48/layout-only/health-light.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/screenshots/a48/layout-only/health-medium.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
docs/screenshots/a48/layout-only/logs-dark.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/screenshots/a48/layout-only/logs-light.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
docs/screenshots/a48/layout-only/logs-medium.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
docs/screenshots/a48/layout-only/overview-dark.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/screenshots/a48/layout-only/overview-light.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/screenshots/a48/layout-only/overview-medium.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
BIN
docs/screenshots/a48/layout-only/routing-dark.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
docs/screenshots/a48/layout-only/routing-light.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/screenshots/a48/layout-only/routing-medium.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
docs/screenshots/a48/layout-only/settings-dark.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
docs/screenshots/a48/layout-only/settings-light.png
Normal file
|
After Width: | Height: | Size: 86 KiB |
BIN
docs/screenshots/a48/layout-only/settings-medium.png
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
docs/screenshots/a48/reference/1.1.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/screenshots/a48/reference/2.1.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
docs/screenshots/a48/reference/3.1.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
docs/screenshots/a48/reference/4.1.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/screenshots/a48/reference/5.1.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/screenshots/a48/reference/6.1.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
docs/screenshots/a48/reference/7.1.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
|
|
@ -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);
|
||||
|
|
|
|||
BIN
src/antigravity_provider/router/web/static/hermes-logo.png
Normal file
|
After Width: | Height: | Size: 111 KiB |
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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; } }
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
223
src/antigravity_provider/router/web/static/workspace.js
Normal 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');
|
||||
}
|
||||
29
tests/test_workspace_a48.js
Normal 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('<','<').replaceAll('"','"')});
|
||||
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');
|
||||