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:
7
.changeset/fn-8134-restart-after-update.md
Normal file
7
.changeset/fn-8134-restart-after-update.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user