feat(web): routing control center with drag-and-drop, inline models, and 7-view navigation (A24)
This commit is contained in:
parent
bad24ff6aa
commit
965ef1272c
8 changed files with 931 additions and 326 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)}"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
184
tests/test_routing_control_center_a24.py
Normal file
184
tests/test_routing_control_center_a24.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue