feat(ui): A43 интерактивный холст workflow (n8n style), панорамирование, зум колесом, 6 KPI и верстка по макетам
This commit is contained in:
parent
ff303b591d
commit
81a58f6b13
5 changed files with 528 additions and 32 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
97
tests/test_a43_frontend_canvas.py
Normal file
97
tests/test_a43_frontend_canvas.py
Normal 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
|
||||
Loading…
Reference in a new issue