Stage 298: PR #1677 — feat: link official Hermes dashboard by @Michaelyklam
This commit is contained in:
commit
3699e83c43
10 changed files with 632 additions and 0 deletions
211
api/dashboard_probe.py
Normal file
211
api/dashboard_probe.py
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
"""Safe server-side probe for the official Hermes Agent dashboard.
|
||||
|
||||
The official `hermes dashboard` binds to 127.0.0.1:9119 by default and exposes
|
||||
GET /api/status as a public, read-only identity/status endpoint. Keep all
|
||||
probing server-side to avoid browser CORS/mixed-content failures, and only allow
|
||||
loopback targets so a user-controlled setting cannot become an SSRF primitive.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DASHBOARD_PORT = 9119
|
||||
DEFAULT_DASHBOARD_TIMEOUT = 0.5
|
||||
DEFAULT_DASHBOARD_TARGETS = (("127.0.0.1", DEFAULT_DASHBOARD_PORT), ("localhost", DEFAULT_DASHBOARD_PORT))
|
||||
_DASHBOARD_ENABLED_VALUES = {"auto", "always", "never"}
|
||||
_LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
|
||||
|
||||
|
||||
def _base_url(host: str, port: int, scheme: str = "http") -> str:
|
||||
display_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
|
||||
return f"{scheme}://{display_host}:{port}"
|
||||
|
||||
|
||||
def normalize_dashboard_url(raw_url: str | None) -> tuple[str, int, str, str] | None:
|
||||
"""Return (host, port, scheme, base_url) for a safe loopback dashboard URL.
|
||||
|
||||
Overrides intentionally accept only scheme + loopback host + explicit port.
|
||||
Paths, query strings, fragments, and credentials are rejected: the probe
|
||||
appends the official `/api/status` fingerprint itself and must not become an
|
||||
arbitrary local URL fetcher.
|
||||
"""
|
||||
raw = str(raw_url or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
parsed = urlparse(raw)
|
||||
if parsed.scheme not in {"http", "https"}:
|
||||
raise ValueError("invalid dashboard URL scheme")
|
||||
if parsed.username or parsed.password:
|
||||
raise ValueError("invalid dashboard URL credentials")
|
||||
host = parsed.hostname or ""
|
||||
normalized_host = host.strip().lower()
|
||||
if normalized_host not in _LOOPBACK_HOSTS:
|
||||
raise ValueError("invalid dashboard URL host")
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise ValueError("invalid dashboard URL port") from exc
|
||||
if not isinstance(port, int) or not (1 <= port <= 65535):
|
||||
raise ValueError("invalid dashboard URL port")
|
||||
path = parsed.path or ""
|
||||
if path not in ("", "/") or parsed.params or parsed.query or parsed.fragment:
|
||||
raise ValueError("invalid dashboard URL path")
|
||||
base = _base_url(normalized_host, port, parsed.scheme)
|
||||
return normalized_host, port, parsed.scheme, base
|
||||
|
||||
|
||||
def _looks_like_official_dashboard(payload: object) -> bool:
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
version = payload.get("version")
|
||||
if not isinstance(version, str) or not version.strip():
|
||||
return False
|
||||
# Verified against current Hermes Agent `hermes_cli.web_server.get_status()`:
|
||||
# /api/status returns version plus these Hermes-specific fields. Requiring at
|
||||
# least one avoids treating any generic {version: ...} local service as the
|
||||
# official dashboard.
|
||||
return any(key in payload for key in ("release_date", "hermes_home", "config_path", "gateway_running"))
|
||||
|
||||
|
||||
def probe_official_dashboard(
|
||||
host: str,
|
||||
port: int,
|
||||
timeout: float = DEFAULT_DASHBOARD_TIMEOUT,
|
||||
scheme: str = "http",
|
||||
) -> dict:
|
||||
"""Best-effort check that `hermes dashboard` is running on host:port."""
|
||||
try:
|
||||
normalized_host = str(host or "").strip().lower()
|
||||
if normalized_host not in _LOOPBACK_HOSTS:
|
||||
raise ValueError("dashboard probe host must be loopback")
|
||||
port = int(port)
|
||||
if not (1 <= port <= 65535):
|
||||
raise ValueError("dashboard probe port out of range")
|
||||
if scheme not in {"http", "https"}:
|
||||
raise ValueError("dashboard probe scheme must be http or https")
|
||||
base = _base_url(normalized_host, port, scheme)
|
||||
request = urllib.request.Request(
|
||||
f"{base}/api/status",
|
||||
headers={"Accept": "application/json", "User-Agent": "hermes-webui-dashboard-probe"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
if getattr(response, "status", None) != 200:
|
||||
return {"running": False}
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
if not _looks_like_official_dashboard(payload):
|
||||
return {"running": False}
|
||||
result = {"running": True, "host": normalized_host, "port": port, "url": base}
|
||||
version = payload.get("version")
|
||||
if isinstance(version, str) and version.strip():
|
||||
result["version"] = version.strip()
|
||||
return result
|
||||
except Exception:
|
||||
logger.debug("official Hermes dashboard probe failed", exc_info=True)
|
||||
return {"running": False}
|
||||
|
||||
|
||||
def _dashboard_config(config_data: dict | None = None) -> dict:
|
||||
if config_data is None:
|
||||
try:
|
||||
from api.config import get_config
|
||||
|
||||
config_data = get_config()
|
||||
except Exception:
|
||||
config_data = {}
|
||||
webui_cfg = config_data.get("webui", {}) if isinstance(config_data, dict) else {}
|
||||
dashboard_cfg = webui_cfg.get("dashboard", {}) if isinstance(webui_cfg, dict) else {}
|
||||
return dashboard_cfg if isinstance(dashboard_cfg, dict) else {}
|
||||
|
||||
|
||||
def get_dashboard_config(config_data: dict | None = None) -> dict:
|
||||
"""Return normalized profile config for the Settings → System controls."""
|
||||
dashboard_cfg = _dashboard_config(config_data)
|
||||
enabled = str(dashboard_cfg.get("enabled", "auto") or "auto").strip().lower()
|
||||
if enabled not in _DASHBOARD_ENABLED_VALUES:
|
||||
enabled = "auto"
|
||||
raw_url = str(dashboard_cfg.get("url") or "").strip()
|
||||
if raw_url:
|
||||
# Normalize before echoing so the UI never displays unsafe/stale values.
|
||||
_host, _port, _scheme, raw_url = normalize_dashboard_url(raw_url)
|
||||
return {"enabled": enabled, "url": raw_url}
|
||||
|
||||
|
||||
def save_dashboard_config(payload: dict) -> dict:
|
||||
"""Persist dashboard link settings under webui.dashboard in config.yaml."""
|
||||
enabled = str((payload or {}).get("enabled", "auto") or "auto").strip().lower()
|
||||
if enabled not in _DASHBOARD_ENABLED_VALUES:
|
||||
raise ValueError("invalid dashboard enabled mode")
|
||||
raw_url = str((payload or {}).get("url", "") or "").strip()
|
||||
normalized_url = ""
|
||||
if raw_url:
|
||||
_host, _port, _scheme, normalized_url = normalize_dashboard_url(raw_url)
|
||||
|
||||
from api import config as webui_config
|
||||
|
||||
config_path = webui_config._get_config_path()
|
||||
config_data = webui_config._load_yaml_config_file(config_path)
|
||||
webui_section = config_data.get("webui")
|
||||
if not isinstance(webui_section, dict):
|
||||
webui_section = {}
|
||||
config_data["webui"] = webui_section
|
||||
dashboard_section = webui_section.get("dashboard")
|
||||
if not isinstance(dashboard_section, dict):
|
||||
dashboard_section = {}
|
||||
webui_section["dashboard"] = dashboard_section
|
||||
dashboard_section["enabled"] = enabled
|
||||
if normalized_url:
|
||||
dashboard_section["url"] = normalized_url
|
||||
else:
|
||||
dashboard_section.pop("url", None)
|
||||
webui_config._save_yaml_config_file(config_path, config_data)
|
||||
webui_config.reload_config()
|
||||
return {"enabled": enabled, "url": normalized_url}
|
||||
|
||||
|
||||
def _webui_bind_host_allows_auto_probe() -> bool:
|
||||
raw_host = str(os.environ.get("HERMES_WEBUI_HOST") or "127.0.0.1").strip().lower()
|
||||
host = raw_host.replace("[", "").replace("]", "")
|
||||
return host in _LOOPBACK_HOSTS
|
||||
|
||||
|
||||
def get_dashboard_status(config_data: dict | None = None) -> dict:
|
||||
"""Return the safe status payload consumed by GET /api/dashboard/status."""
|
||||
dashboard_cfg = _dashboard_config(config_data)
|
||||
enabled = str(dashboard_cfg.get("enabled", "auto") or "auto").strip().lower()
|
||||
if enabled not in _DASHBOARD_ENABLED_VALUES:
|
||||
enabled = "auto"
|
||||
if enabled == "never":
|
||||
return {"running": False, "enabled": "never"}
|
||||
|
||||
raw_url = dashboard_cfg.get("url") or dashboard_cfg.get("target") or ""
|
||||
try:
|
||||
override = normalize_dashboard_url(raw_url)
|
||||
except ValueError:
|
||||
return {"running": False, "enabled": enabled, "error": "invalid dashboard url"}
|
||||
|
||||
targets: list[tuple[str, int, str, str]]
|
||||
if override:
|
||||
targets = [override]
|
||||
else:
|
||||
targets = [(host, port, "http", _base_url(host, port)) for host, port in DEFAULT_DASHBOARD_TARGETS]
|
||||
|
||||
if enabled == "always":
|
||||
host, port, scheme, base = targets[0]
|
||||
return {"running": True, "enabled": enabled, "host": host, "port": port, "url": base}
|
||||
|
||||
if not _webui_bind_host_allows_auto_probe():
|
||||
return {"running": False, "enabled": enabled}
|
||||
|
||||
for host, port, scheme, _base in targets:
|
||||
result = probe_official_dashboard(host, port, timeout=DEFAULT_DASHBOARD_TIMEOUT, scheme=scheme)
|
||||
if result.get("running"):
|
||||
result["enabled"] = enabled
|
||||
return result
|
||||
return {"running": False, "enabled": enabled}
|
||||
|
|
@ -2161,6 +2161,21 @@ def handle_get(handler, parsed) -> bool:
|
|||
if parsed.path == "/api/models/live":
|
||||
return _handle_live_models(handler, parsed)
|
||||
|
||||
if parsed.path == "/api/dashboard/status":
|
||||
from api import dashboard_probe
|
||||
|
||||
j(handler, dashboard_probe.get_dashboard_status())
|
||||
return True
|
||||
|
||||
if parsed.path == "/api/dashboard/config":
|
||||
from api import dashboard_probe
|
||||
|
||||
try:
|
||||
j(handler, dashboard_probe.get_dashboard_config())
|
||||
except ValueError as exc:
|
||||
bad(handler, str(exc), status=400)
|
||||
return True
|
||||
|
||||
# ── Providers (GET) ──
|
||||
if parsed.path == "/api/providers":
|
||||
return j(handler, get_providers())
|
||||
|
|
@ -2880,6 +2895,17 @@ def handle_post(handler, parsed) -> bool:
|
|||
from api.kanban_bridge import handle_kanban_post
|
||||
|
||||
return handle_kanban_post(handler, parsed, body)
|
||||
if parsed.path == "/api/dashboard/config":
|
||||
from api import dashboard_probe
|
||||
|
||||
try:
|
||||
j(handler, dashboard_probe.save_dashboard_config(body))
|
||||
except ValueError as exc:
|
||||
bad(handler, str(exc), status=400)
|
||||
except Exception as exc:
|
||||
logger.exception("dashboard config save failed")
|
||||
bad(handler, str(exc), status=500)
|
||||
return True
|
||||
|
||||
if parsed.path == "/api/session/new":
|
||||
try:
|
||||
|
|
|
|||
BIN
docs/pr-media/1459/dashboard-nav-link.png
Normal file
BIN
docs/pr-media/1459/dashboard-nav-link.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
|
|
@ -530,6 +530,8 @@ const LOCALES = {
|
|||
kanban_status_archived: 'Archived',
|
||||
tab_todos: 'Todos',
|
||||
tab_insights: 'Insights',
|
||||
tab_dashboard: 'Hermes Dashboard',
|
||||
dashboard_loopback_warning: 'Dashboard is loopback-only on the server. Either browse from the server itself or restart it with --host 0.0.0.0 (insecure).',
|
||||
tab_settings: 'Settings',
|
||||
new_conversation: 'New conversation',
|
||||
filter_conversations: 'Filter conversations...',
|
||||
|
|
@ -1521,6 +1523,8 @@ const LOCALES = {
|
|||
kanban_status_archived: 'Archived',
|
||||
tab_todos: 'ToDo',
|
||||
tab_insights: 'インサイト',
|
||||
tab_dashboard: 'Hermes ダッシュボード',
|
||||
dashboard_loopback_warning: 'ダッシュボードはサーバー上のループバック専用です。サーバー上で閲覧するか、--host 0.0.0.0(安全ではありません)で再起動してください。',
|
||||
tab_settings: '設定',
|
||||
new_conversation: '新しい会話',
|
||||
filter_conversations: '会話を絞り込み...',
|
||||
|
|
@ -2351,6 +2355,8 @@ const LOCALES = {
|
|||
kanban_status_archived: 'Archived',
|
||||
tab_todos: 'Список дел',
|
||||
tab_insights: 'Аналитика',
|
||||
tab_dashboard: 'Панель Hermes',
|
||||
dashboard_loopback_warning: 'Панель доступна только через loopback на сервере. Откройте её с самого сервера или перезапустите с --host 0.0.0.0 (небезопасно).',
|
||||
tab_settings: 'Настройки',
|
||||
new_conversation: 'Новая беседа',
|
||||
filter_conversations: 'Фильтр бесед...',
|
||||
|
|
@ -3277,6 +3283,8 @@ const LOCALES = {
|
|||
kanban_status_archived: 'Archived',
|
||||
tab_todos: 'Todos',
|
||||
tab_insights: 'Analíticas',
|
||||
tab_dashboard: 'Panel de Hermes',
|
||||
dashboard_loopback_warning: 'El panel solo usa loopback en el servidor. Navega desde el propio servidor o reinícialo con --host 0.0.0.0 (inseguro).',
|
||||
tab_settings: 'Ajustes',
|
||||
new_conversation: 'Nueva conversación',
|
||||
filter_conversations: 'Filtrar conversaciones...',
|
||||
|
|
@ -4191,6 +4199,8 @@ const LOCALES = {
|
|||
kanban_status_archived: 'Archived',
|
||||
tab_todos: 'Todos',
|
||||
tab_insights: 'Statistiken',
|
||||
tab_dashboard: 'Hermes-Dashboard',
|
||||
dashboard_loopback_warning: 'Das Dashboard ist auf dem Server nur per Loopback erreichbar. Öffne es direkt auf dem Server oder starte es mit --host 0.0.0.0 neu (unsicher).',
|
||||
tab_settings: 'Einstellungen',
|
||||
new_conversation: 'Neuer Chat',
|
||||
filter_conversations: 'Chats filtern...',
|
||||
|
|
@ -5126,6 +5136,8 @@ const LOCALES = {
|
|||
kanban_status_archived: 'Archived',
|
||||
tab_todos: '待办',
|
||||
tab_insights: '统计',
|
||||
tab_dashboard: 'Hermes 仪表盘',
|
||||
dashboard_loopback_warning: '仪表盘在服务器上仅限 loopback 访问。请从服务器本机浏览,或使用 --host 0.0.0.0 重启(不安全)。',
|
||||
tab_workspaces: '工作区',
|
||||
tab_profiles: '配置',
|
||||
tab_settings: '设置',
|
||||
|
|
@ -6011,6 +6023,8 @@ const LOCALES = {
|
|||
tab_tasks: '\u4efb\u52d9',
|
||||
tab_todos: '待辦',
|
||||
tab_insights: '統計',
|
||||
tab_dashboard: 'Hermes 儀表板',
|
||||
dashboard_loopback_warning: '儀表板在伺服器上僅限 loopback 存取。請從伺服器本機瀏覽,或使用 --host 0.0.0.0 重新啟動(不安全)。',
|
||||
tab_workspaces: '\u5de5\u4f5c\u5340',
|
||||
new_conversation: '新對話',
|
||||
filter_conversations: '篩選對話',
|
||||
|
|
@ -7078,6 +7092,8 @@ const LOCALES = {
|
|||
kanban_status_archived: 'Archived',
|
||||
tab_todos: 'Todos',
|
||||
tab_insights: 'Estatísticas',
|
||||
tab_dashboard: 'Painel Hermes',
|
||||
dashboard_loopback_warning: 'O painel é somente loopback no servidor. Navegue pelo próprio servidor ou reinicie com --host 0.0.0.0 (inseguro).',
|
||||
tab_settings: 'Configurações',
|
||||
new_conversation: 'Nova conversa',
|
||||
filter_conversations: 'Filtrar conversas...',
|
||||
|
|
@ -7968,6 +7984,8 @@ const LOCALES = {
|
|||
kanban_status_archived: 'Archived',
|
||||
tab_todos: 'Todos',
|
||||
tab_insights: '통계',
|
||||
tab_dashboard: 'Hermes 대시보드',
|
||||
dashboard_loopback_warning: '대시보드는 서버에서 loopback 전용입니다. 서버 자체에서 접속하거나 --host 0.0.0.0(안전하지 않음)으로 다시 시작하세요.',
|
||||
tab_settings: '설정',
|
||||
new_conversation: '새 대화',
|
||||
filter_conversations: '대화 필터…',
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@
|
|||
<button class="rail-btn nav-tab" data-panel="profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles" aria-label="Agent profiles"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
|
||||
<button class="rail-btn nav-tab" data-panel="todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos" aria-label="Todos"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
|
||||
<button class="rail-btn nav-tab" data-panel="insights" onclick="switchPanel('insights')" title="Insights" data-i18n-title="tab_insights" aria-label="Insights"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
|
||||
<button class="rail-btn nav-tab dashboard-link" id="dashboardRailBtn" data-dashboard-link style="display:none" onclick="openHermesDashboard(event)" title="Hermes Dashboard" data-i18n-title="tab_dashboard" aria-label="Hermes Dashboard"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg><span class="dashboard-external-badge" aria-hidden="true"></span></button>
|
||||
<div class="rail-spacer"></div>
|
||||
<button class="rail-btn nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings" aria-label="Settings"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
</nav>
|
||||
|
|
@ -105,6 +106,7 @@
|
|||
<button class="nav-tab" data-panel="profiles" data-label="Profiles" onclick="switchPanel('profiles')" title="Agent profiles" data-i18n-title="tab_profiles"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>
|
||||
<button class="nav-tab" data-panel="todos" data-label="Todos" onclick="switchPanel('todos')" title="Current task list" data-i18n-title="tab_todos"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3 17 2 2 4-4"/><path d="M13 6h8"/><path d="M13 12h8"/><path d="M13 18h8"/></svg></button>
|
||||
<button class="nav-tab" data-panel="insights" data-label="Insights" onclick="switchPanel('insights')" title="Insights" data-i18n-title="tab_insights"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 20V10"/><path d="M12 20V4"/><path d="M6 20v-6"/></svg></button>
|
||||
<button class="nav-tab dashboard-link" id="dashboardMobileBtn" data-dashboard-link data-label="Dashboard" style="display:none" onclick="openHermesDashboard(event)" title="Hermes Dashboard" data-i18n-title="tab_dashboard"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg><span class="dashboard-external-badge" aria-hidden="true"></span></button>
|
||||
<!-- Settings button mirrored here for mobile (rail is desktop-only via @media >=768px). Keep in sync with rail entry. -->
|
||||
<button class="nav-tab" data-panel="settings" onclick="switchPanel('settings')" title="Settings" data-i18n-title="tab_settings"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg></button>
|
||||
</div>
|
||||
|
|
@ -1016,6 +1018,18 @@
|
|||
</div>
|
||||
<button class="sm-btn" id="btnDisableAuth" onclick="disableAuth()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:#e8a030;border-color:rgba(232,160,48,.3);display:none" data-i18n="disable_auth">Disable Auth</button>
|
||||
<button class="sm-btn" id="btnSignOut" onclick="signOut()" style="margin-top:6px;width:100%;padding:8px;font-weight:600;color:var(--accent);border-color:rgba(233,69,96,.3);display:none" data-i18n="sign_out">Sign Out</button>
|
||||
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
|
||||
<label for="settingsDashboardMode">Official Hermes Dashboard</label>
|
||||
<div style="font-size:11px;color:var(--muted);margin-bottom:8px">Show a nav-rail link when the official <code>hermes dashboard</code> is reachable. Overrides are restricted to loopback URLs.</div>
|
||||
<select id="settingsDashboardMode" style="width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px">
|
||||
<option value="auto">Auto-detect</option>
|
||||
<option value="always">Always show</option>
|
||||
<option value="never">Never show</option>
|
||||
</select>
|
||||
<input type="text" id="settingsDashboardUrl" placeholder="http://127.0.0.1:9119" style="margin-top:8px;width:100%;padding:8px;background:var(--code-bg);color:var(--text);border:1px solid var(--border2);border-radius:6px;font-size:13px">
|
||||
<button class="sm-btn" onclick="saveDashboardSettings()" style="margin-top:8px;width:100%;padding:7px;font-weight:600">Save dashboard link settings</button>
|
||||
<div id="settingsDashboardStatus" class="settings-autosave-status" aria-live="polite"></div>
|
||||
</div>
|
||||
<!-- Gateway Status Section -->
|
||||
<div class="settings-field" style="margin-top:18px;padding-top:16px;border-top:1px solid var(--border)">
|
||||
<label>Gateway Status</label>
|
||||
|
|
|
|||
|
|
@ -4388,6 +4388,7 @@ async function loadSettingsPanel(){
|
|||
if(disableBtn) disableBtn.style.display='none';
|
||||
}
|
||||
_syncHermesPanelSessionActions();
|
||||
if(typeof loadDashboardSettings==='function') loadDashboardSettings();
|
||||
loadProvidersPanel(); // load provider cards in background
|
||||
loadPluginsPanel(); // load plugin/hook visibility in background
|
||||
switchSettingsSection(_settingsSection);
|
||||
|
|
|
|||
|
|
@ -627,6 +627,11 @@
|
|||
.rail .nav-tab{flex:0 0 auto;padding:0;font-size:inherit;border-bottom:none;overflow:visible;}
|
||||
.rail .nav-tab:hover::after{content:none;}
|
||||
.rail .nav-tab.active::before{content:'';position:absolute;left:-6px;top:50%;bottom:auto;transform:translateY(-50%);width:3px;height:16px;background:var(--accent);border-radius:0 2px 2px 0;}
|
||||
.dashboard-link{position:relative;}
|
||||
.dashboard-link-visible{display:flex!important;}
|
||||
.dashboard-external-badge{position:absolute;right:5px;top:5px;width:9px;height:9px;border-radius:2px;border:1px solid var(--accent);background:var(--sidebar);box-shadow:0 0 0 2px var(--sidebar);opacity:.95;}
|
||||
.dashboard-external-badge::after{content:'';position:absolute;right:-2px;top:-2px;width:5px;height:5px;border-top:1.5px solid var(--accent);border-right:1.5px solid var(--accent);}
|
||||
.sidebar-nav .dashboard-external-badge{right:8px;top:7px;width:8px;height:8px;}
|
||||
@media(min-width:641px){.rail{display:flex;}.sidebar > .sidebar-nav{display:none;}}
|
||||
/* Sidebar navigation tabs */
|
||||
.sidebar-nav{display:flex;border-bottom:1px solid var(--border);flex-shrink:0;padding:6px 8px 0;gap:2px;}
|
||||
|
|
|
|||
90
static/ui.js
90
static/ui.js
|
|
@ -163,6 +163,96 @@ function _showEarlierRenderedMessages(){
|
|||
_scrollPinned=false;
|
||||
}
|
||||
|
||||
const DASHBOARD_STATUS_TTL_MS=60000;
|
||||
let _dashboardStatusCache=null;
|
||||
let _dashboardStatusFetchedAt=0;
|
||||
|
||||
function _dashboardIsBrowserLoopback(){
|
||||
const host=(window.location.hostname||'').replace(/^\[|\]$/g,'').toLowerCase();
|
||||
return host==='127.0.0.1'||host==='localhost'||host==='::1';
|
||||
}
|
||||
function _dashboardBrowserUrl(status){
|
||||
if(!status||!status.running||!status.port) return '';
|
||||
let source;
|
||||
try{source=new URL(status.url||('http://127.0.0.1:'+status.port));}
|
||||
catch(_){source=new URL('http://127.0.0.1:'+status.port);}
|
||||
const browserHost=window.location.hostname||source.hostname;
|
||||
const displayHost=browserHost.includes(':')&&!browserHost.startsWith('[')?'['+browserHost+']':browserHost;
|
||||
return source.protocol+'//'+displayHost+':'+status.port;
|
||||
}
|
||||
function _applyDashboardStatus(status){
|
||||
const running=!!(status&&status.running);
|
||||
const url=running?_dashboardBrowserUrl(status):'';
|
||||
const warning=running&&!_dashboardIsBrowserLoopback()?t('dashboard_loopback_warning'):'';
|
||||
document.querySelectorAll('[data-dashboard-link]').forEach(btn=>{
|
||||
btn.classList.toggle('dashboard-link-visible',running);
|
||||
btn.style.display=running?'':'none';
|
||||
btn.dataset.dashboardUrl=url;
|
||||
btn.title=warning||t('tab_dashboard');
|
||||
btn.setAttribute('aria-label',warning||t('tab_dashboard'));
|
||||
});
|
||||
}
|
||||
async function refreshDashboardStatus(force=false){
|
||||
const now=Date.now();
|
||||
if(!force&&_dashboardStatusCache&&(now-_dashboardStatusFetchedAt)<DASHBOARD_STATUS_TTL_MS){
|
||||
_applyDashboardStatus(_dashboardStatusCache);
|
||||
return _dashboardStatusCache;
|
||||
}
|
||||
try{
|
||||
const status=await api('/api/dashboard/status');
|
||||
_dashboardStatusCache=status||{running:false};
|
||||
}catch(_){
|
||||
_dashboardStatusCache={running:false};
|
||||
}
|
||||
_dashboardStatusFetchedAt=Date.now();
|
||||
_applyDashboardStatus(_dashboardStatusCache);
|
||||
return _dashboardStatusCache;
|
||||
}
|
||||
async function loadDashboardSettings(){
|
||||
const modeEl=$('settingsDashboardMode');
|
||||
const urlEl=$('settingsDashboardUrl');
|
||||
if(!modeEl&&!urlEl) return;
|
||||
try{
|
||||
const cfg=await api('/api/dashboard/config');
|
||||
if(modeEl) modeEl.value=cfg.enabled||'auto';
|
||||
if(urlEl) urlEl.value=cfg.url||'';
|
||||
}catch(_){/* leave defaults visible */}
|
||||
}
|
||||
async function saveDashboardSettings(){
|
||||
const modeEl=$('settingsDashboardMode');
|
||||
const urlEl=$('settingsDashboardUrl');
|
||||
const statusEl=$('settingsDashboardStatus');
|
||||
const payload={enabled:(modeEl&&modeEl.value)||'auto',url:(urlEl&&urlEl.value||'').trim()};
|
||||
try{
|
||||
const saved=await api('/api/dashboard/config',{method:'POST',body:JSON.stringify(payload)});
|
||||
if(modeEl) modeEl.value=saved.enabled||'auto';
|
||||
if(urlEl) urlEl.value=saved.url||'';
|
||||
if(statusEl) statusEl.textContent='Dashboard link settings saved.';
|
||||
await refreshDashboardStatus(true);
|
||||
}catch(err){
|
||||
if(statusEl) statusEl.textContent='Dashboard link settings failed to save.';
|
||||
else if(typeof showToast==='function') showToast('Dashboard link settings failed to save.');
|
||||
}
|
||||
}
|
||||
function openHermesDashboard(event){
|
||||
if(event){event.preventDefault();event.stopPropagation();}
|
||||
const btn=event&&event.currentTarget?event.currentTarget:document.querySelector('[data-dashboard-link]');
|
||||
const url=(btn&&btn.dataset&&btn.dataset.dashboardUrl)||_dashboardBrowserUrl(_dashboardStatusCache);
|
||||
if(!url) return false;
|
||||
window.open(url,'_blank','noopener,noreferrer');
|
||||
return false;
|
||||
}
|
||||
function _initDashboardLinkProbe(){
|
||||
loadDashboardSettings();
|
||||
refreshDashboardStatus(true);
|
||||
setInterval(refreshDashboardStatus,DASHBOARD_STATUS_TTL_MS);
|
||||
}
|
||||
if(document.readyState==='complete'){
|
||||
_initDashboardLinkProbe();
|
||||
}else{
|
||||
document.addEventListener('DOMContentLoaded',_initDashboardLinkProbe,{once:true});
|
||||
}
|
||||
|
||||
/* ── Image lightbox — click any .msg-media-img to enlarge ─────────────────── */
|
||||
function _openImgLightbox(src, alt) {
|
||||
const lb = document.createElement('div');
|
||||
|
|
|
|||
61
tests/test_dashboard_link_ui.py
Normal file
61
tests/test_dashboard_link_ui.py
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import pathlib
|
||||
import re
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
INDEX_HTML = (REPO / "static" / "index.html").read_text(encoding="utf-8")
|
||||
UI_JS = (REPO / "static" / "ui.js").read_text(encoding="utf-8")
|
||||
STYLE_CSS = (REPO / "static" / "style.css").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_dashboard_nav_buttons_are_hidden_by_default_and_subpath_safe():
|
||||
assert 'id="dashboardRailBtn"' in INDEX_HTML
|
||||
assert 'id="dashboardMobileBtn"' in INDEX_HTML
|
||||
assert 'data-dashboard-link' in INDEX_HTML
|
||||
assert 'data-i18n-title="tab_dashboard"' in INDEX_HTML
|
||||
assert 'display:none' in INDEX_HTML
|
||||
assert "Dashboard" in INDEX_HTML
|
||||
assert "href=\"/" not in INDEX_HTML
|
||||
|
||||
|
||||
def test_dashboard_rail_item_sits_between_insights_and_settings_spacer():
|
||||
rail = re.search(r'<nav class="rail".*?</nav>', INDEX_HTML, re.DOTALL).group(0)
|
||||
assert rail.index('data-panel="insights"') < rail.index('id="dashboardRailBtn"') < rail.index('rail-spacer')
|
||||
|
||||
|
||||
def test_dashboard_frontend_fetches_status_with_sixty_second_cache():
|
||||
assert "DASHBOARD_STATUS_TTL_MS=60000" in UI_JS
|
||||
assert "function refreshDashboardStatus" in UI_JS
|
||||
assert "api('/api/dashboard/status')" in UI_JS
|
||||
assert "setInterval(refreshDashboardStatus,DASHBOARD_STATUS_TTL_MS)" in UI_JS
|
||||
assert 'fetch("/api/dashboard/status"' not in UI_JS
|
||||
assert "fetch('/api/dashboard/status'" not in UI_JS
|
||||
|
||||
|
||||
def test_dashboard_probe_initializes_after_shared_api_helper_is_loaded():
|
||||
assert "function _initDashboardLinkProbe" in UI_JS
|
||||
assert "document.addEventListener('DOMContentLoaded',_initDashboardLinkProbe,{once:true})" in UI_JS
|
||||
assert "else _initDashboardLinkProbe();" not in UI_JS
|
||||
|
||||
|
||||
def test_dashboard_frontend_opens_external_tab_safely_and_derives_browser_host_url():
|
||||
assert "function openHermesDashboard" in UI_JS
|
||||
assert "window.open" in UI_JS
|
||||
assert "noopener,noreferrer" in UI_JS
|
||||
assert "window.location.hostname" in UI_JS
|
||||
assert "_dashboardBrowserUrl" in UI_JS
|
||||
assert 'id="dashboardRailBtn"' in INDEX_HTML
|
||||
assert re.search(r'id="dashboardRailBtn"[^>]*onclick="openHermesDashboard\(event\)"', INDEX_HTML)
|
||||
|
||||
|
||||
def test_dashboard_loopback_warning_and_external_badge_are_present():
|
||||
assert "dashboard_loopback_warning" in UI_JS
|
||||
assert "dashboard-external-badge" in INDEX_HTML
|
||||
assert ".dashboard-external-badge" in STYLE_CSS
|
||||
assert "dashboard-link-visible" in STYLE_CSS
|
||||
|
||||
|
||||
def test_dashboard_settings_controls_live_in_system_panel():
|
||||
assert 'id="settingsDashboardMode"' in INDEX_HTML
|
||||
assert 'id="settingsDashboardUrl"' in INDEX_HTML
|
||||
assert "function saveDashboardSettings" in UI_JS
|
||||
assert "api('/api/dashboard/config'" in UI_JS
|
||||
206
tests/test_dashboard_probe.py
Normal file
206
tests/test_dashboard_probe.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
class _FakeHandler:
|
||||
def __init__(self):
|
||||
self.status = None
|
||||
self.sent_headers = []
|
||||
self.body = bytearray()
|
||||
self.wfile = self
|
||||
|
||||
def send_response(self, status):
|
||||
self.status = status
|
||||
|
||||
def send_header(self, name, value):
|
||||
self.sent_headers.append((name, value))
|
||||
|
||||
def end_headers(self):
|
||||
pass
|
||||
|
||||
def write(self, data):
|
||||
self.body.extend(data)
|
||||
|
||||
def json_body(self):
|
||||
return json.loads(bytes(self.body).decode("utf-8"))
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, payload, status=200):
|
||||
self.status = status
|
||||
self._payload = payload
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self._payload).encode("utf-8")
|
||||
|
||||
|
||||
def test_probe_uses_official_dashboard_status_fingerprint(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
calls.append((request.full_url, timeout))
|
||||
return _FakeResponse({"version": "0.12.0", "release_date": "2026-05-01", "hermes_home": "/tmp/hermes"})
|
||||
|
||||
from api import dashboard_probe
|
||||
|
||||
monkeypatch.setattr(dashboard_probe.urllib.request, "urlopen", fake_urlopen)
|
||||
result = dashboard_probe.probe_official_dashboard("127.0.0.1", 9119, timeout=0.25)
|
||||
|
||||
assert result["running"] is True
|
||||
assert result["host"] == "127.0.0.1"
|
||||
assert result["port"] == 9119
|
||||
assert result["url"] == "http://127.0.0.1:9119"
|
||||
assert result["version"] == "0.12.0"
|
||||
assert calls == [("http://127.0.0.1:9119/api/status", 0.25)]
|
||||
|
||||
|
||||
def test_probe_rejects_non_dashboard_json(monkeypatch):
|
||||
def fake_urlopen(request, timeout):
|
||||
return _FakeResponse({"version": "1.2.3"})
|
||||
|
||||
from api import dashboard_probe
|
||||
|
||||
monkeypatch.setattr(dashboard_probe.urllib.request, "urlopen", fake_urlopen)
|
||||
result = dashboard_probe.probe_official_dashboard("localhost", 9119, timeout=0.25)
|
||||
|
||||
assert result == {"running": False}
|
||||
|
||||
|
||||
def test_probe_failure_and_timeout_are_safe_false(monkeypatch):
|
||||
def fake_urlopen(request, timeout):
|
||||
raise TimeoutError("slow dashboard")
|
||||
|
||||
from api import dashboard_probe
|
||||
|
||||
monkeypatch.setattr(dashboard_probe.urllib.request, "urlopen", fake_urlopen)
|
||||
result = dashboard_probe.probe_official_dashboard("127.0.0.1", 9119, timeout=0.01)
|
||||
|
||||
assert result == {"running": False}
|
||||
|
||||
|
||||
def test_dashboard_target_validation_allows_only_loopback_base_urls():
|
||||
from api.dashboard_probe import normalize_dashboard_url
|
||||
|
||||
assert normalize_dashboard_url("") is None
|
||||
assert normalize_dashboard_url("http://127.0.0.1:9120") == ("127.0.0.1", 9120, "http", "http://127.0.0.1:9120")
|
||||
assert normalize_dashboard_url("https://localhost:9443") == ("localhost", 9443, "https", "https://localhost:9443")
|
||||
assert normalize_dashboard_url("http://[::1]:9119") == ("::1", 9119, "http", "http://[::1]:9119")
|
||||
|
||||
for bad in (
|
||||
"http://example.com:9119",
|
||||
"http://169.254.169.254:80",
|
||||
"http://127.0.0.1:9119/api/status",
|
||||
"http://user:***@127.0.0.1:9119",
|
||||
"file:///etc/passwd",
|
||||
"http://127.0.0.1:99999",
|
||||
):
|
||||
try:
|
||||
normalize_dashboard_url(bad)
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError(f"unsafe dashboard override accepted: {bad}")
|
||||
|
||||
|
||||
def test_status_tries_default_loopback_targets_until_dashboard_found(monkeypatch):
|
||||
from api import dashboard_probe
|
||||
|
||||
attempts = []
|
||||
|
||||
def fake_probe(host, port, timeout=0.5, scheme="http"):
|
||||
attempts.append((host, port, timeout, scheme))
|
||||
if host == "localhost":
|
||||
return {"running": True, "host": host, "port": port, "url": "http://localhost:9119", "version": "0.12.0"}
|
||||
return {"running": False}
|
||||
|
||||
monkeypatch.setattr(dashboard_probe, "probe_official_dashboard", fake_probe)
|
||||
result = dashboard_probe.get_dashboard_status(config_data={})
|
||||
|
||||
assert result["running"] is True
|
||||
assert result["host"] == "localhost"
|
||||
assert attempts == [("127.0.0.1", 9119, 0.5, "http"), ("localhost", 9119, 0.5, "http")]
|
||||
|
||||
|
||||
def test_status_honors_never_and_strict_override(monkeypatch):
|
||||
from api import dashboard_probe
|
||||
|
||||
def fail_probe(*args, **kwargs):
|
||||
raise AssertionError("disabled dashboard must not probe")
|
||||
|
||||
monkeypatch.setattr(dashboard_probe, "probe_official_dashboard", fail_probe)
|
||||
assert dashboard_probe.get_dashboard_status(config_data={"webui": {"dashboard": {"enabled": "never"}}}) == {
|
||||
"running": False,
|
||||
"enabled": "never",
|
||||
}
|
||||
|
||||
result = dashboard_probe.get_dashboard_status(config_data={"webui": {"dashboard": {"url": "http://example.com:9119"}}})
|
||||
assert result["running"] is False
|
||||
assert "invalid" in result["error"]
|
||||
|
||||
|
||||
|
||||
|
||||
def test_status_skips_auto_probe_when_webui_bind_host_is_non_loopback(monkeypatch):
|
||||
from api import dashboard_probe
|
||||
|
||||
def fail_probe(*args, **kwargs):
|
||||
raise AssertionError("auto mode must not probe dashboard when WebUI binds non-loopback")
|
||||
|
||||
monkeypatch.setenv("HERMES_WEBUI_HOST", "0.0.0.0")
|
||||
monkeypatch.setattr(dashboard_probe, "probe_official_dashboard", fail_probe)
|
||||
|
||||
result = dashboard_probe.get_dashboard_status(config_data={})
|
||||
|
||||
assert result == {"running": False, "enabled": "auto"}
|
||||
|
||||
|
||||
def test_dashboard_status_route_returns_safe_payload(monkeypatch):
|
||||
from api import dashboard_probe
|
||||
from api.routes import handle_get
|
||||
|
||||
monkeypatch.setattr(
|
||||
dashboard_probe,
|
||||
"get_dashboard_status",
|
||||
lambda: {"running": True, "host": "127.0.0.1", "port": 9119, "url": "http://127.0.0.1:9119", "version": "0.12.0"},
|
||||
)
|
||||
|
||||
handler = _FakeHandler()
|
||||
parsed = urlparse("http://example.com/api/dashboard/status")
|
||||
handled = handle_get(handler, parsed)
|
||||
|
||||
assert handled is True
|
||||
assert handler.status == 200
|
||||
assert handler.json_body() == {
|
||||
"running": True,
|
||||
"host": "127.0.0.1",
|
||||
"port": 9119,
|
||||
"url": "http://127.0.0.1:9119",
|
||||
"version": "0.12.0",
|
||||
}
|
||||
|
||||
|
||||
def test_dashboard_config_roundtrip_writes_profile_config_yaml(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_CONFIG_PATH", str(tmp_path / "config.yaml"))
|
||||
|
||||
from api.dashboard_probe import get_dashboard_config, save_dashboard_config
|
||||
|
||||
assert get_dashboard_config() == {"enabled": "auto", "url": ""}
|
||||
saved = save_dashboard_config({"enabled": "never", "url": ""})
|
||||
assert saved == {"enabled": "never", "url": ""}
|
||||
|
||||
saved = save_dashboard_config({"enabled": "auto", "url": "http://127.0.0.1:19119"})
|
||||
assert saved == {"enabled": "auto", "url": "http://127.0.0.1:19119"}
|
||||
assert "dashboard:" in (tmp_path / "config.yaml").read_text(encoding="utf-8")
|
||||
|
||||
try:
|
||||
save_dashboard_config({"enabled": "auto", "url": "http://example.com:9119"})
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("external dashboard URL override must be rejected")
|
||||
Loading…
Reference in a new issue