Improve File Manger UI scaling, fix file manager disconnect, disable more than one file manager at a time.
This commit is contained in:
@@ -85,7 +85,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# File manager recent, pinned, shortcuts (handled by SSH service)
|
|
||||||
location /ssh/file_manager/recent {
|
location /ssh/file_manager/recent {
|
||||||
proxy_pass http://127.0.0.1:8081;
|
proxy_pass http://127.0.0.1:8081;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -113,7 +112,6 @@ http {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
# SSH file manager operations (handled by file manager service)
|
|
||||||
location /ssh/file_manager/ssh/ {
|
location /ssh/file_manager/ssh/ {
|
||||||
proxy_pass http://127.0.0.1:8084;
|
proxy_pass http://127.0.0.1:8084;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ interface SSHSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sshSessions: Record<string, SSHSession> = {};
|
const sshSessions: Record<string, SSHSession> = {};
|
||||||
const SESSION_TIMEOUT_MS = 10 * 60 * 1000;
|
|
||||||
|
|
||||||
function cleanupSession(sessionId: string) {
|
function cleanupSession(sessionId: string) {
|
||||||
const session = sshSessions[sessionId];
|
const session = sshSessions[sessionId];
|
||||||
@@ -66,25 +65,26 @@ function scheduleSessionCleanup(sessionId: string) {
|
|||||||
const session = sshSessions[sessionId];
|
const session = sshSessions[sessionId];
|
||||||
if (session) {
|
if (session) {
|
||||||
if (session.timeout) clearTimeout(session.timeout);
|
if (session.timeout) clearTimeout(session.timeout);
|
||||||
session.timeout = setTimeout(() => cleanupSession(sessionId), SESSION_TIMEOUT_MS);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.post('/ssh/file_manager/ssh/connect', (req, res) => {
|
app.post('/ssh/file_manager/ssh/connect', async (req, res) => {
|
||||||
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
|
const {sessionId, ip, port, username, password, sshKey, keyPassword} = req.body;
|
||||||
if (!sessionId || !ip || !username || !port) {
|
if (!sessionId || !ip || !username || !port) {
|
||||||
return res.status(400).json({error: 'Missing SSH connection parameters'});
|
return res.status(400).json({error: 'Missing SSH connection parameters'});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sshSessions[sessionId]?.isConnected) cleanupSession(sessionId);
|
if (sshSessions[sessionId]?.isConnected) {
|
||||||
|
cleanupSession(sessionId);
|
||||||
|
}
|
||||||
const client = new SSHClient();
|
const client = new SSHClient();
|
||||||
const config: any = {
|
const config: any = {
|
||||||
host: ip,
|
host: ip,
|
||||||
port: port || 22,
|
port: port || 22,
|
||||||
username,
|
username,
|
||||||
readyTimeout: 20000,
|
readyTimeout: 0,
|
||||||
keepaliveInterval: 10000,
|
keepaliveInterval: 30000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 0,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
kex: [
|
kex: [
|
||||||
'diffie-hellman-group14-sha256',
|
'diffie-hellman-group14-sha256',
|
||||||
@@ -122,8 +122,22 @@ app.post('/ssh/file_manager/ssh/connect', (req, res) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (sshKey && sshKey.trim()) {
|
if (sshKey && sshKey.trim()) {
|
||||||
config.privateKey = sshKey;
|
try {
|
||||||
if (keyPassword) config.passphrase = keyPassword;
|
if (!sshKey.includes('-----BEGIN') || !sshKey.includes('-----END')) {
|
||||||
|
throw new Error('Invalid private key format');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanKey = sshKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
|
config.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
|
|
||||||
|
if (keyPassword) config.passphrase = keyPassword;
|
||||||
|
|
||||||
|
logger.info('SSH key authentication configured successfully for file manager');
|
||||||
|
} catch (keyError) {
|
||||||
|
logger.error('SSH key format error: ' + keyError.message);
|
||||||
|
return res.status(400).json({error: 'Invalid SSH key format'});
|
||||||
|
}
|
||||||
} else if (password && password.trim()) {
|
} else if (password && password.trim()) {
|
||||||
config.password = password;
|
config.password = password;
|
||||||
} else {
|
} else {
|
||||||
@@ -136,7 +150,6 @@ app.post('/ssh/file_manager/ssh/connect', (req, res) => {
|
|||||||
if (responseSent) return;
|
if (responseSent) return;
|
||||||
responseSent = true;
|
responseSent = true;
|
||||||
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
|
sshSessions[sessionId] = {client, isConnected: true, lastActive: Date.now()};
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
res.json({status: 'success', message: 'SSH connection established'});
|
res.json({status: 'success', message: 'SSH connection established'});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,7 +194,7 @@ app.get('/ssh/file_manager/ssh/listFiles', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
const escapedPath = sshPath.replace(/'/g, "'\"'\"'");
|
||||||
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
sshConn.client.exec(`ls -la '${escapedPath}'`, (err, stream) => {
|
||||||
@@ -251,7 +264,7 @@ app.get('/ssh/file_manager/ssh/readFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
const escapedPath = filePath.replace(/'/g, "'\"'\"'");
|
||||||
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
sshConn.client.exec(`cat '${escapedPath}'`, (err, stream) => {
|
||||||
@@ -303,14 +316,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const commandTimeout = setTimeout(() => {
|
|
||||||
logger.error(`SSH writeFile command timed out for session: ${sessionId}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
|
||||||
}
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
const trySFTP = () => {
|
const trySFTP = () => {
|
||||||
try {
|
try {
|
||||||
@@ -331,7 +336,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
fileBuffer = Buffer.from(content);
|
fileBuffer = Buffer.from(content);
|
||||||
}
|
}
|
||||||
} catch (bufferErr) {
|
} catch (bufferErr) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Buffer conversion error:', bufferErr);
|
logger.error('Buffer conversion error:', bufferErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: 'Invalid file content format'});
|
return res.status(500).json({error: 'Invalid file content format'});
|
||||||
@@ -354,7 +358,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
writeStream.on('finish', () => {
|
writeStream.on('finish', () => {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.success(`File written successfully via SFTP: ${filePath}`);
|
logger.success(`File written successfully via SFTP: ${filePath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File written successfully', path: filePath});
|
res.json({message: 'File written successfully', path: filePath});
|
||||||
@@ -364,7 +367,6 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
writeStream.on('close', () => {
|
writeStream.on('close', () => {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.success(`File written successfully via SFTP: ${filePath}`);
|
logger.success(`File written successfully via SFTP: ${filePath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File written successfully', path: filePath});
|
res.json({message: 'File written successfully', path: filePath});
|
||||||
@@ -396,7 +398,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
|
|
||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Fallback write command failed:', err);
|
logger.error('Fallback write command failed:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Write failed: ${err.message}`});
|
return res.status(500).json({error: `Write failed: ${err.message}`});
|
||||||
@@ -416,7 +418,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
logger.success(`File written successfully via fallback: ${filePath}`);
|
logger.success(`File written successfully via fallback: ${filePath}`);
|
||||||
@@ -432,7 +434,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Fallback write stream error:', streamErr);
|
logger.error('Fallback write stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Write stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Write stream error: ${streamErr.message}`});
|
||||||
@@ -440,7 +442,7 @@ app.post('/ssh/file_manager/ssh/writeFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (fallbackErr) {
|
} catch (fallbackErr) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Fallback method failed:', fallbackErr);
|
logger.error('Fallback method failed:', fallbackErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`});
|
res.status(500).json({error: `All write methods failed: ${fallbackErr.message}`});
|
||||||
@@ -468,16 +470,11 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
||||||
|
|
||||||
const commandTimeout = setTimeout(() => {
|
|
||||||
logger.error(`SSH uploadFile command timed out for session: ${sessionId}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
|
||||||
}
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
const trySFTP = () => {
|
const trySFTP = () => {
|
||||||
try {
|
try {
|
||||||
@@ -498,7 +495,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
fileBuffer = Buffer.from(content);
|
fileBuffer = Buffer.from(content);
|
||||||
}
|
}
|
||||||
} catch (bufferErr) {
|
} catch (bufferErr) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Buffer conversion error:', bufferErr);
|
logger.error('Buffer conversion error:', bufferErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: 'Invalid file content format'});
|
return res.status(500).json({error: 'Invalid file content format'});
|
||||||
@@ -521,7 +518,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
writeStream.on('finish', () => {
|
writeStream.on('finish', () => {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||||
@@ -531,7 +528,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
writeStream.on('close', () => {
|
writeStream.on('close', () => {
|
||||||
if (hasError || hasFinished) return;
|
if (hasError || hasFinished) return;
|
||||||
hasFinished = true;
|
hasFinished = true;
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
logger.success(`File uploaded successfully via SFTP: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File uploaded successfully', path: fullPath});
|
res.json({message: 'File uploaded successfully', path: fullPath});
|
||||||
@@ -573,7 +570,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
|
|
||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Fallback upload command failed:', err);
|
logger.error('Fallback upload command failed:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Upload failed: ${err.message}`});
|
return res.status(500).json({error: `Upload failed: ${err.message}`});
|
||||||
@@ -593,7 +590,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
logger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
logger.success(`File uploaded successfully via fallback: ${fullPath}`);
|
||||||
@@ -609,7 +606,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Fallback upload stream error:', streamErr);
|
logger.error('Fallback upload stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Upload stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Upload stream error: ${streamErr.message}`});
|
||||||
@@ -631,7 +628,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
|
|
||||||
sshConn.client.exec(writeCommand, (err, stream) => {
|
sshConn.client.exec(writeCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Chunked fallback upload failed:', err);
|
logger.error('Chunked fallback upload failed:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: `Chunked upload failed: ${err.message}`});
|
return res.status(500).json({error: `Chunked upload failed: ${err.message}`});
|
||||||
@@ -651,7 +648,7 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
|
logger.success(`File uploaded successfully via chunked fallback: ${fullPath}`);
|
||||||
@@ -667,7 +664,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Chunked fallback upload stream error:', streamErr);
|
logger.error('Chunked fallback upload stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Chunked upload stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Chunked upload stream error: ${streamErr.message}`});
|
||||||
@@ -676,7 +672,6 @@ app.post('/ssh/file_manager/ssh/uploadFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (fallbackErr) {
|
} catch (fallbackErr) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('Fallback method failed:', fallbackErr);
|
logger.error('Fallback method failed:', fallbackErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `All upload methods failed: ${fallbackErr.message}`});
|
res.status(500).json({error: `All upload methods failed: ${fallbackErr.message}`});
|
||||||
@@ -704,23 +699,14 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
const fullPath = filePath.endsWith('/') ? filePath + fileName : filePath + '/' + fileName;
|
||||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
const commandTimeout = setTimeout(() => {
|
|
||||||
logger.error(`SSH createFile command timed out for session: ${sessionId}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
const createCommand = `touch '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||||
|
|
||||||
sshConn.client.exec(createCommand, (err, stream) => {
|
sshConn.client.exec(createCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH createFile error:', err);
|
logger.error('SSH createFile error:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: err.message});
|
return res.status(500).json({error: err.message});
|
||||||
@@ -739,7 +725,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
|||||||
errorData += chunk.toString();
|
errorData += chunk.toString();
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
if (chunk.toString().includes('Permission denied')) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error(`Permission denied creating file: ${fullPath}`);
|
logger.error(`Permission denied creating file: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -751,8 +736,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'File created successfully', path: fullPath});
|
res.json({message: 'File created successfully', path: fullPath});
|
||||||
@@ -774,7 +757,6 @@ app.post('/ssh/file_manager/ssh/createFile', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH createFile stream error:', streamErr);
|
logger.error('SSH createFile stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||||
@@ -800,23 +782,15 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const fullPath = folderPath.endsWith('/') ? folderPath + folderName : folderPath + '/' + folderName;
|
const fullPath = folderPath.endsWith('/') ? folderPath + folderName : folderPath + '/' + folderName;
|
||||||
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
const escapedPath = fullPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
const commandTimeout = setTimeout(() => {
|
|
||||||
logger.error(`SSH createFolder command timed out for session: ${sessionId}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
const createCommand = `mkdir -p '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||||
|
|
||||||
sshConn.client.exec(createCommand, (err, stream) => {
|
sshConn.client.exec(createCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH createFolder error:', err);
|
logger.error('SSH createFolder error:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: err.message});
|
return res.status(500).json({error: err.message});
|
||||||
@@ -835,7 +809,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
|||||||
errorData += chunk.toString();
|
errorData += chunk.toString();
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
if (chunk.toString().includes('Permission denied')) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error(`Permission denied creating folder: ${fullPath}`);
|
logger.error(`Permission denied creating folder: ${fullPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -847,8 +820,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'Folder created successfully', path: fullPath});
|
res.json({message: 'Folder created successfully', path: fullPath});
|
||||||
@@ -870,7 +841,6 @@ app.post('/ssh/file_manager/ssh/createFolder', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH createFolder stream error:', streamErr);
|
logger.error('SSH createFolder stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||||
@@ -896,24 +866,14 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
const escapedPath = itemPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
const commandTimeout = setTimeout(() => {
|
|
||||||
logger.error(`SSH deleteItem command timed out for session: ${sessionId}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
const deleteCommand = isDirectory
|
const deleteCommand = isDirectory
|
||||||
? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0`
|
? `rm -rf '${escapedPath}' && echo "SUCCESS" && exit 0`
|
||||||
: `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
: `rm -f '${escapedPath}' && echo "SUCCESS" && exit 0`;
|
||||||
|
|
||||||
sshConn.client.exec(deleteCommand, (err, stream) => {
|
sshConn.client.exec(deleteCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH deleteItem error:', err);
|
logger.error('SSH deleteItem error:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: err.message});
|
return res.status(500).json({error: err.message});
|
||||||
@@ -932,7 +892,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
errorData += chunk.toString();
|
errorData += chunk.toString();
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
if (chunk.toString().includes('Permission denied')) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error(`Permission denied deleting: ${itemPath}`);
|
logger.error(`Permission denied deleting: ${itemPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -944,8 +903,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'Item deleted successfully', path: itemPath});
|
res.json({message: 'Item deleted successfully', path: itemPath});
|
||||||
@@ -967,7 +924,6 @@ app.delete('/ssh/file_manager/ssh/deleteItem', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH deleteItem stream error:', streamErr);
|
logger.error('SSH deleteItem stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||||
@@ -993,25 +949,16 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sshConn.lastActive = Date.now();
|
sshConn.lastActive = Date.now();
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
|
|
||||||
const oldDir = oldPath.substring(0, oldPath.lastIndexOf('/') + 1);
|
const oldDir = oldPath.substring(0, oldPath.lastIndexOf('/') + 1);
|
||||||
const newPath = oldDir + newName;
|
const newPath = oldDir + newName;
|
||||||
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
const escapedOldPath = oldPath.replace(/'/g, "'\"'\"'");
|
||||||
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
const escapedNewPath = newPath.replace(/'/g, "'\"'\"'");
|
||||||
|
|
||||||
const commandTimeout = setTimeout(() => {
|
|
||||||
logger.error(`SSH renameItem command timed out for session: ${sessionId}`);
|
|
||||||
if (!res.headersSent) {
|
|
||||||
res.status(500).json({error: 'SSH command timed out'});
|
|
||||||
}
|
|
||||||
}, 15000);
|
|
||||||
|
|
||||||
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
const renameCommand = `mv '${escapedOldPath}' '${escapedNewPath}' && echo "SUCCESS" && exit 0`;
|
||||||
|
|
||||||
sshConn.client.exec(renameCommand, (err, stream) => {
|
sshConn.client.exec(renameCommand, (err, stream) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH renameItem error:', err);
|
logger.error('SSH renameItem error:', err);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(500).json({error: err.message});
|
return res.status(500).json({error: err.message});
|
||||||
@@ -1030,7 +977,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
|||||||
errorData += chunk.toString();
|
errorData += chunk.toString();
|
||||||
|
|
||||||
if (chunk.toString().includes('Permission denied')) {
|
if (chunk.toString().includes('Permission denied')) {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error(`Permission denied renaming: ${oldPath}`);
|
logger.error(`Permission denied renaming: ${oldPath}`);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
@@ -1042,8 +988,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('close', (code) => {
|
stream.on('close', (code) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
|
|
||||||
if (outputData.includes('SUCCESS')) {
|
if (outputData.includes('SUCCESS')) {
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.json({message: 'Item renamed successfully', oldPath, newPath});
|
res.json({message: 'Item renamed successfully', oldPath, newPath});
|
||||||
@@ -1065,7 +1009,6 @@ app.put('/ssh/file_manager/ssh/renameItem', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stream.on('error', (streamErr) => {
|
stream.on('error', (streamErr) => {
|
||||||
clearTimeout(commandTimeout);
|
|
||||||
logger.error('SSH renameItem stream error:', streamErr);
|
logger.error('SSH renameItem stream error:', streamErr);
|
||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
res.status(500).json({error: `Stream error: ${streamErr.message}`});
|
||||||
|
|||||||
@@ -115,10 +115,29 @@ function buildSshConfig(host: HostRecord): ConnectConfig {
|
|||||||
(base as any).password = host.password || '';
|
(base as any).password = host.password || '';
|
||||||
} else if (host.authType === 'key') {
|
} else if (host.authType === 'key') {
|
||||||
if (host.key) {
|
if (host.key) {
|
||||||
(base as any).privateKey = Buffer.from(host.key, 'utf8');
|
try {
|
||||||
}
|
if (!host.key.includes('-----BEGIN') || !host.key.includes('-----END')) {
|
||||||
if (host.keyPassword) {
|
throw new Error('Invalid private key format');
|
||||||
(base as any).passphrase = host.keyPassword;
|
}
|
||||||
|
|
||||||
|
const cleanKey = host.key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
|
(base as any).privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
|
|
||||||
|
if (host.keyPassword) {
|
||||||
|
(base as any).passphrase = host.keyPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`SSH key authentication configured for host ${host.ip}`);
|
||||||
|
} catch (keyError) {
|
||||||
|
logger.error(`SSH key format error for host ${host.ip}: ${keyError.message}`);
|
||||||
|
if (host.password) {
|
||||||
|
(base as any).password = host.password;
|
||||||
|
logger.info(`Falling back to password authentication for host ${host.ip}`);
|
||||||
|
} else {
|
||||||
|
throw new Error(`Invalid SSH key format for host ${host.ip}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return base;
|
return base;
|
||||||
@@ -413,10 +432,4 @@ app.listen(PORT, async () => {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Initial poll failed', err);
|
logger.error('Initial poll failed', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Disable automatic background polling to prevent log flooding
|
|
||||||
// setInterval(() => {
|
|
||||||
// pollStatusesOnce().catch(err => logger.error('Background poll failed', err));
|
|
||||||
// }, 60_000);
|
|
||||||
|
|
||||||
@@ -294,12 +294,28 @@ wss.on('connection', (ws: WebSocket) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (authType === 'key' && key) {
|
if (authType === 'key' && key) {
|
||||||
connectConfig.privateKey = key;
|
try {
|
||||||
if (keyPassword) {
|
if (!key.includes('-----BEGIN') || !key.includes('-----END')) {
|
||||||
connectConfig.passphrase = keyPassword;
|
throw new Error('Invalid private key format');
|
||||||
}
|
}
|
||||||
if (keyType && keyType !== 'auto') {
|
|
||||||
connectConfig.privateKeyType = keyType;
|
const cleanKey = key.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
|
||||||
|
connectConfig.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
|
|
||||||
|
if (keyPassword) {
|
||||||
|
connectConfig.passphrase = keyPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyType && keyType !== 'auto') {
|
||||||
|
connectConfig.privateKeyType = keyType;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info('SSH key authentication configured successfully');
|
||||||
|
} catch (keyError) {
|
||||||
|
logger.error('SSH key format error: ' + keyError.message);
|
||||||
|
ws.send(JSON.stringify({type: 'error', message: 'SSH key format error: Invalid private key format'}));
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} else if (authType === 'key') {
|
} else if (authType === 'key') {
|
||||||
logger.error('SSH key authentication requested but no key provided');
|
logger.error('SSH key authentication requested but no key provided');
|
||||||
|
|||||||
@@ -438,264 +438,13 @@ function handleDisconnect(tunnelName: string, tunnelConfig: TunnelConfig | null,
|
|||||||
}
|
}
|
||||||
|
|
||||||
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
|
function verifyTunnelConnection(tunnelName: string, tunnelConfig: TunnelConfig, isPeriodic = false): void {
|
||||||
if (manualDisconnects.has(tunnelName) || !activeTunnels.has(tunnelName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tunnelVerifications.has(tunnelName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn = activeTunnels.get(tunnelName);
|
|
||||||
if (!conn) return;
|
|
||||||
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: true,
|
||||||
status: CONNECTION_STATES.VERIFYING
|
status: CONNECTION_STATES.CONNECTED
|
||||||
});
|
});
|
||||||
|
|
||||||
const verificationConn = new Client();
|
|
||||||
tunnelVerifications.set(tunnelName, {
|
|
||||||
conn: verificationConn,
|
|
||||||
timeout: setTimeout(() => {
|
|
||||||
logger.error(`Verification timeout for '${tunnelName}'`);
|
|
||||||
cleanupVerification(false, "Verification timeout");
|
|
||||||
}, 10000)
|
|
||||||
});
|
|
||||||
|
|
||||||
function cleanupVerification(isSuccessful: boolean, failureReason = "Unknown verification failure") {
|
|
||||||
const verification = tunnelVerifications.get(tunnelName);
|
|
||||||
if (verification) {
|
|
||||||
clearTimeout(verification.timeout);
|
|
||||||
try {
|
|
||||||
verification.conn.end();
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
tunnelVerifications.delete(tunnelName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSuccessful) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: true,
|
|
||||||
status: CONNECTION_STATES.CONNECTED
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!isPeriodic) {
|
|
||||||
setupPingInterval(tunnelName, tunnelConfig);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
logger.warn(`Verification failed for '${tunnelName}': ${failureReason}`);
|
|
||||||
|
|
||||||
if (failureReason.includes('command failed') || failureReason.includes('connection error') || failureReason.includes('timeout')) {
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.FAILED,
|
|
||||||
reason: failureReason
|
|
||||||
});
|
|
||||||
}
|
|
||||||
activeTunnels.delete(tunnelName);
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
|
||||||
} else {
|
|
||||||
logger.info(`Assuming tunnel '${tunnelName}' is working despite verification warning: ${failureReason}`);
|
|
||||||
cleanupVerification(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function attemptVerification() {
|
|
||||||
const testCmd = `timeout 3 bash -c 'nc -z ${tunnelConfig.endpointIP} ${tunnelConfig.endpointPort}'`;
|
|
||||||
|
|
||||||
verificationConn.exec(testCmd, (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
logger.error(`Verification command failed for '${tunnelName}': ${err.message}`);
|
|
||||||
cleanupVerification(false, `Verification command failed: ${err.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = '';
|
|
||||||
let errorOutput = '';
|
|
||||||
|
|
||||||
stream.on('data', (data: Buffer) => {
|
|
||||||
output += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.stderr?.on('data', (data: Buffer) => {
|
|
||||||
errorOutput += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('close', (code: number) => {
|
|
||||||
if (code === 0) {
|
|
||||||
cleanupVerification(true);
|
|
||||||
} else {
|
|
||||||
const isTimeout = errorOutput.includes('timeout') || errorOutput.includes('Connection timed out');
|
|
||||||
const isConnectionRefused = errorOutput.includes('Connection refused') || errorOutput.includes('No route to host');
|
|
||||||
|
|
||||||
let failureReason = `Cannot connect to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
|
||||||
if (isTimeout) {
|
|
||||||
failureReason = `Tunnel verification timeout - cannot reach ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort}`;
|
|
||||||
} else if (isConnectionRefused) {
|
|
||||||
failureReason = `Connection refused to ${tunnelConfig.endpointIP}:${tunnelConfig.endpointPort} - tunnel may not be established`;
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupVerification(false, failureReason);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', (err: Error) => {
|
|
||||||
logger.error(`Verification stream error for '${tunnelName}': ${err.message}`);
|
|
||||||
cleanupVerification(false, `Verification stream error: ${err.message}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
verificationConn.on('ready', () => {
|
|
||||||
setTimeout(() => {
|
|
||||||
attemptVerification();
|
|
||||||
}, 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
verificationConn.on('error', (err: Error) => {
|
|
||||||
cleanupVerification(false, `Verification connection error: ${err.message}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
verificationConn.on('close', () => {
|
|
||||||
if (tunnelVerifications.has(tunnelName)) {
|
|
||||||
cleanupVerification(false, "Verification connection closed");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const connOptions: any = {
|
|
||||||
host: tunnelConfig.sourceIP,
|
|
||||||
port: tunnelConfig.sourceSSHPort,
|
|
||||||
username: tunnelConfig.sourceUsername,
|
|
||||||
readyTimeout: 10000,
|
|
||||||
keepaliveInterval: 30000,
|
|
||||||
keepaliveCountMax: 3,
|
|
||||||
tcpKeepAlive: true,
|
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
|
||||||
algorithms: {
|
|
||||||
kex: [
|
|
||||||
'diffie-hellman-group14-sha256',
|
|
||||||
'diffie-hellman-group14-sha1',
|
|
||||||
'diffie-hellman-group1-sha1',
|
|
||||||
'diffie-hellman-group-exchange-sha256',
|
|
||||||
'diffie-hellman-group-exchange-sha1',
|
|
||||||
'ecdh-sha2-nistp256',
|
|
||||||
'ecdh-sha2-nistp384',
|
|
||||||
'ecdh-sha2-nistp521'
|
|
||||||
],
|
|
||||||
cipher: [
|
|
||||||
'aes128-ctr',
|
|
||||||
'aes192-ctr',
|
|
||||||
'aes256-ctr',
|
|
||||||
'aes128-gcm@openssh.com',
|
|
||||||
'aes256-gcm@openssh.com',
|
|
||||||
'aes128-cbc',
|
|
||||||
'aes192-cbc',
|
|
||||||
'aes256-cbc',
|
|
||||||
'3des-cbc'
|
|
||||||
],
|
|
||||||
hmac: [
|
|
||||||
'hmac-sha2-256',
|
|
||||||
'hmac-sha2-512',
|
|
||||||
'hmac-sha1',
|
|
||||||
'hmac-md5'
|
|
||||||
],
|
|
||||||
compress: [
|
|
||||||
'none',
|
|
||||||
'zlib@openssh.com',
|
|
||||||
'zlib'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
|
||||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
|
||||||
if (tunnelConfig.sourceKeyPassword) {
|
|
||||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
|
||||||
}
|
|
||||||
if (tunnelConfig.sourceKeyType && tunnelConfig.sourceKeyType !== 'auto') {
|
|
||||||
connOptions.privateKeyType = tunnelConfig.sourceKeyType;
|
|
||||||
}
|
|
||||||
} else if (tunnelConfig.sourceAuthMethod === "key") {
|
|
||||||
logger.error(`SSH key authentication requested but no key provided for tunnel '${tunnelName}'`);
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.FAILED,
|
|
||||||
reason: "SSH key authentication requested but no key provided"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
connOptions.password = tunnelConfig.sourcePassword;
|
|
||||||
}
|
|
||||||
|
|
||||||
verificationConn.connect(connOptions);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
|
function setupPingInterval(tunnelName: string, tunnelConfig: TunnelConfig): void {
|
||||||
const pingInterval = setInterval(() => {
|
|
||||||
if (!activeTunnels.has(tunnelName) || manualDisconnects.has(tunnelName)) {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conn = activeTunnels.get(tunnelName);
|
|
||||||
if (!conn) {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.exec('echo "ping"', (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.UNSTABLE,
|
|
||||||
reason: "Ping failed"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
stream.on('close', (code: number) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.UNSTABLE,
|
|
||||||
reason: "Ping command failed"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on('error', (err: Error) => {
|
|
||||||
clearInterval(pingInterval);
|
|
||||||
|
|
||||||
if (!manualDisconnects.has(tunnelName)) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.UNSTABLE,
|
|
||||||
reason: "Ping stream error"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
activeTunnels.delete(tunnelName);
|
|
||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, 60000);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
||||||
@@ -751,7 +500,7 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
handleDisconnect(tunnelName, tunnelConfig, !manualDisconnects.has(tunnelName));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 15000);
|
}, 60000);
|
||||||
|
|
||||||
conn.on("error", (err) => {
|
conn.on("error", (err) => {
|
||||||
clearTimeout(connectionTimeout);
|
clearTimeout(connectionTimeout);
|
||||||
@@ -910,9 +659,9 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
host: tunnelConfig.sourceIP,
|
host: tunnelConfig.sourceIP,
|
||||||
port: tunnelConfig.sourceSSHPort,
|
port: tunnelConfig.sourceSSHPort,
|
||||||
username: tunnelConfig.sourceUsername,
|
username: tunnelConfig.sourceUsername,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 60000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 0,
|
||||||
readyTimeout: 10000,
|
readyTimeout: 60000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 30000,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
@@ -952,8 +701,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||||
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN')) {
|
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
|
||||||
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should start with '-----BEGIN'`);
|
logger.error(`Invalid SSH key format for tunnel '${tunnelName}'. Key should contain both BEGIN and END markers`);
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.FAILED,
|
status: CONNECTION_STATES.FAILED,
|
||||||
@@ -962,7 +711,8 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
if (tunnelConfig.sourceKeyPassword) {
|
if (tunnelConfig.sourceKeyPassword) {
|
||||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||||
}
|
}
|
||||||
@@ -981,43 +731,16 @@ function connectSSHTunnel(tunnelConfig: TunnelConfig, retryAttempt = 0): void {
|
|||||||
connOptions.password = tunnelConfig.sourcePassword;
|
connOptions.password = tunnelConfig.sourcePassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
const testSocket = new net.Socket();
|
const currentStatus = connectionStatus.get(tunnelName);
|
||||||
testSocket.setTimeout(5000);
|
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
||||||
|
|
||||||
testSocket.on('connect', () => {
|
|
||||||
testSocket.destroy();
|
|
||||||
|
|
||||||
const currentStatus = connectionStatus.get(tunnelName);
|
|
||||||
if (!currentStatus || currentStatus.status !== CONNECTION_STATES.WAITING) {
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.CONNECTING,
|
|
||||||
retryCount: retryAttempt > 0 ? retryAttempt : undefined
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
conn.connect(connOptions);
|
|
||||||
});
|
|
||||||
|
|
||||||
testSocket.on('timeout', () => {
|
|
||||||
testSocket.destroy();
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
broadcastTunnelStatus(tunnelName, {
|
||||||
connected: false,
|
connected: false,
|
||||||
status: CONNECTION_STATES.FAILED,
|
status: CONNECTION_STATES.CONNECTING,
|
||||||
reason: "Network connectivity test failed - server not reachable"
|
retryCount: retryAttempt > 0 ? retryAttempt : undefined
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
testSocket.on('error', (err: any) => {
|
conn.connect(connOptions);
|
||||||
testSocket.destroy();
|
|
||||||
broadcastTunnelStatus(tunnelName, {
|
|
||||||
connected: false,
|
|
||||||
status: CONNECTION_STATES.FAILED,
|
|
||||||
reason: `Network connectivity test failed - ${err.message}`
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
testSocket.connect(tunnelConfig.sourceSSHPort, tunnelConfig.sourceIP);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
|
function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string, callback: (err?: Error) => void) {
|
||||||
@@ -1027,9 +750,9 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
|||||||
host: tunnelConfig.sourceIP,
|
host: tunnelConfig.sourceIP,
|
||||||
port: tunnelConfig.sourceSSHPort,
|
port: tunnelConfig.sourceSSHPort,
|
||||||
username: tunnelConfig.sourceUsername,
|
username: tunnelConfig.sourceUsername,
|
||||||
keepaliveInterval: 30000,
|
keepaliveInterval: 60000,
|
||||||
keepaliveCountMax: 3,
|
keepaliveCountMax: 0,
|
||||||
readyTimeout: 10000,
|
readyTimeout: 60000,
|
||||||
tcpKeepAlive: true,
|
tcpKeepAlive: true,
|
||||||
tcpKeepAliveInitialDelay: 30000,
|
tcpKeepAliveInitialDelay: 30000,
|
||||||
algorithms: {
|
algorithms: {
|
||||||
@@ -1068,7 +791,13 @@ function killRemoteTunnelByMarker(tunnelConfig: TunnelConfig, tunnelName: string
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
if (tunnelConfig.sourceAuthMethod === "key" && tunnelConfig.sourceSSHKey) {
|
||||||
connOptions.privateKey = tunnelConfig.sourceSSHKey;
|
if (!tunnelConfig.sourceSSHKey.includes('-----BEGIN') || !tunnelConfig.sourceSSHKey.includes('-----END')) {
|
||||||
|
callback(new Error('Invalid SSH key format'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanKey = tunnelConfig.sourceSSHKey.trim().replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||||
|
connOptions.privateKey = Buffer.from(cleanKey, 'utf8');
|
||||||
if (tunnelConfig.sourceKeyPassword) {
|
if (tunnelConfig.sourceKeyPassword) {
|
||||||
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
connOptions.passphrase = tunnelConfig.sourceKeyPassword;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, {useEffect, useRef, useState} from "react";
|
import React, {useEffect, useRef, useState} from "react";
|
||||||
import {TerminalComponent} from "@/ui/apps/Terminal/TerminalComponent.tsx";
|
import {Terminal} from "@/ui/apps/Terminal/Terminal.tsx";
|
||||||
import {Server as ServerView} from "@/ui/apps/Server/Server.tsx";
|
import {Server as ServerView} from "@/ui/apps/Server/Server.tsx";
|
||||||
import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx";
|
import {FileManager} from "@/ui/apps/File Manager/FileManager.tsx";
|
||||||
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
import {useTabs} from "@/ui/Navigation/Tabs/TabContext.tsx";
|
||||||
@@ -108,12 +108,13 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
|||||||
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
const layoutTabs = [mainTab, ...splitTabs.filter((t: any) => t && t.id !== (mainTab && (mainTab as any).id))].filter(Boolean) as any[];
|
||||||
|
|
||||||
if (allSplitScreenTab.length === 0 && mainTab) {
|
if (allSplitScreenTab.length === 0 && mainTab) {
|
||||||
|
const isFileManagerTab = mainTab.type === 'file_manager';
|
||||||
styles[mainTab.id] = {
|
styles[mainTab.id] = {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 2,
|
top: isFileManagerTab ? 0 : 2,
|
||||||
left: 2,
|
left: isFileManagerTab ? 0 : 2,
|
||||||
right: 2,
|
right: isFileManagerTab ? 0 : 2,
|
||||||
bottom: 2,
|
bottom: isFileManagerTab ? 0 : 2,
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
display: 'block',
|
display: 'block',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
@@ -154,9 +155,9 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
|||||||
const effectiveVisible = isVisible && ready;
|
const effectiveVisible = isVisible && ready;
|
||||||
return (
|
return (
|
||||||
<div key={t.id} style={finalStyle}>
|
<div key={t.id} style={finalStyle}>
|
||||||
<div className="absolute inset-0 rounded-md" style={{background: '#18181b'}}>
|
<div className="absolute inset-0 rounded-md bg-[#18181b]">
|
||||||
{t.type === 'terminal' ? (
|
{t.type === 'terminal' ? (
|
||||||
<TerminalComponent
|
<Terminal
|
||||||
ref={t.terminalRef}
|
ref={t.terminalRef}
|
||||||
hostConfig={t.hostConfig}
|
hostConfig={t.hostConfig}
|
||||||
isVisible={effectiveVisible}
|
isVisible={effectiveVisible}
|
||||||
@@ -523,6 +524,9 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const currentTabData = tabs.find((tab: any) => tab.id === currentTab);
|
||||||
|
const isFileManager = currentTabData?.type === 'file_manager';
|
||||||
|
|
||||||
const topMarginPx = isTopbarOpen ? 74 : 26;
|
const topMarginPx = isTopbarOpen ? 74 : 26;
|
||||||
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
const leftMarginPx = sidebarState === 'collapsed' ? 26 : 8;
|
||||||
const bottomMarginPx = 8;
|
const bottomMarginPx = 8;
|
||||||
@@ -533,7 +537,7 @@ export function AppView({isTopbarOpen = true}: TerminalViewProps): React.ReactEl
|
|||||||
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
|
className="border-2 border-[#303032] rounded-lg overflow-hidden overflow-x-hidden"
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
background: '#18181b',
|
background: isFileManager ? '#09090b' : '#18181b',
|
||||||
marginLeft: leftMarginPx,
|
marginLeft: leftMarginPx,
|
||||||
marginRight: 17,
|
marginRight: 17,
|
||||||
marginTop: topMarginPx,
|
marginTop: topMarginPx,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function TabProvider({children}: TabProviderProps) {
|
|||||||
const usedNumbers = new Set<number>();
|
const usedNumbers = new Set<number>();
|
||||||
let rootUsed = false;
|
let rootUsed = false;
|
||||||
tabs.forEach(t => {
|
tabs.forEach(t => {
|
||||||
if (t.type !== tabType || !t.title) return;
|
if (!t.title) return;
|
||||||
if (t.title === root) {
|
if (t.title === root) {
|
||||||
rootUsed = true;
|
rootUsed = true;
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {Button} from '@/components/ui/button.tsx';
|
|||||||
import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx";
|
import {FIleManagerTopNavbar} from "@/ui/apps/File Manager/FIleManagerTopNavbar.tsx";
|
||||||
import {cn} from '@/lib/utils.ts';
|
import {cn} from '@/lib/utils.ts';
|
||||||
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
import {Save, RefreshCw, Settings, Trash2} from 'lucide-react';
|
||||||
|
import {Separator} from '@/components/ui/separator.tsx';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
import {
|
import {
|
||||||
getFileManagerRecent,
|
getFileManagerRecent,
|
||||||
@@ -489,7 +490,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
|
|
||||||
if (!currentHost) {
|
if (!currentHost) {
|
||||||
return (
|
return (
|
||||||
<div style={{position: 'relative', width: '100%', height: '100%', overflow: 'hidden'}}>
|
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
|
||||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
||||||
<FileManagerLeftSidebar
|
<FileManagerLeftSidebar
|
||||||
onSelectView={onSelectView || (() => {
|
onSelectView={onSelectView || (() => {
|
||||||
@@ -525,7 +526,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{position: 'relative', width: '100%', height: '100%', overflow: 'hidden'}}>
|
<div style={{position: 'absolute', inset: 0, overflow: 'hidden'}} className="rounded-md">
|
||||||
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
<div style={{position: 'absolute', top: 0, left: 0, width: 256, height: '100%', zIndex: 20}}>
|
||||||
<FileManagerLeftSidebar
|
<FileManagerLeftSidebar
|
||||||
onSelectView={onSelectView || (() => {
|
onSelectView={onSelectView || (() => {
|
||||||
@@ -570,6 +571,7 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
>
|
>
|
||||||
<Settings className="h-4 w-4"/>
|
<Settings className="h-4 w-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
|
<div className="p-0.25 w-px h-[30px] bg-[#303032]"></div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -599,9 +601,9 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column'
|
flexDirection: 'column'
|
||||||
}}>
|
}}>
|
||||||
{activeTab === 'home' ? (
|
<div className="flex h-full">
|
||||||
<div className="flex h-full">
|
<div className="flex-1">
|
||||||
<div className="flex-1">
|
{activeTab === 'home' ? (
|
||||||
<FileManagerHomeView
|
<FileManagerHomeView
|
||||||
recent={recent}
|
recent={recent}
|
||||||
pinned={pinned}
|
pinned={pinned}
|
||||||
@@ -614,36 +616,36 @@ export function FileManager({onSelectView, embedded = false, initialHost = null}
|
|||||||
onRemoveShortcut={handleRemoveShortcut}
|
onRemoveShortcut={handleRemoveShortcut}
|
||||||
onAddShortcut={handleAddShortcut}
|
onAddShortcut={handleAddShortcut}
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
{showOperations && (
|
(() => {
|
||||||
<div className="w-80 border-l-2 border-[#303032] bg-[#09090b] overflow-y-auto">
|
const tab = tabs.find(t => t.id === activeTab);
|
||||||
<FileManagerOperations
|
if (!tab) return null;
|
||||||
currentPath={currentPath}
|
return (
|
||||||
sshSessionId={currentHost?.id.toString() || null}
|
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
||||||
onOperationComplete={handleOperationComplete}
|
<div className="flex-1 min-h-0">
|
||||||
onError={handleError}
|
<FileManagerFileEditor
|
||||||
onSuccess={handleSuccess}
|
content={tab.content}
|
||||||
/>
|
fileName={tab.fileName}
|
||||||
</div>
|
onContentChange={content => setTabContent(tab.id, content)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{showOperations && (
|
||||||
(() => {
|
<div className="w-80 border-l-2 border-[#303032] bg-[#09090b] overflow-y-auto">
|
||||||
const tab = tabs.find(t => t.id === activeTab);
|
<FileManagerOperations
|
||||||
if (!tab) return null;
|
currentPath={currentPath}
|
||||||
return (
|
sshSessionId={currentHost?.id.toString() || null}
|
||||||
<div className="flex flex-col h-full" style={{flex: 1, minHeight: 0}}>
|
onOperationComplete={handleOperationComplete}
|
||||||
<div className="flex-1 min-h-0">
|
onError={handleError}
|
||||||
<FileManagerFileEditor
|
onSuccess={handleSuccess}
|
||||||
content={tab.content}
|
/>
|
||||||
fileName={tab.fileName}
|
</div>
|
||||||
onContentChange={content => setTabContent(tab.id, content)}
|
)}
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{deletingItem && (
|
{deletingItem && (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, {useState, useRef} from 'react';
|
import React, {useState, useRef, useEffect} from 'react';
|
||||||
import {Button} from '@/components/ui/button.tsx';
|
import {Button} from '@/components/ui/button.tsx';
|
||||||
import {Input} from '@/components/ui/input.tsx';
|
import {Input} from '@/components/ui/input.tsx';
|
||||||
import {Card} from '@/components/ui/card.tsx';
|
import {Card} from '@/components/ui/card.tsx';
|
||||||
@@ -48,7 +48,29 @@ export function FileManagerOperations({
|
|||||||
const [newName, setNewName] = useState('');
|
const [newName, setNewName] = useState('');
|
||||||
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [showTextLabels, setShowTextLabels] = useState(true);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkContainerWidth = () => {
|
||||||
|
if (containerRef.current) {
|
||||||
|
const width = containerRef.current.offsetWidth;
|
||||||
|
setShowTextLabels(width > 240);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkContainerWidth();
|
||||||
|
|
||||||
|
const resizeObserver = new ResizeObserver(checkContainerWidth);
|
||||||
|
if (containerRef.current) {
|
||||||
|
resizeObserver.observe(containerRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFileUpload = async () => {
|
const handleFileUpload = async () => {
|
||||||
if (!uploadFile || !sshSessionId) return;
|
if (!uploadFile || !sshSessionId) return;
|
||||||
@@ -186,113 +208,121 @@ export function FileManagerOperations({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-4">
|
<div ref={containerRef} className="p-4 space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowUpload(true)}
|
onClick={() => setShowUpload(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
|
title="Upload File"
|
||||||
>
|
>
|
||||||
<Upload className="w-4 h-4 mr-2"/>
|
<Upload className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
Upload File
|
{showTextLabels && <span className="truncate">Upload File</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowCreateFile(true)}
|
onClick={() => setShowCreateFile(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
|
title="New File"
|
||||||
>
|
>
|
||||||
<FilePlus className="w-4 h-4 mr-2"/>
|
<FilePlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
New File
|
{showTextLabels && <span className="truncate">New File</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowCreateFolder(true)}
|
onClick={() => setShowCreateFolder(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
|
title="New Folder"
|
||||||
>
|
>
|
||||||
<FolderPlus className="w-4 h-4 mr-2"/>
|
<FolderPlus className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
New Folder
|
{showTextLabels && <span className="truncate">New Folder</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowRename(true)}
|
onClick={() => setShowRename(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30]"
|
||||||
|
title="Rename"
|
||||||
>
|
>
|
||||||
<Edit3 className="w-4 h-4 mr-2"/>
|
<Edit3 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
Rename
|
{showTextLabels && <span className="truncate">Rename</span>}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowDelete(true)}
|
onClick={() => setShowDelete(true)}
|
||||||
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
|
className="h-10 bg-[#18181b] border-2 border-[#303032] hover:border-[#434345] hover:bg-[#2d2d30] col-span-2"
|
||||||
|
title="Delete Item"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4 mr-2"/>
|
<Trash2 className={cn("w-4 h-4", showTextLabels ? "mr-2" : "")}/>
|
||||||
Delete Item
|
{showTextLabels && <span className="truncate">Delete Item</span>}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-[#18181b] border-2 border-[#303032] rounded-lg p-3">
|
<div className="bg-[#141416] border-2 border-[#373739] rounded-md p-3">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-start gap-2 text-sm">
|
||||||
<Folder className="w-4 h-4 text-blue-400"/>
|
<Folder className="w-4 h-4 text-blue-400 flex-shrink-0 mt-0.5"/>
|
||||||
<span className="text-muted-foreground">Current Path:</span>
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-white font-mono truncate">{currentPath}</span>
|
<span className="text-muted-foreground block mb-1">Current Path:</span>
|
||||||
|
<span className="text-white font-mono text-xs break-all leading-relaxed">{currentPath}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator className="p-0.25 bg-[#303032]"/>
|
<Separator className="p-0.25 bg-[#303032]"/>
|
||||||
|
|
||||||
{showUpload && (
|
{showUpload && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<div>
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2 mb-1">
|
||||||
<Upload className="w-5 h-5"/>
|
<Upload className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
|
||||||
Upload File
|
<span className="break-words">Upload File</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground break-words">
|
||||||
Maximum file size: 100MB (JSON) / 200MB (Binary)
|
Max: 100MB (JSON) / 200MB (Binary)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowUpload(false)}
|
onClick={() => setShowUpload(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4"/>
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="border-2 border-dashed border-[#434345] rounded-lg p-6 text-center">
|
<div className="border-2 border-dashed border-[#434345] rounded-lg p-4 text-center">
|
||||||
{uploadFile ? (
|
{uploadFile ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<FileText className="w-8 h-8 text-blue-400 mx-auto"/>
|
<FileText className="w-12 h-12 text-blue-400 mx-auto"/>
|
||||||
<p className="text-white font-medium">{uploadFile.name}</p>
|
<p className="text-white font-medium text-sm break-words px-2">{uploadFile.name}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{(uploadFile.size / 1024).toFixed(2)} KB
|
{(uploadFile.size / 1024).toFixed(2)} KB
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setUploadFile(null)}
|
onClick={() => setUploadFile(null)}
|
||||||
className="mt-2"
|
className="w-full text-sm h-8"
|
||||||
>
|
>
|
||||||
Remove File
|
Remove File
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-3">
|
||||||
<Upload className="w-8 h-8 text-muted-foreground mx-auto"/>
|
<Upload className="w-12 h-12 text-muted-foreground mx-auto"/>
|
||||||
<p className="text-white">Click to select a file</p>
|
<p className="text-white text-sm break-words px-2">Click to select a file</p>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={openFileDialog}
|
onClick={openFileDialog}
|
||||||
|
className="w-full text-sm h-8"
|
||||||
>
|
>
|
||||||
Choose File
|
Choose File
|
||||||
</Button>
|
</Button>
|
||||||
@@ -308,11 +338,11 @@ export function FileManagerOperations({
|
|||||||
accept="*/*"
|
accept="*/*"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleFileUpload}
|
onClick={handleFileUpload}
|
||||||
disabled={!uploadFile || isLoading}
|
disabled={!uploadFile || isLoading}
|
||||||
className="flex-1"
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Uploading...' : 'Upload File'}
|
{isLoading ? 'Uploading...' : 'Upload File'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -320,6 +350,7 @@ export function FileManagerOperations({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowUpload(false)}
|
onClick={() => setShowUpload(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -329,23 +360,25 @@ export function FileManagerOperations({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showCreateFile && (
|
{showCreateFile && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3 sm:p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
<FilePlus className="w-5 h-5"/>
|
<h3 className="text-base sm:text-lg font-semibold text-white flex items-center gap-2">
|
||||||
Create New File
|
<FilePlus className="w-5 h-5 sm:w-6 sm:h-6 flex-shrink-0"/>
|
||||||
</h3>
|
<span className="break-words">Create New File</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowCreateFile(false)}
|
onClick={() => setShowCreateFile(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4"/>
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
File Name
|
File Name
|
||||||
@@ -354,16 +387,16 @@ export function FileManagerOperations({
|
|||||||
value={newFileName}
|
value={newFileName}
|
||||||
onChange={(e) => setNewFileName(e.target.value)}
|
onChange={(e) => setNewFileName(e.target.value)}
|
||||||
placeholder="Enter file name (e.g., example.txt)"
|
placeholder="Enter file name (e.g., example.txt)"
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateFile()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateFile}
|
onClick={handleCreateFile}
|
||||||
disabled={!newFileName.trim() || isLoading}
|
disabled={!newFileName.trim() || isLoading}
|
||||||
className="flex-1"
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Creating...' : 'Create File'}
|
{isLoading ? 'Creating...' : 'Create File'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -371,6 +404,7 @@ export function FileManagerOperations({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowCreateFile(false)}
|
onClick={() => setShowCreateFile(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -380,23 +414,25 @@ export function FileManagerOperations({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showCreateFolder && (
|
{showCreateFolder && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
<FolderPlus className="w-5 h-5"/>
|
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
Create New Folder
|
<FolderPlus className="w-6 h-6 flex-shrink-0"/>
|
||||||
</h3>
|
<span className="break-words">Create New Folder</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowCreateFolder(false)}
|
onClick={() => setShowCreateFolder(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4"/>
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
Folder Name
|
Folder Name
|
||||||
@@ -405,16 +441,16 @@ export function FileManagerOperations({
|
|||||||
value={newFolderName}
|
value={newFolderName}
|
||||||
onChange={(e) => setNewFolderName(e.target.value)}
|
onChange={(e) => setNewFolderName(e.target.value)}
|
||||||
placeholder="Enter folder name"
|
placeholder="Enter folder name"
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
|
onKeyDown={(e) => e.key === 'Enter' && handleCreateFolder()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleCreateFolder}
|
onClick={handleCreateFolder}
|
||||||
disabled={!newFolderName.trim() || isLoading}
|
disabled={!newFolderName.trim() || isLoading}
|
||||||
className="flex-1"
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Creating...' : 'Create Folder'}
|
{isLoading ? 'Creating...' : 'Create Folder'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -422,6 +458,7 @@ export function FileManagerOperations({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowCreateFolder(false)}
|
onClick={() => setShowCreateFolder(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -431,27 +468,29 @@ export function FileManagerOperations({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showDelete && (
|
{showDelete && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
<Trash2 className="w-5 h-5 text-red-400"/>
|
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
Delete Item
|
<Trash2 className="w-6 h-6 text-red-400 flex-shrink-0"/>
|
||||||
</h3>
|
<span className="break-words">Delete Item</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowDelete(false)}
|
onClick={() => setShowDelete(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4"/>
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
<div className="bg-red-900/20 border border-red-500/30 rounded-lg p-3">
|
||||||
<div className="flex items-center gap-2 text-red-300">
|
<div className="flex items-start gap-2 text-red-300">
|
||||||
<AlertCircle className="w-4 h-4"/>
|
<AlertCircle className="w-5 h-5 flex-shrink-0"/>
|
||||||
<span className="text-sm font-medium">Warning: This action cannot be undone</span>
|
<span className="text-sm font-medium break-words">Warning: This action cannot be undone</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -462,30 +501,30 @@ export function FileManagerOperations({
|
|||||||
<Input
|
<Input
|
||||||
value={deletePath}
|
value={deletePath}
|
||||||
onChange={(e) => setDeletePath(e.target.value)}
|
onChange={(e) => setDeletePath(e.target.value)}
|
||||||
placeholder="Enter full path to item (e.g., /path/to/file.txt)"
|
placeholder="Enter full path to item"
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="deleteIsDirectory"
|
id="deleteIsDirectory"
|
||||||
checked={deleteIsDirectory}
|
checked={deleteIsDirectory}
|
||||||
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
|
onChange={(e) => setDeleteIsDirectory(e.target.checked)}
|
||||||
className="rounded border-[#434345] bg-[#23232a]"
|
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="deleteIsDirectory" className="text-sm text-white">
|
<label htmlFor="deleteIsDirectory" className="text-sm text-white break-words">
|
||||||
This is a directory (will delete recursively)
|
This is a directory (will delete recursively)
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={!deletePath || isLoading}
|
disabled={!deletePath || isLoading}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="flex-1"
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Deleting...' : 'Delete Item'}
|
{isLoading ? 'Deleting...' : 'Delete Item'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -493,6 +532,7 @@ export function FileManagerOperations({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowDelete(false)}
|
onClick={() => setShowDelete(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
@@ -502,23 +542,25 @@ export function FileManagerOperations({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showRename && (
|
{showRename && (
|
||||||
<Card className="bg-[#18181b] border-2 border-[#303032] p-4">
|
<Card className="bg-[#18181b] border-2 border-[#303032] p-3">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-start justify-between mb-3">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
<Edit3 className="w-5 h-5"/>
|
<h3 className="text-base font-semibold text-white flex items-center gap-2">
|
||||||
Rename Item
|
<Edit3 className="w-6 h-6 flex-shrink-0"/>
|
||||||
</h3>
|
<span className="break-words">Rename Item</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowRename(false)}
|
onClick={() => setShowRename(false)}
|
||||||
className="h-8 w-8 p-0"
|
className="h-8 w-8 p-0 flex-shrink-0 ml-2"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4"/>
|
<X className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-sm font-medium text-white mb-2 block">
|
<label className="text-sm font-medium text-white mb-2 block">
|
||||||
Current Path
|
Current Path
|
||||||
@@ -527,7 +569,7 @@ export function FileManagerOperations({
|
|||||||
value={renamePath}
|
value={renamePath}
|
||||||
onChange={(e) => setRenamePath(e.target.value)}
|
onChange={(e) => setRenamePath(e.target.value)}
|
||||||
placeholder="Enter current path to item"
|
placeholder="Enter current path to item"
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -539,29 +581,29 @@ export function FileManagerOperations({
|
|||||||
value={newName}
|
value={newName}
|
||||||
onChange={(e) => setNewName(e.target.value)}
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
placeholder="Enter new name"
|
placeholder="Enter new name"
|
||||||
className="bg-[#23232a] border-2 border-[#434345] text-white"
|
className="bg-[#23232a] border-2 border-[#434345] text-white text-sm"
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
onKeyDown={(e) => e.key === 'Enter' && handleRename()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
id="renameIsDirectory"
|
id="renameIsDirectory"
|
||||||
checked={renameIsDirectory}
|
checked={renameIsDirectory}
|
||||||
onChange={(e) => setRenameIsDirectory(e.target.checked)}
|
onChange={(e) => setRenameIsDirectory(e.target.checked)}
|
||||||
className="rounded border-[#434345] bg-[#23232a]"
|
className="rounded border-[#434345] bg-[#23232a] mt-0.5 flex-shrink-0"
|
||||||
/>
|
/>
|
||||||
<label htmlFor="renameIsDirectory" className="text-sm text-white">
|
<label htmlFor="renameIsDirectory" className="text-sm text-white break-words">
|
||||||
This is a directory
|
This is a directory
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleRename}
|
onClick={handleRename}
|
||||||
disabled={!renamePath || !newName.trim() || isLoading}
|
disabled={!renamePath || !newName.trim() || isLoading}
|
||||||
className="flex-1"
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
{isLoading ? 'Renaming...' : 'Rename Item'}
|
{isLoading ? 'Renaming...' : 'Rename Item'}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -569,6 +611,7 @@ export function FileManagerOperations({
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setShowRename(false)}
|
onClick={() => setShowRename(false)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
|
className="w-full text-sm h-9"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function FileManagerTabList({tabs, activeTab, setActiveTab, closeTab, onH
|
|||||||
<Button
|
<Button
|
||||||
onClick={onHomeClick}
|
onClick={onHomeClick}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`h-8 rounded-md flex items-center !px-2 border-1 border-[#303032] ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
className={`ml-1 h-8 rounded-md flex items-center !px-2 border-1 border-[#303032] ${activeTab === 'home' ? '!bg-[#1d1d1f] !text-white !border-[#2d2d30] !hover:bg-[#1d1d1f] !active:bg-[#1d1d1f] !focus:bg-[#1d1d1f] !hover:text-white !active:text-white !focus:text-white' : ''}`}
|
||||||
>
|
>
|
||||||
<Home className="w-4 h-4"/>
|
<Home className="w-4 h-4"/>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -677,7 +677,7 @@ EXAMPLE STRUCTURE:
|
|||||||
{host.tags && host.tags.length > 0 && (
|
{host.tags && host.tags.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{host.tags.slice(0, 6).map((tag, index) => (
|
{host.tags.slice(0, 6).map((tag, index) => (
|
||||||
<Badge key={index} variant="secondary"
|
<Badge key={index} variant="outline"
|
||||||
className="text-xs px-1 py-0">
|
className="text-xs px-1 py-0">
|
||||||
<Tag className="h-2 w-2 mr-0.5"/>
|
<Tag className="h-2 w-2 mr-0.5"/>
|
||||||
{tag}
|
{tag}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function Server({
|
|||||||
embedded = false
|
embedded = false
|
||||||
}: ServerProps): React.ReactElement {
|
}: ServerProps): React.ReactElement {
|
||||||
const {state: sidebarState} = useSidebar();
|
const {state: sidebarState} = useSidebar();
|
||||||
const {addTab} = useTabs() as any;
|
const {addTab, tabs} = useTabs() as any;
|
||||||
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
const [serverStatus, setServerStatus] = React.useState<'online' | 'offline'>('offline');
|
||||||
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
const [metrics, setMetrics] = React.useState<ServerMetrics | null>(null);
|
||||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||||
@@ -116,6 +116,15 @@ export function Server({
|
|||||||
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
const leftMarginPx = sidebarState === 'collapsed' ? 16 : 8;
|
||||||
const bottomMarginPx = 8;
|
const bottomMarginPx = 8;
|
||||||
|
|
||||||
|
// Check if a file manager tab for this host is already open
|
||||||
|
const isFileManagerAlreadyOpen = React.useMemo(() => {
|
||||||
|
if (!currentHostConfig) return false;
|
||||||
|
return tabs.some((tab: any) =>
|
||||||
|
tab.type === 'file_manager' &&
|
||||||
|
tab.hostConfig?.id === currentHostConfig.id
|
||||||
|
);
|
||||||
|
}, [tabs, currentHostConfig]);
|
||||||
|
|
||||||
const wrapperStyle: React.CSSProperties = embedded
|
const wrapperStyle: React.CSSProperties = embedded
|
||||||
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'}
|
? {opacity: isVisible ? 1 : 0, height: '100%', width: '100%'}
|
||||||
: {
|
: {
|
||||||
@@ -169,8 +178,10 @@ export function Server({
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="font-semibold"
|
className="font-semibold"
|
||||||
|
disabled={isFileManagerAlreadyOpen}
|
||||||
|
title={isFileManagerAlreadyOpen ? "File Manager already open for this host" : "Open File Manager"}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!currentHostConfig) return;
|
if (!currentHostConfig || isFileManagerAlreadyOpen) return;
|
||||||
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
const titleBase = currentHostConfig?.name && currentHostConfig.name.trim() !== ''
|
||||||
? currentHostConfig.name.trim()
|
? currentHostConfig.name.trim()
|
||||||
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
: `${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface SSHTerminalProps {
|
|||||||
splitScreen?: boolean;
|
splitScreen?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TerminalComponent = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
|
||||||
{hostConfig, isVisible, splitScreen = false},
|
{hostConfig, isVisible, splitScreen = false},
|
||||||
ref
|
ref
|
||||||
) {
|
) {
|
||||||
@@ -894,7 +894,8 @@ export async function updateOIDCConfig(config: any): Promise<any> {
|
|||||||
|
|
||||||
export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> {
|
export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get(`/alerts/user/${userId}`);
|
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||||
|
const response = await apiInstance.get(`/alerts/user/${userId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, 'fetch user alerts');
|
handleApiError(error, 'fetch user alerts');
|
||||||
@@ -903,7 +904,9 @@ export async function getUserAlerts(userId: string): Promise<{ alerts: any[] }>
|
|||||||
|
|
||||||
export async function dismissAlert(userId: string, alertId: string): Promise<any> {
|
export async function dismissAlert(userId: string, alertId: string): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.post('/alerts/dismiss', { userId, alertId });
|
// Use the general API instance since alerts endpoint is at root level
|
||||||
|
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||||
|
const response = await apiInstance.post('/alerts/dismiss', { userId, alertId });
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, 'dismiss alert');
|
handleApiError(error, 'dismiss alert');
|
||||||
@@ -916,7 +919,9 @@ export async function dismissAlert(userId: string, alertId: string): Promise<any
|
|||||||
|
|
||||||
export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get(`/releases/rss?per_page=${perPage}`);
|
// Use the general API instance since releases endpoint is at root level
|
||||||
|
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||||
|
const response = await apiInstance.get(`/releases/rss?per_page=${perPage}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, 'fetch releases RSS');
|
handleApiError(error, 'fetch releases RSS');
|
||||||
@@ -925,7 +930,9 @@ export async function getReleasesRSS(perPage: number = 100): Promise<any> {
|
|||||||
|
|
||||||
export async function getVersionInfo(): Promise<any> {
|
export async function getVersionInfo(): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const response = await authApi.get('/version/');
|
// Use the general API instance since version endpoint is at root level
|
||||||
|
const apiInstance = createApiInstance(isDev ? 'http://localhost:8081' : '');
|
||||||
|
const response = await apiInstance.get('/version/');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleApiError(error, 'fetch version info');
|
handleApiError(error, 'fetch version info');
|
||||||
|
|||||||
Reference in New Issue
Block a user