feat(FN-2664): add dashboard updates check endpoint and settings flow

- Add GET /api/updates/check endpoint in dashboard server with coverage for success, failure, and disabled update scenarios
- Extend legacy dashboard API client with check-for-updates request support
- Add Settings modal update-check UI and styling for trigger, loading, and result states
- Expand Settings modal tests and document the updates check endpoint in architecture docs
This commit is contained in:
Fusion
2026-04-27 02:19:44 -07:00
committed by gsxdsm
parent 4330eef126
commit 04157d0ad3
7 changed files with 408 additions and 7 deletions

View File

@@ -192,6 +192,10 @@ export function fetchDashboardHealth(): Promise<DashboardHealthResponse> {
return api<DashboardHealthResponse>("/health");
}
export function checkForUpdates(): Promise<UpdateCheckResponse> {
return api<UpdateCheckResponse>("/updates/check");
}
export function fetchTasks(
limit?: number,
offset?: number,
@@ -411,8 +415,8 @@ export function updateSettings(settings: Partial<Settings>, projectId?: string):
}
export interface UpdateCheckResponse {
currentVersion?: string;
latestVersion?: string | null;
currentVersion: string;
latestVersion: string | null;
updateAvailable: boolean;
lastChecked?: number;
disabled?: boolean;

View File

@@ -20,6 +20,43 @@
font-weight: 500;
}
.settings-update-check {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.settings-update-btn {
border-radius: var(--radius-sm);
}
.settings-update-btn svg.spinning {
animation: settings-update-spin 1s linear infinite;
}
.settings-update-result {
font-size: 0.85rem;
font-weight: 500;
}
.settings-update-result--up-to-date {
color: var(--color-success);
}
.settings-update-result--available {
color: var(--color-info);
}
.settings-update-result--error {
color: var(--text-muted);
}
@keyframes settings-update-spin {
to {
transform: rotate(360deg);
}
}
.settings-layout {
display: flex;
flex: 1;
@@ -844,6 +881,11 @@
@media (max-width: 768px) {
.settings-update-check {
flex-wrap: wrap;
row-gap: var(--space-xs);
}
/* Settings modal: use the section picker as the only mobile navigation */
.settings-layout {
flex-direction: column;

View File

@@ -1,9 +1,9 @@
import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEvent } from "react";
import { Globe, Folder } from "lucide-react";
import { Globe, Folder, RefreshCw } from "lucide-react";
import { THINKING_LEVELS, isGlobalSettingsKey, isProjectSettingsKey, getErrorMessage } from "@fusion/core";
import type { Settings, GlobalSettings, ThemeMode, ColorTheme, ModelPreset, NtfyNotificationEvent, AgentPromptsConfig, ThinkingLevel } from "@fusion/core";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, fetchGitRemotesDetailed, fetchDashboardHealth, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus } from "../api";
import { fetchSettings, fetchSettingsByScope, updateSettings, updateGlobalSettings, fetchAuthStatus, loginProvider, logoutProvider, saveApiKey, clearApiKey, fetchModels, testNtfyNotification, fetchBackups, createBackup, exportSettings, importSettings, fetchMemoryFile, fetchMemoryFiles, saveMemoryFile, compactMemory, fetchGlobalConcurrency, updateGlobalConcurrency, installQmd, testMemoryRetrieval, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed, RemoteSettings, RemoteStatus, UpdateCheckResponse } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import type { ToastType } from "../hooks/useToast";
import { ThemeSelector } from "./ThemeSelector";
@@ -226,6 +226,8 @@ export function SettingsModal({
: false,
);
const [appVersion, setAppVersion] = useState<string | null>(null);
const [updateCheckLoading, setUpdateCheckLoading] = useState(false);
const [updateCheckResult, setUpdateCheckResult] = useState<UpdateCheckResponse | null>(null);
const [prefixError, setPrefixError] = useState<string | null>(null);
const [overlapPathPickerIndex, setOverlapPathPickerIndex] = useState<number | null>(null);
@@ -413,6 +415,30 @@ export function SettingsModal({
};
}, []);
const handleCheckForUpdates = useCallback(async () => {
setUpdateCheckLoading(true);
try {
const result = await checkForUpdates();
setUpdateCheckResult(result);
if (result.error) {
addToast(result.error, "error");
}
} catch (error) {
const message = getErrorMessage(error) || "Failed to check for updates";
setUpdateCheckResult({
currentVersion: appVersion ?? "unknown",
latestVersion: null,
updateAvailable: false,
error: message,
});
addToast(message, "error");
} finally {
setUpdateCheckLoading(false);
}
}, [addToast, appVersion]);
// Load auth status when the authentication section is active
const loadAuthStatus = useCallback(async () => {
try {
@@ -4040,7 +4066,39 @@ export function SettingsModal({
<div className="modal-header">
<div className="settings-modal-heading">
<h3>Settings</h3>
{appVersion && <p className="settings-modal-version">Version {appVersion}</p>}
<div className="settings-update-check">
{appVersion && <p className="settings-modal-version">Version {appVersion}</p>}
<button
type="button"
className="btn btn-icon btn-sm settings-update-btn"
onClick={() => {
void handleCheckForUpdates();
}}
disabled={updateCheckLoading}
aria-label="Check for updates"
title="Check for updates"
>
<RefreshCw className={updateCheckLoading ? "spinning" : undefined} />
</button>
{updateCheckResult && (
<span
aria-live="polite"
className={`settings-update-result ${
updateCheckResult.error
? "settings-update-result--error"
: updateCheckResult.updateAvailable
? "settings-update-result--available"
: "settings-update-result--up-to-date"
}`}
>
{updateCheckResult.error
? updateCheckResult.error
: updateCheckResult.updateAvailable && updateCheckResult.latestVersion
? `v${updateCheckResult.latestVersion} available`
: "You're up to date ✓"}
</span>
)}
</div>
</div>
<button className="modal-close" onClick={onClose} aria-label="Close">
&times;

View File

@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SettingsModal } from "../SettingsModal";
import type { SettingsExportData } from "../../api";
import type { SettingsExportData, UpdateCheckResponse } from "../../api";
// --- API mocks ---
const mockFetchSettings = vi.fn();
@@ -29,6 +29,7 @@ const mockTestMemoryRetrieval = vi.fn();
const mockInstallQmd = vi.fn();
const mockFetchGitRemotesDetailed = vi.fn();
const mockFetchDashboardHealth = vi.fn();
const mockCheckForUpdates = vi.fn();
const mockFetchRemoteSettings = vi.fn();
const mockUpdateRemoteSettings = vi.fn();
const mockFetchRemoteStatus = vi.fn();
@@ -66,6 +67,7 @@ vi.mock("../../api", () => ({
installQmd: (...args: unknown[]) => mockInstallQmd(...args),
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
checkForUpdates: (...args: unknown[]) => mockCheckForUpdates(...args),
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
@@ -84,6 +86,16 @@ vi.mock("../../hooks/useMemoryBackendStatus", () => ({
useMemoryBackendStatus: (...args: unknown[]) => mockUseMemoryBackendStatus(...args),
}));
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
Globe: () => <span data-testid="icon-globe" />,
Folder: () => <span data-testid="icon-folder" />,
RefreshCw: ({ className }: { className?: string }) => <span data-testid="icon-refresh" className={className} />,
};
});
vi.mock("../PluginManager", () => ({
PluginManager: () => <div data-testid="plugin-manager">Plugin manager content</div>,
}));
@@ -198,6 +210,7 @@ describe("SettingsModal", () => {
});
mockFetchGitRemotesDetailed.mockResolvedValue([]);
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
mockCheckForUpdates.mockResolvedValue(undefined);
mockFetchRemoteSettings.mockResolvedValue({
settings: {
remoteActiveProvider: null,
@@ -369,6 +382,72 @@ describe("SettingsModal", () => {
expect(await screen.findByLabelText("Max Concurrent Tasks")).toBeInTheDocument();
expect(addToast).not.toHaveBeenCalled();
});
it("renders check for updates button in header", async () => {
renderModal();
await waitForSettingsModalReady();
expect(screen.getByRole("button", { name: "Check for updates" })).toBeInTheDocument();
});
it("clicking check for updates shows up-to-date message", async () => {
mockCheckForUpdates.mockResolvedValueOnce({
currentVersion: "1.2.3",
latestVersion: "1.2.3",
updateAvailable: false,
});
renderModal();
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
expect(await screen.findByText("You're up to date ✓")).toBeInTheDocument();
});
it("clicking check for updates shows update available message", async () => {
mockCheckForUpdates.mockResolvedValueOnce({
currentVersion: "1.0.0",
latestVersion: "2.0.0",
updateAvailable: true,
});
renderModal();
await waitForSettingsModalReady();
await userEvent.click(screen.getByRole("button", { name: "Check for updates" }));
expect(await screen.findByText("v2.0.0 available")).toBeInTheDocument();
});
it("disables button while checking", async () => {
let resolveCheck: ((result: UpdateCheckResponse) => void) | undefined;
const pendingCheck = new Promise<UpdateCheckResponse>((resolve) => {
resolveCheck = resolve;
});
mockCheckForUpdates.mockReturnValueOnce(pendingCheck);
renderModal();
await waitForSettingsModalReady();
const button = screen.getByRole("button", { name: "Check for updates" });
expect(button).not.toBeDisabled();
fireEvent.click(button);
await waitFor(() => {
expect(button).toBeDisabled();
});
resolveCheck?.({
currentVersion: "1.2.3",
latestVersion: "1.2.3",
updateAvailable: false,
});
await waitFor(() => {
expect(button).not.toBeDisabled();
});
});
});
describe("settings export filename", () => {

View File

@@ -0,0 +1,132 @@
// @vitest-environment node
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, it, expect, vi, afterEach } from "vitest";
import type { TaskStore } from "@fusion/core";
import { createServer } from "../server.js";
import { get as performGet } from "../test-request.js";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CLI_PACKAGE_VERSION = (() => {
const packageJsonPath = join(__dirname, "..", "..", "..", "cli", "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
version?: unknown;
};
return typeof packageJson.version === "string" ? packageJson.version : "0.0.0";
})();
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
getDatabase: vi.fn().mockReturnValue({
exec: vi.fn(),
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
}),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
updateMission: vi.fn(),
getMission: vi.fn(),
deleteMission: vi.fn(),
listMilestonesByMission: vi.fn().mockReturnValue([]),
createMilestone: vi.fn(),
updateMilestone: vi.fn(),
getMilestone: vi.fn(),
deleteMilestone: vi.fn(),
listTasksByMilestone: vi.fn().mockReturnValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
}),
on: vi.fn(),
off: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("GET /api/updates/check", () => {
it("returns updateAvailable=true when npm has a newer version", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ version: "99.0.0" }),
}),
);
const app = createServer(createMockStore());
const response = await performGet(app, "/api/updates/check");
expect(response.status).toBe(200);
expect(response.headers["cache-control"]).toBe("no-store");
expect(response.body).toEqual({
currentVersion: CLI_PACKAGE_VERSION,
latestVersion: "99.0.0",
updateAvailable: true,
});
});
it("returns updateAvailable=false when already up to date", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ version: CLI_PACKAGE_VERSION }),
}),
);
const app = createServer(createMockStore());
const response = await performGet(app, "/api/updates/check");
expect(response.status).toBe(200);
expect(response.headers["cache-control"]).toBe("no-store");
expect(response.body).toEqual({
currentVersion: CLI_PACKAGE_VERSION,
latestVersion: CLI_PACKAGE_VERSION,
updateAvailable: false,
});
});
it("gracefully returns an error payload when npm registry is unreachable", async () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down")));
const app = createServer(createMockStore());
const response = await performGet(app, "/api/updates/check");
expect(response.status).toBe(200);
expect(response.headers["cache-control"]).toBe("no-store");
expect(response.body).toEqual({
currentVersion: CLI_PACKAGE_VERSION,
latestVersion: null,
updateAvailable: false,
error: "Failed to check for updates",
});
});
});

