hermes-hub/src/antigravity_provider/router/web/static/app.js
2026-08-25 19:53:24 +07:00

1696 lines
75 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = `
<div class="modal-feedback info" style="margin-bottom:14px;">
Этот хаб открыт по сети, поэтому запросы к нему требуют токен.
Он был показан при выполнении <code>enable_lan_access.py</code> и хранится
на сервере в <code>~/.hermes/hub_settings.json</code>.
</div>
<label style="display:block; font-weight:600; margin-bottom:4px;">Токен доступа:</label>
<input type="password" class="input-text" style="width:100%;" id="auth-token-input"
placeholder="Вставьте токен" autocomplete="off">
<div id="auth-token-feedback" style="font-size:12px; color:var(--text-muted); margin-top:8px;">
Токен сохранится в этом браузере и больше спрашиваться не будет.
</div>
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-primary" onclick="saveAuthTokenFromPrompt()">Сохранить и продолжить</button>
`;
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 = '<span style="color:var(--status-warning);">Поле пустое.</span>';
return;
}
// Заголовки HTTP переносят только ASCII. Без этой проверки fetch бросает
// TypeError и обработчик умирает целиком, не показав ничего: так бывает,
// если вместе с токеном скопировали русский текст или лишний символ.
if (!/^[!-~]+$/.test(value)) {
feedback.innerHTML = '<span style="color:var(--status-error);">В токене посторонние символы. Скопируйте только сам токен, без кавычек, пробелов и текста вокруг.</span>';
return;
}
feedback.textContent = 'Проверяем…';
let res;
try {
res = await fetch('/api/snapshot', { headers: { 'X-Hub-Token': value } });
} catch (err) {
feedback.innerHTML = `<span style="color:var(--status-error);">Не удалось обратиться к серверу: ${escapeHtml(err.message)}</span>`;
return;
}
if (res.status === 401) {
feedback.innerHTML = '<span style="color:var(--status-error);">Токен не подошёл. Проверьте, что скопирован целиком.</span>';
return;
}
if (!res.ok) {
feedback.innerHTML = `<span style="color:var(--status-error);">Сервер ответил ${res.status}.</span>`;
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 = `
<div class="accounts-empty-state">
<div class="empty-state-icon">👥</div>
<h3>Нет подключённых аккаунтов</h3>
<p>Подключите ваш первый аккаунт провайдера ИИ для распределения ролей и работы с Hermes Hub.</p>
<button class="btn btn-primary" onclick="openAddAccountWizard()">+ Подключить аккаунт</button>
</div>
`;
if (elements.accountsStatsSummary) {
elements.accountsStatsSummary.innerHTML = 'Показано: <strong>0</strong> из <strong>0</strong> подключённых аккаунтов';
}
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 += `
<div class="provider-group">
<div class="provider-group-header">
<div class="provider-group-title">
<span class="provider-dot" style="color: var(--prov-${providerId.replace('openai-', '').replace('-go', '')})">●</span>
<span>${providerNames[providerId] || providerId}</span>
</div>
<div class="provider-group-count">${filtered.length} аккаунт(ов)</div>
</div>
<div class="accounts-grid">
${filtered.map((p) => renderAccountCard(p)).join('')}
</div>
</div>
`;
}
container.innerHTML = html || '<div class="view-header-note">Аккаунты по заданным фильтрам не найдены.</div>';
if (elements.accountsStatsSummary) {
elements.accountsStatsSummary.innerHTML = `Показано: <strong>${visibleProfiles}</strong> из <strong>${totalProfiles}</strong> подключённых аккаунтов`;
}
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 = `
<div class="account-quota-grid ${visibleBuckets.length === 1 ? 'single-cell' : ''}">
${visibleBuckets.map((b) => renderQuotaCell(b, unavailableReason)).join('')}
</div>
`;
} 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 = `
<div class="account-quota-grid single-cell">
<div class="quota-cell">
<div class="quota-cell-top">
<span class="quota-cell-title">Квота</span>
<span class="quota-cell-value text-muted">Н/Д</span>
</div>
<div class="quota-bar-track">
<div class="quota-bar-fill" style="width: 0%; background-color: var(--status-disabled);"></div>
</div>
<div class="quota-cell-reset" title="${escapeHtml(reasonText)}">${escapeHtml(reasonText)}</div>
</div>
</div>
`;
}
return `
<div class="account-card ${isMain ? 'main-account' : ''}" data-profile-id="${escapeHtml(profile.profile_id)}">
<div class="account-card-header">
<div class="account-provider-tag">
<span>${escapeHtml(profile.provider_display_name || profile.provider)}</span>
</div>
<div class="account-badges">
${plan ? `<span class="badge badge-plan">${escapeHtml(plan)}</span>` : ''}
<span class="badge badge-status ${healthState}">● ${escapeHtml(healthLabel)}</span>
</div>
</div>
<div class="account-identity-row">
<div class="account-email" title="${escapeHtml(identity)}">${escapeHtml(identity)}</div>
<div class="account-meta" title="${escapeHtml(profile.display_name)}${escapeHtml(roles)}">
${escapeHtml(profile.display_name)}${escapeHtml(roles)}
</div>
</div>
${quotaGridHtml}
</div>
`;
}
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 `
<div class="quota-cell">
<div class="quota-cell-top">
<span class="quota-cell-title" title="${escapeHtml(bucket.display_name)}">${escapeHtml(bucket.display_name)}</span>
<span class="quota-cell-value" style="color: ${colorClass}">${formattedValue}</span>
</div>
<div class="quota-bar-track">
<div class="quota-bar-fill" style="width: ${barWidth}%; background-color: ${colorClass}"></div>
</div>
<div class="quota-cell-reset" title="${escapeHtml(resetText)}">${escapeHtml(resetText)}</div>
</div>
`;
}
// ═══════════════════════════════════════════════════════════════
// 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 += `
<div class="diagram-column">
<div class="diagram-column-header">${escapeHtml(pipeline.role_name_ru || roleId)}</div>
${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 = `
<select class="diagram-account-select" title="Сменить назначенный аккаунт" onchange="handleNodeAccountChange('${escapeHtml(roleId)}', this.value, ${idx === 0})">
${allConnectedProfiles.map((p) => `<option value="${escapeHtml(p.profile_id)}" ${p.profile_id === node.profile_id ? 'selected' : ''}>${escapeHtml(p.email || p.account_identity || p.display_name || p.profile_id)} (${escapeHtml(p.provider)})</option>`).join('')}
${!hasCurrentInConnected && node.profile_id ? `<option value="${escapeHtml(node.profile_id)}" selected>${escapeHtml(node.display_name || node.profile_id)}</option>` : ''}
</select>
`;
}
let modelControlHtml = '';
if (discoveredModels.length > 0) {
modelControlHtml = `
<select class="diagram-model-select" title="Сменить рабочую модель" onchange="handleNodeModelChange('${escapeHtml(roleId)}', '${escapeHtml(node.profile_id)}', this.value)">
${discoveredModels.map((m) => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
</select>
`;
} else {
modelControlHtml = `
<div style="font-size:10px; color:var(--text-muted); display:flex; align-items:center; justify-content:space-between;">
<span>Список моделей ещё не получен</span>
<button class="btn btn-secondary btn-xs" onclick="handleRefreshProviderModels('${escapeHtml(provId)}')"></button>
</div>
`;
}
return `
<div class="diagram-node ${node.is_active ? 'active' : ''}">
<div style="display:flex; justify-content:space-between; font-weight:600; font-size:11px;">
<span>${idx === 0 ? '★ Основной' : `Резерв ${idx}`}</span>
<span class="${node.is_active ? 'text-healthy' : 'text-muted'}">${node.is_active ? '● Активен' : 'Ожидание'}</span>
</div>
<div style="font-size:12px; font-weight:700; margin-top:2px;">${escapeHtml(node.account_identity && node.account_identity !== 'Аккаунт не добавлен' ? node.account_identity : (node.display_name || node.profile_id))}</div>
<div style="font-size:10px; color:var(--text-secondary); margin-bottom:4px;">${escapeHtml(node.display_name || node.profile_id)} (${escapeHtml(node.provider)})</div>
${accountControlHtml}
${modelControlHtml}
</div>
`;
}).join('') || '<div class="empty-text">Цепочка не задана</div>'}
</div>
`;
}
diagramBox.innerHTML = diagramHtml || '<div class="empty-text">Нет данных маршрутизации.</div>';
}
const provSummaryBox = document.getElementById('overview-providers-summary');
if (provSummaryBox) {
provSummaryBox.innerHTML = providers.map((prov) => `
<div class="provider-summary-card">
<div class="provider-summary-title">${escapeHtml(prov.provider_name || prov.provider_id)}</div>
<div class="provider-summary-stats">
Всего слотов: <strong>${prov.total_slots}</strong> •
Подключено: <strong>${prov.connected_count}</strong> •
Онлайн: <strong>${prov.online_count}</strong> •
Требуют входа: <strong>${prov.auth_required_count}</strong> •
Холодный резерв: <strong>${prov.cold_spare_count}</strong>
</div>
<div class="provider-models-tag">
Модели: ${prov.discovered_models && prov.discovered_models.length ? escapeHtml(prov.discovered_models.join(', ')) : 'Н/Д — список моделей ещё не получен'}
</div>
</div>
`).join('') || '<div class="empty-text">Нет данных провайдеров.</div>';
}
}
// ═══════════════════════════════════════════════════════════════
// 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 ? `<span class="role-badge"><i class="fa-solid fa-star"></i> Важная роль</span>` : '';
let roleDesc = CANONICAL_ROLE_DESCRIPTIONS[roleId] || '';
rolesHtml += `
<div class="role-section" data-role="${escapeHtml(roleId)}">
<div class="role-header">
<div>
<h4><i class="fa-solid fa-network-wired" style="color:var(--accent);"></i> ${escapeHtml(pipeline.role_name_ru || roleId)} ${badgeHtml}</h4>
<div style="font-size:11px; color:var(--text-muted); margin-top:2px;">${escapeHtml(roleDesc)}</div>
</div>
<div style="display:flex; align-items:center; gap:12px;">
<span style="font-size:12px; color:var(--text-secondary);">${chain.length} аккаунта</span>
<button class="btn btn-secondary btn-sm" onclick="openAddNodeToChainModal('${escapeHtml(roleId)}')">
+ Добавить аккаунт
</button>
<i class="fa-solid fa-ellipsis-vertical" style="color:var(--text-muted); cursor:pointer;"></i>
</div>
</div>
<div class="grid-header">
<div><!-- drag handle --></div>
<div>Приоритет</div>
<div>Аккаунт</div>
<div>Модель</div>
<div>Провайдер</div>
<div>Квоты</div>
<div>Сброс</div>
<div>Статус</div>
<div><!-- remove --></div>
</div>
<div class="role-chain-list" id="chain-${escapeHtml(roleId)}" style="min-height: 10px;">
`;
chain.forEach((pid, index) => {
const prof = profiles[pid] || {};
const prov = prof.provider || 'unknown';
const icon = getProviderIcon(prov);
rolesHtml += `
<div class="account-row draggable-item" draggable="true" data-pid="${escapeHtml(pid)}" data-role="${escapeHtml(roleId)}">
<div class="drag-handle"><i class="fa-solid fa-grip-vertical"></i></div>
<div style="font-weight:600; color:var(--text-primary); text-align:center;">${index + 1}</div>
<div style="display:flex; align-items:center; gap:8px;">
<img src="/static/${icon}" class="provider-logo-sm" onerror="this.src='/static/llama.png'">
<div>
<div style="font-weight:600;">${escapeHtml(pid)}</div>
<div style="font-size:10px; color:var(--text-muted);">${escapeHtml(prof.account_id || '')}</div>
</div>
</div>
<div>
<select class="form-control" style="padding:2px 4px; font-size:11px;" disabled>
<option>${escapeHtml(prof.default_model || 'Default')}</option>
</select>
</div>
<div style="display:flex; align-items:center; gap:4px;">
<img src="/static/${icon}" class="provider-logo-sm" onerror="this.src='/static/llama.png'">
${escapeHtml(prov)}
</div>
<div>
<div class="cell-bar-track"><div class="cell-bar-fill" style="width:70%; background:var(--status-healthy);"></div></div>
<div class="cell-bar-track" style="margin-top:4px;"><div class="cell-bar-fill" style="width:40%; background:var(--status-healthy);"></div></div>
</div>
<div style="font-size:10px; color:var(--text-muted);">
26 авг.,<br>18:42
</div>
<div>
<span style="color:var(--status-healthy);">● Активен</span>
</div>
<div style="text-align:center; cursor:pointer; color:var(--status-warning);" onclick="removeProfileFromChain('${escapeHtml(roleId)}', '${escapeHtml(pid)}')">
<i class="fa-solid fa-xmark"></i>
</div>
</div>
`;
});
rolesHtml += `
</div>
<div class="drop-zone" data-role="${escapeHtml(roleId)}">
<i class="fa-solid fa-arrows-up-down"></i> Перетащите аккаунт сюда для добавления в конец списка
</div>
</div>
`;
}
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 += `
<div class="available-account-card draggable-item" draggable="true" data-pid="${escapeHtml(pid)}" data-source="available">
<img src="/static/${icon}" style="width:24px; height:24px; border-radius:4px;" onerror="this.src='/static/llama.png'">
<div style="flex:1;">
<div style="font-weight:600; font-size:12px;">${escapeHtml(pid)}</div>
<div style="font-size:10px; color:var(--text-muted);">${escapeHtml(prof.provider || '')}</div>
</div>
<div style="width:40px;">
<div class="cell-bar-track"><div class="cell-bar-fill" style="width:80%; background:var(--status-healthy);"></div></div>
</div>
<button class="btn btn-secondary btn-sm" style="padding:2px 6px;" onclick="quickAddProfile('${escapeHtml(pid)}')"><i class="fa-solid fa-plus"></i></button>
</div>
`;
}
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 = `
<div class="view-header-note">Все зарегистрированные профили уже включены в эту цепочку.</div>
`;
elements.modalFooter.innerHTML = `<button class="btn btn-ghost" onclick="closeModal()">Закрыть</button>`;
} else {
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Выберите доступный профиль для включения в цепочку отказоустойчивости:
</div>
<div style="margin-bottom:16px;">
<select id="add-node-profile-select" class="select-filter" style="width:100%;">
${available.map((p) => `
<option value="${escapeHtml(p.profile_id)}">
${escapeHtml(p.display_name || p.profile_id)} (${escapeHtml(p.provider_display_name || p.provider)}) — ${escapeHtml(p.email || p.account_identity || 'без email')}
</option>
`).join('')}
</select>
</div>
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
<button class="btn btn-primary" onclick="handleAddNodeToChain('${escapeHtml(roleId)}')">+ Добавить в цепочку</button>
`;
}
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 = '<div class="modal-feedback info">⏳ Добавление профиля в цепочку...</div>';
}
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 = `<div class="modal-feedback error">❌ ${escapeHtml(res.message || 'Ошибка сохранения')}</div>`;
}
}
}
// ── 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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 = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
return;
}
const d = res.data || {};
window._wiz_device_session = d.session_id;
window._wiz_device_profile = d.profile_id;
box.innerHTML = `
<div style="font-weight:700; margin-bottom:6px;">1. Откройте ссылку:</div>
<div style="display:flex; gap:8px; margin-bottom:12px;">
<input type="text" class="input-text" style="flex:1;" id="wiz-auth-url" value="${escapeHtml(d.url || '')}" readonly>
<button class="btn btn-secondary btn-sm" onclick="window.open(document.getElementById('wiz-auth-url').value, '_blank')">Открыть</button>
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('wiz-auth-url').value, 'Ссылка скопирована')">Копировать</button>
</div>
<div style="font-weight:700; margin-bottom:6px;">2. Введите код:</div>
<div style="display:flex; align-items:center; gap:12px; margin-bottom:12px;">
<div style="font-family:var(--font-mono); font-size:22px; font-weight:700; color:var(--text-accent); letter-spacing:2px;" id="wiz-auth-code">${escapeHtml(d.code || '')}</div>
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('wiz-auth-code').innerText, 'Код скопирован')">Копировать код</button>
</div>
<div id="device-auth-status" style="font-size:12px; color:var(--text-muted);">
3. Подтвердите доступ — окно обновится само. Код живёт недолго, не откладывайте.
</div>
`;
_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 = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>';
showToast('Аккаунт подключён', 'success');
fetchSnapshot();
return;
}
if (!res.ok) {
// Отказ и просроченный код — конечные исходы, а не ожидание.
stopDeviceAuthPolling();
status.innerHTML = `<span style="color:var(--status-error); font-weight:600;">${escapeHtml(res.message || 'Авторизация не завершена')}</span>`;
}
}
// Профили, реально участвующие в цепочках маршрутизации.
//
// Судить по 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 '<option value="">Список слотов ещё не получен</option>';
}
// Роль слота показываем прямо в списке. Без этого выбор вслепую: слоты
// 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 = `<option value="${escapeHtml(p.profile_id)}">${escapeHtml(label)}</option>`;
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 = `<div class="modal-feedback error">${escapeHtml((res && res.message) || 'Не удалось начать авторизацию')}</div>`;
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
? `<div class="modal-feedback info" style="margin-top:10px; font-size:12px;">
<strong>Проще всего — пробросить порт возврата.</strong> Выполните у себя
в терминале, до подтверждения доступа:
<div style="display:flex; gap:6px; margin:6px 0;">
<input type="text" class="input-text" style="flex:1; font-family:var(--font-mono); font-size:11px;"
id="wiz-redirect-tunnel" readonly
value="ssh -L ${cbPort}:127.0.0.1:${cbPort} ${escapeHtml(host)}">
<button class="btn btn-secondary btn-sm"
onclick="copyToClipboard(document.getElementById('wiz-redirect-tunnel').value, 'Команда скопирована')">Копировать</button>
</div>
Тогда вход завершится сам и вставлять ничего не придётся.
</div>`
: '';
const localNote = pastesUrl && d.redirect_uri
? `<div style="font-size:12px; color:var(--text-muted); margin-top:8px;">
Без проброса браузер после подтверждения уйдёт на <code>${escapeHtml(d.redirect_uri)}</code>
и покажет «страница недоступна» — это ожидаемо, хаб на другой машине.
Скопируйте из адресной строки весь адрес целиком либо только значение
<code>code=</code> — принимается и то, и другое.
</div>`
: '';
box.innerHTML = `
<div style="font-weight:700; margin-bottom:6px;">1. Откройте ссылку — на любой машине, где есть браузер:</div>
<div style="display:flex; gap:8px; margin-bottom:12px;">
<input type="text" class="input-text" style="flex:1;" id="wiz-redirect-url" value="${escapeHtml(d.url || '')}" readonly>
<button class="btn btn-secondary btn-sm" onclick="window.open(document.getElementById('wiz-redirect-url').value, '_blank')">Открыть</button>
<button class="btn btn-secondary btn-sm" onclick="copyToClipboard(document.getElementById('wiz-redirect-url').value, 'Ссылка скопирована')">Копировать</button>
</div>
<div style="font-weight:700; margin-bottom:6px;">2. ${escapeHtml(label)}</div>
<div style="display:flex; gap:8px; margin-bottom:6px;">
<input type="text" class="input-text" style="flex:1;" id="wiz-redirect-paste" placeholder="${escapeHtml(placeholder)}">
<button class="btn btn-primary btn-sm" onclick="submitRedirectCallback()">Завершить вход</button>
</div>
${tunnelNote}
${localNote}
<div id="redirect-auth-status" style="font-size:12px; color:var(--text-muted); margin-top:10px;">
Слот: ${escapeHtml(d.profile_id || '—')}. Ссылка действует 20 минут.
</div>
`;
// Если браузер открыт на этой же машине, слушатель поймает возврат сам —
// тогда вставлять ничего не придётся.
_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 = '<span style="color:var(--status-warning);">Поле пустое — вставьте значение из браузера.</span>';
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 = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>' + redirectSlotRoleNote();
showToast('Аккаунт подключён', 'success');
fetchSnapshot();
return;
}
// Промах при вставке не заканчивает сессию: ссылка ещё годна, можно
// вставить снова. Поэтому опрос не останавливаем.
status.innerHTML = `<span style="color:var(--status-error);">${escapeHtml((res && res.message) || 'Не удалось завершить вход')}</span>`;
}
// Если слот не входит ни в одну цепочку, аккаунт не появится ни в «Обзоре»,
// ни в «Маршрутизации» — и это выглядит как пропажа. Говорим об этом сразу.
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 `<div style="margin-top:6px; font-size:12px; color:var(--text-muted);">Роль: ${escapeHtml(roles.join(', '))}</div>`;
}
return `<div class="modal-feedback info" style="margin-top:8px; font-size:12px;">
Слот <code>${escapeHtml(pid)}</code> не входит ни в одну цепочку, поэтому в
«Обзоре» и «Маршрутизации» аккаунт не появится. Добавьте его нужной роли
в разделе «Маршрутизация» кнопкой «+ Добавить».
</div>`;
}
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 = '<span style="color:var(--status-healthy); font-weight:600;">Аккаунт подключён</span>';
showToast('Аккаунт подключён', 'success');
fetchSnapshot();
return;
}
if (!res.ok) {
// Конечный отказ провайдера — например, доступ отклонён.
stopRedirectAuthPolling();
status.innerHTML = `<span style="color:var(--status-error); font-weight:600;">${escapeHtml(res.message || 'Авторизация не завершена')}</span>`;
}
}
async function handleDeleteCredentials(profileId) {
// Подтверждение обязательно: действие необратимо, аккаунт придётся
// подключать заново.
if (!confirm(`Удалить учётные данные профиля ${profileId}? Аккаунт придётся подключить заново.`)) {
return;
}
const res = await executeAction('delete_credentials', { profile_id: profileId });
if (res && res.ok) {
closeModal();
fetchSnapshot();
}
}