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 `
`; + }).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 = `
Н/Д
`; + } + const healthClass = prof.health_state || (prof.enabled ? 'healthy' : 'disabled'); + const healthLabel = prof.health_label_ru || (prof.enabled ? 'Активен' : 'Отключён'); + rolesHtml += `
@@ -1083,14 +1105,14 @@ function renderRoutingView() { ${escapeHtml(prov)}
-
-
+ ${quotaBarsHtml}
- 26 авг.,
18:42 + ${resetHtml}
- ● Активен + + ${escapeHtml(healthLabel)}
@@ -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 = ` + + + + + + + + + + + + + `; + provKeys.forEach((pKey) => { + const item = providersData[pKey] || {}; + tableHtml += ` + + + + + + + + + `; + }); + tableHtml += '
ПровайдерВсего вызововУспешныхОшибокp50 задержкаРасход токенов
${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) : 'Н/Д'}
'; + 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 = ` + + + + + + + + + + + + `; + roleKeys.forEach((rKey) => { + const item = rolesData[rKey] || {}; + tableHtml += ` + + + + + + + + `; + }); + tableHtml += '
РольВсего вызововУспешныхОшибокp50 задержка
${escapeHtml(rKey)}${item.total_calls ?? 0}${item.successful_calls ?? 0}${item.failed_calls ?? 0}${item.latency_p50_ms ? `${item.latency_p50_ms} мс` : '—'}
'; + 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.title_ru || 'Состояние готовности')}

+
+

${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 ` + + + + + + + `; + }).join('')} + +
ВремяУровеньКатегорияСообщение
${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)}
` : ''} +
+ `; +} + // ── 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%
- -
+
+ +
+
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