feat(dashboard): check-before-update flow on the System page (#38205)

The dashboard's update button ran 'hermes update' immediately with no
preview. Now the System page shows whether an update is available and
asks the user to confirm before applying it.

- New GET /api/hermes/update/check: reports install method, current
  version, and commits-behind (via banner.check_for_updates, 6h-cached;
  ?force=1 busts the cache). Soft-fails to behind=null on network error;
  marks docker/nix/homebrew as can_apply=false with the out-of-band cmd.
- System page: update-status badge on the Hermes version row (latest /
  N behind), a Check-for-updates button, and an Update-now button that
  opens a ConfirmDialog showing the commit count before POST /api/hermes/
  update fires. Cached status loads with the rest of the page.
- Docs + 5 endpoint tests (git/up-to-date/docker/soft-failure + auth gate).
This commit is contained in:
Teknium
2026-06-03 05:57:15 -07:00
committed by GitHub
parent ba57ebec33
commit c5d199eada
5 changed files with 296 additions and 3 deletions

View File

@ -495,6 +495,10 @@ export const api = {
fetchJSON<ActionResponse>("/api/gateway/restart", { method: "POST" }),
updateHermes: () =>
fetchJSON<ActionResponse>("/api/hermes/update", { method: "POST" }),
checkHermesUpdate: (force = false) =>
fetchJSON<UpdateCheckResponse>(
`/api/hermes/update/check${force ? "?force=true" : ""}`,
),
getActionStatus: (name: string, lines = 200) =>
fetchJSON<ActionStatusResponse>(
`/api/actions/${encodeURIComponent(name)}/status?lines=${lines}`,
@ -1009,6 +1013,18 @@ export interface HookCreate {
approve?: boolean;
}
export interface UpdateCheckResponse {
install_method: string;
current_version: string;
// commits behind: >=1 known count, 0 up to date, -1 behind by unknown
// count (nix/pypi), or null when the check could not run.
behind: number | null;
update_available: boolean;
can_apply: boolean;
update_command: string;
message: string | null;
}
export interface SystemStats {
os: string;
os_release: string;

View File

@ -5,6 +5,7 @@ import {
Brain,
Cpu,
Database,
Download,
Globe,
HardDrive,
KeyRound,
@ -31,6 +32,7 @@ import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { ConfirmDialog } from "@nous-research/ui/ui/components/confirm-dialog";
import { useModalBehavior } from "@/hooks/useModalBehavior";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import { cn, themedBody } from "@/lib/utils";
@ -43,6 +45,7 @@ import type {
HooksResponse,
HookEntry,
SystemStats,
UpdateCheckResponse,
CuratorStatus,
PortalStatus,
} from "@/lib/api";
@ -176,6 +179,13 @@ export default function SystemPage() {
const [hookApprove, setHookApprove] = useState(true);
const [creatingHook, setCreatingHook] = useState(false);
// ── Update check ───────────────────────────────────────────────────
const [updateInfo, setUpdateInfo] = useState<UpdateCheckResponse | null>(
null,
);
const [checkingUpdate, setCheckingUpdate] = useState(false);
const [updateConfirmOpen, setUpdateConfirmOpen] = useState(false);
const loadAll = useCallback(() => {
Promise.allSettled([
api.getStatus(),
@ -186,8 +196,11 @@ export default function SystemPage() {
api.getHooks(),
api.getCurator(),
api.getPortal(),
// Cached (non-forced) check so the version row shows update status on
// load without a separate effect / a forced network round-trip.
api.checkHermesUpdate(false),
])
.then(([s, st, m, p, c, h, cur, prt]) => {
.then(([s, st, m, p, c, h, cur, prt, upd]) => {
if (s.status === "fulfilled") setStatus(s.value);
if (st.status === "fulfilled") setStats(st.value);
if (m.status === "fulfilled") setMemory(m.value);
@ -196,6 +209,7 @@ export default function SystemPage() {
if (h.status === "fulfilled") setHooks(h.value);
if (cur.status === "fulfilled") setCurator(cur.value);
if (prt.status === "fulfilled") setPortal(prt.value);
if (upd.status === "fulfilled") setUpdateInfo(upd.value);
})
.finally(() => setLoading(false));
}, []);
@ -310,6 +324,57 @@ export default function SystemPage() {
}
};
// ── Update check / apply ───────────────────────────────────────────
const checkForUpdate = useCallback(
async (force = false) => {
setCheckingUpdate(true);
try {
const info = await api.checkHermesUpdate(force);
setUpdateInfo(info);
if (force) {
if (info.update_available) {
showToast(
info.behind && info.behind > 0
? `Update available — ${info.behind} commit${info.behind === 1 ? "" : "s"} behind`
: "Update available",
"success",
);
} else if (info.behind === 0) {
showToast("You're on the latest version", "success");
} else if (info.message) {
showToast(info.message, "error");
}
}
} catch (e) {
showToast(`Update check failed: ${e}`, "error");
} finally {
setCheckingUpdate(false);
}
},
[showToast],
);
// Auto-check (cached) runs inside loadAll on mount; this is the
// user-triggered forced re-check from the "Check for updates" button.
const applyUpdate = async () => {
setUpdateConfirmOpen(false);
try {
const resp = await api.updateHermes();
if (!resp.ok && resp.error === "docker_update_unsupported") {
showToast(
resp.message ??
"Updates don't apply inside Docker — re-pull the image instead.",
"error",
);
return;
}
setActiveAction(resp.name ?? "hermes-update");
showToast("Update started", "success");
} catch (e) {
showToast(`Update failed: ${e}`, "error");
}
};
const checkpointsPrune = useConfirmDelete({
onDelete: useCallback(async () => {
try {
@ -387,6 +452,19 @@ export default function SystemPage() {
<div className="flex flex-col gap-8">
<Toast toast={toast} />
<ConfirmDialog
open={updateConfirmOpen}
onCancel={() => setUpdateConfirmOpen(false)}
onConfirm={() => void applyUpdate()}
title="Update Hermes?"
description={
updateInfo && updateInfo.behind && updateInfo.behind > 0
? `This will run 'hermes update' (${updateInfo.update_command}) and pull ${updateInfo.behind} new commit${updateInfo.behind === 1 ? "" : "s"}. The gateway restarts when the update finishes; the current session keeps its prompt cache until then.`
: `This will run 'hermes update' (${updateInfo?.update_command ?? "hermes update"}) and restart the gateway when it finishes.`
}
confirmLabel="Update now"
/>
<DeleteConfirmDialog
open={memoryReset.isOpen}
onCancel={memoryReset.cancel}
@ -552,7 +630,19 @@ export default function SystemPage() {
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground">Hermes</div>
<div>v{stats?.hermes_version}</div>
<div className="flex items-center gap-2">
<span>v{stats?.hermes_version}</span>
{updateInfo &&
(updateInfo.update_available ? (
<Badge tone="warning">
{updateInfo.behind && updateInfo.behind > 0
? `${updateInfo.behind} behind`
: "update available"}
</Badge>
) : updateInfo.behind === 0 ? (
<Badge tone="success">latest</Badge>
) : null)}
</div>
</div>
<div>
<div className="text-xs uppercase tracking-wider text-muted-foreground flex items-center gap-1">
@ -602,6 +692,45 @@ export default function SystemPage() {
CPU / memory / disk metrics.
</p>
)}
<div className="mt-4 flex flex-wrap items-center gap-2 border-t border-border pt-4">
<Button
size="sm"
ghost
disabled={checkingUpdate}
prefix={
checkingUpdate ? (
<Spinner className="h-3.5 w-3.5" />
) : (
<RotateCw className="h-3.5 w-3.5" />
)
}
onClick={() => void checkForUpdate(true)}
>
Check for updates
</Button>
{updateInfo?.update_available && updateInfo.can_apply && (
<Button
size="sm"
prefix={<Download className="h-3.5 w-3.5" />}
onClick={() => setUpdateConfirmOpen(true)}
>
Update now
</Button>
)}
{updateInfo &&
!updateInfo.can_apply &&
updateInfo.update_available && (
<span className="text-xs text-muted-foreground">
Update with{" "}
<span className="font-mono">{updateInfo.update_command}</span>
</span>
)}
{updateInfo?.message && !updateInfo.update_available && (
<span className="text-xs text-muted-foreground">
{updateInfo.message}
</span>
)}
</div>
</CardContent>
</Card>
</section>