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:
@@ -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, and — when 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>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* @file UpdateNotifier.test.tsx
|
||||
* @description Pins the "Update now" self-apply flow added on top of the
|
||||
* existing manual-command modal: the button only renders when the checkout
|
||||
* situation is fast-forwardable, clicking it calls `api.updates.apply()`,
|
||||
* and a successful `applied: true` response flips the modal into a
|
||||
* "restarting" state that polls `api.updates.status()` until it resolves.
|
||||
*
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import i18n from "i18next";
|
||||
import { UpdateNotifier } from "../UpdateNotifier";
|
||||
import type { UpdateStatusPayload } from "../../lib/types";
|
||||
|
||||
const statusMock = vi.fn();
|
||||
const checkMock = vi.fn();
|
||||
const applyMock = vi.fn();
|
||||
|
||||
vi.mock("../../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<Record<string, unknown>>();
|
||||
return {
|
||||
...actual,
|
||||
api: {
|
||||
updates: {
|
||||
status: (...args: unknown[]) => statusMock(...args),
|
||||
check: (...args: unknown[]) => checkMock(...args),
|
||||
apply: (...args: unknown[]) => applyMock(...args),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const BASE_STATUS: UpdateStatusPayload = {
|
||||
git_repo: true,
|
||||
update_available: true,
|
||||
repo_root: "/repo",
|
||||
remote_ref: "origin/main",
|
||||
canonical_remote: "origin",
|
||||
current_branch: "main",
|
||||
tracking_upstream: "origin/main",
|
||||
tracks_canonical: true,
|
||||
situation: "tracking_canonical",
|
||||
situation_note: null,
|
||||
local_sha: "aaa",
|
||||
remote_sha: "bbb",
|
||||
commits_behind: 2,
|
||||
manual_command: 'cd "/repo" && git pull --ff-only && npm run setup',
|
||||
message: "2 commit(s) on origin/main not in your checkout.",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
statusMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||
checkMock.mockReset().mockResolvedValue(BASE_STATUS);
|
||||
applyMock.mockReset();
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {
|
||||
// Some CI environments stub a non-functional localStorage; the component
|
||||
// already tolerates that (see UpdateNotifier's own try/catch), so tests do too.
|
||||
}
|
||||
});
|
||||
|
||||
describe("UpdateNotifier — Update now", () => {
|
||||
it("shows the Update now button for a fast-forwardable checkout and applies on click", async () => {
|
||||
applyMock.mockResolvedValue({ ...BASE_STATUS, applied: true, update_available: false });
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
const updateBtn = await screen.findByText(i18n.t("updates:updateNow"));
|
||||
await userEvent.click(updateBtn);
|
||||
|
||||
await waitFor(() => expect(applyMock).toHaveBeenCalled());
|
||||
// Appears both in the status banner and the button label while restarting.
|
||||
const restartingNodes = await screen.findAllByText(i18n.t("updates:restarting"));
|
||||
expect(restartingNodes.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("hides the Update now button for a non-fast-forwardable branch", async () => {
|
||||
statusMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
situation: "feature_branch",
|
||||
tracks_canonical: false,
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
await screen.findByText(i18n.t("updates:title"));
|
||||
expect(screen.queryByText(i18n.t("updates:updateNow"))).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces the decline reason instead of restarting on a 409", async () => {
|
||||
applyMock.mockResolvedValue({
|
||||
...BASE_STATUS,
|
||||
applied: false,
|
||||
reason: "not_fast_forwardable",
|
||||
});
|
||||
render(<UpdateNotifier />);
|
||||
|
||||
const updateBtn = await screen.findByText(i18n.t("updates:updateNow"));
|
||||
await userEvent.click(updateBtn);
|
||||
|
||||
expect(await screen.findByText(i18n.t("updates:reasonNotFastForwardable"))).toBeInTheDocument();
|
||||
expect(screen.queryByText(i18n.t("updates:restarting"))).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -11,5 +11,10 @@
|
||||
"restartNote": "After the command finishes, close and restart the dashboard the same way you started it: if you used npm start, run npm start again.",
|
||||
"checkNow": "Check now",
|
||||
"checking": "Checking...",
|
||||
"checkError": "Could not check for updates"
|
||||
"checkError": "Could not check for updates",
|
||||
"updateNow": "Update now",
|
||||
"updating": "Updating...",
|
||||
"restarting": "Rebuilt — restarting the dashboard...",
|
||||
"applyError": "Could not apply the update",
|
||||
"reasonNotFastForwardable": "This checkout isn't on the tracked default branch — apply the command above manually."
|
||||
}
|
||||
|
||||
@@ -11,5 +11,10 @@
|
||||
"restartNote": "Sau khi chạy xong lệnh, hãy khởi động lại bảng điều khiển theo cách bạn đã dùng để chạy nó.",
|
||||
"checkNow": "Kiểm tra ngay",
|
||||
"checking": "Đang kiểm tra...",
|
||||
"checkError": "Không thể kiểm tra cập nhật"
|
||||
"checkError": "Không thể kiểm tra cập nhật",
|
||||
"updateNow": "Cập nhật ngay",
|
||||
"updating": "Đang cập nhật...",
|
||||
"restarting": "Đã build xong — đang khởi động lại bảng điều khiển...",
|
||||
"applyError": "Không thể áp dụng cập nhật",
|
||||
"reasonNotFastForwardable": "Nhánh hiện tại không phải nhánh mặc định được theo dõi — hãy chạy lệnh ở trên thủ công."
|
||||
}
|
||||
|
||||
@@ -557,6 +557,23 @@ export const api = {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
/**
|
||||
* POST /api/updates/apply - fast-forward, rebuild, and restart.
|
||||
*
|
||||
* Only acts when the checkout is fast-forwardable (`tracking_canonical`
|
||||
* or `fork_or_diverged_tracking`); a 409 response means it declined
|
||||
* (`applied: false`, `reason` explains why). On success the server
|
||||
* restarts itself right after responding, so the caller should poll
|
||||
* `status()` until it answers again rather than expect the connection
|
||||
* to outlive the call.
|
||||
*
|
||||
* @returns {@link UpdateStatusPayload} with `applied` set.
|
||||
*/
|
||||
apply: () =>
|
||||
request<UpdateStatusPayload>("/updates/apply", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
},
|
||||
|
||||
// ──────────────────────────────── Stats API ────────────────────────────────
|
||||
|
||||
@@ -1275,6 +1275,14 @@ export interface UpdateStatusPayload {
|
||||
/** Set instead of a normal result when the remote fetch itself failed
|
||||
* (e.g. offline) - the message text explains it in user-facing terms. */
|
||||
fetch_error?: string;
|
||||
/** Present only on the response from `POST /api/updates/apply` - true once
|
||||
* the checkout was fast-forwarded and rebuilt (the process is about to
|
||||
* restart itself). Absent from `status`/`check` responses. */
|
||||
applied?: boolean;
|
||||
/** Set alongside `applied: false` on an `/apply` response that declined to
|
||||
* act - `"up_to_date"` or `"not_fast_forwardable"` (feature branch /
|
||||
* detached HEAD, where there's no safe automatic move). */
|
||||
reason?: "up_to_date" | "not_fast_forwardable";
|
||||
}
|
||||
|
||||
// ───── Terminal run status ─────
|
||||
|
||||
Reference in New Issue
Block a user