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
+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(
timeoutHours: number,
): Promise<void> {
@@ -288,6 +312,7 @@ export type HostDefaults = {
cursorBlink?: boolean;
enableSessionLogging?: boolean;
enableCommandHistory?: boolean;
autoTmux?: boolean;
};
export async function getHostDefaults(): Promise<HostDefaults> {
+51
View File
@@ -1182,6 +1182,56 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
}, 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<TerminalHandle, SSHTerminalProps>(
isAttachingSessionRef.current = false;
sessionIdRef.current = null;
wasSessionExpiredRef.current = true;
void explainSessionExpiry();
if (hostConfig.instanceId) {
import("@/main-axios").then(({ patchOpenTab }) => {
patchOpenTab(hostConfig.instanceId!, {
+15 -2
View File
@@ -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",
+27
View File
@@ -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}
+47
View File
@@ -50,6 +50,9 @@ type GeneralSettingsSectionProps = {
sessionTimeout: string;
setSessionTimeout: Dispatch<SetStateAction<string>>;
handleSaveSessionTimeout: () => void;
terminalTimeout: string;
setTerminalTimeout: Dispatch<SetStateAction<string>>;
handleSaveTerminalTimeout: () => void;
statusInterval: string;
setStatusInterval: Dispatch<SetStateAction<string>>;
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({
</span>
</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">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.monitoringDefaults")}
@@ -1096,6 +1132,17 @@ export function AdminHostDefaultsSection({
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{t("admin.hostDefaultsTerminal")}
</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
label={t("admin.hostDefaultsSessionLogging")}
description={t("admin.hostDefaultsSessionLoggingDesc")}
+11 -1
View File
@@ -314,6 +314,14 @@ export function ConnectionsPanel({
}) {
const { t } = useTranslation();
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 activeSessionsRef = useRef(activeSessions);
activeSessionsRef.current = activeSessions;
@@ -589,7 +597,9 @@ export function ConnectionsPanel({
/>
<div className="px-3 py-1.5 border-b border-border/40">
<span className="text-[10px] text-muted-foreground/50">
{t("connections.backgroundDesc")}
{t("connections.backgroundDesc", {
minutes: persistMinutes,
})}
</span>
</div>
{filteredBackgroundTabs.map((record) => {
+21 -21
View File
@@ -1788,6 +1788,27 @@ export function HostEditor({
onValueChange={([v]) => setField("scrollback", v)}
/>
</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
label={t("hosts.sshAgentForwardingLabel")}
description={t("hosts.sshAgentForwardingShortDesc")}
@@ -1815,27 +1836,6 @@ export function HostEditor({
onChange={(v) => setField("autoMosh", v)}
/>
</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
label={t("hosts.enableSessionLogging")}
description={
+1 -1
View File
@@ -232,7 +232,7 @@ export function createHostEditorForm(
moshCommand: host?.terminalConfig?.moshCommand ?? "",
agentForwarding: host?.terminalConfig?.agentForwarding ?? false,
autoMosh: host?.terminalConfig?.autoMosh ?? false,
autoTmux: host?.terminalConfig?.autoTmux ?? false,
autoTmux: host?.terminalConfig?.autoTmux ?? d?.autoTmux ?? false,
sudoPasswordAutoFill: host?.terminalConfig?.sudoPasswordAutoFill ?? false,
sudoPassword: host?.hasSudoPassword
? "existing_sudo_password"
@@ -627,3 +627,17 @@ describe("createHostEditorForm credentialId", () => {
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);
});
});