mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 18:31:33 +00:00
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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user