mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-07-16 05:13:36 +00:00
feat: save quick connect sessions as hosts (#1055)
This commit is contained in:
+1
-1
@@ -242,7 +242,7 @@ export interface HostData {
|
|||||||
| "agent";
|
| "agent";
|
||||||
useWarpgate?: boolean;
|
useWarpgate?: boolean;
|
||||||
password?: string;
|
password?: string;
|
||||||
key?: File | null;
|
key?: File | string | null;
|
||||||
keyPassword?: string;
|
keyPassword?: string;
|
||||||
keyType?: string;
|
keyType?: string;
|
||||||
sudoPassword?: string;
|
sudoPassword?: string;
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ import {
|
|||||||
addOpenTab,
|
addOpenTab,
|
||||||
deleteOpenTab,
|
deleteOpenTab,
|
||||||
patchOpenTab,
|
patchOpenTab,
|
||||||
|
createSSHHost,
|
||||||
getActiveSessions,
|
getActiveSessions,
|
||||||
getUserPreferences,
|
getUserPreferences,
|
||||||
type UserPreferences,
|
type UserPreferences,
|
||||||
@@ -134,6 +135,7 @@ import { TransferMonitor } from "@/features/file-manager/TransferMonitor.tsx";
|
|||||||
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
import { sshHostToHost } from "@/sidebar/HostManagerData";
|
||||||
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
import { resolveHostTabType } from "@/lib/host-connection-tabs";
|
||||||
import { changeAppLanguage } from "@/i18n/i18n";
|
import { changeAppLanguage } from "@/i18n/i18n";
|
||||||
|
import { quickConnectHostToPayload } from "@/sidebar/quick-connect-host";
|
||||||
|
|
||||||
function buildHostTree(
|
function buildHostTree(
|
||||||
hosts: SSHHostWithStatus[],
|
hosts: SSHHostWithStatus[],
|
||||||
@@ -1092,6 +1094,21 @@ export function AppShell({
|
|||||||
openTab(host, type);
|
openTab(host, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const saveQuickConnectHost = useCallback(
|
||||||
|
async (tab: Tab, host: Host) => {
|
||||||
|
try {
|
||||||
|
const savedHost = await createSSHHost(quickConnectHostToPayload(host));
|
||||||
|
await patchOpenTab(tab.instanceId, { hostId: savedHost.id });
|
||||||
|
await loadHosts();
|
||||||
|
toast.success(t("hosts.hostCreated"));
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(t("hosts.failedToSave"));
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadHosts, t],
|
||||||
|
);
|
||||||
|
|
||||||
function openSerialTab(config: SerialConfig) {
|
function openSerialTab(config: SerialConfig) {
|
||||||
const pseudoHost: Host = {
|
const pseudoHost: Host = {
|
||||||
id: `serial-${Date.now()}`,
|
id: `serial-${Date.now()}`,
|
||||||
@@ -1877,6 +1894,7 @@ export function AppShell({
|
|||||||
initialFilePath: path,
|
initialFilePath: path,
|
||||||
}),
|
}),
|
||||||
renameTab,
|
renameTab,
|
||||||
|
saveQuickConnectHost,
|
||||||
),
|
),
|
||||||
tabNode,
|
tabNode,
|
||||||
tab.id,
|
tab.id,
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import {
|
|||||||
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
|
import { ConnectionLog } from "@/ssh/connection-log/ConnectionLog.tsx";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/button";
|
import { Button } from "@/components/button";
|
||||||
|
import { Save } from "lucide-react";
|
||||||
import { resolveTermixThemeColors } from "./terminal-theme.ts";
|
import { resolveTermixThemeColors } from "./terminal-theme.ts";
|
||||||
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
|
import type { TerminalHandle, TerminalHostConfig } from "./terminal-types.ts";
|
||||||
import {
|
import {
|
||||||
@@ -85,6 +86,8 @@ interface SSHTerminalProps {
|
|||||||
previewTheme?: string | null;
|
previewTheme?: string | null;
|
||||||
/** When true, suppress automatic focus on connect/visibility change. */
|
/** When true, suppress automatic focus on connect/visibility change. */
|
||||||
disableAutoFocus?: boolean;
|
disableAutoFocus?: boolean;
|
||||||
|
isQuickConnect?: boolean;
|
||||||
|
onSaveQuickConnect?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALTERNATE_SCREEN_SEQUENCE = /\x1b\[\?(47|1047|1049)([hl])/g;
|
const ALTERNATE_SCREEN_SEQUENCE = /\x1b\[\?(47|1047|1049)([hl])/g;
|
||||||
@@ -118,6 +121,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
onOpenFileInEditor,
|
onOpenFileInEditor,
|
||||||
previewTheme,
|
previewTheme,
|
||||||
disableAutoFocus = false,
|
disableAutoFocus = false,
|
||||||
|
isQuickConnect = false,
|
||||||
|
onSaveQuickConnect,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) {
|
) {
|
||||||
@@ -159,6 +164,8 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
const pongReceivedRef = useRef(true);
|
const pongReceivedRef = useRef(true);
|
||||||
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const pongTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
const [isSavingQuickConnect, setIsSavingQuickConnect] = useState(false);
|
||||||
|
const [isQuickConnectSaved, setIsQuickConnectSaved] = useState(false);
|
||||||
const [isConnecting, setIsConnecting] = useState(false);
|
const [isConnecting, setIsConnecting] = useState(false);
|
||||||
const [isFitted, setIsFitted] = useState(false);
|
const [isFitted, setIsFitted] = useState(false);
|
||||||
const [connectionError, setConnectionError] = useState<string | null>(null);
|
const [connectionError, setConnectionError] = useState<string | null>(null);
|
||||||
@@ -2810,6 +2817,32 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isQuickConnect &&
|
||||||
|
isConnected &&
|
||||||
|
!isQuickConnectSaved &&
|
||||||
|
onSaveQuickConnect && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={isSavingQuickConnect}
|
||||||
|
onClick={async () => {
|
||||||
|
setIsSavingQuickConnect(true);
|
||||||
|
try {
|
||||||
|
await onSaveQuickConnect();
|
||||||
|
setIsQuickConnectSaved(true);
|
||||||
|
} catch {
|
||||||
|
// The shell reports the failure with a toast.
|
||||||
|
} finally {
|
||||||
|
setIsSavingQuickConnect(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute top-2 left-2 z-[110] h-7 gap-1.5 bg-black/60 text-white/80 hover:bg-black/80 hover:text-white"
|
||||||
|
>
|
||||||
|
<Save className="size-3.5" />
|
||||||
|
{t("hosts.addHost")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
<SimpleLoader
|
<SimpleLoader
|
||||||
visible={isConnecting && !isConnectionLogExpanded}
|
visible={isConnecting && !isConnectionLogExpanded}
|
||||||
message={t("terminal.connecting")}
|
message={t("terminal.connecting")}
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ function TerminalTabContent({
|
|||||||
onRenameTab,
|
onRenameTab,
|
||||||
onOpenFileInEditor,
|
onOpenFileInEditor,
|
||||||
onOpenFileManager,
|
onOpenFileManager,
|
||||||
|
onSaveQuickConnect,
|
||||||
}: {
|
}: {
|
||||||
tab: Tab;
|
tab: Tab;
|
||||||
host: Host;
|
host: Host;
|
||||||
@@ -217,6 +218,7 @@ function TerminalTabContent({
|
|||||||
onRenameTab?: (tabId: string, newLabel: string) => void;
|
onRenameTab?: (tabId: string, newLabel: string) => void;
|
||||||
onOpenFileInEditor?: (filePath: string) => void;
|
onOpenFileInEditor?: (filePath: string) => void;
|
||||||
onOpenFileManager?: (path?: string) => void;
|
onOpenFileManager?: (path?: string) => void;
|
||||||
|
onSaveQuickConnect?: () => Promise<void>;
|
||||||
}) {
|
}) {
|
||||||
const { previewTerminalTheme } = useTabsSafe();
|
const { previewTerminalTheme } = useTabsSafe();
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
@@ -248,6 +250,8 @@ function TerminalTabContent({
|
|||||||
previewTheme={previewTerminalTheme}
|
previewTheme={previewTerminalTheme}
|
||||||
onOpenFileInEditor={onOpenFileInEditor}
|
onOpenFileInEditor={onOpenFileInEditor}
|
||||||
onOpenFileManager={onOpenFileManager}
|
onOpenFileManager={onOpenFileManager}
|
||||||
|
isQuickConnect={host.id.startsWith("quick-connect-")}
|
||||||
|
onSaveQuickConnect={onSaveQuickConnect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
@@ -272,6 +276,7 @@ export function renderTabContent(
|
|||||||
onOpenFileManager?: (host: Host, path?: string) => void,
|
onOpenFileManager?: (host: Host, path?: string) => void,
|
||||||
onOpenTerminalTab?: (host: Host, path?: string) => void,
|
onOpenTerminalTab?: (host: Host, path?: string) => void,
|
||||||
onRenameTab?: (tabId: string, newLabel: string) => void,
|
onRenameTab?: (tabId: string, newLabel: string) => void,
|
||||||
|
onSaveQuickConnect?: (tab: Tab, host: Host) => Promise<void>,
|
||||||
) {
|
) {
|
||||||
const { host, label } = tab;
|
const { host, label } = tab;
|
||||||
|
|
||||||
@@ -309,6 +314,9 @@ export function renderTabContent(
|
|||||||
onOpenFileManager={
|
onOpenFileManager={
|
||||||
onOpenFileManager ? (p) => onOpenFileManager(host, p) : undefined
|
onOpenFileManager ? (p) => onOpenFileManager(host, p) : undefined
|
||||||
}
|
}
|
||||||
|
onSaveQuickConnect={
|
||||||
|
onSaveQuickConnect ? () => onSaveQuickConnect(tab, host) : undefined
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Input } from "@/components/input";
|
|||||||
import type { Host } from "@/types/ui-types";
|
import type { Host } from "@/types/ui-types";
|
||||||
import { getCredentials } from "@/api/credentials-api";
|
import { getCredentials } from "@/api/credentials-api";
|
||||||
import { mapCredentials } from "./HostManagerData";
|
import { mapCredentials } from "./HostManagerData";
|
||||||
|
import { createQuickConnectHost } from "./quick-connect-host";
|
||||||
|
|
||||||
interface QuickConnectPanelProps {
|
interface QuickConnectPanelProps {
|
||||||
onConnect: (host: Host, type: "terminal" | "files") => void;
|
onConnect: (host: Host, type: "terminal" | "files") => void;
|
||||||
@@ -34,38 +35,15 @@ export function QuickConnectPanel({ onConnect }: QuickConnectPanelProps) {
|
|||||||
|
|
||||||
const connect = (type: "terminal" | "files") => {
|
const connect = (type: "terminal" | "files") => {
|
||||||
if (!host || !username) return;
|
if (!host || !username) return;
|
||||||
const hostConfig: Host = {
|
const hostConfig = createQuickConnectHost({
|
||||||
id: `quick-connect-${Date.now()}`,
|
|
||||||
name: `${username}@${host}`,
|
|
||||||
ip: host,
|
ip: host,
|
||||||
port: parseInt(port) || 22,
|
port: parseInt(port) || 22,
|
||||||
username,
|
username,
|
||||||
authType,
|
authType,
|
||||||
password: authType === "password" ? password : undefined,
|
password,
|
||||||
key: authType === "key" ? privateKey : undefined,
|
key: privateKey,
|
||||||
credentialId: authType === "credential" ? credentialId : undefined,
|
credentialId,
|
||||||
folder: "",
|
});
|
||||||
online: false,
|
|
||||||
cpu: null,
|
|
||||||
ram: null,
|
|
||||||
lastAccess: new Date().toISOString(),
|
|
||||||
pin: false,
|
|
||||||
defaultPath: "",
|
|
||||||
serverTunnels: [],
|
|
||||||
quickActions: [],
|
|
||||||
enableTerminal: true,
|
|
||||||
enableFileManager: true,
|
|
||||||
enableTunnel: true,
|
|
||||||
enableDocker: true,
|
|
||||||
enableSsh: true,
|
|
||||||
enableRdp: false,
|
|
||||||
enableVnc: false,
|
|
||||||
enableTelnet: false,
|
|
||||||
sshPort: parseInt(port) || 22,
|
|
||||||
rdpPort: 3389,
|
|
||||||
vncPort: 5900,
|
|
||||||
telnetPort: 23,
|
|
||||||
};
|
|
||||||
onConnect(hostConfig, type);
|
onConnect(hostConfig, type);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import {
|
||||||
|
createQuickConnectHost,
|
||||||
|
quickConnectHostToPayload,
|
||||||
|
} from "./quick-connect-host";
|
||||||
|
|
||||||
|
describe("quick connect host", () => {
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
it("preserves password authentication when saving the connection", () => {
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(1234);
|
||||||
|
|
||||||
|
const host = createQuickConnectHost({
|
||||||
|
ip: "server.example.com",
|
||||||
|
port: 2222,
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
password: "secret",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(host.id).toBe("quick-connect-1234");
|
||||||
|
expect(quickConnectHostToPayload(host)).toMatchObject({
|
||||||
|
name: "root@server.example.com",
|
||||||
|
ip: "server.example.com",
|
||||||
|
port: 2222,
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
password: "secret",
|
||||||
|
connectionType: "ssh",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps only the selected credential authentication data", () => {
|
||||||
|
const host = createQuickConnectHost({
|
||||||
|
ip: "10.0.0.2",
|
||||||
|
port: 22,
|
||||||
|
username: "deploy",
|
||||||
|
authType: "credential",
|
||||||
|
credentialId: "42",
|
||||||
|
password: "ignored",
|
||||||
|
key: "ignored",
|
||||||
|
});
|
||||||
|
const payload = quickConnectHostToPayload(host);
|
||||||
|
|
||||||
|
expect(payload.credentialId).toBe(42);
|
||||||
|
expect(payload.password).toBeUndefined();
|
||||||
|
expect(payload.key).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { SSHHostData } from "@/types";
|
||||||
|
import type { Host } from "@/types/ui-types";
|
||||||
|
|
||||||
|
type QuickConnectInput = Pick<
|
||||||
|
Host,
|
||||||
|
"ip" | "port" | "username" | "authType" | "password" | "key" | "credentialId"
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createQuickConnectHost(input: QuickConnectInput): Host {
|
||||||
|
return {
|
||||||
|
id: `quick-connect-${Date.now()}`,
|
||||||
|
name: `${input.username}@${input.ip}`,
|
||||||
|
ip: input.ip,
|
||||||
|
port: input.port,
|
||||||
|
username: input.username,
|
||||||
|
authType: input.authType,
|
||||||
|
password: input.authType === "password" ? input.password : undefined,
|
||||||
|
key: input.authType === "key" ? input.key : undefined,
|
||||||
|
credentialId:
|
||||||
|
input.authType === "credential" ? input.credentialId : undefined,
|
||||||
|
folder: "",
|
||||||
|
online: false,
|
||||||
|
cpu: null,
|
||||||
|
ram: null,
|
||||||
|
lastAccess: new Date().toISOString(),
|
||||||
|
pin: false,
|
||||||
|
defaultPath: "",
|
||||||
|
serverTunnels: [],
|
||||||
|
quickActions: [],
|
||||||
|
enableTerminal: true,
|
||||||
|
enableCommandHistory: true,
|
||||||
|
enableFileManager: true,
|
||||||
|
enableTunnel: true,
|
||||||
|
enableDocker: true,
|
||||||
|
enableProxmox: false,
|
||||||
|
enableTmuxMonitor: false,
|
||||||
|
enableSsh: true,
|
||||||
|
enableRdp: false,
|
||||||
|
enableVnc: false,
|
||||||
|
enableTelnet: false,
|
||||||
|
sshPort: input.port,
|
||||||
|
rdpPort: 3389,
|
||||||
|
vncPort: 5900,
|
||||||
|
telnetPort: 23,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function quickConnectHostToPayload(host: Host): SSHHostData {
|
||||||
|
return {
|
||||||
|
name: host.name,
|
||||||
|
ip: host.ip,
|
||||||
|
port: host.port,
|
||||||
|
username: host.username,
|
||||||
|
authType: host.authType,
|
||||||
|
password: host.password,
|
||||||
|
key: host.key,
|
||||||
|
credentialId: host.credentialId
|
||||||
|
? Number.parseInt(host.credentialId, 10)
|
||||||
|
: null,
|
||||||
|
folder: host.folder,
|
||||||
|
pin: host.pin,
|
||||||
|
defaultPath: host.defaultPath,
|
||||||
|
enableTerminal: true,
|
||||||
|
enableSessionLogging: true,
|
||||||
|
enableCommandHistory: host.enableCommandHistory,
|
||||||
|
enableFileManager: host.enableFileManager,
|
||||||
|
enableTunnel: host.enableTunnel,
|
||||||
|
enableDocker: host.enableDocker,
|
||||||
|
enableProxmox: host.enableProxmox,
|
||||||
|
enableTmuxMonitor: host.enableTmuxMonitor,
|
||||||
|
showTerminalInSidebar: true,
|
||||||
|
showFileManagerInSidebar: true,
|
||||||
|
showTunnelInSidebar: true,
|
||||||
|
showDockerInSidebar: true,
|
||||||
|
showServerStatsInSidebar: true,
|
||||||
|
connectionType: "ssh",
|
||||||
|
enableSsh: true,
|
||||||
|
enableRdp: false,
|
||||||
|
enableVnc: false,
|
||||||
|
enableTelnet: false,
|
||||||
|
sshPort: host.sshPort,
|
||||||
|
rdpPort: host.rdpPort,
|
||||||
|
vncPort: host.vncPort,
|
||||||
|
telnetPort: host.telnetPort,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user