feat(FN-2516): show app version in settings modal

- Add typed health endpoint helper in dashboard API for retrieving status metadata
- Fetch dashboard health in SettingsModal and render a non-blocking version label in the modal header
- Add settings modal heading/version styles to support the new header layout
- Expand SettingsModal tests to cover successful version rendering and graceful failure behavior
- Add mobile settings test coverage for displaying the version label
This commit is contained in:
Fusion
2026-04-25 13:38:49 -07:00
committed by gsxdsm
parent d65b75d4b2
commit 6461108a05
5 changed files with 90 additions and 2 deletions

View File

@@ -175,6 +175,16 @@ export async function api<T = unknown>(path: string, opts: RequestInit = {}): Pr
return data as T;
}
export interface DashboardHealthResponse {
status: string;
version: string;
uptime: number;
}
export function fetchDashboardHealth(): Promise<DashboardHealthResponse> {
return api<DashboardHealthResponse>("/health");
}
export function fetchTasks(
limit?: number,
offset?: number,

View File

@@ -4,6 +4,23 @@
for the classes they share). */
/* === Settings Layout === */
.settings-modal-heading {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.settings-modal-heading h3 {
margin: 0;
}
.settings-modal-version {
margin: 0;
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 500;
}
.settings-layout {
display: flex;
flex: 1;

View File

@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, lazy, Suspense } from "react"
import { Globe, Folder } 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 } 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 } from "../api";
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed } from "../api";
import { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
import type { ToastType } from "../hooks/useToast";
@@ -181,6 +181,7 @@ export function SettingsModal({
? window.matchMedia(MOBILE_SETTINGS_MEDIA_QUERY)?.matches === true
: false,
);
const [appVersion, setAppVersion] = useState<string | null>(null);
const [prefixError, setPrefixError] = useState<string | null>(null);
/** Get the scope of the currently active section */
@@ -298,6 +299,28 @@ export function SettingsModal({
});
}, []);
useEffect(() => {
let cancelled = false;
fetchDashboardHealth()
.then((health) => {
if (cancelled) {
return;
}
if (typeof health.version === "string" && health.version.trim().length > 0) {
setAppVersion(health.version);
}
})
.catch(() => {
// Non-blocking metadata only — settings remains usable when unavailable.
});
return () => {
cancelled = true;
};
}, []);
// Load auth status when the authentication section is active
const loadAuthStatus = useCallback(async () => {
try {
@@ -3400,7 +3423,10 @@ export function SettingsModal({
<div className="modal-overlay open" onClick={handleOverlayClick} role="dialog" aria-modal="true">
<div className="modal modal-lg">
<div className="modal-header">
<h3>Settings</h3>
<div className="settings-modal-heading">
<h3>Settings</h3>
{appVersion && <p className="settings-modal-version">Version {appVersion}</p>}
</div>
<button className="modal-close" onClick={onClose} aria-label="Close">
&times;
</button>

View File

@@ -28,6 +28,7 @@ const mockFetchMemoryBackendStatus = vi.fn();
const mockTestMemoryRetrieval = vi.fn();
const mockInstallQmd = vi.fn();
const mockFetchGitRemotesDetailed = vi.fn();
const mockFetchDashboardHealth = vi.fn();
vi.mock("../../api", () => ({
fetchSettings: (...args: unknown[]) => mockFetchSettings(...args),
@@ -53,6 +54,7 @@ vi.mock("../../api", () => ({
testMemoryRetrieval: (...args: unknown[]) => mockTestMemoryRetrieval(...args),
installQmd: (...args: unknown[]) => mockInstallQmd(...args),
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
}));
// Mock the hook
@@ -161,6 +163,7 @@ describe("SettingsModal", () => {
qmdInstallCommand: "bun install -g @tobilu/qmd",
});
mockFetchGitRemotesDetailed.mockResolvedValue([]);
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
mockImportSettings.mockResolvedValue({ success: true, globalCount: 0, projectCount: 0 });
mockFetchGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
mockUpdateGlobalConcurrency.mockResolvedValue({ globalMaxConcurrent: 4, currentlyActive: 0, queuedCount: 0, projectsActive: {} });
@@ -218,6 +221,29 @@ describe("SettingsModal", () => {
vi.restoreAllMocks();
});
describe("settings version display", () => {
it("renders the app version from the health endpoint", async () => {
renderModal();
await waitForSettingsModalReady();
expect(await screen.findByText("Version 1.2.3")).toBeInTheDocument();
expect(mockFetchDashboardHealth).toHaveBeenCalledTimes(1);
});
it("keeps settings interactive when version lookup fails", async () => {
const addToast = vi.fn();
mockFetchDashboardHealth.mockRejectedValueOnce(new Error("health unavailable"));
render(<SettingsModal onClose={noop} addToast={addToast} />);
await waitForSettingsModalReady();
expect(screen.queryByText(/^Version\s+/)).not.toBeInTheDocument();
await userEvent.click(screen.getByText("Scheduling"));
expect(await screen.findByLabelText("Max Concurrent Tasks")).toBeInTheDocument();
expect(addToast).not.toHaveBeenCalled();
});
});
describe("settings export filename", () => {
it("uses fusion-settings- prefix for exported filename", async () => {
const mockExportData: SettingsExportData = {

View File

@@ -94,6 +94,7 @@ vi.mock("../../api", () => ({
qmdAvailable: true,
qmdInstallCommand: "bun install -g @tobilu/qmd",
})),
fetchDashboardHealth: vi.fn(() => Promise.resolve({ status: "ok", version: "1.2.3", uptime: 120 })),
}));
vi.mock("../../hooks/useMemoryBackendStatus", () => ({
@@ -177,6 +178,14 @@ describe("SettingsModal mobile adaptations", () => {
expect(container.querySelector(".settings-content")).toBeTruthy();
});
it("renders the app version label in mobile layout", async () => {
mockSettingsViewport(true);
const { findByText } = render(<SettingsModal onClose={vi.fn()} addToast={vi.fn()} />);
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
expect(await findByText("Version 1.2.3")).toBeTruthy();
});
it("can open memory settings from the mobile section picker", async () => {
mockSettingsViewport(true);
const user = userEvent.setup();