From 81a58f6b13c1f71714a247b49be344e43bb7d2e4 Mon Sep 17 00:00:00 2001
From: ochenstarik-ui <267932263+ochenstarik-ui@users.noreply.github.com>
Date: Mon, 31 Aug 2026 12:29:06 +0700
Subject: [PATCH] =?UTF-8?q?feat(ui):=20A43=20=D0=B8=D0=BD=D1=82=D0=B5?=
=?UTF-8?q?=D1=80=D0=B0=D0=BA=D1=82=D0=B8=D0=B2=D0=BD=D1=8B=D0=B9=20=D1=85?=
=?UTF-8?q?=D0=BE=D0=BB=D1=81=D1=82=20workflow=20(n8n=20style),=20=D0=BF?=
=?UTF-8?q?=D0=B0=D0=BD=D0=BE=D1=80=D0=B0=D0=BC=D0=B8=D1=80=D0=BE=D0=B2?=
=?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=B7=D1=83=D0=BC=20=D0=BA=D0=BE?=
=?UTF-8?q?=D0=BB=D0=B5=D1=81=D0=BE=D0=BC,=206=20KPI=20=D0=B8=20=D0=B2?=
=?UTF-8?q?=D0=B5=D1=80=D1=81=D1=82=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D0=BC?=
=?UTF-8?q?=D0=B0=D0=BA=D0=B5=D1=82=D0=B0=D0=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../router/web/static/app.js | 302 +++++++++++++++++-
.../router/web/static/index.html | 10 +-
.../router/web/static/workflow.css | 15 +-
.../router/web/static/workflow.js | 136 ++++++--
tests/test_a43_frontend_canvas.py | 97 ++++++
5 files changed, 528 insertions(+), 32 deletions(-)
create mode 100644 tests/test_a43_frontend_canvas.py
diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js
index 4b3ba8b..87b6896 100644
--- a/src/antigravity_provider/router/web/static/app.js
+++ b/src/antigravity_provider/router/web/static/app.js
@@ -567,7 +567,7 @@ function updateGlobalHeader() {
if (kpiAccountsSub) kpiAccountsSub.textContent = `Подключено: ${connectedAccounts}`;
if (kpiReadyRoles) kpiReadyRoles.textContent = `${readyRoles}/${totalRoles}`;
if (kpiRolesSub) kpiRolesSub.textContent = `${readyRoles} из ${totalRoles} ролей маршрутизации активны`;
- if (kpiProvidersCount) kpiProvidersCount.textContent = (currentSnapshot.providers || []).length || 5;
+ if (kpiProvidersCount) kpiProvidersCount.textContent = (currentSnapshot.providers || []).length;
}
// ── VIEW ROUTER ──
@@ -1062,6 +1062,28 @@ function renderRoutingView() {
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 `
@@ -1341,6 +1363,276 @@ async function handleAddNodeToChain(roleId) {
}
}
+// ── ANALYTICS VIEW ──
+function renderAnalyticsView() {
+ if (!currentSnapshot) return;
+ const metrics = currentSnapshot.metrics || {};
+ const telemetry = metrics.telemetry || {};
+ const global = telemetry.global || {};
+
+ const totalCallsEl = document.getElementById('analytics-total-calls');
+ const callsBreakdownEl = document.getElementById('analytics-calls-breakdown');
+ const errorRateEl = document.getElementById('analytics-error-rate');
+ const errorRateSubEl = document.getElementById('analytics-error-rate-sub');
+ const latencyP50El = document.getElementById('analytics-latency-p50');
+ const latencySubEl = document.getElementById('analytics-latency-sub');
+ const tokensTotalEl = document.getElementById('analytics-tokens-total');
+ const tokensSubEl = document.getElementById('analytics-tokens-sub');
+
+ 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}%`;
+ if (errorRateSubEl) errorRateSubEl.textContent = `${global.failed_calls ?? 0} сбоев из ${global.total_calls} вызовов`;
+ } else {
+ if (totalCallsEl) totalCallsEl.textContent = '—';
+ if (callsBreakdownEl) callsBreakdownEl.textContent = 'Нет данных за 24 ч';
+ if (errorRateEl) errorRateEl.textContent = '—';
+ if (errorRateSubEl) errorRateSubEl.textContent = 'Нет зарегистрированных сбоев';
+ }
+
+ if (global.latency_p50_ms !== undefined) {
+ if (latencyP50El) latencyP50El.textContent = `${global.latency_p50_ms} мс`;
+ if (latencySubEl) latencySubEl.textContent = `p95: ${global.latency_p95_ms ?? '—'} мс · max: ${global.latency_max_ms ?? '—'} мс`;
+ } else {
+ if (latencyP50El) latencyP50El.textContent = '—';
+ if (latencySubEl) latencySubEl.textContent = 'Задержка не измерена';
+ }
+
+ 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)}`;
+ } else {
+ if (tokensTotalEl) tokensTotalEl.textContent = 'Н/Д';
+ if (tokensSubEl) tokensSubEl.textContent = 'Н/Д: провайдеры не отдают usage';
+ }
+
+ // Providers telemetry table
+ const providersContainer = document.getElementById('analytics-providers-table');
+ const providersData = telemetry.providers || {};
+ if (providersContainer) {
+ const provKeys = Object.keys(providersData);
+ if (provKeys.length === 0) {
+ providersContainer.innerHTML = '
Нет накопленной телеметрии по провайдерам
';
+ } else {
+ let tableHtml = `
+
+
+
+ | Провайдер |
+ Всего вызовов |
+ Успешных |
+ Ошибок |
+ p50 задержка |
+ Расход токенов |
+
+
+
+ `;
+ provKeys.forEach((pKey) => {
+ const item = providersData[pKey] || {};
+ tableHtml += `
+
+ | ${escapeHtml(pKey)} |
+ ${item.total_calls ?? 0} |
+ ${item.successful_calls ?? 0} |
+ ${item.failed_calls ?? 0} |
+ ${item.latency_p50_ms ? `${item.latency_p50_ms} мс` : '—'} |
+ ${item.total_tokens ? new Intl.NumberFormat('ru-RU').format(item.total_tokens) : 'Н/Д'} |
+
+ `;
+ });
+ tableHtml += '
';
+ providersContainer.innerHTML = tableHtml;
+ }
+ }
+
+ // Roles telemetry table
+ const rolesContainer = document.getElementById('analytics-roles-table');
+ const rolesData = telemetry.roles || {};
+ if (rolesContainer) {
+ const roleKeys = Object.keys(rolesData);
+ if (roleKeys.length === 0) {
+ rolesContainer.innerHTML = '
Нет накопленной телеметрии по ролям
';
+ } else {
+ let tableHtml = `
+
+
+
+ | Роль |
+ Всего вызовов |
+ Успешных |
+ Ошибок |
+ p50 задержка |
+
+
+
+ `;
+ roleKeys.forEach((rKey) => {
+ const item = rolesData[rKey] || {};
+ tableHtml += `
+
+ | ${escapeHtml(rKey)} |
+ ${item.total_calls ?? 0} |
+ ${item.successful_calls ?? 0} |
+ ${item.failed_calls ?? 0} |
+ ${item.latency_p50_ms ? `${item.latency_p50_ms} мс` : '—'} |
+
+ `;
+ });
+ tableHtml += '
';
+ rolesContainer.innerHTML = tableHtml;
+ }
+ }
+}
+
+// ── HEALTH VIEW ──
+function renderHealthView() {
+ if (!currentSnapshot) return;
+ 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');
+ banner.className = `readiness-banner ${stateClass}`;
+ banner.innerHTML = `
+
+
${escapeHtml(readiness.summary_ru || 'Проверка состояния маршрутизатора')}
+
+ Готовых ролей: ${readiness.roles_ready_count ?? 0} из ${readiness.total_roles ?? 13}
+ Подключенных аккаунтов: ${(currentSnapshot.all_profiles ? Object.values(currentSnapshot.all_profiles).filter(isConnectedProfile).length : 0)}
+
+ `;
+ }
+
+ const resContainer = document.getElementById('health-host-resources');
+ if (resContainer) {
+ const host = currentSnapshot.host_resources || {};
+ resContainer.innerHTML = `
+
+
CPU
+
${host.cpu_percent !== undefined ? `${host.cpu_percent}%` : 'Н/Д'}
+
${host.cpu_count ? `${host.cpu_count} ядер` : 'Системный CPU'}
+
+
+
Память (RAM)
+
${host.memory_percent !== undefined ? `${host.memory_percent}%` : 'Н/Д'}
+
${host.memory_used_gb ? `${host.memory_used_gb} ГБ / ${host.memory_total_gb} ГБ` : 'Оперативная память'}
+
+
+
Диск
+
${host.disk_percent !== undefined ? `${host.disk_percent}%` : 'Н/Д'}
+
${host.disk_free_gb ? `Свободно ${host.disk_free_gb} ГБ` : 'Хранилище'}
+
+
+
Время работы (Uptime)
+
${host.uptime_formatted || host.uptime || 'В сети'}
+
Процесс Hermes Hub
+
+ `;
+ }
+
+ const warningsContainer = document.getElementById('health-warnings-list');
+ if (warningsContainer) {
+ const warnings = readiness.warnings || [];
+ if (warnings.length === 0) {
+ warningsContainer.innerHTML = '
✓ Критических предупреждений и деградаций не обнаружено
';
+ } else {
+ warningsContainer.innerHTML = warnings.map((w) => `
+
+ ⚠ ${escapeHtml(w.title || 'Предупреждение')}: ${escapeHtml(w.message || w)}
+
+ `).join('');
+ }
+ }
+}
+
+// ── LOGS VIEW ──
+let cachedLogs = [];
+
+async function fetchLogs() {
+ const container = document.getElementById('logs-container');
+ if (container) {
+ container.innerHTML = '
⏳ Загрузка журнала событий...
';
+ }
+ 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 = `
❌ Ошибка загрузки журнала: ${escapeHtml(err.message)}
`;
+ }
+ }
+}
+
+function renderLogsView() {
+ fetchLogs();
+}
+
+function renderLogsList(events) {
+ const listToRender = events || cachedLogs;
+ const container = document.getElementById('logs-container');
+ if (!container) return;
+ const searchEl = document.getElementById('logs-search');
+ const levelEl = document.getElementById('logs-filter-level');
+ const catEl = document.getElementById('logs-filter-category');
+
+ const q = searchEl ? searchEl.value.toLowerCase().trim() : '';
+ const levelFilter = levelEl ? levelEl.value : 'all';
+ const catFilter = catEl ? catEl.value : 'all';
+
+ const filtered = listToRender.filter((ev) => {
+ if (levelFilter !== 'all' && (ev.level || '').toLowerCase() !== levelFilter) return false;
+ if (catFilter !== 'all' && (ev.category || '').toLowerCase() !== catFilter) return false;
+ if (q) {
+ const matchText = `${ev.message || ''} ${ev.details || ''} ${ev.category || ''} ${ev.profile_id || ''}`.toLowerCase();
+ if (!matchText.includes(q)) return false;
+ }
+ return true;
+ });
+
+ if (filtered.length === 0) {
+ container.innerHTML = '
События не найдены
';
+ return;
+ }
+
+ container.innerHTML = `
+
+
+
+ | Время |
+ Уровень |
+ Категория |
+ Сообщение |
+
+
+
+ ${filtered.map((ev) => {
+ const lvl = (ev.level || 'info').toLowerCase();
+ const lvlClass = lvl === 'error' ? 'error' : (lvl === 'warning' || lvl === 'warn' ? 'warning' : (lvl === 'success' ? 'healthy' : 'info'));
+ return `
+
+ | ${escapeHtml(ev.timestamp || '—')} |
+ ${escapeHtml(ev.level || 'INFO')} |
+ ${escapeHtml(ev.category || 'system')} |
+
+ ${escapeHtml(ev.message || '')}
+ ${ev.details ? `${escapeHtml(typeof ev.details === 'object' ? JSON.stringify(ev.details) : ev.details)} ` : ''}
+ |
+
+ `;
+ }).join('')}
+
+
+ `;
+}
+
// ── SETTINGS MANAGEMENT ──
function renderSettingsView() {
if (!currentSnapshot) return;
diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html
index 9ba4e51..75d57dd 100644
--- a/src/antigravity_provider/router/web/static/index.html
+++ b/src/antigravity_provider/router/web/static/index.html
@@ -4,6 +4,9 @@
Hermes Hub — Панель управления
+
+
+
@@ -111,6 +114,7 @@
Среднее время ответаЗагрузка…Получение телеметрии
Использование токеновЗагрузка…Получение телеметрии
Успешность задачЗагрузка…Получение телеметрии
+
Статус системыЗагрузка…Проверка готовности
@@ -121,8 +125,10 @@
100%
-
-
+
В workflow пока нет агентовДобавьте агента, затем переключитесь в EDIT и соедините узлы.
diff --git a/src/antigravity_provider/router/web/static/workflow.css b/src/antigravity_provider/router/web/static/workflow.css
index 19de83e..bfe3fd7 100644
--- a/src/antigravity_provider/router/web/static/workflow.css
+++ b/src/antigravity_provider/router/web/static/workflow.css
@@ -8,13 +8,14 @@
.workflow-toolbar-actions { display:flex; gap:8px; align-items:center; }
.workflow-save-state { color:var(--text-muted); font-size:11px; }
.workflow-save-state.dirty { color:var(--status-warning); }
-.workflow-kpis { display:grid; grid-template-columns:repeat(5,minmax(130px,1fr)); gap:8px; margin-bottom:10px; }
+.workflow-kpis { display:grid; grid-template-columns:repeat(6,minmax(120px,1fr)); gap:8px; margin-bottom:10px; }
.workflow-kpi { padding:11px 13px; min-height:78px; border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:linear-gradient(145deg,var(--surface),var(--surface-muted)); }
.workflow-kpi span,.workflow-kpi small { display:block; color:var(--text-muted); }
-.workflow-kpi strong { display:block; margin:4px 0 2px; font-size:20px; font-weight:650; color:var(--text-primary); }
+.workflow-kpi strong { display:block; margin:4px 0 2px; font-size:18px; font-weight:650; color:var(--text-primary); }
.workflow-kpi small { font-size:10px; line-height:1.25; }
-.workflow-main-layout { display:grid; grid-template-columns:minmax(580px,1fr) 330px; gap:10px; min-height:520px; }
+.workflow-main-layout { display:grid; grid-template-columns:minmax(580px,1fr) 330px; gap:10px; min-height:540px; }
.workflow-board-card,.workflow-inspector,.workflow-events,.workflow-run-panel { border:1px solid var(--border-subtle); border-radius:var(--radius-md); background:var(--surface); overflow:hidden; }
+.workflow-board-card { display:flex; flex-direction:column; }
.workflow-board-header { min-height:45px; padding:8px 12px; display:flex; gap:18px; align-items:center; justify-content:space-between; border-bottom:1px solid var(--border-subtle); color:var(--text-secondary); }
.workflow-board-header strong { color:var(--text-accent); text-transform:uppercase; }
.workflow-board-header label { margin-left:auto; font-size:11px; color:var(--text-muted); }
@@ -25,9 +26,11 @@
.workflow-zoom { display:flex; align-items:center; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); }
.workflow-zoom button { width:28px; height:27px; border:0; border-left:1px solid var(--border-subtle); color:var(--text-accent); background:transparent; cursor:pointer; }
.workflow-zoom span { min-width:44px; text-align:center; font-size:11px; }
-.workflow-canvas { position:relative; height:430px; overflow:hidden; background-color:var(--bg-base); background-image:radial-gradient(var(--border-subtle) 1px,transparent 1px); background-size:18px 18px; }
-.workflow-edges,.workflow-node-layer { position:absolute; inset:0; width:100%; height:100%; transform-origin:0 0; }
-.workflow-edges { overflow:visible; pointer-events:none; }
+.workflow-canvas { position:relative; min-height:520px; height:58vh; flex:1; overflow:hidden; background-color:var(--bg-base); background-image:radial-gradient(var(--border-subtle) 1px,transparent 1px); background-size:18px 18px; cursor:grab; user-select:none; }
+.workflow-canvas:active { cursor:grabbing; }
+.workflow-viewport { position:absolute; top:0; left:0; width:100%; height:100%; transform-origin:0 0; pointer-events:none; }
+.workflow-edges { position:absolute; top:0; left:0; width:5000px; height:5000px; overflow:visible; pointer-events:none; }
+.workflow-node-layer { position:absolute; top:0; left:0; width:5000px; height:5000px; pointer-events:none; }
.workflow-edges path { fill:none; stroke:var(--accent); stroke-width:1.6; marker-end:url(#wf-arrow); }
.workflow-edges path.success,.workflow-edges path.review_passed { stroke:var(--status-healthy); }
.workflow-edges path.review_failed,.workflow-edges path.error { stroke:var(--status-error); stroke-dasharray:7 5; }
diff --git a/src/antigravity_provider/router/web/static/workflow.js b/src/antigravity_provider/router/web/static/workflow.js
index a22c80c..a9b2657 100644
--- a/src/antigravity_provider/router/web/static/workflow.js
+++ b/src/antigravity_provider/router/web/static/workflow.js
@@ -7,6 +7,10 @@ const workflowUi = {
selectedAgentId: null,
selectedTab: 'main',
scale: 1,
+ panX: 0,
+ panY: 0,
+ isPanning: false,
+ panStart: { x: 0, y: 0 },
dirty: false,
draftEdges: [],
draftPositions: {},
@@ -31,6 +35,17 @@ function wfUnavailable(elementId, reason) {
if (detail) detail.textContent = `Н/Д: ${reason}`;
}
+function updateCanvasTransform() {
+ const viewport = document.getElementById('workflow-viewport');
+ if (viewport) {
+ viewport.style.transform = `translate(${workflowUi.panX}px, ${workflowUi.panY}px) scale(${workflowUi.scale})`;
+ }
+ const zoomVal = document.getElementById('workflow-zoom-value');
+ if (zoomVal) {
+ zoomVal.textContent = `${Math.round(workflowUi.scale * 100)}%`;
+ }
+}
+
function initWorkflowOverview() {
if (workflowUi.initialized) return;
workflowUi.initialized = true;
@@ -46,10 +61,28 @@ function initWorkflowOverview() {
document.getElementById('workflow-zoom-out')?.addEventListener('click', () => setWorkflowScale(workflowUi.scale - 0.1));
document.getElementById('workflow-fit')?.addEventListener('click', fitWorkflowGraph);
const canvas = document.getElementById('workflow-canvas');
+ canvas?.addEventListener('wheel', (e) => {
+ e.preventDefault();
+ const rect = canvas.getBoundingClientRect();
+ const mouseX = e.clientX - rect.left;
+ const mouseY = e.clientY - rect.top;
+ const zoomFactor = e.deltaY < 0 ? 1.1 : 0.9;
+ 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.button === 0 || e.button === 1) {
+ workflowUi.isPanning = true;
+ workflowUi.panStart = { x: e.clientX - workflowUi.panX, y: e.clientY - workflowUi.panY };
+ canvas.style.cursor = 'grabbing';
+ }
+ });
canvas?.addEventListener('mousemove', workflowPointerMove);
canvas?.addEventListener('mouseup', workflowPointerUp);
canvas?.addEventListener('mouseleave', workflowPointerCancel);
- window.addEventListener('resize', drawWorkflowEdges);
+ window.addEventListener('resize', () => {
+ drawWorkflowEdges();
+ });
}
function renderWorkflowOverview(snapshot) {
@@ -103,8 +136,18 @@ function renderWorkflowKpis(snapshot) {
const readiness = snapshot.readiness || {};
if (readiness.roles_ready_count === null || readiness.roles_ready_count === undefined || readiness.total_roles === undefined) {
wfUnavailable('workflow-kpi-online', 'readiness не содержит число готовых ролей');
+ wfUnavailable('workflow-kpi-status', 'readiness не содержит данных');
} else {
- setWorkflowKpi('workflow-kpi-online', `${readiness.roles_ready_count} / ${readiness.total_roles}`, 'Источник: readiness');
+ const readyCount = readiness.roles_ready_count;
+ const totalCount = readiness.total_roles;
+ setWorkflowKpi('workflow-kpi-online', `${readyCount} / ${totalCount}`, 'Источник: readiness');
+ if (readyCount === totalCount && totalCount > 0) {
+ setWorkflowKpi('workflow-kpi-status', 'Все сервисы работают', `Отлично (${readyCount}/${totalCount} ролей)`);
+ } else if (readyCount > 0) {
+ setWorkflowKpi('workflow-kpi-status', 'Частично готов', `${readyCount} из ${totalCount} ролей готовы`);
+ } else {
+ setWorkflowKpi('workflow-kpi-status', 'Требует настройки', '0 ролей настроено');
+ }
}
if (!global.total_calls) {
wfUnavailable('workflow-kpi-latency', 'за 24 часа нет измеренных вызовов');
@@ -165,7 +208,7 @@ function renderWorkflowNodes(workflow) {
${wfEscape(runtimeStateLabel(agent.runtime_state))}
`;
}).join('');
- layer.style.transform = `scale(${workflowUi.scale})`;
+ updateCanvasTransform();
layer.querySelectorAll('.workflow-node').forEach((node) => {
node.addEventListener('click', () => selectWorkflowAgent(node.dataset.agentId));
node.addEventListener('mousedown', beginNodeDrag);
@@ -200,10 +243,17 @@ function beginNodeDrag(event) {
const node = event.currentTarget;
const position = workflowUi.draftPositions[node.dataset.agentId] || { x: node.offsetLeft, y: node.offsetTop };
workflowUi.drag = { id: node.dataset.agentId, startX: event.clientX, startY: event.clientY, original: { ...position }, moved: false };
+ event.stopPropagation();
event.preventDefault();
}
function workflowPointerMove(event) {
+ if (workflowUi.isPanning) {
+ workflowUi.panX = event.clientX - workflowUi.panStart.x;
+ workflowUi.panY = event.clientY - workflowUi.panStart.y;
+ updateCanvasTransform();
+ return;
+ }
if (workflowUi.drag) {
const drag = workflowUi.drag;
const dx = (event.clientX - drag.startX) / workflowUi.scale;
@@ -220,6 +270,11 @@ function workflowPointerMove(event) {
}
function workflowPointerUp(event) {
+ if (workflowUi.isPanning) {
+ workflowUi.isPanning = false;
+ const canvas = document.getElementById('workflow-canvas');
+ if (canvas) canvas.style.cursor = '';
+ }
if (workflowUi.drag) {
if (workflowUi.drag.moved) markWorkflowDirty();
workflowUi.drag = null;
@@ -231,6 +286,11 @@ function workflowPointerUp(event) {
}
function workflowPointerCancel() {
+ if (workflowUi.isPanning) {
+ workflowUi.isPanning = false;
+ const canvas = document.getElementById('workflow-canvas');
+ if (canvas) canvas.style.cursor = '';
+ }
if (workflowUi.drag) {
workflowUi.draftPositions[workflowUi.drag.id] = workflowUi.drag.original;
workflowUi.drag = null;
@@ -257,21 +317,19 @@ function finishConnection(event) {
}
function drawWorkflowEdges() {
- const canvas = document.getElementById('workflow-canvas');
const svg = document.getElementById('workflow-edges');
const layer = document.getElementById('workflow-edge-layer');
- if (!canvas || !svg || !layer) return;
- svg.setAttribute('viewBox', `0 0 ${canvas.clientWidth} ${canvas.clientHeight}`);
+ if (!svg || !layer) return;
const parts = [];
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)}"]`);
if (!source || !target) return;
- const x1 = (source.offsetLeft + source.offsetWidth) * workflowUi.scale;
- const y1 = (source.offsetTop + source.offsetHeight / 2) * workflowUi.scale;
- const x2 = target.offsetLeft * workflowUi.scale;
- const y2 = (target.offsetTop + target.offsetHeight / 2) * workflowUi.scale;
- const bend = Math.max(45, Math.abs(x2 - x1) * .42);
+ const x1 = source.offsetLeft + source.offsetWidth;
+ 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 labelX = (x1 + x2) / 2;
@@ -296,18 +354,58 @@ function renderWorkflowMinimap(agents) {
}).join('');
}
-function setWorkflowScale(value) {
- workflowUi.scale = Math.max(.5, Math.min(1.6, Math.round(value * 10) / 10));
- document.getElementById('workflow-zoom-value').textContent = `${Math.round(workflowUi.scale * 100)}%`;
- renderWorkflowOverview(currentSnapshot);
+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));
+ if (focusX === undefined || focusY === undefined) {
+ focusX = canvas.clientWidth / 2;
+ focusY = canvas.clientHeight / 2;
+ }
+ workflowUi.panX = focusX - (focusX - workflowUi.panX) * (newScale / oldScale);
+ workflowUi.panY = focusY - (focusY - workflowUi.panY) * (newScale / oldScale);
+ workflowUi.scale = newScale;
+ updateCanvasTransform();
}
function fitWorkflowGraph() {
- const agents = currentSnapshot?.workflow?.agents || [];
- const maxX = Math.max(...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.x || 0) + 210), 600);
- const maxY = Math.max(...agents.map((agent) => (workflowUi.draftPositions[agent.id]?.y || 0) + 110), 400);
const canvas = document.getElementById('workflow-canvas');
- setWorkflowScale(Math.min(canvas.clientWidth / maxX, canvas.clientHeight / maxY, 1));
+ if (!canvas) return;
+ const agents = currentSnapshot?.workflow?.agents || [];
+ if (!agents.length) {
+ workflowUi.scale = 1;
+ workflowUi.panX = 0;
+ workflowUi.panY = 0;
+ updateCanvasTransform();
+ return;
+ }
+ let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
+ 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);
+ minY = Math.min(minY, pos.y);
+ maxY = Math.max(maxY, pos.y + 110);
+ });
+ 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 viewport = document.getElementById('workflow-viewport');
+ if (viewport) viewport.style.transition = 'transform 0.25s ease-out';
+
+ workflowUi.scale = fitScale;
+ workflowUi.panX = (cW - width * fitScale) / 2 - minX * fitScale;
+ workflowUi.panY = (cH - height * fitScale) / 2 - minY * fitScale;
+ updateCanvasTransform();
+
+ setTimeout(() => {
+ if (viewport) viewport.style.transition = '';
+ }, 260);
}
function markWorkflowDirty() {
diff --git a/tests/test_a43_frontend_canvas.py b/tests/test_a43_frontend_canvas.py
new file mode 100644
index 0000000..425269c
--- /dev/null
+++ b/tests/test_a43_frontend_canvas.py
@@ -0,0 +1,97 @@
+"""Automated tests for Task A43: Frontend Canvas and Brandbook Layouts."""
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+STATIC_DIR = REPO_ROOT / "src" / "antigravity_provider" / "router" / "web" / "static"
+
+
+def test_index_html_canvas_viewport_and_kpis():
+ html_path = STATIC_DIR / "index.html"
+ assert html_path.exists(), "index.html must exist"
+ content = html_path.read_text(encoding="utf-8")
+
+ # Fonts
+ assert "Cinzel" in content, "Cinzel font must be linked in head"
+ assert "Inter" in content, "Inter font must be linked in head"
+
+ # Canvas Viewport
+ assert 'id="workflow-viewport"' in content, "workflow-viewport wrapper must exist"
+ assert 'id="workflow-edges"' in content
+ assert 'id="workflow-node-layer"' in content
+
+ # 6 KPIs
+ assert 'id="workflow-kpi-active"' in content
+ assert 'id="workflow-kpi-online"' in content
+ assert 'id="workflow-kpi-latency"' in content
+ assert 'id="workflow-kpi-tokens"' in content
+ assert 'id="workflow-kpi-success"' in content
+ assert 'id="workflow-kpi-status"' in content
+
+
+def test_workflow_css_canvas_flexibility_and_viewport():
+ css_path = STATIC_DIR / "workflow.css"
+ assert css_path.exists(), "workflow.css must exist"
+ content = css_path.read_text(encoding="utf-8")
+
+ # Flexible canvas height (not locked at 430px)
+ assert ".workflow-canvas" in content
+ assert "height: 430px" not in content, "workflow-canvas must not have fixed 430px height"
+ assert "min-height" in content
+
+ # Viewport transform
+ assert ".workflow-viewport" in content
+ assert "transform-origin: 0 0" in content or "transform-origin:0 0" in content
+
+ # 6 KPI columns
+ assert "repeat(6" in content, "KPI grid must have 6 columns"
+
+
+def test_workflow_js_pan_zoom_and_6_kpis():
+ js_path = STATIC_DIR / "workflow.js"
+ assert js_path.exists(), "workflow.js must exist"
+ content = js_path.read_text(encoding="utf-8")
+
+ # Pan and zoom state
+ assert "panX:" in content or "panX =" in content
+ assert "panY:" in content or "panY =" in content
+ assert "isPanning" in content
+ assert "updateCanvasTransform" in content
+
+ # Wheel listener
+ assert "addEventListener('wheel'" in content or 'addEventListener("wheel"' in content
+
+ # Fit graph
+ assert "function fitWorkflowGraph" in content
+
+ # 6th KPI status
+ assert "workflow-kpi-status" in content
+
+
+def test_app_js_no_mock_numbers_and_all_views_defined():
+ app_js_path = STATIC_DIR / "app.js"
+ assert app_js_path.exists(), "app.js must exist"
+ content = app_js_path.read_text(encoding="utf-8")
+
+ # No hardcoded fake progress bars or mock dates in routing
+ assert 'width:70%' not in content and 'width: 70%' not in content
+ assert '26 авг.,' not in content
+
+ # Views must be defined
+ assert "function renderAnalyticsView" in content
+ assert "function renderHealthView" in content
+ assert "function renderLogsView" in content
+ assert "function fetchLogs" in content
+ assert "function renderLogsList" in content
+
+
+def test_style_css_three_themes():
+ css_path = STATIC_DIR / "style.css"
+ assert css_path.exists(), "style.css must exist"
+ content = css_path.read_text(encoding="utf-8")
+
+ # Dark theme
+ assert 'body[data-theme="dark"]' in content
+ # Medium theme
+ assert 'body[data-theme="medium"]' in content
+ # Light theme
+ assert 'body[data-theme="light"]' in content