/** * Hermes Hub Web Client * Vanilla JavaScript (ES2022) — No npm, no build, no framework. * Single source of truth: docs/web-api/CONTRACT.md */ // ── CONFIGURATION & STATE ── const USE_MOCK_FIXTURE = false; let lastAppliedSeq = -1; let currentSnapshot = null; let activeView = 'overview'; let pollTimer = null; let pollIntervalMs = 5000; let authToken = localStorage.getItem('hermes_hub_token') || ''; let cachedEvents = []; let currentSettings = {}; let currentDragState = null; const CANONICAL_ROLE_DESCRIPTIONS = { orchestrator: 'Главный оркестратор команды и маршрутизатор запросов', 'coder-primary': 'Основная разработка кода и реализация задач', 'coder-secondary': 'Вспомогательная разработка и параллельные задачи', reviewer: 'Ревью кода, аудит изменений и контроль качества', research: 'Read-only поиск в кодовой базе и сбор фактов', fast: 'Оперативные вызовы, быстрые проверки и тесты', }; function getProviderIdFromName(name) { if (!name) return ''; const n = name.toLowerCase(); if (n.includes('antigravity') || n.includes('google')) return 'antigravity'; if (n.includes('codex') || n.includes('openai')) return 'openai-codex'; if (n.includes('opencode') || n.includes('go')) return 'opencode-go'; if (n.includes('claude') || n.includes('anthropic')) return 'claude'; if (n.includes('grok') || n.includes('xai')) return 'grok'; return name; } // ── DOM ELEMENTS ── const elements = { navItems: document.querySelectorAll('.nav-item'), viewPanes: document.querySelectorAll('.view-pane'), pageTitle: document.getElementById('page-title'), navAccountsCount: document.getElementById('nav-accounts-count'), headerReadinessBadge: document.getElementById('header-readiness-badge'), headerReadinessText: document.getElementById('header-readiness-text'), sourceText: document.getElementById('source-text'), sourceDot: document.querySelector('#source-indicator .status-dot'), accountsContainer: document.getElementById('accounts-container'), accountsSearch: document.getElementById('accounts-search'), filterProvider: document.getElementById('filter-provider'), filterHealth: document.getElementById('filter-health'), accountsStatsSummary: document.getElementById('accounts-stats-summary'), btnRefreshAll: document.getElementById('btn-refresh-all'), btnAddAccount: document.getElementById('btn-add-account'), modalBackdrop: document.getElementById('modal-backdrop'), modalTitle: document.getElementById('modal-title'), modalBody: document.getElementById('modal-body'), modalFooter: document.getElementById('modal-footer'), modalCloseBtn: document.getElementById('modal-close-btn'), toastContainer: document.getElementById('toast-container'), }; // ── INITIALIZATION ── document.addEventListener('DOMContentLoaded', () => { initNavigation(); initEventListeners(); initSettings(); fetchSnapshot(); startPolling(); }); // ── NAVIGATION ── function initNavigation() { document.querySelectorAll('.nav-item').forEach((btn) => { btn.addEventListener('click', () => { const view = btn.dataset.view; switchView(view); }); }); } function switchView(viewName) { activeView = viewName; document.querySelectorAll('.nav-item').forEach((btn) => { btn.classList.toggle('active', btn.dataset.view === viewName); }); document.querySelectorAll('.view-pane').forEach((pane) => { pane.classList.toggle('active', pane.id === `view-${viewName}`); }); const titles = { overview: 'Обзор системы', accounts: 'Аккаунты и квоты', routing: 'Главный экран управления маршрутизацией', analytics: 'Аналитика и телеметрия', health: 'Состояние и диагностика', logs: 'Журнал событий', settings: 'Настройки Hermes Hub', }; if (elements.pageTitle) { elements.pageTitle.textContent = titles[viewName] || 'Hermes Hub'; } if (currentSnapshot) { renderCurrentView(); } } // ── EVENT LISTENERS ── function initEventListeners() { if (elements.accountsSearch) elements.accountsSearch.addEventListener('input', () => renderAccountsView()); if (elements.filterProvider) elements.filterProvider.addEventListener('change', () => renderAccountsView()); if (elements.filterHealth) elements.filterHealth.addEventListener('change', () => renderAccountsView()); if (elements.btnRefreshAll) { elements.btnRefreshAll.addEventListener('click', () => { executeAction('refresh_all', {}); }); } if (elements.btnAddAccount) { elements.btnAddAccount.addEventListener('click', () => { openAddAccountWizard(); }); } if (elements.modalCloseBtn) elements.modalCloseBtn.addEventListener('click', closeModal); if (elements.modalBackdrop) { elements.modalBackdrop.addEventListener('click', (e) => { if (e.target === elements.modalBackdrop) closeModal(); }); } // Logs view filters const logsSearch = document.getElementById('logs-search'); const logsFilterLevel = document.getElementById('logs-filter-level'); const logsFilterCategory = document.getElementById('logs-filter-category'); const btnRefreshLogs = document.getElementById('btn-refresh-logs'); if (logsSearch) logsSearch.addEventListener('input', () => renderLogsList()); if (logsFilterLevel) logsFilterLevel.addEventListener('change', () => renderLogsList()); if (logsFilterCategory) logsFilterCategory.addEventListener('change', () => renderLogsList()); if (btnRefreshLogs) btnRefreshLogs.addEventListener('click', () => fetchLogs()); // Settings view buttons const btnSaveHub = document.getElementById('btn-save-hub-settings'); if (btnSaveHub) btnSaveHub.addEventListener('click', () => saveHubServerSettings()); const themeSel = document.getElementById('setting-theme'); if (themeSel) { themeSel.addEventListener('change', () => { applyTheme(themeSel.value); }); } } // ── SNAPSHOT INGESTION & MONOTONIC SEQ ── async function fetchSnapshot() { const urlParams = new URLSearchParams(window.location.search); const forceFixture = USE_MOCK_FIXTURE || urlParams.get('fixture') === '1' || window.location.protocol === 'file:'; if (forceFixture) { try { const res = await fetch('snapshot.example.json'); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); setSourceIndicator(true, 'Фикстура (snapshot.example.json)'); applySnapshot(data); return; } catch (err) { console.error('Failed to load snapshot.example.json:', err); setSourceIndicator(false, 'Ошибка фикстуры'); return; } } try { const headers = {}; if (authToken) headers['X-Hub-Token'] = authToken; const res = await fetch('/api/snapshot', { headers }); if (!res.ok) { if (res.status === 404 || res.status === 502 || res.status === 503) { throw new Error(`Server returned ${res.status}`); } const errBody = await res.json().catch(() => ({})); showToast(errBody.error || `Ошибка сервера: ${res.status}`, 'error'); setSourceIndicator(false, `Ошибка /api/snapshot (${res.status})`); return; } const data = await res.json(); setSourceIndicator(true, 'Live API (/api/snapshot)'); applySnapshot(data); } catch (err) { console.warn('Live API unavailable, attempting snapshot.example.json fallback:', err); try { const fallbackRes = await fetch('snapshot.example.json'); if (fallbackRes.ok) { const fallbackData = await fallbackRes.json(); setSourceIndicator(true, 'Фикстура (fallback)'); applySnapshot(fallbackData); return; } } catch (e) {} setSourceIndicator(false, 'Сервер недоступен'); } } function applySnapshot(snapshot) { if (!snapshot || typeof snapshot !== 'object') return; // Monotonic seq check: reject out-of-order stale responses if (typeof snapshot.seq === 'number') { if (snapshot.seq < lastAppliedSeq) { console.warn(`[Hub] Stale snapshot rejected: seq ${snapshot.seq} < lastAppliedSeq ${lastAppliedSeq}`); return; } lastAppliedSeq = snapshot.seq; } const isFirstLoad = !currentSnapshot; currentSnapshot = snapshot; updateGlobalHeader(); if (isFirstLoad) { const params = new URLSearchParams(window.location.search); const targetView = params.get('view'); const targetModal = params.get('modal'); const targetProfile = params.get('profile'); if (targetView) { switchView(targetView); } else { renderCurrentView(); } if (targetModal === 'grok_wizard') { openAddAccountWizard(); showWizardStep2('grok'); } else if (targetModal === 'antigravity_wizard') { openAddAccountWizard(); showWizardStep2('antigravity'); } else if (targetModal === 'account_details') { openAccountDetailsModal(targetProfile || 'ag-w1'); } else if (targetModal === 'agent_model') { const targetRole = params.get('role') || 'coder-primary'; const ag = (currentSnapshot.agents || []).find(a => a.role_id === targetRole) || (currentSnapshot.agents || [])[1]; if (ag) openAgentModelModal(ag.role_id, ag.assigned_profile_id); } } else { renderCurrentView(); } } function setSourceIndicator(healthy, text) { if (elements.sourceDot) { elements.sourceDot.className = `status-dot ${healthy ? 'healthy' : 'error'}`; } if (elements.sourceText) { elements.sourceText.textContent = text; } } function startPolling() { if (pollTimer) clearInterval(pollTimer); if (pollIntervalMs > 0) { pollTimer = setInterval(fetchSnapshot, pollIntervalMs); } } // ── ACTIONS EXECUTION (POST /api/action) ── async function executeAction(actionName, actionData = {}) { showToast(`Выполняется «${actionName}»...`, 'info'); try { const headers = { 'Content-Type': 'application/json' }; if (authToken) headers['X-Hub-Token'] = authToken; const res = await fetch('/api/action', { method: 'POST', headers, body: JSON.stringify({ action: actionName, data: actionData }), }); const result = await res.json().catch(() => ({ ok: false, message: `Ошибка парсинга ответа (${res.status})` })); if (result.ok) { showToast(result.message || 'Действие выполнено успешно', 'success'); fetchSnapshot(); return result; } else { showToast(result.message || 'Отказ выполнения действия', 'warning'); return result; } } catch (err) { console.error(`Action ${actionName} failed:`, err); showToast(`Ошибка сети: ${err.message}`, 'error'); return { ok: false, message: `Ошибка сети: ${err.message}` }; } } // ── GLOBAL HEADER ── function updateGlobalHeader() { if (!currentSnapshot) return; const totalAccounts = Object.keys(currentSnapshot.all_profiles || {}).length; if (elements.navAccountsCount) elements.navAccountsCount.textContent = totalAccounts; const readiness = currentSnapshot.readiness || {}; const isHealthy = readiness.state === 'healthy'; const readyRoles = readiness.roles_ready_count || 0; const totalRoles = readiness.total_roles || 6; if (elements.headerReadinessBadge) { elements.headerReadinessBadge.className = `header-readiness-badge ${isHealthy ? 'text-healthy' : 'text-warning'}`; } if (elements.headerReadinessText) { elements.headerReadinessText.textContent = readiness.title_ru ? `${readiness.title_ru} (${readyRoles}/${totalRoles} ролей)` : 'Система готова'; } const kpiReadiness = document.getElementById('kpi-system-readiness'); const kpiSummary = document.getElementById('kpi-readiness-summary'); const kpiTotalAccounts = document.getElementById('kpi-total-accounts'); const kpiAccountsSub = document.getElementById('kpi-accounts-sub'); const kpiReadyRoles = document.getElementById('kpi-ready-roles'); const kpiRolesSub = document.getElementById('kpi-roles-sub'); const kpiProvidersCount = document.getElementById('kpi-providers-count'); if (kpiReadiness) kpiReadiness.textContent = readiness.title_ru || 'Работает'; if (kpiSummary) kpiSummary.textContent = readiness.summary_ru || 'Все маршруты доступны'; if (kpiTotalAccounts) kpiTotalAccounts.textContent = totalAccounts; if (kpiAccountsSub) kpiAccountsSub.textContent = `Подключено: ${readiness.accounts_connected_count || totalAccounts}`; if (kpiReadyRoles) kpiReadyRoles.textContent = `${readyRoles}/${totalRoles}`; if (kpiRolesSub) kpiRolesSub.textContent = `${readyRoles} из ${totalRoles} ролей маршрутизации активны`; if (kpiProvidersCount) kpiProvidersCount.textContent = (currentSnapshot.providers || []).length || 5; } // ── VIEW ROUTER ── function renderCurrentView() { if (!currentSnapshot) return; switch (activeView) { case 'overview': renderOverviewView(); break; case 'accounts': renderAccountsView(); break; case 'routing': renderRoutingView(); break; case 'analytics': renderAnalyticsView(); break; case 'health': renderHealthView(); break; case 'logs': renderLogsView(); break; case 'settings': renderSettingsView(); break; } } // ═══════════════════════════════════════════════════════════════ // 1. ACCOUNTS VIEW (Compact Fixed-Height Cards & Quotas) // ═══════════════════════════════════════════════════════════════ function renderAccountsView() { const container = elements.accountsContainer; if (!container || !currentSnapshot) return; const searchQuery = (elements.accountsSearch ? elements.accountsSearch.value : '').trim().toLowerCase(); const providerFilter = elements.filterProvider ? elements.filterProvider.value : 'all'; const healthFilter = elements.filterHealth ? elements.filterHealth.value : 'all'; const providerNames = { antigravity: 'Google Antigravity', 'openai-codex': 'OpenAI Codex', 'opencode-go': 'OpenCode Go', claude: 'Claude (Anthropic)', grok: 'Grok (xAI)', }; const profilesByProv = currentSnapshot.profiles_by_provider || {}; let totalProfiles = 0; let visibleProfiles = 0; let html = ''; for (const [providerId, profiles] of Object.entries(profilesByProv)) { if (providerFilter !== 'all' && providerFilter !== providerId) continue; const filtered = profiles.filter((p) => { totalProfiles++; const matchesSearch = !searchQuery || (p.display_name && p.display_name.toLowerCase().includes(searchQuery)) || (p.account_identity && p.account_identity.toLowerCase().includes(searchQuery)) || (p.email && p.email.toLowerCase().includes(searchQuery)) || (p.profile_id && p.profile_id.toLowerCase().includes(searchQuery)) || (p.assigned_roles && p.assigned_roles.some((r) => r.toLowerCase().includes(searchQuery))) || (p.preferred_models && p.preferred_models.some((m) => m.toLowerCase().includes(searchQuery))); const matchesHealth = healthFilter === 'all' || p.health_state === healthFilter || (healthFilter === 'disabled' && (p.is_cold_spare || !p.enabled || p.health_state === 'disabled')); return matchesSearch && matchesHealth; }); if (filtered.length === 0) continue; visibleProfiles += filtered.length; html += `
${providerNames[providerId] || providerId}
${filtered.length} аккаунт(ов)
${filtered.map((p) => renderAccountCard(p)).join('')}
`; } container.innerHTML = html || '
Аккаунты по заданным фильтрам не найдены.
'; if (elements.accountsStatsSummary) { elements.accountsStatsSummary.innerHTML = `Показано: ${visibleProfiles} из ${totalProfiles} аккаунтов`; } container.querySelectorAll('.account-card').forEach((card) => { card.addEventListener('click', (e) => { // If click was on refresh button, skip modal if (e.target.closest('.btn-ghost')) return; const profileId = card.dataset.profileId; openAccountDetailsModal(profileId); }); }); } function renderAccountCard(profile) { const isMain = profile.is_main_account || profile.is_main_orchestrator; const roles = (profile.assigned_roles || []).join(', ') || 'Роль: Н/Д'; const identity = profile.email || profile.account_identity || profile.display_name || profile.profile_id; const healthState = profile.health_state || 'unknown'; const healthLabel = profile.health_label_ru || (profile.enabled ? 'Работает' : 'Отключён'); const plan = profile.plan_code && profile.plan_code !== 'UNKNOWN' ? profile.plan_code : ''; const quotaSnap = profile.quota_snapshot || (currentSnapshot.quotas || {})[profile.profile_id]; const buckets = (quotaSnap && quotaSnap.buckets) ? quotaSnap.buckets : []; const unavailableReason = quotaSnap ? quotaSnap.unavailable_reason : null; let quotaGridHtml = ''; if (buckets.length > 0) { const visibleBuckets = buckets.slice(0, 4); quotaGridHtml = `
${visibleBuckets.map((b) => renderQuotaCell(b, unavailableReason)).join('')}
`; } else { let reasonText = unavailableReason; if (!unavailableReason && quotaSnap && quotaSnap.is_loading) { reasonText = 'Загрузка квот…'; } else if (!reasonText) { reasonText = (profile.health_state === 'not_configured' || profile.health_state === 'auth_required') ? 'Аккаунт не подключён' : 'Провайдер не отдаёт лимиты'; } quotaGridHtml = `
Квота Н/Д
${escapeHtml(reasonText)}
`; } return `
${quotaGridHtml}
`; } function renderQuotaCell(bucket, unavailableReason) { const remaining = bucket.remaining_percent; let formattedValue = 'Н/Д'; let barWidth = 0; let colorClass = 'var(--status-disabled)'; if (typeof remaining === 'number') { formattedValue = `${remaining.toFixed(1)}%`; barWidth = Math.max(0, Math.min(100, remaining)); if (remaining <= 0) colorClass = 'var(--status-error)'; else if (remaining < 20) colorClass = 'var(--status-warning)'; else colorClass = 'var(--status-healthy)'; } else if (unavailableReason) { formattedValue = 'Н/Д'; } let resetText = bucket.reset_at ? `Сброс: ${formatIsoDate(bucket.reset_at)}` : (bucket.period ? `Период: ${bucket.period}` : (unavailableReason || 'Период провайдера')); return `
${escapeHtml(bucket.display_name)} ${formattedValue}
${escapeHtml(resetText)}
`; } // ═══════════════════════════════════════════════════════════════ // 1. OVERVIEW VIEW (P0-3, P0-4 Diagram Model Select & Counters) // ═══════════════════════════════════════════════════════════════ function renderOverviewView() { if (!currentSnapshot) return; const providers = currentSnapshot.providers || []; const readiness = currentSnapshot.readiness || {}; let totalSlots = 0; let connectedSlots = 0; let onlineSlots = 0; let authRequiredSlots = 0; providers.forEach((p) => { totalSlots += p.total_slots || 0; connectedSlots += p.connected_count || 0; onlineSlots += p.online_count || 0; authRequiredSlots += p.auth_required_count || 0; }); const kpiSystem = document.getElementById('kpi-system-readiness'); const kpiSystemSub = document.getElementById('kpi-readiness-summary'); if (kpiSystem) { kpiSystem.textContent = readiness.title_ru || 'Система готова'; kpiSystem.className = `kpi-value ${readiness.state === 'healthy' ? 'text-healthy' : (readiness.state === 'warning' ? 'text-warning' : 'text-error')}`; } if (kpiSystemSub) { kpiSystemSub.textContent = readiness.summary_ru || 'Все ключевые роли обеспечены'; } const kpiAccounts = document.getElementById('kpi-total-accounts'); const kpiAccountsSub = document.getElementById('kpi-accounts-sub'); if (kpiAccounts) { kpiAccounts.textContent = `${connectedSlots}/${totalSlots} слотов`; } if (kpiAccountsSub) { kpiAccountsSub.textContent = `Онлайн: ${onlineSlots} • Требуют входа: ${authRequiredSlots}`; } const kpiRoles = document.getElementById('kpi-ready-roles'); const kpiRolesSub = document.getElementById('kpi-roles-sub'); if (kpiRoles) { kpiRoles.textContent = `${readiness.roles_ready_count || 0}/${readiness.total_roles || 6}`; } if (kpiRolesSub) { kpiRolesSub.textContent = (readiness.roles_ready_count >= readiness.total_roles) ? 'Все роли обеспечены' : 'Требуется подключение аккаунтов'; } const kpiProviders = document.getElementById('kpi-providers-count'); const kpiProvidersSub = document.getElementById('kpi-providers-sub'); if (kpiProviders) { const connectedProviders = providers.filter((p) => (p.connected_count || 0) > 0).length; kpiProviders.textContent = `${connectedProviders}/${providers.length}`; } if (kpiProvidersSub) { kpiProvidersSub.textContent = 'Подключено провайдеров ИИ'; } const diagramBox = document.getElementById('overview-route-diagram'); if (diagramBox) { const roles = currentSnapshot.routing || {}; let diagramHtml = ''; for (const [roleId, pipeline] of Object.entries(roles)) { const nodes = pipeline.nodes || []; diagramHtml += `
${escapeHtml(pipeline.role_name_ru || roleId)}
${nodes.map((node, idx) => { const profile = (currentSnapshot.all_profiles || {})[node.profile_id]; const provId = profile?.provider || getProviderIdFromName(node.provider); const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === provId || p.provider_name === node.provider); const discoveredModels = (provSummary && provSummary.discovered_models && provSummary.discovered_models.length > 0) ? provSummary.discovered_models : []; const currentModel = node.model || (profile && profile.preferred_models && profile.preferred_models[0]) || ''; let modelControlHtml = ''; if (discoveredModels.length > 0) { modelControlHtml = ` `; } else { modelControlHtml = `
Список моделей ещё не получен
`; } return `
${idx === 0 ? '★ Основной' : `Резерв ${idx}`} ${node.is_active ? '● Активен' : 'Ожидание'}
${escapeHtml(node.account_identity && node.account_identity !== 'Аккаунт не добавлен' ? node.account_identity : (node.display_name || node.profile_id))}
${escapeHtml(node.display_name || node.profile_id)} (${escapeHtml(node.provider)})
${modelControlHtml}
`; }).join('') || '
Цепочка не задана
'}
`; } diagramBox.innerHTML = diagramHtml || '
Нет данных маршрутизации.
'; } const provSummaryBox = document.getElementById('overview-providers-summary'); if (provSummaryBox) { provSummaryBox.innerHTML = providers.map((prov) => `
${escapeHtml(prov.provider_name || prov.provider_id)}
Всего слотов: ${prov.total_slots} • Подключено: ${prov.connected_count} • Онлайн: ${prov.online_count} • Требуют входа: ${prov.auth_required_count} • Холодный резерв: ${prov.cold_spare_count}
Модели: ${prov.discovered_models && prov.discovered_models.length ? escapeHtml(prov.discovered_models.join(', ')) : 'Н/Д — список моделей ещё не получен'}
`).join('') || '
Нет данных провайдеров.
'; } } // ═══════════════════════════════════════════════════════════════ // 2. ROUTING VIEW (P0-1, P0-2 Main Routing Control Center) // ═══════════════════════════════════════════════════════════════ function renderRoutingView() { const container = document.getElementById('routing-pipelines-container'); if (!container || !currentSnapshot) return; const routing = currentSnapshot.routing || {}; const agents = currentSnapshot.agents || []; let html = ''; for (const [roleId, pipeline] of Object.entries(routing)) { const nodes = pipeline.nodes || []; const agentInfo = agents.find((a) => a.role_id === roleId); const roleDesc = agentInfo?.role_description_ru || (CANONICAL_ROLE_DESCRIPTIONS[roleId] || ''); const quotaLabel = agentInfo?.active_quota_label || ''; const quotaStatus = agentInfo?.active_quota_status || 'healthy'; html += `
${escapeHtml(pipeline.role_name_ru || roleId)} ${quotaLabel ? `Квота: ${escapeHtml(quotaLabel)}` : ''} ${pipeline.session_affinity ? 'Session Affinity' : 'Без affinity'}
${roleDesc ? `
${escapeHtml(roleDesc)}
` : ''}
${nodes.map((node, index) => { const profile = (currentSnapshot.all_profiles || {})[node.profile_id]; const provId = profile?.provider || getProviderIdFromName(node.provider); const provSummary = (currentSnapshot.providers || []).find((p) => p.provider_id === provId || p.provider_name === node.provider); const discoveredModels = (provSummary && provSummary.discovered_models && provSummary.discovered_models.length > 0) ? provSummary.discovered_models : []; const currentModel = node.model || (profile && profile.preferred_models && profile.preferred_models[0]) || ''; const identity = node.account_identity && node.account_identity !== 'Аккаунт не добавлен' ? node.account_identity : (profile?.email || node.display_name || node.profile_id); let modelControlHtml = ''; if (discoveredModels.length > 0) { modelControlHtml = `
`; } else { modelControlHtml = `
Список моделей ещё не получен
`; } return `
${index === 0 ? '★ Основной' : `Резерв ${index}`}
${node.is_active ? '● АКТИВЕН' : ''}
${escapeHtml(identity)}
${escapeHtml(node.display_name || node.profile_id)} (${escapeHtml(node.profile_id)}) • ${escapeHtml(node.provider)}
${modelControlHtml} ${node.failover_reason ? `
⚠ ${escapeHtml(node.failover_reason)}
` : ''}
`; }).join('') || '
Цепочка не настроена. Нажмите «+ Добавить профиль».
'}
`; } container.innerHTML = html || '
Маршруты отсутствуют.
'; } // ═══════════════════════════════════════════════════════════════ // 3. ANALYTICS VIEW (P0-1, P0-5 Telemetry & Honesty) // ═══════════════════════════════════════════════════════════════ function renderAnalyticsView() { if (!currentSnapshot) return; const metrics = currentSnapshot.metrics || {}; const telemetry = metrics.telemetry || {}; const global = telemetry.global || {}; // KPI 1: Total Calls (24h) const totalCallsEl = document.getElementById('analytics-total-calls'); const callsBreakdownEl = document.getElementById('analytics-calls-breakdown'); if (totalCallsEl) { totalCallsEl.textContent = (global.total_calls !== null && global.total_calls !== undefined) ? global.total_calls : 'Н/Д'; } if (callsBreakdownEl) { const succ = global.successful_calls ?? 0; const fail = global.failed_calls ?? 0; callsBreakdownEl.textContent = `Успешно: ${succ} • Сбоев: ${fail} (окно: 24ч)`; } // KPI 2: Error Rate (24h) const errorRateEl = document.getElementById('analytics-error-rate'); const errorRateSubEl = document.getElementById('analytics-error-rate-sub'); if (errorRateEl) { if (global.error_rate !== null && global.error_rate !== undefined) { const pct = (global.error_rate * 100).toFixed(1); errorRateEl.textContent = `${pct}%`; errorRateEl.className = `kpi-value ${global.error_rate > 0.5 ? 'text-error' : (global.error_rate > 0.2 ? 'text-warning' : 'text-healthy')}`; } else { errorRateEl.textContent = 'Н/Д'; errorRateEl.className = 'kpi-value text-muted'; } } if (errorRateSubEl) { errorRateSubEl.textContent = (global.failed_calls !== null && global.failed_calls !== undefined && global.failed_calls > 0) ? `${global.failed_calls} отказов из ${global.total_calls || 0} вызовов (24ч)` : 'Отказов за 24ч не зафиксировано'; } // KPI 3: Latency (24h) with Fast-fail Explanation const latencyEl = document.getElementById('analytics-latency-p50'); const latencySubEl = document.getElementById('analytics-latency-sub'); if (latencyEl) { if (global.latency_p50_ms !== null && global.latency_p50_ms !== undefined) { latencyEl.textContent = `${global.latency_p50_ms.toFixed(1)} ms`; } else { latencyEl.textContent = 'Н/Д'; } } if (latencySubEl) { const p95Str = global.latency_p95_ms != null ? (global.latency_p95_ms >= 1000 ? `${(global.latency_p95_ms / 1000).toFixed(1)} s` : `${global.latency_p95_ms.toFixed(1)} ms`) : 'Н/Д'; const maxStr = global.latency_max_ms != null ? (global.latency_max_ms >= 1000 ? `${(global.latency_max_ms / 1000).toFixed(1)} s` : `${global.latency_max_ms.toFixed(1)} ms`) : 'Н/Д'; let note = `p95: ${p95Str} • max: ${maxStr} (окно: 24ч)`; // P0-5: Explain discrepancy if p50 is low while error rate is non-zero (fast-fail) if (global.failed_calls > 0 && (global.latency_p50_ms == null || global.latency_p50_ms < 50 || (global.latency_max_ms && global.latency_max_ms > 10 * Math.max(1, global.latency_p50_ms || 0)))) { note += ' • Низкий p50 вызван быстрыми отказами (fast-fail)'; } latencySubEl.textContent = note; } // KPI 4: Tokens (Honesty rule: null means N/D, never 0) const tokensEl = document.getElementById('analytics-tokens-total'); const tokensSubEl = document.getElementById('analytics-tokens-sub'); const hasTokens = global.total_tokens !== null && global.total_tokens !== undefined; if (tokensEl) { if (hasTokens) { tokensEl.textContent = global.total_tokens.toLocaleString('ru-RU'); } else { tokensEl.textContent = 'Н/Д'; } } if (tokensSubEl) { if (hasTokens) { tokensSubEl.textContent = 'Учитывается провайдером (24ч)'; } else { tokensSubEl.textContent = 'Н/Д: провайдеры не отдают данные о токенах'; } } // Providers Table (P0-5 Honesty: Unconnected providers labeled "Не подключён") const provTableBox = document.getElementById('analytics-providers-table'); if (provTableBox) { const byProv = telemetry.by_provider || {}; const providersList = currentSnapshot.providers || []; const allKnownProvIds = Array.from(new Set([...providersList.map((p) => p.provider_id), ...Object.keys(byProv)])); if (allKnownProvIds.length === 0) { provTableBox.innerHTML = '
Нет данных телеметрии по провайдерам.
'; } else { const rowsHtml = allKnownProvIds.map((pId) => { const pData = byProv[pId] || {}; const provSummary = providersList.find((p) => p.provider_id === pId); const provName = provSummary?.provider_name || pId; const isConnected = provSummary ? ((provSummary.connected_count || 0) > 0) : ((pData.total_calls || 0) > 0); if (!isConnected && (!pData.total_calls || pData.total_calls === 0)) { return ` ${escapeHtml(provName)} (${escapeHtml(pId)}) Не подключён — — Аккаунт не добавлен — — Н/Д `; } const errPct = pData.error_rate != null ? (pData.error_rate * 100).toFixed(1) : '0.0'; const p50 = pData.latency_p50_ms != null ? `${pData.latency_p50_ms.toFixed(1)} ms` : 'Н/Д'; const p95 = pData.latency_p95_ms != null ? (pData.latency_p95_ms >= 1000 ? `${(pData.latency_p95_ms / 1000).toFixed(1)} s` : `${pData.latency_p95_ms.toFixed(1)} ms`) : 'Н/Д'; const barColor = (pData.error_rate || 0) > 0.5 ? 'var(--status-error)' : 'var(--status-healthy)'; const barW = Math.min(100, Math.max(0, (pData.error_rate || 0) * 100)); return ` ${escapeHtml(provName)} (${escapeHtml(pId)}) ${pData.total_calls ?? 0} ${pData.successful_calls ?? 0} ${pData.failed_calls ?? 0}
${errPct}%
${p50} ${p95} Н/Д (не отдаются) `; }).join(''); provTableBox.innerHTML = ` ${rowsHtml}
Провайдер Всего вызовов Успешно Сбои Доля ошибок p50 p95 Токены
`; } } // Roles Table const rolesTableBox = document.getElementById('analytics-roles-table'); if (rolesTableBox) { const byRole = telemetry.by_role || {}; const roleKeys = Object.keys(byRole); if (roleKeys.length === 0) { rolesTableBox.innerHTML = '
Нет данных телеметрии по ролям агентов.
'; } else { const rowsHtml = roleKeys.map((rId) => { const rData = byRole[rId] || {}; const errPct = rData.error_rate != null ? (rData.error_rate * 100).toFixed(1) : '0.0'; const p50 = rData.latency_p50_ms != null ? `${rData.latency_p50_ms.toFixed(1)} ms` : 'Н/Д'; const p95 = rData.latency_p95_ms != null ? (rData.latency_p95_ms >= 1000 ? `${(rData.latency_p95_ms / 1000).toFixed(1)} s` : `${rData.latency_p95_ms.toFixed(1)} ms`) : 'Н/Д'; const roleInfo = (currentSnapshot.routing || {})[rId]; const rName = (roleInfo && roleInfo.role_name_ru) || rId; return ` ${escapeHtml(rName)} (${escapeHtml(rId)}) ${rData.total_calls ?? 0} ${(rData.total_calls ?? 0) - (rData.failed_calls ?? 0)} ${rData.failed_calls ?? 0} ${errPct}% ${p50} ${p95} `; }).join(''); rolesTableBox.innerHTML = ` ${rowsHtml}
Роль агента Всего вызовов Успешно Сбои Доля ошибок p50 p95
`; } } } // ═══════════════════════════════════════════════════════════════ // 7. HEALTH VIEW (P0-2 Host & System Diagnostics) // ═══════════════════════════════════════════════════════════════ function renderHealthView() { if (!currentSnapshot) return; const readiness = currentSnapshot.readiness || {}; const metrics = currentSnapshot.metrics || {}; const host = metrics.host || {}; // 1. Readiness Banner const bannerBox = document.getElementById('health-readiness-banner'); if (bannerBox) { const st = (readiness.state || 'healthy').toLowerCase(); bannerBox.className = `readiness-banner ${st}`; bannerBox.innerHTML = `
${escapeHtml(readiness.title_ru || 'Система готова к работе')}
${escapeHtml(readiness.state || 'HEALTHY')}
${escapeHtml(readiness.summary_ru || 'Все настроенные маршруты и профили доступны.')}
Ролей в строю: ${readiness.roles_ready_count ?? 0} / ${readiness.total_roles ?? 6} Аккаунтов подключено: ${readiness.accounts_connected_count ?? 0} / ${readiness.total_accounts ?? 0} Провайдеров онлайн: ${readiness.providers_ready_count ?? 5} / ${readiness.total_providers ?? 5}
`; } // 2. Host Resources Grid const hostBox = document.getElementById('health-host-resources'); if (hostBox) { const cpuPct = host.cpu_percent != null ? host.cpu_percent.toFixed(1) : 'Н/Д'; const cpuVal = host.cpu_percent != null ? host.cpu_percent : 0; const memPct = host.memory_percent != null ? host.memory_percent.toFixed(1) : 'Н/Д'; const memMb = host.memory_used_mb != null ? (host.memory_used_mb >= 1024 ? `${(host.memory_used_mb / 1024).toFixed(1)} GB` : `${host.memory_used_mb.toFixed(0)} MB`) : ''; const memVal = host.memory_percent != null ? host.memory_percent : 0; const diskPct = host.disk_percent != null ? host.disk_percent.toFixed(1) : 'Н/Д'; const diskGb = host.disk_used_gb != null ? `${host.disk_used_gb.toFixed(1)} GB` : ''; const diskVal = host.disk_percent != null ? host.disk_percent : 0; const netSpeed = host.net_speed_mbps != null ? `${host.net_speed_mbps.toFixed(1)} Mbps` : 'Н/Д'; const netSub = host.net_speed_mbps != null ? 'Активное соединение' : 'Н/Д: замер скорости сети отключён'; hostBox.innerHTML = `
CPU (Процессор) ${cpuPct}${cpuPct !== 'Н/Д' ? '%' : ''}
Нагрузка хост-системы
RAM (Оперативная память) ${memPct}${memPct !== 'Н/Д' ? '%' : ''}
${memMb ? `Использовано: ${memMb}` : 'Статус использования RAM'}
Диск (Хранилище) ${diskPct}${diskPct !== 'Н/Д' ? '%' : ''}
${diskGb ? `Занято: ${diskGb}` : 'Статус дискового пространства'}
Сеть (Пропускная способность) ${netSpeed}
${netSub}
`; } // 3. Warnings List const warningsBox = document.getElementById('health-warnings-list'); if (warningsBox) { const warnings = readiness.warnings || []; if (warnings.length === 0) { warningsBox.innerHTML = `
Все системы работают штатно: сбоев конфигурации и деградации маршрутов не обнаружено.
`; } else { warningsBox.innerHTML = warnings.map((w) => `
⚠️
${escapeHtml(w)}
`).join(''); } } } // ═══════════════════════════════════════════════════════════════ // 8. LOGS VIEW (P0-3 GET /api/events with Filtering & Search) // ═══════════════════════════════════════════════════════════════ async function fetchLogs() { const container = document.getElementById('logs-container'); if (container && cachedEvents.length === 0) { container.innerHTML = '
⏳ Загрузка журнала событий...
'; } try { const headers = {}; if (authToken) headers['X-Hub-Token'] = authToken; const res = await fetch('/api/events?limit=100', { headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); cachedEvents = data.events || []; renderLogsList(); } catch (err) { console.error('Failed to fetch events:', err); if (container) { container.innerHTML = `
Не удалось получить события: ${escapeHtml(err.message)}
`; } } } function renderLogsView() { fetchLogs(); } function renderLogsList() { const container = document.getElementById('logs-container'); if (!container) return; const searchInput = document.getElementById('logs-search'); const levelSelect = document.getElementById('logs-filter-level'); const catSelect = document.getElementById('logs-filter-category'); const q = (searchInput ? searchInput.value : '').trim().toLowerCase(); const levelFilter = levelSelect ? levelSelect.value : 'all'; const catFilter = catSelect ? catSelect.value : 'all'; let filtered = cachedEvents.filter((ev) => { if (levelFilter !== 'all' && (ev.level || 'info').toLowerCase() !== levelFilter.toLowerCase()) { return false; } if (catFilter !== 'all' && (ev.category || '').toLowerCase() !== catFilter.toLowerCase()) { return false; } if (q) { const msg = (ev.message || '').toLowerCase(); const det = (ev.details || '').toLowerCase(); if (!msg.includes(q) && !det.includes(q)) return false; } return true; }); if (filtered.length === 0) { container.innerHTML = '
Нет событий, соответствующих выбранным фильтрам.
'; return; } container.innerHTML = filtered.map((ev) => { const lvl = (ev.level || 'info').toLowerCase(); return `
${escapeHtml(ev.timestamp || '—')} ${escapeHtml(lvl.toUpperCase())} ${escapeHtml((ev.category || 'system').toUpperCase())}
${escapeHtml(ev.message || '')}
${ev.details ? `
${escapeHtml(ev.details)}
` : ''}
`; }).join(''); } // ═══════════════════════════════════════════════════════════════ // 9. SETTINGS VIEW (P0-4 GET /api/settings & save_settings) // ═══════════════════════════════════════════════════════════════ async function loadServerSettings() { try { const headers = {}; if (authToken) headers['X-Hub-Token'] = authToken; const res = await fetch('/api/settings', { headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); currentSettings = data; populateSettingsForm(data); } catch (err) { console.error('Failed to load settings:', err); } } function populateSettingsForm(s) { const hostInput = document.getElementById('setting-server-host'); const portInput = document.getElementById('setting-server-port'); const tokenBadge = document.getElementById('setting-token-status-badge'); const quotaSel = document.getElementById('setting-quota-interval'); const themeSel = document.getElementById('setting-theme'); const pathHome = document.getElementById('path-hermes-home'); const pathConfig = document.getElementById('path-config-dir'); const pathLog = document.getElementById('path-log-file'); if (hostInput) hostInput.value = s.web_api_host || '127.0.0.1'; if (portInput) portInput.value = s.web_api_port || 5800; if (tokenBadge) { tokenBadge.textContent = s.web_api_token_configured ? '✓ Токен задан' : 'Токен не задан'; tokenBadge.className = `badge ${s.web_api_token_configured ? 'healthy' : ''}`; } if (quotaSel && s.quota_refresh_interval_sec) { quotaSel.value = String(s.quota_refresh_interval_sec); } if (themeSel && s.theme) { themeSel.value = s.theme; applyTheme(s.theme); } if (pathHome) pathHome.textContent = s.hermes_home || '~/.hermes'; if (pathConfig) pathConfig.textContent = s.config_dir || '~/.hermes/config'; if (pathLog) pathLog.textContent = s.log_file || '~/.hermes/logs/hermes-hub.log'; } function applyTheme(theme) { if (theme === 'light') { document.body.setAttribute('data-theme', 'light'); document.body.classList.add('theme-light'); } else { document.body.removeAttribute('data-theme'); document.body.classList.remove('theme-light'); } } function renderSettingsView() { loadServerSettings(); } async function saveHubServerSettings() { const hostInput = document.getElementById('setting-server-host'); const portInput = document.getElementById('setting-server-port'); const tokenInput = document.getElementById('setting-server-token-input'); const quotaSel = document.getElementById('setting-quota-interval'); const themeSel = document.getElementById('setting-theme'); const payload = { web_api_host: hostInput ? hostInput.value.trim() : '127.0.0.1', web_api_port: portInput ? parseInt(portInput.value, 10) || 5800 : 5800, quota_refresh_interval_sec: quotaSel ? parseInt(quotaSel.value, 10) || 300 : 300, theme: themeSel ? themeSel.value : 'system', }; if (tokenInput && tokenInput.value.trim()) { payload.web_api_token = tokenInput.value.trim(); } const res = await executeAction('save_settings', payload); if (res.ok) { if (themeSel) applyTheme(themeSel.value); showToast('Настройки сервера успешно сохранены', 'success'); if (tokenInput) tokenInput.value = ''; loadServerSettings(); } } // ── MODALS (Account Details, Model Choice, Routing, Wizard) ── function openAccountDetailsModal(profileId) { if (!currentSnapshot) return; const profile = (currentSnapshot.all_profiles || {})[profileId]; if (!profile) return; const provSummary = (currentSnapshot.providers || []).find(p => p.provider_id === profile.provider); const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []; const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : ''; const qs = profile.quota_snapshot; const buckets = (qs && qs.buckets) ? qs.buckets : []; let modelBlockHtml = ''; if (discoveredModels.length > 0) { modelBlockHtml = `
`; } else { modelBlockHtml = `
⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
`; } elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`; elements.modalBody.innerHTML = `
${escapeHtml(profile.account_identity || profile.email || profileId)}
Провайдер: ${escapeHtml(profile.provider_display_name || profile.provider)} • Тариф: ${escapeHtml(profile.plan || 'Неизвестен')} • Статус: ${escapeHtml(profile.health_label_ru || 'Работает')}
Назначенные роли: ${escapeHtml((profile.assigned_roles || []).join(', ') || 'Нет')}
${modelBlockHtml}

Квоты и корзины провайдера

${buckets.map((b) => `
${escapeHtml(b.display_name)} ${b.remaining_percent !== null && b.remaining_percent !== undefined ? `${b.remaining_percent.toFixed(1)}%` : 'Н/Д'}
${b.reset_at ? `Сброс: ${formatIsoDate(b.reset_at)}` : (b.period ? `Период: ${b.period}` : 'Без отметки сброса')}
`).join('') || '
Данные о квотах отсутствуют (провайдер не отдал лимиты).
'}
`; elements.modalFooter.innerHTML = ` `; showModal(); } async function handleSaveProfileModel(profileId) { const sel = document.getElementById('modal-model-select'); if (!sel) return; const model = sel.value; const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('set_model', { profile_id: profileId, model: model }); if (feedbackArea) { if (res.ok) { feedbackArea.innerHTML = ``; if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { currentSnapshot.all_profiles[profileId].preferred_models = [model]; } } else { feedbackArea.innerHTML = ``; } } } function openAgentModelModal(roleId, profileId) { if (!currentSnapshot) return; const profile = (currentSnapshot.all_profiles || {})[profileId]; if (!profile) return; const provSummary = (currentSnapshot.providers || []).find(p => p.provider_id === profile.provider); const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : []; const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : ''; const roleName = ((currentSnapshot.routing || {})[roleId]?.role_name_ru) || roleId; elements.modalTitle.textContent = `Выбор модели для роли: ${roleName}`; elements.modalBody.innerHTML = `
Профиль агента: ${escapeHtml(profile.display_name)} (${profileId}) • Провайдер: ${escapeHtml(profile.provider_display_name || profile.provider)}
${discoveredModels.length > 0 ? `
` : `
⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
`} `; elements.modalFooter.innerHTML = ` ${discoveredModels.length > 0 ? `` : ''} `; showModal(); } async function handleSaveRoleModel(roleId, profileId) { const sel = document.getElementById('role-model-select'); if (!sel) return; const model = sel.value; const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('set_model', { profile_id: profileId, model: model, role_id: roleId }); if (feedbackArea) { if (res.ok) { feedbackArea.innerHTML = ``; if (currentSnapshot) { if (currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { currentSnapshot.all_profiles[profileId].preferred_models = [model]; } if (currentSnapshot.routing && currentSnapshot.routing[roleId]) { currentSnapshot.routing[roleId].default_model = model; } if (currentSnapshot.agents) { const ag = currentSnapshot.agents.find(a => a.role_id === roleId); if (ag) ag.model = model; } } setTimeout(() => { closeModal(); renderCurrentView(); }, 700); } else { feedbackArea.innerHTML = ``; } } } async function handleRefreshProviderModels(providerId, profileId = null) { showToast(`Запрос списка моделей для ${providerId}...`, 'info'); const res = await executeAction('refresh_models', { provider: providerId }); if (res.ok) { showToast('Запрос обновления моделей отправлен', 'success'); if (profileId) { setTimeout(() => openAccountDetailsModal(profileId), 500); } } } async function handleTestProfile(profileId) { const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('test', { profile_id: profileId }); if (feedbackArea) { if (res.ok) { feedbackArea.innerHTML = ``; } else { feedbackArea.innerHTML = ``; } } } // ── Add Account Wizard (P0-5 Headless Server Honesty) ── function openAddAccountWizard() { elements.modalTitle.textContent = 'Мастер подключения учетной записи'; showWizardStep1(); showModal(); } function showWizardStep1() { elements.modalBody.innerHTML = `
Шаг 1 из 3: Выберите провайдера ИИ
`; elements.modalFooter.innerHTML = ` `; } function showWizardStep2(providerId) { let bodyHtml = ''; if (providerId === 'grok' || providerId === 'openai-codex') { // Здесь стояли ВЫДУМАННЫЕ код устройства (GRK-7842 / CDX-9104) и жёстко // вписанный адрес x.ai/device, который отдаёт 404. Мастер не был подключён // к серверу вовсе: пользователь вводил бы несуществующий код бесконечно. // Пока поток не проведён через API, честнее сказать правду и указать // рабочий путь, чем показывать правдоподобную пустышку. const providerName = providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex'; bodyHtml = `
Шаг 2 из 3: Авторизация ${providerName}
`; } else if (providerId === 'antigravity') { bodyHtml = `
Шаг 2 из 3: Авторизация Google Antigravity
`; } else if (providerId === 'claude') { bodyHtml = `
Шаг 2 из 3: Авторизация Claude
`; } else { bodyHtml = `
Шаг 2 из 3: Ввод API ключа ${providerId}
`; } elements.modalBody.innerHTML = ` ${bodyHtml} `; elements.modalFooter.innerHTML = ` `; } function showWizardStep3(providerId) { elements.modalBody.innerHTML = `
Шаг 3 из 3: Назначение роли для нового аккаунта
`; elements.modalFooter.innerHTML = ` `; } async function finishAddAccount(providerId) { const roleSelect = document.getElementById('wiz-target-role'); const targetRole = roleSelect ? roleSelect.value : 'coder-primary'; const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('add_account', { provider: providerId, target_role: targetRole, }); if (res.ok) { showToast('Аккаунт успешно добавлен в маршрутизацию', 'success'); closeModal(); fetchSnapshot(); } else { if (feedbackArea) { feedbackArea.innerHTML = ``; } } } // ── Routing Drag & Drop Reordering (P0-1, P0-2) ── function handleNodeDragStart(e, roleId, index) { currentDragState = { roleId, fromIndex: index }; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', JSON.stringify(currentDragState)); } catch (err) { // fallback } const chip = e.currentTarget; if (chip) { chip.classList.add('dragging'); } } function handleNodeDragOver(e, roleId, index) { if (!currentDragState || currentDragState.roleId !== roleId) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; const chip = e.currentTarget; if (chip && !chip.classList.contains('dragging')) { chip.classList.add('drop-target'); } } function handleNodeDragLeave(e) { const chip = e.currentTarget; if (chip) { chip.classList.remove('drop-target'); } } function handleNodeDragEnd(e) { document.querySelectorAll('.pipeline-node-chip').forEach((c) => { c.classList.remove('dragging', 'drop-target'); }); currentDragState = null; } async function handleNodeDrop(e, roleId, targetIndex) { e.preventDefault(); document.querySelectorAll('.pipeline-node-chip').forEach((c) => { c.classList.remove('dragging', 'drop-target'); }); if (!currentDragState || currentDragState.roleId !== roleId) { currentDragState = null; return; } const sourceIndex = currentDragState.fromIndex; currentDragState = null; if (sourceIndex === targetIndex) return; const pipeline = (currentSnapshot.routing || {})[roleId]; if (!pipeline || !pipeline.nodes) return; const chain = pipeline.nodes.map((n) => n.profile_id); if (sourceIndex < 0 || sourceIndex >= chain.length || targetIndex < 0 || targetIndex >= chain.length) return; const [moved] = chain.splice(sourceIndex, 1); chain.splice(targetIndex, 0, moved); showToast(`Обновление порядка цепочки '${pipeline.role_name_ru || roleId}'...`, 'info'); const res = await executeAction('save_chain', { role_id: roleId, chain: chain }); if (res.ok) { showToast(`Порядок цепочки '${pipeline.role_name_ru || roleId}' сохранен`, 'success'); if (pipeline.nodes) { const movedNode = pipeline.nodes.splice(sourceIndex, 1)[0]; pipeline.nodes.splice(targetIndex, 0, movedNode); renderRoutingView(); } fetchSnapshot(); } else { showToast(res.message || 'Ошибка сохранения цепочки', 'error'); } } // ── Routing Node Model & Chain Management ── async function handleNodeModelChange(roleId, profileId, newModel) { if (!newModel) return; showToast(`Сохранение модели '${newModel}' для ${profileId}...`, 'info'); const res = await executeAction('set_model', { profile_id: profileId, model: newModel, role_id: roleId }); if (res.ok) { showToast(`Модель '${newModel}' успешно сохранена`, 'success'); if (currentSnapshot) { if (currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { currentSnapshot.all_profiles[profileId].preferred_models = [newModel]; } if (currentSnapshot.routing && currentSnapshot.routing[roleId]) { currentSnapshot.routing[roleId].default_model = newModel; const node = (currentSnapshot.routing[roleId].nodes || []).find((n) => n.profile_id === profileId); if (node) node.model = newModel; } } renderCurrentView(); } else { showToast(res.message || 'Ошибка сохранения модели', 'error'); } } async function handleRemoveNodeFromChain(roleId, profileId) { const pipeline = (currentSnapshot.routing || {})[roleId]; if (!pipeline || !pipeline.nodes) return; const chain = pipeline.nodes.map((n) => n.profile_id).filter((p) => p !== profileId); showToast(`Удаление профиля ${profileId} из цепочки...`, 'info'); const res = await executeAction('save_chain', { role_id: roleId, chain: chain }); if (res.ok) { showToast(`Профиль удален из цепочки '${pipeline.role_name_ru || roleId}'`, 'success'); pipeline.nodes = pipeline.nodes.filter((n) => n.profile_id !== profileId); renderRoutingView(); fetchSnapshot(); } else { showToast(res.message || 'Ошибка обновления цепочки', 'error'); } } function openAddNodeToChainModal(roleId) { if (!currentSnapshot) return; const pipeline = (currentSnapshot.routing || {})[roleId]; if (!pipeline) return; const currentChain = (pipeline.nodes || []).map((n) => n.profile_id); const allProfiles = currentSnapshot.all_profiles || {}; const available = Object.values(allProfiles).filter((p) => !currentChain.includes(p.profile_id)); elements.modalTitle.textContent = `Добавить профиль в цепочку: ${pipeline.role_name_ru || roleId}`; if (available.length === 0) { elements.modalBody.innerHTML = `
Все зарегистрированные профили уже включены в эту цепочку.
`; elements.modalFooter.innerHTML = ``; } else { elements.modalBody.innerHTML = `
Выберите доступный профиль для включения в цепочку отказоустойчивости:
`; elements.modalFooter.innerHTML = ` `; } showModal(); } async function handleAddNodeToChain(roleId) { const sel = document.getElementById('add-node-profile-select'); if (!sel) return; const newProfileId = sel.value; if (!newProfileId) return; const pipeline = (currentSnapshot.routing || {})[roleId]; const currentChain = (pipeline?.nodes || []).map((n) => n.profile_id); const newChain = [...currentChain, newProfileId]; const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('save_chain', { role_id: roleId, chain: newChain }); if (res.ok) { showToast(`Профиль добавлен в цепочку '${pipeline?.role_name_ru || roleId}'`, 'success'); closeModal(); fetchSnapshot(); } else { if (feedbackArea) { feedbackArea.innerHTML = ``; } } } // ── SETTINGS MANAGEMENT ── function initSettings() { const btnSave = document.getElementById('btn-save-client-settings'); const tokenInput = document.getElementById('setting-client-token-input'); const pollSelect = document.getElementById('setting-poll-interval'); if (tokenInput && authToken) { tokenInput.value = authToken; } if (btnSave) { btnSave.addEventListener('click', () => { if (tokenInput) { authToken = tokenInput.value.trim(); localStorage.setItem('hermes_hub_token', authToken); } if (pollSelect) { pollIntervalMs = parseInt(pollSelect.value, 10); startPolling(); } showToast('Параметры веб-клиента сохранены', 'success'); fetchSnapshot(); }); } } // ── MODAL HELPERS ── function showModal() { if (elements.modalBackdrop) elements.modalBackdrop.classList.remove('hidden'); } function closeModal() { if (elements.modalBackdrop) elements.modalBackdrop.classList.add('hidden'); } // ── TOAST NOTIFICATIONS ── function showToast(message, type = 'info') { if (!elements.toastContainer) return; const toast = document.createElement('div'); toast.className = `toast ${type}`; toast.textContent = message; elements.toastContainer.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; toast.style.transition = 'opacity 0.3s ease'; setTimeout(() => toast.remove(), 300); }, 4000); } // ── UTILITIES ── function escapeHtml(str) { if (!str) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function formatIsoDate(isoStr) { if (!isoStr) return ''; try { const d = new Date(isoStr); if (isNaN(d.getTime())) return isoStr; return d.toLocaleString('ru-RU', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', }); } catch (e) { return isoStr; } }