feat(web): routing control center with drag-and-drop, inline models, and 7-view navigation (A24)

This commit is contained in:
Hermes Team 2026-08-24 10:53:57 +07:00
parent bad24ff6aa
commit 965ef1272c
8 changed files with 931 additions and 326 deletions

View file

@ -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": "<id>"}`. Обнаружение ходит в сеть и подпроцесс — действие возвращается сразу, результат приходит следующим снапшотом. Замерено: `agy models` отвечает за десятки секунд, иногда виснет дольше двух минут; при таймауте прежний кэш не затирается.
- **`save_chain`** / **`reorder_chain`** / **`edit_route`** (добавлено/расширено в A24): сохранение упорядоченной цепочки профилей для роли маршрутизатора. `data: {"role_id": "<role_id>", "chain": ["<pid1>", "<pid2>", ...]}`. Сохраняет конфигурацию в `router_profiles.yaml` через `AutoAssigner.persist_role_chain`.
- **`assign_role`**: назначение профиля на роль. `data: {"profile_id": "<pid>", "role_id": "<role_id>", "is_primary": true|false}`. Выполняется через `AutoAssigner.assign_profile_to_role`.
- **`open_routing`** и **`account_details`** в вебе — навигация, состояние держит клиент; сервер на них отвечает `ok: true` без побочных эффектов.
- **`refresh_models`** (добавлено в A23): принудительное обновление списка моделей провайдера. `data: {"provider": "<id>"}`. Обнаружение ходит в сеть и подпроцесс — действие возвращается сразу, результат приходит следующим снапшотом.
### `GET /api/health`

View file

@ -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')

View file

@ -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)}"

View file

