FN-8134: add update banner restart action

Offer a supervised Fusion restart directly after an in-app update succeeds.

- Add restart capability detection, scheduling feedback, and manual-restart guidance to the update banner
- Cover supported, unsupported, failed, loading, and mobile restart states
- Document the update flow and add a minor CLI changeset

Files changed:
 .changeset/fn-8134-restart-after-update.md         |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 packages/dashboard/app/components/UpdateAvailableBanner.css |  10 +-
 packages/dashboard/app/components/UpdateAvailableBanner.tsx | 100 +++++++++++++--
 packages/dashboard/app/components/__tests__/UpdateAvailableBanner.test.tsx | 139 ++++++++++++++++++---
 packages/i18n/locales/en/app.json                  |   4 +
 6 files changed, 236 insertions(+), 26 deletions(-)

Fusion-Task-Id: FN-8134

Fusion-Task-Lineage: 84f9290e-aa87-4144-a9db-da445dcb76e3

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 13:29:13 -07:00
parent 3e55492c97
commit 255e9c19b9
6 changed files with 236 additions and 26 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Add a one-click "Restart Fusion" button to the update banner after an in-app update.
category: feature
dev: Reuses POST /api/system/restart via requestSystemRestart and the SystemInfoResponse.restartSupported capability flag; button degrades to a disabled state with a manual-restart note when unsupervised.

View File

@@ -6,7 +6,7 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, sett
## Dashboard Updates
When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, restart Fusion to apply the new version because the already-running dashboard server is unchanged until restart.
When Fusion detects a newer `@runfusion/fusion` release, the Settings modal footer shows the available version with **Learn more** and **Update now** actions. **Update now** installs the latest global package with npm; after it succeeds, the dashboard update banner offers a one-click **Restart Fusion** action because the already-running dashboard server is unchanged until restart. When Fusion is unsupervised (for example, started with `--no-supervise`), that banner action is disabled and explains that Fusion must be restarted manually.
## Settings discovery

View File

