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

@ -1165,6 +1165,72 @@ async def update_hermes():
}
@app.get("/api/hermes/update/check")
async def check_hermes_update(force: bool = False):
"""Report whether a Hermes update is available, without applying it.
Powers the dashboard's "check before you update" flow: the System page
shows the commit-behind count and asks the user to confirm before
``POST /api/hermes/update`` actually runs ``hermes update``.
Returns:
install_method: 'git' | 'pip' | 'docker' | 'nixos' | 'homebrew' | ...
current_version: installed Hermes version string
behind: commits behind upstream (>=1), 0 if up to date,
-1 if behind by an unknown count (nix/pypi), or null if the
check could not run (offline, no remote, etc.)
update_available: convenience bool (behind is non-zero and not null)
can_apply: True when the dashboard's update button can apply it
in place (git/pip); False for docker/nix/homebrew where the
user must update out-of-band
update_command: the recommended command for this install method
message: human-readable guidance for non-applyable methods
"""
install_method = detect_install_method(PROJECT_ROOT)
update_command = recommended_update_command_for_method(install_method)
payload: Dict[str, Any] = {
"install_method": install_method,
"current_version": __version__,
"behind": None,
"update_available": False,
"can_apply": install_method in ("git", "pip"),
"update_command": update_command,
"message": None,
}
if install_method == "docker":
payload["message"] = format_docker_update_message()
return payload
# banner.check_for_updates() handles git / pypi / nix-revision paths and
# caches the result for 6h. ``force`` busts the cache so the "Check now"
# button reflects reality immediately.
try:
from hermes_cli.banner import check_for_updates
if force:
try:
(get_hermes_home() / ".update_check").unlink()
except OSError:
pass
behind = await asyncio.to_thread(check_for_updates)
except Exception:
_log.exception("Update check failed")
behind = None
payload["behind"] = behind
if behind is None:
payload["message"] = "Couldn't reach the update source — try again later."
elif behind == 0:
payload["message"] = "You're on the latest version."
else:
payload["update_available"] = True
return payload
@app.post("/api/audio/transcribe")
async def transcribe_audio_upload(payload: AudioTranscriptionRequest):
data_url = (payload.data_url or "").strip()

View File

@ -412,8 +412,89 @@ class TestAdminEndpointsAuthGate:
"/api/curator",
"/api/portal",
"/api/system/stats",
"/api/hermes/update/check",
],
)
def test_gated(self, path):
resp = self.client.get(path)
assert resp.status_code in (401, 403)
class TestUpdateCheckEndpoint:
"""``GET /api/hermes/update/check`` reports availability without applying.
Powers the dashboard's check-before-you-update flow: the System page
shows the commit-behind count and asks the user to confirm before
``POST /api/hermes/update`` runs ``hermes update``.
"""
@pytest.fixture(autouse=True)
def _setup(self, _isolate_hermes_home):
self.client, _ = _client()
def test_git_install_reports_behind_count(self, monkeypatch):
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
# Stub the shared checker so the contract is deterministic (no network).
import hermes_cli.banner as banner
monkeypatch.setattr(banner, "check_for_updates", lambda: 5)
r = self.client.get("/api/hermes/update/check")
assert r.status_code == 200
body = r.json()
assert {
"install_method",
"current_version",
"behind",
"update_available",
"can_apply",
"update_command",
"message",
} <= set(body)
assert body["install_method"] == "git"
assert body["behind"] == 5
assert body["update_available"] is True
# git/pip installs can apply the update in place from the dashboard.
assert body["can_apply"] is True
def test_up_to_date(self, monkeypatch):
import hermes_cli.web_server as ws
import hermes_cli.banner as banner
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
monkeypatch.setattr(banner, "check_for_updates", lambda: 0)
body = self.client.get("/api/hermes/update/check").json()
assert body["behind"] == 0
assert body["update_available"] is False
def test_docker_is_not_applyable(self, monkeypatch):
import hermes_cli.web_server as ws
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "docker")
body = self.client.get("/api/hermes/update/check").json()
# Docker images are immutable — the dashboard can't apply an update.
assert body["can_apply"] is False
assert body["message"]
assert body["behind"] is None
def test_check_failure_is_soft(self, monkeypatch):
import hermes_cli.web_server as ws
import hermes_cli.banner as banner
monkeypatch.setattr(ws, "detect_install_method", lambda *a, **k: "git")
def _boom():
raise RuntimeError("offline")
monkeypatch.setattr(banner, "check_for_updates", _boom)
# A failed check must not 500 — it returns behind=null with guidance.
r = self.client.get("/api/hermes/update/check")
assert r.status_code == 200
body = r.json()
assert body["behind"] is None
assert body["update_available"] is False
assert body["message"]

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>

View File

@ -259,7 +259,7 @@ the API server and webhook endpoints) with its live connection status.
A consolidated administration panel for installation-wide operations:
- **Host** — live system stats: OS / kernel, architecture, hostname, Python and Hermes versions, CPU core count + utilization, memory, disk usage of the Hermes home, uptime, and load average. (CPU/memory/disk come from `psutil` when installed; identity fields are always shown.)
- **Host** — live system stats: OS / kernel, architecture, hostname, Python and Hermes versions, CPU core count + utilization, memory, disk usage of the Hermes home, uptime, and load average. (CPU/memory/disk come from `psutil` when installed; identity fields are always shown.) The Hermes version shows an **update-status badge** (up to date / N commits behind) and a **Check for updates** button. When an update is available on a git or pip install, an **Update now** button opens a confirmation dialog — showing how many commits you'll pull — before running `hermes update` in the background. On Docker/Nix/Homebrew installs the dashboard can't apply the update in place, so it shows the correct out-of-band command instead.
- **Nous Portal** — login status, the active inference provider, and the Tool Gateway routing table (which tools run via the Portal vs. locally), with a link to manage your subscription. Read-only mirror of `hermes portal`.
- **Skill curator** — the background skill-maintenance status (active / paused, interval, last run) with pause/resume and a run-now button. Mirrors `hermes curator`.
- **Gateway** — start, stop, and restart the messaging gateway, with live status (running/stopped, PID, state)
@ -430,6 +430,7 @@ same auth gate as the rest of `/api/`.
| `GET /api/ops/checkpoints` · `POST .../prune` | Inspect / prune the `/rollback` store |
| `POST /api/ops/hooks` · `DELETE /api/ops/hooks` | Create / remove a shell hook (consent-gated) |
| `GET /api/system/stats` | Host stats — OS, CPU, memory, disk, uptime |
| `GET /api/hermes/update/check` | Report update availability (commits behind, install method) without applying. `?force=1` busts the 6h cache |
| `GET /api/curator` · `PUT .../paused` · `POST .../run` | Skill-curator status + pause/resume + run |
| `GET /api/portal` | Nous Portal auth + Tool Gateway routing (read-only) |
| `POST /api/ops/prompt-size` · `/dump` · `/config-migrate` | Diagnostics (backgrounded) |