diff --git a/artifacts/a21-screenshots/01_analytics_view.png b/artifacts/a21-screenshots/01_analytics_view.png
new file mode 100644
index 0000000..c981c92
Binary files /dev/null and b/artifacts/a21-screenshots/01_analytics_view.png differ
diff --git a/artifacts/a21-screenshots/02_health_view.png b/artifacts/a21-screenshots/02_health_view.png
new file mode 100644
index 0000000..71331fa
Binary files /dev/null and b/artifacts/a21-screenshots/02_health_view.png differ
diff --git a/artifacts/a21-screenshots/03_logs_view.png b/artifacts/a21-screenshots/03_logs_view.png
new file mode 100644
index 0000000..374bd2d
Binary files /dev/null and b/artifacts/a21-screenshots/03_logs_view.png differ
diff --git a/artifacts/a21-screenshots/04_settings_view.png b/artifacts/a21-screenshots/04_settings_view.png
new file mode 100644
index 0000000..cb4522d
Binary files /dev/null and b/artifacts/a21-screenshots/04_settings_view.png differ
diff --git a/docs/web-api/CONTRACT.md b/docs/web-api/CONTRACT.md
index 6a26051..5ff8627 100644
--- a/docs/web-api/CONTRACT.md
+++ b/docs/web-api/CONTRACT.md
@@ -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/` монтируется целиком.
diff --git a/scripts/capture_live_a21_screenshots.py b/scripts/capture_live_a21_screenshots.py
new file mode 100644
index 0000000..ba56da3
--- /dev/null
+++ b/scripts/capture_live_a21_screenshots.py
@@ -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()
diff --git a/src/antigravity_provider/router/web/server.py b/src/antigravity_provider/router/web/server.py
index 57d2a0c..5d473e1 100644
--- a/src/antigravity_provider/router/web/server.py
+++ b/src/antigravity_provider/router/web/server.py
@@ -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"))
diff --git a/src/antigravity_provider/router/web/static/app.js b/src/antigravity_provider/router/web/static/app.js
index ec46300..0c5337f 100644
--- a/src/antigravity_provider/router/web/static/app.js
+++ b/src/antigravity_provider/router/web/static/app.js
@@ -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 = '
Журнал очищен пользователем.
';
+ // 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) {
${escapeHtml(bucket.display_name)}
- ${escapeHtml(formattedValue)}
+ ${formattedValue}
${escapeHtml(resetText)}
@@ -599,20 +628,19 @@ function renderRoutingView() {
${escapeHtml(node.display_name || node.profile_id)}
${escapeHtml(node.provider)} • ${escapeHtml(node.model)}
- ${node.failover_reason ? `Причина: ${escapeHtml(node.failover_reason)}
` : ''}
+ ${node.failover_reason ? `⚠ ${escapeHtml(node.failover_reason)}
` : ''}
- ${index < nodes.length - 1 ? '→ ' : ''}
- `).join('')}
+ `).join('') || 'Цепочка не настроена.
'}
`;
}
- container.innerHTML = html || 'Маршрутизация не настроена.
';
+ container.innerHTML = html || 'Маршруты отсутствуют.
';
}
// ═══════════════════════════════════════════════════════════════
-// 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) => `
-
+
-
- Онлайн: ${prov.online_count}/${prov.connected_count} •
- Требуют авторизации: ${prov.auth_required_count} •
- Квота исчерпана: ${prov.quota_exhausted_count} •
- Холодный резерв: ${prov.cold_spare_count}
-
-
-
-
Обнаруженные модели:
- ${prov.discovered_models && prov.discovered_models.length ? escapeHtml(prov.discovered_models.join(' • ')) : 'Н/Д — список моделей ещё не получен от провайдера'}
+
+
+ Обнаруженные модели:
+
+
+ ${(prov.discovered_models && prov.discovered_models.length > 0)
+ ? prov.discovered_models.map(m => `${escapeHtml(m)} `).join('')
+ : 'Н/Д — список моделей ещё не получен от провайдера '
+ }
+
- `).join('') || '
Список провайдеров пуст.
';
+ `).join('') || '
Нет данных провайдеров.
';
}
// ═══════════════════════════════════════════════════════════════
-// 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) => `
-
-
-
${escapeHtml(agent.role_name_ru || agent.role_id)}
- ${agent.is_main_orchestrator ? '
👑 ЛИДЕР ' : ''}
+ container.innerHTML = agents.map((ag) => `
+
+
-
${escapeHtml(agent.role_description_ru || '')}
-
-
Профиль: ${escapeHtml(agent.assigned_profile_id || 'Не назначен')}
-
Провайдер: ${escapeHtml(agent.provider_display_name || agent.provider)}
-
Модель: ${escapeHtml(agent.model || '—')}
-
-
-
● ${escapeHtml(agent.status_label_ru || 'Работает')}
-
-
- Сменить модель
-
-
- Детали →
-
+
+
+
Назначенный аккаунт:
+
${escapeHtml(ag.assigned_display_name || ag.assigned_profile_id || 'Не назначен')}
+
+ Провайдер: ${escapeHtml(ag.provider_display_name || ag.provider)} • Модель: ${escapeHtml(ag.model || 'default')}
+
+
- `).join('') || '
Команда агентов пуста.
';
+ `).join('') || '
Список агентов пуст.
';
}
// ═══════════════════════════════════════════════════════════════
-// 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) => `
-
- [${escapeHtml(log.time || '')}]
- ${escapeHtml(log.role || '')} :
- ${escapeHtml(log.message || '')}
-
- `).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 = '
Нет данных телеметрии по провайдерам.
';
+ } 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 `
+
+ ${escapeHtml(pId)}
+ ${pData.total_calls ?? 'Н/Д'}
+ ${pData.successful_calls ?? 0}
+ ${pData.failed_calls ?? 0}
+
+
+
${errPct}${errPct !== 'Н/Д' ? '%' : ''}
+
+
+
+ ${p50}
+ ${p95}
+ Н/Д (не отдаются)
+
+ `;
+ }).join('');
+
+ provTableBox.innerHTML = `
+
+
+
+ Провайдер
+ Всего вызовов
+ Успешно
+ Сбои
+ Доля ошибок
+ p50
+ p95
+ Токены
+
+
+
+ ${rowsHtml}
+
+
+ `;
+ }
+ }
+
+ // Roles Table
+ const rolesTableBox = document.getElementById('analytics-roles-table');
+ if (rolesTableBox) {
+ const byRole = telemetry.by_role || {};
+ const roleKeys = Object.keys(byRole);
+ if (roleKeys.length === 0) {
+ rolesTableBox.innerHTML = '
Нет данных телеметрии по ролям агентов.
';
+ } else {
+ const rowsHtml = roleKeys.map((rId) => {
+ const rData = byRole[rId] || {};
+ const errPct = rData.error_rate != null ? (rData.error_rate * 100).toFixed(1) : '0.0';
+ const p50 = rData.latency_p50_ms != null ? `${rData.latency_p50_ms.toFixed(1)} ms` : 'Н/Д';
+ const p95 = rData.latency_p95_ms != null
+ ? (rData.latency_p95_ms >= 1000 ? `${(rData.latency_p95_ms / 1000).toFixed(1)} s` : `${rData.latency_p95_ms.toFixed(1)} ms`)
+ : 'Н/Д';
+ const roleInfo = (currentSnapshot.routing || {})[rId];
+ const rName = (roleInfo && roleInfo.role_name_ru) || rId;
+
+ return `
+
+ ${escapeHtml(rName)} (${escapeHtml(rId)})
+ ${rData.total_calls ?? 'Н/Д'}
+ ${(rData.total_calls ?? 0) - (rData.failed_calls ?? 0)}
+ ${rData.failed_calls ?? 0}
+ ${errPct}%
+ ${p50}
+ ${p95}
+
+ `;
+ }).join('');
+
+ rolesTableBox.innerHTML = `
+
+
+
+ Роль агента
+ Всего вызовов
+ Успешно
+ Сбои
+ Доля ошибок
+ p50
+ p95
+
+
+
+ ${rowsHtml}
+
+
+ `;
+ }
}
}
// ═══════════════════════════════════════════════════════════════
-// 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 = `
+
+
+ ${escapeHtml(readiness.summary_ru || 'Все настроенные маршруты и профили доступны.')}
+
+
+ Ролей в строю: ${readiness.roles_ready_count ?? 0} / ${readiness.total_roles ?? 6}
+ Аккаунтов подключено: ${readiness.accounts_connected_count ?? 0} / ${readiness.total_accounts ?? 0}
+ Провайдеров онлайн: ${readiness.providers_ready_count ?? 5} / ${readiness.total_providers ?? 5}
+
+ `;
+ }
+
+ // 2. Host Resources Grid
+ const hostBox = document.getElementById('health-host-resources');
+ if (hostBox) {
+ const cpuPct = host.cpu_percent != null ? host.cpu_percent.toFixed(1) : 'Н/Д';
+ const cpuVal = host.cpu_percent != null ? host.cpu_percent : 0;
+
+ const memPct = host.memory_percent != null ? host.memory_percent.toFixed(1) : 'Н/Д';
+ const memMb = host.memory_used_mb != null ? (host.memory_used_mb >= 1024 ? `${(host.memory_used_mb / 1024).toFixed(1)} GB` : `${host.memory_used_mb.toFixed(0)} MB`) : '';
+ const memVal = host.memory_percent != null ? host.memory_percent : 0;
+
+ const diskPct = host.disk_percent != null ? host.disk_percent.toFixed(1) : 'Н/Д';
+ const diskGb = host.disk_used_gb != null ? `${host.disk_used_gb.toFixed(1)} GB` : '';
+ const diskVal = host.disk_percent != null ? host.disk_percent : 0;
+
+ const netSpeed = host.net_speed_mbps != null ? `${host.net_speed_mbps.toFixed(1)} Mbps` : 'Н/Д';
+ const netSub = host.net_speed_mbps != null ? 'Активное соединение' : 'Н/Д: замер скорости сети отключён';
+
+ hostBox.innerHTML = `
+
+
+
+
Нагрузка хост-системы
+
+
+
+
+
+
${memMb ? `Использовано: ${memMb}` : 'Статус использования RAM'}
+
+
+
+
+
+
${diskGb ? `Занято: ${diskGb}` : 'Статус дискового пространства'}
+
+
+
+ `;
+ }
+
+ // 3. Warnings List
+ const warningsBox = document.getElementById('health-warnings-list');
+ if (warningsBox) {
+ const warnings = readiness.warnings || [];
+ if (warnings.length === 0) {
+ warningsBox.innerHTML = `
+
+ ✓
+ Все системы работают штатно: сбоев конфигурации и деградации маршрутов не обнаружено.
+
+ `;
+ } else {
+ warningsBox.innerHTML = warnings.map((w) => `
+
+
⚠️
+
${escapeHtml(w)}
+
+ `).join('');
+ }
+ }
+}
+
+// ═══════════════════════════════════════════════════════════════
+// 8. LOGS VIEW (P0-3 GET /api/events with Filtering & Search)
+// ═══════════════════════════════════════════════════════════════
+async function fetchLogs() {
+ const container = document.getElementById('logs-container');
+ if (container && cachedEvents.length === 0) {
+ container.innerHTML = '
⏳ Загрузка журнала событий...
';
+ }
+
+ try {
+ const headers = {};
+ if (authToken) headers['X-Hub-Token'] = authToken;
+
+ const res = await fetch('/api/events?limit=100', { headers });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ cachedEvents = data.events || [];
+ renderLogsList();
+ } catch (err) {
+ console.error('Failed to fetch events:', err);
+ if (container) {
+ container.innerHTML = `
Не удалось получить события: ${escapeHtml(err.message)}
`;
+ }
+ }
+}
+
+function renderLogsView() {
+ fetchLogs();
+}
+
+function renderLogsList() {
+ const container = document.getElementById('logs-container');
+ if (!container) return;
+
+ const searchInput = document.getElementById('logs-search');
+ const levelSelect = document.getElementById('logs-filter-level');
+ const catSelect = document.getElementById('logs-filter-category');
+
+ const q = (searchInput ? searchInput.value : '').trim().toLowerCase();
+ const levelFilter = levelSelect ? levelSelect.value : 'all';
+ const catFilter = catSelect ? catSelect.value : 'all';
+
+ let filtered = cachedEvents.filter((ev) => {
+ if (levelFilter !== 'all' && (ev.level || 'info').toLowerCase() !== levelFilter.toLowerCase()) {
+ return false;
+ }
+ if (catFilter !== 'all' && (ev.category || '').toLowerCase() !== catFilter.toLowerCase()) {
+ return false;
+ }
+ if (q) {
+ const msg = (ev.message || '').toLowerCase();
+ const det = (ev.details || '').toLowerCase();
+ if (!msg.includes(q) && !det.includes(q)) return false;
+ }
+ return true;
+ });
+
+ if (filtered.length === 0) {
+ container.innerHTML = '
Нет событий, соответствующих выбранным фильтрам.
';
+ return;
+ }
+
+ container.innerHTML = filtered.map((ev) => {
+ const lvl = (ev.level || 'info').toLowerCase();
+ return `
+
+
${escapeHtml(ev.timestamp || '—')}
+
${escapeHtml(lvl.toUpperCase())}
+
${escapeHtml((ev.category || 'system').toUpperCase())}
+
+
${escapeHtml(ev.message || '')}
+ ${ev.details ? `
${escapeHtml(ev.details)}
` : ''}
+
+
+ `;
+ }).join('');
+}
+
+// ═══════════════════════════════════════════════════════════════
+// 9. SETTINGS VIEW (P0-4 GET /api/settings & save_settings)
+// ═══════════════════════════════════════════════════════════════
+async function loadServerSettings() {
+ try {
+ const headers = {};
+ if (authToken) headers['X-Hub-Token'] = authToken;
+
+ const res = await fetch('/api/settings', { headers });
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const data = await res.json();
+ currentSettings = data;
+ populateSettingsForm(data);
+ } catch (err) {
+ console.error('Failed to load settings:', err);
+ }
+}
+
+function populateSettingsForm(s) {
+ const hostInput = document.getElementById('setting-server-host');
+ const portInput = document.getElementById('setting-server-port');
+ const tokenBadge = document.getElementById('setting-token-status-badge');
+ const quotaSel = document.getElementById('setting-quota-interval');
+ const 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 = `
-
- Выбор модели по умолчанию
-
-
-
- ${discoveredModels.map(m => `${escapeHtml(m)} `).join('')}
-
-
Сохранить модель
-
↻ Обновить список
+
+
Предпочитаемая модель профиля:
+
+
+ ${discoveredModels.map(m => `${escapeHtml(m)} `).join('')}
+
+ Сохранить
+
`;
} else {
modelBlockHtml = `
-
- Выбор модели по умолчанию
-
-
+
⚠ Список моделей ещё не получен от провайдера ${escapeHtml(profile.provider_display_name || profile.provider)}.
@@ -745,7 +1224,7 @@ function openAccountDetailsModal(profileId) {
`;
}
- elements.modalTitle.textContent = `Учетная запись: ${profile.display_name} (${profileId})`;
+ elements.modalTitle.textContent = `Учетная запись: ${profile.display_name || profileId}`;
elements.modalBody.innerHTML = `
@@ -976,7 +1455,7 @@ function showWizardStep2(providerId) {
if (providerId === 'grok' || providerId === 'openai-codex') {
bodyHtml = `
- Шаг 2 из 3: Авторизация ${providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex'}
+ Шаг 2 из 3: Авторизация ${providerId === 'grok' ? 'Grok (xAI)' : 'OpenAI Codex'} (Device Code OAuth)
1. Откройте ссылку на любом устройстве:
@@ -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) {
diff --git a/src/antigravity_provider/router/web/static/index.html b/src/antigravity_provider/router/web/static/index.html
index 4c3c303..74c8c72 100644
--- a/src/antigravity_provider/router/web/static/index.html
+++ b/src/antigravity_provider/router/web/static/index.html
@@ -1,4 +1,4 @@
-
+
@@ -9,7 +9,7 @@
-
+