View File

@@ -68,6 +68,52 @@ const PACKAGE_VERSION = (() => {
return process.env.npm_package_version ?? "0.0.0";
})();
const CLI_PACKAGE_VERSION = (() => {
try {
const packageJsonPath = join(__dirname, "..", "..", "cli", "package.json");
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8")) as {
version?: unknown;
};
if (typeof packageJson.version === "string" && packageJson.version.length > 0) {
return packageJson.version;
}
} catch {
// Fall through to environment fallback.
}
return process.env.npm_package_version ?? "0.0.0";
})();
function parseVersion(version: string): number[] {
return version
.split(".")
.slice(0, 3)
.map((part) => Number.parseInt(part, 10))
.map((value) => (Number.isFinite(value) ? value : 0));
}
function isRemoteVersionNewer(remoteVersion: string, currentVersion: string): boolean {
const remote = parseVersion(remoteVersion);
const current = parseVersion(currentVersion);
const maxLength = Math.max(remote.length, current.length, 3);
for (let i = 0; i < maxLength; i += 1) {
const remotePart = remote[i] ?? 0;
const currentPart = current[i] ?? 0;
if (remotePart > currentPart) {
return true;
}
if (remotePart < currentPart) {
return false;
}
}
return false;
}
const DEFAULT_AI_SESSION_TTL_MS = SESSION_CLEANUP_DEFAULT_MAX_AGE_MS;
const MIN_AI_SESSION_TTL_MS = 10 * 60 * 1000;
const MAX_AI_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000;
@@ -921,6 +967,45 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
});
});
app.get("/api/updates/check", async (_req, res) => {
const currentVersion = CLI_PACKAGE_VERSION;
res.set("Cache-Control", "no-store");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch("https://registry.npmjs.org/@runfusion/fusion/latest", {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`registry request failed: ${response.status}`);
}
const payload = (await response.json()) as { version?: unknown };
if (typeof payload.version !== "string" || payload.version.trim().length === 0) {
throw new Error("registry response missing version");
}
const latestVersion = payload.version;
res.json({
currentVersion,
latestVersion,
updateAvailable: isRemoteVersionNewer(latestVersion, currentVersion),
});
} catch {
res.status(200).json({
currentVersion,
latestVersion: null,
updateAvailable: false,
error: "Failed to check for updates",
});
} finally {
clearTimeout(timeout);
}
});
app.get("/remote-login", async (req, res) => {
const remoteToken = typeof req.query.rt === "string" ? req.query.rt : undefined;