From 8d0bcb3b1f519ed542c6abf781259567100d7f2c Mon Sep 17 00:00:00 2001 From: ZacharyZcR Date: Tue, 25 Aug 2026 02:15:23 +0800 Subject: [PATCH] fix: guide users to Auto-Tmux when a persisted session expires (#1336) * fix: guide users to Auto-Tmux when a persisted session expires A timed-out terminal session silently reconnected to a fresh shell, so people running long jobs lost them with no explanation and never learned about Auto-Tmux. Explain the expiry with a one-click Enable Auto-Tmux action, let admins default it for new hosts and tune the persistence timeout from the UI, and move the setting up with copy that says what it does. The global default stays off. * style: format terminal expiry notice --- src/backend/database/routes/host.ts | 84 +++++++++++++++++++ .../database/routes/user-settings-routes.ts | 1 + src/ui/api/host-terminal-config-api.ts | 15 ++++ src/ui/api/settings-api.ts | 25 ++++++ src/ui/features/terminal/Terminal.tsx | 51 +++++++++++ src/ui/locales/en.json | 17 +++- src/ui/sidebar/AdminSettingsPanel.tsx | 27 ++++++ src/ui/sidebar/AdminSettingsSections.tsx | 47 +++++++++++ src/ui/sidebar/ConnectionsPanel.tsx | 12 ++- src/ui/sidebar/HostEditor.tsx | 42 +++++----- src/ui/sidebar/HostEditorData.ts | 2 +- src/ui/tests/sidebar/HostEditorData.test.ts | 14 ++++ 12 files changed, 312 insertions(+), 25 deletions(-) create mode 100644 src/ui/api/host-terminal-config-api.ts 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); + }); +});