hermes-agent/web/src/contexts/SystemActions.tsx
Ben Barclay 4f7fe9bcff
fix(dashboard): surface Docker update guidance instead of generic failure (#34347) (#37085)
The dashboard Update button's backend guard (#36263) already returns a
structured {ok:false, error:"docker_update_unsupported", message,
update_command} envelope (HTTP 200) when running in a Docker install,
instead of surfacing a raw SystemExit. But the frontend ignored that
envelope: runAction() only branched on a thrown error, so the 200 fell
through to the action-status poll, which reported a generic
"Action failed (exit 1)" toast and never showed the actual guidance.

Now runAction() inspects the update response and, on the
docker_update_unsupported case, surfaces the backend's guidance message
plus the recommended re-pull command directly (success-styled, since it's
actionable guidance — not a crash) without starting the poll.

Closes #34347.
2026-06-02 10:36:10 +10:00

138 lines
4 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { ActionStatusResponse } from "@/lib/api";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useI18n } from "@/i18n";
import {
SystemActionsContext,
type SystemAction,
} from "./system-actions-context";
const ACTION_NAMES: Record<SystemAction, string> = {
restart: "gateway-restart",
update: "hermes-update",
};
export function SystemActionsProvider({
children,
}: {
children: React.ReactNode;
}) {
const [pendingAction, setPendingAction] = useState<SystemAction | null>(null);
const [activeAction, setActiveAction] = useState<SystemAction | null>(null);
const [actionStatus, setActionStatus] = useState<ActionStatusResponse | null>(
null,
);
const [toast, setToast] = useState<ToastState | null>(null);
const { t } = useI18n();
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]);
const runAction = useCallback(
async (action: SystemAction) => {
setPendingAction(action);
setActionStatus(null);
try {
if (action === "restart") {
await api.restartGateway();
setActiveAction(action);
} else {
const resp = await api.updateHermes();
// In a Docker install the image is immutable, so `hermes update`
// can't apply — the endpoint returns 200 with a structured
// {ok:false, error:"docker_update_unsupported", message, update_command}
// envelope instead of spawning the action (see #34347 / #36263).
// Surface that guidance to the user rather than starting the poll,
// which would otherwise report a generic "failed (exit 1)".
if (!resp.ok && resp.error === "docker_update_unsupported") {
const cmd = resp.update_command ? ` ${resp.update_command}` : "";
setToast({
type: "success",
message:
(resp.message ??
"Updates don't apply inside Docker — re-pull the image instead.") +
cmd,
});
return;
}
setActiveAction(action);
}
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
setToast({
type: "error",
message: `${t.status.actionFailed}: ${detail}`,
});
} finally {
setPendingAction(null);
}
},
[t.status.actionFailed],
);
const dismissLog = useCallback(() => {
setActiveAction(null);
setActionStatus(null);
}, []);
const isRunning = activeAction !== null && actionStatus?.running !== false;
const isBusy = pendingAction !== null || isRunning;
return (
<SystemActionsContext.Provider
value={{
actionStatus,
activeAction,
dismissLog,
isBusy,
isRunning,
pendingAction,
runAction,
}}
>
{children}
<Toast toast={toast} />
</SystemActionsContext.Provider>
);
}
interface ToastState {
message: string;
type: "success" | "error";
}