feat(web): A21 — паритет веб-интерфейса: аналитика, состояние, события, настройки

This commit is contained in:
Hermes Team 2026-08-23 22:59:02 +07:00
parent 187f181aec
commit e738dd0c5d
11 changed files with 1444 additions and 118 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

View file

@ -1,7 +1,7 @@
# Контракт веб-интерфейса Hermes Hub
Версия контракта: **1.1**
Дата: 2026-08-23
Версия контракта: **1.2**
Дата: 2026-08-24
Этот документ — **единственный** источник истины для двух сторон: серверной (задание A15) и клиентской (A16). Обе стороны разрабатываются параллельно и до слияния друг друга не видят.
@ -117,6 +117,55 @@ set_orchestrator test
Веб-клиент **обязан** проверять это поле и не показывать неработающую кнопку, а предлагать обходной путь.
### `GET /api/events`
Возвращает список недавних событий системы из `EventLogService` в обратном хронологическом порядке.
Параметры запроса (Query Params):
- `limit` (int, необязательно, по умолчанию `50`, максимум `200`): количество событий;
- `category` (string, необязательно): фильтрация по категории (`account`, `quota`, `routing`, `auth`, `system`).
Ответ:
```json
{
"events": [
{
"timestamp": "22:50:14",
"category": "system",
"message": "Успешная проверка подключения ag-w2 (gemini-3.1-pro-high) за 1.4s.",
"details": "...",
"level": "success"
}
]
}
```
**Секреты в события не попадают.** На эндпоинт распространяется санитайзер `sanitize_snapshot`.
---
### `GET /api/settings`
Возвращает текущие настройки из `hub_settings.json` и пути системы.
**Секреты не отдаются:** поле `web_api_token` скрыто; наружу отдаётся только флаг `web_api_token_configured` (`true`/`false`).
Ответ:
```json
{
"web_api_host": "127.0.0.1",
"web_api_port": 5800,
"web_api_token_configured": false,
"theme": "system",
"quota_refresh_interval_sec": 300,
"hermes_home": "C:\\Users\\...\\.hermes",
"config_dir": "C:\\Users\\...\\.hermes\\config",
"log_file": "C:\\Users\\...\\.hermes\\logs\\hermes-hub.log"
}
```
Сохранение настроек выполняется через стандартное действие `POST /api/action` с `action: "save_settings"`.
---
### `GET /` и статика
**Сервер обязан отдавать сам интерфейс, а не только API.** Корневой маршрут возвращает `static/index.html`, каталог `static/` монтируется целиком.

View file

@ -0,0 +1,111 @@
"""
Hermes Hub Task A21 Screenshot Capture Script
Captures high-resolution (1440x920) screenshots for all 4 new screens:
1. Analytics (Аналитика)
2. Health / Readiness (Состояние)
3. Logs (Журнал событий)
4. Settings (Настройки)
"""
from __future__ import annotations
import os
from pathlib import Path
import socket
import subprocess
import sys
import threading
import time
REPO_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(REPO_ROOT / "src"))
import uvicorn
from antigravity_provider.router.web.server import app
from antigravity_provider.router.unified_health import EventLogService
ARTIFACTS_DIR = REPO_ROOT / "artifacts" / "a21-screenshots"
ARTIFACTS_DIR.mkdir(parents=True, exist_ok=True)
CHROME_PATHS = [
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
]
def get_free_port() -> int:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(("127.0.0.1", 0))
return s.getsockname()[1]
def seed_event_logs():
svc = EventLogService.get()
svc.log("system", "Hermes Hub запущен в нативном режиме Windows.", level="info")
svc.log("account", "Обновлены квоты для 22 аккаунтов (Antigravity, Codex, Grok, Claude, OpenCode).", level="info")
svc.log("routing", "Основной маршрут 'coder-primary' переключен на ag-w1.", level="info")
svc.log("quota", "Превышен лимит запросов для резервного профиля codex-2.", level="warning", details="HTTP 429 Too Many Requests от OpenAI API")
svc.log("system", "Фоновая синхронизация конфигурации завершена успешно.", level="info")
svc.log("routing", "Автоматический failover: 'reviewer' переведён на claude-main.", level="warning", details="Таймаут первичного узла grok-fast")
def main():
seed_event_logs()
port = get_free_port()
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
server = uvicorn.Server(config)
t = threading.Thread(target=server.run, daemon=True)
t.start()
time.sleep(1.0)
chrome_exe = None
for p in CHROME_PATHS:
if os.path.isfile(p):
chrome_exe = p
break
if not chrome_exe:
raise RuntimeError("No Chrome/Edge executable found")
temp_profile = REPO_ROOT / "artifacts" / "temp_chrome_profile_a21"
temp_profile.mkdir(parents=True, exist_ok=True)
scenarios = [
("01_analytics_view.png", f"http://127.0.0.1:{port}/index.html?view=analytics"),
("02_health_view.png", f"http://127.0.0.1:{port}/index.html?view=health"),
("03_logs_view.png", f"http://127.0.0.1:{port}/index.html?view=logs"),
("04_settings_view.png", f"http://127.0.0.1:{port}/index.html?view=settings"),
]
captured = 0
for filename, url in scenarios:
out_file = ARTIFACTS_DIR / filename
cmd = [
chrome_exe,
"--headless=new",
"--disable-gpu",
"--no-sandbox",
"--hide-scrollbars",
"--virtual-time-budget=2500",
f"--user-data-dir={temp_profile}",
"--window-size=1440,920",
f"--screenshot={out_file}",
url,
]
print(f"Capturing: {filename}...")
subprocess.run(cmd, capture_output=True, timeout=15)
if out_file.is_file() and out_file.stat().st_size > 1000:
print(f" [OK] Saved {out_file.name} ({out_file.stat().st_size // 1024} KB)")
captured += 1
else:
print(f" [FAIL] Could not generate {out_file.name}")
server.should_exit = True
print(f"\nCaptured {captured}/{len(scenarios)} screenshots in {ARTIFACTS_DIR}")
if __name__ == "__main__":
main()

View file