@ -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 += `
<div class="diagram-column">
<div class="diagram-column-header">${escapeHtml(pipeline.role_name_ru || roleId)}</div>
${nodes.map((node, idx) => `
<div class="diagram-node ${node.is_active ? 'active' : ''}">
<div style="display:flex; justify-content:space-between; font-weight:600; font-size:11px;">
<span>${idx === 0 ? '★ Основной' : `Резерв ${idx}`}</span>
<span class="${node.is_active ? 'text-healthy' : 'text-muted'}">${node.is_active ? '● Активен' : 'Ожидание'}</span>
${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 = `
<select class="diagram-model-select" title="Сменить рабочую модель" onchange="handleNodeModelChange('${escapeHtml(roleId)}', '${escapeHtml(node.profile_id)}', this.value)">
${discoveredModels.map((m) => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
</select>
`;
} else {
modelControlHtml = `
<div style="font-size:10px; color:var(--text-muted); display:flex; align-items:center; justify-content:space-between;">
<span>Список моделей ещё не получен</span>
<button class="btn btn-secondary btn-xs" onclick="handleRefreshProviderModels('${escapeHtml(provId)}')"></button>
</div>
`;
}
return `
<div class="diagram-node ${node.is_active ? 'active' : ''}">
<div style="display:flex; justify-content:space-between; font-weight:600; font-size:11px;">
<span>${idx === 0 ? '★ Основной' : `Резерв ${idx}`}</span>
<span class="${node.is_active ? 'text-healthy' : 'text-muted'}">${node.is_active ? '● Активен' : 'Ожидание'}</span>
</div>
<div style="font-size:12px; font-weight:700; margin-top:2px;">${escapeHtml(node.account_identity && node.account_identity !== 'Аккаунт не добавлен' ? node.account_identity : (node.display_name || node.profile_id))}</div>
<div style="font-size:10px; color:var(--text-secondary); margin-bottom:4px;">${escapeHtml(node.display_name || node.profile_id)} (${escapeHtml(node.provider)})</div>
${modelControlHtml}
</div>
<div style="font-size:12px; font-weight:700; margin-top:2px;">${escapeHtml(node.display_name || node.profile_id)}</div>
<div style="font-size:10px; color:var(--text-muted);">${escapeHtml(node.provider)} ${escapeHtml(node.model)}</div>
</div>
`).join('')}
`;
}).join('') || '<div class="empty-text">Цепочка не задана</div>'}
</div>
`;
}
@ -574,12 +666,13 @@ function renderOverviewView() {
const provSummaryBox = document.getElementById('overview-providers-summary');
if (provSummaryBox) {
const providers = currentSnapshot.providers || [];
provSummaryBox.innerHTML = providers.map((prov) => `
<div class="provider-summary-card">
<div class="provider-summary-title">${escapeHtml(prov.provider_name || prov.provider_id)}</div>
<div class="provider-summary-stats">
Онлайн: <strong>${prov.online_count}/${prov.connected_count}</strong>
Всего слотов: <strong>${prov.total_slots}</strong>
Подключено: <strong>${prov.connected_count}</strong>
Онлайн: <strong>${prov.online_count}</strong>
Требуют входа: <strong>${prov.auth_required_count}</strong>
Холодный резерв: <strong>${prov.cold_spare_count}</strong>
</div>
@ -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 += `
<div class="pipeline-card">
<div class="pipeline-card" data-role-id="${escapeHtml(roleId)}">
<div class="pipeline-header">
<div>
<div class="pipeline-title">${escapeHtml(pipeline.role_name_ru || roleId)}</div>
<div style="font-size:11px; color:var(--text-muted);">
Модель по умолчанию: <strong>${escapeHtml(pipeline.default_model || '—')}</strong>
${pipeline.session_affinity ? 'Session Affinity включена' : 'Без affinity'}
<div style="display:flex; align-items:center; gap:8px;">
<span class="pipeline-title">${escapeHtml(pipeline.role_name_ru || roleId)}</span>
${quotaLabel ? `<span class="badge badge-quota ${quotaStatus}" title="Оперативная квота активного профиля">Квота: ${escapeHtml(quotaLabel)}</span>` : ''}
<span class="badge badge-affinity">${pipeline.session_affinity ? 'Session Affinity' : 'Без affinity'}</span>
</div>
${roleDesc ? `<div class="pipeline-desc">${escapeHtml(roleDesc)}</div>` : ''}
</div>
<div class="pipeline-header-actions">
<button class="btn btn-secondary btn-sm" onclick="openAddNodeToChainModal('${escapeHtml(roleId)}')">
+ Добавить профиль
</button>
</div>
<button class="btn btn-secondary btn-sm" onclick="openEditRouteModal('${escapeHtml(roleId)}')">
Изменить цепочку
</button>
</div>
<div class="pipeline-chain-flow">
${nodes.map((node, index) => `
<div class="pipeline-node-chip ${node.is_active ? 'active' : ''}">
<div style="display:flex; justify-content:space-between; font-size:10px;">
<strong style="color:var(--text-accent);">${index === 0 ? 'Основной' : `Резерв ${index}`}</strong>
<span class="${node.is_active ? 'text-healthy' : 'text-muted'}">${node.is_active ? '● АКТИВЕН' : ''}</span>
<div class="pipeline-chain-flow" data-role-id="${escapeHtml(roleId)}">
${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 = `
<div class="node-model-row">
<label class="node-model-label">Модель:</label>
<select class="node-model-select" onchange="handleNodeModelChange('${escapeHtml(roleId)}', '${escapeHtml(node.profile_id)}', this.value)">
${discoveredModels.map((m) => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
</select>
</div>
`;
} else {
modelControlHtml = `
<div class="node-model-row node-model-refresh-row">
<span class="node-model-text" title="Список моделей ещё не получен">Список моделей ещё не получен</span>
<button class="btn btn-secondary btn-xs" title="Запросить список моделей у провайдера" onclick="handleRefreshProviderModels('${escapeHtml(provId)}')"> Модели</button>
</div>
`;
}
return `
<div class="pipeline-node-chip ${node.is_active ? 'active' : ''}"
draggable="true"
data-role-id="${escapeHtml(roleId)}"
data-profile-id="${escapeHtml(node.profile_id)}"
data-index="${index}"
ondragstart="handleNodeDragStart(event, '${escapeHtml(roleId)}', ${index})"
ondragover="handleNodeDragOver(event, '${escapeHtml(roleId)}', ${index})"
ondragleave="handleNodeDragLeave(event)"
ondrop="handleNodeDrop(event, '${escapeHtml(roleId)}', ${index})"
ondragend="handleNodeDragEnd(event)">
<div class="node-top-row">
<span class="node-rank">${index === 0 ? '★ Основной' : `Резерв ${index}`}</span>
<div style="display:flex; align-items:center; gap:4px;">
${node.is_active ? '<span class="badge badge-status healthy">● АКТИВЕН</span>' : ''}
<button class="btn-node-remove" title="Удалить из цепочки" onclick="handleRemoveNodeFromChain('${escapeHtml(roleId)}', '${escapeHtml(node.profile_id)}')"></button>
</div>
</div>
<div class="node-identity" title="${escapeHtml(identity)}">${escapeHtml(identity)}</div>
<div class="node-meta" title="${escapeHtml(node.display_name || node.profile_id)} • ${escapeHtml(node.provider)}">
${escapeHtml(node.display_name || node.profile_id)} <span class="mono-tag">(${escapeHtml(node.profile_id)})</span> ${escapeHtml(node.provider)}
</div>
${modelControlHtml}
${node.failover_reason ? `<div class="node-failover-warning">⚠ ${escapeHtml(node.failover_reason)}</div>` : ''}
</div>
<div style="font-size:12px; font-weight:700;">${escapeHtml(node.display_name || node.profile_id)}</div>
<div style="font-size:10px; color:var(--text-muted);">${escapeHtml(node.provider)} ${escapeHtml(node.model)}</div>
${node.failover_reason ? `<div style="font-size:9px; color:var(--status-warning); margin-top:2px;">⚠ ${escapeHtml(node.failover_reason)}</div>` : ''}
</div>
`).join('') || '<div class="empty-text">Цепочка не настроена.</div>'}
`;
}).join('') || '<div class="empty-text">Цепочка не настроена. Нажмите «+ Добавить профиль».</div>'}
</div>
</div>
`;
@ -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) => `
<div class="section-card" style="margin-bottom:16px;">
<div class="section-card-header">
<div>
<div class="section-card-title">${escapeHtml(prov.provider_name || prov.provider_id)}</div>
<div class="section-card-subtitle">
Всего слотов: <strong>${prov.total_slots}</strong> Подключено: <strong>${prov.connected_count}</strong> Онлайн: <strong>${prov.online_count}</strong>
</div>
</div>
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(prov.provider_id)}')">
Запросить модели
</button>
</div>
<div style="padding:14px;">
<div style="font-weight:600; font-size:12px; margin-bottom:6px; color:var(--text-secondary);">
Обнаруженные модели:
</div>
<div style="display:flex; flex-wrap:wrap; gap:6px;">
${(prov.discovered_models && prov.discovered_models.length > 0)
? prov.discovered_models.map(m => `<span class="account-model-tag" style="font-size:11px; padding:4px 8px;">${escapeHtml(m)}</span>`).join('')
: '<span style="color:var(--text-muted); font-size:12px;">Н/Д — список моделей ещё не получен от провайдера</span>'
}
</div>
</div>
</div>
`).join('') || '<div class="empty-text">Нет данных провайдеров.</div>';
}
// ═══════════════════════════════════════════════════════════════
// 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) => `
<div class="agent-card ${ag.is_main_orchestrator ? 'is-orchestrator' : ''}">
<div class="agent-card-header">
<div>
<div class="agent-role-title">${escapeHtml(ag.role_name_ru || ag.role_id)}</div>
<div class="agent-role-desc">${escapeHtml(ag.role_description_ru || '')}</div>
</div>
<span class="agent-status-badge ${ag.is_active ? 'active' : 'idle'}">
${ag.is_active ? '● АКТИВЕН' : 'ОЖИДАНИЕ'}
</span>
</div>
<div class="agent-assigned-info">
<div style="font-size:11px; color:var(--text-muted);">Назначенный аккаунт:</div>
<div style="font-weight:700; font-size:13px;">${escapeHtml(ag.assigned_display_name || ag.assigned_profile_id || 'Не назначен')}</div>
<div style="font-size:11px; color:var(--text-secondary); margin-top:2px;">
Провайдер: <strong>${escapeHtml(ag.provider_display_name || ag.provider)}</strong> Модель: <strong>${escapeHtml(ag.model || 'default')}</strong>
</div>
</div>
<div class="agent-card-footer">
<button class="btn btn-secondary btn-sm" onclick="openAgentModelModal('${escapeHtml(ag.role_id)}', '${escapeHtml(ag.assigned_profile_id)}')">
Выбрать модель
</button>
<button class="btn btn-secondary btn-sm" onclick="openEditRouteModal('${escapeHtml(ag.role_id)}')">
Маршрут
</button>
</div>
</div>
`).join('') || '<div class="empty-text">Список агентов пуст.</div>';
}
// ═══════════════════════════════════════════════════════════════
// 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 = '<div class="empty-text">Нет данных телеметрии по провайдерам.</div>';
} 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 `
<tr>
<td><strong>${escapeHtml(provName)}</strong> <span style="font-size:10px; color:var(--text-muted);">(${escapeHtml(pId)})</span></td>
<td class="text-muted">Не подключён</td>
<td class="text-muted"></td>
<td class="text-muted"></td>
<td><span class="badge badge-muted">Аккаунт не добавлен</span></td>
<td class="text-muted"></td>
<td class="text-muted"></td>
<td class="text-muted">Н/Д</td>
</tr>
`;
}
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 `
<tr>
<td><strong>${escapeHtml(pId)}</strong></td>
<td>${pData.total_calls ?? 'Н/Д'}</td>
<td><strong>${escapeHtml(provName)}</strong> <span style="font-size:10px; color:var(--text-muted);">(${escapeHtml(pId)})</span></td>
<td>${pData.total_calls ?? 0}</td>
<td class="text-healthy">${pData.successful_calls ?? 0}</td>
<td class="${(pData.failed_calls || 0) > 0 ? 'text-error' : 'text-muted'}">${pData.failed_calls ?? 0}</td>
<td>
<div class="cell-bar-container">
<span>${errPct}${errPct !== 'Н/Д' ? '%' : ''}</span>
<span>${errPct}%</span>
<div class="cell-bar-track">
<div class="cell-bar-fill" style="width:${barW}%; background:${barColor};"></div>
</div>
@ -875,7 +971,7 @@ function renderAnalyticsView() {
return `
<tr>
<td><strong>${escapeHtml(rName)}</strong> <span style="font-size:10px; color:var(--text-muted);">(${escapeHtml(rId)})</span></td>
<td>${rData.total_calls ?? 'Н/Д'}</td>
<td>${rData.total_calls ?? 0}</td>
<td class="text-healthy">${(rData.total_calls ?? 0) - (rData.failed_calls ?? 0)}</td>
<td class="${(rData.failed_calls || 0) > 0 ? 'text-error' : 'text-muted'}">${rData.failed_calls ?? 0}</td>
<td>${errPct}%</td>
@ -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) => `
<div style="display:flex; align-items:center; justify-content:space-between; background:var(--surface-muted); padding:8px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:6px;">
<div>
<span style="font-weight:700; color:var(--text-accent); margin-right:8px;">${index + 1}.</span>
<strong style="font-size:13px;">${escapeHtml(node.display_name || node.profile_id)}</strong>
<span style="font-size:11px; color:var(--text-muted); margin-left:6px;">(${escapeHtml(node.provider)})</span>
</div>
<div style="display:flex; gap:4px;">
<button class="btn btn-secondary btn-sm" onclick="moveRouteNode('${roleId}', ${index}, -1)" ${index === 0 ? 'disabled' : ''}></button>
<button class="btn btn-secondary btn-sm" onclick="moveRouteNode('${roleId}', ${index}, 1)" ${index === nodes.length - 1 ? 'disabled' : ''}></button>
<button class="btn btn-ghost btn-sm text-error" onclick="removeRouteNode('${roleId}', ${index})"></button>
</div>
elements.modalTitle.textContent = `Добавить профиль в цепочку: ${pipeline.role_name_ru || roleId}`;
if (available.length === 0) {
elements.modalBody.innerHTML = `
<div class="view-header-note">Все зарегистрированные профили уже включены в эту цепочку.</div>
`;
elements.modalFooter.innerHTML = `<button class="btn btn-ghost" onclick="closeModal()">Закрыть</button>`;
} else {
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Выберите доступный профиль для включения в цепочку отказоустойчивости:
</div>
`).join('') || '<div class="empty-text">Цепочка пуста.</div>';
<div style="margin-bottom:16px;">
<select id="add-node-profile-select" class="select-filter" style="width:100%;">
${available.map((p) => `
<option value="${escapeHtml(p.profile_id)}">
${escapeHtml(p.display_name || p.profile_id)} (${escapeHtml(p.provider_display_name || p.provider)}) ${escapeHtml(p.email || p.account_identity || 'без email')}
</option>
`).join('')}
</select>
</div>
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
<button class="btn btn-primary" onclick="handleAddNodeToChain('${escapeHtml(roleId)}')">+ Добавить в цепочку</button>
`;
}
window.activeRouteNodes = nodes;
elements.modalTitle.textContent = `Цепочка маршрутизации: ${pipeline.role_name_ru || roleId}`;
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
<div style="font-size:12px; color:var(--text-muted); margin-bottom:12px;">
Первый профиль основной (Primary). Нижестоящие профили используются как резервы в порядке переключения.
</div>
<div id="route-nodes-list-container">
${renderRows()}
</div>
`;
elements.modalFooter.innerHTML = `
<button class="btn btn-ghost" onclick="closeModal()">Отмена</button>
<button class="btn btn-primary" onclick="saveRouteChain('${escapeHtml(roleId)}')">Сохранить цепочку</button>
`;
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 = '<div class="modal-feedback info">⏳ Сохранение конфигурации...</div>';
feedbackArea.innerHTML = '<div class="modal-feedback info">⏳ Добавление профиля в цепочку...</div>';
}
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 {

View file

@ -20,27 +20,19 @@
</div>
<nav class="nav-menu">
<button class="nav-item active" data-view="accounts">
<button class="nav-item active" data-view="overview">
<span class="nav-icon">📊</span>
<span class="nav-label">Обзор</span>
</button>
<button class="nav-item" data-view="accounts">
<span class="nav-icon">👥</span>
<span class="nav-label">Аккаунты</span>
<span class="nav-badge" id="nav-accounts-count">0</span>
</button>
<button class="nav-item" data-view="overview">
<span class="nav-icon">📊</span>
<span class="nav-label">Обзор</span>
</button>
<button class="nav-item" data-view="routing">
<span class="nav-icon">🔀</span>
<span class="nav-label">Маршрутизация</span>
</button>
<button class="nav-item" data-view="providers">
<span class="nav-icon">🧩</span>
<span class="nav-label">Модели и провайдеры</span>
</button>
<button class="nav-item" data-view="team">
<span class="nav-icon">👑</span>
<span class="nav-label">Команда агентов</span>
</button>
<button class="nav-item" data-view="analytics">
<span class="nav-icon">📈</span>
<span class="nav-label">Аналитика</span>
@ -73,7 +65,7 @@
<!-- Top Header -->
<header class="top-header">
<div class="header-left">
<h1 class="header-title" id="page-title">Аккаунты и квоты</h1>
<h1 class="header-title" id="page-title">Обзор системы</h1>
<span class="header-readiness-badge" id="header-readiness-badge">
<span class="status-dot"></span>
<span id="header-readiness-text">Инициализация...</span>
@ -91,45 +83,10 @@
</div>
</header>
<!-- Dynamic Views -->
<!-- Dynamic Views (7 Control Sections) -->
<div class="content-scroll">
<!-- 1. ACCOUNTS VIEW -->
<section id="view-accounts" class="view-pane active">
<div class="toolbar">
<div class="search-box">
<span class="search-icon">🔍</span>
<input type="text" id="accounts-search" placeholder="Поиск по аккаунту, почте, роли или модели...">
</div>
<div class="filters-row">
<select id="filter-provider" class="select-filter">
<option value="all">Все провайдеры</option>
<option value="antigravity">Google Antigravity</option>
<option value="openai-codex">OpenAI Codex</option>
<option value="opencode-go">OpenCode Go</option>
<option value="claude">Claude (Anthropic)</option>
<option value="grok">Grok (xAI)</option>
</select>
<select id="filter-health" class="select-filter">
<option value="all">Все состояния</option>
<option value="healthy">Работает</option>
<option value="warning">Предупреждение</option>
<option value="quota_exhausted">Квота исчерпана</option>
<option value="auth_required">Требуется вход</option>
<option value="disabled">Отключён / Резерв</option>
</select>
<div class="toolbar-stats" id="accounts-stats-summary">
Показано: <strong>0</strong> из <strong>0</strong> аккаунтов
</div>
</div>
</div>
<div id="accounts-container" class="accounts-groups-container">
<!-- Rendered by app.js -->
</div>
</section>
<!-- 2. OVERVIEW VIEW -->
<section id="view-overview" class="view-pane">
<!-- 1. OVERVIEW VIEW -->
<section id="view-overview" class="view-pane active">
<div class="overview-grid">
<div class="kpi-card">
<div class="kpi-label">Состояние системы</div>
@ -174,38 +131,56 @@
</div>
</section>
<!-- 3. ROUTING VIEW -->
<!-- 2. ACCOUNTS VIEW -->
<section id="view-accounts" class="view-pane">
<div class="toolbar">
<div class="search-box">
<span class="search-icon">🔍</span>
<input type="text" id="accounts-search" placeholder="Поиск по аккаунту, почте, роли или модели...">
</div>
<div class="filters-row">
<select id="filter-provider" class="select-filter">
<option value="all">Все провайдеры</option>
<option value="antigravity">Google Antigravity</option>
<option value="openai-codex">OpenAI Codex</option>
<option value="opencode-go">OpenCode Go</option>
<option value="claude">Claude (Anthropic)</option>
<option value="grok">Grok (xAI)</option>
</select>
<select id="filter-health" class="select-filter">
<option value="all">Все состояния</option>
<option value="healthy">Работает</option>
<option value="warning">Предупреждение</option>
<option value="quota_exhausted">Квота исчерпана</option>
<option value="auth_required">Требуется вход</option>
<option value="disabled">Отключён / Резерв</option>
</select>
<div class="toolbar-stats" id="accounts-stats-summary">
Показано: <strong>0</strong> из <strong>0</strong> аккаунтов
</div>
</div>
</div>
<div id="accounts-container" class="accounts-groups-container">
<!-- Rendered by app.js -->
</div>
</section>
<!-- 3. ROUTING VIEW (Main Control Center) -->
<section id="view-routing" class="view-pane">
<div class="view-header-note">
Цепочки маршрутизации: <strong>Основной → Резерв 1 → Резерв 2 → Резерв 3</strong>. Переключения выполняются автоматически при исчерпании квоты или ошибке провайдера.
Главный центр управления маршрутизацией: перетаскивайте узлы для смены приоритета (Основной → Резерв 1 → Резерв 2), настраивайте рабочие модели и управляйте составом цепочек.
</div>
<div class="routing-pipelines-list" id="routing-pipelines-container">
<!-- Rendered by app.js -->
</div>
</section>
<!-- 4. PROVIDERS & MODELS VIEW -->
<section id="view-providers" class="view-pane">
<div class="view-header-note">
Реально обнаруженные модели провайдеров и локальные адаптеры.
</div>
<div class="providers-full-list" id="providers-full-container">
<!-- Rendered by app.js -->
</div>
</section>
<!-- 5. TEAM VIEW -->
<section id="view-team" class="view-pane">
<div class="view-header-note">
Команда агентов Hermes Hub: роли, привязанные профили и оперативные квоты.
</div>
<div class="team-cards-grid" id="team-cards-container">
<!-- Rendered by app.js -->
</div>
</section>
<!-- 6. ANALYTICS VIEW (P0-1) -->
<!-- 4. ANALYTICS VIEW (P0-1 & P0-5) -->
<section id="view-analytics" class="view-pane">
<div class="view-header-note">
Телеметрия вызовов и отказоустойчивости за 24 часа. Метрики отражают реальные замеры задержки и частоты ошибок при маршрутизации.
</div>
<div class="overview-grid">
<div class="kpi-card">
<div class="kpi-label">Всего вызовов (24ч)</div>

View file

@ -755,54 +755,237 @@ body {
word-break: break-all;
}
/* ── Routing Pipelines List ── */
/* ── Routing Pipelines List (Main Control Center) ── */
.routing-pipelines-list {
display: flex;
flex-direction: column;
gap: 12px;
gap: 14px;
}
.pipeline-card {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 14px 18px;
padding: 16px 20px;
}
.pipeline-header {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 12px;
margin-bottom: 14px;
}
.pipeline-title {
font-size: 14px;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.pipeline-chain-flow {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
.pipeline-desc {
font-size: 12px;
color: var(--text-muted);
margin-top: 4px;
}
.badge-quota {
background-color: rgba(64, 158, 255, 0.15);
color: var(--accent);
border: 1px solid rgba(64, 158, 255, 0.3);
font-size: 11px;
padding: 2px 8px;
border-radius: var(--radius-sm);
font-weight: 600;
}
.badge-quota.warning {
background-color: rgba(230, 162, 60, 0.15);
color: var(--status-warning);
border-color: rgba(230, 162, 60, 0.3);
}
.badge-quota.error {
background-color: rgba(245, 108, 108, 0.15);
color: var(--status-error);
border-color: rgba(245, 108, 108, 0.3);
}
.badge-affinity {
background-color: var(--surface-muted);
color: var(--text-secondary);
border: 1px solid var(--border-subtle);
font-size: 10px;
padding: 2px 6px;
border-radius: var(--radius-sm);
}
.pipeline-chain-flow {
display: flex;
align-items: stretch;
gap: 12px;
flex-wrap: wrap;
min-height: 100px;
padding: 4px 0;
}
/* ── Draggable Node Chip (HTML5 DND) ── */
.pipeline-node-chip {
background-color: var(--surface-muted);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 10px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 140px;
gap: 4px;
min-width: 220px;
max-width: 280px;
cursor: grab;
user-select: none;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease, opacity 0.15s ease;
position: relative;
}
.pipeline-node-chip[draggable]:hover {
border-color: var(--accent);
background-color: var(--surface-hover);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.pipeline-node-chip.active {
border-color: var(--status-healthy);
background-color: var(--surface-hover);
box-shadow: 0 0 6px rgba(103, 194, 58, 0.2);
}
.pipeline-node-chip.dragging {
opacity: 0.45;
cursor: grabbing;
transform: scale(0.96);
border-style: dashed;
border-color: var(--accent);
}
.pipeline-node-chip.drop-target {
border-color: var(--accent);
box-shadow: 0 0 14px rgba(64, 158, 255, 0.5);
transform: scale(1.03);
background-color: var(--surface-hover);
}
.node-top-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 11px;
}
.node-rank {
font-weight: 700;
color: var(--accent);
}
.btn-node-remove {
background: transparent;
border: none;
color: var(--text-muted);
font-size: 13px;
cursor: pointer;
padding: 0 4px;
line-height: 1;
border-radius: var(--radius-xs);
transition: color 0.15s ease, background 0.15s ease;
}
.btn-node-remove:hover {
color: var(--status-error);
background: rgba(245, 108, 108, 0.15);
}
.node-identity {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.node-meta {
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mono-tag {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-muted);
}
.node-model-row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--border-subtle);
}
.node-model-label {
font-size: 10px;
color: var(--text-muted);
font-weight: 600;
white-space: nowrap;
}
.node-model-select,
.diagram-model-select {
flex: 1;
background-color: var(--bg-base);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-xs);
color: var(--text-primary);
font-size: 11px;
font-family: var(--font-mono);
padding: 3px 6px;
outline: none;
cursor: pointer;
max-width: 100%;
}
.node-model-select:focus,
.diagram-model-select:focus {
border-color: var(--accent);
}
.node-model-refresh-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
}
.node-model-text {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.node-failover-warning {
font-size: 10px;
color: var(--status-warning);
margin-top: 4px;
}
.btn-xs {
font-size: 10px;
padding: 2px 6px;
}
.pipeline-arrow {
@ -810,24 +993,6 @@ body {
font-weight: bold;
}
/* ── Team Cards Grid ── */
.team-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
.team-agent-card {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 14px;
}
.team-agent-card.orchestrator {
border-color: var(--border-accent);
}
/* ── Settings Card ── */
.settings-card {
background-color: var(--surface);

View file

@ -0,0 +1,184 @@
"""Tests for Routing Control Center & Web Navigation (A24).
Verifies:
1. AutoAssigner.persist_role_chain saves reordered chains to YAML and survives reload.
2. ActionExecutor handles 'save_chain', 'reorder_chain', 'edit_route', 'assign_role'.
3. Model assignment via ActionExecutor('set_model') updates profile and role default.
4. Web client navigation contains exactly 7 primary sections in order.
5. Redundant views (team, providers) are removed from navigation and client script.
"""
from pathlib import Path
from typing import List
import pytest
from antigravity_provider.router.action_handler import ActionExecutor
from antigravity_provider.router.auto_assigner import AutoAssigner
from antigravity_provider.router.router_config import (
RolePolicy,
RouterConfig,
RouterProfileConfig,
load_router_config,
save_router_config,
)
@pytest.fixture
def temp_router_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Creates an isolated temporary router configuration."""
cfg_path = tmp_path / "router_profiles.yaml"
monkeypatch.setenv("HERMES_ROUTER_CONFIG", str(cfg_path))
# Initialize sample profiles and roles
config = RouterConfig(
enabled=True,
default_role="orchestrator",
profiles={
"ag-w1": RouterProfileConfig(
profile_id="ag-w1",
provider="antigravity",
account_id="ag-dev-1",
preferred_models=["gemini-2.5-pro"],
),
"ag-w2": RouterProfileConfig(
profile_id="ag-w2",
provider="antigravity",
account_id="ag-dev-2",
preferred_models=["gemini-2.5-flash"],
),
"op-1": RouterProfileConfig(
profile_id="op-1",
provider="opencode-go",
account_id="op-primary",
preferred_models=["glm-4.7"],
),
"cl-1": RouterProfileConfig(
profile_id="cl-1",
provider="claude",
account_id="cl-fast",
preferred_models=["claude-3-5-sonnet"],
),
},
roles={
"coder-primary": RolePolicy(
role_name="coder-primary",
preferred_chain=["ag-w1", "ag-w2", "op-1"],
default_model="gemini-2.5-pro",
),
"coder-secondary": RolePolicy(
role_name="coder-secondary",
preferred_chain=["ag-w2", "op-1"],
default_model="gemini-2.5-flash",
),
},
)
save_router_config(config)
return cfg_path
def test_persist_role_chain_reorder_and_persistence(temp_router_config: Path):
"""Verify persist_role_chain updates preferred_chain and primary_profile_id and survives reload."""
new_chain: List[str] = ["op-1", "ag-w2", "ag-w1"]
ok, msg = AutoAssigner.persist_role_chain("coder-primary", new_chain)
assert ok is True
assert "успешно сохранена" in msg
# Reload from disk
reloaded = load_router_config()
role_cfg = reloaded.roles["coder-primary"]
assert role_cfg.preferred_chain == new_chain
assert role_cfg.preferred_chain[0] == "op-1"
def test_persist_role_chain_canonical_name_mapping(temp_router_config: Path):
"""Verify persist_role_chain resolves Russian aliases and canonical names."""
new_chain = ["ag-w2", "ag-w1"]
ok, msg = AutoAssigner.persist_role_chain("кодер 1", new_chain)
assert ok is True
reloaded = load_router_config()
assert reloaded.roles["coder-primary"].preferred_chain == new_chain
def test_persist_role_chain_validation_errors(temp_router_config: Path):
"""Verify persist_role_chain rejects invalid profiles and duplicates."""
# Unknown profile
ok, msg = AutoAssigner.persist_role_chain("coder-primary", ["ag-w1", "non-existent-profile"])
assert ok is False
assert "не найден" in msg
# Duplicate profile in chain
ok, msg = AutoAssigner.persist_role_chain("coder-primary", ["ag-w1", "ag-w1"])
assert ok is False
assert "повторяться" in msg or "дублир" in msg
# Non-existent role
ok, msg = AutoAssigner.persist_role_chain("unknown-role-xyz", ["ag-w1"])
assert ok is False
assert "Неизвестная роль" in msg or "неизвестн" in msg.lower()
def test_action_executor_save_chain(temp_router_config: Path):
"""Verify ActionExecutor executes 'save_chain', 'reorder_chain', and 'edit_route'."""
executor = ActionExecutor()
# save_chain
res1 = executor.execute("save_chain", {"role_id": "coder-primary", "chain": ["ag-w2", "ag-w1"]})
assert res1["ok"] is True
# reorder_chain
res2 = executor.execute("reorder_chain", {"role_id": "coder-primary", "desired_chain": ["op-1", "ag-w1"]})
assert res2["ok"] is True
# edit_route
res3 = executor.execute("edit_route", {"role_id": "coder-secondary", "chain": ["op-1", "ag-w2"]})
assert res3["ok"] is True
reloaded = load_router_config()
assert reloaded.roles["coder-primary"].preferred_chain == ["op-1", "ag-w1"]
assert reloaded.roles["coder-secondary"].preferred_chain == ["op-1", "ag-w2"]
def test_action_executor_set_model_updates_profile_and_role(temp_router_config: Path):
"""Verify ActionExecutor('set_model') updates profile preferred_models and role default_model."""
executor = ActionExecutor()
res = executor.execute("set_model", {
"profile_id": "ag-w1",
"model": "gemini-3.1-pro",
"role_id": "coder-primary",
})
assert res["ok"] is True
reloaded = load_router_config()
assert reloaded.profiles["ag-w1"].preferred_models[0] == "gemini-3.1-pro"
assert reloaded.roles["coder-primary"].default_model == "gemini-3.1-pro"
def test_web_client_7_views_exact_order_and_no_redundant_views():
"""Verify index.html navigation items match exactly 7 sections in required order."""
static_dir = Path(__file__).parent.parent / "src" / "antigravity_provider" / "router" / "web" / "static"
index_html = (static_dir / "index.html").read_text(encoding="utf-8")
app_js = (static_dir / "app.js").read_text(encoding="utf-8")
expected_views = [
"overview",
"accounts",
"routing",
"analytics",
"health",
"logs",
"settings",
]
# Verify presence in exact order in index.html nav-menu
nav_positions = [index_html.find(f'data-view="{v}"') for v in expected_views]
assert all(pos != -1 for pos in nav_positions), "All 7 views must be present in index.html"
assert nav_positions == sorted(nav_positions), "Views in index.html must be in exact specified order"
# Verify absence of removed views
assert 'data-view="providers"' not in index_html
assert 'data-view="team"' not in index_html
assert "renderProvidersView" not in app_js
assert "renderTeamView" not in app_js

View file

@ -99,14 +99,14 @@ def test_api_settings_endpoint_no_raw_tokens(client, tmp_path, monkeypatch):
settings_file.unlink()
def test_web_client_html_and_js_9_views_parity():
"""Verify index.html and app.js implement all 9 views required for desktop parity."""
def test_web_client_html_and_js_7_views_parity():
"""Verify index.html and app.js implement the 7 primary views and remove redundant views."""
index_html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
app_js = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
expected_views = [
"accounts", "overview", "routing", "providers",
"team", "analytics", "health", "logs", "settings"
"overview", "accounts", "routing", "analytics",
"health", "logs", "settings"
]
for v in expected_views:
@ -115,6 +115,23 @@ def test_web_client_html_and_js_9_views_parity():
# Section container exists
assert f'id="view-{v}"' in index_html, f"Missing section #view-{v} in index.html"
# Redundant views must NOT exist in nav menu
assert 'data-view="providers"' not in index_html, "View 'providers' must be removed from web navigation"
assert 'data-view="team"' not in index_html, "View 'team' must be removed from web navigation"
assert "renderProvidersView" not in app_js, "renderProvidersView must be removed from app.js"
assert "renderTeamView" not in app_js, "renderTeamView must be removed from app.js"
# Routing view elements
assert "renderRoutingView" in app_js
assert "routing-pipelines-container" in index_html
assert "handleNodeDragStart" in app_js
assert "handleNodeDrop" in app_js
assert "handleNodeModelChange" in app_js
# Overview view elements
assert "renderOverviewView" in app_js
assert "overview-route-diagram" in index_html
# Analytics view elements
assert "analytics-total-calls" in index_html
assert "analytics-error-rate" in index_html
@ -140,3 +157,4 @@ def test_web_client_html_and_js_9_views_parity():
assert "setting-theme" in index_html
assert "renderSettingsView" in app_js
assert "saveHubServerSettings" in app_js