Files
fusion/packages/dashboard/app/components/UpdateAvailableBanner.tsx
gsxdsm ba643fc384 FN-9042: fix dashboard update installation feedback
Make dashboard update actions report clear terminal outcomes and avoid unsupported installs.

- Distinguish update checks, no-op responses, installation failures, and unsupported installation methods.
- Block source-checkout, Homebrew, and missing-npm global installation attempts with actionable guidance.
- Surface update results in dashboard controls and cover API, updater, and UI behavior with tests.

Files changed:
 .changeset/fn-9042-update-now-outcomes.md          |   7 ++
 docs/dashboard-guide.md                            |   2 +-
 docs/settings-reference.md                         |   2 +-
 packages/dashboard/app/api/settings/settings.ts    |   2 +
 .../dashboard/app/components/SettingsModal.tsx     |  18 +--
 .../app/components/UpdateAvailableBanner.tsx       |  11 +-
 .../__tests__/SettingsModal.general.test.tsx       |  20 ++++
 .../__tests__/UpdateAvailableBanner.test.tsx       |  19 ++++
 .../dashboard/src/__tests__/auto-update.test.ts    |  38 ++++++-
 .../dashboard/src/__tests__/update-check.test.ts   |  19 +++-
 packages/dashboard/src/auto-update.ts              |  33 +++++-
 .../__tests__/register-update-check-routes.test.ts | 104 ++++++++++++++++++
 .../src/routes/register-update-check-routes.ts     |  28 +++--
 packages/dashboard/src/server.ts                   |   9 +-
 packages/dashboard/src/update-check.ts             | 122 ++++++++++-----------
 15 files changed, 338 insertions(+), 96 deletions(-)

Fusion-Task-Id: FN-9042

Fusion-Task-Lineage: 22ae5540-e5b5-43cf-ad16-c7aa05782593

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-14 12:59:23 -07:00

202 lines
8.3 KiB
TypeScript

import "./UpdateAvailableBanner.css";
import { useEffect, useState } from "react";
import { Power, RefreshCw, X } from "lucide-react";
import { useTranslation, Trans } from "react-i18next";
import { getErrorMessage } from "@fusion/core";
import { fetchSystemInfo, installUpdate, requestSystemRestart } from "../api";
import type { UpdateInstallResponse } from "../api";
interface UpdateAvailableBannerProps {
latestVersion: string;
currentVersion: string;
onDismiss: () => void;
}
export function UpdateAvailableBanner({ latestVersion, currentVersion, onDismiss }: UpdateAvailableBannerProps) {
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);
setInstallResult(null);
try {
setInstallResult(await installUpdate());
} catch (error) {
setInstallResult({
currentVersion,
latestVersion,
updated: false,
error: getErrorMessage(error) || t("updateBanner.updateFailed", "Update failed"),
outcome: "failed",
message: getErrorMessage(error) || t("updateBanner.updateFailed", "Update failed"),
});
} finally {
setInstallLoading(false);
}
};
/*
FNXC:UpdateBanner 2026-07-16-00:00:
Issue #1799 requires a successful in-app update to offer the supervised restart hook in-place.
FNXC:UpdateBanner 2026-07-25-10:05:
Capability is advisory, never a hard block — same contract as the Settings footer.
A failed or stale /system/info probe must not turn this into a button that silently
does nothing; let the request reach the server and show its refusal reason instead.
*/
const handleRestart = async () => {
if (restartLoading) 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);
}
};
/* FNXC:UpdateBanner 2026-08-14-19:31: an Update now result always remains visible; failed checks are errors, never up-to-date reassurance. */
const installSucceeded = installResult?.updated === true;
const installError = installResult?.error;
const installMessage = installResult?.message ?? installError ?? (installResult && !installResult.updated ? t("updateBanner.updateUnknown", "Update did not complete — see the Fusion logs") : undefined);
const installIsError = installResult?.outcome === "check-failed" || installResult?.outcome === "failed" || Boolean(installError && installResult?.outcome !== "unsupported-install-method");
// Advisory guidance only — shown when the host explicitly reported no supervising parent.
const restartUnavailable = restartSupported === false;
return (
<div className="update-available-banner" role="status" aria-live="polite">
<div className="update-available-banner__content">
<p className="update-available-banner__text">
<Trans
i18nKey="app:updateBanner.message"
defaults="Update available: v{{latestVersion}} (current: v{{currentVersion}}). Run <code>fn update</code> for an installed CLI, or pull this source checkout."
values={{ latestVersion, currentVersion }}
components={{ code: <code /> }}
/>{" "}
<a
className="update-available-banner__link"
href="https://github.com/Runfusion/Fusion/blob/main/CHANGELOG.md"
target="_blank"
rel="noreferrer"
>
{t("updateBanner.releaseNotes", "Release notes")}
</a>{" "}
·{" "}
<a className="update-available-banner__link" href="https://runfusion.ai" target="_blank" rel="noreferrer">
{t("updateBanner.learnMore", "Learn more")}
</a>
</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>
{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={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"
className="btn btn-sm update-available-banner__update-btn"
onClick={() => {
void handleInstallUpdate();
}}
disabled={installLoading}
>
{installLoading ? (
<>
<RefreshCw size={12} className="spinning" aria-hidden="true" />
{t("updateBanner.updating", "Updating…")}
</>
) : (
t("updateBanner.updateNow", "Update now")
)}
</button>
)}
{installMessage && !installSucceeded && (
<span className={`update-available-banner__install-status${installIsError ? " update-available-banner__install-status--error" : ""}`} aria-live="polite">
{installIsError ? t("updateBanner.updateFailedWithMessage", "Update failed: {{message}}", { message: installMessage }) : installMessage}
</span>
)}
</div>
</div>
<button
type="button"
className="update-available-banner__dismiss touch-target"
aria-label={t("updateBanner.dismissLabel", "Dismiss update notice")}
onClick={onDismiss}
>
<X size={16} aria-hidden="true" />
</button>
</div>
);
}