feat(ui): A43 интерактивный холст workflow (n8n style), панорамирование, зум колесом, 6 KPI и верстка по макетам

This commit is contained in:
ochenstarik-ui 2026-08-31 12:29:06 +07:00
parent ff303b591d
commit 81a58f6b13
5 changed files with 528 additions and 32 deletions

View file

@ -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 `<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>
@ -1083,14 +1105,14 @@ function renderRoutingView() {
${escapeHtml(prov)}
</div>
<div>
<div class="cell-bar-track"><div class="cell-bar-fill" style="width:70%; background:var(--status-healthy);"></div></div>
<div class="cell-bar-track" style="margin-top:4px;"><div class="cell-bar-fill" style="width:40%; background:var(--status-healthy);"></div></div>
${quotaBarsHtml}
</div>
<div style="font-size:10px; color:var(--text-muted);">
26 авг.,<br>18:42
${resetHtml}
</div>
<div>
<span style="color:var(--status-healthy);"> Активен</span>
<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>
@ -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 = '<div style="padding:16px; text-align:center; color:var(--text-muted);">Нет накопленной телеметрии по провайдерам</div>';
} else {
let tableHtml = `
<table class="data-table">
<thead>
<tr>
<th>Провайдер</th>
<th>Всего вызовов</th>
<th>Успешных</th>
<th>Ошибок</th>
<th>p50 задержка</th>
<th>Расход токенов</th>
</tr>
</thead>
<tbody>
`;
provKeys.forEach((pKey) => {
const item = providersData[pKey] || {};
tableHtml += `
<tr>
<td><strong>${escapeHtml(pKey)}</strong></td>
<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>${item.total_tokens ? new Intl.NumberFormat('ru-RU').format(item.total_tokens) : 'Н/Д'}</td>
</tr>
`;
});
tableHtml += '</tbody></table>';
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 = '<div style="padding:16px; text-align:center; color:var(--text-muted);">Нет накопленной телеметрии по ролям</div>';
} else {
let tableHtml = `
<table class="data-table">
<thead>
<tr>
<th>Роль</th>
<th>Всего вызовов</th>
<th>Успешных</th>
<th>Ошибок</th>
<th>p50 задержка</th>
</tr>
</thead>
<tbody>
`;
roleKeys.forEach((rKey) => {
const item = rolesData[rKey] || {};
tableHtml += `
<tr>
<td><strong>${escapeHtml(rKey)}</strong></td>
<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>
</tr>
`;
});
tableHtml += '</tbody></table>';
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 = `
<div class="readiness-banner-header">
<span class="status-dot ${stateClass}"></span>
<h3>${escapeHtml(readiness.title_ru || 'Состояние готовности')}</h3>
</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>${(currentSnapshot.all_profiles ? Object.values(currentSnapshot.all_profiles).filter(isConnectedProfile).length : 0)}</strong></span>
</div>
`;
}
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>
`;
}
const warningsContainer = document.getElementById('health-warnings-list');
if (warningsContainer) {
const warnings = readiness.warnings || [];
if (warnings.length === 0) {
warningsContainer.innerHTML = '<div style="padding:14px; color:var(--status-healthy); font-size:12px;">✓ Критических предупреждений и деградаций не обнаружено</div>';
} else {
warningsContainer.innerHTML = warnings.map((w) => `
<div class="warning-item" style="padding:8px 12px; margin-bottom:6px; border-left:3px solid var(--status-warning); background:var(--surface-muted); font-size:12px;">
<strong> ${escapeHtml(w.title || 'Предупреждение')}</strong>: ${escapeHtml(w.message || w)}
</div>
`).join('');
}
}
}
// ── LOGS VIEW ──
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>`;
}
}
}
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 = '<div style="padding:24px; text-align:center; color:var(--text-muted); font-size:12px;">События не найдены</div>';
return;
}
container.innerHTML = `
<table class="data-table">
<thead>
<tr>
<th style="width:140px;">Время</th>
<th style="width:90px;">Уровень</th>
<th style="width:110px;">Категория</th>
<th>Сообщение</th>
</tr>
</thead>
<tbody>
${filtered.map((ev) => {
const lvl = (ev.level || 'info').toLowerCase();
const lvlClass = lvl === 'error' ? 'error' : (lvl === 'warning' || lvl === 'warn' ? 'warning' : (lvl === 'success' ? 'healthy' : 'info'));
return `
<tr>
<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: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>` : ''}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
}
// ── SETTINGS MANAGEMENT ──
function renderSettingsView() {
if (!currentSnapshot) return;

View file

@ -4,6 +4,9 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hermes Hub — Панель управления</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<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>">
@ -111,6 +114,7 @@
<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-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-status">Загрузка…</strong><small id="workflow-kpi-status-reason">Проверка готовности</small></article>
</div>
<div class="workflow-main-layout">
@ -121,8 +125,10 @@
<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">
<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-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>
<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>
</div>

View file

@ -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; }

View file

@ -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) {
<div class="workflow-node-status"><i></i><span>${wfEscape(runtimeStateLabel(agent.runtime_state))}</span></div>
</article>`;
}).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() {

View file

@ -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