feat(web): A21 — паритет веб-интерфейса: аналитика, состояние, события, настройки
This commit is contained in:
parent
187f181aec
commit
e738dd0c5d
11 changed files with 1444 additions and 118 deletions
BIN
artifacts/a21-screenshots/01_analytics_view.png
Normal file
BIN
artifacts/a21-screenshots/01_analytics_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 91 KiB |
BIN
artifacts/a21-screenshots/02_health_view.png
Normal file
BIN
artifacts/a21-screenshots/02_health_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 82 KiB |
BIN
artifacts/a21-screenshots/03_logs_view.png
Normal file
BIN
artifacts/a21-screenshots/03_logs_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
BIN
artifacts/a21-screenshots/04_settings_view.png
Normal file
BIN
artifacts/a21-screenshots/04_settings_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
|
|
@ -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/` монтируется целиком.
|
||||
|
|
|
|||
111
scripts/capture_live_a21_screenshots.py
Normal file
111
scripts/capture_live_a21_screenshots.py
Normal 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()
|
||||
|
|
@ -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"))
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
142
tests/test_web_parity_a21.py
Normal file
142
tests/test_web_parity_a21.py
Normal 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
|
||||
Loading…
Reference in a new issue