From fc21c14206c021a5ca0b10d5bceef49729fce0f7 Mon Sep 17 00:00:00 2001 From: Austin Pickett Date: Tue, 21 Apr 2026 09:01:23 -0400 Subject: [PATCH] feat: add buttons to update hermes and restart gateway --- hermes_cli/web_server.py | 133 ++++++++++++++++++++ package-lock.json | 3 + ui-tui/package-lock.json | 40 +++--- web/package-lock.json | 26 +++- web/src/i18n/en.ts | 35 ++++-- web/src/i18n/types.ts | 37 +++--- web/src/i18n/zh.ts | 35 ++++-- web/src/lib/api.ts | 24 ++++ web/src/pages/StatusPage.tsx | 229 ++++++++++++++++++++++++++++++++++- 9 files changed, 492 insertions(+), 70 deletions(-) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index fe6b979e4..cb0e81d9a 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -16,6 +16,7 @@ import json import logging import os import secrets +import subprocess import sys import threading import time @@ -476,6 +477,138 @@ async def get_status(): } +# --------------------------------------------------------------------------- +# Gateway + update actions (invoked from the Status page). +# +# Both commands are spawned as detached subprocesses so the HTTP request +# returns immediately. stdin is closed (``DEVNULL``) so any stray ``input()`` +# calls fail fast with EOF rather than hanging forever. stdout/stderr are +# streamed to a per-action log file under ``~/.hermes/logs/.log`` so +# the dashboard can tail them back to the user. +# --------------------------------------------------------------------------- + +_ACTION_LOG_DIR: Path = get_hermes_home() / "logs" + +# Short ``name`` (from the URL) → absolute log file path. +_ACTION_LOG_FILES: Dict[str, str] = { + "gateway-restart": "gateway-restart.log", + "hermes-update": "hermes-update.log", +} + +# ``name`` → most recently spawned Popen handle. Used so ``status`` can +# report liveness and exit code without shelling out to ``ps``. +_ACTION_PROCS: Dict[str, subprocess.Popen] = {} + + +def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: + """Spawn ``hermes `` detached and record the Popen handle. + + Uses the running interpreter's ``hermes_cli.main`` module so the action + inherits the same venv/PYTHONPATH the web server is using. + """ + log_file_name = _ACTION_LOG_FILES[name] + _ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True) + log_path = _ACTION_LOG_DIR / log_file_name + log_file = open(log_path, "ab", buffering=0) + log_file.write( + f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode() + ) + + cmd = [sys.executable, "-m", "hermes_cli.main", *subcommand] + + popen_kwargs: Dict[str, Any] = { + "cwd": str(PROJECT_ROOT), + "stdin": subprocess.DEVNULL, + "stdout": log_file, + "stderr": subprocess.STDOUT, + "env": {**os.environ, "HERMES_NONINTERACTIVE": "1"}, + } + if sys.platform == "win32": + popen_kwargs["creationflags"] = ( + subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] + | getattr(subprocess, "DETACHED_PROCESS", 0) + ) + else: + popen_kwargs["start_new_session"] = True + + proc = subprocess.Popen(cmd, **popen_kwargs) + _ACTION_PROCS[name] = proc + return proc + + +def _tail_lines(path: Path, n: int) -> List[str]: + """Return the last ``n`` lines of ``path``. Reads the whole file — fine + for our small per-action logs. Binary-decoded with ``errors='replace'`` + so log corruption doesn't 500 the endpoint.""" + if not path.exists(): + return [] + try: + text = path.read_text(errors="replace") + except OSError: + return [] + lines = text.splitlines() + return lines[-n:] if n > 0 else lines + + +@app.post("/api/gateway/restart") +async def restart_gateway(): + """Kick off a ``hermes gateway restart`` in the background.""" + try: + proc = _spawn_hermes_action(["gateway", "restart"], "gateway-restart") + except Exception as exc: + _log.exception("Failed to spawn gateway restart") + raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}") + return { + "ok": True, + "pid": proc.pid, + "name": "gateway-restart", + } + + +@app.post("/api/hermes/update") +async def update_hermes(): + """Kick off ``hermes update`` in the background.""" + try: + proc = _spawn_hermes_action(["update"], "hermes-update") + except Exception as exc: + _log.exception("Failed to spawn hermes update") + raise HTTPException(status_code=500, detail=f"Failed to start update: {exc}") + return { + "ok": True, + "pid": proc.pid, + "name": "hermes-update", + } + + +@app.get("/api/actions/{name}/status") +async def get_action_status(name: str, lines: int = 200): + """Tail an action log and report whether the process is still running.""" + log_file_name = _ACTION_LOG_FILES.get(name) + if log_file_name is None: + raise HTTPException(status_code=404, detail=f"Unknown action: {name}") + + log_path = _ACTION_LOG_DIR / log_file_name + tail = _tail_lines(log_path, min(max(lines, 1), 2000)) + + proc = _ACTION_PROCS.get(name) + if proc is None: + running = False + exit_code: Optional[int] = None + pid: Optional[int] = None + else: + exit_code = proc.poll() + running = exit_code is None + pid = proc.pid + + return { + "name": name, + "running": running, + "exit_code": exit_code, + "pid": pid, + "lines": tail, + } + + @app.get("/api/sessions") async def get_sessions(limit: int = 20, offset: int = 0): try: diff --git a/package-lock.json b/package-lock.json index 9d0ae80cd..728429e51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1069,6 +1069,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3911,6 +3912,7 @@ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "playwright-core": "1.59.1" }, @@ -3929,6 +3931,7 @@ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "license": "Apache-2.0", + "peer": true, "bin": { "playwright-core": "cli.js" }, diff --git a/ui-tui/package-lock.json b/ui-tui/package-lock.json index 46c83d195..522b416e5 100644 --- a/ui-tui/package-lock.json +++ b/ui-tui/package-lock.json @@ -89,6 +89,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -318,31 +319,6 @@ "node": ">=6.9.0" } }, - "node_modules/@emnapi/core": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", - "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", - "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", @@ -1509,6 +1485,7 @@ "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -1519,6 +1496,7 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1529,6 +1507,7 @@ "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.58.1", @@ -1558,6 +1537,7 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -1875,6 +1855,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2210,6 +2191,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2895,6 +2877,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3790,6 +3773,7 @@ "resolved": "https://registry.npmjs.org/ink-text-input/-/ink-text-input-6.0.0.tgz", "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", "license": "MIT", + "peer": true, "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" @@ -5146,6 +5130,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5245,6 +5230,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -6017,6 +6003,7 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6143,6 +6130,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6252,6 +6240,7 @@ "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -6660,6 +6649,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/package-lock.json b/web/package-lock.json index c522d8ba0..474fd2f4e 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -70,6 +70,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1103,6 +1104,7 @@ "resolved": "https://registry.npmjs.org/@observablehq/plot/-/plot-0.6.17.tgz", "integrity": "sha512-/qaXP/7mc4MUS0s4cPPFASDRjtsWp85/TbfsciqDgU1HwYixbSbbytNuInD8AcTYC3xaxACgVX06agdfQy9W+g==", "license": "ISC", + "peer": true, "dependencies": { "d3": "^7.9.0", "interval-tree-1d": "^1.0.0", @@ -1755,6 +1757,7 @@ "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.0.tgz", "integrity": "sha512-90abYK2q5/qDM+GACs9zRvc5KhEEpEWqWlHSd64zTPNxg+9wCJvTfyD9x2so7hlQhjRYO1Fa6flR3BC/kpTFkA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.17.8", "@types/webxr": "*", @@ -2489,6 +2492,7 @@ "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2498,6 +2502,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2508,6 +2513,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2572,6 +2578,7 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -2867,6 +2874,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3019,6 +3027,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3526,6 +3535,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -3839,6 +3849,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4217,7 +4228,8 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", - "license": "Standard 'no charge' license: https://gsap.com/standard-license." + "license": "Standard 'no charge' license: https://gsap.com/standard-license.", + "peer": true }, "node_modules/has-flag": { "version": "4.0.0", @@ -4532,6 +4544,7 @@ "resolved": "https://registry.npmjs.org/leva/-/leva-0.10.1.tgz", "integrity": "sha512-BcjnfUX8jpmwZUz2L7AfBtF9vn4ggTH33hmeufDULbP3YgNZ/C+ss/oO3stbrqRQyaOmRwy70y7BGTGO81S3rA==", "license": "MIT", + "peer": true, "dependencies": { "@radix-ui/react-portal": "^1.1.4", "@radix-ui/react-tooltip": "^1.1.8", @@ -4953,6 +4966,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -5080,6 +5094,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -5151,6 +5166,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -5170,6 +5186,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5532,7 +5549,8 @@ "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tinyglobby": { "version": "0.2.15", @@ -5597,6 +5615,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5682,6 +5701,7 @@ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", + "peer": true, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -5697,6 +5717,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -5818,6 +5839,7 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 8941fcda4..90b4aae63 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -65,27 +65,36 @@ export const en: Translations = { }, status: { + actionFailed: "Action failed", + actionFinished: "Finished", + actions: "Actions", agent: "Agent", - gateway: "Gateway", activeSessions: "Active Sessions", - recentSessions: "Recent Sessions", - connectedPlatforms: "Connected Platforms", - running: "Running", - starting: "Starting", - failed: "Failed", - stopped: "Stopped", connected: "Connected", + connectedPlatforms: "Connected Platforms", disconnected: "Disconnected", error: "Error", - notRunning: "Not running", - startFailed: "Start failed", - pid: "PID", - runningRemote: "Running (remote)", - noneRunning: "None", + failed: "Failed", + gateway: "Gateway", gatewayFailedToStart: "Gateway failed to start", lastUpdate: "Last update", - platformError: "error", + noneRunning: "None", + notRunning: "Not running", + pid: "PID", platformDisconnected: "disconnected", + platformError: "error", + recentSessions: "Recent Sessions", + restartGateway: "Restart Gateway", + restartingGateway: "Restarting gateway…", + running: "Running", + runningRemote: "Running (remote)", + startFailed: "Start failed", + starting: "Starting", + startedInBackground: "Started in background — check logs for progress", + stopped: "Stopped", + updateHermes: "Update Hermes", + updatingHermes: "Updating Hermes…", + waitingForOutput: "Waiting for output…", }, sessions: { diff --git a/web/src/i18n/types.ts b/web/src/i18n/types.ts index 5ae559c9c..1e16ee9f6 100644 --- a/web/src/i18n/types.ts +++ b/web/src/i18n/types.ts @@ -68,27 +68,36 @@ export interface Translations { // ── Status page ── status: { + actionFailed: string; + actionFinished: string; + actions: string; agent: string; - gateway: string; - activeSessions: string; - recentSessions: string; - connectedPlatforms: string; - running: string; - starting: string; - failed: string; - stopped: string; connected: string; + connectedPlatforms: string; disconnected: string; error: string; - notRunning: string; - startFailed: string; - pid: string; - runningRemote: string; - noneRunning: string; + failed: string; + gateway: string; gatewayFailedToStart: string; lastUpdate: string; - platformError: string; + noneRunning: string; + notRunning: string; + pid: string; platformDisconnected: string; + platformError: string; + activeSessions: string; + recentSessions: string; + restartGateway: string; + restartingGateway: string; + running: string; + runningRemote: string; + startFailed: string; + starting: string; + startedInBackground: string; + stopped: string; + updateHermes: string; + updatingHermes: string; + waitingForOutput: string; }; // ── Sessions page ── diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 34941f616..a6f3c067f 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -65,27 +65,36 @@ export const zh: Translations = { }, status: { + actionFailed: "操作失败", + actionFinished: "已完成", + actions: "操作", agent: "代理", - gateway: "网关", activeSessions: "活跃会话", - recentSessions: "最近会话", - connectedPlatforms: "已连接平台", - running: "运行中", - starting: "启动中", - failed: "失败", - stopped: "已停止", connected: "已连接", + connectedPlatforms: "已连接平台", disconnected: "已断开", error: "错误", - notRunning: "未运行", - startFailed: "启动失败", - pid: "进程", - runningRemote: "运行中(远程)", - noneRunning: "无", + failed: "失败", + gateway: "网关", gatewayFailedToStart: "网关启动失败", lastUpdate: "最后更新", - platformError: "错误", + noneRunning: "无", + notRunning: "未运行", + pid: "进程", platformDisconnected: "已断开", + platformError: "错误", + recentSessions: "最近会话", + restartGateway: "重启网关", + restartingGateway: "正在重启网关…", + running: "运行中", + runningRemote: "运行中(远程)", + startFailed: "启动失败", + starting: "启动中", + startedInBackground: "已在后台启动 — 请查看日志", + stopped: "已停止", + updateHermes: "更新 Hermes", + updatingHermes: "正在更新 Hermes…", + waitingForOutput: "等待输出…", }, sessions: { diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4d3960406..81225fb5d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -183,6 +183,16 @@ export const api = { ); }, + // Gateway / update actions + restartGateway: () => + fetchJSON("/api/gateway/restart", { method: "POST" }), + updateHermes: () => + fetchJSON("/api/hermes/update", { method: "POST" }), + getActionStatus: (name: string, lines = 200) => + fetchJSON( + `/api/actions/${encodeURIComponent(name)}/status?lines=${lines}`, + ), + // Dashboard plugins getPlugins: () => fetchJSON("/api/dashboard/plugins"), @@ -200,6 +210,20 @@ export const api = { }), }; +export interface ActionResponse { + name: string; + ok: boolean; + pid: number; +} + +export interface ActionStatusResponse { + exit_code: number | null; + lines: string[]; + name: string; + pid: number | null; + running: boolean; +} + export interface PlatformStatus { error_code?: string; error_message?: string; diff --git a/web/src/pages/StatusPage.tsx b/web/src/pages/StatusPage.tsx index 51e87e8e2..ab5e8f011 100644 --- a/web/src/pages/StatusPage.tsx +++ b/web/src/pages/StatusPage.tsx @@ -1,25 +1,53 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { Activity, AlertTriangle, + CheckCircle2, Clock, Cpu, Database, + Download, + Loader2, Radio, + RotateCw, Wifi, WifiOff, + X, } from "lucide-react"; import { Cell, Grid } from "@nous-research/ui"; import { api } from "@/lib/api"; -import type { PlatformStatus, SessionInfo, StatusResponse } from "@/lib/api"; -import { timeAgo, isoTimeAgo } from "@/lib/utils"; +import type { + ActionStatusResponse, + PlatformStatus, + SessionInfo, + StatusResponse, +} from "@/lib/api"; +import { cn, timeAgo, isoTimeAgo } from "@/lib/utils"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Toast } from "@/components/Toast"; import { useI18n } from "@/i18n"; +const ACTION_NAMES: Record<"restart" | "update", string> = { + restart: "gateway-restart", + update: "hermes-update", +}; + export default function StatusPage() { const [status, setStatus] = useState(null); const [sessions, setSessions] = useState([]); + const [pendingAction, setPendingAction] = useState< + "restart" | "update" | null + >(null); + const [activeAction, setActiveAction] = useState<"restart" | "update" | null>( + null, + ); + const [actionStatus, setActionStatus] = useState( + null, + ); + const [toast, setToast] = useState(null); + const logScrollRef = useRef(null); const { t } = useI18n(); useEffect(() => { @@ -38,6 +66,75 @@ export default function StatusPage() { return () => clearInterval(interval); }, []); + useEffect(() => { + if (!toast) return; + const timer = setTimeout(() => setToast(null), 4000); + return () => clearTimeout(timer); + }, [toast]); + + useEffect(() => { + if (!activeAction) return; + const name = ACTION_NAMES[activeAction]; + let cancelled = false; + + const poll = async () => { + try { + const resp = await api.getActionStatus(name); + if (cancelled) return; + setActionStatus(resp); + if (!resp.running) { + const ok = resp.exit_code === 0; + setToast({ + type: ok ? "success" : "error", + message: ok + ? t.status.actionFinished + : `${t.status.actionFailed} (exit ${resp.exit_code ?? "?"})`, + }); + return; + } + } catch { + // transient fetch error; keep polling + } + if (!cancelled) setTimeout(poll, 1500); + }; + + poll(); + return () => { + cancelled = true; + }; + }, [activeAction, t.status.actionFinished, t.status.actionFailed]); + + useEffect(() => { + const el = logScrollRef.current; + if (el) el.scrollTop = el.scrollHeight; + }, [actionStatus?.lines]); + + const runAction = async (action: "restart" | "update") => { + setPendingAction(action); + setActionStatus(null); + try { + if (action === "restart") { + await api.restartGateway(); + } else { + await api.updateHermes(); + } + setActiveAction(action); + } catch (err) { + const detail = err instanceof Error ? err.message : String(err); + setToast({ + type: "error", + message: `${t.status.actionFailed}: ${detail}`, + }); + } finally { + setPendingAction(null); + } + }; + + const dismissLog = () => { + setActiveAction(null); + setActionStatus(null); + }; + if (!status) { return (
@@ -144,6 +241,8 @@ export default function StatusPage() { return (
+ + {alerts.length > 0 && (
@@ -196,6 +295,125 @@ export default function StatusPage() { ))} + + + {t.status.actions} + + + +
+ + + +
+ + {activeAction && ( +
+
+
+ {actionStatus?.running ? ( + + ) : actionStatus?.exit_code === 0 ? ( + + ) : actionStatus !== null ? ( + + ) : ( + + )} + + + {activeAction === "restart" + ? t.status.restartGateway + : t.status.updateHermes} + + + + {actionStatus?.running + ? t.status.running + : actionStatus?.exit_code === 0 + ? t.status.actionFinished + : actionStatus + ? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})` + : t.common.loading} + +
+ + +
+ +
+                {actionStatus?.lines && actionStatus.lines.length > 0
+                  ? actionStatus.lines.join("\n")
+                  : t.status.waitingForOutput}
+              
+
+ )} +
+
+ {platforms.length > 0 && (