fix(FN-2867): simplify remote access settings activation flow
- Remove the separate "Activate Provider" action from SettingsModal and rely on save-only provider configuration - Ensure saving remote settings enables the selected active provider and keeps provider flags consistent - Seed remoteAccess defaults in remote settings routes when project settings are missing instead of returning conflicts - Expand dashboard tests to cover first-use/default remote settings behavior and updated provider lifecycle expectations
This commit is contained in:
@@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEve
|
||||
import { Globe, Folder, RefreshCw, Star, HelpCircle, Loader2 } 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, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, activateRemoteProvider, startRemoteTunnel, stopRemoteTunnel, regenerateRemotePersistentToken, generateShortLivedRemoteToken, fetchRemoteQr, fetchRemoteUrl } 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, triggerMemoryDreams, fetchGitRemotesDetailed, fetchDashboardHealth, checkForUpdates, fetchRemoteSettings, updateRemoteSettings, fetchRemoteStatus, 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 { useOverlayDismiss } from "../hooks/useOverlayDismiss";
|
||||
@@ -1451,13 +1451,18 @@ export function SettingsModal({
|
||||
};
|
||||
|
||||
const handleSaveRemoteSettings = useCallback(async () => {
|
||||
const activeProvider = ((form as Record<string, unknown>).remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null;
|
||||
const nextSettings: Partial<RemoteSettings> = {
|
||||
remoteActiveProvider: ((form as Record<string, unknown>).remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||
remoteTailscaleEnabled: Boolean((form as Record<string, unknown>).remoteTailscaleEnabled),
|
||||
remoteActiveProvider: activeProvider,
|
||||
remoteTailscaleEnabled: activeProvider === "tailscale"
|
||||
? true
|
||||
: Boolean((form as Record<string, unknown>).remoteTailscaleEnabled),
|
||||
remoteTailscaleHostname: String((form as Record<string, unknown>).remoteTailscaleHostname ?? ""),
|
||||
remoteTailscaleTargetPort: Number((form as Record<string, unknown>).remoteTailscaleTargetPort ?? 4040),
|
||||
remoteTailscaleAcceptRoutes: Boolean((form as Record<string, unknown>).remoteTailscaleAcceptRoutes),
|
||||
remoteCloudflareEnabled: Boolean((form as Record<string, unknown>).remoteCloudflareEnabled),
|
||||
remoteCloudflareEnabled: activeProvider === "cloudflare"
|
||||
? true
|
||||
: Boolean((form as Record<string, unknown>).remoteCloudflareEnabled),
|
||||
remoteCloudflareQuickTunnel: Boolean((form as Record<string, unknown>).remoteCloudflareQuickTunnel),
|
||||
remoteCloudflareTunnelName: String((form as Record<string, unknown>).remoteCloudflareTunnelName ?? ""),
|
||||
remoteCloudflareTunnelToken: (((form as Record<string, unknown>).remoteCloudflareTunnelToken as string | null) || null),
|
||||
@@ -2410,6 +2415,55 @@ export function SettingsModal({
|
||||
/>
|
||||
<small>Maximum concurrent planning agents</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="defaultNodeId">Default Execution Node</label>
|
||||
<select
|
||||
id="defaultNodeId"
|
||||
className="select"
|
||||
value={typeof form.defaultNodeId === "string" ? form.defaultNodeId : ""}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
setForm((f) => ({ ...f, defaultNodeId: val || undefined } as SettingsFormState));
|
||||
}}
|
||||
>
|
||||
<option value="">Local execution (no default node)</option>
|
||||
{nodes.map((node) => (
|
||||
<option key={node.id} value={node.id}>
|
||||
{node.name} ({getNodeStatusLabel(node.status)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{(() => {
|
||||
const selectedNode = nodes.find((node) => node.id === form.defaultNodeId);
|
||||
if (!selectedNode) return null;
|
||||
return (
|
||||
<div className={`settings-node-status ${getNodeStatusClass(selectedNode.status)}`}>
|
||||
<span className="settings-node-status__dot" aria-hidden="true" />
|
||||
<span>{`Selected node: ${getNodeStatusLabel(selectedNode.status)}`}</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<small>Used when a task has no node override. Node status is shown for safer routing selection.</small>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="unavailableNodePolicy">Unavailable Node Policy</label>
|
||||
<select
|
||||
id="unavailableNodePolicy"
|
||||
className="select"
|
||||
value={
|
||||
form.unavailableNodePolicy === "fallback-local" ? "fallback-local" : "block"
|
||||
}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
unavailableNodePolicy: e.target.value as "block" | "fallback-local",
|
||||
} as SettingsFormState))
|
||||
}
|
||||
>
|
||||
<option value="block">Block execution</option>
|
||||
<option value="fallback-local">Fallback to local</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="pollIntervalMs">Poll Interval (ms)</label>
|
||||
<input
|
||||
@@ -3840,20 +3894,6 @@ export function SettingsModal({
|
||||
<option value="cloudflare">Cloudflare Tunnel</option>
|
||||
</select>
|
||||
<div className="settings-button-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={!activeProvider || remoteBusyAction !== null}
|
||||
onClick={() => {
|
||||
if (!activeProvider) return;
|
||||
void runRemoteAction("activate provider", async () => {
|
||||
await activateRemoteProvider(activeProvider, projectId);
|
||||
addToast(`Activated ${activeProvider}`, "success");
|
||||
});
|
||||
}}
|
||||
>
|
||||
Activate Provider
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
|
||||
@@ -33,7 +33,6 @@ const mockCheckForUpdates = vi.fn();
|
||||
const mockFetchRemoteSettings = vi.fn();
|
||||
const mockUpdateRemoteSettings = vi.fn();
|
||||
const mockFetchRemoteStatus = vi.fn();
|
||||
const mockActivateRemoteProvider = vi.fn();
|
||||
const mockStartRemoteTunnel = vi.fn();
|
||||
const mockStopRemoteTunnel = vi.fn();
|
||||
const mockRegenerateRemotePersistentToken = vi.fn();
|
||||
@@ -72,7 +71,6 @@ vi.mock("../../api", () => ({
|
||||
fetchRemoteSettings: (...args: unknown[]) => mockFetchRemoteSettings(...args),
|
||||
updateRemoteSettings: (...args: unknown[]) => mockUpdateRemoteSettings(...args),
|
||||
fetchRemoteStatus: (...args: unknown[]) => mockFetchRemoteStatus(...args),
|
||||
activateRemoteProvider: (...args: unknown[]) => mockActivateRemoteProvider(...args),
|
||||
startRemoteTunnel: (...args: unknown[]) => mockStartRemoteTunnel(...args),
|
||||
stopRemoteTunnel: (...args: unknown[]) => mockStopRemoteTunnel(...args),
|
||||
regenerateRemotePersistentToken: (...args: unknown[]) => mockRegenerateRemotePersistentToken(...args),
|
||||
@@ -259,7 +257,6 @@ describe("SettingsModal", () => {
|
||||
},
|
||||
});
|
||||
mockFetchRemoteStatus.mockResolvedValue({ provider: null, state: "stopped", url: null, lastError: null });
|
||||
mockActivateRemoteProvider.mockResolvedValue({ activeProvider: "tailscale" });
|
||||
mockStartRemoteTunnel.mockResolvedValue({ state: "starting", provider: "tailscale" });
|
||||
mockStopRemoteTunnel.mockResolvedValue({ state: "stopped", provider: null });
|
||||
mockRegenerateRemotePersistentToken.mockResolvedValue({ token: "token", maskedToken: "****" });
|
||||
@@ -1517,6 +1514,30 @@ describe("SettingsModal", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forces enabled=true for selected active provider when saving", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
await openRemoteSection();
|
||||
|
||||
const tailscaleToggle = screen.getByLabelText("Enable Tailscale provider config");
|
||||
expect(tailscaleToggle).not.toBeChecked();
|
||||
|
||||
await userEvent.selectOptions(screen.getByLabelText("Active provider"), "tailscale");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save Remote Settings" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateRemoteSettings).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteTailscaleEnabled: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles Cloudflare quick tunnel and hides manual cloudflare fields", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
@@ -1544,7 +1565,7 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("updates active provider selection and provider status affordance after activation", async () => {
|
||||
it("uses a save-only remote provider setup flow", async () => {
|
||||
mockFetchRemoteStatus
|
||||
.mockResolvedValueOnce({ provider: null, state: "stopped", url: null, lastError: null })
|
||||
.mockResolvedValueOnce({ provider: "tailscale", state: "running", url: "https://tail.example", lastError: null });
|
||||
@@ -1553,13 +1574,21 @@ describe("SettingsModal", () => {
|
||||
await waitForSettingsModalReady();
|
||||
await openRemoteSection();
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Activate Provider" })).not.toBeInTheDocument();
|
||||
|
||||
const activeProviderSelect = screen.getByLabelText("Active provider");
|
||||
await userEvent.selectOptions(activeProviderSelect, "tailscale");
|
||||
expect(activeProviderSelect).toHaveValue("tailscale");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Activate Provider" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save Remote Settings" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockActivateRemoteProvider).toHaveBeenCalledWith("tailscale", undefined);
|
||||
expect(mockUpdateRemoteSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteTailscaleEnabled: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(getTunnelStateSummary()).toHaveTextContent("Provider: tailscale");
|
||||
|
||||
@@ -104,6 +104,28 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds defaults when activating a provider on a fresh project", async () => {
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings,
|
||||
});
|
||||
const { app } = createApp({ store });
|
||||
|
||||
const activate = await REQUEST(app, "POST", "/api/remote/provider/activate", { provider: "cloudflare" });
|
||||
|
||||
expect(activate.status).toBe(200);
|
||||
expect(updateSettings).toHaveBeenCalledWith(expect.objectContaining({
|
||||
remoteAccess: expect.objectContaining({
|
||||
activeProvider: "cloudflare",
|
||||
providers: expect.objectContaining({
|
||||
tailscale: expect.objectContaining({ enabled: false }),
|
||||
cloudflare: expect.objectContaining({ enabled: false }),
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns NO_ACTIVE_PROVIDER when tunnel start is requested without an active provider", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
@@ -142,7 +164,7 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("maps runtime prerequisite failures to a structured conflict response", async () => {
|
||||
it("returns REMOTE_TUNNEL_PREREQUISITE_MISSING when provider is selected but runtime prerequisites are missing", async () => {
|
||||
const store = createMockStore();
|
||||
const engine = {
|
||||
getTaskStore: vi.fn().mockReturnValue(store),
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import express from "express";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { DEFAULT_PROJECT_SETTINGS, type TaskStore } from "@fusion/core";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
|
||||
@@ -131,6 +131,58 @@ describe("remote access API route contracts", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("returns default remote settings payload when remoteAccess is missing", async () => {
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
});
|
||||
const { app } = createApp({ store });
|
||||
|
||||
const getRes = await REQUEST(app, "GET", "/api/remote/settings");
|
||||
|
||||
expect(getRes.status).toBe(200);
|
||||
expect(getRes.body).toMatchObject({
|
||||
settings: expect.objectContaining({
|
||||
remoteEnabled: false,
|
||||
remoteActiveProvider: null,
|
||||
remoteTailscaleEnabled: false,
|
||||
remoteCloudflareEnabled: false,
|
||||
remoteShortLivedEnabled: false,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds defaults when saving remote settings on a fresh project", async () => {
|
||||
const updateSettings = vi.fn().mockResolvedValue(undefined);
|
||||
const store = createMockStore({
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings,
|
||||
});
|
||||
const { app } = createApp({ store });
|
||||
|
||||
const putRes = await REQUEST(app, "PUT", "/api/remote/settings", {
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteTailscaleEnabled: true,
|
||||
remoteTailscaleHostname: "first-use.ts.net",
|
||||
});
|
||||
|
||||
expect(putRes.status).toBe(200);
|
||||
expect(updateSettings).toHaveBeenCalledWith({
|
||||
remoteAccess: expect.objectContaining({
|
||||
...DEFAULT_PROJECT_SETTINGS.remoteAccess,
|
||||
activeProvider: "tailscale",
|
||||
providers: expect.objectContaining({
|
||||
tailscale: expect.objectContaining({ enabled: true, hostname: "first-use.ts.net" }),
|
||||
cloudflare: expect.objectContaining({ enabled: false }),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
expect(putRes.body.settings).toMatchObject({
|
||||
remoteEnabled: true,
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteTailscaleEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("supports provider activation and tunnel lifecycle endpoints", async () => {
|
||||
const engine = {
|
||||
startRemoteTunnel: vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
GLOBAL_SETTINGS_KEYS,
|
||||
QMD_INSTALL_COMMAND,
|
||||
MemoryBackendError,
|
||||
@@ -316,11 +317,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
|
||||
if (!remoteAccess) {
|
||||
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
|
||||
}
|
||||
const remoteAccess = settings.remoteAccess ?? DEFAULT_PROJECT_SETTINGS.remoteAccess;
|
||||
|
||||
res.json({ settings: toRemoteSettingsPayload(remoteAccess) });
|
||||
} catch (err: unknown) {
|
||||
@@ -333,10 +330,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
if (!remoteAccess) {
|
||||
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
|
||||
}
|
||||
const remoteAccess = settings.remoteAccess ?? DEFAULT_PROJECT_SETTINGS.remoteAccess;
|
||||
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const nextRemoteAccess = {
|
||||
@@ -433,10 +427,7 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
}
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const settings = await scopedStore.getSettings();
|
||||
const remoteAccess = settings.remoteAccess;
|
||||
if (!remoteAccess) {
|
||||
throw new ApiError(409, "Remote access is not configured", { code: "REMOTE_ACCESS_DISABLED" });
|
||||
}
|
||||
const remoteAccess = settings.remoteAccess ?? DEFAULT_PROJECT_SETTINGS.remoteAccess;
|
||||
|
||||
await scopedStore.updateSettings({
|
||||
remoteAccess: {
|
||||
|
||||
Reference in New Issue
Block a user