feat(FN-2517): add remote access controls across dashboard and TUI
- Add project-scoped remote settings and control APIs for provider activation, tunnel lifecycle, token generation, URL, and QR retrieval - Extend Settings modal with a dedicated Remote Access section, provider forms, status/actions, and coverage in SettingsModal tests - Wire dashboard TUI settings state to remote configuration/status and add interactive remote shortcuts for start/stop/token/url/qr actions - Document remote-access behavior in architecture, CLI, and settings references and include a patch changeset for @runfusion/fusion
This commit is contained in:
@@ -47,6 +47,10 @@ function makeInteractiveData(opts: {
|
||||
pollIntervalMs: 60000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
remoteEnabled: false,
|
||||
remoteActiveProvider: null,
|
||||
remoteShortLivedEnabled: false,
|
||||
remoteShortLivedTtlMs: 900000,
|
||||
};
|
||||
const models = opts.models ?? [];
|
||||
return {
|
||||
@@ -65,6 +69,16 @@ function makeInteractiveData(opts: {
|
||||
getSettings: async () => settings,
|
||||
updateSettings: async (_partial: Partial<SettingsValues>) => {},
|
||||
listModels: () => models,
|
||||
remote: {
|
||||
getStatus: async () => ({ provider: null, state: "stopped", url: null, lastError: null }),
|
||||
activateProvider: async () => {},
|
||||
start: async () => {},
|
||||
stop: async () => {},
|
||||
regeneratePersistentToken: async () => {},
|
||||
generateShortLivedToken: async () => ({ token: "short", expiresAt: new Date().toISOString(), ttlMs: 60000 }),
|
||||
fetchUrl: async () => ({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null }),
|
||||
fetchQr: async () => ({ url: "https://remote.example.com", expiresAt: null, format: "image/svg", data: "<svg/>" }),
|
||||
},
|
||||
git: {
|
||||
getStatus: async () => ({
|
||||
branch: "main",
|
||||
@@ -317,6 +331,11 @@ describe("Settings view", () => {
|
||||
pollIntervalMs: 60000,
|
||||
enginePaused: false,
|
||||
globalPause: false,
|
||||
remoteEnabled: true,
|
||||
remoteActiveProvider: "tailscale",
|
||||
remoteShortLivedEnabled: true,
|
||||
remoteShortLivedTtlMs: 600000,
|
||||
remoteStatus: { provider: "tailscale", state: "running", url: "https://remote.example.com", lastError: null },
|
||||
};
|
||||
controller.setInteractiveData(makeInteractiveData({ settings }));
|
||||
controller.setMode("interactive");
|
||||
|
||||
@@ -448,6 +448,9 @@ function SettingsPanel({ state, isFocused }: { state: DashboardState; isFocused:
|
||||
["pollMs", `${s.pollIntervalMs}`],
|
||||
["paused", s.enginePaused ? "yes" : "no"],
|
||||
["globalPause", s.globalPause ? "yes" : "no"],
|
||||
["remoteEnabled", s.remoteEnabled ? "enabled" : "disabled"],
|
||||
["remoteProvider", s.remoteActiveProvider ?? "none"],
|
||||
["remoteState", s.remoteStatus?.state ?? "unknown"],
|
||||
] as Array<[string, string]>
|
||||
).map(([key, value]) => {
|
||||
const isEnabled = value === "enabled" || value === "yes";
|
||||
@@ -2102,7 +2105,7 @@ function AgentsView({ state }: { state: DashboardState }) {
|
||||
|
||||
// ── Settings interactive view ─────────────────────────────────────────────────
|
||||
|
||||
type SettingKey = "maxConcurrent" | "maxWorktrees" | "autoMerge" | "mergeStrategy" | "pollIntervalMs" | "enginePaused" | "globalPause";
|
||||
type SettingKey = "maxConcurrent" | "maxWorktrees" | "autoMerge" | "mergeStrategy" | "pollIntervalMs" | "enginePaused" | "globalPause" | "remoteEnabled" | "remoteActiveProvider" | "remoteShortLivedEnabled" | "remoteShortLivedTtlMs";
|
||||
|
||||
interface SettingDef {
|
||||
key: SettingKey;
|
||||
@@ -2119,6 +2122,10 @@ const SETTING_DEFS: SettingDef[] = [
|
||||
{ key: "pollIntervalMs", label: "Poll Interval (ms)", type: "number" },
|
||||
{ key: "enginePaused", label: "Engine Paused", type: "boolean" },
|
||||
{ key: "globalPause", label: "Global Pause", type: "boolean" },
|
||||
{ key: "remoteEnabled", label: "Remote Access", type: "boolean" },
|
||||
{ key: "remoteActiveProvider", label: "Remote Provider", type: "enum", options: ["tailscale", "cloudflare"] },
|
||||
{ key: "remoteShortLivedEnabled", label: "Short-Lived Tokens", type: "boolean" },
|
||||
{ key: "remoteShortLivedTtlMs", label: "Short-Lived TTL (ms)", type: "number" },
|
||||
];
|
||||
|
||||
function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
||||
@@ -2128,12 +2135,25 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [statusMsg, setStatusMsg] = useState<string | null>(null);
|
||||
const [detailFocused, setDetailFocused] = useState(false);
|
||||
const [remoteUrl, setRemoteUrl] = useState<string | null>(null);
|
||||
const [remoteTokenMeta, setRemoteTokenMeta] = useState<string | null>(null);
|
||||
|
||||
const data = state.interactiveData;
|
||||
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
data.getSettings().then(setLocalSettings).catch(() => {});
|
||||
data.getSettings().then(async (settings) => {
|
||||
if (data.remote) {
|
||||
try {
|
||||
const remoteStatus = await data.remote.getStatus();
|
||||
setLocalSettings({ ...settings, remoteStatus });
|
||||
} catch {
|
||||
setLocalSettings(settings);
|
||||
}
|
||||
} else {
|
||||
setLocalSettings(settings);
|
||||
}
|
||||
}).catch(() => {});
|
||||
setModels(data.listModels());
|
||||
}, [data]);
|
||||
|
||||
@@ -2145,7 +2165,8 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
||||
try {
|
||||
await data.updateSettings(partial);
|
||||
const updated = await data.getSettings();
|
||||
setLocalSettings(updated);
|
||||
const remoteStatus = data.remote ? await data.remote.getStatus().catch(() => null) : null;
|
||||
setLocalSettings(remoteStatus ? { ...updated, remoteStatus } : updated);
|
||||
setStatusMsg("Saved");
|
||||
} catch (err) {
|
||||
setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
@@ -2154,6 +2175,16 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRemoteStatus() {
|
||||
if (!data || !localSettings) return;
|
||||
try {
|
||||
const remoteStatus = await data.remote.getStatus();
|
||||
setLocalSettings({ ...localSettings, remoteStatus });
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
}
|
||||
|
||||
useInput((input, key) => {
|
||||
// Tab cycles list ↔ detail. Left/right also switch — list = left,
|
||||
// detail = right, matching the visual layout (consistent with AgentsView).
|
||||
@@ -2184,6 +2215,52 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
||||
|
||||
if (!selectedDef || !localSettings) return;
|
||||
|
||||
if (input === "R") {
|
||||
void refreshRemoteStatus();
|
||||
setStatusMsg("Remote status refreshed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.remote && input === "S") {
|
||||
void data.remote.start().then(() => refreshRemoteStatus()).then(() => setStatusMsg("Remote tunnel starting"))
|
||||
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.remote && input === "X") {
|
||||
void data.remote.stop().then(() => refreshRemoteStatus()).then(() => setStatusMsg("Remote tunnel stopped"))
|
||||
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.remote && input === "P") {
|
||||
void data.remote.regeneratePersistentToken().then(() => setStatusMsg("Persistent token regenerated"))
|
||||
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.remote && input === "U") {
|
||||
void data.remote.fetchUrl()
|
||||
.then((result) => {
|
||||
setRemoteUrl(result.url);
|
||||
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : result.tokenType);
|
||||
setStatusMsg("Remote URL fetched");
|
||||
})
|
||||
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.remote && input === "Q") {
|
||||
void data.remote.fetchQr()
|
||||
.then((result) => {
|
||||
setRemoteUrl(result.url);
|
||||
setRemoteTokenMeta(result.expiresAt ? `expires ${new Date(result.expiresAt).toLocaleString()}` : "persistent");
|
||||
setStatusMsg("QR URL generated");
|
||||
})
|
||||
.catch((err) => setStatusMsg(`Error: ${err instanceof Error ? err.message : String(err)}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedDef.type === "boolean" && input === " ") {
|
||||
const current = localSettings[selectedDef.key] as boolean;
|
||||
const updated = { ...localSettings, [selectedDef.key]: !current };
|
||||
@@ -2331,6 +2408,25 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Box height={1} />
|
||||
<Text dimColor>──── Remote Access ────</Text>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text dimColor>Provider:</Text>
|
||||
<Text>{localSettings.remoteActiveProvider ?? "none"}</Text>
|
||||
<Text dimColor>State:</Text>
|
||||
<Text color={localSettings.remoteStatus?.state === "running" ? "green" : "yellow"}>{localSettings.remoteStatus?.state ?? "unknown"}</Text>
|
||||
</Box>
|
||||
{localSettings.remoteStatus?.url && (
|
||||
<Text dimColor wrap="truncate-end">URL: {localSettings.remoteStatus.url}</Text>
|
||||
)}
|
||||
{remoteUrl && (
|
||||
<Text dimColor wrap="truncate-end">Auth URL: {remoteUrl}</Text>
|
||||
)}
|
||||
{remoteTokenMeta && (
|
||||
<Text dimColor>{remoteTokenMeta}</Text>
|
||||
)}
|
||||
<Text dimColor>[S] start [X] stop [P] regenerate token [U] URL [Q] QR URL [R] refresh</Text>
|
||||
|
||||
{/* Models subsection */}
|
||||
{models.length > 0 && (
|
||||
<>
|
||||
@@ -2357,7 +2453,7 @@ function SettingsInteractiveView({ state }: { state: DashboardState }) {
|
||||
</Box>
|
||||
|
||||
<Box paddingX={1}>
|
||||
<Text dimColor>[Tab] switch panel ↑↓ select setting [Space] toggle bool [+/-] adjust num [←/→] cycle enum</Text>
|
||||
<Text dimColor>[Tab] switch panel ↑↓ select setting [Space] toggle bool [+/-] adjust num [←/→] cycle enum [S/X/P/U/Q/R] remote actions</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -51,6 +51,13 @@ export interface SystemStats {
|
||||
platform: string;
|
||||
}
|
||||
|
||||
export interface RemoteStatusValue {
|
||||
provider: "tailscale" | "cloudflare" | null;
|
||||
state: "stopped" | "starting" | "running" | "error";
|
||||
url: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export interface SettingsValues {
|
||||
maxConcurrent: number;
|
||||
maxWorktrees: number;
|
||||
@@ -59,6 +66,11 @@ export interface SettingsValues {
|
||||
pollIntervalMs: number;
|
||||
enginePaused: boolean;
|
||||
globalPause: boolean;
|
||||
remoteEnabled: boolean;
|
||||
remoteActiveProvider: "tailscale" | "cloudflare" | null;
|
||||
remoteShortLivedEnabled: boolean;
|
||||
remoteShortLivedTtlMs: number;
|
||||
remoteStatus?: RemoteStatusValue;
|
||||
}
|
||||
|
||||
export interface UtilityAction {
|
||||
@@ -245,6 +257,16 @@ export interface InteractiveData {
|
||||
getSettings: () => Promise<SettingsValues>;
|
||||
updateSettings: (partial: Partial<SettingsValues>) => Promise<void>;
|
||||
listModels: () => ModelItem[];
|
||||
remote: {
|
||||
getStatus: () => Promise<RemoteStatusValue>;
|
||||
activateProvider: (provider: "tailscale" | "cloudflare") => Promise<void>;
|
||||
start: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
regeneratePersistentToken: () => Promise<void>;
|
||||
generateShortLivedToken: (ttlMs: number) => Promise<{ token: string; expiresAt: string; ttlMs: number }>;
|
||||
fetchUrl: () => Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>;
|
||||
fetchQr: () => Promise<{ url: string; expiresAt: string | null; format: "text" | "image/svg"; data?: string }>;
|
||||
};
|
||||
git: {
|
||||
getStatus: (projectPath: string) => Promise<GitStatus>;
|
||||
listCommits: (projectPath: string, limit?: number) => Promise<GitCommit[]>;
|
||||
|
||||
@@ -722,6 +722,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
pollIntervalMs: fullSettings.pollIntervalMs ?? 60_000,
|
||||
enginePaused: fullSettings.enginePaused ?? false,
|
||||
globalPause: fullSettings.globalPause ?? false,
|
||||
remoteEnabled: Boolean(fullSettings.remoteEnabled),
|
||||
remoteActiveProvider: (fullSettings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||
remoteShortLivedEnabled: Boolean(fullSettings.remoteShortLivedEnabled),
|
||||
remoteShortLivedTtlMs: Number(fullSettings.remoteShortLivedTtlMs ?? 900_000),
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -732,6 +736,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
pollIntervalMs: 60_000,
|
||||
enginePaused: paused,
|
||||
globalPause: false,
|
||||
remoteEnabled: false,
|
||||
remoteActiveProvider: null,
|
||||
remoteShortLivedEnabled: false,
|
||||
remoteShortLivedTtlMs: 900_000,
|
||||
};
|
||||
},
|
||||
onPersistVitestKillSettings: async (partial) => {
|
||||
@@ -866,6 +874,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
pollIntervalMs: settings.pollIntervalMs ?? 60_000,
|
||||
enginePaused: settings.enginePaused ?? false,
|
||||
globalPause: settings.globalPause ?? false,
|
||||
remoteEnabled: Boolean(settings.remoteEnabled),
|
||||
remoteActiveProvider: (settings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||
remoteShortLivedEnabled: Boolean(settings.remoteShortLivedEnabled),
|
||||
remoteShortLivedTtlMs: Number(settings.remoteShortLivedTtlMs ?? 900_000),
|
||||
});
|
||||
} catch {
|
||||
// Ignore errors refreshing settings
|
||||
@@ -1834,6 +1846,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
pollIntervalMs: settings.pollIntervalMs ?? 60_000,
|
||||
enginePaused: settings.enginePaused ?? false,
|
||||
globalPause: settings.globalPause ?? false,
|
||||
remoteEnabled: Boolean(settings.remoteEnabled),
|
||||
remoteActiveProvider: (settings.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||
remoteShortLivedEnabled: Boolean(settings.remoteShortLivedEnabled),
|
||||
remoteShortLivedTtlMs: Number(settings.remoteShortLivedTtlMs ?? 900_000),
|
||||
});
|
||||
|
||||
// Hydrate the TUI memory guard from persisted global settings so the
|
||||
@@ -1881,6 +1897,13 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// are cached so repeated panel switches don't re-init SQLite.
|
||||
if (centralCoreForMesh) {
|
||||
const centralCore = centralCoreForMesh;
|
||||
const buildAuthHeaders = (): Record<string, string> => {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (dashboardAuthToken) {
|
||||
headers.Authorization = `Bearer ${dashboardAuthToken}`;
|
||||
}
|
||||
return headers;
|
||||
};
|
||||
tui.setInteractiveData({
|
||||
listProjects: async () => {
|
||||
const projects = await centralCore.listProjects();
|
||||
@@ -1963,6 +1986,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
pollIntervalMs: s.pollIntervalMs ?? 60_000,
|
||||
enginePaused: s.enginePaused ?? false,
|
||||
globalPause: s.globalPause ?? false,
|
||||
remoteEnabled: Boolean(s.remoteEnabled),
|
||||
remoteActiveProvider: (s.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||
remoteShortLivedEnabled: Boolean(s.remoteShortLivedEnabled),
|
||||
remoteShortLivedTtlMs: Number(s.remoteShortLivedTtlMs ?? 900_000),
|
||||
};
|
||||
},
|
||||
updateSettings: async (partial) => {
|
||||
@@ -1975,6 +2002,10 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
if (partial.pollIntervalMs !== undefined) mapped.pollIntervalMs = partial.pollIntervalMs;
|
||||
if (partial.enginePaused !== undefined) mapped.enginePaused = partial.enginePaused;
|
||||
if (partial.globalPause !== undefined) mapped.globalPause = partial.globalPause;
|
||||
if (partial.remoteEnabled !== undefined) mapped.remoteEnabled = partial.remoteEnabled;
|
||||
if (partial.remoteActiveProvider !== undefined) mapped.remoteActiveProvider = partial.remoteActiveProvider;
|
||||
if (partial.remoteShortLivedEnabled !== undefined) mapped.remoteShortLivedEnabled = partial.remoteShortLivedEnabled;
|
||||
if (partial.remoteShortLivedTtlMs !== undefined) mapped.remoteShortLivedTtlMs = partial.remoteShortLivedTtlMs;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await store.updateSettings(mapped as any);
|
||||
},
|
||||
@@ -1986,6 +2017,77 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
contextWindow: m.contextWindow ?? 0,
|
||||
}));
|
||||
},
|
||||
remote: {
|
||||
getStatus: async () => {
|
||||
const response = await fetch(`${baseUrl}/api/remote/status`, { headers: buildAuthHeaders() });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Remote status request failed: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
activateProvider: async (provider: "tailscale" | "cloudflare") => {
|
||||
const response = await fetch(`${baseUrl}/api/remote/provider/activate`, {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify({ provider }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Remote provider activation failed: ${response.status}`);
|
||||
}
|
||||
},
|
||||
start: async () => {
|
||||
const response = await fetch(`${baseUrl}/api/remote/tunnel/start`, {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Remote start failed: ${response.status}`);
|
||||
}
|
||||
},
|
||||
stop: async () => {
|
||||
const response = await fetch(`${baseUrl}/api/remote/tunnel/stop`, {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Remote stop failed: ${response.status}`);
|
||||
}
|
||||
},
|
||||
regeneratePersistentToken: async () => {
|
||||
const response = await fetch(`${baseUrl}/api/remote/token/persistent/regenerate`, {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Persistent token regeneration failed: ${response.status}`);
|
||||
}
|
||||
},
|
||||
generateShortLivedToken: async (ttlMs: number) => {
|
||||
const response = await fetch(`${baseUrl}/api/remote/token/short-lived/generate`, {
|
||||
method: "POST",
|
||||
headers: buildAuthHeaders(),
|
||||
body: JSON.stringify({ ttlMs }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Short-lived token generation failed: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
fetchUrl: async () => {
|
||||
const response = await fetch(`${baseUrl}/api/remote/url`, { headers: buildAuthHeaders() });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Remote URL request failed: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
fetchQr: async () => {
|
||||
const response = await fetch(`${baseUrl}/api/remote/qr?format=image%2Fsvg`, { headers: buildAuthHeaders() });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Remote QR request failed: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
},
|
||||
},
|
||||
git: {
|
||||
getStatus: (projectPath: string) => buildGitStatus(projectPath),
|
||||
listCommits: (projectPath: string, limit?: number) => buildGitCommits(projectPath, limit),
|
||||
|
||||
@@ -403,6 +403,94 @@ export function updateSettings(settings: Partial<Settings>, projectId?: string):
|
||||
});
|
||||
}
|
||||
|
||||
export interface RemoteSettings {
|
||||
remoteEnabled: boolean;
|
||||
remoteActiveProvider: "tailscale" | "cloudflare" | null;
|
||||
remoteTailscaleEnabled: boolean;
|
||||
remoteTailscaleHostname: string;
|
||||
remoteTailscaleTargetPort: number;
|
||||
remoteTailscaleAcceptRoutes: boolean;
|
||||
remoteCloudflareEnabled: boolean;
|
||||
remoteCloudflareTunnelName: string;
|
||||
remoteCloudflareTunnelToken: string | null;
|
||||
remoteCloudflareIngressUrl: string;
|
||||
remotePersistentToken: string | null;
|
||||
remoteShortLivedEnabled: boolean;
|
||||
remoteShortLivedTtlMs: number;
|
||||
remoteShortLivedMaxTtlMs: number;
|
||||
remoteRememberLastRunning: boolean;
|
||||
remoteWasRunningOnShutdown: boolean;
|
||||
remoteLastStartedProvider: "tailscale" | "cloudflare" | null;
|
||||
}
|
||||
|
||||
export interface RemoteStatus {
|
||||
provider: "tailscale" | "cloudflare" | null;
|
||||
state: "stopped" | "starting" | "running" | "error";
|
||||
url: string | null;
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
export function fetchRemoteSettings(projectId?: string): Promise<{ settings: RemoteSettings }> {
|
||||
return api<{ settings: RemoteSettings }>(withProjectId("/remote/settings", projectId));
|
||||
}
|
||||
|
||||
export function updateRemoteSettings(
|
||||
settings: Partial<RemoteSettings>,
|
||||
projectId?: string,
|
||||
): Promise<{ settings: RemoteSettings }> {
|
||||
return api<{ settings: RemoteSettings }>(withProjectId("/remote/settings", projectId), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchRemoteStatus(projectId?: string): Promise<RemoteStatus> {
|
||||
return api<RemoteStatus>(withProjectId("/remote/status", projectId));
|
||||
}
|
||||
|
||||
export function activateRemoteProvider(provider: "tailscale" | "cloudflare", projectId?: string): Promise<{ activeProvider: "tailscale" | "cloudflare" }> {
|
||||
return api<{ activeProvider: "tailscale" | "cloudflare" }>(withProjectId("/remote/provider/activate", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ provider }),
|
||||
});
|
||||
}
|
||||
|
||||
export function startRemoteTunnel(projectId?: string): Promise<{ state: "starting" | "running"; provider: string }> {
|
||||
return api<{ state: "starting" | "running"; provider: string }>(withProjectId("/remote/tunnel/start", projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function stopRemoteTunnel(projectId?: string): Promise<{ state: "stopped"; provider: string | null }> {
|
||||
return api<{ state: "stopped"; provider: string | null }>(withProjectId("/remote/tunnel/stop", projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function regenerateRemotePersistentToken(projectId?: string): Promise<{ token: string; maskedToken: string }> {
|
||||
return api<{ token: string; maskedToken: string }>(withProjectId("/remote/token/persistent/regenerate", projectId), {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
export function generateShortLivedRemoteToken(ttlMs: number, projectId?: string): Promise<{ token: string; expiresAt: string; ttlMs: number }> {
|
||||
return api<{ token: string; expiresAt: string; ttlMs: number }>(withProjectId("/remote/token/short-lived/generate", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ttlMs }),
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchRemoteUrl(projectId?: string): Promise<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }> {
|
||||
return api<{ url: string; tokenType: "persistent" | "short-lived"; expiresAt: string | null }>(withProjectId("/remote/url", projectId));
|
||||
}
|
||||
|
||||
export function fetchRemoteQr(
|
||||
format: "text" | "image/svg" = "text",
|
||||
projectId?: string,
|
||||
): Promise<{ url: string; expiresAt: string | null; format: "text" | "image/svg"; data?: string }> {
|
||||
return api<{ url: string; expiresAt: string | null; format: "text" | "image/svg"; data?: string }>(withProjectId(`/remote/qr?format=${encodeURIComponent(format)}`, projectId));
|
||||
}
|
||||
|
||||
export function fetchMemory(projectId?: string): Promise<{ content: string }> {
|
||||
return api<{ content: string }>(withProjectId("/memory", projectId));
|
||||
}
|
||||
|
||||
@@ -258,6 +258,54 @@
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.settings-button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.settings-raw-output {
|
||||
margin: var(--space-sm) 0 0;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--surface);
|
||||
color: var(--text-muted);
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.settings-qr-preview {
|
||||
margin-top: var(--space-sm);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.settings-qr-preview-label {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-qr-preview-image-wrap {
|
||||
width: fit-content;
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
.settings-qr-preview-image {
|
||||
display: block;
|
||||
width: calc(var(--space-2xl) * 6);
|
||||
height: calc(var(--space-2xl) * 6);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.settings-overlap-ignore-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState, useEffect, useCallback, useRef, lazy, Suspense, type MouseEve
|
||||
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, fetchDashboardHealth } from "../api";
|
||||
import type { AuthProvider, ModelInfo, BackupListResponse, SettingsExportData, MemoryFileInfo, MemoryRetrievalTestResult, GitRemoteDetailed } 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, 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 { useMemoryBackendStatus } from "../hooks/useMemoryBackendStatus";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { ThemeSelector } from "./ThemeSelector";
|
||||
@@ -20,7 +20,6 @@ import { AgentPromptsManager } from "./AgentPromptsManager";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { applyPresetToSelection, generateUniquePresetId } from "../utils/modelPresets";
|
||||
import { appendTokenQuery } from "../auth";
|
||||
import "./SettingsModal.css";
|
||||
|
||||
/**
|
||||
* Settings sections configuration.
|
||||
@@ -78,6 +77,7 @@ const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
{ id: "experimental", label: "Experimental Features", scope: "project" },
|
||||
{ id: "prompts", label: "Prompts", scope: "project" },
|
||||
{ id: "backups", label: "Backups", scope: "project" },
|
||||
{ id: "remote", label: "Remote Access", scope: "project" },
|
||||
{ id: "plugins", label: "Plugins", scope: "project" },
|
||||
];
|
||||
|
||||
@@ -257,6 +257,13 @@ export function SettingsModal({
|
||||
const [backupInfo, setBackupInfo] = useState<BackupListResponse | null>(null);
|
||||
const [backupLoading, setBackupLoading] = useState(false);
|
||||
|
||||
// Remote access state
|
||||
const [remoteStatus, setRemoteStatus] = useState<RemoteStatus | null>(null);
|
||||
const [remoteBusyAction, setRemoteBusyAction] = useState<string | null>(null);
|
||||
const [remoteUrlPreview, setRemoteUrlPreview] = useState<{ url: string; expiresAt: string | null; tokenType?: "persistent" | "short-lived" } | null>(null);
|
||||
const [remoteQrSvg, setRemoteQrSvg] = useState<string | null>(null);
|
||||
const [remoteShortLivedToken, setRemoteShortLivedToken] = useState<{ token: string; expiresAt: string; ttlMs: number } | null>(null);
|
||||
|
||||
// Project memory state
|
||||
const [memoryContent, setMemoryContent] = useState("");
|
||||
const [memoryLoading, setMemoryLoading] = useState(false);
|
||||
@@ -431,6 +438,31 @@ export function SettingsModal({
|
||||
}
|
||||
}, [activeSection, projectId]);
|
||||
|
||||
const loadRemoteData = useCallback(async () => {
|
||||
const [settingsResult, statusResult] = await Promise.allSettled([
|
||||
fetchRemoteSettings(projectId),
|
||||
fetchRemoteStatus(projectId),
|
||||
]);
|
||||
|
||||
if (settingsResult.status === "fulfilled") {
|
||||
setForm((prev) => ({ ...prev, ...(settingsResult.value.settings as unknown as Partial<SettingsFormState>) }));
|
||||
}
|
||||
|
||||
if (statusResult.status === "fulfilled") {
|
||||
setRemoteStatus(statusResult.value);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeSection !== "remote") {
|
||||
return;
|
||||
}
|
||||
|
||||
loadRemoteData().catch(() => {
|
||||
setRemoteStatus(null);
|
||||
});
|
||||
}, [activeSection, loadRemoteData]);
|
||||
|
||||
// Lazy-load git remotes for the rebase-remote dropdown when the Worktrees
|
||||
// section becomes visible. Failure is non-fatal: the dropdown falls back
|
||||
// to just "Use git default".
|
||||
@@ -1209,6 +1241,47 @@ export function SettingsModal({
|
||||
setPresetDraft(null);
|
||||
};
|
||||
|
||||
const handleSaveRemoteSettings = useCallback(async () => {
|
||||
const nextSettings: Partial<RemoteSettings> = {
|
||||
remoteEnabled: Boolean((form as Record<string, unknown>).remoteEnabled),
|
||||
remoteActiveProvider: ((form as Record<string, unknown>).remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null,
|
||||
remoteTailscaleEnabled: 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),
|
||||
remoteCloudflareTunnelName: String((form as Record<string, unknown>).remoteCloudflareTunnelName ?? ""),
|
||||
remoteCloudflareTunnelToken: (((form as Record<string, unknown>).remoteCloudflareTunnelToken as string | null) || null),
|
||||
remoteCloudflareIngressUrl: String((form as Record<string, unknown>).remoteCloudflareIngressUrl ?? ""),
|
||||
remoteShortLivedEnabled: Boolean((form as Record<string, unknown>).remoteShortLivedEnabled),
|
||||
remoteShortLivedTtlMs: Number((form as Record<string, unknown>).remoteShortLivedTtlMs ?? 900000),
|
||||
remoteRememberLastRunning: Boolean((form as Record<string, unknown>).remoteRememberLastRunning),
|
||||
};
|
||||
|
||||
setRemoteBusyAction("save");
|
||||
try {
|
||||
await updateRemoteSettings(nextSettings, projectId);
|
||||
addToast("Remote settings saved", "success");
|
||||
await loadRemoteData();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to save remote settings", "error");
|
||||
} finally {
|
||||
setRemoteBusyAction(null);
|
||||
}
|
||||
}, [form, projectId, loadRemoteData, addToast]);
|
||||
|
||||
const runRemoteAction = useCallback(async (label: string, action: () => Promise<void>) => {
|
||||
setRemoteBusyAction(label);
|
||||
try {
|
||||
await action();
|
||||
await loadRemoteData();
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || `Failed to ${label}`, "error");
|
||||
} finally {
|
||||
setRemoteBusyAction(null);
|
||||
}
|
||||
}, [addToast, loadRemoteData]);
|
||||
|
||||
/** Render a scope indicator banner for the current section with theme-aware Lucide icons */
|
||||
const renderScopeBanner = () => {
|
||||
if (activeSectionScope === "global") {
|
||||
@@ -3327,6 +3400,264 @@ export function SettingsModal({
|
||||
)}
|
||||
</>
|
||||
);
|
||||
case "remote": {
|
||||
const remoteForm = form as Record<string, unknown>;
|
||||
const activeProvider = (remoteForm.remoteActiveProvider as "tailscale" | "cloudflare" | null) ?? null;
|
||||
const tunnelState = remoteStatus?.state ?? "stopped";
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderScopeBanner()}
|
||||
<h4 className="settings-section-heading">Remote Access</h4>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="remoteEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="remoteEnabled"
|
||||
type="checkbox"
|
||||
checked={Boolean(remoteForm.remoteEnabled)}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteEnabled: e.target.checked } as SettingsFormState))}
|
||||
/>
|
||||
Enable remote access controls
|
||||
</label>
|
||||
<small>Configure provider settings, tunnel lifecycle, and tokenized remote URLs.</small>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="remoteActiveProvider">Active provider</label>
|
||||
<select
|
||||
id="remoteActiveProvider"
|
||||
className="select"
|
||||
value={activeProvider ?? ""}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteActiveProvider: (e.target.value || null) as "tailscale" | "cloudflare" | null } as SettingsFormState))}
|
||||
>
|
||||
<option value="">Not selected</option>
|
||||
<option value="tailscale">Tailscale Serve</option>
|
||||
<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"
|
||||
disabled={remoteBusyAction !== null}
|
||||
onClick={() => void handleSaveRemoteSettings()}
|
||||
>
|
||||
{remoteBusyAction === "save" ? "Saving…" : "Save Remote Settings"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Tailscale</label>
|
||||
<label htmlFor="remoteTailscaleEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="remoteTailscaleEnabled"
|
||||
type="checkbox"
|
||||
checked={Boolean(remoteForm.remoteTailscaleEnabled)}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleEnabled: e.target.checked } as SettingsFormState))}
|
||||
/>
|
||||
Enable Tailscale provider config
|
||||
</label>
|
||||
<label htmlFor="remoteTailscaleHostname">Hostname label</label>
|
||||
<input
|
||||
id="remoteTailscaleHostname"
|
||||
type="text"
|
||||
placeholder="tailnet label (optional)"
|
||||
value={String(remoteForm.remoteTailscaleHostname ?? "")}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleHostname: e.target.value } as SettingsFormState))}
|
||||
/>
|
||||
<label htmlFor="remoteTailscaleTargetPort">Target port</label>
|
||||
<input
|
||||
id="remoteTailscaleTargetPort"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
value={Number(remoteForm.remoteTailscaleTargetPort ?? 4040)}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteTailscaleTargetPort: Number(e.target.value || 4040) } as SettingsFormState))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Cloudflare</label>
|
||||
<label htmlFor="remoteCloudflareEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="remoteCloudflareEnabled"
|
||||
type="checkbox"
|
||||
checked={Boolean(remoteForm.remoteCloudflareEnabled)}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareEnabled: e.target.checked } as SettingsFormState))}
|
||||
/>
|
||||
Enable Cloudflare provider config
|
||||
</label>
|
||||
<label htmlFor="remoteCloudflareTunnelName">Tunnel name</label>
|
||||
<input
|
||||
id="remoteCloudflareTunnelName"
|
||||
type="text"
|
||||
placeholder="Tunnel name"
|
||||
value={String(remoteForm.remoteCloudflareTunnelName ?? "")}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelName: e.target.value } as SettingsFormState))}
|
||||
/>
|
||||
<label htmlFor="remoteCloudflareTunnelToken">Tunnel token</label>
|
||||
<input
|
||||
id="remoteCloudflareTunnelToken"
|
||||
type="password"
|
||||
placeholder="Tunnel token"
|
||||
value={String(remoteForm.remoteCloudflareTunnelToken ?? "")}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareTunnelToken: e.target.value } as SettingsFormState))}
|
||||
/>
|
||||
<label htmlFor="remoteCloudflareIngressUrl">Ingress URL</label>
|
||||
<input
|
||||
id="remoteCloudflareIngressUrl"
|
||||
type="text"
|
||||
placeholder="https://your-domain.example"
|
||||
value={String(remoteForm.remoteCloudflareIngressUrl ?? "")}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteCloudflareIngressUrl: e.target.value } as SettingsFormState))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Tunnel lifecycle</label>
|
||||
<small>State: <strong>{tunnelState}</strong>{remoteStatus?.provider ? ` · Provider: ${remoteStatus.provider}` : ""}</small>
|
||||
{remoteStatus?.url && <small>URL: <code>{remoteStatus.url}</code></small>}
|
||||
{remoteStatus?.lastError && <small className="field-error">{remoteStatus.lastError}</small>}
|
||||
<div className="settings-button-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-primary"
|
||||
disabled={remoteBusyAction !== null}
|
||||
onClick={() => void runRemoteAction("start tunnel", async () => {
|
||||
await startRemoteTunnel(projectId);
|
||||
addToast("Remote tunnel start requested", "success");
|
||||
})}
|
||||
>
|
||||
Start tunnel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={remoteBusyAction !== null}
|
||||
onClick={() => void runRemoteAction("stop tunnel", async () => {
|
||||
await stopRemoteTunnel(projectId);
|
||||
addToast("Remote tunnel stopped", "success");
|
||||
})}
|
||||
>
|
||||
Stop tunnel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>Auth links</label>
|
||||
<div className="settings-button-row">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={remoteBusyAction !== null}
|
||||
onClick={() => void runRemoteAction("regenerate persistent token", async () => {
|
||||
await regenerateRemotePersistentToken(projectId);
|
||||
addToast("Persistent token regenerated", "success");
|
||||
})}
|
||||
>
|
||||
Regenerate persistent token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={remoteBusyAction !== null}
|
||||
onClick={() => void runRemoteAction("generate short-lived token", async () => {
|
||||
const ttlMs = Number(remoteForm.remoteShortLivedTtlMs ?? 900000);
|
||||
const generated = await generateShortLivedRemoteToken(ttlMs, projectId);
|
||||
setRemoteShortLivedToken(generated);
|
||||
addToast("Short-lived token generated", "success");
|
||||
})}
|
||||
>
|
||||
Generate short-lived token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={remoteBusyAction !== null}
|
||||
onClick={() => void runRemoteAction("fetch remote url", async () => {
|
||||
const nextUrl = await fetchRemoteUrl(projectId);
|
||||
setRemoteUrlPreview(nextUrl);
|
||||
})}
|
||||
>
|
||||
Show URL
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
disabled={remoteBusyAction !== null}
|
||||
onClick={() => void runRemoteAction("generate QR", async () => {
|
||||
const qr = await fetchRemoteQr("image/svg", projectId);
|
||||
setRemoteUrlPreview({ url: qr.url, expiresAt: qr.expiresAt });
|
||||
setRemoteQrSvg(qr.data ?? null);
|
||||
})}
|
||||
>
|
||||
Generate QR
|
||||
</button>
|
||||
</div>
|
||||
<label htmlFor="remoteShortLivedEnabled" className="checkbox-label">
|
||||
<input
|
||||
id="remoteShortLivedEnabled"
|
||||
type="checkbox"
|
||||
checked={Boolean(remoteForm.remoteShortLivedEnabled)}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteShortLivedEnabled: e.target.checked } as SettingsFormState))}
|
||||
/>
|
||||
Enable short-lived tokens
|
||||
</label>
|
||||
<label htmlFor="remoteShortLivedTtlMs">Short-lived TTL (ms)</label>
|
||||
<input
|
||||
id="remoteShortLivedTtlMs"
|
||||
type="number"
|
||||
min={60000}
|
||||
max={86400000}
|
||||
value={Number(remoteForm.remoteShortLivedTtlMs ?? 900000)}
|
||||
onChange={(e) => setForm((f) => ({ ...f, remoteShortLivedTtlMs: Number(e.target.value || 900000) } as SettingsFormState))}
|
||||
/>
|
||||
{remoteShortLivedToken && (
|
||||
<small>
|
||||
Last short-lived token expires at {new Date(remoteShortLivedToken.expiresAt).toLocaleString()} ({remoteShortLivedToken.ttlMs}ms)
|
||||
</small>
|
||||
)}
|
||||
{remoteUrlPreview?.url && (
|
||||
<small>
|
||||
Authenticated URL: <code>{remoteUrlPreview.url}</code>
|
||||
</small>
|
||||
)}
|
||||
{remoteQrSvg && (
|
||||
<div className="settings-qr-preview" aria-live="polite">
|
||||
<p className="settings-qr-preview-label">Scan this QR code on your phone</p>
|
||||
<div className="settings-qr-preview-image-wrap">
|
||||
<img
|
||||
src={`data:image/svg+xml;utf8,${encodeURIComponent(remoteQrSvg)}`}
|
||||
alt="Remote access QR code"
|
||||
className="settings-qr-preview-image"
|
||||
/>
|
||||
</div>
|
||||
<details>
|
||||
<summary>QR SVG markup</summary>
|
||||
<pre className="settings-raw-output">{remoteQrSvg}</pre>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case "prompts":
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -29,6 +29,16 @@ const mockTestMemoryRetrieval = vi.fn();
|
||||
const mockInstallQmd = vi.fn();
|
||||
const mockFetchGitRemotesDetailed = vi.fn();
|
||||
const mockFetchDashboardHealth = 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();
|
||||
const mockGenerateShortLivedRemoteToken = vi.fn();
|
||||
const mockFetchRemoteQr = vi.fn();
|
||||
const mockFetchRemoteUrl = vi.fn();
|
||||
const mockUseWorkspaceFileBrowser = vi.fn();
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
@@ -56,6 +66,16 @@ vi.mock("../../api", () => ({
|
||||
installQmd: (...args: unknown[]) => mockInstallQmd(...args),
|
||||
fetchGitRemotesDetailed: (...args: unknown[]) => mockFetchGitRemotesDetailed(...args),
|
||||
fetchDashboardHealth: (...args: unknown[]) => mockFetchDashboardHealth(...args),
|
||||
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),
|
||||
generateShortLivedRemoteToken: (...args: unknown[]) => mockGenerateShortLivedRemoteToken(...args),
|
||||
fetchRemoteQr: (...args: unknown[]) => mockFetchRemoteQr(...args),
|
||||
fetchRemoteUrl: (...args: unknown[]) => mockFetchRemoteUrl(...args),
|
||||
}));
|
||||
|
||||
// Mock the hook
|
||||
@@ -178,6 +198,56 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
mockFetchGitRemotesDetailed.mockResolvedValue([]);
|
||||
mockFetchDashboardHealth.mockResolvedValue({ status: "ok", version: "1.2.3", uptime: 123 });
|
||||
mockFetchRemoteSettings.mockResolvedValue({
|
||||
settings: {
|
||||
remoteEnabled: false,
|
||||
remoteActiveProvider: null,
|
||||
remoteTailscaleEnabled: false,
|
||||
remoteTailscaleHostname: "",
|
||||
remoteTailscaleTargetPort: 4040,
|
||||
remoteTailscaleAcceptRoutes: false,
|
||||
remoteCloudflareEnabled: false,
|
||||
remoteCloudflareTunnelName: "",
|
||||
remoteCloudflareTunnelToken: null,
|
||||
remoteCloudflareIngressUrl: "",
|
||||
remotePersistentToken: null,
|
||||
remoteShortLivedEnabled: false,
|
||||
remoteShortLivedTtlMs: 900000,
|
||||
remoteShortLivedMaxTtlMs: 86400000,
|
||||
remoteRememberLastRunning: false,
|
||||
remoteWasRunningOnShutdown: false,
|
||||
remoteLastStartedProvider: null,
|
||||
},
|
||||
});
|
||||
mockUpdateRemoteSettings.mockResolvedValue({
|
||||
settings: {
|
||||
remoteEnabled: false,
|
||||
remoteActiveProvider: null,
|
||||
remoteTailscaleEnabled: false,
|
||||
remoteTailscaleHostname: "",
|
||||
remoteTailscaleTargetPort: 4040,
|
||||
remoteTailscaleAcceptRoutes: false,
|
||||
remoteCloudflareEnabled: false,
|
||||
remoteCloudflareTunnelName: "",
|
||||
remoteCloudflareTunnelToken: null,
|
||||
remoteCloudflareIngressUrl: "",
|
||||
remotePersistentToken: null,
|
||||
remoteShortLivedEnabled: false,
|
||||
remoteShortLivedTtlMs: 900000,
|
||||
remoteShortLivedMaxTtlMs: 86400000,
|
||||
remoteRememberLastRunning: false,
|
||||
remoteWasRunningOnShutdown: false,
|
||||
remoteLastStartedProvider: null,
|
||||
},
|
||||
});
|
||||
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: "****" });
|
||||
mockGenerateShortLivedRemoteToken.mockResolvedValue({ token: "short", expiresAt: new Date(Date.now() + 60000).toISOString(), ttlMs: 60000 });
|
||||
mockFetchRemoteQr.mockResolvedValue({ url: "https://remote.example.com", expiresAt: null, format: "image/svg", data: "<svg></svg>" });
|
||||
mockFetchRemoteUrl.mockResolvedValue({ url: "https://remote.example.com", tokenType: "persistent", expiresAt: null });
|
||||
mockUseWorkspaceFileBrowser.mockReturnValue({
|
||||
entries: [],
|
||||
currentPath: ".",
|
||||
@@ -1227,5 +1297,30 @@ describe("SettingsModal", () => {
|
||||
const payload = mockUpdateSettings.mock.calls[0][0];
|
||||
expect(payload.experimentalFeatures).toEqual({ "feature-a": true, "feature-b": true });
|
||||
});
|
||||
|
||||
it("renders remote access section and tunnel controls", async () => {
|
||||
renderModal();
|
||||
await waitForSettingsModalReady();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Remote Access/ }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Remote Access" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Start tunnel" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Generate QR" })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Hostname label")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Target port")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Tunnel name")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Tunnel token")).toBeInTheDocument();
|
||||
expect(screen.getByLabelText("Ingress URL")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Show URL" }));
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRemoteUrl).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: "Generate QR" }));
|
||||
expect(await screen.findByRole("img", { name: "Remote access QR code" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Scan this QR code on your phone")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user