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

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<