diff --git a/docs/web-api/CONTRACT.md b/docs/web-api/CONTRACT.md index 6a16091..b3f6482 100644 --- a/docs/web-api/CONTRACT.md +++ b/docs/web-api/CONTRACT.md @@ -1,6 +1,6 @@ # Контракт веб-интерфейса Hermes Hub -Версия контракта: **1.3** +Версия контракта: **1.4** Дата: 2026-08-24 Этот документ — **единственный** источник истины для двух сторон: серверной (задание A15) и клиентской (A16). Обе стороны разрабатываются параллельно и до слияния друг друга не видят. @@ -19,11 +19,20 @@ Обоснование, а не вкусовщина. Проект ведут агенты на трёх разных машинах; любой шаг сборки означает согласование версий Node, установку зависимостей и дрейф lock-файлов между машинами. Интерфейс и так был единственным узким местом всех прошлых раундов. Страница, которую можно открыть файлом и отладить в браузере без инструментов, снимает целый класс проблем. React в этом проекте отклонён и раньше — по той же причине. -## 2. Границы +## 2. Границы и структура разделов Веб-интерфейс **дополняет** десктоп, а не заменяет его немедленно. Десктопное приложение обязано продолжать работать до тех пор, пока веб не достигнет паритета. Ничего из `router/ui/**` не удаляется в рамках этих заданий. -Веб-слой живёт в новом пакете: +Веб-клиент организован в **7 функциональных разделов** (в порядке меню навигации): +1. **`overview` (Обзор)** — Сводные счетчики слотов и подключений провайдеров, схема маршрутизации и выбор моделей ролей прямо на схеме. +2. **`accounts` (Аккаунты)** — Список подключенных профилей, статусы авторизации и оперативные квоты. +3. **`routing` (Маршрутизация)** — Главный центр управления: перетаскивание узлов Drag-and-Drop, выбор модели на узле, добавление (+ Добавить) и удаление (✕) профилей из цепочек. +4. **`analytics` (Аналитика)** — Телеметрия вызовов и задержек за 24ч (p50/p95/max), пояснение fast-fail и честное отображение неподключенных провайдеров. +5. **`health` (Состояние)** — Готовность системы, мониторинг системных ресурсов и рисков. +6. **`logs` (Журнал событий)** — Обратный хронологический журнал событий Hermes Hub. +7. **`settings` (Настройки)** — Параметры веб-сервера, токены, интервалы и системные пути. + +Веб-слой живёт в пакете: ``` src/antigravity_provider/router/web/ @@ -79,14 +88,15 @@ readiness, agents, providers, routing, quotas, metrics, is_stale { "action": "<имя>", "data": { ... } } ``` -Имена действий берутся **ровно** из общего слоя `action_handler.py`. Их девятнадцать: +Имена действий берутся **ровно** из общего слоя `action_handler.py`: ``` account_details add_account agent_settings assign_role auto_assign_all check_updates delete_credentials edit_route oauth open_routing refresh_account refresh_all -refresh_data refresh_models save_settings set_main -set_model set_orchestrator test +refresh_data refresh_models reorder_chain save_chain +save_settings set_main set_model set_orchestrator +test ``` Ответ: @@ -98,10 +108,10 @@ set_model set_orchestrator test **`ok: false` — это `200`, а не `4xx`.** Отказ действия — нормальный результат, а не ошибка протокола. `4xx` остаётся для неизвестного действия и непройденной авторизации. -Действия `open_routing` и `account_details` в вебе — навигация, состояние держит клиент; сервер на них отвечает `ok: true` без побочных эффектов. - - -**`refresh_models`** (добавлено в A23): принудительное обновление списка моделей провайдера. `data: {"provider": ""}`. Обнаружение ходит в сеть и подпроцесс — действие возвращается сразу, результат приходит следующим снапшотом. Замерено: `agy models` отвечает за десятки секунд, иногда виснет дольше двух минут; при таймауте прежний кэш не затирается. +- **`save_chain`** / **`reorder_chain`** / **`edit_route`** (добавлено/расширено в A24): сохранение упорядоченной цепочки профилей для роли маршрутизатора. `data: {"role_id": "", "chain": ["", "", ...]}`. Сохраняет конфигурацию в `router_profiles.yaml` через `AutoAssigner.persist_role_chain`. +- **`assign_role`**: назначение профиля на роль. `data: {"profile_id": "", "role_id": "", "is_primary": true|false}`. Выполняется через `AutoAssigner.assign_profile_to_role`. +- **`open_routing`** и **`account_details`** в вебе — навигация, состояние держит клиент; сервер на них отвечает `ok: true` без побочных эффектов. +- **`refresh_models`** (добавлено в A23): принудительное обновление списка моделей провайдера. `data: {"provider": ""}`. Обнаружение ходит в сеть и подпроцесс — действие возвращается сразу, результат приходит следующим снапшотом. ### `GET /api/health` diff --git a/src/antigravity_provider/router/action_handler.py b/src/antigravity_provider/router/action_handler.py index c6c7e78..3276dba 100644 --- a/src/antigravity_provider/router/action_handler.py +++ b/src/antigravity_provider/router/action_handler.py @@ -240,10 +240,27 @@ class ActionExecutor: prov = data.get('provider', '') # Purely UI navigation actions return True for Web API (no-op on server side). - if action in ['oauth', 'add_account', 'account_details', 'agent_settings', 'edit_route', 'open_routing', 'assign_role']: + if action in ['oauth', 'add_account', 'account_details', 'agent_settings', 'open_routing']: return {'ok': True, 'message': 'Навигация'} - - if action == 'set_main': + + if action in ['save_chain', 'reorder_chain', 'edit_route']: + role_id = data.get('role_id') or data.get('role') or data.get('role_name', '') + chain = data.get('chain') or data.get('desired_chain') or data.get('preferred_chain') or data.get('nodes') or [] + if isinstance(chain, str): + chain = [p.strip() for p in chain.split(',') if p.strip()] + ok, msg = AutoAssigner.persist_role_chain(role_id, list(chain)) + return {'ok': ok, 'message': msg} + + elif action == 'assign_role': + target_role = data.get('role_id') or data.get('target_role') or data.get('role') or data.get('role_name', '') + target_pid = pid or data.get('profile_id') or data.get('profile', '') + is_primary = data.get('is_primary', True) + ok, msg = AutoAssigner.assign_profile_to_role(target_pid, target_role, is_primary=bool(is_primary)) + if ok: + EventLogService.get().log('routing', f'Профиль {target_pid} назначен на роль {target_role}.', level='info') + return {'ok': ok, 'message': msg} + + elif action == 'set_main': ok, msg = do_set_main(prov, pid) return {'ok': ok, 'message': msg} @@ -252,11 +269,11 @@ class ActionExecutor: role_id = data.get('role_id', '') ok, msg = do_set_model(pid, model_name, role_id=role_id) return {'ok': ok, 'message': msg} - + elif action == 'set_orchestrator': ok, msg = do_set_orchestrator(pid) return {'ok': ok, 'message': msg} - + elif action == 'test': if async_runner: async_runner(lambda: do_test_profile(prov, pid), 'TestProfile') diff --git a/src/antigravity_provider/router/auto_assigner.py b/src/antigravity_provider/router/auto_assigner.py index 55f779f..f27adfe 100644 --- a/src/antigravity_provider/router/auto_assigner.py +++ b/src/antigravity_provider/router/auto_assigner.py @@ -397,3 +397,44 @@ class AutoAssigner: def set_primary_orchestrator(profile_id: str) -> Tuple[bool, str]: """Designate a profile as the primary orchestrator and adjust fallback chains.""" return AutoAssigner.assign_profile_to_role(profile_id, "orchestrator", is_primary=True) + + @staticmethod + def persist_role_chain(role_id: str, desired_chain: List[str]) -> Tuple[bool, str]: + """Persist a custom role chain order into router configuration.""" + if not role_id or not isinstance(role_id, str): + return False, "Не указана роль для обновления цепочки" + + config = load_router_config() + clean_role = role_id.strip().lower() + canonical_role = role_id.strip() if role_id in config.roles else CANONICAL_ROLE_MAP.get(clean_role, role_id.strip()) + + if canonical_role not in config.roles: + return False, f"Неизвестная роль маршрутизатора: '{role_id}'" + + if len(desired_chain) != len(set(desired_chain)): + return False, "Профиль не может повторяться в одной цепочке" + + missing = [pid for pid in desired_chain if pid not in config.profiles] + if missing: + return False, f"Профиль '{missing[0]}' не найден в конфигурации" + + policy = config.roles[canonical_role] + policy.preferred_chain = list(desired_chain) + config.roles[canonical_role] = policy + + if not save_router_config(config): + return False, f"Не удалось сохранить цепочку роли '{canonical_role}' в конфигурации" + + try: + from antigravity_provider.router.state_store import HubStateStore + HubStateStore.get().refresh(force_scan=True) + except Exception: + pass + + from antigravity_provider.router.unified_health import EventLogService + EventLogService.get().log( + "routing", + f"Для роли '{canonical_role}' сохранена новая цепочка: {list(desired_chain)}.", + level="info", + ) + return True, f"Цепочка роли '{canonical_role}' успешно сохранена: {', '.join(desired_chain)}" diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js index 55214f3..617768f 100644 --- a/src/antigravity_provider/router/web/static/app.js +++ b/src/antigravity_provider/router/web/static/app.js @@ -9,12 +9,33 @@ const USE_MOCK_FIXTURE = false; let lastAppliedSeq = -1; let currentSnapshot = null; -let activeView = 'accounts'; +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 = { @@ -70,11 +91,9 @@ function switchView(viewName) { }); const titles = { - accounts: 'Аккаунты и квоты', overview: 'Обзор системы', - routing: 'Маршрутизация запросов', - providers: 'Модели и провайдеры', - team: 'Команда агентов', + accounts: 'Аккаунты и квоты', + routing: 'Главный экран управления маршрутизацией', analytics: 'Аналитика и телеметрия', health: 'Состояние и диагностика', logs: 'Журнал событий', @@ -325,21 +344,15 @@ function updateGlobalHeader() { function renderCurrentView() { if (!currentSnapshot) return; switch (activeView) { - case 'accounts': - renderAccountsView(); - break; case 'overview': renderOverviewView(); break; + case 'accounts': + renderAccountsView(); + break; case 'routing': renderRoutingView(); break; - case 'providers': - renderProvidersView(); - break; - case 'team': - renderTeamView(); - break; case 'analytics': renderAnalyticsView(); break; @@ -540,11 +553,65 @@ function renderQuotaCell(bucket, unavailableReason) { } // ═══════════════════════════════════════════════════════════════ -// 2. OVERVIEW VIEW +// 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 || {}; @@ -556,16 +623,41 @@ function renderOverviewView() { diagramHtml += `
${escapeHtml(pipeline.role_name_ru || roleId)}
- ${nodes.map((node, idx) => ` -
-
- ${idx === 0 ? '★ Основной' : `Резерв ${idx}`} - ${node.is_active ? '● Активен' : 'Ожидание'} + ${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 = ` + + `; + } 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}
-
${escapeHtml(node.display_name || node.profile_id)}
-
${escapeHtml(node.provider)} • ${escapeHtml(node.model)}
-
- `).join('')} + `; + }).join('') || '
Цепочка не задана
'}
`; } @@ -574,12 +666,13 @@ function renderOverviewView() { const provSummaryBox = document.getElementById('overview-providers-summary'); if (provSummaryBox) { - const providers = currentSnapshot.providers || []; provSummaryBox.innerHTML = providers.map((prov) => `
${escapeHtml(prov.provider_name || prov.provider_id)}
- Онлайн: ${prov.online_count}/${prov.connected_count} • + Всего слотов: ${prov.total_slots} • + Подключено: ${prov.connected_count} • + Онлайн: ${prov.online_count} • Требуют входа: ${prov.auth_required_count} • Холодный резерв: ${prov.cold_spare_count}
@@ -592,45 +685,96 @@ function renderOverviewView() { } // ═══════════════════════════════════════════════════════════════ -// 3. ROUTING VIEW +// 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 += ` -
+
-
${escapeHtml(pipeline.role_name_ru || roleId)}
-
- Модель по умолчанию: ${escapeHtml(pipeline.default_model || '—')} • - ${pipeline.session_affinity ? 'Session Affinity включена' : 'Без affinity'} +
+ ${escapeHtml(pipeline.role_name_ru || roleId)} + ${quotaLabel ? `Квота: ${escapeHtml(quotaLabel)}` : ''} + ${pipeline.session_affinity ? 'Session Affinity' : 'Без affinity'}
+ ${roleDesc ? `
${escapeHtml(roleDesc)}
` : ''} +
+
+
-
-
- ${nodes.map((node, index) => ` -
-
- ${index === 0 ? 'Основной' : `Резерв ${index}`} - ${node.is_active ? '● АКТИВЕН' : ''} +
+ ${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)}
` : ''}
-
${escapeHtml(node.display_name || node.profile_id)}
-
${escapeHtml(node.provider)} • ${escapeHtml(node.model)}
- ${node.failover_reason ? `
⚠ ${escapeHtml(node.failover_reason)}
` : ''} -
- `).join('') || '
Цепочка не настроена.
'} + `; + }).join('') || '
Цепочка не настроена. Нажмите «+ Добавить профиль».
'}
`; @@ -640,84 +784,7 @@ function renderRoutingView() { } // ═══════════════════════════════════════════════════════════════ -// 4. PROVIDERS & MODELS VIEW -// ═══════════════════════════════════════════════════════════════ -function renderProvidersView() { - const container = document.getElementById('providers-full-container'); - if (!container || !currentSnapshot) return; - - const providers = currentSnapshot.providers || []; - container.innerHTML = providers.map((prov) => ` -
-
-
-
${escapeHtml(prov.provider_name || prov.provider_id)}
-
- Всего слотов: ${prov.total_slots} • Подключено: ${prov.connected_count} • Онлайн: ${prov.online_count} -
-
- -
- -
-
- Обнаруженные модели: -
-
- ${(prov.discovered_models && prov.discovered_models.length > 0) - ? prov.discovered_models.map(m => ``).join('') - : 'Н/Д — список моделей ещё не получен от провайдера' - } -
-
-
- `).join('') || '
Нет данных провайдеров.
'; -} - -// ═══════════════════════════════════════════════════════════════ -// 5. TEAM VIEW -// ═══════════════════════════════════════════════════════════════ -function renderTeamView() { - const container = document.getElementById('team-cards-container'); - if (!container || !currentSnapshot) return; - - const agents = currentSnapshot.agents || []; - container.innerHTML = agents.map((ag) => ` -
-
-
-
${escapeHtml(ag.role_name_ru || ag.role_id)}
-
${escapeHtml(ag.role_description_ru || '')}
-
- - ${ag.is_active ? '● АКТИВЕН' : 'ОЖИДАНИЕ'} - -
- -
-
Назначенный аккаунт:
-
${escapeHtml(ag.assigned_display_name || ag.assigned_profile_id || 'Не назначен')}
-
- Провайдер: ${escapeHtml(ag.provider_display_name || ag.provider)} • Модель: ${escapeHtml(ag.model || 'default')} -
-
- - -
- `).join('') || '
Список агентов пуст.
'; -} - -// ═══════════════════════════════════════════════════════════════ -// 6. ANALYTICS VIEW (P0-1 Real Telemetry & Honesty) +// 3. ANALYTICS VIEW (P0-1, P0-5 Telemetry & Honesty) // ═══════════════════════════════════════════════════════════════ function renderAnalyticsView() { if (!currentSnapshot) return; @@ -725,11 +792,11 @@ function renderAnalyticsView() { const telemetry = metrics.telemetry || {}; const global = telemetry.global || {}; - // KPI 1: Total Calls + // 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 : 'Н/Д'; + totalCallsEl.textContent = (global.total_calls !== null && global.total_calls !== undefined) ? global.total_calls : 'Н/Д'; } if (callsBreakdownEl) { const succ = global.successful_calls ?? 0; @@ -737,7 +804,7 @@ function renderAnalyticsView() { callsBreakdownEl.textContent = `Успешно: ${succ} • Сбоев: ${fail} (окно: 24ч)`; } - // KPI 2: Error Rate + // KPI 2: Error Rate (24h) const errorRateEl = document.getElementById('analytics-error-rate'); const errorRateSubEl = document.getElementById('analytics-error-rate-sub'); if (errorRateEl) { @@ -751,10 +818,12 @@ function renderAnalyticsView() { } } if (errorRateSubEl) { - errorRateSubEl.textContent = global.failed_calls ? `${global.failed_calls} отказов из ${global.total_calls || 0} вызовов` : 'Отказов не зафиксировано'; + 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 + // KPI 3: Latency (24h) with Fast-fail Explanation const latencyEl = document.getElementById('analytics-latency-p50'); const latencySubEl = document.getElementById('analytics-latency-sub'); if (latencyEl) { @@ -771,7 +840,13 @@ function renderAnalyticsView() { 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`) : 'Н/Д'; - latencySubEl.textContent = `p95: ${p95Str} • max: ${maxStr}`; + + 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) @@ -787,23 +862,44 @@ function renderAnalyticsView() { } if (tokensSubEl) { if (hasTokens) { - tokensSubEl.textContent = 'Учитывается провайдером'; + tokensSubEl.textContent = 'Учитывается провайдером (24ч)'; } else { tokensSubEl.textContent = 'Н/Д: провайдеры не отдают данные о токенах'; } } - // Providers Table + // Providers Table (P0-5 Honesty: Unconnected providers labeled "Не подключён") const provTableBox = document.getElementById('analytics-providers-table'); if (provTableBox) { const byProv = telemetry.by_provider || {}; - const provKeys = Object.keys(byProv); - if (provKeys.length === 0) { + 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 = provKeys.map((pId) => { + const rowsHtml = allKnownProvIds.map((pId) => { const pData = byProv[pId] || {}; - const errPct = pData.error_rate != null ? (pData.error_rate * 100).toFixed(1) : 'Н/Д'; + 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`) @@ -813,13 +909,13 @@ function renderAnalyticsView() { return ` - ${escapeHtml(pId)} - ${pData.total_calls ?? 'Н/Д'} + ${escapeHtml(provName)} (${escapeHtml(pId)}) + ${pData.total_calls ?? 0} ${pData.successful_calls ?? 0} ${pData.failed_calls ?? 0}
- ${errPct}${errPct !== 'Н/Д' ? '%' : ''} + ${errPct}%
@@ -875,7 +971,7 @@ function renderAnalyticsView() { return ` ${escapeHtml(rName)} (${escapeHtml(rId)}) - ${rData.total_calls ?? 'Н/Д'} + ${rData.total_calls ?? 0} ${(rData.total_calls ?? 0) - (rData.failed_calls ?? 0)} ${rData.failed_calls ?? 0} ${errPct}% @@ -1589,83 +1685,182 @@ async function finishAddAccount(providerId) { } } -// ── Routing Pipeline Modal ── -function openEditRouteModal(roleId) { +// ── 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 nodes = [...(pipeline.nodes || [])]; + 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)); - function renderRows() { - return nodes.map((node, index) => ` -
-
- ${index + 1}. - ${escapeHtml(node.display_name || node.profile_id)} - (${escapeHtml(node.provider)}) -
-
- - - -
+ elements.modalTitle.textContent = `Добавить профиль в цепочку: ${pipeline.role_name_ru || roleId}`; + if (available.length === 0) { + elements.modalBody.innerHTML = ` +
Все зарегистрированные профили уже включены в эту цепочку.
+ `; + elements.modalFooter.innerHTML = ``; + } else { + elements.modalBody.innerHTML = ` + +
+ Выберите доступный профиль для включения в цепочку отказоустойчивости:
- `).join('') || '
Цепочка пуста.
'; +
+ +
+ `; + elements.modalFooter.innerHTML = ` + + + `; } - - window.activeRouteNodes = nodes; - - elements.modalTitle.textContent = `Цепочка маршрутизации: ${pipeline.role_name_ru || roleId}`; - elements.modalBody.innerHTML = ` - -
- Первый профиль — основной (Primary). Нижестоящие профили используются как резервы в порядке переключения. -
-
- ${renderRows()} -
- `; - - elements.modalFooter.innerHTML = ` - - - `; - showModal(); } -window.moveRouteNode = function(roleId, index, delta) { - const nodes = window.activeRouteNodes; - const target = index + delta; - if (target >= 0 && target < nodes.length) { - const temp = nodes[index]; - nodes[index] = nodes[target]; - nodes[target] = temp; - openEditRouteModal(roleId); - } -}; +async function handleAddNodeToChain(roleId) { + const sel = document.getElementById('add-node-profile-select'); + if (!sel) return; + const newProfileId = sel.value; + if (!newProfileId) return; -window.removeRouteNode = function(roleId, index) { - const nodes = window.activeRouteNodes; - nodes.splice(index, 1); - openEditRouteModal(roleId); -}; + const pipeline = (currentSnapshot.routing || {})[roleId]; + const currentChain = (pipeline?.nodes || []).map((n) => n.profile_id); + const newChain = [...currentChain, newProfileId]; -async function saveRouteChain(roleId) { - const chain = (window.activeRouteNodes || []).map((n) => n.profile_id); const feedbackArea = document.getElementById('modal-feedback-area'); if (feedbackArea) { - feedbackArea.innerHTML = ''; + feedbackArea.innerHTML = ''; } - const res = await executeAction('edit_route', { - role_id: roleId, - chain: chain, - }); - + const res = await executeAction('save_chain', { role_id: roleId, chain: newChain }); if (res.ok) { - showToast(`Цепочка '${roleId}' сохранена`, 'success'); + showToast(`Профиль добавлен в цепочку '${pipeline?.role_name_ru || roleId}'`, 'success'); closeModal(); fetchSnapshot(); } else { diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html index 74c8c72..20545b4 100644 --- a/src/antigravity_provider/router/web/static/index.html +++ b/src/antigravity_provider/router/web/static/index.html @@ -20,27 +20,19 @@