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
This commit is contained in:
ZacharyZcR
2026-08-25 02:15:23 +08:00
committed by GitHub
parent ae9cce4de3
commit 8d0bcb3b1f
12 changed files with 312 additions and 25 deletions
+84
View File
@@ -1459,6 +1459,90 @@ router.put(
* 500: * 500:
* description: Failed to fetch SSH data. * 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<string, unknown> = {};
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( router.get(
"/db/host", "/db/host",
authenticateJWT, authenticateJWT,
@@ -44,6 +44,7 @@ export type HostDefaults = {
cursorBlink?: boolean; cursorBlink?: boolean;
enableSessionLogging?: boolean; enableSessionLogging?: boolean;
enableCommandHistory?: boolean; enableCommandHistory?: boolean;
autoTmux?: boolean;
}; };
async function getAdminActor( async function getAdminActor(
+15
View File
@@ -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<void> {
try {
await authApi.patch(`/host/db/host/${hostId}/terminal-config`, {
autoTmux,
});
} catch (error) {
throw handleApiError(error, "update host auto-tmux");
}
}
+25
View File
@@ -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<void> {
try {
await authApi.patch("/terminal/session_settings", input);
} catch (error) {
handleApiError(error, "update terminal session settings");
}
}
export async function updateSessionTimeout( export async function updateSessionTimeout(
timeoutHours: number, timeoutHours: number,
): Promise<void> { ): Promise<void> {
@@ -288,6 +312,7 @@ export type HostDefaults = {
cursorBlink?: boolean; cursorBlink?: boolean;
enableSessionLogging?: boolean; enableSessionLogging?: boolean;
enableCommandHistory?: boolean; enableCommandHistory?: boolean;
autoTmux?: boolean;
}; };
export async function getHostDefaults(): Promise<HostDefaults> { export async function getHostDefaults(): Promise<HostDefaults> {
+51
View File
@@ -1182,6 +1182,56 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}, delay); }, 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) { async function connectToHost(cols: number, rows: number) {
if (isConnectingRef.current) { if (isConnectingRef.current) {
return; return;
@@ -1979,6 +2029,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
isAttachingSessionRef.current = false; isAttachingSessionRef.current = false;
sessionIdRef.current = null; sessionIdRef.current = null;
wasSessionExpiredRef.current = true; wasSessionExpiredRef.current = true;
void explainSessionExpiry();
if (hostConfig.instanceId) { if (hostConfig.instanceId) {
import("@/main-axios").then(({ patchOpenTab }) => { import("@/main-axios").then(({ patchOpenTab }) => {
patchOpenTab(hostConfig.instanceId!, { patchOpenTab(hostConfig.instanceId!, {
+15 -2
View File
@@ -1027,7 +1027,7 @@
"enableAutoMosh": "Enable Auto-Mosh", "enableAutoMosh": "Enable Auto-Mosh",
"enableAutoMoshDesc": "Prefer Mosh over SSH if available", "enableAutoMoshDesc": "Prefer Mosh over SSH if available",
"enableAutoTmux": "Enable Auto-Tmux", "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", "enableSessionLogging": "Session Logging",
"enableSessionLoggingDesc": "Record terminal session output for later review", "enableSessionLoggingDesc": "Record terminal session output for later review",
"allowSessionSharing": "Allow Session Sharing", "allowSessionSharing": "Allow Session Sharing",
@@ -1796,7 +1796,7 @@
"reopenTab": "Reopen", "reopenTab": "Reopen",
"sectionOpen": "Open", "sectionOpen": "Open",
"sectionBackground": "Background", "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", "persisted": "Persisted in background",
"expiresIn": "Expires in {{duration}}", "expiresIn": "Expires in {{duration}}",
"search": "Search connections...", "search": "Search connections...",
@@ -1989,6 +1989,11 @@
} }
}, },
"terminal": { "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", "connect": "Connect to Host",
"clear": "Clear", "clear": "Clear",
"paste": "Paste", "paste": "Paste",
@@ -3645,6 +3650,14 @@
"hostDefaultsStatusCheckEnabled": "Enable Status Check", "hostDefaultsStatusCheckEnabled": "Enable Status Check",
"hostDefaultsStatusCheckEnabledDesc": "Poll online/offline status on new hosts by default", "hostDefaultsStatusCheckEnabledDesc": "Poll online/offline status on new hosts by default",
"hostDefaultsTerminal": "Terminal", "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", "hostDefaultsSessionLogging": "Session Logging",
"hostDefaultsSessionLoggingDesc": "Record terminal sessions on new hosts by default", "hostDefaultsSessionLoggingDesc": "Record terminal sessions on new hosts by default",
"hostDefaultsCommandHistory": "Command History", "hostDefaultsCommandHistory": "Command History",
+27
View File
@@ -94,6 +94,10 @@ import {
type AdminUser, type AdminUser,
} from "./AdminManagementSections"; } from "./AdminManagementSections";
import { toast } from "sonner"; import { toast } from "sonner";
import {
getTerminalSessionSettings,
updateTerminalSessionSettings,
} from "@/api/settings-api";
import { getDatabaseTransferUrl } from "@/lib/database-transfer-url"; import { getDatabaseTransferUrl } from "@/lib/database-transfer-url";
import { import {
AdminDatabaseSection, AdminDatabaseSection,
@@ -154,6 +158,12 @@ export function AdminSettingsPanel({
const [allowPasswordLogin, setAllowPasswordLogin] = useState(true); const [allowPasswordLogin, setAllowPasswordLogin] = useState(true);
const [allowPasswordReset, setAllowPasswordReset] = useState(true); const [allowPasswordReset, setAllowPasswordReset] = useState(true);
const [sessionTimeout, setSessionTimeout] = useState("24"); 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 [statusInterval, setStatusInterval] = useState("60");
const [metricsInterval, setMetricsInterval] = useState("30"); const [metricsInterval, setMetricsInterval] = useState("30");
const [metricsHistoryRetention, setMetricsHistoryRetention] = useState("7"); 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() { async function handleSaveMonitoring() {
const status = parseInt(statusInterval, 10); const status = parseInt(statusInterval, 10);
const metrics = parseInt(metricsInterval, 10); const metrics = parseInt(metricsInterval, 10);
@@ -1164,6 +1188,9 @@ export function AdminSettingsPanel({
sessionTimeout={sessionTimeout} sessionTimeout={sessionTimeout}
setSessionTimeout={setSessionTimeout} setSessionTimeout={setSessionTimeout}
handleSaveSessionTimeout={handleSaveSessionTimeout} handleSaveSessionTimeout={handleSaveSessionTimeout}
terminalTimeout={terminalTimeout}
setTerminalTimeout={setTerminalTimeout}
handleSaveTerminalTimeout={handleSaveTerminalTimeout}
statusInterval={statusInterval} statusInterval={statusInterval}
setStatusInterval={setStatusInterval} setStatusInterval={setStatusInterval}
metricsInterval={metricsInterval} metricsInterval={metricsInterval}
+47
View File
@@ -50,6 +50,9 @@ type GeneralSettingsSectionProps = {
sessionTimeout: string; sessionTimeout: string;
setSessionTimeout: Dispatch<SetStateAction<string>>; setSessionTimeout: Dispatch<SetStateAction<string>>;
handleSaveSessionTimeout: () => void; handleSaveSessionTimeout: () => void;
terminalTimeout: string;
setTerminalTimeout: Dispatch<SetStateAction<string>>;
handleSaveTerminalTimeout: () => void;
statusInterval: string; statusInterval: string;
setStatusInterval: Dispatch<SetStateAction<string>>; setStatusInterval: Dispatch<SetStateAction<string>>;
metricsInterval: string; metricsInterval: string;
@@ -101,6 +104,9 @@ export function AdminGeneralSettingsSection({
sessionTimeout, sessionTimeout,
setSessionTimeout, setSessionTimeout,
handleSaveSessionTimeout, handleSaveSessionTimeout,
terminalTimeout,
setTerminalTimeout,
handleSaveTerminalTimeout,
statusInterval, statusInterval,
setStatusInterval, setStatusInterval,
metricsInterval, metricsInterval,
@@ -299,6 +305,36 @@ export function AdminGeneralSettingsSection({
</span> </span>
</div> </div>
<div className="flex flex-col gap-2 pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.terminalSessionTimeout")}
</span>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={1440}
value={terminalTimeout}
onChange={(e) => setTerminalTimeout(e.target.value)}
className="w-20 text-sm"
/>
<span className="text-xs text-muted-foreground">
{t("admin.minutes")}
</span>
<Button
variant="outline"
size="sm"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand h-7"
onClick={handleSaveTerminalTimeout}
>
{t("common.save")}
</Button>
</div>
<span className="text-[10px] text-muted-foreground">
{t("admin.terminalSessionTimeoutDesc")}
</span>
</div>
<div className="flex flex-col gap-2 border-t border-border pt-3 mt-2"> <div className="flex flex-col gap-2 border-t border-border pt-3 mt-2">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.monitoringDefaults")} {t("admin.monitoringDefaults")}
@@ -1096,6 +1132,17 @@ export function AdminHostDefaultsSection({
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground"> <span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.hostDefaultsTerminal")} {t("admin.hostDefaultsTerminal")}
</span> </span>
<SettingRow
label={t("admin.hostDefaultsAutoTmux")}
description={t("admin.hostDefaultsAutoTmuxDesc")}
>
<AdminToggle
on={defaults.autoTmux ?? false}
onToggle={() =>
setDefaults((p) => ({ ...p, autoTmux: !(p.autoTmux ?? false) }))
}
/>
</SettingRow>
<SettingRow <SettingRow
label={t("admin.hostDefaultsSessionLogging")} label={t("admin.hostDefaultsSessionLogging")}
description={t("admin.hostDefaultsSessionLoggingDesc")} description={t("admin.hostDefaultsSessionLoggingDesc")}
+11 -1
View File
@@ -314,6 +314,14 @@ export function ConnectionsPanel({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [now, setNow] = useState(Date.now()); const [now, setNow] = useState(Date.now());
const [persistMinutes, setPersistMinutes] = useState(30);
useEffect(() => {
import("@/api/settings-api")
.then(({ getTerminalSessionSettings }) => getTerminalSessionSettings())
.then((settings) => setPersistMinutes(settings.timeoutMinutes))
.catch(() => {});
}, []);
const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]); const [activeSessions, setActiveSessions] = useState<ActiveSessionInfo[]>([]);
const activeSessionsRef = useRef(activeSessions); const activeSessionsRef = useRef(activeSessions);
activeSessionsRef.current = activeSessions; activeSessionsRef.current = activeSessions;
@@ -589,7 +597,9 @@ export function ConnectionsPanel({
/> />
<div className="px-3 py-1.5 border-b border-border/40"> <div className="px-3 py-1.5 border-b border-border/40">
<span className="text-[10px] text-muted-foreground/50"> <span className="text-[10px] text-muted-foreground/50">
{t("connections.backgroundDesc")} {t("connections.backgroundDesc", {
minutes: persistMinutes,
})}
</span> </span>
</div> </div>
{filteredBackgroundTabs.map((record) => { {filteredBackgroundTabs.map((record) => {
+21 -21
View File
@@ -1788,6 +1788,27 @@ export function HostEditor({
onValueChange={([v]) => setField("scrollback", v)} onValueChange={([v]) => setField("scrollback", v)}
/> />
</div> </div>
<SettingRow
label={t("hosts.enableAutoTmux")}
description={
<>
{t("hosts.enableAutoTmuxDesc")}{" "}
<a
href="https://docs.termix.site/features/terminal/tmux"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch
checked={form.autoTmux}
onChange={(v) => setField("autoTmux", v)}
/>
</SettingRow>
<SettingRow <SettingRow
label={t("hosts.sshAgentForwardingLabel")} label={t("hosts.sshAgentForwardingLabel")}
description={t("hosts.sshAgentForwardingShortDesc")} description={t("hosts.sshAgentForwardingShortDesc")}
@@ -1815,27 +1836,6 @@ export function HostEditor({
onChange={(v) => setField("autoMosh", v)} onChange={(v) => setField("autoMosh", v)}
/> />
</SettingRow> </SettingRow>
<SettingRow
label={t("hosts.enableAutoTmux")}
description={
<>
{t("hosts.enableAutoTmuxDesc")}{" "}
<a
href="https://docs.termix.site/features/terminal/tmux"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("hosts.docsLink")}
</a>
</>
}
>
<FakeSwitch
checked={form.autoTmux}
onChange={(v) => setField("autoTmux", v)}
/>
</SettingRow>
<SettingRow <SettingRow
label={t("hosts.enableSessionLogging")} label={t("hosts.enableSessionLogging")}
description={ description={
+1 -1
View File
@@ -232,7 +232,7 @@ export function createHostEditorForm(
moshCommand: host?.terminalConfig?.moshCommand ?? "", moshCommand: host?.terminalConfig?.moshCommand ?? "",
agentForwarding: host?.terminalConfig?.agentForwarding ?? false, agentForwarding: host?.terminalConfig?.agentForwarding ?? false,
autoMosh: host?.terminalConfig?.autoMosh ?? false, autoMosh: host?.terminalConfig?.autoMosh ?? false,
autoTmux: host?.terminalConfig?.autoTmux ?? false, autoTmux: host?.terminalConfig?.autoTmux ?? d?.autoTmux ?? false,
sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false, sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false,
sudoPassword: host?.hasSudoPassword sudoPassword: host?.hasSudoPassword
? "existing_sudo_password" ? "existing_sudo_password"
@@ -627,3 +627,17 @@ describe("createHostEditorForm credentialId", () => {
expect(createHostEditorForm(null).credentialId).toBe(""); 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);
});
});