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:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
×
|
||||
</button>
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user