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