fix ssh auth and transfer modal state
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
package com.sshmanager.controller;
|
package com.sshmanager.controller;
|
||||||
|
|
||||||
|
import com.jcraft.jsch.JSchException;
|
||||||
import com.jcraft.jsch.SftpException;
|
import com.jcraft.jsch.SftpException;
|
||||||
import com.sshmanager.dto.SftpFileInfo;
|
import com.sshmanager.dto.SftpFileInfo;
|
||||||
import com.sshmanager.entity.Connection;
|
import com.sshmanager.entity.Connection;
|
||||||
@@ -7,6 +8,7 @@ import com.sshmanager.entity.User;
|
|||||||
import com.sshmanager.repository.UserRepository;
|
import com.sshmanager.repository.UserRepository;
|
||||||
import com.sshmanager.service.ConnectionService;
|
import com.sshmanager.service.ConnectionService;
|
||||||
import com.sshmanager.service.SftpService;
|
import com.sshmanager.service.SftpService;
|
||||||
|
import com.sshmanager.util.JschUtil;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@@ -150,16 +152,19 @@ public class SftpController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private String toSftpErrorMessage(Exception e, String path, String operation) {
|
private String toSftpErrorMessage(Exception e, String path, String operation) {
|
||||||
if (e.getMessage() != null && !e.getMessage().trim().isEmpty()) {
|
|
||||||
return e.getMessage();
|
|
||||||
}
|
|
||||||
Throwable cur = e;
|
Throwable cur = e;
|
||||||
for (int i = 0; i < 10 && cur != null; i++) {
|
for (int i = 0; i < 10 && cur != null; i++) {
|
||||||
|
// SFTP protocol errors → human-readable format
|
||||||
if (cur instanceof SftpException) {
|
if (cur instanceof SftpException) {
|
||||||
return SftpService.formatSftpExceptionMessage((SftpException) cur, path, operation);
|
return SftpService.formatSftpExceptionMessage((SftpException) cur, path, operation);
|
||||||
}
|
}
|
||||||
if (cur.getMessage() != null && !cur.getMessage().trim().isEmpty()) {
|
String msg = cur.getMessage();
|
||||||
return cur.getMessage();
|
if (msg != null && !msg.trim().isEmpty()) {
|
||||||
|
// Auth failure → translated message
|
||||||
|
if (cur instanceof JSchException || msg.contains("Auth fail") || msg.contains("authentication")) {
|
||||||
|
return JschUtil.formatAuthErrorMessage(msg);
|
||||||
|
}
|
||||||
|
return msg;
|
||||||
}
|
}
|
||||||
cur = cur.getCause();
|
cur = cur.getCause();
|
||||||
}
|
}
|
||||||
@@ -247,9 +252,10 @@ public class SftpController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("SFTP pwd failed: connectionId={}", connectionId, e);
|
String errorMsg = toSftpErrorMessage(e, null, "pwd");
|
||||||
|
log.warn("SFTP pwd failed: connectionId={}, error={}", connectionId, errorMsg, e);
|
||||||
Map<String, String> err = new HashMap<>();
|
Map<String, String> err = new HashMap<>();
|
||||||
err.put("error", e.getMessage() != null ? e.getMessage() : "pwd failed");
|
err.put("error", errorMsg);
|
||||||
return ResponseEntity.status(500).body(err);
|
return ResponseEntity.status(500).body(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -461,8 +467,9 @@ public class SftpController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
String errorMsg = toSftpErrorMessage(e, null, "delete");
|
||||||
Map<String, String> error = new HashMap<>();
|
Map<String, String> error = new HashMap<>();
|
||||||
error.put("error", e.getMessage());
|
error.put("error", errorMsg);
|
||||||
return ResponseEntity.status(500).body(error);
|
return ResponseEntity.status(500).body(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -491,8 +498,9 @@ public class SftpController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
String errorMsg = toSftpErrorMessage(e, null, "mkdir");
|
||||||
Map<String, String> error = new HashMap<>();
|
Map<String, String> error = new HashMap<>();
|
||||||
error.put("error", e.getMessage());
|
error.put("error", errorMsg);
|
||||||
return ResponseEntity.status(500).body(error);
|
return ResponseEntity.status(500).body(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -522,8 +530,9 @@ public class SftpController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
String errorMsg = toSftpErrorMessage(e, null, "rename");
|
||||||
Map<String, String> error = new HashMap<>();
|
Map<String, String> error = new HashMap<>();
|
||||||
error.put("error", e.getMessage());
|
error.put("error", errorMsg);
|
||||||
return ResponseEntity.status(500).body(error);
|
return ResponseEntity.status(500).body(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -548,8 +557,9 @@ public class SftpController {
|
|||||||
result.put("message", "Transferred");
|
result.put("message", "Transferred");
|
||||||
return ResponseEntity.ok(result);
|
return ResponseEntity.ok(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
String errorMsg = toSftpErrorMessage(e, null, "transfer");
|
||||||
Map<String, String> error = new HashMap<>();
|
Map<String, String> error = new HashMap<>();
|
||||||
error.put("error", e.getMessage() != null ? e.getMessage() : "Transfer failed");
|
error.put("error", errorMsg);
|
||||||
return ResponseEntity.status(500).body(error);
|
return ResponseEntity.status(500).body(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,13 @@ import com.jcraft.jsch.SftpATTRS;
|
|||||||
import com.jcraft.jsch.SftpException;
|
import com.jcraft.jsch.SftpException;
|
||||||
import com.jcraft.jsch.SftpProgressMonitor;
|
import com.jcraft.jsch.SftpProgressMonitor;
|
||||||
import com.sshmanager.entity.Connection;
|
import com.sshmanager.entity.Connection;
|
||||||
|
import com.sshmanager.util.JschUtil;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.io.PipedInputStream;
|
import java.io.PipedInputStream;
|
||||||
import java.io.PipedOutputStream;
|
import java.io.PipedOutputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Vector;
|
import java.util.Vector;
|
||||||
@@ -33,29 +33,7 @@ public class SftpService {
|
|||||||
|
|
||||||
public SftpSession connect(Connection conn, String password, String privateKey, String passphrase)
|
public SftpSession connect(Connection conn, String password, String privateKey, String passphrase)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
JSch jsch = new JSch();
|
Session session = JschUtil.createSession(conn, password, privateKey, passphrase);
|
||||||
|
|
||||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
|
||||||
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
|
||||||
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
|
||||||
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
|
||||||
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
|
||||||
session.setConfig("StrictHostKeyChecking", "no");
|
|
||||||
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
|
||||||
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
|
||||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
|
||||||
if (password == null || password.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("Password is required for password authentication");
|
|
||||||
}
|
|
||||||
session.setConfig("PreferredAuthentications", "password");
|
|
||||||
session.setPassword(password);
|
|
||||||
} else {
|
|
||||||
session.setConfig("PreferredAuthentications", "publickey");
|
|
||||||
}
|
|
||||||
session.connect(10000);
|
|
||||||
|
|
||||||
ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
|
ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
|
||||||
channel.connect(5000);
|
channel.connect(5000);
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package com.sshmanager.service;
|
|||||||
|
|
||||||
import com.jcraft.jsch.ChannelExec;
|
import com.jcraft.jsch.ChannelExec;
|
||||||
import com.jcraft.jsch.ChannelShell;
|
import com.jcraft.jsch.ChannelShell;
|
||||||
import com.jcraft.jsch.JSch;
|
|
||||||
import com.jcraft.jsch.Session;
|
import com.jcraft.jsch.Session;
|
||||||
import com.sshmanager.entity.Connection;
|
import com.sshmanager.entity.Connection;
|
||||||
|
import com.sshmanager.util.JschUtil;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
@@ -21,31 +21,7 @@ public class SshService {
|
|||||||
|
|
||||||
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
|
public SshSession createShellSession(Connection conn, String password, String privateKey, String passphrase)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
JSch jsch = new JSch();
|
Session session = JschUtil.createSession(conn, password, privateKey, passphrase);
|
||||||
|
|
||||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
|
||||||
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
|
||||||
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
|
||||||
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
|
||||||
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
|
||||||
session.setConfig("StrictHostKeyChecking", "no");
|
|
||||||
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
|
||||||
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
|
||||||
|
|
||||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
|
||||||
if (password == null || password.isEmpty()) {
|
|
||||||
throw new IllegalArgumentException("Password is required for password authentication");
|
|
||||||
}
|
|
||||||
session.setConfig("PreferredAuthentications", "password");
|
|
||||||
session.setPassword(password);
|
|
||||||
} else {
|
|
||||||
session.setConfig("PreferredAuthentications", "publickey");
|
|
||||||
}
|
|
||||||
|
|
||||||
session.connect(10000);
|
|
||||||
|
|
||||||
ChannelShell channel = (ChannelShell) session.openChannel("shell");
|
ChannelShell channel = (ChannelShell) session.openChannel("shell");
|
||||||
channel.setPtyType("xterm");
|
channel.setPtyType("xterm");
|
||||||
@@ -83,25 +59,7 @@ public class SshService {
|
|||||||
String privateKey,
|
String privateKey,
|
||||||
String passphrase,
|
String passphrase,
|
||||||
String command) throws Exception {
|
String command) throws Exception {
|
||||||
JSch jsch = new JSch();
|
Session session = JschUtil.createSession(conn, password, privateKey, passphrase);
|
||||||
|
|
||||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
|
||||||
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
|
||||||
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
|
||||||
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
|
||||||
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
|
||||||
session.setConfig("StrictHostKeyChecking", "no");
|
|
||||||
session.setConfig("kex", "diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
|
||||||
session.setConfig("PreferredAuthentications", conn.getAuthType() == Connection.AuthType.PASSWORD ? "password" : "publickey");
|
|
||||||
|
|
||||||
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
|
||||||
session.setPassword(password);
|
|
||||||
}
|
|
||||||
|
|
||||||
session.connect(8000);
|
|
||||||
|
|
||||||
ChannelExec channel = (ChannelExec) session.openChannel("exec");
|
ChannelExec channel = (ChannelExec) session.openChannel("exec");
|
||||||
channel.setCommand(command);
|
channel.setCommand(command);
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package com.sshmanager.util;
|
||||||
|
|
||||||
|
import com.jcraft.jsch.JSch;
|
||||||
|
import com.jcraft.jsch.Session;
|
||||||
|
import com.jcraft.jsch.UIKeyboardInteractive;
|
||||||
|
import com.jcraft.jsch.UserInfo;
|
||||||
|
import com.sshmanager.entity.Connection;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared utility for creating JSch SSH sessions with consistent configuration.
|
||||||
|
*
|
||||||
|
* Supports two authentication modes:
|
||||||
|
* <ul>
|
||||||
|
* <li>PASSWORD — sets PreferredAuthentications=password,keyboard-interactive,
|
||||||
|
* provides UserInfo + UIKeyboardInteractive so keyboard-interactive
|
||||||
|
* challenges are answered with the same password.</li>
|
||||||
|
* <li>PRIVATE_KEY — sets PreferredAuthentications=publickey only.</li>
|
||||||
|
* </ul>
|
||||||
|
*/
|
||||||
|
public final class JschUtil {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(JschUtil.class);
|
||||||
|
private static final int CONNECT_TIMEOUT = 10000;
|
||||||
|
|
||||||
|
private JschUtil() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and connect an SSH session for the given connection configuration.
|
||||||
|
*
|
||||||
|
* @param conn the connection entity (host, port, username, authType)
|
||||||
|
* @param password decrypted password (may be null for private-key auth)
|
||||||
|
* @param privateKey decrypted private key PEM (may be null for password auth)
|
||||||
|
* @param passphrase decrypted passphrase for the private key (may be null)
|
||||||
|
* @return connected Session
|
||||||
|
* @throws IllegalArgumentException if required credentials are missing or empty
|
||||||
|
* @throws Exception if JSch fails to connect (auth failure, timeout, etc.)
|
||||||
|
*/
|
||||||
|
public static Session createSession(Connection conn, String password, String privateKey, String passphrase)
|
||||||
|
throws Exception {
|
||||||
|
JSch jsch = new JSch();
|
||||||
|
|
||||||
|
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY) {
|
||||||
|
if (privateKey == null || privateKey.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"SSH authentication failed: private key is empty. Please check the connection key configuration.");
|
||||||
|
}
|
||||||
|
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
||||||
|
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
||||||
|
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
||||||
|
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||||
|
session.setConfig("StrictHostKeyChecking", "no");
|
||||||
|
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
||||||
|
session.setConfig("kex",
|
||||||
|
"diffie-hellman-group-exchange-sha256,diffie-hellman-group14-sha256,"
|
||||||
|
+ "diffie-hellman-group14-sha1,diffie-hellman-group-exchange-sha1");
|
||||||
|
|
||||||
|
if (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
||||||
|
if (password == null || password.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"SSH authentication failed: password is empty. Please check the connection password configuration.");
|
||||||
|
}
|
||||||
|
session.setConfig("PreferredAuthentications", "password,keyboard-interactive");
|
||||||
|
session.setPassword(password);
|
||||||
|
session.setUserInfo(new PasswordAuth(password));
|
||||||
|
} else {
|
||||||
|
session.setConfig("PreferredAuthentications", "publickey");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
session.connect(CONNECT_TIMEOUT);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// Sanitized log: connection metadata only, no secrets
|
||||||
|
log.warn("SSH connection failed: connectionId={}, host={}, port={}, username={}, authType={}, error={}",
|
||||||
|
conn.getId(), conn.getHost(), conn.getPort(), conn.getUsername(), conn.getAuthType(),
|
||||||
|
formatAuthErrorMessage(e.getMessage()));
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate common JSch exception messages into human-readable text.
|
||||||
|
* Returns the original message if no pattern matches.
|
||||||
|
*/
|
||||||
|
public static String formatAuthErrorMessage(String rawMessage) {
|
||||||
|
if (rawMessage == null) {
|
||||||
|
return "SSH connection failed (unknown error)";
|
||||||
|
}
|
||||||
|
String msg = rawMessage.trim();
|
||||||
|
|
||||||
|
// Too many authentication failures — check before generic auth fail
|
||||||
|
// because "Auth fail for methods ..." may appear in the same chain
|
||||||
|
// but "too many" is the actionable root cause.
|
||||||
|
if (msg.toLowerCase().contains("too many authentication failures")) {
|
||||||
|
return "SSH authentication failed: too many failed attempts, server rejected the connection. "
|
||||||
|
+ "Please check the password or authentication method, or try again later.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth fail for methods 'publickey,password'
|
||||||
|
// (may be prefixed with e.g. "com.jcraft.jsch.JSchException: Auth fail ...")
|
||||||
|
int authFailIdx = msg.indexOf("Auth fail for methods ");
|
||||||
|
if (authFailIdx >= 0) {
|
||||||
|
String afterPrefix = msg.substring(authFailIdx + "Auth fail for methods ".length());
|
||||||
|
String methods = afterPrefix.contains("'") ? afterPrefix.substring(0, afterPrefix.lastIndexOf('\'') + 1) : afterPrefix;
|
||||||
|
methods = methods.replace("'", "");
|
||||||
|
return "SSH authentication failed: please check username and password / private key. "
|
||||||
|
+ "Server supports authentication methods: " + methods;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth cancel
|
||||||
|
if (msg.toLowerCase().contains("auth") && msg.toLowerCase().contains("cancel")) {
|
||||||
|
return "SSH authentication was cancelled by the server. Please check your credentials.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection refused
|
||||||
|
if (msg.toLowerCase().contains("connection refused")) {
|
||||||
|
return "SSH connection refused. Please check that the host address and port are correct and the SSH service is running.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection timeout
|
||||||
|
if (msg.toLowerCase().contains("timeout") || msg.toLowerCase().contains("timed out")) {
|
||||||
|
return "SSH connection timed out. Please check network connectivity and host reachability.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Host key verification (should not happen with StrictHostKeyChecking=no, but just in case)
|
||||||
|
if (msg.toLowerCase().contains("hostkey") || msg.toLowerCase().contains("host key")) {
|
||||||
|
return "SSH host key verification failed. The server may have changed its host key.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown host
|
||||||
|
if (msg.toLowerCase().contains("unknown host") || msg.toLowerCase().contains("unable to resolve")) {
|
||||||
|
return "Unable to resolve host address. Please check the hostname.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Socket / IO errors
|
||||||
|
if (msg.toLowerCase().contains("socket") || msg.toLowerCase().contains("i/o error")) {
|
||||||
|
return "Network I/O error during SSH connection. Please check network connectivity.";
|
||||||
|
}
|
||||||
|
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines UserInfo and UIKeyboardInteractive so that password-based auth
|
||||||
|
* also works when the server uses keyboard-interactive (e.g. some OpenSSH
|
||||||
|
* configurations that prompt for password via keyboard-interactive).
|
||||||
|
*
|
||||||
|
* After one password attempt, subsequent prompts return false / empty
|
||||||
|
* to prevent the same wrong password from being retried until the
|
||||||
|
* server closes the connection with "Too many authentication failures".
|
||||||
|
*/
|
||||||
|
static class PasswordAuth implements UserInfo, UIKeyboardInteractive {
|
||||||
|
private final String password;
|
||||||
|
private boolean passwordAttempted = false;
|
||||||
|
|
||||||
|
PasswordAuth(String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean promptYesNo(String message) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String getPassphrase() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean promptPassphrase(String message) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean promptPassword(String message) {
|
||||||
|
// Allow exactly one password prompt; reject retries with the same wrong credential
|
||||||
|
if (passwordAttempted) return false;
|
||||||
|
passwordAttempted = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void showMessage(String message) {
|
||||||
|
// No-op
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String[] promptKeyboardInteractive(String destination, String name,
|
||||||
|
String instruction, String[] prompt, boolean[] echo) {
|
||||||
|
String[] result = new String[prompt.length];
|
||||||
|
for (int i = 0; i < prompt.length; i++) {
|
||||||
|
if (echo[i]) {
|
||||||
|
result[i] = "";
|
||||||
|
} else if (!passwordAttempted) {
|
||||||
|
result[i] = password;
|
||||||
|
} else {
|
||||||
|
result[i] = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
passwordAttempted = true;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package com.sshmanager.util;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class JschUtilTest {
|
||||||
|
|
||||||
|
// ── formatAuthErrorMessage ──────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatTooManyAuthFailures() {
|
||||||
|
String raw = "Too many authentication failures";
|
||||||
|
String result = JschUtil.formatAuthErrorMessage(raw);
|
||||||
|
assertTrue(result.contains("too many failed attempts"), "Should detect too many authentication failures");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatTooManyAuthFailuresPrefixed() {
|
||||||
|
String raw = "com.jcraft.jsch.JSchException: Too many authentication failures";
|
||||||
|
String result = JschUtil.formatAuthErrorMessage(raw);
|
||||||
|
assertTrue(result.contains("too many failed attempts"), "Should detect prefixed variant");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatConnectionResetWithTooManyAuth() {
|
||||||
|
// Connection reset wrapping the real cause — the formatter sees the raw message
|
||||||
|
// (toSftpErrorMessage in the controller unwraps the cause chain separately)
|
||||||
|
String raw = "Connection reset";
|
||||||
|
String result = JschUtil.formatAuthErrorMessage(raw);
|
||||||
|
// "Connection reset" by itself does NOT match "too many auth failures" in
|
||||||
|
// formatAuthErrorMessage alone — the controller's toSftpErrorMessage
|
||||||
|
// walks the cause chain to find the root JSchException first.
|
||||||
|
// This test verifies the formatter doesn't crash on unrelated messages.
|
||||||
|
assertNotNull(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatAuthFailPrefixed() {
|
||||||
|
String raw = "com.jcraft.jsch.JSchException: Auth fail for methods 'publickey,password'";
|
||||||
|
String result = JschUtil.formatAuthErrorMessage(raw);
|
||||||
|
assertTrue(result.contains("authentication failed"), "Should detect auth failure");
|
||||||
|
assertTrue(result.contains("publickey,password"), "Should extract supported methods");
|
||||||
|
assertFalse(result.contains("JSchException"), "Should not contain raw exception class name");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatAuthFailPlain() {
|
||||||
|
String raw = "Auth fail for methods 'password,keyboard-interactive'";
|
||||||
|
String result = JschUtil.formatAuthErrorMessage(raw);
|
||||||
|
assertTrue(result.contains("authentication failed"), "Should detect auth failure");
|
||||||
|
assertTrue(result.contains("keyboard-interactive"), "Should extract methods");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatConnectionRefused() {
|
||||||
|
String result = JschUtil.formatAuthErrorMessage("Connection refused: connect");
|
||||||
|
assertTrue(result.contains("connection refused"), "Should detect connection refused");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatTimeout() {
|
||||||
|
String result = JschUtil.formatAuthErrorMessage("connect timed out");
|
||||||
|
assertTrue(result.contains("timed out"), "Should detect timeout");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatUnknownHost() {
|
||||||
|
String result = JschUtil.formatAuthErrorMessage("unknown host: example.invalid");
|
||||||
|
assertTrue(result.contains("resolve host"), "Should detect unknown host");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatUnrecognizedReturnsOriginal() {
|
||||||
|
String raw = "Some random error";
|
||||||
|
String result = JschUtil.formatAuthErrorMessage(raw);
|
||||||
|
assertEquals(raw, result, "Unrecognized messages should pass through unchanged");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void formatNullReturnsDefault() {
|
||||||
|
String result = JschUtil.formatAuthErrorMessage(null);
|
||||||
|
assertTrue(result.contains("unknown error"), "Null input should return a fallback message");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── PasswordAuth behavior ───────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void passwordAuthFirstPromptPasswordReturnsTrue() {
|
||||||
|
// Use reflection-like approach via the public API: instantiation is package-private
|
||||||
|
// so we test the behavior indirectly through known contracts
|
||||||
|
String password = "test-password";
|
||||||
|
JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password);
|
||||||
|
|
||||||
|
assertTrue(auth.promptPassword("Password:"), "First promptPassword should return true");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void passwordAuthSecondPromptPasswordReturnsFalse() {
|
||||||
|
String password = "test-password";
|
||||||
|
JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password);
|
||||||
|
|
||||||
|
auth.promptPassword("Password:"); // first call
|
||||||
|
assertFalse(auth.promptPassword("Password:"), "Second promptPassword should return false");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void passwordAuthKeyboardInteractiveFillsPasswordOnce() {
|
||||||
|
String password = "test-password";
|
||||||
|
JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password);
|
||||||
|
|
||||||
|
String[] prompt = {"Password:", "OTP:"};
|
||||||
|
boolean[] echo = {false, false};
|
||||||
|
|
||||||
|
// First call — fill non-echo prompts with password
|
||||||
|
String[] result1 = auth.promptKeyboardInteractive("host", "ssh", "", prompt, echo);
|
||||||
|
assertEquals(password, result1[0], "First keyboard-interactive should return password");
|
||||||
|
assertEquals(password, result1[1], "Should fill all non-echo prompts");
|
||||||
|
|
||||||
|
// Second call — no more password
|
||||||
|
String[] result2 = auth.promptKeyboardInteractive("host", "ssh", "", prompt, echo);
|
||||||
|
assertEquals("", result2[0], "Second keyboard-interactive should return empty");
|
||||||
|
assertEquals("", result2[1], "Second call should not fill password");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void passwordAuthGetPasswordReturnsOriginal() {
|
||||||
|
String password = "test-password";
|
||||||
|
JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password);
|
||||||
|
assertEquals(password, auth.getPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void passwordAuthEchoPromptsAreLeftEmpty() {
|
||||||
|
String password = "test-password";
|
||||||
|
JschUtil.PasswordAuth auth = new JschUtil.PasswordAuth(password);
|
||||||
|
|
||||||
|
String[] prompt = {"Username:", "Password:"};
|
||||||
|
boolean[] echo = {true, false}; // username is echo, password is not
|
||||||
|
|
||||||
|
String[] result = auth.promptKeyboardInteractive("host", "ssh", "", prompt, echo);
|
||||||
|
assertEquals("", result[0], "Echo prompt should be empty");
|
||||||
|
assertEquals(password, result[1], "Non-echo prompt should get password");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,13 +6,17 @@ export default function Modal({
|
|||||||
children,
|
children,
|
||||||
footer,
|
footer,
|
||||||
maxWidth = 'max-w-3xl',
|
maxWidth = 'max-w-3xl',
|
||||||
|
open = true,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
footer?: React.ReactNode
|
footer?: React.ReactNode
|
||||||
maxWidth?: string
|
maxWidth?: string
|
||||||
|
open?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm">
|
||||||
<div className={`flex max-h-[92vh] w-full flex-col overflow-hidden rounded-3xl border border-slate-700 bg-slate-900 ${maxWidth}`}>
|
<div className={`flex max-h-[92vh] w-full flex-col overflow-hidden rounded-3xl border border-slate-700 bg-slate-900 ${maxWidth}`}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
FileUp,
|
FileUp,
|
||||||
@@ -68,6 +68,35 @@ export default function TransferCenterModal({
|
|||||||
const [remoteTargetPath, setRemoteTargetPath] = useState('/data/deploy/')
|
const [remoteTargetPath, setRemoteTargetPath] = useState('/data/deploy/')
|
||||||
const [showBrowser, setShowBrowser] = useState(false)
|
const [showBrowser, setShowBrowser] = useState(false)
|
||||||
|
|
||||||
|
// ── sync remoteSourceId when connections or statuses change ──
|
||||||
|
useEffect(() => {
|
||||||
|
if (connections.length === 0) return
|
||||||
|
setRemoteSourceId((prev) => {
|
||||||
|
if (prev > 0 && connections.some((c) => c.id === prev)) {
|
||||||
|
// Keep current selection if it's online or status is still loading
|
||||||
|
const st = connectionStatuses[prev]
|
||||||
|
if (st === 'online' || !st) return prev
|
||||||
|
// current source is offline/unknown — re-evaluate
|
||||||
|
const firstOnline = connections.find((c) => connectionStatuses[c.id] === 'online')
|
||||||
|
return firstOnline?.id ?? prev
|
||||||
|
}
|
||||||
|
return connections.find((c) => connectionStatuses[c.id] === 'online')?.id ?? connections[0]?.id ?? 0
|
||||||
|
})
|
||||||
|
}, [connections, connectionStatuses])
|
||||||
|
|
||||||
|
// ── derived state ──
|
||||||
|
const selectedRemoteSource = useMemo(
|
||||||
|
() => connections.find((c) => c.id === remoteSourceId) ?? null,
|
||||||
|
[connections, remoteSourceId],
|
||||||
|
)
|
||||||
|
const isRemoteSourceValid = remoteSourceId > 0 && selectedRemoteSource !== null
|
||||||
|
const isRemoteSourceOnline = isRemoteSourceValid && connectionStatuses[remoteSourceId] === 'online'
|
||||||
|
|
||||||
|
// In remote-distribution mode, exclude the source server from the effective target list
|
||||||
|
const effectiveTargetIds = tab === 'remote' ? targetIds.filter((id) => id !== remoteSourceId) : targetIds
|
||||||
|
const remoteStartDisabled =
|
||||||
|
!isRemoteSourceValid || !isRemoteSourceOnline || effectiveTargetIds.length === 0 || !remoteSourcePath.trim()
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
function updateTaskGroup(groupId: string, updater: (group: TransferTaskGroup) => TransferTaskGroup) {
|
function updateTaskGroup(groupId: string, updater: (group: TransferTaskGroup) => TransferTaskGroup) {
|
||||||
@@ -130,7 +159,8 @@ export default function TransferCenterModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleStartRemote() {
|
async function handleStartRemote() {
|
||||||
if (!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()) return
|
const targets = effectiveTargetIds
|
||||||
|
if (!isRemoteSourceValid || !isRemoteSourceOnline || targets.length === 0 || !remoteSourcePath.trim()) return
|
||||||
const groupId = String(Date.now())
|
const groupId = String(Date.now())
|
||||||
const sourceName = remoteSourcePath.trim().split('/').filter(Boolean).pop() || remoteSourcePath.trim()
|
const sourceName = remoteSourcePath.trim().split('/').filter(Boolean).pop() || remoteSourcePath.trim()
|
||||||
const group: TransferTaskGroup = {
|
const group: TransferTaskGroup = {
|
||||||
@@ -140,7 +170,7 @@ export default function TransferCenterModal({
|
|||||||
status: 'running',
|
status: 'running',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
createdAt: new Date().toLocaleTimeString(),
|
createdAt: new Date().toLocaleTimeString(),
|
||||||
items: targetIds.map((id) => ({
|
items: targets.map((id) => ({
|
||||||
id: `${groupId}-${id}`,
|
id: `${groupId}-${id}`,
|
||||||
label: connections.find((item) => item.id === id)?.name || String(id),
|
label: connections.find((item) => item.id === id)?.name || String(id),
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
@@ -150,7 +180,7 @@ export default function TransferCenterModal({
|
|||||||
}
|
}
|
||||||
onTasksChange((prev) => [group, ...prev])
|
onTasksChange((prev) => [group, ...prev])
|
||||||
|
|
||||||
targetIds.forEach(async (targetId) => {
|
targets.forEach(async (targetId) => {
|
||||||
const response = await createRemoteTransferTask(remoteSourceId, remoteSourcePath, targetId, remoteTargetPath)
|
const response = await createRemoteTransferTask(remoteSourceId, remoteSourcePath, targetId, remoteTargetPath)
|
||||||
const taskId = response.data.taskId
|
const taskId = response.data.taskId
|
||||||
updateTaskGroup(groupId, (current) => ({
|
updateTaskGroup(groupId, (current) => ({
|
||||||
@@ -339,9 +369,9 @@ export default function TransferCenterModal({
|
|||||||
</div>
|
</div>
|
||||||
<select
|
<select
|
||||||
className={`w-full rounded-xl border bg-black px-4 py-3 text-sm text-white ${
|
className={`w-full rounded-xl border bg-black px-4 py-3 text-sm text-white ${
|
||||||
connectionStatuses[remoteSourceId] === 'online'
|
isRemoteSourceOnline
|
||||||
? 'border-emerald-700'
|
? 'border-emerald-700'
|
||||||
: connectionStatuses[remoteSourceId] === 'offline'
|
: isRemoteSourceValid && connectionStatuses[remoteSourceId] === 'offline'
|
||||||
? 'border-red-800'
|
? 'border-red-800'
|
||||||
: 'border-slate-700'
|
: 'border-slate-700'
|
||||||
}`}
|
}`}
|
||||||
@@ -351,6 +381,11 @@ export default function TransferCenterModal({
|
|||||||
setShowBrowser(false)
|
setShowBrowser(false)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{!isRemoteSourceValid && (
|
||||||
|
<option value={0} disabled>
|
||||||
|
请选择源服务器
|
||||||
|
</option>
|
||||||
|
)}
|
||||||
{connections.map((server) => {
|
{connections.map((server) => {
|
||||||
const st = connectionStatuses[server.id]
|
const st = connectionStatuses[server.id]
|
||||||
const isOnline = st === 'online'
|
const isOnline = st === 'online'
|
||||||
@@ -414,13 +449,24 @@ export default function TransferCenterModal({
|
|||||||
<span className="text-sm text-slate-300">目标服务器</span>
|
<span className="text-sm text-slate-300">目标服务器</span>
|
||||||
<button
|
<button
|
||||||
className="text-xs text-blue-400"
|
className="text-xs text-blue-400"
|
||||||
onClick={() => setTargetIds(connections.filter((c) => connectionStatuses[c.id] === 'online').map((c) => c.id))}
|
onClick={() =>
|
||||||
|
setTargetIds(
|
||||||
|
tab === 'remote'
|
||||||
|
? connections
|
||||||
|
.filter((c) => c.id !== remoteSourceId && connectionStatuses[c.id] === 'online')
|
||||||
|
.map((c) => c.id)
|
||||||
|
: connections.filter((c) => connectionStatuses[c.id] === 'online').map((c) => c.id),
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
全选在线
|
全选在线
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-slate-700 bg-black p-2">
|
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-slate-700 bg-black p-2">
|
||||||
{connections.map((server) => {
|
{(tab === 'remote'
|
||||||
|
? connections.filter((c) => c.id !== remoteSourceId)
|
||||||
|
: connections
|
||||||
|
).map((server) => {
|
||||||
const st = connectionStatuses[server.id]
|
const st = connectionStatuses[server.id]
|
||||||
const isOnline = st === 'online'
|
const isOnline = st === 'online'
|
||||||
return (
|
return (
|
||||||
@@ -450,21 +496,23 @@ export default function TransferCenterModal({
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
|
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
|
||||||
!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()
|
remoteStartDisabled
|
||||||
? 'cursor-not-allowed bg-slate-800 text-slate-500'
|
? 'cursor-not-allowed bg-slate-800 text-slate-500'
|
||||||
: 'bg-purple-600 text-white hover:bg-purple-500'
|
: 'bg-purple-600 text-white hover:bg-purple-500'
|
||||||
}`}
|
}`}
|
||||||
disabled={!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()}
|
disabled={remoteStartDisabled}
|
||||||
onClick={() => void handleStartRemote()}
|
onClick={() => void handleStartRemote()}
|
||||||
>
|
>
|
||||||
<Zap size={18} fill="currentColor" />
|
<Zap size={18} fill="currentColor" />
|
||||||
{!remoteSourceId
|
{!isRemoteSourceValid || !remoteSourceId
|
||||||
? '请选择源服务器'
|
? '请选择源服务器'
|
||||||
: !remoteSourcePath.trim()
|
: !isRemoteSourceOnline
|
||||||
? '请填写源路径'
|
? '源服务器未在线'
|
||||||
: targetIds.length === 0
|
: !remoteSourcePath.trim()
|
||||||
? '请选择目标服务器'
|
? '请填写源路径'
|
||||||
: '跨服同步分发'}
|
: effectiveTargetIds.length === 0
|
||||||
|
? '请选择目标服务器'
|
||||||
|
: '跨服同步分发'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { startTransition, useEffect, useMemo, useState } from 'react'
|
import { startTransition, useEffect, useMemo, useReducer, useState } from 'react'
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Command,
|
Command,
|
||||||
@@ -104,6 +104,131 @@ function omitConnectionIdsFromRecord<T>(record: Record<number, T>, deletedConnec
|
|||||||
) as Record<number, T>
|
) as Record<number, T>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createTabId(connectionId: number) {
|
||||||
|
return `${connectionId}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TabState {
|
||||||
|
tabs: WorkspaceTab[]
|
||||||
|
currentTabKey: string | null
|
||||||
|
terminalStatuses: Record<string, TerminalConnectionStatus>
|
||||||
|
}
|
||||||
|
|
||||||
|
type TabAction =
|
||||||
|
| { type: 'ACTIVATE_TAB'; tabId: string }
|
||||||
|
| { type: 'OPEN_CONNECTION'; connection: Connection; tabId: string }
|
||||||
|
| { type: 'CLOSE_TAB'; tabId: string }
|
||||||
|
| { type: 'CLOSE_ALL' }
|
||||||
|
| { type: 'DUPLICATE_TAB'; tabId: string; newTabId: string }
|
||||||
|
| { type: 'UPDATE_CONNECTION'; connectionId: number; name: string; connection: Connection }
|
||||||
|
| { type: 'REMOVE_CONNECTIONS'; connectionIds: Set<number> }
|
||||||
|
| { type: 'SET_TERMINAL_STATUS'; tabId: string; status: TerminalConnectionStatus }
|
||||||
|
|
||||||
|
const initialTabState: TabState = {
|
||||||
|
tabs: [],
|
||||||
|
currentTabKey: null,
|
||||||
|
terminalStatuses: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabReducer(state: TabState, action: TabAction): TabState {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'ACTIVATE_TAB': {
|
||||||
|
if (state.currentTabKey === action.tabId) return state
|
||||||
|
return { ...state, currentTabKey: action.tabId }
|
||||||
|
}
|
||||||
|
case 'OPEN_CONNECTION': {
|
||||||
|
const existing = state.tabs.find((tab) => tab.connection.id === action.connection.id)
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
currentTabKey: existing.tabId,
|
||||||
|
tabs: state.tabs.map((tab) =>
|
||||||
|
tab.tabId === existing.tabId
|
||||||
|
? { ...tab, name: action.connection.name, connection: action.connection }
|
||||||
|
: tab,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tabs: [...state.tabs, { tabId: action.tabId, name: action.connection.name, connection: action.connection }],
|
||||||
|
currentTabKey: action.tabId,
|
||||||
|
terminalStatuses: { ...state.terminalStatuses, [action.tabId]: 'connecting' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'CLOSE_TAB': {
|
||||||
|
const next = state.tabs.filter((tab) => tab.tabId !== action.tabId)
|
||||||
|
const nextStatuses = { ...state.terminalStatuses }
|
||||||
|
delete nextStatuses[action.tabId]
|
||||||
|
return {
|
||||||
|
tabs: next,
|
||||||
|
currentTabKey:
|
||||||
|
state.currentTabKey === action.tabId
|
||||||
|
? next[next.length - 1]?.tabId ?? null
|
||||||
|
: state.currentTabKey,
|
||||||
|
terminalStatuses: nextStatuses,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'CLOSE_ALL':
|
||||||
|
return { tabs: [], currentTabKey: null, terminalStatuses: {} }
|
||||||
|
case 'DUPLICATE_TAB': {
|
||||||
|
const idx = state.tabs.findIndex((tab) => tab.tabId === action.tabId)
|
||||||
|
if (idx === -1) return state
|
||||||
|
const source = state.tabs[idx]
|
||||||
|
const newTab: WorkspaceTab = {
|
||||||
|
tabId: action.newTabId,
|
||||||
|
name: source.name,
|
||||||
|
connection: source.connection,
|
||||||
|
}
|
||||||
|
const next = [...state.tabs]
|
||||||
|
next.splice(idx + 1, 0, newTab)
|
||||||
|
return {
|
||||||
|
tabs: next,
|
||||||
|
currentTabKey: action.newTabId,
|
||||||
|
terminalStatuses: { ...state.terminalStatuses, [action.newTabId]: 'connecting' },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'UPDATE_CONNECTION': {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
tabs: state.tabs.map((tab) =>
|
||||||
|
tab.connection.id === action.connectionId
|
||||||
|
? { ...tab, name: action.name, connection: action.connection }
|
||||||
|
: tab,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'REMOVE_CONNECTIONS': {
|
||||||
|
const remaining = state.tabs.filter((tab) => !action.connectionIds.has(tab.connection.id))
|
||||||
|
const removedTabIds = new Set(
|
||||||
|
state.tabs
|
||||||
|
.filter((tab) => action.connectionIds.has(tab.connection.id))
|
||||||
|
.map((tab) => tab.tabId),
|
||||||
|
)
|
||||||
|
const nextStatuses = { ...state.terminalStatuses }
|
||||||
|
for (const tabId of removedTabIds) {
|
||||||
|
delete nextStatuses[tabId]
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tabs: remaining,
|
||||||
|
currentTabKey:
|
||||||
|
state.currentTabKey != null && removedTabIds.has(state.currentTabKey)
|
||||||
|
? remaining[remaining.length - 1]?.tabId ?? null
|
||||||
|
: state.currentTabKey,
|
||||||
|
terminalStatuses: nextStatuses,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'SET_TERMINAL_STATUS': {
|
||||||
|
if (state.terminalStatuses[action.tabId] === action.status) return state
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
terminalStatuses: { ...state.terminalStatuses, [action.tabId]: action.status },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function WorkspacePage({
|
export default function WorkspacePage({
|
||||||
initialTool,
|
initialTool,
|
||||||
onLogout,
|
onLogout,
|
||||||
@@ -117,8 +242,8 @@ export default function WorkspacePage({
|
|||||||
const [layout, setLayout] = useState<WorkspaceLayout>('split')
|
const [layout, setLayout] = useState<WorkspaceLayout>('split')
|
||||||
const [treeLayout, setTreeLayout] = useState<SessionTreeLayoutPayload | null>(null)
|
const [treeLayout, setTreeLayout] = useState<SessionTreeLayoutPayload | null>(null)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [tabs, setTabs] = useState<WorkspaceTab[]>([])
|
const [tabState, dispatchTab] = useReducer(tabReducer, initialTabState)
|
||||||
const [currentTabId, setCurrentTabId] = useState<number | null>(null)
|
const { tabs, currentTabKey, terminalStatuses } = tabState
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
||||||
const [showConnectionModal, setShowConnectionModal] = useState(false)
|
const [showConnectionModal, setShowConnectionModal] = useState(false)
|
||||||
@@ -135,9 +260,13 @@ export default function WorkspacePage({
|
|||||||
const [connectionStatusDetails, setConnectionStatusDetails] = useState<Record<number, ConnectionStatusItem>>({})
|
const [connectionStatusDetails, setConnectionStatusDetails] = useState<Record<number, ConnectionStatusItem>>({})
|
||||||
const [connectionStatusError, setConnectionStatusError] = useState<string | null>(null)
|
const [connectionStatusError, setConnectionStatusError] = useState<string | null>(null)
|
||||||
const [connectionStatusLoading, setConnectionStatusLoading] = useState(false)
|
const [connectionStatusLoading, setConnectionStatusLoading] = useState(false)
|
||||||
const [terminalStatuses, setTerminalStatuses] = useState<Record<number, TerminalConnectionStatus>>({})
|
|
||||||
const [treeContextMenu, setTreeContextMenu] = useState<TreeContextMenuState>(closedTreeContextMenu)
|
const [treeContextMenu, setTreeContextMenu] = useState<TreeContextMenuState>(closedTreeContextMenu)
|
||||||
const [tabContextMenu, setTabContextMenu] = useState({ visible: false, x: 0, y: 0 })
|
const [tabContextMenu, setTabContextMenu] = useState<{ visible: boolean; x: number; y: number; targetTabId: string | null }>({
|
||||||
|
visible: false,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
targetTabId: null,
|
||||||
|
})
|
||||||
const [terminalFontSize, setTerminalFontSize] = useLocalStorage('ssh-manager.terminal-font-size', 14)
|
const [terminalFontSize, setTerminalFontSize] = useLocalStorage('ssh-manager.terminal-font-size', 14)
|
||||||
const [terminalFontFamily, setTerminalFontFamily] = useLocalStorage(
|
const [terminalFontFamily, setTerminalFontFamily] = useLocalStorage(
|
||||||
'ssh-manager.terminal-font-family',
|
'ssh-manager.terminal-font-family',
|
||||||
@@ -200,9 +329,9 @@ export default function WorkspacePage({
|
|||||||
|
|
||||||
const treeNodes = useMemo(() => buildSessionTree(treeLayout, connections), [treeLayout, connections])
|
const treeNodes = useMemo(() => buildSessionTree(treeLayout, connections), [treeLayout, connections])
|
||||||
const folderOptions = useMemo(() => listSessionFolderOptions(treeLayout, connections), [treeLayout, connections])
|
const folderOptions = useMemo(() => listSessionFolderOptions(treeLayout, connections), [treeLayout, connections])
|
||||||
const openConnectionIds = useMemo(() => tabs.map((tab) => tab.id), [tabs])
|
const openConnectionIds = useMemo(() => [...new Set(tabs.map((tab) => tab.connection.id))], [tabs])
|
||||||
const activeConnection = tabs.find((tab) => tab.id === currentTabId)?.connection ?? null
|
const activeConnection = tabs.find((tab) => tab.tabId === currentTabKey)?.connection ?? null
|
||||||
const activeTerminalStatus = activeConnection ? terminalStatuses[activeConnection.id] ?? 'connecting' : 'idle'
|
const activeTerminalStatus = activeConnection ? terminalStatuses[currentTabKey ?? ''] ?? 'connecting' : 'idle'
|
||||||
const hasFolders = treeLayout?.nodes.some((node) => node.type === 'folder') ?? false
|
const hasFolders = treeLayout?.nodes.some((node) => node.type === 'folder') ?? false
|
||||||
const hasCollapsedFolders = treeLayout?.nodes.some((node) => node.type === 'folder' && node.expanded === false) ?? false
|
const hasCollapsedFolders = treeLayout?.nodes.some((node) => node.type === 'folder' && node.expanded === false) ?? false
|
||||||
const connectionModalFolderId = useMemo(
|
const connectionModalFolderId = useMemo(
|
||||||
@@ -328,17 +457,7 @@ export default function WorkspacePage({
|
|||||||
|
|
||||||
function openConnection(connection: Connection, nodeId?: string | null) {
|
function openConnection(connection: Connection, nodeId?: string | null) {
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
setTerminalStatuses((prev) => (prev[connection.id] ? prev : { ...prev, [connection.id]: 'connecting' }))
|
dispatchTab({ type: 'OPEN_CONNECTION', connection, tabId: createTabId(connection.id) })
|
||||||
setTabs((prev) => {
|
|
||||||
const existing = prev.find((tab) => tab.id === connection.id)
|
|
||||||
if (existing) {
|
|
||||||
return prev.map((tab) =>
|
|
||||||
tab.id === connection.id ? { ...tab, name: connection.name, connection } : tab,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return [...prev, { id: connection.id, name: connection.name, connection }]
|
|
||||||
})
|
|
||||||
setCurrentTabId(connection.id)
|
|
||||||
setSelectedFiles([])
|
setSelectedFiles([])
|
||||||
if (nodeId) {
|
if (nodeId) {
|
||||||
setSelectedNodeId(nodeId)
|
setSelectedNodeId(nodeId)
|
||||||
@@ -419,17 +538,10 @@ export default function WorkspacePage({
|
|||||||
|
|
||||||
const deletedConnectionIds = new Set(connectionIds)
|
const deletedConnectionIds = new Set(connectionIds)
|
||||||
setConnections((prev) => prev.filter((connection) => !deletedConnectionIds.has(connection.id)))
|
setConnections((prev) => prev.filter((connection) => !deletedConnectionIds.has(connection.id)))
|
||||||
setTabs((prev) => {
|
dispatchTab({ type: 'REMOVE_CONNECTIONS', connectionIds: deletedConnectionIds })
|
||||||
const next = prev.filter((tab) => !deletedConnectionIds.has(tab.id))
|
|
||||||
setCurrentTabId((current) =>
|
|
||||||
current != null && deletedConnectionIds.has(current) ? next[next.length - 1]?.id ?? null : current,
|
|
||||||
)
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
setSelectedFiles([])
|
setSelectedFiles([])
|
||||||
setConnectionStatuses((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
setConnectionStatuses((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
||||||
setConnectionStatusDetails((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
setConnectionStatusDetails((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
||||||
setTerminalStatuses((prev) => omitConnectionIdsFromRecord(prev, deletedConnectionIds))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleEditTreeItem() {
|
async function handleEditTreeItem() {
|
||||||
@@ -512,9 +624,12 @@ export default function WorkspacePage({
|
|||||||
|
|
||||||
if (connectionToEdit) {
|
if (connectionToEdit) {
|
||||||
setSelectedNodeId(nodeId)
|
setSelectedNodeId(nodeId)
|
||||||
setTabs((prev) =>
|
dispatchTab({
|
||||||
prev.map((tab) => (tab.id === nextConnection.id ? { ...tab, name: nextConnection.name, connection: nextConnection } : tab)),
|
type: 'UPDATE_CONNECTION',
|
||||||
)
|
connectionId: nextConnection.id,
|
||||||
|
name: nextConnection.name,
|
||||||
|
connection: nextConnection,
|
||||||
|
})
|
||||||
setConnectionStatusDetails((prev) =>
|
setConnectionStatusDetails((prev) =>
|
||||||
prev[nextConnection.id]
|
prev[nextConnection.id]
|
||||||
? {
|
? {
|
||||||
@@ -532,27 +647,24 @@ export default function WorkspacePage({
|
|||||||
openConnection(nextConnection, nodeId)
|
openConnection(nextConnection, nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCloseTab(id: number) {
|
function handleCloseTab(tabId: string) {
|
||||||
setTabs((prev) => {
|
dispatchTab({ type: 'CLOSE_TAB', tabId })
|
||||||
const next = prev.filter((tab) => tab.id !== id)
|
|
||||||
setCurrentTabId((current) => (current === id ? next[next.length - 1]?.id ?? null : current))
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
setTerminalStatuses((prev) => {
|
|
||||||
const next = { ...prev }
|
|
||||||
delete next[id]
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeTabContextMenu() {
|
function closeTabContextMenu() {
|
||||||
setTabContextMenu({ visible: false, x: 0, y: 0 })
|
setTabContextMenu({ visible: false, x: 0, y: 0, targetTabId: null })
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAllTabs() {
|
function closeAllTabs() {
|
||||||
setTabs([])
|
dispatchTab({ type: 'CLOSE_ALL' })
|
||||||
setCurrentTabId(null)
|
setSelectedFiles([])
|
||||||
setTerminalStatuses({})
|
closeTabContextMenu()
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateTab(tabId: string) {
|
||||||
|
const sourceTab = tabs.find((tab) => tab.tabId === tabId)
|
||||||
|
if (!sourceTab) return
|
||||||
|
dispatchTab({ type: 'DUPLICATE_TAB', tabId, newTabId: createTabId(sourceTab.connection.id) })
|
||||||
setSelectedFiles([])
|
setSelectedFiles([])
|
||||||
closeTabContextMenu()
|
closeTabContextMenu()
|
||||||
}
|
}
|
||||||
@@ -685,7 +797,7 @@ export default function WorkspacePage({
|
|||||||
>
|
>
|
||||||
<SessionTree
|
<SessionTree
|
||||||
nodes={treeNodes}
|
nodes={treeNodes}
|
||||||
activeConnectionId={currentTabId}
|
activeConnectionId={activeConnection?.id ?? null}
|
||||||
connectionStatuses={connectionStatuses}
|
connectionStatuses={connectionStatuses}
|
||||||
openConnectionIds={openConnectionIds}
|
openConnectionIds={openConnectionIds}
|
||||||
selectedNodeId={selectedNodeId}
|
selectedNodeId={selectedNodeId}
|
||||||
@@ -703,25 +815,26 @@ export default function WorkspacePage({
|
|||||||
{tabs.length === 0 ? <div className="h-full flex-1" /> : null}
|
{tabs.length === 0 ? <div className="h-full flex-1" /> : null}
|
||||||
{tabs.map((tab) => (
|
{tabs.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.id}
|
key={tab.tabId}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group flex h-full min-w-[160px] max-w-[220px] items-center gap-2 border-r border-slate-800 px-4 text-sm transition',
|
'group flex h-full min-w-[160px] max-w-[220px] items-center gap-2 border-r border-slate-800 px-4 text-sm transition',
|
||||||
currentTabId === tab.id ? 'bg-[#0d1117] text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-200',
|
currentTabKey === tab.tabId ? 'bg-[#0d1117] text-white' : 'text-slate-400 hover:bg-slate-800 hover:text-slate-200',
|
||||||
)}
|
)}
|
||||||
onClick={() => setCurrentTabId(tab.id)}
|
onClick={() => dispatchTab({ type: 'ACTIVATE_TAB', tabId: tab.tabId })}
|
||||||
onContextMenu={(event) => {
|
onContextMenu={(event) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
closeTreeContextMenu()
|
closeTreeContextMenu()
|
||||||
setTabContextMenu({ visible: true, x: event.clientX, y: event.clientY })
|
const position = getClampedContextMenuPosition(event.clientX, event.clientY, 2)
|
||||||
|
setTabContextMenu({ visible: true, x: position.x, y: position.y, targetTabId: tab.tabId })
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Terminal size={14} className={currentTabId === tab.id ? 'text-emerald-400' : 'text-slate-500'} />
|
<Terminal size={14} className={currentTabKey === tab.tabId ? 'text-emerald-400' : 'text-slate-500'} />
|
||||||
<span className="flex-1 truncate">{tab.name}</span>
|
<span className="flex-1 truncate">{tab.name}</span>
|
||||||
<span
|
<span
|
||||||
className="opacity-0 transition group-hover:opacity-100"
|
className="opacity-0 transition group-hover:opacity-100"
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
handleCloseTab(tab.id)
|
handleCloseTab(tab.tabId)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
@@ -808,18 +921,16 @@ export default function WorkspacePage({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const visible = tab.id === currentTabId && layout !== 'sftp'
|
const visible = tab.tabId === currentTabKey && layout !== 'sftp'
|
||||||
return (
|
return (
|
||||||
<div key={tab.id} className={cn('absolute inset-0', !visible && 'hidden')}>
|
<div key={tab.tabId} className={cn('absolute inset-0', !visible && 'hidden')}>
|
||||||
<TerminalPane
|
<TerminalPane
|
||||||
connection={tab.connection}
|
connection={tab.connection}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
fontSize={terminalFontSize}
|
fontSize={terminalFontSize}
|
||||||
fontFamily={terminalFontFamily}
|
fontFamily={terminalFontFamily}
|
||||||
onStatusChange={(status) => {
|
onStatusChange={(status) => {
|
||||||
setTerminalStatuses((prev) =>
|
dispatchTab({ type: 'SET_TERMINAL_STATUS', tabId: tab.tabId, status })
|
||||||
prev[tab.id] === status ? prev : { ...prev, [tab.id]: status },
|
|
||||||
)
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -944,6 +1055,16 @@ export default function WorkspacePage({
|
|||||||
onClick={(event) => event.stopPropagation()}
|
onClick={(event) => event.stopPropagation()}
|
||||||
style={{ left: tabContextMenu.x, top: tabContextMenu.y }}
|
style={{ left: tabContextMenu.x, top: tabContextMenu.y }}
|
||||||
>
|
>
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
||||||
|
onClick={() => {
|
||||||
|
if (tabContextMenu.targetTabId) {
|
||||||
|
duplicateTab(tabContextMenu.targetTabId)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
复制标签
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
className="flex w-full items-center rounded-lg px-3 py-2 text-left text-sm text-slate-200 transition hover:bg-slate-800 hover:text-white"
|
||||||
onClick={closeAllTabs}
|
onClick={closeAllTabs}
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ export interface MonitorMetrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceTab {
|
export interface WorkspaceTab {
|
||||||
id: number
|
tabId: string
|
||||||
name: string
|
name: string
|
||||||
connection: Connection
|
connection: Connection
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user