From e8c2d516af28f42db22b98116dd3040c1cbc97a3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 13 Jun 2026 15:01:01 -0700 Subject: [PATCH] FN-6386: add one-click update installation Add a dashboard update action that installs available Fusion releases directly from the UI. - Add an install-update API route backed by npm global install with bin-collision retry handling. - Add Update now controls, loading/error/success states, and responsive styling to the update banner and settings modal. - Extend localization, documentation, changeset coverage, and update-check tests for the new install flow. Files changed: .changeset/fn-6386-update-now-button.md | 5 + docs/dashboard-guide.md | 4 + docs/settings-reference.md | 2 + packages/dashboard/app/api/legacy.ts | 13 ++ .../dashboard/app/components/SettingsModal.css | 38 +++++- .../dashboard/app/components/SettingsModal.tsx | 96 ++++++++++++-- .../app/components/UpdateAvailableBanner.css | 46 +++++++ .../app/components/UpdateAvailableBanner.tsx | 103 ++++++++++++--- .../components/__tests__/SettingsModal.test.tsx | 79 +++++++++++ .../__tests__/UpdateAvailableBanner.test.tsx | 57 +++++++- .../components/__tests__/settings-mobile.test.tsx | 19 +++ .../src/__tests__/update-check-route.test.ts | 78 ++++++++++- .../dashboard/src/__tests__/update-check.test.ts | 45 +++++++ .../src/routes/register-update-check-routes.ts | 25 +++- packages/dashboard/src/update-check.ts | 93 +++++++++++++ packages/i18n/locales/en/app.json | 14 +- packages/i18n/src/resources.d.ts | 146 ++++++++++++++++++++- 17 files changed, 819 insertions(+), 44 deletions(-) Fusion-Task-Id: FN-6386 Fusion-Task-Lineage: bcccf253-c0f1-493e-b849-b189a65d1479 --- .changeset/fn-6386-update-now-button.md | 5 + docs/dashboard-guide.md | 4 + docs/settings-reference.md | 2 + packages/dashboard/app/api/legacy.ts | 13 ++ .../app/components/SettingsModal.css | 38 ++++- .../app/components/SettingsModal.tsx | 96 ++++++++++-- .../app/components/UpdateAvailableBanner.css | 46 ++++++ .../app/components/UpdateAvailableBanner.tsx | 103 +++++++++--- .../__tests__/SettingsModal.test.tsx | 79 ++++++++++ .../__tests__/UpdateAvailableBanner.test.tsx | 57 ++++++- .../__tests__/settings-mobile.test.tsx | 19 +++ .../src/__tests__/update-check-route.test.ts | 78 +++++++++- .../src/__tests__/update-check.test.ts | 45 ++++++ .../routes/register-update-check-routes.ts | 25 ++- packages/dashboard/src/update-check.ts | 93 +++++++++++ packages/i18n/locales/en/app.json | 14 +- packages/i18n/src/resources.d.ts | 146 +++++++++++++++++- 17 files changed, 819 insertions(+), 44 deletions(-) create mode 100644 .changeset/fn-6386-update-now-button.md diff --git a/.changeset/fn-6386-update-now-button.md b/.changeset/fn-6386-update-now-button.md new file mode 100644 index 0000000000..00ceb85d09 --- /dev/null +++ b/.changeset/fn-6386-update-now-button.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": minor +--- + +Add a one-click dashboard Update now action for installing available Fusion updates. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index 5f3a79769b..d4138c1fde 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -4,6 +4,10 @@ The Fusion dashboard is the main control plane for tasks, agents, missions, settings, logs, and repository operations. +## 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. + ## Browser Navigation The dashboard now handles browser back navigation consistently on desktop and mobile. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 1bf5bc522c..cfc7f7172e 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -171,6 +171,8 @@ Disable daily update checks globally: fn settings set updateCheckEnabled false ``` +When the dashboard footer reports that a newer `@runfusion/fusion` version is available, **Update now** runs the same global npm install as `fn update` (`npm install -g @runfusion/fusion@latest`) and retries once with `--force` for the legacy `fn`/`fusion` binary-collision case. A successful install updates the global package on disk, but the currently running Fusion server is not hot-swapped; restart Fusion to run the newly installed version. + --- ## Workflow Settings diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index f8d98c8740..6805071c78 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -812,6 +812,19 @@ export function refreshUpdateCheck(projectId?: string): Promise { + return api(withProjectId("/update-check/install", projectId), { + method: "POST", + }); +} + export interface RemoteSettings { remoteActiveProvider: "tailscale" | "cloudflare" | null; remoteTailscaleEnabled: boolean; diff --git a/packages/dashboard/app/components/SettingsModal.css b/packages/dashboard/app/components/SettingsModal.css index 013e951c7c..d82c8f8ea5 100644 --- a/packages/dashboard/app/components/SettingsModal.css +++ b/packages/dashboard/app/components/SettingsModal.css @@ -184,6 +184,10 @@ row-gap: var(--space-xs); } + .settings-update-result { + flex: 1 1 100%; + } + .settings-footer-help-btn { flex-shrink: 0; } @@ -229,6 +233,10 @@ } .settings-update-result { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs); font-size: 0.85rem; font-weight: 500; } @@ -242,7 +250,7 @@ } .settings-update-result--error { - color: var(--text-muted); + color: var(--color-error); } .settings-update-result-link { @@ -261,6 +269,34 @@ border-radius: var(--radius-sm); } +.settings-update-now-btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs); + min-height: calc(var(--space-lg) + var(--space-sm)); + color: var(--text); +} + +.settings-update-now-btn:disabled { + color: var(--color-warning); +} + +.settings-update-now-btn svg.spinning { + animation: settings-update-spin 1s linear infinite; +} + +.settings-update-install-status { + color: var(--text-muted); +} + +.settings-update-install-status--success { + color: var(--color-success); +} + +.settings-update-install-status--error { + color: var(--color-error); +} + @keyframes settings-update-spin { to { transform: rotate(360deg); diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 84c56f8829..0b388aea11 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -6,8 +6,8 @@ import { normalizeMergeAdvanceAutoSyncMode, } from "@fusion/core"; import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset } from "@fusion/core"; -import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api"; -import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, OAuthDeviceCodeInfo } from "../api"; +import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, cancelProviderLogin, saveApiKey, clearApiKey, fetchModels, testNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, triggerMemoryDreams, fetchGitRemotes, fetchGitRemotesDetailed, fetchGitBranches, fetchProjects, fetchDashboardHealth, checkForUpdates, installUpdate, fetchRemoteSettings, fetchRemoteStatus, installCloudflared, fetchRemoteQr, fetchRemoteUrl, submitProviderManualCode } from "../api"; +import type { AuthProvider, ManualOAuthCodeInfo, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemote, GitRemoteDetailed, ProjectInfo, RemoteStatus, UpdateCheckResponse, UpdateInstallResponse, OAuthDeviceCodeInfo } from "../api"; import { splitSettingsSave } from "./settings/save-split"; import type { SectionSaveHandler } from "./settings/sections/context"; import { AppearanceSection } from "./settings/sections/AppearanceSection"; @@ -686,6 +686,8 @@ export function SettingsModal({ const [appVersion, setAppVersion] = useState(null); const [updateCheckLoading, setUpdateCheckLoading] = useState(false); const [updateCheckResult, setUpdateCheckResult] = useState(null); + const [updateInstallLoading, setUpdateInstallLoading] = useState(false); + const [updateInstallResult, setUpdateInstallResult] = useState(null); const gitHubStarCount = useGitHubStarCount(); const [starClicked, markStarClicked] = useStarClickedFlag(); const [prefixError, setPrefixError] = useState(null); @@ -963,6 +965,7 @@ export function SettingsModal({ const handleCheckForUpdates = useCallback(async () => { setUpdateCheckLoading(true); + setUpdateInstallResult(null); try { const result = await checkForUpdates(); @@ -972,7 +975,7 @@ export function SettingsModal({ addToast(result.error, "error"); } } catch (error) { - const message = getErrorMessage(error) || "Failed to check for updates"; + const message = getErrorMessage(error) || t("settings.general.updateCheckFailed", "Failed to check for updates"); setUpdateCheckResult({ currentVersion: appVersion ?? "unknown", latestVersion: null, @@ -983,7 +986,37 @@ export function SettingsModal({ } finally { setUpdateCheckLoading(false); } - }, [addToast, appVersion]); + }, [addToast, appVersion, t]); + + const handleInstallUpdate = useCallback(async () => { + setUpdateInstallLoading(true); + setUpdateInstallResult(null); + + try { + const result = await installUpdate(projectId); + setUpdateInstallResult(result); + + if (result.error) { + addToast(result.error, "error"); + return; + } + + if (result.updated) { + addToast(t("settings.general.updateSuccessToast", "Update installed. Restart Fusion to apply it."), "success"); + } + } catch (error) { + const message = getErrorMessage(error) || t("settings.general.updateFailed", "Update failed"); + setUpdateInstallResult({ + currentVersion: updateCheckResult?.currentVersion ?? appVersion ?? "unknown", + latestVersion: updateCheckResult?.latestVersion ?? null, + updated: false, + error: message, + }); + addToast(message, "error"); + } finally { + setUpdateInstallLoading(false); + } + }, [addToast, appVersion, projectId, t, updateCheckResult]); const renderUpdateCheckResultContent = useCallback(() => { if (!updateCheckResult) { @@ -995,23 +1028,58 @@ export function SettingsModal({ } if (updateCheckResult.updateAvailable && updateCheckResult.latestVersion) { + const installSucceeded = updateInstallResult?.updated === true; + const installError = updateInstallResult?.error; + return ( <> - {t("settings.general.updateAvailablePrefix", "v{{version}} available", { version: updateCheckResult.latestVersion })} ·{" "} - - {t("settings.general.learnMore", "Learn more")} - + + {t("settings.general.updateAvailablePrefix", "v{{version}} available", { version: updateCheckResult.latestVersion })} ·{" "} + + {t("settings.general.learnMore", "Learn more")} + + + {installSucceeded ? ( + + {t("settings.general.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", { + version: updateInstallResult.latestVersion ?? updateCheckResult.latestVersion, + })} + + ) : ( + + )} + {installError && ( + + {t("settings.general.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })} + + )} ); } return t("settings.general.upToDate", "You're up to date ✓"); - }, [updateCheckResult]); + }, [handleInstallUpdate, t, updateCheckResult, updateInstallLoading, updateInstallResult]); // Load auth status when the authentication section is active const loadAuthStatus = useCallback(async () => { diff --git a/packages/dashboard/app/components/UpdateAvailableBanner.css b/packages/dashboard/app/components/UpdateAvailableBanner.css index c472680e7c..2361d33207 100644 --- a/packages/dashboard/app/components/UpdateAvailableBanner.css +++ b/packages/dashboard/app/components/UpdateAvailableBanner.css @@ -13,6 +13,13 @@ color: var(--text); } +.update-available-banner__content { + display: flex; + flex-direction: column; + gap: var(--space-xs); + min-width: 0; +} + .update-available-banner__text { margin: 0; color: var(--text); @@ -39,6 +46,39 @@ border-radius: var(--radius-sm); } +.update-available-banner__actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-xs); +} + +.update-available-banner__update-btn { + display: inline-flex; + align-items: center; + gap: var(--space-xs); +} + +.update-available-banner__update-btn:disabled { + color: var(--color-warning); +} + +.update-available-banner__update-btn svg.spinning { + animation: update-available-banner-spin 1s linear infinite; +} + +.update-available-banner__install-status { + font-weight: 500; +} + +.update-available-banner__install-status--success { + color: var(--color-success); +} + +.update-available-banner__install-status--error { + color: var(--color-error); +} + .update-available-banner__dismiss { background: none; border: none; @@ -61,6 +101,12 @@ box-shadow: var(--focus-ring-strong); } +@keyframes update-available-banner-spin { + to { + transform: rotate(360deg); + } +} + @media (max-width: 768px) { .update-available-banner { flex-direction: column; diff --git a/packages/dashboard/app/components/UpdateAvailableBanner.tsx b/packages/dashboard/app/components/UpdateAvailableBanner.tsx index bbc4788159..10d11f76d0 100644 --- a/packages/dashboard/app/components/UpdateAvailableBanner.tsx +++ b/packages/dashboard/app/components/UpdateAvailableBanner.tsx @@ -1,6 +1,10 @@ import "./UpdateAvailableBanner.css"; -import { X } from "lucide-react"; +import { useState } from "react"; +import { RefreshCw, X } from "lucide-react"; import { useTranslation, Trans } from "react-i18next"; +import { getErrorMessage } from "@fusion/core"; +import { installUpdate } from "../api"; +import type { UpdateInstallResponse } from "../api"; interface UpdateAvailableBannerProps { latestVersion: string; @@ -10,29 +14,86 @@ interface UpdateAvailableBannerProps { export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss }: UpdateAvailableBannerProps) { const { t } = useTranslation("app"); + const [installLoading, setInstallLoading] = useState(false); + const [installResult, setInstallResult] = useState(null); + + const handleInstallUpdate = async () => { + setInstallLoading(true); + setInstallResult(null); + + try { + setInstallResult(await installUpdate()); + } catch (error) { + setInstallResult({ + currentVersion, + latestVersion, + updated: false, + error: getErrorMessage(error) || t("updateBanner.updateFailed", "Update failed"), + }); + } finally { + setInstallLoading(false); + } + }; + + const installSucceeded = installResult?.updated === true; + const installError = installResult?.error; return (
-

- }} - />{" "} - - {t("updateBanner.releaseNotes", "Release notes")} - {" "} - ·{" "} - - {t("updateBanner.learnMore", "Learn more")} - -

+
+

+ }} + />{" "} + + {t("updateBanner.releaseNotes", "Release notes")} + {" "} + ·{" "} + + {t("updateBanner.learnMore", "Learn more")} + +

+
+ {installSucceeded ? ( + + {t("updateBanner.updateSuccess", "Updated to v{{version}} — restart Fusion to apply", { + version: installResult.latestVersion ?? latestVersion, + })} + + ) : ( + + )} + {installError && ( + + {t("updateBanner.updateFailedWithMessage", "Update failed: {{message}}", { message: installError })} + + )} +
+