feat: add buttons to update hermes and restart gateway

This commit is contained in:
Austin Pickett 2026-04-21 09:01:23 -04:00
parent ea06104a3c
commit fc21c14206
9 changed files with 492 additions and 70 deletions

26
web/package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

@ -183,6 +183,16 @@ export const api = {
);
},
// Gateway / update actions
restartGateway: () =>
fetchJSON<ActionResponse>("/api/gateway/restart", { method: "POST" }),
updateHermes: () =>
fetchJSON<ActionResponse>("/api/hermes/update", { method: "POST" }),
getActionStatus: (name: string, lines = 200) =>
fetchJSON<ActionStatusResponse>(
`/api/actions/${encodeURIComponent(name)}/status?lines=${lines}`,
),
// Dashboard plugins
getPlugins: () =>
fetchJSON<PluginManifestResponse[]>("/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;

View file

@ -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<StatusResponse | null>(null);
const [sessions, setSessions] = useState<SessionInfo[]>([]);
const [pendingAction, setPendingAction] = useState<
"restart" | "update" | null
>(null);
const [activeAction, setActiveAction] = useState<"restart" | "update" | null>(
null,
);
const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(
null,
);
const [toast, setToast] = useState<ToastState | null>(null);
const logScrollRef = useRef<HTMLPreElement | null>(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 (
<div className="flex items-center justify-center py-24">
@ -144,6 +241,8 @@ export default function StatusPage() {
return (
<div className="flex flex-col gap-6">
<Toast toast={toast} />
{alerts.length > 0 && (
<div className="border border-destructive/30 bg-destructive/[0.06] p-4">
<div className="flex items-start gap-3">
@ -196,6 +295,125 @@ export default function StatusPage() {
))}
</Grid>
<Card>
<CardHeader>
<CardTitle className="text-base">{t.status.actions}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-3">
<Button
variant="outline"
size="sm"
onClick={() => runAction("restart")}
disabled={
pendingAction !== null ||
(activeAction !== null && actionStatus?.running !== false)
}
>
<RotateCw
className={cn(
"h-3.5 w-3.5",
(pendingAction === "restart" ||
(activeAction === "restart" && actionStatus?.running)) &&
"animate-spin",
)}
/>
{activeAction === "restart" && actionStatus?.running
? t.status.restartingGateway
: t.status.restartGateway}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => runAction("update")}
disabled={
pendingAction !== null ||
(activeAction !== null && actionStatus?.running !== false)
}
>
<Download
className={cn(
"h-3.5 w-3.5",
(pendingAction === "update" ||
(activeAction === "update" && actionStatus?.running)) &&
"animate-pulse",
)}
/>
{activeAction === "update" && actionStatus?.running
? t.status.updatingHermes
: t.status.updateHermes}
</Button>
</div>
{activeAction && (
<div className="border border-border bg-background-base/50">
<div className="flex items-center justify-between gap-2 border-b border-border px-3 py-2">
<div className="flex items-center gap-2 min-w-0">
{actionStatus?.running ? (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-warning" />
) : actionStatus?.exit_code === 0 ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : actionStatus !== null ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-destructive" />
) : (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
)}
<span className="text-xs font-mondwest tracking-[0.12em] truncate">
{activeAction === "restart"
? t.status.restartGateway
: t.status.updateHermes}
</span>
<Badge
variant={
actionStatus?.running
? "warning"
: actionStatus?.exit_code === 0
? "success"
: actionStatus
? "destructive"
: "outline"
}
className="text-[10px] shrink-0"
>
{actionStatus?.running
? t.status.running
: actionStatus?.exit_code === 0
? t.status.actionFinished
: actionStatus
? `${t.status.actionFailed} (${actionStatus.exit_code ?? "?"})`
: t.common.loading}
</Badge>
</div>
<button
type="button"
onClick={dismissLog}
className="shrink-0 opacity-60 hover:opacity-100 cursor-pointer"
aria-label={t.common.close}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
<pre
ref={logScrollRef}
className="max-h-72 overflow-auto px-3 py-2 font-mono-ui text-[11px] leading-relaxed whitespace-pre-wrap break-all"
>
{actionStatus?.lines && actionStatus.lines.length > 0
? actionStatus.lines.join("\n")
: t.status.waitingForOutput}
</pre>
</div>
)}
</CardContent>
</Card>
{platforms.length > 0 && (
<PlatformsCard
platforms={platforms}
@ -378,6 +596,11 @@ function PlatformsCard({ platforms, platformStateBadge }: PlatformsCardProps) {
);
}
interface ToastState {
message: string;
type: "success" | "error";
}
interface PlatformsCardProps {
platforms: [string, PlatformStatus][];
platformStateBadge: Record<