/** * Hermes Hub Web Client * Vanilla JavaScript (ES2022) — No npm, no build, no framework. * Single source of truth: docs/web-api/CONTRACT.md */ // ── CONFIGURATION & STATE ── // Set USE_MOCK_FIXTURE = true to develop strictly offline against snapshot.example.json const USE_MOCK_FIXTURE = false; let lastAppliedSeq = -1; let currentSnapshot = null; let activeView = 'accounts'; let pollTimer = null; let pollIntervalMs = 5000; let authToken = localStorage.getItem('hermes_hub_token') || ''; // ── 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() { elements.navItems.forEach((btn) => { btn.addEventListener('click', () => { const view = btn.dataset.view; switchView(view); }); }); } function switchView(viewName) { activeView = viewName; elements.navItems.forEach((btn) => { btn.classList.toggle('active', btn.dataset.view === viewName); }); elements.viewPanes.forEach((pane) => { pane.classList.toggle('active', pane.id === `view-${viewName}`); }); const titles = { accounts: 'Аккаунты и квоты', overview: 'Обзор системы', routing: 'Маршрутизация запросов', providers: 'Модели и провайдеры', team: 'Команда агентов', logs: 'Журнал событий', settings: 'Параметры веб-клиента', }; 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(); }); } const btnClearLogs = document.getElementById('btn-clear-logs'); if (btnClearLogs) { btnClearLogs.addEventListener('click', () => { const logsBox = document.getElementById('logs-container'); if (logsBox) logsBox.innerHTML = '
Журнал очищен пользователем.
'; }); } } // ── 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) { // ignore } 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-spare-1'); } } 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 'accounts': renderAccountsView(); break; case 'overview': renderOverviewView(); break; case 'routing': renderRoutingView(); break; case 'providers': renderProvidersView(); break; case 'team': renderTeamView(); break; case 'logs': renderLogsView(); break; } } // ═══════════════════════════════════════════════════════════════ // 1. ACCOUNTS VIEW (P0-1 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', () => { 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; // Опрос провайдера идёт в фоне и занимает секунды. Пока он не завершился, // корзины пусты — но это НЕ «данных нет». Показывать в этот момент «Н/Д» // значит выдавать загрузку за отсутствие данных: владелец видел ровно это // и решил, что лимиты не подтягиваются. Причина отказа важнее флага: если // провайдер уже ответил «лимитов не даю», это не загрузка. const isLoading = Boolean(quotaSnap && quotaSnap.is_loading) && !unavailableReason; let quotaGridHtml = ''; if (buckets.length > 0) { const visibleBuckets = buckets.slice(0, 4); quotaGridHtml = `
${visibleBuckets.map((b) => renderQuotaCell(b, unavailableReason, isLoading)).join('')}
`; } else { const reasonText = (isLoading ? 'Опрашиваем провайдера…' : null) || unavailableReason || ( profile.health_state === 'not_configured' || profile.health_state === 'auth_required' ? 'Аккаунт не подключён' : 'Провайдер не отдаёт лимиты' ); quotaGridHtml = `
Квота ${isLoading ? 'Загрузка…' : 'Н/Д'}
${escapeHtml(reasonText)}
`; } return `
${quotaGridHtml}
`; } function renderQuotaCell(bucket, unavailableReason, isLoading) { const remaining = bucket.remaining_percent; let formattedValue = isLoading ? 'Загрузка…' : 'Н/Д'; 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 || 'Период провайдера')); if (isLoading && typeof remaining !== 'number') { resetText = 'Опрашиваем провайдера…'; } return `
${escapeHtml(bucket.display_name)} ${escapeHtml(formattedValue)}
${escapeHtml(resetText)}
`; } // ═══════════════════════════════════════════════════════════════ // 2. OVERVIEW VIEW // ═══════════════════════════════════════════════════════════════ function renderOverviewView() { if (!currentSnapshot) return; 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) => `
${idx === 0 ? '★ Основной' : `Резерв ${idx}`} ${node.is_active ? '● Активен' : 'Ожидание'}
${escapeHtml(node.display_name || node.profile_id)}
${escapeHtml(node.provider)} • ${escapeHtml(node.model)}
`).join('')}
`; } diagramBox.innerHTML = diagramHtml || '
Нет данных маршрутизации.
'; } const provSummaryBox = document.getElementById('overview-providers-summary'); if (provSummaryBox) { const providers = currentSnapshot.providers || []; provSummaryBox.innerHTML = providers.map((prov) => `
${escapeHtml(prov.provider_name || prov.provider_id)}
Онлайн: ${prov.online_count}/${prov.connected_count} • Требуют входа: ${prov.auth_required_count} • Холодный резерв: ${prov.cold_spare_count}
Модели: ${prov.discovered_models && prov.discovered_models.length ? escapeHtml(prov.discovered_models.join(', ')) : 'Н/Д — список моделей ещё не получен'}
`).join('') || '
Нет данных провайдеров.
'; } } // ═══════════════════════════════════════════════════════════════ // 3. ROUTING VIEW // ═══════════════════════════════════════════════════════════════ function renderRoutingView() { const container = document.getElementById('routing-pipelines-container'); if (!container || !currentSnapshot) return; const routing = currentSnapshot.routing || {}; let html = ''; for (const [roleId, pipeline] of Object.entries(routing)) { const nodes = pipeline.nodes || []; html += `
${escapeHtml(pipeline.role_name_ru || roleId)}
Модель по умолчанию: ${escapeHtml(pipeline.default_model || '—')} • ${pipeline.session_affinity ? 'Session Affinity включена' : 'Без affinity'}
${nodes.map((node, index) => `
${index === 0 ? 'Основной' : `Резерв ${index}`} ${node.is_active ? '● АКТИВЕН' : ''}
${escapeHtml(node.display_name || node.profile_id)}
${escapeHtml(node.provider)} • ${escapeHtml(node.model)}
${node.failover_reason ? `
Причина: ${escapeHtml(node.failover_reason)}
` : ''}
${index < nodes.length - 1 ? '' : ''} `).join('')}
`; } container.innerHTML = html || '
Маршрутизация не настроена.
'; } // ═══════════════════════════════════════════════════════════════ // 4. PROVIDERS VIEW // ═══════════════════════════════════════════════════════════════ function renderProvidersView() { const container = document.getElementById('providers-full-container'); if (!container || !currentSnapshot) return; const providers = currentSnapshot.providers || []; container.innerHTML = providers.map((prov) => `
${escapeHtml(prov.provider_name || prov.provider_id)}
Обновлено: ${prov.last_refresh_at ? formatIsoDate(prov.last_refresh_at) : 'Н/Д — обнаружение ещё не запускалось'}
Онлайн: ${prov.online_count}/${prov.connected_count} • Требуют авторизации: ${prov.auth_required_count} • Квота исчерпана: ${prov.quota_exhausted_count} • Холодный резерв: ${prov.cold_spare_count}
Обнаруженные модели:
${prov.discovered_models && prov.discovered_models.length ? escapeHtml(prov.discovered_models.join(' • ')) : 'Н/Д — список моделей ещё не получен от провайдера'}
`).join('') || '
Список провайдеров пуст.
'; } // ═══════════════════════════════════════════════════════════════ // 5. TEAM VIEW // ═══════════════════════════════════════════════════════════════ function renderTeamView() { const container = document.getElementById('team-cards-container'); if (!container || !currentSnapshot) return; const agents = currentSnapshot.agents || []; container.innerHTML = agents.map((agent) => `
${escapeHtml(agent.role_name_ru || agent.role_id)} ${agent.is_main_orchestrator ? '👑 ЛИДЕР' : ''}
${escapeHtml(agent.role_description_ru || '')}
Профиль: ${escapeHtml(agent.assigned_profile_id || 'Не назначен')}
Провайдер: ${escapeHtml(agent.provider_display_name || agent.provider)}
Модель: ${escapeHtml(agent.model || '—')}
● ${escapeHtml(agent.status_label_ru || 'Работает')}
`).join('') || '
Команда агентов пуста.
'; } // ═══════════════════════════════════════════════════════════════ // 6. LOGS VIEW // ═══════════════════════════════════════════════════════════════ function renderLogsView() { const container = document.getElementById('logs-container'); if (!container || !currentSnapshot) return; const logs = currentSnapshot.metrics?.recent_events || []; if (logs.length > 0) { container.innerHTML = logs.map((log) => `
[${escapeHtml(log.time || '')}] ${escapeHtml(log.role || '')}: ${escapeHtml(log.message || '')}
`).join(''); } } // ═══════════════════════════════════════════════════════════════ // MODALS & WIZARDS // ═══════════════════════════════════════════════════════════════ function openAccountDetailsModal(profileId) { if (!currentSnapshot) return; const profile = (currentSnapshot.all_profiles || {})[profileId]; if (!profile) return; const quotaSnap = profile.quota_snapshot || (currentSnapshot.quotas || {})[profileId]; const buckets = (quotaSnap && quotaSnap.buckets) ? quotaSnap.buckets : []; 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(', ') || 'Нет')}

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

${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 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') { bodyHtml = `
Шаг 2 из 3: Авторизация ${providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex'}
1. Откройте ссылку на любом устройстве:
2. Введите код подтверждения:
${providerId === 'grok' ? 'GRK-7842' : 'CDX-9104'}
3. Подтвердите доступ в браузере. Hub автоматически зафиксирует авторизацию.
`; } else if (providerId === 'antigravity' || providerId === 'claude') { bodyHtml = `
Шаг 2 из 3: Авторизация ${providerId === 'antigravity' ? 'Google Antigravity' : '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 = ``; } } } function openEditRouteModal(roleId) { if (!currentSnapshot) return; const pipeline = (currentSnapshot.routing || {})[roleId]; if (!pipeline) return; const nodes = [...(pipeline.nodes || [])]; function renderRows() { return nodes.map((node, index) => `
${index + 1}. ${escapeHtml(node.display_name || node.profile_id)} (${escapeHtml(node.provider)})
`).join('') || '
Цепочка пуста.
'; } window.activeRouteNodes = nodes; elements.modalTitle.textContent = `Цепочка маршрутизации: ${pipeline.role_name_ru || roleId}`; elements.modalBody.innerHTML = `
Первый профиль — основной (Primary). Нижестоящие профили используются как резервы в порядке переключения.
${renderRows()}
`; elements.modalFooter.innerHTML = ` `; showModal(); } window.moveRouteNode = function(roleId, index, delta) { const nodes = window.activeRouteNodes; const target = index + delta; if (target >= 0 && target < nodes.length) { const temp = nodes[index]; nodes[index] = nodes[target]; nodes[target] = temp; openEditRouteModal(roleId); } }; window.removeRouteNode = function(roleId, index) { const nodes = window.activeRouteNodes; nodes.splice(index, 1); openEditRouteModal(roleId); }; async function saveRouteChain(roleId) { const chain = (window.activeRouteNodes || []).map((n) => n.profile_id); const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { feedbackArea.innerHTML = ''; } const res = await executeAction('edit_route', { role_id: roleId, chain: chain, }); if (res.ok) { showToast(`Цепочка '${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-auth-token'); 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; } }