Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 218 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import math
import mimetypes
import os
import signal
import queue
import re
import secrets
Expand Down Expand Up @@ -3144,6 +3145,7 @@ def _bounded_health_probe():
"config_version": current_ver,
"latest_config_version": latest_ver,
"can_update_hermes": not _dashboard_local_update_managed_externally(),
"can_restart_dashboard": bool(_dashboard_service_name()),
"gateway_running": gateway_running,
"gateway_state": gateway_state,
"gateway_platforms": gateway_platforms,
Expand Down Expand Up @@ -3640,6 +3642,8 @@ async def run_debug_share_endpoint(body: DebugShareRequest | None = None):
"gateway-restart": "gateway-restart.log",
"gateway-start": "gateway-start.log",
"gateway-stop": "gateway-stop.log",
"dashboard-restart": "dashboard-restart.log",
"hermes-restart": "hermes-restart.log",
"hermes-update": "hermes-update.log",
"doctor": "action-doctor.log",
"security-audit": "action-security-audit.log",
Expand Down Expand Up @@ -4043,6 +4047,220 @@ async def update_hermes():
}


def _dashboard_service_name() -> str:
"""Name of the systemd unit that runs this dashboard, when managed by systemd."""
if sys.platform != "linux":
return ""
# ``hermes dashboard install`` on Linux creates a systemd service named
# ``hermes-dashboard``. Check for it explicitly so the restart button only
# appears where the command we would run actually exists.
try:
out = subprocess.run(
["systemctl", "list-unit-files", "hermes-dashboard.service"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probes only the system manager. Current main deliberately probes systemctl --user first and keeps that scope for restart because Hermes installs Linux services in user scope by default (hermes_cli/main.py:7283-7335). Please reuse or extract that scope-aware logic; otherwise the standard hermes-dashboard.service is not detected here.

capture_output=True,
text=True,
timeout=5,
)
except Exception:
return ""
if "hermes-dashboard.service" not in out.stdout:
return ""
return "hermes-dashboard"


def _dashboard_restart_command(service: str) -> str:
"""Best command to restart the dashboard service (shown to the user)."""
return f"systemctl restart {service}"


@app.post("/api/system/restart")
async def restart_dashboard():
"""Restart the Hermes dashboard process via systemd.

Mirrors ``hermes gateway restart`` semantics but targets the dashboard
service itself (``hermes-dashboard``), so the dashboard comes back up
without touching the gateway. Non-systemd installs (Windows service, bare
``hermes dashboard`` in a terminal, containers) can't be restarted from
inside their own process, so return structured guidance instead — same
envelope shape the update endpoint uses for unsupported installs.
"""
service = _dashboard_service_name()
if not service:
message = (
"This dashboard is not running as a systemd service "
"(hermes-dashboard), so it can't restart itself from the browser. "
"Restart it from your terminal or service manager instead."
)
_record_completed_action("dashboard-restart", message, exit_code=1)
return {
"ok": False,
"pid": None,
"name": "dashboard-restart",
"error": "dashboard_restart_unsupported",
"message": message,
"restart_command": None,
}
command = _dashboard_restart_command(service)

def _do_restart() -> None:
# Give the HTTP response a moment to flush before the server dies.
time.sleep(1)
# systemd service units run as root; use sudo non-interactively (the
# dashboard user has NOPASSWD for this on a standard `hermes dashboard
# install`). start_new_session detaches the child from our process
# group so it survives long enough for systemd to act even though the
# restart kills this very process.
restart_cmd = (
["sudo", "-n", "systemctl", "restart", service]
if sys.platform == "linux"
else ["systemctl", "restart", service]
)
subprocess.Popen(
restart_cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)

try:
threading.Thread(target=_do_restart, daemon=True).start()
except Exception as exc:
_log.exception("Failed to spawn dashboard restart")
raise HTTPException(status_code=500, detail=f"Failed to restart dashboard: {exc}")
return {
"ok": True,
"pid": None,
"name": "dashboard-restart",
"message": f"Restarting dashboard via `{command}`…",
"restart_command": command,
}


def _signal_gateway_self_restart() -> Optional[int]:
"""Ask the running gateway to restart itself via SIGUSR1 (fire-and-forget).

The gateway runs as its own user-scope systemd service (``Restart=always``
in the unit), so signalling it to drain-and-exit makes systemd bring it
back up on its own. This is the drain-aware path the ``hermes gateway
restart`` command itself uses (``_graceful_restart_via_sigusr1`` in
``hermes_cli/gateway.py``).

Why not just spawn ``hermes gateway restart`` like the Restart Gateway
button does? ``restart-hermes`` restarts BOTH services, and the
dashboard's own ``systemctl restart`` (fired ~1s later) kills every
process in the dashboard's systemd cgroup — including a just-spawned
``hermes gateway restart`` child (``start_new_session`` only escapes the
process group, not the cgroup). Signalling the gateway directly avoids
the doomed subprocess entirely: the gateway lives in its own user-scope
service cgroup, survives the dashboard restart, and systemd revives it.

Returns the gateway PID signalled, or ``None`` if no running gateway
could be found (caller falls back to the spawn path).
"""
if not hasattr(signal, "SIGUSR1"):
return None
try:
pid = get_running_pid()
except Exception:
pid = None
if not pid:
try:
pid = get_runtime_status_running_pid()
except Exception:
pid = None
if not pid:
return None
try:
os.kill(pid, signal.SIGUSR1) # POSIX-only, guarded above

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get_running_pid() proves this is a live gateway, not that it has a supervisor. SIGUSR1 sets via_service=True (gateway/run.py:25364-25365) and the gateway exits with code 75 (gateway/run.py:12372-12393), so a manually launched gateway can be stopped without being relaunched. Gate this path on confirmed service supervision and otherwise use the existing restart fallback.

except (ProcessLookupError, PermissionError, OSError):
return None
return pid


@app.post("/api/system/restart-hermes")
async def restart_hermes(profile: Optional[str] = None):
"""Restart the whole Hermes stack: gateway + dashboard.

Composes the two individual restarts the sidebar already offers into a
single action: the gateway goes through the regular ``hermes gateway
restart`` path (spawned detached, logged, pollable) and the dashboard
restarts itself via systemd from a detached thread. The gateway restart
is kicked off first so its subprocess survives the dashboard's own
systemd restart (both use ``start_new_session``, so neither is killed
when the other dies).

Non-systemd dashboard installs can't restart themselves; return the same
structured envelope as the update/dashboard-restart endpoints so the
frontend can surface guidance instead of a fake success.
"""
# 1) Gateway: prefer direct SIGUSR1 self-restart so the gateway (its own
# user-scope systemd service) survives this dashboard's systemd restart.
# Fall back to the spawn path when no running gateway PID is found.
gateway_pid = _signal_gateway_self_restart()
gateway_via = "sigusr1"
if gateway_pid is None:
try:
gateway_proc, _reused = _spawn_gateway_restart(profile)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn gateway restart (hermes-restart)")
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
gateway_pid = gateway_proc.pid
gateway_via = "spawn"

# 2) Dashboard: only when systemd-managed.
service = _dashboard_service_name()
if not service:
message = (
"Gateway restart started, but this dashboard is not running as a "
"systemd service (hermes-dashboard), so it can't restart itself "
"from the browser. Restart it from your terminal or service "
"manager instead."
)
_record_completed_action("hermes-restart", message, exit_code=1)
return {
"ok": False,
"pid": gateway_pid,
"name": "hermes-restart",
"error": "dashboard_restart_unsupported",
"message": message,
"restart_command": None,
}
command = _dashboard_restart_command(service)

def _do_restart() -> None:
# Give the HTTP response a moment to flush before the server dies.
time.sleep(1)
restart_cmd = (
["sudo", "-n", "systemctl", "restart", service]
if sys.platform == "linux"
else ["systemctl", "restart", service]
)
subprocess.Popen(
restart_cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)

try:
threading.Thread(target=_do_restart, daemon=True).start()
except Exception as exc:
_log.exception("Failed to spawn dashboard restart (hermes-restart)")
raise HTTPException(status_code=500, detail=f"Failed to restart dashboard: {exc}")
return {
"ok": True,
"pid": gateway_pid,
"name": "hermes-restart",
"message": (
f"Restarting gateway (pid {gateway_pid}, via {gateway_via}) and "
f"dashboard via `{command}`…"
),
"restart_command": command,
}


def _recent_upstream_commits(n: int = 20) -> List[Dict[str, Any]]:
"""Commits the local checkout is behind ``origin/main`` by, newest first.

Expand Down
81 changes: 81 additions & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ import {
PanelLeftClose,
PanelLeftOpen,
Plug,
Power,
Puzzle,
Radio,
RefreshCw,
RotateCw,
Settings,
Shield,
Expand Down Expand Up @@ -934,7 +936,12 @@ function SidebarSystemActions({
const { activeAction, isBusy, isRunning, pendingAction, runAction } =
useSystemActions();
const canUpdateHermes = status?.can_update_hermes === true;
const canRestartDashboard = status?.can_restart_dashboard === true;
const [restartConfirmOpen, setRestartConfirmOpen] = useState(false);
const [dashboardRestartConfirmOpen, setDashboardRestartConfirmOpen] =
useState(false);
const [hermesRestartConfirmOpen, setHermesRestartConfirmOpen] =
useState(false);
const [updateConfirmOpen, setUpdateConfirmOpen] = useState(false);
const [updateConfirmInfo, setUpdateConfirmInfo] =
useState<UpdateCheckResponse | null>(null);
Expand Down Expand Up @@ -985,6 +992,24 @@ function SidebarSystemActions({
spin: true,
},
];
if (canRestartDashboard) {
items.push({
action: "dashboard",
icon: RefreshCw,
label: t.status.restartDashboard ?? "Restart Dashboard",
runningLabel: t.status.restartingDashboard ?? "Restarting dashboard…",
spin: true,
});
}
if (canRestartDashboard) {
items.push({
action: "hermes",
icon: Power,
label: t.status.restartHermes ?? "Restart Hermes",
runningLabel: t.status.restartingHermes ?? "Restarting Hermes…",
spin: true,
});
}
if (canUpdateHermes) {
items.push({
action: "update",
Expand All @@ -1001,6 +1026,14 @@ function SidebarSystemActions({
setRestartConfirmOpen(true);
return;
}
if (action === "dashboard") {
setDashboardRestartConfirmOpen(true);
return;
}
if (action === "hermes") {
setHermesRestartConfirmOpen(true);
return;
}
if (action === "update") {
setUpdateConfirmOpen(true);
return;
Expand All @@ -1017,6 +1050,20 @@ function SidebarSystemActions({
onNavigate();
};

const confirmDashboardRestart = () => {
setDashboardRestartConfirmOpen(false);
void runAction("dashboard");
navigate("/sessions");
onNavigate();
};

const confirmHermesRestart = () => {
setHermesRestartConfirmOpen(false);
void runAction("hermes");
navigate("/sessions");
onNavigate();
};

const confirmUpdate = () => {
setUpdateConfirmOpen(false);
void runAction("update");
Expand Down Expand Up @@ -1081,6 +1128,40 @@ function SidebarSystemActions({
}
/>

<ConfirmDialog
cancelLabel={t.common.cancel}
confirmLabel={t.status.restartDashboard ?? "Restart Dashboard"}
description={
t.status.restartDashboardConfirmMessage ??
"This restarts the Hermes dashboard service. The page will reconnect when it comes back up."
}
loading={pendingAction === "dashboard"}
onCancel={() => setDashboardRestartConfirmOpen(false)}
onConfirm={confirmDashboardRestart}
open={dashboardRestartConfirmOpen}
title={
t.status.restartDashboardConfirmTitle ??
`${t.status.restartDashboard ?? "Restart Dashboard"}?`
}
/>

<ConfirmDialog
cancelLabel={t.common.cancel}
confirmLabel={t.status.restartHermes ?? "Restart Hermes"}
description={
t.status.restartHermesConfirmMessage ??
"This restarts the Hermes gateway and dashboard services. The page will reconnect when everything comes back up."
}
loading={pendingAction === "hermes"}
onCancel={() => setHermesRestartConfirmOpen(false)}
onConfirm={confirmHermesRestart}
open={hermesRestartConfirmOpen}
title={
t.status.restartHermesConfirmTitle ??
`${t.status.restartHermes ?? "Restart Hermes"}?`
}
/>

<ConfirmDialog
cancelLabel={t.common.cancel}
confirmLabel={t.status.updateHermesConfirmNow ?? "Update now"}
Expand Down
Loading