From a8c37ca6e3de5fc6874f8afa5102e5082ed39507 Mon Sep 17 00:00:00 2001 From: Hermes Team Date: Tue, 25 Aug 2026 19:53:24 +0700 Subject: [PATCH] A29: Design System and Routing UI Drag-and-Drop --- .../router/web/static/app.js | 1550 +++-------------- .../router/web/static/index.html | 61 +- .../router/web/static/style.css | 253 ++- 3 files changed, 492 insertions(+), 1372 deletions(-) diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index e97bfd8..c499b1b 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -951,1366 +951,280 @@ function renderOverviewView() { // ═══════════════════════════════════════════════════════════════ // 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 container = document.getElementById('routing-pipelines-container'); - if (!container || !currentSnapshot) return; + 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 || []; - let html = ''; + const profiles = currentSnapshot.profiles || {}; + // Render Left Column (Roles) + let rolesHtml = ''; for (const [roleId, pipeline] of Object.entries(routing)) { - const nodes = pipeline.nodes || []; + const chain = pipeline.preferred_chain || []; 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'; + + let isImportant = ['manager', 'developer-1', 'developer-2'].includes(roleId); + let badgeHtml = isImportant ? ` Важная роль` : ''; + let roleDesc = CANONICAL_ROLE_DESCRIPTIONS[roleId] || ''; - html += ` -
-
+ rolesHtml += ` +
+
-
- ${escapeHtml(pipeline.role_name_ru || roleId)} - ${quotaLabel ? `Квота: ${escapeHtml(quotaLabel)}` : ''} - ${pipeline.session_affinity ? 'Session Affinity' : 'Без affinity'} -
- ${roleDesc ? `
${escapeHtml(roleDesc)}
` : ''} +

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

+
${escapeHtml(roleDesc)}
-
+
+ ${chain.length} аккаунта +
- -
- ${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 = ` -
- - -
- `; - } 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} - -
- ${errPct}% -
-
-
-
- - ${p50} - ${p95} - Н/Д (не отдаются) - - `; - }).join(''); - - provTableBox.innerHTML = ` - - - - - - - - - - - - - - - ${rowsHtml} - -
ПровайдерВсего вызововУспешноСбоиДоля ошибокp50p95Токены
- `; - } - } - - // 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 = ` - - - - - - - - - - - - - - ${rowsHtml} - -
Роль агентаВсего вызововУспешноСбоиДоля ошибокp50p95
- `; - } - } -} - -// ═══════════════════════════════════════════════════════════════ -// 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.title_ru || 'Система готова к работе')} -
- ${escapeHtml(readiness.state || 'HEALTHY')} -
-
- ${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 = ` -
-
- CPU (Процессор) - ${cpuPct}${cpuPct !== 'Н/Д' ? '%' : ''} -
-
-
-
-
Нагрузка хост-системы
-
- -
-
- RAM (Оперативная память) - ${memPct}${memPct !== 'Н/Д' ? '%' : ''} -
-
-
-
-
${memMb ? `Использовано: ${memMb}` : 'Статус использования RAM'}
-
- -
-
- Диск (Хранилище) - ${diskPct}${diskPct !== 'Н/Д' ? '%' : ''} -
-
-
-
-
${diskGb ? `Занято: ${diskGb}` : 'Статус дискового пространства'}
-
- -
-
- Сеть (Пропускная способность) - ${netSpeed} -
-
-
-
-
${netSub}
-
- `; - } - - // 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) => ` -
- ⚠️ -
${escapeHtml(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 quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); - const quotaActionSel = document.getElementById('setting-quota-threshold-action'); - 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 (quotaThresholdSel && s.quota_threshold_percent !== undefined) { - quotaThresholdSel.value = String(Math.round(s.quota_threshold_percent)); - } - if (quotaActionSel && s.quota_threshold_action) { - quotaActionSel.value = s.quota_threshold_action; - } - 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'; - - if (s.last_update_check && !latestUpdateInfo) { - latestUpdateInfo = s.last_update_check; - } - renderUpdateUI(); -} - -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(); - renderUpdateUI(); -} - -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 quotaThresholdSel = document.getElementById('setting-quota-threshold-percent'); - const quotaActionSel = document.getElementById('setting-quota-threshold-action'); - 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, - quota_threshold_percent: quotaThresholdSel ? parseFloat(quotaThresholdSel.value) || 10.0 : 10.0, - quota_threshold_action: quotaActionSel ? quotaActionSel.value : 'notify', - 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(); - } -} - -// ── IN-APP UPDATES (P0-1 — P0-4) ── -async function checkUpdates(silent = false) { - if (!silent) { - showToast('Проверка обновлений...', 'info'); - } - try { - const res = await executeAction('check_updates', {}); - if (res.ok && res.data) { - latestUpdateInfo = res.data; - renderUpdateUI(); - if (!silent) { - if (res.data.update_available) { - const c = res.data.latest_commit ? res.data.latest_commit.slice(0, 7) : (res.data.release_tag || 'new'); - showToast(`Доступно обновление (сборка ${c})`, 'info'); - } else { - showToast(res.data.message || 'Установлена последняя сборка', 'success'); - } - } - } else { - if (res.data) { - latestUpdateInfo = res.data; - renderUpdateUI(); - } - if (!silent) { - showToast(res.message || 'Ошибка проверки обновлений', 'error'); - } - } - } catch (err) { - if (!silent) { - showToast(`Ошибка проверки обновлений: ${err.message}`, 'error'); - } - } -} - -function renderUpdateUI() { - const badge = document.getElementById('header-update-badge'); - const badgeText = document.getElementById('header-update-text'); - const commitTag = document.getElementById('commit-tag'); - - const installedCommit = (latestUpdateInfo && latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown') - ? latestUpdateInfo.installed_commit - : (currentSettings && currentSettings.installed_commit ? currentSettings.installed_commit : ''); - - if (commitTag) { - commitTag.textContent = installedCommit ? `Сборка: ${installedCommit.slice(0, 7)}` : 'Сборка: —'; - } - - if (badge && badgeText) { - if (latestUpdateInfo && latestUpdateInfo.update_available) { - badge.classList.remove('hidden'); - const c = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || 'new'); - badgeText.textContent = `Доступно обновление (${c})`; - } else { - badge.classList.add('hidden'); - } - } - - // Populate settings view updates block if elements exist - const updateInfoDesc = document.getElementById('update-installed-info'); - const statusBadge = document.getElementById('update-status-badge'); - const lastCheckedDesc = document.getElementById('update-last-checked-desc'); - const btnApply = document.getElementById('btn-apply-update'); - const detailsBlock = document.getElementById('update-details-block'); - const releaseTitle = document.getElementById('update-release-title'); - const releaseMeta = document.getElementById('update-release-meta'); - const releaseNotes = document.getElementById('update-release-notes'); - - const curVer = (latestUpdateInfo && latestUpdateInfo.current_version) || (currentSettings && currentSettings.version) || '0.1.1'; - const cDisplay = installedCommit ? installedCommit.slice(0, 7) : 'неизвестно'; - if (updateInfoDesc) { - updateInfoDesc.textContent = `Hermes Hub v${curVer} (сборка: ${cDisplay})`; - } - - if (statusBadge) { - if (latestUpdateInfo && latestUpdateInfo.error) { - statusBadge.textContent = 'Ошибка проверки'; - statusBadge.className = 'badge badge-status warning'; - statusBadge.title = latestUpdateInfo.error; - } else if (latestUpdateInfo && latestUpdateInfo.update_available) { - statusBadge.textContent = 'Доступно обновление'; - statusBadge.className = 'badge badge-status warning'; - statusBadge.title = ''; - } else if (latestUpdateInfo && latestUpdateInfo.checked_at > 0) { - statusBadge.textContent = 'Актуально'; - statusBadge.className = 'badge healthy'; - statusBadge.title = ''; - } else { - statusBadge.textContent = 'Не проверялось'; - statusBadge.className = 'badge'; - statusBadge.title = ''; - } - } - - if (lastCheckedDesc) { - if (latestUpdateInfo && latestUpdateInfo.checked_at > 0) { - const tStr = new Date(latestUpdateInfo.checked_at * 1000).toLocaleTimeString('ru-RU'); - const errNote = latestUpdateInfo.error ? ` — Ошибка: ${latestUpdateInfo.error}` : ''; - lastCheckedDesc.textContent = `Последняя проверка: сегодня в ${tStr}${errNote}`; - } else { - lastCheckedDesc.textContent = 'Последняя проверка: еще не выполнялась'; - } - } - - if (btnApply) { - btnApply.disabled = !(latestUpdateInfo && latestUpdateInfo.update_available); - } - - if (detailsBlock && releaseTitle && releaseMeta && releaseNotes) { - if (latestUpdateInfo && latestUpdateInfo.update_available) { - detailsBlock.classList.remove('hidden'); - const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : '—'; - releaseTitle.textContent = `Релиз: ${latestUpdateInfo.release_tag || latestUpdateInfo.latest_version || 'Новая сборка'} (коммит: ${latC})`; - releaseMeta.textContent = latestUpdateInfo.published_at ? `Опубликован: ${latestUpdateInfo.published_at}` : ''; - releaseNotes.textContent = latestUpdateInfo.changelog || latestUpdateInfo.release_notes || 'Описание изменений отсутствует.'; - } else { - detailsBlock.classList.add('hidden'); - } - } -} - -function openUpdateModal() { - if (!latestUpdateInfo) { - checkUpdates(false); - return; - } - - const instC = (latestUpdateInfo.installed_commit && latestUpdateInfo.installed_commit !== 'unknown') - ? latestUpdateInfo.installed_commit.slice(0, 7) - : 'неизвестно'; - const latC = latestUpdateInfo.latest_commit ? latestUpdateInfo.latest_commit.slice(0, 7) : (latestUpdateInfo.release_tag || '—'); - - if (elements.modalTitle) elements.modalTitle.textContent = 'Обновление Hermes Hub'; - if (elements.modalBody) { - elements.modalBody.innerHTML = ` -
-
+ chain.forEach((pid, index) => { + const prof = profiles[pid] || {}; + const prov = prof.provider || 'unknown'; + const icon = getProviderIcon(prov); + + rolesHtml += ` + - `; - } - if (elements.modalFooter) { - elements.modalFooter.innerHTML = ` - - - `; - } - showModal(); -} - -async function handleInstallUpdateFromModal() { - const btn = document.getElementById('btn-modal-install-update'); - if (btn) { - btn.disabled = true; - btn.textContent = 'Установка...'; - } - await applyUpdate(); - closeModal(); -} - -async function applyUpdate() { - showToast('Загрузка и запуск обновления...', 'info'); - try { - const res = await executeAction('apply_update', {}); - if (res.ok) { - showToast(res.message || 'Обновление запущено успешно!', 'success'); - } else { - showToast(res.message || 'Ошибка установки обновления', 'error'); - } - } catch (err) { - showToast(`Ошибка установки: ${err.message}`, 'error'); - } -} - -// ── MODALS (Account Details, Model Choice, Routing, Wizard) ── -function openAccountDetailsModal(profileId) { - _openAccountModalProfile = 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 = ` -
- -
- - -
-
- `; - } 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}` : 'Без отметки сброса')} + 26 авг.,
18:42 +
+
+ ● Активен +
+
+
- `).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 = ``; - if (currentSnapshot && currentSnapshot.all_profiles && currentSnapshot.all_profiles[profileId]) { - currentSnapshot.all_profiles[profileId].preferred_models = [model]; - } - } else { - feedbackArea.innerHTML = ``; - } - } -} - -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 ? ` -
- - -
- ` : ` -
-
- ⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}. + rolesHtml += `
- -
- `} - `; - - 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 = ``; - 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 = ``; - } - } -} - -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 = ``; - } else { - feedbackArea.innerHTML = ``; - } - } -} - -// ── Add Account Wizard (P0-5 Headless Server Honesty) ── -function openAddAccountWizard() { - elements.modalTitle.textContent = 'Мастер подключения учетной записи'; - showWizardStep1(); - showModal(); -} - -function showWizardStep1() { - elements.modalBody.innerHTML = ` -
- Шаг 1 из 3: Выберите провайдера ИИ -
-
- - - - - - -
- `; - elements.modalFooter.innerHTML = ` - - `; -} - -function showWizardStep2(providerId) { - let bodyHtml = ''; - - if (providerId === 'grok' || providerId === 'openai-codex') { - // Поток кода устройства проведён через веб-API. Адрес и код приходят от - // ПРОВАЙДЕРА и подставляются сюда; ничего не вписано в код. Раньше здесь - // стояли выдуманные GRK-7842 и CDX-9104 при жёстко вписанном адресе. - const providerName = providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex'; - bodyHtml = ` -
- Шаг 2 из 3: Авторизация ${providerName} по коду устройства -
-
-
Запрашиваем код у провайдера…
`; - setTimeout(() => startDeviceAuth(providerId), 0); - } else if (providerId === 'antigravity' || providerId === 'claude') { - // Раньше здесь стояла заглушка: «авторизация через веб-интерфейс - // невозможна», со ссылкой на SSH и перенос каталога профилей. Это было - // неверно — сервер умеет принять вставленное вручную значение, поэтому - // браузер нужен ГДЕ УГОДНО, а не на машине с Hub. - const providerName = providerId === 'antigravity' ? 'Google Antigravity' : 'Claude'; - bodyHtml = ` -
- Шаг 2 из 3: Авторизация ${providerName} -
-
- - -
- Вход в занятый слот заменит учётные данные, которые в нём сейчас. + } + 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 += ` + -
- -
-
-
Выберите слот и нажмите «Получить ссылку».
-
- `; - } else if (providerId === 'local' || providerId === 'local-llm' || providerId === 'llama.cpp' || providerId === 'ollama' || providerId === 'vllm') { - bodyHtml = ` -
- Шаг 2 из 3: Настройка локального сервера (Local LLM) -
-
- - -
-
- - +
+
+
+
`; + } + 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 { - bodyHtml = ` -
- Шаг 2 из 3: Ввод API ключа ${providerId} -
-
- - -
- `; - } - - elements.modalBody.innerHTML = ` - - ${bodyHtml} - `; - - elements.modalFooter.innerHTML = ` - - - `; -} - -function proceedToWizardStep3(providerId) { - const baseInput = document.getElementById('wiz-base-url-input'); - if (baseInput) { - window._wiz_base_url = baseInput.value.trim(); - } - const tokenInput = document.getElementById('wiz-token-input'); - if (tokenInput) { - window._wiz_token = tokenInput.value.trim(); - } - showWizardStep3(providerId); -} - -function showWizardStep3(providerId) { - elements.modalBody.innerHTML = ` - -
- Шаг 3 из 3: Назначение роли для нового аккаунта -
-
- - -
- `; - - elements.modalFooter.innerHTML = ` - - - `; -} - -async function finishAddAccount(providerId) { - const roleSelect = document.getElementById('wiz-target-role'); - const targetRole = roleSelect ? roleSelect.value : 'coder-primary'; - - const feedbackArea = document.getElementById('modal-feedback-area'); - if (feedbackArea) { - feedbackArea.innerHTML = ''; - } - - const payload = { - provider: providerId, - target_role: targetRole, - }; - if (window._wiz_base_url) { - payload.base_url = window._wiz_base_url; - } - if (window._wiz_token) { - payload.token = window._wiz_token; - } - - const res = await executeAction('add_account', payload); - - if (res.ok) { - showToast('Аккаунт успешно добавлен в маршрутизацию', 'success'); - closeModal(); - fetchSnapshot(); - } else { - if (feedbackArea) { - feedbackArea.innerHTML = ``; + if (targetChain.includes(pid)) { + showToast(`Аккаунт ${pid} уже есть в роли ${targetRole}`, 'warning'); + return; } + if (insertIndex === -1) { + targetChain.push(pid); + } else { + targetChain.splice(insertIndex, 0, pid); + } + updateRoleChain(targetRole, targetChain); } } -// ── Routing Drag & Drop Reordering (P0-1, P0-2) ── -function handleNodeDragStart(e, roleId, index) { - currentDragState = { roleId, fromIndex: index }; - e.dataTransfer.effectAllowed = 'move'; +async function updateRoleChain(roleId, newChain) { try { - e.dataTransfer.setData('text/plain', JSON.stringify(currentDragState)); - } catch (err) { - // fallback - } - const chip = e.currentTarget; - if (chip) { - chip.classList.add('dragging'); + 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'); } } -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(); +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); } - fetchSnapshot(); - } else { - showToast(res.message || 'Ошибка сохранения цепочки', 'error'); - } -} - -// ── Routing Node Model, Account & Chain Management ── -async function handleNodeAccountChange(roleId, profileId, isPrimary = true) { - if (!roleId || !profileId) return; - showToast(`Назначение аккаунта '${profileId}' на роль '${roleId}'...`, 'info'); - const res = await executeAction('assign_role', { - role_id: roleId, - profile_id: profileId, - is_primary: isPrimary, - }); - if (res.ok) { - showToast(`Аккаунт '${profileId}' успешно назначен`, 'success'); - fetchSnapshot(); - } else { - showToast(res.message || 'Ошибка назначения аккаунта', 'error'); - } -} - -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) { diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html index d9cf4a5..9ab026a 100644 --- a/src/antigravity_provider/router/web/static/index.html +++ b/src/antigravity_provider/router/web/static/index.html @@ -177,14 +177,48 @@ -
-
- Главный центр управления маршрутизацией: перетаскивайте узлы для смены приоритета (Основной → Резерв 1 → Резерв 2), настраивайте рабочие модели и управляйте составом цепочек. -
-
- -
-
+ +
+
+
+ + Перетащите аккаунт из правой панели в нужную роль и установите порядок (приоритет) использования. +
+ Система будет использовать аккаунты сверху вниз по списку при выполнении задач с учётом квот и состояния. +
+
+ +
+ +
+ +
+ + +
+
+
+

Доступные аккаунты

+ 0 аккаунтов +
+
+ +
+
+ + +
+
+
+ +
+
+ Перетащите аккаунт в нужную роль или нажмите «+» для добавления +
+
+
+
+
@@ -505,5 +539,16 @@ + + + diff --git a/src/antigravity_provider/router/web/static/style.css b/src/antigravity_provider/router/web/static/style.css index c5b89cf..12db823 100644 --- a/src/antigravity_provider/router/web/static/style.css +++ b/src/antigravity_provider/router/web/static/style.css @@ -1,33 +1,7 @@ -/* Hermes Hub Web Client — Dark Theme & Cockpit Design System */ +/* Theme definitions according to A29 Design System "Крона" */ :root { - --bg-base: #061916; - --bg-sidebar: #08221E; - --bg-header: #071B18; - --bg-statusbar: #061512; - --bg-modal-backdrop: rgba(2, 11, 9, 0.85); - - --surface: #0B2520; - --surface-hover: #12342C; - --surface-active: #194535; - --surface-selected: #173B2E; - --surface-muted: #091E1A; - - --border: #36513B; - --border-subtle: #203A2D; - --border-accent: #B78525; - --border-hover: #537456; - - --text-primary: #F8F0DC; - --text-secondary: #D1C7AE; - --text-muted: #8FA395; - --text-accent: #E0B84E; - - --accent: #C89A2B; - --accent-hover: #E0B84E; - --accent-pressed: #A9781E; - --accent-dim: rgba(200, 154, 43, 0.15); - + /* Common variables */ --status-healthy: #72C943; --status-warning: #E1A62B; --status-error: #E45C4F; @@ -44,14 +18,107 @@ --radius-md: 6px; --radius-lg: 8px; - --font-ui: "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, sans-serif; - --font-title: "Cinzel", "Segoe UI", serif, sans-serif; + --font-ui: 'Inter', "Segoe UI", -apple-system, sans-serif; + --font-title: "Cinzel", "Cinzel Decorative", "Segoe UI", serif; --font-mono: "Consolas", "Courier New", monospace; --header-height: 58px; --sidebar-width: 230px; } +/* Default DARK theme */ +:root, body[data-theme="dark"] { + --bg-base: #101510; + --bg-sidebar: #0D120D; + --bg-header: #0D120D; + --bg-statusbar: #0D120D; + --bg-modal-backdrop: rgba(16, 21, 16, 0.85); + + --surface: #1A2A1F; + --surface-hover: #2F4A36; + --surface-active: #3C5C44; + --surface-selected: #28402F; + --surface-muted: #152219; + + --border: #36513B; + --border-subtle: #203A2D; + --border-accent: #CDAA64; + --border-hover: #537456; + + --text-primary: #F7F1E3; + --text-secondary: #B0B8B2; + --text-muted: #8FA395; + --text-accent: #CDAA64; + + --accent: #CDAA64; + --accent-hover: #E0B84E; + --accent-pressed: #A9781E; + --accent-dim: rgba(205, 170, 100, 0.15); +} + +/* MEDIUM theme (cream cards on green canvas) */ +body[data-theme="medium"] { + --bg-base: #1A2A1F; + --bg-sidebar: #152219; + --bg-header: #152219; + --bg-statusbar: #152219; + --bg-modal-backdrop: rgba(26, 42, 31, 0.85); + + --surface: #F7F1E3; + --surface-hover: #F0EAD6; + --surface-active: #E6DFCB; + --surface-selected: #EAE3CF; + --surface-muted: #2F4A36; + + --border: #D1C7AE; + --border-subtle: #E0D7BF; + --border-accent: #CDAA64; + --border-hover: #B7A88D; + + --text-primary: #101510; + --text-secondary: #2F4A36; + --text-muted: #537456; + --text-accent: #B78525; + + --accent: #B78525; + --accent-hover: #CDAA64; + --accent-pressed: #9A6F1D; + --accent-dim: rgba(183, 133, 37, 0.15); +} + +/* LIGHT theme */ +body[data-theme="light"] { + --bg-base: #F7F1E3; + --bg-sidebar: #F0EAD6; + --bg-header: #F0EAD6; + --bg-statusbar: #E6DFCB; + --bg-modal-backdrop: rgba(247, 241, 227, 0.85); + + --surface: #FFFFFF; + --surface-hover: #FDFBF7; + --surface-active: #F4EEDF; + --surface-selected: #FAF7F0; + --surface-muted: #F0EAD6; + + --border: #D1C7AE; + --border-subtle: #EAE3CF; + --border-accent: #CDAA64; + --border-hover: #C5BAA1; + + --text-primary: #101510; + --text-secondary: #2F4A36; + --text-muted: #537456; + --text-accent: #B78525; + + --accent: #B78525; + --accent-hover: #CDAA64; + --accent-pressed: #9A6F1D; + --accent-dim: rgba(183, 133, 37, 0.15); +} +/* Hermes Hub Web Client — Dark Theme & Cockpit Design System */ + + + * { box-sizing: border-box; margin: 0; @@ -1559,20 +1626,114 @@ body { } /* ── Light Theme Override ── */ -body[data-theme="light"], body.theme-light { - --bg-base: #F4F6F8; - --bg-sidebar: #E9ECEF; - --bg-header: #FFFFFF; - --bg-statusbar: #DEE2E6; - --surface: #FFFFFF; - --surface-hover: #F1F3F5; - --surface-active: #E9ECEF; - --surface-selected: #E2E6EA; - --surface-muted: #F8F9FA; - --border: #CED4DA; - --border-subtle: #DEE2E6; - --border-accent: #B78525; - --text-primary: #212529; - --text-secondary: #495057; - --text-muted: #6C757D; + + +/* Drag and Drop Styles */ +.draggable-item { + cursor: grab; + user-select: none; +} +.draggable-item:active { + cursor: grabbing; +} +.drop-zone { + min-height: 40px; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + font-size: 11px; + color: var(--text-muted); + transition: all 0.2s; + margin-top: 8px; +} +.drop-zone.drag-over { + border-color: var(--accent); + background-color: var(--accent-dim); + color: var(--accent); +} +.role-section { + background-color: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + margin-bottom: 16px; + display: flex; + flex-direction: column; +} +.role-header { + padding: 12px 16px; + border-bottom: 1px solid var(--border-subtle); + display: flex; + justify-content: space-between; + align-items: center; +} +.role-header h4 { + font-family: var(--font-title); + color: var(--text-primary); + font-size: 14px; + margin: 0; + display: flex; + align-items: center; + gap: 8px; +} +.role-badge { + font-size: 10px; + background-color: var(--accent-dim); + color: var(--accent); + padding: 2px 6px; + border-radius: 4px; + font-family: var(--font-ui); +} +.account-row { + display: grid; + grid-template-columns: 24px 20px 200px 120px 120px 150px 80px 80px 24px; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border-subtle); + background-color: var(--surface); + font-size: 12px; +} +.account-row:last-child { + border-bottom: none; +} +.account-row.drag-over { + border-top: 2px solid var(--accent); +} +.drag-handle { + color: var(--text-muted); + cursor: grab; + text-align: center; +} +.provider-logo-sm { + width: 16px; + height: 16px; + border-radius: 2px; +} +.available-account-card { + background-color: var(--surface); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 10px; + display: flex; + align-items: center; + gap: 10px; + cursor: grab; +} +.available-account-card:hover { + border-color: var(--border); + background-color: var(--surface-hover); +} +.grid-header { + display: grid; + grid-template-columns: 24px 20px 200px 120px 120px 150px 80px 80px 24px; + align-items: center; + gap: 8px; + padding: 8px 12px; + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + border-bottom: 1px solid var(--border-subtle); + background-color: var(--surface-muted); }