Please provide the code changes or file diffs you would like me to summarize.
This commit is contained in:
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"java.compile.nullAnalysis.mode": "automatic"
|
||||||
|
}
|
||||||
@@ -1,22 +1,28 @@
|
|||||||
.PHONY: help build up down restart logs ps
|
.PHONY: help build up up-build down restart logs ps
|
||||||
|
|
||||||
COMPOSE_FILE := docker/docker-compose.yml
|
COMPOSE_FILE := docker/docker-compose.yml
|
||||||
COMPOSE := docker compose -f $(COMPOSE_FILE)
|
COMPOSE := docker compose -f $(COMPOSE_FILE)
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@printf "Available targets:\n"
|
@printf "Available targets:\n"
|
||||||
@printf " make build Build Docker images\n"
|
@printf " make build Build Docker images (with cache)\n"
|
||||||
@printf " make up Build and start services in background\n"
|
@printf " make up Start services (NO rebuild, reuse existing image)\n"
|
||||||
@printf " make down Stop and remove services\n"
|
@printf " make up-build Rebuild image then start (use after code changes)\n"
|
||||||
@printf " make restart Restart services\n"
|
@printf " make down Stop and remove services\n"
|
||||||
@printf " make logs Follow service logs\n"
|
@printf " make restart Restart services without rebuild\n"
|
||||||
@printf " make ps Show service status\n"
|
@printf " make logs Follow service logs\n"
|
||||||
|
@printf " make ps Show service status\n"
|
||||||
@printf " Note: do not use 'docker compose down -v' in daily usage (it removes persistent volumes)\n"
|
@printf " Note: do not use 'docker compose down -v' in daily usage (it removes persistent volumes)\n"
|
||||||
|
|
||||||
build:
|
build:
|
||||||
$(COMPOSE) build
|
$(COMPOSE) build
|
||||||
|
|
||||||
|
# 日常启动:直接用已有镜像,不重新构建(秒启动)
|
||||||
up:
|
up:
|
||||||
|
$(COMPOSE) up -d
|
||||||
|
|
||||||
|
# 代码有改动时使用:重新构建镜像再启动
|
||||||
|
up-build:
|
||||||
$(COMPOSE) build
|
$(COMPOSE) build
|
||||||
$(COMPOSE) up -d
|
$(COMPOSE) up -d
|
||||||
|
|
||||||
@@ -24,8 +30,7 @@ down:
|
|||||||
$(COMPOSE) down
|
$(COMPOSE) down
|
||||||
|
|
||||||
restart:
|
restart:
|
||||||
$(COMPOSE) down
|
$(COMPOSE) restart
|
||||||
$(COMPOSE) up -d
|
|
||||||
|
|
||||||
logs:
|
logs:
|
||||||
$(COMPOSE) logs -f
|
$(COMPOSE) logs -f
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
package com.sshmanager.service;
|
package com.sshmanager.service;
|
||||||
|
|
||||||
import com.jcraft.jsch.ChannelSftp;
|
import com.jcraft.jsch.ChannelSftp;
|
||||||
import com.jcraft.jsch.JSch;
|
import com.jcraft.jsch.JSch;
|
||||||
import com.jcraft.jsch.Session;
|
import com.jcraft.jsch.Session;
|
||||||
@@ -8,12 +8,12 @@ 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 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.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;
|
||||||
@@ -21,86 +21,86 @@ import java.util.concurrent.ExecutorService;
|
|||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.Future;
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class SftpService {
|
public class SftpService {
|
||||||
|
|
||||||
private ExecutorService executorService = Executors.newFixedThreadPool(2);
|
private ExecutorService executorService = Executors.newCachedThreadPool();
|
||||||
|
|
||||||
public void setExecutorService(ExecutorService executorService) {
|
public void setExecutorService(ExecutorService executorService) {
|
||||||
this.executorService = executorService;
|
this.executorService = executorService;
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
JSch jsch = new JSch();
|
||||||
|
|
||||||
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
if (conn.getAuthType() == Connection.AuthType.PRIVATE_KEY && privateKey != null && !privateKey.isEmpty()) {
|
||||||
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
byte[] keyBytes = privateKey.getBytes(StandardCharsets.UTF_8);
|
||||||
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
byte[] passphraseBytes = (passphrase != null && !passphrase.isEmpty())
|
||||||
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
? passphrase.getBytes(StandardCharsets.UTF_8) : null;
|
||||||
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
jsch.addIdentity("key", keyBytes, null, passphraseBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
Session session = jsch.getSession(conn.getUsername(), conn.getHost(), conn.getPort());
|
||||||
session.setConfig("StrictHostKeyChecking", "no");
|
session.setConfig("StrictHostKeyChecking", "no");
|
||||||
// Use only DH-based kex to avoid "Algorithm ECDH not available" on Java 8 / minimal JRE
|
// 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");
|
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 (conn.getAuthType() == Connection.AuthType.PASSWORD) {
|
||||||
if (password == null || password.isEmpty()) {
|
if (password == null || password.isEmpty()) {
|
||||||
throw new IllegalArgumentException("Password is required for password authentication");
|
throw new IllegalArgumentException("Password is required for password authentication");
|
||||||
}
|
}
|
||||||
session.setConfig("PreferredAuthentications", "password");
|
session.setConfig("PreferredAuthentications", "password");
|
||||||
session.setPassword(password);
|
session.setPassword(password);
|
||||||
} else {
|
} else {
|
||||||
session.setConfig("PreferredAuthentications", "publickey");
|
session.setConfig("PreferredAuthentications", "publickey");
|
||||||
}
|
}
|
||||||
session.connect(10000);
|
session.connect(10000);
|
||||||
|
|
||||||
ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
|
ChannelSftp channel = (ChannelSftp) session.openChannel("sftp");
|
||||||
channel.connect(5000);
|
channel.connect(5000);
|
||||||
|
|
||||||
return new SftpSession(session, channel);
|
return new SftpSession(session, channel);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class SftpSession {
|
public static class SftpSession {
|
||||||
private final Session session;
|
private final Session session;
|
||||||
private final ChannelSftp channel;
|
private final ChannelSftp channel;
|
||||||
|
|
||||||
public SftpSession(Session session, ChannelSftp channel) {
|
public SftpSession(Session session, ChannelSftp channel) {
|
||||||
this.session = session;
|
this.session = session;
|
||||||
this.channel = channel;
|
this.channel = channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChannelSftp getChannel() {
|
public ChannelSftp getChannel() {
|
||||||
return channel;
|
return channel;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void disconnect() {
|
public void disconnect() {
|
||||||
if (channel != null && channel.isConnected()) {
|
if (channel != null && channel.isConnected()) {
|
||||||
channel.disconnect();
|
channel.disconnect();
|
||||||
}
|
}
|
||||||
if (session != null && session.isConnected()) {
|
if (session != null && session.isConnected()) {
|
||||||
session.disconnect();
|
session.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isConnected() {
|
public boolean isConnected() {
|
||||||
return channel != null && channel.isConnected();
|
return channel != null && channel.isConnected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class FileInfo {
|
public static class FileInfo {
|
||||||
public String name;
|
public String name;
|
||||||
public boolean directory;
|
public boolean directory;
|
||||||
public long size;
|
public long size;
|
||||||
public long mtime;
|
public long mtime;
|
||||||
|
|
||||||
public FileInfo(String name, boolean directory, long size, long mtime) {
|
public FileInfo(String name, boolean directory, long size, long mtime) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.directory = directory;
|
this.directory = directory;
|
||||||
this.size = size;
|
this.size = size;
|
||||||
this.mtime = mtime;
|
this.mtime = mtime;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,125 +117,126 @@ public class SftpService {
|
|||||||
|
|
||||||
void onProgress(long transferredBytes, long totalBytes);
|
void onProgress(long transferredBytes, long totalBytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception {
|
public List<FileInfo> listFiles(SftpSession sftpSession, String path) throws Exception {
|
||||||
String listPath = (path == null || path.trim().isEmpty()) ? "." : path.trim();
|
String listPath = (path == null || path.trim().isEmpty()) ? "." : path.trim();
|
||||||
try {
|
try {
|
||||||
Vector<?> entries = sftpSession.getChannel().ls(listPath);
|
Vector<?> entries = sftpSession.getChannel().ls(listPath);
|
||||||
List<FileInfo> result = new ArrayList<>();
|
List<FileInfo> result = new ArrayList<>();
|
||||||
for (Object obj : entries) {
|
for (Object obj : entries) {
|
||||||
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
|
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
|
||||||
String name = entry.getFilename();
|
String name = entry.getFilename();
|
||||||
if (".".equals(name) || "..".equals(name)) continue;
|
if (".".equals(name) || "..".equals(name)) continue;
|
||||||
result.add(new FileInfo(
|
result.add(new FileInfo(
|
||||||
name,
|
name,
|
||||||
entry.getAttrs().isDir(),
|
entry.getAttrs().isDir(),
|
||||||
entry.getAttrs().getSize(),
|
entry.getAttrs().getSize(),
|
||||||
entry.getAttrs().getMTime() * 1000L
|
entry.getAttrs().getMTime() * 1000L
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (SftpException e) {
|
} catch (SftpException e) {
|
||||||
String msg = formatSftpExceptionMessage(e, listPath, "list");
|
String msg = formatSftpExceptionMessage(e, listPath, "list");
|
||||||
throw new RuntimeException(msg, e);
|
throw new RuntimeException(msg, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build a user-visible message from JSch SftpException (getMessage() is often null).
|
* Build a user-visible message from JSch SftpException (getMessage() is often null).
|
||||||
*/
|
*/
|
||||||
public static String formatSftpExceptionMessage(SftpException e, String path, String operation) {
|
public static String formatSftpExceptionMessage(SftpException e, String path, String operation) {
|
||||||
int id = e.id;
|
int id = e.id;
|
||||||
String serverMsg = e.getMessage();
|
String serverMsg = e.getMessage();
|
||||||
String reason = sftpErrorCodeToMessage(id);
|
String reason = sftpErrorCodeToMessage(id);
|
||||||
StringBuilder sb = new StringBuilder();
|
StringBuilder sb = new StringBuilder();
|
||||||
sb.append(reason);
|
sb.append(reason);
|
||||||
if (path != null && !path.isEmpty()) {
|
if (path != null && !path.isEmpty()) {
|
||||||
sb.append(": ").append(path);
|
sb.append(": ").append(path);
|
||||||
}
|
}
|
||||||
if (serverMsg != null && !serverMsg.trim().isEmpty()) {
|
if (serverMsg != null && !serverMsg.trim().isEmpty()) {
|
||||||
sb.append(" (").append(serverMsg).append(")");
|
sb.append(" (").append(serverMsg).append(")");
|
||||||
} else {
|
} else {
|
||||||
sb.append(" [SFTP status ").append(id).append("]");
|
sb.append(" [SFTP status ").append(id).append("]");
|
||||||
}
|
}
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String sftpErrorCodeToMessage(int id) {
|
private static String sftpErrorCodeToMessage(int id) {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case 2: return "No such file or directory";
|
case 2: return "No such file or directory";
|
||||||
case 3: return "Permission denied";
|
case 3: return "Permission denied";
|
||||||
case 4: return "Operation failed";
|
case 4: return "Operation failed";
|
||||||
case 5: return "Bad message";
|
case 5: return "Bad message";
|
||||||
case 6: return "No connection";
|
case 6: return "No connection";
|
||||||
case 7: return "Connection lost";
|
case 7: return "Connection lost";
|
||||||
case 8: return "Operation not supported";
|
case 8: return "Operation not supported";
|
||||||
default: return "SFTP error";
|
default: return "SFTP error";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void download(SftpSession sftpSession, String remotePath, OutputStream out) throws Exception {
|
public void download(SftpSession sftpSession, String remotePath, OutputStream out) throws Exception {
|
||||||
sftpSession.getChannel().get(remotePath, out);
|
sftpSession.getChannel().get(remotePath, out);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void upload(SftpSession sftpSession, String remotePath, InputStream in) throws Exception {
|
public void upload(SftpSession sftpSession, String remotePath, InputStream in) throws Exception {
|
||||||
sftpSession.getChannel().put(in, remotePath);
|
sftpSession.getChannel().put(in, remotePath);
|
||||||
}
|
|
||||||
|
|
||||||
public void upload(SftpSession sftpSession, String remotePath, InputStream in, TransferProgressListener progressListener) throws Exception {
|
|
||||||
sftpSession.getChannel().put(in, remotePath, new SftpProgressMonitor() {
|
|
||||||
@Override
|
|
||||||
public void init(int op, String src, String dest, long max) {
|
|
||||||
if (progressListener != null) {
|
|
||||||
progressListener.onStart(max);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean count(long count) {
|
|
||||||
if (progressListener != null) {
|
|
||||||
progressListener.onProgress(count, 0);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void end() {
|
|
||||||
// Progress listener will be notified by controller
|
|
||||||
}
|
|
||||||
}, ChannelSftp.OVERWRITE);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception {
|
public void upload(SftpSession sftpSession, String remotePath, InputStream in, TransferProgressListener progressListener) throws Exception {
|
||||||
if (isDir) {
|
sftpSession.getChannel().put(in, remotePath, new SftpProgressMonitor() {
|
||||||
sftpSession.getChannel().rmdir(remotePath);
|
@Override
|
||||||
} else {
|
public void init(int op, String src, String dest, long max) {
|
||||||
sftpSession.getChannel().rm(remotePath);
|
if (progressListener != null) {
|
||||||
}
|
progressListener.onStart(max);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
public void mkdir(SftpSession sftpSession, String remotePath) throws Exception {
|
|
||||||
sftpSession.getChannel().mkdir(remotePath);
|
@Override
|
||||||
}
|
public boolean count(long count) {
|
||||||
|
if (progressListener != null) {
|
||||||
public void rename(SftpSession sftpSession, String oldPath, String newPath) throws Exception {
|
progressListener.onProgress(count, 0);
|
||||||
sftpSession.getChannel().rename(oldPath, newPath);
|
}
|
||||||
}
|
return true;
|
||||||
|
}
|
||||||
public String pwd(SftpSession sftpSession) throws Exception {
|
|
||||||
return sftpSession.getChannel().pwd();
|
@Override
|
||||||
}
|
public void end() {
|
||||||
|
// Progress listener will be notified by controller
|
||||||
public void cd(SftpSession sftpSession, String path) throws Exception {
|
}
|
||||||
sftpSession.getChannel().cd(path);
|
}, ChannelSftp.OVERWRITE);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public void delete(SftpSession sftpSession, String remotePath, boolean isDir) throws Exception {
|
||||||
* Transfer a single file from source session to target session (streaming, no full file in memory).
|
if (isDir) {
|
||||||
* Fails if sourcePath is a directory.
|
sftpSession.getChannel().rmdir(remotePath);
|
||||||
*/
|
} else {
|
||||||
|
sftpSession.getChannel().rm(remotePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void mkdir(SftpSession sftpSession, String remotePath) throws Exception {
|
||||||
|
sftpSession.getChannel().mkdir(remotePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void rename(SftpSession sftpSession, String oldPath, String newPath) throws Exception {
|
||||||
|
sftpSession.getChannel().rename(oldPath, newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public String pwd(SftpSession sftpSession) throws Exception {
|
||||||
|
return sftpSession.getChannel().pwd();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cd(SftpSession sftpSession, String path) throws Exception {
|
||||||
|
sftpSession.getChannel().cd(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transfer a file or directory from source session to target session.
|
||||||
|
* If sourcePath is a directory, transfers recursively (the source directory itself is
|
||||||
|
* recreated under targetPath — "plan A" / scp -r behaviour).
|
||||||
|
*/
|
||||||
public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath)
|
public void transferRemote(SftpSession source, String sourcePath, SftpSession target, String targetPath)
|
||||||
throws Exception {
|
throws Exception {
|
||||||
transferRemote(source, sourcePath, target, targetPath, null);
|
transferRemote(source, sourcePath, target, targetPath, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,8 +247,161 @@ public class SftpService {
|
|||||||
TransferProgressListener progressListener) throws Exception {
|
TransferProgressListener progressListener) throws Exception {
|
||||||
SftpATTRS attrs = source.getChannel().stat(sourcePath);
|
SftpATTRS attrs = source.getChannel().stat(sourcePath);
|
||||||
if (attrs.isDir()) {
|
if (attrs.isDir()) {
|
||||||
throw new IllegalArgumentException("Source path is a directory; only single file transfer is supported");
|
final long totalBytes = calculateTotalSize(source, sourcePath);
|
||||||
|
if (progressListener != null) {
|
||||||
|
progressListener.onStart(totalBytes);
|
||||||
|
}
|
||||||
|
AtomicLong transferred = new AtomicLong(0);
|
||||||
|
transferDirectoryRemote(source, sourcePath, target, targetPath, totalBytes, transferred, progressListener);
|
||||||
|
} else {
|
||||||
|
String finalTargetPath = targetPath;
|
||||||
|
try {
|
||||||
|
SftpATTRS targetAttrs = target.getChannel().stat(targetPath);
|
||||||
|
if (targetAttrs.isDir()) {
|
||||||
|
String fileName = sourcePath.contains("/") ? sourcePath.substring(sourcePath.lastIndexOf('/') + 1) : sourcePath;
|
||||||
|
finalTargetPath = targetPath.endsWith("/") ? targetPath + fileName : targetPath + "/" + fileName;
|
||||||
|
}
|
||||||
|
} catch (SftpException e) {
|
||||||
|
if (targetPath.endsWith("/")) {
|
||||||
|
String fileName = sourcePath.contains("/") ? sourcePath.substring(sourcePath.lastIndexOf('/') + 1) : sourcePath;
|
||||||
|
finalTargetPath = targetPath + fileName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
transferSingleFileRemote(source, sourcePath, target, finalTargetPath, progressListener);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively calculate total byte size of all files under a remote path. */
|
||||||
|
private long calculateTotalSize(SftpSession session, String path) throws Exception {
|
||||||
|
SftpATTRS attrs = session.getChannel().stat(path);
|
||||||
|
if (!attrs.isDir()) {
|
||||||
|
return attrs.getSize();
|
||||||
|
}
|
||||||
|
long total = 0;
|
||||||
|
Vector<?> entries = session.getChannel().ls(path);
|
||||||
|
for (Object obj : entries) {
|
||||||
|
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
|
||||||
|
String name = entry.getFilename();
|
||||||
|
if (".".equals(name) || "..".equals(name)) continue;
|
||||||
|
String childPath = path.endsWith("/") ? path + name : path + "/" + name;
|
||||||
|
total += calculateTotalSize(session, childPath);
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively create a directory and its parents on a remote session (mkdir -p). */
|
||||||
|
private void mkdirRecursive(SftpSession session, String path) throws Exception {
|
||||||
|
try {
|
||||||
|
SftpATTRS attrs = session.getChannel().stat(path);
|
||||||
|
if (attrs.isDir()) return;
|
||||||
|
throw new IllegalStateException("Path exists but is not a directory: " + path);
|
||||||
|
} catch (SftpException e) {
|
||||||
|
if (e.id != ChannelSftp.SSH_FX_NO_SUCH_FILE) throw e;
|
||||||
|
}
|
||||||
|
int slash = path.lastIndexOf('/');
|
||||||
|
if (slash > 0) {
|
||||||
|
mkdirRecursive(session, path.substring(0, slash));
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
session.getChannel().mkdir(path);
|
||||||
|
} catch (SftpException e) {
|
||||||
|
// Another thread may have created it concurrently; SSH_FX_FAILURE is returned in that case
|
||||||
|
if (e.id != ChannelSftp.SSH_FX_FAILURE) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively transfer a directory.
|
||||||
|
* The source directory name is recreated under targetParentPath (plan A / scp -r behaviour).
|
||||||
|
*/
|
||||||
|
private void transferDirectoryRemote(SftpSession source,
|
||||||
|
String sourceDirPath,
|
||||||
|
SftpSession target,
|
||||||
|
String targetParentPath,
|
||||||
|
long totalBytes,
|
||||||
|
AtomicLong transferred,
|
||||||
|
TransferProgressListener progressListener) throws Exception {
|
||||||
|
String dirName = sourceDirPath.contains("/")
|
||||||
|
? sourceDirPath.substring(sourceDirPath.lastIndexOf('/') + 1)
|
||||||
|
: sourceDirPath;
|
||||||
|
String targetDirPath = targetParentPath.endsWith("/")
|
||||||
|
? targetParentPath + dirName
|
||||||
|
: targetParentPath + "/" + dirName;
|
||||||
|
|
||||||
|
mkdirRecursive(target, targetDirPath);
|
||||||
|
|
||||||
|
Vector<?> entries = source.getChannel().ls(sourceDirPath);
|
||||||
|
for (Object obj : entries) {
|
||||||
|
ChannelSftp.LsEntry entry = (ChannelSftp.LsEntry) obj;
|
||||||
|
String name = entry.getFilename();
|
||||||
|
if (".".equals(name) || "..".equals(name)) continue;
|
||||||
|
String childSourcePath = sourceDirPath.endsWith("/")
|
||||||
|
? sourceDirPath + name
|
||||||
|
: sourceDirPath + "/" + name;
|
||||||
|
if (entry.getAttrs().isDir()) {
|
||||||
|
transferDirectoryRemote(source, childSourcePath, target, targetDirPath,
|
||||||
|
totalBytes, transferred, progressListener);
|
||||||
|
} else {
|
||||||
|
String childTargetPath = targetDirPath + "/" + name;
|
||||||
|
transferSingleFileAccumulating(source, childSourcePath,
|
||||||
|
target, childTargetPath, totalBytes, transferred, progressListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stream a single file and accumulate progress into a shared counter (used during directory transfer). */
|
||||||
|
private void transferSingleFileAccumulating(SftpSession source,
|
||||||
|
String sourcePath,
|
||||||
|
SftpSession target,
|
||||||
|
String targetFilePath,
|
||||||
|
long totalBytes,
|
||||||
|
AtomicLong transferred,
|
||||||
|
TransferProgressListener progressListener) throws Exception {
|
||||||
|
final int pipeBufferSize = 65536;
|
||||||
|
PipedOutputStream pos = new PipedOutputStream();
|
||||||
|
PipedInputStream pis = new PipedInputStream(pos, pipeBufferSize);
|
||||||
|
|
||||||
|
Future<?> putFuture = executorService.submit(() -> {
|
||||||
|
try {
|
||||||
|
target.getChannel().put(pis, targetFilePath, new SftpProgressMonitor() {
|
||||||
|
@Override public void init(int op, String src, String dest, long max) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean count(long count) {
|
||||||
|
long current = transferred.addAndGet(count);
|
||||||
|
if (progressListener != null) {
|
||||||
|
progressListener.onProgress(current, totalBytes);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void end() {}
|
||||||
|
}, ChannelSftp.OVERWRITE);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
} finally {
|
||||||
|
try { pis.close(); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
source.getChannel().get(sourcePath, pos);
|
||||||
|
} finally {
|
||||||
|
try { pos.close(); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
putFuture.get();
|
||||||
|
} finally {
|
||||||
|
try { pis.close(); } catch (Exception ignored) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transfer a single file with its own independent progress tracking. */
|
||||||
|
private void transferSingleFileRemote(SftpSession source,
|
||||||
|
String sourcePath,
|
||||||
|
SftpSession target,
|
||||||
|
String targetPath,
|
||||||
|
TransferProgressListener progressListener) throws Exception {
|
||||||
|
SftpATTRS attrs = source.getChannel().stat(sourcePath);
|
||||||
final long totalBytes = attrs.getSize();
|
final long totalBytes = attrs.getSize();
|
||||||
final int pipeBufferSize = 65536;
|
final int pipeBufferSize = 65536;
|
||||||
PipedOutputStream pos = new PipedOutputStream();
|
PipedOutputStream pos = new PipedOutputStream();
|
||||||
@@ -263,46 +417,36 @@ public class SftpService {
|
|||||||
target.getChannel().put(pis, targetPath, new SftpProgressMonitor() {
|
target.getChannel().put(pis, targetPath, new SftpProgressMonitor() {
|
||||||
@Override
|
@Override
|
||||||
public void init(int op, String src, String dest, long max) {
|
public void init(int op, String src, String dest, long max) {
|
||||||
if (progressListener != null) {
|
if (progressListener != null) progressListener.onStart(totalBytes);
|
||||||
progressListener.onStart(totalBytes);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean count(long count) {
|
public boolean count(long count) {
|
||||||
long current = transferredBytes.addAndGet(count);
|
long current = transferredBytes.addAndGet(count);
|
||||||
if (progressListener != null) {
|
if (progressListener != null) progressListener.onProgress(current, totalBytes);
|
||||||
progressListener.onProgress(current, totalBytes);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void end() {
|
public void end() {
|
||||||
if (progressListener != null) {
|
if (progressListener != null) progressListener.onProgress(totalBytes, totalBytes);
|
||||||
progressListener.onProgress(totalBytes, totalBytes);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, ChannelSftp.OVERWRITE);
|
}, ChannelSftp.OVERWRITE);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new RuntimeException(e);
|
throw new RuntimeException(e);
|
||||||
|
} finally {
|
||||||
|
try { pis.close(); } catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
source.getChannel().get(sourcePath, pos);
|
source.getChannel().get(sourcePath, pos);
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try { pos.close(); } catch (Exception ignored) {}
|
||||||
pos.close();
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
putFuture.get();
|
putFuture.get();
|
||||||
} finally {
|
} finally {
|
||||||
try {
|
try { pis.close(); } catch (Exception ignored) {}
|
||||||
pis.close();
|
|
||||||
} catch (Exception ignored) {
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import SftpPane from './SftpPane'
|
||||||
|
import type { Connection } from '../types'
|
||||||
|
|
||||||
|
export default function SftpFileSelectorModal({
|
||||||
|
open,
|
||||||
|
connection,
|
||||||
|
onSelect,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
connection: Connection
|
||||||
|
onSelect: (path: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<string[]>([])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex h-[80vh] w-[80vw] max-w-5xl flex-col overflow-hidden rounded-2xl border border-slate-700 bg-[#0d1117] shadow-2xl shadow-black/60">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex shrink-0 items-center justify-between border-b border-slate-800 bg-slate-900 px-5 py-3.5">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-white">浏览远程文件</h2>
|
||||||
|
<p className="mt-0.5 text-xs text-slate-400">
|
||||||
|
来源:<span className="text-purple-400">{connection.name}</span>
|
||||||
|
· 单击选中,双击进入目录,点击底部"确认选择"完成
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="rounded-xl p-2 text-slate-500 transition hover:bg-slate-800 hover:text-white"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="关闭"
|
||||||
|
>
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SftpPane in selection mode */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
|
<SftpPane
|
||||||
|
connection={connection}
|
||||||
|
selectedFiles={selectedFiles}
|
||||||
|
onSelectedFilesChange={setSelectedFiles}
|
||||||
|
selectionMode
|
||||||
|
onConfirmSelection={(path) => {
|
||||||
|
onSelect(path)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -70,11 +70,16 @@ export default function SftpPane({
|
|||||||
selectedFiles,
|
selectedFiles,
|
||||||
onSelectedFilesChange,
|
onSelectedFilesChange,
|
||||||
onRefreshSignal,
|
onRefreshSignal,
|
||||||
|
selectionMode,
|
||||||
|
onConfirmSelection,
|
||||||
}: {
|
}: {
|
||||||
connection: Connection
|
connection: Connection
|
||||||
selectedFiles: string[]
|
selectedFiles: string[]
|
||||||
onSelectedFilesChange: (files: string[]) => void
|
onSelectedFilesChange: (files: string[]) => void
|
||||||
onRefreshSignal?: (refresh: () => Promise<void>) => void
|
onRefreshSignal?: (refresh: () => Promise<void>) => void
|
||||||
|
/** When true, shows a bottom bar for path confirmation */
|
||||||
|
selectionMode?: boolean
|
||||||
|
onConfirmSelection?: (fullPath: string) => void
|
||||||
}) {
|
}) {
|
||||||
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
const fileInputRef = useRef<HTMLInputElement | null>(null)
|
||||||
const uploadMenuRef = useRef<HTMLDivElement | null>(null)
|
const uploadMenuRef = useRef<HTMLDivElement | null>(null)
|
||||||
@@ -978,6 +983,29 @@ export default function SftpPane({
|
|||||||
onClose={() => setCreateDirectoryModalOpen(false)}
|
onClose={() => setCreateDirectoryModalOpen(false)}
|
||||||
onSubmit={handleCreateDir}
|
onSubmit={handleCreateDir}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{selectionMode ? (() => {
|
||||||
|
const singleSelected = selectedFiles.length === 1 ? selectedFiles[0] : null
|
||||||
|
const confirmPath = singleSelected
|
||||||
|
? joinPath(currentPath, singleSelected)
|
||||||
|
: currentPath
|
||||||
|
return (
|
||||||
|
<div className="shrink-0 border-t border-purple-900/50 bg-purple-950/30 px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-xs text-slate-400">{singleSelected ? '已选择文件/文件夹' : '将选择当前目录'}</p>
|
||||||
|
<p className="mt-0.5 truncate font-mono text-xs text-purple-300">{confirmPath}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="shrink-0 rounded-xl bg-purple-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-purple-500"
|
||||||
|
onClick={() => onConfirmSelection?.(confirmPath)}
|
||||||
|
>
|
||||||
|
确认选择
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})() : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,10 +142,8 @@ export default function TerminalPane({
|
|||||||
}, [visible])
|
}, [visible])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-hidden bg-slate-900">
|
<div className="flex h-full flex-col overflow-hidden bg-black">
|
||||||
<div className="flex-1 bg-black p-2 font-mono">
|
<div ref={containerRef} className="h-full w-full overflow-hidden" />
|
||||||
<div ref={containerRef} className="h-full w-full overflow-hidden rounded-2xl border border-slate-900 bg-black" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,18 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { CheckCircle2, FileUp, ListTree, Monitor, RefreshCw, Server, Target, Upload, Zap } from 'lucide-react'
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
FileUp,
|
||||||
|
FolderOpen,
|
||||||
|
ListTree,
|
||||||
|
Monitor,
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
Target,
|
||||||
|
Upload,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
|
import SftpFileSelectorModal from './SftpFileSelectorModal'
|
||||||
import {
|
import {
|
||||||
cancelRemoteTransferTask,
|
cancelRemoteTransferTask,
|
||||||
createRemoteTransferTask,
|
createRemoteTransferTask,
|
||||||
@@ -8,7 +20,7 @@ import {
|
|||||||
subscribeUploadProgress,
|
subscribeUploadProgress,
|
||||||
uploadFile,
|
uploadFile,
|
||||||
} from '../services/sftp'
|
} from '../services/sftp'
|
||||||
import type { Connection, TransferTaskGroup, TransferTaskItem } from '../types'
|
import type { Connection, ConnectionReachabilityStatus, TransferTaskGroup, TransferTaskItem } from '../types'
|
||||||
|
|
||||||
function aggregate(items: TransferTaskItem[]) {
|
function aggregate(items: TransferTaskItem[]) {
|
||||||
const total = items.length || 1
|
const total = items.length || 1
|
||||||
@@ -20,18 +32,32 @@ function aggregate(items: TransferTaskItem[]) {
|
|||||||
return { progress, status: status as TransferTaskGroup['status'] }
|
return { progress, status: status as TransferTaskGroup['status'] }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StatusDot({ status }: { status: ConnectionReachabilityStatus | undefined }) {
|
||||||
|
if (status === 'online')
|
||||||
|
return <span className="h-2 w-2 shrink-0 rounded-full bg-emerald-400" title="在线" />
|
||||||
|
if (status === 'offline')
|
||||||
|
return <span className="h-2 w-2 shrink-0 rounded-full bg-red-400" title="离线" />
|
||||||
|
if (status === 'checking')
|
||||||
|
return <span className="h-2 w-2 shrink-0 animate-pulse rounded-full bg-yellow-400" title="检测中" />
|
||||||
|
return <span className="h-2 w-2 shrink-0 rounded-full bg-slate-600" title="未知" />
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default function TransferCenterModal({
|
export default function TransferCenterModal({
|
||||||
open,
|
open,
|
||||||
connections,
|
connections,
|
||||||
|
connectionStatuses,
|
||||||
tasks,
|
tasks,
|
||||||
onClose,
|
onClose,
|
||||||
onTasksChange,
|
onTasksChange,
|
||||||
}: {
|
}: {
|
||||||
open: boolean
|
open: boolean
|
||||||
connections: Connection[]
|
connections: Connection[]
|
||||||
|
connectionStatuses: Record<number, ConnectionReachabilityStatus>
|
||||||
tasks: TransferTaskGroup[]
|
tasks: TransferTaskGroup[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onTasksChange: (tasks: TransferTaskGroup[]) => void
|
onTasksChange: (updater: TransferTaskGroup[] | ((prev: TransferTaskGroup[]) => TransferTaskGroup[])) => void
|
||||||
}) {
|
}) {
|
||||||
const [tab, setTab] = useState<'local' | 'remote'>('local')
|
const [tab, setTab] = useState<'local' | 'remote'>('local')
|
||||||
const [targetIds, setTargetIds] = useState<number[]>(connections.length ? [connections[0].id] : [])
|
const [targetIds, setTargetIds] = useState<number[]>(connections.length ? [connections[0].id] : [])
|
||||||
@@ -40,11 +66,13 @@ export default function TransferCenterModal({
|
|||||||
const [remoteSourceId, setRemoteSourceId] = useState<number>(connections[0]?.id ?? 0)
|
const [remoteSourceId, setRemoteSourceId] = useState<number>(connections[0]?.id ?? 0)
|
||||||
const [remoteSourcePath, setRemoteSourcePath] = useState('/opt/app/build.tar.gz')
|
const [remoteSourcePath, setRemoteSourcePath] = useState('/opt/app/build.tar.gz')
|
||||||
const [remoteTargetPath, setRemoteTargetPath] = useState('/data/deploy/')
|
const [remoteTargetPath, setRemoteTargetPath] = useState('/data/deploy/')
|
||||||
|
const [showBrowser, setShowBrowser] = useState(false)
|
||||||
|
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
function updateTaskGroup(groupId: string, updater: (group: TransferTaskGroup) => TransferTaskGroup) {
|
function updateTaskGroup(groupId: string, updater: (group: TransferTaskGroup) => TransferTaskGroup) {
|
||||||
onTasksChange(tasks.map((task) => (task.id === groupId ? updater(task) : task)))
|
// Use functional update to always read latest state, avoiding stale-closure bugs in async callbacks
|
||||||
|
onTasksChange((prev) => prev.map((task) => (task.id === groupId ? updater(task) : task)))
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleStartLocal() {
|
async function handleStartLocal() {
|
||||||
@@ -66,7 +94,7 @@ export default function TransferCenterModal({
|
|||||||
message: '等待上传',
|
message: '等待上传',
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
onTasksChange([group, ...tasks])
|
onTasksChange((prev) => [group, ...prev])
|
||||||
|
|
||||||
targetIds.forEach(async (targetId) => {
|
targetIds.forEach(async (targetId) => {
|
||||||
const response = await uploadFile(targetId, localTargetPath, file, { overwrite: true })
|
const response = await uploadFile(targetId, localTargetPath, file, { overwrite: true })
|
||||||
@@ -87,7 +115,7 @@ export default function TransferCenterModal({
|
|||||||
...item,
|
...item,
|
||||||
progress: task.progress,
|
progress: task.progress,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
message: task.error || (task.status === 'success' ? '上传完成' : '正在传输...'),
|
message: task.error || (task.status === 'success' ? '上传完成' : task.status === 'error' ? '上传失败' : task.status === 'cancelled' ? '已取消' : '正在传输...'),
|
||||||
}
|
}
|
||||||
: item,
|
: item,
|
||||||
)
|
)
|
||||||
@@ -104,10 +132,11 @@ export default function TransferCenterModal({
|
|||||||
async function handleStartRemote() {
|
async function handleStartRemote() {
|
||||||
if (!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()) return
|
if (!remoteSourceId || targetIds.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 group: TransferTaskGroup = {
|
const group: TransferTaskGroup = {
|
||||||
id: groupId,
|
id: groupId,
|
||||||
mode: 'REMOTE_TO_MANY',
|
mode: 'REMOTE_TO_MANY',
|
||||||
title: '跨主机文件同步',
|
title: `跨主机分发 · ${sourceName}`,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
progress: 0,
|
progress: 0,
|
||||||
createdAt: new Date().toLocaleTimeString(),
|
createdAt: new Date().toLocaleTimeString(),
|
||||||
@@ -119,7 +148,7 @@ export default function TransferCenterModal({
|
|||||||
message: '等待创建任务',
|
message: '等待创建任务',
|
||||||
})),
|
})),
|
||||||
}
|
}
|
||||||
onTasksChange([group, ...tasks])
|
onTasksChange((prev) => [group, ...prev])
|
||||||
|
|
||||||
targetIds.forEach(async (targetId) => {
|
targetIds.forEach(async (targetId) => {
|
||||||
const response = await createRemoteTransferTask(remoteSourceId, remoteSourcePath, targetId, remoteTargetPath)
|
const response = await createRemoteTransferTask(remoteSourceId, remoteSourcePath, targetId, remoteTargetPath)
|
||||||
@@ -140,7 +169,7 @@ export default function TransferCenterModal({
|
|||||||
...item,
|
...item,
|
||||||
progress: task.progress,
|
progress: task.progress,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
message: task.error || (task.status === 'success' ? '同步完成' : '正在同步...'),
|
message: task.error || (task.status === 'success' ? '同步完成' : task.status === 'error' ? '同步失败' : task.status === 'cancelled' ? '已取消' : '正在同步...'),
|
||||||
}
|
}
|
||||||
: item,
|
: item,
|
||||||
)
|
)
|
||||||
@@ -155,16 +184,24 @@ export default function TransferCenterModal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal title="文件传输中心" onClose={onClose} maxWidth="max-w-7xl">
|
<>
|
||||||
|
<Modal title="文件传输中心" onClose={onClose} maxWidth="max-w-7xl">
|
||||||
<div className="flex h-[74vh] flex-col overflow-hidden rounded-2xl border border-slate-800 bg-[#0d1117]">
|
<div className="flex h-[74vh] flex-col overflow-hidden rounded-2xl border border-slate-800 bg-[#0d1117]">
|
||||||
|
{/* Tabs */}
|
||||||
<div className="flex gap-8 border-b border-slate-800 bg-slate-900 px-6 pt-4">
|
<div className="flex gap-8 border-b border-slate-800 bg-slate-900 px-6 pt-4">
|
||||||
<button className={`border-b-2 pb-3 text-sm font-medium ${tab === 'local' ? 'border-blue-500 text-blue-400' : 'border-transparent text-slate-400'}`} onClick={() => setTab('local')}>
|
<button
|
||||||
|
className={`border-b-2 pb-3 text-sm font-medium ${tab === 'local' ? 'border-blue-500 text-blue-400' : 'border-transparent text-slate-400'}`}
|
||||||
|
onClick={() => setTab('local')}
|
||||||
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Monitor size={16} />
|
<Monitor size={16} />
|
||||||
本地分发到多台
|
本地分发到多台
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button className={`border-b-2 pb-3 text-sm font-medium ${tab === 'remote' ? 'border-purple-500 text-purple-400' : 'border-transparent text-slate-400'}`} onClick={() => setTab('remote')}>
|
<button
|
||||||
|
className={`border-b-2 pb-3 text-sm font-medium ${tab === 'remote' ? 'border-purple-500 text-purple-400' : 'border-transparent text-slate-400'}`}
|
||||||
|
onClick={() => { setTab('remote'); setShowBrowser(false) }}
|
||||||
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Server size={16} />
|
<Server size={16} />
|
||||||
远程分发到多台
|
远程分发到多台
|
||||||
@@ -173,7 +210,8 @@ export default function TransferCenterModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
<div className="flex flex-1 overflow-auto border-r border-slate-800 bg-slate-900/70 p-6">
|
{/* Left panel */}
|
||||||
|
<div className="flex flex-1 overflow-hidden border-r border-slate-800 bg-slate-900/70 p-6">
|
||||||
{tab === 'local' ? (
|
{tab === 'local' ? (
|
||||||
<div className="flex flex-1 flex-col space-y-6">
|
<div className="flex flex-1 flex-col space-y-6">
|
||||||
<label className="space-y-2">
|
<label className="space-y-2">
|
||||||
@@ -196,71 +234,19 @@ export default function TransferCenterModal({
|
|||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
<span className="text-sm text-slate-300">3. 目标服务器</span>
|
<span className="text-sm text-slate-300">3. 目标服务器</span>
|
||||||
<div className="rounded-2xl border border-slate-700 bg-black p-2">
|
<div className="rounded-2xl border border-slate-700 bg-black p-2">
|
||||||
{connections.map((server) => (
|
{connections.map((server) => {
|
||||||
<label key={server.id} className="flex items-center gap-2 rounded-xl px-3 py-2 hover:bg-slate-800">
|
const st = connectionStatuses[server.id]
|
||||||
<input
|
const isOnline = st === 'online'
|
||||||
type="checkbox"
|
return (
|
||||||
checked={targetIds.includes(server.id)}
|
<label
|
||||||
onChange={() =>
|
key={server.id}
|
||||||
setTargetIds((prev) =>
|
className={`flex items-center gap-2 rounded-xl px-3 py-2 ${
|
||||||
prev.includes(server.id) ? prev.filter((item) => item !== server.id) : [...prev, server.id],
|
isOnline ? 'cursor-pointer hover:bg-slate-800' : 'cursor-not-allowed opacity-40'
|
||||||
)
|
}`}
|
||||||
}
|
>
|
||||||
/>
|
|
||||||
<Server size={14} className="text-slate-500" />
|
|
||||||
<span className="text-sm text-slate-300">{server.name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button className="flex w-full items-center justify-center gap-2 rounded-xl bg-blue-600 py-3 font-medium text-white transition hover:bg-blue-500" onClick={() => void handleStartLocal()}>
|
|
||||||
<Upload size={18} />
|
|
||||||
开始分发
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-1 gap-6">
|
|
||||||
<div className="flex w-1/2 flex-col gap-5 border-r border-slate-800 pr-6">
|
|
||||||
<h3 className="flex items-center gap-2 text-sm font-bold text-purple-400">
|
|
||||||
<Zap size={16} />
|
|
||||||
源配置
|
|
||||||
</h3>
|
|
||||||
<label className="space-y-2">
|
|
||||||
<span className="text-sm text-slate-300">源服务器</span>
|
|
||||||
<select className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white" value={remoteSourceId} onChange={(event) => setRemoteSourceId(Number(event.target.value))}>
|
|
||||||
{connections.map((server) => (
|
|
||||||
<option key={server.id} value={server.id}>
|
|
||||||
{server.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-1 flex-col space-y-2">
|
|
||||||
<span className="text-sm text-slate-300">源文件路径</span>
|
|
||||||
<textarea className="min-h-[180px] flex-1 rounded-xl border border-slate-700 bg-black px-4 py-3 font-mono text-sm text-white" value={remoteSourcePath} onChange={(event) => setRemoteSourcePath(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex w-1/2 flex-col gap-5">
|
|
||||||
<h3 className="flex items-center gap-2 text-sm font-bold text-blue-400">
|
|
||||||
<Target size={16} />
|
|
||||||
目标配置
|
|
||||||
</h3>
|
|
||||||
<label className="space-y-2">
|
|
||||||
<span className="text-sm text-slate-300">目标目录</span>
|
|
||||||
<input className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white" value={remoteTargetPath} onChange={(event) => setRemoteTargetPath(event.target.value)} />
|
|
||||||
</label>
|
|
||||||
<div className="flex-1 space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-slate-300">目标服务器</span>
|
|
||||||
<button className="text-xs text-blue-400" onClick={() => setTargetIds(connections.map((item) => item.id))}>
|
|
||||||
全选
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-2xl border border-slate-700 bg-black p-2">
|
|
||||||
{connections.map((server) => (
|
|
||||||
<label key={server.id} className="flex items-center gap-2 rounded-xl px-3 py-2 hover:bg-slate-800">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
disabled={!isOnline}
|
||||||
checked={targetIds.includes(server.id)}
|
checked={targetIds.includes(server.id)}
|
||||||
onChange={() =>
|
onChange={() =>
|
||||||
setTargetIds((prev) =>
|
setTargetIds((prev) =>
|
||||||
@@ -268,20 +254,182 @@ export default function TransferCenterModal({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<StatusDot status={st} />
|
||||||
|
<Server size={14} className={isOnline ? 'text-emerald-500' : 'text-slate-600'} />
|
||||||
<span className="text-sm text-slate-300">{server.name}</span>
|
<span className="text-sm text-slate-300">{server.name}</span>
|
||||||
</label>
|
</label>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
|
||||||
|
!localFiles?.length || targetIds.length === 0
|
||||||
|
? 'cursor-not-allowed bg-slate-800 text-slate-500'
|
||||||
|
: 'bg-blue-600 text-white hover:bg-blue-500'
|
||||||
|
}`}
|
||||||
|
disabled={!localFiles?.length || targetIds.length === 0}
|
||||||
|
onClick={() => void handleStartLocal()}
|
||||||
|
>
|
||||||
|
<Upload size={18} />
|
||||||
|
{!localFiles?.length ? '请选择本地文件' : targetIds.length === 0 ? '请选择目标服务器' : '开始分发'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full flex-1 gap-6 overflow-hidden">
|
||||||
|
{/* Source config */}
|
||||||
|
<div className="flex w-1/2 shrink-0 flex-col gap-4 overflow-y-auto border-r border-slate-800 pr-6">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-bold text-purple-400">
|
||||||
|
<Zap size={16} />
|
||||||
|
源配置
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-300">源服务器</span>
|
||||||
|
{(() => {
|
||||||
|
const st = connectionStatuses[remoteSourceId]
|
||||||
|
if (st === 'online') return <span className="flex items-center gap-1 rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs font-medium text-emerald-400"><span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />在线</span>
|
||||||
|
if (st === 'offline') return <span className="flex items-center gap-1 rounded-full bg-red-500/15 px-2 py-0.5 text-xs font-medium text-red-400"><span className="h-1.5 w-1.5 rounded-full bg-red-400" />离线</span>
|
||||||
|
if (st === 'checking') return <span className="flex items-center gap-1 rounded-full bg-yellow-500/15 px-2 py-0.5 text-xs font-medium text-yellow-400"><span className="h-1.5 w-1.5 animate-pulse rounded-full bg-yellow-400" />检测中</span>
|
||||||
|
return <span className="flex items-center gap-1 rounded-full bg-slate-700/60 px-2 py-0.5 text-xs font-medium text-slate-500"><span className="h-1.5 w-1.5 rounded-full bg-slate-500" />未知</span>
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className={`w-full rounded-xl border bg-black px-4 py-3 text-sm text-white ${
|
||||||
|
connectionStatuses[remoteSourceId] === 'online'
|
||||||
|
? 'border-emerald-700'
|
||||||
|
: connectionStatuses[remoteSourceId] === 'offline'
|
||||||
|
? 'border-red-800'
|
||||||
|
: 'border-slate-700'
|
||||||
|
}`}
|
||||||
|
value={remoteSourceId}
|
||||||
|
onChange={(event) => {
|
||||||
|
setRemoteSourceId(Number(event.target.value))
|
||||||
|
setShowBrowser(false)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{connections.map((server) => {
|
||||||
|
const st = connectionStatuses[server.id]
|
||||||
|
const isOnline = st === 'online'
|
||||||
|
const label = st === 'online' ? '● ' : st === 'offline' ? '● ' : st === 'checking' ? '◔ ' : '◦ '
|
||||||
|
return (
|
||||||
|
<option key={server.id} value={server.id} disabled={!isOnline}>
|
||||||
|
{label}{server.name}{!isOnline ? (st === 'offline' ? ' (离线)' : ' (未知)') : ''}
|
||||||
|
</option>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm text-slate-300">源文件 / 文件夹路径</span>
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-1 rounded-lg px-2 py-1 text-xs transition ${
|
||||||
|
showBrowser
|
||||||
|
? 'bg-purple-600 text-white'
|
||||||
|
: 'border border-slate-700 text-slate-400 hover:border-purple-500 hover:text-purple-400'
|
||||||
|
}`}
|
||||||
|
onClick={() => setShowBrowser((v) => !v)}
|
||||||
|
>
|
||||||
|
<FolderOpen size={12} />
|
||||||
|
{showBrowser ? '收起' : '浏览...'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 font-mono text-sm text-white"
|
||||||
|
value={remoteSourcePath}
|
||||||
|
onChange={(event) => setRemoteSourcePath(event.target.value)}
|
||||||
|
placeholder="/opt/app"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto rounded-xl border border-slate-800 bg-slate-950/50 px-4 py-3 text-xs text-slate-500">
|
||||||
|
<p className="font-medium text-slate-400">📁 支持文件夹传输</p>
|
||||||
|
<p className="mt-1">选择文件夹时,将在目标路径下创建同名子目录并递归传输所有内容。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Target config */}
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-4 overflow-hidden">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-bold text-blue-400">
|
||||||
|
<Target size={16} />
|
||||||
|
目标配置
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<label className="space-y-2">
|
||||||
|
<span className="text-sm text-slate-300">目标目录</span>
|
||||||
|
<input
|
||||||
|
className="w-full rounded-xl border border-slate-700 bg-black px-4 py-3 text-sm text-white"
|
||||||
|
value={remoteTargetPath}
|
||||||
|
onChange={(event) => setRemoteTargetPath(event.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col space-y-2">
|
||||||
|
<div className="flex shrink-0 items-center justify-between">
|
||||||
|
<span className="text-sm text-slate-300">目标服务器</span>
|
||||||
|
<button
|
||||||
|
className="text-xs text-blue-400"
|
||||||
|
onClick={() => setTargetIds(connections.filter((c) => connectionStatuses[c.id] === 'online').map((c) => c.id))}
|
||||||
|
>
|
||||||
|
全选在线
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-slate-700 bg-black p-2">
|
||||||
|
{connections.map((server) => {
|
||||||
|
const st = connectionStatuses[server.id]
|
||||||
|
const isOnline = st === 'online'
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={server.id}
|
||||||
|
className={`flex items-center gap-2 rounded-xl px-3 py-2 ${
|
||||||
|
isOnline ? 'cursor-pointer hover:bg-slate-800' : 'cursor-not-allowed opacity-40'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
disabled={!isOnline}
|
||||||
|
checked={targetIds.includes(server.id)}
|
||||||
|
onChange={() =>
|
||||||
|
setTargetIds((prev) =>
|
||||||
|
prev.includes(server.id) ? prev.filter((item) => item !== server.id) : [...prev, server.id],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatusDot status={st} />
|
||||||
|
<span className="text-sm text-slate-300">{server.name}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button className="flex w-full items-center justify-center gap-2 rounded-xl bg-purple-600 py-3 font-medium text-white transition hover:bg-purple-500" onClick={() => void handleStartRemote()}>
|
|
||||||
|
<button
|
||||||
|
className={`flex w-full items-center justify-center gap-2 rounded-xl py-3 font-medium transition ${
|
||||||
|
!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()
|
||||||
|
? 'cursor-not-allowed bg-slate-800 text-slate-500'
|
||||||
|
: 'bg-purple-600 text-white hover:bg-purple-500'
|
||||||
|
}`}
|
||||||
|
disabled={!remoteSourceId || targetIds.length === 0 || !remoteSourcePath.trim()}
|
||||||
|
onClick={() => void handleStartRemote()}
|
||||||
|
>
|
||||||
<Zap size={18} fill="currentColor" />
|
<Zap size={18} fill="currentColor" />
|
||||||
跨服同步分发
|
{!remoteSourceId
|
||||||
|
? '请选择源服务器'
|
||||||
|
: !remoteSourcePath.trim()
|
||||||
|
? '请填写源路径'
|
||||||
|
: targetIds.length === 0
|
||||||
|
? '请选择目标服务器'
|
||||||
|
: '跨服同步分发'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Task status panel */}
|
||||||
<div className="flex w-[360px] shrink-0 flex-col bg-[#0d1117]">
|
<div className="flex w-[360px] shrink-0 flex-col bg-[#0d1117]">
|
||||||
<div className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-4">
|
<div className="flex items-center justify-between border-b border-slate-800 bg-slate-900 px-4 py-4">
|
||||||
<h4 className="flex items-center gap-2 text-sm font-medium text-slate-200">
|
<h4 className="flex items-center gap-2 text-sm font-medium text-slate-200">
|
||||||
@@ -317,12 +465,8 @@ export default function TransferCenterModal({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-1 flex justify-between text-xs text-slate-500">
|
<div className="text-xs text-slate-500">
|
||||||
<span>{task.createdAt}</span>
|
{task.createdAt}
|
||||||
<span className={task.status === 'success' ? 'text-emerald-400' : 'text-blue-400'}>{task.progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="h-1.5 rounded-full border border-slate-700 bg-slate-950">
|
|
||||||
<div className={`h-1.5 rounded-full ${task.status === 'success' ? 'bg-emerald-500' : 'bg-blue-500'}`} style={{ width: `${task.progress}%` }} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-48 space-y-1 overflow-auto bg-slate-900/60 p-2">
|
<div className="max-h-48 space-y-1 overflow-auto bg-slate-900/60 p-2">
|
||||||
@@ -330,7 +474,10 @@ export default function TransferCenterModal({
|
|||||||
<div key={item.id} className="rounded-xl p-2 hover:bg-slate-800">
|
<div key={item.id} className="rounded-xl p-2 hover:bg-slate-800">
|
||||||
<div className="flex justify-between text-xs">
|
<div className="flex justify-between text-xs">
|
||||||
<span className="truncate text-slate-300">{item.label}</span>
|
<span className="truncate text-slate-300">{item.label}</span>
|
||||||
<span className="text-slate-500">{item.message}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-500">{item.message}</span>
|
||||||
|
<span className={item.status === 'success' ? 'text-emerald-400' : 'text-blue-400'}>{item.progress}%</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="mt-2 flex items-center gap-2">
|
||||||
<div className="h-1 flex-1 rounded-full bg-black">
|
<div className="h-1 flex-1 rounded-full bg-black">
|
||||||
@@ -356,5 +503,19 @@ export default function TransferCenterModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* SFTP file selector popup — mounted outside the main modal so it overlays on top */}
|
||||||
|
{showBrowser && (
|
||||||
|
<SftpFileSelectorModal
|
||||||
|
open={showBrowser}
|
||||||
|
connection={connections.find((c) => c.id === remoteSourceId) ?? connections[0]}
|
||||||
|
onSelect={(path) => {
|
||||||
|
setRemoteSourcePath(path)
|
||||||
|
setShowBrowser(false)
|
||||||
|
}}
|
||||||
|
onClose={() => setShowBrowser(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -757,8 +757,8 @@ export default function WorkspacePage({
|
|||||||
'rounded-md p-2 transition',
|
'rounded-md p-2 transition',
|
||||||
layout === 'split' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
|
layout === 'split' ? 'bg-slate-700 text-white' : 'text-slate-400 hover:text-white',
|
||||||
)}
|
)}
|
||||||
onClick={() => setLayout('split')}
|
onClick={() => setLayout(layout === 'split' ? 'terminal' : 'split')}
|
||||||
title="分屏"
|
title={layout === 'split' ? '切换到终端' : '分屏'}
|
||||||
>
|
>
|
||||||
<SplitSquareHorizontal size={15} />
|
<SplitSquareHorizontal size={15} />
|
||||||
</button>
|
</button>
|
||||||
@@ -857,6 +857,7 @@ export default function WorkspacePage({
|
|||||||
<TransferCenterModal
|
<TransferCenterModal
|
||||||
open={showTransferModal}
|
open={showTransferModal}
|
||||||
connections={connections}
|
connections={connections}
|
||||||
|
connectionStatuses={connectionStatuses}
|
||||||
tasks={transferTasks}
|
tasks={transferTasks}
|
||||||
onTasksChange={setTransferTasks}
|
onTasksChange={setTransferTasks}
|
||||||
onClose={() => setShowTransferModal(false)}
|
onClose={() => setShowTransferModal(false)}
|
||||||
|
|||||||
Reference in New Issue
Block a user