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) => ` -
+
${escapeHtml(prov.provider_name || prov.provider_id)}
- Обновлено: ${prov.last_refresh_at ? formatIsoDate(prov.last_refresh_at) : 'Н/Д — обнаружение ещё не запускалось'} + Всего слотов: ${prov.total_slots} • Подключено: ${prov.connected_count} • Онлайн: ${prov.online_count}
-
-
- Онлайн: ${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 => ``).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(ag.role_name_ru || ag.role_id)}
+
${escapeHtml(ag.role_description_ru || '')}
+
+ + ${ag.is_active ? '● АКТИВЕН' : 'ОЖИДАНИЕ'} +
-
${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 = ` + + + + + + + + + + + + + + + ${rowsHtml} + +
ПровайдерВсего вызововУспешноСбоиДоля ошибокp50p95Токены
+ `; + } + } + + // 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 = ` + + + + + + + + + + + + + + ${rowsHtml} + +
Роль агентаВсего вызововУспешноСбоиДоля ошибокp50p95
+ `; + } } } // ═══════════════════════════════════════════════════════════════ -// 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.title_ru || 'Система готова к работе')} +
+ ${escapeHtml(readiness.state || 'HEALTHY')} +
+
+ ${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 = ` +
+
+ CPU (Процессор) + ${cpuPct}${cpuPct !== 'Н/Д' ? '%' : ''} +
+
+
+
+
Нагрузка хост-системы
+
+ +
+
+ RAM (Оперативная память) + ${memPct}${memPct !== 'Н/Д' ? '%' : ''} +
+
+
+
+
${memMb ? `Использовано: ${memMb}` : 'Статус использования RAM'}
+
+ +
+
+ Диск (Хранилище) + ${diskPct}${diskPct !== 'Н/Д' ? '%' : ''} +
+
+
+
+
${diskGb ? `Занято: ${diskGb}` : 'Статус дискового пространства'}
+
+ +
+
+ Сеть (Пропускная способность) + ${netSpeed} +
+
+
+
+
${netSub}
+
+ `; + } + + // 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 = ` -

- Выбор модели по умолчанию -

-
- - - +
+ +
+ + +
`; } 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 @@
- +