/**
* 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.body.dataset.view = 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());
}
// Preflight check listener
const btnPreflight = document.getElementById('btn-run-preflight');
if (btnPreflight) {
btnPreflight.addEventListener('click', () => runPreflightChecks());
}
// Reset router config listener (P0-4)
const btnResetConfig = document.getElementById('btn-reset-router-config');
if (btnResetConfig) {
btnResetConfig.addEventListener('click', () => openResetConfigModal());
}
}
// ── 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 означают подключённый аккаунт, которому нужен
// повторный вход, — их показываем.
// Цвет индикатора по измеренному состоянию. Неизвестное состояние остаётся
// серым (базовый .status-dot), а не выдаёт себя за здоровое.
function healthDotClass(state) {
const st = String(state || '').toLowerCase();
if (st === 'healthy') return 'healthy';
if (['quota_exhausted', 'rate_limited', 'auth_required', 'auth_expired'].includes(st)) return 'warning';
if (['error', 'unhealthy'].includes(st)) return 'error';
return '';
}
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 = `
Этот хаб открыт по сети, поэтому запросы к нему требуют токен.
Он был показан при выполнении enable_lan_access.py и хранится
на сервере в ~/.hermes/hub_settings.json.
Токен сохранится в этом браузере и больше спрашиваться не будет.
`;
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 || {};
renderAccountSummary(currentSnapshot);
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 ?? 0;
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;
}
// ── 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.
Модели: ${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() { renderAccountRouting(); }
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].nodes || []).map(node => node.profile_id);
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) {
const result = await executeAction('save_chain', { role_id: roleId, chain: newChain });
if (result.ok) await fetchSnapshot();
}
async function removeProfileFromChain(roleId, pid) {
const routing = currentSnapshot.routing;
if (!routing || !routing[roleId]) return;
const chain = (routing[roleId].nodes || []).map(node => node.profile_id);
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) => isConnectedProfile(p) && !currentChain.includes(p.profile_id));
elements.modalTitle.textContent = `Добавить профиль в цепочку: ${pipeline.role_name_ru || roleId}`;
if (available.length === 0) {
elements.modalBody.innerHTML = `
Все зарегистрированные профили уже включены в эту цепочку.
Вход в занятый слот заменит учётные данные, которые в нём сейчас.
Оставьте пустым для значения по умолчанию.
`;
footerHtml = `
`;
} else {
bodyHtml = `
Шаг 2 из 3: Ввод API ключа ${escapeHtml(providerId)}
`;
footerHtml = `
`;
}
elements.modalBody.innerHTML = `
${bodyHtml}
`;
elements.modalFooter.innerHTML = footerHtml;
}
function proceedToWizardStep3(providerId) {
const baseInput = document.getElementById('wiz-base-url-input');
if (baseInput) {
window._wiz_base_url = baseInput.value.trim();
}
const tokenInput = document.getElementById('wiz-token-input');
if (tokenInput) {
window._wiz_token = tokenInput.value.trim();
}
// P0-1 BUG-1: persist owner-selected slot BEFORE showWizardStep3 destroys the select elements
// Only read slot elements for providers that have them (grok/openai-codex have wiz-device-slot,
// antigravity/claude/openrouter/nvidia have wiz-redirect-slot). Local providers have no slot elements.
const isDeviceAuthFlow = providerId === 'grok' || providerId === 'openai-codex';
const isRedirectAuthFlow = providerId === 'antigravity' || providerId === 'claude' || providerId === 'openrouter' || providerId === 'nvidia';
if (isDeviceAuthFlow) {
const deviceSlot = document.getElementById('wiz-device-slot');
if (deviceSlot && deviceSlot.value) {
window._wiz_device_profile = deviceSlot.value;
}
} else if (isRedirectAuthFlow) {
const redirectSlot = document.getElementById('wiz-redirect-slot');
if (redirectSlot && redirectSlot.value) {
window._wiz_device_profile = redirectSlot.value;
}
}
// For local providers (local, local-llm, llama.cpp, ollama, vllm), do not read any slot elements
showWizardStep3(providerId);
}
function showWizardStep3(providerId) {
// GAP-3: динамически строим options ролей из currentSnapshot.routing
const routing = currentSnapshot && currentSnapshot.routing ? currentSnapshot.routing : {};
const roleIds = Object.keys(routing);
let roleOptionsHtml = '';
if (roleIds.length === 0) {
// Fallback: минимальный набор если snapshot ещё не загружен
roleOptionsHtml = `
`;
} else {
roleOptionsHtml = roleIds.map((roleId) => {
const pipeline = routing[roleId] || {};
const label = pipeline.role_name_ru || roleId;
const desc = CANONICAL_ROLE_DESCRIPTIONS[roleId] || pipeline.role_description_ru || '';
return ``;
}).join('');
}
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';
// GAP-2: owner-selected slot — читаем выбранный профиль из UI
// Local providers (local, local-llm, llama.cpp, ollama, vllm) have no slot selector;
// do NOT read stale DOM slot elements from previous flows in the same test session.
const isLocalProvider = providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm';
let selectedProfileId;
if (isLocalProvider) {
selectedProfileId = '';
} else {
const deviceSlot = document.getElementById('wiz-device-slot');
const redirectSlot = document.getElementById('wiz-redirect-slot');
// Use nullish coalescing: if _wiz_device_profile was explicitly set (not null/undefined),
// it wins over any stale DOM element value (e.g. from a previous grok flow in the same test)
selectedProfileId = window._wiz_device_profile ?? (deviceSlot?.value || redirectSlot?.value || '');
}
const feedbackArea = document.getElementById('modal-feedback-area');
if (feedbackArea) {
feedbackArea.innerHTML = '
⏳ Сохранение учетной записи в роутере...
';
}
const payload = {
provider: providerId,
target_role: targetRole,
// GAP-2: передаём выбранный слот, чтобы бэкенд НЕ делал find_free_slot для owner
profile_id: selectedProfileId,
};
if (window._wiz_base_url) {
payload.base_url = window._wiz_base_url;
}
if (window._wiz_token) {
payload.token = window._wiz_token;
}
const res = await executeAction('add_account', payload);
if (res && res.ok) {
showToast('Аккаунт успешно добавлен в маршрутизацию', 'success');
closeModal();
fetchSnapshot();
} else {
if (feedbackArea) {
feedbackArea.innerHTML = `
❌ ${escapeHtml((res && res.message) || 'Не удалось завершить подключение')}
`;
}
}
}
// ─────────────────────────────────────────────────────────────
// Авторизация по коду устройства (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;
// GAP-1: owner-selected slot — показать выбранное в UI до начала auth
const slotSelect = document.getElementById('wiz-device-slot');
const selectedSlot = slotSelect ? slotSelect.value : '';
if (selectedSlot) {
window._wiz_device_profile = selectedSlot;
}
box.innerHTML = `
Запрашиваем код у провайдера…
`;
// P0-1 BUG-2: send profile_id so server knows which slot the owner chose
const res = await executeAction('start_device_auth', { provider: providerId, profile_id: selectedSlot });
if (!res || !res.ok) {
box.innerHTML = `
${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}
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 = `
${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}
`;
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
? `
Проще всего — пробросить порт возврата. Выполните у себя
в терминале, до подтверждения доступа:
Тогда вход завершится сам и вставлять ничего не придётся.
Без проброса браузер после подтверждения уйдёт на ${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 `
Слот ${escapeHtml(pid)} не входит ни в одну цепочку, поэтому в
«Обзоре» и «Маршрутизации» аккаунт не появится. Добавьте его нужной роли
в разделе «Маршрутизация» кнопкой «+ Добавить».
`;
}
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();
}
}
// ── P0-3: Local LLM server discovery ──────────────────────────────────────
async function discoverLocalServers() {
const btn = document.getElementById('wiz-discover-btn');
const statusEl = document.getElementById('wiz-discover-status');
const resultsEl = document.getElementById('wiz-discover-results');
if (!btn || !statusEl || !resultsEl) return;
btn.disabled = true;
btn.textContent = '⏳ Поиск...';
statusEl.textContent = '';
resultsEl.innerHTML = '';
// wiz-base-url-input: filled by selectDiscoveredServer when user picks a server
const res = await executeAction('discover_local_models', {});
btn.disabled = false;
btn.textContent = '🔍 Найти на этом компьютере';
const servers = (res && res.data && res.data.servers) ? res.data.servers : [];
if (!servers.length) {
statusEl.textContent = res && res.message ? res.message
: 'Ничего не найдено. Запустите Ollama, LM Studio или llama.cpp, либо введите адрес вручную.';
statusEl.style.color = 'var(--text-muted)';
return;
}
// Show message if any servers have errors (occupied by other service)
const errorServers = servers.filter(s => s.error);
if (errorServers.length && errorServers.length === servers.length) {
statusEl.textContent = 'Найдены серверы, но подключиться к ним не удалось:';
statusEl.style.color = 'var(--text-secondary)';
} else if (errorServers.length) {
statusEl.textContent = `Найдено ${servers.length - errorServers.length} рабочих серверов (+${errorServers.length} с ошибкой). Выберите рабочий сервер:`;
statusEl.style.color = 'var(--text-secondary)';
} else {
statusEl.textContent = `Найдено серверов: ${servers.length}. Выберите:`;
statusEl.style.color = 'var(--text-secondary)';
}
// Render server list
let html = '