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>
202 lines
8.3 KiB
TypeScript
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>
|
|
);
|
|
}
|