Refine A48 overview layout and verify authenticated interface
|
|
@ -1,4 +1,4 @@
|
|||
# A48 — промежуточный отчёт, НЕ ГОТОВО К ПРИЁМКЕ
|
||||
# A48 — реализация и визуальная проверка
|
||||
|
||||
Дата: 2026-08-31. Исполнитель: Codex, один исполнитель, без субагентов.
|
||||
|
||||
|
|
@ -7,7 +7,7 @@
|
|||
- START_HEAD: `80aab00ee2bef353e12d128bd8ba6a6dde503abd`.
|
||||
- origin/main перед началом: `80aab00ee2bef353e12d128bd8ba6a6dde503abd`.
|
||||
- Ветка: `codex/a48-interface-by-mockup`, отдельный worktree `hermes-hub-a48`.
|
||||
- FINAL_HEAD / FINAL_COMMIT_SHA: **ещё не определён — визуальная приёмка не завершена**. SHA промежуточного коммита передаётся в сообщении исполнителя.
|
||||
- FINAL_COMMIT_SHA передаётся в итоговом сообщении после фиксации этого отчёта. Визуальное согласование владельцем не подменяется технической проверкой.
|
||||
- Главный checkout и незакоммиченная `.agents/` сохранены; ветка A47 не переключалась.
|
||||
- Живой хаб фактически слушает порт 5800, сообщает сборку `81a58f6`. На 8080 работает другое приложение; оно не изменялось.
|
||||
- Предпросмотр клиента A48: loopback 5801, защищённый API пересылается в существующий хаб без добавления авторизации и без изменения служб. Для другого порта браузеру требуется отдельный вход владельца.
|
||||
|
|
@ -45,15 +45,19 @@ HubSnapshot: generation, seq, timestamp, profiles_by_provider, all_profiles, rea
|
|||
|
||||
## Визуальные доказательства и пропуски
|
||||
|
||||
[Галерея макет / до / промежуточная вёрстка](../screenshots/a48/index.html).
|
||||
[Галерея макет / до / после](../screenshots/a48/index.html).
|
||||
|
||||
- Собраны **21 исходный снимок**: 7 экранов × 3 темы, авторизованный живой хаб.
|
||||
- Собраны 21 снимок `layout-only` без входа для проверки базовой сетки/тем. Они НЕ заменяют требуемые скриншоты после с реальными данными; часть последних мелких CSS-правок требует повторной съёмки.
|
||||
- **ПРОПУЩЕНО / ожидает входа:** 21 авторизованный снимок после, инспектор на живом снапшоте, панорамирование/зум/узел за край/вписать, читаемость подписей на нескольких масштабах, проверка действий маршрутизации и выбора модели с подключёнными аккаунтами, повторный полный визуальный проход.
|
||||
- Собраны **21 авторизованный снимок после**, 1280×720. Дополнительно: Обзор 1536×1024, адаптивность 760×900, холст после переноса за край и Fit, нижние части аналитики/состояния/настроек. Проверены темы, переходы, поиск агента, вкладки инспектора и чтение Agent File.
|
||||
- Панорамирование меняет translate; зум проверен 85→95%. После перетаскивания узла за край в EDIT и Fit: 13/13 узлов внутри холста, пересечений подписей с узлами нет. Черновик теста не сохранён на сервер. Ошибок консоли нет.
|
||||
- **НЕ ПРОВЕРЕНО на живых данных:** заполненные карточки аккаунтов, перестановка аккаунтов в цепочке, выбор обнаруженной модели, детали реального события: API возвращает 0 подключённых аккаунтов и 0 событий workflow. Контракты списков моделей и отсутствующих значений проверены JS-тестами. Новые аккаунты и фиктивные события для скриншотов не создавались.
|
||||
- **ПРОПУЩЕНО намеренно:** кнопки несуществующих приложений экосистемы, выдуманные графики/рост процентов/кольцо 42, версия v2.9.0, неподдерживаемые настройки уведомлений/backup/2FA, история температур и сетевых замеров без источника, вымышленный профиль Administrator.
|
||||
- Отличия от макета: используется существующий утверждённый растровый логотип с кремовой подложкой, а не генерация нового вензеля; нет фиктивных аккаунтов и назначений. Точные плотность и отступы окончательно не приняты.
|
||||
- Отличия от макета: используется существующий утверждённый растровый логотип с кремовой подложкой, а не генерация нового вензеля; нет фиктивных аккаунтов и назначений. Сохранённые координаты графа не менялись: основная цепочка остаётся горизонтальной, остальные узлы разнесены. Поэтому геометрия графа отличается от демонстрационного дерева макета. При 1280×720 нижние панели требуют прокрутки.
|
||||
- Ограничение backend: `set_model` меняет предпочтение профиля, общее для его использования в разных ролях. Независимые модели на каждую пару роль/аккаунт нельзя честно обещать без отдельного изменения backend. Python в A48 не менялся.
|
||||
|
||||
## Продолжение
|
||||
## Доработка после замечания владельца
|
||||
|
||||
После входа владельца на 5801: перезагрузить клиент, проверить консоль и DOM, исправить найденные дефекты, снять авторизованные после для всех тем, пройти холст, обновить галерею и отчёт. Только затем назначить FINAL_COMMIT_SHA и пометить A48 выполненным.
|
||||
Переключатель LIVE/EDIT перенесён в шапку; KPI на desktop в одном ряду; инспектор занимает правый столбец рядом с графом и нижними карточками. Уплотнены шапка и холст. Подписи лежат на линиях связей, а не отдельно от них. Начальный вид сфокусирован на оркестраторе, Fit учитывает настоящие размеры узлов и подписей. Перетаскивание продолжает работать вне холста до отпускания мыши. Исправлены критический индикатор, объяснение сетевого замера, нулевая задержка в таблицах и отображение ранее настроенной, но неподтверждённой модели.
|
||||
|
||||
Оригинальная пользовательская вкладка с несохранённым графом не перезагружалась. Проверки выполнялись в отдельной вкладке того же защищённого preview. Python, службы и установленный хаб не менялись. Нужна последующая функциональная проверка на подключённых аккаунтах; визуальную приёмку выполняет владелец.
|
||||
|
|
|
|||
BIN
docs/screenshots/a48/after/accounts-dark.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
docs/screenshots/a48/after/accounts-light.png
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
docs/screenshots/a48/after/accounts-medium.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
docs/screenshots/a48/after/analytics-dark.png
Normal file
|
After Width: | Height: | Size: 87 KiB |
BIN
docs/screenshots/a48/after/analytics-light.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
docs/screenshots/a48/after/analytics-lower-dark.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
docs/screenshots/a48/after/analytics-medium.png
Normal file
|
After Width: | Height: | Size: 88 KiB |
BIN
docs/screenshots/a48/after/canvas-fit-after-drag.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
docs/screenshots/a48/after/health-dark.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
docs/screenshots/a48/after/health-light.png
Normal file
|
After Width: | Height: | Size: 96 KiB |
BIN
docs/screenshots/a48/after/health-lower-dark.png
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
docs/screenshots/a48/after/health-medium.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
docs/screenshots/a48/after/logs-dark.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/screenshots/a48/after/logs-light.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
docs/screenshots/a48/after/logs-medium.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/screenshots/a48/after/overview-760.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
docs/screenshots/a48/after/overview-dark.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
docs/screenshots/a48/after/overview-light.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
docs/screenshots/a48/after/overview-medium.png
Normal file
|
After Width: | Height: | Size: 128 KiB |
BIN
docs/screenshots/a48/after/overview-reference-size.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
docs/screenshots/a48/after/routing-dark.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/screenshots/a48/after/routing-light.png
Normal file
|
After Width: | Height: | Size: 102 KiB |
BIN
docs/screenshots/a48/after/routing-medium.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
BIN
docs/screenshots/a48/after/settings-dark.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/screenshots/a48/after/settings-light.png
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
docs/screenshots/a48/after/settings-lower-dark.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
docs/screenshots/a48/after/settings-medium.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
<!doctype html><html lang="ru"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>A48 — визуальная проверка, НЕ ПРИНЯТО</title>
|
||||
<!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>
|
||||
<header><h1>A48 · Сравнение с макетами</h1><p><strong>Клиент проверен с живыми данными; визуальное согласование остаётся за владельцем.</strong> «До»: хаб 81a58f6. «После»: клиент A48 с тем же защищённым API. 21 снимок до и 21 после. Аккаунтов 0, поэтому заполненные карточки и выбор реальной модели не проверены.</p><p>База кода: 80aab00. Контрольная ширина снимков: 1280 px. Макеты имеют разные исходные размеры; изображения показаны полностью, без подмены данных.</p><p><a href="after/overview-reference-size.png">Обзор 1536×1024</a> · <a href="after/canvas-fit-after-drag.png">Граф после перетаскивания и вписывания</a> · <a href="after/overview-760.png">Адаптивность 760 px</a></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'],[`after/${view}-${theme}.png`,'ПОСЛЕ · живой API']].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>
|
||||
|
|
|
|||
|
|
@ -1268,7 +1268,7 @@ function renderAnalyticsView() {
|
|||
<td>${item.total_calls ?? 0}</td>
|
||||
<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>${Number.isFinite(item.latency_p50_ms) ? `${item.latency_p50_ms} мс` : '—'}</td>
|
||||
<td>${item.total_tokens != null ? new Intl.NumberFormat('ru-RU').format(item.total_tokens) : 'Н/Д'}</td>
|
||||
</tr>
|
||||
`;
|
||||
|
|
@ -1307,7 +1307,7 @@ function renderAnalyticsView() {
|
|||
<td>${item.total_calls ?? 0}</td>
|
||||
<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>${Number.isFinite(item.latency_p50_ms) ? `${item.latency_p50_ms} мс` : '—'}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<link rel="stylesheet" href="static/workflow.css">
|
||||
<link rel="icon" href="static/hermes-logo.png">
|
||||
</head>
|
||||
<body>
|
||||
<body data-view="overview" data-workflow-mode="live">
|
||||
<div id="app" class="app-layout">
|
||||
<!-- Seven workspaces share the same navigation. -->
|
||||
<aside class="sidebar">
|
||||
|
|
@ -81,6 +81,10 @@
|
|||
<span id="header-update-text">Доступно обновление</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="workflow-mode" role="group" aria-label="Режим workflow">
|
||||
<button class="workflow-mode-btn active" id="workflow-mode-live" data-mode="live">LIVE</button>
|
||||
<button class="workflow-mode-btn" id="workflow-mode-edit" data-mode="edit">EDIT</button>
|
||||
</div>
|
||||
<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>
|
||||
|
|
@ -98,10 +102,7 @@
|
|||
<!-- 1. OVERVIEW VIEW -->
|
||||
<section id="view-overview" class="view-pane active">
|
||||
<div class="workflow-toolbar">
|
||||
<div class="workflow-mode" role="group" aria-label="Режим workflow">
|
||||
<button class="workflow-mode-btn active" id="workflow-mode-live" data-mode="live">LIVE</button>
|
||||
<button class="workflow-mode-btn" id="workflow-mode-edit" data-mode="edit">EDIT</button>
|
||||
</div>
|
||||
|
||||
<div class="workflow-toolbar-actions">
|
||||
<span class="workflow-save-state" id="workflow-save-state">Сохранено</span>
|
||||
<button class="btn btn-secondary" id="btn-workflow-save">Сохранить workflow</button>
|
||||
|
|
@ -142,12 +143,12 @@
|
|||
<aside class="workflow-inspector" id="workflow-inspector">
|
||||
<div class="workflow-inspector-empty"><strong>INSPECTOR</strong><span>Выберите агента на графе</span></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<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-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>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. ACCOUNTS VIEW -->
|
||||
|
|
|
|||
|
|
@ -1904,3 +1904,14 @@ body[data-theme="medium"] .nav-icon { stroke:var(--canvas-ink); }
|
|||
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; }
|
||||
.header-update-badge { color:var(--chrome-ink); background:transparent; }
|
||||
@media(min-width:1051px) {
|
||||
.top-header { min-height:84px; padding-top:8px; padding-bottom:8px; }
|
||||
.header-left { display:flex; flex-wrap:wrap; gap:6px; min-width:200px; }
|
||||
.header-title { width:100%; }
|
||||
.header-readiness-badge,.header-update-badge { margin:0; font-size:9px; padding:4px 6px; }
|
||||
}
|
||||
.readiness-banner.not-ready { border-left-color:var(--status-error); }
|
||||
.status-dot.not-ready { background:var(--status-error); }
|
||||
.status-dot.ready { background:var(--status-healthy); }
|
||||
.resource-value { font-size:clamp(18px,1.7vw,26px); }
|
||||
|
|
|
|||
|
|
@ -148,3 +148,43 @@ dt { color:var(--text-muted); } dd { text-align:right; color:var(--text-secondar
|
|||
@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; } }
|
||||
|
||||
/* Match the reference: one KPI row; inspector spans graph and lower cards. */
|
||||
body[data-view="overview"] .header-page-actions { display:none; }
|
||||
body:not([data-view="overview"]) .top-header>.workflow-mode { display:none; }
|
||||
.workflow-toolbar { display:none; }
|
||||
body[data-workflow-mode="edit"] .workflow-toolbar { display:flex; justify-content:flex-end; }
|
||||
.workflow-kpis { grid-template-columns:repeat(6,minmax(0,1fr)); }
|
||||
.workflow-kpi { min-height:86px; padding:11px 10px; }
|
||||
.workflow-kpi strong { font-size:18px; }
|
||||
.workflow-main-layout { grid-template-columns:minmax(0,1fr) 30%; grid-template-rows:auto auto; min-height:0; gap:10px; }
|
||||
.workflow-board-card { grid-column:1; grid-row:1; }
|
||||
.workflow-inspector { grid-column:2; grid-row:1 / span 2; max-height:calc(100vh - 204px); }
|
||||
.workflow-bottom-grid { grid-column:1; grid-row:2; margin:0; min-width:0; align-items:stretch; }
|
||||
.workflow-bottom-grid>section { min-width:0; min-height:170px; }
|
||||
.workflow-canvas { min-height:300px; height:clamp(300px,calc(100vh - 460px),520px); flex:auto; }
|
||||
.workflow-board-header { min-height:44px; padding:8px 10px; }
|
||||
.workflow-board-header>div:first-child { flex-basis:100%; }
|
||||
.workflow-board-header strong { text-transform:none; }
|
||||
.workflow-legend { gap:8px; padding:6px 10px; font-size:9px; }
|
||||
.workflow-run-panel textarea { min-height:46px; }
|
||||
.workflow-run-panel .btn { padding:6px; font-size:10px; }
|
||||
.workflow-events header { flex-wrap:wrap; gap:6px; }
|
||||
.workflow-statistics dl { grid-template-columns:1fr; gap:4px; }
|
||||
.workflow-statistics dd { text-align:left; margin-bottom:6px; }
|
||||
.workflow-statistics p { font-size:10px; }
|
||||
.workflow-events-list { max-height:150px; }
|
||||
@media(min-width:1450px) { .workflow-board-header>div:first-child { flex-basis:220px; } }
|
||||
@media(max-width:1050px) { .workflow-kpis { grid-template-columns:repeat(3,minmax(0,1fr)); } .workflow-bottom-grid { grid-template-columns:1fr; } }
|
||||
@media(max-width:950px) { .workflow-main-layout { grid-template-columns:1fr; } .workflow-inspector { grid-column:1; grid-row:2; max-height:580px; } .workflow-bottom-grid { grid-row:3; grid-template-columns:repeat(3,minmax(0,1fr)); } }
|
||||
@media(max-width:650px) { .workflow-kpis { grid-template-columns:repeat(2,minmax(0,1fr)); } .workflow-bottom-grid { grid-template-columns:1fr; } }
|
||||
@media(min-width:1051px) {
|
||||
body[data-view="overview"] .top-header { min-height:74px; padding-top:8px; padding-bottom:8px; }
|
||||
body[data-view="overview"] .header-left { display:grid; grid-template-columns:max-content max-content; gap:6px 10px; }
|
||||
body[data-view="overview"] .header-title { grid-column:1 / -1; }
|
||||
body[data-view="overview"] .header-readiness-badge,body[data-view="overview"] .header-update-badge { margin:0; font-size:9px; padding:4px 6px; }
|
||||
}
|
||||
.workflow-canvas { height:clamp(300px,calc(100vh - 570px),430px); }
|
||||
.workflow-inspector .inspector-section { padding:10px; margin:8px 0; }
|
||||
.workflow-inspector .inspector-section p { line-height:1.4; margin:6px 0; }
|
||||
.workflow-inspector dl { gap:6px 10px; margin:8px 0; }
|
||||
|
|
|
|||
|
|
@ -84,9 +84,9 @@ function initWorkflowOverview() {
|
|||
canvas.style.cursor = 'grabbing';
|
||||
}
|
||||
});
|
||||
canvas?.addEventListener('mousemove', workflowPointerMove);
|
||||
canvas?.addEventListener('mouseup', workflowPointerUp);
|
||||
canvas?.addEventListener('mouseleave', workflowPointerCancel);
|
||||
window.addEventListener('mousemove', workflowPointerMove);
|
||||
window.addEventListener('mouseup', workflowPointerUp);
|
||||
window.addEventListener('blur', workflowPointerCancel);
|
||||
window.addEventListener('resize', () => {
|
||||
drawWorkflowEdges();
|
||||
});
|
||||
|
|
@ -120,7 +120,7 @@ function renderWorkflowOverview(snapshot) {
|
|||
renderWorkflowInspector(snapshot, workflow);
|
||||
renderWorkflowEvents(workflow.events || []);
|
||||
renderWorkflowStatistics(workflow);
|
||||
if (!workflowUi.fitted && workflow.agents?.length) { workflowUi.fitted = true; requestAnimationFrame(fitWorkflowGraph); }
|
||||
if (!workflowUi.fitted && workflow.agents?.length) { workflowUi.fitted = true; requestAnimationFrame(focusWorkflowStart); }
|
||||
requestAnimationFrame(drawWorkflowEdges);
|
||||
}
|
||||
|
||||
|
|
@ -236,6 +236,7 @@ function setWorkflowMode(mode) {
|
|||
return;
|
||||
}
|
||||
workflowUi.mode = mode;
|
||||
document.body.dataset.workflowMode = mode;
|
||||
document.querySelectorAll('.workflow-mode-btn').forEach((button) => button.classList.toggle('active', button.dataset.mode === mode));
|
||||
renderWorkflowOverview(currentSnapshot);
|
||||
}
|
||||
|
|
@ -268,7 +269,7 @@ function workflowPointerMove(event) {
|
|||
const dx = (event.clientX - drag.startX) / workflowUi.scale;
|
||||
const dy = (event.clientY - drag.startY) / workflowUi.scale;
|
||||
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
|
||||
workflowUi.draftPositions[drag.id] = { x: Math.max(0, drag.original.x + dx), y: Math.max(0, drag.original.y + dy) };
|
||||
workflowUi.draftPositions[drag.id] = { x: drag.original.x + dx, y: drag.original.y + dy };
|
||||
const node = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(drag.id)}"]`);
|
||||
if (node) {
|
||||
node.style.left = `${workflowUi.draftPositions[drag.id].x}px`;
|
||||
|
|
@ -340,18 +341,17 @@ function drawWorkflowEdges() {
|
|||
const y1 = source.offsetTop + source.offsetHeight / 2;
|
||||
const x2 = target.offsetLeft;
|
||||
const y2 = target.offsetTop + target.offsetHeight / 2;
|
||||
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;
|
||||
let labelY = (y1 + y2) / 2 - 12;
|
||||
let labelY = Math.min(source.offsetTop, target.offsetTop) - 30;
|
||||
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>`);
|
||||
const overlaps = box => labelX + labelWidth/2 > box.left - 6 && labelX - labelWidth/2 < box.left + box.width + 6 && labelY + 12 > box.top - 6 && labelY - 12 < box.top + box.height + 6;
|
||||
while ([...boxes,...occupiedLabels].some(overlaps)) labelY -= 30;
|
||||
occupiedLabels.push({left:labelX-labelWidth/2,top:labelY-12,width:labelWidth,height:24});
|
||||
const path = `M ${x1} ${y1} C ${x1+28} ${y1}, ${x1+28} ${labelY}, ${x1} ${labelY} L ${x2} ${labelY} C ${x2-28} ${labelY}, ${x2-28} ${y2}, ${x2} ${y2}`;
|
||||
labels.push(`<g><rect class="workflow-label-bg" x="${labelX-labelWidth/2}" y="${labelY-12}" width="${labelWidth}" height="24" 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('');
|
||||
|
|
@ -377,7 +377,7 @@ function setWorkflowScale(value, focusX, focusY) {
|
|||
const canvas = document.getElementById('workflow-canvas');
|
||||
if (!canvas) return;
|
||||
const oldScale = workflowUi.scale;
|
||||
const newScale = Math.max(0.3, Math.min(2.5, Math.round(value * 100) / 100));
|
||||
const newScale = Math.max(0.1, Math.min(2.5, Math.round(value * 100) / 100));
|
||||
if (focusX === undefined || focusY === undefined) {
|
||||
focusX = canvas.clientWidth / 2;
|
||||
focusY = canvas.clientHeight / 2;
|
||||
|
|
@ -388,6 +388,16 @@ function setWorkflowScale(value, focusX, focusY) {
|
|||
updateCanvasTransform();
|
||||
}
|
||||
|
||||
function focusWorkflowStart() {
|
||||
const canvas = document.getElementById('workflow-canvas');
|
||||
const node = document.querySelector('.workflow-node.selected') || document.querySelector('.workflow-node');
|
||||
if (!canvas || !node) return;
|
||||
workflowUi.scale = .85;
|
||||
workflowUi.panX = 80 - node.offsetLeft * workflowUi.scale;
|
||||
workflowUi.panY = 110 - node.offsetTop * workflowUi.scale;
|
||||
updateCanvasTransform();
|
||||
}
|
||||
|
||||
function fitWorkflowGraph() {
|
||||
const canvas = document.getElementById('workflow-canvas');
|
||||
if (!canvas) return;
|
||||
|
|
@ -403,9 +413,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 + 250);
|
||||
const node = document.querySelector(`.workflow-node[data-agent-id="${CSS.escape(agent.id)}"]`);
|
||||
maxX = Math.max(maxX, pos.x + (node?.offsetWidth || 250));
|
||||
minY = Math.min(minY, pos.y);
|
||||
maxY = Math.max(maxY, pos.y + 124);
|
||||
maxY = Math.max(maxY, pos.y + (node?.offsetHeight || 124));
|
||||
});
|
||||
document.querySelectorAll('.workflow-label-bg').forEach(label => {
|
||||
const x = Number(label.getAttribute('x')), y = Number(label.getAttribute('y'));
|
||||
minX = Math.min(minX, x); minY = Math.min(minY, y);
|
||||
maxX = Math.max(maxX, x + Number(label.getAttribute('width')));
|
||||
maxY = Math.max(maxY, y + Number(label.getAttribute('height')));
|
||||
});
|
||||
const width = maxX - minX || 200;
|
||||
const height = maxY - minY || 120;
|
||||
|
|
@ -501,6 +518,7 @@ function renderAgentModelTab(content, snapshot, agent) {
|
|||
const providerSelect = document.getElementById('agent-provider');
|
||||
const accountSelect = document.getElementById('agent-account');
|
||||
const modelSelect = document.getElementById('agent-model');
|
||||
let initialRendering = true;
|
||||
const refreshAccounts = () => {
|
||||
const matches = profiles.filter((profile) => profile.provider === providerSelect.value);
|
||||
accountSelect.innerHTML = matches.length ? matches.map((profile) => `<option value="${wfEscape(profile.profile_id)}" ${profile.profile_id === cfg.account ? 'selected' : ''}>${wfEscape(profile.account_identity || profile.display_name || profile.profile_id)}</option>`).join('') : '<option value="">Н/Д: нет подключённых аккаунтов</option>';
|
||||
|
|
@ -508,12 +526,13 @@ function renderAgentModelTab(content, snapshot, agent) {
|
|||
};
|
||||
const refreshModels = () => {
|
||||
const profile = profiles.find((item) => item.profile_id === accountSelect.value);
|
||||
const selected = profile?.profile_id === cfg.account && providerSelect.value === cfg.provider ? cfg.model : '';
|
||||
const selected = initialRendering || (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;
|
||||
refreshAccounts();
|
||||
initialRendering = false;
|
||||
document.getElementById('agent-model-save').onclick = () => executeAction('update_agent', {
|
||||
agent_id: agent.id, provider: providerSelect.value, profile_id: accountSelect.value, model: modelSelect.value,
|
||||
temperature: nullableNumber('agent-temperature'), max_tokens: nullableNumber('agent-max-tokens'), timeout: nullableNumber('agent-timeout'),
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ function renderHostResources(container, host) {
|
|||
['Память', 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,' Мбит/с'), 'Н/Д до второго замера счётчиков'],
|
||||
['Сеть', measured(host.net_speed_mbps,' Мбит/с'), Number.isFinite(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('');
|
||||
}
|
||||
|
|
|
|||