@@ -53,7 +53,8 @@
gap: var(--space-xs);
}
.update-available-banner__update-btn {
.update-available-banner__update-btn,
.update-available-banner__restart-btn {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
@@ -63,7 +64,8 @@
color: var(--color-warning);
}
.update-available-banner__update-btn svg.spinning {
.update-available-banner__update-btn svg.spinning,
.update-available-banner__restart-btn svg.spinning {
animation: update-available-banner-spin 1s linear infinite;
}
@@ -113,6 +115,10 @@
align-items: stretch;
}
.update-available-banner__actions {
align-items: flex-start;
}
.update-available-banner__dismiss {
align-self: flex-end;
}

View File

@@ -1,9 +1,9 @@
import "./UpdateAvailableBanner.css";
import { useState } from "react";
import { RefreshCw, X } from "lucide-react";
import { useEffect, useState } from "react";
import { Power, RefreshCw, X } from "lucide-react";
import { useTranslation, Trans } from "react-i18next";
import { getErrorMessage } from "@fusion/core";
import { installUpdate } from "../api";
import { fetchSystemInfo, installUpdate, requestSystemRestart } from "../api";
import type { UpdateInstallResponse } from "../api";
interface UpdateAvailableBannerProps {
@@ -16,6 +16,27 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
const { t } = useTranslation("app");
const [installLoading, setInstallLoading] = useState(false);
const [installResult, setInstallResult] = useState<UpdateInstallResponse | null>(null);
const [restartSupported, setRestartSupported] = useState<boolean | undefined>();
const [restartLoading, setRestartLoading] = useState(false);
const [restartScheduled, setRestartScheduled] = useState(false);
const [restartError, setRestartError] = useState<string | null>(null);
useEffect(() => {
let active = true;
void fetchSystemInfo()
.then((info) => {
if (active) setRestartSupported(info.restartSupported);
})
.catch(() => {
// Fail closed: an unavailable capability response must not offer a restart that cannot run.
if (active) setRestartSupported(false);
});
return () => {
active = false;
};
}, []);
const handleInstallUpdate = async () => {
setInstallLoading(true);
@@ -35,8 +56,33 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
}
};
/*
FNXC:UpdateBanner 2026-07-16-00:00:
Issue #1799 requires a successful in-app update to offer the supervised restart hook in-place.
Hosts without restart support keep the control visible but disabled with manual-restart guidance.
*/
const handleRestart = async () => {
if (restartLoading || restartSupported !== true) return;
setRestartLoading(true);
setRestartError(null);
try {
const result = await requestSystemRestart("update-banner");
if (result.scheduled) {
setRestartScheduled(true);
} else {
setRestartError(t("updateBanner.restartFailed", "Restart could not be scheduled. Try restarting Fusion manually."));
}
} catch (error) {
setRestartError(getErrorMessage(error) || t("updateBanner.restartFailed", "Restart could not be scheduled. Try restarting Fusion manually."));
} finally {
setRestartLoading(false);
}
};
const installSucceeded = installResult?.updated === true;
const installError = installResult?.error;
const restartUnavailable = restartSupported !== true;
return (
<div className="update-available-banner" role="status" aria-live="polite">
@@ -63,11 +109,49 @@ export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss
</p>
<div className="update-available-banner__actions">
{installSucceeded ? (
<span className="update-available-banner__install-status update-available-banner__install-status--success" aria-live="polite">
{t("updateBanner.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
version: installResult.latestVersion ?? latestVersion,
})}
</span>
<>
<span className="update-available-banner__install-status update-available-banner__install-status--success" aria-live="polite">
{t("updateBanner.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", {
version: installResult.latestVersion ?? latestVersion,
})}
</span>
{restartScheduled ? (
<span className="update-available-banner__install-status" aria-live="polite">
{t("updateBanner.restarting", "Restarting… Your connection will close shortly.")}
</span>
) : (
<button
type="button"
className="btn btn-sm update-available-banner__restart-btn"
onClick={() => {
void handleRestart();
}}
disabled={restartUnavailable || restartLoading}
>
{restartLoading ? (
<>
<RefreshCw size={12} className="spinning" aria-hidden="true" />
{t("updateBanner.restarting", "Restarting…")}
</>
) : (
<>
<Power size={12} aria-hidden="true" />
{t("updateBanner.restartNow", "Restart Fusion")}
</>
)}
</button>
)}
{restartUnavailable && (
<span className="update-available-banner__install-status" aria-live="polite">
{t("updateBanner.restartUnavailable", "Needs a supervising parent — restart Fusion manually without --no-supervise.")}
</span>
)}
{restartError && (
<span className="update-available-banner__install-status update-available-banner__install-status--error" aria-live="polite">
{restartError}
</span>
)}
</>
) : (
<button
type="button"

View File

@@ -3,10 +3,14 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { useState } from "react";
import { UpdateAvailableBanner } from "../UpdateAvailableBanner";
const mockFetchSystemInfo = vi.hoisted(() => vi.fn());
const mockInstallUpdate = vi.hoisted(() => vi.fn());
const mockRequestSystemRestart = vi.hoisted(() => vi.fn());
vi.mock("../../api", () => ({
fetchSystemInfo: (...args: unknown[]) => mockFetchSystemInfo(...args),
installUpdate: (...args: unknown[]) => mockInstallUpdate(...args),
requestSystemRestart: (...args: unknown[]) => mockRequestSystemRestart(...args),
}));
vi.mock("lucide-react", async (importOriginal) => {
@@ -17,15 +21,29 @@ vi.mock("lucide-react", async (importOriginal) => {
};
});
const successfulInstall = { currentVersion: "0.6.0", latestVersion: "0.7.0", updated: true };
function renderBanner() {
return render(<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />);
}
async function completeInstall() {
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
await screen.findByText("Updated to v0.7.0 — restart Fusion to apply");
}
describe("UpdateAvailableBanner", () => {
beforeEach(() => {
mockFetchSystemInfo.mockReset();
mockInstallUpdate.mockReset();
mockInstallUpdate.mockResolvedValue({ currentVersion: "0.6.0", latestVersion: "0.7.0", updated: true });
mockRequestSystemRestart.mockReset();
mockFetchSystemInfo.mockResolvedValue({ restartSupported: true });
mockInstallUpdate.mockResolvedValue(successfulInstall);
mockRequestSystemRestart.mockResolvedValue({ scheduled: true });
});
it("renders version information with release notes and learn more links", () => {
render(
<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />,
);
renderBanner();
expect(screen.getByText(/Update available: v0.7.0 \(current: v0.6.0\)/)).toBeInTheDocument();
expect(screen.getByText("fn update")).toBeInTheDocument();
@@ -40,9 +58,7 @@ describe("UpdateAvailableBanner", () => {
it("dismiss button calls onDismiss", () => {
const onDismiss = vi.fn();
render(
<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={onDismiss} />,
);
render(<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={onDismiss} />);
fireEvent.click(screen.getByRole("button", { name: "Dismiss update notice" }));
expect(onDismiss).toHaveBeenCalledTimes(1);
@@ -68,25 +84,95 @@ describe("UpdateAvailableBanner", () => {
expect(screen.queryByRole("status")).toBeNull();
});
it("disables update-now while installing and then shows restart hint", async () => {
let resolveInstall: ((result: { currentVersion: string; latestVersion: string; updated: boolean }) => void) | undefined;
it("disables update-now while installing and then renders an enabled restart button", async () => {
let resolveInstall: ((result: typeof successfulInstall) => void) | undefined;
mockInstallUpdate.mockReturnValueOnce(new Promise((resolve) => {
resolveInstall = resolve;
}));
render(<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />);
renderBanner();
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
expect(screen.getByRole("button", { name: "Updating…" })).toBeDisabled();
expect(screen.getByTestId("icon-refresh")).toHaveClass("spinning");
resolveInstall?.({ currentVersion: "0.6.0", latestVersion: "0.7.0", updated: true });
resolveInstall?.(successfulInstall);
expect(await screen.findByText("Updated to v0.7.0 — restart Fusion to apply")).toBeInTheDocument();
await screen.findByText("Updated to v0.7.0 — restart Fusion to apply");
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled();
expect(screen.queryByRole("button", { name: "Update now" })).not.toBeInTheDocument();
});
it("shows install errors inline without removing retry button", async () => {
it("restarts a supervised host with the update-banner reason", async () => {
renderBanner();
await completeInstall();
fireEvent.click(screen.getByRole("button", { name: "Restart Fusion" }));
await waitFor(() => expect(mockRequestSystemRestart).toHaveBeenCalledWith("update-banner"));
expect(await screen.findByText("Restarting… Your connection will close shortly.")).toBeInTheDocument();
});
it("renders the restart button disabled with manual guidance when unsupported", async () => {
mockFetchSystemInfo.mockResolvedValueOnce({ restartSupported: false });
renderBanner();
await completeInstall();
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeDisabled();
expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument();
});
it("keeps restart disabled while system info is loading", async () => {
mockFetchSystemInfo.mockReturnValueOnce(new Promise(() => {}));
renderBanner();
await completeInstall();
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeDisabled();
});
it("fails closed with manual guidance when system info cannot be loaded", async () => {
mockFetchSystemInfo.mockRejectedValueOnce(new Error("network unavailable"));
renderBanner();
await completeInstall();
await waitFor(() => expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeDisabled());
expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument();
});
it("shows a disabled spinning restart action while a restart request is in flight", async () => {
mockRequestSystemRestart.mockReturnValueOnce(new Promise(() => {}));
renderBanner();
await completeInstall();
fireEvent.click(screen.getByRole("button", { name: "Restart Fusion" }));
expect(screen.getByRole("button", { name: /Restarting/ })).toBeDisabled();
expect(screen.getByTestId("icon-refresh")).toHaveClass("spinning");
});
it("shows a re-clickable inline error when restart rejects", async () => {
mockRequestSystemRestart.mockRejectedValueOnce(new Error("restart conflict"));
renderBanner();
await completeInstall();
fireEvent.click(screen.getByRole("button", { name: "Restart Fusion" }));
expect(await screen.findByText("restart conflict")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled();
});
it("shows a re-clickable inline error when restart is not scheduled", async () => {
mockRequestSystemRestart.mockResolvedValueOnce({ scheduled: false });
renderBanner();
await completeInstall();
fireEvent.click(screen.getByRole("button", { name: "Restart Fusion" }));
expect(await screen.findByText("Restart could not be scheduled. Try restarting Fusion manually.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Restart Fusion" })).toBeEnabled();
});
it("shows install errors inline without rendering a restart button or removing retry", async () => {
mockInstallUpdate.mockResolvedValueOnce({
currentVersion: "0.6.0",
latestVersion: "0.7.0",
@@ -94,12 +180,35 @@ describe("UpdateAvailableBanner", () => {
error: "permission denied",
});
render(<UpdateAvailableBanner latestVersion="0.7.0" currentVersion="0.6.0" onDismiss={vi.fn()} />);
renderBanner();
fireEvent.click(screen.getByRole("button", { name: "Update now" }));
await waitFor(() => expect(mockInstallUpdate).toHaveBeenCalledTimes(1));
expect(await screen.findByText("Update failed: permission denied")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Update now" })).not.toBeDisabled();
expect(screen.getByRole("button", { name: "Update now" })).toBeEnabled();
expect(screen.queryByRole("button", { name: "Restart Fusion" })).not.toBeInTheDocument();
});
it.each([
["supported", true],
["unsupported", false],
])("keeps the mobile action row and %s restart control in the document", async (_state, restartSupported) => {
const previousWidth = window.innerWidth;
Object.defineProperty(window, "innerWidth", { configurable: true, value: 480 });
mockFetchSystemInfo.mockResolvedValueOnce({ restartSupported });
renderBanner();
await completeInstall();
const actions = document.querySelector(".update-available-banner__actions");
const restartButton = screen.getByRole("button", { name: "Restart Fusion" });
expect(actions).toBeInTheDocument();
expect(actions).toContainElement(restartButton);
expect(restartButton).toBeInTheDocument();
expect(restartButton).toHaveProperty("disabled", !restartSupported);
if (!restartSupported) expect(screen.getByText(/Needs a supervising parent/)).toBeInTheDocument();
Object.defineProperty(window, "innerWidth", { configurable: true, value: previousWidth });
});
});

View File

@@ -8487,6 +8487,10 @@
"learnMore": "Learn more",
"message": "Update available: v{{latestVersion}} (current: v{{currentVersion}}). Run fn update for an installed CLI, or pull this source checkout.",
"releaseNotes": "Release notes",
"restartFailed": "Restart could not be scheduled. Try restarting Fusion manually.",
"restartNow": "Restart Fusion",
"restartUnavailable": "Needs a supervising parent — restart Fusion manually without --no-supervise.",
"restarting": "Restarting… Your connection will close shortly.",
"updateFailed": "Update failed",
"updateFailedWithMessage": "Update failed: {{message}}",
"updateNow": "Update now",