@ -5,7 +5,7 @@ import threading
import time
import dataclasses
import logging
from typing import Any, Dict
from typing import Any, Dict, List, Optional
from antigravity_provider import paths
from antigravity_provider.version import __version__
@ -75,15 +75,34 @@ def health_check():
}
}
def sanitize_snapshot(snap_dict: Dict[str, Any]) -> Dict[str, Any]:
def sanitize_snapshot(snap_dict: Any) -> Any:
import re
secret_patterns = [
re.compile(r'(access_token|refresh_token|api_key|token|password|secret|key)=([^\s&,"]+)', re.IGNORECASE),
re.compile(r'(sk-[a-zA-Z0-9_\-]{8,})'),
re.compile(r'(gho_[a-zA-Z0-9_\-]{8,})'),
re.compile(r'(Bearer\s+)([a-zA-Z0-9_\-\.]{8,})', re.IGNORECASE),
]
def _mask_str(val: str) -> str:
res = val
for pat in secret_patterns:
if pat.groups == 2:
res = pat.sub(r'\1=***', res)
elif pat.groups == 1:
res = pat.sub(r'***', res)
return res
def _sanitize(node):
if isinstance(node, dict):
return {
k: _sanitize(v) for k, v in node.items()
if not any(secret in k.lower() for secret in ['access_token', 'refresh_token', 'api_key', 'jwt'])
if not any(secret in k.lower() for secret in ['access_token', 'refresh_token', 'api_key', 'jwt', 'client_secret'])
}
elif isinstance(node, list):
return [_sanitize(x) for x in node]
elif isinstance(node, str):
return _mask_str(node)
return node
return _sanitize(snap_dict)
@ -122,6 +141,37 @@ async def handle_action(request: Request, authorized: bool = Depends(get_auth_to
"data": result.get("data", {})
}
@app.get("/api/events")
def get_events(limit: int = 100, category: Optional[str] = None, authorized: bool = Depends(get_auth_token)):
"""Return recent events log in reverse chronological order without secrets."""
from antigravity_provider.router.unified_health import EventLogService
events = EventLogService.get().get_events(limit=limit, category=category)
event_dicts = [dataclasses.asdict(e) for e in events]
sanitized = sanitize_snapshot(event_dicts)
return JSONResponse(content=jsonable_encoder({"events": sanitized}))
@app.get("/api/settings")
def get_settings(authorized: bool = Depends(get_auth_token)):
"""Return current server and hub settings without exposing raw auth tokens."""
raw = _web_settings()
has_token = bool(raw.get("web_api_token"))
settings_out: Dict[str, Any] = {
"web_api_host": raw.get("web_api_host", "127.0.0.1"),
"web_api_port": raw.get("web_api_port", 5800),
"web_api_token_configured": has_token,
"theme": raw.get("theme", "system"),
"quota_refresh_interval_sec": raw.get("quota_refresh_interval_sec", 300),
"hermes_home": str(paths.get_hermes_home()),
"config_dir": str(paths.get_config_dir()),
"log_file": str(paths.get_log_file()),
}
for k, v in raw.items():
if k not in settings_out and not any(secret in k.lower() for secret in ['token', 'secret', 'key', 'password', 'jwt']):
settings_out[k] = v
return JSONResponse(content=jsonable_encoder(settings_out))
def run_server():
import uvicorn
settings = _web_settings()
@ -150,6 +200,7 @@ if _STATIC_DIR.is_dir():
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
@app.get("/")
@app.get("/index.html")
def index():
return FileResponse(str(_STATIC_DIR / "index.html"))

View file

@ -5,7 +5,6 @@
*/
// ── CONFIGURATION & STATE ──
// Set USE_MOCK_FIXTURE = true to develop strictly offline against snapshot.example.json
const USE_MOCK_FIXTURE = false;
let lastAppliedSeq = -1;
@ -14,6 +13,8 @@ let activeView = 'accounts';
let pollTimer = null;
let pollIntervalMs = 5000;
let authToken = localStorage.getItem('hermes_hub_token') || '';
let cachedEvents = [];
let currentSettings = {};
// ── DOM ELEMENTS ──
const elements = {
@ -51,7 +52,7 @@ document.addEventListener('DOMContentLoaded', () => {
// ── NAVIGATION ──
function initNavigation() {
elements.navItems.forEach((btn) => {
document.querySelectorAll('.nav-item').forEach((btn) => {
btn.addEventListener('click', () => {
const view = btn.dataset.view;
switchView(view);
@ -61,10 +62,10 @@ function initNavigation() {
function switchView(viewName) {
activeView = viewName;
elements.navItems.forEach((btn) => {
document.querySelectorAll('.nav-item').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.view === viewName);
});
elements.viewPanes.forEach((pane) => {
document.querySelectorAll('.view-pane').forEach((pane) => {
pane.classList.toggle('active', pane.id === `view-${viewName}`);
});
@ -74,10 +75,14 @@ function switchView(viewName) {
routing: 'Маршрутизация запросов',
providers: 'Модели и провайдеры',
team: 'Команда агентов',
analytics: 'Аналитика и телеметрия',
health: 'Состояние и диагностика',
logs: 'Журнал событий',
settings: 'Параметры веб-клиента',
settings: 'Настройки Hermes Hub',
};
elements.pageTitle.textContent = titles[viewName] || 'Hermes Hub';
if (elements.pageTitle) {
elements.pageTitle.textContent = titles[viewName] || 'Hermes Hub';
}
if (currentSnapshot) {
renderCurrentView();
@ -109,11 +114,25 @@ function initEventListeners() {
});
}
const btnClearLogs = document.getElementById('btn-clear-logs');
if (btnClearLogs) {
btnClearLogs.addEventListener('click', () => {
const logsBox = document.getElementById('logs-container');
if (logsBox) logsBox.innerHTML = '<div class="empty-text">Журнал очищен пользователем.</div>';
// Logs view filters
const logsSearch = document.getElementById('logs-search');
const logsFilterLevel = document.getElementById('logs-filter-level');
const logsFilterCategory = document.getElementById('logs-filter-category');
const btnRefreshLogs = document.getElementById('btn-refresh-logs');
if (logsSearch) logsSearch.addEventListener('input', () => renderLogsList());
if (logsFilterLevel) logsFilterLevel.addEventListener('change', () => renderLogsList());
if (logsFilterCategory) logsFilterCategory.addEventListener('change', () => renderLogsList());
if (btnRefreshLogs) btnRefreshLogs.addEventListener('click', () => fetchLogs());
// Settings view buttons
const btnSaveHub = document.getElementById('btn-save-hub-settings');
if (btnSaveHub) btnSaveHub.addEventListener('click', () => saveHubServerSettings());
const themeSel = document.getElementById('setting-theme');
if (themeSel) {
themeSel.addEventListener('change', () => {
applyTheme(themeSel.value);
});
}
}
@ -166,9 +185,8 @@ async function fetchSnapshot() {
applySnapshot(fallbackData);
return;
}
} catch (e) {
// ignore
}
} catch (e) {}
setSourceIndicator(false, 'Сервер недоступен');
}
}
@ -322,14 +340,23 @@ function renderCurrentView() {
case 'team':
renderTeamView();
break;
case 'analytics':
renderAnalyticsView();
break;
case 'health':
renderHealthView();
break;
case 'logs':
renderLogsView();
break;
case 'settings':
renderSettingsView();
break;
}
}
// ═══════════════════════════════════════════════════════════════
// 1. ACCOUNTS VIEW (P0-1 Compact Fixed-Height Cards & Quotas)
// 1. ACCOUNTS VIEW (Compact Fixed-Height Cards & Quotas)
// ═══════════════════════════════════════════════════════════════
function renderAccountsView() {
const container = elements.accountsContainer;
@ -399,7 +426,9 @@ function renderAccountsView() {
}
container.querySelectorAll('.account-card').forEach((card) => {
card.addEventListener('click', () => {
card.addEventListener('click', (e) => {
// If click was on refresh button, skip modal
if (e.target.closest('.btn-ghost')) return;
const profileId = card.dataset.profileId;
openAccountDetailsModal(profileId);
});
@ -500,10 +529,10 @@ function renderQuotaCell(bucket, unavailableReason) {
<div class="quota-cell">
<div class="quota-cell-top">
<span class="quota-cell-title" title="${escapeHtml(bucket.display_name)}">${escapeHtml(bucket.display_name)}</span>
<span class="quota-cell-value" style="color: ${colorClass};">${escapeHtml(formattedValue)}</span>
<span class="quota-cell-value" style="color: ${colorClass}">${formattedValue}</span>
</div>
<div class="quota-bar-track">
<div class="quota-bar-fill" style="width: ${barWidth}%; background-color: ${colorClass};"></div>
<div class="quota-bar-fill" style="width: ${barWidth}%; background-color: ${colorClass}"></div>
</div>
<div class="quota-cell-reset" title="${escapeHtml(resetText)}">${escapeHtml(resetText)}</div>
</div>
@ -599,20 +628,19 @@ function renderRoutingView() {
</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);">Причина: ${escapeHtml(node.failover_reason)}</div>` : ''}
${node.failover_reason ? `<div style="font-size:9px; color:var(--status-warning); margin-top:2px;">⚠ ${escapeHtml(node.failover_reason)}</div>` : ''}
</div>
${index < nodes.length - 1 ? '<span class="pipeline-arrow">→</span>' : ''}
`).join('')}
`).join('') || '<div class="empty-text">Цепочка не настроена.</div>'}
</div>
</div>
`;
}
container.innerHTML = html || '<div class="empty-text">Маршрутизация не настроена.</div>';
container.innerHTML = html || '<div class="empty-text">Маршруты отсутствуют.</div>';
}
// ═══════════════════════════════════════════════════════════════
// 4. PROVIDERS VIEW
// 4. PROVIDERS & MODELS VIEW
// ═══════════════════════════════════════════════════════════════
function renderProvidersView() {
const container = document.getElementById('providers-full-container');
@ -620,123 +648,574 @@ function renderProvidersView() {
const providers = currentSnapshot.providers || [];
container.innerHTML = providers.map((prov) => `
<div class="section-card" style="margin-bottom:14px;">
<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">
Обновлено: ${prov.last_refresh_at ? formatIsoDate(prov.last_refresh_at) : 'Н/Д — обнаружение ещё не запускалось'}
Всего слотов: <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="executeAction('refresh_data', { provider: '${escapeHtml(prov.provider_id)}' })">
Обновить модели
<button class="btn btn-secondary btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(prov.provider_id)}')">
Запросить модели
</button>
</div>
<div style="font-size:12px; margin-bottom:10px;">
Онлайн: <strong class="text-healthy">${prov.online_count}/${prov.connected_count}</strong>
Требуют авторизации: <strong class="text-warning">${prov.auth_required_count}</strong>
Квота исчерпана: <strong class="text-error">${prov.quota_exhausted_count}</strong>
Холодный резерв: <strong>${prov.cold_spare_count}</strong>
</div>
<div class="provider-models-tag">
<strong>Обнаруженные модели:</strong><br>
${prov.discovered_models && prov.discovered_models.length ? escapeHtml(prov.discovered_models.join(' • ')) : 'Н/Д — список моделей ещё не получен от провайдера'}
<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>';
`).join('') || '<div class="empty-text">Нет данных провайдеров.</div>';
}
// ═══════════════════════════════════════════════════════════════
// 5. TEAM VIEW (P0-1 / P1-3 Model Choice on Agent Cards)
// 5. TEAM VIEW
// ═══════════════════════════════════════════════════════════════
function renderTeamView() {
const container = document.getElementById('team-cards-container');
if (!container || !currentSnapshot) return;
const agents = currentSnapshot.agents || [];
container.innerHTML = agents.map((agent) => `
<div class="team-agent-card ${agent.is_main_orchestrator ? 'orchestrator' : ''}">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
<span style="font-weight:700; font-size:14px;">${escapeHtml(agent.role_name_ru || agent.role_id)}</span>
${agent.is_main_orchestrator ? '<span class="badge badge-plan">👑 ЛИДЕР</span>' : ''}
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 style="font-size:11px; color:var(--text-muted); margin-bottom:8px;">${escapeHtml(agent.role_description_ru || '')}</div>
<div style="background:var(--surface-muted); padding:8px 10px; border-radius:var(--radius-sm); font-size:12px; margin-bottom:8px;">
<div>Профиль: <strong>${escapeHtml(agent.assigned_profile_id || 'Не назначен')}</strong></div>
<div>Провайдер: <strong>${escapeHtml(agent.provider_display_name || agent.provider)}</strong></div>
<div style="margin-top:2px;">Модель: <strong class="text-accent">${escapeHtml(agent.model || '—')}</strong></div>
</div>
<div style="display:flex; justify-content:space-between; align-items:center; font-size:11px; gap:6px;">
<span class="text-healthy"> ${escapeHtml(agent.status_label_ru || 'Работает')}</span>
<div style="display:flex; gap:4px;">
<button class="btn btn-secondary btn-sm" onclick="openAgentModelModal('${escapeHtml(agent.role_id)}', '${escapeHtml(agent.assigned_profile_id)}')">
Сменить модель
</button>
<button class="btn btn-ghost btn-sm" onclick="openAccountDetailsModal('${escapeHtml(agent.assigned_profile_id)}')">
Детали
</button>
<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>';
`).join('') || '<div class="empty-text">Список агентов пуст.</div>';
}
// ═══════════════════════════════════════════════════════════════
// 6. LOGS VIEW
// 6. ANALYTICS VIEW (P0-1 Real Telemetry & Honesty)
// ═══════════════════════════════════════════════════════════════
function renderLogsView() {
const container = document.getElementById('logs-container');
if (!container || !currentSnapshot) return;
const logs = currentSnapshot.metrics?.recent_events || [];
if (logs.length > 0) {
container.innerHTML = logs.map((log) => `
<div style="padding:6px 0; border-bottom:1px solid var(--border-subtle); font-family:var(--font-mono); font-size:11px;">
<span class="text-muted">[${escapeHtml(log.time || '')}]</span>
<span class="text-accent">${escapeHtml(log.role || '')}</span>:
<span>${escapeHtml(log.message || '')}</span>
</div>
`).join('');
function renderAnalyticsView() {
if (!currentSnapshot) return;
const metrics = currentSnapshot.metrics || {};
const telemetry = metrics.telemetry || {};
const global = telemetry.global || {};
// KPI 1: Total Calls
const totalCallsEl = document.getElementById('analytics-total-calls');
const callsBreakdownEl = document.getElementById('analytics-calls-breakdown');
if (totalCallsEl) {
totalCallsEl.textContent = global.total_calls !== null && global.total_calls !== undefined ? global.total_calls : 'Н/Д';
}
if (callsBreakdownEl) {
const succ = global.successful_calls ?? 0;
const fail = global.failed_calls ?? 0;
callsBreakdownEl.textContent = `Успешно: ${succ} • Сбоев: ${fail} (окно: 24ч)`;
}
// KPI 2: Error Rate
const errorRateEl = document.getElementById('analytics-error-rate');
const errorRateSubEl = document.getElementById('analytics-error-rate-sub');
if (errorRateEl) {
if (global.error_rate !== null && global.error_rate !== undefined) {
const pct = (global.error_rate * 100).toFixed(1);
errorRateEl.textContent = `${pct}%`;
errorRateEl.className = `kpi-value ${global.error_rate > 0.5 ? 'text-error' : (global.error_rate > 0.2 ? 'text-warning' : 'text-healthy')}`;
} else {
errorRateEl.textContent = 'Н/Д';
errorRateEl.className = 'kpi-value text-muted';
}
}
if (errorRateSubEl) {
errorRateSubEl.textContent = global.failed_calls ? `${global.failed_calls} отказов из ${global.total_calls || 0} вызовов` : 'Отказов не зафиксировано';
}
// KPI 3: Latency
const latencyEl = document.getElementById('analytics-latency-p50');
const latencySubEl = document.getElementById('analytics-latency-sub');
if (latencyEl) {
if (global.latency_p50_ms !== null && global.latency_p50_ms !== undefined) {
latencyEl.textContent = `${global.latency_p50_ms.toFixed(1)} ms`;
} else {
latencyEl.textContent = 'Н/Д';
}
}
if (latencySubEl) {
const p95Str = global.latency_p95_ms != null
? (global.latency_p95_ms >= 1000 ? `${(global.latency_p95_ms / 1000).toFixed(1)} s` : `${global.latency_p95_ms.toFixed(1)} ms`)
: 'Н/Д';
const maxStr = global.latency_max_ms != null
? (global.latency_max_ms >= 1000 ? `${(global.latency_max_ms / 1000).toFixed(1)} s` : `${global.latency_max_ms.toFixed(1)} ms`)
: 'Н/Д';
latencySubEl.textContent = `p95: ${p95Str} • max: ${maxStr}`;
}
// KPI 4: Tokens (Honesty rule: null means N/D, never 0)
const tokensEl = document.getElementById('analytics-tokens-total');
const tokensSubEl = document.getElementById('analytics-tokens-sub');
if (tokensEl) {
if (global.total_tokens !== null && global.total_tokens !== undefined) {
tokensEl.textContent = global.total_tokens.toLocaleString('ru-RU');
} else {
tokensEl.textContent = 'Н/Д';
}
}
if (tokensSubEl) {
tokensSubEl.textContent = 'Н/Д: провайдеры не отдают данные о токенах';
}
// Providers Table
const provTableBox = document.getElementById('analytics-providers-table');
if (provTableBox) {
const byProv = telemetry.by_provider || {};
const provKeys = Object.keys(byProv);
if (provKeys.length === 0) {
provTableBox.innerHTML = '<div class="empty-text">Нет данных телеметрии по провайдерам.</div>';
} else {
const rowsHtml = provKeys.map((pId) => {
const pData = byProv[pId] || {};
const errPct = pData.error_rate != null ? (pData.error_rate * 100).toFixed(1) : 'Н/Д';
const p50 = pData.latency_p50_ms != null ? `${pData.latency_p50_ms.toFixed(1)} ms` : 'Н/Д';
const p95 = pData.latency_p95_ms != null
? (pData.latency_p95_ms >= 1000 ? `${(pData.latency_p95_ms / 1000).toFixed(1)} s` : `${pData.latency_p95_ms.toFixed(1)} ms`)
: 'Н/Д';
const barColor = (pData.error_rate || 0) > 0.5 ? 'var(--status-error)' : 'var(--status-healthy)';
const barW = Math.min(100, Math.max(0, (pData.error_rate || 0) * 100));
return `
<tr>
<td><strong>${escapeHtml(pId)}</strong></td>
<td>${pData.total_calls ?? 'Н/Д'}</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>
<div class="cell-bar-track">
<div class="cell-bar-fill" style="width:${barW}%; background:${barColor};"></div>
</div>
</div>
</td>
<td>${p50}</td>
<td>${p95}</td>
<td class="text-muted">Н/Д (не отдаются)</td>
</tr>
`;
}).join('');
provTableBox.innerHTML = `
<table class="data-table">
<thead>
<tr>
<th>Провайдер</th>
<th>Всего вызовов</th>
<th>Успешно</th>
<th>Сбои</th>
<th>Доля ошибок</th>
<th>p50</th>
<th>p95</th>
<th>Токены</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
`;
}
}
// Roles Table
const rolesTableBox = document.getElementById('analytics-roles-table');
if (rolesTableBox) {
const byRole = telemetry.by_role || {};
const roleKeys = Object.keys(byRole);
if (roleKeys.length === 0) {
rolesTableBox.innerHTML = '<div class="empty-text">Нет данных телеметрии по ролям агентов.</div>';
} else {
const rowsHtml = roleKeys.map((rId) => {
const rData = byRole[rId] || {};
const errPct = rData.error_rate != null ? (rData.error_rate * 100).toFixed(1) : '0.0';
const p50 = rData.latency_p50_ms != null ? `${rData.latency_p50_ms.toFixed(1)} ms` : 'Н/Д';
const p95 = rData.latency_p95_ms != null
? (rData.latency_p95_ms >= 1000 ? `${(rData.latency_p95_ms / 1000).toFixed(1)} s` : `${rData.latency_p95_ms.toFixed(1)} ms`)
: 'Н/Д';
const roleInfo = (currentSnapshot.routing || {})[rId];
const rName = (roleInfo && roleInfo.role_name_ru) || rId;
return `
<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 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>
<td>${p50}</td>
<td>${p95}</td>
</tr>
`;
}).join('');
rolesTableBox.innerHTML = `
<table class="data-table">
<thead>
<tr>
<th>Роль агента</th>
<th>Всего вызовов</th>
<th>Успешно</th>
<th>Сбои</th>
<th>Доля ошибок</th>
<th>p50</th>
<th>p95</th>
</tr>
</thead>
<tbody>
${rowsHtml}
</tbody>
</table>
`;
}
}
}
// ═══════════════════════════════════════════════════════════════
// MODALS & WIZARDS
// 7. HEALTH VIEW (P0-2 Host & System Diagnostics)
// ═══════════════════════════════════════════════════════════════
function renderHealthView() {
if (!currentSnapshot) return;
const readiness = currentSnapshot.readiness || {};
const metrics = currentSnapshot.metrics || {};
const host = metrics.host || {};
// 1. Readiness Banner
const bannerBox = document.getElementById('health-readiness-banner');
if (bannerBox) {
const st = (readiness.state || 'healthy').toLowerCase();
bannerBox.className = `readiness-banner ${st}`;
bannerBox.innerHTML = `
<div class="readiness-banner-header">
<div class="readiness-banner-title">
<span class="status-dot ${st}"></span>
<span>${escapeHtml(readiness.title_ru || 'Система готова к работе')}</span>
</div>
<span class="badge ${st === 'healthy' ? 'healthy' : ''}">${escapeHtml(readiness.state || 'HEALTHY')}</span>
</div>
<div class="readiness-banner-summary">
${escapeHtml(readiness.summary_ru || 'Все настроенные маршруты и профили доступны.')}
</div>
<div class="readiness-banner-stats">
<span>Ролей в строю: <strong>${readiness.roles_ready_count ?? 0} / ${readiness.total_roles ?? 6}</strong></span>
<span>Аккаунтов подключено: <strong>${readiness.accounts_connected_count ?? 0} / ${readiness.total_accounts ?? 0}</strong></span>
<span>Провайдеров онлайн: <strong>${readiness.providers_ready_count ?? 5} / ${readiness.total_providers ?? 5}</strong></span>
</div>
`;
}
// 2. Host Resources Grid
const hostBox = document.getElementById('health-host-resources');
if (hostBox) {
const cpuPct = host.cpu_percent != null ? host.cpu_percent.toFixed(1) : 'Н/Д';
const cpuVal = host.cpu_percent != null ? host.cpu_percent : 0;
const memPct = host.memory_percent != null ? host.memory_percent.toFixed(1) : 'Н/Д';
const memMb = host.memory_used_mb != null ? (host.memory_used_mb >= 1024 ? `${(host.memory_used_mb / 1024).toFixed(1)} GB` : `${host.memory_used_mb.toFixed(0)} MB`) : '';
const memVal = host.memory_percent != null ? host.memory_percent : 0;
const diskPct = host.disk_percent != null ? host.disk_percent.toFixed(1) : 'Н/Д';
const diskGb = host.disk_used_gb != null ? `${host.disk_used_gb.toFixed(1)} GB` : '';
const diskVal = host.disk_percent != null ? host.disk_percent : 0;
const netSpeed = host.net_speed_mbps != null ? `${host.net_speed_mbps.toFixed(1)} Mbps` : 'Н/Д';
const netSub = host.net_speed_mbps != null ? 'Активное соединение' : 'Н/Д: замер скорости сети отключён';
hostBox.innerHTML = `
<div class="host-resource-card">
<div class="host-resource-header">
<span class="host-resource-title">CPU (Процессор)</span>
<span class="host-resource-value">${cpuPct}${cpuPct !== 'Н/Д' ? '%' : ''}</span>
</div>
<div class="host-resource-bar">
<div class="host-resource-fill" style="width:${Math.min(100, Math.max(0, cpuVal))}%; background:${cpuVal > 85 ? 'var(--status-error)' : 'var(--accent)'};"></div>
</div>
<div class="host-resource-sub">Нагрузка хост-системы</div>
</div>
<div class="host-resource-card">
<div class="host-resource-header">
<span class="host-resource-title">RAM (Оперативная память)</span>
<span class="host-resource-value">${memPct}${memPct !== 'Н/Д' ? '%' : ''}</span>
</div>
<div class="host-resource-bar">
<div class="host-resource-fill" style="width:${Math.min(100, Math.max(0, memVal))}%; background:${memVal > 85 ? 'var(--status-error)' : 'var(--accent)'};"></div>
</div>
<div class="host-resource-sub">${memMb ? `Использовано: ${memMb}` : 'Статус использования RAM'}</div>
</div>
<div class="host-resource-card">
<div class="host-resource-header">
<span class="host-resource-title">Диск (Хранилище)</span>
<span class="host-resource-value">${diskPct}${diskPct !== 'Н/Д' ? '%' : ''}</span>
</div>
<div class="host-resource-bar">
<div class="host-resource-fill" style="width:${Math.min(100, Math.max(0, diskVal))}%; background:${diskVal > 90 ? 'var(--status-error)' : 'var(--accent)'};"></div>
</div>
<div class="host-resource-sub">${diskGb ? `Занято: ${diskGb}` : 'Статус дискового пространства'}</div>
</div>
<div class="host-resource-card">
<div class="host-resource-header">
<span class="host-resource-title">Сеть (Пропускная способность)</span>
<span class="host-resource-value text-muted">${netSpeed}</span>
</div>
<div class="host-resource-bar">
<div class="host-resource-fill" style="width:0%; background:var(--text-muted);"></div>
</div>
<div class="host-resource-sub">${netSub}</div>
</div>
`;
}
// 3. Warnings List
const warningsBox = document.getElementById('health-warnings-list');
if (warningsBox) {
const warnings = readiness.warnings || [];
if (warnings.length === 0) {
warningsBox.innerHTML = `
<div style="padding:14px; color:var(--status-healthy); font-size:12px; display:flex; align-items:center; gap:8px;">
<span></span>
<span>Все системы работают штатно: сбоев конфигурации и деградации маршрутов не обнаружено.</span>
</div>
`;
} else {
warningsBox.innerHTML = warnings.map((w) => `
<div class="warning-item">
<span class="warning-icon"></span>
<div class="warning-text">${escapeHtml(w)}</div>
</div>
`).join('');
}
}
}
// ═══════════════════════════════════════════════════════════════
// 8. LOGS VIEW (P0-3 GET /api/events with Filtering & Search)
// ═══════════════════════════════════════════════════════════════
async function fetchLogs() {
const container = document.getElementById('logs-container');
if (container && cachedEvents.length === 0) {
container.innerHTML = '<div class="empty-text">⏳ Загрузка журнала событий...</div>';
}
try {
const headers = {};
if (authToken) headers['X-Hub-Token'] = authToken;
const res = await fetch('/api/events?limit=100', { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
cachedEvents = data.events || [];
renderLogsList();
} catch (err) {
console.error('Failed to fetch events:', err);
if (container) {
container.innerHTML = `<div class="empty-text text-error">Не удалось получить события: ${escapeHtml(err.message)}</div>`;
}
}
}
function renderLogsView() {
fetchLogs();
}
function renderLogsList() {
const container = document.getElementById('logs-container');
if (!container) return;
const searchInput = document.getElementById('logs-search');
const levelSelect = document.getElementById('logs-filter-level');
const catSelect = document.getElementById('logs-filter-category');
const q = (searchInput ? searchInput.value : '').trim().toLowerCase();
const levelFilter = levelSelect ? levelSelect.value : 'all';
const catFilter = catSelect ? catSelect.value : 'all';
let filtered = cachedEvents.filter((ev) => {
if (levelFilter !== 'all' && (ev.level || 'info').toLowerCase() !== levelFilter.toLowerCase()) {
return false;
}
if (catFilter !== 'all' && (ev.category || '').toLowerCase() !== catFilter.toLowerCase()) {
return false;
}
if (q) {
const msg = (ev.message || '').toLowerCase();
const det = (ev.details || '').toLowerCase();
if (!msg.includes(q) && !det.includes(q)) return false;
}
return true;
});
if (filtered.length === 0) {
container.innerHTML = '<div class="empty-text">Нет событий, соответствующих выбранным фильтрам.</div>';
return;
}
container.innerHTML = filtered.map((ev) => {
const lvl = (ev.level || 'info').toLowerCase();
return `
<div class="log-entry">
<span class="log-timestamp">${escapeHtml(ev.timestamp || '—')}</span>
<span class="log-badge ${lvl}">${escapeHtml(lvl.toUpperCase())}</span>
<span class="log-category">${escapeHtml((ev.category || 'system').toUpperCase())}</span>
<div class="log-content">
<div class="log-message">${escapeHtml(ev.message || '')}</div>
${ev.details ? `<div class="log-details">${escapeHtml(ev.details)}</div>` : ''}
</div>
</div>
`;
}).join('');
}
// ═══════════════════════════════════════════════════════════════
// 9. SETTINGS VIEW (P0-4 GET /api/settings & save_settings)
// ═══════════════════════════════════════════════════════════════
async function loadServerSettings() {
try {
const headers = {};
if (authToken) headers['X-Hub-Token'] = authToken;
const res = await fetch('/api/settings', { headers });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
currentSettings = data;
populateSettingsForm(data);
} catch (err) {
console.error('Failed to load settings:', err);
}
}
function populateSettingsForm(s) {
const hostInput = document.getElementById('setting-server-host');
const portInput = document.getElementById('setting-server-port');
const tokenBadge = document.getElementById('setting-token-status-badge');
const quotaSel = document.getElementById('setting-quota-interval');
const themeSel = document.getElementById('setting-theme');
const pathHome = document.getElementById('path-hermes-home');
const pathConfig = document.getElementById('path-config-dir');
const pathLog = document.getElementById('path-log-file');
if (hostInput) hostInput.value = s.web_api_host || '127.0.0.1';
if (portInput) portInput.value = s.web_api_port || 5800;
if (tokenBadge) {
tokenBadge.textContent = s.web_api_token_configured ? '✓ Токен задан' : 'Токен не задан';
tokenBadge.className = `badge ${s.web_api_token_configured ? 'healthy' : ''}`;
}
if (quotaSel && s.quota_refresh_interval_sec) {
quotaSel.value = String(s.quota_refresh_interval_sec);
}
if (themeSel && s.theme) {
themeSel.value = s.theme;
applyTheme(s.theme);
}
if (pathHome) pathHome.textContent = s.hermes_home || '~/.hermes';
if (pathConfig) pathConfig.textContent = s.config_dir || '~/.hermes/config';
if (pathLog) pathLog.textContent = s.log_file || '~/.hermes/logs/hermes-hub.log';
}
function applyTheme(theme) {
if (theme === 'light') {
document.body.setAttribute('data-theme', 'light');
document.body.classList.add('theme-light');
} else {
document.body.removeAttribute('data-theme');
document.body.classList.remove('theme-light');
}
}
function renderSettingsView() {
loadServerSettings();
}
async function saveHubServerSettings() {
const hostInput = document.getElementById('setting-server-host');
const portInput = document.getElementById('setting-server-port');
const tokenInput = document.getElementById('setting-server-token-input');
const quotaSel = document.getElementById('setting-quota-interval');
const themeSel = document.getElementById('setting-theme');
const payload = {
web_api_host: hostInput ? hostInput.value.trim() : '127.0.0.1',
web_api_port: portInput ? parseInt(portInput.value, 10) || 5800 : 5800,
quota_refresh_interval_sec: quotaSel ? parseInt(quotaSel.value, 10) || 300 : 300,
theme: themeSel ? themeSel.value : 'system',
};
if (tokenInput && tokenInput.value.trim()) {
payload.web_api_token = tokenInput.value.trim();
}
const res = await executeAction('save_settings', payload);
if (res.ok) {
if (themeSel) applyTheme(themeSel.value);
showToast('Настройки сервера успешно сохранены', 'success');
if (tokenInput) tokenInput.value = '';
loadServerSettings();
}
}
// ── MODALS (Account Details, Model Choice, Routing, Wizard) ──
function openAccountDetailsModal(profileId) {
if (!currentSnapshot) return;
const profile = (currentSnapshot.all_profiles || {})[profileId];
if (!profile) return;
const quotaSnap = profile.quota_snapshot || (currentSnapshot.quotas || {})[profileId];
const buckets = (quotaSnap && quotaSnap.buckets) ? quotaSnap.buckets : [];
const provSummary = (currentSnapshot.providers || []).find(p => p.provider_id === profile.provider);
const discoveredModels = (provSummary && provSummary.discovered_models) ? provSummary.discovered_models : [];
const currentModel = (profile.preferred_models && profile.preferred_models.length) ? profile.preferred_models[0] : '';
const qs = profile.quota_snapshot;
const buckets = (qs && qs.buckets) ? qs.buckets : [];
let modelBlockHtml = '';
if (discoveredModels.length > 0) {
modelBlockHtml = `
<h3 style="font-size:13px; font-weight:700; margin:14px 0 6px; border-bottom:1px solid var(--border-subtle); padding-bottom:4px;">
Выбор модели по умолчанию
</h3>
<div style="display:flex; gap:8px; align-items:center; margin-bottom:16px;">
<select id="modal-model-select" class="select-filter" style="flex:1;">
${discoveredModels.map(m => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
</select>
<button class="btn btn-secondary btn-sm" onclick="handleSaveProfileModel('${escapeHtml(profileId)}')">Сохранить модель</button>
<button class="btn btn-ghost btn-sm" onclick="handleRefreshProviderModels('${escapeHtml(profile.provider)}', '${escapeHtml(profileId)}')"> Обновить список</button>
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<label style="display:block; font-weight:600; font-size:12px; margin-bottom:6px;">Предпочитаемая модель профиля:</label>
<div style="display:flex; gap:8px;">
<select id="modal-model-select" class="select-filter" style="flex:1;">
${discoveredModels.map(m => `<option value="${escapeHtml(m)}" ${m === currentModel ? 'selected' : ''}>${escapeHtml(m)}</option>`).join('')}
</select>
<button class="btn btn-secondary btn-sm" onclick="handleSaveProfileModel('${escapeHtml(profileId)}')">Сохранить</button>
</div>
</div>
`;
} else {
modelBlockHtml = `
<h3 style="font-size:13px; font-weight:700; margin:14px 0 6px; border-bottom:1px solid var(--border-subtle); padding-bottom:4px;">
Выбор модели по умолчанию
</h3>
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:16px;">
<div style="background:var(--surface-muted); padding:10px 12px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<div style="font-size:12px; color:var(--status-warning); margin-bottom:6px;">
Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
</div>
@ -745,7 +1224,7 @@ function openAccountDetailsModal(profileId) {
`;
}
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name} (${profileId})`;
elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
elements.modalBody.innerHTML = `
<div id="modal-feedback-area"></div>
<div style="margin-bottom:14px;">
@ -976,7 +1455,7 @@ function showWizardStep2(providerId) {
if (providerId === 'grok' || providerId === 'openai-codex') {
bodyHtml = `
<div style="margin-bottom:12px; font-size:13px; color:var(--text-secondary);">
Шаг 2 из 3: Авторизация ${providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex'}
Шаг 2 из 3: Авторизация ${providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex'} (Device Code OAuth)
</div>
<div style="background:var(--surface-muted); padding:14px; border-radius:var(--radius-sm); border:1px solid var(--border-subtle); margin-bottom:14px;">
<div style="font-weight:700; margin-bottom:6px;">1. Откройте ссылку на любом устройстве:</div>
@ -1092,6 +1571,7 @@ async function finishAddAccount(providerId) {
}
}
// ── Routing Pipeline Modal ──
function openEditRouteModal(roleId) {
if (!currentSnapshot) return;
const pipeline = (currentSnapshot.routing || {})[roleId];
@ -1180,7 +1660,7 @@ async function saveRouteChain(roleId) {
// ── SETTINGS MANAGEMENT ──
function initSettings() {
const btnSave = document.getElementById('btn-save-client-settings');
const tokenInput = document.getElementById('setting-auth-token');
const tokenInput = document.getElementById('setting-client-token-input');
const pollSelect = document.getElementById('setting-poll-interval');
if (tokenInput && authToken) {

View file

@ -1,4 +1,4 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
@ -9,7 +9,7 @@
</head>
<body>
<div id="app" class="app-layout">
<!-- Sidebar Navigation -->
<!-- Sidebar Navigation (9 Full Views) -->
<aside class="sidebar">
<div class="brand">
<div class="brand-logo"></div>
@ -41,6 +41,14 @@
<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>
</button>
<button class="nav-item" data-view="health">
<span class="nav-icon">🩺</span>
<span class="nav-label">Состояние</span>
</button>
<button class="nav-item" data-view="logs">
<span class="nav-icon">📜</span>
<span class="nav-label">Журнал событий</span>
@ -196,31 +204,208 @@
</div>
</section>
<!-- 6. LOGS VIEW -->
<section id="view-logs" class="view-pane">
<!-- 6. ANALYTICS VIEW (P0-1) -->
<section id="view-analytics" class="view-pane">
<div class="overview-grid">
<div class="kpi-card">
<div class="kpi-label">Всего вызовов (24ч)</div>
<div class="kpi-value text-accent" id="analytics-total-calls"></div>
<div class="kpi-sub" id="analytics-calls-breakdown"></div>
</div>
<div class="kpi-card">
<div class="kpi-label">Доля отказов / ошибок</div>
<div class="kpi-value" id="analytics-error-rate"></div>
<div class="kpi-sub" id="analytics-error-rate-sub"></div>
</div>
<div class="kpi-card">
<div class="kpi-label">Латентность (p50 / p95 / max)</div>
<div class="kpi-value text-info" id="analytics-latency-p50"></div>
<div class="kpi-sub" id="analytics-latency-sub"></div>
</div>
<div class="kpi-card">
<div class="kpi-label">Расход токенов</div>
<div class="kpi-value text-muted" id="analytics-tokens-total">Н</div>
<div class="kpi-sub" id="analytics-tokens-sub">Н/Д: провайдеры не отдают токены</div>
</div>
</div>
<div class="section-card">
<div class="section-card-header">
<div class="section-card-title">Журнал телеметрии и событий роутера</div>
<button class="btn btn-secondary btn-sm" id="btn-clear-logs">Очистить вид</button>
<div class="section-card-title">Телеметрия по провайдерам ИИ</div>
<div class="section-card-subtitle">Распределение запросов, отказов и задержек в окне измерений</div>
</div>
<div class="logs-container" id="logs-container">
<div class="empty-text">Журнал событий пуст или сбор ещё не выполнен.</div>
<div class="table-responsive" id="analytics-providers-table">
<!-- Rendered by app.js -->
</div>
</div>
<div class="section-card">
<div class="section-card-header">
<div class="section-card-title">Телеметрия по ролям агентов</div>
<div class="section-card-subtitle">Нагрузка по ролям команды, частота сбоев и тайминги</div>
</div>
<div class="table-responsive" id="analytics-roles-table">
<!-- Rendered by app.js -->
</div>
</div>
</section>
<!-- 7. SETTINGS VIEW -->
<!-- 7. HEALTH VIEW (P0-2) -->
<section id="view-health" class="view-pane">
<div class="readiness-banner" id="health-readiness-banner">
<!-- Readiness state banner rendered by app.js -->
</div>
<div class="section-card">
<div class="section-card-header">
<div class="section-card-title">Ресурсы хост-системы</div>
<div class="section-card-subtitle">Мониторинг CPU, памяти, диска и сетевых параметров</div>
</div>
<div class="host-resources-grid" id="health-host-resources">
<!-- Rendered by app.js -->
</div>
</div>
<div class="section-card">
<div class="section-card-header">
<div class="section-card-title">Предупреждения и диагностика</div>
<div class="section-card-subtitle">Анализ рисков, отсутствие резервов и деградация маршрутов</div>
</div>
<div class="warnings-list" id="health-warnings-list">
<!-- Rendered by app.js -->
</div>
</div>
</section>
<!-- 8. LOGS / EVENTS VIEW (P0-3) -->
<section id="view-logs" class="view-pane">
<div class="toolbar">
<div class="search-box">
<span class="search-icon">🔍</span>
<input type="text" id="logs-search" placeholder="Поиск по событиям или деталям...">
</div>
<div class="filters-row">
<select id="logs-filter-level" class="select-filter">
<option value="all">Все уровни</option>
<option value="info">INFO</option>
<option value="success">SUCCESS</option>
<option value="warning">WARNING</option>
<option value="error">ERROR</option>
</select>
<select id="logs-filter-category" class="select-filter">
<option value="all">Все категории</option>
<option value="system">Система (system)</option>
<option value="account">Аккаунты (account)</option>
<option value="quota">Квоты (quota)</option>
<option value="routing">Маршрутизация (routing)</option>
<option value="auth">Авторизация (auth)</option>
</select>
<button class="btn btn-secondary btn-sm" id="btn-refresh-logs">↻ Обновить журнал</button>
</div>
</div>
<div class="section-card" style="margin-top:12px;">
<div class="section-card-header">
<div class="section-card-title">Журнал событий Hermes Hub</div>
<div class="section-card-subtitle">События маршрутизатора, обновления квот, авторизации и тестов</div>
</div>
<div class="logs-container" id="logs-container">
<!-- Rendered by app.js from /api/events -->
</div>
</div>
</section>
<!-- 9. SETTINGS VIEW (P0-4) -->
<section id="view-settings" class="view-pane">
<!-- Hub Server Settings -->
<div class="settings-card">
<h2 class="settings-group-title">Параметры веб-клиента</h2>
<h2 class="settings-group-title">Параметры сервера Hermes Hub</h2>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Источник данных</div>
<div class="setting-desc">Автоматическое переключение между живым сервером и фикстурой</div>
<div class="setting-label">Хост и порт веб-сервера</div>
<div class="setting-desc">Сетевой адрес и порт для HTTP/REST API и веб-интерфейса</div>
</div>
<div class="setting-control" style="display:flex; gap:8px;">
<input type="text" id="setting-server-host" class="input-text" style="width:140px;" placeholder="127.0.0.1">
<input type="number" id="setting-server-port" class="input-text" style="width:90px;" placeholder="5800">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Токен безопасности сервера</div>
<div class="setting-desc">Обязателен при внешнем подключении (host != 127.0.0.1)</div>
</div>
<div class="setting-control" style="display:flex; align-items:center; gap:8px;">
<span id="setting-token-status-badge" class="badge">Токен не задан</span>
<input type="password" id="setting-server-token-input" class="input-text" placeholder="Задать новый токен...">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Интервал обновления квот</div>
<div class="setting-desc">Период фонового опроса квот провайдеров (секунды)</div>
</div>
<div class="setting-control">
<select id="setting-quota-interval" class="select-filter">
<option value="60">60 секунд (1 мин)</option>
<option value="120">120 секунд (2 мин)</option>
<option value="300">300 секунд (5 мин — стандарт)</option>
<option value="600">600 секунд (10 мин)</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Тема оформления</div>
<div class="setting-desc">Цветовая схема интерфейса Hub</div>
</div>
<div class="setting-control">
<select id="setting-theme" class="select-filter">
<option value="system">Системная (по умолчанию)</option>
<option value="dark">Тёмная (Dark)</option>
<option value="light">Светлая (Light)</option>
</select>
</div>
</div>
<div class="settings-actions">
<button class="btn btn-primary" id="btn-save-hub-settings">Сохранить настройки сервера</button>
</div>
</div>
<!-- Read-Only System Paths -->
<div class="settings-card" style="margin-top:16px;">
<h2 class="settings-group-title">Системные пути и окружение</h2>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Каталог данных Hermes</div>
<div class="setting-desc mono-path" id="path-hermes-home"></div>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Каталог конфигурации</div>
<div class="setting-desc mono-path" id="path-config-dir"></div>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Файл журнала событий</div>
<div class="setting-desc mono-path" id="path-log-file"></div>
</div>
</div>
</div>
<!-- Client Local Settings -->
<div class="settings-card" style="margin-top:16px;">
<h2 class="settings-group-title">Параметры веб-клиента (Браузер)</h2>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Источник данных в браузере</div>
<div class="setting-desc">Автоматическое переключение между Live API и фикстурой</div>
</div>
<div class="setting-control">
<select id="setting-source-mode" class="select-filter">
<option value="auto">Авто (Live API с fallback на фикстуру)</option>
<option value="auto">Авто (Live API с fallback)</option>
<option value="live">Строго Live API (/api/snapshot)</option>
<option value="fixture">Строго фикстура (snapshot.example.json)</option>
</select>
@ -228,8 +413,8 @@
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Интервал авто-опроса</div>
<div class="setting-desc">Частота обновления снимка состояния с сервера</div>
<div class="setting-label">Интервал авто-опроса UI</div>
<div class="setting-desc">Частота обновления снимка состояния в окне браузера</div>
</div>
<div class="setting-control">
<select id="setting-poll-interval" class="select-filter">
@ -242,15 +427,15 @@
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Токен безопасности (X-Hub-Token)</div>
<div class="setting-desc">Обязателен при запуске сервера на нелокальном IP адресе</div>
<div class="setting-label">Локальный токен клиента (X-Hub-Token)</div>
<div class="setting-desc">Токен для заголовка X-Hub-Token при внешних вызовах</div>
</div>
<div class="setting-control">
<input type="password" id="setting-auth-token" class="input-text" placeholder="Введите токен сервера...">
<input type="password" id="setting-client-token-input" class="input-text" placeholder="Введите токен...">
</div>
</div>
<div class="settings-actions">
<button class="btn btn-primary" id="btn-save-client-settings">Сохранить параметры</button>
<button class="btn btn-secondary" id="btn-save-client-settings">Сохранить локальные параметры</button>
</div>
</div>
</section>

View file

@ -1,4 +1,4 @@
/* Hermes Hub Web Client — Dark Theme & Cockpit Design System */
/* Hermes Hub Web Client — Dark Theme & Cockpit Design System */
:root {
--bg-base: #061916;
@ -1042,3 +1042,311 @@ body {
::-webkit-scrollbar-thumb:hover {
background: var(--border-hover);
}
/* ── Data Tables (Analytics) ── */
.table-responsive {
width: 100%;
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
text-align: left;
}
.data-table th {
background-color: var(--surface-muted);
color: var(--text-muted);
font-weight: 600;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.5px;
}
.data-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
}
.data-table tr:hover td {
background-color: var(--surface-hover);
}
.cell-bar-container {
display: flex;
align-items: center;
gap: 8px;
min-width: 120px;
}
.cell-bar-track {
flex: 1;
height: 6px;
background: var(--surface-muted);
border-radius: 3px;
overflow: hidden;
}
.cell-bar-fill {
height: 100%;
border-radius: 3px;
}
/* ── Readiness Banner (Health) ── */
.readiness-banner {
background-color: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: 18px 20px;
margin-bottom: 16px;
border-left: 4px solid var(--status-healthy);
}
.readiness-banner.degraded {
border-left-color: var(--status-warning);
}
.readiness-banner.critical {
border-left-color: var(--status-error);
}
.readiness-banner.limited {
border-left-color: var(--status-info);
}
.readiness-banner-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.readiness-banner-title {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
display: flex;
align-items: center;
gap: 8px;
}
.readiness-banner-summary {
font-size: 13px;
color: var(--text-secondary);
margin-bottom: 12px;
}
.readiness-banner-stats {
display: flex;
gap: 16px;
flex-wrap: wrap;
font-size: 12px;
color: var(--text-muted);
}
.readiness-banner-stats strong {
color: var(--text-primary);
}
/* ── Host Resources Grid (Health) ── */
.host-resources-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
padding: 14px;
}
.host-resource-card {
background-color: var(--surface-muted);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm);
padding: 14px;
}
.host-resource-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.host-resource-title {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
}
.host-resource-value {
font-size: 16px;
font-weight: 700;
color: var(--text-primary);
}
.host-resource-bar {
height: 6px;
background: var(--surface);
border-radius: 3px;
overflow: hidden;
margin-bottom: 6px;
}
.host-resource-fill {
height: 100%;
border-radius: 3px;
background: var(--accent);
}
.host-resource-sub {
font-size: 11px;
color: var(--text-muted);
}
/* ── Warnings List (Health) ── */
.warnings-list {
padding: 14px;
}
.warning-item {
display: flex;
align-items: flex-start;
gap: 10px;
background-color: rgba(225, 166, 43, 0.08);
border: 1px solid var(--status-warning);
border-radius: var(--radius-sm);
padding: 10px 14px;
margin-bottom: 8px;
font-size: 12px;
color: var(--text-primary);
}
.warning-item:last-child {
margin-bottom: 0;
}
.warning-icon {
font-size: 14px;
color: var(--status-warning);
line-height: 1.2;
}
/* ── Logs Feed (Events) ── */
.logs-container {
max-height: 620px;
overflow-y: auto;
padding: 8px;
}
.log-entry {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 10px;
border-bottom: 1px solid var(--border-subtle);
font-size: 12px;
}
.log-entry:hover {
background-color: var(--surface-hover);
}
.log-timestamp {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-muted);
min-width: 65px;
}
.log-badge {
padding: 2px 6px;
border-radius: 3px;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
min-width: 54px;
text-align: center;
}
.log-badge.info { background: rgba(76, 141, 216, 0.2); color: var(--status-info); border: 1px solid var(--status-info); }
.log-badge.success { background: rgba(114, 201, 67, 0.2); color: var(--status-healthy); border: 1px solid var(--status-healthy); }
.log-badge.warning { background: rgba(225, 166, 43, 0.2); color: var(--status-warning); border: 1px solid var(--status-warning); }
.log-badge.error { background: rgba(228, 92, 79, 0.2); color: var(--status-error); border: 1px solid var(--status-error); }
.log-category {
font-size: 10px;
padding: 2px 6px;
border-radius: 3px;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
}
.log-content {
flex: 1;
}
.log-message {
color: var(--text-primary);
font-weight: 500;
}
.log-details {
font-size: 11px;
color: var(--text-muted);
margin-top: 2px;
font-family: var(--font-mono);
word-break: break-all;
}
/* ── Settings View ── */
.mono-path {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
background: var(--surface-muted);
padding: 4px 8px;
border-radius: 3px;
border: 1px solid var(--border-subtle);
display: inline-block;
margin-top: 4px;
}
.badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
background: var(--surface-muted);
border: 1px solid var(--border-subtle);
color: var(--text-muted);
}
.badge.healthy {
background: rgba(114, 201, 67, 0.15);
border-color: var(--status-healthy);
color: var(--status-healthy);
}
/* ── Light Theme Override ── */
body[data-theme="light"], body.theme-light {
--bg-base: #F4F6F8;
--bg-sidebar: #E9ECEF;
--bg-header: #FFFFFF;
--bg-statusbar: #DEE2E6;
--surface: #FFFFFF;
--surface-hover: #F1F3F5;
--surface-active: #E9ECEF;
--surface-selected: #E2E6EA;
--surface-muted: #F8F9FA;
--border: #CED4DA;
--border-subtle: #DEE2E6;
--border-accent: #B78525;
--text-primary: #212529;
--text-secondary: #495057;
--text-muted: #6C757D;
}

View file

@ -0,0 +1,142 @@
"""
Hermes Hub Task A21 Web Parity Test Suite
Verifies endpoints /api/events, /api/settings, security, and full 9-view parity.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from antigravity_provider.router.web.server import app, sanitize_snapshot
from antigravity_provider.router.unified_health import EventLogService
from antigravity_provider import paths
REPO_ROOT = Path(__file__).resolve().parent.parent
STATIC_DIR = REPO_ROOT / "src" / "antigravity_provider" / "router" / "web" / "static"
@pytest.fixture
def client():
return TestClient(app)
def test_api_events_endpoint_and_security(client):
"""Verify GET /api/events returns reverse-chronological sanitized events list."""
event_svc = EventLogService.get()
event_svc.log("system", "Test event 1 — normal log", level="info")
event_svc.log("account", "Test event 2 with bearer token access_token=secret_12345", level="warning")
event_svc.log("quota", "Test event 3 — quota exhausted", level="error")
res = client.get("/api/events?limit=10")
assert res.status_code == 200
data = res.json()
assert "events" in data
events = data["events"]
assert len(events) >= 3
# Check reverse chronological order (latest event first)
assert events[0]["message"] == "Test event 3 — quota exhausted"
assert events[0]["level"] == "error"
assert events[0]["category"] == "quota"
# Verify secret sanitization on event payload
raw_str = json.dumps(data)
assert "secret_12345" not in raw_str
def test_api_events_category_filter(client):
"""Verify category filtering in GET /api/events."""
event_svc = EventLogService.get()
event_svc.log("routing", "Route failover test event", level="info")
res = client.get("/api/events?category=routing&limit=50")
assert res.status_code == 200
data = res.json()
assert "events" in data
for ev in data["events"]:
assert ev["category"] == "routing"
def test_api_settings_endpoint_no_raw_tokens(client, tmp_path, monkeypatch):
"""Verify GET /api/settings never exposes raw web_api_token, returns paths and boolean configured flag."""
settings_file = paths.get_hermes_home() / "hub_settings.json"
orig_content = settings_file.read_text(encoding="utf-8") if settings_file.exists() else "{}"
try:
# Write test settings with a secret token
settings_file.write_text(json.dumps({
"web_api_host": "127.0.0.1",
"web_api_port": 5800,
"web_api_token": "super_secret_hub_token_xyz",
"theme": "dark",
"quota_refresh_interval_sec": 120
}), encoding="utf-8")
res = client.get("/api/settings")
assert res.status_code == 200
data = res.json()
# Token must NEVER be exposed
assert "web_api_token" not in data
assert "super_secret_hub_token_xyz" not in json.dumps(data)
# Configured flag must be true
assert data.get("web_api_token_configured") is True
assert data.get("web_api_host") == "127.0.0.1"
assert data.get("web_api_port") == 5800
assert data.get("theme") == "dark"
assert data.get("quota_refresh_interval_sec") == 120
assert "hermes_home" in data
assert "config_dir" in data
assert "log_file" in data
finally:
if orig_content != "{}":
settings_file.write_text(orig_content, encoding="utf-8")
elif settings_file.exists():
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."""
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"
]
for v in expected_views:
# Nav item exists
assert f'data-view="{v}"' in index_html, f"Missing nav button for view: {v}"
# Section container exists
assert f'id="view-{v}"' in index_html, f"Missing section #view-{v} in index.html"
# Analytics view elements
assert "analytics-total-calls" in index_html
assert "analytics-error-rate" in index_html
assert "analytics-latency-p50" in index_html
assert "analytics-tokens-total" in index_html
assert "renderAnalyticsView" in app_js
# Health view elements
assert "health-readiness-banner" in index_html
assert "health-host-resources" in index_html
assert "health-warnings-list" in index_html
assert "renderHealthView" in app_js
# Logs view elements
assert "logs-filter-level" in index_html
assert "logs-filter-category" in index_html
assert "logs-search" in index_html
assert "renderLogsView" in app_js
# Settings view elements
assert "setting-server-host" in index_html
assert "setting-server-port" in index_html
assert "setting-theme" in index_html
assert "renderSettingsView" in app_js
assert "saveHubServerSettings" in app_js