From a8a086548d3281a1ed2a52c7cc825271572da735 Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Tue, 21 Apr 2026 22:30:22 -0400 Subject: [PATCH] feat: add restart gateway and update Hermes action buttons to web dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the update/restart action buttons called out in issue #961: - Backend (web_server.py): two new POST endpoints - /api/actions/restart-gateway — sends SIGUSR1 to the running gateway PID - /api/actions/update-hermes — runs `hermes update --yes` in a subprocess - Frontend (api.ts): restartGateway() / updateHermes() API helpers + ActionResponse type - UI (StatusPage.tsx): "Actions" card with Restart Gateway and Update Hermes buttons - idle → running (spinner) → success/failure states - feedback detail text; auto-resets to idle after 8 s - i18n: new status.actions / restartGateway / updateHermes strings in en, zh, and types Refs #961 Co-Authored-By: Claude Sonnet 4.6 --- hermes_cli/web_server.py | 67 +++++++++++++++++++++++ web/src/i18n/en.ts | 9 ++++ web/src/i18n/types.ts | 9 ++++ web/src/i18n/zh.ts | 9 ++++ web/src/lib/api.ts | 11 ++++ web/src/pages/StatusPage.tsx | 100 ++++++++++++++++++++++++++++++++++- 6 files changed, 204 insertions(+), 1 deletion(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 143860a69..d9aab4876 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1981,6 +1981,73 @@ async def update_config_raw(body: RawConfigUpdate): raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}") +# --------------------------------------------------------------------------- +# Action endpoints — restart gateway / update Hermes +# --------------------------------------------------------------------------- + + +class ActionResponse(BaseModel): + ok: bool + detail: str = "" + + +@app.post("/api/actions/restart-gateway") +async def restart_gateway(): + """Send SIGUSR1 to the running gateway so it drains and restarts. + + Falls back to a hard kill+restart if no PID is found or the signal + fails (e.g. the gateway is managed by a remote process / container). + Returns immediately with ``{"ok": true}`` if the signal was delivered; + the caller should poll ``/api/status`` to confirm the new state. + """ + from gateway.status import get_running_pid + + pid = get_running_pid() + if pid is None: + raise HTTPException(status_code=409, detail="Gateway is not running") + + import signal as _signal + + try: + os.kill(pid, _signal.SIGUSR1) + except (ProcessLookupError, PermissionError, OSError, AttributeError) as exc: + raise HTTPException(status_code=500, detail=f"Failed to signal gateway: {exc}") + + return {"ok": True, "detail": f"Restart signal sent to PID {pid}"} + + +@app.post("/api/actions/update-hermes") +async def update_hermes(): + """Run ``hermes update`` in a subprocess and return the output. + + The update is performed synchronously (in a thread pool executor) so + the endpoint blocks until completion. Clients should treat a 200 + response with ``"ok": true`` as success; ``"ok": false`` means the + subprocess exited non-zero. + """ + import subprocess + + loop = asyncio.get_event_loop() + + def _run_update(): + try: + result = subprocess.run( + [sys.executable, "-m", "hermes_cli.main", "update", "--yes"], + capture_output=True, + text=True, + timeout=300, + ) + combined = (result.stdout + result.stderr).strip() + return result.returncode == 0, combined + except subprocess.TimeoutExpired: + return False, "Update timed out after 5 minutes" + except Exception as exc: + return False, str(exc) + + ok, detail = await loop.run_in_executor(None, _run_update) + return {"ok": ok, "detail": detail} + + # --------------------------------------------------------------------------- # Token / cost analytics endpoint # --------------------------------------------------------------------------- diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 3bf693f21..937564b89 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -86,6 +86,15 @@ export const en: Translations = { lastUpdate: "Last update", platformError: "error", platformDisconnected: "disconnected", + actions: "Actions", + restartGateway: "Restart Gateway", + restarting: "Restarting…", + restartSuccess: "Gateway restart signal sent", + restartFailed: "Restart failed", + updateHermes: "Update Hermes", + updating: "Updating…", + updateSuccess: "Update complete", + updateFailed: "Update failed", }, sessions: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 34813c68f..9716f72e2 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -89,6 +89,15 @@ export interface Translations { lastUpdate: string; platformError: string; platformDisconnected: string; + actions: string; + restartGateway: string; + restarting: string; + restartSuccess: string; + restartFailed: string; + updateHermes: string; + updating: string; + updateSuccess: string; + updateFailed: string; }; // ── Sessions page ── diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 18cb3ee38..6e9b38056 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -86,6 +86,15 @@ export const zh: Translations = { lastUpdate: "最后更新", platformError: "错误", platformDisconnected: "已断开", + actions: "操作", + restartGateway: "重启网关", + restarting: "重启中…", + restartSuccess: "重启信号已发送", + restartFailed: "重启失败", + updateHermes: "更新 Hermes", + updating: "更新中…", + updateSuccess: "更新完成", + updateFailed: "更新失败", }, sessions: { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e61043993..9a728a97f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -182,6 +182,12 @@ export const api = { }, ); }, + + // Dashboard actions + restartGateway: () => + fetchJSON("/api/actions/restart-gateway", { method: "POST" }), + updateHermes: () => + fetchJSON("/api/actions/update-hermes", { method: "POST" }), }; export interface PlatformStatus { @@ -409,6 +415,11 @@ export interface OAuthSubmitResponse { message?: string; } +export interface ActionResponse { + ok: boolean; + detail: string; +} + export interface OAuthPollResponse { session_id: string; status: "pending" | "approved" | "denied" | "expired" | "error"; diff --git a/web/src/pages/StatusPage.tsx b/web/src/pages/StatusPage.tsx index 0b71d2c96..d457cabc9 100644 --- a/web/src/pages/StatusPage.tsx +++ b/web/src/pages/StatusPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Activity, AlertTriangle, @@ -6,19 +6,30 @@ import { Cpu, Database, Radio, + RefreshCw, + TriangleAlert, Wifi, WifiOff, + Zap, } from "lucide-react"; import { api } from "@/lib/api"; import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api"; import { timeAgo, isoTimeAgo } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { useI18n } from "@/i18n"; +type ActionState = "idle" | "running" | "success" | "failure"; + export default function StatusPage() { const [status, setStatus] = useState(null); const [sessions, setSessions] = useState([]); + const [restartState, setRestartState] = useState("idle"); + const [restartDetail, setRestartDetail] = useState(""); + const [updateState, setUpdateState] = useState("idle"); + const [updateDetail, setUpdateDetail] = useState(""); + const resetTimers = useRef>>({}); const { t } = useI18n(); useEffect(() => { @@ -31,6 +42,39 @@ export default function StatusPage() { return () => clearInterval(interval); }, []); + function scheduleReset(key: string, setter: (s: ActionState) => void) { + clearTimeout(resetTimers.current[key]); + resetTimers.current[key] = setTimeout(() => setter("idle"), 8000); + } + + async function handleRestartGateway() { + setRestartState("running"); + setRestartDetail(""); + try { + const resp = await api.restartGateway(); + setRestartState(resp.ok ? "success" : "failure"); + setRestartDetail(resp.detail); + } catch (err: unknown) { + setRestartState("failure"); + setRestartDetail(err instanceof Error ? err.message : String(err)); + } + scheduleReset("restart", setRestartState); + } + + async function handleUpdateHermes() { + setUpdateState("running"); + setUpdateDetail(""); + try { + const resp = await api.updateHermes(); + setUpdateState(resp.ok ? "success" : "failure"); + setUpdateDetail(resp.detail); + } catch (err: unknown) { + setUpdateState("failure"); + setUpdateDetail(err instanceof Error ? err.message : String(err)); + } + scheduleReset("update", setUpdateState); + } + if (!status) { return (
@@ -159,6 +203,60 @@ export default function StatusPage() { ))}
+ {/* Action buttons — restart gateway / update Hermes */} + + +
+ + {t.status.actions} +
+
+ + {/* Restart Gateway */} +
+ + {restartDetail && ( +

+ {restartState === "failure" && } + {restartState === "success" ? t.status.restartSuccess : restartState === "failure" ? t.status.restartFailed : ""} + {restartDetail && ` — ${restartDetail}`} +

+ )} + {restartState === "success" && !restartDetail && ( +

{t.status.restartSuccess}

+ )} +
+ + {/* Update Hermes */} +
+ + {(updateDetail || updateState === "success" || updateState === "failure") && ( +

+ {updateState === "failure" && } + {updateState === "success" ? t.status.updateSuccess : updateState === "failure" ? t.status.updateFailed : ""} + {updateDetail && ` — ${updateDetail}`} +

+ )} +
+
+
+ {platforms.length > 0 && ( )}