This commit is contained in:
Baobhan Sith
2025-05-16 22:49:07 +08:00
parent 084cc570f4
commit 56833d58e1
23 changed files with 89 additions and 194 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ export const getDbInstance = (): Promise<sqlite3.Database> => {
await runDatabaseInitializations(db); await runDatabaseInitializations(db);
// +++ 运行数据库迁移 +++ // +++ 运行数据库迁移 +++
await runMigrations(db); await runMigrations(db);
console.log('[数据库] 初始化和迁移完成。'); // 添加日志确认 console.log('[数据库] 初始化和迁移完成。');
resolve(db); resolve(db);
} catch (initError) { } catch (initError) {
console.error('[数据库] 连接后初始化失败,正在关闭连接...'); console.error('[数据库] 连接后初始化失败,正在关闭连接...');
+7 -23
View File
@@ -53,7 +53,7 @@ import appearanceRoutes from './appearance/appearance.routes';
import sshKeysRouter from './ssh_keys/ssh_keys.routes'; import sshKeysRouter from './ssh_keys/ssh_keys.routes';
import quickCommandTagRoutes from './quick-command-tags/quick-command-tag.routes'; import quickCommandTagRoutes from './quick-command-tags/quick-command-tag.routes';
import sshSuspendRouter from './ssh-suspend/ssh-suspend.routes'; import sshSuspendRouter from './ssh-suspend/ssh-suspend.routes';
import { transfersRoutes } from './transfers/transfers.routes'; // 新增:导入传输路由 import { transfersRoutes } from './transfers/transfers.routes';
import { initializeWebSocket } from './websocket'; import { initializeWebSocket } from './websocket';
import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware'; import { ipWhitelistMiddleware } from './auth/ipWhitelist.middleware';
@@ -69,29 +69,19 @@ import './services/notification.dispatcher.service';
process.on('unhandledRejection', (reason: any, promise: Promise<any>) => { process.on('unhandledRejection', (reason: any, promise: Promise<any>) => {
console.error('---未处理的 Promise Rejection---'); console.error('---未处理的 Promise Rejection---');
console.error('原因:', reason); console.error('原因:', reason);
// 可以在这里添加更详细的日志记录,例如将错误发送到监控系统
// 注意:根据 Node.js 官方建议,未来版本的 Node.js 可能会默认在 unhandledRejection 时终止进程。
// 目前我们选择记录错误并继续运行,但这可能导致应用程序状态不一致。
}); });
// 捕获未捕获的同步异常 // 捕获未捕获的同步异常
process.on('uncaughtException', (error: Error) => { process.on('uncaughtException', (error: Error) => {
console.error('---未捕获的异常---'); console.error('---未捕获的异常---');
console.error('错误:', error); console.error('错误:', error);
// 记录错误,但避免退出进程,尝试让服务器继续运行(有风险)
// 在生产环境中,更安全的做法可能是记录错误后优雅地关闭服务器并重启。
// process.exit(1); // 强制退出(更安全,但会中断服务)
}); });
// --- 结束全局错误处理 ---
const initializeEnvironment = async () => { const initializeEnvironment = async () => {
// Env files (root and data/.env) are now loaded at the very top of the file.
// This function will now focus on generating keys if they are missing
// and setting defaults for GUACD variables.
// Use the globally defined path for data .env const dataEnvPath = dataEnvPathGlobal;
const dataEnvPath = dataEnvPathGlobal; // Use the path defined at the top
let keysGenerated = false; let keysGenerated = false;
let keysToAppend = ''; let keysToAppend = '';
@@ -117,16 +107,10 @@ const initializeEnvironment = async () => {
if (!process.env.GUACD_HOST) { if (!process.env.GUACD_HOST) {
console.warn('[ENV Init] GUACD_HOST 未设置,将使用默认值 "localhost"'); console.warn('[ENV Init] GUACD_HOST 未设置,将使用默认值 "localhost"');
process.env.GUACD_HOST = 'localhost'; process.env.GUACD_HOST = 'localhost';
// Optionally add to keysToAppend if you want to save the default
// keysToAppend += `\nGUACD_HOST=localhost`;
// keysGenerated = true; // Mark if you want to save
} }
if (!process.env.GUACD_PORT) { if (!process.env.GUACD_PORT) {
console.warn('[ENV Init] GUACD_PORT 未设置,将使用默认值 "4822"'); console.warn('[ENV Init] GUACD_PORT 未设置,将使用默认值 "4822"');
process.env.GUACD_PORT = '4822'; process.env.GUACD_PORT = '4822';
// Optionally add to keysToAppend
// keysToAppend += `\nGUACD_PORT=4822`;
// keysGenerated = true; // Mark if you want to save
} }
@@ -271,10 +255,10 @@ const startServer = () => {
app.use('/api/v1/quick-commands', quickCommandsRoutes); app.use('/api/v1/quick-commands', quickCommandsRoutes);
app.use('/api/v1/terminal-themes', terminalThemeRoutes); app.use('/api/v1/terminal-themes', terminalThemeRoutes);
app.use('/api/v1/appearance', appearanceRoutes); app.use('/api/v1/appearance', appearanceRoutes);
app.use('/api/v1/ssh-keys', sshKeysRouter); // +++ Register SSH Key routes +++ app.use('/api/v1/ssh-keys', sshKeysRouter);
app.use('/api/v1/quick-command-tags', quickCommandTagRoutes); // +++ Register Quick Command Tag routes +++ app.use('/api/v1/quick-command-tags', quickCommandTagRoutes);
app.use('/api/v1/ssh-suspend', sshSuspendRouter); // +++ Register SSH Suspend routes +++ app.use('/api/v1/ssh-suspend', sshSuspendRouter);
app.use('/api/v1/transfers', transfersRoutes()); // 新增:注册传输路由 app.use('/api/v1/transfers', transfersRoutes());
// 状态检查接口 // 状态检查接口
app.get('/api/v1/status', (req: Request, res: Response) => { app.get('/api/v1/status', (req: Request, res: Response) => {
@@ -120,7 +120,7 @@ export const ensureDefaultSettingsExist = async (db: sqlite3.Database): Promise<
{ key: 'terminalBackgroundImage', value: defaults.terminalBackgroundImage ?? '' }, // 数据库中使用空字符串 { key: 'terminalBackgroundImage', value: defaults.terminalBackgroundImage ?? '' }, // 数据库中使用空字符串
{ key: 'pageBackgroundImage', value: defaults.pageBackgroundImage ?? '' }, // 数据库中使用空字符串 { key: 'pageBackgroundImage', value: defaults.pageBackgroundImage ?? '' }, // 数据库中使用空字符串
{ key: 'terminalBackgroundEnabled', value: defaults.terminalBackgroundEnabled }, { key: 'terminalBackgroundEnabled', value: defaults.terminalBackgroundEnabled },
{ key: 'terminalBackgroundOverlayOpacity', value: defaults.terminalBackgroundOverlayOpacity }, // 新增 { key: 'terminalBackgroundOverlayOpacity', value: defaults.terminalBackgroundOverlayOpacity },
]; ];
try { try {
@@ -7,7 +7,7 @@ import { getDbInstance, runDb, getDb as getDbRow, allDb } from '../database/conn
interface ConnectionBase { interface ConnectionBase {
id: number; id: number;
name: string | null; name: string | null;
type: 'SSH' | 'RDP' | 'VNC'; // Add type field type: 'SSH' | 'RDP' | 'VNC';
host: string; host: string;
port: number; port: number;
username: string; username: string;
@@ -16,8 +16,8 @@ interface ConnectionBase {
created_at: number; created_at: number;
updated_at: number; updated_at: number;
last_connected_at: number | null; last_connected_at: number | null;
ssh_key_id?: number | null; // +++ Add ssh_key_id here as well +++ ssh_key_id?: number | null;
notes?: string | null; // 新增备注字段 notes?: string | null;
} }
// ConnectionWithTagsRow implicitly includes 'type' and 'ssh_key_id' via ConnectionBase // ConnectionWithTagsRow implicitly includes 'type' and 'ssh_key_id' via ConnectionBase
@@ -36,7 +36,7 @@ export interface FullConnectionData extends ConnectionBase {
encrypted_password?: string | null; encrypted_password?: string | null;
encrypted_private_key?: string | null; encrypted_private_key?: string | null;
encrypted_passphrase?: string | null; encrypted_passphrase?: string | null;
notes?: string | null; // 新增备注字段 notes?: string | null;
tag_ids?: number[]; tag_ids?: number[];
} }
@@ -44,7 +44,6 @@ export class TransfersController {
res.status(202).json(task); // 202 Accepted 表示请求已接受处理,但尚未完成 res.status(202).json(task); // 202 Accepted 表示请求已接受处理,但尚未完成
} catch (error) { } catch (error) {
console.error('[TransfersController] Error initiating transfer:', error); console.error('[TransfersController] Error initiating transfer:', error);
// next(error) 可以将错误传递给全局错误处理中间件
res.status(500).json({ message: 'Failed to initiate transfer.', error: error instanceof Error ? error.message : String(error) }); res.status(500).json({ message: 'Failed to initiate transfer.', error: error instanceof Error ? error.message : String(error) });
} }
} }
@@ -1,19 +1,17 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path'; import * as path from 'path';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { v4 as uuidv4 } from 'uuid'; // 用于生成唯一ID import { v4 as uuidv4 } from 'uuid';
import { Client, ConnectConfig, SFTPWrapper } from 'ssh2'; import { Client, ConnectConfig, SFTPWrapper } from 'ssh2';
import { InitiateTransferPayload, TransferTask, TransferSubTask } from './transfers.types'; import { InitiateTransferPayload, TransferTask, TransferSubTask } from './transfers.types';
import { getConnectionWithDecryptedCredentials } from '../services/connection.service'; import { getConnectionWithDecryptedCredentials } from '../services/connection.service';
import type { ConnectionWithTags, DecryptedConnectionCredentials } from '../types/connection.types'; import type { ConnectionWithTags, DecryptedConnectionCredentials } from '../types/connection.types';
// import { logger } from '../utils/logger'; // 假设的日志工具路径
export class TransfersService { export class TransfersService {
private transferTasks: Map<string, TransferTask> = new Map(); private transferTasks: Map<string, TransferTask> = new Map();
private taskAbortControllers: Map<string, AbortController> = new Map(); // +++ 用于存储任务的 AbortController +++ private taskAbortControllers: Map<string, AbortController> = new Map(); // +++ 用于存储任务的 AbortController +++
private readonly TEMP_KEY_PREFIX = 'nexus_target_key_'; private readonly TEMP_KEY_PREFIX = 'nexus_target_key_';
private readonly MAX_CONCURRENT_SUB_TASKS = 5; // Maximum concurrent sub-tasks private readonly MAX_CONCURRENT_SUB_TASKS = 5;
constructor() { constructor() {
console.info('[TransfersService] Initialized.'); console.info('[TransfersService] Initialized.');
@@ -23,8 +21,8 @@ export class TransfersService {
const taskId = uuidv4(); const taskId = uuidv4();
const now = new Date(); const now = new Date();
const subTasks: TransferSubTask[] = []; const subTasks: TransferSubTask[] = [];
const abortController = new AbortController(); // +++ 创建 AbortController +++ const abortController = new AbortController();
this.taskAbortControllers.set(taskId, abortController); // +++ 存储 AbortController +++ this.taskAbortControllers.set(taskId, abortController);
// 每个 (目标服务器, 源文件) 组合都是一个子任务 // 每个 (目标服务器, 源文件) 组合都是一个子任务
for (const connectionId of payload.connectionIds) { // 目标服务器ID列表 for (const connectionId of payload.connectionIds) { // 目标服务器ID列表
@@ -91,10 +89,6 @@ export class TransfersService {
} }
}); });
// 确保在 AbortController Map 中移除,以防内存泄漏(如果任务不再处理)
// 也可以在任务彻底结束后移除
// this.taskAbortControllers.delete(taskId); // 暂时不在这里删除,可能在 processTransferTask 的 finally 中
return true; return true;
} }
console.warn(`[TransfersService] No AbortController found for task ${taskId} to cancel.`); console.warn(`[TransfersService] No AbortController found for task ${taskId} to cancel.`);
@@ -299,7 +293,6 @@ export class TransfersService {
}); });
subTaskExecutionPromises.push(taskPromise); subTaskExecutionPromises.push(taskPromise);
} }
// If all tasks were launched and some are still active, or if all tasks were skipped due to early cancellation
if (currentSubTaskIndex === totalSubTasks && currentlyActiveSubTasks === 0 && !signal.aborted) { if (currentSubTaskIndex === totalSubTasks && currentlyActiveSubTasks === 0 && !signal.aborted) {
console.info(`[TransfersService] Task ${taskId}: All sub-tasks processed (no active, no more to launch).`); console.info(`[TransfersService] Task ${taskId}: All sub-tasks processed (no active, no more to launch).`);
signal.removeEventListener('abort', onAbortOverall); signal.removeEventListener('abort', onAbortOverall);
@@ -343,7 +336,7 @@ export class TransfersService {
} }
} }
this.finalizeOverallTaskStatus(taskId); // Ensure final status is set this.finalizeOverallTaskStatus(taskId); // Ensure final status is set
this.taskAbortControllers.delete(taskId); // +++ Clean up AbortController +++ this.taskAbortControllers.delete(taskId);
if (task) { // task 可能未定义如果 taskId 错误 if (task) { // task 可能未定义如果 taskId 错误
console.info(`[TransfersService] Task ${taskId} processing finished. Final status: ${task.status}.`); console.info(`[TransfersService] Task ${taskId} processing finished. Final status: ${task.status}.`);
} else { } else {
@@ -355,10 +348,8 @@ export class TransfersService {
private async checkCommandOnSource(client: Client, command: string): Promise<string | null> { private async checkCommandOnSource(client: Client, command: string): Promise<string | null> {
return new Promise((resolve) => { return new Promise((resolve) => {
const checkCmd = `command -v ${this.escapeShellArg(command)} 2>/dev/null`; const checkCmd = `command -v ${this.escapeShellArg(command)} 2>/dev/null`;
console.error(`[Roo Debug][transfers.service.ts] checkCommandOnSource: Executing: ${checkCmd}`);
client.exec(checkCmd, (err, stream) => { client.exec(checkCmd, (err, stream) => {
if (err) { if (err) {
console.warn(`[Roo Debug][transfers.service.ts] Error checking for command '${command}' on source:`, err);
return resolve(null); return resolve(null);
} }
let stdout = ''; let stdout = '';
@@ -367,15 +358,12 @@ export class TransfersService {
.on('close', (code: number) => { .on('close', (code: number) => {
const foundPath = stdout.trim(); const foundPath = stdout.trim();
if (code === 0 && foundPath) { if (code === 0 && foundPath) {
console.error(`[Roo Debug][transfers.service.ts] checkCommandOnSource: Command '${command}' found at '${foundPath}'.`);
resolve(foundPath); resolve(foundPath);
} else { } else {
console.warn(`[Roo Debug][transfers.service.ts] checkCommandOnSource: Command '${command}' not found (exit code: ${code}).`);
resolve(null); resolve(null);
} }
}) })
.stderr.on('data', (data: Buffer) => { // Should be empty due to 2>/dev/null, but good to have .stderr.on('data', (data: Buffer) => {
console.warn(`[Roo Debug][transfers.service.ts] checkCommandOnSource: STDERR for '${command}': ${data.toString()}`);
}); });
}); });
}); });
@@ -386,7 +374,6 @@ export class TransfersService {
const connectConfig = this.buildSshConnectConfig(targetConnection, targetCredentials); const connectConfig = this.buildSshConnectConfig(targetConnection, targetCredentials);
let foundCommandPath: string | null = null; let foundCommandPath: string | null = null;
console.error(`[Roo Debug][transfers.service.ts] checkCommandOnTargetServer: Attempting to connect to target ${targetConnection.host} to check for command '${command}'.`);
try { try {
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
@@ -407,10 +394,8 @@ export class TransfersService {
foundCommandPath = await new Promise((resolve) => { foundCommandPath = await new Promise((resolve) => {
const checkCmd = `command -v ${this.escapeShellArg(command)} 2>/dev/null`; const checkCmd = `command -v ${this.escapeShellArg(command)} 2>/dev/null`;
console.error(`[Roo Debug][transfers.service.ts] checkCommandOnTargetServer: Executing on target: ${checkCmd}`);
targetClient.exec(checkCmd, (err, stream) => { targetClient.exec(checkCmd, (err, stream) => {
if (err) { if (err) {
console.warn(`[Roo Debug][transfers.service.ts] Error checking for command '${command}' on target ${targetConnection.host}:`, err);
return resolve(null); return resolve(null);
} }
let stdout = ''; let stdout = '';
@@ -419,20 +404,16 @@ export class TransfersService {
.on('close', (code: number) => { .on('close', (code: number) => {
const pathOutput = stdout.trim(); const pathOutput = stdout.trim();
if (code === 0 && pathOutput) { if (code === 0 && pathOutput) {
console.error(`[Roo Debug][transfers.service.ts] checkCommandOnTargetServer: Command '${command}' found at '${pathOutput}' on target ${targetConnection.host}.`);
resolve(pathOutput); resolve(pathOutput);
} else { } else {
console.warn(`[Roo Debug][transfers.service.ts] checkCommandOnTargetServer: Command '${command}' not found on target ${targetConnection.host} (exit code: ${code}).`);
resolve(null); resolve(null);
} }
}) })
.stderr.on('data', (data: Buffer) => { .stderr.on('data', (data: Buffer) => {
console.warn(`[Roo Debug][transfers.service.ts] checkCommandOnTargetServer: STDERR for '${command}' on target ${targetConnection.host}: ${data.toString()}`);
}); });
}); });
}); });
} catch (error) { } catch (error) {
console.error(`[Roo Debug][transfers.service.ts] checkCommandOnTargetServer: Failed to check command '${command}' on target ${targetConnection.host}:`, error);
foundCommandPath = null; // Ensure it's null on error foundCommandPath = null; // Ensure it's null on error
} finally { } finally {
targetClient.end(); targetClient.end();
@@ -441,7 +422,6 @@ export class TransfersService {
} }
private async uploadKeyToSourceViaSftp(client: Client, privateKeyContent: string, remotePath: string): Promise<void> { private async uploadKeyToSourceViaSftp(client: Client, privateKeyContent: string, remotePath: string): Promise<void> {
console.error(`[Roo Debug][transfers.service.ts] ENTERING uploadKeyToSourceViaSftp for remotePath: ${remotePath}`);
const SFTP_UPLOAD_TIMEOUT_MS = 30000; // 30 seconds timeout for SFTP key upload const SFTP_UPLOAD_TIMEOUT_MS = 30000; // 30 seconds timeout for SFTP key upload
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -450,8 +430,6 @@ export class TransfersService {
const cleanupAndReject = (errMsg: string, errObj?: any) => { const cleanupAndReject = (errMsg: string, errObj?: any) => {
if (timeoutHandle) clearTimeout(timeoutHandle); if (timeoutHandle) clearTimeout(timeoutHandle);
if (errObj) console.error(`[Roo Debug][transfers.service.ts] uploadKeyToSourceViaSftp error: ${errMsg}`, errObj);
else console.error(`[Roo Debug][transfers.service.ts] uploadKeyToSourceViaSftp error: ${errMsg}`);
sftpSession?.end(); sftpSession?.end();
reject(new Error(errMsg)); reject(new Error(errMsg));
}; };
@@ -460,7 +438,6 @@ export class TransfersService {
cleanupAndReject(`SFTP upload to ${remotePath} timed out after ${SFTP_UPLOAD_TIMEOUT_MS / 1000}s.`); cleanupAndReject(`SFTP upload to ${remotePath} timed out after ${SFTP_UPLOAD_TIMEOUT_MS / 1000}s.`);
}, SFTP_UPLOAD_TIMEOUT_MS); }, SFTP_UPLOAD_TIMEOUT_MS);
console.error(`[Roo Debug][transfers.service.ts] uploadKeyToSourceViaSftp: Calling client.sftp(). Timeout set for ${SFTP_UPLOAD_TIMEOUT_MS}ms.`);
client.sftp((err, sftp) => { client.sftp((err, sftp) => {
sftpSession = sftp; // Store session for potential cleanup sftpSession = sftp; // Store session for potential cleanup
if (err) { if (err) {
@@ -469,7 +446,6 @@ export class TransfersService {
if (!sftp) { if (!sftp) {
return cleanupAndReject(`SFTP session error: SFTP object is null.`); return cleanupAndReject(`SFTP session error: SFTP object is null.`);
} }
console.error(`[Roo Debug][transfers.service.ts] uploadKeyToSourceViaSftp: client.sftp() CALLBACK success. SFTP session obtained. Creating write stream to ${remotePath}`);
const stream = sftp.createWriteStream(remotePath, { mode: 0o600 }); const stream = sftp.createWriteStream(remotePath, { mode: 0o600 });
stream.on('error', (writeErr: Error) => { stream.on('error', (writeErr: Error) => {
@@ -479,19 +455,13 @@ export class TransfersService {
// Listen to 'close' instead of 'finish' for more reliability // Listen to 'close' instead of 'finish' for more reliability
stream.on('close', () => { stream.on('close', () => {
if (timeoutHandle) clearTimeout(timeoutHandle); if (timeoutHandle) clearTimeout(timeoutHandle);
console.error(`[Roo Debug][transfers.service.ts] uploadKeyToSourceViaSftp: WriteStream ON CLOSE for ${remotePath}. Key upload likely successful.`);
console.info(`[TransfersService] Private key for target successfully uploaded to source at ${remotePath}`); console.info(`[TransfersService] Private key for target successfully uploaded to source at ${remotePath}`);
sftp.end(); sftp.end();
resolve(); resolve();
}); });
console.error(`[Roo Debug][transfers.service.ts] uploadKeyToSourceViaSftp: Previewing privateKeyContent before stream.end(). Length: ${privateKeyContent.length}`);
console.error(`[Roo Debug][transfers.service.ts] uploadKeyToSourceViaSftp: Key content START: <<<${privateKeyContent.substring(0, 70)}>>>`);
console.error(`[Roo Debug][transfers.service.ts] uploadKeyToSourceViaSftp: Key content END: <<<${privateKeyContent.substring(Math.max(0, privateKeyContent.length - 70))}>>>`);
console.error(`[Roo Debug][transfers.service.ts] uploadKeyToSourceViaSftp: Calling stream.end() to write key content.`);
let keyContentToWrite = privateKeyContent; let keyContentToWrite = privateKeyContent;
if (!keyContentToWrite.endsWith('\n')) { if (!keyContentToWrite.endsWith('\n')) {
console.error(`[Roo Debug][transfers.service.ts] uploadKeyToSourceViaSftp: privateKeyContent does not end with a newline. Appending one.`);
keyContentToWrite += '\n'; keyContentToWrite += '\n';
} }
stream.end(keyContentToWrite); stream.end(keyContentToWrite);
@@ -544,8 +514,7 @@ export class TransfersService {
commandParts.push(options.sshPassCommand); commandParts.push(options.sshPassCommand);
} }
// Use the full path here (should be safe, no special chars from command -v)
// Arguments will still be quoted later.
commandParts.push(executableCommand); commandParts.push(executableCommand);
if (commandType === 'rsync') { if (commandType === 'rsync') {
@@ -568,12 +537,6 @@ export class TransfersService {
commandParts.push(remoteFullDest); commandParts.push(remoteFullDest);
} else { // scp } else { // scp
// For scp, SSH options are typically passed directly if scp is a wrapper around ssh, or via scp's own options that map to ssh options.
// Common scp implementations accept -P for port and -i for identity file directly.
// StrictHostKeyChecking and UserKnownHostsFile are ssh options.
// We build the ssh part for scp separately if needed, or rely on scp passing -o options.
// Let's assume scp will pass these -o options to its underlying ssh call.
// If not, a more complex construction of scp's ssh command via -S might be needed.
commandParts.push('-o StrictHostKeyChecking=no'); // For scp, pass as direct option commandParts.push('-o StrictHostKeyChecking=no'); // For scp, pass as direct option
commandParts.push('-o UserKnownHostsFile=/dev/null'); // For scp, pass as direct option commandParts.push('-o UserKnownHostsFile=/dev/null'); // For scp, pass as direct option
if (isDir) commandParts.push('-r'); if (isDir) commandParts.push('-r');
@@ -601,14 +564,12 @@ private async executeRemoteTransferOnSource(
transferMethodPreference: 'auto' | 'rsync' | 'scp', transferMethodPreference: 'auto' | 'rsync' | 'scp',
signal: AbortSignal // +++ Add AbortSignal parameter +++ signal: AbortSignal // +++ Add AbortSignal parameter +++
): Promise<void> { ): Promise<void> {
console.error(`[Roo Debug][transfers.service.ts] ENTERING executeRemoteTransferOnSource for sub-task ${subTaskId}, item: ${sourceItem.name}`);
if (signal.aborted) throw new DOMException('Transfer cancelled by user.', 'AbortError'); if (signal.aborted) throw new DOMException('Transfer cancelled by user.', 'AbortError');
this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 0, `Initializing remote transfer for ${sourceItem.name}`); this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 0, `Initializing remote transfer for ${sourceItem.name}`);
let tempTargetKeyPathOnSource: string | undefined; let tempTargetKeyPathOnSource: string | undefined;
try { try {
if (signal.aborted) throw new DOMException('Transfer cancelled by user.', 'AbortError'); if (signal.aborted) throw new DOMException('Transfer cancelled by user.', 'AbortError');
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Starting try block in executeRemoteTransferOnSource.`);
// Pass signal to these check commands if they are made to support it. For now, they are quick. // Pass signal to these check commands if they are made to support it. For now, they are quick.
const sshpassPath = await this.checkCommandOnSource(sourceSshClient, 'sshpass' /*, signal */); const sshpassPath = await this.checkCommandOnSource(sourceSshClient, 'sshpass' /*, signal */);
if (signal.aborted) throw new DOMException('Transfer cancelled by user.', 'AbortError'); if (signal.aborted) throw new DOMException('Transfer cancelled by user.', 'AbortError');
@@ -617,7 +578,6 @@ private async executeRemoteTransferOnSource(
const scpPathOnSource = await this.checkCommandOnSource(sourceSshClient, 'scp' /*, signal */); const scpPathOnSource = await this.checkCommandOnSource(sourceSshClient, 'scp' /*, signal */);
if (signal.aborted) throw new DOMException('Transfer cancelled by user.', 'AbortError'); if (signal.aborted) throw new DOMException('Transfer cancelled by user.', 'AbortError');
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Source checks -> sshpass: ${sshpassPath}, rsync: ${rsyncPathOnSource}, scp: ${scpPathOnSource}`);
let executableCommandPath: string | null = null; let executableCommandPath: string | null = null;
let commandTypeForLogic: 'rsync' | 'scp' | undefined = undefined; // Initialize as undefined let commandTypeForLogic: 'rsync' | 'scp' | undefined = undefined; // Initialize as undefined
@@ -626,7 +586,6 @@ private async executeRemoteTransferOnSource(
if (transferMethodPreference === 'auto') { if (transferMethodPreference === 'auto') {
if (rsyncPathOnSource) { if (rsyncPathOnSource) {
// Source has rsync, check target // Source has rsync, check target
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: 'auto' mode, rsync found on source. Checking target...`);
rsyncPathOnTarget = await this.checkCommandOnTargetServer(targetConnection, targetCredentials, 'rsync' /*, signal */); rsyncPathOnTarget = await this.checkCommandOnTargetServer(targetConnection, targetCredentials, 'rsync' /*, signal */);
if (signal.aborted) throw new DOMException('Transfer cancelled by user.', 'AbortError'); if (signal.aborted) throw new DOMException('Transfer cancelled by user.', 'AbortError');
if (rsyncPathOnTarget) { if (rsyncPathOnTarget) {
@@ -677,7 +636,6 @@ private async executeRemoteTransferOnSource(
// +++ 自动创建目标目录 +++ // +++ 自动创建目标目录 +++
this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 6, `Ensuring target directory ${this.escapeShellArg(remoteTargetPathOnTarget)} exists on ${targetConnection.host}.`); this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 6, `Ensuring target directory ${this.escapeShellArg(remoteTargetPathOnTarget)} exists on ${targetConnection.host}.`);
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Ensuring target directory exists: ${remoteTargetPathOnTarget} on ${targetConnection.host}`);
const targetClientForMkdir = new Client(); const targetClientForMkdir = new Client();
const targetConnectConfigForMkdir = this.buildSshConnectConfig(targetConnection, targetCredentials); const targetConnectConfigForMkdir = this.buildSshConnectConfig(targetConnection, targetCredentials);
try { try {
@@ -685,8 +643,8 @@ private async executeRemoteTransferOnSource(
await new Promise<void>((resolveMkdir, rejectMkdir) => { await new Promise<void>((resolveMkdir, rejectMkdir) => {
let mkdirStreamClosed = false; let mkdirStreamClosed = false;
const onAbortMkdir = () => { const onAbortMkdir = () => {
if (!mkdirStreamClosed) { // Only if stream/connection is still active if (!mkdirStreamClosed) {
targetClientForMkdir.end(); // Attempt to close the connection targetClientForMkdir.end();
} }
rejectMkdir(new DOMException('Mkdir operation cancelled by user.', 'AbortError')); rejectMkdir(new DOMException('Mkdir operation cancelled by user.', 'AbortError'));
}; };
@@ -699,7 +657,6 @@ private async executeRemoteTransferOnSource(
return rejectMkdir(new DOMException('Mkdir operation cancelled by user (on ready).', 'AbortError')); return rejectMkdir(new DOMException('Mkdir operation cancelled by user (on ready).', 'AbortError'));
} }
const mkdirCommand = `mkdir -p ${this.escapeShellArg(remoteTargetPathOnTarget)}`; const mkdirCommand = `mkdir -p ${this.escapeShellArg(remoteTargetPathOnTarget)}`;
console.error(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: Executing on target for mkdir: ${mkdirCommand}`);
targetClientForMkdir.exec(mkdirCommand, (err, stream) => { targetClientForMkdir.exec(mkdirCommand, (err, stream) => {
if (err) { if (err) {
signal.removeEventListener('abort', onAbortMkdir); signal.removeEventListener('abort', onAbortMkdir);
@@ -718,11 +675,9 @@ private async executeRemoteTransferOnSource(
rejectMkdir(new Error(`Failed to create directory ${remoteTargetPathOnTarget} on ${targetConnection.host}. Exit code: ${code}. Stderr: ${mkdirStderr.trim()}`)); rejectMkdir(new Error(`Failed to create directory ${remoteTargetPathOnTarget} on ${targetConnection.host}. Exit code: ${code}. Stderr: ${mkdirStderr.trim()}`));
} }
}).on('data', (data: Buffer) => { }).on('data', (data: Buffer) => {
// stdout from mkdir -p is usually empty
}).stderr.on('data', (data: Buffer) => { }).stderr.on('data', (data: Buffer) => {
mkdirStderr += data.toString(); mkdirStderr += data.toString();
console.warn(`[Roo Debug][transfers.service.ts] Sub-task ${subTaskId}: STDERR (mkdir on target): ${data.toString()}`); }).on('error', (streamErr: Error) => {
}).on('error', (streamErr: Error) => { // Handle stream errors specifically
mkdirStreamClosed = true; mkdirStreamClosed = true;
signal.removeEventListener('abort', onAbortMkdir); signal.removeEventListener('abort', onAbortMkdir);
targetClientForMkdir.end(); targetClientForMkdir.end();
@@ -731,11 +686,9 @@ private async executeRemoteTransferOnSource(
}); });
}).on('error', (connErr: Error) => { }).on('error', (connErr: Error) => {
signal.removeEventListener('abort', onAbortMkdir); signal.removeEventListener('abort', onAbortMkdir);
// targetClientForMkdir.end(); // .end() might not be needed if 'close' always follows 'error'
rejectMkdir(connErr); rejectMkdir(connErr);
}).on('close', () => { // This 'close' is for the client connection itself }).on('close', () => {
signal.removeEventListener('abort', onAbortMkdir); // Ensure cleanup if closed for other reasons signal.removeEventListener('abort', onAbortMkdir);
// console.info(`[TransfersService] SSH connection for mkdir to target ${targetConnection.host} closed.`);
}).connect(targetConnectConfigForMkdir); }).connect(targetConnectConfigForMkdir);
}); });
@@ -743,17 +696,14 @@ private async executeRemoteTransferOnSource(
this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 8, `Target directory ensured. Preparing transfer command.`); this.updateSubTaskStatus(taskId, subTaskId, 'transferring', 8, `Target directory ensured. Preparing transfer command.`);
} catch (mkdirError: any) { } catch (mkdirError: any) {
// Ensure client is closed on error if it's still somehow connected
// (though on 'error' or exec stream 'close'/'error', it should be handled)
if (targetClientForMkdir && (targetClientForMkdir as any)._sock && !(targetClientForMkdir as any)._sock.destroyed) { if (targetClientForMkdir && (targetClientForMkdir as any)._sock && !(targetClientForMkdir as any)._sock.destroyed) {
try { targetClientForMkdir.end(); } catch (e) { /* ignore */ } try { targetClientForMkdir.end(); } catch (e) { /* ignore */ }
} }
console.error(`[TransfersService] Sub-task ${subTaskId}: Failed to ensure target directory ${remoteTargetPathOnTarget} on ${targetConnection.host}:`, mkdirError.message); console.error(`[TransfersService] Sub-task ${subTaskId}: Failed to ensure target directory ${remoteTargetPathOnTarget} on ${targetConnection.host}:`, mkdirError.message);
if (mkdirError.name === 'AbortError') { if (mkdirError.name === 'AbortError') {
this.updateSubTaskStatus(taskId, subTaskId, 'cancelled', undefined, `Directory creation cancelled: ${mkdirError.message}`); this.updateSubTaskStatus(taskId, subTaskId, 'cancelled', undefined, `Directory creation cancelled: ${mkdirError.message}`);
throw mkdirError; // Re-throw AbortError to be handled by the main try-catch throw mkdirError;
} }
// For other errors, update status to failed and throw a new error to be caught by main try-catch
this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, `Failed to create target directory: ${mkdirError.message}`); this.updateSubTaskStatus(taskId, subTaskId, 'failed', undefined, `Failed to create target directory: ${mkdirError.message}`);
throw new Error(`Failed to create target directory ${remoteTargetPathOnTarget}: ${mkdirError.message}`); // This will be caught by the outer try-catch throw new Error(`Failed to create target directory ${remoteTargetPathOnTarget}: ${mkdirError.message}`); // This will be caught by the outer try-catch
} }
@@ -792,10 +742,6 @@ private async executeRemoteTransferOnSource(
const onAbortCmd = () => { const onAbortCmd = () => {
if (!streamClosed) { if (!streamClosed) {
console.warn(`[TransfersService] Abort signal received for command stream of sub-task ${subTaskId}. Attempting to close stream.`); console.warn(`[TransfersService] Abort signal received for command stream of sub-task ${subTaskId}. Attempting to close stream.`);
// execStream?.close(); // 'execStream' is not defined here, should be 'stream' from exec callback
// It's tricky to access the stream here to close it directly.
// The main mechanism will be the timeout and the client connection eventually closing if task is aborted.
// Or, if ssh2's stream object can be made available to this scope, call .close() or .destroy().
} }
rejectCmd(new DOMException('Command cancelled by user.', 'AbortError')); rejectCmd(new DOMException('Command cancelled by user.', 'AbortError'));
}; };
@@ -840,7 +786,6 @@ private async executeRemoteTransferOnSource(
stream.stderr.on('data', (data: Buffer) => { stream.stderr.on('data', (data: Buffer) => {
if (signal.aborted) return; if (signal.aborted) return;
stderrCombined += data.toString(); stderrCombined += data.toString();
console.warn(`[Roo Debug][transfers.service.ts] STDERR Sub-task ${subTaskId}: ${data.toString()}`);
}); });
stream.on('close', (code: number | null) => { stream.on('close', (code: number | null) => {
streamClosed = true; streamClosed = true;
@@ -882,7 +827,6 @@ private async executeRemoteTransferOnSource(
} }
throw error; // Re-throw to be caught by processSingleSubTaskWrapper throw error; // Re-throw to be caught by processSingleSubTaskWrapper
} finally { } finally {
console.info(`[Roo Debug][transfers.service.ts] executeRemoteTransferOnSource FINALLY for sub-task ${subTaskId}`);
if (tempTargetKeyPathOnSource) { if (tempTargetKeyPathOnSource) {
try { try {
// TODO: Make deleteFileOnSourceViaSftp accept signal // TODO: Make deleteFileOnSourceViaSftp accept signal
@@ -1001,7 +945,6 @@ private async executeRemoteTransferOnSource(
if (numSubTasks === 0) { if (numSubTasks === 0) {
task.overallProgress = 0; task.overallProgress = 0;
// task.status remains as set by initiate or direct updateOverallTaskStatus if no subtasks.
return; return;
} }
@@ -1013,12 +956,9 @@ private async executeRemoteTransferOnSource(
break; break;
case 'failed': case 'failed':
failedCount++; failedCount++;
// Failed tasks are "done" but contribute 0 to success progress.
// Depending on definition, they could count as 100 for task "completion" progress.
// Here, only successful completion adds to progress towards 100%.
break; break;
case 'transferring': case 'transferring':
case 'connecting': // consider connecting as in-progress for overall status case 'connecting':
inProgressCount++; inProgressCount++;
totalProgress += (st.progress !== undefined ? st.progress : (st.status === 'connecting' ? 5 : 0)); // Small progress for connecting totalProgress += (st.progress !== undefined ? st.progress : (st.status === 'connecting' ? 5 : 0)); // Small progress for connecting
break; break;
@@ -1043,8 +983,6 @@ private async executeRemoteTransferOnSource(
} else if (queuedCount === numSubTasks) { } else if (queuedCount === numSubTasks) {
newOverallStatus = 'queued'; // All subtasks are still queued newOverallStatus = 'queued'; // All subtasks are still queued
} else { } else {
// Fallback or unexpected mixed state, treat as in-progress generally
// This case implies some completed, some queued, no failed, no in-progress items.
newOverallStatus = 'in-progress'; // Or 'partially-completed' if completedCount > 0 newOverallStatus = 'in-progress'; // Or 'partially-completed' if completedCount > 0
if (completedCount > 0 && queuedCount > 0 && failedCount === 0 && inProgressCount === 0) { if (completedCount > 0 && queuedCount > 0 && failedCount === 0 && inProgressCount === 0) {
newOverallStatus = 'partially-completed'; // More accurate for this specific mix newOverallStatus = 'partially-completed'; // More accurate for this specific mix
@@ -1056,7 +994,6 @@ private async executeRemoteTransferOnSource(
task.status = newOverallStatus; task.status = newOverallStatus;
} }
task.updatedAt = new Date(); task.updatedAt = new Date();
// console.debug(`[TransfersService] Task ${taskId} overall progress: ${task.overallProgress}%, status: ${task.status}`);
} }
private finalizeOverallTaskStatus(taskId: string): void { private finalizeOverallTaskStatus(taskId: string): void {
@@ -10,7 +10,7 @@ export interface ConnectionBase {
created_at: number; created_at: number;
updated_at: number; updated_at: number;
last_connected_at: number | null; last_connected_at: number | null;
notes?: string | null; // 新增备注字段 notes?: string | null;
} }
export interface ConnectionWithTags extends ConnectionBase { export interface ConnectionWithTags extends ConnectionBase {
@@ -31,7 +31,7 @@ export interface CreateConnectionInput {
ssh_key_id?: number | null; // +++ Add ssh_key_id +++ ssh_key_id?: number | null; // +++ Add ssh_key_id +++
proxy_id?: number | null; proxy_id?: number | null;
tag_ids?: number[]; tag_ids?: number[];
notes?: string | null; // 新增备注字段 notes?: string | null;
} }
@@ -47,7 +47,7 @@ export interface UpdateConnectionInput {
passphrase?: string; passphrase?: string;
ssh_key_id?: number | null; // +++ Add ssh_key_id +++ ssh_key_id?: number | null; // +++ Add ssh_key_id +++
proxy_id?: number | null; proxy_id?: number | null;
notes?: string | null; // 新增备注字段 notes?: string | null;
tag_ids?: number[]; tag_ids?: number[];
} }
@@ -63,10 +63,10 @@ export interface FullConnectionData {
encrypted_password: string | null; encrypted_password: string | null;
encrypted_private_key: string | null; encrypted_private_key: string | null;
encrypted_passphrase: string | null; encrypted_passphrase: string | null;
ssh_key_id?: number | null; // +++ Add ssh_key_id +++ ssh_key_id?: number | null;
proxy_id: number | null; proxy_id: number | null;
created_at: number; created_at: number;
notes: string | null; // 新增备注字段 (数据库原始字段) notes: string | null;
updated_at: number; updated_at: number;
last_connected_at: number | null; last_connected_at: number | null;
} }
@@ -28,7 +28,7 @@ const initialFormData = {
port: 1080, // SOCKS5 port: 1080, // SOCKS5
username: '', username: '',
password: '', password: '',
tag_ids: [] as number[], // tag_ids tag_ids: [] as number[],
}; };
const formData = reactive({ ...initialFormData }); const formData = reactive({ ...initialFormData });
@@ -1,11 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch, nextTick, type PropType, onUnmounted, computed } from 'vue'; // Added watch, nextTick, computed import { ref, watch, nextTick, type PropType, onUnmounted, computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import SendFilesModal from './SendFilesModal.vue'; import SendFilesModal from './SendFilesModal.vue';
import type { ContextMenuItem } from '../composables/file-manager/useFileManagerContextMenu'; import type { ContextMenuItem } from '../composables/file-manager/useFileManagerContextMenu';
import type { FileListItem } from '../types/sftp.types'; // Import FileListItem import type { FileListItem } from '../types/sftp.types';
import { useDeviceDetection } from '../composables/useDeviceDetection'; import { useDeviceDetection } from '../composables/useDeviceDetection';
import { useSessionStore } from '../stores/session.store'; // +++ session store +++ import { useSessionStore } from '../stores/session.store';
const props = defineProps({ const props = defineProps({
isVisible: { isVisible: {
@@ -28,8 +28,8 @@ const connectionStatus = ref<'disconnected' | 'connecting' | 'connected' | 'erro
const isResizing = ref(false); const isResizing = ref(false);
const resizeStartX = ref(0); const resizeStartX = ref(0);
const resizeStartY = ref(0); const resizeStartY = ref(0);
const initialModalWidthForResize = ref(0); // Renamed to avoid conflict if other 'initialModalWidth' exists const initialModalWidthForResize = ref(0);
const initialModalHeightForResize = ref(0); // Renamed const initialModalHeightForResize = ref(0);
const statusMessage = ref(''); const statusMessage = ref('');
const keyboard = ref<any | null>(null); const keyboard = ref<any | null>(null);
const mouse = ref<any | null>(null); const mouse = ref<any | null>(null);
@@ -42,7 +42,7 @@ const isDraggingRestoreButton = ref(false);
const restoreButtonPosition = ref({ x: 16, y: window.innerHeight / 2 - 25 }); // 16px from left, vertically centered const restoreButtonPosition = ref({ x: 16, y: window.innerHeight / 2 - 25 }); // 16px from left, vertically centered
let dragOffsetX = 0; let dragOffsetX = 0;
let dragOffsetY = 0; let dragOffsetY = 0;
let hasDragged = false; // hasDragged let hasDragged = false;
const MIN_MODAL_WIDTH = 1024; const MIN_MODAL_WIDTH = 1024;
const MIN_MODAL_HEIGHT = 768; const MIN_MODAL_HEIGHT = 768;
@@ -431,7 +431,7 @@ watch(desiredModalWidth, (newWidth, oldWidth) => {
console.log(`[RDP 模态框] 宽度监听触发,但值 (${newWidth}) 未改变。跳过保存。`); console.log(`[RDP 模态框] 宽度监听触发,但值 (${newWidth}) 未改变。跳过保存。`);
return; return;
} }
console.log(`[RDP 模态框] 监听 desiredModalWidth 触发: ${oldWidth} -> ${newWidth}`); // console.log(`[RDP 模态框] 监听 desiredModalWidth 触发: ${oldWidth} -> ${newWidth}`);
// //
const validatedWidth = Math.max(MIN_MODAL_WIDTH, Number(newWidth) || MIN_MODAL_WIDTH); const validatedWidth = Math.max(MIN_MODAL_WIDTH, Number(newWidth) || MIN_MODAL_WIDTH);
// ** // **
@@ -210,11 +210,6 @@ const getGroupId = (group: GroupedConnection): string => {
const toggleTagGroupExpansion = (group: GroupedConnection) => { const toggleTagGroupExpansion = (group: GroupedConnection) => {
const groupId = getGroupId(group); const groupId = getGroupId(group);
// If state is undefined, default to true (expanded), then toggle it.
// So, if undefined, it becomes !(true) = false. If defined, it's just toggled.
// To make it default to expanded and then collapse on first click:
// expandedTagGroups.value[groupId] = !(expandedTagGroups.value[groupId] ?? true);
// To make it default to collapsed and then expand on first click (if true means expanded):
expandedTagGroups.value[groupId] = !(expandedTagGroups.value[groupId] ?? true); expandedTagGroups.value[groupId] = !(expandedTagGroups.value[groupId] ?? true);
}; };
@@ -276,8 +271,6 @@ const groupedConnections = computed<GroupedConnection[]>(() => {
groups[tag.id].connections.push(conn); groups[tag.id].connections.push(conn);
} }
} else { } else {
// Connection has a tag ID that doesn't exist in tagsStore, treat as untagged for this modal
// Or handle as an error, or create a "missing tag" group
if (!untaggedConnections.some(c => c.id === conn.id)) { if (!untaggedConnections.some(c => c.id === conn.id)) {
untaggedConnections.push(conn); untaggedConnections.push(conn);
} }
@@ -359,11 +352,6 @@ const toggleTagGroupSelection = (group: GroupedConnection) => {
watch(() => props.visible, (newValue) => { watch(() => props.visible, (newValue) => {
if (newValue) { if (newValue) {
// Reset state when modal becomes visible, except perhaps targetPath if desired
// searchTerm.value = '';
// selectedConnectionIds.value = [];
// transferMethod.value = 'auto';
// If stores might be empty, fetch again or ensure they are fresh
if (connectionsStore.connections.length === 0) { if (connectionsStore.connections.length === 0) {
connectionsStore.fetchConnections().catch(error => console.error(t('sendFilesModal.errorFetchingConnections'), error)); connectionsStore.fetchConnections().catch(error => console.error(t('sendFilesModal.errorFetchingConnections'), error));
} }
@@ -428,7 +416,6 @@ const toggleIndividualConnectionSelection = (connectionId: number) => {
}; };
const getConnectionIconClass = (connectionType?: string): string => { const getConnectionIconClass = (connectionType?: string): string => {
// Ensure connectionType is treated as optional and provide a default if undefined
const type = connectionType?.toLowerCase(); const type = connectionType?.toLowerCase();
switch (type) { switch (type) {
case 'rdp': return 'fas fa-desktop'; case 'rdp': return 'fas fa-desktop';
@@ -438,9 +425,8 @@ const getConnectionIconClass = (connectionType?: string): string => {
case 'local': return 'fas fa-laptop'; case 'local': return 'fas fa-laptop';
case 'serial': return 'fas fa-microchip'; case 'serial': return 'fas fa-microchip';
case 'docker': return 'fab fa-docker'; case 'docker': return 'fab fa-docker';
default: return 'fas fa-server'; // Default icon for unknown or undefined types default: return 'fas fa-server';
} }
}; };
// Fallback i18n messages are now removed as they are expected to be in the locale JSON files.
</script> </script>
@@ -98,8 +98,8 @@ const initializeEditableState = () => {
editableEditorFontSize.value = currentEditorFontSize.value; // <-- editableEditorFontSize.value = currentEditorFontSize.value; // <--
localTerminalBackgroundEnabled.value = isTerminalBackgroundEnabled.value; // <-- localTerminalBackgroundEnabled.value = isTerminalBackgroundEnabled.value; // <--
editableTerminalBackgroundOverlayOpacity.value = currentTerminalBackgroundOverlayOpacity.value; // editableTerminalBackgroundOverlayOpacity.value = currentTerminalBackgroundOverlayOpacity.value; //
console.log(`[StyleCustomizer initializeEditableState] Initializing localTerminalBackgroundEnabled to: ${localTerminalBackgroundEnabled.value} (from store: ${isTerminalBackgroundEnabled.value})`); // console.log(`[StyleCustomizer initializeEditableState] Initializing localTerminalBackgroundEnabled to: ${localTerminalBackgroundEnabled.value} (from store: ${isTerminalBackgroundEnabled.value})`);
console.log(`[StyleCustomizer initializeEditableState] Initializing editableTerminalBackgroundOverlayOpacity to: ${editableTerminalBackgroundOverlayOpacity.value} (from store: ${currentTerminalBackgroundOverlayOpacity.value})`); // console.log(`[StyleCustomizer initializeEditableState] Initializing editableTerminalBackgroundOverlayOpacity to: ${editableTerminalBackgroundOverlayOpacity.value} (from store: ${currentTerminalBackgroundOverlayOpacity.value})`);
uploadError.value = null; uploadError.value = null;
importError.value = null; importError.value = null;
saveThemeError.value = null; saveThemeError.value = null;
@@ -143,7 +143,7 @@ watch([
newSettings?.terminalBackgroundEnabled !== oldSettings?.terminalBackgroundEnabled || newSettings?.terminalBackgroundEnabled !== oldSettings?.terminalBackgroundEnabled ||
newSettings?.terminalBackgroundOverlayOpacity !== oldSettings?.terminalBackgroundOverlayOpacity; // newSettings?.terminalBackgroundOverlayOpacity !== oldSettings?.terminalBackgroundOverlayOpacity; //
if (!isEditingTheme.value || newActiveThemeId !== oldActiveThemeId || settingsChanged) { if (!isEditingTheme.value || newActiveThemeId !== oldActiveThemeId || settingsChanged) {
console.log(`[StyleCustomizer Watch] Triggering re-initialization. isEditing: ${isEditingTheme.value}, themeIdChanged: ${newActiveThemeId !== oldActiveThemeId}, settingsChanged: ${settingsChanged}`); // console.log(`[StyleCustomizer Watch] Triggering re-initialization. isEditing: ${isEditingTheme.value}, themeIdChanged: ${newActiveThemeId !== oldActiveThemeId}, settingsChanged: ${settingsChanged}`);
initializeEditableState(); // initializeEditableState(); //
} else { } else {
// ( UI initializeEditableState ) // ( UI initializeEditableState )
@@ -165,10 +165,10 @@ watch(isTerminalBackgroundEnabled, (newValue) => {
// store // store
// handleToggleTerminalBackground // handleToggleTerminalBackground
if (localTerminalBackgroundEnabled.value !== newValue) { if (localTerminalBackgroundEnabled.value !== newValue) {
console.log(`[StyleCustomizer Watch isTerminalBackgroundEnabled] Store changed to ${newValue}, updating local state.`); // console.log(`[StyleCustomizer Watch isTerminalBackgroundEnabled] Store changed to ${newValue}, updating local state.`);
localTerminalBackgroundEnabled.value = newValue; localTerminalBackgroundEnabled.value = newValue;
} else { } else {
console.log(`[StyleCustomizer Watch isTerminalBackgroundEnabled] Store changed to ${newValue}, but local state already matches. No update needed.`); // console.log(`[StyleCustomizer Watch isTerminalBackgroundEnabled] Store changed to ${newValue}, but local state already matches. No update needed.`);
} }
}); });
// isTerminalBackgroundEnabled watcher // isTerminalBackgroundEnabled watcher
@@ -6,7 +6,7 @@ import { useRoute } from 'vue-router';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import WorkspaceConnectionListComponent from './WorkspaceConnectionList.vue'; import WorkspaceConnectionListComponent from './WorkspaceConnectionList.vue';
import TabBarContextMenu from './TabBarContextMenu.vue'; import TabBarContextMenu from './TabBarContextMenu.vue';
import TransferProgressModal from './TransferProgressModal.vue'; // import TransferProgressModal from './TransferProgressModal.vue';
import { useSessionStore } from '../stores/session.store'; import { useSessionStore } from '../stores/session.store';
import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.store'; import { useConnectionsStore, type ConnectionInfo } from '../stores/connections.store';
import { useLayoutStore, type PaneName } from '../stores/layout.store'; import { useLayoutStore, type PaneName } from '../stores/layout.store';
@@ -15,7 +15,7 @@ import { useWorkspaceEventEmitter, useWorkspaceEventSubscriber, useWorkspaceEven
import type { SessionTabInfoWithStatus } from '../stores/session/types'; // import type { SessionTabInfoWithStatus } from '../stores/session/types'; //
const { t } = useI18n(); // i18n const { t } = useI18n();
const emitWorkspaceEvent = useWorkspaceEventEmitter(); // +++ +++ const emitWorkspaceEvent = useWorkspaceEventEmitter(); // +++ +++
const onWorkspaceEvent = useWorkspaceEventSubscriber(); // +++ +++ const onWorkspaceEvent = useWorkspaceEventSubscriber(); // +++ +++
const offWorkspaceEvent = useWorkspaceEventOff(); // +++ +++ const offWorkspaceEvent = useWorkspaceEventOff(); // +++ +++
@@ -65,8 +65,8 @@ interface TransferTask {
updatedAt: string | Date; updatedAt: string | Date;
subTasks: TransferSubTask[]; subTasks: TransferSubTask[];
overallProgress?: number; overallProgress?: number;
sourceConnectionId?: number; // ID () sourceConnectionId?: number;
remoteTargetPath?: string; // () remoteTargetPath?: string;
} }
const transferTasks = ref<TransferTask[]>([]); const transferTasks = ref<TransferTask[]>([]);
@@ -133,8 +133,8 @@ const getDisplayStatus = (status: string): string => {
'partially-completed': 'transferProgressModal.status.partiallyCompleted', 'partially-completed': 'transferProgressModal.status.partiallyCompleted',
'connecting': 'transferProgressModal.status.connecting', 'connecting': 'transferProgressModal.status.connecting',
'transferring': 'transferProgressModal.status.transferring', 'transferring': 'transferProgressModal.status.transferring',
'cancelling': 'transferProgressModal.status.cancelling', // +++ +++ 'cancelling': 'transferProgressModal.status.cancelling',
'cancelled': 'transferProgressModal.status.cancelled', // +++ +++ 'cancelled': 'transferProgressModal.status.cancelled',
}; };
// 退i18n key // 退i18n key
const defaultText = status.charAt(0).toUpperCase() + status.slice(1).replace('-', ' '); const defaultText = status.charAt(0).toUpperCase() + status.slice(1).replace('-', ' ');
@@ -150,7 +150,7 @@ const formatDate = (dateInput: string | Date): string => {
hour: '2-digit', minute: '2-digit', second: '2-digit' hour: '2-digit', minute: '2-digit', second: '2-digit'
}); });
} catch (e) { } catch (e) {
return String(dateInput); // Fallback if date is invalid return String(dateInput);
} }
}; };
@@ -185,7 +185,7 @@ watch(() => props.visible, (newVisible) => {
} }
}, { immediate: false }); // immediate: false onMounted }, { immediate: false }); // immediate: false onMounted
// --- --- // --- ---
const internalVisible = ref(props.visible); const internalVisible = ref(props.visible);
// props.visible internalVisible // props.visible internalVisible
@@ -221,17 +221,10 @@ const handleCancelTask = async (taskId: string) => {
// UI 'cancelling' // UI 'cancelling'
const task = transferTasks.value.find(t => t.taskId === taskId); const task = transferTasks.value.find(t => t.taskId === taskId);
if (task) { if (task) {
// : ,
// , : task.status = 'cancelling';
// loading
} }
await apiClient.post(`/transfers/cancel/${taskId}`); await apiClient.post(`/transfers/cancel/${taskId}`);
//
// uiNotificationsStore.showSuccess(t('transferProgressModal.cancelRequested', ''));
// 'cancelling'
//
const taskBeingCancelled = transferTasks.value.find(t => t.taskId === taskId); const taskBeingCancelled = transferTasks.value.find(t => t.taskId === taskId);
if (taskBeingCancelled && ['queued', 'in-progress', 'connecting', 'transferring'].includes(taskBeingCancelled.status)) { if (taskBeingCancelled && ['queued', 'in-progress', 'connecting', 'transferring'].includes(taskBeingCancelled.status)) {
taskBeingCancelled.status = 'cancelling'; taskBeingCancelled.status = 'cancelling';
@@ -241,8 +234,6 @@ const handleCancelTask = async (taskId: string) => {
fetchTransferTasks(); fetchTransferTasks();
} catch (error: any) { } catch (error: any) {
console.error(`Failed to cancel task ${taskId}:`, error); console.error(`Failed to cancel task ${taskId}:`, error);
// uiNotificationsStore.showError(error.response?.data?.message || error.message || t('transferProgressModal.error.cancelFailed', ''));
//
} }
}; };
@@ -383,7 +383,7 @@ const handleMenuAction = (action: 'add' | 'edit' | 'delete' | 'clone') => { //
closeContextMenu(); // closeContextMenu(); //
if (action === 'add') { if (action === 'add') {
console.log('[WorkspaceConnectionList] handleMenuAction called with action: add. Emitting request-add-connection...'); // console.log('[WorkspaceConnectionList] handleMenuAction called with action: add. Emitting request-add-connection...');
// router.push('/connections/add'); // // router.push('/connections/add'); //
emitWorkspaceEvent('connection:requestAdd'); emitWorkspaceEvent('connection:requestAdd');
} else if (conn) { } else if (conn) {
@@ -516,7 +516,7 @@ const handleTagMenuAction = (action: 'connectAll' | 'manageTag' | 'deleteAllConn
// //
if (group.tagId === null) { if (group.tagId === null) {
uiNotificationsStore.addNotification({ uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.cannotDeleteFromUntagged'), // i18n message: t('workspaceConnectionList.cannotDeleteFromUntagged'),
type: 'warning', type: 'warning',
}); });
return; return;
@@ -524,13 +524,13 @@ const handleTagMenuAction = (action: 'connectAll' | 'manageTag' | 'deleteAllConn
// //
if (group.connections.length === 0) { if (group.connections.length === 0) {
uiNotificationsStore.addNotification({ uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.noConnectionsToDeleteInGroup', { groupName: group.groupName }), // i18n message: t('workspaceConnectionList.noConnectionsToDeleteInGroup', { groupName: group.groupName }),
type: 'info', type: 'info',
}); });
return; return;
} }
if (confirm(t('workspaceConnectionList.confirmDeleteAllConnectionsInGroup', { count: group.connections.length, groupName: group.groupName }))) { // i18n if (confirm(t('workspaceConnectionList.confirmDeleteAllConnectionsInGroup', { count: group.connections.length, groupName: group.groupName }))) {
const connectionIdsToDelete = group.connections.map(conn => conn.id); const connectionIdsToDelete = group.connections.map(conn => conn.id);
const deletePromises = connectionIdsToDelete.map(connId => const deletePromises = connectionIdsToDelete.map(connId =>
@@ -547,13 +547,13 @@ const handleTagMenuAction = (action: 'connectAll' | 'manageTag' | 'deleteAllConn
if (successfulDeletes > 0) { if (successfulDeletes > 0) {
uiNotificationsStore.addNotification({ uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.allConnectionsInGroupDeletedSuccess', { count: successfulDeletes, groupName: group.groupName }), // i18n message: t('workspaceConnectionList.allConnectionsInGroupDeletedSuccess', { count: successfulDeletes, groupName: group.groupName }),
type: 'success', type: 'success',
}); });
} }
if (failedDeletes > 0) { if (failedDeletes > 0) {
uiNotificationsStore.addNotification({ uiNotificationsStore.addNotification({
message: t('workspaceConnectionList.someConnectionsInGroupDeleteFailed', { count: failedDeletes, groupName: group.groupName }), // i18n message: t('workspaceConnectionList.someConnectionsInGroupDeleteFailed', { count: failedDeletes, groupName: group.groupName }),
type: 'error', type: 'error',
}); });
} }
@@ -365,8 +365,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
// --- 返回状态和处理函数 --- // --- 返回状态和处理函数 ---
return { return {
// isDraggingOver, // 不再导出 showExternalDropOverlay,
showExternalDropOverlay, // 新增导出
dragOverTarget, dragOverTarget,
draggedItem, // 需要暴露以供 handleDragOverRow 等函数内部判断 draggedItem, // 需要暴露以供 handleDragOverRow 等函数内部判断
// --- 事件处理器 --- // --- 事件处理器 ---
@@ -374,7 +373,7 @@ export function useFileManagerDragAndDrop(options: UseFileManagerDragAndDropOpti
handleDragOver, handleDragOver,
handleDragLeave, handleDragLeave,
handleDrop, // 容器的 drop (主要用于清理) handleDrop, // 容器的 drop (主要用于清理)
handleOverlayDrop, // 新增导出:蒙版的 drop handleOverlayDrop,
handleDragStart, handleDragStart,
handleDragEnd, handleDragEnd,
handleDragOverRow, handleDragOverRow,
@@ -38,20 +38,20 @@ export function useAddConnectionForm(props: AddConnectionFormProps, emit: AddCon
// 表单数据模型 // 表单数据模型
const initialFormData = { const initialFormData = {
type: 'SSH' as 'SSH' | 'RDP' | 'VNC', // Use uppercase to match ConnectionInfo type: 'SSH' as 'SSH' | 'RDP' | 'VNC',
name: '', name: '',
host: '', host: '',
port: 22, port: 22,
username: '', username: '',
auth_method: 'password' as 'password' | 'key', // SSH specific auth_method: 'password' as 'password' | 'key',
password: '', password: '',
private_key: '', // SSH specific (for direct input) - This field seems unused in the new logic, but kept for initialData consistency private_key: '',
passphrase: '', // SSH specific (for direct input) - This field seems unused, kept for consistency passphrase: '',
selected_ssh_key_id: null as number | null, // +++ Add field for selected key ID +++ selected_ssh_key_id: null as number | null,
proxy_id: null as number | null, proxy_id: null as number | null,
tag_ids: [] as number[], // 新增 tag_ids 字段 tag_ids: [] as number[],
notes: '', // 新增备注字段 notes: '',
vncPassword: '', // VNC specific password vncPassword: '',
}; };
const formData = reactive({ ...initialFormData }); const formData = reactive({ ...initialFormData });
@@ -86,7 +86,6 @@ export function createSshTerminalManager(sessionId: string, wsDeps: SshTerminalD
}; };
const handleTerminalResize = (dimensions: { cols: number; rows: number }) => { const handleTerminalResize = (dimensions: { cols: number; rows: number }) => {
// 添加日志,确认从 WorkspaceView 收到的尺寸
console.log(`[SSH ${sessionId}] handleTerminalResize called with:`, dimensions); console.log(`[SSH ${sessionId}] handleTerminalResize called with:`, dimensions);
// 只有在连接状态下才发送 resize 命令给后端 // 只有在连接状态下才发送 resize 命令给后端
if (isConnected.value) { if (isConnected.value) {
@@ -235,9 +235,9 @@ export const useAppearanceStore = defineStore('appearance', () => {
* @param enabled * @param enabled
*/ */
async function setTerminalBackgroundEnabled(enabled: boolean) { async function setTerminalBackgroundEnabled(enabled: boolean) {
console.log(`[AppearanceStore LOG] setTerminalBackgroundEnabled 调用,准备发送给后端的值: ${enabled}`); // 添加日志 console.log(`[AppearanceStore LOG] setTerminalBackgroundEnabled 调用,准备发送给后端的值: ${enabled}`);
await updateAppearanceSettings({ terminalBackgroundEnabled: enabled }); await updateAppearanceSettings({ terminalBackgroundEnabled: enabled });
console.log(`[AppearanceStore LOG] setTerminalBackgroundEnabled 更新后端调用完成。`); // 添加日志 console.log(`[AppearanceStore LOG] setTerminalBackgroundEnabled 更新后端调用完成。`);
} }
/** /**
+5 -5
View File
@@ -55,17 +55,17 @@ interface AuthState {
user: UserInfo | null; user: UserInfo | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
loginRequires2FA: boolean; // 新增状态:标记登录是否需要 2FA loginRequires2FA: boolean;
// 存储 IP 黑名单数据 (虽然 actions 在这里,但 state 结构保持) // 存储 IP 黑名单数据 (虽然 actions 在这里,但 state 结构保持)
ipBlacklist: { ipBlacklist: {
entries: any[]; // TODO: Define a proper type for blacklist entries entries: any[]; // TODO: Define a proper type for blacklist entries
total: number; total: number;
}; };
needsSetup: boolean; // 是否需要初始设置 needsSetup: boolean; // 是否需要初始设置
publicCaptchaConfig: PublicCaptchaConfig | null; // Public CAPTCHA config publicCaptchaConfig: PublicCaptchaConfig | null;
passkeys: PasskeyInfo[] | null; // Store for user's passkeys passkeys: PasskeyInfo[] | null;
passkeysLoading: boolean; // Loading state for passkeys passkeysLoading: boolean;
hasPasskeysAvailable: boolean; // Indicates if passkeys are available for login hasPasskeysAvailable: boolean;
} }
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
@@ -16,7 +16,7 @@ export interface ConnectionInfo {
created_at: number; created_at: number;
updated_at: number; updated_at: number;
last_connected_at: number | null; last_connected_at: number | null;
notes?: string | null; // 新增备注字段 notes?: string | null;
vncPassword?: string; // VNC specific password vncPassword?: string; // VNC specific password
} }
+8 -8
View File
@@ -411,7 +411,7 @@ function ensureNodeIds(node: LayoutNode | null): LayoutNode | null {
} }
// 新增 Action: 更新特定容器节点的子节点大小 // 更新特定容器节点的子节点大小
function updateNodeSizes(nodeId: string, childrenSizes: { index: number; size: number }[]) { function updateNodeSizes(nodeId: string, childrenSizes: { index: number; size: number }[]) {
console.log(`[Layout Store] 请求更新节点 ${nodeId} 的子节点大小:`, childrenSizes); console.log(`[Layout Store] 请求更新节点 ${nodeId} 的子节点大小:`, childrenSizes);
const originalJson = JSON.stringify(layoutTree.value); // Store original state const originalJson = JSON.stringify(layoutTree.value); // Store original state
@@ -426,14 +426,14 @@ function ensureNodeIds(node: LayoutNode | null): LayoutNode | null {
console.log(`[Layout Store] 未找到节点 ${nodeId} 或大小未改变。`); console.log(`[Layout Store] 未找到节点 ${nodeId} 或大小未改变。`);
} }
} }
// 新增 Action: 切换布局(Header/Footer)的可见性 // 切换布局(Header/Footer)的可见性
function toggleLayoutVisibility() { function toggleLayoutVisibility() {
isLayoutVisible.value = !isLayoutVisible.value; isLayoutVisible.value = !isLayoutVisible.value;
console.log(`[Layout Store] 布局可见性切换为: ${isLayoutVisible.value}`); console.log(`[Layout Store] 布局可见性切换为: ${isLayoutVisible.value}`);
// 注意:这个状态目前不与后端同步 // 注意:这个状态目前不与后端同步
} }
// 新增 Action: 从后端加载主导航栏可见性设置 // 从后端加载主导航栏可见性设置
async function loadHeaderVisibility() { async function loadHeaderVisibility() {
console.log('[Layout Store] Attempting to load header visibility from backend...'); console.log('[Layout Store] Attempting to load header visibility from backend...');
try { try {
@@ -453,7 +453,7 @@ function ensureNodeIds(node: LayoutNode | null): LayoutNode | null {
} }
} }
// 新增 Action: 切换主导航栏可见性并同步到后端 // 切换主导航栏可见性并同步到后端
async function toggleHeaderVisibility() { async function toggleHeaderVisibility() {
const newValue = !isHeaderVisible.value; const newValue = !isHeaderVisible.value;
console.log(`[Layout Store] Toggling header visibility to: ${newValue}`); console.log(`[Layout Store] Toggling header visibility to: ${newValue}`);
@@ -471,19 +471,19 @@ function ensureNodeIds(node: LayoutNode | null): LayoutNode | null {
} }
} }
// 新增 Action: 获取系统内置的默认布局 // 获取系统内置的默认布局
function getSystemDefaultLayout(): LayoutNode { function getSystemDefaultLayout(): LayoutNode {
console.log('[Layout Store] Getting system default layout.'); console.log('[Layout Store] Getting system default layout.');
return getDefaultLayout(); // 直接调用内部函数 return getDefaultLayout(); // 直接调用内部函数
} }
// 新增 Action: 获取系统内置的默认侧栏配置 // 获取系统内置的默认侧栏配置
function getSystemDefaultSidebarPanes(): { left: PaneName[], right: PaneName[] } { function getSystemDefaultSidebarPanes(): { left: PaneName[], right: PaneName[] } {
console.log('[Layout Store] Getting system default sidebar panes.'); console.log('[Layout Store] Getting system default sidebar panes.');
return getDefaultSidebarPanes(); return getDefaultSidebarPanes();
} }
// 新增 Action: 将当前主布局树持久化到后端和 localStorage // 将当前主布局树持久化到后端和 localStorage
async function persistLayoutTree() { // Make async async function persistLayoutTree() { // Make async
// ... (existing try/catch logic for backend and localStorage) ... // ... (existing try/catch logic for backend and localStorage) ...
// Ensure apiClient calls are awaited if they return promises // Ensure apiClient calls are awaited if they return promises
@@ -504,7 +504,7 @@ function ensureNodeIds(node: LayoutNode | null): LayoutNode | null {
} }
} }
// 新增 Action: 将当前侧栏配置持久化到后端和 localStorage // 将当前侧栏配置持久化到后端和 localStorage
async function persistSidebarPanes() { // Make async async function persistSidebarPanes() { // Make async
// ... (existing try/catch logic for backend and localStorage) ... // ... (existing try/catch logic for backend and localStorage) ...
try { try {
@@ -214,7 +214,7 @@ const unsubscribeFromWorkspaceEvents = useWorkspaceEventOff();
// --- ( UI ) --- // --- ( UI ) ---
const handleRequestAddConnection = () => { const handleRequestAddConnection = () => {
console.log('[WorkspaceView] handleRequestAddConnection 被调用!'); // console.log('[WorkspaceView] handleRequestAddConnection 被调用!');
connectionToEdit.value = null; connectionToEdit.value = null;
showAddEditForm.value = true; showAddEditForm.value = true;
}; };