feat(updates): auto-apply and self-restart on new dashboard versions

Adds POST /api/updates/apply (pull, rebuild, restart) plus an "Update now"
button and hourly auto-check in the UI, for checkouts that are safely
fast-forwardable. Reintroduces self-restart (previously removed in edc25ca
for cross-environment reliability concerns) per explicit user request.
This commit is contained in:
2026-08-18 16:04:27 +07:00
parent f053051a5d
commit 8e49d2b300
15 changed files with 502 additions and 19 deletions
+98 -8
View File
@@ -2,11 +2,15 @@
* @file UpdateNotifier.tsx
* @description Modal surfaced when the dashboard's git checkout is behind its
* remote tracking branch. Shows how many commits behind, the exact terminal
* command to update, and copy-to-clipboard — the dashboard never pulls or
* restarts itself.
* command to update with copy-to-clipboard, andwhen the checkout is on a
* fast-forwardable branch — an "Update now" button that calls
* `POST /api/updates/apply` to pull, rebuild, and restart the server itself,
* then polls until it's back and reloads the page.
*
* ## State sources
* - Initial fetch via `api.updates.status()` on mount.
* - Background re-check via `api.updates.check()` every hour
* ({@link AUTO_CHECK_INTERVAL_MS}), plus the manual "Check now" button.
* - Live refresh from WebSocket `update_status` events on {@link eventBus}.
*
* ## Dismissal persistence
@@ -63,7 +67,7 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, X, Copy, Check, RefreshCw } from "lucide-react";
import { Download, X, Copy, Check, RefreshCw, Zap } from "lucide-react";
import { api } from "../lib/api";
import { eventBus } from "../lib/eventBus";
import type { UpdateStatusPayload, WSMessage } from "../lib/types";
@@ -71,6 +75,15 @@ import type { UpdateStatusPayload, WSMessage } from "../lib/types";
/** `localStorage` key storing the dismissed upstream SHA. */
const DISMISS_KEY = "agent-monitor-update-dismissed-sha";
/** How often to silently re-check for updates in the background. */
const AUTO_CHECK_INTERVAL_MS = 60 * 60 * 1000;
/** Situations `POST /api/updates/apply` will actually act on — mirrors the
* server-side check in `server/lib/update-check.js`'s `applyUpdate`. */
function isAutoApplicable(situation: UpdateStatusPayload["situation"]): boolean {
return situation === "tracking_canonical" || situation === "fork_or_diverged_tracking";
}
/** Narrow unknown WebSocket payloads to {@link UpdateStatusPayload}. */
function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
return typeof x === "object" && x !== null && "git_repo" in x && "update_available" in x;
@@ -96,6 +109,8 @@ export function UpdateNotifier() {
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [checking, setChecking] = useState(false);
const [applying, setApplying] = useState(false);
const [restarting, setRestarting] = useState(false);
const syncFromPayload = useCallback((s: UpdateStatusPayload) => {
setStatus(s);
@@ -128,6 +143,19 @@ export function UpdateNotifier() {
});
}, [syncFromPayload]);
// Background re-check every hour, on top of the initial mount fetch and the
// manual "Check now" button — so a long-lived tab notices an update without
// the user having to click anything.
useEffect(() => {
const id = setInterval(() => {
api.updates
.check()
.then(syncFromPayload)
.catch(() => {});
}, AUTO_CHECK_INTERVAL_MS);
return () => clearInterval(id);
}, [syncFromPayload]);
useEffect(() => {
const handler = () => setDismissedSha(null);
window.addEventListener("dashboard:reset-update-dismissal", handler);
@@ -139,14 +167,14 @@ export function UpdateNotifier() {
);
const dismiss = useCallback(() => {
if (!status?.remote_sha) return;
if (restarting || !status?.remote_sha) return;
try {
localStorage.setItem(DISMISS_KEY, status.remote_sha);
} catch {
/* ignore */
}
setDismissedSha(status.remote_sha);
}, [status?.remote_sha]);
}, [restarting, status?.remote_sha]);
// Escape to dismiss - standard modal affordance.
useEffect(() => {
@@ -183,6 +211,41 @@ export function UpdateNotifier() {
}
};
// Polls `status()` until the restarted server answers again, then reloads
// so the tab picks up the new client bundle too — the server can't push
// this over its own WebSocket since it's mid-restart.
const pollUntilBack = useCallback(() => {
const attempt = () => {
api.updates
.status()
.then(() => window.location.reload())
.catch(() => setTimeout(attempt, 1500));
};
setTimeout(attempt, 1500);
}, []);
const applyNow = async () => {
if (applying || restarting) return;
setError(null);
setApplying(true);
try {
const result = await api.updates.apply();
if (result.applied) {
setApplying(false);
setRestarting(true);
pollUntilBack();
return;
}
setError(
result.reason === "not_fast_forwardable" ? t("reasonNotFastForwardable") : t("applyError")
);
} catch (e) {
setError(e instanceof Error ? e.message : t("applyError"));
} finally {
setApplying(false);
}
};
if (!show || !status) return null;
const refLabel = status.remote_ref || "origin";
@@ -266,6 +329,13 @@ export function UpdateNotifier() {
<p className="text-[11px] text-fg-muted leading-relaxed">{t("restartNote")}</p>
) : null}
{restarting ? (
<div className="text-xs text-accent bg-accent-muted border border-accent/30 rounded-lg px-3 py-2 flex items-center gap-2">
<RefreshCw className="w-3.5 h-3.5 animate-spin flex-shrink-0" aria-hidden />
{t("restarting")}
</div>
) : null}
{error ? (
<p className="text-xs text-status-danger" role="alert">
{error}
@@ -278,13 +348,18 @@ export function UpdateNotifier() {
<button
type="button"
onClick={checkNow}
disabled={checking}
disabled={checking || applying || restarting}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
<RefreshCw className={`w-3.5 h-3.5 ${checking ? "animate-spin" : ""}`} aria-hidden />
{checking ? t("checking") : t("checkNow")}
</button>
<button type="button" onClick={dismiss} className="btn-ghost">
<button
type="button"
onClick={dismiss}
disabled={restarting}
className="btn-ghost disabled:opacity-60 disabled:cursor-not-allowed"
>
{t("dismiss")}
</button>
{status.manual_command ? (
@@ -292,12 +367,27 @@ export function UpdateNotifier() {
type="button"
onClick={copyCmd}
disabled={copied}
className="btn-primary disabled:opacity-70"
className="btn-ghost disabled:opacity-70"
>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
{copied ? t("copied") : t("copy")}
</button>
) : null}
{isAutoApplicable(status.situation) ? (
<button
type="button"
onClick={applyNow}
disabled={applying || restarting}
className="btn-primary disabled:opacity-70"
>
{applying || restarting ? (
<RefreshCw className="w-4 h-4 animate-spin" aria-hidden />
) : (
<Zap className="w-4 h-4" aria-hidden />
)}
{applying ? t("updating") : restarting ? t("restarting") : t("updateNow")}
</button>
) : null}
</div>
</div>
</div>