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:
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
Reference in New Issue
Block a user