diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index b7e7d1aa..211834a0 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -1459,6 +1459,90 @@ router.put( * 500: * description: Failed to fetch SSH data. */ +/** + * @openapi + * /host/db/host/{id}/terminal-config: + * patch: + * summary: Update a host's terminal behaviour flags + * description: Merges the given flags into the host's terminalConfig. Owner or a recipient with edit access. Currently supports autoTmux; used by the "enable Auto-Tmux" action shown when a persisted session expires. + * tags: + * - Hosts + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * autoTmux: + * type: boolean + * responses: + * 200: + * description: Updated. + * 403: + * description: No edit access. + * 404: + * description: Host not found. + */ +router.patch( + "/db/host/:id/terminal-config", + authenticateJWT, + permissionManager.requirePermission("hosts.edit"), + requireDataAccess, + async (req: Request, res: Response) => { + const userId = (req as AuthenticatedRequest).userId!; + const hostId = parseInt(String(req.params.id), 10); + const { autoTmux } = req.body ?? {}; + + if (isNaN(hostId)) { + return res.status(400).json({ error: "Invalid host ID" }); + } + if (typeof autoTmux !== "boolean") { + return res.status(400).json({ error: "autoTmux must be a boolean" }); + } + + try { + const access = await permissionManager.canAccessHost( + userId, + hostId, + "edit", + ); + if (!access.hasAccess) { + return res.status(403).json({ error: "Access denied to host" }); + } + const ownerId = + await createCurrentHostResolutionRepository().findHostOwnerId(hostId); + const host = ownerId + ? await createCurrentHostRepository().findByIdForUser(ownerId, hostId) + : null; + if (!host || !ownerId) { + return res.status(404).json({ error: "Host not found" }); + } + + let terminalConfig: Record = {}; + if (host.terminalConfig) { + try { + terminalConfig = JSON.parse(host.terminalConfig); + } catch { + terminalConfig = {}; + } + } + await createCurrentHostRepository().updateForUser(ownerId, hostId, { + terminalConfig: JSON.stringify({ ...terminalConfig, autoTmux }), + }); + + res.json({ success: true, autoTmux }); + } catch (error) { + sshLogger.error("Failed to update host terminal config", error, { + operation: "host_terminal_config_update", + hostId, + userId, + }); + res.status(500).json({ error: "Failed to update terminal config" }); + } + }, +); + router.get( "/db/host", authenticateJWT, diff --git a/src/backend/database/routes/user-settings-routes.ts b/src/backend/database/routes/user-settings-routes.ts index 40df928b..b840cba3 100644 --- a/src/backend/database/routes/user-settings-routes.ts +++ b/src/backend/database/routes/user-settings-routes.ts @@ -44,6 +44,7 @@ export type HostDefaults = { cursorBlink?: boolean; enableSessionLogging?: boolean; enableCommandHistory?: boolean; + autoTmux?: boolean; }; async function getAdminActor( diff --git a/src/ui/api/host-terminal-config-api.ts b/src/ui/api/host-terminal-config-api.ts new file mode 100644 index 00000000..ac6d64a4 --- /dev/null +++ b/src/ui/api/host-terminal-config-api.ts @@ -0,0 +1,15 @@ +import { authApi, handleApiError } from "@/main-axios"; + +/** Flips Auto-Tmux for one host without round-tripping the whole editor form. */ +export async function setHostAutoTmux( + hostId: number, + autoTmux: boolean, +): Promise { + try { + await authApi.patch(`/host/db/host/${hostId}/terminal-config`, { + autoTmux, + }); + } catch (error) { + throw handleApiError(error, "update host auto-tmux"); + } +} diff --git a/src/ui/api/settings-api.ts b/src/ui/api/settings-api.ts index 96c05403..a11824c2 100644 --- a/src/ui/api/settings-api.ts +++ b/src/ui/api/settings-api.ts @@ -61,6 +61,30 @@ export async function getSessionTimeout(): Promise<{ timeoutHours: number }> { } } +// How long a detached terminal session is kept alive server-side. +export async function getTerminalSessionSettings(): Promise<{ + timeoutMinutes: number; + enabled: boolean; +}> { + try { + const response = await authApi.get("/terminal/session_settings"); + return response.data; + } catch (error) { + handleApiError(error, "fetch terminal session settings"); + } +} + +export async function updateTerminalSessionSettings(input: { + timeoutMinutes?: number; + enabled?: boolean; +}): Promise { + try { + await authApi.patch("/terminal/session_settings", input); + } catch (error) { + handleApiError(error, "update terminal session settings"); + } +} + export async function updateSessionTimeout( timeoutHours: number, ): Promise { @@ -288,6 +312,7 @@ export type HostDefaults = { cursorBlink?: boolean; enableSessionLogging?: boolean; enableCommandHistory?: boolean; + autoTmux?: boolean; }; export async function getHostDefaults(): Promise { diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index 9ae60483..6a62f2e7 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -1182,6 +1182,56 @@ const TerminalInner = forwardRef( }, delay); } + // A persisted session that timed out reconnects to a fresh shell below. + // Say so, and offer the setting that would have kept it alive. + async function explainSessionExpiry() { + const hostLabel = hostConfig.name || hostConfig.ip; + let minutes: number | null = null; + try { + const { getTerminalSessionSettings } = + await import("@/api/settings-api"); + minutes = (await getTerminalSessionSettings()).timeoutMinutes; + } catch { + /* the notice still makes sense without the number */ + } + const notice = minutes + ? t("terminal.sessionExpiredNotice", { host: hostLabel, minutes }) + : t("terminal.sessionExpiredNoticeNoMinutes", { host: hostLabel }); + addLog({ type: "warning", stage: "connection", message: notice }); + + const canEnable = + typeof hostConfig.id === "number" && + !hostConfig.terminalConfig?.autoTmux && + !hostConfig.joinShareId; + toast.warning(notice, { + duration: 15000, + ...(canEnable + ? { + action: { + label: t("terminal.enableAutoTmuxAction"), + onClick: () => { + void import("@/api/host-terminal-config-api") + .then(({ setHostAutoTmux }) => + setHostAutoTmux(hostConfig.id as number, true), + ) + .then(() => { + window.dispatchEvent( + new CustomEvent("termix:hosts-changed"), + ); + toast.success( + t("terminal.autoTmuxEnabled", { host: hostLabel }), + ); + }) + .catch(() => + toast.error(t("terminal.autoTmuxEnableFailed")), + ); + }, + }, + } + : {}), + }); + } + async function connectToHost(cols: number, rows: number) { if (isConnectingRef.current) { return; @@ -1979,6 +2029,7 @@ const TerminalInner = forwardRef( isAttachingSessionRef.current = false; sessionIdRef.current = null; wasSessionExpiredRef.current = true; + void explainSessionExpiry(); if (hostConfig.instanceId) { import("@/main-axios").then(({ patchOpenTab }) => { patchOpenTab(hostConfig.instanceId!, { diff --git a/src/ui/locales/en.json b/src/ui/locales/en.json index 64d3017c..0ccea2ce 100644 --- a/src/ui/locales/en.json +++ b/src/ui/locales/en.json @@ -1027,7 +1027,7 @@ "enableAutoMosh": "Enable Auto-Mosh", "enableAutoMoshDesc": "Prefer Mosh over SSH if available", "enableAutoTmux": "Enable Auto-Tmux", - "enableAutoTmuxDesc": "Automatically launch or attach to tmux session", + "enableAutoTmuxDesc": "Keep your shell and anything running in it alive across disconnects and browser restarts by running inside tmux (tmux must be installed on the host)", "enableSessionLogging": "Session Logging", "enableSessionLoggingDesc": "Record terminal session output for later review", "allowSessionSharing": "Allow Session Sharing", @@ -1796,7 +1796,7 @@ "reopenTab": "Reopen", "sectionOpen": "Open", "sectionBackground": "Background", - "backgroundDesc": "Sessions persist for 30 minutes after disconnect and can be reconnected.", + "backgroundDesc": "Sessions persist for {{minutes}} minutes after disconnect and can be reconnected.", "persisted": "Persisted in background", "expiresIn": "Expires in {{duration}}", "search": "Search connections...", @@ -1989,6 +1989,11 @@ } }, "terminal": { + "sessionExpiredNotice": "Your previous session on {{host}} expired after {{minutes}} minutes disconnected; the shell and anything running in it are gone. Enable Auto-Tmux on this host to keep sessions alive.", + "sessionExpiredNoticeNoMinutes": "Your previous session on {{host}} expired while disconnected; the shell and anything running in it are gone. Enable Auto-Tmux on this host to keep sessions alive.", + "enableAutoTmuxAction": "Enable Auto-Tmux", + "autoTmuxEnabled": "Auto-Tmux enabled for {{host}}. It applies from the next connection.", + "autoTmuxEnableFailed": "Failed to enable Auto-Tmux", "connect": "Connect to Host", "clear": "Clear", "paste": "Paste", @@ -3645,6 +3650,14 @@ "hostDefaultsStatusCheckEnabled": "Enable Status Check", "hostDefaultsStatusCheckEnabledDesc": "Poll online/offline status on new hosts by default", "hostDefaultsTerminal": "Terminal", + "hostDefaultsAutoTmux": "Auto-Tmux", + "hostDefaultsAutoTmuxDesc": "Run new hosts' terminals inside tmux so sessions survive disconnects (needs tmux on the host)", + "terminalSessionTimeout": "Terminal Session Persistence", + "terminalSessionTimeoutDesc": "How long a disconnected terminal is kept alive on the server before it is closed. Min 1 ยท Max 1440 minutes. For sessions that must outlive this, enable Auto-Tmux on the host.", + "terminalSessionTimeoutRange": "Enter a value between 1 and 1440 minutes", + "terminalSessionTimeoutSaved": "Terminal session persistence saved", + "terminalSessionTimeoutSaveFailed": "Failed to save terminal session persistence", + "minutes": "minutes", "hostDefaultsSessionLogging": "Session Logging", "hostDefaultsSessionLoggingDesc": "Record terminal sessions on new hosts by default", "hostDefaultsCommandHistory": "Command History", diff --git a/src/ui/sidebar/AdminSettingsPanel.tsx b/src/ui/sidebar/AdminSettingsPanel.tsx index 06d5c843..9ece3214 100644 --- a/src/ui/sidebar/AdminSettingsPanel.tsx +++ b/src/ui/sidebar/AdminSettingsPanel.tsx @@ -94,6 +94,10 @@ import { type AdminUser, } from "./AdminManagementSections"; import { toast } from "sonner"; +import { + getTerminalSessionSettings, + updateTerminalSessionSettings, +} from "@/api/settings-api"; import { getDatabaseTransferUrl } from "@/lib/database-transfer-url"; import { AdminDatabaseSection, @@ -154,6 +158,12 @@ export function AdminSettingsPanel({ const [allowPasswordLogin, setAllowPasswordLogin] = useState(true); const [allowPasswordReset, setAllowPasswordReset] = useState(true); const [sessionTimeout, setSessionTimeout] = useState("24"); + const [terminalTimeout, setTerminalTimeout] = useState("30"); + useEffect(() => { + getTerminalSessionSettings() + .then((settings) => setTerminalTimeout(String(settings.timeoutMinutes))) + .catch(() => {}); + }, []); const [statusInterval, setStatusInterval] = useState("60"); const [metricsInterval, setMetricsInterval] = useState("30"); const [metricsHistoryRetention, setMetricsHistoryRetention] = useState("7"); @@ -689,6 +699,20 @@ export function AdminSettingsPanel({ } } + async function handleSaveTerminalTimeout() { + const minutes = parseInt(terminalTimeout, 10); + if (isNaN(minutes) || minutes < 1 || minutes > 1440) { + toast.error(t("admin.terminalSessionTimeoutRange")); + return; + } + try { + await updateTerminalSessionSettings({ timeoutMinutes: minutes }); + toast.success(t("admin.terminalSessionTimeoutSaved")); + } catch { + toast.error(t("admin.terminalSessionTimeoutSaveFailed")); + } + } + async function handleSaveMonitoring() { const status = parseInt(statusInterval, 10); const metrics = parseInt(metricsInterval, 10); @@ -1164,6 +1188,9 @@ export function AdminSettingsPanel({ sessionTimeout={sessionTimeout} setSessionTimeout={setSessionTimeout} handleSaveSessionTimeout={handleSaveSessionTimeout} + terminalTimeout={terminalTimeout} + setTerminalTimeout={setTerminalTimeout} + handleSaveTerminalTimeout={handleSaveTerminalTimeout} statusInterval={statusInterval} setStatusInterval={setStatusInterval} metricsInterval={metricsInterval} diff --git a/src/ui/sidebar/AdminSettingsSections.tsx b/src/ui/sidebar/AdminSettingsSections.tsx index d4ec6e08..38dab945 100644 --- a/src/ui/sidebar/AdminSettingsSections.tsx +++ b/src/ui/sidebar/AdminSettingsSections.tsx @@ -50,6 +50,9 @@ type GeneralSettingsSectionProps = { sessionTimeout: string; setSessionTimeout: Dispatch>; handleSaveSessionTimeout: () => void; + terminalTimeout: string; + setTerminalTimeout: Dispatch>; + handleSaveTerminalTimeout: () => void; statusInterval: string; setStatusInterval: Dispatch>; metricsInterval: string; @@ -101,6 +104,9 @@ export function AdminGeneralSettingsSection({ sessionTimeout, setSessionTimeout, handleSaveSessionTimeout, + terminalTimeout, + setTerminalTimeout, + handleSaveTerminalTimeout, statusInterval, setStatusInterval, metricsInterval, @@ -299,6 +305,36 @@ export function AdminGeneralSettingsSection({ +
+ + {t("admin.terminalSessionTimeout")} + +
+ setTerminalTimeout(e.target.value)} + className="w-20 text-sm" + /> + + {t("admin.minutes")} + + +
+ + {t("admin.terminalSessionTimeoutDesc")} + +
+
{t("admin.monitoringDefaults")} @@ -1096,6 +1132,17 @@ export function AdminHostDefaultsSection({ {t("admin.hostDefaultsTerminal")} + + + setDefaults((p) => ({ ...p, autoTmux: !(p.autoTmux ?? false) })) + } + /> + { + import("@/api/settings-api") + .then(({ getTerminalSessionSettings }) => getTerminalSessionSettings()) + .then((settings) => setPersistMinutes(settings.timeoutMinutes)) + .catch(() => {}); + }, []); const [activeSessions, setActiveSessions] = useState([]); const activeSessionsRef = useRef(activeSessions); activeSessionsRef.current = activeSessions; @@ -589,7 +597,9 @@ export function ConnectionsPanel({ />
- {t("connections.backgroundDesc")} + {t("connections.backgroundDesc", { + minutes: persistMinutes, + })}
{filteredBackgroundTabs.map((record) => { diff --git a/src/ui/sidebar/HostEditor.tsx b/src/ui/sidebar/HostEditor.tsx index b0d1652d..7115afaf 100644 --- a/src/ui/sidebar/HostEditor.tsx +++ b/src/ui/sidebar/HostEditor.tsx @@ -1788,6 +1788,27 @@ export function HostEditor({ onValueChange={([v]) => setField("scrollback", v)} />
+ + {t("hosts.enableAutoTmuxDesc")}{" "} + + {t("hosts.docsLink")} + + + } + > + setField("autoTmux", v)} + /> + setField("autoMosh", v)} /> - - {t("hosts.enableAutoTmuxDesc")}{" "} - - {t("hosts.docsLink")} - - - } - > - setField("autoTmux", v)} - /> - { expect(createHostEditorForm(null).credentialId).toBe(""); }); }); + +describe("createHostEditorForm auto-tmux", () => { + it("inherits the admin default for a new host but keeps an existing host's own choice", () => { + expect(createHostEditorForm(null, { autoTmux: true }).autoTmux).toBe(true); + expect(createHostEditorForm(null, {}).autoTmux).toBe(false); + + const host = { + id: "1", + name: "box", + terminalConfig: { autoTmux: false }, + } as unknown as Host; + expect(createHostEditorForm(host, { autoTmux: true }).autoTmux).toBe(false); + }); +});