/** * 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 _openAccountModalProfile = null; 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; let latestUpdateInfo = 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(); checkUpdates(true); }); // ── 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(); }); } const btnAutoAssign = document.getElementById('btn-auto-assign'); if (btnAutoAssign) { btnAutoAssign.addEventListener('click', () => { executeAction('auto_assign_all', {}); }); } 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); }); } // Updates event listeners const btnHeaderUpdate = document.getElementById('header-update-badge'); if (btnHeaderUpdate) { btnHeaderUpdate.addEventListener('click', () => openUpdateModal()); } const btnCheckUpdates = document.getElementById('btn-check-updates'); if (btnCheckUpdates) { btnCheckUpdates.addEventListener('click', () => checkUpdates(false)); } const btnApplyUpdate = document.getElementById('btn-apply-update'); if (btnApplyUpdate) { btnApplyUpdate.addEventListener('click', () => applyUpdate()); } } // ── 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.status === 401) { handleUnauthorized(); return; } 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) { // Раньше здесь при недоступном API молча подставлялась // snapshot.example.json — 63 чужих профиля с почтами user@example.test — // и индикатор ставился в зелёное. Экран выглядел здоровой панелью, // которая не имеет отношения к этому серверу: при работе через SSH-туннель // достаточно промахнуться портом или потерять туннель, чтобы принять // пример за свои данные. Осознанная работа с фикстурой осталась — // ?fixture=1 и открытие файлом, — но подставлять её вместо ответа сервера // нельзя: отсутствие данных должно читаться как отсутствие данных. console.warn('Live API unavailable:', err); setSourceIndicator(false, 'Сервер недоступен'); showToast('Сервер не отвечает. Данные на экране могут быть устаревшими.', 'error'); } } 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(); // Открытое окно аккаунта рисовалось один раз и на опрос не реагировало. // Если его открыть до того, как придут живые квоты, оно навсегда // оставалось с заглушкой («Grok 2h — Н/Д») и со статусом «Не проверялся» // даже после успешной проверки. Перерисовываем по свежему снапшоту. if (_openAccountModalProfile) { try { openAccountDetailsModal(_openAccountModalProfile, true); } catch (e) { console.warn('[Hub] Не удалось обновить окно аккаунта:', e); } } 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); } } // Подключён ли профиль на самом деле. // // A26 определял это как «health_state не равен not_configured», а поле // authenticated в модели вообще отсутствует, поэтому первая половина условия // была мертва. Через фильтр проходили холодный резерв (health_state // "disabled") и непроверенные пустые слоты: при нуле настоящих аккаунтов // страница показывала три карточки «Холодный резерв», а «Обзор» предлагал // назначать роли на пустые слоты — то самое мышление слотами, ради отмены // которого задание и делалось. // // Authoritative признак — auth_state: у подключённого AUTHENTICATED, у // пустого слота и у холодного резерва NOT_CONFIGURED. Состояния // AUTH_REQUIRED и AUTH_EXPIRED означают подключённый аккаунт, которому нужен // повторный вход, — их показываем. function isConnectedProfile(p) { if (!p) return false; const st = String(p.auth_state || '').toUpperCase(); if (st) return st !== 'NOT_CONFIGURED'; // Запасной путь, если поле не пришло: судим по наличию опознанного аккаунта. return Boolean(p.email); } // ── КОПИРОВАНИЕ В БУФЕР ── // // navigator.clipboard существует только в защищённом контексте: HTTPS или // localhost. Когда хаб открыт по сети (http://192.168.1.81:5800), его нет // вовсе, и кнопки «Копировать» молча не работали — хуже того, показывали // «Ссылка скопирована», потому что промис никто не проверял. // Запасной путь — execCommand('copy'), он работает и по HTTP. async function copyToClipboard(text, okMessage) { try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); showToast(okMessage || 'Скопировано', 'success'); return true; } } catch (err) { console.warn('clipboard API недоступен:', err); } try { const ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.top = '-1000px'; document.body.appendChild(ta); ta.select(); const ok = document.execCommand('copy'); document.body.removeChild(ta); if (ok) { showToast(okMessage || 'Скопировано', 'success'); return true; } } catch (err) { console.warn('execCommand copy не сработал:', err); } // Молчать нельзя: владелец решит, что скопировалось, и вставит старое. showToast('Скопировать не удалось — выделите текст в поле и нажмите Ctrl+C', 'warning'); return false; } // ── ЗАПРОС ТОКЕНА ПРИ 401 ── // // Сервер, привязанный не к localhost, требует X-Hub-Token. Раньше клиент этого // случая не знал вовсе: 401 попадал в общую ветку ошибок и превращался в // красный тост «Ошибка сервера: 401», который повторялся на каждом опросе. // Владелец видел пустую панель и не имел ни одной подсказки, что нужен токен // и где его взять. Теперь спрашиваем прямо, один раз. let _tokenPromptOpen = false; function handleUnauthorized() { if (_tokenPromptOpen) return; _tokenPromptOpen = true; // Опрос останавливаем, иначе окно ввода будет перекрываться новыми 401 // каждые несколько секунд. Отдельной stopPolling в клиенте нет — таймер // гасится напрямую, как это делает startPolling перед перезапуском. if (pollTimer) { clearInterval(pollTimer); pollTimer = null; } setSourceIndicator(false, 'Требуется токен доступа'); elements.modalTitle.textContent = 'Требуется токен доступа'; elements.modalBody.innerHTML = `
Токен сохранится в этом браузере и больше спрашиваться не будет.
`; elements.modalFooter.innerHTML = ` `; showModal(); setTimeout(() => { const el = document.getElementById('auth-token-input'); if (el) el.focus(); }, 50); } async function saveAuthTokenFromPrompt() { const input = document.getElementById('auth-token-input'); const feedback = document.getElementById('auth-token-feedback'); if (!input) return; const value = (input.value || '').trim(); if (!value) { feedback.innerHTML = 'Поле пустое.'; return; } // Заголовки HTTP переносят только ASCII. Без этой проверки fetch бросает // TypeError и обработчик умирает целиком, не показав ничего: так бывает, // если вместе с токеном скопировали русский текст или лишний символ. if (!/^[!-~]+$/.test(value)) { feedback.innerHTML = 'В токене посторонние символы. Скопируйте только сам токен, без кавычек, пробелов и текста вокруг.'; return; } feedback.textContent = 'Проверяем…'; let res; try { res = await fetch('/api/snapshot', { headers: { 'X-Hub-Token': value } }); } catch (err) { feedback.innerHTML = `Не удалось обратиться к серверу: ${escapeHtml(err.message)}`; return; } if (res.status === 401) { feedback.innerHTML = 'Токен не подошёл. Проверьте, что скопирован целиком.'; return; } if (!res.ok) { feedback.innerHTML = `Сервер ответил ${res.status}.`; return; } authToken = value; localStorage.setItem('hermes_hub_token', authToken); const tokenInput = document.getElementById('setting-client-token-input'); if (tokenInput) tokenInput.value = authToken; _tokenPromptOpen = false; closeModal(); showToast('Токен принят', 'success'); startPolling(); fetchSnapshot(); } // ── 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 }), }); if (res.status === 401) { handleUnauthorized(); return { ok: false, message: 'Требуется токен доступа' }; } 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 readiness = currentSnapshot.readiness || {}; const allProfiles = Object.values(currentSnapshot.all_profiles || {}); const connectedAccounts = readiness.accounts_connected_count ?? allProfiles.filter( (p) => isConnectedProfile(p) ).length; if (elements.navAccountsCount) elements.navAccountsCount.textContent = connectedAccounts; 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 = connectedAccounts; 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; } // ── 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 (P0-2 Only Connected Accounts & Empty State) // ═══════════════════════════════════════════════════════════════ function renderAccountsView() { const container = elements.accountsContainer; if (!container || !currentSnapshot) return; const allProfiles = Object.values(currentSnapshot.all_profiles || {}); const totalConnectedInSystem = allProfiles.filter( (p) => isConnectedProfile(p) ).length; if (totalConnectedInSystem === 0) { container.innerHTML = `
👥

Нет подключённых аккаунтов

Подключите ваш первый аккаунт провайдера ИИ для распределения ролей и работы с Hermes Hub.

`; if (elements.accountsStatsSummary) { elements.accountsStatsSummary.innerHTML = 'Показано: 0 из 0 подключённых аккаунтов'; } 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)', local: 'Local LLM', 'local-llm': 'Local LLM', 'llama.cpp': 'Local LLM (llama.cpp)', ollama: 'Ollama', vllm: 'vLLM', }; 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) => { const isConnected = isConnectedProfile(p); if (!isConnected) return false; 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; }); filtered.sort((a, b) => { const rank = (p) => { if (p.health_state === 'not_configured') return 5; if (p.is_cold_spare || !p.enabled || p.health_state === 'disabled') return 4; if (p.health_state === 'not_tested') return 3; if (p.health_state === 'auth_required' || p.health_state === 'auth_expired') return 2; if (p.health_state === 'healthy') return 0; return 1; }; const d = rank(a) - rank(b); if (d !== 0) return d; return (a.display_name || '').localeCompare(b.display_name || '', 'ru'); }); 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 || {}; const allConnectedProfiles = Object.values(currentSnapshot.all_profiles || {}).filter( (p) => isConnectedProfile(p) ); 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]) || ''; const hasCurrentInConnected = allConnectedProfiles.some((p) => p.profile_id === node.profile_id); let accountControlHtml = ''; if (allConnectedProfiles.length > 0) { accountControlHtml = ` `; } 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)})
${accountControlHtml} ${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 getProviderIcon(provider) { const map = { 'openai-codex': 'codex.png', 'google-antigravity': 'антигравити.png', 'opencode-go': 'opencode.png', 'anthropic-claude': 'claude.png', 'deepseek': 'deepseek.png', 'grok': 'grok.jfif' }; return map[provider] || 'llama.png'; } function renderRoutingView() { const leftCol = document.getElementById('routing-roles-container'); const rightCol = document.getElementById('routing-available-container'); if (!leftCol || !rightCol || !currentSnapshot) return; const routing = currentSnapshot.routing || {}; const agents = currentSnapshot.agents || []; const profiles = currentSnapshot.profiles || {}; // Render Left Column (Roles) let rolesHtml = ''; for (const [roleId, pipeline] of Object.entries(routing)) { const chain = pipeline.preferred_chain || []; const agentInfo = agents.find((a) => a.role_id === roleId); let isImportant = ['manager', 'developer-1', 'developer-2'].includes(roleId); let badgeHtml = isImportant ? ` Важная роль` : ''; let roleDesc = CANONICAL_ROLE_DESCRIPTIONS[roleId] || ''; rolesHtml += `

${escapeHtml(pipeline.role_name_ru || roleId)} ${badgeHtml}

${escapeHtml(roleDesc)}
${chain.length} аккаунта
Приоритет
Аккаунт
Модель
Провайдер
Квоты
Сброс
Статус
`; chain.forEach((pid, index) => { const prof = profiles[pid] || {}; const prov = prof.provider || 'unknown'; const icon = getProviderIcon(prov); rolesHtml += ` `; }); rolesHtml += `
Перетащите аккаунт сюда для добавления в конец списка
`; } leftCol.innerHTML = rolesHtml; // Render Right Column (Available Accounts) let availHtml = ''; const searchEl = document.getElementById('routing-account-search'); const q = searchEl ? searchEl.value.toLowerCase() : ''; let count = 0; for (const [pid, prof] of Object.entries(profiles)) { if (q && !pid.toLowerCase().includes(q) && !(prof.provider||'').toLowerCase().includes(q)) continue; count++; const icon = getProviderIcon(prof.provider); availHtml += `
${escapeHtml(pid)}
${escapeHtml(prof.provider || '')}
`; } rightCol.innerHTML = availHtml; const countEl = document.getElementById('available-accounts-count'); if (countEl) countEl.innerText = `${count} аккаунтов`; setupDragAndDrop(); } function quickAddProfile(pid) { const routing = currentSnapshot.routing || {}; let role = 'manager'; if (!routing[role]) role = Object.keys(routing)[0]; if (!role) return; addProfileToChain(role, pid); } function setupDragAndDrop() { let draggedEl = null; let dragPid = null; let sourceRole = null; document.querySelectorAll('.draggable-item').forEach(el => { el.addEventListener('dragstart', (e) => { draggedEl = el; dragPid = el.dataset.pid; sourceRole = el.dataset.role || null; e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', dragPid); setTimeout(() => el.style.opacity = '0.5', 0); }); el.addEventListener('dragend', (e) => { el.style.opacity = '1'; document.querySelectorAll('.drag-over').forEach(d => d.classList.remove('drag-over')); draggedEl = null; }); }); // Drop zones (the "add to end" zones) document.querySelectorAll('.drop-zone').forEach(zone => { zone.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; zone.classList.add('drag-over'); }); zone.addEventListener('dragleave', (e) => { zone.classList.remove('drag-over'); }); zone.addEventListener('drop', (e) => { e.preventDefault(); zone.classList.remove('drag-over'); const targetRole = zone.dataset.role; if (!targetRole || !dragPid) return; handleDropMove(dragPid, sourceRole, targetRole, -1); }); }); // Reordering inside role-chain-list document.querySelectorAll('.account-row').forEach(row => { row.addEventListener('dragover', (e) => { e.preventDefault(); row.classList.add('drag-over'); }); row.addEventListener('dragleave', (e) => { row.classList.remove('drag-over'); }); row.addEventListener('drop', (e) => { e.preventDefault(); row.classList.remove('drag-over'); const targetRole = row.dataset.role; if (!targetRole || !dragPid) return; const list = row.parentNode; const children = Array.from(list.children); const insertIndex = children.indexOf(row); handleDropMove(dragPid, sourceRole, targetRole, insertIndex); }); }); } function handleDropMove(pid, sourceRole, targetRole, insertIndex) { const routing = currentSnapshot.routing; if (!routing || !routing[targetRole]) return; const targetChain = [...(routing[targetRole].preferred_chain || [])]; if (sourceRole && sourceRole === targetRole) { const oldIndex = targetChain.indexOf(pid); if (oldIndex > -1) { targetChain.splice(oldIndex, 1); } if (insertIndex === -1) { targetChain.push(pid); } else { let idx = insertIndex; if (oldIndex > -1 && oldIndex < insertIndex) idx--; targetChain.splice(idx, 0, pid); } updateRoleChain(targetRole, targetChain); } else { if (targetChain.includes(pid)) { showToast(`Аккаунт ${pid} уже есть в роли ${targetRole}`, 'warning'); return; } if (insertIndex === -1) { targetChain.push(pid); } else { targetChain.splice(insertIndex, 0, pid); } updateRoleChain(targetRole, targetChain); } } async function updateRoleChain(roleId, newChain) { try { const resp = await fetch(`/api/v1/router/roles/${encodeURIComponent(roleId)}/chain`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newChain) }); if (!resp.ok) throw new Error(await resp.text()); showToast(`Цепочка для ${roleId} обновлена`, 'success'); await fetchSnapshot(); } catch (e) { showToast(`Ошибка сохранения: ${e.message}`, 'error'); } } async function removeProfileFromChain(roleId, pid) { const routing = currentSnapshot.routing; if (!routing || !routing[roleId]) return; const chain = [...(routing[roleId].preferred_chain || [])]; const idx = chain.indexOf(pid); if (idx > -1) { chain.splice(idx, 1); await updateRoleChain(roleId, chain); } } 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() { _openAccountModalProfile = null; stopDeviceAuthPolling(); // Опрос входа по ссылке иначе продолжал бы стучать в закрытое окно. stopRedirectAuthPolling(); 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; } } // ───────────────────────────────────────────────────────────── // Авторизация по коду устройства (Grok, OpenAI Codex) // ───────────────────────────────────────────────────────────── let _deviceAuthTimer = null; function stopDeviceAuthPolling() { if (_deviceAuthTimer) { clearInterval(_deviceAuthTimer); _deviceAuthTimer = null; } } async function startDeviceAuth(providerId) { stopDeviceAuthPolling(); const box = document.getElementById('device-auth-box'); if (!box) return; const res = await executeAction('start_device_auth', { provider: providerId }); if (!res || !res.ok) { box.innerHTML = ``; return; } const d = res.data || {}; window._wiz_device_session = d.session_id; window._wiz_device_profile = d.profile_id; box.innerHTML = `
1. Откройте ссылку:
2. Введите код:
${escapeHtml(d.code || '')}
3. Подтвердите доступ — окно обновится само. Код живёт недолго, не откладывайте.
`; _deviceAuthTimer = setInterval(() => pollDeviceAuth(providerId), 3000); } async function pollDeviceAuth(providerId) { const status = document.getElementById('device-auth-status'); if (!status) { stopDeviceAuthPolling(); return; } const res = await executeAction('poll_device_auth', { provider: providerId, session_id: window._wiz_device_session, }); if (!res) return; const state = (res.data || {}).status; if (res.ok && state === 'completed') { stopDeviceAuthPolling(); status.innerHTML = 'Аккаунт подключён'; showToast('Аккаунт подключён', 'success'); fetchSnapshot(); return; } if (!res.ok) { // Отказ и просроченный код — конечные исходы, а не ожидание. stopDeviceAuthPolling(); status.innerHTML = `${escapeHtml(res.message || 'Авторизация не завершена')}`; } } // Профили, реально участвующие в цепочках маршрутизации. // // Судить по assigned_roles нельзя: холодный резерв и ag-spare-2 значатся с // ролью "spare", которой среди шести маршрутизируемых ролей нет. Поэтому // берём состав самих цепочек — это точно и не зависит от названий ролей. function profilesInRouting() { const routing = (currentSnapshot || {}).routing || {}; const ids = new Set(); Object.values(routing).forEach((pipeline) => { ((pipeline && pipeline.nodes) || []).forEach((n) => { if (n && n.profile_id) ids.add(n.profile_id); }); }); return ids; } // Список слотов провайдера из снимка: занятые помечены, свободные идут первыми. function buildSlotOptions(providerId) { const profiles = ((currentSnapshot || {}).profiles_by_provider || {})[providerId] || []; if (!profiles.length) { return ''; } // Роль слота показываем прямо в списке. Без этого выбор вслепую: слоты // ag-spare-* и ag-cold-* не входят ни в одну цепочку, поэтому подключённый // в них аккаунт честно не появляется ни в «Обзоре», ни в «Маршрутизации» — // и выглядит это как пропажа. const routed = profilesInRouting(); const free = []; const used = []; const idle = []; profiles.forEach((p) => { const isFree = p.health_state === 'not_configured'; const inRouting = routed.has(p.profile_id); const roles = (p.assigned_roles || []).join(', '); const who = p.email || p.account_identity || ''; const state = isFree ? 'свободен' : `занят${who ? ': ' + who : ''}`; const label = inRouting ? `${p.profile_id} — ${state} · ${roles || 'в маршрутизации'}` : `${p.profile_id} — ${state} · не участвует в маршрутизации`; const opt = ``; if (!inRouting) idle.push(opt); else if (isFree) free.push(opt); else used.push(opt); }); // Свободные слоты с ролью — первыми: именно они дают работающий маршрут. return free.concat(used, idle).join(''); } let _redirectAuthTimer = null; function stopRedirectAuthPolling() { if (_redirectAuthTimer) { clearInterval(_redirectAuthTimer); _redirectAuthTimer = null; } } // Вход по ссылке для Antigravity и Claude. // // Смысл: браузер не обязан быть на той машине, где работает Hub. Владелец // открывает ссылку у себя, подтверждает доступ и возвращает результат сюда. // Antigravity кладёт код в адресную строку (браузер при этом покажет ошибку // соединения, если слушатель на другой машине — это нормально, адрес всё // равно годен). Claude показывает код прямо на странице. async function startRedirectAuth(providerId) { stopRedirectAuthPolling(); const box = document.getElementById('redirect-auth-box'); if (!box) return; // Слот выбирает владелец, а не догадка сервера. // // find_free_slot определяет занятость по файлу учётных данных, но agy на // Windows хранит их в keyring — файла нет ни у одного слота, поэтому все // десять считаются свободными и всегда возвращается первый, ag-orch-fallback. // Вход в него затёр бы работающий аккаунт. Список ниже строится из снимка, // который знает настоящее состояние, и показывает, что занято. const slot = document.getElementById('wiz-redirect-slot'); const chosen = slot ? slot.value : ''; const res = await executeAction('start_redirect_auth', { provider: providerId, profile_id: chosen || undefined, }); if (!res || !res.ok) { box.innerHTML = ``; return; } const d = res.data || {}; window._wiz_redirect_session = d.session_id; window._wiz_redirect_provider = providerId; window._wiz_redirect_slot_id = d.profile_id; const pastesUrl = d.paste_kind !== 'code'; const label = pastesUrl ? 'Вставьте адрес из адресной строки браузера целиком:' : 'Вставьте код, показанный на странице:'; const placeholder = pastesUrl ? 'http://127.0.0.1:…/oauth-callback?code=…' : 'Код со страницы провайдера'; // Если хаб открыт не с этой же машины, браузер после подтверждения уйдёт на // 127.0.0.1:<порт> СВОЕЙ машины, где никто не слушает: получается тупик, // из которого адрес с кодом ещё надо как-то выковырять. Проброс этого порта // убирает проблему целиком — возврат попадает прямо в слушатель хаба и вход // завершается сам. Показываем готовую команду, а вставку оставляем запасным // путём. const host = window.location.hostname; const isRemote = host !== '127.0.0.1' && host !== 'localhost' && host !== ''; const cbPort = d.port || 0; const tunnelNote = pastesUrl && isRemote && cbPort ? `` : ''; const localNote = pastesUrl && d.redirect_uri ? `
Без проброса браузер после подтверждения уйдёт на ${escapeHtml(d.redirect_uri)} и покажет «страница недоступна» — это ожидаемо, хаб на другой машине. Скопируйте из адресной строки весь адрес целиком либо только значение code= — принимается и то, и другое.
` : ''; box.innerHTML = `
1. Откройте ссылку — на любой машине, где есть браузер:
2. ${escapeHtml(label)}
${tunnelNote} ${localNote}
Слот: ${escapeHtml(d.profile_id || '—')}. Ссылка действует 20 минут.
`; // Если браузер открыт на этой же машине, слушатель поймает возврат сам — // тогда вставлять ничего не придётся. _redirectAuthTimer = setInterval(pollRedirectAuth, 3000); } async function submitRedirectCallback() { const input = document.getElementById('wiz-redirect-paste'); const status = document.getElementById('redirect-auth-status'); if (!input || !status) return; const value = (input.value || '').trim(); if (!value) { status.innerHTML = 'Поле пустое — вставьте значение из браузера.'; return; } status.innerHTML = 'Проверяем…'; const res = await executeAction('submit_redirect_callback', { session_id: window._wiz_redirect_session, provider: window._wiz_redirect_provider, callback_url: value, }); if (res && res.ok) { stopRedirectAuthPolling(); status.innerHTML = 'Аккаунт подключён' + redirectSlotRoleNote(); showToast('Аккаунт подключён', 'success'); fetchSnapshot(); return; } // Промах при вставке не заканчивает сессию: ссылка ещё годна, можно // вставить снова. Поэтому опрос не останавливаем. status.innerHTML = `${escapeHtml((res && res.message) || 'Не удалось завершить вход')}`; } // Если слот не входит ни в одну цепочку, аккаунт не появится ни в «Обзоре», // ни в «Маршрутизации» — и это выглядит как пропажа. Говорим об этом сразу. function redirectSlotRoleNote() { const pid = window._wiz_redirect_slot_id; if (!pid) return ''; const all = (currentSnapshot || {}).all_profiles || []; const prof = all.find((p) => p.profile_id === pid); const roles = prof ? (prof.assigned_roles || []) : []; if (profilesInRouting().has(pid)) { return `
Роль: ${escapeHtml(roles.join(', '))}
`; } return ``; } async function pollRedirectAuth() { const status = document.getElementById('redirect-auth-status'); if (!status) { stopRedirectAuthPolling(); return; } const res = await executeAction('poll_redirect_auth', { session_id: window._wiz_redirect_session, provider: window._wiz_redirect_provider, }); if (!res) return; if (res.ok && (res.data || {}).status === 'completed') { stopRedirectAuthPolling(); status.innerHTML = 'Аккаунт подключён'; showToast('Аккаунт подключён', 'success'); fetchSnapshot(); return; } if (!res.ok) { // Конечный отказ провайдера — например, доступ отклонён. stopRedirectAuthPolling(); status.innerHTML = `${escapeHtml(res.message || 'Авторизация не завершена')}`; } } async function handleDeleteCredentials(profileId) { // Подтверждение обязательно: действие необратимо, аккаунт придётся // подключать заново. if (!confirm(`Удалить учётные данные профиля ${profileId}? Аккаунт придётся подключить заново.`)) { return; } const res = await executeAction('delete_credentials', { profile_id: profileId }); if (res && res.ok) { closeModal(); fetchSnapshot(); } }