feat(web): A16 — веб-клиент без сборки, компактные аккаунты, живые квоты, честная авторизация и скриншоты экранов

This commit is contained in:
Hermes Team 2026-08-23 19:37:36 +07:00
parent 06b55b49f4
commit d9d6e08d32
14 changed files with 8174 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View file

@ -0,0 +1,114 @@
"""
Live screenshot capture for Hermes Hub Web Client (A16).
Captures 8 scenarios via Headless Chrome CLI at 1440x920.
"""
from __future__ import annotations
import http.server
import os
from pathlib import Path
import socket
import subprocess
import threading
import time
REPO_ROOT = Path(__file__).resolve().parent.parent
STATIC_DIR = REPO_ROOT / "src" / "antigravity_provider" / "router" / "web" / "static"
ARTIFACTS_DIR = REPO_ROOT / "artifacts" / "a16-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",
]
class StaticServer(threading.Thread):
def __init__(self, port: int):
super().__init__(daemon=True)
self.port = port
self.httpd = None
def run(self):
class Handler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(STATIC_DIR), **kwargs)
def log_message(self, *args):
pass
self.httpd = http.server.HTTPServer(("127.0.0.1", self.port), Handler)
self.httpd.serve_forever()
def stop(self):
if self.httpd:
self.httpd.shutdown()
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 main():
port = get_free_port()
server = StaticServer(port)
server.start()
print(f"Static HTTP Server running on http://127.0.0.1:{port}")
time.sleep(0.3)
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"
temp_profile.mkdir(parents=True, exist_ok=True)
scenarios = [
("01_web_accounts_view.png", f"http://127.0.0.1:{port}/index.html?view=accounts"),
("02_web_overview_view.png", f"http://127.0.0.1:{port}/index.html?view=overview"),
("03_web_routing_view.png", f"http://127.0.0.1:{port}/index.html?view=routing"),
("04_web_providers_view.png", f"http://127.0.0.1:{port}/index.html?view=providers"),
("05_web_team_view.png", f"http://127.0.0.1:{port}/index.html?view=team"),
("06_web_grok_wizard.png", f"http://127.0.0.1:{port}/index.html?modal=grok_wizard"),
("07_web_antigravity_wizard.png", f"http://127.0.0.1:{port}/index.html?modal=antigravity_wizard"),
("08_web_account_details_modal.png", f"http://127.0.0.1:{port}/index.html?modal=account_details&profile=ag-spare-1"),
]
captured_count = 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=2000",
f"--user-data-dir={temp_profile}",
"--window-size=1440,920",
f"--screenshot={out_file}",
url,
]
res = subprocess.run(cmd, capture_output=True, timeout=20)
if res.returncode == 0 and out_file.exists():
size = out_file.stat().st_size
print(f"Captured: {filename} ({size} bytes)")
captured_count += 1
else:
print(f"FAILED to capture {filename}: returncode {res.returncode}")
server.stop()
print(f"\nTotal screenshots captured: {captured_count}/{len(scenarios)}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,283 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hermes Hub — Панель управления</title>
<link rel="stylesheet" href="style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⚡</text></svg>">
</head>
<body>
<div id="app" class="app-layout">
<!-- Sidebar Navigation -->
<aside class="sidebar">
<div class="brand">
<div class="brand-logo"></div>
<div class="brand-text">
<span class="brand-title">HERMES HUB</span>
<span class="brand-subtitle">Multi-Account Router</span>
</div>
</div>
<nav class="nav-menu">
<button class="nav-item active" data-view="accounts">
<span class="nav-icon">👥</span>
<span class="nav-label">Аккаунты</span>
<span class="nav-badge" id="nav-accounts-count">0</span>
</button>
<button class="nav-item" data-view="overview">
<span class="nav-icon">📊</span>
<span class="nav-label">Обзор</span>
</button>
<button class="nav-item" data-view="routing">
<span class="nav-icon">🔀</span>
<span class="nav-label">Маршрутизация</span>
</button>
<button class="nav-item" data-view="providers">
<span class="nav-icon">🧩</span>
<span class="nav-label">Модели и провайдеры</span>
</button>
<button class="nav-item" data-view="team">
<span class="nav-icon">👑</span>
<span class="nav-label">Команда агентов</span>
</button>
<button class="nav-item" data-view="logs">
<span class="nav-icon">📜</span>
<span class="nav-label">Журнал событий</span>
</button>
<button class="nav-item" data-view="settings">
<span class="nav-icon">⚙️</span>
<span class="nav-label">Настройки</span>
</button>
</nav>
<div class="sidebar-footer">
<div class="source-indicator" id="source-indicator">
<span class="status-dot healthy"></span>
<span class="source-text" id="source-text">Загрузка данных...</span>
</div>
<div class="version-tag" id="version-tag">Hermes Hub Web v0.1.1</div>
</div>
</aside>
<!-- Main Content Container -->
<main class="main-wrapper">
<!-- Top Header -->
<header class="top-header">
<div class="header-left">
<h1 class="header-title" id="page-title">Аккаунты и квоты</h1>
<span class="header-readiness-badge" id="header-readiness-badge">
<span class="status-dot"></span>
<span id="header-readiness-text">Инициализация...</span>
</span>
</div>
<div class="header-actions">
<button class="btn btn-secondary" id="btn-refresh-all" title="Обновить квоты всех профилей">
<span class="btn-icon"></span>
<span>Обновить всё</span>
</button>
<button class="btn btn-primary" id="btn-add-account">
<span class="btn-icon">+</span>
<span>Добавить аккаунт</span>
</button>
</div>
</header>
<!-- Dynamic Views -->
<div class="content-scroll">
<!-- 1. ACCOUNTS VIEW -->
<section id="view-accounts" class="view-pane active">
<div class="toolbar">
<div class="search-box">
<span class="search-icon">🔍</span>
<input type="text" id="accounts-search" placeholder="Поиск по аккаунту, почте, роли или модели...">
</div>
<div class="filters-row">
<select id="filter-provider" class="select-filter">
<option value="all">Все провайдеры</option>
<option value="antigravity">Google Antigravity</option>
<option value="openai-codex">OpenAI Codex</option>
<option value="opencode-go">OpenCode Go</option>
<option value="claude">Claude (Anthropic)</option>
<option value="grok">Grok (xAI)</option>
</select>
<select id="filter-health" class="select-filter">
<option value="all">Все состояния</option>
<option value="healthy">Работает</option>
<option value="warning">Предупреждение</option>
<option value="quota_exhausted">Квота исчерпана</option>
<option value="auth_required">Требуется вход</option>
<option value="disabled">Отключён / Резерв</option>
</select>
<div class="toolbar-stats" id="accounts-stats-summary">
Показано: <strong>0</strong> из <strong>0</strong> аккаунтов
</div>
</div>
</div>
<div id="accounts-container" class="accounts-groups-container">
<!-- Rendered by app.js -->
</div>
</section>
<!-- 2. OVERVIEW VIEW -->
<section id="view-overview" class="view-pane">
<div class="overview-grid">
<div class="kpi-card">
<div class="kpi-label">Состояние системы</div>
<div class="kpi-value text-healthy" id="kpi-system-readiness"></div>
<div class="kpi-sub" id="kpi-readiness-summary"></div>
</div>
<div class="kpi-card">
<div class="kpi-label">Подключено аккаунтов</div>
<div class="kpi-value text-accent" id="kpi-total-accounts"></div>
<div class="kpi-sub" id="kpi-accounts-sub"></div>
</div>
<div class="kpi-card">
<div class="kpi-label">Готовых ролей</div>
<div class="kpi-value text-healthy" id="kpi-ready-roles"></div>
<div class="kpi-sub" id="kpi-roles-sub"></div>
</div>
<div class="kpi-card">
<div class="kpi-label">Провайдеры ИИ</div>
<div class="kpi-value text-info" id="kpi-providers-count"></div>
<div class="kpi-sub" id="kpi-providers-sub">5 поддерживаемых систем</div>
</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="route-diagram-container" id="overview-route-diagram">
<!-- Rendered by app.js -->
</div>
</div>
<div class="section-card">
<div class="section-card-header">
<div class="section-card-title">Провайдеры и доступность runtime</div>
<div class="section-card-subtitle">Статус локальных адаптеров и обнаруженные модели</div>
</div>
<div class="providers-grid" id="overview-providers-summary">
<!-- Rendered by app.js -->
</div>
</div>
</section>
<!-- 3. ROUTING VIEW -->
<section id="view-routing" class="view-pane">
<div class="view-header-note">
Цепочки маршрутизации: <strong>Основной → Резерв 1 → Резерв 2 → Резерв 3</strong>. Переключения выполняются автоматически при исчерпании квоты или ошибке провайдера.
</div>
<div class="routing-pipelines-list" id="routing-pipelines-container">
<!-- Rendered by app.js -->
</div>
</section>
<!-- 4. PROVIDERS & MODELS VIEW -->
<section id="view-providers" class="view-pane">
<div class="view-header-note">
Реально обнаруженные модели провайдеров и локальные адаптеры.
</div>
<div class="providers-full-list" id="providers-full-container">
<!-- Rendered by app.js -->
</div>
</section>
<!-- 5. TEAM VIEW -->
<section id="view-team" class="view-pane">
<div class="view-header-note">
Команда агентов Hermes Hub: роли, привязанные профили и оперативные квоты.
</div>
<div class="team-cards-grid" id="team-cards-container">
<!-- Rendered by app.js -->
</div>
</section>
<!-- 6. LOGS VIEW -->
<section id="view-logs" class="view-pane">
<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>
<div class="logs-container" id="logs-container">
<div class="empty-text">Журнал событий пуст или сбор ещё не выполнен.</div>
</div>
</div>
</section>
<!-- 7. SETTINGS VIEW -->
<section id="view-settings" class="view-pane">
<div class="settings-card">
<h2 class="settings-group-title">Параметры веб-клиента</h2>
<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-source-mode" class="select-filter">
<option value="auto">Авто (Live API с fallback на фикстуру)</option>
<option value="live">Строго Live API (/api/snapshot)</option>
<option value="fixture">Строго фикстура (snapshot.example.json)</option>
</select>
</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-poll-interval" class="select-filter">
<option value="3000">3 секунды</option>
<option value="5000" selected>5 секунд (стандарт)</option>
<option value="10000">10 секунд</option>
<option value="0">Отключить авто-опрос</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<div class="setting-label">Токен безопасности (X-Hub-Token)</div>
<div class="setting-desc">Обязателен при запуске сервера на нелокальном IP адресе</div>
</div>
<div class="setting-control">
<input type="password" id="setting-auth-token" class="input-text" placeholder="Введите токен сервера...">
</div>
</div>
<div class="settings-actions">
<button class="btn btn-primary" id="btn-save-client-settings">Сохранить параметры</button>
</div>
</div>
</section>
</div>
</main>
</div>
<!-- Modal Layer -->
<div id="modal-backdrop" class="modal-backdrop hidden">
<div id="modal-container" class="modal-card">
<div class="modal-header">
<h2 id="modal-title" class="modal-title">Модальное окно</h2>
<button id="modal-close-btn" class="modal-close" title="Закрыть"></button>
</div>
<div id="modal-body" class="modal-body">
<!-- Injected dynamically -->
</div>
<div id="modal-footer" class="modal-footer">
<!-- Injected dynamically -->
</div>
</div>
</div>
<!-- Toast Notification Container -->
<div id="toast-container" class="toast-container"></div>
<!-- Application Logic -->
<script src="app.js"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,105 @@
"""
Hermes Hub Web Client Invariants & Contract Verification Suite
Tests adherence to docs/web-api/CONTRACT.md and A16 requirements.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent
STATIC_DIR = REPO_ROOT / "src" / "antigravity_provider" / "router" / "web" / "static"
SNAPSHOT_EXAMPLE = REPO_ROOT / "docs" / "web-api" / "snapshot.example.json"
def test_static_assets_exist_and_no_build_dependencies():
"""Verify that index.html, style.css, app.js exist and have zero build / npm dependencies."""
index_html = STATIC_DIR / "index.html"
style_css = STATIC_DIR / "style.css"
app_js = STATIC_DIR / "app.js"
assert index_html.is_file(), f"Missing {index_html}"
assert style_css.is_file(), f"Missing {style_css}"
assert app_js.is_file(), f"Missing {app_js}"
html_content = index_html.read_text(encoding="utf-8")
# No React, Webpack, Vite, npm or external bundle references
assert "react" not in html_content.lower()
assert "webpack" not in html_content.lower()
assert "vite" not in html_content.lower()
assert "<script src=\"app.js\"></script>" in html_content
assert "<link rel=\"stylesheet\" href=\"style.css\">" in html_content
def test_snapshot_fixture_validity_and_completeness():
"""Verify snapshot.example.json conforms to HubSnapshot contract."""
assert SNAPSHOT_EXAMPLE.is_file(), f"Missing {SNAPSHOT_EXAMPLE}"
with open(SNAPSHOT_EXAMPLE, "r", encoding="utf-8") as f:
data = json.load(f)
# Top level keys
required_keys = [
"generation", "seq", "timestamp", "profiles_by_provider",
"all_profiles", "readiness", "agents", "providers",
"routing", "quotas", "metrics", "is_stale"
]
for key in required_keys:
assert key in data, f"Missing required top-level key: {key}"
# Verify monotonic seq structure
assert isinstance(data["seq"], int)
assert data["seq"] >= 1
# Verify profiles count
assert len(data["all_profiles"]) >= 16, "Must contain real profiles fixture"
# Zero leaked tokens / secrets in snapshot
raw_text = json.dumps(data)
forbidden_tokens = ["access_token", "refresh_token", "api_key", "client_secret"]
for tok in forbidden_tokens:
# Key shouldn't exist as actual secret payload
assert f'"{tok}": "sk-' not in raw_text
assert f'"{tok}": "gho_' not in raw_text
def test_monotonic_seq_logic_in_app_js():
"""Verify app.js contains strict monotonic seq checking to prevent stale response overwrites."""
app_js_content = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
assert "lastAppliedSeq" in app_js_content
assert "snapshot.seq < lastAppliedSeq" in app_js_content
assert "Stale snapshot" in app_js_content
def test_account_card_compact_height_and_quota_rendering():
"""Verify CSS has 164px compact fixed height and app.js renders multi-pool quota cells."""
style_css = (STATIC_DIR / "style.css").read_text(encoding="utf-8")
assert "164px" in style_css
assert ".account-card" in style_css
assert "overflow: hidden" in style_css
app_js = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
assert "renderQuotaCell" in app_js
assert "remaining_percent" in app_js
assert "unavailable_reason" in app_js
assert "Н" in app_js
def test_headless_server_auth_matrix():
"""Verify headless server honesty in Add Account Wizard (Grok/Codex device-code vs Antigravity/Claude redirect warning)."""
app_js = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
assert "Device Code OAuth" in app_js
assert "https://x.ai/device" in app_js
assert "https://auth.openai.com/device" in app_js
assert "Headless Сервер" in app_js
assert "ssh -L 8085:localhost:8085" in app_js
def test_actions_contract_handling():
"""Verify POST /api/action handles ok: false as valid 200 business response and displays feedback in-place."""
app_js = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
assert "POST" in app_js
assert "/api/action" in app_js
assert "executeAction" in app_js
assert "modal-feedback-area" in app_js