Stage 298: PR #1677 — feat: link official Hermes dashboard by @Michaelyklam

This commit is contained in:
test 2026-05-05 01:29:49 +00:00
commit 3699e83c43
10 changed files with 632 additions and 0 deletions

211
api/dashboard_probe.py Normal file
View 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}

View file

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

View file

@ -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: '대화 필터…',

View file

@ -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>

View file

@ -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);

View file

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

View file

@ -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');

View 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

View 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")