diff --git a/application/i18n/locales/en/terminal.ts b/application/i18n/locales/en/terminal.ts index 0dda4acf8a..378b7ab940 100644 --- a/application/i18n/locales/en/terminal.ts +++ b/application/i18n/locales/en/terminal.ts @@ -183,6 +183,7 @@ export const enTerminalMessages: Messages = { 'terminal.progress.timeoutIn': 'Timeout in {seconds}s', 'terminal.progress.waitingForUserInput': 'Waiting for user input', 'terminal.progress.disconnected': 'Disconnected', + 'terminal.progress.connected': 'Connected', 'terminal.progress.cancelling': 'Cancelling...', 'terminal.progress.startOver': 'Start over', 'terminal.progress.enterReconnectHint': 'Press Enter to reconnect', diff --git a/application/i18n/locales/en/vault.ts b/application/i18n/locales/en/vault.ts index ac3ea06a2a..fa29dc7a16 100644 --- a/application/i18n/locales/en/vault.ts +++ b/application/i18n/locales/en/vault.ts @@ -682,6 +682,16 @@ export const enVaultMessages: Messages = { 'hostDetails.title.new': 'New Host', 'vault.panel.resizeWidth': 'Resize panel width', 'hostDetails.saveAria': 'Save', + // Host connection test + 'hostConnectionTest.title': 'Test Connection', + 'hostConnectionTest.test': 'Test', + 'hostConnectionTest.chainOf': 'Chain {current} of {total}', + 'hostConnectionTest.success': 'Connection successful', + 'hostConnectionTest.successHint': 'The host accepted your credentials. You can close this window.', + 'hostConnectionTest.connecting': 'Connecting… timeout in {seconds}s', + 'hostConnectionTest.failed': 'Connection failed.', + 'hostConnectionTest.retry': 'Retry', + 'hostConnectionTest.credentialsUnavailable': 'Saved credentials cannot be decrypted on this device. Re-enter them below.', 'hostDetails.section.address': 'Address', 'hostDetails.hostname.placeholder': 'IP or Hostname', 'hostDetails.section.general': 'General', diff --git a/application/i18n/locales/ru/terminal.ts b/application/i18n/locales/ru/terminal.ts index 66686c75b4..2740e6b309 100644 --- a/application/i18n/locales/ru/terminal.ts +++ b/application/i18n/locales/ru/terminal.ts @@ -204,6 +204,7 @@ export const ruTerminalMessages: Messages = { 'terminal.progress.timeoutIn': 'Тайм-аут через {seconds}с', 'terminal.progress.waitingForUserInput': 'Ожидание ввода пользователя', 'terminal.progress.disconnected': 'Отключено', + 'terminal.progress.connected': 'Подключено', 'terminal.progress.cancelling': 'Отмена...', 'terminal.progress.startOver': 'Начать заново', 'terminal.progress.enterReconnectHint': 'Нажмите Enter, чтобы подключиться снова', diff --git a/application/i18n/locales/ru/vault.ts b/application/i18n/locales/ru/vault.ts index dadfaff386..4494722475 100644 --- a/application/i18n/locales/ru/vault.ts +++ b/application/i18n/locales/ru/vault.ts @@ -718,6 +718,15 @@ export const ruVaultMessages: Messages = { 'hostDetails.title.new': 'Новый хост', 'vault.panel.resizeWidth': 'Изменить ширину панели', 'hostDetails.saveAria': 'Сохранить', + 'hostConnectionTest.title': 'Проверка подключения', + 'hostConnectionTest.test': 'Проверить', + 'hostConnectionTest.chainOf': 'Цепочка {current} из {total}', + 'hostConnectionTest.success': 'Подключение успешно', + 'hostConnectionTest.successHint': 'Хост принял ваши учётные данные. Можно закрыть окно.', + 'hostConnectionTest.connecting': 'Подключение… таймаут через {seconds} с', + 'hostConnectionTest.failed': 'Не удалось подключиться.', + 'hostConnectionTest.retry': 'Повторить', + 'hostConnectionTest.credentialsUnavailable': 'Сохранённые учётные данные не удаётся расшифровать на этом устройстве. Введите их заново ниже.', 'hostDetails.section.address': 'Адрес', 'hostDetails.hostname.placeholder': 'IP или имя хоста', 'hostDetails.section.general': 'Общие', diff --git a/application/i18n/locales/zh-CN/vault.ts b/application/i18n/locales/zh-CN/vault.ts index 999bacbe48..1884c21b4f 100644 --- a/application/i18n/locales/zh-CN/vault.ts +++ b/application/i18n/locales/zh-CN/vault.ts @@ -128,6 +128,15 @@ export const zhCNVaultMessages: Messages = { 'hostDetails.title.new': '新建主机', 'vault.panel.resizeWidth': '调整面板宽度', 'hostDetails.saveAria': '保存', + 'hostConnectionTest.title': '测试连接', + 'hostConnectionTest.test': '测试', + 'hostConnectionTest.chainOf': '第 {current}/{total} 跳', + 'hostConnectionTest.success': '连接成功', + 'hostConnectionTest.successHint': '主机已接受您的凭据。您可以关闭此窗口。', + 'hostConnectionTest.connecting': '正在连接… {seconds} 秒后超时', + 'hostConnectionTest.failed': '连接失败。', + 'hostConnectionTest.retry': '重试', + 'hostConnectionTest.credentialsUnavailable': '保存的凭据无法在此设备上解密。请在下方重新输入。', 'hostDetails.section.address': '地址', 'hostDetails.hostname.placeholder': 'IP 或 主机名', 'hostDetails.section.general': '通用', @@ -572,6 +581,7 @@ export const zhCNVaultMessages: Messages = { 'terminal.progress.timeoutIn': '将在 {seconds}s 后超时', 'terminal.progress.waitingForUserInput': '等待用户输入', 'terminal.progress.disconnected': '已断开', + 'terminal.progress.connected': '已连接', 'terminal.progress.cancelling': '正在取消...', 'terminal.progress.startOver': '重新开始', 'terminal.connection.dismissDisconnectedDialog': '关闭断连提示', diff --git a/application/i18n/locales/zh-TW/vault.ts b/application/i18n/locales/zh-TW/vault.ts index eadb55a234..1726bd2c4f 100644 --- a/application/i18n/locales/zh-TW/vault.ts +++ b/application/i18n/locales/zh-TW/vault.ts @@ -127,6 +127,15 @@ export const zhTWVaultMessages: Messages = { 'hostDetails.title.details': '主機詳情', 'hostDetails.title.new': '新增主機', 'hostDetails.saveAria': '儲存', + 'hostConnectionTest.title': '測試連線', + 'hostConnectionTest.test': '測試', + 'hostConnectionTest.chainOf': '第 {current}/{total} 跳', + 'hostConnectionTest.success': '連線成功', + 'hostConnectionTest.successHint': '主機已接受您的憑證。您可以關閉此視窗。', + 'hostConnectionTest.connecting': '正在連線… {seconds} 秒後逾時', + 'hostConnectionTest.failed': '連線失敗。', + 'hostConnectionTest.retry': '重試', + 'hostConnectionTest.credentialsUnavailable': '儲存的憑證無法在此裝置上解密。請在下方重新輸入。', 'hostDetails.section.address': '位址', 'hostDetails.hostname.placeholder': 'IP 或 主機名', 'hostDetails.section.general': '通用', @@ -572,6 +581,7 @@ export const zhTWVaultMessages: Messages = { 'terminal.progress.timeoutIn': '將在 {seconds}s 後超時', 'terminal.progress.waitingForUserInput': '等待使用者輸入', 'terminal.progress.disconnected': '已斷開', + 'terminal.progress.connected': '已連線', 'terminal.progress.cancelling': '正在取消...', 'terminal.progress.startOver': '重新開始', 'terminal.connection.dismissDisconnectedDialog': '關閉斷連提示', diff --git a/application/state/useHostConnectionTest.ts b/application/state/useHostConnectionTest.ts new file mode 100644 index 0000000000..0a2f581507 --- /dev/null +++ b/application/state/useHostConnectionTest.ts @@ -0,0 +1,324 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, TerminalSettings } from "../../domain/models"; +import type { HostKeyInfo } from "../../domain/hostKey"; +import { toHostKeyInfo } from "../../domain/hostKey"; +import { createKnownHostFromHostKeyInfo } from "../../domain/knownHosts"; +import { resolveHostSshConnectionTimeouts } from "../../domain/sshConnectionTimeouts"; +import { + buildHostConnectionTestPlan, + formatConnectionTestProgressLog, + type HostConnectionTestAuthOverride, +} from "../../domain/hostConnectionTest"; +import { useTerminalBackend } from "./useTerminalBackend"; + +export type ChainProgress = { + currentHop: number; + totalHops: number; + currentHostLabel: string; + connectionPhase: string; +} | null; + +export type HostKeyVerificationState = { + requestId: string; + hostKeyInfo: HostKeyInfo; +}; + +export type HostConnectionTestStatus = "idle" | "connecting" | "connected" | "disconnected"; + +export type HostConnectionTestState = { + status: HostConnectionTestStatus; + error: string | null; + needsAuth: boolean; + progressValue: number; + chainProgress: ChainProgress; + progressLogs: string[]; + hostKeyVerification: HostKeyVerificationState | null; + timeLeft: number; + isAwaitingUserInput: boolean; + isCancelling: boolean; +}; + +export type HostConnectionTestInput = { + host: Host; + hosts: Host[]; + keys: SSHKey[]; + identities?: Identity[]; + knownHosts?: KnownHost[]; + groupConfigs?: GroupConfig[]; + proxyProfiles?: ProxyProfile[]; + terminalSettings?: TerminalSettings; + onAddKnownHost?: (knownHost: KnownHost) => void; +}; + +const CONNECTION_TIMEOUT_MS = 120000; + +const messageOf = (err: unknown): string => (err instanceof Error ? err.message : String(err)); + +export function useHostConnectionTest(input: HostConnectionTestInput) { + const { + host, + hosts, + keys, + identities = [], + knownHosts, + groupConfigs = [], + proxyProfiles = [], + terminalSettings, + onAddKnownHost, + } = input; + const backend = useTerminalBackend(); + + const [status, setStatus] = useState("idle"); + const [error, setError] = useState(null); + const [needsAuth, setNeedsAuth] = useState(false); + const [progressValue, setProgressValue] = useState(0); + const [chainProgress, setChainProgress] = useState(null); + const [progressLogs, setProgressLogs] = useState([]); + const [hostKeyVerification, setHostKeyVerification] = useState(null); + const [timeLeft, setTimeLeft] = useState(0); + const [isAwaitingUserInput, setIsAwaitingUserInput] = useState(false); + const [isCancelling, setIsCancelling] = useState(false); + + const sessionIdRef = useRef(null); + const bootEpochRef = useRef(0); + const pendingHostKeyRequestIdRef = useRef(null); + const disposedRef = useRef(false); + + const resetState = useCallback(() => { + setError(null); + setNeedsAuth(false); + setProgressValue(0); + setChainProgress(null); + setProgressLogs([]); + setHostKeyVerification(null); + setIsAwaitingUserInput(false); + setIsCancelling(false); + setTimeLeft(0); + pendingHostKeyRequestIdRef.current = null; + }, []); + + const clearSession = useCallback(() => { + const sessionId = sessionIdRef.current; + sessionIdRef.current = null; + if (sessionId && !disposedRef.current) { + void backend.cancelTestConnection(sessionId).catch(() => {}); + } + }, [backend]); + + // Unmount cleanup. The per-attempt subscriptions below dispose themselves via + // their own effect returns; nothing here touches them. + useEffect(() => { + disposedRef.current = false; + return () => { + disposedRef.current = true; + }; + }, []); + + // Subscribe to chain progress for the active attempt. + useEffect(() => { + const unsub = backend.onChainProgress?.((sessionId, hop, total, label, phase, phaseError) => { + if (sessionId !== sessionIdRef.current) return; + if (total > 1) { + setChainProgress({ + currentHop: hop, + totalHops: total, + currentHostLabel: label, + connectionPhase: phase, + }); + } + setProgressLogs((prev) => [ + ...prev, + formatConnectionTestProgressLog({ hop, total, label, phase, error: phaseError }), + ]); + const hopProgress = (hop / total) * 80 + 10; + setProgressValue((prev) => Math.max(prev, Math.min(95, hopProgress))); + setIsAwaitingUserInput(phase === "auth-attempt" && phaseError === "waiting for user input..."); + }); + return () => unsub?.(); + }, [backend]); + + // Subscribe to host-key verification for the active attempt. + useEffect(() => { + const unsub = backend.onHostKeyVerification?.((request) => { + if (request.sessionId !== sessionIdRef.current) return; + pendingHostKeyRequestIdRef.current = request.requestId; + setHostKeyVerification({ + requestId: request.requestId, + hostKeyInfo: toHostKeyInfo(request), + }); + }); + return () => unsub?.(); + }, [backend]); + + const respondHostKey = useCallback( + (requestId: string, accept: boolean, addToKnownHosts: boolean, hostKeyInfo: HostKeyInfo) => { + if (accept && addToKnownHosts && onAddKnownHost) { + onAddKnownHost(createKnownHostFromHostKeyInfo(hostKeyInfo)); + } + void backend.respondHostKeyVerification(requestId, accept, addToKnownHosts); + pendingHostKeyRequestIdRef.current = null; + setHostKeyVerification(null); + }, + [backend, onAddKnownHost], + ); + + const handleHostKeyContinue = useCallback(() => { + if (!hostKeyVerification) return; + respondHostKey(hostKeyVerification.requestId, true, false, hostKeyVerification.hostKeyInfo); + }, [hostKeyVerification, respondHostKey]); + + const handleHostKeyAddAndContinue = useCallback(() => { + if (!hostKeyVerification) return; + respondHostKey(hostKeyVerification.requestId, true, true, hostKeyVerification.hostKeyInfo); + }, [hostKeyVerification, respondHostKey]); + + const handleHostKeyClose = useCallback(() => { + if (!hostKeyVerification) return; + respondHostKey(hostKeyVerification.requestId, false, false, hostKeyVerification.hostKeyInfo); + }, [hostKeyVerification, respondHostKey]); + + const startTest = useCallback( + async (authOverride?: HostConnectionTestAuthOverride | null) => { + clearSession(); + resetState(); + + const sessionId = crypto.randomUUID(); + const bootEpoch = (bootEpochRef.current += 1); + sessionIdRef.current = sessionId; + + const plan = buildHostConnectionTestPlan({ + host, + hosts, + keys, + identities, + knownHosts, + groupConfigs, + proxyProfiles, + terminalSettings: terminalSettings + ? { + keepaliveInterval: terminalSettings.keepaliveInterval, + keepaliveCountMax: terminalSettings.keepaliveCountMax, + verifyHostKeys: terminalSettings.verifyHostKeys, + } + : undefined, + sessionId, + bootEpoch, + authOverride, + }); + + if (!plan.ok) { + setError(plan.error); + setStatus("disconnected"); + return; + } + + if (plan.needsCredentialReentry) { + setNeedsAuth(true); + setStatus("connecting"); + return; + } + + setStatus("connecting"); + setIsAwaitingUserInput(false); + + try { + await backend.testConnection(plan.options); + if (disposedRef.current || sessionIdRef.current !== sessionId) return; + setProgressValue(100); + setChainProgress(null); + setStatus("connected"); + } catch (err) { + if (disposedRef.current || sessionIdRef.current !== sessionId) return; + setError(messageOf(err)); + setStatus("disconnected"); + } + }, + [ + backend, + clearSession, + groupConfigs, + host, + hosts, + identities, + keys, + knownHosts, + proxyProfiles, + resetState, + terminalSettings, + ], + ); + + const submitAuth = useCallback( + (auth: HostConnectionTestAuthOverride) => { + void startTest(auth); + }, + [startTest], + ); + + const cancelTest = useCallback(() => { + setIsCancelling(true); + // Reject any pending host-key prompt so the dial fails fast, then clear + // this attempt's session. An in-flight TCP/auth dial without a prompt + // settles on its own (it is not registered as a terminal session). + if (pendingHostKeyRequestIdRef.current) { + void backend.respondHostKeyVerification(pendingHostKeyRequestIdRef.current, false, false); + pendingHostKeyRequestIdRef.current = null; + setHostKeyVerification(null); + } + clearSession(); + setStatus("idle"); + setNeedsAuth(false); + setError(null); + }, [backend, clearSession]); + + // Connection timeout countdown + gentle progress while connecting. + useEffect(() => { + if (status !== "connecting" || needsAuth || hostKeyVerification || isAwaitingUserInput) { + return; + } + const timeouts = resolveHostSshConnectionTimeouts(host); + const authReadyTimeoutMs = timeouts.authReadyTimeoutSeconds * 1000 || CONNECTION_TIMEOUT_MS; + setTimeLeft(authReadyTimeoutMs / 1000); + const countdown = setInterval(() => { + setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0)); + }, 1000); + const timeout = setTimeout(() => { + setError("Connection timed out. Please try again."); + setStatus("disconnected"); + }, authReadyTimeoutMs); + const prog = setInterval(() => { + setProgressValue((prev) => { + if (prev >= 95) return prev; + const remaining = 95 - prev; + return Math.min(95, prev + Math.max(1, remaining * 0.15)); + }); + }, 200); + return () => { + clearInterval(countdown); + clearTimeout(timeout); + clearInterval(prog); + }; + }, [status, needsAuth, hostKeyVerification, isAwaitingUserInput, host]); + + return { + state: { + status, + error, + needsAuth, + progressValue, + chainProgress, + progressLogs, + hostKeyVerification, + timeLeft, + isAwaitingUserInput, + isCancelling, + } satisfies HostConnectionTestState, + startTest, + submitAuth, + cancelTest, + retry: () => void startTest(), + handleHostKeyContinue, + handleHostKeyAddAndContinue, + handleHostKeyClose, + }; +} diff --git a/application/state/useTerminalBackend.ts b/application/state/useTerminalBackend.ts index 01920b6d93..2b4bf6bf38 100644 --- a/application/state/useTerminalBackend.ts +++ b/application/state/useTerminalBackend.ts @@ -133,6 +133,18 @@ export const useTerminalBackend = () => { return bridge.startSSHSession(options); }, []); + const testConnection = useCallback(async (options: NetcattySSHOptions) => { + const bridge = netcattyBridge.get(); + if (!bridge?.testConnection) throw new Error("testConnection unavailable"); + return bridge.testConnection(options); + }, []); + + const cancelTestConnection = useCallback(async (sessionId: string) => { + const bridge = netcattyBridge.get(); + if (!bridge?.cancelTestConnection) return { success: false, error: "cancelTestConnection unavailable" }; + return bridge.cancelTestConnection(sessionId); + }, []); + const startTelnetSession = useCallback(async (options: Parameters>[0]) => { const bridge = netcattyBridge.get(); if (!bridge?.startTelnetSession) throw new Error("startTelnetSession unavailable"); @@ -566,6 +578,8 @@ export const useTerminalBackend = () => { execAvailable, openExternalAvailable, startSSHSession, + testConnection, + cancelTestConnection, startTelnetSession, startMoshSession, startEtSession, @@ -648,6 +662,8 @@ export const useTerminalBackend = () => { execAvailable, openExternalAvailable, startSSHSession, + testConnection, + cancelTestConnection, startTelnetSession, startMoshSession, startEtSession, diff --git a/components/HostDetailsPanel.tsx b/components/HostDetailsPanel.tsx index 98f64e79ee..f446c01702 100644 --- a/components/HostDetailsPanel.tsx +++ b/components/HostDetailsPanel.tsx @@ -30,7 +30,7 @@ import { resolveHostTerminalFontSize, resolveHostTerminalThemeId, } from "../domain/terminalAppearance"; -import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey } from "../types"; +import { EnvVar, GroupConfig, Host, Identity, KnownHost, ManagedSource, ProxyConfig, ProxyProfile, Snippet, SSHKey, TerminalSettings } from "../types"; import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar"; import ThemeSelectPanel from "./ThemeSelectPanel"; import { @@ -77,6 +77,8 @@ import { stripBuiltInConnectionFieldsForPluginHost, } from "../domain/pluginConnection"; import { PluginConnectionSection } from "./PluginConnectionSection"; +import { useHostConnectionTest } from "../application/state/useHostConnectionTest"; +import { HostConnectionTestDialog } from "./host-details/HostConnectionTestDialog"; type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null; type SubPanel = @@ -111,6 +113,9 @@ interface HostDetailsPanelProps { snippets?: Snippet[]; onSnippetsChange?: (snippets: Snippet[]) => void; onHostsChange?: (hosts: Host[] | ((prev: Host[]) => Host[])) => void; + knownHosts?: KnownHost[]; + onAddKnownHost?: (knownHost: KnownHost) => void; + terminalSettings?: TerminalSettings; className?: string; } @@ -139,6 +144,9 @@ const HostDetailsPanel: React.FC = ({ snippets = [], onSnippetsChange, onHostsChange, + knownHosts, + onAddKnownHost, + terminalSettings, className, resizable, persistWidthStorageKey, @@ -198,6 +206,28 @@ const HostDetailsPanel: React.FC = ({ const [sshAgentStatus, setSshAgentStatus] = useState(null); const [sshForwardingAgentStatus, setSshForwardingAgentStatus] = useState(null); + const [testOpen, setTestOpen] = useState(false); + const hostConnectionTest = useHostConnectionTest({ + host: form, + hosts: allHosts, + keys: availableKeys, + identities, + knownHosts, + groupConfigs, + proxyProfiles, + terminalSettings, + onAddKnownHost, + }); + + const handleStartTest = () => { + setTestOpen(true); + void hostConnectionTest.startTest(); + }; + + const handleCloseTest = () => { + setTestOpen(false); + }; + useEffect(() => { let cancelled = false; if (form.agentForwarding || form.useSshAgent === true) { @@ -1309,15 +1339,32 @@ const HostDetailsPanel: React.FC = ({ )} - + + {!pluginProtocolActive && form.protocol !== "telnet" && ( + + )} + ); }; diff --git a/components/VaultView.tsx b/components/VaultView.tsx index 0f9d954a05..ff1762c6c1 100644 --- a/components/VaultView.tsx +++ b/components/VaultView.tsx @@ -73,6 +73,7 @@ import { upsertHostById, } from "../domain/host"; import { exportHostsToCsvWithStats } from "../domain/vaultImport"; +import { upsertKnownHost } from "../domain/knownHosts"; import { remapSnippetTargetGroupPaths, } from "../domain/hostGroupPathMutations"; @@ -662,6 +663,13 @@ const VaultViewInner: React.FC = ({ setIsHostPanelOpen(true); }, []); + const handleAddKnownHost = useCallback( + (kh: KnownHost) => { + onUpdateKnownHosts(upsertKnownHost(knownHosts, kh)); + }, + [knownHosts, onUpdateKnownHosts], + ); + const handleDuplicateHost = useCallback( (host: Host) => { setCurrentSection("hosts"); @@ -1487,6 +1495,7 @@ const VaultViewInner: React.FC = ({ keys, knownHosts, knownHostsManagerElement, + onAddKnownHost: handleAddKnownHost, Label, lastPinnedId, LayoutGrid, diff --git a/components/host-details/HostConnectionTestDialog.tsx b/components/host-details/HostConnectionTestDialog.tsx new file mode 100644 index 0000000000..d443cf9c8a --- /dev/null +++ b/components/host-details/HostConnectionTestDialog.tsx @@ -0,0 +1,153 @@ +import React, { useState } from "react"; +import { createPortal } from "react-dom"; +import { useI18n } from "../../application/i18n/I18nProvider"; +import type { Host, SSHKey } from "../../types"; +import { TerminalConnectionDialog } from "../terminal/TerminalConnectionDialog"; +import type { TerminalAuthMethod } from "../terminal/TerminalAuthDialog"; +import type { useHostConnectionTest } from "../../application/state/useHostConnectionTest"; + +type HostConnectionTest = ReturnType; + +export interface HostConnectionTestDialogProps { + open: boolean; + onClose: () => void; + host: Host; + keys: SSHKey[]; + test: HostConnectionTest; +} + +/** + * Host-editor "test connection" overlay. It reuses the terminal's own + * connection dialog verbatim (progress bar, per-hop chain header, auth prompt, + * and host-key verification) and keeps it mounted on success so the final + * "Connected" state shows through the same dialog with retry/close. + */ +export const HostConnectionTestDialog: React.FC = ({ + open, + onClose, + host, + keys, + test, +}) => { + const { t } = useI18n(); + const { state } = test; + const [showLogs, setShowLogs] = useState(false); + + // Auth re-entry form state (shown when state.needsAuth is true). + const [authMethod, setAuthMethod] = useState("password"); + const [authUsername, setAuthUsername] = useState(host.username || "root"); + const [authPassword, setAuthPassword] = useState(""); + const [authKeyId, setAuthKeyId] = useState(null); + const [authPassphrase, setAuthPassphrase] = useState(""); + const [showAuthPassphrase, setShowAuthPassphrase] = useState(false); + const [showAuthPassword, setShowAuthPassword] = useState(false); + + const handleClose = () => { + if (state.status === "connecting") { + test.cancelTest(); + } + setShowLogs(false); + onClose(); + }; + + const authIsValid = + authMethod === "password" ? authPassword.length > 0 : Boolean(authKeyId); + + const submitAuth = () => { + if (!authIsValid) return; + test.submitAuth({ + authMethod, + username: authUsername, + ...(authMethod === "password" + ? { password: authPassword } + : { keyId: authKeyId ?? undefined, passphrase: authPassphrase || undefined }), + }); + }; + + if (!open) return null; + + const dialogStatus = + state.status === "connecting" + ? "connecting" + : state.status === "connected" + ? "connected" + : "disconnected"; + + return createPortal( +
+ void test.retry(), + reconnectLabel: t("hostConnectionTest.retry"), + closeLabel: t("common.close"), + }} + /> +
, + document.body, + ); +}; + +export default HostConnectionTestDialog; diff --git a/components/terminal/TerminalConnectionProgress.tsx b/components/terminal/TerminalConnectionProgress.tsx index cd98b29c48..6ae8cc2bcd 100644 --- a/components/terminal/TerminalConnectionProgress.tsx +++ b/components/terminal/TerminalConnectionProgress.tsx @@ -21,6 +21,7 @@ export interface TerminalConnectionProgressProps { onCloseSession: () => void; onRetry?: () => void; reconnectLabel?: string; + closeLabel?: string; } export interface TerminalConnectionLogListProps { @@ -65,6 +66,7 @@ export const TerminalConnectionProgress: React.FC { const { t } = useI18n(); @@ -81,6 +83,13 @@ export const TerminalConnectionProgress: React.FC + ) : status === 'connected' ? ( + <> +
+ + {t('terminal.progress.connected')} + + ) : ( <>
@@ -105,7 +114,7 @@ export const TerminalConnectionProgress: React.FC {onRetry && (